Rel v0.40.9 (#3205)

* update write icon

* fix#3202

* rel docs

* update linter

* lint

* test

* lint
mine
Fernand Galiana 2025-03-15 16:41:39 -06:00 committed by GitHub
parent 39eef03373
commit fc5f1907c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 559 additions and 217 deletions

View File

@ -18,7 +18,7 @@ jobs:
cache-dependency-path: go.sum
- name: Lint
uses: golangci/golangci-lint-action@v6.5.0
uses: golangci/golangci-lint-action@v6.5.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-check

View File

@ -50,7 +50,12 @@ linters-settings:
goimports:
local-prefixes: github.com/cilium/cilium
unused:
go: "1.23"
parameters-are-used: true
local-variables-are-used: true
field-writes-are-uses: true
post-statements-are-reads: true
exported-fields-are-used: true
generated-is-used: true
goheader:
values:
regexp:
@ -61,23 +66,6 @@ linters-settings:
gosec:
includes:
- G402
gomodguard:
blocked:
modules:
- github.com/miekg/dns:
recommendations:
- github.com/cilium/dns
reason: "use the cilium fork directly to avoid replace directives in go.mod, see https://github.com/cilium/cilium/pull/27582"
- gopkg.in/check.v1:
recommendations:
- testing
- github.com/stretchr/testify/assert
reason: "gocheck has been deprecated, see https://docs.cilium.io/en/latest/contributing/testing/unit/#migrating-tests-off-of-gopkg-in-check-v1"
- go.uber.org/multierr:
recommendations:
- errors
reason: "Go 1.20+ has support for combining multiple errors, see https://go.dev/doc/go1.20#errors"
sloglint:
# Enforce not mixing key-value pairs and attributes.
# https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-mixed-arguments
@ -138,7 +126,7 @@ issues:
# default is true. Enables skipping of directories:
# vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
skip-dirs-use-default: true
exclude-dirs-use-default: true
# Excluding configuration per-path, per-linter, per-text and per-source
exclude-rules:

View File

@ -11,7 +11,7 @@ DATE ?= $(shell TZ=UTC date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:
else
DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
endif
VERSION ?= v0.40.8
VERSION ?= v0.40.9
IMG_NAME := derailed/k9s
IMAGE := ${IMG_NAME}:${VERSION}

View File

@ -726,7 +726,15 @@ views:
## 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.
K9s allows you to extend your command line and tooling by defining your very own cluster commands via plugins.
Minimally we look at `$XDG_CONFIG_HOME/k9s/plugins.yaml` to locate all available plugins.
Additionally, K9s will scan the following directories for additional plugins:
* `$XDG_CONFIG_HOME/k9s/plugins`
* `$XDG_DATA_HOME/k9s/plugins`
* `$XDG_DATA_DIRS/k9s/plugins`
The plugin file content can be either a single plugin snippet, a collections of snippets or a complete plugins definition (see examples below...).
A plugin is defined as follows:
@ -760,12 +768,15 @@ K9s does provide additional environment variables for you to customize your plug
Curly braces can be used to embed an environment variable inside another string, or if the column name contains special characters. (e.g. `${NAME}-example` or `${COL-%CPU/L}`)
### Plugin Example
### Plugin Examples
This defines a plugin for viewing logs on a selected pod using `ctrl-l` as shortcut.
Define several plugins and host them in a single file. These can leave in the K9s root config so that they are available on any clusters. Additionally, you can define cluster/context specific plugins for your clusters of choice by adding clusterA/contextB/plugins.yaml file.
The following defines a plugin for viewing logs on a selected pod using `ctrl-l` as shortcut.
```yaml
# $XDG_DATA_HOME/k9s/plugins.yaml
# Define several plugins in a single file in the K9s root configuration
# $XDG_DATA_HOME/k9s/plugins.yaml
plugins:
# Defines a plugin to provide a `ctrl-l` shortcut to tail the logs while in pod view.
fred:
@ -789,6 +800,41 @@ plugins:
- $CONTEXT
```
Similarly you can define the plugin above in a directory using either a file per plugin or several plugins per files as follow...
The following defines two plugins namely fred and zorg.
```yaml
# Multiple plugins in a single file...
# Note: as of v0.40.9 you can have ad-hoc plugin dirs
# Loads plugins fred and zorg
# $XDG_DATA_HOME/k9s/plugins/misc-plugins/blee.yaml
fred:
shortCut: Shift-B
description: Bozo
scopes:
- deploy
command: bozo
zorg:
shortCut: Shift-Z
description: Pod logs
scopes:
- svc
command: zorg
```
Lastly you can define plugin snippets in their own file. The snippet will be named from the file name. In this case, we define a `bozo` plugin using a plugin snippet.
```yaml
# $XDG_DATA_HOME/k9s/plugins/schtuff/bozo.yaml
shortCut: Shift-B
description: Bozo
scopes:
- deploy
command: bozo
```
> NOTE: This is an experimental feature! Options and layout may change in future K9s releases as this feature solidifies.
---

View File

@ -0,0 +1,44 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# Release v0.40.9
## Notes
Thank you to all that contributed with flushing out issues and enhancements for 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.
Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated!
Also big thanks to all that have allocated their own time to help others on both slack and on this repo!!
As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey,
please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
## Maintenance Release!
* Refactored plugins implementation, hopefully we didn't hose them 😳
* Updated plugins docs
* Apparently when it comes to icons, I've chosen... poorly 🙀
Updated `write` icon 🔓->✍️, hopefully for the better 👀??
## Videos Are In The Can!
Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content...
* [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A)
* [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE)
* [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4)
* [Vulnerability Scans](https://youtu.be/ULkl0MsaidU)
---
## Resolved Issues
* [#3202](https://github.com/derailed/k9s/issues/3202) 0.40.8 breaks plugins loading
---
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -28,6 +28,7 @@ func isStringSet(s *string) bool {
func isYamlFile(file string) bool {
ext := filepath.Ext(file)
return ext == ".yml" || ext == ".yaml"
}

View File

@ -0,0 +1,9 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "K9s plugin-multi schema",
"type": "object",
"additionalProperties": {
"$ref": "file://internal/config/json/schemas/plugin.json",
"additionalProperties": false
}
}

View File

@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "K9s plugin schema",
"type": "object",
"additionalProperties": false,
"properties": {
"shortCut": { "type": "string" },
"override": { "type": "boolean" },
"description": { "type": "string" },
"confirm": { "type": "boolean" },
"dangerous": { "type": "boolean" },
"scopes": {
"type": "array",
"items": { "type": "string" }
},
"command": { "type": "string" },
"background": { "type": "boolean" },
"overwriteOutput": { "type": "boolean" },
"args": {
"type": "array",
"items": { "type": ["string", "number"] }
}
},
"required": ["shortCut", "description", "scopes", "command"]
}

View File

@ -7,27 +7,8 @@
"plugins": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"shortCut": { "type": "string" },
"override": { "type": "boolean" },
"description": { "type": "string" },
"confirm": { "type": "boolean" },
"dangerous": { "type": "boolean" },
"scopes": {
"type": "array",
"items": { "type": "string" }
},
"command": { "type": "string" },
"background": { "type": "boolean" },
"overwriteOutput": { "type": "boolean" },
"args": {
"type": "array",
"items": { "type": ["string", "number"] }
}
},
"required": ["shortCut", "description", "scopes", "command"]
"$ref": "file://internal/config/json/schemas/plugin.json",
"additionalProperties": false
},
"required": []
}

View File

@ -0,0 +1,10 @@
shortCut: g
confirm: false
description: blee
scopes:
- namespaces
command: sh
background: false
args:
- -c
- "blee bla"

View File

@ -0,0 +1,23 @@
blee:
shortCut: g
confirm: false
description: blee
scopes:
- namespaces
command: sh
background: false
args:
- -c
- "blee bla"
duh:
shortCut: h
confirm: true
description: duh
scopes:
- all
command: sh
background: true
args:
- -c
- "duh fred"

View File

@ -20,6 +20,12 @@ const (
// PluginsSchema describes plugins schema.
PluginsSchema = "plugins.json"
// PluginSchema describes a plugin snippet schema.
PluginSchema = "plugin.json"
// PluginMultiSchema describes plugin snippets schema.
PluginMultiSchema = "plugin-multi.json"
// AliasesSchema describes aliases schema.
AliasesSchema = "aliases.json"
@ -41,8 +47,14 @@ const (
var (
//go:embed schemas/plugins.json
pluginsSchema string
//go:embed schemas/plugin.json
pluginSchema string
//go:embed schemas/plugin-multi.json
pluginMultiSchema string
//go:embed schemas/aliases.json
aliasSchema string
@ -72,13 +84,15 @@ type Validator struct {
func NewValidator() *Validator {
v := Validator{
schemas: map[string]gojsonschema.JSONLoader{
K9sSchema: gojsonschema.NewStringLoader(k9sSchema),
ContextSchema: gojsonschema.NewStringLoader(contextSchema),
AliasesSchema: gojsonschema.NewStringLoader(aliasSchema),
ViewsSchema: gojsonschema.NewStringLoader(viewsSchema),
PluginsSchema: gojsonschema.NewStringLoader(pluginSchema),
HotkeysSchema: gojsonschema.NewStringLoader(hotkeysSchema),
SkinSchema: gojsonschema.NewStringLoader(skinSchema),
K9sSchema: gojsonschema.NewStringLoader(k9sSchema),
ContextSchema: gojsonschema.NewStringLoader(contextSchema),
AliasesSchema: gojsonschema.NewStringLoader(aliasSchema),
ViewsSchema: gojsonschema.NewStringLoader(viewsSchema),
PluginsSchema: gojsonschema.NewStringLoader(pluginsSchema),
PluginSchema: gojsonschema.NewStringLoader(pluginSchema),
PluginMultiSchema: gojsonschema.NewStringLoader(pluginMultiSchema),
HotkeysSchema: gojsonschema.NewStringLoader(hotkeysSchema),
SkinSchema: gojsonschema.NewStringLoader(skinSchema),
},
}
v.register()
@ -92,7 +106,6 @@ func (v *Validator) register() {
v.loader.Validate = true
clog := slog.With(slogs.Subsys, "schema")
for k, s := range v.schemas {
if err := v.loader.AddSchema(k, s); err != nil {
clog.Error("Schema initialization failed",
@ -103,9 +116,24 @@ func (v *Validator) register() {
}
}
// ValidatePlugins validates plugins schema.
// Checks for full, snippet and multi snippets schemas.
func (v *Validator) ValidatePlugins(bb []byte) (string, error) {
var errs error
for _, k := range []string{PluginsSchema, PluginSchema, PluginMultiSchema} {
if err := v.Validate(k, bb); err != nil {
errs = errors.Join(errs, err)
continue
}
return k, nil
}
return "", errs
}
// Validate runs document thru given schema validation.
func (v *Validator) Validate(k string, bb []byte) error {
var m interface{}
var m any
err := yaml.Unmarshal(bb, &m)
if err != nil {
return err

View File

@ -14,11 +14,59 @@ import (
"github.com/stretchr/testify/assert"
)
func TestValidatePluginDir(t *testing.T) {
skinDir := "../../../plugins"
ee, err := os.ReadDir(skinDir)
func TestValidatePluginSnippet(t *testing.T) {
plugPath := "testdata/plugins/snippet.yaml"
bb, err := os.ReadFile(plugPath)
assert.NoError(t, err)
p := json.NewValidator()
assert.NoError(t, p.Validate(json.PluginSchema, bb), plugPath)
}
func TestValidatePlugins(t *testing.T) {
uu := map[string]struct {
path, schema string
err string
}{
"cool": {
path: "testdata/plugins/cool.yaml",
schema: json.PluginsSchema,
},
"toast": {
path: "testdata/plugins/toast.yaml",
schema: json.PluginsSchema,
err: "Additional property shortCuts is not allowed\nscopes is required\nshortCut is required",
},
"cool-snippet": {
path: "testdata/plugins/snippet.yaml",
schema: json.PluginSchema,
},
"cool-snippets": {
path: "testdata/plugins/snippets.yaml",
schema: json.PluginMultiSchema,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
bb, err := os.ReadFile(u.path)
assert.NoError(t, err)
dir, _ := os.Getwd()
assert.NoError(t, os.Chdir("../../.."))
v := json.NewValidator()
if err := v.Validate(u.schema, bb); err != nil {
assert.Equal(t, u.err, err.Error())
}
assert.NoError(t, os.Chdir(dir))
})
}
}
func TestValidatePluginDir(t *testing.T) {
plugDir := "../../../plugins"
ee, err := os.ReadDir(plugDir)
assert.NoError(t, err)
for _, e := range ee {
if e.IsDir() {
continue
@ -28,10 +76,15 @@ func TestValidatePluginDir(t *testing.T) {
continue
}
assert.True(t, ext == ".yaml", fmt.Sprintf("expected yaml file: %q", e.Name()))
assert.True(t, !strings.Contains(e.Name(), "_"), fmt.Sprintf("underscore in: %q", e.Name()))
bb, err := os.ReadFile(filepath.Join(skinDir, e.Name()))
assert.False(t, strings.Contains(e.Name(), "_"), fmt.Sprintf("underscore in: %q", e.Name()))
bb, err := os.ReadFile(filepath.Join(plugDir, e.Name()))
assert.NoError(t, err)
dir, _ := os.Getwd()
assert.NoError(t, os.Chdir("../../.."))
p := json.NewValidator()
assert.NoError(t, p.Validate(json.PluginsSchema, bb), e.Name())
assert.NoError(t, os.Chdir(dir))
}
}
@ -135,35 +188,6 @@ Additional property namespaces is not allowed`,
}
}
func TestValidatePlugins(t *testing.T) {
uu := map[string]struct {
f string
err string
}{
"happy": {
f: "testdata/plugins/cool.yaml",
},
"toast": {
f: "testdata/plugins/toast.yaml",
err: `Additional property shortCuts is not allowed
scopes is required
shortCut is required`,
},
}
v := json.NewValidator()
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
bb, err := os.ReadFile(u.f)
assert.NoError(t, err)
if err := v.Validate(json.PluginsSchema, bb); err != nil {
assert.Equal(t, u.err, err.Error())
}
})
}
}
func TestValidateAliases(t *testing.T) {
uu := map[string]struct {
f string

View File

@ -20,11 +20,11 @@ import (
"gopkg.in/yaml.v3"
)
const k9sPluginsDir = "k9s/plugins"
type plugins map[string]Plugin
// Plugins represents a collection of plugins.
type Plugins struct {
Plugins map[string]Plugin `yaml:"plugins"`
Plugins plugins `yaml:"plugins"`
}
// Plugin describes a K9s plugin.
@ -54,18 +54,27 @@ func NewPlugins() Plugins {
}
// Load K9s plugins.
func (p Plugins) Load(path string) error {
func (p Plugins) Load(path string, loadExtra bool) error {
var errs error
// Load from global config file
if err := p.load(AppPluginsFile); err != nil {
errs = errors.Join(errs, err)
}
// Load from cluster/context config
if err := p.load(path); err != nil {
errs = errors.Join(errs, err)
}
for _, dataDir := range append(xdg.DataDirs, xdg.DataHome, xdg.ConfigHome) {
if err := p.loadPluginDir(filepath.Join(dataDir, k9sPluginsDir)); err != nil {
if !loadExtra {
return errs
}
// Load from XDG dirs
const k9sPluginsDir = "k9s/plugins"
for _, dir := range append(xdg.DataDirs, xdg.DataHome, xdg.ConfigHome) {
path := filepath.Join(dir, k9sPluginsDir)
if err := p.loadDir(path); err != nil {
errs = errors.Join(errs, err)
}
}
@ -73,43 +82,6 @@ func (p Plugins) Load(path string) error {
return errs
}
func (p Plugins) loadPluginDir(dir string) error {
pluginFiles, err := os.ReadDir(dir)
if err != nil {
return nil
}
var errs error
for _, file := range pluginFiles {
if file.IsDir() || !isYamlFile(file.Name()) {
continue
}
fileName := filepath.Join(dir, file.Name())
fileContent, err := os.ReadFile(fileName)
if err != nil {
errs = errors.Join(errs, err)
}
d := yaml.NewDecoder(bytes.NewReader(fileContent))
d.KnownFields(true)
var plugin Plugin
if err = d.Decode(&plugin); err != nil {
var plugins Plugins
if err = d.Decode(&plugins); err != nil {
return fmt.Errorf("cannot parse %s into either a single plugin nor plugins: %w", fileName, err)
}
for name, plugin := range plugins.Plugins {
p.Plugins[name] = plugin
}
continue
}
p.Plugins[strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))] = plugin
}
return errs
}
func (p *Plugins) load(path string) error {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return nil
@ -118,19 +90,62 @@ func (p *Plugins) load(path string) error {
if err != nil {
return err
}
if err := data.JSONValidator.Validate(json.PluginsSchema, bb); err != nil {
slog.Warn("Validation failed. Please update your config and restart!",
scheme, err := data.JSONValidator.ValidatePlugins(bb)
if err != nil {
slog.Warn("Plugin schema validation failed",
slogs.Path, path,
slogs.Error, err,
)
return fmt.Errorf("plugin validation failed for %s: %w", path, err)
}
var pp Plugins
if err := yaml.Unmarshal(bb, &pp); err != nil {
return err
}
for k, v := range pp.Plugins {
p.Plugins[k] = v
d := yaml.NewDecoder(bytes.NewReader(bb))
d.KnownFields(true)
switch scheme {
case json.PluginSchema:
var o Plugin
if err := yaml.Unmarshal(bb, &o); err != nil {
return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err)
}
p.Plugins[strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))] = o
case json.PluginsSchema:
var oo Plugins
if err := yaml.Unmarshal(bb, &oo); err != nil {
return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err)
}
for k, v := range oo.Plugins {
p.Plugins[k] = v
}
case json.PluginMultiSchema:
var oo plugins
if err := yaml.Unmarshal(bb, &oo); err != nil {
return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err)
}
for k, v := range oo {
p.Plugins[k] = v
}
}
return nil
}
func (p Plugins) loadDir(dir string) error {
if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {
return nil
}
var errs error
errs = errors.Join(errs, filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !isYamlFile(info.Name()) {
return nil
}
errs = errors.Join(errs, p.load(path))
return nil
}))
return errs
}

View File

@ -5,85 +5,193 @@ package config
import (
"os"
"path"
"strings"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
)
var pluginYmlTestData = Plugin{
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
Background: false,
}
var test1YmlTestData = Plugin{
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
Background: false,
OverwriteOutput: true,
}
var test2YmlTestData = Plugin{
Scopes: []string{"svc", "ing"},
Args: []string{"-n", "$NAMESPACE", "-oyaml"},
ShortCut: "shift-r",
Description: "bla",
Command: "duha",
Confirm: false,
Background: true,
OverwriteOutput: false,
}
func TestPluginLoad(t *testing.T) {
AppPluginsFile = "/tmp/k9s-test/fred.yaml"
os.Setenv("XDG_DATA_HOME", "/tmp/k9s-test")
xdg.Reload()
uu := map[string]struct {
path string
err string
ee Plugins
}{
"snippet": {
path: "testdata/plugins/dir/snippet.1.yaml",
ee: Plugins{
Plugins: plugins{
"snippet.1": Plugin{
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
OverwriteOutput: true,
},
},
},
},
p := NewPlugins()
assert.NoError(t, p.Load("testdata/plugins.yaml"))
"multi-snippets": {
path: "testdata/plugins/dir/snippet.multi.yaml",
ee: Plugins{
Plugins: plugins{
"crapola": Plugin{
ShortCut: "Shift-1",
Command: "crapola",
Description: "crapola",
Scopes: []string{"pods"},
},
"bozo": Plugin{
ShortCut: "Shift-2",
Description: "bozo",
Command: "bozo",
Scopes: []string{"pods", "svc"},
},
},
},
},
assert.Equal(t, 1, len(p.Plugins))
k, ok := p.Plugins["blah"]
assert.True(t, ok)
assert.ObjectsAreEqual(pluginYmlTestData, k)
"full": {
path: "testdata/plugins/plugins.yaml",
ee: Plugins{
Plugins: plugins{
"blah": Plugin{
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
},
},
},
},
"toast-no-file": {
path: "testdata/plugins/plugins-bozo.yaml",
ee: NewPlugins(),
},
"toast-invalid": {
path: "testdata/plugins/plugins-toast.yaml",
ee: NewPlugins(),
err: "Additional property scoped is not allowed\nscopes is required\nAdditional property plugins is not allowed\ncommand is required\ndescription is required\nscopes is required\nshortCut is required\nAdditional property blah is not allowed\ncommand is required\ndescription is required\nscopes is required\nshortCut is required",
},
}
dir, _ := os.Getwd()
assert.NoError(t, os.Chdir("../.."))
defer func() {
assert.NoError(t, os.Chdir(dir))
}()
for k, u := range uu {
t.Run(k, func(t *testing.T) {
p := NewPlugins()
err := p.Load(path.Join(dir, u.path), false)
if err != nil {
idx := strings.Index(err.Error(), ":")
assert.Equal(t, u.err, err.Error()[idx+2:])
}
assert.Equal(t, u.ee, p)
})
}
}
func TestSinglePluginFileLoad(t *testing.T) {
e := Plugin{
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
}
dir, _ := os.Getwd()
assert.NoError(t, os.Chdir("../.."))
defer func() {
assert.NoError(t, os.Chdir(dir))
}()
p := NewPlugins()
assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.NoError(t, p.loadPluginDir("/random/dir/not/exist"))
assert.NoError(t, p.load(path.Join(dir, "testdata/plugins/plugins.yaml")))
assert.NoError(t, p.loadDir(path.Join(dir, "/random/dir/not/exist")))
assert.Equal(t, 1, len(p.Plugins))
k, ok := p.Plugins["blah"]
assert.True(t, ok)
v, ok := p.Plugins["blah"]
assert.ObjectsAreEqual(pluginYmlTestData, k)
assert.True(t, ok)
assert.ObjectsAreEqual(e, v)
}
func TestMultiplePluginFilesLoad(t *testing.T) {
p := NewPlugins()
assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.NoError(t, p.loadPluginDir("testdata/plugins"))
testPlugins := map[string]Plugin{
"blah": pluginYmlTestData,
"test1": test1YmlTestData,
"test2": test2YmlTestData,
uu := map[string]struct {
path string
dir string
ee Plugins
}{
"empty": {
path: "internal/config/testdata/plugins/plugins.yaml",
dir: "internal/config/testdata/plugins/dir",
ee: Plugins{
Plugins: plugins{
"blah": {
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
ShortCut: "shift-s",
Description: "blee",
Command: "duh",
Confirm: true,
},
"snippet.1": {
ShortCut: "shift-s",
Command: "duh",
Scopes: []string{"po", "dp"},
Args: []string{"-n", "$NAMESPACE", "-boolean"},
Description: "blee",
Confirm: true,
OverwriteOutput: true,
},
"snippet.2": {
Scopes: []string{"svc", "ing"},
Args: []string{"-n", "$NAMESPACE", "-oyaml"},
ShortCut: "shift-r",
Description: "bla",
Command: "duha",
Background: true,
},
"crapola": {
Scopes: []string{"pods"},
Command: "crapola",
Description: "crapola",
ShortCut: "Shift-1",
},
"bozo": {
Scopes: []string{"pods", "svc"},
Command: "bozo",
Description: "bozo",
ShortCut: "Shift-2",
},
},
},
},
}
assert.Equal(t, len(testPlugins), len(p.Plugins))
for name, expectedPlugin := range testPlugins {
k, ok := p.Plugins[name]
assert.True(t, ok)
assert.ObjectsAreEqual(expectedPlugin, k)
dir, _ := os.Getwd()
assert.NoError(t, os.Chdir("../.."))
defer func() {
assert.NoError(t, os.Chdir(dir))
}()
for k, u := range uu {
t.Run(k, func(t *testing.T) {
p := NewPlugins()
assert.NoError(t, p.load(u.path))
assert.NoError(t, p.loadDir(u.dir))
assert.Equal(t, u.ee, p)
})
}
}

View File

@ -1,12 +1,13 @@
shortCut: shift-s
confirm: true
description: blee
scopes:
- po
- dp
command: duh
background: false
args:
- -n
- $NAMESPACE
- -boolean
background: false
confirm: true
overwriteOutput: true

View File

@ -0,0 +1,15 @@
crapola:
shortCut: Shift-1
description: crapola
scopes:
- pods
command: crapola
background: false
bozo:
shortCut: Shift-2
description: bozo
scopes:
- pods
- svc
command: bozo

View File

@ -0,0 +1,14 @@
plugins:
blah:
shortCut: shift-s
confirm: true
description: blee
scoped:
- po
- dp
command: duh
background: false
args:
- -n
- $NAMESPACE
- -boolean

View File

@ -52,8 +52,8 @@ const (
// GOR tracks a gor logger key.
GOR = "gor"
// Action tracks an action logger key.
Action = "action"
// Shortcut tracks a shortcut logger key.
Shortcut = "shortcut"
// Page tracks a page logger key.
Page = "page"
@ -117,6 +117,9 @@ const (
// Key tracks a key logger key.
Key = "key"
// Plugin tracks a plugin logger key.
Plugin = "plugin"
// Component tracks a component logger key.
Component = "component"

View File

@ -16,11 +16,11 @@ import (
)
const (
unlockedIC = "🔓"
unlockedIC = "✍️ "
lockedIC = "🔒"
)
// Namespaceable represents a namespaceable model.
// Namespaceable tracks namespaces.
type Namespaceable interface {
// ClusterWide returns true if the model represents resource in all namespaces.
ClusterWide() bool
@ -35,7 +35,7 @@ type Namespaceable interface {
InNamespace(string) bool
}
// Lister represents a viewable resource.
// Lister tracks resource getter.
type Lister interface {
// Get returns a resource instance.
Get(ctx context.Context, path string) (runtime.Object, error)

View File

@ -81,8 +81,8 @@ func hotKeyActions(r Runner, aa *ui.KeyActions) error {
errs = errors.Join(errs, fmt.Errorf("duplicate hotkey found for %q in %q", hk.ShortCut, k))
continue
}
slog.Debug("Action has been overridden by hotkey",
slogs.Action, hk.ShortCut,
slog.Debug("HotKey overrode action shortcut",
slogs.Shortcut, hk.ShortCut,
slogs.Key, k,
)
}
@ -125,7 +125,7 @@ func pluginActions(r Runner, aa *ui.KeyActions) error {
return err
}
pp := config.NewPlugins()
if err := pp.Load(path); err != nil {
if err := pp.Load(path, true); err != nil {
return err
}
@ -135,9 +135,10 @@ func pluginActions(r Runner, aa *ui.KeyActions) error {
ro = r.App().Config.IsReadOnly()
)
for k, plugin := range pp.Plugins {
if !inScope(plugin.Scopes, aliases) {
if !inScope(plugin.Scopes, aliases) || (ro && plugin.Dangerous) {
continue
}
key, err := asKey(plugin.ShortCut)
if err != nil {
errs = errors.Join(errs, err)
@ -148,15 +149,12 @@ func pluginActions(r Runner, aa *ui.KeyActions) error {
errs = errors.Join(errs, fmt.Errorf("duplicate plugin key found for %q in %q", plugin.ShortCut, k))
continue
}
slog.Debug("Action has been overridden by plugin action",
slogs.Action, plugin.ShortCut,
slogs.Key, k,
slog.Debug("Plugin overrode action shortcut",
slogs.Plugin, k,
slogs.Key, plugin.ShortCut,
)
}
if plugin.Dangerous && ro {
continue
}
aa.Add(key, ui.NewKeyActionWithOpts(
plugin.Description,
pluginAction(r, plugin),

View File

@ -1,6 +1,6 @@
name: k9s
base: core22
version: 'v0.40.8'
version: 'v0.40.9'
summary: K9s is a CLI to view and manage your Kubernetes clusters.
description: |
K9s is a CLI to view and manage your Kubernetes clusters. By leveraging a terminal UI, you can easily traverse Kubernetes resources and view the state of your clusters in a single powerful session.

9
testdata/aliases/aliases.yaml vendored Normal file
View File

@ -0,0 +1,9 @@
aliases:
dp: deployments
sec: v1/secrets
jo: jobs
cr: clusterroles
crb: clusterrolebindings
ro: roles
rb: rolebindings
np: networkpolicies