Repository: score-spec/score-compose
Pull request #302 (2025-04-24 01:26:57)

Support new `files|volumes` spec with `score-go` `v1.10.0`
github.com/score-spec/score-go v1.10.0

https://github.com/score-spec/score-go/pull/103 based on https://github.com/score-spec/spec/pull/123#issuecomment-2793203515

This PR also makes sure that previous/old `files|volumes` spec is still working while we support the new spec.

Both examples working with this new version:

Old `files|volumes` spec:
```yaml
apiVersion: score.dev/v1b1
metadata:
  name: hello-world
containers:
  hello:
    image: .
    files:
      - target: /fileB.txt
        content: |
          I am ${metadata.name}
    volumes:
     - target: /data
        source: ${resources.data}
resources:
  data:
    type: volume
```

New `files|volumes` spec:
```yaml
apiVersion: score.dev/v1b1
metadata:
  name: hello-world
containers:
  hello:
    image: .
    files:
      "/fileB.txt":
        content: |
          I am ${metadata.name}
    volumes:
      "/data":
        source: ${resources.data}
resources:
  data:
    type: volume
```

mathieu-benoit: I think that's it, ready for your review @astromechza please, thanks!

Patch:
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..90c1647
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,19 @@
+# Disable all the default make stuff
+MAKEFLAGS += --no-builtin-rules
+.SUFFIXES:
+
+## Display a list of the documented make targets
+.PHONY: help
+help:
+	@echo Documented Make targets:
+	@perl -e 'undef $$/; while (<>) { while ($$_ =~ /## (.*?)(?:\n# .*)*\n.PHONY:\s+(\S+).*/mg) { printf "\033[36m%-30s\033[0m %s\n", $$2, $$1 } }' $(MAKEFILE_LIST) | sort
+
+.PHONY: .FORCE
+.FORCE:
+
+build:
+	go build ./cmd/score-compose/
+
+test:
+	go vet ./...    
+	go test ./... -cover -race
\ No newline at end of file
diff --git a/examples/03-files/score.yaml b/examples/03-files/score.yaml
index 3c952c0..a0d12f6 100644
--- a/examples/03-files/score.yaml
+++ b/examples/03-files/score.yaml
@@ -9,10 +9,10 @@ containers:
     command: ["/bin/sh"]
     args: ["-c", "while true; do cat /fileA.txt; cat /fileB.txt; cat /fileC.bin; sleep 5; done"]
     files:
-      - target: /fileA.txt
+      "/fileA.txt":
         source: fileA.txt
-      - target: /fileB.txt
+      "/fileB.txt":
         content: |
           I am ${metadata.name}
-      - target: /fileC.bin
+      "/fileC.bin":
         binaryContent: aGVsbG8gd29ybGQ=
diff --git a/examples/05-volume-mounts/score.yaml b/examples/05-volume-mounts/score.yaml
index 0daaf76..89cc9b9 100644
--- a/examples/05-volume-mounts/score.yaml
+++ b/examples/05-volume-mounts/score.yaml
@@ -7,7 +7,7 @@ containers:
   first:
     image: "nginx:latest"
     volumes:
-      - target: /data
+      "/data":
         source: ${resources.data}
 
 resources:
diff --git a/go.mod b/go.mod
index 3c8ee07..7310c10 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.3.0
 	github.com/compose-spec/compose-go/v2 v2.6.0
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/score-spec/score-go v1.9.7
+	github.com/score-spec/score-go v1.10.0
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/pflag v1.0.6
 	github.com/stretchr/testify v1.10.0
diff --git a/go.sum b/go.sum
index 8aedfc3..8e1d112 100644
--- a/go.sum
+++ b/go.sum
@@ -61,8 +61,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
-github.com/score-spec/score-go v1.9.7 h1:2afe1vSHIWafRcEc3484U9nGWPo6Fmr0T97ypjvMSLs=
-github.com/score-spec/score-go v1.9.7/go.mod h1:uvlaGT8wvBujAqU0aU2Kx3gFrq8owBGjjeUjWNI7/zY=
+github.com/score-spec/score-go v1.10.0 h1:ukWV2Df41uAYvLbVdRS1njmbedMCtp80zd6Pio3Wy60=
+github.com/score-spec/score-go v1.10.0/go.mod h1:bEtNhlGekZaNuWI11n15V5ibcX+E0Inry+TC6NWTuFI=
 github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index 90fcd35..0222c27 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -22,6 +22,8 @@ import (
 	"fmt"
 	"log/slog"
 	"os"
+	"slices"
+	"maps"
 	"path/filepath"
 	"sort"
 	"strconv"
@@ -103,13 +105,14 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 
 		var volumes []compose.ServiceVolumeConfig
 		if len(cSpec.Volumes) > 0 {
-			volumes = make([]compose.ServiceVolumeConfig, len(cSpec.Volumes))
-			for idx, vol := range cSpec.Volumes {
-				cfg, err := convertVolumeSourceIntoVolume(state, deferredSubstitutionFunction, workloadName, vol)
+			volumes = make([]compose.ServiceVolumeConfig, 0, len(cSpec.Volumes))
+			for _, target := range slices.Sorted(maps.Keys(cSpec.Volumes)) {
+			    vol := cSpec.Volumes[target]
+				cfg, err := convertVolumeSourceIntoVolume(state, deferredSubstitutionFunction, workloadName, target, vol)
 				if err != nil {
-					return nil, fmt.Errorf("containers.%s.volumes[%d]: %w", containerName, idx, err)
+					return nil, fmt.Errorf("containers.%s.volumes[%s]: %w", containerName, target, err)
 				}
-				volumes[idx] = *cfg
+				volumes = append(volumes, *cfg)
 			}
 		}
 
@@ -237,7 +240,8 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 	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 {
+	for _, target := range slices.Sorted(maps.Keys(input)) {
+	    file := input[target]
 		var content []byte
 		if file.Content != nil {
 			content = []byte(*file.Content)
@@ -248,25 +252,25 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 			}
 			content, err = os.ReadFile(sourcePath)
 			if err != nil {
-				return nil, fmt.Errorf("containers.%s.files[%d].source: failed to read: %w", containerName, idx, err)
+				return nil, fmt.Errorf("containers.%s.files[%s].source: failed to read: %w", containerName, target, err)
 			}
 		} else if file.BinaryContent != nil {
 			content, err = base64.StdEncoding.DecodeString(*file.BinaryContent)
 			if err != nil {
-				return nil, fmt.Errorf("containers.%s.files[%d].binaryContent: failed to decode base64: %w", containerName, idx, err)
+				return nil, fmt.Errorf("containers.%s.files[%s].binaryContent: failed to decode base64: %w", containerName, target, err)
 			}
 		} else {
-			return nil, fmt.Errorf("containers.%s.files[%d]: missing 'content', 'binaryContent', or 'source'", containerName, idx)
+			return nil, fmt.Errorf("containers.%s.files[%s]: missing 'content', 'binaryContent', or 'source'", containerName, target)
 		}
 		if (file.NoExpand == nil || !*file.NoExpand) && utf8.Valid(content) && file.BinaryContent == nil {
 			stringContent, err := framework.SubstituteString(string(content), substitutionFunction)
 			if err != nil {
-				return nil, fmt.Errorf("containers.%s.files[%d]: failed to substitute in content: %w", containerName, idx, err)
+				return nil, fmt.Errorf("containers.%s.files[%s]: failed to substitute in content: %w", containerName, target, 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)))
+		newName := fmt.Sprintf("%s-files-%s", workloadName, strings.Trim(filepath.Base(target), string(filepath.Separator)))
+		slog.Debug(fmt.Sprintf("Writing %d bytes of content for %s containers.%s.files[%s] to %s", len(content), workloadName, containerName, target, filepath.Join(filesDir, newName)))
 
 		// Parse and correct the file mode of the mount. If the user permissions do not allow write, then we enable the read only flag
 		// on the bind mount so that we can still remove the file from disk on the outside without sudo.
@@ -275,11 +279,11 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 		if file.Mode != nil {
 			newMode, err := strconv.ParseInt(*file.Mode, 8, 32)
 			if err != nil {
-				return nil, fmt.Errorf("containers.%s.files[%d]: failed to parse '%s' as octal", containerName, idx, *file.Mode)
+				return nil, fmt.Errorf("containers.%s.files[%s]: failed to parse '%s' as octal", containerName, target, *file.Mode)
 			} else if newMode > 0777 {
-				return nil, fmt.Errorf("containers.%s.files[%d]: mode must be <= 0777", containerName, idx)
+				return nil, fmt.Errorf("containers.%s.files[%s]: mode must be <= 0777", containerName, target)
 			} else if newMode&0400 != 0400 {
-				return nil, fmt.Errorf("containers.%s.files[%d]: mode must be at least 0400", containerName, idx)
+				return nil, fmt.Errorf("containers.%s.files[%s]: mode must be at least 0400", containerName, target)
 			} else if newMode&0600 != 0600 {
 				newMode = newMode | 0600
 				readOnly = true
@@ -288,13 +292,13 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 		}
 
 		if err := os.WriteFile(filepath.Join(filesDir, newName), content, fileMode); err != nil {
-			return nil, fmt.Errorf("containers.%s.files[%d]: failed to write to disk: %w", containerName, idx, err)
+			return nil, fmt.Errorf("containers.%s.files[%s]: failed to write to disk: %w", containerName, target, err)
 		}
 
 		output = append(output, compose.ServiceVolumeConfig{
 			Type:     "bind",
 			Source:   filepath.Join(filesDir, newName),
-			Target:   file.Target,
+			Target:   target,
 			ReadOnly: readOnly,
 		})
 	}
@@ -302,7 +306,7 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 	return output, nil
 }
 
-func convertVolumeSourceIntoVolume(state *project.State, substitutionFunction func(string) (string, error), workloadName string, vol score.ContainerVolumesElem) (*compose.ServiceVolumeConfig, error) {
+func convertVolumeSourceIntoVolume(state *project.State, substitutionFunction func(string) (string, error), workloadName string, target string, vol score.ContainerVolume) (*compose.ServiceVolumeConfig, error) {
 	spec := state.Workloads[workloadName].Spec
 
 	// The way volumes are linked to a resource is a bit of a special case. The goal is to confirm that the
@@ -324,7 +328,7 @@ func convertVolumeSourceIntoVolume(state *project.State, substitutionFunction fu
 	outputVolume := &compose.ServiceVolumeConfig{
 		Type:     "volume",
 		Source:   resolvedVolumeSource,
-		Target:   vol.Target,
+		Target:   target,
 		ReadOnly: util.DerefOr(vol.ReadOnly, false),
 	}
 


Test patch:
diff --git a/internal/command/generate_examples_test.go b/internal/command/generate_examples_test.go
index df85fcf..2a255d7 100644
--- a/internal/command/generate_examples_test.go
+++ b/internal/command/generate_examples_test.go
@@ -94,13 +94,13 @@ services:
         image: busybox
         volumes:
             - type: bind
-              source: .score-compose/mounts/files/hello-world-files-0-fileA.txt
+              source: .score-compose/mounts/files/hello-world-files-fileA.txt
               target: /fileA.txt
             - type: bind
-              source: .score-compose/mounts/files/hello-world-files-1-fileB.txt
+              source: .score-compose/mounts/files/hello-world-files-fileB.txt
               target: /fileB.txt
             - type: bind
-              source: .score-compose/mounts/files/hello-world-files-2-fileC.bin
+              source: .score-compose/mounts/files/hello-world-files-fileC.bin
               target: /fileC.bin
 `,
 		},
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index 22af12c..714771e 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -329,7 +329,7 @@ services:
 
 }
 
-func TestInitAndGenerate_with_files(t *testing.T) {
+func TestInitAndGenerate_with_files_old_files_spec(t *testing.T) {
 	td := changeToTempDir(t)
 	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
 	assert.NoError(t, err)
@@ -360,7 +360,43 @@ services:
         image: foo
         volumes:
             - type: bind
-              source: .score-compose/mounts/files/example-files-0-blah.txt
+              source: .score-compose/mounts/files/example-files-blah.txt
+              target: /blah.txt
+`, string(raw))
+}
+
+func TestInitAndGenerate_with_files_new_files_spec(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:
+      "/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:
+        annotations:
+            compose.score.dev/workload-name: example
+        hostname: example
+        image: foo
+        volumes:
+            - type: bind
+              source: .score-compose/mounts/files/example-files-blah.txt
               target: /blah.txt
 `, string(raw))
 }
@@ -976,7 +1012,7 @@ services:
 `, string(raw))
 }
 
-func TestEnvVarsMustResolveInsideFiles(t *testing.T) {
+func TestEnvVarsMustResolveInsideFiles_old_files_spec(t *testing.T) {
 	td := changeToTempDir(t)
 	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
 	assert.NoError(t, err)
@@ -996,7 +1032,33 @@ resources:
     type: environment
 `), 0644))
 	_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
-	assert.EqualError(t, err, "failed to convert workload 'example' to Docker compose: containers.example.files[0]: "+
+	assert.EqualError(t, err, "failed to convert workload 'example' to Docker compose: containers.example.files[/some/file]: "+
+		"failed to substitute in content: invalid ref 'resources.env.UNKNOWN_SCORE_VARIABLE': "+
+		"environment variable 'UNKNOWN_SCORE_VARIABLE' must be resolved",
+	)
+}
+
+func TestEnvVarsMustResolveInsideFiles_new_files_spec(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:
+      "/some/file":
+        content: ${resources.env.UNKNOWN_SCORE_VARIABLE}
+resources:
+  env:
+    type: environment
+`), 0644))
+	_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.EqualError(t, err, "failed to convert workload 'example' to Docker compose: containers.example.files[/some/file]: "+
 		"failed to substitute in content: invalid ref 'resources.env.UNKNOWN_SCORE_VARIABLE': "+
 		"environment variable 'UNKNOWN_SCORE_VARIABLE' must be resolved",
 	)
@@ -1029,7 +1091,7 @@ resources:
 	)
 }
 
-func TestInitAndGenerate_with_volume_types(t *testing.T) {
+func TestInitAndGenerate_with_volume_types_with_old_volumes_spec(t *testing.T) {
 	td := changeToTempDir(t)
 	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
 	assert.NoError(t, err)
@@ -1115,6 +1177,92 @@ services:
 `, string(raw))
 }
 
+func TestInitAndGenerate_with_volume_types_with_new_volumes_spec(t *testing.T) {
+	td := changeToTempDir(t)
+	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+
+	// write custom providers
+	assert.NoError(t, os.WriteFile(filepath.Join(td, ".score-compose", "00-custom.provisioners.yaml"), []byte(`
+- uri: template://docker-volume
+  type: volume
+  outputs: |
+    type: volume
+    source: named-volume
+- uri: template://tmpfs-volume
+  type: tmp-volume
+  outputs: |
+    type: tmpfs
+    tmpfs:
+      size: 10000000
+- uri: template://bind-volume
+  type: bind-volume
+  outputs: |
+    type: bind
+    source: /dev/something
+    bind:
+      create_host_path: true
+`), 0644))
+
+	// write custom score file
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: busybox
+    volumes:
+      "/mnt/v1":
+        source: ${resources.v1}
+      "/mnt/v2":
+        source: ${resources.v2}
+        path: thing
+      "/mnt/v3":
+        source: ${resources.v3}
+        path: other/thing
+resources:
+  v1:
+    type: tmp-volume
+  v2:
+    type: bind-volume
+  v3:
+    type: volume
+`), 0644))
+
+	// generate
+	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:
+        annotations:
+            compose.score.dev/workload-name: example
+        hostname: example
+        image: busybox
+        volumes:
+            - type: bind
+              source: /dev/something/thing
+              target: /mnt/v2
+              bind:
+                create_host_path: true
+            - type: volume
+              source: named-volume
+              target: /mnt/v3
+              volume:
+                subpath: other/thing
+            - type: tmpfs
+              source: tmp-volume.default#example.v1
+              target: /mnt/v1
+              tmpfs:
+                size: "10000000"
+`, string(raw))
+}
+
 func TestGenerateMongodbResource(t *testing.T) {
 	td := changeToTempDir(t)
 	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index 3ebdb2e..92b4e49 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -121,10 +121,9 @@ func TestScoreConvert(t *testing.T) {
 							"DOMAIN_NAME":       "${resources.some-dns.domain_name}",
 							"CONNECTION_STRING": "postgresql://${resources.app-db.host}:${resources.app-db.port}/${resources.app-db.name}",
 						},
-						Volumes: []score.ContainerVolumesElem{
-							{
+						Volumes: map[string]score.ContainerVolume{
+							"/mnt/data": {
 								Source:   "${resources.data}",
-								Target:   "/mnt/data",
 								ReadOnly: util.Ref(true),
 							},
 						},
@@ -194,10 +193,9 @@ func TestScoreConvert(t *testing.T) {
 							"DOMAIN_NAME":       "${resources.some-dns.domain_name}",
 							"CONNECTION_STRING": "mysql://${resources.app-db.host}:${resources.app-db.port}/${resources.app-db.name}",
 						},
-						Volumes: []score.ContainerVolumesElem{
-							{
+						Volumes: map[string]score.ContainerVolume{
+							"/mnt/data": {
 								Source:   "${resources.data}",
-								Target:   "/mnt/data",
 								ReadOnly: util.Ref(true),
 							},
 						},
@@ -363,10 +361,9 @@ func TestScoreConvert(t *testing.T) {
 				Containers: score.WorkloadContainers{
 					"backend": score.Container{
 						Image: "busybox",
-						Volumes: []score.ContainerVolumesElem{
-							{
+						Volumes: map[string]score.ContainerVolume{
+							"/mnt/data": {
 								Source:   "${resources.data}",
-								Target:   "/mnt/data",
 								Path:     util.Ref("sub/path"),
 								ReadOnly: util.Ref(true),
 							},
@@ -414,11 +411,11 @@ func TestScoreConvert(t *testing.T) {
 				Containers: score.WorkloadContainers{
 					"test": score.Container{
 						Image:   "busybox",
-						Volumes: []score.ContainerVolumesElem{{Source: "${resources.data}", Target: "/mnt/data"}},
+						Volumes: map[string]score.ContainerVolume{"/mnt/data": {Source: "${resources.data}"}},
 					},
 				},
 			},
-			Error: errors.New("containers.test.volumes[0]: resource 'data' does not exist"),
+			Error: errors.New("containers.test.volumes[/mnt/data]: resource 'data' does not exist"),
 		},
 
 		{
@@ -428,12 +425,12 @@ func TestScoreConvert(t *testing.T) {
 				Containers: score.WorkloadContainers{
 					"test": score.Container{
 						Image:   "busybox",
-						Volumes: []score.ContainerVolumesElem{{Source: "${resources.data}", Target: "/mnt/data"}},
+						Volumes: map[string]score.ContainerVolume{"/mnt/data": {Source: "${resources.data}"}},
 					},
 				},
 				Resources: map[string]score.Resource{"data": {Type: "thing"}},
 			},
-			Error: errors.New("containers.test.volumes[0]: resource 'thing.default#test.data' has no 'type' output"),
+			Error: errors.New("containers.test.volumes[/mnt/data]: resource 'thing.default#test.data' has no 'type' output"),
 		},
 	}
 
@@ -484,14 +481,14 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
-						Files: []score.ContainerFilesElem{
-							{Target: "/ant.txt", Source: util.Ref("original.txt")},
-							{Target: "/bat.txt", Source: util.Ref("original.txt"), NoExpand: util.Ref(true)},
-							{Target: "/cat.txt", Source: util.Ref("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)},
-							{Target: "/goat.txt", BinaryContent: util.Ref("ZmlmdGggJHttZXRhZGF0YS5uYW1lfSBzaXh0aA==")},
+						Files: map[string]score.ContainerFile{
+							"/ant.txt": {Source: util.Ref("original.txt")},
+							"/bat.txt": {Source: util.Ref("original.txt"), NoExpand: util.Ref(true)},
+							"/cat.txt": {Source: util.Ref("original.txt"), NoExpand: util.Ref(false)},
+							"/dog.txt": {Content: util.Ref("third ${metadata.name} fourth")},
+							"/eel.txt": {Content: util.Ref("third ${metadata.name} fourth"), NoExpand: util.Ref(true)},
+							"/fox.txt": {Content: util.Ref("third ${metadata.name} fourth"), NoExpand: util.Ref(false)},
+							"/goat.txt": {BinaryContent: util.Ref("ZmlmdGggJHttZXRhZGF0YS5uYW1lfSBzaXh0aA==")},
 						},
 					},
 				},
@@ -515,23 +512,23 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 	)
 	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")},
-		{Type: "bind", Target: "/goat.txt", Source: filepath.Join(td, "files", "my-workload-files-6-goat.txt")},
+		{Type: "bind", Target: "/ant.txt", Source: filepath.Join(td, "files", "my-workload-files-ant.txt")},
+		{Type: "bind", Target: "/bat.txt", Source: filepath.Join(td, "files", "my-workload-files-bat.txt")},
+		{Type: "bind", Target: "/cat.txt", Source: filepath.Join(td, "files", "my-workload-files-cat.txt")},
+		{Type: "bind", Target: "/dog.txt", Source: filepath.Join(td, "files", "my-workload-files-dog.txt")},
+		{Type: "bind", Target: "/eel.txt", Source: filepath.Join(td, "files", "my-workload-files-eel.txt")},
+		{Type: "bind", Target: "/fox.txt", Source: filepath.Join(td, "files", "my-workload-files-fox.txt")},
+		{Type: "bind", Target: "/goat.txt", Source: filepath.Join(td, "files", "my-workload-files-goat.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",
+		"my-workload-files-ant.txt": "first blah second",
+		"my-workload-files-bat.txt": "first ${metadata.name} second",
+		"my-workload-files-cat.txt": "first blah second",
+		"my-workload-files-dog.txt": "third blah fourth",
+		"my-workload-files-eel.txt": "third ${metadata.name} fourth",
+		"my-workload-files-fox.txt": "third blah fourth",
 		// NOTE: binaryContent doesn't support placeholders
-		"my-workload-files-6-goat.txt": "fifth ${metadata.name} sixth",
+		"my-workload-files-goat.txt": "fifth ${metadata.name} sixth",
 	} {
 		t.Run(k, func(t *testing.T) {
 			raw, err := os.ReadFile(filepath.Join(td, "files", k))
@@ -549,8 +546,8 @@ func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
-						Files: []score.ContainerFilesElem{
-							{Target: "/ant.txt", Source: util.Ref(filepath.Join(td, "original.txt"))},
+						Files: map[string]score.ContainerFile{
+							"/ant.txt": {Source: util.Ref(filepath.Join(td, "original.txt"))},
 						},
 					},
 				},
@@ -567,7 +564,7 @@ func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 			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))
+	assert.EqualError(t, err, fmt.Sprintf("containers.my-container.files[/ant.txt].source: failed to read: open %s/original.txt: no such file or directory", td))
 }
 
 func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
@@ -577,8 +574,8 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
-						Files: []score.ContainerFilesElem{
-							{Target: "/ant.txt"},
+						Files: map[string]score.ContainerFile{
+							"/ant.txt": {},
 						},
 					},
 				},
@@ -595,7 +592,7 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
-	assert.EqualError(t, err, "containers.my-container.files[0]: missing 'content', 'binaryContent', or 'source'")
+	assert.EqualError(t, err, "containers.my-container.files[/ant.txt]: missing 'content', 'binaryContent', or 'source'")
 }
 
 func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
@@ -605,8 +602,8 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
-						Files: []score.ContainerFilesElem{
-							{Target: "/ant.txt", Content: util.Ref("${metadata.blah}")},
+						Files: map[string]score.ContainerFile{
+							"/ant.txt": {Content: util.Ref("${metadata.blah}")},
 						},
 					},
 				},
@@ -623,7 +620,7 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
-	assert.EqualError(t, err, "containers.my-container.files[0]: failed to substitute in content: unknown key")
+	assert.EqualError(t, err, "containers.my-container.files[/ant.txt]: failed to substitute in content: unknown key")
 }
 
 func TestConvertFiles_with_mode(t *testing.T) {
@@ -633,11 +630,11 @@ func TestConvertFiles_with_mode(t *testing.T) {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
-						Files: []score.ContainerFilesElem{
-							{Target: "/ant.txt", Content: util.Ref("stuff")},
-							{Target: "/bat.txt", Content: util.Ref("stuff"), Mode: util.Ref("0600")},
-							{Target: "/cat.txt", Content: util.Ref("stuff"), Mode: util.Ref("0755")},
-							{Target: "/dog.txt", Content: util.Ref("stuff"), Mode: util.Ref("0444")},
+						Files: map[string]score.ContainerFile{
+							"/ant.txt": {Content: util.Ref("stuff")},
+							"/bat.txt": {Content: util.Ref("stuff"), Mode: util.Ref("0600")},
+							"/cat.txt": {Content: util.Ref("stuff"), Mode: util.Ref("0755")},
+							"/dog.txt": {Content: util.Ref("stuff"), Mode: util.Ref("0444")},
 						},
 					},
 				},
@@ -655,19 +652,19 @@ func TestConvertFiles_with_mode(t *testing.T) {
 		},
 	)
 	assert.NoError(t, err)
-	st, err := os.Stat(filepath.Join(td, "files/my-workload-files-0-ant.txt"))
+	st, err := os.Stat(filepath.Join(td, "files/my-workload-files-ant.txt"))
 	assert.NoError(t, err)
 	assert.Equal(t, 0644, int(st.Mode()))
 	assert.False(t, out[0].ReadOnly)
-	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-1-bat.txt"))
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-bat.txt"))
 	assert.NoError(t, err)
 	assert.Equal(t, 0600, int(st.Mode()))
 	assert.False(t, out[1].ReadOnly)
-	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-2-cat.txt"))
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-cat.txt"))
 	assert.NoError(t, err)
 	assert.Equal(t, 0755, int(st.Mode()))
 	assert.False(t, out[2].ReadOnly)
-	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-3-dog.txt"))
+	st, err = os.Stat(filepath.Join(td, "files/my-workload-files-dog.txt"))
 	assert.NoError(t, err)
 	assert.Equal(t, 0644, int(st.Mode()))
 	assert.True(t, out[3].ReadOnly)