Skip to content
Draft
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
60 changes: 60 additions & 0 deletions internal/command/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,37 @@ arguments.
return fmt.Errorf("failed to persist updated state directory: %w", err)
}

// Apply legacy compose version transformations when a version string has been configured.
// Older tooling (e.g. Podman's docker-compose compatibility layer) rejects the versionless
// spec's top-level 'name' field and service-level 'annotations' field. Setting a version
// string (e.g. "3") enables a compatibility output: version: is added, name: is removed,
// and service annotations are merged into labels before being cleared.
if sd.State.Extras.ComposeVersion != "" {
for svcName, svc := range superProject.Services {
if len(svc.Annotations) > 0 {
if svc.Labels == nil {
svc.Labels = make(types.Labels)
}
for k, val := range svc.Annotations {
if _, exists := svc.Labels[k]; !exists {
svc.Labels[k] = val
}
}
svc.Annotations = nil
superProject.Services[svcName] = svc
}
}
}

raw, _ := yaml.Marshal(superProject)

// compose-go/v2 removed the Version field from types.Project since the modern
// Compose Specification is versionless. For legacy mode we post-process the
// marshalled YAML to inject version: and strip the name: field.
if v := sd.State.Extras.ComposeVersion; v != "" {
raw = injectLegacyComposeVersion(raw, v)
}

v, _ := cmd.Flags().GetString(generateCmdOutputFlag)
if v == "" {
return fmt.Errorf("no output file specified")
Expand Down Expand Up @@ -500,6 +529,37 @@ func parseAndApplyOverrideProperty(entry string, flagName string, spec map[strin
}
}

// injectLegacyComposeVersion post-processes marshalled compose YAML to add a top-level
// version: field and remove the name: field, for compatibility with older tooling such as
// Podman's docker-compose layer. compose-go/v2 removed the Version field from types.Project
// since the modern Compose Specification is versionless, so we manipulate the YAML AST directly.
func injectLegacyComposeVersion(raw []byte, version string) []byte {
var doc yaml.Node
if err := yaml.Unmarshal(raw, &doc); err != nil || len(doc.Content) == 0 {
return raw
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return raw
}
// Rebuild the mapping content without the 'name' key.
filtered := make([]*yaml.Node, 0, len(root.Content))
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value != "name" {
filtered = append(filtered, root.Content[i], root.Content[i+1])
}
}
// Prepend version: "<version>" as the first key.
vKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "version"}
vVal := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: version, Style: yaml.DoubleQuotedStyle}
root.Content = append([]*yaml.Node{vKey, vVal}, filtered...)
out, err := yaml.Marshal(&doc)
if err != nil {
return raw
}
return out
}

// 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.
Expand Down
50 changes: 50 additions & 0 deletions internal/command/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -1629,3 +1630,52 @@ services:
read_only: true
`)
}

func TestInitAndGenerate_with_compose_version(t *testing.T) {
td := changeToTempDir(t)

assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
apiVersion: score.dev/v1b1
metadata:
name: example
annotations:
my.org/custom: "value"
containers:
hello:
image: nginx:latest
variables:
PORT: "8080"
`), 0644))

t.Run("default output has name and annotations", func(t *testing.T) {
_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
require.NoError(t, err)
_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
require.NoError(t, err)
raw, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
require.NoError(t, err)
content := string(raw)
// regression guard: default output must contain name: and annotations:, no version:
assert.Contains(t, content, "name:")
assert.Contains(t, content, "annotations:")
assert.NotContains(t, content, "version:")
})

t.Run("legacy output has version and labels not annotations", func(t *testing.T) {
_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample", "--compose-version", "3"})
require.NoError(t, err)
_, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
require.NoError(t, err)
raw, err := os.ReadFile(filepath.Join(td, "compose.yaml"))
require.NoError(t, err)
content := string(raw)
assert.Contains(t, content, `version: "3"`)
assert.NotContains(t, content, "\nname:")
assert.False(t, strings.HasPrefix(content, "name:"), "output should not start with name:")
assert.NotContains(t, content, "annotations:")
assert.Contains(t, content, "labels:")
assert.Contains(t, content, "compose.score.dev/workload-name: example")
assert.Contains(t, content, "my.org/custom: value")
assert.Contains(t, content, "services:")
})
}
10 changes: 10 additions & 0 deletions internal/command/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ resources: {}
initCmdProvisionerFlag = "provisioners"
initCmdPatchTemplateFlag = "patch-templates"
initCmdNoDefaultProvisionersFlag = "no-default-provisioners"
initCmdComposeVersionFlag = "compose-version"
)

//go:embed default.provisioners.yaml
Expand Down Expand Up @@ -140,6 +141,7 @@ URI Retrieval:
initCmdScoreFile, _ := cmd.Flags().GetString(initCmdFileFlag)
initCmdComposeProject, _ := cmd.Flags().GetString(initCmdFileProjectFlag)
initCmdPatchingFiles, _ := cmd.Flags().GetStringArray(initCmdPatchTemplateFlag)
initCmdComposeVersion, _ := cmd.Flags().GetString(initCmdComposeVersionFlag)

// validate project
if initCmdComposeProject != "" {
Expand Down Expand Up @@ -171,6 +173,10 @@ URI Retrieval:
sd.State.Extras.ComposeProjectName = initCmdComposeProject
hasChanges = true
}
if initCmdComposeVersion != "" && sd.State.Extras.ComposeVersion != initCmdComposeVersion {
sd.State.Extras.ComposeVersion = initCmdComposeVersion
hasChanges = true
}
if len(templates) > 0 {
sd.State.Extras.PatchingTemplates = templates
hasChanges = true
Expand Down Expand Up @@ -200,6 +206,9 @@ URI Retrieval:
if initCmdComposeProject != "" {
sd.State.Extras.ComposeProjectName = initCmdComposeProject
}
if initCmdComposeVersion != "" {
sd.State.Extras.ComposeVersion = initCmdComposeVersion
}
if len(templates) > 0 {
sd.State.Extras.PatchingTemplates = templates
}
Expand Down Expand Up @@ -297,6 +306,7 @@ func init() {
initCmd.Flags().StringArray(initCmdProvisionerFlag, nil, "Provisioner files to install. May be specified multiple times. Supports URI retrieval.")
initCmd.Flags().StringArray(initCmdPatchTemplateFlag, nil, "Patching template files to include. May be specified multiple times. Supports URI retrieval.")
initCmd.Flags().Bool(initCmdNoDefaultProvisionersFlag, false, "Disable generation of the default provisioners file")
initCmd.Flags().String(initCmdComposeVersionFlag, "", "Set a compose file version string (e.g. \"3\") for compatibility with older tools such as Podman. When set, the top-level 'name' field is omitted and service annotations are converted to labels.")

rootCmd.AddCommand(initCmd)
}
Expand Down
33 changes: 33 additions & 0 deletions internal/command/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ URI Retrieval:
- Stdin : - (read from standard input)

Flags:
--compose-version string Set a compose file version string (e.g. "3") for compatibility with older tools such as Podman. When set, the top-level 'name' field is omitted and service annotations are converted to labels.
-f, --file string The score file to initialize (default "./score.yaml")
-h, --help help for init
--no-default-provisioners Disable generation of the default provisioners file
Expand Down Expand Up @@ -329,3 +330,35 @@ func TestInitWithPatchingFiles(t *testing.T) {
assert.Error(t, err, "failed to parse template: template: :1: function \"what\" not defined")
})
}

func TestInitWithComposeVersion(t *testing.T) {
_ = changeToTempDir(t)

t.Run("set on new project", func(t *testing.T) {
_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample", "--compose-version", "3"})
require.NoError(t, err)
sd, ok, err := project.LoadStateDirectory(".")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "3", sd.State.Extras.ComposeVersion)
})

t.Run("update on re-init", func(t *testing.T) {
_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample", "--compose-version", "3.8"})
require.NoError(t, err)
sd, ok, err := project.LoadStateDirectory(".")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "3.8", sd.State.Extras.ComposeVersion)
})

t.Run("not changed when flag absent on re-init", func(t *testing.T) {
_, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
require.NoError(t, err)
sd, ok, err := project.LoadStateDirectory(".")
require.NoError(t, err)
require.True(t, ok)
// should still be "3.8" from previous re-init
assert.Equal(t, "3.8", sd.State.Extras.ComposeVersion)
})
}
1 change: 1 addition & 0 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type State = framework.State[StateExtras, WorkloadExtras, framework.NoExtras]

type StateExtras struct {
ComposeProjectName string `yaml:"compose_project"`
ComposeVersion string `yaml:"compose_version,omitempty"`
MountsDirectory string `yaml:"mounts_directory"`
PatchingTemplates []string `yaml:"patching_templates"`
}
Expand Down