Repository: score-spec/score-compose
Pull request #74 (2024-03-14 11:30:48)

feat: support dependency order between resource services and workload services
When resource provisioners create services that take some time to start up or have init containers that _must_ complete before the workload services start, we need to determine a start up order.

This PR adds a new service called `wait_for_resources` which is used as a coordination point. This is only added when resource services are provisioned and it gets a dependency added for each one. When present, the workload services will now wait for this service to run before they may start.

You can see this affect in the unit test added in generate_test.go.

The ready condition is tuned based on what we can detect about the container. Since we cannot always distinguish services that are init containers, we have an opt-in / best-effort label which can be added in provisioners to indicate that we must wait for the service to actually finish.




Patch:
diff --git a/internal/command/default.provisioners.yaml b/internal/command/default.provisioners.yaml
index 0fac2fd..1ae28e7 100644
--- a/internal/command/default.provisioners.yaml
+++ b/internal/command/default.provisioners.yaml
@@ -152,6 +152,8 @@
       - |
         cd /db-scripts
         ls db-*.sql | xargs cat | psql "postgresql://postgres:$${POSTGRES_PASSWORD}@{{ dig .Init.sk "instanceServiceName" "" .Shared }}:5432/postgres"
+      labels:
+        dev.score.compose.labels.is-init-container: "true"
       depends_on:
         {{ dig .Init.sk "instanceServiceName" "" .Shared }}:
           condition: service_healthy
diff --git a/internal/command/generate.go b/internal/command/generate.go
index de817e1..a6e7a95 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)
 			}
@@ -164,6 +204,8 @@ arguments.
 			slog.Info(fmt.Sprintf("Provisioned %d resources", len(currentState.Resources)))
 		}
 
+		waitServiceName, hasWaitService := injectWaitService(superProject)
+
 		for workloadName, workloadState := range currentState.Workloads {
 			outputFunctions, err := currentState.GetResourceOutputForWorkload(workloadName)
 			if err != nil {
@@ -171,7 +213,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)
 			}
@@ -180,6 +222,12 @@ arguments.
 				if _, ok := superProject.Services[serviceName]; ok {
 					return fmt.Errorf("failed to add converted workload '%s': duplicate service name '%s'", workloadName, serviceName)
 				}
+				if hasWaitService {
+					if service.DependsOn == nil {
+						service.DependsOn = make(types.DependsOnConfig)
+					}
+					service.DependsOn[waitServiceName] = types.ServiceDependency{Condition: "service_started"}
+				}
 				superProject.Services[serviceName] = service
 			}
 			for volumeName, volume := range converted.Volumes {
@@ -249,6 +297,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)
 }
 
@@ -289,3 +339,37 @@ func parseAndApplyOverrideProperty(entry string, flagName string, spec map[strin
 	}
 	return nil
 }
+
+// injectWaitService injects a service into the compose project which waits for all other services to be started,
+// healthy, or complete depending on their definition. The workload services may then wait for this.
+// This will return an empty string and false if there are no applicable services.
+func injectWaitService(p *types.Project) (string, bool) {
+	if len(p.Services) == 0 {
+		return "", false
+	}
+	newService := types.ServiceConfig{
+		Name:      "wait-for-resources",
+		Image:     "alpine",
+		Command:   types.ShellCommand{"echo"},
+		DependsOn: make(types.DependsOnConfig),
+	}
+	for otherServiceName, otherService := range p.Services {
+		condition := "service_started"
+		if otherService.HealthCheck != nil {
+			condition = "service_healthy"
+		} else if v := otherService.Labels["dev.score.compose.labels.is-init-container"]; v == "true" {
+			// annoyingly we can't tell based on the definition whether a service is designed to stop or not,
+			// so we'll use this label as a best effort indicator.
+			condition = "service_completed_successfully"
+		}
+		newService.DependsOn[otherServiceName] = types.ServiceDependency{
+			Condition: condition,
+			Required:  true,
+		}
+	}
+	if p.Services == nil {
+		p.Services = make(types.Services)
+	}
+	p.Services[newService.Name] = newService
+	return newService.Name, true
+}
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..b9c7a8c 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"})
@@ -261,3 +356,93 @@ resources:
 		assert.NoError(t, cmd.Run())
 	})
 }
+
+func TestInitAndGenerate_with_depends_on(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("score.yaml", []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: foo
+resources:
+  thing:
+    type: thing
+`), 0644))
+
+	assert.NoError(t, os.WriteFile(".score-compose/00-custom.provisioners.yaml", []byte(`
+- uri: template://blah
+  type: thing
+  services: |
+    init_service:
+      image: thing
+      labels:
+        dev.score.compose.labels.is-init-container: "true"
+    generic_service:
+      image: other
+    service_with_healthcheck:
+      image: something
+      healthcheck:
+        test: ["CMD", "boo"]
+`), 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:
+        depends_on:
+            wait-for-resources:
+                condition: service_started
+                required: false
+        image: foo
+    generic_service:
+        image: other
+    init_service:
+        image: thing
+        labels:
+            dev.score.compose.labels.is-init-container: "true"
+    service_with_healthcheck:
+        healthcheck:
+            test:
+                - CMD
+                - boo
+        image: something
+    wait-for-resources:
+        command:
+            - echo
+        depends_on:
+            generic_service:
+                condition: service_started
+                required: true
+            init_service:
+                condition: service_completed_successfully
+                required: true
+            service_with_healthcheck:
+                condition: service_healthy
+                required: true
+        image: alpine
+`, string(raw))
+
+	t.Run("validate compose spec", func(t *testing.T) {
+		if os.Getenv("NO_DOCKER") != "" {
+			t.Skip("NO_DOCKER is set")
+			return
+		}
+		dockerCmd, err := exec.LookPath("docker")
+		require.NoError(t, err)
+		cmd := exec.Command(dockerCmd, "compose", "-f", "compose.yaml", "convert", "--quiet", "--dry-run")
+		cmd.Dir = td
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+		assert.NoError(t, cmd.Run())
+	})
+}
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)