From 57e3758834556545090e72169bc7da8a0d4bcc91 Mon Sep 17 00:00:00 2001 From: Alan Clucas Date: Mon, 27 Oct 2025 14:25:56 +0000 Subject: [PATCH] feat: CLI convert command Signed-off-by: Alan Clucas --- .features/pending/convert.md | 9 + cmd/argo/commands/convert.go | 172 ++++++++++++++++++ cmd/argo/commands/root.go | 1 + test/e2e/cli_test.go | 66 +++++++ test/e2e/testdata/convert-legacy-cron.yaml | 14 ++ test/e2e/testdata/convert-legacy-sync.yaml | 26 +++ workflow/convert/doc.go | 2 + workflow/convert/legacy_types.go | 195 +++++++++++++++++++++ 8 files changed, 485 insertions(+) create mode 100644 .features/pending/convert.md create mode 100644 cmd/argo/commands/convert.go create mode 100644 test/e2e/testdata/convert-legacy-cron.yaml create mode 100644 test/e2e/testdata/convert-legacy-sync.yaml create mode 100644 workflow/convert/doc.go create mode 100644 workflow/convert/legacy_types.go diff --git a/.features/pending/convert.md b/.features/pending/convert.md new file mode 100644 index 000000000000..90274b23fd64 --- /dev/null +++ b/.features/pending/convert.md @@ -0,0 +1,9 @@ +Description: `convert` CLI command to convert to new workflow format +Authors: [Alan Clucas](https://github.com/Joibel) +Component: CLI +Issues: 14977 + +A new CLI command `convert` which will convert Workflows, CronWorkflows, and (Cluster)WorkflowTemplates to the new format. +It will remove `schedule` from CronWorkflows, moving that into `schedules` +It will remove `mutex` and `semaphore` from `synchronization` blocks and move them to the plural version. +Otherwise this command works much the same as linting. diff --git a/cmd/argo/commands/convert.go b/cmd/argo/commands/convert.go new file mode 100644 index 000000000000..45cecfc64dca --- /dev/null +++ b/cmd/argo/commands/convert.go @@ -0,0 +1,172 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/argoproj/argo-workflows/v3/cmd/argo/commands/common" + wf "github.com/argoproj/argo-workflows/v3/pkg/apis/workflow" + fileutil "github.com/argoproj/argo-workflows/v3/util/file" + jsonpkg "github.com/argoproj/argo-workflows/v3/util/json" + "github.com/argoproj/argo-workflows/v3/workflow/convert" +) + +func NewConvertCommand() *cobra.Command { + var ( + output = common.EnumFlagValue{ + AllowedValues: []string{"yaml", "json"}, + Value: "yaml", + } + ) + + command := &cobra.Command{ + Use: "convert FILE...", + Short: "convert workflow manifests from legacy format to current format", + Example: ` +# Convert manifests in a specified directory: + + argo convert ./manifests + +# Convert a single file: + + argo convert workflow.yaml + +# Convert from stdin: + + cat workflow.yaml | argo convert -`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runConvert(cmd.Context(), args, output.String()) + }, + } + + command.Flags().VarP(&output, "output", "o", "Output format. "+output.Usage()) + command.Flags().BoolVar(&common.NoColor, "no-color", false, "Disable colorized output") + + return command +} + +var yamlSeparator = regexp.MustCompile(`\n---`) + +func runConvert(ctx context.Context, args []string, output string) error { + for _, file := range args { + err := fileutil.WalkManifests(ctx, file, func(path string, data []byte) error { + isJSON := jsonpkg.IsJSON(data) + + if isJSON { + // Parse single JSON document + if err := convertDocument(data, output, isJSON); err != nil { + return fmt.Errorf("error converting %s: %w", path, err) + } + } else { + // Split YAML documents + for _, doc := range yamlSeparator.Split(string(data), -1) { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + if err := convertDocument([]byte(doc), output, false); err != nil { + return fmt.Errorf("error converting %s: %w", path, err) + } + } + } + return nil + }) + if err != nil { + return err + } + } + return nil +} + +func convertDocument(data []byte, outputFormat string, isJSON bool) error { + // First, determine the kind + var typeMeta metav1.TypeMeta + if err := yaml.Unmarshal(data, &typeMeta); err != nil { + return fmt.Errorf("failed to parse TypeMeta: %w", err) + } + + var converted interface{} + + // Parse into legacy type and convert to current type + switch typeMeta.Kind { + case wf.CronWorkflowKind: + var legacy convert.LegacyCronWorkflow + if err := yaml.Unmarshal(data, &legacy); err != nil { + return fmt.Errorf("failed to parse CronWorkflow: %w", err) + } + converted = legacy.ToCurrent() + + case wf.WorkflowKind: + var legacy convert.LegacyWorkflow + if err := yaml.Unmarshal(data, &legacy); err != nil { + return fmt.Errorf("failed to parse Workflow: %w", err) + } + converted = legacy.ToCurrent() + + case wf.WorkflowTemplateKind: + var legacy convert.LegacyWorkflowTemplate + if err := yaml.Unmarshal(data, &legacy); err != nil { + return fmt.Errorf("failed to parse WorkflowTemplate: %w", err) + } + converted = legacy.ToCurrent() + + case wf.ClusterWorkflowTemplateKind: + var legacy convert.LegacyClusterWorkflowTemplate + if err := yaml.Unmarshal(data, &legacy); err != nil { + return fmt.Errorf("failed to parse ClusterWorkflowTemplate: %w", err) + } + converted = legacy.ToCurrent() + + default: + // Unknown type - pass through unchanged + // Re-parse as generic map to preserve structure + var generic map[string]interface{} + if err := yaml.Unmarshal(data, &generic); err != nil { + return fmt.Errorf("failed to parse unknown kind %s: %w", typeMeta.Kind, err) + } + converted = generic + } + + return outputObject(converted, outputFormat, isJSON) +} + +func outputObject(obj interface{}, format string, preferJSON bool) error { + var outBytes []byte + var err error + + // If input was JSON and format is yaml, convert to JSON + // If input was YAML and format is json, convert to JSON + // Otherwise respect the format flag + outputJSON := format == "json" || (preferJSON && format != "yaml") + + if outputJSON { + outBytes, err = json.Marshal(obj) + if err != nil { + return err + } + fmt.Println(string(outBytes)) + } else { + outBytes, err = yaml.Marshal(obj) + if err != nil { + return err + } + // Print separator between objects for YAML + if _, err := os.Stdout.Write([]byte("---\n")); err != nil { + return err + } + if _, err := os.Stdout.Write(outBytes); err != nil { + return err + } + } + + return nil +} diff --git a/cmd/argo/commands/root.go b/cmd/argo/commands/root.go index b7c62cb420bd..0b04ec9b854b 100644 --- a/cmd/argo/commands/root.go +++ b/cmd/argo/commands/root.go @@ -96,6 +96,7 @@ If your server is behind an ingress with a path (running "argo server --base-hre }, } command.AddCommand(NewCompletionCommand()) + command.AddCommand(NewConvertCommand()) command.AddCommand(NewDeleteCommand()) command.AddCommand(NewGetCommand()) command.AddCommand(NewLintCommand()) diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go index cdd10ccdf0f6..88b3c6382389 100644 --- a/test/e2e/cli_test.go +++ b/test/e2e/cli_test.go @@ -2037,6 +2037,72 @@ func (s *CLISuite) TestPluginStruct() { }) } +func (s *CLISuite) TestWorkflowConvert() { + s.Run("ConvertCronWorkflowSchedule", func() { + s.Given().RunCli([]string{"convert", "testdata/convert-legacy-cron.yaml"}, func(t *testing.T, output string, err error) { + require.NoError(t, err) + // Check that schedule (singular) has been converted to schedules (plural) + assert.Contains(t, output, "schedules:") + assert.Contains(t, output, "- 0 0 * * *") + assert.NotContains(t, output, "schedule: \"0 0 * * *\"") + }) + }) + s.Run("ConvertSynchronization", func() { + s.Given().RunCli([]string{"convert", "testdata/convert-legacy-sync.yaml"}, func(t *testing.T, output string, err error) { + require.NoError(t, err) + // Check that semaphore (singular) has been converted to semaphores (plural) + assert.Contains(t, output, "semaphores:") + // Check that mutex (singular) has been converted to mutexes (plural) + assert.Contains(t, output, "mutexes:") + // Verify workflow-level conversions + assert.Contains(t, output, "name: my-config") + assert.Contains(t, output, "name: test-mutex") + // Verify template-level conversions + assert.Contains(t, output, "name: template-config") + assert.Contains(t, output, "name: template-mutex") + }) + }) + s.Run("ConvertJSON", func() { + s.Given().RunCli([]string{"convert", "-o", "json", "testdata/convert-legacy-cron.yaml"}, func(t *testing.T, output string, err error) { + require.NoError(t, err) + // Parse as JSON to verify it's valid JSON + var cronWf wfv1.CronWorkflow + lines := strings.Split(strings.TrimSpace(output), "\n") + require.Greater(t, len(lines), 0) + err = json.Unmarshal([]byte(lines[0]), &cronWf) + require.NoError(t, err) + // Verify conversion happened - legacy schedule field should be converted to schedules array + assert.Len(t, cronWf.Spec.Schedules, 1) + assert.Equal(t, "0 0 * * *", cronWf.Spec.Schedules[0]) + }) + }) + s.Run("ConvertStdin", func() { + tmp, err := os.CreateTemp("", "convert-test-*.yaml") + s.CheckError(err) + defer os.Remove(tmp.Name()) + + data, err := os.ReadFile("testdata/convert-legacy-cron.yaml") + s.CheckError(err) + _, err = tmp.Write(data) + s.CheckError(err) + tmp.Close() + + s.Given().RunCli([]string{"convert", tmp.Name()}, func(t *testing.T, output string, err error) { + require.NoError(t, err) + assert.Contains(t, output, "schedules:") + assert.Contains(t, output, "- 0 0 * * *") + }) + }) + s.Run("ConvertPreservesNonLegacy", func() { + // Test that files already in the new format pass through unchanged (except for default/empty fields) + s.Given().RunCli([]string{"convert", "smoke/basic.yaml"}, func(t *testing.T, output string, err error) { + require.NoError(t, err) + // Should successfully parse and output the workflow + assert.Contains(t, output, "kind: Workflow") + }) + }) +} + func TestCLISuite(t *testing.T) { suite.Run(t, new(CLISuite)) } diff --git a/test/e2e/testdata/convert-legacy-cron.yaml b/test/e2e/testdata/convert-legacy-cron.yaml new file mode 100644 index 000000000000..cce936ef611d --- /dev/null +++ b/test/e2e/testdata/convert-legacy-cron.yaml @@ -0,0 +1,14 @@ +apiVersion: argoproj.io/v1alpha1 +kind: CronWorkflow +metadata: + name: legacy-cron +spec: + schedule: "0 0 * * *" + workflowSpec: + entrypoint: whalesay + templates: + - name: whalesay + container: + image: docker/whalesay:latest + command: [cowsay] + args: ["hello world"] diff --git a/test/e2e/testdata/convert-legacy-sync.yaml b/test/e2e/testdata/convert-legacy-sync.yaml new file mode 100644 index 000000000000..e9e06278c300 --- /dev/null +++ b/test/e2e/testdata/convert-legacy-sync.yaml @@ -0,0 +1,26 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Workflow +metadata: + name: legacy-sync +spec: + entrypoint: main + synchronization: + semaphore: + configMapKeyRef: + name: my-config + key: workflow + mutex: + name: test-mutex + templates: + - name: main + container: + image: alpine:latest + command: [echo] + args: ["hello"] + synchronization: + semaphore: + configMapKeyRef: + name: template-config + key: template + mutex: + name: template-mutex diff --git a/workflow/convert/doc.go b/workflow/convert/doc.go new file mode 100644 index 000000000000..707824ce4e5f --- /dev/null +++ b/workflow/convert/doc.go @@ -0,0 +1,2 @@ +// Package convert contains functions to convert between different versions of Argo Workflows objects. +package convert diff --git a/workflow/convert/legacy_types.go b/workflow/convert/legacy_types.go new file mode 100644 index 000000000000..8f563d4ccec1 --- /dev/null +++ b/workflow/convert/legacy_types.go @@ -0,0 +1,195 @@ +package convert + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + wfv1 "github.com/argoproj/argo-workflows/v3/pkg/apis/workflow/v1alpha1" +) + +// Legacy types for parsing manifests with deprecated fields. +// These types include both old (deprecated) and new fields to support +// parsing v3.5 and earlier manifests. + +// LegacySynchronization can parse both old (semaphore/mutex) and new (semaphores/mutexes) formats +type LegacySynchronization struct { + // Deprecated v3.5 and before: singular semaphore + Semaphore *wfv1.SemaphoreRef `json:"semaphore,omitempty"` + // Deprecated v3.5 and before: singular mutex + Mutex *wfv1.Mutex `json:"mutex,omitempty"` + // v3.6 and after: plural semaphores + Semaphores []*wfv1.SemaphoreRef `json:"semaphores,omitempty"` + // v3.6 and after: plural mutexes + Mutexes []*wfv1.Mutex `json:"mutexes,omitempty"` +} + +// ToCurrent converts a LegacySynchronization to the current Synchronization type +func (ls *LegacySynchronization) ToCurrent() *wfv1.Synchronization { + if ls == nil { + return nil + } + + sync := &wfv1.Synchronization{ + Semaphores: ls.Semaphores, + Mutexes: ls.Mutexes, + } + + // Migrate singular to plural if needed + if ls.Semaphore != nil && len(sync.Semaphores) == 0 { + sync.Semaphores = []*wfv1.SemaphoreRef{ls.Semaphore} + } + if ls.Mutex != nil && len(sync.Mutexes) == 0 { + sync.Mutexes = []*wfv1.Mutex{ls.Mutex} + } + + return sync +} + +// LegacyTemplate wraps Template with legacy synchronization support +type LegacyTemplate struct { + wfv1.Template + Synchronization *LegacySynchronization `json:"synchronization,omitempty"` +} + +// ToCurrent converts a LegacyTemplate to the current Template type +func (lt *LegacyTemplate) ToCurrent() wfv1.Template { + tmpl := lt.Template + if lt.Synchronization != nil { + tmpl.Synchronization = lt.Synchronization.ToCurrent() + } + return tmpl +} + +// LegacyWorkflowSpec wraps WorkflowSpec with legacy synchronization support +type LegacyWorkflowSpec struct { + wfv1.WorkflowSpec + Synchronization *LegacySynchronization `json:"synchronization,omitempty"` + Templates []LegacyTemplate `json:"templates,omitempty"` +} + +// ToCurrent converts a LegacyWorkflowSpec to the current WorkflowSpec type +func (lws *LegacyWorkflowSpec) ToCurrent() wfv1.WorkflowSpec { + spec := lws.WorkflowSpec + + // Convert synchronization + if lws.Synchronization != nil { + spec.Synchronization = lws.Synchronization.ToCurrent() + } + + // Convert templates + if len(lws.Templates) > 0 { + spec.Templates = make([]wfv1.Template, len(lws.Templates)) + for i, legacyTmpl := range lws.Templates { + spec.Templates[i] = legacyTmpl.ToCurrent() + } + } + + return spec +} + +// LegacyCronWorkflowSpec can parse both old (schedule) and new (schedules) formats +type LegacyCronWorkflowSpec struct { + WorkflowSpec LegacyWorkflowSpec `json:"workflowSpec"` + Schedule string `json:"schedule,omitempty"` // Deprecated v3.5 and before + Schedules []string `json:"schedules,omitempty"` // v3.6 and after + ConcurrencyPolicy wfv1.ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` + Suspend bool `json:"suspend,omitempty"` + StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"` + SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"` + FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"` + Timezone string `json:"timezone,omitempty"` + WorkflowMetadata *metav1.ObjectMeta `json:"workflowMetadata,omitempty"` + StopStrategy *wfv1.StopStrategy `json:"stopStrategy,omitempty"` + When string `json:"when,omitempty"` +} + +// ToCurrent converts a LegacyCronWorkflowSpec to the current CronWorkflowSpec type +func (lcs *LegacyCronWorkflowSpec) ToCurrent() wfv1.CronWorkflowSpec { + spec := wfv1.CronWorkflowSpec{ + WorkflowSpec: lcs.WorkflowSpec.ToCurrent(), + Schedules: lcs.Schedules, + ConcurrencyPolicy: lcs.ConcurrencyPolicy, + Suspend: lcs.Suspend, + StartingDeadlineSeconds: lcs.StartingDeadlineSeconds, + SuccessfulJobsHistoryLimit: lcs.SuccessfulJobsHistoryLimit, + FailedJobsHistoryLimit: lcs.FailedJobsHistoryLimit, + Timezone: lcs.Timezone, + WorkflowMetadata: lcs.WorkflowMetadata, + StopStrategy: lcs.StopStrategy, + When: lcs.When, + } + + // Migrate singular schedule to plural if needed + if lcs.Schedule != "" && len(spec.Schedules) == 0 { + spec.Schedules = []string{lcs.Schedule} + } + + return spec +} + +// LegacyCronWorkflow wraps CronWorkflow with legacy field support +type LegacyCronWorkflow struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec LegacyCronWorkflowSpec `json:"spec"` + Status wfv1.CronWorkflowStatus `json:"status,omitempty"` +} + +// ToCurrent converts a LegacyCronWorkflow to the current CronWorkflow type +func (lcw *LegacyCronWorkflow) ToCurrent() *wfv1.CronWorkflow { + return &wfv1.CronWorkflow{ + TypeMeta: lcw.TypeMeta, + ObjectMeta: lcw.ObjectMeta, + Spec: lcw.Spec.ToCurrent(), + Status: lcw.Status, + } +} + +// LegacyWorkflow wraps Workflow with legacy field support +type LegacyWorkflow struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec LegacyWorkflowSpec `json:"spec"` + Status wfv1.WorkflowStatus `json:"status,omitempty"` +} + +// ToCurrent converts a LegacyWorkflow to the current Workflow type +func (lw *LegacyWorkflow) ToCurrent() *wfv1.Workflow { + return &wfv1.Workflow{ + TypeMeta: lw.TypeMeta, + ObjectMeta: lw.ObjectMeta, + Spec: lw.Spec.ToCurrent(), + Status: lw.Status, + } +} + +// LegacyWorkflowTemplate wraps WorkflowTemplate with legacy field support +type LegacyWorkflowTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec LegacyWorkflowSpec `json:"spec"` +} + +// ToCurrent converts a LegacyWorkflowTemplate to the current WorkflowTemplate type +func (lwt *LegacyWorkflowTemplate) ToCurrent() *wfv1.WorkflowTemplate { + return &wfv1.WorkflowTemplate{ + TypeMeta: lwt.TypeMeta, + ObjectMeta: lwt.ObjectMeta, + Spec: lwt.Spec.ToCurrent(), + } +} + +// LegacyClusterWorkflowTemplate wraps ClusterWorkflowTemplate with legacy field support +type LegacyClusterWorkflowTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec LegacyWorkflowSpec `json:"spec"` +} + +// ToCurrent converts a LegacyClusterWorkflowTemplate to the current ClusterWorkflowTemplate type +func (lcwt *LegacyClusterWorkflowTemplate) ToCurrent() *wfv1.ClusterWorkflowTemplate { + return &wfv1.ClusterWorkflowTemplate{ + TypeMeta: lcwt.TypeMeta, + ObjectMeta: lcwt.ObjectMeta, + Spec: lcwt.Spec.ToCurrent(), + } +}