Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions examples/03-files/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
93 changes: 88 additions & 5 deletions internal/compose/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"slices"
"maps"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
187 changes: 187 additions & 0 deletions internal/compose/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}