Repository: score-spec/score-compose
Pull request #243 (2025-01-19 21:09:56)

feat: update to score spec release 1.9.0 with binaryContent files and…
See https://github.com/score-spec/score-go/releases/tag/v1.9.0 for the spec updates.

With this update, score-compose now supports exec mode liveness/readiness probes and can embed binary/non-utf8 files from `source` or `binaryContent`.

This is tested via existing unit tests and samples.



Patch:
diff --git a/README.md b/README.md
index db78600..8c56835 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,10 @@
 
 `score-compose` supports as many Score features as possible, however there are certain parts that don't fit well in a local Docker case and are not supported:
 
-| Feature                                                             | Support | Impact                                                                                                                                                                                                                                                                                                                                              |
-|---------------------------------------------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `containers.*.resources.limits` / `containers.*.resources.requests` | none    | **Limits will be validated but ignored.** While the compose specification has some support for this, it is requires particular Docker versions that cannot be relied on. *This should have no impact on Workload execution*.                                                                                                                        |
-| `containers.*.livenessProbe` / `containers.*.readinessProbe`        | none    | **Probes will be validated but ignored.** The Score specification only details K8s-like HTTP probes, but the compose specification only supports direct command execution. We cannot convert between the two reliably. *This should have no impact on Workload execution*. Tracked in [#86](https://github.com/score-spec/score-compose/issues/86). |
+| Feature                                                                      | Support | Impact                                                                                                                                                                                                                                                                                                                                              |
+|------------------------------------------------------------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `containers.*.resources.limits` / `containers.*.resources.requests`          | none    | **Limits will be validated but ignored.** While the compose specification has some support for this, it is requires particular Docker versions that cannot be relied on. *This should have no impact on Workload execution*.                                                                                                                        |
+| `containers.*.livenessProbe.httpGet` / `containers.*.readinessProbe.httpGet` | none    | **Probes will be validated but ignored.** The Score specification only details K8s-like HTTP probes, but the compose specification only supports direct command execution. We cannot convert between the two reliably. *This should have no impact on Workload execution*. Tracked in [#86](https://github.com/score-spec/score-compose/issues/86). |
 
 ## Resource support
 
diff --git a/examples/03-files/README.md b/examples/03-files/README.md
index b373df6..204f127 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 relative to the input Score file.
+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. Binary file content, or files that contain non-utf-8 characters can be provided via the a base64 encoded `binaryContent` source property but does understandably doesn't support placeholder replacements.s
 
 ```yaml
 apiVersion: score.dev/v1b1
@@ -16,13 +16,15 @@ containers:
   hello:
     image: busybox
     command: ["/bin/sh"]
-    args: ["-c", "while true; do cat /fileA.txt; cat /fileB.txt; sleep 5; done"]
+    args: ["-c", "while true; do cat /fileA.txt; cat /fileB.txt; cat /fileC.bin; sleep 5; done"]
     files:
       - target: /fileA.txt
         source: fileA.txt
       - target: /fileB.txt
         content: |
           I am ${metadata.name}
+      - target: /fileC.bin
+        binaryContent: aGVsbG8gd29ybGQ=
 ```
 
 As usual, we run this with `init` and `generate score.yaml`. After generate, you may notice that the files have been written to disk at `.score-compose/mounts/files`.
@@ -42,8 +44,9 @@ $ docker compose up
 Attaching to hello-world-hello-1
 hello-world-hello-1  | This is fileA.
 hello-world-hello-1  | I am hello-world
-hello-world-hello-1  | This is fileA.
+hello-world-hello-1  | hello worldThis is fileA.
 hello-world-hello-1  | I am hello-world
+hello-world-hello-1  | hello worldThis is fileA.
 ```
 
 `files[*].noExpand` is supported to disable placeholder interpolation in inline content. `files[*].mode` is not yet supported, see [#88](https://github.com/score-spec/score-compose/issues/88).
diff --git a/examples/03-files/score.yaml b/examples/03-files/score.yaml
index 967fa12..3c952c0 100644
--- a/examples/03-files/score.yaml
+++ b/examples/03-files/score.yaml
@@ -7,10 +7,12 @@ containers:
   hello:
     image: busybox
     command: ["/bin/sh"]
-    args: ["-c", "while true; do cat /fileA.txt; cat /fileB.txt; sleep 5; done"]
+    args: ["-c", "while true; do cat /fileA.txt; cat /fileB.txt; cat /fileC.bin; sleep 5; done"]
     files:
       - target: /fileA.txt
         source: fileA.txt
       - target: /fileB.txt
         content: |
           I am ${metadata.name}
+      - target: /fileC.bin
+        binaryContent: aGVsbG8gd29ybGQ=
diff --git a/examples/08-service-port-resource/README.md b/examples/08-service-port-resource/README.md
index 17c026d..9ef056c 100644
--- a/examples/08-service-port-resource/README.md
+++ b/examples/08-service-port-resource/README.md
@@ -15,6 +15,9 @@ metadata:
 containers:
   example:
     image: nginx
+    readinessProbe:
+      exec:
+        command: ["/usr/bin/curl", "-f", "-m", "5", "http://localhost"]
 service:
   ports:
     web:
diff --git a/examples/08-service-port-resource/score.yaml b/examples/08-service-port-resource/score.yaml
deleted file mode 100755
index f2fa13d..0000000
--- a/examples/08-service-port-resource/score.yaml
+++ /dev/null
@@ -1,35 +0,0 @@
-# Score provides a developer-centric and platform-agnostic 
-# Workload specification to improve developer productivity and experience. 
-# Score eliminates configuration management between local and remote environments.
-#
-# Specification reference: https://docs.score.dev/docs/reference/score-spec-reference/
----
-
-# Score specification version
-apiVersion: score.dev/v1b1
-
-metadata:
-  name: example
-
-containers:
-  hello-world:
-    image: nginx:latest
-
-    # Uncomment the following for a custom entrypoint command
-    # command: []
-
-    # Uncomment the following for custom arguments
-    # args: []
-
-    # Environment variables to inject into the container
-    variables:
-      EXAMPLE_VARIABLE: "example-value"
-
-service:
-  ports:
-    # Expose the http port from nginx on port 8080
-    www:
-      port: 8080
-      targetPort: 80
-
-resources: {}
diff --git a/examples/08-service-port-resource/scoreA.yaml b/examples/08-service-port-resource/scoreA.yaml
index b9c4323..aca3e45 100644
--- a/examples/08-service-port-resource/scoreA.yaml
+++ b/examples/08-service-port-resource/scoreA.yaml
@@ -4,8 +4,11 @@ metadata:
 containers:
   example:
     image: nginx
+    readinessProbe:
+      exec:
+        command: ["/usr/bin/curl", "-f", "-m", "5", "http://localhost"]
 service:
   ports:
     web:
       port: 8080
-      targetPort: 80
+      targetPort: 80
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 9f0dd51..3adc46e 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/compose-spec/compose-go/v2 v2.4.7
 	github.com/imdario/mergo v1.0.1
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/score-spec/score-go v1.8.5
+	github.com/score-spec/score-go v1.9.0
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.10.0
diff --git a/go.sum b/go.sum
index 78a1cf8..44c38ef 100644
--- a/go.sum
+++ b/go.sum
@@ -57,6 +57,8 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6Ng
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
 github.com/score-spec/score-go v1.8.5 h1:ssCvGojcM+cs+GK7/GuE2oA5iDoCqcAofL/Usce4UFI=
 github.com/score-spec/score-go v1.8.5/go.mod h1:rW7jKWPPqubDzHmIlE/r/x5z64Q4eMdjKCxZ8XB+/LM=
+github.com/score-spec/score-go v1.9.0 h1:PLuRZHduzL4jIPIbHjLFYZN6HlHwlbl217hL9tIURaY=
+github.com/score-spec/score-go v1.9.0/go.mod h1:rW7jKWPPqubDzHmIlE/r/x5z64Q4eMdjKCxZ8XB+/LM=
 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 fae363e..90fcd35 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -16,6 +16,7 @@ package compose
 
 import (
 	"bytes"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -25,6 +26,8 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+	"time"
+	"unicode/utf8"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
 	"github.com/score-spec/score-go/framework"
@@ -135,13 +138,6 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 			}
 		}
 
-		if cSpec.ReadinessProbe != nil {
-			slog.Warn(fmt.Sprintf("containers.%s.readinessProbe: not supported - ignoring", containerName))
-		}
-		if cSpec.LivenessProbe != nil {
-			slog.Warn(fmt.Sprintf("containers.%s.livenessProbe: not supported - ignoring", containerName))
-		}
-
 		var svc = compose.ServiceConfig{
 			Name:        workloadName + "-" + containerName,
 			Annotations: buildWorkloadAnnotations(workloadName, spec),
@@ -152,6 +148,20 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 			Volumes:     volumes,
 		}
 
+		if cSpec.ReadinessProbe != nil {
+			if hc, err := convertProbeToExec(cSpec.ReadinessProbe); err != nil {
+				return nil, fmt.Errorf("containers.%s.readinessProbe: %w", containerName, err)
+			} else if hc != nil {
+				svc.HealthCheck = hc
+			}
+		} else if cSpec.LivenessProbe != nil {
+			if hc, err := convertProbeToExec(cSpec.ReadinessProbe); err != nil {
+				return nil, fmt.Errorf("containers.%s.livenessProbe: %w", containerName, err)
+			} else if hc != nil {
+				svc.HealthCheck = hc
+			}
+		}
+
 		if bc, ok := state.Workloads[workloadName].Extras.BuildConfigs[containerName]; ok {
 			slog.Info(fmt.Sprintf("containers.%s: overriding container build config to context=%s", containerName, bc.Context))
 			svc.Build = &bc
@@ -194,6 +204,24 @@ func buildWorkloadAnnotations(name string, spec *score.Workload) map[string]stri
 	return out
 }
 
+func convertProbeToExec(p *score.ContainerProbe) (*compose.HealthCheckConfig, error) {
+	if p.Exec != nil {
+		if len(p.Exec.Command) == 0 {
+			return nil, fmt.Errorf("exec command is empty")
+		}
+		return &compose.HealthCheckConfig{
+			Test:     append([]string{"CMD"}, p.Exec.Command...),
+			Interval: util.Ref(compose.Duration(time.Second * 5)),
+			Timeout:  util.Ref(compose.Duration(time.Second * 5)),
+			Disable:  false,
+		}, nil
+	} else if p.HttpGet != nil {
+		slog.Warn("httpGet container probe: not supported - ignoring")
+		return nil, nil
+	}
+	return nil, fmt.Errorf("exec or httpGet must be specified")
+}
+
 // convertFilesIntoVolumes converts the lists of files into a list of bind mounts in the mounts directory.
 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
@@ -222,10 +250,15 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 			if err != nil {
 				return nil, fmt.Errorf("containers.%s.files[%d].source: failed to read: %w", containerName, idx, 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)
+			}
 		} else {
-			return nil, fmt.Errorf("containers.%s.files[%d]: missing 'content' or 'source'", containerName, idx)
+			return nil, fmt.Errorf("containers.%s.files[%d]: missing 'content', 'binaryContent', or 'source'", containerName, idx)
 		}
-		if file.NoExpand == nil || !*file.NoExpand {
+		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)


Test patch:
diff --git a/internal/command/generate_examples_test.go b/internal/command/generate_examples_test.go
index 2325daa..c94d86f 100644
--- a/internal/command/generate_examples_test.go
+++ b/internal/command/generate_examples_test.go
@@ -86,7 +86,7 @@ services:
             compose.score.dev/workload-name: hello-world
         command:
             - -c
-            - while true; do cat /fileA.txt; cat /fileB.txt; sleep 5; done
+            - while true; do cat /fileA.txt; cat /fileB.txt; cat /fileC.bin; sleep 5; done
         entrypoint:
             - /bin/sh
         hostname: hello-world
@@ -98,6 +98,9 @@ services:
             - type: bind
               source: .score-compose/mounts/files/hello-world-files-1-fileB.txt
               target: /fileB.txt
+            - type: bind
+              source: .score-compose/mounts/files/hello-world-files-2-fileC.bin
+              target: /fileC.bin
 `,
 		},
 		{
@@ -185,6 +188,16 @@ services:
         annotations:
             compose.score.dev/workload-name: workload-a
         hostname: workload-a
+        healthcheck:
+            test:
+                - CMD
+                - /usr/bin/curl
+                - -f
+                - -m
+                - "5"
+                - http://localhost
+            timeout: 5s
+            interval: 5s
         image: nginx
         ports:
             - target: 80
@@ -239,7 +252,7 @@ services:
 			require.NoError(t, os.RemoveAll(".score-compose"))
 			require.NoError(t, os.RemoveAll("compose.yaml"))
 
-			stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+			stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
 			require.NoError(t, err)
 			assert.Equal(t, "", stdout)
 
diff --git a/internal/command/run_test.go b/internal/command/run_test.go
index 899342d..f631a46 100644
--- a/internal/command/run_test.go
+++ b/internal/command/run_test.go
@@ -173,8 +173,6 @@ resources:
 		"WARN: resources.resource-two2: 'Resource-Two.default' is not directly supported in score-compose, references will be converted to environment variables\n",
 		"WARN: containers.container-one1.resources.requests: not supported - ignoring\n",
 		"WARN: containers.container-one1.resources.limits: not supported - ignoring\n",
-		"WARN: containers.container-one1.readinessProbe: not supported - ignoring\n",
-		"WARN: containers.container-one1.livenessProbe: not supported - ignoring\n",
 	} {
 		assert.Contains(t, stderr, l)
 	}
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index f08967a..3ebdb2e 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -491,6 +491,7 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 							{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==")},
 						},
 					},
 				},
@@ -520,6 +521,7 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 		{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")},
 	}, out)
 	for k, v := range map[string]string{
 		"my-workload-files-0-ant.txt": "first blah second",
@@ -528,6 +530,8 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 		"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",
+		// NOTE: binaryContent doesn't support placeholders
+		"my-workload-files-6-goat.txt": "fifth ${metadata.name} sixth",
 	} {
 		t.Run(k, func(t *testing.T) {
 			raw, err := os.ReadFile(filepath.Join(td, "files", k))
@@ -591,7 +595,7 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 			return "", fmt.Errorf("unknown key")
 		},
 	)
-	assert.EqualError(t, err, "containers.my-container.files[0]: missing 'content' or 'source'")
+	assert.EqualError(t, err, "containers.my-container.files[0]: missing 'content', 'binaryContent', or 'source'")
 }
 
 func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {