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 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..46f0e530 --- /dev/null +++ b/pkg/cmd/kubernetes/live-status/live-status.go @@ -0,0 +1,307 @@ +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 { + // 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 { + 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..fdf214c7 --- /dev/null +++ b/pkg/cmd/kubernetes/live-status/live-status_test.go @@ -0,0 +1,286 @@ +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"`) + }}, + + {"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() + 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