diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0df6d614..0bb5c4ea 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -160,9 +160,9 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags := cmd.Flags() flags.StringVarP(&deployFlags.Project.Value, deployFlags.Project.Name, "p", "", "Name or ID of the project to deploy the release from") flags.StringVarP(&deployFlags.ReleaseVersion.Value, deployFlags.ReleaseVersion.Name, "", "", "Release version to deploy") - flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times)") - flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times)") - flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&deployFlags.DeployAt.Value, deployFlags.DeployAt.Name, "", "", "Deploy at a later time. Deploy now if omitted. TODO date formats and timezones!") flags.StringVarP(&deployFlags.MaxQueueTime.Value, deployFlags.MaxQueueTime.Name, "", "", "Cancel the deployment if it hasn't started within this time period.") flags.StringArrayVarP(&deployFlags.Variables.Value, deployFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -170,8 +170,8 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&deployFlags.ExcludedSteps.Value, deployFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the deployment") flags.StringVarP(&deployFlags.GuidedFailureMode.Value, deployFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") flags.BoolVarP(&deployFlags.ForcePackageDownload.Value, deployFlags.ForcePackageDownload.Name, "", false, "Force re-download of packages") - flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, deployFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times)") - flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times)") + flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, deployFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times, or as a comma-separated list)") flags.StringArrayVarP(&deployFlags.DeploymentFreezeNames.Value, deployFlags.DeploymentFreezeNames.Name, "", nil, "Override this deployment freeze (can be specified multiple times)") flags.StringVarP(&deployFlags.DeploymentFreezeOverrideReason.Value, deployFlags.DeploymentFreezeOverrideReason.Name, "", "", "Reason for overriding a deployment freeze") @@ -198,6 +198,13 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { } func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { + // these flags accept a comma-separated list as well as being specified multiple times + flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) + flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) + flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) + flags.DeploymentTargets.Value = executionscommon.ExpandCommaSeparated(flags.DeploymentTargets.Value) + flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) if err != nil { // should never happen, but fallback if it does outputFormat = constants.OutputFormatTable diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index fde01017..618ec30e 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -2006,6 +2006,101 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) assert.Equal(t, "", stdErr.String()) }}, + + {"release deploy accepts comma-separated targets and environments; untenanted", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev,test", // comma form + // mixed form; names containing spaces are preserved, whitespace around the comma is not + "--deployment-target", "first Machine, second Machine", "--deployment-target", "third Machine", + "--exclude-deployment-target", "fourthMachine,fifthMachine", + "--output-format", "basic", // not neccessary, just means we don't need the follow up HTTP requests at the end to print the web link + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentUntenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentNames: []string{"dev", "test"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + SpecificMachineNames: []string{"first Machine", "second Machine", "third Machine"}, + ExcludedMachineNames: []string{"fourthMachine", "fifthMachine"}, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release deploy accepts comma-separated tenants and tenant tags; tenanted", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev", + "--tenant", "Coke,Pepsi", // comma form + "--tenant-tag", "Region/us-east", "--tenant-tag", "Region/us-west,Region/eu", // mixed form + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentTenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentName: "dev", + Tenants: []string{"Coke", "Pepsi"}, + TenantTags: []string{"Region/us-east", "Region/us-west", "Region/eu"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, } for _, test := range tests { diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index ad57eb89..83959392 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -162,9 +162,9 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringVarP(&runFlags.Project.Value, runFlags.Project.Name, "p", "", "Name or ID of the project to run the runbook from") flags.StringVarP(&runFlags.RunbookName.Value, runFlags.RunbookName.Name, "n", "", "Name of the runbook to run") flags.StringArrayVarP(&runFlags.RunbookTags.Value, runFlags.RunbookTags.Name, "", nil, "Run all runbooks matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name'. Mutually exclusive with --name.") - flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times)") - flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times)") - flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&runFlags.RunAt.Value, runFlags.RunAt.Name, "", "", "Run at a later time. Run now if omitted. TODO date formats and timezones!") flags.StringVarP(&runFlags.MaxQueueTime.Value, runFlags.MaxQueueTime.Name, "", "", "Cancel a scheduled run if it hasn't started within this time period.") flags.StringArrayVarP(&runFlags.Variables.Value, runFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -172,8 +172,8 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&runFlags.ExcludedSteps.Value, runFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the runbook") flags.StringVarP(&runFlags.GuidedFailureMode.Value, runFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") flags.BoolVarP(&runFlags.ForcePackageDownload.Value, runFlags.ForcePackageDownload.Name, "", false, "Force re-download of packages") - flags.StringArrayVarP(&runFlags.RunTargets.Value, runFlags.RunTargets.Name, "", nil, "Run on this target (can be specified multiple times)") - flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times)") + flags.StringArrayVarP(&runFlags.RunTargets.Value, runFlags.RunTargets.Name, "", nil, "Run on this target (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times, or as a comma-separated list)") flags.StringVarP(&runFlags.GitRef.Value, runFlags.GitRef.Name, "", "", "Git Reference e.g. refs/heads/main. Only relevant for config-as-code projects where runbooks are stored in Git.") flags.StringVarP(&runFlags.PackageVersion.Value, runFlags.PackageVersion.Name, "", "", "Default version to use for all packages. Only relevant for config-as-code projects where runbooks are stored in Git.") flags.StringArrayVarP(&runFlags.PackageVersionSpec.Value, runFlags.PackageVersionSpec.Name, "", nil, "Version specification for a specific package.\nFormat as {package}:{version}, {step}:{version} or {package-ref-name}:{packageOrStep}:{version}\nYou may specify this multiple times.\nOnly relevant for config-as-code projects where runbooks are stored in Git.") @@ -201,6 +201,13 @@ func NewCmdRun(f factory.Factory) *cobra.Command { } func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { + // these flags accept a comma-separated list as well as being specified multiple times + flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) + flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) + flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) + flags.RunTargets.Value = executionscommon.ExpandCommaSeparated(flags.RunTargets.Value) + flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + if flags.RunbookName.Value != "" && len(flags.RunbookTags.Value) > 0 { return errors.New("--name and --runbook-tag are mutually exclusive. Please specify either a runbook name or runbook tags, not both") } diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 33c1904d..fc5b872b 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -338,6 +338,54 @@ func TestRunbookRun_AutomationMode(t *testing.T) { assert.Contains(t, stdOut.String(), "ServerTasks-29394\n") assert.Equal(t, "", stdErr.String()) }}, + + {"runbook run accepts comma-separated environments and targets", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{ + "runbook", "run", + "--project", "Fire Project", + "--runbook", "Provision Database", + "--environment", "dev,test", // comma form + // mixed form; names containing spaces are preserved, whitespace around the comma is not + "--run-target", "first Machine, second Machine", "--run-target", "third Machine", + "--exclude-run-target", "fourthMachine,fifthMachine", + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") + requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, runbooks.RunbookRunCommandV1{ + RunbookName: "Provision Database", + EnvironmentNames: []string{"dev", "test"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + SpecificMachineNames: []string{"first Machine", "second Machine", "third Machine"}, + ExcludedMachineNames: []string{"fourthMachine", "fifthMachine"}, + }, + }, requestBody) + + req.RespondWith(&runbooks.RunbookRunResponseV1{ + RunbookRunServerTasks: []*runbooks.RunbookRunServerTask{ + {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Contains(t, stdOut.String(), "ServerTasks-29394\n") + assert.Equal(t, "", stdErr.String()) + }}, } for _, test := range tests { diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index 4348d7bd..e875557e 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -301,6 +301,29 @@ func AskVariableSpecificPrompt(asker question.Asker, message string, variableTyp } } +// ExpandCommaSeparated splits each entry on commas so `--flag "A,B"` behaves the same as +// `--flag A --flag B`. Whitespace around each entry is trimmed and blank entries are dropped. +// Only apply this to flags whose values cannot legitimately contain a comma; notably NOT to +// --variable, --skip or the package/git-resource specs. +func ExpandCommaSeparated(values []string) []string { + if len(values) == 0 { + return values + } + result := make([]string, 0, len(values)) + for _, value := range values { + for _, component := range strings.Split(value, ",") { + component = strings.TrimSpace(component) + if component != "" { + result = append(result, component) + } + } + } + if len(result) == 0 { + return nil + } + return result +} + func ParseVariableStringArray(variables []string) (map[string]string, error) { result := make(map[string]string, len(variables)) for _, v := range variables { diff --git a/pkg/executionscommon/executionscommon_test.go b/pkg/executionscommon/executionscommon_test.go index 72604be2..26db6a16 100644 --- a/pkg/executionscommon/executionscommon_test.go +++ b/pkg/executionscommon/executionscommon_test.go @@ -412,3 +412,33 @@ func TestToVariableStringArray(t *testing.T) { }) } } + +func TestExpandCommaSeparated(t *testing.T) { + tests := []struct { + name string + input []string + expect []string + }{ + {name: "nil stays nil", input: nil, expect: nil}, + {name: "single value", input: []string{"ABC"}, expect: []string{"ABC"}}, + + {name: "comma form", input: []string{"ABC,XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "repeated form", input: []string{"ABC", "XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "mixed form", input: []string{"ABC,XYZ", "DEF"}, expect: []string{"ABC", "XYZ", "DEF"}}, + + {name: "preserves spaces within values", input: []string{"Web Server 01,Web Server 02"}, expect: []string{"Web Server 01", "Web Server 02"}}, + {name: "trims spaces around values", input: []string{" ABC ,\tXYZ "}, expect: []string{"ABC", "XYZ"}}, + + {name: "preserves order and duplicates", input: []string{"ABC,ABC"}, expect: []string{"ABC", "ABC"}}, + {name: "tenant tags", input: []string{"Regions/us-east,Regions/us-west"}, expect: []string{"Regions/us-east", "Regions/us-west"}}, + + {name: "drops blank entries", input: []string{"ABC,,XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "all blank entries returns nil", input: []string{"", " , "}, expect: nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expect, executionscommon.ExpandCommaSeparated(test.input)) + }) + } +}