allow scaling custom resource (#2833)
parent
bd4a8ca1f6
commit
6881892433
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue