From 62cf1dfc3ea90a9a3bedefbc3159d8bcc9976c2c Mon Sep 17 00:00:00 2001 From: Andrei Pohilko Date: Tue, 17 Mar 2026 13:40:42 +0000 Subject: [PATCH] feat(health): add status display for DaemonSet and StatefulSet (#32) Introduce extendedCarp struct to capture numeric status fields (desiredNumberScheduled, numberReady, replicas, readyReplicas, etc.) that are lost during standard Carp unmarshaling. Synthesize an "Available" condition from these fields so EnhanceStatus can determine health correctly. Closes #32 Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/dashboard/objects/kubectl.go | 70 ++++++++++++++++- pkg/dashboard/objects/kubectl_test.go | 105 ++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 pkg/dashboard/objects/kubectl_test.go diff --git a/pkg/dashboard/objects/kubectl.go b/pkg/dashboard/objects/kubectl.go index 193097b..1e20a68 100644 --- a/pkg/dashboard/objects/kubectl.go +++ b/pkg/dashboard/objects/kubectl.go @@ -3,7 +3,9 @@ package objects import ( "context" "encoding/json" + "fmt" "sort" + "strings" "github.com/joomcode/errorx" "github.com/pkg/errors" @@ -158,12 +160,17 @@ func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*test return nil, errorx.Decorate(err, "failed to marshal k8s object into JSON") } - res := new(testapiv1.Carp) - err = json.Unmarshal(data, &res) + ext := new(extendedCarp) + err = json.Unmarshal(data, &ext) if err != nil { return nil, errorx.Decorate(err, "failed to decode k8s object from JSON") } + synthesizeConditions(kind, ext) + + res := &ext.Carp + res.Status = ext.Status.CarpStatus + sort.Slice(res.Status.Conditions, func(i, j int) bool { // some condition types always bubble up if res.Status.Conditions[i].Type == "Available" { @@ -182,6 +189,65 @@ func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*test return res, nil } +// extendedCarp extends Carp to capture numeric status fields from DaemonSet/StatefulSet +// that are lost during standard Carp unmarshaling. +type extendedCarp struct { + testapiv1.Carp + Status extendedCarpStatus `json:"status,omitempty"` +} + +type extendedCarpStatus struct { + testapiv1.CarpStatus + // DaemonSet fields + DesiredNumberScheduled int `json:"desiredNumberScheduled,omitempty"` + NumberReady int `json:"numberReady,omitempty"` + UpdatedNumberScheduled int `json:"updatedNumberScheduled,omitempty"` + // StatefulSet fields + Replicas int `json:"replicas,omitempty"` + ReadyReplicas int `json:"readyReplicas,omitempty"` + UpdatedReplicas int `json:"updatedReplicas,omitempty"` +} + +// synthesizeConditions creates synthetic conditions for resource kinds that use +// numeric status fields instead of conditions (DaemonSet, StatefulSet). +func synthesizeConditions(kind string, ext *extendedCarp) { + kind = strings.ToLower(kind) + + var desired, ready, updated int + switch kind { + case "daemonset": + desired = ext.Status.DesiredNumberScheduled + ready = ext.Status.NumberReady + updated = ext.Status.UpdatedNumberScheduled + case "statefulset": + desired = ext.Status.Replicas + ready = ext.Status.ReadyReplicas + updated = ext.Status.UpdatedReplicas + default: + return + } + + status := testapiv1.ConditionStatus("True") + reason := "AllReady" + message := fmt.Sprintf("%d/%d ready", ready, desired) + + if ready < desired { + status = "False" + reason = "NotAllReady" + } else if updated < desired { + status = "False" + reason = "UpdateInProgress" + message = fmt.Sprintf("%d/%d updated", updated, desired) + } + + ext.Status.Conditions = append(ext.Status.Conditions, testapiv1.CarpCondition{ + Type: "Available", + Status: status, + Reason: reason, + Message: message, + }) +} + func (k *K8s) GetResourceYAML(kind string, namespace string, name string) (string, error) { obj, err := k.GetResource(kind, namespace, name) if err != nil { diff --git a/pkg/dashboard/objects/kubectl_test.go b/pkg/dashboard/objects/kubectl_test.go new file mode 100644 index 0000000..70e701b --- /dev/null +++ b/pkg/dashboard/objects/kubectl_test.go @@ -0,0 +1,105 @@ +package objects + +import ( + "testing" + + testapiv1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" +) + +func TestSynthesizeConditions_DaemonSet_AllReady(t *testing.T) { + ext := &extendedCarp{ + Status: extendedCarpStatus{ + DesiredNumberScheduled: 3, + NumberReady: 3, + UpdatedNumberScheduled: 3, + }, + } + synthesizeConditions("DaemonSet", ext) + cond := findCondition(ext.Status.Conditions, "Available") + if cond == nil { + t.Fatal("expected Available condition") + } + if cond.Status != "True" { + t.Errorf("expected True, got %s", cond.Status) + } + if cond.Reason != "AllReady" { + t.Errorf("expected AllReady, got %s", cond.Reason) + } +} + +func TestSynthesizeConditions_DaemonSet_NotReady(t *testing.T) { + ext := &extendedCarp{ + Status: extendedCarpStatus{ + DesiredNumberScheduled: 3, + NumberReady: 1, + UpdatedNumberScheduled: 3, + }, + } + synthesizeConditions("DaemonSet", ext) + cond := findCondition(ext.Status.Conditions, "Available") + if cond == nil { + t.Fatal("expected Available condition") + } + if cond.Status != "False" { + t.Errorf("expected False, got %s", cond.Status) + } + if cond.Reason != "NotAllReady" { + t.Errorf("expected NotAllReady, got %s", cond.Reason) + } +} + +func TestSynthesizeConditions_StatefulSet_AllReady(t *testing.T) { + ext := &extendedCarp{ + Status: extendedCarpStatus{ + Replicas: 3, + ReadyReplicas: 3, + UpdatedReplicas: 3, + }, + } + synthesizeConditions("StatefulSet", ext) + cond := findCondition(ext.Status.Conditions, "Available") + if cond == nil { + t.Fatal("expected Available condition") + } + if cond.Status != "True" { + t.Errorf("expected True, got %s", cond.Status) + } +} + +func TestSynthesizeConditions_StatefulSet_UpdateInProgress(t *testing.T) { + ext := &extendedCarp{ + Status: extendedCarpStatus{ + Replicas: 3, + ReadyReplicas: 3, + UpdatedReplicas: 1, + }, + } + synthesizeConditions("StatefulSet", ext) + cond := findCondition(ext.Status.Conditions, "Available") + if cond == nil { + t.Fatal("expected Available condition") + } + if cond.Status != "False" { + t.Errorf("expected False, got %s", cond.Status) + } + if cond.Reason != "UpdateInProgress" { + t.Errorf("expected UpdateInProgress, got %s", cond.Reason) + } +} + +func TestSynthesizeConditions_OtherKind_NoCondition(t *testing.T) { + ext := &extendedCarp{} + synthesizeConditions("Deployment", ext) + if len(ext.Status.Conditions) != 0 { + t.Errorf("expected no conditions for Deployment, got %d", len(ext.Status.Conditions)) + } +} + +func findCondition(conditions []testapiv1.CarpCondition, condType string) *testapiv1.CarpCondition { + for i, c := range conditions { + if string(c.Type) == condType { + return &conditions[i] + } + } + return nil +}