Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom resource links #2562

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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