From fc852afb6325565c63521202bf1faaa6c4dacceb Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Tue, 15 Sep 2026 16:31:58 +1000 Subject: [PATCH] feat: show the disabled state of deployment targets and workers `enable`/`disable` had no read-back: nothing in the CLI reported whether a target or worker was disabled, so the only way to confirm a toggle was the web UI. Adds the state to every surface that already reports on a machine: - `deployment-target list`, `deployment-target view`, `worker list` and `worker view` gain an IS DISABLED table column, an IsDisabled JSON field, and a "Disabled" line in basic output. - the per-type views (`deployment-target ssh view`, `worker listening-tentacle view`, and the rest) gain a Disabled row; they share one ViewRun each, so that is one line per resource. Follows `tenant list`, which already prints an IS DISABLED column, and names the concept IsDisabled/IS DISABLED/Disabled after the API field on every surface. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/target/list/list.go | 5 +- pkg/cmd/target/list/list_disabled_test.go | 106 +++++++++++++++++ pkg/cmd/target/shared/json.go | 2 + pkg/cmd/target/shared/view.go | 2 + pkg/cmd/target/shared/view_test.go | 60 ++++++++++ pkg/cmd/target/view/view.go | 8 +- pkg/cmd/target/view/view_disabled_test.go | 118 +++++++++++++++++++ pkg/cmd/worker/list/list.go | 7 +- pkg/cmd/worker/list/list_disabled_test.go | 108 ++++++++++++++++++ pkg/cmd/worker/shared/view.go | 4 +- pkg/cmd/worker/shared/view_test.go | 76 +++++++++++++ pkg/cmd/worker/view/view.go | 7 +- pkg/cmd/worker/view/view_disabled_test.go | 133 ++++++++++++++++++++++ 13 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 pkg/cmd/target/list/list_disabled_test.go create mode 100644 pkg/cmd/target/shared/view_test.go create mode 100644 pkg/cmd/target/view/view_disabled_test.go create mode 100644 pkg/cmd/worker/list/list_disabled_test.go create mode 100644 pkg/cmd/worker/shared/view_test.go create mode 100644 pkg/cmd/worker/view/view_disabled_test.go diff --git a/pkg/cmd/target/list/list.go b/pkg/cmd/target/list/list.go index 4975099e..5c8a0f24 100644 --- a/pkg/cmd/target/list/list.go +++ b/pkg/cmd/target/list/list.go @@ -10,6 +10,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" "github.com/spf13/cobra" + "strconv" ) type ListOptions struct { @@ -75,12 +76,12 @@ func ListRun(opts *ListOptions) error { return shared.GetDeploymentTargetAsJson(opts.Dependencies, item) }, Table: output.TableDefinition[*machines.DeploymentTarget]{ - Header: []string{"NAME", "TYPE", "ROLES", "ENVIRONMENTS", "TENANTS", "TAGS", "DEFAULT WORKER POOL"}, + Header: []string{"NAME", "TYPE", "IS DISABLED", "ROLES", "ENVIRONMENTS", "TENANTS", "TAGS", "DEFAULT WORKER POOL"}, Row: func(item *machines.DeploymentTarget) []string { environmentNames := resolveValues(item.EnvironmentIDs, environmentMap) tenantNames := resolveValues(item.TenantIDs, tenantMap) workerPool := shared.ResolveDefaultWorkerPool(item, workerPoolMap, "None") - return []string{output.Bold(item.Name), describeTargetType(item), output.FormatAsList(item.Roles), output.FormatAsList(environmentNames), output.FormatAsList(tenantNames), output.FormatAsList(item.TenantTags), workerPool} + return []string{output.Bold(item.Name), describeTargetType(item), strconv.FormatBool(item.IsDisabled), output.FormatAsList(item.Roles), output.FormatAsList(environmentNames), output.FormatAsList(tenantNames), output.FormatAsList(item.TenantTags), workerPool} }, }, Basic: func(item *machines.DeploymentTarget) string { diff --git a/pkg/cmd/target/list/list_disabled_test.go b/pkg/cmd/target/list/list_disabled_test.go new file mode 100644 index 00000000..ebae2102 --- /dev/null +++ b/pkg/cmd/target/list/list_disabled_test.go @@ -0,0 +1,106 @@ +package list_test + +import ( + "bytes" + "testing" + + 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" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + octopusConstants "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// the shared root resource has no worker pool link; the target commands need it +var rootResource = newRootResourceWithWorkerPools() + +func newRootResourceWithWorkerPools() *octopusApiClient.RootResource { + root := testutil.NewRootResource() + root.Links[octopusConstants.LinkWorkerPools] = octopusConstants.TestURIWorkerPools + return root +} + +const spaceID = "Spaces-1" + +func TestDeploymentTargetListShowsDisabledState(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) + }{ + {"table output has an IS DISABLED column", 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", "list", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + respondWithTargets(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "IS DISABLED") + assert.Regexp(t, `web-server.*false`, stdOut.String()) + assert.Regexp(t, `db-server.*true`, stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"json output carries IsDisabled", 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", "list", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondWithTargets(t, api) + // the json mapper re-resolves the lookups per target + for range 2 { + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{}) + } + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), `"IsDisabled": false`) + assert.Contains(t, stdOut.String(), `"IsDisabled": true`) + 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) + }) + } +} + +func respondWithTargets(t *testing.T, api *testutil.MockHttpServer) { + 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{ + fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", false), + fixtures.NewDeploymentTarget(spaceID, "Machines-200", "db-server", true), + }}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) +} + +var development = fixtures.NewEnvironment(spaceID, "Environments-1", "Development") diff --git a/pkg/cmd/target/shared/json.go b/pkg/cmd/target/shared/json.go index b732c0f4..af9489da 100644 --- a/pkg/cmd/target/shared/json.go +++ b/pkg/cmd/target/shared/json.go @@ -14,6 +14,7 @@ type DeploymentTargetAsJson struct { Name string `json:"Name"` HealthStatus string `json:"HealthStatus"` StatusSummary string `json:"StatusSummary"` + IsDisabled bool `json:"IsDisabled"` CommunicationStyle string `json:"CommunicationStyle"` Environments []string `json:"Environments"` Roles []string `json:"Roles"` @@ -42,6 +43,7 @@ func GetDeploymentTargetAsJson(deps *cmd.Dependencies, target *machines.Deployme Name: target.Name, HealthStatus: target.HealthStatus, StatusSummary: target.StatusSummary, + IsDisabled: target.IsDisabled, CommunicationStyle: machinescommon.GetCommunicationStyle(target.Endpoint), Environments: environments, Roles: target.Roles, diff --git a/pkg/cmd/target/shared/view.go b/pkg/cmd/target/shared/view.go index 186f0fbb..d5fc9384 100644 --- a/pkg/cmd/target/shared/view.go +++ b/pkg/cmd/target/shared/view.go @@ -2,6 +2,7 @@ package shared import ( "fmt" + "strconv" "github.com/OctopusDeploy/cli/pkg/cmd" "github.com/OctopusDeploy/cli/pkg/machinescommon" @@ -51,6 +52,7 @@ func ViewRun(opts *ViewOptions, contributeEndpoint ContributeEndpointCallback, d data = append(data, output.NewDataRow("Name", fmt.Sprintf("%s %s", output.Bold(target.Name), output.Dimf("(%s)", target.GetID())))) data = append(data, output.NewDataRow("Health status", getHealthStatus(target))) data = append(data, output.NewDataRow("Current status", target.StatusSummary)) + data = append(data, output.NewDataRow("Disabled", strconv.FormatBool(target.IsDisabled))) if contributeEndpoint != nil { if machines.IsNil(target.Endpoint) { diff --git a/pkg/cmd/target/shared/view_test.go b/pkg/cmd/target/shared/view_test.go new file mode 100644 index 00000000..efeb4c7f --- /dev/null +++ b/pkg/cmd/target/shared/view_test.go @@ -0,0 +1,60 @@ +package shared_test + +import ( + "bytes" + "testing" + + 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/environments" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var viewRootResource = testutil.NewRootResource() + +// the per-type views all render through shared.ViewRun, so one of them is enough +// to cover the Disabled row it adds +func TestPerTypeViewShowsDisabledState(t *testing.T) { + const spaceID = "Spaces-1" + space1 := fixtures.NewSpace(spaceID, "Default Space") + development := fixtures.NewEnvironment(spaceID, "Environments-1", "Development") + + for _, tc := range []struct { + name string + isDisabled bool + expected string + }{ + {"disabled target", true, "true"}, + {"enabled target", false, "false"}, + } { + t.Run(tc.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) + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"deployment-target", "cloud-region", "view", "Machines-100", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(viewRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(viewRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-100"). + RespondWith(fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", tc.isDisabled)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Regexp(t, `Disabled\s+`+tc.expected, stdout.String()) + assert.Equal(t, "", stderr.String()) + }) + } +} diff --git a/pkg/cmd/target/view/view.go b/pkg/cmd/target/view/view.go index 21a95641..b68f2c0a 100644 --- a/pkg/cmd/target/view/view.go +++ b/pkg/cmd/target/view/view.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "strconv" "strings" "github.com/MakeNowJust/heredoc/v2" @@ -53,7 +54,7 @@ func ViewRun(opts *shared.ViewOptions) error { return getDeploymentTargetAsJson(opts.Dependencies, t, environmentMap, tenantMap, workerPoolMap) }, Table: output.TableDefinition[*machines.DeploymentTarget]{ - Header: []string{"NAME", "TYPE", "HEALTH", "ENVIRONMENTS", "ROLES", "TENANTS", "TENANT TAGS", "ENDPOINT DETAILS", "DEFAULT WORKER POOL"}, + Header: []string{"NAME", "TYPE", "HEALTH", "IS DISABLED", "ENVIRONMENTS", "ROLES", "TENANTS", "TENANT TAGS", "ENDPOINT DETAILS", "DEFAULT WORKER POOL"}, Row: func(t *machines.DeploymentTarget) []string { return getDeploymentTargetAsTableRow(opts, t, environmentMap, tenantMap, workerPoolMap) }, @@ -75,6 +76,7 @@ func getDeploymentTargetAsJson(deps *cmd.Dependencies, target *machines.Deployme Name: target.Name, HealthStatus: target.HealthStatus, StatusSummary: target.StatusSummary, + IsDisabled: target.IsDisabled, CommunicationStyle: machinescommon.GetCommunicationStyle(target.Endpoint), Environments: environments, Roles: target.Roles, @@ -134,6 +136,7 @@ func getDeploymentTargetAsTableRow(opts *shared.ViewOptions, target *machines.De output.Bold(target.Name), targetType, healthStatus, + strconv.FormatBool(target.IsDisabled), strings.Join(environments, ", "), strings.Join(target.Roles, ", "), tenants, @@ -198,6 +201,9 @@ func getDeploymentTargetAsBasic(opts *shared.ViewOptions, target *machines.Deplo // Current status result.WriteString(fmt.Sprintf("Current status: %s\n", target.StatusSummary)) + // Disabled state + result.WriteString(fmt.Sprintf("Disabled: %s\n", strconv.FormatBool(target.IsDisabled))) + // Target type and endpoint details targetType := getTargetTypeDisplayName(machinescommon.GetCommunicationStyle(target.Endpoint)) result.WriteString(fmt.Sprintf("Type: %s\n", output.Cyan(targetType))) diff --git a/pkg/cmd/target/view/view_disabled_test.go b/pkg/cmd/target/view/view_disabled_test.go new file mode 100644 index 00000000..81a959ad --- /dev/null +++ b/pkg/cmd/target/view/view_disabled_test.go @@ -0,0 +1,118 @@ +package view_test + +import ( + "bytes" + "testing" + + 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/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +const spaceID = "Spaces-1" + +var development = fixtures.NewEnvironment(spaceID, "Environments-1", "Development") + +func TestDeploymentTargetViewShowsDisabledState(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) + }{ + {"basic output reports a disabled target", 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", "view", "Machines-100", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + respondWithTarget(t, api, true) + // the basic renderer re-resolves the lookups it needs + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Disabled: true") + assert.Equal(t, "", stdErr.String()) + }}, + + {"basic output reports an enabled target", 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", "view", "Machines-100", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + respondWithTarget(t, api, false) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Disabled: false") + assert.Equal(t, "", stdErr.String()) + }}, + + {"json output carries IsDisabled", 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", "view", "Machines-100", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondWithTarget(t, api, true) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), `"IsDisabled": true`) + assert.Equal(t, "", stdErr.String()) + }}, + + {"table output has an IS DISABLED column", 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", "view", "Machines-100", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + respondWithTarget(t, api, true) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "IS DISABLED") + assert.Regexp(t, `web-server.*true`, stdOut.String()) + 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) + }) + } +} + +func respondWithTarget(t *testing.T, api *testutil.MockHttpServer, isDisabled bool) { + 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(fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", isDisabled)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{}) +} diff --git a/pkg/cmd/worker/list/list.go b/pkg/cmd/worker/list/list.go index 7ef40f03..9f78c10f 100644 --- a/pkg/cmd/worker/list/list.go +++ b/pkg/cmd/worker/list/list.go @@ -12,6 +12,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" "github.com/spf13/cobra" + "strconv" ) type ListOptions struct { @@ -55,6 +56,7 @@ func ListRun(opts *ListOptions) error { Type string `json:"Type"` HealthStatus string `json:"HealthStatus"` StatusSummary string `json:"StatusSummary"` + IsDisabled bool `json:"IsDisabled"` WorkerPools []model.Entity `json:"WorkerPools"` URI string `json:"URI"` Version string `json:"Version,omitempty"` @@ -77,6 +79,7 @@ func ListRun(opts *ListOptions) error { Type: describeWorkerType(item.Endpoint), HealthStatus: item.HealthStatus, StatusSummary: item.StatusSummary, + IsDisabled: item.IsDisabled, WorkerPools: resolveEntities(item.WorkerPoolIDs, workerPoolMap), URI: getEndpointUri(item.Endpoint), Version: getVersion(item.Endpoint), @@ -86,10 +89,10 @@ func ListRun(opts *ListOptions) error { } }, Table: output.TableDefinition[*machines.Worker]{ - Header: []string{"NAME", "TYPE", "WORKER POOLS"}, + Header: []string{"NAME", "TYPE", "IS DISABLED", "WORKER POOLS"}, Row: func(item *machines.Worker) []string { poolNames := resolveValues(item.WorkerPoolIDs, workerPoolMap) - return []string{output.Bold(item.Name), describeWorkerStyle(item.Endpoint), output.FormatAsList(poolNames)} + return []string{output.Bold(item.Name), describeWorkerStyle(item.Endpoint), strconv.FormatBool(item.IsDisabled), output.FormatAsList(poolNames)} }, }, Basic: func(item *machines.Worker) string { diff --git a/pkg/cmd/worker/list/list_disabled_test.go b/pkg/cmd/worker/list/list_disabled_test.go new file mode 100644 index 00000000..e3fad28b --- /dev/null +++ b/pkg/cmd/worker/list/list_disabled_test.go @@ -0,0 +1,108 @@ +package list_test + +import ( + "bytes" + "net/url" + "testing" + + 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" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + octopusConstants "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// the shared root resource has no infrastructure links; the worker commands need them +var rootResource = newRootResourceWithInfrastructureLinks() + +func newRootResourceWithInfrastructureLinks() *octopusApiClient.RootResource { + root := testutil.NewRootResource() + root.Links[octopusConstants.LinkWorkers] = octopusConstants.TestURIWorkers + root.Links[octopusConstants.LinkWorkerPools] = octopusConstants.TestURIWorkerPools + return root +} + +const spaceID = "Spaces-1" + +func newWorker(workerID string, name string, isDisabled bool) *machines.Worker { + endpoint := machines.NewListeningTentacleEndpoint( + &url.URL{Scheme: "https", Host: "worker:10933"}, "0123456789ABCDEF0123456789ABCDEF01234567") + // `worker list` reads the version unconditionally in its json mapper + endpoint.TentacleVersionDetails = machines.NewTentacleVersionDetails("8.1.0", false, false, false) + + worker := machines.NewWorker(name, endpoint) + worker.ID = workerID + worker.SpaceID = spaceID + worker.IsDisabled = isDisabled + return worker +} + +func TestWorkerListShowsDisabledState(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) + }{ + {"table output has an IS DISABLED column", 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{"worker", "list", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + respondWithWorkers(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "IS DISABLED") + assert.Regexp(t, `build-worker.*false`, stdOut.String()) + assert.Regexp(t, `test-worker.*true`, stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"json output carries IsDisabled", 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{"worker", "list", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondWithWorkers(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), `"IsDisabled": false`) + assert.Contains(t, stdOut.String(), `"IsDisabled": true`) + 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) + }) + } +} + +func respondWithWorkers(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workers/all").RespondWith([]*machines.Worker{ + newWorker("Workers-100", "build-worker", false), + newWorker("Workers-200", "test-worker", true), + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) +} diff --git a/pkg/cmd/worker/shared/view.go b/pkg/cmd/worker/shared/view.go index acb62e86..783fde00 100644 --- a/pkg/cmd/worker/shared/view.go +++ b/pkg/cmd/worker/shared/view.go @@ -7,6 +7,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" "github.com/spf13/cobra" + "strconv" ) type ContributeEndpointCallback func(opts *ViewOptions, endpoint machines.IEndpoint) ([]*output.DataRow, error) @@ -19,7 +20,7 @@ type ViewOptions struct { *cmd.Dependencies IdOrName string *ViewFlags - Command *cobra.Command + Command *cobra.Command } func NewViewFlags() *ViewFlags { @@ -48,6 +49,7 @@ func ViewRun(opts *ViewOptions, contributeEndpoint ContributeEndpointCallback, d data = append(data, output.NewDataRow("Name", fmt.Sprintf("%s %s", output.Bold(worker.Name), output.Dimf("(%s)", worker.GetID())))) data = append(data, output.NewDataRow("Health status", getHealthStatus(worker))) data = append(data, output.NewDataRow("Current status", worker.StatusSummary)) + data = append(data, output.NewDataRow("Disabled", strconv.FormatBool(worker.IsDisabled))) workerPoolMap, err := GetWorkerPoolMap(opts) workerPoolNames := resolveValues(worker.WorkerPoolIDs, workerPoolMap) diff --git a/pkg/cmd/worker/shared/view_test.go b/pkg/cmd/worker/shared/view_test.go new file mode 100644 index 00000000..9d8e576a --- /dev/null +++ b/pkg/cmd/worker/shared/view_test.go @@ -0,0 +1,76 @@ +package shared_test + +import ( + "bytes" + "net/url" + "testing" + + 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" + octopusConstants "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// the per-type worker views all render through shared.ViewRun, so one of them is +// enough to cover the Disabled row it adds +func TestPerTypeWorkerViewShowsDisabledState(t *testing.T) { + const spaceID = "Spaces-1" + space1 := fixtures.NewSpace(spaceID, "Default Space") + + root := testutil.NewRootResource() + root.Links[octopusConstants.LinkWorkers] = octopusConstants.TestURIWorkers + root.Links[octopusConstants.LinkWorkerPools] = octopusConstants.TestURIWorkerPools + + for _, tc := range []struct { + name string + isDisabled bool + expected string + }{ + {"disabled worker", true, "true"}, + {"enabled worker", false, "false"}, + } { + t.Run(tc.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) + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"worker", "listening-tentacle", "view", "Workers-100", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(root) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(root) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workers/Workers-100"). + RespondWith(newTestWorker(spaceID, "Workers-100", "build-worker", tc.isDisabled)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Regexp(t, `Disabled\s+`+tc.expected, stdout.String()) + assert.Equal(t, "", stderr.String()) + }) + } +} + +func newTestWorker(spaceID string, workerID string, name string, isDisabled bool) *machines.Worker { + endpoint := machines.NewListeningTentacleEndpoint( + &url.URL{Scheme: "https", Host: "worker:10933"}, "0123456789ABCDEF0123456789ABCDEF01234567") + endpoint.TentacleVersionDetails = machines.NewTentacleVersionDetails("8.1.0", false, false, false) + + worker := machines.NewWorker(name, endpoint) + worker.ID = workerID + worker.SpaceID = spaceID + worker.IsDisabled = isDisabled + return worker +} diff --git a/pkg/cmd/worker/view/view.go b/pkg/cmd/worker/view/view.go index a1f425f0..2c5761ed 100644 --- a/pkg/cmd/worker/view/view.go +++ b/pkg/cmd/worker/view/view.go @@ -2,6 +2,7 @@ package view import ( "fmt" + "strconv" "strings" "github.com/MakeNowJust/heredoc/v2" @@ -49,7 +50,7 @@ func ViewRun(opts *shared.ViewOptions) error { return getWorkerAsJson(opts, w) }, Table: output.TableDefinition[*machines.Worker]{ - Header: []string{"NAME", "TYPE", "HEALTH", "STATUS", "WORKER POOLS", "ENDPOINT DETAILS"}, + Header: []string{"NAME", "TYPE", "HEALTH", "STATUS", "IS DISABLED", "WORKER POOLS", "ENDPOINT DETAILS"}, Row: func(w *machines.Worker) []string { return getWorkerAsTableRow(opts, w) }, @@ -74,6 +75,7 @@ type WorkerAsJson struct { Name string `json:"Name"` HealthStatus string `json:"HealthStatus"` StatusSummary string `json:"StatusSummary"` + IsDisabled bool `json:"IsDisabled"` CommunicationStyle string `json:"CommunicationStyle"` WorkerPools []string `json:"WorkerPools"` EndpointDetails map[string]string `json:"EndpointDetails"` @@ -91,6 +93,7 @@ func getWorkerAsJson(opts *shared.ViewOptions, worker *machines.Worker) WorkerAs Name: worker.Name, HealthStatus: worker.HealthStatus, StatusSummary: worker.StatusSummary, + IsDisabled: worker.IsDisabled, CommunicationStyle: machinescommon.GetCommunicationStyle(worker.Endpoint), WorkerPools: workerPoolNames, EndpointDetails: endpointDetails, @@ -110,6 +113,7 @@ func getWorkerAsTableRow(opts *shared.ViewOptions, worker *machines.Worker) []st getWorkerTypeDisplayName(machinescommon.GetCommunicationStyle(worker.Endpoint)), getHealthStatusFormatted(worker.HealthStatus), worker.StatusSummary, + strconv.FormatBool(worker.IsDisabled), strings.Join(workerPoolNames, ", "), endpointDetailsStr, } @@ -121,6 +125,7 @@ func getWorkerAsBasic(opts *shared.ViewOptions, worker *machines.Worker) string result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(worker.Name), output.Dimf("(%s)", worker.GetID()))) result.WriteString(fmt.Sprintf("Health status: %s\n", getHealthStatusFormatted(worker.HealthStatus))) result.WriteString(fmt.Sprintf("Current status: %s\n", worker.StatusSummary)) + result.WriteString(fmt.Sprintf("Disabled: %s\n", strconv.FormatBool(worker.IsDisabled))) result.WriteString(fmt.Sprintf("Communication style: %s\n", getWorkerTypeDisplayName(machinescommon.GetCommunicationStyle(worker.Endpoint)))) workerPoolMap, _ := shared.GetWorkerPoolMap(opts) diff --git a/pkg/cmd/worker/view/view_disabled_test.go b/pkg/cmd/worker/view/view_disabled_test.go new file mode 100644 index 00000000..fc84010b --- /dev/null +++ b/pkg/cmd/worker/view/view_disabled_test.go @@ -0,0 +1,133 @@ +package view_test + +import ( + "bytes" + "net/url" + "testing" + + 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" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + octopusConstants "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// the shared root resource has no infrastructure links; the worker commands need them +var rootResource = newRootResourceWithInfrastructureLinks() + +func newRootResourceWithInfrastructureLinks() *octopusApiClient.RootResource { + root := testutil.NewRootResource() + root.Links[octopusConstants.LinkWorkers] = octopusConstants.TestURIWorkers + root.Links[octopusConstants.LinkWorkerPools] = octopusConstants.TestURIWorkerPools + return root +} + +const spaceID = "Spaces-1" + +func newWorker(workerID string, name string, isDisabled bool) *machines.Worker { + endpoint := machines.NewListeningTentacleEndpoint( + &url.URL{Scheme: "https", Host: "worker:10933"}, "0123456789ABCDEF0123456789ABCDEF01234567") + endpoint.TentacleVersionDetails = machines.NewTentacleVersionDetails("8.1.0", false, false, false) + + worker := machines.NewWorker(name, endpoint) + worker.ID = workerID + worker.SpaceID = spaceID + worker.IsDisabled = isDisabled + return worker +} + +func TestWorkerViewShowsDisabledState(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) + }{ + {"basic output reports a disabled worker", 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{"worker", "view", "Workers-100", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + respondWithWorker(t, api, true) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Disabled: true") + assert.Equal(t, "", stdErr.String()) + }}, + + {"basic output reports an enabled worker", 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{"worker", "view", "Workers-100", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + respondWithWorker(t, api, false) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Disabled: false") + assert.Equal(t, "", stdErr.String()) + }}, + + {"json output carries IsDisabled", 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{"worker", "view", "Workers-100", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondWithWorker(t, api, true) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), `"IsDisabled": true`) + assert.Equal(t, "", stdErr.String()) + }}, + + {"table output has an IS DISABLED column", 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{"worker", "view", "Workers-100", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + respondWithWorker(t, api, true) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "IS DISABLED") + assert.Regexp(t, `build-worker.*true`, stdOut.String()) + 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) + }) + } +} + +func respondWithWorker(t *testing.T, api *testutil.MockHttpServer, isDisabled bool) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workers/Workers-100"). + RespondWith(newWorker("Workers-100", "build-worker", isDisabled)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{}) +}