diff --git a/internal/dao/registry.go b/internal/dao/registry.go index d7ac54ad..0e707a8c 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -5,11 +5,11 @@ package dao import ( "fmt" + "slices" "sort" "strings" "sync" - "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -17,13 +17,16 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/derailed/k9s/internal/client" ) const ( - crdCat = "crd" - k9sCat = "k9s" - helmCat = "helm" - crdGVR = "apiextensions.k8s.io/v1/customresourcedefinitions" + crdCat = "crd" + k9sCat = "k9s" + helmCat = "helm" + scaleCat = "scale" + crdGVR = "apiextensions.k8s.io/v1/customresourcedefinitions" ) // MetaAccess tracks resources metadata. @@ -93,7 +96,7 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) { r, ok := m[gvr] if !ok { - r = new(Generic) + r = new(Scaler) log.Debug().Msgf("No DAO registry entry for %q. Using generics!", gvr) } r.Init(f, gvr) @@ -151,34 +154,24 @@ func (m *Meta) MetaFor(gvr client.GVR) (metav1.APIResource, error) { // IsCRD checks if resource represents a CRD func IsCRD(r metav1.APIResource) bool { - for _, c := range r.Categories { - if c == crdCat { - return true - } - } - return false + return slices.Contains(r.Categories, crdCat) } // IsK8sMeta checks for non resource meta. func IsK8sMeta(m metav1.APIResource) bool { - for _, c := range m.Categories { - if c == k9sCat || c == helmCat { - return false - } - } - - return true + return !slices.ContainsFunc(m.Categories, func(category string) bool { + return category == k9sCat || category == helmCat + }) } // IsK9sMeta checks for non resource meta. func IsK9sMeta(m metav1.APIResource) bool { - for _, c := range m.Categories { - if c == k9sCat { - return true - } - } + return slices.Contains(m.Categories, k9sCat) +} - return false +// IsScalable check if the resource can be scaled +func IsScalable(m metav1.APIResource) bool { + return slices.Contains(m.Categories, scaleCat) } // LoadResources hydrates server preferred+CRDs resource metadata. @@ -191,6 +184,9 @@ func (m *Meta) LoadResources(f Factory) error { return err } loadNonResource(m.resMetas) + + // We've actually loaded all the CRDs in loadPreferred, and we're now adding + // some additional CRD properties on top of that. loadCRDs(f, m.resMetas) return nil @@ -401,11 +397,13 @@ func isDeprecated(gvr client.GVR) bool { return ok } +// loadCRDs Wait for the cache to synced and then add some additional properties to CRD. func loadCRDs(f Factory, m ResourceMetas) { if f.Client() == nil || !f.Client().ConnectionOK() { return } - oo, err := f.List(crdGVR, client.ClusterScope, false, labels.Everything()) + + oo, err := f.List(crdGVR, client.ClusterScope, true, labels.Everything()) if err != nil { log.Warn().Err(err).Msgf("Fail CRDs load") return @@ -419,34 +417,35 @@ func loadCRDs(f Factory, m ResourceMetas) { continue } - var meta metav1.APIResource - meta.Kind = crd.Spec.Names.Kind - meta.Group = crd.Spec.Group - // Since CRD names are cluster scoped they need to be unique, however, it is allowed - // to have the CRDs with the same names in different groups. Because of that, the - // returned `crd.Name` values have the group as a suffix, for example - // "ciliumnetworkpolicies.cilium.io". - // - // `Name` field of `meta/v1/APIResource` is supposed to be the plural name of the - // resource, without the group. Because of that we need to trim the group suffix. - meta.Name = strings.TrimSuffix(crd.Name, "."+meta.Group) - - meta.SingularName = crd.Spec.Names.Singular - meta.ShortNames = crd.Spec.Names.ShortNames - meta.Namespaced = crd.Spec.Scope == apiext.NamespaceScoped - for _, v := range crd.Spec.Versions { - if v.Served && !v.Deprecated { - meta.Version = v.Name - break + if gvr, version, ok := newGVRFromCRD(&crd); ok { + if meta, ok := m[gvr]; ok && version.Subresources != nil && version.Subresources.Scale != nil { + if !slices.Contains(meta.Categories, scaleCat) { + meta.Categories = append(meta.Categories, scaleCat) + m[gvr] = meta + } } } - - meta.Categories = append(meta.Categories, crdCat) - gvr := client.NewGVRFromMeta(meta) - m[gvr] = meta } } +func newGVRFromCRD(crd *apiext.CustomResourceDefinition) (client.GVR, apiext.CustomResourceDefinitionVersion, bool) { + for _, v := range crd.Spec.Versions { + if v.Served && !v.Deprecated { + return client.NewGVRFromMeta(metav1.APIResource{ + Kind: crd.Spec.Names.Kind, + Group: crd.Spec.Group, + Name: crd.Spec.Names.Plural, + Version: v.Name, + ShortNames: crd.Spec.Names.ShortNames, + SingularName: crd.Spec.Names.Plural, + Namespaced: crd.Spec.Scope == apiext.NamespaceScoped, + }), v, true + } + } + + return client.GVR{}, apiext.CustomResourceDefinitionVersion{}, false +} + func extractMeta(o runtime.Object) (metav1.APIResource, []error) { var ( m metav1.APIResource diff --git a/internal/dao/scalable.go b/internal/dao/scalable.go new file mode 100644 index 00000000..7664b535 --- /dev/null +++ b/internal/dao/scalable.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package dao + +import ( + "context" + + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/scale" + + "github.com/derailed/k9s/internal/client" +) + +var _ Scalable = (*Scaler)(nil) +var _ ReplicasGetter = (*Scaler)(nil) + +// Scaler represents a generic resource with scaling. +type Scaler struct { + Generic +} + +// Replicas returns the number of replicas for the resource located at the given path. +func (s *Scaler) Replicas(ctx context.Context, path string) (int32, error) { + scaleClient, err := s.scaleClient() + if err != nil { + return 0, err + } + + ns, name := client.Namespaced(path) + currScale, err := scaleClient.Scales(ns).Get(ctx, *s.gvr.GR(), name, metav1.GetOptions{}) + if err != nil { + return 0, err + } + + return currScale.Spec.Replicas, nil +} + +// Scale modifies the number of replicas for a given resource specified by the path. +func (s *Scaler) Scale(ctx context.Context, path string, replicas int32) error { + ns, name := client.Namespaced(path) + + scaleClient, err := s.scaleClient() + if err != nil { + return err + } + + currentScale, err := scaleClient.Scales(ns).Get(ctx, *s.gvr.GR(), name, metav1.GetOptions{}) + if err != nil { + return err + } + + currentScale.Spec.Replicas = replicas + updatedScale, err := scaleClient.Scales(ns).Update(ctx, *s.gvr.GR(), currentScale, metav1.UpdateOptions{}) + if err != nil { + return err + } + + log.Debug().Msgf("%s scaled to %d", path, updatedScale.Spec.Replicas) + return nil +} + +func (s *Scaler) scaleClient() (scale.ScalesGetter, error) { + cfg, err := s.Client().RestConfig() + if err != nil { + return nil, err + } + + discoveryClient, err := s.Client().CachedDiscovery() + if err != nil { + return nil, err + } + + mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient) + scaleKindResolver := scale.NewDiscoveryScaleKindResolver(discoveryClient) + + return scale.NewForConfig(cfg, mapper, dynamic.LegacyAPIPathResolverFunc, scaleKindResolver) +} diff --git a/internal/dao/types.go b/internal/dao/types.go index eaf9f6db..deb99795 100644 --- a/internal/dao/types.go +++ b/internal/dao/types.go @@ -8,14 +8,15 @@ import ( "io" "time" - "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/watch" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" restclient "k8s.io/client-go/rest" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/watch" ) // ResourceMetas represents a collection of resource metadata. @@ -124,6 +125,12 @@ type Scalable interface { Scale(ctx context.Context, path string, replicas int32) error } +// ReplicasGetter represents a resource with replicas. +type ReplicasGetter interface { + // Replicas returns the number of replicas for the resource located at the given path. + Replicas(ctx context.Context, path string) (int32, error) +} + // Controller represents a pod controller. type Controller interface { // Pod returns a pod instance matching the selector. diff --git a/internal/view/command.go b/internal/view/command.go index 71f8fc7b..bf570f78 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -11,11 +11,12 @@ import ( "strings" "sync" + "github.com/rs/zerolog/log" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/view/cmd" - "github.com/rs/zerolog/log" ) var ( @@ -293,7 +294,7 @@ func (c *Command) viewMetaFor(p *cmd.Interpreter) (client.GVR, *MetaViewer, erro v := MetaViewer{ viewerFn: func(gvr client.GVR) ResourceViewer { - return NewOwnerExtender(NewBrowser(gvr)) + return NewScaleExtender(NewOwnerExtender(NewBrowser(gvr))) }, } if mv, ok := customViewers[gvr]; ok { diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index a3e8a51f..1e2074dc 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -6,9 +6,10 @@ package view_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" - "github.com/stretchr/testify/assert" ) func TestDeploy(t *testing.T) { diff --git a/internal/view/scale_extender.go b/internal/view/scale_extender.go index e10666fa..fd4b7d3a 100644 --- a/internal/view/scale_extender.go +++ b/internal/view/scale_extender.go @@ -9,11 +9,12 @@ import ( "strconv" "strings" - "github.com/derailed/k9s/internal/dao" - "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/rs/zerolog/log" + + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/ui" ) // ScaleExtender adds scaling extensions. @@ -33,12 +34,21 @@ func (s *ScaleExtender) bindKeys(aa *ui.KeyActions) { if s.App().Config.K9s.IsReadOnly() { return } - aa.Add(ui.KeyS, ui.NewKeyActionWithOpts("Scale", s.scaleCmd, - ui.ActionOpts{ - Visible: true, - Dangerous: true, - }, - )) + + meta, err := dao.MetaAccess.MetaFor(s.GVR()) + if err != nil { + log.Error().Err(err).Msgf("Unable to retrieve meta information for %s", s.GVR()) + return + } + + if !dao.IsCRD(meta) || dao.IsScalable(meta) { + aa.Add(ui.KeyS, ui.NewKeyActionWithOpts("Scale", s.scaleCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + )) + } } func (s *ScaleExtender) scaleCmd(evt *tcell.EventKey) *tcell.EventKey { @@ -81,18 +91,64 @@ func (s *ScaleExtender) valueOf(col string) (string, error) { return s.GetTable().GetSelectedCell(colIdx), nil } +func (s *ScaleExtender) replicasFromReady(_ string) (string, error) { + replicas, err := s.valueOf("READY") + if err != nil { + return "", err + } + + tokens := strings.Split(replicas, "/") + if len(tokens) < 2 { + return "", fmt.Errorf("unable to locate replicas from %s", replicas) + } + + return strings.TrimRight(tokens[1], ui.DeltaSign), nil +} + +func (s *ScaleExtender) replicasFromScaleSubresource(sel string) (string, error) { + res, err := dao.AccessorFor(s.App().factory, s.GVR()) + if err != nil { + return "", err + } + + replicasGetter, ok := res.(dao.ReplicasGetter) + if !ok { + return "", fmt.Errorf("expecting a replicasGetter resource for %q", s.GVR()) + } + + ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) + defer cancel() + + replicas, err := replicasGetter.Replicas(ctx, sel) + if err != nil { + return "", err + } + + return strconv.Itoa(int(replicas)), nil +} + func (s *ScaleExtender) makeScaleForm(sels []string) (*tview.Form, error) { factor := "0" if len(sels) == 1 { - replicas, err := s.valueOf("READY") - if err != nil { - return nil, err + // If the CRD resource supports scaling, then first try to + // read the replicas directly from the CRD. + if meta, _ := dao.MetaAccess.MetaFor(s.GVR()); dao.IsScalable(meta) { + replicas, err := s.replicasFromScaleSubresource(sels[0]) + if err == nil && len(replicas) != 0 { + factor = replicas + } } - tokens := strings.Split(replicas, "/") - if len(tokens) < 2 { - return nil, fmt.Errorf("unable to locate replicas from %s", replicas) + + // For built-in resources or cases where we can't get the replicas from the CRD, we can + // only try to get the number of copies from the READY field. + if factor == "0" { + replicas, err := s.replicasFromReady(sels[0]) + if err != nil { + return nil, err + } + + factor = replicas } - factor = strings.TrimRight(tokens[1], ui.DeltaSign) } styles := s.App().Styles.Dialog() @@ -127,7 +183,7 @@ func (s *ScaleExtender) makeScaleForm(sels []string) (*tview.Form, error) { return } } - if len(sels) == 1 { + if len(sels) != 1 { s.App().Flash().Infof("[%d] %s scaled successfully", len(sels), singularize(s.GVR().R())) } else { s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), sels[0]) diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 44551945..256587c6 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -6,9 +6,10 @@ package view_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" - "github.com/stretchr/testify/assert" ) func TestStatefulSetNew(t *testing.T) { diff --git a/internal/watch/factory.go b/internal/watch/factory.go index eccbd38f..014ecfb1 100644 --- a/internal/watch/factory.go +++ b/internal/watch/factory.go @@ -9,7 +9,6 @@ import ( "sync" "time" - "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -17,6 +16,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" di "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/informers" + + "github.com/derailed/k9s/internal/client" ) const ( @@ -47,7 +48,7 @@ func (f *Factory) Start(ns string) { f.mx.Lock() defer f.mx.Unlock() - log.Debug().Msgf("Factory START with ns `%q", ns) + log.Debug().Msgf("Factory START with ns %q", ns) f.stopChan = make(chan struct{}) for ns, fac := range f.factories { log.Debug().Msgf("Starting factory in ns %q", ns)