Repository: score-spec/score-compose
Pull request #71 (2024-03-12 14:10:26)

feat: support --image and --build on score-compose generate 
Depends on https://github.com/score-spec/score-compose/pull/65

This PR adds the `--image` and `--build` flags to score-compose `generate`.

The `--image` flag works like other score implementations, and provides a default image for any containers that have `image: .`. If you want to override a container which already has an image, use `--override-property` instead.

The `--build` flag accepts either a plain directory string as a build context per container: `--build hello-world=./subdir`, or a more complex json structure which supports additional context things like args and custom docker files: `--build 'hello-world={"dockerfile":"Dockerfile.example"}'`.

Both `--image` and `--build` are stored in the context so that any future generate calls continue to use them unless the workload is overidden again.

This is tested with unit tests and all decoding is strict with compose-specified models.


Patch:
diff --git a/internal/command/generate.go b/internal/command/generate.go
index de817e1..db33538 100644
--- a/internal/command/generate.go
+++ b/internal/command/generate.go
@@ -2,6 +2,7 @@ package command
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"log/slog"
 	"os"
@@ -26,6 +27,8 @@ import (
 const (
 	generateCmdOverridesFileFlag    = "overrides-file"
 	generateCmdOverridePropertyFlag = "override-property"
+	generateCmdImageFlag            = "image"
+	generateCmdBuildFlag            = "build"
 )
 
 var generateCommand = &cobra.Command{
@@ -124,7 +127,44 @@ arguments.
 				return fmt.Errorf("failed to convert '%s' to structure: %w", workloadName, err)
 			}
 
-			currentState, err = currentState.WithWorkload(&out, nil)
+			// Gather container build contexts, these will be stored and added to the generated compose output later
+			containerBuildContexts := make(map[string]types.BuildConfig)
+			if v, _ := cmd.Flags().GetStringArray(generateCmdBuildFlag); len(v) > 0 {
+				for _, buildFlag := range v {
+					parts := strings.SplitN(buildFlag, "=", 2)
+					if len(parts) != 2 {
+						return fmt.Errorf("invalid --%s '%s': expected 2 =-separated parts", generateCmdBuildFlag, buildFlag)
+					} else if _, ok := out.Containers[parts[0]]; !ok {
+						return fmt.Errorf("invalid --%s '%s': unknown container '%s'", generateCmdBuildFlag, buildFlag, parts[0])
+					}
+					if strings.HasPrefix(parts[1], "{") {
+						var out types.BuildConfig
+						dec := json.NewDecoder(strings.NewReader(parts[1]))
+						dec.DisallowUnknownFields()
+						if err := dec.Decode(&out); err != nil {
+							return fmt.Errorf("invalid --%s '%s': %w", generateCmdBuildFlag, buildFlag, err)
+						}
+						containerBuildContexts[parts[0]] = out
+					} else {
+						containerBuildContexts[parts[0]] = types.BuildConfig{Context: parts[1]}
+					}
+				}
+			}
+
+			// Apply image if container image is .
+			for containerName, container := range out.Containers {
+				if container.Image == "." {
+					if v, _ := cmd.Flags().GetString(generateCmdImageFlag); v != "" {
+						container.Image = v
+						slog.Info("Set container image for workload '%s' container '%s' to %s from --%s", workloadName, containerName, v, generateCmdImageFlag)
+						out.Containers[containerName] = container
+					} else if _, ok := containerBuildContexts[containerName]; !ok {
+						return fmt.Errorf("failed to convert '%s' because container '%s' has no image and neither --%s nor --%s was provided", workloadName, containerName, generateCmdImageFlag, generateCmdBuildFlag)
+					}
+				}
+			}
+
+			currentState, err = currentState.WithWorkload(&out, nil, containerBuildContexts)
 			if err != nil {
 				return fmt.Errorf("failed to add workload '%s': %w", workloadName, err)
 			}
@@ -171,7 +211,7 @@ arguments.
 			}
 
 			slog.Info(fmt.Sprintf("Converting workload '%s' to Docker compose", workloadName))
-			converted, err := compose.ConvertSpec(&workloadState.Spec, outputFunctions)
+			converted, err := compose.ConvertSpec(&workloadState.Spec, workloadState.BuildConfigs, outputFunctions)
 			if err != nil {
 				return fmt.Errorf("failed to convert workload '%s' to Docker compose: %w", workloadName, err)
 			}
@@ -249,6 +289,8 @@ func init() {
 	generateCommand.Flags().StringP("output", "o", "compose.yaml", "The output file to write the composed compose file to")
 	generateCommand.Flags().String(generateCmdOverridesFileFlag, "", "An optional file of Score overrides to merge in")
 	generateCommand.Flags().StringArray(generateCmdOverridePropertyFlag, []string{}, "An optional set of path=key overrides to set or remove")
+	generateCommand.Flags().String(generateCmdImageFlag, "", "An optional container image to use for any container with image == '.'")
+	generateCommand.Flags().StringArray(generateCmdBuildFlag, []string{}, "An optional build context to use for the given container --build=container=./dir or --build=container={'\"context\":\"./dir\"}")
 	rootCmd.AddCommand(generateCommand)
 }
 
diff --git a/internal/command/run.go b/internal/command/run.go
index b3f3a06..f80fa53 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -176,7 +176,7 @@ func run(cmd *cobra.Command, args []string) error {
 	// Build a fake score-compose init state. We don't actually need to store or persist this because we're not doing
 	// anything iterative or stateful.
 	state := &project.State{MountsDirectory: "/dev/null"}
-	state, err = state.WithWorkload(&spec, &scoreFile)
+	state, err = state.WithWorkload(&spec, &scoreFile, nil)
 	if err != nil {
 		return fmt.Errorf("failed to add score file to state: %w", err)
 	}
@@ -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, workloadResourceOutputs)
+	proj, err := compose.ConvertSpec(&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 6859e81..e2f975e 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -22,7 +22,7 @@ import (
 )
 
 // ConvertSpec converts SCORE specification into docker-compose configuration.
-func ConvertSpec(spec *score.Workload, resources map[string]project.OutputLookupFunc) (*compose.Project, error) {
+func ConvertSpec(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")
@@ -160,6 +160,12 @@ func ConvertSpec(spec *score.Workload, resources map[string]project.OutputLookup
 			Volumes:     volumes,
 		}
 
+		if bc, ok := containerBuildConfigs[containerName]; ok {
+			slog.Info(fmt.Sprintf("containers.%s: overriding container build config to context=%s", containerName, bc.Context))
+			svc.Build = &bc
+			svc.Image = ""
+		}
+
 		// if we are not the "first" service, then inherit the network from the first service
 		if firstService == "" {
 			firstService = svc.Name
diff --git a/internal/project/state.go b/internal/project/state.go
index 0a0398d..e7728f2 100644
--- a/internal/project/state.go
+++ b/internal/project/state.go
@@ -5,6 +5,7 @@ import (
 	"maps"
 	"reflect"
 
+	compose "github.com/compose-spec/compose-go/v2/types"
 	score "github.com/score-spec/score-go/types"
 )
 
@@ -23,6 +24,9 @@ type ScoreWorkloadState struct {
 	Spec score.Workload `yaml:"spec"`
 	// File is the source score file if known.
 	File *string `yaml:"file,omitempty"`
+	// BuildConfigs is a stored set of container build configs for this workload. Any known container should inherit
+	// the appropriate config when being converted.
+	BuildConfigs map[string]compose.BuildConfig
 }
 
 type ScoreResourceState struct {
@@ -56,7 +60,7 @@ type OutputLookupFunc func(keys ...string) (interface{}, error)
 // WithWorkload returns a new copy of State with the workload added, if the workload already exists with the same name
 // then it will be replaced.
 // This is not a deep copy, but any writes are executed in a copy-on-write manner to avoid modifying the source.
-func (s *State) WithWorkload(spec *score.Workload, filePath *string) (*State, error) {
+func (s *State) WithWorkload(spec *score.Workload, filePath *string, contexts map[string]compose.BuildConfig) (*State, error) {
 	out := *s
 	if s.Workloads == nil {
 		out.Workloads = make(map[string]ScoreWorkloadState)
@@ -64,8 +68,9 @@ func (s *State) WithWorkload(spec *score.Workload, filePath *string) (*State, er
 		out.Workloads = maps.Clone(s.Workloads)
 	}
 	out.Workloads[spec.Metadata["name"].(string)] = ScoreWorkloadState{
-		Spec: *spec,
-		File: filePath,
+		Spec:         *spec,
+		File:         filePath,
+		BuildConfigs: maps.Clone(contexts),
 	}
 	return &out, nil
 }


Test patch:
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index f53efe9..a9b1f2e 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -39,7 +39,9 @@ Examples:
   score-compose generate score.yaml --override-file=./overrides.score.yaml --override-property=metadata.key=value
 
 Flags:
+      --build stringArray               An optional build context to use for the given container --build=container=./dir or --build=container={'"context":"./dir"}
   -h, --help                            help for generate
+      --image string                    An optional container image to use for any container with image == '.'
   -o, --output string                   The output file to write the composed compose file to (default "compose.yaml")
       --override-property stringArray   An optional set of path=key overrides to set or remove
       --overrides-file string           An optional file of Score overrides to merge in
@@ -157,6 +159,99 @@ services:
 	assert.Len(t, sd.State.Resources, 1)
 }
 
+func TestInitAndGenerate_with_image_override(t *testing.T) {
+	td := changeToTempDir(t)
+	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+
+	// write new score file
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: .
+`), 0644))
+
+	t.Run("generate but fail due to missing override", func(t *testing.T) {
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{
+			"generate", "-o", "compose-output.yaml", "--", "score.yaml",
+		})
+		assert.EqualError(t, err, "failed to convert 'example' because container 'example' has no image and neither --image nor --build was provided")
+	})
+
+	t.Run("generate with image", func(t *testing.T) {
+		// generate with image
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{
+			"generate", "-o", "compose-output.yaml", "--image", "busybox:latest", "--", "score.yaml",
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, "", stdout)
+		raw, err := os.ReadFile(filepath.Join(td, "compose-output.yaml"))
+		assert.NoError(t, err)
+		expectedOutput := `name: "001"
+services:
+    example-example:
+        image: busybox:latest
+`
+		assert.Equal(t, expectedOutput, string(raw))
+		// generate again just for luck
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "-o", "compose-output.yaml"})
+		assert.NoError(t, err)
+		assert.Equal(t, "", stdout)
+		raw, err = os.ReadFile(filepath.Join(td, "compose-output.yaml"))
+		assert.NoError(t, err)
+		assert.Equal(t, expectedOutput, string(raw))
+	})
+
+	t.Run("generate with raw build context", func(t *testing.T) {
+		// generate with build context
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{
+			"generate", "-o", "compose-output.yaml", "--build", "example=./dir", "--", "score.yaml",
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, "", stdout)
+		raw, err := os.ReadFile(filepath.Join(td, "compose-output.yaml"))
+		assert.NoError(t, err)
+		expectedOutput := `name: "001"
+services:
+    example-example:
+        build:
+            context: ./dir
+`
+		assert.Equal(t, expectedOutput, string(raw))
+		// generate again just for luck
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "-o", "compose-output.yaml"})
+		assert.NoError(t, err)
+		assert.Equal(t, "", stdout)
+		raw, err = os.ReadFile(filepath.Join(td, "compose-output.yaml"))
+		assert.NoError(t, err)
+		assert.Equal(t, expectedOutput, string(raw))
+	})
+
+	t.Run("generate with json build context", func(t *testing.T) {
+		stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{
+			"generate", "-o", "compose-output.yaml", "--build", `example={"context":"./dir","args":{"DEBUG":"true"}}`, "--", "score.yaml",
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, "", stdout)
+		raw, err := os.ReadFile(filepath.Join(td, "compose-output.yaml"))
+		assert.NoError(t, err)
+		expectedOutput := `name: "001"
+services:
+    example-example:
+        build:
+            context: ./dir
+            args:
+                DEBUG: "true"
+`
+		assert.Equal(t, expectedOutput, string(raw))
+	})
+
+}
+
 func TestGenerateRedisResource(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 5a76ad1..9f82923 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -288,7 +288,7 @@ func TestScoreConvert(t *testing.T) {
 			po, _ = evt.GenerateSubProvisioner("some-dns", "").Provision(nil, nil)
 			resourceOutputs["some-dns"] = po.OutputLookupFunc
 
-			proj, err := ConvertSpec(tt.Source, resourceOutputs)
+			proj, err := ConvertSpec(tt.Source, nil, resourceOutputs)
 
 			if tt.Error != nil {
 				// On Error
diff --git a/internal/project/state_test.go b/internal/project/state_test.go
index 034cd9f..729366c 100644
--- a/internal/project/state_test.go
+++ b/internal/project/state_test.go
@@ -19,7 +19,7 @@ func mustLoadWorkload(t *testing.T, spec string) *score.Workload {
 func mustAddWorkload(t *testing.T, s *State, spec string) *State {
 	t.Helper()
 	w := mustLoadWorkload(t, spec)
-	n, err := s.WithWorkload(w, nil)
+	n, err := s.WithWorkload(w, nil, nil)
 	require.NoError(t, err)
 	return n
 }
@@ -37,7 +37,7 @@ containers:
 resources:
   foo:
     type: thing
-`), nil)
+`), nil, nil)
 		require.NoError(t, err)
 		assert.Len(t, start.Workloads, 0)
 		assert.Len(t, next.Workloads, 1)
@@ -59,7 +59,7 @@ containers:
 resources:
   foo:
     type: thing
-`), nil)
+`), nil, nil)
 		require.NoError(t, err)
 		next2, err := next1.WithWorkload(mustLoadWorkload(t, `
 metadata:
@@ -67,7 +67,7 @@ metadata:
 containers:
   hello-world:
     image: hi
-`), nil)
+`), nil, nil)
 		require.NoError(t, err)
 
 		assert.Len(t, start.Workloads, 0)