Skip to content

Commit fc852af

Browse files
NickJosevskiclaude
andcommitted
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) <noreply@anthropic.com>
1 parent db73ca7 commit fc852af

13 files changed

Lines changed: 629 additions & 7 deletions

File tree

‎pkg/cmd/target/list/list.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/OctopusDeploy/cli/pkg/output"
1111
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines"
1212
"github.com/spf13/cobra"
13+
"strconv"
1314
)
1415

1516
type ListOptions struct {
@@ -75,12 +76,12 @@ func ListRun(opts *ListOptions) error {
7576
return shared.GetDeploymentTargetAsJson(opts.Dependencies, item)
7677
},
7778
Table: output.TableDefinition[*machines.DeploymentTarget]{
78-
Header: []string{"NAME", "TYPE", "ROLES", "ENVIRONMENTS", "TENANTS", "TAGS", "DEFAULT WORKER POOL"},
79+
Header: []string{"NAME", "TYPE", "IS DISABLED", "ROLES", "ENVIRONMENTS", "TENANTS", "TAGS", "DEFAULT WORKER POOL"},
7980
Row: func(item *machines.DeploymentTarget) []string {
8081
environmentNames := resolveValues(item.EnvironmentIDs, environmentMap)
8182
tenantNames := resolveValues(item.TenantIDs, tenantMap)
8283
workerPool := shared.ResolveDefaultWorkerPool(item, workerPoolMap, "None")
83-
return []string{output.Bold(item.Name), describeTargetType(item), output.FormatAsList(item.Roles), output.FormatAsList(environmentNames), output.FormatAsList(tenantNames), output.FormatAsList(item.TenantTags), workerPool}
84+
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}
8485
},
8586
},
8687
Basic: func(item *machines.DeploymentTarget) string {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package list_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
8+
"github.com/OctopusDeploy/cli/pkg/question"
9+
"github.com/OctopusDeploy/cli/test/fixtures"
10+
"github.com/OctopusDeploy/cli/test/testutil"
11+
octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
12+
octopusConstants "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants"
13+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments"
14+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines"
15+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources"
16+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants"
17+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools"
18+
"github.com/spf13/cobra"
19+
"github.com/stretchr/testify/assert"
20+
)
21+
22+
// the shared root resource has no worker pool link; the target commands need it
23+
var rootResource = newRootResourceWithWorkerPools()
24+
25+
func newRootResourceWithWorkerPools() *octopusApiClient.RootResource {
26+
root := testutil.NewRootResource()
27+
root.Links[octopusConstants.LinkWorkerPools] = octopusConstants.TestURIWorkerPools
28+
return root
29+
}
30+
31+
const spaceID = "Spaces-1"
32+
33+
func TestDeploymentTargetListShowsDisabledState(t *testing.T) {
34+
space1 := fixtures.NewSpace(spaceID, "Default Space")
35+
36+
tests := []struct {
37+
name string
38+
run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
39+
}{
40+
{"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) {
41+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
42+
defer api.Close()
43+
rootCmd.SetArgs([]string{"deployment-target", "list", "--no-prompt", "-f", "table"})
44+
return rootCmd.ExecuteC()
45+
})
46+
47+
respondWithTargets(t, api)
48+
49+
_, err := testutil.ReceivePair(cmdReceiver)
50+
assert.Nil(t, err)
51+
assert.Contains(t, stdOut.String(), "IS DISABLED")
52+
assert.Regexp(t, `web-server.*false`, stdOut.String())
53+
assert.Regexp(t, `db-server.*true`, stdOut.String())
54+
assert.Equal(t, "", stdErr.String())
55+
}},
56+
57+
{"json output carries IsDisabled", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
58+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
59+
defer api.Close()
60+
rootCmd.SetArgs([]string{"deployment-target", "list", "--no-prompt", "-f", "json"})
61+
return rootCmd.ExecuteC()
62+
})
63+
64+
respondWithTargets(t, api)
65+
// the json mapper re-resolves the lookups per target
66+
for range 2 {
67+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
68+
api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{})
69+
}
70+
71+
_, err := testutil.ReceivePair(cmdReceiver)
72+
assert.Nil(t, err)
73+
assert.Contains(t, stdOut.String(), `"IsDisabled": false`)
74+
assert.Contains(t, stdOut.String(), `"IsDisabled": true`)
75+
assert.Equal(t, "", stdErr.String())
76+
}},
77+
}
78+
79+
for _, test := range tests {
80+
t.Run(test.name, func(t *testing.T) {
81+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
82+
api, qa := testutil.NewMockServerAndAsker()
83+
askProvider := question.NewAskProvider(qa.AsAsker())
84+
fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider)
85+
rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider)
86+
rootCmd.SetOut(stdout)
87+
rootCmd.SetErr(stderr)
88+
test.run(t, api, qa, rootCmd, stdout, stderr)
89+
})
90+
}
91+
}
92+
93+
func respondWithTargets(t *testing.T, api *testutil.MockHttpServer) {
94+
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
95+
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
96+
api.ExpectRequest(t, "GET", "/api/Spaces-1/machines?take=2147483647").
97+
RespondWith(resources.Resources[*machines.DeploymentTarget]{Items: []*machines.DeploymentTarget{
98+
fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", false),
99+
fixtures.NewDeploymentTarget(spaceID, "Machines-200", "db-server", true),
100+
}})
101+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
102+
api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{})
103+
api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{})
104+
}
105+
106+
var development = fixtures.NewEnvironment(spaceID, "Environments-1", "Development")

‎pkg/cmd/target/shared/json.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type DeploymentTargetAsJson struct {
1414
Name string `json:"Name"`
1515
HealthStatus string `json:"HealthStatus"`
1616
StatusSummary string `json:"StatusSummary"`
17+
IsDisabled bool `json:"IsDisabled"`
1718
CommunicationStyle string `json:"CommunicationStyle"`
1819
Environments []string `json:"Environments"`
1920
Roles []string `json:"Roles"`
@@ -42,6 +43,7 @@ func GetDeploymentTargetAsJson(deps *cmd.Dependencies, target *machines.Deployme
4243
Name: target.Name,
4344
HealthStatus: target.HealthStatus,
4445
StatusSummary: target.StatusSummary,
46+
IsDisabled: target.IsDisabled,
4547
CommunicationStyle: machinescommon.GetCommunicationStyle(target.Endpoint),
4648
Environments: environments,
4749
Roles: target.Roles,

‎pkg/cmd/target/shared/view.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package shared
22

33
import (
44
"fmt"
5+
"strconv"
56

67
"github.com/OctopusDeploy/cli/pkg/cmd"
78
"github.com/OctopusDeploy/cli/pkg/machinescommon"
@@ -51,6 +52,7 @@ func ViewRun(opts *ViewOptions, contributeEndpoint ContributeEndpointCallback, d
5152
data = append(data, output.NewDataRow("Name", fmt.Sprintf("%s %s", output.Bold(target.Name), output.Dimf("(%s)", target.GetID()))))
5253
data = append(data, output.NewDataRow("Health status", getHealthStatus(target)))
5354
data = append(data, output.NewDataRow("Current status", target.StatusSummary))
55+
data = append(data, output.NewDataRow("Disabled", strconv.FormatBool(target.IsDisabled)))
5456

5557
if contributeEndpoint != nil {
5658
if machines.IsNil(target.Endpoint) {

‎pkg/cmd/target/shared/view_test.go‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package shared_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
8+
"github.com/OctopusDeploy/cli/pkg/question"
9+
"github.com/OctopusDeploy/cli/test/fixtures"
10+
"github.com/OctopusDeploy/cli/test/testutil"
11+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments"
12+
"github.com/spf13/cobra"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
var viewRootResource = testutil.NewRootResource()
17+
18+
// the per-type views all render through shared.ViewRun, so one of them is enough
19+
// to cover the Disabled row it adds
20+
func TestPerTypeViewShowsDisabledState(t *testing.T) {
21+
const spaceID = "Spaces-1"
22+
space1 := fixtures.NewSpace(spaceID, "Default Space")
23+
development := fixtures.NewEnvironment(spaceID, "Environments-1", "Development")
24+
25+
for _, tc := range []struct {
26+
name string
27+
isDisabled bool
28+
expected string
29+
}{
30+
{"disabled target", true, "true"},
31+
{"enabled target", false, "false"},
32+
} {
33+
t.Run(tc.name, func(t *testing.T) {
34+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
35+
api, qa := testutil.NewMockServerAndAsker()
36+
askProvider := question.NewAskProvider(qa.AsAsker())
37+
fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider)
38+
rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider)
39+
rootCmd.SetOut(stdout)
40+
rootCmd.SetErr(stderr)
41+
42+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
43+
defer api.Close()
44+
rootCmd.SetArgs([]string{"deployment-target", "cloud-region", "view", "Machines-100", "--no-prompt"})
45+
return rootCmd.ExecuteC()
46+
})
47+
48+
api.ExpectRequest(t, "GET", "/api/").RespondWith(viewRootResource)
49+
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(viewRootResource)
50+
api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-100").
51+
RespondWith(fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", tc.isDisabled))
52+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
53+
54+
_, err := testutil.ReceivePair(cmdReceiver)
55+
assert.Nil(t, err)
56+
assert.Regexp(t, `Disabled\s+`+tc.expected, stdout.String())
57+
assert.Equal(t, "", stderr.String())
58+
})
59+
}
60+
}

‎pkg/cmd/target/view/view.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package view
22

33
import (
44
"fmt"
5+
"strconv"
56
"strings"
67

78
"github.com/MakeNowJust/heredoc/v2"
@@ -53,7 +54,7 @@ func ViewRun(opts *shared.ViewOptions) error {
5354
return getDeploymentTargetAsJson(opts.Dependencies, t, environmentMap, tenantMap, workerPoolMap)
5455
},
5556
Table: output.TableDefinition[*machines.DeploymentTarget]{
56-
Header: []string{"NAME", "TYPE", "HEALTH", "ENVIRONMENTS", "ROLES", "TENANTS", "TENANT TAGS", "ENDPOINT DETAILS", "DEFAULT WORKER POOL"},
57+
Header: []string{"NAME", "TYPE", "HEALTH", "IS DISABLED", "ENVIRONMENTS", "ROLES", "TENANTS", "TENANT TAGS", "ENDPOINT DETAILS", "DEFAULT WORKER POOL"},
5758
Row: func(t *machines.DeploymentTarget) []string {
5859
return getDeploymentTargetAsTableRow(opts, t, environmentMap, tenantMap, workerPoolMap)
5960
},
@@ -75,6 +76,7 @@ func getDeploymentTargetAsJson(deps *cmd.Dependencies, target *machines.Deployme
7576
Name: target.Name,
7677
HealthStatus: target.HealthStatus,
7778
StatusSummary: target.StatusSummary,
79+
IsDisabled: target.IsDisabled,
7880
CommunicationStyle: machinescommon.GetCommunicationStyle(target.Endpoint),
7981
Environments: environments,
8082
Roles: target.Roles,
@@ -134,6 +136,7 @@ func getDeploymentTargetAsTableRow(opts *shared.ViewOptions, target *machines.De
134136
output.Bold(target.Name),
135137
targetType,
136138
healthStatus,
139+
strconv.FormatBool(target.IsDisabled),
137140
strings.Join(environments, ", "),
138141
strings.Join(target.Roles, ", "),
139142
tenants,
@@ -198,6 +201,9 @@ func getDeploymentTargetAsBasic(opts *shared.ViewOptions, target *machines.Deplo
198201
// Current status
199202
result.WriteString(fmt.Sprintf("Current status: %s\n", target.StatusSummary))
200203

204+
// Disabled state
205+
result.WriteString(fmt.Sprintf("Disabled: %s\n", strconv.FormatBool(target.IsDisabled)))
206+
201207
// Target type and endpoint details
202208
targetType := getTargetTypeDisplayName(machinescommon.GetCommunicationStyle(target.Endpoint))
203209
result.WriteString(fmt.Sprintf("Type: %s\n", output.Cyan(targetType)))
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package view_test
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root"
8+
"github.com/OctopusDeploy/cli/pkg/question"
9+
"github.com/OctopusDeploy/cli/test/fixtures"
10+
"github.com/OctopusDeploy/cli/test/testutil"
11+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments"
12+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants"
13+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools"
14+
"github.com/spf13/cobra"
15+
"github.com/stretchr/testify/assert"
16+
)
17+
18+
const spaceID = "Spaces-1"
19+
20+
var development = fixtures.NewEnvironment(spaceID, "Environments-1", "Development")
21+
22+
func TestDeploymentTargetViewShowsDisabledState(t *testing.T) {
23+
space1 := fixtures.NewSpace(spaceID, "Default Space")
24+
25+
tests := []struct {
26+
name string
27+
run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer)
28+
}{
29+
{"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) {
30+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
31+
defer api.Close()
32+
rootCmd.SetArgs([]string{"deployment-target", "view", "Machines-100", "--no-prompt", "-f", "basic"})
33+
return rootCmd.ExecuteC()
34+
})
35+
36+
respondWithTarget(t, api, true)
37+
// the basic renderer re-resolves the lookups it needs
38+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
39+
api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{})
40+
41+
_, err := testutil.ReceivePair(cmdReceiver)
42+
assert.Nil(t, err)
43+
assert.Contains(t, stdOut.String(), "Disabled: true")
44+
assert.Equal(t, "", stdErr.String())
45+
}},
46+
47+
{"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) {
48+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
49+
defer api.Close()
50+
rootCmd.SetArgs([]string{"deployment-target", "view", "Machines-100", "--no-prompt", "-f", "basic"})
51+
return rootCmd.ExecuteC()
52+
})
53+
54+
respondWithTarget(t, api, false)
55+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
56+
api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{})
57+
58+
_, err := testutil.ReceivePair(cmdReceiver)
59+
assert.Nil(t, err)
60+
assert.Contains(t, stdOut.String(), "Disabled: false")
61+
assert.Equal(t, "", stdErr.String())
62+
}},
63+
64+
{"json output carries IsDisabled", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) {
65+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
66+
defer api.Close()
67+
rootCmd.SetArgs([]string{"deployment-target", "view", "Machines-100", "--no-prompt", "-f", "json"})
68+
return rootCmd.ExecuteC()
69+
})
70+
71+
respondWithTarget(t, api, true)
72+
73+
_, err := testutil.ReceivePair(cmdReceiver)
74+
assert.Nil(t, err)
75+
assert.Contains(t, stdOut.String(), `"IsDisabled": true`)
76+
assert.Equal(t, "", stdErr.String())
77+
}},
78+
79+
{"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) {
80+
cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) {
81+
defer api.Close()
82+
rootCmd.SetArgs([]string{"deployment-target", "view", "Machines-100", "--no-prompt", "-f", "table"})
83+
return rootCmd.ExecuteC()
84+
})
85+
86+
respondWithTarget(t, api, true)
87+
88+
_, err := testutil.ReceivePair(cmdReceiver)
89+
assert.Nil(t, err)
90+
assert.Contains(t, stdOut.String(), "IS DISABLED")
91+
assert.Regexp(t, `web-server.*true`, stdOut.String())
92+
assert.Equal(t, "", stdErr.String())
93+
}},
94+
}
95+
96+
for _, test := range tests {
97+
t.Run(test.name, func(t *testing.T) {
98+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
99+
api, qa := testutil.NewMockServerAndAsker()
100+
askProvider := question.NewAskProvider(qa.AsAsker())
101+
fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider)
102+
rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider)
103+
rootCmd.SetOut(stdout)
104+
rootCmd.SetErr(stderr)
105+
test.run(t, api, qa, rootCmd, stdout, stderr)
106+
})
107+
}
108+
}
109+
110+
func respondWithTarget(t *testing.T, api *testutil.MockHttpServer, isDisabled bool) {
111+
api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource)
112+
api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource)
113+
api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-100").
114+
RespondWith(fixtures.NewDeploymentTarget(spaceID, "Machines-100", "web-server", isDisabled))
115+
api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{development})
116+
api.ExpectRequest(t, "GET", "/api/Spaces-1/workerpools/all").RespondWith([]*workerpools.WorkerPoolListResult{})
117+
api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/all").RespondWith([]*tenants.Tenant{})
118+
}

0 commit comments

Comments
 (0)