From fc5f1907c40b63e080ffe4ccc49a32dc411b8002 Mon Sep 17 00:00:00 2001 From: Fernand Galiana Date: Sat, 15 Mar 2025 16:41:39 -0600 Subject: [PATCH] Rel v0.40.9 (#3205) * update write icon * fix#3202 * rel docs * update linter * lint * test * lint --- .github/workflows/lint.yml | 2 +- .golangci.yml | 26 +- Makefile | 2 +- README.md | 54 ++++- change_logs/release_v0.40.9.md | 44 ++++ internal/config/helpers.go | 1 + .../config/json/schemas/plugin-multi.json | 9 + internal/config/json/schemas/plugin.json | 25 ++ internal/config/json/schemas/plugins.json | 23 +- .../config/json/testdata/plugins/snippet.yaml | 10 + .../json/testdata/plugins/snippets.yaml | 23 ++ internal/config/json/validator.go | 46 +++- internal/config/json/validator_test.go | 92 ++++--- internal/config/plugin.go | 115 +++++---- internal/config/plugin_test.go | 228 +++++++++++++----- .../{test1.yaml => dir/snippet.1.yaml} | 5 +- .../{test2.yaml => dir/snippet.2.yaml} | 0 .../testdata/plugins/dir/snippet.multi.yaml | 15 ++ .../testdata/plugins/plugins-toast.yaml | 14 ++ .../testdata/{ => plugins}/plugins.yaml | 0 internal/slogs/keys.go | 7 +- internal/ui/types.go | 6 +- internal/view/actions.go | 18 +- snap/snapcraft.yaml | 2 +- testdata/aliases/aliases.yaml | 9 + 25 files changed, 559 insertions(+), 217 deletions(-) create mode 100644 change_logs/release_v0.40.9.md create mode 100644 internal/config/json/schemas/plugin-multi.json create mode 100644 internal/config/json/schemas/plugin.json create mode 100644 internal/config/json/testdata/plugins/snippet.yaml create mode 100644 internal/config/json/testdata/plugins/snippets.yaml rename internal/config/testdata/plugins/{test1.yaml => dir/snippet.1.yaml} (86%) rename internal/config/testdata/plugins/{test2.yaml => dir/snippet.2.yaml} (100%) create mode 100644 internal/config/testdata/plugins/dir/snippet.multi.yaml create mode 100644 internal/config/testdata/plugins/plugins-toast.yaml rename internal/config/testdata/{ => plugins}/plugins.yaml (100%) create mode 100644 testdata/aliases/aliases.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c9e93225..2d5e8e6c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 31169efe..e1fd377b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/Makefile b/Makefile index 97146da1..2b9571d0 100644 --- a/Makefile +++ b/Makefile @@ -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} diff --git a/README.md b/README.md index db0170e8..1351b125 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/change_logs/release_v0.40.9.md b/change_logs/release_v0.40.9.md new file mode 100644 index 00000000..3dcdcbea --- /dev/null +++ b/change_logs/release_v0.40.9.md @@ -0,0 +1,44 @@ + + +# 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 + +--- + + © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) \ No newline at end of file diff --git a/internal/config/helpers.go b/internal/config/helpers.go index cb0d7f4f..08b5a174 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -28,6 +28,7 @@ func isStringSet(s *string) bool { func isYamlFile(file string) bool { ext := filepath.Ext(file) + return ext == ".yml" || ext == ".yaml" } diff --git a/internal/config/json/schemas/plugin-multi.json b/internal/config/json/schemas/plugin-multi.json new file mode 100644 index 00000000..1c773c5c --- /dev/null +++ b/internal/config/json/schemas/plugin-multi.json @@ -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 + } +} diff --git a/internal/config/json/schemas/plugin.json b/internal/config/json/schemas/plugin.json new file mode 100644 index 00000000..4d489024 --- /dev/null +++ b/internal/config/json/schemas/plugin.json @@ -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"] +} diff --git a/internal/config/json/schemas/plugins.json b/internal/config/json/schemas/plugins.json index c1ba587a..20deaede 100644 --- a/internal/config/json/schemas/plugins.json +++ b/internal/config/json/schemas/plugins.json @@ -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": [] } diff --git a/internal/config/json/testdata/plugins/snippet.yaml b/internal/config/json/testdata/plugins/snippet.yaml new file mode 100644 index 00000000..868c2dba --- /dev/null +++ b/internal/config/json/testdata/plugins/snippet.yaml @@ -0,0 +1,10 @@ +shortCut: g +confirm: false +description: blee +scopes: + - namespaces +command: sh +background: false +args: + - -c + - "blee bla" diff --git a/internal/config/json/testdata/plugins/snippets.yaml b/internal/config/json/testdata/plugins/snippets.yaml new file mode 100644 index 00000000..90ca1b80 --- /dev/null +++ b/internal/config/json/testdata/plugins/snippets.yaml @@ -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" \ No newline at end of file diff --git a/internal/config/json/validator.go b/internal/config/json/validator.go index 5c6e44bd..ff972976 100644 --- a/internal/config/json/validator.go +++ b/internal/config/json/validator.go @@ -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 diff --git a/internal/config/json/validator_test.go b/internal/config/json/validator_test.go index 9bab5e07..aa7221ef 100644 --- a/internal/config/json/validator_test.go +++ b/internal/config/json/validator_test.go @@ -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 diff --git a/internal/config/plugin.go b/internal/config/plugin.go index 11b7e02d..4d23d0ff 100644 --- a/internal/config/plugin.go +++ b/internal/config/plugin.go @@ -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 +} diff --git a/internal/config/plugin_test.go b/internal/config/plugin_test.go index 1634760e..24303a53 100644 --- a/internal/config/plugin_test.go +++ b/internal/config/plugin_test.go @@ -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) + }) } } diff --git a/internal/config/testdata/plugins/test1.yaml b/internal/config/testdata/plugins/dir/snippet.1.yaml similarity index 86% rename from internal/config/testdata/plugins/test1.yaml rename to internal/config/testdata/plugins/dir/snippet.1.yaml index 6d8eec13..a66bba69 100644 --- a/internal/config/testdata/plugins/test1.yaml +++ b/internal/config/testdata/plugins/dir/snippet.1.yaml @@ -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 diff --git a/internal/config/testdata/plugins/test2.yaml b/internal/config/testdata/plugins/dir/snippet.2.yaml similarity index 100% rename from internal/config/testdata/plugins/test2.yaml rename to internal/config/testdata/plugins/dir/snippet.2.yaml diff --git a/internal/config/testdata/plugins/dir/snippet.multi.yaml b/internal/config/testdata/plugins/dir/snippet.multi.yaml new file mode 100644 index 00000000..4bc42e0e --- /dev/null +++ b/internal/config/testdata/plugins/dir/snippet.multi.yaml @@ -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 \ No newline at end of file diff --git a/internal/config/testdata/plugins/plugins-toast.yaml b/internal/config/testdata/plugins/plugins-toast.yaml new file mode 100644 index 00000000..3ec96b7f --- /dev/null +++ b/internal/config/testdata/plugins/plugins-toast.yaml @@ -0,0 +1,14 @@ +plugins: + blah: + shortCut: shift-s + confirm: true + description: blee + scoped: + - po + - dp + command: duh + background: false + args: + - -n + - $NAMESPACE + - -boolean diff --git a/internal/config/testdata/plugins.yaml b/internal/config/testdata/plugins/plugins.yaml similarity index 100% rename from internal/config/testdata/plugins.yaml rename to internal/config/testdata/plugins/plugins.yaml diff --git a/internal/slogs/keys.go b/internal/slogs/keys.go index abdd01c4..7ece5324 100644 --- a/internal/slogs/keys.go +++ b/internal/slogs/keys.go @@ -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" diff --git a/internal/ui/types.go b/internal/ui/types.go index 3fb98de7..0ebfd53d 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -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) diff --git a/internal/view/actions.go b/internal/view/actions.go index 335b2965..b15bf7d1 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -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), diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 8d6cedc1..6b4929af 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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. diff --git a/testdata/aliases/aliases.yaml b/testdata/aliases/aliases.yaml new file mode 100644 index 00000000..ee4d9ec0 --- /dev/null +++ b/testdata/aliases/aliases.yaml @@ -0,0 +1,9 @@ +aliases: + dp: deployments + sec: v1/secrets + jo: jobs + cr: clusterroles + crb: clusterrolebindings + ro: roles + rb: rolebindings + np: networkpolicies