diff --git a/README.md b/README.md index 2bc7138b54..b99f08e795 100644 --- a/README.md +++ b/README.md @@ -647,6 +647,52 @@ views: --- +## Custom Resource Links + +You can change the behaviour of an `` keypress on resources defined by yourself. K9S will prepare selectors using your configuration and show you a browser with selected resources. +The configuration of custom resource links leverages GVR (Group/Version/Resource) to configure the associated links. If no GVR is found for a view the default rendering will take over (ie what we have now). + +There are two types of selectors: `fieldSelector` and `labelSelector`. Both of them are optional and can be used together. +The `fieldSelector` is used to filter resources by a fields, and the `labelSelector` is used to filter resources by a labels. +The value of the `fieldSelector` and `labelSelector` is a JSONPath expression executed on the selected resource. + +For example, you can define a custom resource link for ExternalSecrets - `` keypress on ExternalSecrets will show your browser with target secret. + +```yaml +k9s: + customResourceLinks: + external-secrets.io/v1beta1/externalsecrets: + target: v1/secrets + fieldSelector: + # key defines target field to filter + # value defines source field to filter (JSONPath executed on selected resource) + metadata.name: .spec.target.name +``` + +Or if you're using `cluster.k8s.io` resources, you can define a custom resource link to show pods on the machine: + +```yaml +k9s: + customResourceLinks: + cluster.k8s.io/v1alpha1/machines: + target: v1/pods + fieldSelector: + spec.nodeName: .metadata.name +``` + +If you're running your own k8s operator which is spawning jobs for you, you can select pods from your custom resources: + +```yaml +k9s: + customResourceLinks: + any.io/v1/customresources: + target: v1/pods + labelSelector: + batch.kubernetes.io/job-name: .metadata.name +``` + +--- + ## Plugins K9s allows you to extend your command line and tooling by defining your very own cluster commands via plugins. K9s will look at `$XDG_CONFIG_HOME/k9s/plugins.yaml` to locate all available plugins. @@ -966,6 +1012,7 @@ k9s: memory: critical: 90 warn: 70 + customResourceLinks: {} ``` ```yaml diff --git a/internal/config/json/schemas/k9s.json b/internal/config/json/schemas/k9s.json index e217bbec29..5fe7cc4dc3 100644 --- a/internal/config/json/schemas/k9s.json +++ b/internal/config/json/schemas/k9s.json @@ -125,6 +125,21 @@ } } } + }, + "customResourceLinks":{ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "target": {"type": "string"}, + "labelSelector": { + "type": "object", + "additionalItems": { + "type": "string" + } + } + } + } } } } diff --git a/internal/config/k9s.go b/internal/config/k9s.go index 953fb7ca3b..e046dcbdbd 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -18,19 +18,20 @@ import ( // K9s tracks K9s configuration options. type K9s struct { - LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"` - ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"` - RefreshRate int `json:"refreshRate" yaml:"refreshRate"` - MaxConnRetry int `json:"maxConnRetry" yaml:"maxConnRetry"` - ReadOnly bool `json:"readOnly" yaml:"readOnly"` - NoExitOnCtrlC bool `json:"noExitOnCtrlC" yaml:"noExitOnCtrlC"` - UI UI `json:"ui" yaml:"ui"` - SkipLatestRevCheck bool `json:"skipLatestRevCheck" yaml:"skipLatestRevCheck"` - DisablePodCounting bool `json:"disablePodCounting" yaml:"disablePodCounting"` - ShellPod ShellPod `json:"shellPod" yaml:"shellPod"` - ImageScans ImageScans `json:"imageScans" yaml:"imageScans"` - Logger Logger `json:"logger" yaml:"logger"` - Thresholds Threshold `json:"thresholds" yaml:"thresholds"` + LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"` + ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"` + RefreshRate int `json:"refreshRate" yaml:"refreshRate"` + MaxConnRetry int `json:"maxConnRetry" yaml:"maxConnRetry"` + ReadOnly bool `json:"readOnly" yaml:"readOnly"` + NoExitOnCtrlC bool `json:"noExitOnCtrlC" yaml:"noExitOnCtrlC"` + UI UI `json:"ui" yaml:"ui"` + SkipLatestRevCheck bool `json:"skipLatestRevCheck" yaml:"skipLatestRevCheck"` + DisablePodCounting bool `json:"disablePodCounting" yaml:"disablePodCounting"` + ShellPod ShellPod `json:"shellPod" yaml:"shellPod"` + ImageScans ImageScans `json:"imageScans" yaml:"imageScans"` + Logger Logger `json:"logger" yaml:"logger"` + Thresholds Threshold `json:"thresholds" yaml:"thresholds"` + CustomResourceLinks CustomResourceLinks `json:"customResourceLinks" yaml:"customResourceLinks"` manualRefreshRate int manualHeadless *bool manualLogoless *bool @@ -49,16 +50,17 @@ type K9s struct { // NewK9s create a new K9s configuration. func NewK9s(conn client.Connection, ks data.KubeSettings) *K9s { return &K9s{ - RefreshRate: defaultRefreshRate, - MaxConnRetry: defaultMaxConnRetry, - ScreenDumpDir: AppDumpsDir, - Logger: NewLogger(), - Thresholds: NewThreshold(), - ShellPod: NewShellPod(), - ImageScans: NewImageScans(), - dir: data.NewDir(AppContextsDir), - conn: conn, - ks: ks, + RefreshRate: defaultRefreshRate, + MaxConnRetry: defaultMaxConnRetry, + ScreenDumpDir: AppDumpsDir, + Logger: NewLogger(), + Thresholds: NewThreshold(), + ShellPod: NewShellPod(), + ImageScans: NewImageScans(), + CustomResourceLinks: NewCustomResourceLinks(), + dir: data.NewDir(AppContextsDir), + conn: conn, + ks: ks, } } @@ -108,6 +110,7 @@ func (k *K9s) Merge(k1 *K9s) { if k1.Thresholds != nil { k.Thresholds = k1.Thresholds } + k.CustomResourceLinks = k1.CustomResourceLinks } // AppScreenDumpDir fetch screen dumps dir. diff --git a/internal/config/resource_link.go b/internal/config/resource_link.go new file mode 100644 index 0000000000..a96a512501 --- /dev/null +++ b/internal/config/resource_link.go @@ -0,0 +1,17 @@ +package config + +type CustomResourceLinks map[string]*CustomResourceLink + +func NewCustomResourceLinks() CustomResourceLinks { + return CustomResourceLinks{} +} + +// CustomResourceLink tracks K9s CustomResourceLink configuration. +type CustomResourceLink struct { + // Target represents the target GVR to open when activating a custom resource link. + Target string `yaml:"target"` + // LabelSelector defines keys (=target label) and values (=json path) to extract from the current resource. + LabelSelector map[string]string `yaml:"labelSelector,omitempty"` + // FieldSelector defines keys (=target field) and values (=json path) to extract from the current resource. + FieldSelector map[string]string `yaml:"fieldSelector,omitempty"` +} diff --git a/internal/dao/resource_links.go b/internal/dao/resource_links.go new file mode 100644 index 0000000000..ad46ea5336 --- /dev/null +++ b/internal/dao/resource_links.go @@ -0,0 +1,101 @@ +package dao + +import ( + "fmt" + "github.com/derailed/k9s/internal/config" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/client-go/util/jsonpath" + "reflect" + "strings" +) + +// extractPathFromObject extracts a json path from a given object. +func extractPathFromObject(o runtime.Object, path string) ([]string, error) { + var err error + parser := jsonpath.New("accessor").AllowMissingKeys(true) + parser.EnableJSONOutput(true) + fullPath := fmt.Sprintf("{%s}", path) + if err := parser.Parse(fullPath); err != nil { + return nil, err + } + log.Debug().Msgf("Prepared JSONPath %s.", fullPath) + + var results [][]reflect.Value + if unstructured, ok := o.(runtime.Unstructured); ok { + results, err = parser.FindResults(unstructured.UnstructuredContent()) + } else { + results, err = parser.FindResults(reflect.ValueOf(o).Elem().Interface()) + } + + if err != nil { + return nil, err + } + + log.Debug().Msgf("Results extracted %s.", results) + + if len(results) != 1 { + return nil, nil + } + + var values = make([]string, 0) + for arrIx := range results { + for valIx := range results[arrIx] { + values = append(values, fmt.Sprint(results[arrIx][valIx].Interface())) + } + } + return values, nil +} + +// SelectorsForLink builds label and field selectors from a given object based on a custom resource link. +func SelectorsForLink(link *config.CustomResourceLink, o runtime.Object) (string, string, error) { + var labelSelector = labels.Everything() + var fieldSelector fields.Selector + + for target, source := range link.LabelSelector { + values, err := extractPathFromObject(o, source) + switch { + case err != nil: + return "", "", err + case values == nil || len(values) != 1: + continue + } + log.Debug().Msgf("Extracted values for label selector %s: %+v.", target, values) + + req, err := labels.NewRequirement(target, selection.Equals, values) + if err != nil { + return "", "", err + } + labelSelector = labelSelector.Add(*req) + } + + for target, source := range link.FieldSelector { + values, err := extractPathFromObject(o, source) + switch { + case err != nil: + return "", "", err + case values == nil: + continue + } + log.Debug().Msgf("Extracted values for field selector %s: %+v.", target, values) + + sel := fields.OneTermEqualSelector(target, strings.Join(values, ",")) + if fieldSelector == nil { + fieldSelector = sel + continue + } + fieldSelector = fields.AndSelectors( + fieldSelector, + sel, + ) + } + fsel := "" + if fieldSelector != nil { + fsel = fieldSelector.String() + } + + return labelSelector.String(), fsel, nil +} diff --git a/internal/dao/resource_links_test.go b/internal/dao/resource_links_test.go new file mode 100644 index 0000000000..6eb2c8371b --- /dev/null +++ b/internal/dao/resource_links_test.go @@ -0,0 +1,76 @@ +package dao + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "testing" +) + +type obj struct { + Value string `json:"value,omitempty"` + Child struct { + Nested string `json:"nested,omitempty"` + } `json:"child"` +} + +func (o obj) GetObjectKind() schema.ObjectKind { + return nil +} + +func (o obj) DeepCopyObject() runtime.Object { + return nil +} + +func TestExtractPathFromObject(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + path string + want []string + }{ + { + name: "empty path", + obj: &obj{ + Value: "", + Child: struct { + Nested string `json:"nested,omitempty"` + }{}, + }, + path: ".nonexistent", + want: []string{}, + }, + { + name: "object with path", + obj: &obj{ + Value: "foo", + Child: struct { + Nested string `json:"nested,omitempty"` + }{}, + }, + path: ".value", + want: []string{"foo"}, + }, + { + name: "nested with path", + obj: &obj{ + Value: "foo", + Child: struct { + Nested string `json:"nested,omitempty"` + }{ + Nested: "bar", + }, + }, + path: ".child.nested", + want: []string{"bar"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractPathFromObject(tt.obj, tt.path) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/view/browser.go b/internal/view/browser.go index 7f0876d1ab..a4719d8c46 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -6,6 +6,8 @@ package view import ( "context" "fmt" + "github.com/derailed/k9s/internal/view/cmd" + "k8s.io/apimachinery/pkg/labels" "sort" "strconv" "strings" @@ -92,6 +94,11 @@ func (b *Browser) Init(ctx context.Context) error { return err } + if target, found := b.App().Config.K9s.CustomResourceLinks[b.GVR().String()]; found { + log.Info().Msgf("Enabling custom resource link from %s to %s", b.GVR().String(), target.Target) + b.enterFn = b.showLinkedCustomResources + } + b.setNamespace(ns) row, _ := b.GetSelection() if row == 0 && b.GetRowCount() > 0 { @@ -644,3 +651,46 @@ func (b *Browser) resourceDelete(selections []string, msg string) { } dialog.ShowDelete(b.app.Styles.Dialog(), b.app.Content.Pages, msg, okFn, func() {}) } + +func (b *Browser) showLinkedCustomResources(app *App, tabular ui.Tabular, gvr client.GVR, path string) { + gvrValue := gvr.String() + + o, err := app.factory.Get(gvrValue, path, true, labels.Everything()) + if err != nil { + app.Flash().Err(err) + return + } + + resourceLink, ok := app.Config.K9s.CustomResourceLinks[gvrValue] + + if !ok { + b.app.Flash().Errf("Custom resource link for resource %s not found", gvrValue) + return + } + + lsel, fsel, err := dao.SelectorsForLink(resourceLink, o) + if err != nil { + app.Flash().Err(err) + return + } + + // new browser for linked resource + browser := NewOwnerExtender(NewBrowser(client.NewGVR(resourceLink.Target))) + browser.SetLabelFilter(cmd.ToLabels(lsel)) + browser.SetContextFn(func(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, internal.KeyPath, path) + + if lsel != labels.Everything().String() { + ctx = context.WithValue(ctx, internal.KeyLabels, lsel) + } + + if fsel != "" { + ctx = context.WithValue(ctx, internal.KeyFields, fsel) + } + return ctx + }) + + if err := app.inject(browser, false); err != nil { + app.Flash().Err(err) + } +}