Skip to content

Commit

Permalink
feat: custom resource links
Browse files Browse the repository at this point in the history
  • Loading branch information
thejoeejoee committed Feb 24, 2024
1 parent 93cad8c commit a50f25c
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 24 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,52 @@ views:

---

## Custom Resource Links

You can change the behaviour of an `<ENTER>` 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 - `<ENTER>` 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.
Expand Down Expand Up @@ -951,6 +997,7 @@ k9s:
memory:
critical: 90
warn: 70
customResourceLinks: {}
```

```yaml
Expand Down
15 changes: 15 additions & 0 deletions internal/config/json/schemas/k9s.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,21 @@
}
}
}
},
"customResourceLinks":{
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"target": {"type": "string"},
"labelSelector": {
"type": "object",
"additionalItems": {
"type": "string"
}
}
}
}
}
}
}
Expand Down
49 changes: 26 additions & 23 deletions internal/config/k9s.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,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 `yaml:"customResourceLinks,omitempty"`
manualRefreshRate int
manualHeadless *bool
manualLogoless *bool
Expand All @@ -46,16 +47,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,
}
}

Expand Down Expand Up @@ -102,6 +104,7 @@ func (k *K9s) Merge(k1 *K9s) {
if k1.Thresholds != nil {
k.Thresholds = k1.Thresholds
}
k.CustomResourceLinks = k1.CustomResourceLinks
}

// AppScreenDumpDir fetch screen dumps dir.
Expand Down
17 changes: 17 additions & 0 deletions internal/config/resource_link.go
Original file line number Diff line number Diff line change
@@ -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"`
}
4 changes: 3 additions & 1 deletion internal/dao/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) {
// List all Resources in a given namespace.
func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) {
labelSel, _ := ctx.Value(internal.KeyLabels).(string)
fieldSel, _ := ctx.Value(internal.KeyFields).(string)

a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName)
_, codec := t.codec()

Expand All @@ -59,7 +61,7 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) {
SetHeader("Accept", a).
Namespace(ns).
Resource(t.gvr.R()).
VersionedParams(&metav1.ListOptions{LabelSelector: labelSel}, codec).
VersionedParams(&metav1.ListOptions{LabelSelector: labelSel, FieldSelector: fieldSel}, codec).
Do(ctx).Get()
if err != nil {
return nil, err
Expand Down
138 changes: 138 additions & 0 deletions internal/view/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ package view
import (
"context"
"fmt"
"github.com/derailed/k9s/internal/view/cmd"
"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"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -79,6 +86,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 {
Expand Down Expand Up @@ -601,3 +613,129 @@ 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
}

extractFromObject := func(sourceField string) ([]string, error) {
parser := jsonpath.New("labelSelector").AllowMissingKeys(true)
parser.EnableJSONOutput(true)
fullPath := fmt.Sprintf("{%s}", sourceField)
if err := parser.Parse(fullPath); err != nil {
app.Flash().Err(err)
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
}

if err != nil {
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
}

labelSelector := labels.Everything()
for targetLabel, sourceField := range resourceLink.LabelSelector {
values, err := extractFromObject(sourceField)
if err != nil {
app.Flash().Err(err)
return
}
if values == nil {
continue
}
if len(values) != 1 {
continue
}
log.Debug().Msgf("Extracted values for label selector %s: %+v.", targetLabel, values)

req, err := labels.NewRequirement(targetLabel, selection.Equals, values)
if err != nil {
app.Flash().Err(err)
return
}
labelSelector = labelSelector.Add(*req)
}
log.Debug().Msgf("Generated labelSelector: %s", labelSelector.String())

var fieldSelector fields.Selector
for targetField, sourceField := range resourceLink.FieldSelector {
values, err := extractFromObject(sourceField)
if err != nil {
app.Flash().Err(err)
return
}
if values == nil {
continue
}
log.Debug().Msgf("Extracted values for field selector %s: %+v.", targetField, values)

sel := fields.OneTermEqualSelector(targetField, strings.Join(values, ","))
if fieldSelector == nil {
fieldSelector = sel
continue
}
fieldSelector = fields.AndSelectors(
fieldSelector,
sel,
)
}
if fieldSelector != nil {
log.Debug().Msgf("Generated fieldSelector: %s", fieldSelector.String())
}

// new browser for linked resource
browser := NewBrowser(client.NewGVR(resourceLink.Target))
browser.SetLabelFilter(cmd.ToLabels(labelSelector.String()))
browser.SetContextFn(func(ctx context.Context) context.Context {
ctx = context.WithValue(ctx, internal.KeyPath, path)
lSel := labelSelector.String()
if lSel != labels.Everything().String() {
ctx = context.WithValue(ctx, internal.KeyLabels, lSel)
}

if fieldSelector != nil {
ctx = context.WithValue(ctx, internal.KeyFields, fieldSelector.String())
}
return ctx
})

if err := app.inject(browser, false); err != nil {
app.Flash().Err(err)
}
}

0 comments on commit a50f25c

Please sign in to comment.