Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .features/pending/convert.md
Original file line number Diff line number Diff line change
@@ -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.
172 changes: 172 additions & 0 deletions cmd/argo/commands/convert.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions cmd/argo/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
66 changes: 66 additions & 0 deletions test/e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
14 changes: 14 additions & 0 deletions test/e2e/testdata/convert-legacy-cron.yaml
Original file line number Diff line number Diff line change
@@ -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"]
26 changes: 26 additions & 0 deletions test/e2e/testdata/convert-legacy-sync.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions workflow/convert/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package convert contains functions to convert between different versions of Argo Workflows objects.
package convert
Loading
Loading