diff --git a/internal/k8s/mapper.go b/internal/k8s/mapper.go index 7335d21b..187e9149 100644 --- a/internal/k8s/mapper.go +++ b/internal/k8s/mapper.go @@ -167,6 +167,11 @@ var resMap = map[string]*meta.RESTMapping{ GroupVersionKind: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, Scope: RestMapping, }, + "StorageClasses": { + Resource: schema.GroupVersionResource{Group: "storage.k8s.io", Version: "v1", Resource: "storageclass"}, + GroupVersionKind: schema.GroupVersionKind{Group: "storage.k8s.io", Version: "v1", Kind: "StorageClass"}, + Scope: RestMapping, + }, "ServiceAccounts": { Resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccount"}, GroupVersionKind: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ServiceAccount"}, diff --git a/internal/k8s/sc.go b/internal/k8s/sc.go new file mode 100644 index 00000000..4b3014aa --- /dev/null +++ b/internal/k8s/sc.go @@ -0,0 +1,45 @@ +package k8s + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StorageClass represents a Kubernetes StorageClass. +type StorageClass struct { + *base + Connection +} + +// NewStorageClass returns a new StorageClass. +func NewStorageClass(c Connection) *StorageClass { + return &StorageClass{&base{}, c} +} + +// Get a StorageClass. +func (p *StorageClass) Get(_, n string) (interface{}, error) { + return p.DialOrDie().StorageV1().StorageClasses().Get(n, metav1.GetOptions{}) +} + +// List all StorageClasses in a given namespace. +func (p *StorageClass) List(_ string) (Collection, error) { + opts := metav1.ListOptions{ + LabelSelector: p.labelSelector, + FieldSelector: p.fieldSelector, + } + rr, err := p.DialOrDie().StorageV1().StorageClasses().List(opts) + if err != nil { + return nil, err + } + + cc := make(Collection, len(rr.Items)) + for i, r := range rr.Items { + cc[i] = r + } + + return cc, nil +} + +// Delete a StorageClass. +func (p *StorageClass) Delete(_, n string, cascade, force bool) error { + return p.DialOrDie().StorageV1().StorageClasses().Delete(n, nil) +} diff --git a/internal/resource/sc.go b/internal/resource/sc.go new file mode 100644 index 00000000..8b547c34 --- /dev/null +++ b/internal/resource/sc.go @@ -0,0 +1,87 @@ +package resource + +import ( + "github.com/derailed/k9s/internal/k8s" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/storage/v1" +) + +// StorageClass tracks a kubernetes resource. +type StorageClass struct { + *Base + instance *v1.StorageClass +} + +// NewStorageClassList returns a new resource list. +func NewStorageClassList(c Connection, ns string) List { + return NewList( + NotNamespaced, + "sc", + NewStorageClass(c), + CRUDAccess|DescribeAccess, + ) +} + +// NewStorageClass instantiates a new StorageClass. +func NewStorageClass(c Connection) *StorageClass { + p := &StorageClass{&Base{Connection: c, Resource: k8s.NewStorageClass(c)}, nil} + p.Factory = p + + return p +} + +// New builds a new StorageClass instance from a k8s resource. +func (r *StorageClass) New(i interface{}) Columnar { + c := NewStorageClass(r.Connection) + switch instance := i.(type) { + case *v1.StorageClass: + c.instance = instance + case v1.StorageClass: + c.instance = &instance + default: + log.Fatal().Msgf("unknown StorageClass type %#v", i) + } + c.path = c.namespacedName(c.instance.ObjectMeta) + + return c +} + +// Marshal resource to yaml. +func (r *StorageClass) Marshal(path string) (string, error) { + ns, n := Namespaced(path) + i, err := r.Resource.Get(ns, n) + if err != nil { + return "", err + } + + sc := i.(*v1.StorageClass) + sc.TypeMeta.APIVersion = "storage.k8s.io/v1" + sc.TypeMeta.Kind = "StorageClass" + + return r.marshalObject(sc) +} + +// Header return resource header. +func (*StorageClass) Header(ns string) Row { + hh := Row{} + if ns == AllNamespaces { + hh = append(hh, "NAMESPACE") + } + + return append(hh, "NAME", "PROVISIONER", "AGE") +} + +// Fields retrieves displayable fields. +func (r *StorageClass) Fields(ns string) Row { + ff := make(Row, 0, len(r.Header(ns))) + i := r.instance + if ns == AllNamespaces { + ff = append(ff, i.Namespace) + } + + return append(ff, + i.Name, + string(i.Provisioner), + toAge(i.ObjectMeta.CreationTimestamp), + ) +} diff --git a/internal/resource/sc_test.go b/internal/resource/sc_test.go new file mode 100644 index 00000000..43520bc8 --- /dev/null +++ b/internal/resource/sc_test.go @@ -0,0 +1,104 @@ +package resource_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/k8s" + "github.com/derailed/k9s/internal/resource" + m "github.com/petergtz/pegomock" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewSCListWithArgs(ns string, r *resource.StorageClass) resource.List { + return resource.NewList(resource.NotNamespaced, "sc", r, resource.CRUDAccess|resource.DescribeAccess) +} + +func NewSCWithArgs(conn k8s.Connection, res resource.Cruder) *resource.StorageClass { + r := &resource.StorageClass{Base: resource.NewBase(conn, res)} + r.Factory = r + return r +} + +func TestSCListAccess(t *testing.T) { + mc := NewMockConnection() + mr := NewMockCruder() + + ns := "blee" + l := NewSCListWithArgs(resource.AllNamespaces, NewSCWithArgs(mc, mr)) + l.SetNamespace(ns) + + assert.Equal(t, resource.NotNamespaced, l.GetNamespace()) + assert.Equal(t, "sc", l.GetName()) + for _, a := range []int{resource.GetAccess, resource.ListAccess, resource.DeleteAccess, resource.ViewAccess, resource.EditAccess} { + assert.True(t, l.Access(a)) + } +} + +func TestSCFields(t *testing.T) { + r := newSC().Fields("blee") + assert.Equal(t, "storage-test", r[0]) +} + +func TestSCMarshal(t *testing.T) { + mc := NewMockConnection() + mr := NewMockCruder() + m.When(mr.Get("blee", "storage-test")).ThenReturn(k8sSC(), nil) + + cm := NewSCWithArgs(mc, mr) + ma, err := cm.Marshal("blee/storage-test") + mr.VerifyWasCalledOnce().Get("blee", "storage-test") + assert.Nil(t, err) + assert.Equal(t, scYaml(), ma) +} + +func TestSCListData(t *testing.T) { + mc := NewMockConnection() + mr := NewMockCruder() + m.When(mr.List(resource.NotNamespaced)).ThenReturn(k8s.Collection{*k8sSC()}, nil) + + l := NewSCListWithArgs("-", NewSCWithArgs(mc, mr)) + // Make sure we mrn get deltas! + for i := 0; i < 2; i++ { + err := l.Reconcile(nil, nil) + assert.Nil(t, err) + } + + mr.VerifyWasCalled(m.Times(2)).List(resource.NotNamespaced) + td := l.Data() + assert.Equal(t, 1, len(td.Rows)) + assert.Equal(t, resource.NotNamespaced, l.GetNamespace()) + row := td.Rows["storage-test"] + assert.Equal(t, 3, len(row.Deltas)) + for _, d := range row.Deltas { + assert.Equal(t, "", d) + } + assert.Equal(t, resource.Row{"storage-test"}, row.Fields[:1]) +} + +// Helpers... + +func k8sSC() *v1.StorageClass { + return &v1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "storage-test", + CreationTimestamp: metav1.Time{Time: testTime()}, + }, + } +} + +func newSC() resource.Columnar { + mc := NewMockConnection() + return resource.NewStorageClass(mc).New(k8sSC()) +} + +func scYaml() string { + return `apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + creationTimestamp: "2018-12-14T17:36:43Z" + name: storage-test +provisioner: "" +` +} diff --git a/internal/views/alias_test.go b/internal/views/alias_test.go index f6512474..60471de4 100644 --- a/internal/views/alias_test.go +++ b/internal/views/alias_test.go @@ -13,6 +13,6 @@ func TestAliasView(t *testing.T) { v.Init(nil, "") assert.Equal(t, 3, len(td.Header)) - assert.Equal(t, 32, len(td.Rows)) + assert.Equal(t, 33, len(td.Rows)) assert.Equal(t, "Aliases", v.getTitle()) } diff --git a/internal/views/registrar.go b/internal/views/registrar.go index 23497477..7cfaf9dd 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -174,6 +174,14 @@ func stateRes(m map[string]resCmd) { viewFn: newSecretView, listFn: resource.NewSecretList, } + m["sc"] = resCmd{ + title: "StorageClasses", + crdCmd: crdCmd{ + api: "storage.k8s.io", + }, + viewFn: newResourceView, + listFn: resource.NewStorageClassList, + } } func primRes(m map[string]resCmd) {