From 2c6cf2f890c93d964761296c7bca25b78c293ce6 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:43:46 +1000 Subject: [PATCH 1/3] feat: toggle the enabled state of deployment targets Adds `octopus deployment-target enable|disable [ | ]`, which flips `IsDisabled` on the machine and reports when the target is already in the requested state. The target is prompted for when no name or ID is supplied, matching the existing `tenant enable|disable` commands. Also adds a shared `--disabled` flag to every deployment-target create command so a target can be created in a disabled state, and includes it in the generated automation command. Fixes #311 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/target/azure-web-app/create/create.go | 7 +- pkg/cmd/target/cloud-region/create/create.go | 7 +- pkg/cmd/target/disable/disable.go | 28 +++++ pkg/cmd/target/disable/disable_test.go | 119 ++++++++++++++++++ pkg/cmd/target/enable/enable.go | 28 +++++ pkg/cmd/target/enable/enable_test.go | 119 ++++++++++++++++++ pkg/cmd/target/kubernetes/create/create.go | 6 + .../listening-tentacle/create/create.go | 7 +- pkg/cmd/target/shared/disabledstate.go | 95 ++++++++++++++ pkg/cmd/target/shared/disabledstate_test.go | 73 +++++++++++ pkg/cmd/target/ssh/create/create.go | 7 +- pkg/cmd/target/target.go | 4 + pkg/cmd/target/target_test.go | 47 +++++++ pkg/machinescommon/disabled.go | 22 ++++ test/testutil/fakeoctopusserver.go | 1 + 15 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 pkg/cmd/target/disable/disable.go create mode 100644 pkg/cmd/target/disable/disable_test.go create mode 100644 pkg/cmd/target/enable/enable.go create mode 100644 pkg/cmd/target/enable/enable_test.go create mode 100644 pkg/cmd/target/shared/disabledstate.go create mode 100644 pkg/cmd/target/shared/disabledstate_test.go create mode 100644 pkg/cmd/target/target_test.go create mode 100644 pkg/machinescommon/disabled.go diff --git a/pkg/cmd/target/azure-web-app/create/create.go b/pkg/cmd/target/azure-web-app/create/create.go index e41c0bf2..21d72f8e 100644 --- a/pkg/cmd/target/azure-web-app/create/create.go +++ b/pkg/cmd/target/azure-web-app/create/create.go @@ -46,6 +46,7 @@ type CreateFlags struct { *shared.CreateTargetRoleFlags *shared.CreateTargetTenantFlags *shared.WorkerPoolFlags + *machinescommon.CreateTargetDisabledFlags *machinescommon.WebFlags } @@ -73,6 +74,7 @@ func NewCreateFlags() *CreateFlags { CreateTargetEnvironmentFlags: shared.NewCreateTargetEnvironmentFlags(), CreateTargetTenantFlags: shared.NewCreateTargetTenantFlags(), WorkerPoolFlags: shared.NewWorkerPoolFlags(), + CreateTargetDisabledFlags: machinescommon.NewCreateTargetDisabledFlags(), WebFlags: machinescommon.NewWebFlags(), } } @@ -123,6 +125,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { shared.RegisterCreateTargetRoleFlags(cmd, createFlags.CreateTargetRoleFlags) shared.RegisterCreateTargetTenantFlags(cmd, createFlags.CreateTargetTenantFlags) shared.RegisterCreateTargetWorkerPoolFlags(cmd, createFlags.WorkerPoolFlags) + machinescommon.RegisterCreateTargetDisabledFlags(cmd, createFlags.CreateTargetDisabledFlags) machinescommon.RegisterWebFlag(cmd, createFlags.WebFlags) return cmd } @@ -174,6 +177,8 @@ func createRun(opts *CreateOptions) error { return err } + deploymentTarget.IsDisabled = opts.Disabled.Value + createdTarget, err := opts.Client.Machines.Add(deploymentTarget) if err != nil { return err @@ -181,7 +186,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created Azure web app '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.Account, opts.WebApp, opts.ResourceGroup, opts.Slot, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.Account, opts.WebApp, opts.ResourceGroup, opts.Slot, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags, opts.Disabled) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/cloud-region/create/create.go b/pkg/cmd/target/cloud-region/create/create.go index ca73e7ef..30b987bb 100644 --- a/pkg/cmd/target/cloud-region/create/create.go +++ b/pkg/cmd/target/cloud-region/create/create.go @@ -28,6 +28,7 @@ type CreateFlags struct { *shared.CreateTargetRoleFlags *shared.WorkerPoolFlags *shared.CreateTargetTenantFlags + *machinescommon.CreateTargetDisabledFlags *machinescommon.WebFlags } @@ -47,6 +48,7 @@ func NewCreateFlags() *CreateFlags { CreateTargetEnvironmentFlags: shared.NewCreateTargetEnvironmentFlags(), CreateTargetRoleFlags: shared.NewCreateTargetRoleFlags(), CreateTargetTenantFlags: shared.NewCreateTargetTenantFlags(), + CreateTargetDisabledFlags: machinescommon.NewCreateTargetDisabledFlags(), WebFlags: machinescommon.NewWebFlags(), } } @@ -84,6 +86,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { shared.RegisterCreateTargetRoleFlags(cmd, createFlags.CreateTargetRoleFlags) shared.RegisterCreateTargetWorkerPoolFlags(cmd, createFlags.WorkerPoolFlags) shared.RegisterCreateTargetTenantFlags(cmd, createFlags.CreateTargetTenantFlags) + machinescommon.RegisterCreateTargetDisabledFlags(cmd, createFlags.CreateTargetDisabledFlags) machinescommon.RegisterWebFlag(cmd, createFlags.WebFlags) return cmd @@ -122,13 +125,15 @@ func createRun(opts *CreateOptions) error { return err } + target.IsDisabled = opts.Disabled.Value + createdTarget, err := opts.Client.Machines.Add(target) if err != nil { return err } fmt.Fprintf(opts.Out, "Successfully created cloud region '%s'.\n", target.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.WorkerPool, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.WorkerPool, opts.Environments, opts.Roles, opts.Tags, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags, opts.Disabled) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/disable/disable.go b/pkg/cmd/target/disable/disable.go new file mode 100644 index 00000000..6be5ea20 --- /dev/null +++ b/pkg/cmd/target/disable/disable.go @@ -0,0 +1,28 @@ +package disable + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/target/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/usage" + "github.com/spf13/cobra" +) + +func NewCmdDisable(f factory.Factory) *cobra.Command { + return &cobra.Command{ + Args: usage.MaximumNArgs(1), + Use: "disable [ | ]", + Short: "Disable a deployment target", + Long: "Disable a deployment target in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s deployment-target disable Machines-100 + %[1]s deployment-target disable 'web-server' + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := shared.NewSetDisabledStateOptions(args, cmd.NewDependencies(f, c)) + return shared.SetDisabledState(opts, true) + }, + } +} diff --git a/pkg/cmd/target/disable/disable_test.go b/pkg/cmd/target/disable/disable_test.go new file mode 100644 index 00000000..b622856b --- /dev/null +++ b/pkg/cmd/target/disable/disable_test.go @@ -0,0 +1,119 @@ +package disable_test + +import ( + "bytes" + "testing" + + "github.com/AlecAivazis/survey/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +const spaceID = "Spaces-1" + +func newTarget(id string, name string, isDisabled bool) *machines.DeploymentTarget { + target := machines.NewDeploymentTarget(name, machines.NewCloudRegionEndpoint(), []string{"Environments-1"}, []string{"web"}) + target.ID = id + target.SpaceID = spaceID + target.IsDisabled = isDisabled + return target +} + +func TestDeploymentTargetDisable(t *testing.T) { + space1 := fixtures.NewSpace(spaceID, "Default Space") + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"disables a target identified on the command line", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "disable", "Machines-100", "--no-prompt"}) + 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/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", false)) + + updateRequest := api.ExpectRequest(t, "PUT", "/api/Spaces-1/machines/Machines-100") + updated, err := testutil.ReadJson[machines.DeploymentTarget](updateRequest.Request.Body) + assert.Nil(t, err) + assert.True(t, updated.IsDisabled) + updateRequest.RespondWith(newTarget("Machines-100", "web-server", true)) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully disabled deployment target 'web-server'") + assert.Equal(t, "", stdErr.String()) + }}, + + {"does not update a target which is already disabled", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "disable", "Machines-100", "--no-prompt"}) + 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/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", true)) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "is already disabled") + assert.Equal(t, "", stdErr.String()) + }}, + + {"prompts for the target when none was supplied", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "disable"}) + 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/machines?take=2147483647"). + RespondWith(resources.Resources[*machines.DeploymentTarget]{Items: []*machines.DeploymentTarget{ + newTarget("Machines-100", "web-server", false), + newTarget("Machines-200", "db-server", false), + }}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the deployment target you wish to disable:", + Options: []string{"web-server", "db-server"}, + }).AnswerWith("db-server") + + api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-200").RespondWith(newTarget("Machines-200", "db-server", false)) + api.ExpectRequest(t, "PUT", "/api/Spaces-1/machines/Machines-200").RespondWith(newTarget("Machines-200", "db-server", true)) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully disabled deployment target 'db-server'") + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/target/enable/enable.go b/pkg/cmd/target/enable/enable.go new file mode 100644 index 00000000..cd850ae3 --- /dev/null +++ b/pkg/cmd/target/enable/enable.go @@ -0,0 +1,28 @@ +package enable + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/target/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/usage" + "github.com/spf13/cobra" +) + +func NewCmdEnable(f factory.Factory) *cobra.Command { + return &cobra.Command{ + Args: usage.MaximumNArgs(1), + Use: "enable [ | ]", + Short: "Enable a deployment target", + Long: "Enable a deployment target in Octopus Deploy", + Example: heredoc.Docf(` + %[1]s deployment-target enable Machines-100 + %[1]s deployment-target enable 'web-server' + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := shared.NewSetDisabledStateOptions(args, cmd.NewDependencies(f, c)) + return shared.SetDisabledState(opts, false) + }, + } +} diff --git a/pkg/cmd/target/enable/enable_test.go b/pkg/cmd/target/enable/enable_test.go new file mode 100644 index 00000000..91dfcbad --- /dev/null +++ b/pkg/cmd/target/enable/enable_test.go @@ -0,0 +1,119 @@ +package enable_test + +import ( + "bytes" + "testing" + + "github.com/AlecAivazis/survey/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +const spaceID = "Spaces-1" + +func newTarget(id string, name string, isDisabled bool) *machines.DeploymentTarget { + target := machines.NewDeploymentTarget(name, machines.NewCloudRegionEndpoint(), []string{"Environments-1"}, []string{"web"}) + target.ID = id + target.SpaceID = spaceID + target.IsDisabled = isDisabled + return target +} + +func TestDeploymentTargetEnable(t *testing.T) { + space1 := fixtures.NewSpace(spaceID, "Default Space") + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"enables a target identified on the command line", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "enable", "Machines-100", "--no-prompt"}) + 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/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", true)) + + updateRequest := api.ExpectRequest(t, "PUT", "/api/Spaces-1/machines/Machines-100") + updated, err := testutil.ReadJson[machines.DeploymentTarget](updateRequest.Request.Body) + assert.Nil(t, err) + assert.False(t, updated.IsDisabled) + updateRequest.RespondWith(newTarget("Machines-100", "web-server", false)) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully enabled deployment target 'web-server'") + assert.Equal(t, "", stdErr.String()) + }}, + + {"does not update a target which is already enabled", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "enable", "Machines-100", "--no-prompt"}) + 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/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", false)) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "is already enabled") + assert.Equal(t, "", stdErr.String()) + }}, + + {"prompts for the target when none was supplied", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "enable"}) + 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/machines?take=2147483647"). + RespondWith(resources.Resources[*machines.DeploymentTarget]{Items: []*machines.DeploymentTarget{ + newTarget("Machines-100", "web-server", true), + newTarget("Machines-200", "db-server", true), + }}) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the deployment target you wish to enable:", + Options: []string{"web-server", "db-server"}, + }).AnswerWith("web-server") + + api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", true)) + api.ExpectRequest(t, "PUT", "/api/Spaces-1/machines/Machines-100").RespondWith(newTarget("Machines-100", "web-server", false)) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully enabled deployment target 'web-server'") + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/target/kubernetes/create/create.go b/pkg/cmd/target/kubernetes/create/create.go index 08a86416..f4d506b3 100644 --- a/pkg/cmd/target/kubernetes/create/create.go +++ b/pkg/cmd/target/kubernetes/create/create.go @@ -151,6 +151,7 @@ type CreateFlags struct { *machinescommon.CreateTargetMachinePolicyFlags *shared.WorkerPoolFlags *shared.CreateTargetTenantFlags + *machinescommon.CreateTargetDisabledFlags *machinescommon.WebFlags } @@ -216,6 +217,7 @@ func NewCreateFlags() *CreateFlags { CreateTargetRoleFlags: shared.NewCreateTargetRoleFlags(), CreateTargetEnvironmentFlags: shared.NewCreateTargetEnvironmentFlags(), + CreateTargetDisabledFlags: machinescommon.NewCreateTargetDisabledFlags(), WebFlags: machinescommon.NewWebFlags(), WorkerPoolFlags: shared.NewWorkerPoolFlags(), CreateTargetTenantFlags: shared.NewCreateTargetTenantFlags(), @@ -307,6 +309,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { shared.RegisterCreateTargetWorkerPoolFlags(cmd, createFlags.WorkerPoolFlags) shared.RegisterCreateTargetTenantFlags(cmd, createFlags.CreateTargetTenantFlags) shared.RegisterCreateTargetRoleFlags(cmd, createFlags.CreateTargetRoleFlags) + machinescommon.RegisterCreateTargetDisabledFlags(cmd, createFlags.CreateTargetDisabledFlags) machinescommon.RegisterWebFlag(cmd, createFlags.WebFlags) return cmd @@ -464,6 +467,8 @@ func (opts *CreateOptions) Commit() error { return err } + deploymentTarget.IsDisabled = opts.Disabled.Value + createdTarget, err := opts.Client.Machines.Add(deploymentTarget) if err != nil { return err @@ -522,6 +527,7 @@ func (opts *CreateOptions) Commit() error { opts.Tenants, opts.TenantTags, opts.WorkerPool, + opts.Disabled, ) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/listening-tentacle/create/create.go b/pkg/cmd/target/listening-tentacle/create/create.go index 30f35ec9..5fb5f2e1 100644 --- a/pkg/cmd/target/listening-tentacle/create/create.go +++ b/pkg/cmd/target/listening-tentacle/create/create.go @@ -35,6 +35,7 @@ type CreateFlags struct { *shared.CreateTargetRoleFlags *machinescommon.CreateTargetMachinePolicyFlags *shared.CreateTargetTenantFlags + *machinescommon.CreateTargetDisabledFlags *machinescommon.WebFlags } @@ -58,6 +59,7 @@ func NewCreateFlags() *CreateFlags { CreateTargetMachinePolicyFlags: machinescommon.NewCreateTargetMachinePolicyFlags(), CreateTargetEnvironmentFlags: shared.NewCreateTargetEnvironmentFlags(), CreateTargetTenantFlags: shared.NewCreateTargetTenantFlags(), + CreateTargetDisabledFlags: machinescommon.NewCreateTargetDisabledFlags(), WebFlags: machinescommon.NewWebFlags(), } } @@ -99,6 +101,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { machinescommon.RegisterCreateTargetProxyFlags(cmd, createFlags.CreateTargetProxyFlags, "Listening Tentacle") machinescommon.RegisterCreateTargetMachinePolicyFlags(cmd, createFlags.CreateTargetMachinePolicyFlags) shared.RegisterCreateTargetTenantFlags(cmd, createFlags.CreateTargetTenantFlags) + machinescommon.RegisterCreateTargetDisabledFlags(cmd, createFlags.CreateTargetDisabledFlags) machinescommon.RegisterWebFlag(cmd, createFlags.WebFlags) return cmd @@ -147,6 +150,8 @@ func createRun(opts *CreateOptions) error { return err } + deploymentTarget.IsDisabled = opts.Disabled.Value + createdTarget, err := opts.Client.Machines.Add(deploymentTarget) if err != nil { return err @@ -154,7 +159,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created listening tenatcle '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.URL, opts.Thumbprint, opts.Environments, opts.Roles, opts.Tags, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.URL, opts.Thumbprint, opts.Environments, opts.Roles, opts.Tags, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags, opts.Disabled) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/shared/disabledstate.go b/pkg/cmd/target/shared/disabledstate.go new file mode 100644 index 00000000..81028ffa --- /dev/null +++ b/pkg/cmd/target/shared/disabledstate.go @@ -0,0 +1,95 @@ +package shared + +import ( + "errors" + "fmt" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" +) + +type SetDisabledStateOptions struct { + *cmd.Dependencies + *GetTargetsOptions + IdOrName string +} + +func NewSetDisabledStateOptions(args []string, dependencies *cmd.Dependencies) *SetDisabledStateOptions { + idOrName := "" + if len(args) > 0 { + idOrName = args[0] + } + + return &SetDisabledStateOptions{ + Dependencies: dependencies, + GetTargetsOptions: NewGetTargetsOptionsForAllTargets(dependencies), + IdOrName: idOrName, + } +} + +// SetDisabledState enables or disables a deployment target, prompting for the target when no +// name or ID was supplied. +func SetDisabledState(opts *SetDisabledStateOptions, isDisabled bool) error { + if !opts.NoPrompt { + if err := PromptMissingTarget(opts, isDisabled); err != nil { + return err + } + } + + if opts.IdOrName == "" { + return errors.New("deployment target identifier is required but was not provided") + } + + target, err := opts.Client.Machines.GetByIdentifier(opts.IdOrName) + if err != nil { + return err + } + + state := disabledStateDescription(isDisabled) + if target.IsDisabled == isDisabled { + _, _ = fmt.Fprintf(opts.Out, "Deployment target '%s' %s is already %s.\n", target.Name, output.Dimf("(%s)", target.GetID()), state) + return nil + } + + target.IsDisabled = isDisabled + if _, err = machines.Update(opts.Client, target); err != nil { + return err + } + + _, _ = fmt.Fprintf(opts.Out, "Successfully %s deployment target '%s' %s.\n", state, target.Name, output.Dimf("(%s)", target.GetID())) + return nil +} + +func PromptMissingTarget(opts *SetDisabledStateOptions, isDisabled bool) error { + if opts.IdOrName != "" { + return nil + } + + selectedTarget, err := selectors.Select( + opts.Ask, + fmt.Sprintf("Select the deployment target you wish to %s:", actionDescription(isDisabled)), + opts.GetTargetsCallback, + func(target *machines.DeploymentTarget) string { return target.Name }) + if err != nil { + return err + } + + opts.IdOrName = selectedTarget.GetID() + return nil +} + +func actionDescription(isDisabled bool) string { + if isDisabled { + return "disable" + } + return "enable" +} + +func disabledStateDescription(isDisabled bool) string { + if isDisabled { + return "disabled" + } + return "enabled" +} diff --git a/pkg/cmd/target/shared/disabledstate_test.go b/pkg/cmd/target/shared/disabledstate_test.go new file mode 100644 index 00000000..919c1efc --- /dev/null +++ b/pkg/cmd/target/shared/disabledstate_test.go @@ -0,0 +1,73 @@ +package shared_test + +import ( + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/target/shared" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/stretchr/testify/assert" +) + +func TestPromptMissingTarget_IdentifierSupplied(t *testing.T) { + pa := []*testutil.PA{} + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + opts := shared.NewSetDisabledStateOptions([]string{"Machines-1"}, &cmd.Dependencies{Ask: asker}) + + err := shared.PromptMissingTarget(opts, true) + checkRemainingPrompts() + + assert.NoError(t, err) + assert.Equal(t, "Machines-1", opts.IdOrName) +} + +func TestPromptMissingTarget_NoIdentifierSupplied(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewSelectPrompt("Select the deployment target you wish to disable:", "", []string{"web-server", "db-server"}, "db-server"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + opts := shared.NewSetDisabledStateOptions([]string{}, &cmd.Dependencies{Ask: asker}) + opts.GetTargetsCallback = func() ([]*machines.DeploymentTarget, error) { + return []*machines.DeploymentTarget{ + newTestTarget("Machines-1", "web-server"), + newTestTarget("Machines-2", "db-server"), + }, nil + } + + err := shared.PromptMissingTarget(opts, true) + checkRemainingPrompts() + + assert.NoError(t, err) + assert.Equal(t, "Machines-2", opts.IdOrName) +} + +func TestPromptMissingTarget_EnableUsesEnableWording(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewSelectPrompt("Select the deployment target you wish to enable:", "", []string{"web-server", "db-server"}, "web-server"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + opts := shared.NewSetDisabledStateOptions([]string{}, &cmd.Dependencies{Ask: asker}) + opts.GetTargetsCallback = func() ([]*machines.DeploymentTarget, error) { + return []*machines.DeploymentTarget{ + newTestTarget("Machines-1", "web-server"), + newTestTarget("Machines-2", "db-server"), + }, nil + } + + err := shared.PromptMissingTarget(opts, false) + checkRemainingPrompts() + + assert.NoError(t, err) + assert.Equal(t, "Machines-1", opts.IdOrName) +} + +func newTestTarget(id string, name string) *machines.DeploymentTarget { + target := machines.NewDeploymentTarget(name, machines.NewCloudRegionEndpoint(), []string{"Environments-1"}, []string{"web"}) + target.ID = id + target.SpaceID = "Spaces-1" + return target +} diff --git a/pkg/cmd/target/ssh/create/create.go b/pkg/cmd/target/ssh/create/create.go index 504378fd..c1fe2954 100644 --- a/pkg/cmd/target/ssh/create/create.go +++ b/pkg/cmd/target/ssh/create/create.go @@ -33,6 +33,7 @@ type CreateFlags struct { *shared.CreateTargetRoleFlags *machinescommon.CreateTargetMachinePolicyFlags *shared.CreateTargetTenantFlags + *machinescommon.CreateTargetDisabledFlags *machinescommon.WebFlags *machinescommon.SshCommonFlags } @@ -58,6 +59,7 @@ func NewCreateFlags() *CreateFlags { CreateTargetMachinePolicyFlags: machinescommon.NewCreateTargetMachinePolicyFlags(), CreateTargetEnvironmentFlags: shared.NewCreateTargetEnvironmentFlags(), CreateTargetTenantFlags: shared.NewCreateTargetTenantFlags(), + CreateTargetDisabledFlags: machinescommon.NewCreateTargetDisabledFlags(), WebFlags: machinescommon.NewWebFlags(), } } @@ -100,6 +102,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { machinescommon.RegisterCreateTargetProxyFlags(cmd, createFlags.CreateTargetProxyFlags, "SSH target") machinescommon.RegisterCreateTargetMachinePolicyFlags(cmd, createFlags.CreateTargetMachinePolicyFlags) shared.RegisterCreateTargetTenantFlags(cmd, createFlags.CreateTargetTenantFlags) + machinescommon.RegisterCreateTargetDisabledFlags(cmd, createFlags.CreateTargetDisabledFlags) machinescommon.RegisterWebFlag(cmd, createFlags.WebFlags) return cmd @@ -159,6 +162,8 @@ func createRun(opts *CreateOptions) error { return err } + deploymentTarget.IsDisabled = opts.Disabled.Value + createdTarget, err := opts.Client.Machines.Add(deploymentTarget) if err != nil { return err @@ -166,7 +171,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.Out, "Successfully created SSH deployment target '%s'.\n", deploymentTarget.Name) if !opts.NoPrompt { - autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.HostName, opts.Port, opts.Fingerprint, opts.Runtime, opts.Platform, opts.Environments, opts.Roles, opts.Tags, opts.Account, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags) + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Name, opts.HostName, opts.Port, opts.Fingerprint, opts.Runtime, opts.Platform, opts.Environments, opts.Roles, opts.Tags, opts.Account, opts.Proxy, opts.MachinePolicy, opts.TenantedDeploymentMode, opts.Tenants, opts.TenantTags, opts.Disabled) fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) } diff --git a/pkg/cmd/target/target.go b/pkg/cmd/target/target.go index aa6129a2..76fbc581 100644 --- a/pkg/cmd/target/target.go +++ b/pkg/cmd/target/target.go @@ -5,6 +5,8 @@ import ( cmdAzureWebApp "github.com/OctopusDeploy/cli/pkg/cmd/target/azure-web-app" cmdCloudRegion "github.com/OctopusDeploy/cli/pkg/cmd/target/cloud-region" cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/target/delete" + cmdDisable "github.com/OctopusDeploy/cli/pkg/cmd/target/disable" + cmdEnable "github.com/OctopusDeploy/cli/pkg/cmd/target/enable" cmdKubernetes "github.com/OctopusDeploy/cli/pkg/cmd/target/kubernetes" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/target/list" cmdListeningTentacle "github.com/OctopusDeploy/cli/pkg/cmd/target/listening-tentacle" @@ -35,6 +37,8 @@ func NewCmdDeploymentTarget(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdAzureWebApp.NewCmdAzureWebApp(f)) cmd.AddCommand(cmdKubernetes.NewCmdKubernetes(f)) cmd.AddCommand(cmdDelete.NewCmdDelete(f)) + cmd.AddCommand(cmdEnable.NewCmdEnable(f)) + cmd.AddCommand(cmdDisable.NewCmdDisable(f)) cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdView.NewCmdView(f)) diff --git a/pkg/cmd/target/target_test.go b/pkg/cmd/target/target_test.go new file mode 100644 index 00000000..58aaab43 --- /dev/null +++ b/pkg/cmd/target/target_test.go @@ -0,0 +1,47 @@ +package target_test + +import ( + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd/target" + "github.com/OctopusDeploy/cli/pkg/machinescommon" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeploymentTargetHasEnableAndDisableCommands(t *testing.T) { + cmd := target.NewCmdDeploymentTarget(testutil.NewMockFactory(testutil.NewMockHttpServer())) + + assert.NotNil(t, findCommand(cmd, "enable")) + assert.NotNil(t, findCommand(cmd, "disable")) +} + +func TestEveryTargetCreateCommandSupportsDisabled(t *testing.T) { + root := target.NewCmdDeploymentTarget(testutil.NewMockFactory(testutil.NewMockHttpServer())) + + targetTypes := []string{"azure-web-app", "cloud-region", "kubernetes", "listening-tentacle", "ssh"} + for _, targetType := range targetTypes { + t.Run(targetType, func(t *testing.T) { + typeCmd := findCommand(root, targetType) + require.NotNil(t, typeCmd) + + createCmd := findCommand(typeCmd, "create") + require.NotNil(t, createCmd) + + disabled := createCmd.Flags().Lookup(machinescommon.FlagDisabled) + require.NotNil(t, disabled) + assert.Equal(t, "false", disabled.DefValue) + }) + } +} + +func findCommand(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} diff --git a/pkg/machinescommon/disabled.go b/pkg/machinescommon/disabled.go new file mode 100644 index 00000000..e9fc77ae --- /dev/null +++ b/pkg/machinescommon/disabled.go @@ -0,0 +1,22 @@ +package machinescommon + +import ( + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/spf13/cobra" +) + +const FlagDisabled = "disabled" + +type CreateTargetDisabledFlags struct { + Disabled *flag.Flag[bool] +} + +func NewCreateTargetDisabledFlags() *CreateTargetDisabledFlags { + return &CreateTargetDisabledFlags{ + Disabled: flag.New[bool](FlagDisabled, false), + } +} + +func RegisterCreateTargetDisabledFlags(cmd *cobra.Command, disabledFlags *CreateTargetDisabledFlags) { + cmd.Flags().BoolVar(&disabledFlags.Disabled.Value, disabledFlags.Disabled.Name, false, "Create the deployment target in a disabled state.") +} diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index d417eee3..ac337484 100644 --- a/test/testutil/fakeoctopusserver.go +++ b/test/testutil/fakeoctopusserver.go @@ -227,6 +227,7 @@ func NewRootResource() *octopusApiClient.RootResource { root.Links[constants.LinkAccounts] = "/api/Spaces-1/accounts{/id}{?skip,take,ids,partialName,accountType}" root.Links[constants.LinkPackages] = "/api/Spaces-1/packages{/id}{?nuGetPackageId,filter,latest,skip,take,includeNotes}" root.Links[constants.LinkLifecycles] = "/api/Spaces-1/lifecycles{/id}{?skip,take,ids,partialName}" + root.Links[constants.LinkMachines] = "/api/Spaces-1/machines{/id}{?skip,take,name,ids,partialName,roles,isDisabled,healthStatuses,commStyles,tenantIds,tenantTags,environmentIds,thumbprint,deploymentId,shellNames}" root.Links[constants.LinkProjectGroups] = "/api/Spaces-1/projectgroups{/id}{?skip,take,ids,partialName}" root.Links[constants.LinkUsers] = "/api/users" root.Links[constants.LinkCurrentUser] = "/api/users/me" From f5be1bbea2f5c84beeeab1459c51297198a5c038 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Sat, 22 Aug 2026 12:55:11 +1000 Subject: [PATCH 2/3] test: integration tests for deployment target enable and disable Covers what MockHttpServer can't: the server honouring IsDisabled on create, and the read-modify-write PUT leaving the rest of the target's settings intact. Also pins that a worker ID is not accepted. Co-Authored-By: Claude Opus 5 (1M context) --- test/integration/target_test.go | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 test/integration/target_test.go diff --git a/test/integration/target_test.go b/test/integration/target_test.go new file mode 100644 index 00000000..caf494f9 --- /dev/null +++ b/test/integration/target_test.go @@ -0,0 +1,165 @@ +package integration_test + +import ( + "fmt" + "testing" + + "github.com/OctopusDeploy/cli/test/integration" + "github.com/OctopusDeploy/cli/test/testutil" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cloud regions are the cheapest target to create for real: no endpoint +// credentials and no connectivity for the server to check +func createCloudRegion(t *testing.T, apiClient *octopusApiClient.Client, envName string, name string, extraArgs ...string) *machines.DeploymentTarget { + args := append([]string{ + "deployment-target", "cloud-region", "create", + "--name", name, "--environment", envName, "--role", "target-tests", + }, extraArgs...) + + stdOut, stdErr, err := integration.RunCli("Default", args...) + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return nil + } + + target, err := apiClient.Machines.GetByIdentifier(name) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.Machines.DeleteByID(target.GetID())) }) + return target +} + +// settings a toggle must not disturb. HealthStatus, Status and StatusSummary are +// excluded on purpose: the server derives them from IsDisabled. +type targetSettings struct { + Name string + EnvironmentIDs []string + Roles []string + TenantedDeploymentMode string + TenantIDs []string + TenantTags []string + MachinePolicyID string + Thumbprint string + URI string + EndpointType string +} + +func settingsOf(target *machines.DeploymentTarget) targetSettings { + return targetSettings{ + Name: target.Name, + EnvironmentIDs: target.EnvironmentIDs, + Roles: target.Roles, + TenantedDeploymentMode: string(target.TenantedDeploymentMode), + TenantIDs: target.TenantIDs, + TenantTags: target.TenantTags, + MachinePolicyID: target.MachinePolicyID, + Thumbprint: target.Thumbprint, + URI: target.URI, + EndpointType: fmt.Sprintf("%T", target.Endpoint), + } +} + +func TestDeploymentTargetEnableDisable(t *testing.T) { + runId := uuid.New() + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + env, err := apiClient.Environments.Add(environments.NewEnvironment(fmt.Sprintf("tgtenv-%s", runId))) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.Environments.DeleteByID(env.GetID())) }) + + t.Run("create --disabled", func(t *testing.T) { + target := createCloudRegion(t, apiClient, env.Name, fmt.Sprintf("tgt-disabled-%s", runId), "--disabled") + require.NotNil(t, target) + assert.True(t, target.IsDisabled) + }) + + t.Run("create without --disabled", func(t *testing.T) { + target := createCloudRegion(t, apiClient, env.Name, fmt.Sprintf("tgt-enabled-%s", runId)) + require.NotNil(t, target) + assert.False(t, target.IsDisabled) + }) + + t.Run("enable and disable change nothing else", func(t *testing.T) { + target := createCloudRegion(t, apiClient, env.Name, fmt.Sprintf("tgt-toggle-%s", runId), "--disabled") + require.NotNil(t, target) + before := settingsOf(target) + + stdOut, stdErr, err := integration.RunCli("Default", "deployment-target", "enable", target.Name) + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + assert.Contains(t, stdOut, fmt.Sprintf("Successfully enabled deployment target '%s'", target.Name)) + + enabled, err := apiClient.Machines.GetByIdentifier(target.GetID()) + testutil.RequireSuccess(t, err) + assert.False(t, enabled.IsDisabled) + + // the update is a read-modify-write of the whole target, so the rest of + // its settings have to survive the round trip + assert.Equal(t, before, settingsOf(enabled)) + + // and back again, by ID this time + stdOut, stdErr, err = integration.RunCli("Default", "deployment-target", "disable", target.GetID()) + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + assert.Contains(t, stdOut, fmt.Sprintf("Successfully disabled deployment target '%s'", target.Name)) + + disabled, err := apiClient.Machines.GetByIdentifier(target.GetID()) + testutil.RequireSuccess(t, err) + assert.True(t, disabled.IsDisabled) + assert.Equal(t, before, settingsOf(disabled)) + }) + + t.Run("already in the requested state", func(t *testing.T) { + target := createCloudRegion(t, apiClient, env.Name, fmt.Sprintf("tgt-noop-%s", runId)) + require.NotNil(t, target) + + stdOut, stdErr, err := integration.RunCli("Default", "deployment-target", "enable", target.Name) + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return + } + assert.Contains(t, stdOut, fmt.Sprintf("Deployment target '%s' (%s) is already enabled.", target.Name, target.GetID())) + + unchanged, err := apiClient.Machines.GetByIdentifier(target.GetID()) + testutil.RequireSuccess(t, err) + assert.False(t, unchanged.IsDisabled) + assert.Equal(t, target.ModifiedOn, unchanged.ModifiedOn, "no update should have been sent") + }) + + t.Run("errors", func(t *testing.T) { + for _, tc := range []struct { + name string + args []string + expected string + }{ + {"unknown name", []string{"enable", "no-such-target"}, "cannot find machine with the name or ID of 'no-such-target'"}, + {"no identifier without prompting", []string{"disable"}, "deployment target identifier is required but was not provided"}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"deployment-target"}, tc.args...) + stdOut, stdErr, err := integration.RunCli("Default", args...) + assert.Error(t, err, stdOut) + assert.Contains(t, stdOut+stdErr, tc.expected) + }) + } + + t.Run("a worker is not a deployment target", func(t *testing.T) { + workers, err := apiClient.Workers.Get(machines.WorkersQuery{Take: 1}) + testutil.RequireSuccess(t, err) + if len(workers.Items) == 0 { + t.Skip("no workers in this space") + } + workerID := workers.Items[0].GetID() + + stdOut, stdErr, err := integration.RunCli("Default", "deployment-target", "enable", workerID) + assert.Error(t, err, stdOut) + assert.Contains(t, stdOut+stdErr, fmt.Sprintf("cannot find machine with the name or ID of '%s'", workerID)) + }) + }) +} From ec38540459d1219d118c49a8dd1860635715d184 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 24 Aug 2026 19:14:16 +1000 Subject: [PATCH 3/3] test: cover --disabled on listening tentacles too Second endpoint type, so the assertion is about the server honouring IsDisabled rather than about cloud regions specifically. Co-Authored-By: Claude Opus 5 (1M context) --- test/integration/target_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/integration/target_test.go b/test/integration/target_test.go index caf494f9..0871c573 100644 --- a/test/integration/target_test.go +++ b/test/integration/target_test.go @@ -33,6 +33,28 @@ func createCloudRegion(t *testing.T, apiClient *octopusApiClient.Client, envName return target } +// a second endpoint type, to check the server honours IsDisabled on more than +// just cloud regions. Needs an explicit machine policy under --no-prompt. +func createListeningTentacle(t *testing.T, apiClient *octopusApiClient.Client, envName string, name string, thumbprint string, extraArgs ...string) *machines.DeploymentTarget { + args := append([]string{ + "deployment-target", "listening-tentacle", "create", + "--name", name, "--environment", envName, "--role", "target-tests", + "--machine-policy", "Default Machine Policy", + "--thumbprint", thumbprint, + "--url", fmt.Sprintf("https://%s.invalid:10933", name), + }, extraArgs...) + + stdOut, stdErr, err := integration.RunCli("Default", args...) + if !testutil.AssertSuccess(t, err, stdOut, stdErr) { + return nil + } + + target, err := apiClient.Machines.GetByIdentifier(name) + testutil.RequireSuccess(t, err) + t.Cleanup(func() { assert.Nil(t, apiClient.Machines.DeleteByID(target.GetID())) }) + return target +} + // settings a toggle must not disturb. HealthStatus, Status and StatusSummary are // excluded on purpose: the server derives them from IsDisabled. type targetSettings struct { @@ -84,6 +106,13 @@ func TestDeploymentTargetEnableDisable(t *testing.T) { assert.False(t, target.IsDisabled) }) + t.Run("create --disabled on a listening tentacle", func(t *testing.T) { + target := createListeningTentacle(t, apiClient, env.Name, + fmt.Sprintf("tgt-lt-disabled-%s", runId), "0123456789ABCDEF0123456789ABCDEF01234567", "--disabled") + require.NotNil(t, target) + assert.True(t, target.IsDisabled) + }) + t.Run("enable and disable change nothing else", func(t *testing.T) { target := createCloudRegion(t, apiClient, env.Name, fmt.Sprintf("tgt-toggle-%s", runId), "--disabled") require.NotNil(t, target)