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