diff --git a/README.md b/README.md index 665a8c6a..5cf80337 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ K9s is available on Linux, OSX and Windows platforms. ``` * Building from source - K9s was built using go 1.11 or above. In order to build K9 from source you must: + K9s was built using go 1.12 or above. In order to build K9 from source you must: 1. Clone the repo 2. Set env var *GO111MODULE=on* 3. Add the following command in your go.mod file @@ -151,7 +151,7 @@ K9s uses aliases to navigate most K8s resources. This initial drop is brittle. K9s will most likely blow up... -1. You're running older versions of Kubernetes. K9s works best Kubernetes 1.10+. +1. You're running older versions of Kubernetes. K9s works best Kubernetes 1.12+. 1. You don't have enough RBAC fu to manage your cluster (see RBAC section below). 1. Your cluster does not run a metric server. diff --git a/change_logs/release_0.4.1.md b/change_logs/release_0.4.1.md new file mode 100644 index 00000000..79a24c52 --- /dev/null +++ b/change_logs/release_0.4.1.md @@ -0,0 +1,54 @@ +# Release v0.4.1 + +## 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 + +### Subject View + + You can now view users/groups that are bound by RBAC rules without having to type to full subject name. + To activate use the following command mode + + ```text + # for users + :usr + # for groups + :grp + ``` + + These commands will pull all the available cluster and role binding associated with these subject types. + You can then select and `` to see the associated policies. + You can also filter/sort like in any other K9s views with the added bonus of auto updates when new + users/groups binding come into your clusters. + + To see ServiceAccount RBAC policies, you can now navigate to the serviceaccount view aka `:sa` and press `` + to view the associated policy rules. + +### Fu View + + Has been renamed policy view to see all RBAC policies available on a subject. + You can now use `pol` (instead of `fu`) to list out RBAC policies associated with a + user/group or serviceaccount. + +### Enter now has a meaning! + + Pressing `` on most resource views will now describe the resource by default. + +--- + +## Resolved Bugs + ++ [Issue #143](https://github.com/derailed/k9s/issues/143) ++ [Issue #140](https://github.com/derailed/k9s/issues/140) + NOTE! Describe on v1 HPA is busted just like it is when running v 1.13 of + kubectl against a v1.12 cluster. diff --git a/cmd/root.go b/cmd/root.go index d9e35aec..d5aff827 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,9 +87,6 @@ func loadConfiguration() *config.Config { log.Info().Msg("✅ Kubernetes connectivity") k9sCfg.Save() - // k8s.NewNode(k9sCfg.GetConnection()).FetchReqLimit("minikube") - // os.Exit(0) - return k9sCfg } diff --git a/internal/config/mock_connection_test.go b/internal/config/mock_connection_test.go index da3b5584..82c41068 100644 --- a/internal/config/mock_connection_test.go +++ b/internal/config/mock_connection_test.go @@ -147,6 +147,25 @@ func (mock *MockConnection) RestConfigOrDie() *rest.Config { return ret0 } +func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockConnection().") + } + params := []pegomock.Param{_param0, _param1} + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 string + var ret1 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(bool) + } + } + return ret0, ret1 +} + func (mock *MockConnection) SupportsResource(_param0 string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") @@ -391,6 +410,37 @@ func (c *Connection_RestConfigOrDie_OngoingVerification) GetCapturedArguments() func (c *Connection_RestConfigOrDie_OngoingVerification) GetAllCapturedArguments() { } +func (verifier *VerifierConnection) SupportsRes(_param0 string, _param1 []string) *Connection_SupportsRes_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsRes", params, verifier.timeout) + return &Connection_SupportsRes_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type Connection_SupportsRes_OngoingVerification struct { + mock *MockConnection + methodInvocations []pegomock.MethodInvocation +} + +func (c *Connection_SupportsRes_OngoingVerification) GetCapturedArguments() (string, []string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *Connection_SupportsRes_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([][]string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.([]string) + } + } + return +} + func (verifier *VerifierConnection) SupportsResource(_param0 string) *Connection_SupportsResource_OngoingVerification { params := []pegomock.Param{_param0} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsResource", params, verifier.timeout) diff --git a/internal/k8s/api.go b/internal/k8s/api.go index d5d5c712..aaa7d4d3 100644 --- a/internal/k8s/api.go +++ b/internal/k8s/api.go @@ -54,6 +54,7 @@ type ( SupportsResource(group string) bool ValidNamespaces() ([]v1.Namespace, error) ValidPods(node string) ([]v1.Pod, error) + SupportsRes(grp string, versions []string) (string, bool) } // APIClient represents a Kubernetes api client. @@ -122,6 +123,7 @@ func (a *APIClient) IsNamespaced(res string) bool { func (a *APIClient) SupportsResource(group string) bool { list, _ := a.DialOrDie().Discovery().ServerPreferredResources() for _, l := range list { + log.Debug().Msgf(">>> Group %s", l.GroupVersion) if l.GroupVersion == group { return true } @@ -243,3 +245,28 @@ func (a *APIClient) supportsMxServer() bool { return false } + +// SupportsRes checks latest supported version. +func (a *APIClient) SupportsRes(group string, versions []string) (string, bool) { + apiGroups, err := a.DialOrDie().Discovery().ServerGroups() + if err != nil { + return "", false + } + + for _, grp := range apiGroups.Groups { + if grp.Name != group { + continue + } + return grp.PreferredVersion.Version, true + + // for _, version := range grp.Versions { + // for _, supportedVersion := range versions { + // if version.Version == supportedVersion { + // return supportedVersion, true + // } + // } + // } + } + + return "", false +} diff --git a/internal/k8s/hpa_v1.go b/internal/k8s/hpa_v1.go new file mode 100644 index 00000000..c64f882a --- /dev/null +++ b/internal/k8s/hpa_v1.go @@ -0,0 +1,38 @@ +package k8s + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HPAV1 represents am HorizontalPodAutoscaler. +type HPAV1 struct { + Connection +} + +// NewHPAV1 returns a new HPA. +func NewHPAV1(c Connection) Cruder { + return &HPAV1{c} +} + +// Get a HPA. +func (h *HPAV1) Get(ns, n string) (interface{}, error) { + return h.DialOrDie().AutoscalingV1().HorizontalPodAutoscalers(ns).Get(n, metav1.GetOptions{}) +} + +// List all HPAs in a given namespace. +func (h *HPAV1) List(ns string) (Collection, error) { + rr, err := h.DialOrDie().AutoscalingV1().HorizontalPodAutoscalers(ns).List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + cc := make(Collection, len(rr.Items)) + for i, r := range rr.Items { + cc[i] = r + } + return cc, nil +} + +// Delete a HPA. +func (h *HPAV1) Delete(ns, n string) error { + return h.DialOrDie().AutoscalingV1().HorizontalPodAutoscalers(ns).Delete(n, nil) +} diff --git a/internal/k8s/hpa.go b/internal/k8s/hpa_v2beta1.go similarity index 51% rename from internal/k8s/hpa.go rename to internal/k8s/hpa_v2beta1.go index 51933d15..f7dfbc8e 100644 --- a/internal/k8s/hpa.go +++ b/internal/k8s/hpa_v2beta1.go @@ -1,26 +1,29 @@ package k8s import ( + "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// HPA represents am HorizontalPodAutoscaler. -type HPA struct { +// HPAV2Beta1 represents am HorizontalPodAutoscaler. +type HPAV2Beta1 struct { Connection } -// NewHPA returns a new HPA. -func NewHPA(c Connection) Cruder { - return &HPA{c} +// NewHPAV2Beta1 returns a new HPA. +func NewHPAV2Beta1(c Connection) Cruder { + return &HPAV2Beta1{c} } // Get a HPA. -func (h *HPA) Get(ns, n string) (interface{}, error) { +func (h *HPAV2Beta1) Get(ns, n string) (interface{}, error) { return h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).Get(n, metav1.GetOptions{}) } // List all HPAs in a given namespace. -func (h *HPA) List(ns string) (Collection, error) { +func (h *HPAV2Beta1) List(ns string) (Collection, error) { + log.Debug().Msg("!!!! YO V2B1") + rr, err := h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).List(metav1.ListOptions{}) if err != nil { return nil, err @@ -33,9 +36,6 @@ func (h *HPA) List(ns string) (Collection, error) { } // Delete a HPA. -func (h *HPA) Delete(ns, n string) error { - if h.SupportsResource("autoscaling/v2beta1") { - return h.DialOrDie().AutoscalingV2beta1().HorizontalPodAutoscalers(ns).Delete(n, nil) - } - return h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).Delete(n, nil) +func (h *HPAV2Beta1) Delete(ns, n string) error { + return h.DialOrDie().AutoscalingV2beta1().HorizontalPodAutoscalers(ns).Delete(n, nil) } diff --git a/internal/k8s/hpa_v2beta2.go b/internal/k8s/hpa_v2beta2.go new file mode 100644 index 00000000..cead8c5c --- /dev/null +++ b/internal/k8s/hpa_v2beta2.go @@ -0,0 +1,42 @@ +package k8s + +import ( + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var supportedAutoScalingAPIVersions = []string{"v2beta2", "v2beta1", "v1"} + +// HPAV2Beta2 represents am HorizontalPodAutoscaler. +type HPAV2Beta2 struct { + Connection +} + +// NewHPAV2Beta2 returns a new HPAV2Beta2. +func NewHPAV2Beta2(c Connection) Cruder { + return &HPAV2Beta2{c} +} + +// Get a HPAV2Beta2. +func (h *HPAV2Beta2) Get(ns, n string) (interface{}, error) { + return h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).Get(n, metav1.GetOptions{}) +} + +// List all HPAV2Beta2s in a given namespace. +func (h *HPAV2Beta2) List(ns string) (Collection, error) { + log.Debug().Msg("!!!! YO V2B2") + rr, err := h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + cc := make(Collection, len(rr.Items)) + for i, r := range rr.Items { + cc[i] = r + } + return cc, nil +} + +// Delete a HPAV2Beta2. +func (h *HPAV2Beta2) Delete(ns, n string) error { + return h.DialOrDie().AutoscalingV2beta2().HorizontalPodAutoscalers(ns).Delete(n, nil) +} diff --git a/internal/k8s/metrics.go b/internal/k8s/metrics.go index 38fe90d5..e75d35eb 100644 --- a/internal/k8s/metrics.go +++ b/internal/k8s/metrics.go @@ -98,11 +98,6 @@ func (m *MetricsServer) ClusterLoad(nodes []v1.Node, metrics []mv1beta1.NodeMetr return ClusterMetrics{PercCPU: toPerc(cpu, tcpu), PercMEM: toPerc(mem, tmem)} } -// // HasMetrics check if cluster has a metrics server. -// func (m *MetricsServer) HasMetrics() bool { -// return m.HasMetrics() -// } - // FetchNodesMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchNodesMetrics() ([]mv1beta1.NodeMetrics, error) { client, err := m.MXDial() diff --git a/internal/resource/base.go b/internal/resource/base.go index 8be764cb..23e0064f 100644 --- a/internal/resource/base.go +++ b/internal/resource/base.go @@ -92,13 +92,13 @@ func (b *Base) Describe(kind, pa string, flags *genericclioptions.ConfigFlags) ( d, err := versioned.Describer(flags, mapping) if err != nil { + log.Error().Err(err).Msgf("Unable to find describer for %#v", mapping) return "", err } - opts := describe.DescriberSettings{ - ShowEvents: true, - } - return d.Describe(ns, n, opts) + log.Debug().Msgf("Describer %#v", d) + + return d.Describe(ns, n, describe.DescriberSettings{ShowEvents: true}) } // Delete a resource by name. diff --git a/internal/resource/hpa_v1.go b/internal/resource/hpa_v1.go new file mode 100644 index 00000000..738fb976 --- /dev/null +++ b/internal/resource/hpa_v1.go @@ -0,0 +1,118 @@ +package resource + +import ( + "strconv" + + "github.com/derailed/k9s/internal/k8s" + "github.com/rs/zerolog/log" + autoscalingv1 "k8s.io/api/autoscaling/v1" +) + +// HPAV1 tracks a kubernetes resource. +type HPAV1 struct { + *Base + instance *autoscalingv1.HorizontalPodAutoscaler +} + +// NewHPAV1List returns a new resource list. +func NewHPAV1List(c Connection, ns string) List { + log.Debug().Msg(">>> YO!!!") + return NewList( + ns, + "hpa", + NewHPAV1(c), + AllVerbsAccess|DescribeAccess, + ) +} + +// NewHPAV1 instantiates a new HPAV1. +func NewHPAV1(c Connection) *HPAV1 { + hpa := &HPAV1{&Base{Connection: c, Resource: k8s.NewHPAV1(c)}, nil} + hpa.Factory = hpa + + return hpa +} + +// New builds a new HPAV1 instance from a k8s resource. +func (r *HPAV1) New(i interface{}) Columnar { + c := NewHPAV1(r.Connection) + switch instance := i.(type) { + case *autoscalingv1.HorizontalPodAutoscaler: + c.instance = instance + case autoscalingv1.HorizontalPodAutoscaler: + c.instance = &instance + default: + log.Fatal().Msgf("unknown HPAV1 type %#v", i) + } + c.path = c.namespacedName(c.instance.ObjectMeta) + + return c +} + +// Marshal resource to yaml. +func (r *HPAV1) Marshal(path string) (string, error) { + ns, n := namespaced(path) + i, err := r.Resource.Get(ns, n) + if err != nil { + return "", err + } + + hpa := i.(*autoscalingv1.HorizontalPodAutoscaler) + hpa.TypeMeta.APIVersion = "autoscaling/v1" + hpa.TypeMeta.Kind = "HorizontalPodAutoscaler" + + return r.marshalObject(hpa) +} + +// Header return resource header. +func (*HPAV1) Header(ns string) Row { + hh := Row{} + if ns == AllNamespaces { + hh = append(hh, "NAMESPACE") + } + + return append(hh, + "NAME", + "REFERENCE", + "TARGETS", + "MINPODS", + "MAXPODS", + "REPLICAS", + "AGE") +} + +// Fields retrieves displayable fields. +func (r *HPAV1) Fields(ns string) Row { + ff := make(Row, 0, len(r.Header(ns))) + + i := r.instance + if ns == AllNamespaces { + ff = append(ff, i.Namespace) + } + + return append(ff, + i.ObjectMeta.Name, + i.Spec.ScaleTargetRef.Name, + r.toMetrics(i.Spec, i.Status), + strconv.Itoa(int(*i.Spec.MinReplicas)), + strconv.Itoa(int(i.Spec.MaxReplicas)), + strconv.Itoa(int(i.Status.CurrentReplicas)), + toAge(i.ObjectMeta.CreationTimestamp), + ) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func (r *HPAV1) toMetrics(spec autoscalingv1.HorizontalPodAutoscalerSpec, status autoscalingv1.HorizontalPodAutoscalerStatus) string { + current := "" + if status.CurrentCPUUtilizationPercentage != nil { + current = strconv.Itoa(int(*status.CurrentCPUUtilizationPercentage)) + "%" + } + + target := "" + if spec.TargetCPUUtilizationPercentage != nil { + target = strconv.Itoa(int(*spec.TargetCPUUtilizationPercentage)) + } + return current + "/" + target + "%" +} diff --git a/internal/resource/hpa_v2beta1.go b/internal/resource/hpa_v2beta1.go new file mode 100644 index 00000000..d02627ac --- /dev/null +++ b/internal/resource/hpa_v2beta1.go @@ -0,0 +1,185 @@ +package resource + +import ( + "fmt" + "strconv" + "strings" + + "github.com/derailed/k9s/internal/k8s" + "github.com/rs/zerolog/log" + autoscalingv2beta1 "k8s.io/api/autoscaling/v2beta1" +) + +// HPAV2Beta1 tracks a kubernetes resource. +type HPAV2Beta1 struct { + *Base + instance *autoscalingv2beta1.HorizontalPodAutoscaler +} + +// NewHPAV2Beta1List returns a new resource list. +func NewHPAV2Beta1List(c Connection, ns string) List { + return NewList( + ns, + "hpa", + NewHPAV2Beta1(c), + AllVerbsAccess|DescribeAccess, + ) +} + +// NewHPAV2Beta1 instantiates a new HPAV2Beta1. +func NewHPAV2Beta1(c Connection) *HPAV2Beta1 { + hpa := &HPAV2Beta1{&Base{Connection: c, Resource: k8s.NewHPAV2Beta1(c)}, nil} + hpa.Factory = hpa + + return hpa +} + +// New builds a new HPAV2Beta1 instance from a k8s resource. +func (r *HPAV2Beta1) New(i interface{}) Columnar { + c := NewHPAV2Beta1(r.Connection) + switch instance := i.(type) { + case *autoscalingv2beta1.HorizontalPodAutoscaler: + c.instance = instance + case autoscalingv2beta1.HorizontalPodAutoscaler: + c.instance = &instance + default: + log.Fatal().Msgf("unknown HPAV2Beta1 type %#v", i) + } + c.path = c.namespacedName(c.instance.ObjectMeta) + + return c +} + +// Marshal resource to yaml. +func (r *HPAV2Beta1) Marshal(path string) (string, error) { + ns, n := namespaced(path) + i, err := r.Resource.Get(ns, n) + if err != nil { + return "", err + } + + hpa := i.(*autoscalingv2beta1.HorizontalPodAutoscaler) + hpa.TypeMeta.APIVersion = "autoscaling/v2beta1" + hpa.TypeMeta.Kind = "HorizontalPodAutoscaler" + + return r.marshalObject(hpa) +} + +// Header return resource header. +func (*HPAV2Beta1) Header(ns string) Row { + hh := Row{} + if ns == AllNamespaces { + hh = append(hh, "NAMESPACE") + } + + return append(hh, + "NAME", + "REFERENCE", + "TARGETS", + "MINPODS", + "MAXPODS", + "REPLICAS", + "AGE") +} + +// Fields retrieves displayable fields. +func (r *HPAV2Beta1) Fields(ns string) Row { + ff := make(Row, 0, len(r.Header(ns))) + + i := r.instance + if ns == AllNamespaces { + ff = append(ff, i.Namespace) + } + + return append(ff, + i.ObjectMeta.Name, + i.Spec.ScaleTargetRef.Name, + r.toMetrics(i.Spec.Metrics, i.Status.CurrentMetrics), + strconv.Itoa(int(*i.Spec.MinReplicas)), + strconv.Itoa(int(i.Spec.MaxReplicas)), + strconv.Itoa(int(i.Status.CurrentReplicas)), + toAge(i.ObjectMeta.CreationTimestamp), + ) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +func (r *HPAV2Beta1) toMetrics(specs []autoscalingv2beta1.MetricSpec, statuses []autoscalingv2beta1.MetricStatus) string { + if len(specs) == 0 { + return "" + } + + list, max, more, count := []string{}, 2, false, 0 + for i, spec := range specs { + current := "" + + switch spec.Type { + case autoscalingv2beta1.ExternalMetricSourceType: + list = append(list, r.externalMetrics(i, spec, statuses)) + case autoscalingv2beta1.PodsMetricSourceType: + if len(statuses) > i && statuses[i].Pods != nil { + current = statuses[i].Pods.CurrentAverageValue.String() + } + list = append(list, fmt.Sprintf("%s/%s", current, spec.Pods.TargetAverageValue.String())) + case autoscalingv2beta1.ObjectMetricSourceType: + if len(statuses) > i && statuses[i].Object != nil { + current = statuses[i].Object.CurrentValue.String() + } + list = append(list, fmt.Sprintf("%s/%s", current, spec.Object.TargetValue.String())) + case autoscalingv2beta1.ResourceMetricSourceType: + list = append(list, r.resourceMetrics(i, spec, statuses)) + default: + list = append(list, "") + } + count++ + } + + if count > max { + list, more = list[:max], true + } + + ret := strings.Join(list, ", ") + if more { + return fmt.Sprintf("%s + %d more...", ret, count-max) + } + + return ret +} + +func (*HPAV2Beta1) externalMetrics(i int, spec autoscalingv2beta1.MetricSpec, statuses []autoscalingv2beta1.MetricStatus) string { + current := "" + + if spec.External.TargetAverageValue != nil { + if len(statuses) > i && statuses[i].External != nil && &statuses[i].External.CurrentAverageValue != nil { + current = statuses[i].External.CurrentAverageValue.String() + } + return fmt.Sprintf("%s/%s (avg)", current, spec.External.TargetAverageValue.String()) + } + if len(statuses) > i && statuses[i].External != nil { + current = statuses[i].External.CurrentValue.String() + } + + return fmt.Sprintf("%s/%s", current, spec.External.TargetValue.String()) +} + +func (*HPAV2Beta1) resourceMetrics(i int, spec autoscalingv2beta1.MetricSpec, statuses []autoscalingv2beta1.MetricStatus) string { + current := "" + + if spec.Resource.TargetAverageValue != nil { + if len(statuses) > i && statuses[i].Resource != nil { + current = statuses[i].Resource.CurrentAverageValue.String() + } + return fmt.Sprintf("%s/%s", current, spec.Resource.TargetAverageValue.String()) + } + + if len(statuses) > i && statuses[i].Resource != nil && statuses[i].Resource.CurrentAverageUtilization != nil { + current = fmt.Sprintf("%d%%", *statuses[i].Resource.CurrentAverageUtilization) + } + + target := "" + if spec.Resource.TargetAverageUtilization != nil { + target = fmt.Sprintf("%d%%", *spec.Resource.TargetAverageUtilization) + } + return fmt.Sprintf("%s/%s", current, target) +} diff --git a/internal/resource/hpa.go b/internal/resource/hpa_v2beta2.go similarity index 98% rename from internal/resource/hpa.go rename to internal/resource/hpa_v2beta2.go index 78df90a0..930f5e22 100644 --- a/internal/resource/hpa.go +++ b/internal/resource/hpa_v2beta2.go @@ -28,7 +28,7 @@ func NewHPAList(c Connection, ns string) List { // NewHPA instantiates a new HPA. func NewHPA(c Connection) *HPA { - hpa := &HPA{&Base{Connection: c, Resource: k8s.NewHPA(c)}, nil} + hpa := &HPA{&Base{Connection: c, Resource: k8s.NewHPAV2Beta2(c)}, nil} hpa.Factory = hpa return hpa diff --git a/internal/resource/mock_clustermeta_test.go b/internal/resource/mock_clustermeta_test.go index 7e85d20e..b319ffd2 100644 --- a/internal/resource/mock_clustermeta_test.go +++ b/internal/resource/mock_clustermeta_test.go @@ -196,6 +196,25 @@ func (mock *MockClusterMeta) RestConfigOrDie() *rest.Config { return ret0 } +func (mock *MockClusterMeta) SupportsRes(_param0 string, _param1 []string) (string, bool) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockClusterMeta().") + } + params := []pegomock.Param{_param0, _param1} + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 string + var ret1 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(bool) + } + } + return ret0, ret1 +} + func (mock *MockClusterMeta) SupportsResource(_param0 string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClusterMeta().") @@ -525,6 +544,37 @@ func (c *ClusterMeta_RestConfigOrDie_OngoingVerification) GetCapturedArguments() func (c *ClusterMeta_RestConfigOrDie_OngoingVerification) GetAllCapturedArguments() { } +func (verifier *VerifierClusterMeta) SupportsRes(_param0 string, _param1 []string) *ClusterMeta_SupportsRes_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsRes", params, verifier.timeout) + return &ClusterMeta_SupportsRes_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type ClusterMeta_SupportsRes_OngoingVerification struct { + mock *MockClusterMeta + methodInvocations []pegomock.MethodInvocation +} + +func (c *ClusterMeta_SupportsRes_OngoingVerification) GetCapturedArguments() (string, []string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *ClusterMeta_SupportsRes_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([][]string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.([]string) + } + } + return +} + func (verifier *VerifierClusterMeta) SupportsResource(_param0 string) *ClusterMeta_SupportsResource_OngoingVerification { params := []pegomock.Param{_param0} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsResource", params, verifier.timeout) diff --git a/internal/resource/mock_connection_test.go b/internal/resource/mock_connection_test.go index 25390175..fe6db524 100644 --- a/internal/resource/mock_connection_test.go +++ b/internal/resource/mock_connection_test.go @@ -147,6 +147,25 @@ func (mock *MockConnection) RestConfigOrDie() *rest.Config { return ret0 } +func (mock *MockConnection) SupportsRes(_param0 string, _param1 []string) (string, bool) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockConnection().") + } + params := []pegomock.Param{_param0, _param1} + result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsRes", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*bool)(nil)).Elem()}) + var ret0 string + var ret1 bool + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(bool) + } + } + return ret0, ret1 +} + func (mock *MockConnection) SupportsResource(_param0 string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockConnection().") @@ -391,6 +410,37 @@ func (c *Connection_RestConfigOrDie_OngoingVerification) GetCapturedArguments() func (c *Connection_RestConfigOrDie_OngoingVerification) GetAllCapturedArguments() { } +func (verifier *VerifierConnection) SupportsRes(_param0 string, _param1 []string) *Connection_SupportsRes_OngoingVerification { + params := []pegomock.Param{_param0, _param1} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsRes", params, verifier.timeout) + return &Connection_SupportsRes_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type Connection_SupportsRes_OngoingVerification struct { + mock *MockConnection + methodInvocations []pegomock.MethodInvocation +} + +func (c *Connection_SupportsRes_OngoingVerification) GetCapturedArguments() (string, []string) { + _param0, _param1 := c.GetAllCapturedArguments() + return _param0[len(_param0)-1], _param1[len(_param1)-1] +} + +func (c *Connection_SupportsRes_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + _param1 = make([][]string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.([]string) + } + } + return +} + func (verifier *VerifierConnection) SupportsResource(_param0 string) *Connection_SupportsResource_OngoingVerification { params := []pegomock.Param{_param0} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsResource", params, verifier.timeout) diff --git a/internal/views/cmd.go b/internal/views/cmd.go index f6a683fb..b58e6d74 100644 --- a/internal/views/cmd.go +++ b/internal/views/cmd.go @@ -21,8 +21,8 @@ type cmdView struct { func newCmdView(ic rune) *cmdView { v := cmdView{icon: ic, TextView: tview.NewTextView()} { - v.SetWordWrap(false) - v.SetWrap(false) + v.SetWordWrap(true) + v.SetWrap(true) v.SetDynamicColors(true) v.SetBorderPadding(0, 0, 1, 1) v.SetTextColor(tcell.ColorAqua) diff --git a/internal/views/cmd_stack.go b/internal/views/cmd_stack.go index 3d07c42b..f0ea81a3 100644 --- a/internal/views/cmd_stack.go +++ b/internal/views/cmd_stack.go @@ -44,3 +44,7 @@ func (s *cmdStack) top() (string, bool) { func (s *cmdStack) empty() bool { return len(s.stack) == 0 } + +func (s *cmdStack) last() bool { + return len(s.stack) == 1 +} diff --git a/internal/views/command.go b/internal/views/command.go index 167b99ec..4ee0cb43 100644 --- a/internal/views/command.go +++ b/internal/views/command.go @@ -9,6 +9,12 @@ import ( "github.com/rs/zerolog/log" ) +type subjectViewer interface { + resourceViewer + + setSubject(s string) +} + type command struct { app *appView history *cmdStack @@ -18,6 +24,10 @@ func newCommand(app *appView) *command { return &command{app: app, history: newCmdStack()} } +func (c *command) lastCmd() bool { + return c.history.last() +} + func (c *command) pushCmd(cmd string) { c.history.push(cmd) c.app.crumbsView.update(c.history.stack) @@ -37,7 +47,7 @@ func (c *command) defaultCmd() { // Helpers... -var fuMatcher = regexp.MustCompile(`\Afu\s([u|g|s]):([\w-:]+)\b`) +var policyMatcher = regexp.MustCompile(`\Apol\s([u|g|s]):([\w-:]+)\b`) // Exec the command by showing associated display. func (c *command) run(cmd string) bool { @@ -52,21 +62,21 @@ func (c *command) run(cmd string) bool { case cmd == "alias": c.app.inject(newAliasView(c.app)) return true - case fuMatcher.MatchString(cmd): - tokens := fuMatcher.FindAllStringSubmatch(cmd, -1) + case policyMatcher.MatchString(cmd): + tokens := policyMatcher.FindAllStringSubmatch(cmd, -1) if len(tokens) == 1 && len(tokens[0]) == 3 { - c.app.inject(newFuView(c.app, tokens[0][1], tokens[0][2])) + c.app.inject(newPolicyView(c.app, tokens[0][1], tokens[0][2])) return true } default: - if res, ok := resourceViews()[cmd]; ok { + if res, ok := resourceViews(c.app.conn())[cmd]; ok { var r resource.List if res.listMxFn != nil { r = res.listMxFn(c.app.conn(), k8s.NewMetricsServer(c.app.conn()), resource.DefaultNamespace, ) - } else { + } else if res.listFn != nil { r = res.listFn(c.app.conn(), resource.DefaultNamespace) } v = res.viewFn(res.title, c.app, r) @@ -79,8 +89,8 @@ func (c *command) run(cmd string) bool { 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())) + const fmat = "Viewing resource %s..." + c.app.flash(flashInfo, fmt.Sprintf(fmat, res.title)) log.Debug().Msgf("Running command %s", cmd) c.exec(cmd, v) return true @@ -108,9 +118,11 @@ func (c *command) run(cmd string) bool { } func (c *command) exec(cmd string, v igniter) { - if v != nil { - c.app.config.SetActiveView(cmd) - c.app.config.Save() - c.app.inject(v) + if v == nil { + return } + + c.app.config.SetActiveView(cmd) + c.app.config.Save() + c.app.inject(v) } diff --git a/internal/views/helpers.go b/internal/views/helpers.go index 2421e97e..8e7e94c7 100644 --- a/internal/views/helpers.go +++ b/internal/views/helpers.go @@ -12,9 +12,6 @@ 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 } @@ -83,13 +80,13 @@ func delta(s string) string { } func plus(s string) string { - return suffix(s, "+") + return suffix(s, "⬆") } func minus(s string) string { - return suffix(s, "-") + return suffix(s, "⬇︎") } func suffix(s, su string) string { - return s + "(" + su + ")" + return s + su } diff --git a/internal/views/padding.go b/internal/views/padding.go index 3deb740f..5e7c0ef7 100644 --- a/internal/views/padding.go +++ b/internal/views/padding.go @@ -20,7 +20,7 @@ func computeMaxColumns(pads maxyPad, sortCol int, table resource.TableData) { var row int for _, rev := range table.Rows { for index, field := range rev.Fields { - if len(field) > pads[index] && isASCII(field) { + if len(field) > pads[index] { pads[index] = len([]rune(field)) } } diff --git a/internal/views/padding_test.go b/internal/views/padding_test.go index 76256418..337c7eab 100644 --- a/internal/views/padding_test.go +++ b/internal/views/padding_test.go @@ -44,7 +44,7 @@ func TestMaxColumn(t *testing.T) { }, }, 0, - maxyPad{3, 5}, + maxyPad{28, 5}, }, } diff --git a/internal/views/fu.go b/internal/views/policy.go similarity index 54% rename from internal/views/fu.go rename to internal/views/policy.go index 0371335a..e4aebaf0 100644 --- a/internal/views/fu.go +++ b/internal/views/policy.go @@ -3,7 +3,6 @@ package views import ( "context" "fmt" - "reflect" "time" "github.com/derailed/k9s/internal/resource" @@ -11,12 +10,13 @@ import ( "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...) +const policyTitle = "Policy" -type fuView struct { +var policyHeader = append(resource.Row{"NAMESPACE", "NAME", "API GROUP", "BINDING"}, rbacHeaderVerbs...) + +type policyView struct { *tableView current igniter @@ -26,10 +26,10 @@ type fuView struct { cache resource.RowEvents } -func newFuView(app *appView, subject, name string) *fuView { - v := fuView{} +func newPolicyView(app *appView, subject, name string) *policyView { + v := policyView{} { - v.subjectKind, v.subjectName = v.mapSubject(subject), name + v.subjectKind, v.subjectName = mapSubject(subject), name v.tableView = newTableView(app, v.getTitle()) v.colorerFn = rbacColorer v.current = app.content.GetPrimitive("main").(igniter) @@ -40,16 +40,16 @@ func newFuView(app *appView, subject, name string) *fuView { } // Init the view. -func (v *fuView) init(_ context.Context, ns string) { - v.sortCol = sortColumn{1, len(rbacHeader), true} +func (v *policyView) init(c context.Context, ns string) { + v.sortCol = sortColumn{1, len(rbacHeader), false} - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(c) v.cancel = cancel go func(ctx context.Context) { for { select { case <-ctx.Done(): - log.Debug().Msg("FU Watch bailing out!") + log.Debug().Msgf("Policy %s:%s Watch bailing out!", v.subjectKind, v.subjectName) return case <-time.After(time.Duration(v.app.config.K9s.RefreshRate) * time.Second): v.refresh() @@ -62,7 +62,7 @@ func (v *fuView) init(_ context.Context, ns string) { v.app.SetFocus(v) } -func (v *fuView) bindKeys() { +func (v *policyView) bindKeys() { delete(v.actions, KeyShiftA) v.actions[tcell.KeyEscape] = newKeyAction("Reset", v.resetCmd, false) @@ -75,11 +75,11 @@ func (v *fuView) bindKeys() { 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 *policyView) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, policyTitle, v.subjectKind+":"+v.subjectName) } -func (v *fuView) refresh() { +func (v *policyView) refresh() { data, err := v.reconcile() if err != nil { log.Error().Err(err).Msgf("Unable to reconcile for %s:%s", v.subjectKind, v.subjectName) @@ -87,7 +87,7 @@ func (v *fuView) refresh() { v.update(data) } -func (v *fuView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *policyView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.empty() { v.cmdBuff.reset() return nil @@ -96,31 +96,36 @@ func (v *fuView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { return v.backCmd(evt) } -func (v *fuView) backCmd(evt *tcell.EventKey) *tcell.EventKey { +func (v *policyView) 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 } + v.app.inject(v.current) + return nil } -func (v *fuView) hints() hints { +func (v *policyView) hints() hints { return v.actions.toHints() } -func (v *fuView) reconcile() (resource.TableData, error) { +func (v *policyView) reconcile() (resource.TableData, error) { + var table resource.TableData + + log.Debug().Msgf(">>> Policy %s-%s", v.subjectKind, v.subjectName) + 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] + return table, errs[0] } nevts, errs := v.namespacePolicies() @@ -128,66 +133,29 @@ func (v *fuView) reconcile() (resource.TableData, error) { for _, err := range errs { log.Debug().Err(err).Msg("Unable to find cluster policies") } - return resource.TableData{}, errs[0] + return table, 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 + return buildTable(v, evts), nil } -func (v *fuView) clusterPolicies() (resource.RowEvents, []error) { +func (v *policyView) header() resource.Row { + return policyHeader +} + +func (v *policyView) getCache() resource.RowEvents { + return v.cache +} + +func (v *policyView) setCache(evts resource.RowEvents) { + v.cache = evts +} + +func (v *policyView) clusterPolicies() (resource.RowEvents, []error) { var errs []error evts := make(resource.RowEvents) @@ -196,22 +164,22 @@ func (v *fuView) clusterPolicies() (resource.RowEvents, []error) { return evts, errs } - var roles []string - for _, c := range crbs.Items { - for _, s := range c.Subjects { + var rr []string + for _, crb := range crbs.Items { + for _, s := range crb.Subjects { if s.Kind == v.subjectKind && s.Name == v.subjectName { - roles = append(roles, c.RoleRef.Name) + rr = append(rr, crb.RoleRef.Name) } } } + log.Debug().Msgf("Matching clusterroles: %#v", rr) - for _, r := range roles { - cr, err := v.app.conn().DialOrDie().Rbac().ClusterRoles().Get(r, metav1.GetOptions{}) + for _, r := range rr { + role, 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 { + for k, v := range v.parseRules("*", "CR:"+r, role.Rules) { evts[k] = v } } @@ -219,7 +187,11 @@ func (v *fuView) clusterPolicies() (resource.RowEvents, []error) { return evts, errs } -func (v *fuView) namespacePolicies() (resource.RowEvents, []error) { +type namespacedRole struct { + ns, role string +} + +func (v *policyView) namespacePolicies() (resource.RowEvents, []error) { var errs []error evts := make(resource.RowEvents) @@ -228,25 +200,22 @@ func (v *fuView) namespacePolicies() (resource.RowEvents, []error) { return evts, errs } - type nsRole struct { - ns, role string - } - var roles []nsRole + var rr []namespacedRole 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}) + rr = append(rr, namespacedRole{rb.Namespace, rb.RoleRef.Name}) } } } + log.Debug().Msgf("Matching roles: %#v", rr) - for _, r := range roles { + for _, r := range rr { 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 { + for k, v := range v.parseRules(r.ns, "RO:"+r.role, cr.Rules) { evts[k] = v } } @@ -254,11 +223,11 @@ func (v *fuView) namespacePolicies() (resource.RowEvents, []error) { return evts, errs } -func (v *fuView) namespace(ns, n string) string { +func namespacedName(ns, n string) string { return ns + "/" + n } -func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { +func (v *policyView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resource.RowEvents { m := make(resource.RowEvents, len(rules)) for _, r := range rules { for _, grp := range r.APIGroups { @@ -269,11 +238,11 @@ func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resou } for _, na := range r.ResourceNames { n := k + "/" + na - m[v.namespace(ns, n)] = &resource.RowEvent{ + m[namespacedName(ns, n)] = &resource.RowEvent{ Fields: v.prepRow(ns, n, grp, binding, r.Verbs), } } - m[v.namespace(ns, k)] = &resource.RowEvent{ + m[namespacedName(ns, k)] = &resource.RowEvent{ Fields: v.prepRow(ns, k, grp, binding, r.Verbs), } } @@ -282,7 +251,7 @@ func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resou if nres[0] != '/' { nres = "/" + nres } - m[v.namespace(ns, nres)] = &resource.RowEvent{ + m[namespacedName(ns, nres)] = &resource.RowEvent{ Fields: v.prepRow(ns, nres, resource.NAValue, binding, r.Verbs), } } @@ -291,13 +260,7 @@ func (v *fuView) parseRules(ns, binding string, rules []rbacv1.PolicyRule) resou return m } -func (v *fuView) prepRow(ns, res, grp, binding string, verbs []string) resource.Row { - const ( - nameLen = 60 - groupLen = 30 - nsLen = 30 - ) - +func (v *policyView) prepRow(ns, res, grp, binding string, verbs []string) resource.Row { if grp != resource.NAValue { grp = toGroup(grp) } @@ -305,14 +268,14 @@ func (v *fuView) prepRow(ns, res, grp, binding string, verbs []string) resource. 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)) +func (*policyView) makeRow(ns, res, group, binding string, verbs []string) resource.Row { + r := make(resource.Row, 0, len(policyHeader)) r = append(r, ns, res, group, binding) return append(r, verbs...) } -func (v *fuView) mapSubject(subject string) string { +func mapSubject(subject string) string { switch subject { case "g": return "Group" diff --git a/internal/views/rbac.go b/internal/views/rbac.go index bc783714..09838ed3 100644 --- a/internal/views/rbac.go +++ b/internal/views/rbac.go @@ -3,7 +3,6 @@ package views import ( "context" "fmt" - "reflect" "strings" "time" @@ -12,7 +11,6 @@ import ( "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 ( @@ -50,7 +48,8 @@ var ( "DELETE", "EXTRAS", } - rbacHeader = append(resource.Row{"NAME", "GROUP"}, rbacHeaderVerbs...) + + rbacHeader = append(resource.Row{"NAME", "API GROUP"}, rbacHeaderVerbs...) k8sVerbs = []string{ "get", @@ -93,10 +92,10 @@ func newRBACView(app *appView, ns, name string, kind roleKind) *rbacView { } // Init the view. -func (v *rbacView) init(_ context.Context, ns string) { +func (v *rbacView) init(c context.Context, ns string) { v.sortCol = sortColumn{1, len(rbacHeader), true} - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(c) v.cancel = cancel go func(ctx context.Context) { for { @@ -122,16 +121,11 @@ func (v *rbacView) bindKeys() { 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) + v.actions[KeyShiftO] = newKeyAction("Sort APIGroup", v.sortColCmd(1), true) } func (v *rbacView) getTitle() string { - title := "ClusterRole" - if v.roleType == role { - title = "Role" - } - - return fmt.Sprintf(rbacTitleFmt, title, v.roleName) + return fmt.Sprintf(rbacTitleFmt, rbacTitle, v.roleName) } func (v *rbacView) hints() hints { @@ -139,6 +133,7 @@ func (v *rbacView) hints() hints { } func (v *rbacView) refresh() { + log.Debug().Msg("RBAC Watching...") 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) @@ -162,69 +157,35 @@ func (v *rbacView) backCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.isActive() { v.cmdBuff.reset() - } else { - v.app.prevCmd(evt) + return nil } + v.app.inject(v.current) + return nil } func (v *rbacView) reconcile(ns, name string, kind roleKind) (resource.TableData, error) { + var table resource.TableData + evts, err := v.rowEvents(ns, name, kind) if err != nil { - return resource.TableData{}, err + return table, err } - data := resource.TableData{ - Header: rbacHeader, - Rows: make(resource.RowEvents, len(evts)), - Namespace: resource.NotNamespaced, - } + return buildTable(v, evts), nil +} - 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 +func (v *rbacView) header() resource.Row { + return rbacHeader +} - return data, nil - } +func (v *rbacView) getCache() resource.RowEvents { + return v.cache +} - 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 - } - } +func (v *rbacView) setCache(evts resource.RowEvents) { 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) { @@ -260,6 +221,7 @@ func (v *rbacView) clusterPolicies(name string) (resource.RowEvents, error) { func (v *rbacView) namespacedPolicies(path string) (resource.RowEvents, error) { ns, na := namespaced(path) + log.Debug().Msgf("!!!! YO %s %s", ns, na) cr, err := v.app.conn().DialOrDie().Rbac().Roles(ns).Get(na, metav1.GetOptions{}) if err != nil { return nil, err diff --git a/internal/views/registrar.go b/internal/views/registrar.go index 4b368e12..69621669 100644 --- a/internal/views/registrar.go +++ b/internal/views/registrar.go @@ -4,6 +4,8 @@ import ( "github.com/derailed/k9s/internal/k8s" "github.com/derailed/k9s/internal/resource" "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type ( @@ -27,7 +29,7 @@ type ( ) func helpCmds(c k8s.Connection) map[string]resCmd { - cmdMap := resourceViews() + cmdMap := resourceViews(c) cmds := make(map[string]resCmd, len(cmdMap)) for k, v := range cmdMap { cmds[k] = v @@ -81,12 +83,35 @@ func showRBAC(app *appView, ns, resource, selection string) { 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{ +func showClusterRole(app *appView, ns, resource, selection string) { + crb, err := app.conn().DialOrDie().Rbac().ClusterRoleBindings().Get(selection, metav1.GetOptions{}) + if err != nil { + app.flash(flashErr, "Unable to retrieve crb", selection) + return + } + app.inject(newRBACView(app, ns, crb.RoleRef.Name, clusterRole)) +} + +func showRole(app *appView, _, resource, selection string) { + ns, n := namespaced(selection) + rb, err := app.conn().DialOrDie().Rbac().RoleBindings(ns).Get(n, metav1.GetOptions{}) + if err != nil { + app.flash(flashErr, "Unable to retrieve rb", selection) + return + } + app.inject(newRBACView(app, ns, namespacedName(ns, rb.RoleRef.Name), role)) +} + +func showSAPolicy(app *appView, _, _, selection string) { + _, n := namespaced(selection) + app.inject(newPolicyView(app, mapFuSubject("ServiceAccount"), n)) +} + +func resourceViews(c k8s.Connection) map[string]resCmd { + cmds := map[string]resCmd{ "cm": { title: "ConfigMaps", api: "", @@ -101,11 +126,11 @@ func resourceViews() map[string]resCmd { enterFn: showRBAC, }, "crb": { - title: "ClusterRoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewClusterRoleBindingList, - // decorateFn: crbDecorator, + title: "ClusterRoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewClusterRoleBindingList, + enterFn: showClusterRole, }, "crd": { title: "CustomResourceDefinitions", @@ -153,12 +178,6 @@ func resourceViews() map[string]resCmd { listFn: resource.NewEventList, colorerFn: evColorer, }, - "hpa": { - title: "HorizontalPodAutoscalers", - api: "autoscaling", - viewFn: newResourceView, - listFn: resource.NewHPAList, - }, "ing": { title: "Ingress", api: "extensions", @@ -214,10 +233,11 @@ func resourceViews() map[string]resCmd { colorerFn: pvcColorer, }, "rb": { - title: "RoleBindings", - api: "rbac.authorization.k8s.io", - viewFn: newResourceView, - listFn: resource.NewRoleBindingList, + title: "RoleBindings", + api: "rbac.authorization.k8s.io", + viewFn: newResourceView, + listFn: resource.NewRoleBindingList, + enterFn: showRole, }, "rc": { title: "ReplicationControllers", @@ -241,10 +261,11 @@ func resourceViews() map[string]resCmd { colorerFn: rsColorer, }, "sa": { - title: "ServiceAccounts", - api: "", - viewFn: newResourceView, - listFn: resource.NewServiceAccountList, + title: "ServiceAccounts", + api: "", + viewFn: newResourceView, + listFn: resource.NewServiceAccountList, + enterFn: showSAPolicy, }, "sec": { title: "Secrets", @@ -264,7 +285,52 @@ func resourceViews() map[string]resCmd { api: "", viewFn: newResourceView, listFn: resource.NewServiceList, - // decorateFn: svcDecorator, + }, + "usr": { + title: "Users", + api: "", + viewFn: newSubjectView, + }, + "grp": { + title: "Groups", + api: "", + viewFn: newSubjectView, }, } + + rev, ok := c.SupportsRes("autoscaling", []string{"v1", "v2beta1", "v2beta2"}) + if !ok { + log.Warn().Msg("HPA are not supported on this cluster") + } + + switch rev { + case "v1": + log.Debug().Msg("Using HPA V1!") + cmds["hpa"] = resCmd{ + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAV1List, + } + case "v2beta1": + log.Debug().Msg("Using HPA V2Beta1!") + cmds["hpa"] = resCmd{ + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAV2Beta1List, + } + case "v2beta2": + log.Debug().Msg("Using HPA V2Beta2!") + cmds["hpa"] = resCmd{ + title: "HorizontalPodAutoscalers", + api: "autoscaling", + viewFn: newResourceView, + listFn: resource.NewHPAList, + } + default: + log.Panic().Msgf("K9s does not currently support HPA version %s", rev) + } + + return cmds } diff --git a/internal/views/resource.go b/internal/views/resource.go index df9c0f63..d9bcb21f 100644 --- a/internal/views/resource.go +++ b/internal/views/resource.go @@ -144,9 +144,10 @@ func (v *resourceView) setDecorateFn(f decorateFn) { // 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) + } else { + v.defaultEnter(v.app, v.list.GetNamespace(), v.list.GetName(), v.selectedItem) } return nil } @@ -187,17 +188,15 @@ func (v *resourceView) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -func (v *resourceView) describeCmd(evt *tcell.EventKey) *tcell.EventKey { - if !v.rowSelected() { - return evt - } +func (v *resourceView) defaultEnter(app *appView, ns, resource, selection string) { sel := v.getSelectedItem() raw, err := v.list.Resource().Describe(v.title, sel, v.app.flags) if err != nil { v.app.flash(flashErr, err.Error()) log.Warn().Msgf("Describe %v", err.Error()) - return evt + return } + details := v.GetPrimitive("details").(*detailsView) { details.setCategory("Describe") @@ -207,6 +206,15 @@ func (v *resourceView) describeCmd(evt *tcell.EventKey) *tcell.EventKey { details.ScrollToBeginning() } v.switchPage("details") +} + +func (v *resourceView) describeCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.rowSelected() { + return evt + } + + v.defaultEnter(v.app, v.list.GetNamespace(), v.list.GetName(), v.selectedItem) + return nil } @@ -383,7 +391,7 @@ func (v *resourceView) refreshActions() { } } - aa[tcell.KeyEnter] = newKeyAction("Enter", v.enterCmd, true) + aa[tcell.KeyEnter] = newKeyAction("Enter", v.enterCmd, false) aa[tcell.KeyCtrlR] = newKeyAction("Refresh", v.refreshCmd, false) aa[KeyHelp] = newKeyAction("Help", v.app.noopCmd, false) diff --git a/internal/views/subject.go b/internal/views/subject.go new file mode 100644 index 00000000..8288f40b --- /dev/null +++ b/internal/views/subject.go @@ -0,0 +1,320 @@ +package views + +import ( + "context" + "fmt" + "reflect" + "strings" + "time" + + "github.com/derailed/k9s/internal/resource" + "github.com/gdamore/tcell" + "github.com/rs/zerolog/log" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +const subjectTitleFmt = " [aqua::b]%s([fuchsia::b]%s[aqua::-])" + +var subjectHeader = resource.Row{"NAME", "KIND", "FIRST LOCATION"} + +type ( + cachedEventer interface { + header() resource.Row + getCache() resource.RowEvents + setCache(resource.RowEvents) + } + + subjectView struct { + *tableView + + current igniter + cancel context.CancelFunc + subjectKind string + selectedItem string + cache resource.RowEvents + } +) + +func newSubjectView(ns string, app *appView, list resource.List) resourceViewer { + v := subjectView{} + { + v.tableView = newTableView(app, v.getTitle()) + v.tableView.SetSelectionChangedFunc(v.selChanged) + v.colorerFn = rbacColorer + v.bindKeys() + } + + if current, ok := app.content.GetPrimitive("main").(igniter); ok { + v.current = current + } else { + v.current = &v + } + + return &v +} + +// Init the view. +func (v *subjectView) init(c context.Context, _ string) { + if v.cancel != nil { + v.cancel() + } + + v.sortCol = sortColumn{1, len(rbacHeader), true} + v.subjectKind = mapCmdSubject(v.app.config.K9s.ActiveCluster().View.Active) + v.baseTitle = v.getTitle() + + ctx, cancel := context.WithCancel(c) + v.cancel = cancel + go func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Debug().Msgf("Subject:%s Watch bailing out!", v.subjectKind) + 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 *subjectView) setColorerFn(f colorerFn) {} +func (v *subjectView) setEnterFn(f enterFn) {} +func (v *subjectView) setDecorateFn(f decorateFn) {} + +func (v *subjectView) bindKeys() { + // No time data or ns + delete(v.actions, KeyShiftA) + delete(v.actions, KeyShiftP) + + v.actions[tcell.KeyEnter] = newKeyAction("RBAC", v.rbackCmd, true) + 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[KeyShiftK] = newKeyAction("Sort Kind", v.sortColCmd(1), true) +} + +func (v *subjectView) getTitle() string { + return fmt.Sprintf(rbacTitleFmt, "Subject", v.subjectKind) +} + +func (v *subjectView) selChanged(r, _ int) { + if r == 0 { + v.selectedItem = "" + return + } + v.selectedItem = strings.TrimSpace(v.GetCell(r, 0).Text) +} + +func (v *subjectView) SetSubject(s string) { + v.subjectKind = mapSubject(s) +} + +func (v *subjectView) refresh() { + data, err := v.reconcile() + if err != nil { + log.Error().Err(err).Msgf("Unable to reconcile for %s", v.subjectKind) + } + v.update(data) +} + +func (v *subjectView) rbackCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.selectedItem == "" { + return evt + } + + if v.cancel != nil { + v.cancel() + } + + _, n := namespaced(v.selectedItem) + v.app.inject(newPolicyView(v.app, mapFuSubject(v.subjectKind), n)) + + return nil +} + +func (v *subjectView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { + if !v.cmdBuff.empty() { + v.cmdBuff.reset() + return nil + } + + return v.backCmd(evt) +} + +func (v *subjectView) backCmd(evt *tcell.EventKey) *tcell.EventKey { + if v.cancel != nil { + v.cancel() + } + + if v.cmdBuff.isActive() { + v.cmdBuff.reset() + return nil + } + + v.app.inject(v.current) + + return nil +} + +func (v *subjectView) hints() hints { + return v.actions.toHints() +} + +func (v *subjectView) reconcile() (resource.TableData, error) { + var table resource.TableData + + evts, err := v.clusterSubjects() + if err != nil { + return table, err + } + log.Debug().Msgf("Cluster evts %d", len(evts)) + + nevts, err := v.namespacedSubjects() + if err != nil { + return table, err + } + log.Debug().Msgf("NS evts %d", len(nevts)) + for k, v := range nevts { + evts[k] = v + } + + return buildTable(v, evts), nil +} + +func (v *subjectView) header() resource.Row { + return subjectHeader +} + +func (v *subjectView) getCache() resource.RowEvents { + return v.cache +} + +func (v *subjectView) setCache(evts resource.RowEvents) { + v.cache = evts +} + +func buildTable(v cachedEventer, evts resource.RowEvents) resource.TableData { + table := resource.TableData{ + Header: v.header(), + Rows: make(resource.RowEvents, len(evts)), + Namespace: "*", + } + + noDeltas := make(resource.Row, len(v.header())) + cache := v.getCache() + if len(cache) == 0 { + for k, ev := range evts { + ev.Action = resource.New + ev.Deltas = noDeltas + table.Rows[k] = ev + } + v.setCache(evts) + return table + } + + for k, ev := range evts { + table.Rows[k] = ev + + newr := ev.Fields + if _, ok := cache[k]; !ok { + ev.Action, ev.Deltas = watch.Added, noDeltas + continue + } + oldr := 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 + } + } + + for k := range evts { + if _, ok := table.Rows[k]; !ok { + delete(evts, k) + } + } + v.setCache(evts) + + return table +} + +func (v *subjectView) clusterSubjects() (resource.RowEvents, error) { + crbs, err := v.app.conn().DialOrDie().Rbac().ClusterRoleBindings().List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + evts := make(resource.RowEvents, len(crbs.Items)) + for _, crb := range crbs.Items { + for _, s := range crb.Subjects { + if s.Kind == v.subjectKind { + evts[s.Name] = &resource.RowEvent{ + Fields: v.makeRow("*", s.Name, "ClusterRoleBinding", crb.Name), + } + } + } + } + + return evts, nil +} + +func (v *subjectView) makeRow(_, subject, kind, loc string) resource.Row { + return resource.Row{subject, kind, loc} +} + +func (v *subjectView) namespacedSubjects() (resource.RowEvents, error) { + rbs, err := v.app.conn().DialOrDie().Rbac().RoleBindings("").List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + evts := make(resource.RowEvents, len(rbs.Items)) + for _, rb := range rbs.Items { + for _, s := range rb.Subjects { + if s.Kind == v.subjectKind { + evts[s.Name] = &resource.RowEvent{ + Fields: v.makeRow(rb.Namespace, s.Name, "RoleBinding", rb.Name), + } + } + } + } + + return evts, nil +} + +func mapCmdSubject(subject string) string { + switch subject { + case "grp": + return "Group" + case "sas": + return "ServiceAccount" + default: + return "User" + } +} + +func mapFuSubject(subject string) string { + switch subject { + case "Group": + return "g" + case "ServiceAccount": + return "s" + default: + return "u" + } +} diff --git a/internal/views/table.go b/internal/views/table.go index eb2aa83c..c3fdb444 100644 --- a/internal/views/table.go +++ b/internal/views/table.go @@ -298,10 +298,10 @@ func (v *tableView) sortIndicator(index int, name string) string { func (v *tableView) doUpdate(data resource.TableData) { v.currentNS = data.Namespace - if v.currentNS == resource.AllNamespaces || v.currentNS == "*" { + if v.currentNS == resource.AllNamespaces && v.currentNS != "*" { v.actions[KeyShiftP] = newKeyAction("Sort Namespace", v.sortNamespaceCmd, true) } else { - delete(v.actions, KeyShiftS) + delete(v.actions, KeyShiftP) } v.Clear() @@ -378,7 +378,7 @@ func (v *tableView) addHeaderCell(col int, name string, pads maxyPad) { 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]) + pField = pad(deltas(delta, field), pads[col]+5) } else { pField = deltas(delta, field) } @@ -420,7 +420,7 @@ func (v *tableView) resetTitle() { rc-- } switch v.currentNS { - case resource.NotNamespaced: + case resource.NotNamespaced, "*": title = fmt.Sprintf(titleFmt, v.baseTitle, rc) default: ns := v.currentNS