From 860728c083a5372aac163c78962e4711e2f76112 Mon Sep 17 00:00:00 2001 From: derailed Date: Sat, 18 Jan 2020 08:13:36 -0700 Subject: [PATCH] checkpoint --- internal/client/helpers.go | 12 +- internal/config/{style.go => styles.go} | 0 .../config/{style_test.go => styles_test.go} | 0 internal/dao/container.go | 2 +- internal/dao/generic.go | 23 +- internal/dao/node.go | 13 +- internal/dao/pod.go | 24 +- internal/dao/registry.go | 5 + internal/dao/resource.go | 2 - internal/dao/table.go | 27 + internal/model/registry.go | 31 +- internal/model/table.go | 23 +- internal/model/table_int_test.go | 10 +- internal/model/tree.go | 298 +++++++++++ internal/model/types.go | 9 +- internal/render/chart.go | 2 +- internal/render/cr.go | 3 +- internal/render/crb.go | 3 +- internal/render/crd.go | 2 +- internal/render/cronjob.go | 2 +- internal/render/dp.go | 2 +- internal/render/ds.go | 2 +- internal/render/ep.go | 2 +- internal/render/ev.go | 2 +- internal/render/generic.go | 2 +- internal/render/helpers.go | 26 - internal/render/helpers_test.go | 7 +- internal/render/hpa.go | 6 +- internal/render/ing.go | 2 +- internal/render/job.go | 2 +- internal/render/node.go | 3 +- internal/render/np.go | 2 +- internal/render/ns.go | 3 +- internal/render/pdb.go | 2 +- internal/render/pod.go | 8 +- internal/render/policy.go | 5 +- internal/render/portforward.go | 3 +- internal/render/pv.go | 3 +- internal/render/pvc.go | 2 +- internal/render/ro.go | 2 +- internal/render/rob.go | 2 +- internal/render/rs.go | 2 +- internal/render/sa.go | 2 +- internal/render/sc.go | 2 +- internal/render/sts.go | 2 +- internal/render/svc.go | 2 +- internal/ui/table.go | 8 +- internal/ui/table_helper.go | 8 +- internal/ui/table_test.go | 1 + internal/ui/types.go | 2 + internal/view/actions.go | 10 +- internal/view/alias_test.go | 1 + internal/view/app.go | 10 +- internal/view/browser.go | 4 + internal/view/cluster_info.go | 2 +- internal/view/command.go | 62 ++- internal/view/env.go | 42 +- internal/view/env_test.go | 6 +- internal/view/logs_extender.go | 3 +- internal/view/table_int_test.go | 1 + internal/view/types.go | 1 + internal/view/xray.go | 484 ++++++++++++++++++ internal/xray/container.go | 101 ++++ internal/xray/container_test.go | 244 +++++++++ internal/xray/dp.go | 94 ++++ internal/xray/dp_test.go | 40 ++ internal/xray/ds.go | 67 +++ internal/xray/ds_test.go | 40 ++ internal/xray/generic.go | 67 +++ internal/xray/generic_test.go | 44 ++ internal/xray/ns.go | 35 ++ internal/xray/ns_test.go | 38 ++ internal/xray/pod.go | 178 +++++++ internal/xray/pod_test.go | 194 +++++++ internal/xray/sts.go | 87 ++++ internal/xray/sts_test.go | 40 ++ internal/xray/svc.go | 82 +++ internal/xray/svc_test.go | 40 ++ internal/xray/test_assets/dp.json | 118 +++++ internal/xray/test_assets/ds.json | 109 ++++ internal/xray/test_assets/init.json | 187 +++++++ internal/xray/test_assets/ns.json | 19 + internal/xray/test_assets/po.json | 140 +++++ internal/xray/test_assets/sts.json | 78 +++ internal/xray/test_assets/svc.json | 35 ++ internal/xray/tree_node.go | 369 +++++++++++++ internal/xray/tree_node_test.go | 427 +++++++++++++++ skins/dracula.yml | 73 +++ 88 files changed, 4039 insertions(+), 141 deletions(-) rename internal/config/{style.go => styles.go} (100%) rename internal/config/{style_test.go => styles_test.go} (100%) create mode 100644 internal/model/tree.go create mode 100644 internal/view/xray.go create mode 100644 internal/xray/container.go create mode 100644 internal/xray/container_test.go create mode 100644 internal/xray/dp.go create mode 100644 internal/xray/dp_test.go create mode 100644 internal/xray/ds.go create mode 100644 internal/xray/ds_test.go create mode 100644 internal/xray/generic.go create mode 100644 internal/xray/generic_test.go create mode 100644 internal/xray/ns.go create mode 100644 internal/xray/ns_test.go create mode 100644 internal/xray/pod.go create mode 100644 internal/xray/pod_test.go create mode 100644 internal/xray/sts.go create mode 100644 internal/xray/sts_test.go create mode 100644 internal/xray/svc.go create mode 100644 internal/xray/svc_test.go create mode 100644 internal/xray/test_assets/dp.json create mode 100644 internal/xray/test_assets/ds.json create mode 100644 internal/xray/test_assets/init.json create mode 100644 internal/xray/test_assets/ns.json create mode 100644 internal/xray/test_assets/po.json create mode 100644 internal/xray/test_assets/sts.json create mode 100644 internal/xray/test_assets/svc.json create mode 100644 internal/xray/tree_node.go create mode 100644 internal/xray/tree_node_test.go create mode 100644 skins/dracula.yml diff --git a/internal/client/helpers.go b/internal/client/helpers.go index 522db7b4..581b30e1 100644 --- a/internal/client/helpers.go +++ b/internal/client/helpers.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var toFileName = regexp.MustCompile(`[^(\w/\.)]`) @@ -37,7 +38,7 @@ func IsAllNamespaces(ns string) bool { // IsNamespaced returns true if a specific ns is given. func IsNamespaced(ns string) bool { - return !IsClusterScoped(ns) + return !IsAllNamespaces(ns) } // IsClusterScoped returns true if resource is not namespaced. @@ -60,6 +61,15 @@ func FQN(ns, n string) string { return ns + "/" + n } +// MetaFQN returns a fully qualified resource name. +func MetaFQN(m metav1.ObjectMeta) string { + if m.Namespace == "" { + return FQN(ClusterScope, m.Name) + } + + return FQN(m.Namespace, m.Name) +} + func mustHomeDir() string { usr, err := user.Current() if err != nil { diff --git a/internal/config/style.go b/internal/config/styles.go similarity index 100% rename from internal/config/style.go rename to internal/config/styles.go diff --git a/internal/config/style_test.go b/internal/config/styles_test.go similarity index 100% rename from internal/config/style_test.go rename to internal/config/styles_test.go diff --git a/internal/dao/container.go b/internal/dao/container.go index b2411c90..12a6cec2 100644 --- a/internal/dao/container.go +++ b/internal/dao/container.go @@ -39,7 +39,7 @@ func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error return nil, err } - ns, _ := render.Namespaced(fqn) + ns, _ := client.Namespaced(fqn) var pmx *mv1beta1.PodMetrics if c.Client().HasMetrics() { mx := client.NewMetricsServer(c.Client()) diff --git a/internal/dao/generic.go b/internal/dao/generic.go index bb5cb8ec..dce3a958 100644 --- a/internal/dao/generic.go +++ b/internal/dao/generic.go @@ -16,6 +16,8 @@ import ( var _ Describer = (*Generic)(nil) +var defaultKillGrace int64 = 0 + // Generic represents a generic resource. type Generic struct { NonResource @@ -37,10 +39,10 @@ func (g *Generic) List(ctx context.Context, ns string) ([]runtime.Object, error) ll *unstructured.UnstructuredList err error ) - if client.IsNamespaced(ns) { - ll, err = g.dynClient().Namespace(ns).List(metav1.ListOptions{LabelSelector: labelSel}) - } else { + if client.IsClusterScoped(ns) { ll, err = g.dynClient().List(metav1.ListOptions{LabelSelector: labelSel}) + } else { + ll, err = g.dynClient().Namespace(ns).List(metav1.ListOptions{LabelSelector: labelSel}) } if err != nil { return nil, err @@ -59,12 +61,12 @@ func (g *Generic) Get(ctx context.Context, path string) (runtime.Object, error) var opts metav1.GetOptions ns, n := client.Namespaced(path) - req := g.dynClient() + dial := g.dynClient() if client.IsClusterScoped(ns) { - return req.Get(n, opts) + return dial.Get(n, opts) } - return req.Namespace(ns).Get(n, opts) + return dial.Namespace(ns).Get(n, opts) } // Describe describes a resource. @@ -98,7 +100,14 @@ func (g *Generic) Delete(path string, cascade, force bool) error { if cascade { p = metav1.DeletePropagationBackground } - opts := metav1.DeleteOptions{PropagationPolicy: &p} + var grace *int64 + if force { + grace = &defaultKillGrace + } + opts := metav1.DeleteOptions{ + PropagationPolicy: &p, + GracePeriodSeconds: grace, + } if client.IsClusterScoped(ns) { return g.dynClient().Delete(n, &opts) } diff --git a/internal/dao/node.go b/internal/dao/node.go index e687a815..338a2f77 100644 --- a/internal/dao/node.go +++ b/internal/dao/node.go @@ -30,12 +30,17 @@ type Node struct { func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) { log.Debug().Msgf("NODE-LIST %q:%q", ns, n.gvr) + labels, ok := ctx.Value(internal.KeyLabels).(string) + if !ok { + log.Warn().Msgf("No label selector found in context") + } + nmx, ok := ctx.Value(internal.KeyMetrics).(*mv1beta1.NodeMetricsList) if !ok { log.Warn().Msgf("No node metrics available in context") } - nn, err := FetchNodes(n.Factory) + nn, err := FetchNodes(n.Factory, labels) if err != nil { return nil, err } @@ -59,13 +64,15 @@ func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) { // Helpers... // FetchNodes retrieves all nodes. -func FetchNodes(f Factory) (*v1.NodeList, error) { +func FetchNodes(f Factory, labelsSel string) (*v1.NodeList, error) { auth, err := f.Client().CanI("", "v1/nodes", []string{client.ListVerb}) if !auth || err != nil { return nil, err } - return f.Client().DialOrDie().CoreV1().Nodes().List(metav1.ListOptions{}) + return f.Client().DialOrDie().CoreV1().Nodes().List(metav1.ListOptions{ + LabelSelector: labelsSel, + }) } func nodeMetricsFor(fqn string, mmx *mv1beta1.NodeMetricsList) *mv1beta1.NodeMetrics { diff --git a/internal/dao/pod.go b/internal/dao/pod.go index 72a05a61..83e7ad71 100644 --- a/internal/dao/pod.go +++ b/internal/dao/pod.go @@ -37,13 +37,28 @@ type Pod struct { Resource } -// List returns a collection of nodes. -func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { +// Get returns a resource instance if found, else an error. +func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { + o, err := p.Resource.Get(ctx, path) + if err != nil { + return o, err + } + + u, ok := o.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) + } + pmx, ok := ctx.Value(internal.KeyMetrics).(*mv1beta1.PodMetricsList) if !ok { log.Warn().Msgf("no metrics available for %q", p.gvr) } + return &render.PodWithMetrics{Raw: u, MX: podMetricsFor(o, pmx)}, nil +} + +// List returns a collection of nodes. +func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { sel, ok := ctx.Value(internal.KeyFields).(string) if !ok { return nil, fmt.Errorf("expecting a fieldSelector in context") @@ -59,6 +74,11 @@ func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { return oo, err } + pmx, ok := ctx.Value(internal.KeyMetrics).(*mv1beta1.PodMetricsList) + if !ok { + log.Warn().Msgf("no metrics available for %q", p.gvr) + } + var res []runtime.Object for _, o := range oo { u, ok := o.(*unstructured.Unstructured) diff --git a/internal/dao/registry.go b/internal/dao/registry.go index f4cad67f..a308e498 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -112,6 +112,11 @@ func loadNonResource(m ResourceMetas) { } func loadK9s(m ResourceMetas) { + m[client.NewGVR("xrays")] = metav1.APIResource{ + Name: "xray", + Kind: "XRays", + Categories: []string{"k9s"}, + } m[client.NewGVR("aliases")] = metav1.APIResource{ Name: "aliases", Kind: "Aliases", diff --git a/internal/dao/resource.go b/internal/dao/resource.go index 07d12579..36e04c17 100644 --- a/internal/dao/resource.go +++ b/internal/dao/resource.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/derailed/k9s/internal" - "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) @@ -23,7 +22,6 @@ type Resource struct { // List returns a collection of resources. func (r *Resource) List(ctx context.Context, ns string) ([]runtime.Object, error) { - log.Debug().Msgf("INF-LIST %q:%q", ns, r.gvr) strLabel, ok := ctx.Value(internal.KeyLabels).(string) lsel := labels.Everything() if sel, err := labels.ConvertSelectorToLabelsMap(strLabel); ok && err == nil { diff --git a/internal/dao/table.go b/internal/dao/table.go index 4fe6a14c..11c4c6fb 100644 --- a/internal/dao/table.go +++ b/internal/dao/table.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/derailed/k9s/internal/client" "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" @@ -17,6 +18,32 @@ type Table struct { Generic } +// Get returns a given resource. +func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { + ns, n := client.Namespaced(path) + + log.Debug().Msgf("TABLE-GET %q:%q", ns, t.gvr) + a := fmt.Sprintf(gvFmt, metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName) + _, codec := t.codec() + + c, err := t.getClient() + if err != nil { + return nil, err + } + o, err := c.Get(). + SetHeader("Accept", a). + Namespace(ns). + Name(n). + Resource(t.gvr.ToR()). + VersionedParams(&metav1beta1.TableOptions{}, codec). + Do().Get() + if err != nil { + return nil, err + } + + return o, nil +} + // List all Resources in a given namespace. func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { log.Debug().Msgf("TABLE-LIST %q:%q", ns, t.gvr) diff --git a/internal/model/registry.go b/internal/model/registry.go index 2a606788..51186326 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -3,6 +3,7 @@ package model import ( "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/xray" ) // Registry tracks resources metadata. @@ -14,8 +15,9 @@ var Registry = map[string]ResourceMeta{ Renderer: &render.Chart{}, }, "containers": { - DAO: &dao.Container{}, - Renderer: &render.Container{}, + DAO: &dao.Container{}, + Renderer: &render.Container{}, + TreeRenderer: &xray.Container{}, }, "contexts": { DAO: &dao.Context{}, @@ -62,8 +64,9 @@ var Registry = map[string]ResourceMeta{ Renderer: &render.Event{}, }, "v1/pods": { - DAO: &dao.Pod{}, - Renderer: &render.Pod{}, + DAO: &dao.Pod{}, + Renderer: &render.Pod{}, + TreeRenderer: &xray.Pod{}, }, "v1/namespaces": { Renderer: &render.Namespace{}, @@ -73,8 +76,9 @@ var Registry = map[string]ResourceMeta{ Renderer: &render.Node{}, }, "v1/services": { - DAO: &dao.Service{}, - Renderer: &render.Service{}, + DAO: &dao.Service{}, + Renderer: &render.Service{}, + TreeRenderer: &xray.Service{}, }, "v1/serviceaccounts": { Renderer: &render.ServiceAccount{}, @@ -88,19 +92,22 @@ var Registry = map[string]ResourceMeta{ // Apps... "apps/v1/deployments": { - DAO: &dao.Deployment{}, - Renderer: &render.Deployment{}, + DAO: &dao.Deployment{}, + Renderer: &render.Deployment{}, + TreeRenderer: &xray.Deployment{}, }, "apps/v1/replicasets": { Renderer: &render.ReplicaSet{}, }, "apps/v1/statefulsets": { - DAO: &dao.StatefulSet{}, - Renderer: &render.StatefulSet{}, + DAO: &dao.StatefulSet{}, + Renderer: &render.StatefulSet{}, + TreeRenderer: &xray.StatefulSet{}, }, "apps/v1/daemonsets": { - DAO: &dao.DaemonSet{}, - Renderer: &render.DaemonSet{}, + DAO: &dao.DaemonSet{}, + Renderer: &render.DaemonSet{}, + TreeRenderer: &xray.DaemonSet{}, }, // Extensions... diff --git a/internal/model/table.go b/internal/model/table.go index 5fae6c11..96081082 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -34,6 +34,7 @@ type Table struct { listeners []TableListener inUpdate int32 refreshRate time.Duration + instance string } // NewTable returns a new table model. @@ -45,6 +46,10 @@ func NewTable(gvr string) *Table { } } +func (t *Table) SetInstance(s string) { + t.instance = s +} + // AddListener adds a new model listener. func (t *Table) AddListener(l TableListener) { t.listeners = append(t.listeners, l) @@ -217,25 +222,36 @@ func (t *Table) reconcile(ctx context.Context) error { }(time.Now()) meta := t.resourceMeta() - oo, err := t.list(ctx, meta.DAO) + var ( + oo []runtime.Object + err error + ) + if t.instance == "" { + oo, err = t.list(ctx, meta.DAO) + } else { + o, e := t.Get(ctx, t.instance) + oo, err = []runtime.Object{o}, e + } if err != nil { return err } log.Debug().Msgf(" LIST returned %d rows", len(oo)) var rows render.Rows + ns := client.CleanseNamespace(t.namespace) if _, ok := meta.Renderer.(*render.Generic); ok { table, ok := oo[0].(*metav1beta1.Table) if !ok { return fmt.Errorf("expecting a meta table but got %T", oo[0]) } + log.Debug().Msgf("!!!!YO!!!") rows = make(render.Rows, len(table.Rows)) - if err := genericHydrate(client.CleanseNamespace(t.namespace), table, rows, meta.Renderer); err != nil { + if err := genericHydrate(ns, table, rows, meta.Renderer); err != nil { return err } } else { rows = make(render.Rows, len(oo)) - if err := hydrate(client.CleanseNamespace(t.namespace), oo, rows, meta.Renderer); err != nil { + if err := hydrate(ns, oo, rows, meta.Renderer); err != nil { return err } } @@ -293,6 +309,7 @@ func (t *Table) fireTableLoadFailed(err error) { } } +// ---------------------------------------------------------------------------- // Helpers... func hydrate(ns string, oo []runtime.Object, rr render.Rows, re Renderer) error { diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index de57ada5..19fd80d9 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -58,7 +58,7 @@ func TestTableGet(t *testing.T) { row, err := ta.Get(ctx, "fred") assert.Nil(t, err) assert.NotNil(t, row) - assert.Equal(t, 5, len(row.(*unstructured.Unstructured).Object)) + assert.Equal(t, 5, len(row.(*render.PodWithMetrics).Raw.Object)) } func TestTableMeta(t *testing.T) { @@ -172,6 +172,10 @@ func raw(t *testing.T, n string) []byte { // ---------------------------------------------------------------------------- +func makeFactory() testFactory { + return testFactory{} +} + type testFactory struct { rows []runtime.Object } @@ -205,10 +209,6 @@ func (f testFactory) Forwarders() watch.Forwarders { } func (f testFactory) DeleteForwarder(string) {} -func makeFactory() testFactory { - return testFactory{} -} - // ---------------------------------------------------------------------------- type accessor struct { diff --git a/internal/model/tree.go b/internal/model/tree.go new file mode 100644 index 00000000..b3453c93 --- /dev/null +++ b/internal/model/tree.go @@ -0,0 +1,298 @@ +package model + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync/atomic" + "time" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/xray" + "github.com/rs/zerolog/log" + metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" + "k8s.io/apimachinery/pkg/runtime" +) + +// TreeListener represents a tree model listener. +type TreeListener interface { + // TreeChanged notifies the model data changed. + TreeChanged(*xray.TreeNode) + + // TreeLoadFailed notifies the load failed. + TreeLoadFailed(error) +} + +// Tree represents a tree model. +type Tree struct { + gvr string + namespace string + root *xray.TreeNode + listeners []TreeListener + inUpdate int32 + refreshRate time.Duration + query string +} + +// NewTree returns a new model. +func NewTree(gvr string) *Tree { + return &Tree{ + gvr: gvr, + refreshRate: 2 * time.Second, + } +} + +func (t *Tree) ClearFilter() { + t.query = "" +} + +func (t *Tree) SetFilter(q string) { + t.query = q +} + +// AddListener adds a listener. +func (t *Tree) AddListener(l TreeListener) { + t.listeners = append(t.listeners, l) +} + +// RemoveListener delete a listener. +func (t *Tree) RemoveListener(l TreeListener) { + victim := -1 + for i, lis := range t.listeners { + if lis == l { + victim = i + break + } + } + + if victim >= 0 { + t.listeners = append(t.listeners[:victim], t.listeners[victim+1:]...) + } +} + +// Watch initiates model updates. +func (t *Tree) Watch(ctx context.Context) { + t.Refresh(ctx) + go t.updater(ctx) +} + +// Refresh update the model now. +func (t *Tree) Refresh(ctx context.Context) { + t.refresh(ctx) +} + +// GetNamespace returns the model namespace. +func (t *Tree) GetNamespace() string { + return t.namespace +} + +// SetNamespace sets up model namespace. +func (t *Tree) SetNamespace(ns string) { + t.namespace = ns + if t.root == nil { + return + } + t.root.Clear() +} + +// SetRefreshRate sets model refresh duration. +func (t *Tree) SetRefreshRate(d time.Duration) { + t.refreshRate = d +} + +// ClusterWide checks if resource is scope for all namespaces. +func (t *Tree) ClusterWide() bool { + return client.IsClusterWide(t.namespace) +} + +// InNamespace checks if current namespace matches desired namespace. +func (t *Tree) InNamespace(ns string) bool { + return t.namespace == ns +} + +// Empty return true if no model data. +func (t *Tree) Empty() bool { + return t.root.Empty() +} + +// Peek returns model data. +func (t *Tree) Peek() *xray.TreeNode { + return t.root +} + +func (t *Tree) updater(ctx context.Context) { + defer log.Debug().Msgf("Model canceled -- %q", t.gvr) + + rate := iniRefreshRate + for { + select { + case <-ctx.Done(): + t.root = nil + return + case <-time.After(rate): + rate = t.refreshRate + t.refresh(ctx) + } + } +} + +func (t *Tree) refresh(ctx context.Context) { + if !atomic.CompareAndSwapInt32(&t.inUpdate, 0, 1) { + log.Debug().Msgf("Dropping update...") + return + } + defer atomic.StoreInt32(&t.inUpdate, 0) + + if err := t.reconcile(ctx); err != nil { + log.Error().Err(err).Msg("Reconcile failed") + t.fireTreeLoadFailed(err) + return + } +} + +func (t *Tree) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, error) { + defer func(ti time.Time) { + log.Debug().Msgf(" TREE-LIST %q:%q elapsed %v", t.namespace, t.gvr, time.Since(ti)) + }(time.Now()) + + factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + a.Init(factory, client.NewGVR(t.gvr)) + + return a.List(ctx, client.CleanseNamespace(t.namespace)) +} + +func (t *Tree) reconcile(ctx context.Context) error { + defer func(ti time.Time) { + log.Debug().Msgf("TREE-RECONCILE %q:%q elapsed %v", t.namespace, t.gvr, time.Since(ti)) + }(time.Now()) + + meta := t.resourceMeta() + oo, err := t.list(ctx, meta.DAO) + if err != nil { + return err + } + log.Debug().Msgf(" TREE returned %d rows", len(oo)) + + ns := client.CleanseNamespace(t.namespace) + root := xray.NewTreeNode(t.gvr, client.NewGVR(t.gvr).ToR()) + ctx = context.WithValue(ctx, xray.KeyParent, root) + if _, ok := meta.TreeRenderer.(*xray.Generic); ok { + table, ok := oo[0].(*metav1beta1.Table) + if !ok { + return fmt.Errorf("expecting a Table but got %T", oo[0]) + } + if err := genericTreeHydrate(ctx, ns, table, meta.TreeRenderer); err != nil { + return err + } + } else { + if err := treeHydrate(ctx, ns, oo, meta.TreeRenderer); err != nil { + return err + } + } + + root.Sort() + if t.query != "" { + t.root = root.Filter(t.query, rxFilter) + } + if t.root == nil || t.root.Diff(root) { + log.Debug().Msgf(">>>> DIFFERENCE!!!!") + t.root = root + t.fireTreeTreeChanged(t.root) + } + + log.Debug().Msgf("TREE ROOT returns %d children", len(t.root.Children)) + + return nil +} + +func (t *Tree) getMeta(ctx context.Context) (ResourceMeta, error) { + meta := t.resourceMeta() + factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) + } + meta.DAO.Init(factory, client.NewGVR(t.gvr)) + + return meta, nil +} + +func (t *Tree) resourceMeta() ResourceMeta { + meta, ok := Registry[t.gvr] + if !ok { + log.Debug().Msgf("Resource %s not found in registry. Going generic!", t.gvr) + meta = ResourceMeta{ + DAO: &dao.Table{}, + Renderer: &render.Generic{}, + } + } + if meta.DAO == nil { + meta.DAO = &dao.Resource{} + } + + return meta +} + +func (t *Tree) fireTreeTreeChanged(root *xray.TreeNode) { + for _, l := range t.listeners { + l.TreeChanged(root) + } +} + +func (t *Tree) fireTreeLoadFailed(err error) { + for _, l := range t.listeners { + l.TreeLoadFailed(err) + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func rxFilter(q, path string) bool { + rx := regexp.MustCompile(`(?i)` + q) + + tokens := strings.Split(path, "::") + for _, t := range tokens { + if rx.MatchString(t) { + return true + } + } + return false +} + +func treeHydrate(ctx context.Context, ns string, oo []runtime.Object, re TreeRenderer) error { + defer func(t time.Time) { + log.Debug().Msgf(" TREE-HYDRATE elapsed %v", time.Since(t)) + }(time.Now()) + + for _, o := range oo { + if err := re.Render(ctx, ns, o); err != nil { + return err + } + } + + return nil +} + +func genericTreeHydrate(ctx context.Context, ns string, table *metav1beta1.Table, re TreeRenderer) error { + tre, ok := re.(*xray.Generic) + if !ok { + return fmt.Errorf("expecting xray.Generic renderer but got %T", re) + } + + tre.SetTable(table) + // BOZO!! Need table row sorter!! + for _, row := range table.Rows { + if err := tre.Render(ctx, ns, row); err != nil { + return err + } + } + + return nil +} diff --git a/internal/model/types.go b/internal/model/types.go index 3a6b3584..60bd8f12 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -81,8 +81,13 @@ type Describer interface { Describe(client client.Connection, gvr, path string) (string, error) } +type TreeRenderer interface { + Render(ctx context.Context, ns string, o interface{}) error +} + // ResourceMeta represents model info about a resource. type ResourceMeta struct { - DAO dao.Accessor - Renderer Renderer + DAO dao.Accessor + Renderer Renderer + TreeRenderer TreeRenderer } diff --git a/internal/render/chart.go b/internal/render/chart.go index 3e24473f..cb3aa0c7 100644 --- a/internal/render/chart.go +++ b/internal/render/chart.go @@ -46,7 +46,7 @@ func (c Chart) Render(o interface{}, ns string, r *Row) error { return fmt.Errorf("expected ChartRes, but got %T", o) } - r.ID = FQN(h.Release.Namespace, h.Release.Name) + r.ID = client.FQN(h.Release.Namespace, h.Release.Name) r.Fields = make(Fields, 0, len(c.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, h.Release.Namespace) diff --git a/internal/render/cr.go b/internal/render/cr.go index 29746eec..aed5710c 100644 --- a/internal/render/cr.go +++ b/internal/render/cr.go @@ -3,6 +3,7 @@ package render import ( "fmt" + "github.com/derailed/k9s/internal/client" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -36,7 +37,7 @@ func (ClusterRole) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = FQN("-", cr.ObjectMeta.Name) + r.ID = client.FQN("-", cr.ObjectMeta.Name) r.Fields = Fields{ cr.Name, toAge(cr.ObjectMeta.CreationTimestamp), diff --git a/internal/render/crb.go b/internal/render/crb.go index 2004ea49..74c4ee37 100644 --- a/internal/render/crb.go +++ b/internal/render/crb.go @@ -3,6 +3,7 @@ package render import ( "fmt" + "github.com/derailed/k9s/internal/client" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -41,7 +42,7 @@ func (ClusterRoleBinding) Render(o interface{}, ns string, r *Row) error { kind, ss := renderSubjects(crb.Subjects) - r.ID = FQN("-", crb.ObjectMeta.Name) + r.ID = client.FQN("-", crb.ObjectMeta.Name) r.Fields = Fields{ crb.Name, crb.RoleRef.Name, diff --git a/internal/render/crd.go b/internal/render/crd.go index e2a0bfc2..120ebdc8 100644 --- a/internal/render/crd.go +++ b/internal/render/crd.go @@ -42,7 +42,7 @@ func (CustomResourceDefinition) Render(o interface{}, ns string, r *Row) error { log.Error().Err(err).Msgf("Fields timestamp %v", err) } - r.ID = FQN(client.ClusterScope, extractMetaField(meta, "name")) + r.ID = client.FQN(client.ClusterScope, extractMetaField(meta, "name")) r.Fields = Fields{ extractMetaField(meta, "name"), toAge(metav1.Time{Time: t}), diff --git a/internal/render/cronjob.go b/internal/render/cronjob.go index f2449d6a..d389fc1c 100644 --- a/internal/render/cronjob.go +++ b/internal/render/cronjob.go @@ -52,7 +52,7 @@ func (c CronJob) Render(o interface{}, ns string, r *Row) error { lastScheduled = toAgeHuman(toAge(*cj.Status.LastScheduleTime)) } - r.ID = MetaFQN(cj.ObjectMeta) + r.ID = client.MetaFQN(cj.ObjectMeta) r.Fields = make(Fields, 0, len(c.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, cj.Namespace) diff --git a/internal/render/dp.go b/internal/render/dp.go index e6cef360..8e636f72 100644 --- a/internal/render/dp.go +++ b/internal/render/dp.go @@ -66,7 +66,7 @@ func (d Deployment) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(dp.ObjectMeta) + r.ID = client.MetaFQN(dp.ObjectMeta) r.Fields = make(Fields, 0, len(d.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, dp.Namespace) diff --git a/internal/render/ds.go b/internal/render/ds.go index 67d2a56c..b70f957c 100644 --- a/internal/render/ds.go +++ b/internal/render/ds.go @@ -66,7 +66,7 @@ func (d DaemonSet) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(ds.ObjectMeta) + r.ID = client.MetaFQN(ds.ObjectMeta) r.Fields = make(Fields, 0, len(d.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, ds.Namespace) diff --git a/internal/render/ep.go b/internal/render/ep.go index 418e08ad..3675929c 100644 --- a/internal/render/ep.go +++ b/internal/render/ep.go @@ -45,7 +45,7 @@ func (e Endpoints) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(ep.ObjectMeta) + r.ID = client.MetaFQN(ep.ObjectMeta) r.Fields = make(Fields, 0, len(e.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, ep.Namespace) diff --git a/internal/render/ev.go b/internal/render/ev.go index 8f9b8bae..3513b13a 100644 --- a/internal/render/ev.go +++ b/internal/render/ev.go @@ -65,7 +65,7 @@ func (e Event) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(ev.ObjectMeta) + r.ID = client.MetaFQN(ev.ObjectMeta) r.Fields = make(Fields, 0, len(e.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, ev.Namespace) diff --git a/internal/render/generic.go b/internal/render/generic.go index c34d0980..7e3f8119 100644 --- a/internal/render/generic.go +++ b/internal/render/generic.go @@ -69,7 +69,7 @@ func (g *Generic) Render(o interface{}, ns string, r *Row) error { return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0]) } - r.ID = FQN(nns, n) + r.ID = client.FQN(nns, n) r.Fields = make(Fields, 0, len(g.Header(ns))) if client.IsAllNamespaces(ns) && nns != "" { r.Fields = append(r.Fields, nns) diff --git a/internal/render/helpers.go b/internal/render/helpers.go index 793b183e..95e033d3 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -1,13 +1,11 @@ package render import ( - "path" "sort" "strconv" "strings" "time" - "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" runewidth "github.com/mattn/go-runewidth" "github.com/rs/zerolog/log" @@ -40,23 +38,6 @@ func noMetric() metric { return metric{cpu: NAValue, mem: NAValue} } -// MetaFQN returns a fully qualified resource name. -func MetaFQN(m metav1.ObjectMeta) string { - if m.Namespace == "" { - return FQN(client.ClusterScope, m.Name) - } - - return FQN(m.Namespace, m.Name) -} - -// FQN returns a fully qualified resource name. -func FQN(ns, n string) string { - if ns == "" { - return n - } - return ns + "/" + n -} - // ToSelector flattens a map selector to a string selector. func toSelector(m map[string]string) string { s := make([]string, 0, len(m)) @@ -125,13 +106,6 @@ func toPerc(v1, v2 float64) float64 { return (v1 / v2) * 100 } -// Namespaced return a namesapace and a name. -func Namespaced(n string) (string, string) { - ns, po := path.Split(n) - - return strings.Trim(ns, "/"), po -} - func missing(s string) string { return check(s, MissingValue) } diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index b528724e..b45edd36 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -120,7 +121,7 @@ func TestNamespaced(t *testing.T) { } for _, u := range uu { - ns, n := Namespaced(u.p) + ns, n := client.Namespaced(u.p) assert.Equal(t, u.ns, ns) assert.Equal(t, u.n, n) } @@ -276,7 +277,7 @@ func TestMetaFQN(t *testing.T) { for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, uc.e, MetaFQN(uc.m)) + assert.Equal(t, uc.e, client.MetaFQN(uc.m)) }) } } @@ -293,7 +294,7 @@ func TestFQN(t *testing.T) { for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, uc.e, FQN(uc.ns, uc.n)) + assert.Equal(t, uc.e, client.FQN(uc.ns, uc.n)) }) } } diff --git a/internal/render/hpa.go b/internal/render/hpa.go index 32815435..6042b49f 100644 --- a/internal/render/hpa.go +++ b/internal/render/hpa.go @@ -68,7 +68,7 @@ func (h HorizontalPodAutoscaler) renderV1(raw *unstructured.Unstructured, ns str return err } - r.ID = MetaFQN(hpa.ObjectMeta) + r.ID = client.MetaFQN(hpa.ObjectMeta) r.Fields = make(Fields, 0, len(h.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, hpa.Namespace) @@ -93,7 +93,7 @@ func (h HorizontalPodAutoscaler) renderV2b1(raw *unstructured.Unstructured, ns s return err } - r.ID = MetaFQN(hpa.ObjectMeta) + r.ID = client.MetaFQN(hpa.ObjectMeta) r.Fields = make(Fields, 0, len(h.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, hpa.Namespace) @@ -119,7 +119,7 @@ func (h HorizontalPodAutoscaler) renderV2b2(raw *unstructured.Unstructured, ns s return err } - r.ID = MetaFQN(hpa.ObjectMeta) + r.ID = client.MetaFQN(hpa.ObjectMeta) r.Fields = make(Fields, 0, len(h.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, hpa.Namespace) diff --git a/internal/render/ing.go b/internal/render/ing.go index 1f37afc2..9f90ba74 100644 --- a/internal/render/ing.go +++ b/internal/render/ing.go @@ -47,7 +47,7 @@ func (i Ingress) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(ing.ObjectMeta) + r.ID = client.MetaFQN(ing.ObjectMeta) r.Fields = make(Fields, 0, len(i.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, ing.Namespace) diff --git a/internal/render/job.go b/internal/render/job.go index c242ee80..62048990 100644 --- a/internal/render/job.go +++ b/internal/render/job.go @@ -53,7 +53,7 @@ func (j Job) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(job.ObjectMeta) + r.ID = client.MetaFQN(job.ObjectMeta) r.Fields = make(Fields, 0, len(j.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, job.Namespace) diff --git a/internal/render/node.go b/internal/render/node.go index 94cf6a96..f64038a7 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/derailed/k9s/internal/client" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -73,7 +74,7 @@ func (n Node) Render(o interface{}, ns string, r *Row) error { ro := make([]string, 10) nodeRoles(&no, ro) - r.ID = FQN("", na) + r.ID = client.FQN("", na) r.Fields = make(Fields, 0, len(n.Header(ns))) r.Fields = append(r.Fields, no.Name, diff --git a/internal/render/np.go b/internal/render/np.go index 9d85343d..e1c1b881 100644 --- a/internal/render/np.go +++ b/internal/render/np.go @@ -53,7 +53,7 @@ func (n NetworkPolicy) Render(o interface{}, ns string, r *Row) error { ip, is, ib := ingress(np.Spec.Ingress) ep, es, eb := egress(np.Spec.Egress) - r.ID = MetaFQN(np.ObjectMeta) + r.ID = client.MetaFQN(np.ObjectMeta) r.Fields = make(Fields, 0, len(n.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, np.Namespace) diff --git a/internal/render/ns.go b/internal/render/ns.go index 7b7c76fd..b8547b31 100644 --- a/internal/render/ns.go +++ b/internal/render/ns.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -57,7 +58,7 @@ func (Namespace) Render(o interface{}, _ string, r *Row) error { return err } - r.ID = MetaFQN(ns.ObjectMeta) + r.ID = client.MetaFQN(ns.ObjectMeta) r.Fields = Fields{ ns.Name, string(ns.Status.Phase), diff --git a/internal/render/pdb.go b/internal/render/pdb.go index daaecf86..9103b336 100644 --- a/internal/render/pdb.go +++ b/internal/render/pdb.go @@ -69,7 +69,7 @@ func (p PodDisruptionBudget) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(pdb.ObjectMeta) + r.ID = client.MetaFQN(pdb.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, pdb.Namespace) diff --git a/internal/render/pod.go b/internal/render/pod.go index b3e4a4f6..dbd299a4 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -89,22 +89,22 @@ func (Pod) Header(ns string) HeaderRow { // Render renders a K8s resource to screen. func (p Pod) Render(o interface{}, ns string, r *Row) error { - oo, ok := o.(*PodWithMetrics) + pwm, ok := o.(*PodWithMetrics) if !ok { return fmt.Errorf("Expected PodWithMetrics, but got %T", o) } var po v1.Pod - err := runtime.DefaultUnstructuredConverter.FromUnstructured(oo.Raw.Object, &po) + err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po) if err != nil { return err } ss := po.Status.ContainerStatuses cr, _, rc := p.statuses(ss) - c, perc := p.gatherPodMX(&po, oo.MX) + c, perc := p.gatherPodMX(&po, pwm.MX) - r.ID = MetaFQN(po.ObjectMeta) + r.ID = client.MetaFQN(po.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, po.Namespace) diff --git a/internal/render/policy.go b/internal/render/policy.go index 3eb48a1b..e29ab3ce 100644 --- a/internal/render/policy.go +++ b/internal/render/policy.go @@ -3,6 +3,7 @@ package render import ( "fmt" + "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -50,7 +51,7 @@ func (Policy) Render(o interface{}, gvr string, r *Row) error { return fmt.Errorf("expecting PolicyRes but got %T", o) } - r.ID = FQN(p.Namespace, p.Resource) + r.ID = client.FQN(p.Namespace, p.Resource) r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) @@ -64,7 +65,7 @@ func cleanseResource(r string) string { if r[0] == '/' { return r } - _, n := Namespaced(r) + _, n := client.Namespaced(r) return n } diff --git a/internal/render/portforward.go b/internal/render/portforward.go index aad1b8c7..450188d2 100644 --- a/internal/render/portforward.go +++ b/internal/render/portforward.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -59,7 +60,7 @@ func (f PortForward) Render(o interface{}, gvr string, r *Row) error { } ports := strings.Split(pf.Ports()[0], ":") - ns, n := Namespaced(pf.Path()) + ns, n := client.Namespaced(pf.Path()) r.ID = pf.Path() r.Fields = Fields{ diff --git a/internal/render/pv.go b/internal/render/pv.go index c142abb0..ef0ae627 100644 --- a/internal/render/pv.go +++ b/internal/render/pv.go @@ -5,6 +5,7 @@ import ( "path" "strings" + "github.com/derailed/k9s/internal/client" "github.com/gdamore/tcell" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -79,7 +80,7 @@ func (p PersistentVolume) Render(o interface{}, ns string, r *Row) error { size := pv.Spec.Capacity[v1.ResourceStorage] - r.ID = MetaFQN(pv.ObjectMeta) + r.ID = client.MetaFQN(pv.ObjectMeta) r.Fields = Fields{ pv.Name, size.String(), diff --git a/internal/render/pvc.go b/internal/render/pvc.go index 999ca6de..c6ce1484 100644 --- a/internal/render/pvc.go +++ b/internal/render/pvc.go @@ -84,7 +84,7 @@ func (p PersistentVolumeClaim) Render(o interface{}, ns string, r *Row) error { } } - r.ID = MetaFQN(pvc.ObjectMeta) + r.ID = client.MetaFQN(pvc.ObjectMeta) r.Fields = make(Fields, 0, len(p.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, pvc.Namespace) diff --git a/internal/render/ro.go b/internal/render/ro.go index dee87474..c388889e 100644 --- a/internal/render/ro.go +++ b/internal/render/ro.go @@ -42,7 +42,7 @@ func (r Role) Render(o interface{}, ns string, row *Row) error { return err } - row.ID = MetaFQN(ro.ObjectMeta) + row.ID = client.MetaFQN(ro.ObjectMeta) row.Fields = make(Fields, 0, len(r.Header(ns))) if client.IsAllNamespaces(ns) { row.Fields = append(row.Fields, ro.Namespace) diff --git a/internal/render/rob.go b/internal/render/rob.go index 6e6f9b91..6777c090 100644 --- a/internal/render/rob.go +++ b/internal/render/rob.go @@ -48,7 +48,7 @@ func (r RoleBinding) Render(o interface{}, ns string, row *Row) error { kind, ss := renderSubjects(rb.Subjects) - row.ID = MetaFQN(rb.ObjectMeta) + row.ID = client.MetaFQN(rb.ObjectMeta) row.Fields = make(Fields, 0, len(r.Header(ns))) if client.IsAllNamespaces(ns) { row.Fields = append(row.Fields, rb.Namespace) diff --git a/internal/render/rs.go b/internal/render/rs.go index 1edde9ba..5b38fcf1 100644 --- a/internal/render/rs.go +++ b/internal/render/rs.go @@ -65,7 +65,7 @@ func (s ReplicaSet) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(rs.ObjectMeta) + r.ID = client.MetaFQN(rs.ObjectMeta) r.Fields = make(Fields, 0, len(s.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, rs.Namespace) diff --git a/internal/render/sa.go b/internal/render/sa.go index c468f031..eafffe7a 100644 --- a/internal/render/sa.go +++ b/internal/render/sa.go @@ -44,7 +44,7 @@ func (s ServiceAccount) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(sa.ObjectMeta) + r.ID = client.MetaFQN(sa.ObjectMeta) r.Fields = make(Fields, 0, len(s.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, sa.Namespace) diff --git a/internal/render/sc.go b/internal/render/sc.go index 4897e787..4c43fbc0 100644 --- a/internal/render/sc.go +++ b/internal/render/sc.go @@ -38,7 +38,7 @@ func (StorageClass) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = FQN(client.ClusterScope, sc.ObjectMeta.Name) + r.ID = client.FQN(client.ClusterScope, sc.ObjectMeta.Name) r.Fields = Fields{ sc.Name, string(sc.Provisioner), diff --git a/internal/render/sts.go b/internal/render/sts.go index b385a210..30038c83 100644 --- a/internal/render/sts.go +++ b/internal/render/sts.go @@ -65,7 +65,7 @@ func (s StatefulSet) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(sts.ObjectMeta) + r.ID = client.MetaFQN(sts.ObjectMeta) r.Fields = make(Fields, 0, len(s.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, sts.Namespace) diff --git a/internal/render/svc.go b/internal/render/svc.go index 9be2edf5..008f2d8d 100644 --- a/internal/render/svc.go +++ b/internal/render/svc.go @@ -50,7 +50,7 @@ func (s Service) Render(o interface{}, ns string, r *Row) error { return err } - r.ID = MetaFQN(svc.ObjectMeta) + r.ID = client.MetaFQN(svc.ObjectMeta) r.Fields = make(Fields, 0, len(s.Header(ns))) if client.IsAllNamespaces(ns) { r.Fields = append(r.Fields, svc.Namespace) diff --git a/internal/ui/table.go b/internal/ui/table.go index 06e48c33..b425ef75 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -108,8 +108,7 @@ func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { if t.SearchBuff().IsActive() { t.SearchBuff().Add(evt.Rune()) t.ClearSelection() - data := t.GetModel().Peek() - t.doUpdate(t.filtered(data)) + t.doUpdate(t.filtered(t.GetModel().Peek())) t.UpdateTitle() t.SelectFirstRow() return nil @@ -340,7 +339,6 @@ func (t *Table) UpdateTitle() { t.SetTitle(t.styleTitle()) } -// UpdateTitle refreshes the table title. func (t *Table) styleTitle() string { rc := t.GetRowCount() if rc > 0 { @@ -365,9 +363,9 @@ func (t *Table) styleTitle() string { buff := t.SearchBuff().String() var title string if ns == client.ClusterScope { - title = SkinTitle(fmt.Sprintf(titleFmt, base, rc), t.styles.Frame()) + title = SkinTitle(fmt.Sprintf(TitleFmt, base, rc), t.styles.Frame()) } else { - title = SkinTitle(fmt.Sprintf(nsTitleFmt, base, ns, rc), t.styles.Frame()) + title = SkinTitle(fmt.Sprintf(NSTitleFmt, base, ns, rc), t.styles.Frame()) } if buff == "" { return title diff --git a/internal/ui/table_helper.go b/internal/ui/table_helper.go index 2bebdeab..ce9d9b88 100644 --- a/internal/ui/table_helper.go +++ b/internal/ui/table_helper.go @@ -17,8 +17,12 @@ const ( // SearchFmt represents a filter view title. SearchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " - nsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " - titleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + // NSTitleFmt represents a namespaced view title. + NSTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + + // TitleFmt represents a standard view title. + TitleFmt = "[fg:bg:b] %s[fg:bg:-][[count:bg:b]%d[fg:bg:-]][fg:bg:-] " + descIndicator = "โ†“" ascIndicator = "โ†‘" diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index fd70c02c..9f63fc8e 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -60,6 +60,7 @@ type testModel struct{} var _ ui.Tabular = &testModel{} +func (t *testModel) SetInstance(string) {} func (t *testModel) Empty() bool { return false } func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } diff --git a/internal/ui/types.go b/internal/ui/types.go index c25acfc3..53c9d086 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -53,6 +53,8 @@ type Tabular interface { Namespaceable Lister + SetInstance(string) + // Empty returns true if model has no data. Empty() bool diff --git a/internal/view/actions.go b/internal/view/actions.go index 887fb65c..2c578b7d 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -3,6 +3,7 @@ package view import ( "fmt" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" @@ -116,22 +117,23 @@ func execCmd(r Runner, bin string, bg bool, args ...string) ui.ActionHandler { return evt } + ns, _ := client.Namespaced(path) var ( env = r.EnvFn()() aa = make([]string, len(args)) err error ) for i, a := range args { - aa[i], err = env.envFor(a) + aa[i], err = env.envFor(ns, a) if err != nil { - log.Error().Err(err).Msg("Args match failed") + log.Error().Err(err).Msg("Plugin Args match failed") return nil } } if run(true, r.App(), bin, bg, aa...) { - r.App().Flash().Info("Custom CMD launched!") + r.App().Flash().Info("Plugin command launched successfully!") } else { - r.App().Flash().Info("Custom CMD failed!") + r.App().Flash().Info("Plugin command failed!") } return nil diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index a47078f2..ecc4c8d0 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -99,6 +99,7 @@ type testModel struct{} var _ ui.Tabular = &testModel{} +func (t *testModel) SetInstance(string) {} func (t *testModel) Empty() bool { return false } func (t *testModel) Peek() render.TableData { return makeTableData() } func (t *testModel) ClusterWide() bool { return false } diff --git a/internal/view/app.go b/internal/view/app.go index 8a7d0151..3548a067 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -102,7 +102,7 @@ func (a *App) Init(version string, rate int) error { func (a *App) bindKeys() { a.AddActions(ui.KeyActions{ - ui.KeyH: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), + tcell.KeyCtrlH: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), @@ -427,8 +427,12 @@ func (a *App) aliasCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (a *App) gotoResource(res string, clearStack bool) error { - return a.command.run(res, clearStack) +func (a *App) viewResource(gvr, path string, clearStack bool) error { + return a.command.run(gvr, path, clearStack) +} + +func (a *App) gotoResource(cmd string, clearStack bool) error { + return a.command.run(cmd, "", clearStack) } func (a *App) inject(c model.Component) error { diff --git a/internal/view/browser.go b/internal/view/browser.go index 96e732fb..bc38008f 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -83,6 +83,10 @@ func (b *Browser) bindKeys() { }) } +func (b *Browser) SetInstance(path string) { + b.GetModel().SetInstance(path) +} + // Start initializes browser updates. func (b *Browser) Start() { b.Stop() diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index 708379c9..dfa0f968 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -144,7 +144,7 @@ func (c *ClusterInfo) updateStyle() { } func fetchResources(app *App) (*v1.NodeList, *mv1beta1.NodeMetricsList, error) { - nn, err := dao.FetchNodes(app.factory) + nn, err := dao.FetchNodes(app.factory, "") if err != nil { return nil, nil, err } diff --git a/internal/view/command.go b/internal/view/command.go index 3183ce14..e3033583 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -1,6 +1,7 @@ package view import ( + "errors" "fmt" "regexp" "strings" @@ -42,8 +43,42 @@ func (c *Command) Init() error { return nil } +func (c *Command) xrayCmd(cmd string) error { + + // if _, ok := c.app.Content.GetPrimitive("main").(*Xray); ok { + // return errors.New("unable to locate main panel") + // } + + // if c.app.Content.Top() != nil && c.app.Content.Top().Name() == xrayTitle { + // c.app.Content.Pop() + // return nil + // } + + tokens := strings.Split(cmd, " ") + if len(tokens) < 2 { + return errors.New("You must specify a resource") + } + gvr, ok := c.alias.AsGVR(tokens[1]) + if !ok { + return fmt.Errorf("Huh? `%s` Command not found", cmd) + } + return c.exec(cmd, "xrays", NewXray(gvr), true) + + // if err := c.app.inject(NewXray(gvr)); err != nil { + // c.app.Flash().Err(err) + // return nil + // } + + // c.app.Config.SetActiveView(cmd) + // if err := c.app.Config.Save(); err != nil { + // log.Error().Err(err).Msg("Config save failed!") + // } + + // return nil +} + // Exec the Command by showing associated display. -func (c *Command) run(cmd string, clearStack bool) error { +func (c *Command) run(cmd, path string, clearStack bool) error { if c.specialCmd(cmd) { return nil } @@ -59,8 +94,8 @@ func (c *Command) run(cmd string, clearStack bool) error { if len(cmds) == 2 && c.app.switchCtx(cmds[1], true) != nil { return fmt.Errorf("context switch failed!") } - view := c.componentFor(gvr, v) - return c.exec(gvr, view, clearStack) + view := c.componentFor(gvr, path, v) + return c.exec(cmd, gvr, view, clearStack) default: // checks if Command includes a namespace ns := c.app.Config.ActiveNamespace() @@ -70,7 +105,8 @@ func (c *Command) run(cmd string, clearStack bool) error { if !c.app.switchNS(ns) { return fmt.Errorf("namespace switch failed for ns %q", ns) } - return c.exec(gvr, c.componentFor(gvr, v), clearStack) + + return c.exec(cmd, gvr, c.componentFor(gvr, path, v), clearStack) } } @@ -85,7 +121,7 @@ func (c *Command) Reset() error { } func (c *Command) defaultCmd() error { - return c.run(c.app.Config.ActiveView(), true) + return c.run(c.app.Config.ActiveView(), "", true) } func (c *Command) specialCmd(cmd string) bool { @@ -100,6 +136,11 @@ func (c *Command) specialCmd(cmd string) bool { case "a", "alias": c.app.aliasCmd(nil) return true + case "x", "xray": + if err := c.xrayCmd(cmd); err != nil { + log.Error().Err(err).Msgf("Invalid command") + } + return true default: if !canRX.MatchString(cmd) { return false @@ -130,7 +171,7 @@ func (c *Command) viewMetaFor(cmd string) (string, *MetaViewer, error) { return gvr.String(), &v, nil } -func (c *Command) componentFor(gvr string, v *MetaViewer) ResourceViewer { +func (c *Command) componentFor(gvr, path string, v *MetaViewer) ResourceViewer { var view ResourceViewer if v.viewerFn != nil { log.Debug().Msgf("Custom viewer for %s", gvr) @@ -140,6 +181,7 @@ func (c *Command) componentFor(gvr string, v *MetaViewer) ResourceViewer { view = NewBrowser(client.NewGVR(gvr)) } + view.SetInstance(path) if v.enterFn != nil { log.Debug().Msgf("SETTING CUSTOM ENTER ON %s", gvr) view.GetTable().SetEnterFn(v.enterFn) @@ -148,15 +190,13 @@ func (c *Command) componentFor(gvr string, v *MetaViewer) ResourceViewer { return view } -func (c *Command) exec(gvr string, comp model.Component, clearStack bool) error { +func (c *Command) exec(cmd, gvr string, comp model.Component, clearStack bool) error { if comp == nil { return fmt.Errorf("No component given for %s", gvr) } - g := client.NewGVR(gvr) - c.app.Flash().Infof("Viewing %s resource...", g.ToR()) - log.Debug().Msgf("Running Command %s", gvr) - c.app.Config.SetActiveView(g.ToR()) + c.app.Flash().Infof("Running command %s", cmd) + c.app.Config.SetActiveView(cmd) if err := c.app.Config.Save(); err != nil { log.Error().Err(err).Msg("Config save failed!") } diff --git a/internal/view/env.go b/internal/view/env.go index 92988183..7ddcb4ba 100644 --- a/internal/view/env.go +++ b/internal/view/env.go @@ -3,24 +3,48 @@ package view import ( "fmt" "regexp" + "strconv" "strings" + + "github.com/derailed/k9s/internal/client" ) // K9sEnv represent K9s available env variables. type K9sEnv map[string]string // EnvRX match $XXX custom arg. -var envRX = regexp.MustCompile(`\A\$([\w]+)`) +var envRX = regexp.MustCompile(`\$([\w]+)(\d*)`) -func (e K9sEnv) envFor(n string) (string, error) { - envs := envRX.FindStringSubmatch(n) +func (e K9sEnv) envFor(ns, args string) (string, error) { + envs := envRX.FindStringSubmatch(args) if len(envs) == 0 { - return n, nil - } - env, ok := e[strings.ToUpper(envs[1])] - if !ok { - return "", fmt.Errorf("No matching for %s", n) + return args, nil } - return envRX.ReplaceAllString(n, env), nil + q := envs[1] + if envs[2] == "" { + return e.subOut(args, q) + } + + var index, err = strconv.Atoi(envs[2]) + if err != nil { + return args, err + } + if client.IsNamespaced(ns) { + index -= 1 + } + if index >= 0 { + q += strconv.Itoa(index) + } + + return e.subOut(args, q) +} + +func (e K9sEnv) subOut(args, q string) (string, error) { + env, ok := e[strings.ToUpper(q)] + if !ok { + return "", fmt.Errorf("no env vars exists for argument %q using key %q", args, q) + } + + return envRX.ReplaceAllString(args, env), nil } diff --git a/internal/view/env_test.go b/internal/view/env_test.go index 656aabb6..f17f3dda 100644 --- a/internal/view/env_test.go +++ b/internal/view/env_test.go @@ -11,14 +11,16 @@ func TestK9sEnv(t *testing.T) { uu := map[string]struct { q string + ns string err error e string }{ "match": {q: "$A", e: "10"}, - "noMatch": {q: "$BLEE", err: errors.New("No matching for $BLEE"), e: ""}, + "noMatch": {q: "$BLEE", err: errors.New(`no env vars exists for argument "$BLEE" using key "BLEE"`), e: ""}, "lower": {q: "$b", e: "blee"}, "dash": {q: "$col0", e: "fred"}, "mix": {q: "$col0-blee", e: "fred-blee"}, + "subs": {q: `{"spec" : {"suspend" : $COL0 }}`, e: `{"spec" : {"suspend" : fred }}`}, } e := K9sEnv{ @@ -30,7 +32,7 @@ func TestK9sEnv(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - a, err := e.envFor(u.q) + a, err := e.envFor(u.ns, u.q) assert.Equal(t, u.err, err) assert.Equal(t, u.e, a) }) diff --git a/internal/view/logs_extender.go b/internal/view/logs_extender.go index e5df83d3..2e4ec250 100644 --- a/internal/view/logs_extender.go +++ b/internal/view/logs_extender.go @@ -2,7 +2,6 @@ package view import ( "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/gdamore/tcell" "github.com/rs/zerolog/log" @@ -57,7 +56,7 @@ func isResourcePath(p string) bool { func (l *LogsExtender) showLogs(path string, prev bool) { log.Debug().Msgf("SHOWING LOGS path %q", path) // Need to load and wait for pods - ns, _ := render.Namespaced(path) + ns, _ := client.Namespaced(path) _, err := l.App().factory.CanForResource(ns, "v1/pods", client.MonitorAccess) if err != nil { l.App().Flash().Err(err) diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index bf394b28..f98700c4 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -91,6 +91,7 @@ type testTableModel struct{} var _ ui.Tabular = &testTableModel{} +func (t *testTableModel) SetInstance(string) {} func (t *testTableModel) Empty() bool { return false } func (t *testTableModel) Peek() render.TableData { return makeTableData() } func (t *testTableModel) ClusterWide() bool { return false } diff --git a/internal/view/types.go b/internal/view/types.go index 2bc75128..e55f9490 100644 --- a/internal/view/types.go +++ b/internal/view/types.go @@ -77,6 +77,7 @@ type ResourceViewer interface { // SetBindKeys provision additional key bindings. SetBindKeysFn(BindKeysFunc) + SetInstance(string) } // LogViewer represents a log viewer. diff --git a/internal/view/xray.go b/internal/view/xray.go new file mode 100644 index 00000000..d308b19a --- /dev/null +++ b/internal/view/xray.go @@ -0,0 +1,484 @@ +package view + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/k9s/internal/xray" + "github.com/derailed/tview" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + "github.com/sahilm/fuzzy" +) + +const xrayTitle = "Xray" + +// Xray represents an xray tree view. +type Xray struct { + *tview.TreeView + + actions ui.KeyActions + app *App + gvr client.GVR + selectedNode string + model *model.Tree + cancelFn context.CancelFunc + cmdBuff *ui.CmdBuff + expandNodes bool +} + +var _ ResourceViewer = (*Xray)(nil) + +// NewXray returns a new view. +func NewXray(gvr client.GVR) ResourceViewer { + a := Xray{ + TreeView: tview.NewTreeView(), + model: model.NewTree(gvr.String()), + expandNodes: true, + actions: make(ui.KeyActions), + cmdBuff: ui.NewCmdBuff('/', ui.FilterBuff), + } + + return &a +} + +// Init initializes the view +func (x *Xray) Init(ctx context.Context) error { + var err error + if x.app, err = extractApp(ctx); err != nil { + return err + } + + x.bindKeys() + x.SetBorder(true) + x.SetBorderAttributes(tcell.AttrBold) + x.SetBorderPadding(0, 0, 1, 1) + x.SetBackgroundColor(config.AsColor(x.app.Styles.GetTable().BgColor)) + x.SetBorderColor(config.AsColor(x.app.Styles.GetTable().FgColor)) + x.SetBorderFocusColor(config.AsColor(x.app.Styles.Frame().Border.FocusColor)) + x.SetTitle(" Xray ") + x.SetGraphics(true) + x.SetGraphicsColor(tcell.ColorDimGray) + x.SetInputCapture(x.keyboard) + + x.model.SetRefreshRate(time.Duration(x.app.Config.K9s.GetRefreshRate()) * time.Second) + x.model.SetNamespace(client.AllNamespaces) + x.model.AddListener(x) + + x.SetChangedFunc(func(n *tview.TreeNode) { + ref, ok := n.GetReference().(xray.NodeSpec) + if !ok { + log.Error().Msgf("No ref found on node %s", n.GetText()) + return + } + x.selectedNode = ref.Path + }) + + return nil +} + +// SetInstance sets specific resource instance. +func (x *Xray) SetInstance(string) {} + +// Actions returns active menu bindings. +func (x *Xray) Actions() ui.KeyActions { + return x.actions +} + +// Hints returns the view hints. +func (x *Xray) Hints() model.MenuHints { + return x.actions.Hints() +} + +func (x *Xray) bindKeys() { + x.Actions().Add(ui.KeyActions{ + ui.KeySpace: ui.NewKeyAction("Expand/Collapse", x.noopCmd, true), + ui.KeyE: ui.NewKeyAction("Expand/Collapse All", x.toggleCollapseCmd, true), + ui.KeyV: ui.NewKeyAction("Goto", x.gotoCmd, true), + tcell.KeyEnter: ui.NewKeyAction("Goto", x.gotoCmd, true), + ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", x.activateCmd, false), + tcell.KeyBackspace2: ui.NewSharedKeyAction("Erase", x.eraseCmd, false), + tcell.KeyBackspace: ui.NewSharedKeyAction("Erase", x.eraseCmd, false), + tcell.KeyDelete: ui.NewSharedKeyAction("Erase", x.eraseCmd, false), + tcell.KeyCtrlU: ui.NewSharedKeyAction("Clear Filter", x.clearCmd, false), + tcell.KeyEscape: ui.NewSharedKeyAction("Filter Reset", x.resetCmd, false), + }) +} + +func (x *Xray) keyboard(evt *tcell.EventKey) *tcell.EventKey { + key := evt.Key() + if key == tcell.KeyRune { + if x.cmdBuff.IsActive() { + x.cmdBuff.Add(evt.Rune()) + x.ClearSelection() + x.update(x.filter(x.model.Peek())) + x.UpdateTitle() + return nil + } + + key = mapKey(evt) + } + + if a, ok := x.actions[key]; ok { + return a.Action(evt) + } + + return evt +} + +func (x *Xray) noopCmd(evt *tcell.EventKey) *tcell.EventKey { + return evt +} + +func (x *Xray) activateCmd(evt *tcell.EventKey) *tcell.EventKey { + if x.app.InCmdMode() { + return evt + } + x.app.Flash().Info("Filter mode activated.") + x.cmdBuff.SetActive(true) + + return nil +} + +func (x *Xray) clearCmd(evt *tcell.EventKey) *tcell.EventKey { + if !x.cmdBuff.IsActive() { + return evt + } + x.cmdBuff.Clear() + x.model.ClearFilter() + x.Start() + + return nil +} + +func (x *Xray) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { + if x.cmdBuff.IsActive() { + x.cmdBuff.Delete() + } + x.UpdateTitle() + + return nil +} + +func (x *Xray) filterCmd(evt *tcell.EventKey) *tcell.EventKey { + if !x.cmdBuff.IsActive() { + return evt + } + x.cmdBuff.SetActive(false) + + cmd := x.cmdBuff.String() + x.model.SetFilter(cmd) + x.Start() + + return nil +} + +func (x *Xray) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !x.cmdBuff.InCmdMode() { + x.cmdBuff.Reset() + return x.app.PrevCmd(evt) + } + + x.app.Flash().Info("Clearing filter...") + x.cmdBuff.Reset() + x.model.ClearFilter() + x.Start() + + return nil +} + +func (x *Xray) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { + if x.cmdBuff.IsActive() { + if ui.IsLabelSelector(x.cmdBuff.String()) { + x.Start() + return nil + } + } + n := x.GetCurrentNode() + if n == nil { + return nil + } + + ref, ok := n.GetReference().(xray.NodeSpec) + if !ok { + log.Error().Msgf("Expecting a NodeSpec!") + return nil + } + if len(strings.Split(ref.Path, "/")) == 1 { + return nil + } + + if err := x.app.viewResource(client.NewGVR(ref.GVR).ToR(), ref.Path, false); err != nil { + x.app.Flash().Err(err) + } + return nil +} + +func (x *Xray) toggleCollapseCmd(evt *tcell.EventKey) *tcell.EventKey { + x.expandNodes = !x.expandNodes + x.GetRoot().Walk(func(node, parent *tview.TreeNode) bool { + if parent != nil { + node.SetExpanded(x.expandNodes) + } + return true + }) + return nil +} + +// ClearSelection clears the currently selected node. +func (x *Xray) ClearSelection() { + x.selectedNode = "" + x.SetCurrentNode(nil) +} + +func (x *Xray) filter(root *xray.TreeNode) *xray.TreeNode { + q := x.cmdBuff.String() + if x.cmdBuff.Empty() || ui.IsLabelSelector(q) { + return root + } + + x.UpdateTitle() + if ui.IsFuzzySelector(q) { + return root.Filter(q, fuzzyFilter) + } + + return root.Filter(q, rxFilter) +} + +// TreeNodeSelected callback for node selection. +func (x *Xray) TreeNodeSelected() { + x.app.QueueUpdateDraw(func() { + n := x.GetCurrentNode() + if n != nil { + n.SetColor(config.AsColor(x.app.Styles.GetTable().CursorColor)) + } + }) +} + +// XrayLoadFailed notifies the load failed. +func (x *Xray) TreeLoadFailed(err error) { + x.app.Flash().Err(err) +} + +func (x *Xray) update(node *xray.TreeNode) { + root := makeTreeNode(node, x.expandNodes, x.app.Styles) + if node == nil { + x.app.QueueUpdateDraw(func() { + x.SetRoot(root) + }) + return + } + + for _, c := range node.Children { + x.hydrate(root, c) + } + if x.selectedNode == "" { + x.selectedNode = node.ID + } + + x.app.QueueUpdateDraw(func() { + x.SetRoot(root) + root.Walk(func(node, parent *tview.TreeNode) bool { + ref := node.GetReference().(xray.NodeSpec) + // BOZO!! Figure this out expand/collapse but the root + if parent != nil { + node.SetExpanded(x.expandNodes) + } else { + node.SetExpanded(true) + } + + ref, ok := node.GetReference().(xray.NodeSpec) + if !ok { + log.Error().Msgf("No ref found on node %s", node.GetText()) + return false + } + if ref.Path == x.selectedNode { + node.SetExpanded(true).SetSelectable(true) + x.SetCurrentNode(node) + } + return true + }) + }) +} + +// XrayDataChanged notifies the model data changed. +func (x *Xray) TreeChanged(node *xray.TreeNode) { + log.Debug().Msgf("Tree Changed %d", len(node.Children)) + x.update(x.filter(node)) + x.UpdateTitle() +} + +func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { + node := makeTreeNode(n, x.expandNodes, x.app.Styles) + for _, c := range n.Children { + x.hydrate(node, c) + } + parent.AddChild(node) +} + +// SetEnvFn sets the custom environment function. +func (x *Xray) SetEnvFn(EnvFunc) {} + +// Refresh refresh the view +func (x *Xray) Refresh() { +} + +// BufferChanged indicates the buffer was changed. +func (x *Xray) BufferChanged(s string) {} + +// BufferActive indicates the buff activity changed. +func (x *Xray) BufferActive(state bool, k ui.BufferKind) { + x.app.BufferActive(state, k) +} + +func (x *Xray) defaultContext() context.Context { + ctx := context.WithValue(context.Background(), internal.KeyFactory, x.app.factory) + ctx = context.WithValue(ctx, internal.KeyFields, "") + if x.cmdBuff.Empty() { + ctx = context.WithValue(ctx, internal.KeyLabels, "") + } else { + ctx = context.WithValue(ctx, internal.KeyLabels, ui.TrimLabelSelector(x.cmdBuff.String())) + } + + return ctx +} + +// Start initializes resource watch loop. +func (x *Xray) Start() { + x.Stop() + + log.Debug().Msgf("XRAY STARTING! -- %q", x.selectedNode) + x.cmdBuff.AddListener(x.app.Cmd()) + x.cmdBuff.AddListener(x) + x.app.SetFocus(x) + + ctx := x.defaultContext() + ctx, x.cancelFn = context.WithCancel(ctx) + x.model.Watch(ctx) + x.UpdateTitle() +} + +// Stop terminates watch loop. +func (x *Xray) Stop() { + log.Debug().Msgf("XRAY STOPPING!") + if x.cancelFn == nil { + return + } + x.cancelFn() + x.cancelFn = nil + + x.cmdBuff.RemoveListener(x.app.Cmd()) + x.cmdBuff.RemoveListener(x) +} + +// SetBindKeysFn sets up extra key bindings. +func (x *Xray) SetBindKeysFn(BindKeysFunc) {} + +// SetContextFn sets custom context. +func (x *Xray) SetContextFn(ContextFunc) {} + +// Name returns the component name. +func (x *Xray) Name() string { return "XRay" } + +// GetTable returns the underlying table. +func (x *Xray) GetTable() *Table { return nil } + +// GVR returns a resource descriptor. +func (x *Xray) GVR() string { return x.gvr.String() } + +// App returns the current app handle. +func (x *Xray) App() *App { + return x.app +} + +// UpdateTitle updates the view title. +func (x *Xray) UpdateTitle() { + x.SetTitle(x.styleTitle()) +} + +func (x *Xray) styleTitle() string { + rc := x.GetRowCount() + if rc > 0 { + rc-- + } + + base := strings.Title(xrayTitle) + ns := x.model.GetNamespace() + if client.IsAllNamespaces(ns) { + ns = client.NamespaceAll + } + + buff := x.cmdBuff.String() + var title string + if ns == client.ClusterScope { + title = ui.SkinTitle(fmt.Sprintf(ui.TitleFmt, base, rc), x.app.Styles.Frame()) + } else { + title = ui.SkinTitle(fmt.Sprintf(ui.NSTitleFmt, base, ns, rc), x.app.Styles.Frame()) + } + if buff == "" { + return title + } + + if ui.IsLabelSelector(buff) { + buff = ui.TrimLabelSelector(buff) + } + + return title + ui.SkinTitle(fmt.Sprintf(ui.SearchFmt, buff), x.app.Styles.Frame()) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func mapKey(evt *tcell.EventKey) tcell.Key { + key := tcell.Key(evt.Rune()) + if evt.Modifiers() == tcell.ModAlt { + key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers())) + } + return key +} + +func fuzzyFilter(q, path string) bool { + q = strings.TrimSpace(q[2:]) + mm := fuzzy.Find(q, []string{path}) + log.Debug().Msgf("%#v", mm) + if len(mm) > 0 { + return true + } + + return false +} + +func rxFilter(q, path string) bool { + rx := regexp.MustCompile(`(?i)` + q) + + tokens := strings.Split(path, xray.PathSeparator) + for _, t := range tokens { + if rx.MatchString(t) { + return true + } + } + return false +} + +func makeTreeNode(node *xray.TreeNode, expanded bool, styles *config.Styles) *tview.TreeNode { + n := tview.NewTreeNode("No data...") + if node != nil { + n.SetText(node.Title()) + n.SetReference(xray.NodeSpec{GVR: node.GVR, Path: node.ID}) + } + n.SetSelectable(true) + n.SetExpanded(expanded) + n.SetColor(config.AsColor(styles.GetTable().CursorColor)) + n.SetSelectedFunc(func() { + n.SetExpanded(!n.IsExpanded()) + }) + return n +} diff --git a/internal/xray/container.go b/internal/xray/container.go new file mode 100644 index 00000000..bcdeef99 --- /dev/null +++ b/internal/xray/container.go @@ -0,0 +1,101 @@ +package xray + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + "github.com/rs/zerolog/log" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" +) + +type Container struct{} + +func (c *Container) Render(ctx context.Context, ns string, o interface{}) error { + co, ok := o.(render.ContainerRes) + if !ok { + return fmt.Errorf("Expected ContainerRes, but got %T", o) + } + + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return fmt.Errorf("no factory found in context") + } + + root := NewTreeNode("containers", client.FQN(ns, co.Container.Name)) + parent := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + pns, _ := client.Namespaced(parent.ID) + c.envRefs(f, root, pns, co.Container) + if !root.Empty() { + parent.Add(root) + } + return nil +} + +func (c *Container) envRefs(f dao.Factory, parent *TreeNode, ns string, co *v1.Container) { + for _, e := range co.Env { + if e.ValueFrom == nil { + continue + } + c.secretRefs(f, parent, ns, e.ValueFrom.SecretKeyRef) + c.configMapRefs(f, parent, ns, e.ValueFrom.ConfigMapKeyRef) + } + + for _, e := range co.EnvFrom { + if e.ConfigMapRef != nil { + gvr, id := "v1/configmaps", client.FQN(ns, e.ConfigMapRef.Name) + c.addRef(f, parent, gvr, id, e.ConfigMapRef.Optional) + } + if e.SecretRef != nil { + gvr, id := "v1/secrets", client.FQN(ns, e.SecretRef.Name) + c.addRef(f, parent, gvr, id, e.SecretRef.Optional) + } + } +} + +func (c *Container) secretRefs(f dao.Factory, parent *TreeNode, ns string, ref *v1.SecretKeySelector) { + if ref == nil { + return + } + gvr, id := "v1/secrets", client.FQN(ns, ref.LocalObjectReference.Name) + c.addRef(f, parent, id, gvr, ref.Optional) +} + +func (c *Container) configMapRefs(f dao.Factory, parent *TreeNode, ns string, ref *v1.ConfigMapKeySelector) { + if ref == nil { + return + } + gvr, id := "v1/configmaps", client.FQN(ns, ref.LocalObjectReference.Name) + c.addRef(f, parent, gvr, id, ref.Optional) +} + +func (c *Container) addRef(f dao.Factory, parent *TreeNode, gvr, id string, optional *bool) { + if parent.Find(gvr, id) == nil { + n := NewTreeNode(gvr, id) + validate(f, n, optional) + parent.Add(n) + } +} + +// Helpers... + +func validate(f dao.Factory, n *TreeNode, optional *bool) { + if optional == nil || *optional { + n.Extras[StatusKey] = OkStatus + return + } + res, err := f.Get(n.GVR, n.ID, false, labels.Everything()) + if err != nil || res == nil { + log.Debug().Msgf("Fail to located ref %q::%q -- %#v-%#v", n.GVR, n.ID, err, res) + n.Extras[StatusKey] = MissingRefStatus + return + } + n.Extras[StatusKey] = OkStatus +} diff --git a/internal/xray/container_test.go b/internal/xray/container_test.go new file mode 100644 index 00000000..3688df03 --- /dev/null +++ b/internal/xray/container_test.go @@ -0,0 +1,244 @@ +package xray_test + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/watch" + "github.com/derailed/k9s/internal/xray" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/informers" +) + +func init() { + zerolog.SetGlobalLevel(zerolog.FatalLevel) +} + +func TestCOConfigMapRefs(t *testing.T) { + var re xray.Container + + root := xray.NewTreeNode("root", "root") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", render.ContainerRes{Container: makeCMContainer("c1", false)})) + assert.Equal(t, xray.MissingRefStatus, root.Children[0].Children[0].Extras[xray.StatusKey]) +} + +func TestCORefs(t *testing.T) { + uu := map[string]struct { + co render.ContainerRes + level1, level2 int + e string + }{ + "cm_required": { + co: render.ContainerRes{Container: makeCMContainer("c1", false)}, + level1: 1, + level2: 1, + e: xray.MissingRefStatus, + }, + "cm_optional": { + co: render.ContainerRes{Container: makeCMContainer("c1", true)}, + level1: 1, + level2: 1, + e: xray.OkStatus, + }, + "cm_doubleRef": { + co: render.ContainerRes{Container: makeDoubleCMKeysContainer("c1", false)}, + level1: 1, + level2: 1, + e: xray.MissingRefStatus, + }, + "sec_required": { + co: render.ContainerRes{Container: makeSecContainer("c1", false)}, + level1: 1, + level2: 1, + e: xray.MissingRefStatus, + }, + "sec_optional": { + co: render.ContainerRes{Container: makeSecContainer("c1", true)}, + level1: 1, + level2: 1, + e: xray.OkStatus, + }, + "envFrom_optional": { + co: render.ContainerRes{Container: makeCMEnvFromContainer("c1", false)}, + level1: 1, + level2: 2, + e: xray.MissingRefStatus, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + var re xray.Container + root := xray.NewTreeNode("root", "root") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", u.co)) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + assert.Equal(t, u.e, root.Children[0].Children[0].Extras[xray.StatusKey]) + }) + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func makeFactory() testFactory { + return testFactory{} +} + +type testFactory struct { + rows []runtime.Object +} + +var _ dao.Factory = testFactory{} + +func (f testFactory) Client() client.Connection { + return nil +} +func (f testFactory) Get(gvr, path string, wait bool, sel labels.Selector) (runtime.Object, error) { + if len(f.rows) > 0 { + return f.rows[0], nil + } + return nil, nil +} +func (f testFactory) List(gvr, ns string, wait bool, sel labels.Selector) ([]runtime.Object, error) { + if len(f.rows) > 0 { + return f.rows, nil + } + return nil, nil +} +func (f testFactory) ForResource(ns, gvr string) informers.GenericInformer { + return nil +} +func (f testFactory) CanForResource(ns, gvr string, verbs []string) (informers.GenericInformer, error) { + return nil, nil +} +func (f testFactory) WaitForCacheSync() {} +func (f testFactory) Forwarders() watch.Forwarders { + return nil +} +func (f testFactory) DeleteForwarder(string) {} + +func makeCMEnvFromContainer(n string, optional bool) *v1.Container { + return &v1.Container{ + Name: n, + EnvFrom: []v1.EnvFromSource{ + { + ConfigMapRef: &v1.ConfigMapEnvSource{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Optional: &optional, + }, + SecretRef: &v1.SecretEnvSource{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "sec1", + }, + Optional: &optional, + }, + }, + }, + } +} + +func makeCMContainer(n string, optional bool) *v1.Container { + return &v1.Container{ + Name: n, + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Key: "k1", + Optional: &optional, + }, + }, + }, + }, + } +} + +func makeSecContainer(n string, optional bool) *v1.Container { + return &v1.Container{ + Name: n, + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "sec1", + }, + Key: "k1", + Optional: &optional, + }, + }, + }, + }, + } +} + +func makeDoubleCMKeysContainer(n string, optional bool) *v1.Container { + return &v1.Container{ + Name: n, + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Key: "k2", + Optional: &optional, + }, + }, + }, + { + Name: "e2", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Key: "k1", + Optional: &optional, + }, + }, + }, + }, + } +} + +func load(t *testing.T, n string) *unstructured.Unstructured { + raw, err := ioutil.ReadFile(fmt.Sprintf("test_assets/%s.json", n)) + assert.Nil(t, err) + + var o unstructured.Unstructured + err = json.Unmarshal(raw, &o) + assert.Nil(t, err) + + return &o +} diff --git a/internal/xray/dp.go b/internal/xray/dp.go new file mode 100644 index 00000000..530a0ee3 --- /dev/null +++ b/internal/xray/dp.go @@ -0,0 +1,94 @@ +package xray + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +type Deployment struct{} + +func (d *Deployment) Render(ctx context.Context, ns string, o interface{}) error { + raw, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("Expected Unstructured, but got %T", o) + } + var dp appsv1.Deployment + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &dp) + if err != nil { + return err + } + + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + + nsID, gvr := client.FQN(client.ClusterScope, dp.Namespace), "v1/namespaces" + nsn := parent.Find(gvr, nsID) + if nsn == nil { + nsn = NewTreeNode(gvr, nsID) + parent.Add(nsn) + } + root := NewTreeNode("apps/v1/deployments", client.FQN(dp.Namespace, dp.Name)) + nsn.Add(root) + + oo, err := locatePods(ctx, dp.Namespace, dp.Spec.Selector) + if err != nil { + return err + } + ctx = context.WithValue(ctx, KeyParent, root) + var re Pod + for _, o := range oo { + p := o.(*unstructured.Unstructured) + if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { + return err + } + } + + return d.validate(root, dp) +} + +func (*Deployment) validate(root *TreeNode, dp appsv1.Deployment) error { + root.Extras[StatusKey] = OkStatus + var r int32 + if dp.Spec.Replicas != nil { + r = int32(*dp.Spec.Replicas) + } + a := dp.Status.AvailableReplicas + if a != r { + root.Extras[StatusKey] = ToastStatus + } + return nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func locatePods(ctx context.Context, ns string, sel *metav1.LabelSelector) ([]runtime.Object, error) { + l, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + return nil, err + } + fsel, err := labels.ConvertSelectorToLabelsMap(l.String()) + if err != nil { + return nil, err + } + + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return nil, fmt.Errorf("Expecting a factory but got %T", ctx.Value(internal.KeyFactory)) + } + + return f.List("v1/pods", ns, false, fsel.AsSelector()) +} diff --git a/internal/xray/dp_test.go b/internal/xray/dp_test.go new file mode 100644 index 00000000..fc31f920 --- /dev/null +++ b/internal/xray/dp_test.go @@ -0,0 +1,40 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestDeployRender(t *testing.T) { + uu := map[string]struct { + file string + level1, level2 int + status string + }{ + "plain": { + file: "dp", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + } + + var re xray.Deployment + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("deployments", "deployments") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", o)) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + }) + } +} diff --git a/internal/xray/ds.go b/internal/xray/ds.go new file mode 100644 index 00000000..5f42baf3 --- /dev/null +++ b/internal/xray/ds.go @@ -0,0 +1,67 @@ +package xray + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +type DaemonSet struct{} + +func (d *DaemonSet) Render(ctx context.Context, ns string, o interface{}) error { + raw, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("Expected Unstructured, but got %T", o) + } + var ds appsv1.DaemonSet + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ds) + if err != nil { + return err + } + + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + + nsID, gvr := client.FQN(client.ClusterScope, ds.Namespace), "v1/namespaces" + nsn := parent.Find(gvr, nsID) + if nsn == nil { + nsn = NewTreeNode(gvr, nsID) + parent.Add(nsn) + } + root := NewTreeNode("apps/v1/daemonset", client.FQN(ds.Namespace, ds.Name)) + nsn.Add(root) + + oo, err := locatePods(ctx, ds.Namespace, ds.Spec.Selector) + if err != nil { + return err + } + + ctx = context.WithValue(ctx, KeyParent, root) + var re Pod + for _, o := range oo { + p := o.(*unstructured.Unstructured) + if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { + return err + } + } + + return d.validate(root, ds) +} + +func (*DaemonSet) validate(root *TreeNode, ds appsv1.DaemonSet) error { + root.Extras[StatusKey] = OkStatus + d := ds.Status.DesiredNumberScheduled + a := ds.Status.NumberAvailable + if d != a { + root.Extras[StatusKey] = ToastStatus + } + + return nil +} diff --git a/internal/xray/ds_test.go b/internal/xray/ds_test.go new file mode 100644 index 00000000..877f3dd0 --- /dev/null +++ b/internal/xray/ds_test.go @@ -0,0 +1,40 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestDaemonSetRender(t *testing.T) { + uu := map[string]struct { + file string + level1, level2 int + status string + }{ + "plain": { + file: "ds", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + } + + var re xray.DaemonSet + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("daemonsets", "daemonsets") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", o)) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + }) + } +} diff --git a/internal/xray/generic.go b/internal/xray/generic.go new file mode 100644 index 00000000..35bde59e --- /dev/null +++ b/internal/xray/generic.go @@ -0,0 +1,67 @@ +package xray + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/derailed/k9s/internal/client" + metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" +) + +// Generic renders a generic resource to screen. +type Generic struct { + table *metav1beta1.Table +} + +// SetTable sets the tabular resource. +func (g *Generic) SetTable(t *metav1beta1.Table) { + g.table = t +} + +// Render renders a K8s resource to screen. +func (g *Generic) Render(ctx context.Context, ns string, o interface{}) error { + row, ok := o.(metav1beta1.TableRow) + if !ok { + return fmt.Errorf("expecting a TableRow but got %T", o) + } + + n, ok := row.Cells[0].(string) + if !ok { + return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0]) + } + + root := NewTreeNode("generic", client.FQN(ns, n)) + parent := ctx.Value(KeyParent).(*TreeNode) + parent.Add(root) + + return nil +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func resourceNS(raw []byte) (bool, string, error) { + var obj map[string]interface{} + err := json.Unmarshal(raw, &obj) + if err != nil { + return false, "", err + } + + meta, ok := obj["metadata"].(map[string]interface{}) + if !ok { + return false, "", errors.New("no metadata found on generic resource") + } + + ns, ok := meta["namespace"] + if !ok { + return true, "", nil + } + + nns, ok := ns.(string) + if !ok { + return false, "", fmt.Errorf("expecting namespace string type but got %T", ns) + } + return false, nns, nil +} diff --git a/internal/xray/generic_test.go b/internal/xray/generic_test.go new file mode 100644 index 00000000..a37330ec --- /dev/null +++ b/internal/xray/generic_test.go @@ -0,0 +1,44 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" + metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" +) + +func TestGenericRender(t *testing.T) { + uu := map[string]struct { + level1 int + status string + }{ + "plain": { + level1: 1, + status: xray.OkStatus, + }, + } + + var re xray.Generic + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + root := xray.NewTreeNode("generics", "generics") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", makeTable())) + assert.Equal(t, u.level1, root.Size()) + }) + } +} + +// Helpers... + +func makeTable() metav1beta1.TableRow { + return metav1beta1.TableRow{ + Cells: []interface{}{"fred", "blee"}, + } +} diff --git a/internal/xray/ns.go b/internal/xray/ns.go new file mode 100644 index 00000000..3a76d94f --- /dev/null +++ b/internal/xray/ns.go @@ -0,0 +1,35 @@ +package xray + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal/client" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +type Namespace struct{} + +func (p *Namespace) Render(ctx context.Context, ns string, o interface{}) error { + raw, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("Expected NamespaceWithMetrics, but got %T", o) + } + + var nss v1.Namespace + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &nss) + if err != nil { + return err + } + + root := NewTreeNode("v1/namespaces", client.FQN(client.ClusterScope, nss.Name)) + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + parent.Add(root) + + return nil +} diff --git a/internal/xray/ns_test.go b/internal/xray/ns_test.go new file mode 100644 index 00000000..216eb1a1 --- /dev/null +++ b/internal/xray/ns_test.go @@ -0,0 +1,38 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestNamespaceRender(t *testing.T) { + uu := map[string]struct { + file string + level1 int + status string + }{ + "plain": { + file: "ns", + level1: 1, + status: xray.OkStatus, + }, + } + + var re xray.Namespace + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("namespaces", "namespaces") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", o)) + assert.Equal(t, u.level1, root.Size()) + }) + } +} diff --git a/internal/xray/pod.go b/internal/xray/pod.go new file mode 100644 index 00000000..2e9dcb69 --- /dev/null +++ b/internal/xray/pod.go @@ -0,0 +1,178 @@ +package xray + +import ( + "context" + "fmt" + "strconv" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/render" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kubernetes/pkg/util/node" +) + +type Pod struct{} + +func (p *Pod) Status(po *v1.Pod) { + +} + +func (p *Pod) Render(ctx context.Context, ns string, o interface{}) error { + pwm, ok := o.(*render.PodWithMetrics) + if !ok { + return fmt.Errorf("Expected PodWithMetrics, but got %T", o) + } + + var po v1.Pod + err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po) + if err != nil { + return err + } + + phase := p.phase(&po) + ss := po.Status.ContainerStatuses + cr, _, _ := p.statuses(ss) + status := OkStatus + if cr != len(ss) { + status = ToastStatus + } + if phase == "Completed" { + status = CompletedStatus + } + + root := NewTreeNode("v1/pods", client.FQN(po.Namespace, po.Name)) + root.Extras[StatusKey] = status + root.Extras[StateKey] = strconv.Itoa(cr) + "/" + strconv.Itoa(len(ss)) + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + parent.Add(root) + + ctx = context.WithValue(ctx, KeyParent, root) + var cre Container + for i := 0; i < len(po.Spec.InitContainers); i++ { + if err := cre.Render(ctx, ns, render.ContainerRes{Container: &po.Spec.InitContainers[i]}); err != nil { + return err + } + } + for i := 0; i < len(po.Spec.Containers); i++ { + if err := cre.Render(ctx, ns, render.ContainerRes{Container: &po.Spec.Containers[i]}); err != nil { + return err + } + } + p.podVolumeRefs(root, po.Namespace, po.Spec.Volumes) + + return nil +} + +func (*Pod) podVolumeRefs(parent *TreeNode, ns string, vv []v1.Volume) { + for _, v := range vv { + sv := v.VolumeSource.Secret + if sv != nil { + parent.Add(NewTreeNode("v1/secrets", client.FQN(ns, sv.SecretName))) + continue + } + + cmv := v.VolumeSource.ConfigMap + if cmv != nil { + parent.Add(NewTreeNode("v1/configmaps", client.FQN(ns, cmv.LocalObjectReference.Name))) + } + } +} + +// BOZO!! Dedup... +func (*Pod) statuses(ss []v1.ContainerStatus) (cr, ct, rc int) { + for _, c := range ss { + if c.State.Terminated != nil { + ct++ + } + if c.Ready { + cr = cr + 1 + } + rc += int(c.RestartCount) + } + + return +} + +func (p *Pod) phase(po *v1.Pod) string { + status := string(po.Status.Phase) + if po.Status.Reason != "" { + if po.DeletionTimestamp != nil && po.Status.Reason == node.NodeUnreachablePodReason { + return "Unknown" + } + status = po.Status.Reason + } + + status, ok := p.initContainerPhase(po.Status, len(po.Spec.InitContainers), status) + if ok { + return status + } + + status, ok = p.containerPhase(po.Status, status) + if ok && status == "Completed" { + status = "Running" + } + if po.DeletionTimestamp == nil { + return status + } + + return "Terminated" +} + +func (*Pod) containerPhase(st v1.PodStatus, status string) (string, bool) { + var running bool + for i := len(st.ContainerStatuses) - 1; i >= 0; i-- { + cs := st.ContainerStatuses[i] + switch { + case cs.State.Waiting != nil && cs.State.Waiting.Reason != "": + status = cs.State.Waiting.Reason + case cs.State.Terminated != nil && cs.State.Terminated.Reason != "": + status = cs.State.Terminated.Reason + case cs.State.Terminated != nil: + if cs.State.Terminated.Signal != 0 { + status = "Signal:" + strconv.Itoa(int(cs.State.Terminated.Signal)) + } else { + status = "ExitCode:" + strconv.Itoa(int(cs.State.Terminated.ExitCode)) + } + case cs.Ready && cs.State.Running != nil: + running = true + } + } + + return status, running +} + +func (p *Pod) initContainerPhase(st v1.PodStatus, initCount int, status string) (string, bool) { + for i, cs := range st.InitContainerStatuses { + s := checkContainerStatus(cs, i, initCount) + if s == "" { + continue + } + return s, true + } + + return status, false +} + +func checkContainerStatus(cs v1.ContainerStatus, i, initCount int) string { + switch { + case cs.State.Terminated != nil: + if cs.State.Terminated.ExitCode == 0 { + return "" + } + if cs.State.Terminated.Reason != "" { + return "Init:" + cs.State.Terminated.Reason + } + if cs.State.Terminated.Signal != 0 { + return "Init:Signal:" + strconv.Itoa(int(cs.State.Terminated.Signal)) + } + return "Init:ExitCode:" + strconv.Itoa(int(cs.State.Terminated.ExitCode)) + case cs.State.Waiting != nil && cs.State.Waiting.Reason != "" && cs.State.Waiting.Reason != "PodInitializing": + return "Init:" + cs.State.Waiting.Reason + default: + return "Init:" + strconv.Itoa(i) + "/" + strconv.Itoa(initCount) + } +} diff --git a/internal/xray/pod_test.go b/internal/xray/pod_test.go new file mode 100644 index 00000000..dba40d9b --- /dev/null +++ b/internal/xray/pod_test.go @@ -0,0 +1,194 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/render" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPodRender(t *testing.T) { + uu := map[string]struct { + file string + level1, level2 int + status string + }{ + "plain": { + file: "po", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + "withInit": { + file: "init", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + } + + var re xray.Pod + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("pods", "pods") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", &render.PodWithMetrics{Raw: o})) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + }) + } +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func makePod(n string) v1.Pod { + return v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: n, + Namespace: "default", + }, + } +} + +func makePodEnv(n, ref string, optional bool) v1.Pod { + po := makePod(n) + po.Spec.Containers = []v1.Container{ + { + Name: "c1", + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Key: "k1", + Optional: &optional, + }, + }, + }, + }, + }, + { + Name: "c2", + Env: []v1.EnvVar{ + { + Name: "e2", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm2", + }, + Key: "k2", + Optional: &optional, + }, + }, + }, + }, + }, + } + po.Spec.InitContainers = []v1.Container{ + { + Name: "ic1", + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "sec2"}, + Key: "k2", + Optional: &optional, + }, + }, + }, + }, + }, + } + + return po +} + +func makePodStatus(n, ref string, optional bool) v1.Pod { + po := makePod(n) + po.Status = v1.PodStatus{ + Phase: v1.PodRunning, + Conditions: []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionTrue, + }, + }, + ContainerStatuses: []v1.ContainerStatus{ + { + Name: "c1", + State: v1.ContainerState{Running: &v1.ContainerStateRunning{}}, + }, + }, + } + po.Spec.Containers = []v1.Container{ + { + Name: "c1", + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm1", + }, + Key: "k1", + Optional: &optional, + }, + }, + }, + }, + }, + { + Name: "c2", + Env: []v1.EnvVar{ + { + Name: "e2", + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: "cm2", + }, + Key: "k2", + Optional: &optional, + }, + }, + }, + }, + }, + } + po.Spec.InitContainers = []v1.Container{ + { + Name: "ic1", + Env: []v1.EnvVar{ + { + Name: "e1", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "sec2"}, + Key: "k2", + Optional: &optional, + }, + }, + }, + }, + }, + } + + return po +} diff --git a/internal/xray/sts.go b/internal/xray/sts.go new file mode 100644 index 00000000..41c4db95 --- /dev/null +++ b/internal/xray/sts.go @@ -0,0 +1,87 @@ +package xray + +import ( + "context" + "fmt" + + "github.com/derailed/k9s/internal" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +type StatefulSet struct{} + +func (p *StatefulSet) Render(ctx context.Context, ns string, o interface{}) error { + raw, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("Expected Unstructured, but got %T", o) + } + + var sts appsv1.StatefulSet + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sts) + if err != nil { + return err + } + + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + + nsID, gvr := client.FQN(client.ClusterScope, sts.Namespace), "v1/namespaces" + nsn := parent.Find(gvr, nsID) + if nsn == nil { + nsn = NewTreeNode(gvr, nsID) + parent.Add(nsn) + } + root := NewTreeNode("apps/v1/deployments", client.FQN(sts.Namespace, sts.Name)) + nsn.Add(root) + + l, err := metav1.LabelSelectorAsSelector(sts.Spec.Selector) + if err != nil { + return err + } + + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return fmt.Errorf("Expecting a factory but got %T", ctx.Value(internal.KeyFactory)) + } + + fsel, err := labels.ConvertSelectorToLabelsMap(l.String()) + if err != nil { + return err + } + + oo, err := f.List("v1/pods", sts.Namespace, false, fsel.AsSelector()) + if err != nil { + return err + } + + ctx = context.WithValue(ctx, KeyParent, root) + var re Pod + for _, o := range oo { + p := o.(*unstructured.Unstructured) + if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { + return err + } + } + + root.Extras[StatusKey] = OkStatus + var r int32 + if sts.Spec.Replicas != nil { + r = int32(*sts.Spec.Replicas) + } + a := sts.Status.Replicas + if a != r { + root.Extras[StatusKey] = ToastStatus + } + + return nil +} diff --git a/internal/xray/sts_test.go b/internal/xray/sts_test.go new file mode 100644 index 00000000..2685913f --- /dev/null +++ b/internal/xray/sts_test.go @@ -0,0 +1,40 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestStatefulSetRender(t *testing.T) { + uu := map[string]struct { + file string + level1, level2 int + status string + }{ + "plain": { + file: "sts", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + } + + var re xray.StatefulSet + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("statefulsets", "statefulsets") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", o)) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + }) + } +} diff --git a/internal/xray/svc.go b/internal/xray/svc.go new file mode 100644 index 00000000..02324271 --- /dev/null +++ b/internal/xray/svc.go @@ -0,0 +1,82 @@ +package xray + +import ( + "context" + "fmt" + "strings" + + "github.com/derailed/k9s/internal" + + "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/render" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +type Service struct{} + +func (s *Service) Render(ctx context.Context, ns string, o interface{}) error { + raw, ok := o.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("Expected Unstructured, but got %T", o) + } + + var svc v1.Service + err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &svc) + if err != nil { + return err + } + + parent, ok := ctx.Value(KeyParent).(*TreeNode) + if !ok { + return fmt.Errorf("Expecting a TreeNode but got %T", ctx.Value(KeyParent)) + } + + nsID, gvr := client.FQN(client.ClusterScope, svc.Namespace), "v1/namespaces" + nsn := parent.Find(gvr, nsID) + if nsn == nil { + nsn = NewTreeNode(gvr, nsID) + parent.Add(nsn) + } + root := NewTreeNode("apps/v1/services", client.FQN(svc.Namespace, svc.Name)) + nsn.Add(root) + + oo, err := s.locatePods(ctx, svc.Namespace, svc.Spec.Selector) + if err != nil { + return err + } + + ctx = context.WithValue(ctx, KeyParent, root) + var re Pod + for _, o := range oo { + p := o.(*unstructured.Unstructured) + if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { + return err + } + } + root.Extras[StatusKey] = OkStatus + + return nil +} + +func (s *Service) locatePods(ctx context.Context, ns string, sel map[string]string) ([]runtime.Object, error) { + f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) + if !ok { + return nil, fmt.Errorf("Expecting a factory but got %T", ctx.Value(internal.KeyFactory)) + } + + var ll []string + for k, v := range sel { + ll = append(ll, fmt.Sprintf("%s=%s", k, v)) + } + + fsel, err := labels.ConvertSelectorToLabelsMap(strings.Join(ll, ",")) + if err != nil { + return nil, err + } + + return f.List("v1/pods", ns, false, fsel.AsSelector()) +} diff --git a/internal/xray/svc_test.go b/internal/xray/svc_test.go new file mode 100644 index 00000000..dec8de1e --- /dev/null +++ b/internal/xray/svc_test.go @@ -0,0 +1,40 @@ +package xray_test + +import ( + "context" + "testing" + + "github.com/derailed/k9s/internal" + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestServiceRender(t *testing.T) { + uu := map[string]struct { + file string + level1, level2 int + status string + }{ + "plain": { + file: "svc", + level1: 1, + level2: 1, + status: xray.OkStatus, + }, + } + + var re xray.Service + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + o := load(t, u.file) + root := xray.NewTreeNode("services", "services") + ctx := context.WithValue(context.Background(), xray.KeyParent, root) + ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) + + assert.Nil(t, re.Render(ctx, "", o)) + assert.Equal(t, u.level1, root.Size()) + assert.Equal(t, u.level2, root.Children[0].Size()) + }) + } +} diff --git a/internal/xray/test_assets/dp.json b/internal/xray/test_assets/dp.json new file mode 100644 index 00000000..4b3f3d8e --- /dev/null +++ b/internal/xray/test_assets/dp.json @@ -0,0 +1,118 @@ +{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "3", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"nginx\"},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"nginx\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"nginx\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"FRED\",\"valueFrom\":{\"configMapKeyRef\":{\"key\":\"fred\",\"name\":\"busy\"}}},{\"name\":\"PROPS\",\"valueFrom\":{\"configMapKeyRef\":{\"key\":\"props\",\"name\":\"busy\"}}}],\"image\":\"k8s.gcr.io/nginx-slim:0.8\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"200Mi\"}}}]}}}}\n" + }, + "creationTimestamp": "2020-01-16T04:18:04Z", + "generation": 4, + "labels": { + "app": "nginx" + }, + "name": "nginx", + "namespace": "default", + "resourceVersion": "3338230", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/nginx", + "uid": "a2baf77e-5301-4efd-ac40-ff3da9716c80" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 1, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [ + { + "env": [ + { + "name": "FRED", + "valueFrom": { + "configMapKeyRef": { + "key": "fred", + "name": "busy" + } + } + }, + { + "name": "PROPS", + "valueFrom": { + "configMapKeyRef": { + "key": "props", + "name": "busy" + } + } + } + ], + "image": "k8s.gcr.io/nginx-slim:0.8", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": { + "limits": { + "cpu": "100m", + "memory": "200Mi" + } + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 1, + "conditions": [ + { + "lastTransitionTime": "2020-01-16T14:52:45Z", + "lastUpdateTime": "2020-01-16T14:52:45Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + }, + { + "lastTransitionTime": "2020-01-18T01:20:50Z", + "lastUpdateTime": "2020-01-18T01:20:50Z", + "message": "ReplicaSet \"nginx-5bbc876d89\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + } + ], + "observedGeneration": 4, + "readyReplicas": 1, + "replicas": 1, + "updatedReplicas": 1 + } +} diff --git a/internal/xray/test_assets/ds.json b/internal/xray/test_assets/ds.json new file mode 100644 index 00000000..05552838 --- /dev/null +++ b/internal/xray/test_assets/ds.json @@ -0,0 +1,109 @@ +{ + "apiVersion": "apps/v1", + "kind": "DaemonSet", + "metadata": { + "annotations": { + "deprecated.daemonset.template.generation": "1", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"DaemonSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"k8s-app\":\"fluentd-logging\"},\"name\":\"fluentd-elasticsearch\",\"namespace\":\"default\"},\"spec\":{\"selector\":{\"matchLabels\":{\"name\":\"fluentd-elasticsearch\"}},\"template\":{\"metadata\":{\"labels\":{\"name\":\"fluentd-elasticsearch\"}},\"spec\":{\"containers\":[{\"image\":\"fluentd\",\"name\":\"fluentd-elasticsearch\",\"resources\":{\"limits\":{\"memory\":\"200Mi\"},\"requests\":{\"cpu\":\"100m\",\"memory\":\"200Mi\"}},\"volumeMounts\":[{\"mountPath\":\"/var/log\",\"name\":\"varlog\"},{\"mountPath\":\"/var/lib/docker/containers\",\"name\":\"varlibdockercontainers\",\"readOnly\":true}]}],\"terminationGracePeriodSeconds\":1,\"tolerations\":[{\"effect\":\"NoSchedule\",\"key\":\"node-role.kubernetes.io/master\"}],\"volumes\":[{\"hostPath\":{\"path\":\"/var/log\"},\"name\":\"varlog\"},{\"hostPath\":{\"path\":\"/var/lib/docker/containers\"},\"name\":\"varlibdockercontainers\"}]}}}}\n" + }, + "creationTimestamp": "2020-01-18T14:43:04Z", + "generation": 1, + "labels": { + "k8s-app": "fluentd-logging" + }, + "name": "fluentd-elasticsearch", + "namespace": "default", + "resourceVersion": "3450170", + "selfLink": "/apis/apps/v1/namespaces/default/daemonsets/fluentd-elasticsearch", + "uid": "8c03864a-a428-4769-b89c-11d66e01614d" + }, + "spec": { + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "name": "fluentd-elasticsearch" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "name": "fluentd-elasticsearch" + } + }, + "spec": { + "containers": [ + { + "image": "fluentd", + "imagePullPolicy": "Always", + "name": "fluentd-elasticsearch", + "resources": { + "limits": { + "memory": "200Mi" + }, + "requests": { + "cpu": "100m", + "memory": "200Mi" + } + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/log", + "name": "varlog" + }, + { + "mountPath": "/var/lib/docker/containers", + "name": "varlibdockercontainers", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 1, + "tolerations": [ + { + "effect": "NoSchedule", + "key": "node-role.kubernetes.io/master" + } + ], + "volumes": [ + { + "hostPath": { + "path": "/var/log", + "type": "" + }, + "name": "varlog" + }, + { + "hostPath": { + "path": "/var/lib/docker/containers", + "type": "" + }, + "name": "varlibdockercontainers" + } + ] + } + }, + "updateStrategy": { + "rollingUpdate": { + "maxUnavailable": 1 + }, + "type": "RollingUpdate" + } + }, + "status": { + "currentNumberScheduled": 1, + "desiredNumberScheduled": 1, + "numberAvailable": 1, + "numberMisscheduled": 0, + "numberReady": 1, + "observedGeneration": 1, + "updatedNumberScheduled": 1 + } +} diff --git a/internal/xray/test_assets/init.json b/internal/xray/test_assets/init.json new file mode 100644 index 00000000..eec3f4a1 --- /dev/null +++ b/internal/xray/test_assets/init.json @@ -0,0 +1,187 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"hurry-up-and-wait\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"command\":[\"sh\",\"-c\",\"echo The app is running! \\u0026\\u0026 sleep 3600\"],\"image\":\"busybox\",\"name\":\"busy\",\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"100Mi\"}}}],\"initContainers\":[{\"command\":[\"sh\",\"-c\",\"echo \\\"sleeping...\\\"; sleep 10\"],\"image\":\"busybox\",\"name\":\"init-sleep\",\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"100Mi\"}}}]}}\n" + }, + "creationTimestamp": "2020-01-18T06:31:29Z", + "name": "hurry-up-and-wait", + "namespace": "default", + "resourceVersion": "3381576", + "selfLink": "/api/v1/namespaces/default/pods/hurry-up-and-wait", + "uid": "6b29055a-433b-4398-bfde-0fd371759bbf" + }, + "spec": { + "containers": [ + { + "command": [ + "sh", + "-c", + "echo The app is running! \u0026\u0026 sleep 3600" + ], + "image": "busybox", + "imagePullPolicy": "Always", + "name": "busy", + "resources": { + "limits": { + "cpu": "100m", + "memory": "100Mi" + }, + "requests": { + "cpu": "100m", + "memory": "100Mi" + } + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-rr22g", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "initContainers": [ + { + "command": [ + "sh", + "-c", + "echo \"sleeping...\"; sleep 10" + ], + "image": "busybox", + "imagePullPolicy": "Always", + "name": "init-sleep", + "resources": { + "limits": { + "cpu": "100m", + "memory": "100Mi" + }, + "requests": { + "cpu": "100m", + "memory": "100Mi" + } + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-rr22g", + "readOnly": true + } + ] + } + ], + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-rr22g", + "secret": { + "defaultMode": 420, + "secretName": "default-token-rr22g" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-01-18T06:31:42Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-01-18T06:31:44Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-01-18T06:31:44Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-01-18T06:31:29Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://3c4de1de5d3c8f78bcce5f65218d5cbe4ed7b7b86261dd74dcc0f96e832e7db3", + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:6915be4043561d64e0ab0f8f098dc2ac48e077fe23f488ac24b665166898115a", + "lastState": {}, + "name": "busy", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-01-18T06:31:43Z" + } + } + } + ], + "hostIP": "192.168.64.6", + "initContainerStatuses": [ + { + "containerID": "docker://87f5d5f73827b402263ef77ca72b715c4ad858e7da71abc5655cc049e4c2ae20", + "image": "busybox:latest", + "imageID": "docker-pullable://busybox@sha256:6915be4043561d64e0ab0f8f098dc2ac48e077fe23f488ac24b665166898115a", + "lastState": {}, + "name": "init-sleep", + "ready": true, + "restartCount": 0, + "state": { + "terminated": { + "containerID": "docker://87f5d5f73827b402263ef77ca72b715c4ad858e7da71abc5655cc049e4c2ae20", + "exitCode": 0, + "finishedAt": "2020-01-18T06:31:42Z", + "reason": "Completed", + "startedAt": "2020-01-18T06:31:32Z" + } + } + } + ], + "phase": "Running", + "podIP": "172.17.0.11", + "podIPs": [ + { + "ip": "172.17.0.11" + } + ], + "qosClass": "Guaranteed", + "startTime": "2020-01-18T06:31:29Z" + } +} \ No newline at end of file diff --git a/internal/xray/test_assets/ns.json b/internal/xray/test_assets/ns.json new file mode 100644 index 00000000..d88bfe32 --- /dev/null +++ b/internal/xray/test_assets/ns.json @@ -0,0 +1,19 @@ +{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { + "creationTimestamp": "2019-12-31T20:49:23Z", + "name": "default", + "resourceVersion": "146", + "selfLink": "/api/v1/namespaces/default", + "uid": "3da8811c-7632-4a42-b4f5-608c21165ff7" + }, + "spec": { + "finalizers": [ + "kubernetes" + ] + }, + "status": { + "phase": "Active" + } +} diff --git a/internal/xray/test_assets/po.json b/internal/xray/test_assets/po.json new file mode 100644 index 00000000..57d2c30b --- /dev/null +++ b/internal/xray/test_assets/po.json @@ -0,0 +1,140 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"image\":\"nginx:alpine\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"volumeMounts\":[{\"mountPath\":\"/usr/share/nginx/html\",\"name\":\"index\"}]}],\"terminationGracePeriodSeconds\":0,\"volumes\":[{\"name\":\"index\",\"persistentVolumeClaim\":{\"claimName\":\"web\"}}]}}\n" + }, + "creationTimestamp": "2019-08-09T05:12:19Z", + "name": "nginx", + "namespace": "default", + "resourceVersion": "1482816", + "selfLink": "/api/v1/namespaces/default/pods/nginx", + "uid": "614908ed-415b-4506-8370-e3e36fa8cc13" + }, + "spec": { + "containers": [ + { + "image": "nginx:alpine", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": { + "limits": { + "memory": "170Mi" + }, + "requests": { + "cpu": "100m", + "memory": "70Mi" + } + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/usr/share/nginx/html", + "name": "index" + }, + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-9ph8s", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 0, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "index", + "persistentVolumeClaim": { + "claimName": "web" + } + }, + { + "name": "default-token-9ph8s", + "secret": { + "defaultMode": 420, + "secretName": "default-token-9ph8s" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2019-08-09T05:12:19Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2019-08-09T05:12:21Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2019-08-09T05:12:21Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2019-08-09T05:12:19Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://421bd26d6c682f14b5ea1dcaf06e14a509b2b702fc7793e820520eb1e28e2eaf", + "image": "nginx:alpine", + "imageID": "docker-pullable://nginx@sha256:482ead44b2203fa32b3390abdaf97cbdc8ad15c07fb03a3e68d7c35a19ad7595", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "state": { + "running": { + "startedAt": "2019-08-09T05:12:20Z" + } + } + } + ], + "hostIP": "192.168.64.104", + "phase": "Running", + "podIP": "172.17.0.6", + "qosClass": "BestEffort", + "startTime": "2019-08-09T05:12:19Z" + } +} \ No newline at end of file diff --git a/internal/xray/test_assets/sts.json b/internal/xray/test_assets/sts.json new file mode 100644 index 00000000..46b20c8f --- /dev/null +++ b/internal/xray/test_assets/sts.json @@ -0,0 +1,78 @@ +{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"StatefulSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"nginx-sts\"},\"name\":\"nginx-sts\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"nginx-sts\"}},\"serviceName\":\"nginx-sts\",\"template\":{\"metadata\":{\"labels\":{\"app\":\"nginx-sts\"}},\"spec\":{\"containers\":[{\"image\":\"k8s.gcr.io/nginx-slim:0.8\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80,\"name\":\"web\"}]}]}}}}\n" + }, + "creationTimestamp": "2020-01-15T06:48:21Z", + "generation": 1, + "labels": { + "app": "nginx-sts" + }, + "name": "nginx-sts", + "namespace": "default", + "resourceVersion": "2946929", + "selfLink": "/apis/apps/v1/namespaces/default/statefulsets/nginx-sts", + "uid": "59c516cb-9fe4-4d7f-b7f4-479928506423" + }, + "spec": { + "podManagementPolicy": "OrderedReady", + "replicas": 2, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "nginx-sts" + } + }, + "serviceName": "nginx-sts", + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "nginx-sts" + } + }, + "spec": { + "containers": [ + { + "image": "k8s.gcr.io/nginx-slim:0.8", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "name": "web", + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + }, + "updateStrategy": { + "rollingUpdate": { + "partition": 0 + }, + "type": "RollingUpdate" + } + }, + "status": { + "collisionCount": 0, + "currentReplicas": 2, + "currentRevision": "nginx-sts-688d57df8f", + "observedGeneration": 1, + "readyReplicas": 2, + "replicas": 2, + "updateRevision": "nginx-sts-688d57df8f", + "updatedReplicas": 2 + } +} diff --git a/internal/xray/test_assets/svc.json b/internal/xray/test_assets/svc.json new file mode 100644 index 00000000..a9c075a2 --- /dev/null +++ b/internal/xray/test_assets/svc.json @@ -0,0 +1,35 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"nodePort\":30805,\"port\":8080,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"nginx\"},\"type\":\"NodePort\"}}\n" + }, + "creationTimestamp": "2020-01-16T04:18:04Z", + "name": "nginx", + "namespace": "default", + "resourceVersion": "3066081", + "selfLink": "/api/v1/namespaces/default/services/nginx", + "uid": "3dc94561-06ce-4e56-8002-7c4679203d5b" + }, + "spec": { + "clusterIP": "10.96.10.89", + "externalTrafficPolicy": "Cluster", + "ports": [ + { + "nodePort": 30805, + "port": 8080, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "nginx" + }, + "sessionAffinity": "None", + "type": "NodePort" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/internal/xray/tree_node.go b/internal/xray/tree_node.go new file mode 100644 index 00000000..8612b26d --- /dev/null +++ b/internal/xray/tree_node.go @@ -0,0 +1,369 @@ +package xray + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/derailed/k9s/internal/client" + "github.com/rs/zerolog/log" + "vbom.ml/util/sortorder" +) + +// TreeRef namespaces tree context values. +type TreeRef string + +const ( + // KeyParent indicates a parent node context key. + KeyParent TreeRef = "parent" + + // PathSeparator represents a node path separator. + PathSeparator = "::" + + // StatusKey status map key. + StatusKey = "status" + + // StateKey state map key. + StateKey = "state" + + // OkStatus stands for all is cool. + OkStatus = "ok" + + // ToastStatus stands for a resource is not up to snuff + // aka not running or imcomplete. + ToastStatus = "toast" + + // CompletedStatus stands for a completed resource. + CompletedStatus = "completed" + + // MissingRefStatus stands for a non existing resource reference. + MissingRefStatus = "noref" +) + +type Childrens []*TreeNode + +// Len returns the list size. +func (c Childrens) Len() int { + return len(c) +} + +// Swap swaps list values. +func (c Childrens) Swap(i, j int) { + c[i], c[j] = c[j], c[i] +} + +// Less returns true if i < j. +func (c Childrens) Less(i, j int) bool { + id1, id2 := c[i].ID, c[j].ID + + return sortorder.NaturalLess(id1, id2) +} + +type TreeNode struct { + GVR, ID string + Children Childrens + Parent *TreeNode + Extras map[string]string +} + +func NewTreeNode(gvr, id string) *TreeNode { + return &TreeNode{ + GVR: gvr, + ID: id, + Extras: make(map[string]string), + } +} + +func (t *TreeNode) Size() int { + return len(t.Children) +} + +func count(t *TreeNode, counter int) int { + for _, c := range t.Children { + counter += count(c, counter) + } + return counter +} + +func (t *TreeNode) Diff(d *TreeNode) bool { + if t == nil { + return d != nil + } + + if t.Size() != d.Size() { + log.Debug().Msgf("SIZE-DIFF") + return true + } + + if t.ID != d.ID || t.GVR != d.GVR || !reflect.DeepEqual(t.Extras, d.Extras) { + log.Debug().Msgf("ID DIFF") + return true + } + for i := 0; i < len(t.Children); i++ { + if t.Children[i].Diff(d.Children[i]) { + log.Debug().Msgf("CHILD-DIFF") + return true + } + } + return false +} + +func (t *TreeNode) Sort() { + sortChildren(t) +} + +func sortChildren(t *TreeNode) { + sort.Sort(t.Children) + for _, c := range t.Children { + sortChildren(c) + } +} + +type NodeSpec struct { + GVR, Path string +} + +func (t *TreeNode) Spec() NodeSpec { + parent := t + var gvr, path []string + for parent != nil { + gvr = append(gvr, parent.GVR) + path = append(path, parent.ID) + parent = parent.Parent + } + + return NodeSpec{ + GVR: strings.Join(gvr, PathSeparator), + Path: strings.Join(path, PathSeparator), + } +} + +func (t *TreeNode) Flatten() []NodeSpec { + var refs []NodeSpec + for _, c := range t.Children { + if c.IsLeaf() { + refs = append(refs, c.Spec()) + continue + } + refs = append(refs, c.Flatten()...) + } + return refs +} + +func (t *TreeNode) Blank() bool { + return t.GVR == "" && t.ID == "" +} + +func Hydrate(refs []NodeSpec) *TreeNode { + root := NewTreeNode("", "") + nav := root + for _, ref := range refs { + ids := strings.Split(ref.Path, PathSeparator) + gvrs := strings.Split(ref.GVR, PathSeparator) + for i := len(ids) - 1; i >= 0; i-- { + if nav.Blank() { + nav.GVR, nav.ID = gvrs[i], ids[i] + continue + } + c := NewTreeNode(gvrs[i], ids[i]) + if n := nav.Find(gvrs[i], ids[i]); n == nil { + nav.Add(c) + nav = c + } else { + nav = n + } + } + nav = root + } + + return root +} + +func (t *TreeNode) Level() int { + var level int + p := t + for p != nil { + p = p.Parent + level++ + } + return level - 1 +} + +func (t *TreeNode) MaxDepth(depth int) int { + max := depth + for _, c := range t.Children { + m := c.MaxDepth(depth + 1) + if m > max { + max = m + } + } + return max +} + +func makeSpacer(d int) string { + return strings.Repeat(" ", d) +} + +func (t *TreeNode) Root() *TreeNode { + for p := t; p != nil; p = p.Parent { + if p.Parent == nil { + return p + } + } + return nil +} + +func (r *TreeNode) IsLeaf() bool { + return r.Empty() +} + +func (r *TreeNode) IsRoot() bool { + return r.Parent == nil +} + +func (r *TreeNode) ShallowClone() *TreeNode { + return &TreeNode{GVR: r.GVR, ID: r.ID, Extras: r.Extras} +} + +func (r *TreeNode) Filter(q string, filter func(q, path string) bool) *TreeNode { + specs := r.Flatten() + matches := make([]NodeSpec, 0, len(specs)) + for _, s := range specs { + if filter(q, s.Path) { + matches = append(matches, s) + } + } + + if len(matches) == 0 { + return nil + } + return Hydrate(matches) +} + +func (t *TreeNode) Find(gvr, id string) *TreeNode { + if t.GVR == gvr && t.ID == id { + return t + } + for _, c := range t.Children { + if v := c.Find(gvr, id); v != nil { + return v + } + } + return nil +} + +func (t *TreeNode) Title() string { + const withNS = "[white::b]%s[-::d]" + + title := fmt.Sprintf(withNS, t.colorize()) + + if t.Size() > 0 { + title += fmt.Sprintf("([white::d]%d[-::d])[-::-]", t.Size()) + } + + return title +} + +func (t *TreeNode) Empty() bool { + return len(t.Children) == 0 +} + +func (t *TreeNode) Clear() { + t.Children = []*TreeNode{} +} + +func (t *TreeNode) Dump() { + dump(t, 0) +} + +func dump(n *TreeNode, level int) { + if n == nil { + log.Debug().Msgf("NO DATA!!") + return + } + log.Debug().Msgf("%s%s::%s\n", strings.Repeat(" ", level), n.GVR, n.ID) + for _, c := range n.Children { + dump(c, level+1) + } +} + +func (t *TreeNode) DumpStdOut() { + dumpStdOut(t, 0) +} + +func dumpStdOut(n *TreeNode, level int) { + if n == nil { + fmt.Println("NO DATA!!") + return + } + fmt.Printf("%s%s::%s\n", strings.Repeat(" ", level), n.GVR, n.ID) + for _, c := range n.Children { + dumpStdOut(c, level+1) + } +} + +func (t *TreeNode) Add(c *TreeNode) { + c.Parent = t + t.Children = append(t.Children, c) +} + +// Helpers... + +func statusEmoji(s string) string { + switch s { + case "ok": + return "[green::b]โœ”๏ธŽ" + case "done": + return "[gray::b]๐Ÿ" + case "bad": + return "[red::b]๐„‚" + default: + return "" + } +} + +// ๐Ÿ˜ก๐Ÿ‘Ž๐Ÿ’ฅ๐Ÿงจ๐Ÿ’ฃ๐ŸŽญ ๐ŸŸฅ๐ŸŸฉโœ…โœ”๏ธŽโ˜‘๏ธโœ”๏ธโœ“ +func toEmoji(gvr string) string { + switch gvr { + case "v1/pods": + return "๐Ÿš›" + case "apps/v1/deployments": + return "๐Ÿช‚" + case "apps/v1/statefulset": + return "๐ŸŽŽ" + case "apps/v1/daemonsets": + return "๐Ÿ˜ˆ" + case "containers": + return "๐Ÿณ" + case "v1/serviceaccounts": + return "๐Ÿ›Ž" + case "v1/persistentvolumes": + return "๐Ÿ“š" + case "v1/persistentvolumeclaims": + return "๐ŸŽŸ" + case "v1/secrets": + return "๐Ÿ”’" + case "v1/configmaps": + return "๐Ÿ—„" + default: + return "๐Ÿ“Ž" + } +} + +func (t TreeNode) colorize() string { + const colorFmt = "%s %s [%s::b]%s[::]" + + _, n := client.Namespaced(t.ID) + color, flag := "white", "[green::b]OK" + if v, ok := t.Extras[StatusKey]; ok { + switch v { + case ToastStatus: + color, flag = "orangered", "[red::b]TOAST" + case MissingRefStatus: + color, flag = "orange", "[orange::b]MISSING_REF" + } + } + + return fmt.Sprintf(colorFmt, toEmoji(t.GVR), flag, color, n) +} diff --git a/internal/xray/tree_node_test.go b/internal/xray/tree_node_test.go new file mode 100644 index 00000000..c81dab6e --- /dev/null +++ b/internal/xray/tree_node_test.go @@ -0,0 +1,427 @@ +package xray_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/derailed/k9s/internal/xray" + "github.com/stretchr/testify/assert" +) + +func TestTreeNodeFilter(t *testing.T) { + uu := map[string]struct { + q string + root, e *xray.TreeNode + }{ + "filter_simple": { + root: root1(), + e: diff1(), + q: "c1", + }, + "filter_complex": { + root: root2(), + e: diff2(), + q: "c2", + }, + "filter_no_match": { + root: root2(), + e: nil, + q: "bozo", + }, + "filter_all_match": { + root: root2(), + e: root2(), + q: "", + }, + "filter_complex1": { + root: root3(), + e: diff3(), + q: "coredns", + }, + } + + rx := func(q, path string) bool { + rx := regexp.MustCompile(`(?i)` + q) + + tokens := strings.Split(path, "::") + for _, t := range tokens { + if rx.MatchString(t) { + return true + } + } + return false + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + filtered := u.root.Filter(u.q, rx) + assert.Equal(t, u.e, filtered) + }) + } +} + +func TestTreeNodeHydrate(t *testing.T) { + uu := map[string]struct { + spec []xray.NodeSpec + e *xray.TreeNode + }{ + "flat_simple": { + spec: []xray.NodeSpec{ + { + GVR: "containers::v1/pods", + Path: "c1::default/p1", + }, + { + GVR: "containers::v1/pods", + Path: "c2::default/p1", + }, + }, + e: root1(), + }, + "flat_complex": { + spec: []xray.NodeSpec{ + { + GVR: "v1/secrets::containers::v1/pods", + Path: "s1::c1::default/p1", + }, + { + GVR: "v1/secrets::containers::v1/pods", + Path: "s2::c2::default/p1", + }, + }, + e: root2(), + }, + "complex1": { + spec: []xray.NodeSpec{ + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "default/default-token-rr22g::default/nginx-6b866d578b-c6tcn::default/nginx::-/default::deployments", + }, + { + GVR: "v1/configmaps::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/coredns::kube-system/coredns-6955765f44-89q2p::kube-system/coredns::-/kube-system::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/coredns-token-5cq9j::kube-system/coredns-6955765f44-89q2p::kube-system/coredns::-/kube-system::deployments", + }, + { + GVR: "v1/configmaps::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/coredns::kube-system/coredns-6955765f44-r9j9t::kube-system/coredns::-/kube-system::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/coredns-token-5cq9j::kube-system/coredns-6955765f44-r9j9t::kube-system/coredns::-/kube-system::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/default-token-thzt8::kube-system/metrics-server-6754dbc9df-88bk4::kube-system/metrics-server::-/kube-system::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kube-system/nginx-ingress-token-kff5q::kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55::kube-system/nginx-ingress-controller::-/kube-system::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4::kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56::kubernetes-dashboard/dashboard-metrics-scraper::-/kubernetes-dashboard::deployments", + }, + { + GVR: "v1/secrets::v1/pods::apps/v1/deployments::v1/namespaces::apps/v1/deployments", + Path: "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4::kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d::kubernetes-dashboard/kubernetes-dashboard::-/kubernetes-dashboard::deployments", + }, + }, + e: root3(), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + root := xray.Hydrate(u.spec) + assert.Equal(t, u.e.Flatten(), root.Flatten()) + }) + } +} + +func TestTreeNodeFlatten(t *testing.T) { + uu := map[string]struct { + root *xray.TreeNode + e []xray.NodeSpec + }{ + "flat_simple": { + root: root1(), + e: []xray.NodeSpec{ + { + GVR: "containers::v1/pods", + Path: "c1::default/p1", + }, + { + GVR: "containers::v1/pods", + Path: "c2::default/p1", + }, + }, + }, + "flat_complex": { + root: root2(), + e: []xray.NodeSpec{ + { + GVR: "v1/secrets::containers::v1/pods", + Path: "s1::c1::default/p1", + }, + { + GVR: "v1/secrets::containers::v1/pods", + Path: "s2::c2::default/p1", + }, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + flat := u.root.Flatten() + assert.Equal(t, u.e, flat) + }) + } +} + +func TestTreeNodeDiff(t *testing.T) { + uu := map[string]struct { + n1, n2 *xray.TreeNode + e bool + }{ + "blank": { + n1: &xray.TreeNode{}, + n2: &xray.TreeNode{}, + }, + "same": { + n1: xray.NewTreeNode("v1/pods", "default/p1"), + n2: xray.NewTreeNode("v1/pods", "default/p1"), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.n1.Diff(u.n2)) + }) + } +} + +func TestTreeNodeClone(t *testing.T) { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + n.Add(c1) + + c := n.ShallowClone() + assert.Equal(t, n.GVR, c.GVR) +} + +func TestTreeNodeRoot(t *testing.T) { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + c2 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + n.Add(c2) + + assert.Equal(t, 2, n.Size()) + assert.Equal(t, n, n.Root()) + assert.True(t, n.IsRoot()) + assert.False(t, n.IsLeaf()) + assert.Equal(t, n, c1.Root()) + assert.False(t, c1.IsRoot()) + assert.Equal(t, n, c2.Root()) + assert.True(t, c1.IsLeaf()) +} + +func TestTreeNodeLevel(t *testing.T) { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + c2 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + n.Add(c2) + + assert.Equal(t, 0, n.Level()) + assert.Equal(t, 1, c1.Level()) + assert.Equal(t, 1, c2.Level()) +} + +func TestTreeNodeMaxDepth(t *testing.T) { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + c2 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + n.Add(c2) + + assert.Equal(t, 1, n.MaxDepth(0)) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func root1() *xray.TreeNode { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + c2 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + n.Add(c2) + + return n +} + +func diff1() *xray.TreeNode { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + n.Add(c1) + + return n +} + +func root2() *xray.TreeNode { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c1") + c2 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + n.Add(c2) + + s1 := xray.NewTreeNode("v1/secrets", "s1") + c1.Add(s1) + + s2 := xray.NewTreeNode("v1/secrets", "s2") + c2.Add(s2) + + return n +} + +func diff2() *xray.TreeNode { + n := xray.NewTreeNode("v1/pods", "default/p1") + c1 := xray.NewTreeNode("containers", "c2") + n.Add(c1) + + s1 := xray.NewTreeNode("v1/secrets", "s2") + c1.Add(s1) + + return n +} + +func root3() *xray.TreeNode { + n := xray.NewTreeNode("apps/v1/deployments", "deployments") + + ns1 := xray.NewTreeNode("v1/namespaces", "-/default") + n.Add(ns1) + { + d1 := xray.NewTreeNode("apps/v1/deployments", "default/nginx") + ns1.Add(d1) + { + p1 := xray.NewTreeNode("v1/pods", "default/nginx-6b866d578b-c6tcn") + d1.Add(p1) + { + s1 := xray.NewTreeNode("v1/secrets", "default/default-token-rr22g") + p1.Add(s1) + } + } + } + + ns2 := xray.NewTreeNode("v1/namespaces", "-/kube-system") + n.Add(ns2) + { + d2 := xray.NewTreeNode("apps/v1/deployments", "kube-system/coredns") + ns2.Add(d2) + { + p2 := xray.NewTreeNode("v1/pods", "kube-system/coredns-6955765f44-89q2p") + d2.Add(p2) + { + c1 := xray.NewTreeNode("v1/configmaps", "kube-system/coredns") + p2.Add(c1) + s2 := xray.NewTreeNode("v1/secrets", "kube-system/coredns-token-5cq9j") + p2.Add(s2) + } + p3 := xray.NewTreeNode("v1/pods", "kube-system/coredns-6955765f44-r9j9t") + d2.Add(p3) + { + c2 := xray.NewTreeNode("v1/configmaps", "kube-system/coredns") + p3.Add(c2) + s3 := xray.NewTreeNode("v1/secrets", "kube-system/coredns-token-5cq9j") + p3.Add(s3) + } + } + d3 := xray.NewTreeNode("apps/v1/deployments", "kube-system/metrics-server") + ns2.Add(d3) + { + p3 := xray.NewTreeNode("v1/pods", "kube-system/metrics-server-6754dbc9df-88bk4") + d3.Add(p3) + { + s4 := xray.NewTreeNode("v1/secrets", "kube-system/default-token-thzt8") + p3.Add(s4) + } + } + d4 := xray.NewTreeNode("apps/v1/deployments", "kube-system/nginx-ingress-controller") + ns2.Add(d4) + { + p4 := xray.NewTreeNode("v1/pods", "kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55") + d4.Add(p4) + { + s5 := xray.NewTreeNode("v1/secrets", "kube-system/nginx-ingress-token-kff5q") + p4.Add(s5) + } + } + } + + ns3 := xray.NewTreeNode("v1/namespaces", "-/kubernetes-dashboard") + n.Add(ns3) + { + d5 := xray.NewTreeNode("apps/v1/deployments", "kubernetes-dashboard/dashboard-metrics-scraper") + ns3.Add(d5) + { + p5 := xray.NewTreeNode("v1/pods", "kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56") + d5.Add(p5) + { + s6 := xray.NewTreeNode("v1/secrets", "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4") + p5.Add(s6) + } + } + d6 := xray.NewTreeNode("apps/v1/deployments", "kubernetes-dashboard/kubernetes-dashboard") + ns3.Add(d6) + { + p6 := xray.NewTreeNode("v1/pods", "kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d") + d6.Add(p6) + { + s6 := xray.NewTreeNode("v1/secrets", "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4") + p6.Add(s6) + } + } + } + + return n +} + +func diff3() *xray.TreeNode { + n := xray.NewTreeNode("apps/v1/deployments", "deployments") + ns2 := xray.NewTreeNode("v1/namespaces", "-/kube-system") + n.Add(ns2) + { + d2 := xray.NewTreeNode("apps/v1/deployments", "kube-system/coredns") + ns2.Add(d2) + { + p2 := xray.NewTreeNode("v1/pods", "kube-system/coredns-6955765f44-89q2p") + d2.Add(p2) + { + c1 := xray.NewTreeNode("v1/configmaps", "kube-system/coredns") + p2.Add(c1) + s2 := xray.NewTreeNode("v1/secrets", "kube-system/coredns-token-5cq9j") + p2.Add(s2) + } + p3 := xray.NewTreeNode("v1/pods", "kube-system/coredns-6955765f44-r9j9t") + d2.Add(p3) + { + c2 := xray.NewTreeNode("v1/configmaps", "kube-system/coredns") + p3.Add(c2) + s3 := xray.NewTreeNode("v1/secrets", "kube-system/coredns-token-5cq9j") + p3.Add(s3) + } + } + } + return n +} diff --git a/skins/dracula.yml b/skins/dracula.yml new file mode 100644 index 00000000..efd13fd6 --- /dev/null +++ b/skins/dracula.yml @@ -0,0 +1,73 @@ +foreground: &foreground "#f8f8f2" +background: &background "#282a36" +current_line: ¤t_line "#44475a" +selection: &selection "#44475a" +comment: &comment "#6272a4" +cyan: &cyan "#8be9fd" +green: &green "#50fa7b" +orange: &orange "#ffb86c" +pink: &pink "#ff79c6" +purple: &purple "#bd93f9" +red: &red "#ff5555" +yellow: &yellow "#f1fa8c" +k9s: + # General K9s styles + body: + fgColor: *foreground + bgColor: *background + logoColor: *purple + # ClusterInfoView styles. + info: + fgColor: *pink + sectionColor: *foreground + frame: + # Borders styles. + border: + fgColor: *selection + focusColor: *current_line + menu: + fgColor: *foreground + keyColor: *pink + # Used for favorite namespaces + numKeyColor: *pink + # CrumbView attributes for history navigation. + crumbs: + fgColor: *foreground + bgColor: *current_line + activeColor: *current_line + # Resource status and update styles + status: + newColor: *cyan + modifyColor: *purple + addColor: *green + errorColor: *red + highlightcolor: *orange + killColor: *comment + completedColor: *comment + # Border title styles. + title: + fgColor: *foreground + bgColor: *current_line + highlightColor: *orange + counterColor: *purple + filterColor: *pink + # TableView attributes. + table: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + # Header row styles. + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + views: + # YAML info styles. + yaml: + keyColor: *pink + colonColor: *purple + valueColor: *foreground + # Logs styles. + logs: + fgColor: *foreground + bgColor: *background