From 3f60ddfad597f48b9a747715b77605edb9238b4d Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 5 Jun 2026 01:59:40 +0530 Subject: [PATCH] feat: add custom placeholder delimiters via workload annotations in score-compose Signed-off-by: Abhishek --- examples/03-files/README.md | 35 ++++++ internal/compose/convert.go | 93 ++++++++++++++- internal/compose/convert_test.go | 187 +++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 5 deletions(-) diff --git a/examples/03-files/README.md b/examples/03-files/README.md index 204f127c..d42aed1b 100644 --- a/examples/03-files/README.md +++ b/examples/03-files/README.md @@ -50,3 +50,38 @@ 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). + +## Custom placeholder delimiters (experimental) + +Score's default `${...}` placeholder syntax can clash with config formats that use the same notation themselves, such as Spring Boot properties, shell scripts, or Docker Compose env vars. When you need literal `${...}` to survive in the rendered file, you can ask Score to look for a different delimiter pair by setting two annotations on the workload metadata: + +- `compose.score.dev/experiment-placeholder-start` +- `compose.score.dev/experiment-placeholder-end` + +Both must be set together. When set, file content is expanded using those delimiters instead of `${` and `}`; any literal `${...}` in the file is left alone. `noExpand: true` on a file still takes priority, and `binaryContent` is never expanded. + +```yaml +apiVersion: score.dev/v1b1 + +metadata: + name: spring-app + annotations: + compose.score.dev/experiment-placeholder-start: "<%{" + compose.score.dev/experiment-placeholder-end: "}%>" + +containers: + app: + image: my-spring-app + files: + - target: /app/application.properties + content: | + # left alone (Spring Boot will resolve these at runtime) + spring.datasource.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} + + # expanded by Score + app.name=<%{metadata.name}%> +``` + +There is no escape syntax for the custom delimiters in this experimental version. If your file legitimately contains the chosen start/end strings, pick a different pair. + +This is a `compose.score.dev/experiment-*` annotation, meaning it is opt-in and may change before becoming a stable part of the spec. See [score-spec/spec#108](https://github.com/score-spec/spec/issues/108) for background. diff --git a/internal/compose/convert.go b/internal/compose/convert.go index b10e8a70..57551bb1 100644 --- a/internal/compose/convert.go +++ b/internal/compose/convert.go @@ -25,6 +25,7 @@ import ( "slices" "maps" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -290,16 +291,92 @@ func convertProbeToExec(p *score.ContainerProbe) (*compose.HealthCheckConfig, er return nil, fmt.Errorf("exec or httpGet must be specified") } +// Annotation keys for opting into a custom pair of file-content placeholder delimiters. +// Both annotations must be set together. When set, Score expands placeholders bounded by +// the given start/end strings instead of the default "${" and "}". This is experimental and +// compose-only; if it works out in practice it may later be promoted to a spec property. +const ( + annotationPlaceholderStart = "compose.score.dev/experiment-placeholder-start" + annotationPlaceholderEnd = "compose.score.dev/experiment-placeholder-end" +) + +// substituteWithDelimiters expands placeholders in src using a custom start/end pair instead of +// the default ${...} syntax. Used by the experimental compose.score.dev/experiment-placeholder-* +// annotations. There is no escape mechanism; if the chosen delimiters appear literally in the +// file they will be matched and expanded. +func substituteWithDelimiters(src, start, end string, replacer func(string) (string, error)) (string, error) { + pattern := regexp.QuoteMeta(start) + "(.*?)" + regexp.QuoteMeta(end) + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("failed to build delimiter regex from start=%q end=%q: %w", start, end, err) + } + var subErr error + result := re.ReplaceAllStringFunc(src, func(match string) string { + groups := re.FindStringSubmatch(match) + if len(groups) < 2 { + return match + } + res, err := replacer(groups[1]) + if err != nil { + subErr = errors.Join(subErr, err) + return match + } + return res + }) + return result, subErr +} + +// readPlaceholderDelimiters extracts the experiment-placeholder-{start,end} annotations from a +// workload spec. It returns (start, end, true, nil) when both are set, ("", "", false, nil) when +// neither is set, and an error when exactly one is set. +func readPlaceholderDelimiters(spec score.Workload) (string, string, bool, error) { + annotations, ok := spec.Metadata["annotations"].(map[string]interface{}) + if !ok { + annotations, ok = spec.Metadata["annotations"].(score.WorkloadMetadata) + } + if !ok { + return "", "", false, nil + } + startRaw, hasStart := annotations[annotationPlaceholderStart] + endRaw, hasEnd := annotations[annotationPlaceholderEnd] + if !hasStart && !hasEnd { + return "", "", false, nil + } + if !hasStart || !hasEnd { + return "", "", false, fmt.Errorf( + "annotations %q and %q must be set together (got start=%t, end=%t)", + annotationPlaceholderStart, annotationPlaceholderEnd, hasStart, hasEnd, + ) + } + start, ok := startRaw.(string) + if !ok || start == "" { + return "", "", false, fmt.Errorf("annotation %q must be a non-empty string", annotationPlaceholderStart) + } + end, ok := endRaw.(string) + if !ok || end == "" { + return "", "", false, fmt.Errorf("annotation %q must be a non-empty string", annotationPlaceholderEnd) + } + return start, end, true, nil +} + // 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 + spec := state.Workloads[workloadName].Spec + input := spec.Containers[containerName].Files mountsDirectory := state.Extras.MountsDirectory if mountsDirectory == "" || mountsDirectory == "/dev/null" { return nil, fmt.Errorf("files are not supported") } + // Check for the experimental custom-delimiter annotations on the workload. When set, file + // content is expanded using those delimiters instead of the default ${...} syntax. + // noExpand: true on a file still takes priority, and binaryContent is unaffected. + customStart, customEnd, useCustomDelimiters, err := readPlaceholderDelimiters(spec) + if err != nil { + return nil, fmt.Errorf("workload %q: %w", workloadName, err) + } + output := make([]compose.ServiceVolumeConfig, 0, len(input)) - var err error filesDir := filepath.Join(mountsDirectory, "files") if err = os.MkdirAll(filesDir, 0755); err != nil && !errors.Is(err, os.ErrExist) { @@ -331,9 +408,15 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe 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[%s]: failed to substitute in content: %w", containerName, target, err) + var stringContent string + var subErr error + if useCustomDelimiters { + stringContent, subErr = substituteWithDelimiters(string(content), customStart, customEnd, substitutionFunction) + } else { + stringContent, subErr = framework.SubstituteString(string(content), substitutionFunction) + } + if subErr != nil { + return nil, fmt.Errorf("containers.%s.files[%s]: failed to substitute in content: %w", containerName, target, subErr) } content = []byte(stringContent) } diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go index 14c302a9..673f9b95 100644 --- a/internal/compose/convert_test.go +++ b/internal/compose/convert_test.go @@ -940,3 +940,190 @@ func TestConvertFiles_with_mode(t *testing.T) { assert.Equal(t, 0644, int(st.Mode())) assert.True(t, out[3].ReadOnly) } + +// buildPlaceholderState returns a project.State configured with the given files and annotations. +// It exists to keep the custom-delimiter test bodies short. +func buildPlaceholderState(t *testing.T, td string, annotations map[string]interface{}, files map[string]score.ContainerFile) *project.State { + t.Helper() + metadata := score.WorkloadMetadata{"name": "my-workload"} + if annotations != nil { + metadata["annotations"] = annotations + } + return &project.State{ + Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": { + Spec: score.Workload{ + Metadata: metadata, + Containers: map[string]score.Container{ + "my-container": {Files: files}, + }, + }, + File: util.Ref(filepath.Join(td, "score.yaml")), + }}, + Extras: project.StateExtras{MountsDirectory: td}, + } +} + +// TestConvertFilesIntoVolumes_customDelimiters_happyPath exercises the experiment annotations: +// files use the custom delimiter pair for substitution and literal ${...} is preserved verbatim. +func TestConvertFilesIntoVolumes_customDelimiters_happyPath(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-start": "%{", + "compose.score.dev/experiment-placeholder-end": "}", + }, + map[string]score.ContainerFile{ + "/app.properties": {Content: util.Ref("name=%{metadata.name}\nliteral=${DB_HOST}")}, + }, + ) + out, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { + if s == "metadata.name" { + return "blah", nil + } + return "", fmt.Errorf("unknown key: %s", s) + }, + ) + assert.NoError(t, err) + assert.Len(t, out, 1) + raw, err := os.ReadFile(filepath.Join(td, "files", "my-workload-files-app.properties")) + assert.NoError(t, err) + assert.Equal(t, "name=blah\nliteral=${DB_HOST}", string(raw)) +} + +// TestConvertFilesIntoVolumes_customDelimiters_asymmetric covers a delimiter pair with different +// start/end strings, e.g. Spring Boot-friendly <%{ ... }%>. +func TestConvertFilesIntoVolumes_customDelimiters_asymmetric(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-start": "<%{", + "compose.score.dev/experiment-placeholder-end": "}%>", + }, + map[string]score.ContainerFile{ + "/app.properties": {Content: util.Ref("host=<%{metadata.name}%>")}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { + if s == "metadata.name" { + return "spring-app", nil + } + return "", fmt.Errorf("unknown key: %s", s) + }, + ) + assert.NoError(t, err) + raw, err := os.ReadFile(filepath.Join(td, "files", "my-workload-files-app.properties")) + assert.NoError(t, err) + assert.Equal(t, "host=spring-app", string(raw)) +} + +// TestConvertFilesIntoVolumes_customDelimiters_unset confirms that the default ${...} behavior is +// fully preserved when neither annotation is present (backward compatibility). +func TestConvertFilesIntoVolumes_customDelimiters_unset(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + nil, + map[string]score.ContainerFile{ + "/app.properties": {Content: util.Ref("name=${metadata.name}")}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { + if s == "metadata.name" { + return "blah", nil + } + return "", fmt.Errorf("unknown key: %s", s) + }, + ) + assert.NoError(t, err) + raw, err := os.ReadFile(filepath.Join(td, "files", "my-workload-files-app.properties")) + assert.NoError(t, err) + assert.Equal(t, "name=blah", string(raw)) +} + +// TestConvertFilesIntoVolumes_customDelimiters_noExpandWins: noExpand: true wins over custom +// delimiters, so the file content is written through verbatim. +func TestConvertFilesIntoVolumes_customDelimiters_noExpandWins(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-start": "%{", + "compose.score.dev/experiment-placeholder-end": "}", + }, + map[string]score.ContainerFile{ + "/raw.txt": {Content: util.Ref("verbatim %{metadata.name}"), NoExpand: util.Ref(true)}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { return "", fmt.Errorf("should not be called") }, + ) + assert.NoError(t, err) + raw, err := os.ReadFile(filepath.Join(td, "files", "my-workload-files-raw.txt")) + assert.NoError(t, err) + assert.Equal(t, "verbatim %{metadata.name}", string(raw)) +} + +// TestConvertFilesIntoVolumes_customDelimiters_onlyStart: setting just the start annotation +// without the end is rejected. +func TestConvertFilesIntoVolumes_customDelimiters_onlyStart(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-start": "%{", + }, + map[string]score.ContainerFile{ + "/a.txt": {Content: util.Ref("anything")}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { return "", nil }, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be set together") +} + +// TestConvertFilesIntoVolumes_customDelimiters_onlyEnd: mirror of the start-only case. +func TestConvertFilesIntoVolumes_customDelimiters_onlyEnd(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-end": "}", + }, + map[string]score.ContainerFile{ + "/a.txt": {Content: util.Ref("anything")}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { return "", nil }, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be set together") +} + +// TestConvertFilesIntoVolumes_customDelimiters_emptyValue: empty-string annotation values are +// rejected up front rather than passed down to the score-go constructor. +func TestConvertFilesIntoVolumes_customDelimiters_emptyValue(t *testing.T) { + td := t.TempDir() + state := buildPlaceholderState(t, td, + map[string]interface{}{ + "compose.score.dev/experiment-placeholder-start": "%{", + "compose.score.dev/experiment-placeholder-end": "", + }, + map[string]score.ContainerFile{ + "/a.txt": {Content: util.Ref("anything")}, + }, + ) + _, err := convertFilesIntoVolumes( + state, "my-workload", "my-container", + func(s string) (string, error) { return "", nil }, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be a non-empty string") +}