Files
helm-dashboard/pkg/dashboard/objects/kubectl.go
Andrei Pohilko 62cf1dfc3e 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>
2026-03-17 13:40:42 +00:00

274 lines
7.3 KiB
Go

package objects
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/kube"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
testapiv1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
describecmd "k8s.io/kubectl/pkg/cmd/describe"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
"k8s.io/utils/strings/slices"
)
type KubeContext struct {
IsCurrent bool
Name string
Cluster string
AuthInfo string
Namespace string
}
// maps action.RESTClientGetter into genericclioptions.RESTClientGetter
type cfgProxyObject struct {
Impl action.RESTClientGetter
}
func (p *cfgProxyObject) ToRESTConfig() (*rest.Config, error) {
return p.Impl.ToRESTConfig()
}
func (p *cfgProxyObject) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return p.Impl.ToDiscoveryClient()
}
func (p *cfgProxyObject) ToRESTMapper() (meta.RESTMapper, error) {
return p.Impl.ToRESTMapper()
}
func (p *cfgProxyObject) ToRawKubeConfigLoader() clientcmd.ClientConfig {
panic("Not implemented, stub")
}
type K8s struct {
Namespaces []string
Factory kube.Factory
RestClientGetter genericclioptions.RESTClientGetter
}
func NewK8s(helmConfig *action.Configuration, namespaces []string) (*K8s, error) {
factory := cmdutil.NewFactory(&cfgProxyObject{Impl: helmConfig.RESTClientGetter})
return &K8s{
Namespaces: namespaces,
Factory: factory,
RestClientGetter: factory,
}, nil
}
func (k *K8s) GetNameSpaces() (res *corev1.NamespaceList, err error) {
clientset, err := k.Factory.KubernetesClientSet()
if err != nil {
return nil, errors.Wrap(err, "failed to get KubernetesClientSet")
}
lst, err := clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, errors.Wrap(err, "failed to get list of namespaces")
}
if !slices.Contains(k.Namespaces, "") {
filtered := []corev1.Namespace{}
for _, ns := range lst.Items {
if slices.Contains(k.Namespaces, ns.Name) {
filtered = append(filtered, ns)
}
}
lst.Items = filtered
}
return lst, nil
}
func (k *K8s) DescribeResource(kind string, ns string, name string) (string, error) {
log.Debugf("Describing resource: %s %s in %s", kind, name, ns)
streams, _, out, errout := genericclioptions.NewTestIOStreams()
o := &describecmd.DescribeOptions{
Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return describe.DescriberFn(k.RestClientGetter, mapping)
},
FilenameOptions: &resource.FilenameOptions{},
DescriberSettings: &describe.DescriberSettings{
ShowEvents: true,
ChunkSize: cmdutil.DefaultChunkSize,
},
IOStreams: streams,
NewBuilder: k.Factory.NewBuilder,
}
o.Namespace = ns
o.BuilderArgs = []string{kind, name}
err := o.Run()
if err != nil {
return "", errorx.Decorate(err, "Failed to run describe command: %s", errout.String())
}
return out.String(), nil
}
func (k *K8s) GetResource(kind string, namespace string, name string) (*runtime.Object, error) {
builder := k.Factory.NewBuilder()
builder = builder.Unstructured().SingleResourceType()
if namespace != "" {
builder = builder.NamespaceParam(namespace)
} else {
builder = builder.DefaultNamespace()
}
resp := builder.Flatten().ResourceNames(kind, name).Do()
if resp.Err() != nil {
return nil, errorx.Decorate(resp.Err(), "failed to get k8s resource")
}
obj, err := resp.Object()
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s resulting object")
}
return &obj, nil
}
func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*testapiv1.Carp, error) {
// TODO: mutex to avoid a lot of requests?
obj, err := k.GetResource(kind, namespace, name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return nil, errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
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" {
return false
}
if res.Status.Conditions[j].Type == "Available" {
return true
}
t1 := res.Status.Conditions[i].LastTransitionTime
t2 := res.Status.Conditions[j].LastTransitionTime
return t1.Time.Before(t2.Time)
})
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 {
return "", errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
res := map[string]interface{}{}
err = json.Unmarshal(data, &res)
if err != nil {
return "", errorx.Decorate(err, "failed to decode k8s object from JSON")
}
ydata, err := yaml.Marshal(res)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
return string(ydata), nil
}