diff --git a/internal/command/generate.go b/internal/command/generate.go index 761dbe98..9214f773 100644 --- a/internal/command/generate.go +++ b/internal/command/generate.go @@ -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") @@ -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: "" 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. diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go index d93d3a6f..fe928663 100644 --- a/internal/command/generate_test.go +++ b/internal/command/generate_test.go @@ -19,6 +19,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -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:") + }) +} diff --git a/internal/command/init.go b/internal/command/init.go index d1f15a06..757c690b 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -75,6 +75,7 @@ resources: {} initCmdProvisionerFlag = "provisioners" initCmdPatchTemplateFlag = "patch-templates" initCmdNoDefaultProvisionersFlag = "no-default-provisioners" + initCmdComposeVersionFlag = "compose-version" ) //go:embed default.provisioners.yaml @@ -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 != "" { @@ -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 @@ -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 } @@ -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) } diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 0986b095..d74039c1 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -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 @@ -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) + }) +} diff --git a/internal/project/project.go b/internal/project/project.go index 2755be9a..aa72531c 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -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"` }