allow scaling custom resource (#2833)

mine
Jayson Wang 2025-02-17 01:46:47 +08:00 committed by GitHub
parent bd4a8ca1f6
commit 6881892433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 220 additions and 73 deletions

View File

@ -5,11 +5,11 @@ package dao
import ( import (
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"github.com/derailed/k9s/internal/client"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -17,13 +17,16 @@ import (
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"github.com/derailed/k9s/internal/client"
) )
const ( const (
crdCat = "crd" crdCat = "crd"
k9sCat = "k9s" k9sCat = "k9s"
helmCat = "helm" helmCat = "helm"
crdGVR = "apiextensions.k8s.io/v1/customresourcedefinitions" scaleCat = "scale"
crdGVR = "apiextensions.k8s.io/v1/customresourcedefinitions"
) )
// MetaAccess tracks resources metadata. // MetaAccess tracks resources metadata.
@ -93,7 +96,7 @@ func AccessorFor(f Factory, gvr client.GVR) (Accessor, error) {
r, ok := m[gvr] r, ok := m[gvr]
if !ok { if !ok {
r = new(Generic) r = new(Scaler)
log.Debug().Msgf("No DAO registry entry for %q. Using generics!", gvr) log.Debug().Msgf("No DAO registry entry for %q. Using generics!", gvr)
} }
r.Init(f, 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 // IsCRD checks if resource represents a CRD
func IsCRD(r metav1.APIResource) bool { func IsCRD(r metav1.APIResource) bool {
for _, c := range r.Categories { return slices.Contains(r.Categories, crdCat)
if c == crdCat {
return true
}
}
return false
} }
// IsK8sMeta checks for non resource meta. // IsK8sMeta checks for non resource meta.
func IsK8sMeta(m metav1.APIResource) bool { func IsK8sMeta(m metav1.APIResource) bool {
for _, c := range m.Categories { return !slices.ContainsFunc(m.Categories, func(category string) bool {
if c == k9sCat || c == helmCat { return category == k9sCat || category == helmCat
return false })
}
}
return true
} }
// IsK9sMeta checks for non resource meta. // IsK9sMeta checks for non resource meta.
func IsK9sMeta(m metav1.APIResource) bool { func IsK9sMeta(m metav1.APIResource) bool {
for _, c := range m.Categories { return slices.Contains(m.Categories, k9sCat)
if c == k9sCat { }
return true
}
}
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. // LoadResources hydrates server preferred+CRDs resource metadata.
@ -191,6 +184,9 @@ func (m *Meta) LoadResources(f Factory) error {
return err return err
} }
loadNonResource(m.resMetas) 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) loadCRDs(f, m.resMetas)
return nil return nil
@ -401,11 +397,13 @@ func isDeprecated(gvr client.GVR) bool {
return ok return ok
} }
// loadCRDs Wait for the cache to synced and then add some additional properties to CRD.
func loadCRDs(f Factory, m ResourceMetas) { func loadCRDs(f Factory, m ResourceMetas) {
if f.Client() == nil || !f.Client().ConnectionOK() { if f.Client() == nil || !f.Client().ConnectionOK() {
return return
} }
oo, err := f.List(crdGVR, client.ClusterScope, false, labels.Everything())
oo, err := f.List(crdGVR, client.ClusterScope, true, labels.Everything())
if err != nil { if err != nil {
log.Warn().Err(err).Msgf("Fail CRDs load") log.Warn().Err(err).Msgf("Fail CRDs load")
return return
@ -419,34 +417,35 @@ func loadCRDs(f Factory, m ResourceMetas) {
continue continue
} }
var meta metav1.APIResource if gvr, version, ok := newGVRFromCRD(&crd); ok {
meta.Kind = crd.Spec.Names.Kind if meta, ok := m[gvr]; ok && version.Subresources != nil && version.Subresources.Scale != nil {
meta.Group = crd.Spec.Group if !slices.Contains(meta.Categories, scaleCat) {
// Since CRD names are cluster scoped they need to be unique, however, it is allowed meta.Categories = append(meta.Categories, scaleCat)
// to have the CRDs with the same names in different groups. Because of that, the m[gvr] = meta
// 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
} }
} }
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) { func extractMeta(o runtime.Object) (metav1.APIResource, []error) {
var ( var (
m metav1.APIResource m metav1.APIResource

81
internal/dao/scalable.go Normal file
View File

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

View File

@ -8,14 +8,15 @@ import (
"io" "io"
"time" "time"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/watch"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/informers" "k8s.io/client-go/informers"
restclient "k8s.io/client-go/rest" 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. // ResourceMetas represents a collection of resource metadata.
@ -124,6 +125,12 @@ type Scalable interface {
Scale(ctx context.Context, path string, replicas int32) error 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. // Controller represents a pod controller.
type Controller interface { type Controller interface {
// Pod returns a pod instance matching the selector. // Pod returns a pod instance matching the selector.

View File

@ -11,11 +11,12 @@ import (
"strings" "strings"
"sync" "sync"
"github.com/rs/zerolog/log"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/k9s/internal/view/cmd"
"github.com/rs/zerolog/log"
) )
var ( var (
@ -293,7 +294,7 @@ func (c *Command) viewMetaFor(p *cmd.Interpreter) (client.GVR, *MetaViewer, erro
v := MetaViewer{ v := MetaViewer{
viewerFn: func(gvr client.GVR) ResourceViewer { viewerFn: func(gvr client.GVR) ResourceViewer {
return NewOwnerExtender(NewBrowser(gvr)) return NewScaleExtender(NewOwnerExtender(NewBrowser(gvr)))
}, },
} }
if mv, ok := customViewers[gvr]; ok { if mv, ok := customViewers[gvr]; ok {

View File

@ -6,9 +6,10 @@ package view_test
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/view" "github.com/derailed/k9s/internal/view"
"github.com/stretchr/testify/assert"
) )
func TestDeploy(t *testing.T) { func TestDeploy(t *testing.T) {

View File

@ -9,11 +9,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/tcell/v2" "github.com/derailed/tcell/v2"
"github.com/derailed/tview" "github.com/derailed/tview"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/ui"
) )
// ScaleExtender adds scaling extensions. // ScaleExtender adds scaling extensions.
@ -33,12 +34,21 @@ func (s *ScaleExtender) bindKeys(aa *ui.KeyActions) {
if s.App().Config.K9s.IsReadOnly() { if s.App().Config.K9s.IsReadOnly() {
return return
} }
aa.Add(ui.KeyS, ui.NewKeyActionWithOpts("Scale", s.scaleCmd,
ui.ActionOpts{ meta, err := dao.MetaAccess.MetaFor(s.GVR())
Visible: true, if err != nil {
Dangerous: true, 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 { 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 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) { func (s *ScaleExtender) makeScaleForm(sels []string) (*tview.Form, error) {
factor := "0" factor := "0"
if len(sels) == 1 { if len(sels) == 1 {
replicas, err := s.valueOf("READY") // If the CRD resource supports scaling, then first try to
if err != nil { // read the replicas directly from the CRD.
return nil, err 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 { // For built-in resources or cases where we can't get the replicas from the CRD, we can
return nil, fmt.Errorf("unable to locate replicas from %s", replicas) // 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() styles := s.App().Styles.Dialog()
@ -127,7 +183,7 @@ func (s *ScaleExtender) makeScaleForm(sels []string) (*tview.Form, error) {
return return
} }
} }
if len(sels) == 1 { if len(sels) != 1 {
s.App().Flash().Infof("[%d] %s scaled successfully", len(sels), singularize(s.GVR().R())) s.App().Flash().Infof("[%d] %s scaled successfully", len(sels), singularize(s.GVR().R()))
} else { } else {
s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), sels[0]) s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), sels[0])

View File

@ -6,9 +6,10 @@ package view_test
import ( import (
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/view" "github.com/derailed/k9s/internal/view"
"github.com/stretchr/testify/assert"
) )
func TestStatefulSetNew(t *testing.T) { func TestStatefulSetNew(t *testing.T) {

View File

@ -9,7 +9,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/derailed/k9s/internal/client"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -17,6 +16,8 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
di "k8s.io/client-go/dynamic/dynamicinformer" di "k8s.io/client-go/dynamic/dynamicinformer"
"k8s.io/client-go/informers" "k8s.io/client-go/informers"
"github.com/derailed/k9s/internal/client"
) )
const ( const (
@ -47,7 +48,7 @@ func (f *Factory) Start(ns string) {
f.mx.Lock() f.mx.Lock()
defer f.mx.Unlock() 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{}) f.stopChan = make(chan struct{})
for ns, fac := range f.factories { for ns, fac := range f.factories {
log.Debug().Msgf("Starting factory in ns %q", ns) log.Debug().Msgf("Starting factory in ns %q", ns)