Repository: score-spec/score-compose
Pull request #96 (2024-03-21 17:10:22)

fix: resolve file source relative to the score file
The score specification intends that the container file elements that have a source path are resolved relative to the source of the score file and not just relative to the working directory. This ensures that `score-compose generate sub-component/score.yaml` works as expected.

This PR fixes this by tracking the source file info in the state object and using this when resolving the source file.


Patch:
diff --git a/examples/03-files/README.md b/examples/03-files/README.md
index 66b28ef..b373df6 100644
--- a/examples/03-files/README.md
+++ b/examples/03-files/README.md
@@ -4,7 +4,7 @@ Score workloads can bind-mount files into running containers. Files using `conte
 
 This is invaluable when configuring containers whose images you don't control or cannot modify further.
 
-In the Score file below, two files are mounted, one with inline content, the other sourced from the local directory.
+In the Score file below, two files are mounted, one with inline content, the other sourced from the local directory relative to the input Score file.
 
 ```yaml
 apiVersion: score.dev/v1b1
diff --git a/internal/command/generate.go b/internal/command/generate.go
index f73588d..774c812 100644
--- a/internal/command/generate.go
+++ b/internal/command/generate.go
@@ -124,7 +124,10 @@ arguments.
 		currentState := &sd.State
 
 		// Now validate with score spec
-		for workloadName, spec := range workloadSpecs {
+		for i, workloadName := range workloadNames {
+			spec := workloadSpecs[workloadName]
+			inputFile := inputFiles[i]
+
 			// Ensure transforms are applied (be a good citizen)
 			if changes, err := schema.ApplyCommonUpgradeTransforms(spec); err != nil {
 				return fmt.Errorf("failed to upgrade spec: %w", err)
@@ -180,7 +183,7 @@ arguments.
 				}
 			}
 
-			currentState, err = currentState.WithWorkload(&out, nil, containerBuildContexts)
+			currentState, err = currentState.WithWorkload(&out, &inputFile, containerBuildContexts)
 			if err != nil {
 				return fmt.Errorf("failed to add workload '%s': %w", workloadName, err)
 			}
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index b280c25..271c96c 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -112,7 +112,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 		}
 
 		if len(cSpec.Files) > 0 {
-			newVolumes, err := convertFilesIntoVolumes(workloadName, containerName, cSpec.Files, state.MountsDirectory, substitutionFunction)
+			newVolumes, err := convertFilesIntoVolumes(state, workloadName, containerName, substitutionFunction)
 			if err != nil {
 				return nil, err
 			}
@@ -171,7 +171,9 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 }
 
 // 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) {
+func convertFilesIntoVolumes(state *project.State, workloadName string, containerName string, substitutionFunction func(string) (string, error)) ([]compose.ServiceVolumeConfig, error) {
+	input := state.Workloads[workloadName].Spec.Containers[containerName].Files
+	mountsDirectory := state.MountsDirectory
 	if mountsDirectory == "" || mountsDirectory == "/dev/null" {
 		return nil, fmt.Errorf("files are not supported")
 	}
@@ -188,7 +190,11 @@ func convertFilesIntoVolumes(workloadName string, containerName string, input []
 		if file.Content != nil {
 			content = []byte(*file.Content)
 		} else if file.Source != nil {
-			content, err = os.ReadFile(*file.Source)
+			sourcePath := *file.Source
+			if !filepath.IsAbs(sourcePath) && state.Workloads[workloadName].File != nil {
+				sourcePath = filepath.Join(filepath.Dir(*state.Workloads[workloadName].File), sourcePath)
+			}
+			content, err = os.ReadFile(sourcePath)
 			if err != nil {
 				return nil, fmt.Errorf("containers.%s.files[%d].source: failed to read: %w", containerName, idx, err)
 			}


Test patch:
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index 78f2168..36d71e7 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -170,6 +170,7 @@ services:
 	sd, ok, err := project.LoadStateDirectory(td)
 	assert.NoError(t, err)
 	assert.True(t, ok)
+	assert.Equal(t, "score.yaml", *sd.State.Workloads["example"].File)
 	assert.Len(t, sd.State.Workloads, 1)
 	assert.Len(t, sd.State.Resources, 1)
 }
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index c35574c..6fa67f6 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -313,17 +313,31 @@ 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))
+	assert.NoError(t, os.MkdirAll(filepath.Join(td, "subdir"), 0755))
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "subdir/original.txt"), []byte(`first ${metadata.name} second`), 0644))
+	state := &project.State{
+		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+			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)},
+						},
+					},
+				},
+			},
+			File: util.Ref(filepath.Join(td, "subdir/score.yaml")),
+		}},
+		MountsDirectory: td,
+	}
 	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) {
+		state, "my-workload", "my-container",
+		func(s string) (string, error) {
 			switch s {
 			case "metadata.name":
 				return "blah", nil
@@ -360,11 +374,24 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 
 func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 	td := t.TempDir()
+	state := &project.State{
+		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+			Spec: score.Workload{
+				Containers: map[string]score.Container{
+					"my-container": {
+						Files: []score.ContainerFilesElem{
+							{Target: "/ant.txt", Source: util.Ref(filepath.Join(td, "original.txt"))},
+						},
+					},
+				},
+			},
+			File: util.Ref(filepath.Join(td, "score.yaml")),
+		}},
+		MountsDirectory: td,
+	}
 	_, 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) {
+		state, "my-workload", "my-container",
+		func(s string) (string, error) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
@@ -373,11 +400,24 @@ func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 
 func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 	td := t.TempDir()
+	state := &project.State{
+		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+			Spec: score.Workload{
+				Containers: map[string]score.Container{
+					"my-container": {
+						Files: []score.ContainerFilesElem{
+							{Target: "/ant.txt"},
+						},
+					},
+				},
+			},
+			File: util.Ref(filepath.Join(td, "score.yaml")),
+		}},
+		MountsDirectory: td,
+	}
 	_, err := convertFilesIntoVolumes(
-		"my-workload", "my-container",
-		[]score.ContainerFilesElem{
-			{Target: "/ant.txt"},
-		}, td, func(s string) (string, error) {
+		state, "my-workload", "my-container",
+		func(s string) (string, error) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
@@ -386,11 +426,24 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 
 func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 	td := t.TempDir()
+	state := &project.State{
+		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+			Spec: score.Workload{
+				Containers: map[string]score.Container{
+					"my-container": {
+						Files: []score.ContainerFilesElem{
+							{Target: "/ant.txt", Content: util.Ref("${metadata.blah}")},
+						},
+					},
+				},
+			},
+			File: util.Ref(filepath.Join(td, "score.yaml")),
+		}},
+		MountsDirectory: td,
+	}
 	_, err := convertFilesIntoVolumes(
-		"my-workload", "my-container",
-		[]score.ContainerFilesElem{
-			{Target: "/ant.txt", Content: util.Ref("${metadata.blah}")},
-		}, td, func(s string) (string, error) {
+		state, "my-workload", "my-container",
+		func(s string) (string, error) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
@@ -399,14 +452,27 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 
 func TestConvertFiles_with_mode(t *testing.T) {
 	td := t.TempDir()
+	state := &project.State{
+		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+			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")},
+						},
+					},
+				},
+			},
+			File: util.Ref(filepath.Join(td, "score.yaml")),
+		}},
+		MountsDirectory: td,
+	}
 	out, err := convertFilesIntoVolumes(
-		"my-workload", "my-container",
-		[]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")},
-		}, td, func(s string) (string, error) {
+		state, "my-workload", "my-container",
+		func(s string) (string, error) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)