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 Oct 5, 2024
1 parent 6e0e416 commit df9e84e
Show file tree
Hide file tree
Showing 7 changed files with 332 additions and 23 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,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 @@ -966,6 +1012,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 @@ -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
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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.
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"`
}
101 changes: 101 additions & 0 deletions internal/dao/resource_links.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions internal/dao/resource_links_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading

0 comments on commit df9e84e

Please sign in to comment.