From 0fe7c8326ab4c0abce1cec2cc0fb700a03352db3 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Thu, 16 Apr 2026 14:26:35 +1000 Subject: [PATCH 1/7] Kubernetes live object status support --- pkg/cmd/kubernetes/kubernetes.go | 23 ++ pkg/cmd/kubernetes/live-status/live-status.go | 298 ++++++++++++++++++ .../live-status/live-status_test.go | 246 +++++++++++++++ pkg/cmd/root/root.go | 4 + 4 files changed, 571 insertions(+) create mode 100644 pkg/cmd/kubernetes/kubernetes.go create mode 100644 pkg/cmd/kubernetes/live-status/live-status.go create mode 100644 pkg/cmd/kubernetes/live-status/live-status_test.go diff --git a/pkg/cmd/kubernetes/kubernetes.go b/pkg/cmd/kubernetes/kubernetes.go new file mode 100644 index 00000000..0a396cd9 --- /dev/null +++ b/pkg/cmd/kubernetes/kubernetes.go @@ -0,0 +1,23 @@ +package kubernetes + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdLiveStatus "github.com/OctopusDeploy/cli/pkg/cmd/kubernetes/live-status" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdKubernetes(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubernetes ", + Short: "Kubernetes observability commands", + Long: "Commands for observing Kubernetes resources deployed via Octopus Deploy", + Example: heredoc.Docf("$ %s kubernetes live-status --project MyProject --environment Production", constants.ExecutableName), + Aliases: []string{"k8s"}, + } + + cmd.AddCommand(cmdLiveStatus.NewCmdLiveStatus(f)) + + return cmd +} diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go new file mode 100644 index 00000000..2819f7c6 --- /dev/null +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -0,0 +1,298 @@ +package livestatus + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagEnvironment = "environment" + FlagTenant = "tenant" + FlagSummaryOnly = "summary-only" +) + +type LiveStatusFlags struct { + Project *flag.Flag[string] + Environment *flag.Flag[string] + Tenant *flag.Flag[string] + SummaryOnly *flag.Flag[bool] +} + +func NewLiveStatusFlags() *LiveStatusFlags { + return &LiveStatusFlags{ + Project: flag.New[string](FlagProject, false), + Environment: flag.New[string](FlagEnvironment, false), + Tenant: flag.New[string](FlagTenant, false), + SummaryOnly: flag.New[bool](FlagSummaryOnly, false), + } +} + +// API response types + +type LiveStatusResponse struct { + MachineStatuses []MachineStatus `json:"MachineStatuses"` + Summary StatusSummary `json:"Summary"` +} + +type MachineStatus struct { + MachineId string `json:"MachineId"` + Status string `json:"Status"` + Resources []KubernetesLiveStatusResource `json:"Resources"` +} + +type KubernetesLiveStatusResource struct { + Name string `json:"Name"` + Namespace string `json:"Namespace,omitempty"` + Kind string `json:"Kind"` + Group string `json:"Group"` + HealthStatus string `json:"HealthStatus"` + SyncStatus string `json:"SyncStatus,omitempty"` + HealthStatusMessage string `json:"HealthStatusMessage,omitempty"` + SyncStatusMessage string `json:"SyncStatusMessage,omitempty"` + ResourceSourceId string `json:"ResourceSourceId"` + SourceType string `json:"SourceType"` + Children []KubernetesLiveStatusResource `json:"Children"` + LastUpdated string `json:"LastUpdated"` +} + +type StatusSummary struct { + Status string `json:"Status"` + HealthStatus string `json:"HealthStatus"` + SyncStatus string `json:"SyncStatus"` + LastUpdated string `json:"LastUpdated"` +} + +// FlatResource is a flattened representation of a resource in the tree, used for table output. +type FlatResource struct { + Depth int + Resource KubernetesLiveStatusResource +} + +func NewCmdLiveStatus(f factory.Factory) *cobra.Command { + flags := NewLiveStatusFlags() + + cmd := &cobra.Command{ + Use: "live-status", + Short: "Get Kubernetes live object status", + Long: "Get the live status of Kubernetes resources for a project and environment in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s kubernetes live-status --project MyProject --environment Production + $ %[1]s kubernetes live-status --project MyProject --environment Production --tenant MyTenant + $ %[1]s kubernetes live-status --project MyProject --environment Production --summary-only + $ %[1]s kubernetes live-status --project MyProject --environment Production -f json + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + return liveStatusRun(cmd, f, flags) + }, + } + + cmdFlags := cmd.Flags() + cmdFlags.StringVarP(&flags.Project.Value, flags.Project.Name, "p", "", "Name or ID of the project") + cmdFlags.StringVarP(&flags.Environment.Value, flags.Environment.Name, "e", "", "Name or ID of the environment") + cmdFlags.StringVarP(&flags.Tenant.Value, flags.Tenant.Name, "t", "", "Name or ID of the tenant (for tenanted deployments)") + cmdFlags.BoolVar(&flags.SummaryOnly.Value, flags.SummaryOnly.Name, false, "Return summary status only") + + return cmd +} + +func liveStatusRun(cmd *cobra.Command, f factory.Factory, flags *LiveStatusFlags) error { + client, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + // Resolve project + projectId := flags.Project.Value + if projectId == "" { + if !f.IsPromptEnabled() { + return errors.New("project must be specified; use --project flag or run in interactive mode") + } + selectedProject, err := selectors.Project("Select a project", client, f.Ask) + if err != nil { + return err + } + projectId = selectedProject.GetID() + } else { + resolvedProject, err := selectors.FindProject(client, projectId) + if err != nil { + return err + } + projectId = resolvedProject.GetID() + } + + // Resolve environment + environmentId := flags.Environment.Value + if environmentId == "" { + if !f.IsPromptEnabled() { + return errors.New("environment must be specified; use --environment flag or run in interactive mode") + } + selectedEnvironment, err := selectors.EnvironmentSelect(f.Ask, func() ([]*environments.Environment, error) { + return selectors.GetAllEnvironments(client) + }, "Select an environment") + if err != nil { + return err + } + environmentId = selectedEnvironment.GetID() + } else { + resolvedEnvironment, err := selectors.FindEnvironment(client, environmentId) + if err != nil { + return err + } + environmentId = resolvedEnvironment.GetID() + } + + // Resolve tenant (optional) + var tenantId string + if flags.Tenant.Value != "" { + resolvedTenant, err := client.Tenants.GetByIdentifier(flags.Tenant.Value) + if err != nil { + return fmt.Errorf("failed to resolve tenant: %w", err) + } + tenantId = resolvedTenant.GetID() + } + + // Build API URL + spaceId := client.GetSpaceID() + var apiPath string + if tenantId != "" { + apiPath = fmt.Sprintf("/api/%s/projects/%s/environments/%s/tenants/%s/livestatus", spaceId, projectId, environmentId, tenantId) + } else { + apiPath = fmt.Sprintf("/api/%s/projects/%s/environments/%s/untenanted/livestatus", spaceId, projectId, environmentId) + } + if flags.SummaryOnly.Value { + apiPath += "?summaryOnly=true" + } + + // Make API request + req, err := http.NewRequest("GET", apiPath, nil) + if err != nil { + return err + } + + resp, err := client.HttpSession().DoRawRequest(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var response LiveStatusResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Format output + outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) + + if strings.EqualFold(outputFormat, constants.OutputFormatJson) { + data, err := json.MarshalIndent(response, "", " ") + if err != nil { + return err + } + cmd.Println(string(data)) + return nil + } + + if flags.SummaryOnly.Value { + return printSummary(cmd, &response.Summary) + } + + return printFullStatus(cmd, &response) +} + +func printSummary(cmd *cobra.Command, summary *StatusSummary) error { + rows := []*output.DataRow{ + output.NewDataRow("Status", summary.Status), + output.NewDataRow("Health Status", summary.HealthStatus), + output.NewDataRow("Sync Status", summary.SyncStatus), + output.NewDataRow("Last Updated", summary.LastUpdated), + } + output.PrintRows(rows, cmd.OutOrStdout()) + return nil +} + +func printFullStatus(cmd *cobra.Command, response *LiveStatusResponse) error { + var allFlat []FlatResource + for _, machine := range response.MachineStatuses { + allFlat = append(allFlat, flattenResources(machine.Resources, 0)...) + } + + if len(allFlat) == 0 { + cmd.Println("No Kubernetes resources found.") + return nil + } + + outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) + if strings.EqualFold(outputFormat, constants.OutputFormatBasic) { + for _, fr := range allFlat { + indent := strings.Repeat(" ", fr.Depth) + r := fr.Resource + syncInfo := "" + if r.SyncStatus != "" { + syncInfo = fmt.Sprintf(", Sync: %s", r.SyncStatus) + } + cmd.Printf("%s%s (%s) - Health: %s%s\n", indent, r.Name, r.Kind, r.HealthStatus, syncInfo) + } + return nil + } + + // Table format + return output.PrintArray(allFlat, cmd, output.Mappers[FlatResource]{ + Json: func(fr FlatResource) any { + return fr.Resource + }, + Table: output.TableDefinition[FlatResource]{ + Header: []string{"Name", "Kind", "Namespace", "Health", "Sync", "Last Updated"}, + Row: func(fr FlatResource) []string { + indent := strings.Repeat(" ", fr.Depth) + return []string{ + indent + fr.Resource.Name, + fr.Resource.Kind, + fr.Resource.Namespace, + fr.Resource.HealthStatus, + fr.Resource.SyncStatus, + fr.Resource.LastUpdated, + } + }, + }, + Basic: func(fr FlatResource) string { + indent := strings.Repeat(" ", fr.Depth) + r := fr.Resource + return fmt.Sprintf("%s%s (%s) - Health: %s", indent, r.Name, r.Kind, r.HealthStatus) + }, + }) +} + +func flattenResources(resources []KubernetesLiveStatusResource, depth int) []FlatResource { + var result []FlatResource + for _, r := range resources { + result = append(result, FlatResource{Depth: depth, Resource: r}) + if len(r.Children) > 0 { + result = append(result, flattenResources(r.Children, depth+1)...) + } + } + return result +} diff --git a/pkg/cmd/kubernetes/live-status/live-status_test.go b/pkg/cmd/kubernetes/live-status/live-status_test.go new file mode 100644 index 00000000..797f191d --- /dev/null +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -0,0 +1,246 @@ +package livestatus_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "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/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +const spaceID = "Spaces-1" + +func respondToSpaceScopedInit(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) +} + +func TestKubernetesLiveStatus(t *testing.T) { + space1 := fixtures.NewSpace(spaceID, "Default Space") + fireProject := fixtures.NewProject(spaceID, "Projects-22", "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + + liveStatusResponse := map[string]any{ + "MachineStatuses": []any{ + map[string]any{ + "MachineId": "Machines-1", + "Status": "Healthy", + "Resources": []any{ + map[string]any{ + "Name": "my-deployment", + "Namespace": "default", + "Kind": "Deployment", + "Group": "apps", + "HealthStatus": "Healthy", + "SyncStatus": "InSync", + "ResourceSourceId": "Machines-1", + "SourceType": "KubernetesMonitor", + "Children": []any{ + map[string]any{ + "Name": "my-deployment-abc123", + "Namespace": "default", + "Kind": "ReplicaSet", + "Group": "apps", + "HealthStatus": "Healthy", + "SyncStatus": "InSync", + "ResourceSourceId": "Machines-1", + "SourceType": "KubernetesMonitor", + "Children": []any{}, + "LastUpdated": "2026-01-15T10:30:00Z", + }, + }, + "LastUpdated": "2026-01-15T10:30:00Z", + }, + }, + }, + }, + "Summary": map[string]any{ + "Status": "Healthy", + "HealthStatus": "Healthy", + "SyncStatus": "InSync", + "LastUpdated": "2026-01-15T10:30:00Z", + }, + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"requires project in automation mode", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--no-prompt", "--environment", "Production"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "project must be specified; use --project flag or run in interactive mode") + }}, + + {"requires environment in automation mode", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--no-prompt", "--project", "Fire Project"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + // project lookup + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "environment must be specified; use --environment flag or run in interactive mode") + }}, + + {"makes untenanted request with correct URL", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + // project lookup + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + // environment lookup - returns empty results so FindEnvironment fails + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Production"). + RespondWith(map[string]any{ + "Items": []any{}, + "ItemsPerPage": 30, + "TotalResults": 0, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Production") + }}, + + {"makes untenanted request and returns JSON output", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + // project lookup by name -> found directly + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + // environment lookup + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Production"). + RespondWith(map[string]any{ + "Items": []any{ + map[string]any{ + "Id": "Environments-1", + "Name": "Production", + "Links": map[string]string{ + "Self": "/api/Spaces-1/environments/Environments-1", + }, + }, + }, + "ItemsPerPage": 30, + "TotalResults": 1, + }) + + // live status API call + req := api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus") + req.RespondWith(liveStatusResponse) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + out := stdOut.String() + assert.Contains(t, out, `"MachineStatuses"`) + assert.Contains(t, out, `"my-deployment"`) + assert.Contains(t, out, `"Healthy"`) + }}, + + {"summary-only adds query parameter", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--summary-only", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Production"). + RespondWith(map[string]any{ + "Items": []any{ + map[string]any{ + "Id": "Environments-1", + "Name": "Production", + "Links": map[string]string{ + "Self": "/api/Spaces-1/environments/Environments-1", + }, + }, + }, + "ItemsPerPage": 30, + "TotalResults": 1, + }) + + r, _ := api.ReceiveRequest() + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.String(), "/livestatus") + assert.Equal(t, "true", r.URL.Query().Get("summaryOnly")) + + responseBytes, _ := json.Marshal(liveStatusResponse) + api.Respond(&http.Response{ + StatusCode: 200, + Status: "200 OK", + Body: io.NopCloser(bytes.NewReader(responseBytes)), + ContentLength: int64(len(responseBytes)), + Header: make(http.Header), + }, nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + out := stdOut.String() + assert.Contains(t, out, "Healthy") + assert.Contains(t, out, "InSync") + }}, + + {"k8s alias works", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"k8s", "live-status", "--no-prompt", "--environment", "Production"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + // Should get the same error as when using "kubernetes" - proves the alias routes correctly + assert.EqualError(t, err, "project must be specified; use --project flag or run in interactive mode") + }}, + } + + 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, rootCmd, stdOut, stdErr) + }) + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 05106062..3a2ddfed 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -8,6 +8,7 @@ import ( channelCmd "github.com/OctopusDeploy/cli/pkg/cmd/channel" configCmd "github.com/OctopusDeploy/cli/pkg/cmd/config" environmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/environment" + kubernetesCmd "github.com/OctopusDeploy/cli/pkg/cmd/kubernetes" ephemeralEnvironmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment" loginCmd "github.com/OctopusDeploy/cli/pkg/cmd/login" logoutCmd "github.com/OctopusDeploy/cli/pkg/cmd/logout" @@ -79,6 +80,9 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro cmd.AddCommand(apiCmd.NewCmdAPI(f)) + // observability + cmd.AddCommand(kubernetesCmd.NewCmdKubernetes(f)) + // ----- Configuration ----- // commands are expected to print their own errors to avoid double-ups From 224a222833c2b9fb1f87a2ca4b28858bb8f32800 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Thu, 16 Apr 2026 14:49:06 +1000 Subject: [PATCH 2/7] docs: add kubernetes live-status examples and code structure entry Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 1 + examples.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 652ef304..b9338483 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ pkg/ cmd/ # contains sub-packages for each cobra command account/ # contains commands related to accounts environment/ # contains commands related to environments + kubernetes/ # contains commands related to Kubernetes observability (live status) ... # more commands constants/ # constant values to avoid duplicated strings, ints, etc errors/ # internal error objects diff --git a/examples.md b/examples.md index 78e9594e..d0b9b37c 100644 --- a/examples.md +++ b/examples.md @@ -122,6 +122,32 @@ octopus deployment-target ssh create \ Note: The `--role` flag continues to work for backwards compatibility but will be deprecated in favor of `--tag` once target tag sets are widely adopted. +# View Kubernetes live object status + +Check the live status of Kubernetes resources deployed to an environment: + +``` +octopus kubernetes live-status --project "K8s Smoke Test Demo" --environment Development --no-prompt +``` + +Get a summary of the overall health status: + +``` +octopus kubernetes live-status --project "K8s Smoke Test Demo" --environment Development --summary-only --no-prompt +``` + +For tenanted deployments: + +``` +octopus kubernetes live-status --project "K8s Smoke Test Demo" --environment Production --tenant "My Tenant" --no-prompt +``` + +The `k8s` alias can be used as a shorthand: + +``` +octopus k8s live-status --project "K8s Smoke Test Demo" --environment Development -f json --no-prompt +``` + # Bulk deleting releases by created date This example will delete all releases created before 2AM 6 Dec 2022 UTC From 8304eaa1c175951ad48bfbb0cdaf08b45f8a6588 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Thu, 16 Apr 2026 16:59:45 +1000 Subject: [PATCH 3/7] feat: show machine/gateway as top-level node in live-status output Each machine or Argo CD gateway is now always displayed as a top-level grouping node, with its resources indented underneath. This makes it clear which machine owns which resources when a project-environment pair has multiple deployment targets. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/cmd/kubernetes/live-status/live-status.go | 11 ++++- .../live-status/live-status_test.go | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go index 2819f7c6..46f0e530 100644 --- a/pkg/cmd/kubernetes/live-status/live-status.go +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -237,7 +237,16 @@ func printSummary(cmd *cobra.Command, summary *StatusSummary) error { func printFullStatus(cmd *cobra.Command, response *LiveStatusResponse) error { var allFlat []FlatResource for _, machine := range response.MachineStatuses { - allFlat = append(allFlat, flattenResources(machine.Resources, 0)...) + // Insert machine/gateway as a top-level grouping node + allFlat = append(allFlat, FlatResource{ + Depth: 0, + Resource: KubernetesLiveStatusResource{ + Name: machine.MachineId, + Kind: "Machine", + HealthStatus: machine.Status, + }, + }) + allFlat = append(allFlat, flattenResources(machine.Resources, 1)...) } if len(allFlat) == 0 { diff --git a/pkg/cmd/kubernetes/live-status/live-status_test.go b/pkg/cmd/kubernetes/live-status/live-status_test.go index 797f191d..fdf214c7 100644 --- a/pkg/cmd/kubernetes/live-status/live-status_test.go +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -169,6 +169,46 @@ func TestKubernetesLiveStatus(t *testing.T) { assert.Contains(t, out, `"Healthy"`) }}, + {"table output shows machine as top-level node", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Production"). + RespondWith(map[string]any{ + "Items": []any{ + map[string]any{ + "Id": "Environments-1", + "Name": "Production", + "Links": map[string]string{ + "Self": "/api/Spaces-1/environments/Environments-1", + }, + }, + }, + "ItemsPerPage": 30, + "TotalResults": 1, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus"). + RespondWith(liveStatusResponse) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + out := stdOut.String() + // Machine should appear as a top-level node + assert.Contains(t, out, "Machines-1") + assert.Contains(t, out, "Machine") + // Resources should be indented under the machine + assert.Contains(t, out, " my-deployment") + assert.Contains(t, out, " my-deployment-abc123") + }}, + {"summary-only adds query parameter", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() From e54add338d28ae324f9545fee067cbf87e843821 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 5 Aug 2026 17:12:01 +1000 Subject: [PATCH 4/7] fix: explain when live object status is unavailable A project and environment whose deployment process cannot report live object status returns Summary.Status "NotSupported" with no machine statuses, which read as "No Kubernetes resources found." That sends the user looking for a data problem, when the cause is the deployment process using script-based Kubernetes steps, or a target whose Kubernetes monitor is not enabled. Reproduced against a local Octopus with a Kubernetes agent: every script-step project reports NotSupported, a raw-YAML project reports Healthy with its resources. An empty result on a supported project still reports no resources found; the two are different situations. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/kubernetes/live-status/live-status.go | 23 +++++- .../live-status/live-status_test.go | 71 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go index 46f0e530..2a1bcfa1 100644 --- a/pkg/cmd/kubernetes/live-status/live-status.go +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -223,7 +223,22 @@ func liveStatusRun(cmd *cobra.Command, f factory.Factory, flags *LiveStatusFlags return printFullStatus(cmd, &response) } +// statusNotSupported is reported for a project and environment whose deployment +// process cannot produce live object status; script-based Kubernetes steps apply +// arbitrary commands, so the server has no desired resource set to compare +// against. It is also reported when no Kubernetes monitor is enabled. +const statusNotSupported = "NotSupported" + +const notSupportedMessage = "Live object status is not available for this project and environment. " + + "It requires a deployment made with a Kubernetes step that tracks the resources it applies, " + + "to a target whose Kubernetes monitor is enabled." + func printSummary(cmd *cobra.Command, summary *StatusSummary) error { + if summary.Status == statusNotSupported { + cmd.Println(notSupportedMessage) + return nil + } + rows := []*output.DataRow{ output.NewDataRow("Status", summary.Status), output.NewDataRow("Health Status", summary.HealthStatus), @@ -250,7 +265,13 @@ func printFullStatus(cmd *cobra.Command, response *LiveStatusResponse) error { } if len(allFlat) == 0 { - cmd.Println("No Kubernetes resources found.") + // An empty result and an unsupported one are different things, and the + // difference is what the user needs to act on. + if response.Summary.Status == statusNotSupported { + cmd.Println(notSupportedMessage) + } else { + cmd.Println("No Kubernetes resources found.") + } return nil } diff --git a/pkg/cmd/kubernetes/live-status/live-status_test.go b/pkg/cmd/kubernetes/live-status/live-status_test.go index fdf214c7..b7d3ff7d 100644 --- a/pkg/cmd/kubernetes/live-status/live-status_test.go +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -11,6 +11,7 @@ import ( "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/projects" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -24,6 +25,32 @@ func respondToSpaceScopedInit(t *testing.T, api *testutil.MockHttpServer) { api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) } +var notSupportedResponse = map[string]any{ + "MachineStatuses": []any{}, + "Summary": map[string]any{ + "Status": "NotSupported", + "HealthStatus": "NotSupported", + "SyncStatus": "NotApplicable", + "LastUpdated": "1970-01-01T00:00:00Z", + }, +} + +func respondToProjectAndEnvironment(t *testing.T, api *testutil.MockHttpServer, project *projects.Project) { + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(project) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments?partialName=Production"). + RespondWith(map[string]any{ + "Items": []any{ + map[string]any{ + "Id": "Environments-1", + "Name": "Production", + "Links": map[string]string{"Self": "/api/Spaces-1/environments/Environments-1"}, + }, + }, + "ItemsPerPage": 30, + "TotalResults": 1, + }) +} + func TestKubernetesLiveStatus(t *testing.T) { space1 := fixtures.NewSpace(spaceID, "Default Space") fireProject := fixtures.NewProject(spaceID, "Projects-22", "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") @@ -256,6 +283,50 @@ func TestKubernetesLiveStatus(t *testing.T) { assert.Contains(t, out, "InSync") }}, + {"table output explains an unsupported project rather than reporting no resources", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + respondToProjectAndEnvironment(t, api, fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus"). + RespondWith(notSupportedResponse) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + // "no resources" sends the user looking for a data problem; the actual + // cause is the deployment process or a disabled monitor + out := stdOut.String() + assert.Contains(t, out, "Live object status is not available") + assert.NotContains(t, out, "No Kubernetes resources found") + }}, + + {"summary-only explains an unsupported project", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--summary-only", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + respondToProjectAndEnvironment(t, api, fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus?summaryOnly=true"). + RespondWith(notSupportedResponse) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + out := stdOut.String() + assert.Contains(t, out, "Live object status is not available") + assert.NotContains(t, out, "NotSupported") + }}, + {"k8s alias works", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() From 2e7bc58778e7123108e2546c5937cd1d055c2f28 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 5 Aug 2026 17:12:46 +1000 Subject: [PATCH 5/7] fix: show the target name on the live-status machine row The grouping row printed the raw machine ID (Machines-21), where every other command resolves machine IDs to names. Look the name up, falling back to the ID if the lookup fails, since this is a display detail and should not fail the command. NewRootResource in the test fake had no Machines link, so machine lookups failed without issuing a request and could not be asserted on; added it. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/kubernetes/live-status/live-status.go | 28 +++++++++++++++++-- .../live-status/live-status_test.go | 9 ++++-- test/testutil/fakeoctopusserver.go | 1 + 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go index 2a1bcfa1..c0d45168 100644 --- a/pkg/cmd/kubernetes/live-status/live-status.go +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -15,6 +15,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/question/selectors" "github.com/OctopusDeploy/cli/pkg/util/flag" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" "github.com/spf13/cobra" ) @@ -220,7 +221,23 @@ func liveStatusRun(cmd *cobra.Command, f factory.Factory, flags *LiveStatusFlags return printSummary(cmd, &response.Summary) } - return printFullStatus(cmd, &response) + return printFullStatus(cmd, machineNames(client, &response), &response) +} + +// machineNames resolves the machine IDs in the response to target names, so the +// grouping row reads like the rest of the CLI. Lookup failures fall back to the +// ID rather than failing the command over a display detail. +func machineNames(client *octopusApiClient.Client, response *LiveStatusResponse) map[string]string { + names := make(map[string]string, len(response.MachineStatuses)) + for _, machineStatus := range response.MachineStatuses { + if machineStatus.MachineId == "" || names[machineStatus.MachineId] != "" { + continue + } + if machine, err := client.Machines.GetByID(machineStatus.MachineId); err == nil { + names[machineStatus.MachineId] = machine.Name + } + } + return names } // statusNotSupported is reported for a project and environment whose deployment @@ -249,14 +266,19 @@ func printSummary(cmd *cobra.Command, summary *StatusSummary) error { return nil } -func printFullStatus(cmd *cobra.Command, response *LiveStatusResponse) error { +func printFullStatus(cmd *cobra.Command, machineNames map[string]string, response *LiveStatusResponse) error { var allFlat []FlatResource for _, machine := range response.MachineStatuses { + name := machineNames[machine.MachineId] + if name == "" { + name = machine.MachineId + } + // Insert machine/gateway as a top-level grouping node allFlat = append(allFlat, FlatResource{ Depth: 0, Resource: KubernetesLiveStatusResource{ - Name: machine.MachineId, + Name: name, Kind: "Machine", HealthStatus: machine.Status, }, diff --git a/pkg/cmd/kubernetes/live-status/live-status_test.go b/pkg/cmd/kubernetes/live-status/live-status_test.go index b7d3ff7d..f6e5124d 100644 --- a/pkg/cmd/kubernetes/live-status/live-status_test.go +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -224,12 +224,17 @@ func TestKubernetesLiveStatus(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus"). RespondWith(liveStatusResponse) + // machine lookup, so the grouping row can show the target name + api.ExpectRequest(t, "GET", "/api/Spaces-1/machines/Machines-1"). + RespondWith(map[string]any{"Id": "Machines-1", "Name": "k8s-agent-1"}) + _, err := testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) out := stdOut.String() - // Machine should appear as a top-level node - assert.Contains(t, out, "Machines-1") + // Machine should appear as a top-level node, named as it is elsewhere in the CLI + assert.Contains(t, out, "k8s-agent-1") + assert.NotContains(t, out, "Machines-1") assert.Contains(t, out, "Machine") // Resources should be indented under the machine assert.Contains(t, out, " my-deployment") diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index d417eee3..ca60d459 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,ids,partialName,roles,isDisabled,healthStatuses,commStyles,tenantIds,tenantTags,environmentIds,thumbprint,deploymentId,name,shellNames,deploymentTargetTypes}" 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 22791b9dbc8b02f52355cfa9c2ba2dcaba9ccd45 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 5 Aug 2026 17:14:17 +1000 Subject: [PATCH 6/7] fix: emit the server's document for live-status json output The json output format was re-marshalled from the structs the table needs, which silently dropped every other field the server reports. Against a local Octopus with a Kubernetes monitor, that lost ArgoCDInstanceStatuses, and per resource ResourceId, DesiredResourceId, MachineId, OrphanedAt and the DeletionTask fields, plus Summary.TotalOrphanCount and SyncStatusMessage -- so orphaned and pending-deletion resources were invisible to a programmatic caller. Print the response body instead. The structs stay as they are; they model what the table renders, which is not the same contract as -f json. Also drops the Json mapper from PrintArray, which was unreachable because the json format returns earlier. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/kubernetes/live-status/live-status.go | 17 ++++--- .../live-status/live-status_test.go | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go index c0d45168..7569f67f 100644 --- a/pkg/cmd/kubernetes/live-status/live-status.go +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -1,6 +1,7 @@ package livestatus import ( + "bytes" "encoding/json" "errors" "fmt" @@ -209,11 +210,15 @@ func liveStatusRun(cmd *cobra.Command, f factory.Factory, flags *LiveStatusFlags outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat) if strings.EqualFold(outputFormat, constants.OutputFormatJson) { - data, err := json.MarshalIndent(response, "", " ") - if err != nil { + // Re-print the server's document rather than re-marshalling the structs + // above, which only model the fields the table needs. Emitting those + // would silently drop everything else the server reports, such as + // orphaned and pending-deletion state and Argo CD instance statuses. + var indented bytes.Buffer + if err := json.Indent(&indented, body, "", " "); err != nil { return err } - cmd.Println(string(data)) + cmd.Println(indented.String()) return nil } @@ -311,11 +316,9 @@ func printFullStatus(cmd *cobra.Command, machineNames map[string]string, respons return nil } - // Table format + // Table format. No Json mapper: the json output format is handled above, from + // the server's own document. return output.PrintArray(allFlat, cmd, output.Mappers[FlatResource]{ - Json: func(fr FlatResource) any { - return fr.Resource - }, Table: output.TableDefinition[FlatResource]{ Header: []string{"Name", "Kind", "Namespace", "Health", "Sync", "Last Updated"}, Row: func(fr FlatResource) []string { diff --git a/pkg/cmd/kubernetes/live-status/live-status_test.go b/pkg/cmd/kubernetes/live-status/live-status_test.go index f6e5124d..c6a16a19 100644 --- a/pkg/cmd/kubernetes/live-status/live-status_test.go +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -332,6 +332,56 @@ func TestKubernetesLiveStatus(t *testing.T) { assert.NotContains(t, out, "NotSupported") }}, + {"json output preserves fields the command does not model", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"kubernetes", "live-status", "--project", "Fire Project", "--environment", "Production", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + respondToSpaceScopedInit(t, api) + respondToProjectAndEnvironment(t, api, fireProject) + + // the server reports orphan and deletion state, and Argo CD instances, + // none of which the table needs; a programmatic caller still wants them + response := map[string]any{ + "MachineStatuses": []any{ + map[string]any{ + "MachineId": "Machines-1", + "Status": "Healthy", + "Resources": []any{ + map[string]any{ + "Name": "my-deployment", + "Kind": "Deployment", + "HealthStatus": "Healthy", + "ResourceId": "resource-abc", + "DesiredResourceId": "desired-abc", + "OrphanedAt": "2026-01-15T10:30:00Z", + "DeletionTaskState": "Queued", + "Children": []any{}, + }, + }, + }, + }, + "ArgoCDInstanceStatuses": []any{map[string]any{"Name": "argo-1"}}, + "Summary": map[string]any{ + "Status": "Healthy", + "TotalOrphanCount": 1, + "SyncStatusMessage": "one resource is orphaned", + }, + } + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/environments/Environments-1/untenanted/livestatus"). + RespondWith(response) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + out := stdOut.String() + for _, field := range []string{"ResourceId", "DesiredResourceId", "OrphanedAt", "DeletionTaskState", "ArgoCDInstanceStatuses", "TotalOrphanCount", "SyncStatusMessage"} { + assert.Contains(t, out, field, "%s must survive json output", field) + } + }}, + {"k8s alias works", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() From ad5c7dd716324cc3e14603932a3dd82f49789714 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Thu, 6 Aug 2026 11:55:04 +1000 Subject: [PATCH 7/7] fix: drop the shell prompt from the kubernetes examples The docs repo lints every generated page with markdownlint-cli2, and MD014 rejects a "$" before a command when no output follows. #591 removed these prefixes across the CLI for that reason; the new kubernetes commands reintroduced five of them. Verified by generating the docs from current main with these commands registered and running the docs repo's own .markdownlint.json over the result: 5 errors in 2 files before, 0 after. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/kubernetes/kubernetes.go | 2 +- pkg/cmd/kubernetes/live-status/live-status.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/kubernetes/kubernetes.go b/pkg/cmd/kubernetes/kubernetes.go index 0a396cd9..73ed3136 100644 --- a/pkg/cmd/kubernetes/kubernetes.go +++ b/pkg/cmd/kubernetes/kubernetes.go @@ -13,7 +13,7 @@ func NewCmdKubernetes(f factory.Factory) *cobra.Command { Use: "kubernetes ", Short: "Kubernetes observability commands", Long: "Commands for observing Kubernetes resources deployed via Octopus Deploy", - Example: heredoc.Docf("$ %s kubernetes live-status --project MyProject --environment Production", constants.ExecutableName), + Example: heredoc.Docf("%s kubernetes live-status --project MyProject --environment Production", constants.ExecutableName), Aliases: []string{"k8s"}, } diff --git a/pkg/cmd/kubernetes/live-status/live-status.go b/pkg/cmd/kubernetes/live-status/live-status.go index 7569f67f..c63b07b8 100644 --- a/pkg/cmd/kubernetes/live-status/live-status.go +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -93,10 +93,10 @@ func NewCmdLiveStatus(f factory.Factory) *cobra.Command { Short: "Get Kubernetes live object status", Long: "Get the live status of Kubernetes resources for a project and environment in Octopus Deploy", Example: heredoc.Docf(` - $ %[1]s kubernetes live-status --project MyProject --environment Production - $ %[1]s kubernetes live-status --project MyProject --environment Production --tenant MyTenant - $ %[1]s kubernetes live-status --project MyProject --environment Production --summary-only - $ %[1]s kubernetes live-status --project MyProject --environment Production -f json + %[1]s kubernetes live-status --project MyProject --environment Production + %[1]s kubernetes live-status --project MyProject --environment Production --tenant MyTenant + %[1]s kubernetes live-status --project MyProject --environment Production --summary-only + %[1]s kubernetes live-status --project MyProject --environment Production -f json `, constants.ExecutableName), RunE: func(cmd *cobra.Command, args []string) error { return liveStatusRun(cmd, f, flags)