Repository: openfaas/faas-cli (go)

Issue:
Support copying specific files via copy-extras
<!--- Provide a general summary of the issue in the Title above -->

## Expected Behaviour
<!--- If you're describing a bug, tell us what should happen -->
<!--- If you're suggesting a change/improvement, tell us how it should work -->

```sh
faas-cli build  --copy-extra=./models/Prices-RF-v1.0.0.pkl
```

should copy the file `./models/Prices-RF-v1.0.0.pkl` to `build/models/Prices-RF-v1.0.0.pkl`

## Current Behaviour
<!--- If describing a bug, tell us what happens instead of the expected behaviour -->
<!--- If suggesting a change/improvement, explain the difference from current behaviour -->
```sh
faas-cli build  --copy-extra=./models/Prices-RF-v1.0.0.pkl
```
produces an error

```
Errors received during build:
- error creating dest file: open build/foo/models/Prices-RF-v1.0.0.pkl: no such file or directory
```

## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug with details on how you -->
<!--- arrived at your diagnosis, or ideas how to implement the addition or change -->
We can update the copy files code to ensure that the intermediate folders are created before we try to create the file.


## Context
<!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world -->

This was reported on Slack

> Also, related to the one above, I was wondering whether it is expected behaviour that I can only seem to copy files or folder in the root folder, but not subfolders. For example: `faas-cli build --filter vis-explainmodel --copy-extra=./models works` as expected, but running `faas-cli build --filter vis-explainmodel --copy-extra=./models/HousePrices-RF-v1.0.0.pkl `does not:
> Errors received during build:
> ```
> - error creating dest file: open build/vis-explainmodel/function/models/HousePrices-RF-v1.0.0.pkl: no such file or directory
> ```
> I could open an issue for this if this is unexpected behaviour.

## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
* FaaS-CLI version ( Full output from: `faas-cli version` ): 0.12.13

Pull request:
Support copying specific extra files
## Description
<!--- Describe your changes in detail -->
- Ensure that the CopyFiles will create required missing intermediate
  folders. This allows the copy extra feature to specify a folder
  nested inside several folders, instead of needing to always
  specify the parent folder. When copying folders, the CopyFiles
  already handles the creation of intermediate folders, this patch makes
  behavior consistent.

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
- [x] I have raised an issue to propose this change ([required](https://github.com/openfaas/faas/blob/master/CONTRIBUTING.md))
Resolves #827 

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

1. A new unit test was added. 
2. I manually tested the copy behavior using `faas-cli build --shrinkwrap`

```yaml
#stack.yaml
provider:
  name: openfaas
  gateway: http://127.0.0.1:8080

configuration:
  copy:
    - ./common
    - ./extras/special.txt

functions:
  echo-common:
    lang: Dockerfile
    handler: ./echo-common
    fprocess: python index.py
    image: theaxer/echo-common
    environment:
      write_debug: true
```

```sh
➜  faas-cli build --shrinkwrap
[0] > Building echo-common.
Clearing temporary build folder: ./build/echo-common/
Preparing: ./echo-common/ ./build/echo-common/
Building: theaxer/echo-common:latest with Dockerfile template. Please wait..
echo-common shrink-wrapped to ./build/echo-common/
[0] < Building echo-common done in 0.01s.
[0] Worker done.

Total build time: 0.01s

➜  tree -L 3 build
build
└── echo-common
    ├── Dockerfile
    ├── common
    │   └── lookup_dict.json
    ├── extras
    │   └── special.txt
    ├── function
    │   ├── handler.py
    │   └── requirements.txt
    ├── index.py
    ├── requirements.txt
    └── template.yml

4 directories, 8 files
```

In the stack file, i have both a folder _and_ a specific file specified in the `copy` configuration.

After running shrinkwrap you can see both the `common` folder and the `extras/special.txt` folder are created in the build context.

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I've read the [CONTRIBUTION](https://github.com/openfaas/faas/blob/master/CONTRIBUTING.md) guide
- [x] I have signed-off my commits with `git commit -s`
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.


Patch:
diff --git a/builder/copy.go b/builder/copy.go
index 28565a1a..bac034a0 100644
--- a/builder/copy.go
+++ b/builder/copy.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"io/ioutil"
 	"os"
+	"path"
 	"path/filepath"
 )
 
@@ -60,6 +61,11 @@ func copyFile(src, dest string) error {
 		return fmt.Errorf("error reading src file stats: %s", err.Error())
 	}
 
+	err = ensureBaseDir(dest)
+	if err != nil {
+		return fmt.Errorf("error creating dest base directory: %s", err.Error())
+	}
+
 	f, err := os.Create(dest)
 	if err != nil {
 		return fmt.Errorf("error creating dest file: %s", err.Error())
@@ -84,6 +90,18 @@ func copyFile(src, dest string) error {
 	return nil
 }
 
+// ensureBaseDir creates the base directory of the given file path, if needed.
+// For example, if fpath is 'build/extras/dictionary.txt`, ensureBaseDir will
+// make sure that the directory `buid/extras/` is created.
+func ensureBaseDir(fpath string) error {
+	baseDir := path.Dir(fpath)
+	info, err := os.Stat(baseDir)
+	if err == nil && info.IsDir() {
+		return nil
+	}
+	return os.MkdirAll(baseDir, 0755)
+}
+
 func debugPrint(message string) {
 
 	if val, exists := os.LookupEnv("debug"); exists && (val == "1" || val == "true") {


Test patch:
diff --git a/builder/copy_test.go b/builder/copy_test.go
index 58eee5ac..824f2b13 100644
--- a/builder/copy_test.go
+++ b/builder/copy_test.go
@@ -28,14 +28,55 @@ func Test_CopyFiles(t *testing.T) {
 		}
 		defer os.RemoveAll(destDir)
 
-		CopyFiles(srcDir, destDir+"/")
-		err := checkDestinationFiles(destDir, 2, mode)
+		err := CopyFiles(srcDir, destDir+"/")
+		if err != nil {
+			t.Fatalf("Unexpected copy error\n%v", err)
+		}
+
+		err = checkDestinationFiles(destDir, 2, mode)
 		if err != nil {
 			t.Fatalf("Destination file mode differs from source file mode\n%v", err)
 		}
 	}
 }
 
+func Test_CopyFiles_ToDestinationWithIntermediateFolder(t *testing.T) {
+	dir := os.TempDir()
+	data := []byte("open faas")
+
+	// create a folder for source files
+	srcDir, dirError := ioutil.TempDir(dir, "openfaas-test-source-")
+	if dirError != nil {
+		t.Fatalf("Error creating source folder\n%v", dirError)
+	}
+	defer os.RemoveAll(srcDir)
+
+	// create a file inside the created folder
+	mode := 0600
+	srcFile := fmt.Sprintf("%s/test-file-1", srcDir)
+	fileErr := ioutil.WriteFile(srcFile, data, os.FileMode(mode))
+	if fileErr != nil {
+		t.Fatalf("Error creating source file\n%v", dirError)
+	}
+
+	// create a destination folder to copy the files to
+	destDir, destDirErr := ioutil.TempDir(dir, "openfaas-test-destination-")
+	if destDirErr != nil {
+		t.Fatalf("Error creating destination folder\n%v", destDirErr)
+	}
+	defer os.RemoveAll(destDir)
+
+	err := CopyFiles(srcFile, destDir+"/intermediate/test-file-1")
+	if err != nil {
+		t.Fatalf("Unexpected copy error\n%v", err)
+	}
+
+	err = checkDestinationFiles(destDir+"/intermediate/", 1, mode)
+	if err != nil {
+		t.Fatalf("Destination file mode differs from source file mode\n%v", err)
+	}
+}
+
 func setupSourceFolder(numberOfFiles, mode int) (string, error) {
 	dir := os.TempDir()
 	data := []byte("open faas")