Skip to content

Commit 0fe7c83

Browse files
committed
Kubernetes live object status support
1 parent 7332a79 commit 0fe7c83

4 files changed

Lines changed: 571 additions & 0 deletions

File tree

pkg/cmd/kubernetes/kubernetes.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package kubernetes
2+
3+
import (
4+
"github.com/MakeNowJust/heredoc/v2"
5+
cmdLiveStatus "github.com/OctopusDeploy/cli/pkg/cmd/kubernetes/live-status"
6+
"github.com/OctopusDeploy/cli/pkg/constants"
7+
"github.com/OctopusDeploy/cli/pkg/factory"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewCmdKubernetes(f factory.Factory) *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "kubernetes <command>",
14+
Short: "Kubernetes observability commands",
15+
Long: "Commands for observing Kubernetes resources deployed via Octopus Deploy",
16+
Example: heredoc.Docf("$ %s kubernetes live-status --project MyProject --environment Production", constants.ExecutableName),
17+
Aliases: []string{"k8s"},
18+
}
19+
20+
cmd.AddCommand(cmdLiveStatus.NewCmdLiveStatus(f))
21+
22+
return cmd
23+
}
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package livestatus
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/MakeNowJust/heredoc/v2"
12+
"github.com/OctopusDeploy/cli/pkg/apiclient"
13+
"github.com/OctopusDeploy/cli/pkg/constants"
14+
"github.com/OctopusDeploy/cli/pkg/factory"
15+
"github.com/OctopusDeploy/cli/pkg/output"
16+
"github.com/OctopusDeploy/cli/pkg/question/selectors"
17+
"github.com/OctopusDeploy/cli/pkg/util/flag"
18+
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments"
19+
"github.com/spf13/cobra"
20+
)
21+
22+
const (
23+
FlagProject = "project"
24+
FlagEnvironment = "environment"
25+
FlagTenant = "tenant"
26+
FlagSummaryOnly = "summary-only"
27+
)
28+
29+
type LiveStatusFlags struct {
30+
Project *flag.Flag[string]
31+
Environment *flag.Flag[string]
32+
Tenant *flag.Flag[string]
33+
SummaryOnly *flag.Flag[bool]
34+
}
35+
36+
func NewLiveStatusFlags() *LiveStatusFlags {
37+
return &LiveStatusFlags{
38+
Project: flag.New[string](FlagProject, false),
39+
Environment: flag.New[string](FlagEnvironment, false),
40+
Tenant: flag.New[string](FlagTenant, false),
41+
SummaryOnly: flag.New[bool](FlagSummaryOnly, false),
42+
}
43+
}
44+
45+
// API response types
46+
47+
type LiveStatusResponse struct {
48+
MachineStatuses []MachineStatus `json:"MachineStatuses"`
49+
Summary StatusSummary `json:"Summary"`
50+
}
51+
52+
type MachineStatus struct {
53+
MachineId string `json:"MachineId"`
54+
Status string `json:"Status"`
55+
Resources []KubernetesLiveStatusResource `json:"Resources"`
56+
}
57+
58+
type KubernetesLiveStatusResource struct {
59+
Name string `json:"Name"`
60+
Namespace string `json:"Namespace,omitempty"`
61+
Kind string `json:"Kind"`
62+
Group string `json:"Group"`
63+
HealthStatus string `json:"HealthStatus"`
64+
SyncStatus string `json:"SyncStatus,omitempty"`
65+
HealthStatusMessage string `json:"HealthStatusMessage,omitempty"`
66+
SyncStatusMessage string `json:"SyncStatusMessage,omitempty"`
67+
ResourceSourceId string `json:"ResourceSourceId"`
68+
SourceType string `json:"SourceType"`
69+
Children []KubernetesLiveStatusResource `json:"Children"`
70+
LastUpdated string `json:"LastUpdated"`
71+
}
72+
73+
type StatusSummary struct {
74+
Status string `json:"Status"`
75+
HealthStatus string `json:"HealthStatus"`
76+
SyncStatus string `json:"SyncStatus"`
77+
LastUpdated string `json:"LastUpdated"`
78+
}
79+
80+
// FlatResource is a flattened representation of a resource in the tree, used for table output.
81+
type FlatResource struct {
82+
Depth int
83+
Resource KubernetesLiveStatusResource
84+
}
85+
86+
func NewCmdLiveStatus(f factory.Factory) *cobra.Command {
87+
flags := NewLiveStatusFlags()
88+
89+
cmd := &cobra.Command{
90+
Use: "live-status",
91+
Short: "Get Kubernetes live object status",
92+
Long: "Get the live status of Kubernetes resources for a project and environment in Octopus Deploy",
93+
Example: heredoc.Docf(`
94+
$ %[1]s kubernetes live-status --project MyProject --environment Production
95+
$ %[1]s kubernetes live-status --project MyProject --environment Production --tenant MyTenant
96+
$ %[1]s kubernetes live-status --project MyProject --environment Production --summary-only
97+
$ %[1]s kubernetes live-status --project MyProject --environment Production -f json
98+
`, constants.ExecutableName),
99+
RunE: func(cmd *cobra.Command, args []string) error {
100+
return liveStatusRun(cmd, f, flags)
101+
},
102+
}
103+
104+
cmdFlags := cmd.Flags()
105+
cmdFlags.StringVarP(&flags.Project.Value, flags.Project.Name, "p", "", "Name or ID of the project")
106+
cmdFlags.StringVarP(&flags.Environment.Value, flags.Environment.Name, "e", "", "Name or ID of the environment")
107+
cmdFlags.StringVarP(&flags.Tenant.Value, flags.Tenant.Name, "t", "", "Name or ID of the tenant (for tenanted deployments)")
108+
cmdFlags.BoolVar(&flags.SummaryOnly.Value, flags.SummaryOnly.Name, false, "Return summary status only")
109+
110+
return cmd
111+
}
112+
113+
func liveStatusRun(cmd *cobra.Command, f factory.Factory, flags *LiveStatusFlags) error {
114+
client, err := f.GetSpacedClient(apiclient.NewRequester(cmd))
115+
if err != nil {
116+
return err
117+
}
118+
119+
// Resolve project
120+
projectId := flags.Project.Value
121+
if projectId == "" {
122+
if !f.IsPromptEnabled() {
123+
return errors.New("project must be specified; use --project flag or run in interactive mode")
124+
}
125+
selectedProject, err := selectors.Project("Select a project", client, f.Ask)
126+
if err != nil {
127+
return err
128+
}
129+
projectId = selectedProject.GetID()
130+
} else {
131+
resolvedProject, err := selectors.FindProject(client, projectId)
132+
if err != nil {
133+
return err
134+
}
135+
projectId = resolvedProject.GetID()
136+
}
137+
138+
// Resolve environment
139+
environmentId := flags.Environment.Value
140+
if environmentId == "" {
141+
if !f.IsPromptEnabled() {
142+
return errors.New("environment must be specified; use --environment flag or run in interactive mode")
143+
}
144+
selectedEnvironment, err := selectors.EnvironmentSelect(f.Ask, func() ([]*environments.Environment, error) {
145+
return selectors.GetAllEnvironments(client)
146+
}, "Select an environment")
147+
if err != nil {
148+
return err
149+
}
150+
environmentId = selectedEnvironment.GetID()
151+
} else {
152+
resolvedEnvironment, err := selectors.FindEnvironment(client, environmentId)
153+
if err != nil {
154+
return err
155+
}
156+
environmentId = resolvedEnvironment.GetID()
157+
}
158+
159+
// Resolve tenant (optional)
160+
var tenantId string
161+
if flags.Tenant.Value != "" {
162+
resolvedTenant, err := client.Tenants.GetByIdentifier(flags.Tenant.Value)
163+
if err != nil {
164+
return fmt.Errorf("failed to resolve tenant: %w", err)
165+
}
166+
tenantId = resolvedTenant.GetID()
167+
}
168+
169+
// Build API URL
170+
spaceId := client.GetSpaceID()
171+
var apiPath string
172+
if tenantId != "" {
173+
apiPath = fmt.Sprintf("/api/%s/projects/%s/environments/%s/tenants/%s/livestatus", spaceId, projectId, environmentId, tenantId)
174+
} else {
175+
apiPath = fmt.Sprintf("/api/%s/projects/%s/environments/%s/untenanted/livestatus", spaceId, projectId, environmentId)
176+
}
177+
if flags.SummaryOnly.Value {
178+
apiPath += "?summaryOnly=true"
179+
}
180+
181+
// Make API request
182+
req, err := http.NewRequest("GET", apiPath, nil)
183+
if err != nil {
184+
return err
185+
}
186+
187+
resp, err := client.HttpSession().DoRawRequest(req)
188+
if err != nil {
189+
return err
190+
}
191+
defer resp.Body.Close()
192+
193+
body, err := io.ReadAll(resp.Body)
194+
if err != nil {
195+
return err
196+
}
197+
198+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
199+
return fmt.Errorf("API request failed (HTTP %d): %s", resp.StatusCode, string(body))
200+
}
201+
202+
var response LiveStatusResponse
203+
if err := json.Unmarshal(body, &response); err != nil {
204+
return fmt.Errorf("failed to parse response: %w", err)
205+
}
206+
207+
// Format output
208+
outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat)
209+
210+
if strings.EqualFold(outputFormat, constants.OutputFormatJson) {
211+
data, err := json.MarshalIndent(response, "", " ")
212+
if err != nil {
213+
return err
214+
}
215+
cmd.Println(string(data))
216+
return nil
217+
}
218+
219+
if flags.SummaryOnly.Value {
220+
return printSummary(cmd, &response.Summary)
221+
}
222+
223+
return printFullStatus(cmd, &response)
224+
}
225+
226+
func printSummary(cmd *cobra.Command, summary *StatusSummary) error {
227+
rows := []*output.DataRow{
228+
output.NewDataRow("Status", summary.Status),
229+
output.NewDataRow("Health Status", summary.HealthStatus),
230+
output.NewDataRow("Sync Status", summary.SyncStatus),
231+
output.NewDataRow("Last Updated", summary.LastUpdated),
232+
}
233+
output.PrintRows(rows, cmd.OutOrStdout())
234+
return nil
235+
}
236+
237+
func printFullStatus(cmd *cobra.Command, response *LiveStatusResponse) error {
238+
var allFlat []FlatResource
239+
for _, machine := range response.MachineStatuses {
240+
allFlat = append(allFlat, flattenResources(machine.Resources, 0)...)
241+
}
242+
243+
if len(allFlat) == 0 {
244+
cmd.Println("No Kubernetes resources found.")
245+
return nil
246+
}
247+
248+
outputFormat, _ := cmd.Flags().GetString(constants.FlagOutputFormat)
249+
if strings.EqualFold(outputFormat, constants.OutputFormatBasic) {
250+
for _, fr := range allFlat {
251+
indent := strings.Repeat(" ", fr.Depth)
252+
r := fr.Resource
253+
syncInfo := ""
254+
if r.SyncStatus != "" {
255+
syncInfo = fmt.Sprintf(", Sync: %s", r.SyncStatus)
256+
}
257+
cmd.Printf("%s%s (%s) - Health: %s%s\n", indent, r.Name, r.Kind, r.HealthStatus, syncInfo)
258+
}
259+
return nil
260+
}
261+
262+
// Table format
263+
return output.PrintArray(allFlat, cmd, output.Mappers[FlatResource]{
264+
Json: func(fr FlatResource) any {
265+
return fr.Resource
266+
},
267+
Table: output.TableDefinition[FlatResource]{
268+
Header: []string{"Name", "Kind", "Namespace", "Health", "Sync", "Last Updated"},
269+
Row: func(fr FlatResource) []string {
270+
indent := strings.Repeat(" ", fr.Depth)
271+
return []string{
272+
indent + fr.Resource.Name,
273+
fr.Resource.Kind,
274+
fr.Resource.Namespace,
275+
fr.Resource.HealthStatus,
276+
fr.Resource.SyncStatus,
277+
fr.Resource.LastUpdated,
278+
}
279+
},
280+
},
281+
Basic: func(fr FlatResource) string {
282+
indent := strings.Repeat(" ", fr.Depth)
283+
r := fr.Resource
284+
return fmt.Sprintf("%s%s (%s) - Health: %s", indent, r.Name, r.Kind, r.HealthStatus)
285+
},
286+
})
287+
}
288+
289+
func flattenResources(resources []KubernetesLiveStatusResource, depth int) []FlatResource {
290+
var result []FlatResource
291+
for _, r := range resources {
292+
result = append(result, FlatResource{Depth: depth, Resource: r})
293+
if len(r.Children) > 0 {
294+
result = append(result, flattenResources(r.Children, depth+1)...)
295+
}
296+
}
297+
return result
298+
}

0 commit comments

Comments
 (0)