Repository: score-spec/score-compose
Pull request #76 (2024-03-14 16:09:58)

feat: added support for container files
Until now, score-compose has not supported container files. This is a big lack of a fairly core feature of the score specification. Now we have it!

Given a score file like:

```
apiVersion: score.dev/v1b1

metadata:
  name: example

service:
  ports:
    www:
      port: 8080
      targetPort: 80

containers:
  example:
    image: nginx:latest
    files:
      - source: ./1084-536x354-grayscale.jpg
        noExpand: true
        target: /usr/share/nginx/html/1084-536x354-grayscale.jpg
      - target: /usr/share/nginx/html/index.html
        content: |
          <html>
          <h1>Hello! I am workload '${metadata.name}'</h1>
          <img src="1084-536x354-grayscale.jpg" />
          </html>
```

We now get a useful nginx server out of it:

![image](https://github.com/score-spec/score-compose/assets/1651305/4033c294-4dec-4bbe-a8cc-289dd04b47ca)

This is great when you're deploying an app which uses a container image that you don't control.




Patch:
diff --git a/internal/command/generate.go b/internal/command/generate.go
index a6e7a95..a63e9e9 100644
--- a/internal/command/generate.go
+++ b/internal/command/generate.go
@@ -213,7 +213,7 @@ arguments.
 			}
 
 			slog.Info(fmt.Sprintf("Converting workload '%s' to Docker compose", workloadName))
-			converted, err := compose.ConvertSpec(&workloadState.Spec, workloadState.BuildConfigs, outputFunctions)
+			converted, err := compose.ConvertSpec(currentState, &workloadState.Spec, workloadState.BuildConfigs, outputFunctions)
 			if err != nil {
 				return fmt.Errorf("failed to convert workload '%s' to Docker compose: %w", workloadName, err)
 			}
diff --git a/internal/command/run.go b/internal/command/run.go
index f80fa53..43866c1 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -207,7 +207,7 @@ func run(cmd *cobra.Command, args []string) error {
 	// Build docker-compose configuration
 	//
 	slog.Info("Building docker-compose configuration")
-	proj, err := compose.ConvertSpec(&spec, nil, workloadResourceOutputs)
+	proj, err := compose.ConvertSpec(state, &spec, nil, workloadResourceOutputs)
 	if err != nil {
 		return fmt.Errorf("building docker-compose configuration: %w", err)
 	}
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index c71955e..2c992b1 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -11,6 +11,8 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"os"
+	"path/filepath"
 	"sort"
 	"strings"
 
@@ -22,7 +24,7 @@ import (
 )
 
 // ConvertSpec converts SCORE specification into docker-compose configuration.
-func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.BuildConfig, resources map[string]project.OutputLookupFunc) (*compose.Project, error) {
+func ConvertSpec(state *project.State, spec *score.Workload, containerBuildConfigs map[string]compose.BuildConfig, resources map[string]project.OutputLookupFunc) (*compose.Project, error) {
 	workloadName, ok := spec.Metadata["name"].(string)
 	if !ok || len(workloadName) == 0 {
 		return nil, errors.New("workload metadata is missing a name")
@@ -118,17 +120,21 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 				}
 			}
 		}
+
+		if len(cSpec.Files) > 0 {
+			newVolumes, err := convertFilesIntoVolumes(workloadName, containerName, cSpec.Files, state.MountsDirectory, substitutionFunction)
+			if err != nil {
+				return nil, err
+			}
+			volumes = append(volumes, newVolumes...)
+		}
+
 		// NOTE: Sorting is necessary for DeepEqual call within our Unit Tests to work reliably
 		sort.Slice(volumes, func(i, j int) bool {
 			return volumes[i].Source < volumes[j].Source
 		})
 		// END (NOTE)
 
-		// Files are not supported just yet
-		if len(cSpec.Files) > 0 {
-			return nil, fmt.Errorf("containers.%s.files: not supported", containerName)
-		}
-
 		// Docker compose without swarm/stack mode doesn't really support resource limits. There are optimistic
 		// workarounds but they vary between specific versions of the CLI. Better to just ignore.
 		if cSpec.Resources != nil {
@@ -174,3 +180,51 @@ func ConvertSpec(spec *score.Workload, containerBuildConfigs map[string]compose.
 	}
 	return &composeProject, nil
 }
+
+// convertFilesIntoVolumes converts the lists of files into a list of bind mounts in the mounts directory.
+func convertFilesIntoVolumes(workloadName string, containerName string, input []score.ContainerFilesElem, mountsDirectory string, substitutionFunction func(string) (string, error)) ([]compose.ServiceVolumeConfig, error) {
+	if mountsDirectory == "" || mountsDirectory == "/dev/null" {
+		return nil, fmt.Errorf("files are not supported")
+	}
+
+	output := make([]compose.ServiceVolumeConfig, 0, len(input))
+	var err error
+
+	filesDir := filepath.Join(mountsDirectory, "files")
+	if err = os.MkdirAll(filesDir, 0755); err != nil && !errors.Is(err, os.ErrExist) {
+		return nil, fmt.Errorf("failed to ensure the files directory exists")
+	}
+	for idx, file := range input {
+		var content []byte
+		if file.Content != nil {
+			content = []byte(*file.Content)
+		} else if file.Source != nil {
+			content, err = os.ReadFile(*file.Source)
+			if err != nil {
+				return nil, fmt.Errorf("containers.%s.files[%d].source: failed to read: %w", containerName, idx, err)
+			}
+		} else {
+			return nil, fmt.Errorf("containers.%s.files[%d]: missing 'content' or 'source'", containerName, idx)
+		}
+		if file.NoExpand == nil || !*file.NoExpand {
+			stringContent, err := project.SubstituteString(string(content), substitutionFunction)
+			if err != nil {
+				return nil, fmt.Errorf("containers.%s.files[%d]: failed to substitute in content: %w", containerName, idx, err)
+			}
+			content = []byte(stringContent)
+		}
+		newName := fmt.Sprintf("%s-files-%d-%s", workloadName, idx, strings.Trim(filepath.Base(file.Target), string(filepath.Separator)))
+		slog.Debug(fmt.Sprintf("Writing %d bytes of content for %s containers.%s.files[%d] to %s", len(content), workloadName, containerName, idx, filepath.Join(filesDir, newName)))
+		if err := os.WriteFile(filepath.Join(filesDir, newName), content, 0644); err != nil {
+			return nil, fmt.Errorf("containers.%s.files[%d]: failed to write to disk: %w", containerName, idx, err)
+		}
+
+		output = append(output, compose.ServiceVolumeConfig{
+			Type:   "bind",
+			Source: filepath.Join(filesDir, newName),
+			Target: file.Target,
+		})
+	}
+
+	return output, nil
+}


Test patch:
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index 7b4bb5b..36a963f 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -252,6 +252,39 @@ services:
 
 }
 
+func TestInitAndGenerate_with_files(t *testing.T) {
+	td := changeToTempDir(t)
+	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: foo
+    files:
+    - target: /blah.txt
+      source: ./original.txt
+`), 0644))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "original.txt"), []byte(`first ${metadata.name} second`), 0644))
+	stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	raw, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
+	assert.NoError(t, err)
+	assert.Equal(t, `name: "001"
+services:
+    example-example:
+        image: foo
+        volumes:
+            - type: bind
+              source: .score-compose/mounts/files/example-files-0-blah.txt
+              target: /blah.txt
+`, string(raw))
+}
+
 func TestGenerateRedisResource(t *testing.T) {
 	td := changeToTempDir(t)
 	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
diff --git a/internal/command/run_test.go b/internal/command/run_test.go
index 8c5b79f..c2aae59 100644
--- a/internal/command/run_test.go
+++ b/internal/command/run_test.go
@@ -264,7 +264,7 @@ containers:
       content: bananas
 `), 0600))
 	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"run", "--file", filepath.Join(td, "score.yaml"), "--output", filepath.Join(td, "compose.yaml")})
-	assert.EqualError(t, err, "building docker-compose configuration: containers.container-one1.files: not supported")
+	assert.EqualError(t, err, "building docker-compose configuration: files are not supported")
 	assert.Equal(t, "", stdout)
 	assert.NotEqual(t, "", strings.TrimSpace(stderr))
 }
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index 9f82923..e216403 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -9,6 +9,9 @@ package compose
 
 import (
 	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
@@ -288,7 +291,7 @@ func TestScoreConvert(t *testing.T) {
 			po, _ = evt.GenerateSubProvisioner("some-dns", "").Provision(nil, nil)
 			resourceOutputs["some-dns"] = po.OutputLookupFunc
 
-			proj, err := ConvertSpec(tt.Source, nil, resourceOutputs)
+			proj, err := ConvertSpec(new(project.State), tt.Source, nil, resourceOutputs)
 
 			if tt.Error != nil {
 				// On Error
@@ -304,3 +307,89 @@ func TestScoreConvert(t *testing.T) {
 		})
 	}
 }
+
+func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
+	td := t.TempDir()
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "original.txt"), []byte(`first ${metadata.name} second`), 0644))
+	out, err := convertFilesIntoVolumes(
+		"my-workload", "my-container",
+		[]score.ContainerFilesElem{
+			{Target: "/ant.txt", Source: util.Ref(filepath.Join(td, "original.txt"))},
+			{Target: "/bat.txt", Source: util.Ref(filepath.Join(td, "original.txt")), NoExpand: util.Ref(true)},
+			{Target: "/cat.txt", Source: util.Ref(filepath.Join(td, "original.txt")), NoExpand: util.Ref(false)},
+			{Target: "/dog.txt", Content: util.Ref("third ${metadata.name} fourth")},
+			{Target: "/eel.txt", Content: util.Ref("third ${metadata.name} fourth"), NoExpand: util.Ref(true)},
+			{Target: "/fox.txt", Content: util.Ref("third ${metadata.name} fourth"), NoExpand: util.Ref(false)},
+		}, td, func(s string) (string, error) {
+			switch s {
+			case "metadata.name":
+				return "blah", nil
+			default:
+				return "", fmt.Errorf("unknown key")
+			}
+		},
+	)
+	assert.NoError(t, err)
+	assert.Equal(t, []compose.ServiceVolumeConfig{
+		{Type: "bind", Target: "/ant.txt", Source: filepath.Join(td, "files", "my-workload-files-0-ant.txt")},
+		{Type: "bind", Target: "/bat.txt", Source: filepath.Join(td, "files", "my-workload-files-1-bat.txt")},
+		{Type: "bind", Target: "/cat.txt", Source: filepath.Join(td, "files", "my-workload-files-2-cat.txt")},
+		{Type: "bind", Target: "/dog.txt", Source: filepath.Join(td, "files", "my-workload-files-3-dog.txt")},
+		{Type: "bind", Target: "/eel.txt", Source: filepath.Join(td, "files", "my-workload-files-4-eel.txt")},
+		{Type: "bind", Target: "/fox.txt", Source: filepath.Join(td, "files", "my-workload-files-5-fox.txt")},
+	}, out)
+	for k, v := range map[string]string{
+		"my-workload-files-0-ant.txt": "first blah second",
+		"my-workload-files-1-bat.txt": "first ${metadata.name} second",
+		"my-workload-files-2-cat.txt": "first blah second",
+		"my-workload-files-3-dog.txt": "third blah fourth",
+		"my-workload-files-4-eel.txt": "third ${metadata.name} fourth",
+		"my-workload-files-5-fox.txt": "third blah fourth",
+	} {
+		t.Run(k, func(t *testing.T) {
+			raw, err := os.ReadFile(filepath.Join(td, "files", k))
+			assert.NoError(t, err)
+			assert.Equal(t, v, string(raw))
+		})
+	}
+
+}
+
+func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
+	td := t.TempDir()
+	_, err := convertFilesIntoVolumes(
+		"my-workload", "my-container",
+		[]score.ContainerFilesElem{
+			{Target: "/ant.txt", Source: util.Ref(filepath.Join(td, "original.txt"))},
+		}, td, func(s string) (string, error) {
+			return "", fmt.Errorf("unknown key")
+		},
+	)
+	assert.EqualError(t, err, fmt.Sprintf("containers.my-container.files[0].source: failed to read: open %s/original.txt: no such file or directory", td))
+}
+
+func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
+	td := t.TempDir()
+	_, err := convertFilesIntoVolumes(
+		"my-workload", "my-container",
+		[]score.ContainerFilesElem{
+			{Target: "/ant.txt"},
+		}, td, func(s string) (string, error) {
+			return "", fmt.Errorf("unknown key")
+		},
+	)
+	assert.EqualError(t, err, "containers.my-container.files[0]: missing 'content' or 'source'")
+}
+
+func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
+	td := t.TempDir()
+	_, err := convertFilesIntoVolumes(
+		"my-workload", "my-container",
+		[]score.ContainerFilesElem{
+			{Target: "/ant.txt", Content: util.Ref("${metadata.blah}")},
+		}, td, func(s string) (string, error) {
+			return "", fmt.Errorf("unknown key")
+		},
+	)
+	assert.EqualError(t, err, "containers.my-container.files[0]: failed to substitute in content: unknown key")
+}