diff --git a/.goreleaser.yml b/.goreleaser.yml index 8f394d90..7db847b6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,10 +3,8 @@ before: hooks: - go mod download - go generate ./... - release: prerelease: true - builds: - env: - CGO_ENABLED=0 @@ -24,14 +22,14 @@ builds: - 7 ldflags: - -s -w -X github.com/derailed/k9s/cmd.version={{.Version}} -X github.com/derailed/k9s/cmd.commit={{.Commit}} -X github.com/derailed/k9s/cmd.date={{.Date}} - archive: replacements: darwin: Darwin linux: Linux windows: Windows - arm: arm32 - arm64: arm64 + bit: Arm + bitv6: Arm6 + bitv7: Arm7 386: i386 amd64: x86_64 checksum: @@ -63,6 +61,11 @@ brew: # Snapcraft snapcraft: name: k9s + summary: K9s is a CLI to view and manage your Kubernetes clusters. + description: | + K9s is a CLI to view and manage your Kubernetes clusters. + By leveraging a terminal UI, you can easily traverse Kubernetes resources + and view the state of you clusters in a single powerful session. name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" publish: true replacements: @@ -70,11 +73,9 @@ snapcraft: 386: 32-bit darwin: macOS linux: Tux - summary: K9s is a CLI to view and manage your Kubernetes clusters. - description: | - K9s is a CLI to view and manage your Kubernetes clusters. - By leveraging a terminal UI, you can easily traverse Kubernetes resources - and view the state of you clusters in a single powerful session. + bit: Arm + bitv6: Arm6 + bitv7: Arm7 grade: stable confinement: strict apps: diff --git a/README.md b/README.md index 7dde2c6e..665a8c6a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ for changes and offers subsequent commands to interact with observed resources. [![Go Report Card](https://goreportcard.com/badge/github.com/derailed/k9s?)](https://goreportcard.com/report/github.com/derailed/k9s) [![Build Status](https://travis-ci.com/derailed/k9s.svg?branch=master)](https://travis-ci.com/derailed/k9s) [![release](https://img.shields.io/github/release-pre/derailed/k9s.svg)](https://github.com/derailed/k9s/releases) +[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/k9s) --- diff --git a/change_logs/release_0.4.0.md b/change_logs/release_0.4.0.md new file mode 100644 index 00000000..0421f120 --- /dev/null +++ b/change_logs/release_0.4.0.md @@ -0,0 +1,45 @@ +# Release v0.4.0 + +## Notes + +Thank you to all that contributed with flushing out issues with K9s! I'll try +to mark some of these issues as fixed. But if you don't mind grab the latest +rev and see if we're happier with some of the fixes! + +If you've filed an issue please help me verify and close. + +Thank you so much for your support and awesome suggestions to make K9s better!! + +--- + +## Change Logs + +> NOTE! Lots of changes here, please report any disturbances in the force. Thank you! +> NOTE!: This feature is very much an alpha feature right now. +> I find it really powerful and useful, hopefully I am not the only to be hunanimous on that?? + +1. [Feature #82](https://github.com/derailed/k9s/issues/82) + 1. Added ability to view RBAC policies while in clusterrole or role view. + 2. The RBAC view will auto-refresh just like any K9s views hence showing live RBAC updates + 3. RBAC view supports standard K8s verbs ie get,list,deletecollection,watch,create,patch,update,delete. + 4. Any verbs not in this standard K8s verb list, will end up in the EXTRAS column. + 5. For non resource URLS, we map standard REST verbs to K8s verbs ie post=create patch=update, etc.. + 6. Added initial sorts by name and group while in RBAC view. + 7. Usage: To activate, enter command mode via `:cr` or `:ro` for clusterole(cr)/role(ro), select a row and press `` + 8. To bail out of the view and return to previous use `p` or `` +2. One feature that was mentioned in the comments for the RBAC feature above Tx [faheem-cliqz](https://github.com/faheem-cliqz)! was the ability to check RBAC rules for a given user. Namely reverse RBAC lookup + 1. Added a new view, code name *Fu* view to show all the clusterroles/roles associated with a given user. + 2. The view also supports for checking RBAC Fu for a user, a group or an app via a serviceaccount. + 3. To activate: Enter command mode via `:fu` followed by u|g|s:subject + ``. + For example: To view user *fred* Fu enter `:fu u:fred` + `` will show all clusterroles/roles and verbs associated + with the user *fred* + 4. For group Fu lookup, use the same command as above and substitute `u:fred` with `g:fred` + 5. For ServiceAccount *fred* Fu check: use `s:fred` +3. Eliminated jitter while scrolling tables + + +--- + +## Resolved Bugs + ++ None diff --git a/internal/resource/cr.go b/internal/resource/cr.go index e42ff812..397e589d 100644 --- a/internal/resource/cr.go +++ b/internal/resource/cr.go @@ -72,7 +72,7 @@ func (r *ClusterRole) Fields(ns string) Row { i := r.instance return append(ff, - Pad(i.Name, 70), + i.Name, toAge(i.ObjectMeta.CreationTimestamp), ) } diff --git a/internal/resource/cr_binding.go b/internal/resource/cr_binding.go index f6ddb96c..236127af 100644 --- a/internal/resource/cr_binding.go +++ b/internal/resource/cr_binding.go @@ -1,6 +1,8 @@ package resource import ( + "strings" + "github.com/derailed/k9s/internal/k8s" "github.com/rs/zerolog/log" v1 "k8s.io/api/rbac/v1" @@ -63,15 +65,54 @@ func (r *ClusterRoleBinding) Marshal(path string) (string, error) { // Header return resource header. func (*ClusterRoleBinding) Header(_ string) Row { - return append(Row{}, "NAME", "AGE") + return append(Row{}, "NAME", "ROLE", "KIND", "SUBJECTS", "AGE") } // Fields retrieves displayable fields. func (r *ClusterRoleBinding) Fields(ns string) Row { ff := make(Row, 0, len(r.Header(ns))) + i := r.instance + kind, ss := renderSubjects(i.Subjects) + return append(ff, - r.instance.Name, - toAge(r.instance.ObjectMeta.CreationTimestamp), + i.Name, + i.RoleRef.Name, + kind, + ss, + toAge(i.ObjectMeta.CreationTimestamp), ) } + +// ---------------------------------------------------------------------------- +// Helpers... + +func renderSubjects(ss []v1.Subject) (kind string, subjects string) { + if len(ss) == 0 { + return NAValue, "" + } + + var tt []string + for _, s := range ss { + kind = toSubjectAlias(s.Kind) + tt = append(tt, s.Name) + } + return kind, strings.Join(tt, ",") +} + +func toSubjectAlias(s string) string { + if len(s) == 0 { + return s + } + + switch s { + case v1.UserKind: + return "USR" + case v1.GroupKind: + return "GRP" + case v1.ServiceAccountKind: + return "SA" + default: + return strings.ToUpper(s) + } +} diff --git a/internal/resource/cr_binding_test.go b/internal/resource/cr_binding_test.go index 4325a6ea..75eb1408 100644 --- a/internal/resource/cr_binding_test.go +++ b/internal/resource/cr_binding_test.go @@ -59,7 +59,7 @@ func TestCRBListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, resource.NotNamespaced, l.GetNamespace()) row := td.Rows["fred"] - assert.Equal(t, 2, len(row.Deltas)) + assert.Equal(t, 5, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/resource/cr_test.go b/internal/resource/cr_test.go index 8fc7bc94..c13f274f 100644 --- a/internal/resource/cr_test.go +++ b/internal/resource/cr_test.go @@ -2,7 +2,6 @@ package resource_test import ( "fmt" - "strings" "testing" "time" @@ -42,12 +41,12 @@ func TestCRListAccess(t *testing.T) { func TestCRFields(t *testing.T) { r := newClusterRole().Fields("blee") - assert.Equal(t, "fred"+strings.Repeat(" ", 66), r[0]) + assert.Equal(t, "fred", r[0]) } func TestCRFieldsAllNS(t *testing.T) { r := newClusterRole().Fields(resource.AllNamespaces) - assert.Equal(t, "fred"+strings.Repeat(" ", 66), r[0]) + assert.Equal(t, "fred", r[0]) } func TestCRMarshal(t *testing.T) { @@ -85,7 +84,7 @@ func TestCRListData(t *testing.T) { for _, d := range row.Deltas { assert.Equal(t, "", d) } - assert.Equal(t, resource.Row{"fred" + strings.Repeat(" ", 66)}, row.Fields[:1]) + assert.Equal(t, resource.Row{"fred"}, row.Fields[:1]) } // Helpers... diff --git a/internal/resource/helpers.go b/internal/resource/helpers.go index a8857499..d11cf7f3 100644 --- a/internal/resource/helpers.go +++ b/internal/resource/helpers.go @@ -90,13 +90,6 @@ func toAge(timestamp metav1.Time) string { return duration.HumanDuration(time.Since(timestamp.Time)) } -// Pad a string up to the given length. -func Pad(s string, l int) string { - fmat := "%-" + strconv.Itoa(l) + "s" - - return fmt.Sprintf(fmat, s) -} - // Truncate a string to the given l and suffix ellipsis if needed. func Truncate(str string, width int) string { return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis)) diff --git a/internal/resource/helpers_test.go b/internal/resource/helpers_test.go index aa8a0cda..f9f6a17d 100644 --- a/internal/resource/helpers_test.go +++ b/internal/resource/helpers_test.go @@ -77,22 +77,6 @@ func TestNa(t *testing.T) { } } -func TestPad(t *testing.T) { - uu := []struct { - s string - l int - e string - }{ - {"fred", 10, "fred "}, - {"fred", 6, "fred "}, - {"fred", 4, "fred"}, - } - - for _, u := range uu { - assert.Equal(t, u.e, Pad(u.s, u.l)) - } -} - func TestTruncate(t *testing.T) { uu := []struct { s string diff --git a/internal/resource/list.go b/internal/resource/list.go index 837173d8..c3de5a51 100644 --- a/internal/resource/list.go +++ b/internal/resource/list.go @@ -145,6 +145,10 @@ func (l *list) GetNamespace() string { // SetNamespace updates the namespace on the list. Default ns is "" for all // namespaces. func (l *list) SetNamespace(n string) { + if l.namespace == NotNamespaced { + return + } + if n == AllNamespace { n = AllNamespaces } diff --git a/internal/resource/pod.go b/internal/resource/pod.go index b042e796..d898b126 100644 --- a/internal/resource/pod.go +++ b/internal/resource/pod.go @@ -14,7 +14,6 @@ import ( const ( defaultTimeout = 1 * time.Second - podNameSize = 42 ) type ( @@ -188,7 +187,7 @@ func (*Pod) Header(ns string) Row { "NAME", "READY", "STATUS", - "RESTARTS", + "RS", "CPU", "MEM", "IP", @@ -211,7 +210,7 @@ func (r *Pod) Fields(ns string) Row { cr, _, rc := r.statuses(ss) return append(ff, - Pad(i.ObjectMeta.Name, podNameSize), + i.ObjectMeta.Name, strconv.Itoa(cr)+"/"+strconv.Itoa(len(ss)), r.phase(i.Status), strconv.Itoa(rc), @@ -219,7 +218,7 @@ func (r *Pod) Fields(ns string) Row { ToMi(r.metrics.CurrentMEM), i.Status.PodIP, i.Spec.NodeName, - string(i.Status.QOSClass), + r.mapQOS(i.Status.QOSClass), toAge(i.ObjectMeta.CreationTimestamp), ) } @@ -227,6 +226,17 @@ func (r *Pod) Fields(ns string) Row { // ---------------------------------------------------------------------------- // Helpers... +func (*Pod) mapQOS(class v1.PodQOSClass) string { + switch class { + case v1.PodQOSGuaranteed: + return "GA" + case v1.PodQOSBurstable: + return "BU" + default: + return "BE" + } +} + func (r *Pod) statuses(ss []v1.ContainerStatus) (cr, ct, rc int) { for _, c := range ss { if c.State.Terminated != nil { diff --git a/internal/resource/pod_test.go b/internal/resource/pod_test.go index ccc0ddfb..21109463 100644 --- a/internal/resource/pod_test.go +++ b/internal/resource/pod_test.go @@ -41,7 +41,7 @@ func TestPodListAccess(t *testing.T) { func TestPodFields(t *testing.T) { r := newPod().Fields("blee") - assert.Equal(t, resource.Pad("fred", 42), r[0]) + assert.Equal(t, "fred", r[0]) } func TestPodMarshal(t *testing.T) { diff --git a/internal/resource/ro_binding.go b/internal/resource/ro_binding.go index 5788f7d6..5b800755 100644 --- a/internal/resource/ro_binding.go +++ b/internal/resource/ro_binding.go @@ -1,8 +1,6 @@ package resource import ( - "strings" - "github.com/derailed/k9s/internal/k8s" "github.com/rs/zerolog/log" v1 "k8s.io/api/rbac/v1" @@ -70,49 +68,25 @@ func (*RoleBinding) Header(ns string) Row { hh = append(hh, "NAMESPACE") } - return append(hh, "NAME", "ROLE", "SUBJECTS", "AGE") + return append(hh, "NAME", "ROLE", "KIND", "SUBJECTS", "AGE") } // Fields retrieves displayable fields. func (r *RoleBinding) Fields(ns string) Row { - ff := make(Row, 0, len(r.Header(ns))) i := r.instance + + ff := make(Row, 0, len(r.Header(ns))) if ns == AllNamespaces { ff = append(ff, i.Namespace) } + kind, ss := renderSubjects(i.Subjects) + return append(ff, i.Name, i.RoleRef.Name, - r.toSubjects(i.Subjects), + kind, + ss, toAge(i.ObjectMeta.CreationTimestamp), ) } - -// ---------------------------------------------------------------------------- -// Helpers... - -func (r *RoleBinding) toSubjects(ss []v1.Subject) string { - var acc string - for i, s := range ss { - acc += s.Name + "/" + r.toSubjectAlias(s.Kind) - if i < len(ss)-1 { - acc += "," - } - } - - return acc -} - -func (r *RoleBinding) toSubjectAlias(s string) string { - switch s { - case v1.UserKind: - return "USR" - case v1.GroupKind: - return "GRP" - case v1.ServiceAccountKind: - return "SA" - default: - return strings.ToUpper(s) - } -} diff --git a/internal/resource/ro_binding_int_test.go b/internal/resource/ro_binding_int_test.go index 537a946a..d3ef208d 100644 --- a/internal/resource/ro_binding_int_test.go +++ b/internal/resource/ro_binding_int_test.go @@ -8,8 +8,6 @@ import ( ) func TestToSubjectAlias(t *testing.T) { - r := RoleBinding{} - uu := []struct { i string e string @@ -20,38 +18,44 @@ func TestToSubjectAlias(t *testing.T) { {"fred", "FRED"}, } for _, u := range uu { - assert.Equal(t, u.e, r.toSubjectAlias(u.i)) + assert.Equal(t, u.e, toSubjectAlias(u.i)) } } -func TestToSubjects(t *testing.T) { - r := RoleBinding{} - +func TestRenderSubjects(t *testing.T) { uu := []struct { - i []rbacv1.Subject - e string + ss []rbacv1.Subject + ek string + e string }{ { []rbacv1.Subject{ {Name: "blee", Kind: rbacv1.UserKind}, }, - "blee/USR", + "USR", + "blee", + }, + { + []rbacv1.Subject{}, + "", + "", }, } for _, u := range uu { - assert.Equal(t, u.e, r.toSubjects(u.i)) + kind, ss := renderSubjects(u.ss) + assert.Equal(t, u.e, ss) + assert.Equal(t, u.ek, kind) } } func BenchmarkToSubjects(b *testing.B) { - var r RoleBinding ss := []rbacv1.Subject{ {Name: "blee", Kind: rbacv1.UserKind}, } + b.ResetTimer() b.ReportAllocs() - for i := 0; i < b.N; i++ { - r.toSubjects(ss) + renderSubjects(ss) } } diff --git a/internal/resource/ro_binding_test.go b/internal/resource/ro_binding_test.go index cba2306c..d3bb82a9 100644 --- a/internal/resource/ro_binding_test.go +++ b/internal/resource/ro_binding_test.go @@ -51,7 +51,7 @@ func TestRBListData(t *testing.T) { assert.Equal(t, 1, len(td.Rows)) assert.Equal(t, "blee", l.GetNamespace()) row := td.Rows["blee/fred"] - assert.Equal(t, 4, len(row.Deltas)) + assert.Equal(t, 5, len(row.Deltas)) for _, d := range row.Deltas { assert.Equal(t, "", d) } diff --git a/internal/resource/rs.go b/internal/resource/rs.go index 3852ece1..c096d1d3 100644 --- a/internal/resource/rs.go +++ b/internal/resource/rs.go @@ -75,13 +75,13 @@ func (*ReplicaSet) Header(ns string) Row { // Fields retrieves displayable fields. func (r *ReplicaSet) Fields(ns string) Row { + i := r.instance + ff := make(Row, 0, len(r.Header(ns))) if ns == AllNamespaces { - ff = append(ff, r.instance.Namespace) + ff = append(ff, i.Namespace) } - i := r.instance - return append(ff, i.Name, strconv.Itoa(int(*i.Spec.Replicas)), diff --git a/internal/views/alias.go b/internal/views/alias.go index 036b4d67..0a0288c5 100644 --- a/internal/views/alias.go +++ b/internal/views/alias.go @@ -4,11 +4,9 @@ import ( "context" "fmt" "strings" - "time" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" - "github.com/rs/zerolog/log" ) const ( @@ -30,28 +28,8 @@ func newAliasView(app *appView) *aliasView { v.colorerFn = aliasColorer v.current = app.content.GetPrimitive("main").(igniter) v.currentNS = "" + v.registerActions() } - v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, true) - v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) - v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) - v.actions[KeyShiftR] = newKeyAction("Sort Resources", v.sortResourceCmd, true) - v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortGroupCmd, true) - - ctx, cancel := context.WithCancel(context.TODO()) - v.cancel = cancel - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - log.Debug().Msg("Alias GR bailing out!") - return - case <-time.After(1 * time.Second): - v.update(v.hydrate()) - v.app.Draw() - } - } - }(ctx) - return &v } @@ -62,6 +40,14 @@ func (v *aliasView) init(context.Context, string) { v.resetTitle() } +func (v *aliasView) registerActions() { + v.actions[tcell.KeyEnter] = newKeyAction("Goto", v.gotoCmd, true) + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyShiftR] = newKeyAction("Sort Resources", v.sortResourceCmd, true) + v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortGroupCmd, true) +} + func (v *aliasView) getTitle() string { return aliasTitle } @@ -138,9 +124,9 @@ func (v *aliasView) hydrate() resource.TableData { for k := range cmds { fields := resource.Row{ - resource.Pad(k, 30), - resource.Pad(cmds[k].title, 30), - resource.Pad(cmds[k].api, 30), + pad(k, 30), + pad(cmds[k].title, 30), + pad(cmds[k].api, 30), } data.Rows[k] = &resource.RowEvent{ Action: resource.New, diff --git a/internal/views/app.go b/internal/views/app.go index bf05a982..684b8dde 100644 --- a/internal/views/app.go +++ b/internal/views/app.go @@ -20,6 +20,7 @@ type ( igniter interface { tview.Primitive + getTitle() string init(ctx context.Context, ns string) } @@ -30,6 +31,10 @@ type ( resourceViewer interface { igniter + + setEnterFn(enterFn) + setColorerFn(colorerFn) + setDecorateFn(decorateFn) } appView struct { @@ -90,6 +95,8 @@ func NewApp(cfg *config.Config) *appView { v.actions[tcell.KeyDelete] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyTab] = newKeyAction("Focus", v.focusCmd, false) + // v.actions[KeyO] = newKeyAction("RBAC", v.rbacCmd, false) + return &v } @@ -156,6 +163,11 @@ func (a *appView) keyboard(evt *tcell.EventKey) *tcell.EventKey { return evt } +func (a *appView) rbacCmd(evt *tcell.EventKey) *tcell.EventKey { + a.inject(newRBACView(a, "", "aa_k9s", clusterRole)) + return evt +} + func (a *appView) redrawCmd(evt *tcell.EventKey) *tcell.EventKey { a.Draw() return evt @@ -263,7 +275,7 @@ func (a *appView) inject(p igniter) { var ctx context.Context { - ctx, a.cancel = context.WithCancel(context.TODO()) + ctx, a.cancel = context.WithCancel(context.Background()) p.init(ctx, a.config.ActiveNamespace()) } a.content.AddPage("main", p, true, true) diff --git a/internal/views/colorer.go b/internal/views/colorer.go index 2e14faa4..274ef69d 100644 --- a/internal/views/colorer.go +++ b/internal/views/colorer.go @@ -33,6 +33,13 @@ func aliasColorer(string, *resource.RowEvent) tcell.Color { return tcell.ColorFuchsia } +func rbacColorer(ns string, r *resource.RowEvent) tcell.Color { + c := defaultColorer(ns, r) + + // return tcell.ColorDarkOliveGreen + return c +} + func podColorer(ns string, r *resource.RowEvent) tcell.Color { c := defaultColorer(ns, r) @@ -62,6 +69,7 @@ func podColorer(ns string, r *resource.RowEvent) tcell.Color { c = errColor } } + return c } @@ -74,6 +82,7 @@ func ctxColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.Contains(strings.TrimSpace(r.Fields[0]), "*") { c = highlightColor } + return c } @@ -86,6 +95,7 @@ func pvColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[4]) != "Bound" { return errColor } + return stdColor } @@ -103,6 +113,7 @@ func pvcColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != "Bound" { c = errColor } + return c } @@ -119,6 +130,7 @@ func pdbColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -135,6 +147,7 @@ func dpColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -151,6 +164,7 @@ func stsColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -167,6 +181,7 @@ func rsColorer(ns string, r *resource.RowEvent) tcell.Color { if strings.TrimSpace(r.Fields[markCol]) != strings.TrimSpace(r.Fields[markCol+1]) { return errColor } + return stdColor } @@ -184,6 +199,7 @@ func evColorer(ns string, r *resource.RowEvent) tcell.Color { case "Killing": c = killColor } + return c } diff --git a/internal/views/command.go b/internal/views/command.go index cf5ed75e..167b99ec 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -2,6 +2,7 @@ package views import ( "fmt" + "regexp" "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" @@ -36,32 +37,50 @@ func (c *command) defaultCmd() { // Helpers... +var fuMatcher = regexp.MustCompile(`\Afu\s([u|g|s]):([\w-:]+)\b`) + // Exec the command by showing associated display. func (c *command) run(cmd string) bool { - defer func() { - if err := recover(); err != nil { - log.Debug().Msgf("Command failed %v", err) - } - }() - - var v igniter - switch cmd { - case "q", "quit": + var v resourceViewer + switch { + case cmd == "q", cmd == "quit": c.app.Stop() return true - case "?", "help", "alias": + case cmd == "?", cmd == "help": + c.app.inject(newHelpView(c.app)) + return true + case cmd == "alias": c.app.inject(newAliasView(c.app)) return true + case fuMatcher.MatchString(cmd): + tokens := fuMatcher.FindAllStringSubmatch(cmd, -1) + if len(tokens) == 1 && len(tokens[0]) == 3 { + c.app.inject(newFuView(c.app, tokens[0][1], tokens[0][2])) + return true + } default: if res, ok := resourceViews()[cmd]; ok { var r resource.List if res.listMxFn != nil { - r = res.listMxFn(c.app.conn(), k8s.NewMetricsServer(c.app.conn()), resource.DefaultNamespace) + r = res.listMxFn(c.app.conn(), + k8s.NewMetricsServer(c.app.conn()), + resource.DefaultNamespace, + ) } else { r = res.listFn(c.app.conn(), resource.DefaultNamespace) } - v = res.viewFn(res.title, c.app, r, res.colorerFn) - c.app.flash(flashInfo, fmt.Sprintf("Viewing %s in namespace %s...", res.title, c.app.config.ActiveNamespace())) + v = res.viewFn(res.title, c.app, r) + if res.colorerFn != nil { + v.setColorerFn(res.colorerFn) + } + if res.enterFn != nil { + v.setEnterFn(res.enterFn) + } + if res.decorateFn != nil { + v.setDecorateFn(res.decorateFn) + } + const fmat = "Viewing %s in namespace %s..." + c.app.flash(flashInfo, fmt.Sprintf(fmat, res.title, c.app.config.ActiveNamespace())) log.Debug().Msgf("Running command %s", cmd) c.exec(cmd, v) return true @@ -74,16 +93,16 @@ func (c *command) run(cmd string) bool { return false } - n := res.Plural - if len(n) == 0 { - n = res.Singular + name := res.Plural + if len(name) == 0 { + name = res.Singular } v = newResourceView( res.Kind, c.app, - resource.NewCustomList(c.app.conn(), "", res.Group, res.Version, n), - defaultColorer, + resource.NewCustomList(c.app.conn(), "", res.Group, res.Version, name), ) + v.setColorerFn(defaultColorer) c.exec(cmd, v) return true } diff --git a/internal/views/context.go b/internal/views/context.go index 6183a110..a47a21b2 100644 --- a/internal/views/context.go +++ b/internal/views/context.go @@ -11,8 +11,8 @@ type contextView struct { *resourceView } -func newContextView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := contextView{newResourceView(t, app, list, c).(*resourceView)} +func newContextView(t string, app *appView, list resource.List) resourceViewer { + v := contextView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions v.getTV().cleanseFn = v.cleanser diff --git a/internal/views/cronjob.go b/internal/views/cronjob.go index 1f464cc2..e96ffafe 100644 --- a/internal/views/cronjob.go +++ b/internal/views/cronjob.go @@ -11,12 +11,13 @@ type cronJobView struct { *resourceView } -func newCronJobView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { +func newCronJobView(t string, app *appView, list resource.List) resourceViewer { v := cronJobView{ - resourceView: newResourceView(t, app, list, c).(*resourceView), + resourceView: newResourceView(t, app, list).(*resourceView), } v.extraActionsFn = v.extraActions v.switchPage("cronjob") + return &v } @@ -30,6 +31,7 @@ func (v *cronJobView) trigger(evt *tcell.EventKey) *tcell.EventKey { v.app.flash(flashErr, "Boom!", err.Error()) return evt } + return nil } diff --git a/internal/views/fu.go b/internal/views/fu.go new file mode 100644 index 00000000..0371335a --- /dev/null +++ b/internal/views/fu.go @@ -0,0 +1,324 @@ +package views + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +var fuHeader = append(resource.Row{"NAMESPACE", "NAME", "GROUP", "BINDING"}, rbacHeaderVerbs...) + +type fuView struct { + *tableView + + current igniter + cancel context.CancelFunc + subjectKind string + subjectName string + cache resource.RowEvents +} + +func newFuView(app *appView, subject, name string) *fuView { + v := fuView{} + { + v.subjectKind, v.subjectName = v.mapSubject(subject), name + v.tableView = newTableView(app, v.getTitle()) + v.colorerFn = rbacColorer + v.current = app.content.GetPrimitive("main").(igniter) + v.bindKeys() + } + + return &v +} + +// Init the view. +func (v *fuView) init(_ context.Context, ns string) { + v.sortCol = sortColumn{1, len(rbacHeader), true} + + ctx, cancel := context.WithCancel(context.Background()) + v.cancel = cancel + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("FU Watch bailing out!") + return + case <-time.After(time.Duration(v.app.config.K9s.RefreshRate) * time.Second): + v.refresh() + v.app.Draw() + } + } + }(ctx) + + v.refresh() + v.app.SetFocus(v) +} + +func (v *fuView) bindKeys() { + delete(v.actions, KeyShiftA) + + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + + v.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortColCmd(0), true) + v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(1), true) + v.actions[KeyShiftO] = newKeyAction("Sort Group", v.sortColCmd(2), true) + v.actions[KeyShiftB] = newKeyAction("Sort Binding", v.sortColCmd(3), true) +} + +func (v *fuView) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, "Fu", v.subjectKind+":"+v.subjectName) +} + +func (v *fuView) refresh() { + data, err := v.reconcile() + if err != nil { + log.Error().Err(err).Msgf("Unable to reconcile for %s:%s", v.subjectKind, v.subjectName) + } + v.update(data) +} + +func (v *fuView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.empty() { + v.cmdBuff.reset() + return nil + } + + return v.backCmd(evt) +} + +func (v *fuView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + if v.cmdBuff.isActive() { + v.cmdBuff.reset() + } else { + v.app.prevCmd(evt) + } + + return nil +} + +func (v *fuView) hints() hints { + return v.actions.toHints() +} + +func (v *fuView) reconcile() (resource.TableData, error) { + evts, errs := v.clusterPolicies() + if len(errs) > 0 { + for _, err := range errs { + log.Debug().Err(err).Msg("Unable to find cluster policies") + } + return resource.TableData{}, errs[0] + } + + nevts, errs := v.namespacePolicies() + if len(errs) > 0 { + for _, err := range errs { + log.Debug().Err(err).Msg("Unable to find cluster policies") + } + return resource.TableData{}, errs[0] + } + + for k, v := range nevts { + evts[k] = v + } + + data := resource.TableData{ + Header: fuHeader, + Rows: make(resource.RowEvents, len(evts)), + Namespace: "*", + } + + noDeltas := make(resource.Row, len(fuHeader)) + if len(v.cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + data.Rows[k] = ev + } + v.cache = evts + + return data, nil + } + + for k, ev := range evts { + data.Rows[k] = ev + + newr := ev.Fields + if _, ok := v.cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := v.cache[k].Fields + deltas := make(resource.Row, len(newr)) + if !reflect.DeepEqual(oldr, newr) { + ev.Action = watch.Modified + for i, field := range oldr { + if field != newr[i] { + deltas[i] = field + } + } + ev.Deltas = deltas + } else { + ev.Action = resource.Unchanged + ev.Deltas = noDeltas + } + } + v.cache = evts + + for k := range v.cache { + if _, ok := data.Rows[k]; !ok { + delete(v.cache, k) + } + } + + return data, nil +} + +func (v *fuView) clusterPolicies() (resource.RowEvents, []error) { + var errs []error + evts := make(resource.RowEvents) + + crbs, err := v.app.conn().DialOrDie().Rbac().ClusterRoleBindings().List(metav1.ListOptions{}) + if err != nil { + return evts, errs + } + + var roles []string + for _, c := range crbs.Items { + for _, s := range c.Subjects { + if s.Kind == v.subjectKind && s.Name == v.subjectName { + roles = append(roles, c.RoleRef.Name) + } + } + } + + for _, r := range roles { + cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(r, metav1.GetOptions{}) + if err != nil { + errs = append(errs, err) + } + e := v.parseRules("*", r, cr.Rules) + for k, v := range e { + evts[k] = v + } + } + + return evts, errs +} + +func (v *fuView) namespacePolicies() (resource.RowEvents, []error) { + var errs []error + evts := make(resource.RowEvents) + + rbs, err := v.app.conn().DialOrDie().Rbac().RoleBindings("").List(metav1.ListOptions{}) + if err != nil { + return evts, errs + } + + type nsRole struct { + ns, role string + } + var roles []nsRole + for _, rb := range rbs.Items { + for _, s := range rb.Subjects { + if s.Kind == v.subjectKind && s.Name == v.subjectName { + roles = append(roles, nsRole{rb.Namespace, rb.RoleRef.Name}) + } + } + } + + for _, r := range roles { + cr, err := v.app.conn().DialOrDie().Rbac().Roles(r.ns).Get(r.role, metav1.GetOptions{}) + if err != nil { + errs = append(errs, err) + } + e := v.parseRules(r.ns, r.role, cr.Rules) + for k, v := range e { + evts[k] = v + } + } + + return evts, errs +} + +func (v *fuView) namespace(ns, n string) string { + return ns + "/" + n +} + +func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { + m := make(resource.RowEvents, len(rules)) + for _, r := range rules { + for _, grp := range r.APIGroups { + for _, res := range r.Resources { + k := res + if grp != "" { + k = res + "." + grp + } + for _, na := range r.ResourceNames { + n := k + "/" + na + m[v.namespace(ns, n)] = &resource.RowEvent{ + Fields: v.prepRow(ns, n, grp, binding, r.Verbs), + } + } + m[v.namespace(ns, k)] = &resource.RowEvent{ + Fields: v.prepRow(ns, k, grp, binding, r.Verbs), + } + } + } + for _, nres := range r.NonResourceURLs { + if nres[0] != '/' { + nres = "/" + nres + } + m[v.namespace(ns, nres)] = &resource.RowEvent{ + Fields: v.prepRow(ns, nres, resource.NAValue, binding, r.Verbs), + } + } + } + + return m +} + +func (v *fuView) prepRow(ns, res, grp, binding string, verbs []string) resource.Row { + const ( + nameLen = 60 + groupLen = 30 + nsLen = 30 + ) + + if grp != resource.NAValue { + grp = toGroup(grp) + } + + return v.makeRow(ns, res, grp, binding, asVerbs(verbs...)) +} + +func (*fuView) makeRow(ns, res, group, binding string, verbs []string) resource.Row { + r := make(resource.Row, 0, len(fuHeader)) + r = append(r, ns, res, group, binding) + + return append(r, verbs...) +} + +func (v *fuView) mapSubject(subject string) string { + switch subject { + case "g": + return "Group" + case "s": + return "ServiceAccount" + default: + return "User" + } +} diff --git a/internal/views/help.go b/internal/views/help.go index 3326c93c..c1b13b4f 100644 --- a/internal/views/help.go +++ b/internal/views/help.go @@ -75,7 +75,7 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "🏠 [aqua::b]%s\n", "General") for _, h := range general { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } navigation := []helpItem{ @@ -90,7 +90,7 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "\n🤖 [aqua::b]%s\n", "View Navigation") for _, h := range navigation { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } views := []helpItem{ @@ -99,12 +99,15 @@ func (v *helpView) init(_ context.Context, _ string) { } fmt.Fprintf(v, "️️\n😱 [aqua::b]%s\n", "Help") for _, h := range views { - fmt.Fprintf(v, "[pink::b]%9s [gray::]%s\n", h.key, h.description) + v.printHelp(h.key, h.description) } - v.app.setHints(v.hints()) } +func (v *helpView) printHelp(key, desc string) { + fmt.Fprintf(v, "[pink::b]%9s [white::]%s\n", key, desc) +} + func (v *helpView) hints() hints { return v.actions.toHints() } diff --git a/internal/views/helpers.go b/internal/views/helpers.go index a5b381ba..2421e97e 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -11,6 +11,10 @@ func toPerc(f float64) string { } func deltas(c, n string) string { + c, n = strings.TrimSpace(c), strings.TrimSpace(n) + + // log.Debug().Msgf("`%s` vs `%s`", c, n) + if c == "n/a" { return n } @@ -46,6 +50,7 @@ func deltas(c, n string) string { if len(c) == 0 { return n } + switch strings.Compare(c, n) { case 1, -1: return delta(n) @@ -76,6 +81,7 @@ func numerical(s string) (int, bool) { func delta(s string) string { return suffix(s, "𝜟") } + func plus(s string) string { return suffix(s, "+") } diff --git a/internal/views/job.go b/internal/views/job.go index be080239..bf148060 100644 --- a/internal/views/job.go +++ b/internal/views/job.go @@ -10,13 +10,14 @@ type jobView struct { *resourceView } -func newJobView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := jobView{newResourceView(t, app, list, c).(*resourceView)} +func newJobView(t string, app *appView, list resource.List) resourceViewer { + v := jobView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions + v.AddPage("logs", newLogsView(&v), true, false) + v.switchPage("job") } - v.AddPage("logs", newLogsView(&v), true, false) - v.switchPage("job") + return &v } @@ -60,6 +61,7 @@ func (v *jobView) logs(evt *tcell.EventKey) *tcell.EventKey { v.switchPage("logs") l.init() + return nil } diff --git a/internal/views/namespace.go b/internal/views/namespace.go index e4429319..51ff022e 100644 --- a/internal/views/namespace.go +++ b/internal/views/namespace.go @@ -23,13 +23,16 @@ type namespaceView struct { *resourceView } -func newNamespaceView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := namespaceView{newResourceView(t, app, list, c).(*resourceView)} - v.extraActionsFn = v.extraActions - v.selectedFn = v.getSelectedItem - v.decorateDataFn = v.decorate - v.getTV().cleanseFn = v.cleanser - v.switchPage("ns") +func newNamespaceView(t string, app *appView, list resource.List) resourceViewer { + v := namespaceView{newResourceView(t, app, list).(*resourceView)} + { + v.extraActionsFn = v.extraActions + v.selectedFn = v.getSelectedItem + v.decorateFn = v.decorate + v.getTV().cleanseFn = v.cleanser + v.switchPage("ns") + } + return &v } @@ -44,6 +47,7 @@ func (v *namespaceView) switchNsCmd(evt *tcell.EventKey) *tcell.EventKey { } v.useNamespace(v.getSelectedItem()) v.app.gotoResource("po", true) + return nil } @@ -52,6 +56,7 @@ func (v *namespaceView) useNsCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } v.useNamespace(v.getSelectedItem()) + return nil } @@ -92,5 +97,6 @@ func (v *namespaceView) decorate(data resource.TableData) resource.TableData { r.Action = resource.Unchanged } } + return data } diff --git a/internal/views/no.go b/internal/views/no.go index 0be37178..4650a731 100644 --- a/internal/views/no.go +++ b/internal/views/no.go @@ -9,8 +9,8 @@ type nodeView struct { *resourceView } -func newNodeView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := nodeView{newResourceView(t, app, list, c).(*resourceView)} +func newNodeView(t string, app *appView, list resource.List) resourceViewer { + v := nodeView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions v.switchPage("no") diff --git a/internal/views/padding.go b/internal/views/padding.go new file mode 100644 index 00000000..3deb740f --- /dev/null +++ b/internal/views/padding.go @@ -0,0 +1,51 @@ +package views + +import ( + "strings" + "unicode" + + "github.com/derailed/k9s/internal/resource" +) + +type maxyPad []int + +func computeMaxColumns(pads maxyPad, sortCol int, table resource.TableData) { + for index, h := range table.Header { + pads[index] = len(h) + if index == sortCol { + pads[index] = len(h) + 2 + } + } + + var row int + for _, rev := range table.Rows { + for index, field := range rev.Fields { + if len(field) > pads[index] && isASCII(field) { + pads[index] = len([]rune(field)) + } + } + row++ + } +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} + +// Pad a string up to the given length or truncates if greater than length. +func pad(s string, width int) string { + if len(s) == width { + return s + } + + if len(s) > width { + return resource.Truncate(s, width) + } + + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/internal/views/padding_test.go b/internal/views/padding_test.go new file mode 100644 index 00000000..76256418 --- /dev/null +++ b/internal/views/padding_test.go @@ -0,0 +1,107 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestMaxColumn(t *testing.T) { + uu := []struct { + t resource.TableData + s int + e maxyPad + }{ + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + }, + 0, + maxyPad{5, 5}, + }, + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + }, + 1, + maxyPad{5, 5}, + }, + { + resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"Hello World lord of ipsums 😅", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"o", "mama"}}, + }, + }, + 0, + maxyPad{3, 5}, + }, + } + + for _, u := range uu { + pads := make(maxyPad, len(u.t.Header)) + computeMaxColumns(pads, u.s, u.t) + assert.Equal(t, u.e, pads) + } +} + +func TestIsASCII(t *testing.T) { + uu := []struct { + s string + e bool + }{ + {"hello", true}, + {"Yo! 😄", false}, + {"😄", false}, + } + + for _, u := range uu { + assert.Equal(t, u.e, isASCII(u.s)) + } +} + +func TestPad(t *testing.T) { + uu := []struct { + s string + l int + e string + }{ + {"fred", 3, "fr…"}, + {"01234567890", 10, "012345678…"}, + {"fred", 10, "fred "}, + {"fred", 6, "fred "}, + {"fred", 4, "fred"}, + } + + for _, u := range uu { + assert.Equal(t, u.e, pad(u.s, u.l)) + } +} + +func BenchmarkMaxColumn(b *testing.B) { + table := resource.TableData{ + Header: resource.Row{"A", "B"}, + Rows: resource.RowEvents{ + "r1": &resource.RowEvent{Fields: resource.Row{"hello", "world"}}, + "r2": &resource.RowEvent{Fields: resource.Row{"yo", "mama"}}, + }, + } + + pads := make(maxyPad, len(table.Header)) + + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + computeMaxColumns(pads, 0, table) + } +} diff --git a/internal/views/pod.go b/internal/views/pod.go index b3b9aeb5..6a1cd2e1 100644 --- a/internal/views/pod.go +++ b/internal/views/pod.go @@ -20,8 +20,8 @@ type loggable interface { switchPage(n string) } -func newPodView(t string, app *appView, list resource.List, c colorerFn) resourceViewer { - v := podView{newResourceView(t, app, list, c).(*resourceView)} +func newPodView(t string, app *appView, list resource.List) resourceViewer { + v := podView{newResourceView(t, app, list).(*resourceView)} { v.extraActionsFn = v.extraActions } diff --git a/internal/views/rbac.go b/internal/views/rbac.go new file mode 100644 index 00000000..bc783714 --- /dev/null +++ b/internal/views/rbac.go @@ -0,0 +1,379 @@ +package views + +import ( + "context" + "fmt" + "reflect" + "strings" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +const ( + clusterRole roleKind = iota + role + + all = "*" + rbacTitle = "RBAC" + rbacTitleFmt = " [aqua::b]%s([fuchsia::b]%s[aqua::-])" +) + +type ( + roleKind = int8 + + rbacView struct { + *tableView + + current igniter + cancel context.CancelFunc + roleType roleKind + roleName string + cache resource.RowEvents + } +) + +var ( + rbacHeaderVerbs = resource.Row{ + "GET ", + "LIST ", + "DLIST ", + "WATCH ", + "CREATE", + "PATCH ", + "UPDATE", + "DELETE", + "EXTRAS", + } + rbacHeader = append(resource.Row{"NAME", "GROUP"}, rbacHeaderVerbs...) + + k8sVerbs = []string{ + "get", + "list", + "deletecollection", + "watch", + "create", + "patch", + "update", + "delete", + } + + httpVerbs = []string{ + "get", + "post", + "put", + "patch", + "delete", + "options", + } + + httpTok8sVerbs = map[string]string{ + "post": "create", + "put": "update", + } +) + +func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { + v := rbacView{} + { + v.roleName, v.roleType = name, kind + v.tableView = newTableView(app, v.getTitle()) + v.currentNS = ns + v.colorerFn = rbacColorer + v.current = app.content.GetPrimitive("main").(igniter) + v.bindKeys() + } + + return &v +} + +// Init the view. +func (v *rbacView) init(_ context.Context, ns string) { + v.sortCol = sortColumn{1, len(rbacHeader), true} + + ctx, cancel := context.WithCancel(context.Background()) + v.cancel = cancel + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msg("RBAC Watch bailing out!") + return + case <-time.After(time.Duration(v.app.config.K9s.RefreshRate) * time.Second): + v.refresh() + v.app.Draw() + } + } + }(ctx) + + v.refresh() + v.app.SetFocus(v) +} + +func (v *rbacView) bindKeys() { + delete(v.actions, KeyShiftA) + + v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) + v.actions[KeySlash] = newKeyAction("Filter", v.activateCmd, false) + v.actions[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) + + v.actions[KeyShiftO] = newKeyAction("Sort Groups", v.sortColCmd(1), true) +} + +func (v *rbacView) getTitle() string { + title := "ClusterRole" + if v.roleType == role { + title = "Role" + } + + return fmt.Sprintf(rbacTitleFmt, title, v.roleName) +} + +func (v *rbacView) hints() hints { + return v.actions.toHints() +} + +func (v *rbacView) refresh() { + data, err := v.reconcile(v.currentNS, v.roleName, v.roleType) + if err != nil { + log.Error().Err(err).Msgf("Unable to reconcile for %s:%d", v.roleName, v.roleType) + } + v.update(data) +} + +func (v *rbacView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.empty() { + v.cmdBuff.reset() + return nil + } + + return v.backCmd(evt) +} + +func (v *rbacView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + if v.cmdBuff.isActive() { + v.cmdBuff.reset() + } else { + v.app.prevCmd(evt) + } + + return nil +} + +func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { + evts, err := v.rowEvents(ns, name, kind) + if err != nil { + return resource.TableData{}, err + } + + data := resource.TableData{ + Header: rbacHeader, + Rows: make(resource.RowEvents, len(evts)), + Namespace: resource.NotNamespaced, + } + + noDeltas := make(resource.Row, len(rbacHeader)) + if len(v.cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + data.Rows[k] = ev + } + v.cache = evts + + return data, nil + } + + for k, ev := range evts { + data.Rows[k] = ev + + newr := ev.Fields + if _, ok := v.cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := v.cache[k].Fields + deltas := make(resource.Row, len(newr)) + if !reflect.DeepEqual(oldr, newr) { + ev.Action = watch.Modified + for i, field := range oldr { + if field != newr[i] { + deltas[i] = field + } + } + ev.Deltas = deltas + } else { + ev.Action = resource.Unchanged + ev.Deltas = noDeltas + } + } + v.cache = evts + + for k := range v.cache { + if _, ok := data.Rows[k]; !ok { + delete(v.cache, k) + } + } + + return data, nil +} + +func (v *rbacView) rowEvents(ns, name string, kind roleKind) (resource.RowEvents, error) { + var ( + evts resource.RowEvents + err error + ) + + switch kind { + case clusterRole: + evts, err = v.clusterPolicies(name) + case role: + evts, err = v.namespacedPolicies(name) + default: + return evts, fmt.Errorf("Expecting clusterrole/role but found %d", kind) + } + if err != nil { + log.Error().Err(err).Msg("Unable to load CR") + return evts, err + } + + return evts, nil +} + +func (v *rbacView) clusterPolicies(name string) (resource.RowEvents, error) { + cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return v.parseRules(cr.Rules), nil +} + +func (v *rbacView) namespacedPolicies(path string) (resource.RowEvents, error) { + ns, na := namespaced(path) + cr, err := v.app.conn().DialOrDie().Rbac().Roles(ns).Get(na, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return v.parseRules(cr.Rules), nil +} + +func (v *rbacView) parseRules(rules []rbacv1.PolicyRule) resource.RowEvents { + m := make(resource.RowEvents, len(rules)) + for _, r := range rules { + for _, grp := range r.APIGroups { + for _, res := range r.Resources { + k := res + if grp != "" { + k = res + "." + grp + } + for _, na := range r.ResourceNames { + n := k + "/" + na + m[n] = &resource.RowEvent{ + Fields: prepRow(n, grp, r.Verbs), + } + } + m[k] = &resource.RowEvent{ + Fields: prepRow(k, grp, r.Verbs), + } + } + } + for _, nres := range r.NonResourceURLs { + if nres[0] != '/' { + nres = "/" + nres + } + m[nres] = &resource.RowEvent{ + Fields: prepRow(nres, resource.NAValue, r.Verbs), + } + } + } + + return m +} + +func prepRow(res, grp string, verbs []string) resource.Row { + const ( + nameLen = 60 + groupLen = 30 + ) + + if grp != resource.NAValue { + grp = toGroup(grp) + } + + return makeRow(res, grp, asVerbs(verbs...)) +} + +func makeRow(res, group string, verbs []string) resource.Row { + r := make(resource.Row, 0, len(rbacHeader)) + r = append(r, res, group) + + return append(r, verbs...) +} + +func asVerbs(verbs ...string) resource.Row { + const ( + verbLen = 4 + unknownLen = 30 + ) + + r := make(resource.Row, 0, len(k8sVerbs)+1) + for _, v := range k8sVerbs { + r = append(r, toVerbIcon(hasVerb(verbs, v))) + } + + var unknowns []string + for _, v := range verbs { + if hv, ok := httpTok8sVerbs[v]; ok { + v = hv + } + if !hasVerb(k8sVerbs, v) && v != all { + unknowns = append(unknowns, v) + } + } + + return append(r, resource.Truncate(strings.Join(unknowns, ","), unknownLen)) +} + +func toVerbIcon(ok bool) string { + if ok { + return "[green::b] ✓ [::]" + } + return "[orangered::b] 𐄂 [::]" +} + +func hasVerb(verbs []string, verb string) bool { + if len(verbs) == 1 && verbs[0] == all { + return true + } + + for _, v := range verbs { + if hv, ok := httpTok8sVerbs[v]; ok { + if hv == verb { + return true + } + } + if v == verb { + return true + } + } + + return false +} + +func toGroup(g string) string { + if g == "" { + return "v1" + } + return g +} diff --git a/internal/views/rbac_test.go b/internal/views/rbac_test.go new file mode 100644 index 00000000..fb25db88 --- /dev/null +++ b/internal/views/rbac_test.go @@ -0,0 +1,115 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestHasVerb(t *testing.T) { + uu := []struct { + vv []string + v string + e bool + }{ + {[]string{"*"}, "get", true}, + {[]string{"get", "list", "watch"}, "watch", true}, + {[]string{"get", "dope", "list"}, "watch", false}, + {[]string{"get"}, "get", true}, + {[]string{"post"}, "create", true}, + {[]string{"put"}, "update", true}, + {[]string{"list", "deletecollection"}, "deletecollection", true}, + } + + for _, u := range uu { + assert.Equal(t, u.e, hasVerb(u.vv, u.v)) + } +} + +func TestAsVerbs(t *testing.T) { + ok, nok := toVerbIcon(true), toVerbIcon(false) + + uu := []struct { + vv []string + e resource.Row + }{ + {[]string{"*"}, resource.Row{ok, ok, ok, ok, ok, ok, ok, ok, ""}}, + {[]string{"get", "list", "patch"}, resource.Row{ok, ok, nok, nok, nok, ok, nok, nok, ""}}, + {[]string{"get", "list", "deletecollection", "post"}, resource.Row{ok, ok, ok, nok, ok, nok, nok, nok, ""}}, + {[]string{"get", "list", "blee"}, resource.Row{ok, ok, nok, nok, nok, nok, nok, nok, "blee"}}, + } + + for _, u := range uu { + assert.Equal(t, u.e, asVerbs(u.vv...)) + } +} + +func TestParseRules(t *testing.T) { + ok, nok := toVerbIcon(true), toVerbIcon(false) + _ = nok + + uu := []struct { + pp []rbacv1.PolicyRule + e map[string]resource.Row + }{ + { + []rbacv1.PolicyRule{ + {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}, + }, + map[string]resource.Row{ + "*.*": resource.Row{"*.*", "*", ok, ok, ok, ok, ok, ok, ok, ok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"get"}}, + }, + map[string]resource.Row{ + "*.*": resource.Row{"*.*", "*", ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"*"}, Verbs: []string{"list"}}, + }, + map[string]resource.Row{ + "*": resource.Row{"*", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"list"}, ResourceNames: []string{"fred"}}, + }, + map[string]resource.Row{ + "pods": resource.Row{"pods", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, + "pods/fred": resource.Row{"pods/fred", "v1", nok, ok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"/fred"}}, + }, + map[string]resource.Row{ + "/fred": resource.Row{"/fred", "", ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + { + []rbacv1.PolicyRule{ + {APIGroups: []string{}, Resources: []string{}, Verbs: []string{"get"}, NonResourceURLs: []string{"fred"}}, + }, + map[string]resource.Row{ + "/fred": resource.Row{"/fred", "", ok, nok, nok, nok, nok, nok, nok, nok, ""}, + }, + }, + } + + var v rbacView + for _, u := range uu { + evts := v.parseRules(u.pp) + for k, v := range u.e { + assert.Equal(t, v, evts[k].Fields) + } + } +} diff --git a/internal/views/registrar.go b/internal/views/registrar.go index fa5eed7e..4b368e12 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -7,18 +7,22 @@ import ( ) type ( - viewFn func(ns string, app *appView, list resource.List, colorer colorerFn) resourceViewer - listFn func(c resource.Connection, ns string) resource.List - listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List - colorerFn func(ns string, evt *resource.RowEvent) tcell.Color + viewFn func(ns string, app *appView, list resource.List) resourceViewer + listFn func(c resource.Connection, ns string) resource.List + listMxFn func(c resource.Connection, mx resource.MetricsServer, ns string) resource.List + colorerFn func(ns string, evt *resource.RowEvent) tcell.Color + enterFn func(app *appView, ns, resource, selection string) + decorateFn func(resource.TableData) resource.TableData resCmd struct { - title string - api string - viewFn viewFn - listFn listFn - listMxFn listMxFn - colorerFn colorerFn + title string + api string + viewFn viewFn + listFn listFn + listMxFn listMxFn + enterFn enterFn + colorerFn colorerFn + decorateFn decorateFn } ) @@ -72,42 +76,48 @@ func allCRDs(c k8s.Connection) map[string]k8s.APIGroup { return m } +func showRBAC(app *appView, ns, resource, selection string) { + kind := clusterRole + if resource == "role" { + kind = role + } + app.command.pushCmd("policies") + app.inject(newRBACView(app, ns, selection, kind)) +} + func resourceViews() map[string]resCmd { return map[string]resCmd{ "cm": { - title: "ConfigMaps", - api: "", - viewFn: newResourceView, - listFn: resource.NewConfigMapList, - colorerFn: defaultColorer, + title: "ConfigMaps", + api: "", + viewFn: newResourceView, + listFn: resource.NewConfigMapList, }, "cr": { - title: "ClusterRoles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleList, - colorerFn: defaultColorer, + title: "ClusterRoles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleList, + enterFn: showRBAC, }, "crb": { - title: "ClusterRoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleBindingList, - colorerFn: defaultColorer, + title: "ClusterRoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleBindingList, + // decorateFn: crbDecorator, }, "crd": { - title: "CustomResourceDefinitions", - api: "apiextensions.k8s.io", - viewFn: newResourceView, - listFn: resource.NewCRDList, - colorerFn: defaultColorer, + title: "CustomResourceDefinitions", + api: "apiextensions.k8s.io", + viewFn: newResourceView, + listFn: resource.NewCRDList, }, "cj": { - title: "CronJobs", - api: "batch", - viewFn: newCronJobView, - listFn: resource.NewCronJobList, - colorerFn: defaultColorer, + title: "CronJobs", + api: "batch", + viewFn: newCronJobView, + listFn: resource.NewCronJobList, }, "ctx": { title: "Contexts", @@ -131,11 +141,10 @@ func resourceViews() map[string]resCmd { colorerFn: dpColorer, }, "ep": { - title: "EndPoints", - api: "", - viewFn: newResourceView, - listFn: resource.NewEndpointsList, - colorerFn: defaultColorer, + title: "EndPoints", + api: "", + viewFn: newResourceView, + listFn: resource.NewEndpointsList, }, "ev": { title: "Events", @@ -145,25 +154,22 @@ func resourceViews() map[string]resCmd { colorerFn: evColorer, }, "hpa": { - title: "HorizontalPodAutoscalers", - api: "autoscaling", - viewFn: newResourceView, - listFn: resource.NewHPAList, - colorerFn: defaultColorer, + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAList, }, "ing": { - title: "Ingress", - api: "extensions", - viewFn: newResourceView, - listFn: resource.NewIngressList, - colorerFn: defaultColorer, + title: "Ingress", + api: "extensions", + viewFn: newResourceView, + listFn: resource.NewIngressList, }, "jo": { - title: "Jobs", - api: "batch", - viewFn: newJobView, - listFn: resource.NewJobList, - colorerFn: defaultColorer, + title: "Jobs", + api: "batch", + viewFn: newJobView, + listFn: resource.NewJobList, }, "no": { title: "Nodes", @@ -208,11 +214,10 @@ func resourceViews() map[string]resCmd { colorerFn: pvcColorer, }, "rb": { - title: "RoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleBindingList, - colorerFn: defaultColorer, + title: "RoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleBindingList, }, "rc": { title: "ReplicationControllers", @@ -222,11 +227,11 @@ func resourceViews() map[string]resCmd { colorerFn: rsColorer, }, "ro": { - title: "Roles", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleList, - colorerFn: defaultColorer, + title: "Roles", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleList, + enterFn: showRBAC, }, "rs": { title: "ReplicaSets", @@ -236,18 +241,16 @@ func resourceViews() map[string]resCmd { colorerFn: rsColorer, }, "sa": { - title: "ServiceAccounts", - api: "", - viewFn: newResourceView, - listFn: resource.NewServiceAccountList, - colorerFn: defaultColorer, + title: "ServiceAccounts", + api: "", + viewFn: newResourceView, + listFn: resource.NewServiceAccountList, }, "sec": { - title: "Secrets", - api: "", - viewFn: newResourceView, - listFn: resource.NewSecretList, - colorerFn: defaultColorer, + title: "Secrets", + api: "", + viewFn: newResourceView, + listFn: resource.NewSecretList, }, "sts": { title: "StatefulSets", @@ -257,11 +260,11 @@ func resourceViews() map[string]resCmd { colorerFn: stsColorer, }, "svc": { - title: "Services", - api: "", - viewFn: newResourceView, - listFn: resource.NewServiceList, - colorerFn: defaultColorer, + title: "Services", + api: "", + viewFn: newResourceView, + listFn: resource.NewServiceList, + // decorateFn: svcDecorator, }, } } diff --git a/internal/views/resource.go b/internal/views/resource.go index 32f08140..df9c0f63 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -42,13 +42,15 @@ type ( selectedNS string update sync.Mutex list resource.List + enterFn enterFn extraActionsFn func(keyActions) selectedFn func() string - decorateDataFn func(resource.TableData) resource.TableData + decorateFn decorateFn + colorerFn colorerFn } ) -func newResourceView(title string, app *appView, list resource.List, c colorerFn) resourceViewer { +func newResourceView(title string, app *appView, list resource.List) resourceViewer { v := resourceView{ app: app, title: title, @@ -59,7 +61,6 @@ func newResourceView(title string, app *appView, list resource.List, c colorerFn tv := newTableView(app, v.title) { - tv.SetColorer(c) tv.SetSelectionChangedFunc(v.selChanged) } v.AddPage(v.list.GetName(), tv, true, true) @@ -79,6 +80,12 @@ func newResourceView(title string, app *appView, list resource.List, c colorerFn func (v *resourceView) init(ctx context.Context, ns string) { v.selectedItem, v.selectedNS = noSelection, ns + colorer := defaultColorer + if v.colorerFn != nil { + colorer = v.colorerFn + } + v.getTV().setColorer(colorer) + go func(ctx context.Context) { for { select { @@ -106,10 +113,6 @@ func (v *resourceView) selChanged(r, c int) { v.getTV().cmdBuff.setActive(false) } -func (v *resourceView) colorFn(f colorerFn) { - v.getTV().SetColorer(f) -} - func (v *resourceView) getSelectedItem() string { if v.selectedFn != nil { return v.selectedFn() @@ -124,9 +127,30 @@ func (v *resourceView) hints() hints { return v.CurrentPage().Item.(hinter).hints() } +func (v *resourceView) setColorerFn(f colorerFn) { + v.colorerFn = f + v.getTV().setColorer(f) +} + +func (v *resourceView) setEnterFn(f enterFn) { + v.enterFn = f +} + +func (v *resourceView) setDecorateFn(f decorateFn) { + v.decorateFn = f +} + // ---------------------------------------------------------------------------- // Actions... +func (v *resourceView) enterCmd(*tcell.EventKey) *tcell.EventKey { + v.app.flash(flashInfo, "Enter pressed...") + if v.enterFn != nil { + v.enterFn(v.app, v.list.GetNamespace(), v.list.GetName(), v.selectedItem) + } + return nil +} + func (v *resourceView) refreshCmd(*tcell.EventKey) *tcell.EventKey { v.app.flash(flashInfo, "Refreshing...") v.refresh() @@ -262,12 +286,12 @@ func (v *resourceView) refresh() { v.list.SetNamespace(v.selectedNS) } if err := v.list.Reconcile(); err != nil { - log.Warn().Msgf("Reconcile %v", err) + log.Error().Err(err).Msg("Reconciliation failed") v.app.flash(flashErr, err.Error()) } data := v.list.Data() - if v.decorateDataFn != nil { - data = v.decorateDataFn(data) + if v.decorateFn != nil { + data = v.decorateFn(data) } v.getTV().update(data) v.selectItem(v.selectedRow, 0) @@ -359,6 +383,8 @@ func (v *resourceView) refreshActions() { } } + aa[tcell.KeyEnter] = newKeyAction("Enter", v.enterCmd, true) + aa[tcell.KeyCtrlR] = newKeyAction("Refresh", v.refreshCmd, false) aa[KeyHelp] = newKeyAction("Help", v.app.noopCmd, false) aa[KeyP] = newKeyAction("Previous", v.app.prevCmd, false) @@ -370,7 +396,7 @@ func (v *resourceView) refreshActions() { aa[tcell.KeyCtrlD] = newKeyAction("Delete", v.deleteCmd, true) } if v.list.Access(resource.ViewAccess) { - aa[KeyV] = newKeyAction("View", v.viewCmd, true) + aa[KeyY] = newKeyAction("YAML", v.viewCmd, true) } if v.list.Access(resource.DescribeAccess) { aa[KeyD] = newKeyAction("Describe", v.describeCmd, true) diff --git a/internal/views/table.go b/internal/views/table.go index 5f59dfb9..eb2aa83c 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -14,7 +14,7 @@ import ( ) const ( - titleFmt = " [aqua::b]%s[aqua::-]([fuchsia::b]%d[aqua::-]) " + titleFmt = " [aqua::b]%s[aqua::-][[fuchsia::b]%d[aqua::-]] " searchFmt = "<[green::b]/%s[aqua::]> " nsTitleFmt = " [aqua::b]%s([fuchsia::b]%s[aqua::-])[aqua::-][[aqua::b]%d[aqua::-]][aqua::-] " ) @@ -55,6 +55,7 @@ func newTableView(app *appView, title string) *tableView { v.actions = make(keyActions) v.SetFixed(1, 0) v.SetBorder(true) + v.SetFixed(1, 0) v.SetBorderColor(tcell.ColorDodgerBlue) v.SetBorderAttributes(tcell.AttrBold) v.SetBorderPadding(0, 0, 1, 1) @@ -64,8 +65,13 @@ func newTableView(app *appView, title string) *tableView { v.SetSelectable(true, false) v.SetSelectedStyle(tcell.ColorBlack, tcell.ColorAqua, tcell.AttrBold) v.SetInputCapture(v.keyboard) + v.bindKeys() } + return &v +} + +func (v *tableView) bindKeys() { v.actions[KeyShiftI] = newKeyAction("Invert", v.sortInvertCmd, true) v.actions[KeyShiftN] = newKeyAction("Sort Name", v.sortColCmd(0), true) v.actions[KeyShiftA] = newKeyAction("Sort Age", v.sortColCmd(-1), true) @@ -77,12 +83,10 @@ func newTableView(app *appView, title string) *tableView { v.actions[tcell.KeyBackspace2] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyBackspace] = newKeyAction("Erase", v.eraseCmd, false) v.actions[tcell.KeyDelete] = newKeyAction("Erase", v.eraseCmd, false) - v.actions[KeyG] = newKeyAction("Top", app.puntCmd, false) - v.actions[KeyShiftG] = newKeyAction("Bottom", app.puntCmd, false) + v.actions[KeyG] = newKeyAction("Top", v.app.puntCmd, false) + v.actions[KeyShiftG] = newKeyAction("Bottom", v.app.puntCmd, false) v.actions[KeyB] = newKeyAction("Down", v.pageDownCmd, false) v.actions[KeyF] = newKeyAction("Up", v.pageUpCmd, false) - - return &v } func (v *tableView) clearSelection() { @@ -107,6 +111,7 @@ func (v *tableView) keyboard(evt *tcell.EventKey) *tcell.EventKey { log.Debug().Msgf(">> TableView handled %s", tcell.KeyNames[key]) return a.action(evt) } + return evt } @@ -118,17 +123,20 @@ func (v *tableView) setSelection() { func (v *tableView) pageUpCmd(evt *tcell.EventKey) *tcell.EventKey { v.PageUp() + return nil } func (v *tableView) pageDownCmd(evt *tcell.EventKey) *tcell.EventKey { v.PageDown() + return nil } func (v *tableView) filterCmd(evt *tcell.EventKey) *tcell.EventKey { v.cmdBuff.setActive(false) v.refresh() + return nil } @@ -136,6 +144,7 @@ func (v *tableView) eraseCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { v.cmdBuff.del() } + return nil } @@ -145,6 +154,7 @@ func (v *tableView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { } v.cmdBuff.reset() v.refresh() + return nil } @@ -172,12 +182,14 @@ func (v *tableView) sortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKe func (v *tableView) sortNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { v.sortCol.index, v.sortCol.asc = 0, true v.refresh() + return nil } func (v *tableView) sortInvertCmd(evt *tcell.EventKey) *tcell.EventKey { v.sortCol.asc = !v.sortCol.asc v.refresh() + return nil } @@ -189,6 +201,7 @@ func (v *tableView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { v.app.flash(flashInfo, "Filtering...") v.cmdBuff.reset() v.cmdBuff.setActive(true) + return nil } @@ -201,7 +214,7 @@ func (v *tableView) setDeleted() { } // SetColorer sets up table row color management. -func (v *tableView) SetColorer(f colorerFn) { +func (v *tableView) setColorer(f colorerFn) { v.colorerFn = f } @@ -221,6 +234,7 @@ func (v *tableView) hints() hints { if v.actions != nil { return v.actions.toHints() } + return nil } @@ -266,10 +280,11 @@ func (v *tableView) filtered() resource.TableData { filtered.Rows[k] = row } } + return filtered } -func (v *tableView) displayCol(index int, name string) string { +func (v *tableView) sortIndicator(index int, name string) string { if v.sortCol.index != index { return name } @@ -278,13 +293,13 @@ func (v *tableView) displayCol(index int, name string) string { if v.sortCol.asc { order = "↑" } - return fmt.Sprintf("%s[green::]%s[::]", name, order) + return fmt.Sprintf("%s [green::]%s[::]", name, order) } func (v *tableView) doUpdate(data resource.TableData) { v.currentNS = data.Namespace - if v.currentNS == resource.AllNamespaces { - v.actions[KeyShiftS] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) + if v.currentNS == resource.AllNamespaces || v.currentNS == "*" { + v.actions[KeyShiftP] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) } else { delete(v.actions, KeyShiftS) } @@ -303,118 +318,94 @@ func (v *tableView) doUpdate(data resource.TableData) { v.sortCol.index = 0 } + pads := make(maxyPad, len(data.Header)) + computeMaxColumns(pads, v.sortCol.index, data) var row int for col, h := range data.Header { - v.addHeaderCell(col, h) + v.addHeaderCell(col, h, pads) } row++ - // for k := range data.Rows { - // log.Debug().Msgf("Keys: %s", k) - // } - - keys := v.sortRows(data) - groupKeys := map[string][]string{} - for _, k := range keys { - // log.Debug().Msgf("RKEY: %s", k) - grp := data.Rows[k].Fields[v.sortCol.index] - if s, ok := groupKeys[grp]; ok { - s = append(s, k) - groupKeys[grp] = s - } else { - groupKeys[grp] = []string{k} - } + sortFn := v.defaultSort + if v.sortFn != nil { + sortFn = v.sortFn } - - // Performs secondary to sort by name for each groups. - gKeys := make([]string, len(keys)) - for k, v := range groupKeys { - sort.Strings(v) - gKeys = append(gKeys, k) - } - rs := groupSorter{gKeys, v.sortCol.asc} - sort.Sort(rs) - - for _, gk := range gKeys { - for _, k := range groupKeys[gk] { + prim, sec := v.sortAllRows(data.Rows, sortFn) + for _, pk := range prim { + for _, sk := range sec[pk] { fgColor := tcell.ColorGray if v.colorerFn != nil { - fgColor = v.colorerFn(data.Namespace, data.Rows[k]) + fgColor = v.colorerFn(data.Namespace, data.Rows[sk]) } - for col, field := range data.Rows[k].Fields { - v.addBodyCell(row, col, field, data.Rows[k].Deltas[col], fgColor) + for col, field := range data.Rows[sk].Fields { + v.addBodyCell(row, col, field, data.Rows[sk].Deltas[col], fgColor, pads) } row++ } } } -func (v *tableView) addHeaderCell(col int, name string) { - c := tview.NewTableCell(v.displayCol(col, name)) +func (v *tableView) sortAllRows(rows resource.RowEvents, sortFn sortFn) (resource.Row, map[string]resource.Row) { + keys := make([]string, len(rows)) + v.sortRows(rows, sortFn, v.sortCol, keys) + + sec := make(map[string]resource.Row, len(rows)) + for _, k := range keys { + grp := rows[k].Fields[v.sortCol.index] + sec[grp] = append(sec[grp], k) + } + + // Performs secondary to sort by name for each groups. + prim := make(resource.Row, 0, len(sec)) + for k, v := range sec { + sort.Strings(v) + prim = append(prim, k) + } + sort.Sort(groupSorter{prim, v.sortCol.asc}) + + return prim, sec +} + +func (v *tableView) addHeaderCell(col int, name string, pads maxyPad) { + c := tview.NewTableCell(v.sortIndicator(col, name)) { - c.SetExpansion(3) - if len(name) == 0 { - c.SetExpansion(1) - } + c.SetExpansion(1) c.SetTextColor(tcell.ColorAntiqueWhite) } v.SetCell(0, col, c) } -func (v *tableView) addBodyCell(row, col int, field, delta string, color tcell.Color) { - c := tview.NewTableCell(deltas(delta, field)) +func (v *tableView) addBodyCell(row, col int, field, delta string, color tcell.Color, pads maxyPad) { + var pField string + if isASCII(field) { + pField = pad(deltas(delta, field), pads[col]) + } else { + pField = deltas(delta, field) + } + + c := tview.NewTableCell(pField) { - c.SetExpansion(3) - if len(v.GetCell(0, col).Text) == 0 { - c.SetExpansion(1) - } + c.SetExpansion(1) c.SetTextColor(color) } v.SetCell(row, col, c) } -func (v *tableView) defaultSort(rows resource.Rows) { - t := rowSorter{rows: rows, index: v.sortCol.index, asc: v.sortCol.asc} +func (v *tableView) defaultSort(rows resource.Rows, sortCol sortColumn) { + t := rowSorter{rows: rows, index: sortCol.index, asc: sortCol.asc} sort.Sort(t) } -func (v *tableView) sortRows(data resource.TableData) []string { - rows := make(resource.Rows, 0, len(data.Rows)) - for _, r := range data.Rows { - rows = append(rows, r.Fields) +func (*tableView) sortRows(evts resource.RowEvents, sortFn sortFn, sortCol sortColumn, keys []string) { + rows := make(resource.Rows, 0, len(evts)) + for k, r := range evts { + rows = append(rows, append(r.Fields, k)) } + sortFn(rows, sortCol) - if v.sortFn != nil { - v.sortFn(rows, v.sortCol) - } else { - v.defaultSort(rows) - } - - keys := make([]string, len(rows)) for i, r := range rows { - col, prefix := 0, v.currentNS - switch v.currentNS { - case resource.AllNamespaces: - col, prefix = 1, r[0] - case resource.NotNamespaced: - prefix = "" - } - - key := r[col] - if v.cleanseFn != nil { - key = v.cleanseFn(key) - } else { - key = v.defaultColCleanse(key) - } - - if len(prefix) == 0 { - keys[i] = key - } else { - keys[i] = prefix + "/" + key - } + keys[i] = r[len(r)-1] } - - return keys } func (*tableView) defaultColCleanse(s string) string { @@ -448,8 +439,7 @@ func (v *tableView) resetTitle() { // ---------------------------------------------------------------------------- // Event listeners... -func (v *tableView) changed(s string) { -} +func (v *tableView) changed(s string) {} func (v *tableView) active(b bool) { if b { diff --git a/internal/views/table_test.go b/internal/views/table_test.go new file mode 100644 index 00000000..e4e977d2 --- /dev/null +++ b/internal/views/table_test.go @@ -0,0 +1,72 @@ +package views + +import ( + "testing" + + "github.com/derailed/k9s/internal/resource" + "github.com/stretchr/testify/assert" +) + +func TestTVSortRows(t *testing.T) { + uu := []struct { + rows resource.RowEvents + col int + asc bool + first resource.Row + e []string + }{ + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 0, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + true, + resource.Row{"a", "b"}, + []string{"row2", "row1"}, + }, + { + resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + }, + 1, + false, + resource.Row{"x", "y"}, + []string{"row1", "row2"}, + }, + } + + var v *tableView + for _, u := range uu { + keys := make([]string, len(u.rows)) + v.sortRows(u.rows, v.defaultSort, sortColumn{u.col, len(u.rows), u.asc}, keys) + assert.Equal(t, u.e, keys) + assert.Equal(t, u.first, u.rows[u.e[0]].Fields) + } +} + +func BenchmarkTVSortRows(b *testing.B) { + evts := resource.RowEvents{ + "row1": {Fields: resource.Row{"x", "y"}}, + "row2": {Fields: resource.Row{"a", "b"}}, + } + sc := sortColumn{0, 2, true} + var v *tableView + keys := make([]string, len(evts)) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + v.sortRows(evts, v.defaultSort, sc, keys) + } +}