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) <noreply@anthropic.com>
This commit is contained in:
Andrei Pohilko
2026-03-17 13:40:42 +00:00
parent f7deda06f5
commit 62cf1dfc3e
2 changed files with 173 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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
}