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
17 changes: 12 additions & 5 deletions pkg/cmd/release/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,18 @@ 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")
flags.BoolVarP(&deployFlags.UpdateVariables.Value, deployFlags.UpdateVariables.Name, "", false, "Overwrite the release variable snapshot by re-importing variables from the project.")
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")

Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions pkg/cmd/release/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 12 additions & 5 deletions pkg/cmd/runbook/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,18 +162,18 @@ 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")
flags.StringVarP(&runFlags.Snapshot.Value, runFlags.Snapshot.Name, "", "", "Name or ID of the snapshot to run. If not supplied, the command will attempt to use the published snapshot.")
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.")
Expand Down Expand Up @@ -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")
}
Expand Down
48 changes: 48 additions & 0 deletions pkg/cmd/runbook/run/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions pkg/executionscommon/executionscommon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions pkg/executionscommon/executionscommon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}