Skip to content

Commit 4b7b930

Browse files
typeidclaude
andcommitted
Feat: add hcp status command
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e042692 commit 4b7b930

File tree

9 files changed

+1522
-0
lines changed

9 files changed

+1522
-0
lines changed

cmd/hcp/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package hcp
33
import (
44
"github.com/openshift/osdctl/cmd/hcp/forceupgrade"
55
"github.com/openshift/osdctl/cmd/hcp/mustgather"
6+
"github.com/openshift/osdctl/cmd/hcp/status"
67
"github.com/spf13/cobra"
78
)
89

@@ -14,6 +15,7 @@ func NewCmdHCP() *cobra.Command {
1415

1516
hcp.AddCommand(mustgather.NewCmdMustGather())
1617
hcp.AddCommand(forceupgrade.NewCmdForceUpgrade())
18+
hcp.AddCommand(status.NewCmdStatus())
1719

1820
return hcp
1921
}

cmd/hcp/status/parser.go

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package status
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"sort"
7+
"strconv"
8+
"strings"
9+
"time"
10+
)
11+
12+
// parseLiveResources processes the resources map from the OCM live endpoint
13+
// and returns an HCPStatus struct.
14+
func parseLiveResources(resources map[string]string, clusterID string) (*HCPStatus, error) {
15+
status := &HCPStatus{}
16+
17+
var mainMWKey string
18+
manifestWorkKeys := []string{}
19+
20+
// First pass: identify all manifest_work keys
21+
for key := range resources {
22+
if strings.HasPrefix(key, "manifest_work-") {
23+
manifestWorkKeys = append(manifestWorkKeys, key)
24+
}
25+
}
26+
sort.Strings(manifestWorkKeys)
27+
28+
// The main ManifestWork is always named manifest_work-<cluster_internal_id>
29+
mainMWKey = "manifest_work-" + clusterID
30+
31+
// Parse ManifestWork sync status for all ManifestWorks
32+
for _, key := range manifestWorkKeys {
33+
jsonStr := resources[key]
34+
mws, err := parseManifestWorkSyncStatus(jsonStr)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to parse ManifestWork sync status for %s: %w", key, err)
37+
}
38+
status.ManifestWorks = append(status.ManifestWorks, mws)
39+
}
40+
41+
// Parse main ManifestWork for HostedCluster conditions and version
42+
if mainMWJSON, exists := resources[mainMWKey]; exists {
43+
result, err := parseMainManifestWork(mainMWJSON)
44+
if err != nil {
45+
return nil, fmt.Errorf("failed to parse main ManifestWork: %w", err)
46+
}
47+
status.HostedClusterConditions = result.Conditions
48+
status.Version = result.Version
49+
status.ManagementCluster = result.MgmtCluster
50+
if result.Certificate != nil {
51+
status.APIServerCertificate = result.Certificate
52+
}
53+
}
54+
55+
// Parse all ManifestWorks for NodePool resources
56+
for _, key := range manifestWorkKeys {
57+
nodePools, err := parseManifestWorkNodePools(resources[key])
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to parse NodePool from %s: %w", key, err)
60+
}
61+
status.NodePools = append(status.NodePools, nodePools...)
62+
}
63+
64+
// Parse ingress certificate (standalone resource)
65+
for key, jsonStr := range resources {
66+
if strings.HasPrefix(key, "certificate-") {
67+
cert, err := parseCertificate(jsonStr)
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to parse ingress certificate: %w", err)
70+
}
71+
status.IngressCertificate = cert
72+
break
73+
}
74+
}
75+
76+
return status, nil
77+
}
78+
79+
// parseManifestWorkSyncStatus extracts Applied and Available conditions from
80+
// a ManifestWork's top-level status.
81+
func parseManifestWorkSyncStatus(jsonStr string) (ManifestWorkSync, error) {
82+
var mw struct {
83+
Metadata struct {
84+
Name string `json:"name"`
85+
} `json:"metadata"`
86+
Status struct {
87+
Conditions []struct {
88+
Type string `json:"type"`
89+
Status string `json:"status"`
90+
LastTransitionTime string `json:"lastTransitionTime"`
91+
} `json:"conditions"`
92+
} `json:"status"`
93+
}
94+
95+
if err := json.Unmarshal([]byte(jsonStr), &mw); err != nil {
96+
return ManifestWorkSync{}, fmt.Errorf("invalid JSON: %w", err)
97+
}
98+
99+
result := ManifestWorkSync{Name: mw.Metadata.Name}
100+
var mostRecentTime time.Time
101+
102+
for _, c := range mw.Status.Conditions {
103+
switch c.Type {
104+
case "Applied":
105+
result.Applied = c.Status == "True"
106+
case "Available":
107+
result.Available = c.Status == "True"
108+
}
109+
110+
// Track the most recent transition time from any condition
111+
if c.LastTransitionTime != "" {
112+
if t, err := time.Parse(time.RFC3339, c.LastTransitionTime); err == nil {
113+
if t.After(mostRecentTime) {
114+
mostRecentTime = t
115+
}
116+
}
117+
}
118+
}
119+
120+
result.LastSyncTime = mostRecentTime
121+
return result, nil
122+
}
123+
124+
// parseMainManifestWork parses the main ManifestWork to extract HostedCluster
125+
// conditions, version info, and the management cluster name.
126+
func parseMainManifestWork(jsonStr string) (*mainMWResult, error) {
127+
var mw struct {
128+
Metadata struct {
129+
Labels map[string]string `json:"labels"`
130+
} `json:"metadata"`
131+
Status struct {
132+
ResourceStatus struct {
133+
Manifests []struct {
134+
ResourceMeta struct {
135+
Group string `json:"group"`
136+
Kind string `json:"kind"`
137+
Name string `json:"name"`
138+
Resource string `json:"resource"`
139+
} `json:"resourceMeta"`
140+
StatusFeedback struct {
141+
Values []FeedbackValue `json:"values"`
142+
} `json:"statusFeedback"`
143+
} `json:"manifests"`
144+
} `json:"resourceStatus"`
145+
} `json:"status"`
146+
}
147+
148+
if err := json.Unmarshal([]byte(jsonStr), &mw); err != nil {
149+
return nil, fmt.Errorf("invalid JSON: %w", err)
150+
}
151+
152+
mgmtCluster := mw.Metadata.Labels["api.openshift.com/management-cluster"]
153+
154+
var conditions []Condition
155+
var version VersionInfo
156+
var cert *CertificateStatus
157+
158+
for _, manifest := range mw.Status.ResourceStatus.Manifests {
159+
switch manifest.ResourceMeta.Kind {
160+
case "HostedCluster":
161+
conds, extras := parseFeedbackValues(manifest.StatusFeedback.Values)
162+
conditions = conds
163+
164+
if v, ok := extras["Version-Current"]; ok {
165+
version.Current = v
166+
}
167+
if v, ok := extras["Version-Desired"]; ok {
168+
version.Desired = v
169+
}
170+
if v, ok := extras["Version-Status"]; ok {
171+
version.Status = v
172+
}
173+
if v, ok := extras["Version-Image"]; ok {
174+
version.Image = v
175+
}
176+
if v, ok := extras["Version-AvailableUpdates"]; ok && v != "" {
177+
version.AvailableUpdates = strings.Split(v, ",")
178+
}
179+
case "Certificate":
180+
// Certificate resources are present in ManifestWork but statusFeedback
181+
// doesn't provide complete details due to missing ACM feedback rules.
182+
// This will be implemented in the future.
183+
cert = &CertificateStatus{}
184+
// Mark as present but without detailed status
185+
}
186+
}
187+
188+
return &mainMWResult{
189+
Conditions: conditions,
190+
Version: version,
191+
MgmtCluster: mgmtCluster,
192+
Certificate: cert,
193+
}, nil
194+
}
195+
196+
// parseManifestWorkNodePools parses a ManifestWork looking for NodePool resources
197+
// in the statusFeedback. Returns zero or more NodePoolStatus entries.
198+
func parseManifestWorkNodePools(jsonStr string) ([]NodePoolStatus, error) {
199+
var mw struct {
200+
Status struct {
201+
ResourceStatus struct {
202+
Manifests []struct {
203+
ResourceMeta struct {
204+
Group string `json:"group"`
205+
Kind string `json:"kind"`
206+
Name string `json:"name"`
207+
Resource string `json:"resource"`
208+
} `json:"resourceMeta"`
209+
StatusFeedback struct {
210+
Values []FeedbackValue `json:"values"`
211+
} `json:"statusFeedback"`
212+
} `json:"manifests"`
213+
} `json:"resourceStatus"`
214+
} `json:"status"`
215+
}
216+
217+
if err := json.Unmarshal([]byte(jsonStr), &mw); err != nil {
218+
return nil, fmt.Errorf("invalid JSON: %w", err)
219+
}
220+
221+
var nodePools []NodePoolStatus
222+
for _, manifest := range mw.Status.ResourceStatus.Manifests {
223+
if manifest.ResourceMeta.Kind != "NodePool" {
224+
continue
225+
}
226+
227+
conds, extras := parseFeedbackValues(manifest.StatusFeedback.Values)
228+
np := NodePoolStatus{
229+
Name: manifest.ResourceMeta.Name,
230+
Conditions: conds,
231+
}
232+
233+
if v, ok := extras["Replicas"]; ok {
234+
if n, err := strconv.Atoi(v); err == nil {
235+
np.Replicas = n
236+
}
237+
}
238+
if v, ok := extras["Version"]; ok {
239+
np.Version = v
240+
}
241+
242+
nodePools = append(nodePools, np)
243+
}
244+
245+
return nodePools, nil
246+
}
247+
248+
// parseFeedbackValues groups flat key-value feedback pairs into Condition structs
249+
// and separates out non-condition values. Feedback keys follow the pattern
250+
// "ConditionType-Field" (e.g., "Available-Status", "Available-Message").
251+
// Non-condition keys (like "Version-Current", "Replicas") are returned in the
252+
// extras map.
253+
func parseFeedbackValues(values []FeedbackValue) ([]Condition, map[string]string) {
254+
conditionMap := make(map[string]*Condition)
255+
extras := make(map[string]string)
256+
var conditionOrder []string
257+
258+
// Known condition fields
259+
conditionFields := map[string]bool{
260+
"Status": true, "Reason": true, "Message": true, "LastTransitionTime": true,
261+
}
262+
263+
// Prefixes that are not conditions even if they match the Type-Field pattern
264+
nonConditionPrefixes := map[string]bool{
265+
"Version": true,
266+
}
267+
268+
for _, fv := range values {
269+
val := fv.FieldValue.String
270+
if fv.FieldValue.Type == "Integer" {
271+
val = fmt.Sprintf("%d", fv.FieldValue.Integer)
272+
}
273+
274+
// Try to split into Type-Field. Note: condition types containing
275+
// hyphens would be split incorrectly; current OCM data does not
276+
// include such types.
277+
parts := strings.SplitN(fv.Name, "-", 2)
278+
if len(parts) == 2 && conditionFields[parts[1]] && !nonConditionPrefixes[parts[0]] {
279+
condType := parts[0]
280+
field := parts[1]
281+
282+
if _, exists := conditionMap[condType]; !exists {
283+
conditionMap[condType] = &Condition{Type: condType}
284+
conditionOrder = append(conditionOrder, condType)
285+
}
286+
287+
c := conditionMap[condType]
288+
switch field {
289+
case "Status":
290+
c.Status = val
291+
case "Reason":
292+
c.Reason = val
293+
case "Message":
294+
c.Message = val
295+
case "LastTransitionTime":
296+
c.LastTransitionTime = val
297+
}
298+
} else {
299+
extras[fv.Name] = val
300+
}
301+
}
302+
303+
conditions := make([]Condition, 0, len(conditionOrder))
304+
for _, t := range conditionOrder {
305+
conditions = append(conditions, *conditionMap[t])
306+
}
307+
308+
return conditions, extras
309+
}
310+
311+
// parseCertificate extracts status from a cert-manager Certificate resource.
312+
func parseCertificate(jsonStr string) (*CertificateStatus, error) {
313+
var cert struct {
314+
Spec struct {
315+
DNSNames []string `json:"dnsNames"`
316+
} `json:"spec"`
317+
Status struct {
318+
Conditions []struct {
319+
Type string `json:"type"`
320+
Status string `json:"status"`
321+
} `json:"conditions"`
322+
NotAfter string `json:"notAfter"`
323+
RenewalTime string `json:"renewalTime"`
324+
} `json:"status"`
325+
}
326+
327+
if err := json.Unmarshal([]byte(jsonStr), &cert); err != nil {
328+
return nil, fmt.Errorf("invalid JSON: %w", err)
329+
}
330+
331+
cs := &CertificateStatus{
332+
DNSNames: cert.Spec.DNSNames,
333+
}
334+
335+
for _, c := range cert.Status.Conditions {
336+
if c.Type == "Ready" {
337+
ready := c.Status == "True"
338+
cs.Ready = &ready
339+
}
340+
}
341+
342+
if cert.Status.NotAfter != "" {
343+
t, err := time.Parse(time.RFC3339, cert.Status.NotAfter)
344+
if err == nil {
345+
cs.NotAfter = t
346+
}
347+
}
348+
349+
if cert.Status.RenewalTime != "" {
350+
t, err := time.Parse(time.RFC3339, cert.Status.RenewalTime)
351+
if err == nil {
352+
cs.RenewalTime = t
353+
}
354+
}
355+
356+
return cs, nil
357+
}

0 commit comments

Comments
 (0)