diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4a65fcef..8a4d6fda 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -25,6 +25,9 @@ Steps to reproduce the behavior: 3. Scroll down to '....' 4. See error +**Historical Documents** +When applicable please include any supporting artifacts: k9s debug logs, configurations, resource manifests, ... + **Expected behavior** A clear and concise description of what you expected to happen. diff --git a/Makefile b/Makefile index 7add385c..7e77251a 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.30.8 +VERSION ?= v0.31.0 IMG_NAME := derailed/k9s IMAGE := ${IMG_NAME}:${VERSION} diff --git a/README.md b/README.md index 441e19e6..2d92ceb0 100644 --- a/README.md +++ b/README.md @@ -225,13 +225,11 @@ Binaries for Linux, Windows and Mac are available as tarballs in the [release pa export TERM=xterm-256color ``` -* In order to issue manifest edit commands make sure your EDITOR env is set. +* In order to issue resource edit commands make sure your EDITOR and KUBE_EDITOR env vars are set. ```shell # Kubectl edit command will use this env var. - export EDITOR=my_fav_editor - # Should your editor deal with streamed vs on disk files differently, also set... - export K9S_EDITOR=my_fav_editor + export KUBE_EDITOR=my_fav_editor ``` * K9s prefers recent kubernetes versions ie 1.28+ @@ -607,24 +605,23 @@ Here is a sample views configuration that customize a pods and services views. ```yaml # $XDG_CONFIG_HOME/k9s/views.yaml -k9s: - views: - v1/pods: - columns: - - AGE - - NAMESPACE - - NAME - - IP - - NODE - - STATUS - - READY - v1/services: - columns: - - AGE - - NAMESPACE - - NAME - - TYPE - - CLUSTER-IP +views: + v1/pods: + columns: + - AGE + - NAMESPACE + - NAME + - IP + - NODE + - STATUS + - READY + v1/services: + columns: + - AGE + - NAMESPACE + - NAME + - TYPE + - CLUSTER-IP ``` --- @@ -897,6 +894,7 @@ k9s: You can also specify a default skin for all contexts in the root k9s config file as so: ```yaml +# $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false screenDumpDir: /tmp/dumps @@ -910,6 +908,8 @@ k9s: logoless: false crumbsless: false noIcons: false + # Toggles reactive UI. This option provide for watching on disk artifacts changes and update the UI live Defaults to false. + reactive: false # By default all contexts wil use the dracula skin unless explicitly overridden in the context config file. skin: dracula # => assumes the file skins/dracula.yaml is present in the $XDG_DATA_HOME/k9s/skins directory skipLatestRevCheck: false @@ -929,7 +929,7 @@ k9s: tail: 100 buffer: 5000 sinceSeconds: -1 - fullScreenLogs: false + fullScreen: false textWrap: false showTime: false thresholds: @@ -942,7 +942,7 @@ k9s: ``` ```yaml -# $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml +# $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml # Skin InTheNavy! k9s: # General K9s styles diff --git a/change_logs/release_v0.31.0.md b/change_logs/release_v0.31.0.md new file mode 100644 index 00000000..d4b92246 --- /dev/null +++ b/change_logs/release_v0.31.0.md @@ -0,0 +1,153 @@ + + +# Release v0.31.0 + +## 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) + +--- + +## ♫ Sounds Behind The Release ♭ + +* [Border Crossing - Eek A Mouse](https://www.youtube.com/watch?v=KaAC9dBPcOM) +* [The Weight - The Band](https://www.youtube.com/watch?v=FFqb1I-hiHE) +* [Wonderin' - Neil Young](https://www.youtube.com/watch?v=h0PlwVPbM5k) +* [When Your Lover Has Gone - Louis Armstrong](https://www.youtube.com/watch?v=1tdfIj0fvlA) + +--- + +## A Word From Our Sponsors... + +To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! + +* [Jacky Nguyen](https://github.com/nktpro) +* [Eckl, Máté](https://github.com/ecklm) +* [Jörgen](https://github.com/wthrbtn) +* [kmath313](https://github.com/kmath313) +* [a-thomas-22](https://github.com/a-thomas-22) +* [wpbeckwith](https://github.com/wpbeckwith) +* [Dima Altukhov](https://github.com/alt-dima) +* [Shoshin Nikita](https://github.com/ShoshinNikita) +* [Tu Hoang](https://github.com/rebyn) +* [Andreas Frangopoulos](https://github.com/qubeio) + +> Sponsorship cancellations since the last release: **7!** 🥹 + +## Feature Release! + +😳 Found a few issues in the neutrino drive... +This is another fairly heavy drop so bracing for impact 😱 +Be sure to dial in the v0.31.0 SneakPeek video below for the gory details! + +😵 Hopefully we've move the needle in the right direction on this drop... 🤞 + +Thank you all for your kindness, feedback and assistance in flushing out issues!! + +### Hold My Hand... + +In this drop, we've added schema validation to ensure various configs are setup as expected. +K9s will now run validation checks on the following configurations: + +1. K9s main configuration (config.yaml) +2. Context specific configs (clusterX/contextY/config.yaml) +3. Skins +4. Aliases +5. HotKeys +6. Plugins +7. Views + +K9s behavior changed in this release if the main configuration does not match schema expectations. +In the past, the configuration will be validated, updated and saved should validation checks failed. Now the app will stop and report validation issues. + +The schemas are set to be a bit loose for the time being. Once we/ve vetted they are cool, we could publish them out (with additional TLC!) so k9s users can leverage them in their favorite editors. + +In the meantime, you'll need to keep k9s logs handy, to check for validation errors. The validation messages can be somewhat cryptic at times and so please be sure to include your debug logs and config settings when reporting issues which might be plenty ;(. + +### Breaking Bad! + +Configuration changes: + +1. DRY fullScreenLogs -> fullScreens (k9s root config.yaml) + + ```yaml + # $XDG_CONFIG_HOME/k9s/config.yaml + k9s: + liveViewAutoRefresh: false + logger: + sinceSeconds: -1 + fullScreen: false # => Was fullScreenLogs + ... + ``` + +2. Views Configuration. + To match other configurations the root is now `views:` vs `k9s: views:` + + ```yaml + # $XDG_CONFIG_HOME/k9s/views.yaml + views: # => Was k9s:\n views: + v1/pods: + columns: + - AGE + - NAMESPACE + ... + ``` + +### Serenity Now! + + You can now opt in/out of the `reactive ui` feature. This feature enable users to make change to some configurations and see changes reflected live in the ui. This feature is now disabled by default and one must opt-in to enable via `k9s.UI.reactive` + Reactive UI provides for monitoring various config files on disk and update the UI when changes to those files occur. This is handy while tuning skins, plugins, aliases, hotkeys and benchmarks parameters. + + ```yaml + # $XDG_CONFIG_HOME/k9s/config.yaml + k9s: + liveViewAutoRefresh: false + UI: + ... + reactive: true # => enable/disable reactive UI + ... + ``` + +--- + +## Videos Are In The Can! + +Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... + +* [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 + +* [#2434](https://github.com/derailed/k9s/issues/2434) readOnly: true in config.yaml doesnt get overriden by readOnly: false in cluster config +* [#2430](https://github.com/derailed/k9s/issues/2430) Referencing a namespace with the name of an alias inside an alias causes infinite loop +* [#2428](https://github.com/derailed/k9s/issues/2428) Boom!! runtime error: invalid memory address or nil pointer dereference - v0.30.8 +* [#2421](https://github.com/derailed/k9s/issues/2421) k9s/config.yaml configuration file is overwritten on launch + +--- + +## Contributed PRs + +Please be sure to give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! + +* [#2433](https://github.com/derailed/k9s/pull/2433) switch contexts only when needed +* [#2429](https://github.com/derailed/k9s/pull/2429) Reference correct configuration ENV var in README +* [#2426](https://github.com/derailed/k9s/pull/2426) Update carvel plugin kick to shift K +* [#2420](https://github.com/derailed/k9s/pull/2420) supports referencing envs in hotkeys +* [#2419](https://github.com/derailed/k9s/pull/2419) fix typo + + © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/cmd/info.go b/cmd/info.go index 719c9461..8fbb01e5 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -69,6 +69,9 @@ func getScreenDumpDirForInfo() string { log.Error().Err(err).Msgf("Unmarshal k9s config %v", err) return config.AppDumpsDir } + if cfg.K9s == nil { + return config.AppDumpsDir + } - return cfg.K9s.GetScreenDumpDir() + return cfg.K9s.AppScreenDumpDir() } diff --git a/cmd/root.go b/cmd/root.go index 4f1f4847..c7412fa5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,7 +93,7 @@ func run(cmd *cobra.Command, args []string) error { cfg, err := loadConfiguration() if err != nil { - log.Error().Err(err).Msgf("load configuration failed") + return err } app := view.NewApp(cfg) if err := app.Init(version, *k9sFlags.RefreshRate); err != nil { @@ -115,11 +115,12 @@ func loadConfiguration() (*config.Config, error) { k8sCfg := client.NewConfig(k8sFlags) k9sCfg := config.NewConfig(k8sCfg) if err := k9sCfg.Load(config.AppConfigFile); err != nil { - log.Warn().Msg("Unable to locate K9s config. Generating new configuration...") + return nil, err } k9sCfg.K9s.Override(k9sFlags) if err := k9sCfg.Refine(k8sFlags, k9sFlags, k8sCfg); err != nil { - log.Error().Err(err).Msgf("refine failed") + log.Error().Err(err).Msgf("config refine failed") + return nil, err } conn, err := client.InitConnection(k8sCfg) k9sCfg.SetConnection(conn) diff --git a/go.mod b/go.mod index 25fba7d0..4ca83ec7 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,10 @@ require ( github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 + github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/text v0.14.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.13.3 k8s.io/api v0.29.0 k8s.io/apiextensions-apiserver v0.29.0 @@ -276,7 +278,6 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect @@ -310,7 +311,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.25.5 // indirect k8s.io/apiserver v0.29.0 // indirect k8s.io/component-base v0.29.0 // indirect diff --git a/internal/client/client.go b/internal/client/client.go index 28ecc2f0..02b1e6aa 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -517,12 +517,12 @@ func (a *APIClient) supportsMetricsResources() error { a.cache.Add(cacheMXAPIKey, supported, cacheExpiry) }() - dial, err := a.CachedDiscovery() + dial, err := a.Dial() if err != nil { log.Warn().Err(err).Msgf("Unable to dial discovery API") return err } - apiGroups, err := dial.ServerGroups() + apiGroups, err := dial.Discovery().ServerGroups() if err != nil { return err } diff --git a/internal/client/metrics.go b/internal/client/metrics.go index fe2e90ba..c31ba6c5 100644 --- a/internal/client/metrics.go +++ b/internal/client/metrics.go @@ -90,7 +90,7 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL func (m *MetricsServer) checkAccess(ns, gvr, msg string) error { if !m.HasMetrics() { - return errors.New("No metrics-server detected on cluster") + return errors.New("no metrics-server detected on cluster") } auth, err := m.CanI(ns, gvr, ListAccess) @@ -193,7 +193,7 @@ func (m *MetricsServer) FetchNodeMetrics(ctx context.Context, n string) (*mv1bet mx, ok := mmx[n] if !ok { - return nil, fmt.Errorf("Unable to retrieve node metrics for %q", n) + return nil, fmt.Errorf("unable to retrieve node metrics for %q", n) } return mx, nil } @@ -283,7 +283,7 @@ func (m *MetricsServer) FetchPodMetrics(ctx context.Context, fqn string) (*mv1be } pmx, ok := mmx[fqn] if !ok { - return nil, fmt.Errorf("Unable to locate pod metrics for pod %q", fqn) + return nil, fmt.Errorf("unable to locate pod metrics for pod %q", fqn) } return pmx, nil diff --git a/internal/client/types.go b/internal/client/types.go index 24d66aad..c6bcf760 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -117,7 +117,7 @@ type Connection interface { // HasMetrics checks if metrics server is available. HasMetrics() bool - // ValidNamespaces returns all available namespace names. + // ValidNamespaceNames returns all available namespace names. ValidNamespaceNames() (NamespaceNames, error) // IsValidNamespace checks if given namespace is known. diff --git a/internal/config/alias.go b/internal/config/alias.go index 6ce8365a..798a0576 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -4,10 +4,12 @@ package config import ( + "fmt" "os" "sync" "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" ) @@ -120,18 +122,26 @@ func (a *Aliases) LoadFile(path string) error { if path == "" { return nil } - f, err := os.ReadFile(path) - if err == nil { - var aa Aliases - if err := yaml.Unmarshal(f, &aa); err != nil { - return err - } + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } - a.mx.Lock() - defer a.mx.Unlock() - for k, v := range aa.Alias { - a.Alias[k] = v - } + bb, err := os.ReadFile(path) + if err != nil { + return err + } + if err := data.JSONValidator.Validate(json.AliasesSchema, bb); err != nil { + return fmt.Errorf("validation failed for %q: %w", path, err) + } + + var aa Aliases + if err := yaml.Unmarshal(bb, &aa); err != nil { + return err + } + a.mx.Lock() + defer a.mx.Unlock() + for k, v := range aa.Alias { + a.Alias[k] = v } return nil diff --git a/internal/config/alias_test.go b/internal/config/alias_test.go index f65ddb99..d8551bfc 100644 --- a/internal/config/alias_test.go +++ b/internal/config/alias_test.go @@ -4,25 +4,59 @@ package config_test import ( + "fmt" + "os" + "slices" "testing" "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/config/data" "github.com/stretchr/testify/assert" ) +func TestAliasClear(t *testing.T) { + a := testAliases() + a.Clear() + + assert.Equal(t, 0, len(a.Keys())) +} + +func TestAliasKeys(t *testing.T) { + a := testAliases() + kk := a.Keys() + slices.Sort(kk) + + assert.Equal(t, []string{"a1", "a11", "a2", "a3"}, kk) +} + +func TestAliasShortNames(t *testing.T) { + a := testAliases() + ess := config.ShortNames{ + "gvr1": []string{"a1", "a11"}, + "gvr2": []string{"a2"}, + "gvr3": []string{"a3"}, + } + ss := a.ShortNames() + assert.Equal(t, len(ess), len(ss)) + for k, v := range ss { + v1, ok := ess[k] + assert.True(t, ok, fmt.Sprintf("missing: %q", k)) + slices.Sort(v) + assert.Equal(t, v1, v) + } +} + func TestAliasDefine(t *testing.T) { type aliasDef struct { cmd string aliases []string } - uu := []struct { - name string + uu := map[string]struct { aliases []aliasDef registeredCommands map[string]string }{ - { - name: "simple aliases", + "simple": { aliases: []aliasDef{ { cmd: "one", @@ -34,8 +68,7 @@ func TestAliasDefine(t *testing.T) { "duh": "one", }, }, - { - name: "duplicated aliases", + "duplicates": { aliases: []aliasDef{ { cmd: "one", @@ -54,9 +87,9 @@ func TestAliasDefine(t *testing.T) { }, } - for i := range uu { - u := uu[i] - t.Run(u.name, func(t *testing.T) { + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { configAlias := config.NewAliases() for _, aliases := range u.aliases { for _, a := range aliases.aliases { @@ -73,18 +106,35 @@ func TestAliasDefine(t *testing.T) { } func TestAliasesLoad(t *testing.T) { + config.AppConfigDir = "testdata/aliases" a := config.NewAliases() - assert.Nil(t, a.LoadFile("testdata/alias.yaml")) - assert.Equal(t, 2, len(a.Alias)) + assert.Nil(t, a.Load("testdata/aliases/plain.yaml")) + assert.Equal(t, 56, len(a.Alias)) } func TestAliasesSave(t *testing.T) { - a := config.NewAliases() - a.Alias["test"] = "fred" - a.Alias["blee"] = "duh" + assert.NoError(t, data.EnsureFullPath("/tmp/test-aliases", data.DefaultDirMod)) + defer assert.NoError(t, os.RemoveAll("/tmp/test-aliases")) - assert.Nil(t, a.SaveAliases("/tmp/a.yaml")) - assert.Nil(t, a.LoadFile("/tmp/a.yaml")) - assert.Equal(t, 2, len(a.Alias)) + config.AppAliasesFile = "/tmp/test-aliases/aliases.yaml" + a := testAliases() + c := len(a.Alias) + + assert.Equal(t, c, len(a.Alias)) + assert.Nil(t, a.Save()) + assert.Nil(t, a.LoadFile("/tmp/test-aliases/aliases.yaml")) + assert.Equal(t, c, len(a.Alias)) +} + +// Helpers... + +func testAliases() *config.Aliases { + a := config.NewAliases() + a.Alias["a1"] = "gvr1" + a.Alias["a11"] = "gvr1" + a.Alias["a2"] = "gvr2" + a.Alias["a3"] = "gvr3" + + return a } diff --git a/internal/config/benchmark_test.go b/internal/config/benchmark_test.go index 7a4b54ca..cd80d442 100644 --- a/internal/config/benchmark_test.go +++ b/internal/config/benchmark_test.go @@ -35,14 +35,14 @@ func TestBenchLoad(t *testing.T) { coCount int }{ "goodConfig": { - "testdata/b_good.yaml", + "testdata/benchmarks/b_good.yaml", 2, 1000, 2, 0, }, "malformed": { - "testdata/b_toast.yaml", + "testdata/benchmarks/b_toast.yaml", 1, 200, 0, @@ -103,7 +103,7 @@ func TestBenchServiceLoad(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - b, err := NewBench("testdata/b_good.yaml") + b, err := NewBench("testdata/benchmarks/b_good.yaml") assert.Nil(t, err) assert.Equal(t, 2, len(b.Benchmarks.Services)) @@ -122,11 +122,11 @@ func TestBenchServiceLoad(t *testing.T) { } func TestBenchReLoad(t *testing.T) { - b, err := NewBench("testdata/b_containers.yaml") + b, err := NewBench("testdata/benchmarks/b_containers.yaml") assert.Nil(t, err) assert.Equal(t, 2, b.Benchmarks.Defaults.C) - assert.Nil(t, b.Reload("testdata/b_containers_1.yaml")) + assert.NoError(t, b.Reload("testdata/benchmarks/b_containers_1.yaml")) assert.Equal(t, 20, b.Benchmarks.Defaults.C) } @@ -174,7 +174,7 @@ func TestBenchContainerLoad(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - b, err := NewBench("testdata/b_containers.yaml") + b, err := NewBench("testdata/benchmarks/b_containers.yaml") assert.Nil(t, err) assert.Equal(t, 2, len(b.Benchmarks.Services)) diff --git a/internal/config/color.go b/internal/config/color.go index 59fd4d0a..17fb595f 100644 --- a/internal/config/color.go +++ b/internal/config/color.go @@ -17,6 +17,22 @@ const ( TransparentColor Color = "-" ) +// Colors tracks multiple colors. +type Colors []Color + +// Colors converts series string colors to colors. +func (c Colors) Colors() []tcell.Color { + cc := make([]tcell.Color, 0, len(c)) + for _, color := range c { + cc = append(cc, color.Color()) + } + + return cc +} + +// Color represents a color. +type Color string + // NewColor returns a new color. func NewColor(c string) Color { return Color(c) @@ -50,12 +66,3 @@ func (c Color) Color() tcell.Color { return tcell.GetColor(string(c)).TrueColor() } - -// Colors converts series string colors to colors. -func (c Colors) Colors() []tcell.Color { - cc := make([]tcell.Color, 0, len(c)) - for _, color := range c { - cc = append(cc, color.Color()) - } - return cc -} diff --git a/internal/config/color_test.go b/internal/config/color_test.go new file mode 100644 index 00000000..09d41ae8 --- /dev/null +++ b/internal/config/color_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/derailed/tcell/v2" + "github.com/stretchr/testify/assert" +) + +func TestColors(t *testing.T) { + uu := map[string]struct { + cc []string + ee []tcell.Color + }{ + "empty": { + ee: []tcell.Color{}, + }, + "default": { + cc: []string{"default"}, + ee: []tcell.Color{tcell.ColorDefault}, + }, + "multi": { + cc: []string{ + "default", + "transparent", + "blue", + "green", + }, + ee: []tcell.Color{ + tcell.ColorDefault, + tcell.ColorDefault, + tcell.ColorBlue.TrueColor(), + tcell.ColorGreen.TrueColor(), + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cc := make(config.Colors, 0, len(u.cc)) + for _, c := range u.cc { + cc = append(cc, config.NewColor(c)) + } + assert.Equal(t, u.ee, cc.Colors()) + }) + } +} + +func TestColorString(t *testing.T) { + uu := map[string]struct { + c string + e string + }{ + "empty": { + e: "-", + }, + "default": { + c: "default", + e: "-", + }, + "transparent": { + c: "-", + e: "-", + }, + "blue": { + c: "blue", + e: "#0000ff", + }, + "lightgray": { + c: "lightgray", + e: "#d3d3d3", + }, + "hex": { + c: "#00ff00", + e: "#00ff00", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := config.NewColor(u.c) + assert.Equal(t, u.e, c.String()) + }) + } +} + +func TestColorToColor(t *testing.T) { + uu := map[string]struct { + c string + e tcell.Color + }{ + "default": { + c: "default", + e: tcell.ColorDefault, + }, + "transparent": { + c: "-", + e: tcell.ColorDefault, + }, + "aqua": { + c: "aqua", + e: tcell.ColorAqua.TrueColor(), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := config.NewColor(u.c) + assert.Equal(t, u.e, c.Color()) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c4b781b2..d1ccc302 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,12 +6,10 @@ package config import ( "fmt" "os" - "path/filepath" - "strings" - "github.com/adrg/xdg" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -19,25 +17,11 @@ import ( // Config tracks K9s configuration options. type Config struct { - K9s *K9s `yaml:"k9s"` + K9s *K9s `yaml:"k9s" json:"k9s"` conn client.Connection settings data.KubeSettings } -// K9sHome returns k9s configs home directory. -func K9sHome() string { - if isEnvSet(K9sEnvConfigDir) { - return os.Getenv(K9sEnvConfigDir) - } - - xdgK9sHome, err := xdg.ConfigFile(AppName) - if err != nil { - log.Fatal().Err(err).Msg("Unable to create configuration directory for k9s") - } - - return xdgK9sHome -} - // NewConfig creates a new default config. func NewConfig(ks data.KubeSettings) *Config { return &Config{ @@ -46,6 +30,16 @@ func NewConfig(ks data.KubeSettings) *Config { } } +// ContextHotKeysPath returns a context specific hotkeys file spec. +func (c *Config) ContextHotkeysPath() string { + ct, err := c.K9s.ActiveContext() + if err != nil { + return "" + } + + return AppContextHotkeysFile(ct.ClusterName, c.K9s.activeContextName) +} + // ContextAliasesPath returns a context specific aliases file spec. func (c *Config) ContextAliasesPath() string { ct, err := c.K9s.ActiveContext() @@ -53,13 +47,14 @@ func (c *Config) ContextAliasesPath() string { return "" } - return AppContextAliasesFile(ct.ClusterName, c.K9s.activeContextName) + return AppContextAliasesFile(ct.GetClusterName(), c.K9s.activeContextName) } // ContextPluginsPath returns a context specific plugins file spec. func (c *Config) ContextPluginsPath() string { ct, err := c.K9s.ActiveContext() if err != nil { + log.Error().Err(err).Msgf("active context load failed") return "" } @@ -68,7 +63,10 @@ func (c *Config) ContextPluginsPath() string { // Refine the configuration based on cli args. func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, cfg *client.Config) error { - if isSet(flags.Context) { + if flags == nil { + return nil + } + if isStringSet(flags.Context) { if _, err := c.K9s.ActivateContext(*flags.Context); err != nil { return err } @@ -88,7 +86,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c switch { case k9sFlags != nil && IsBoolSet(k9sFlags.AllNamespaces): ns = client.NamespaceAll - case isSet(flags.Namespace): + case isStringSet(flags.Namespace): ns = *flags.Namespace default: nss, err := c.K9s.ActiveContextNamespace() @@ -104,7 +102,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c return err } - return data.EnsureDirPath(c.K9s.GetScreenDumpDir(), data.DefaultDirMod) + return data.EnsureDirPath(c.K9s.AppScreenDumpDir(), data.DefaultDirMod) } // Reset resets the context to the new current context/cluster. @@ -115,7 +113,7 @@ func (c *Config) Reset() { func (c *Config) SetCurrentContext(n string) (*data.Context, error) { ct, err := c.K9s.ActivateContext(n) if err != nil { - return nil, fmt.Errorf("set current context %q failed: %w", n, err) + return nil, fmt.Errorf("set current context failed. %w", err) } return ct, nil @@ -138,21 +136,13 @@ func (c *Config) ActiveNamespace() string { return ns } -// ValidateFavorites ensure favorite ns are legit. -func (c *Config) ValidateFavorites() { - ct, err := c.K9s.ActiveContext() - if err != nil { - return - } - ct.Validate(c.conn, c.settings) -} - // FavNamespaces returns fav namespaces in the current context. func (c *Config) FavNamespaces() []string { ct, err := c.K9s.ActiveContext() if err != nil { return nil } + ct.Validate(c.conn, c.settings) return ct.Namespace.Favorites } @@ -209,23 +199,26 @@ func (c *Config) ActiveContextName() string { return c.K9s.activeContextName } +func (c *Config) Merge(c1 *Config) { + c.K9s.Merge(c1.K9s) +} + // Load loads K9s configuration from file. func (c *Config) Load(path string) error { - f, err := os.ReadFile(path) + bb, err := os.ReadFile(path) if err != nil { return err } + if err := data.JSONValidator.Validate(json.K9sSchema, bb); err != nil { + return fmt.Errorf("k9s config file %q load failed:\n%w", path, err) + } var cfg Config - if err := yaml.Unmarshal(f, &cfg); err != nil { + if err := yaml.Unmarshal(bb, &cfg); err != nil { return err } - if cfg.K9s != nil { - c.K9s.Refine(cfg.K9s) - } - if c.K9s.Logger == nil { - c.K9s.Logger = NewLogger() - } + c.Merge(&cfg) + return nil } @@ -235,7 +228,11 @@ func (c *Config) Save() error { if err := c.K9s.Save(); err != nil { return err } - return c.SaveFile(AppConfigFile) + if _, err := os.Stat(AppConfigFile); os.IsNotExist(err) { + return c.SaveFile(AppConfigFile) + } + + return nil } // SaveFile K9s configuration to disk. @@ -248,48 +245,26 @@ func (c *Config) SaveFile(path string) error { log.Error().Msgf("[Config] Unable to save K9s config file: %v", err) return err } + return os.WriteFile(path, cfg, 0644) } // Validate the configuration. func (c *Config) Validate() { + if c.K9s == nil { + c.K9s = NewK9s(c.conn, c.settings) + } + c.K9s.Validate(c.conn, c.settings) } -// Dump debug... +// Dump for debug... func (c *Config) Dump(msg string) { ct, err := c.K9s.ActiveContext() - if err != nil { - log.Debug().Msgf("Current Contexts: %s\n", ct.ClusterName) + if err == nil { + bb, _ := yaml.Marshal(ct) + fmt.Printf("Dump: %q\n%s\n", msg, string(bb)) + } else { + fmt.Println("BOOM!", err) } } - -// YamlExtension tries to find the correct extension for a YAML file -func YamlExtension(path string) string { - if !isYamlFile(path) { - log.Error().Msgf("Config: File %s is not a yaml file", path) - return path - } - - // Strip any extension, if there is no extension the path will remain unchanged - path = strings.TrimSuffix(path, filepath.Ext(path)) - result := path + ".yml" - - if _, err := os.Stat(result); os.IsNotExist(err) { - return path + ".yaml" - } - - return result -} - -// ---------------------------------------------------------------------------- -// Helpers... - -func isSet(s *string) bool { - return s != nil && len(*s) > 0 -} - -func isYamlFile(file string) bool { - ext := filepath.Ext(file) - return ext == ".yml" || ext == ".yaml" -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 280fac4a..21bdd564 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,12 +4,16 @@ package config_test import ( + "errors" "fmt" "os" "path/filepath" "testing" + "github.com/adrg/xdg" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" m "github.com/petergtz/pegomock" "github.com/rs/zerolog" @@ -21,37 +25,358 @@ func init() { zerolog.SetGlobalLevel(zerolog.FatalLevel) } -func TestConfigRefine(t *testing.T) { +func TestConfigSave(t *testing.T) { + config.AppConfigFile = "/tmp/k9s-test/k9s.yaml" + sd := "/tmp/k9s-test/screen-dumps" + cl, ct := "cl-1", "ct-1-1" + _ = os.RemoveAll(("/tmp/k9s-test")) + + uu := map[string]struct { + ct string + flags *genericclioptions.ConfigFlags + k9sFlags *config.Flags + }{ + "happy": { + ct: "ct-1-1", + flags: &genericclioptions.ConfigFlags{ + ClusterName: &cl, + Context: &ct, + }, + k9sFlags: &config.Flags{ + ScreenDumpDir: &sd, + }, + }, + } + + for k := range uu { + xdg.Reload() + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, err := c.K9s.ActivateContext(u.ct) + assert.NoError(t, err) + if u.flags != nil { + c.K9s.Override(u.k9sFlags) + assert.NoError(t, c.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags))) + } + assert.NoError(t, c.Save()) + bb, err := os.ReadFile(config.AppConfigFile) + assert.NoError(t, err) + ee, err := os.ReadFile("testdata/configs/default.yaml") + assert.NoError(t, err) + assert.Equal(t, string(ee), string(bb)) + }) + } +} + +func TestSetActiveView(t *testing.T) { var ( - cfgFile = "testdata/kubeconfig-test.yaml" - ctx, cluster, ns = "ct-1-1", "cl-1", "ns-1" + cfgFile = "testdata/kubes/test.yaml" + view = "dp" ) uu := map[string]struct { - flags *genericclioptions.ConfigFlags - issue bool - context, cluster, namespace string + ct string + flags *genericclioptions.ConfigFlags + k9sFlags *config.Flags + view string + e string }{ - "overrideNS": { - flags: &genericclioptions.ConfigFlags{ - KubeConfig: &cfgFile, - Context: &ctx, - ClusterName: &cluster, - Namespace: &ns, - }, - issue: false, - context: ctx, - cluster: cluster, - namespace: ns, + "empty": { + view: data.DefaultView, + e: data.DefaultView, }, - "badContext": { + "not-exists": { + ct: "fred", + view: data.DefaultView, + e: data.DefaultView, + }, + "happy": { + ct: "ct-1-1", + view: "xray", + e: "xray", + }, + "cli-override": { flags: &genericclioptions.ConfigFlags{ - KubeConfig: &cfgFile, - Context: &ns, - ClusterName: &cluster, - Namespace: &ns, + KubeConfig: &cfgFile, }, - issue: true, + k9sFlags: &config.Flags{ + Command: &view, + }, + ct: "ct-1-1", + view: "xray", + e: "dp", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + if u.flags != nil { + assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) + c.K9s.Override(u.k9sFlags) + } + c.SetActiveView(u.view) + assert.Equal(t, u.e, c.ActiveView()) + }) + } +} + +func TestActiveContextName(t *testing.T) { + var ( + cfgFile = "testdata/kubes/test.yaml" + ct2 = "ct-1-2" + ) + + uu := map[string]struct { + flags *genericclioptions.ConfigFlags + k9sFlags *config.Flags + ct string + e string + }{ + "empty": {}, + "happy": { + ct: "ct-1-1", + e: "ct-1-1", + }, + "cli-override": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Context: &ct2, + }, + k9sFlags: &config.Flags{}, + ct: "ct-1-1", + e: "ct-1-2", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + if u.flags != nil { + assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) + c.K9s.Override(u.k9sFlags) + } + assert.Equal(t, u.e, c.ActiveContextName()) + }) + } +} + +func TestActiveView(t *testing.T) { + var ( + cfgFile = "testdata/kubes/test.yaml" + view = "dp" + ) + + uu := map[string]struct { + ct string + flags *genericclioptions.ConfigFlags + k9sFlags *config.Flags + e string + }{ + "empty": { + e: data.DefaultView, + }, + "not-exists": { + ct: "fred", + e: data.DefaultView, + }, + "happy": { + ct: "ct-1-1", + e: data.DefaultView, + }, + "cli-override": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + }, + k9sFlags: &config.Flags{ + Command: &view, + }, + e: "dp", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + if u.flags != nil { + assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) + c.K9s.Override(u.k9sFlags) + } + assert.Equal(t, u.e, c.ActiveView()) + }) + } +} + +func TestFavNamespaces(t *testing.T) { + uu := map[string]struct { + ct string + e []string + }{ + "empty": {}, + "not-exists": { + ct: "fred", + }, + "happy": { + ct: "ct-1-1", + e: []string{client.DefaultNamespace}, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + assert.Equal(t, u.e, c.FavNamespaces()) + }) + } +} + +func TestContextAliasesPath(t *testing.T) { + uu := map[string]struct { + ct string + e string + }{ + "empty": {}, + "not-exists": { + ct: "fred", + }, + "happy": { + ct: "ct-1-1", + e: "/tmp/test/cl-1/ct-1-1/aliases.yaml", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + assert.Equal(t, u.e, c.ContextAliasesPath()) + }) + } +} + +func TestContextPluginsPath(t *testing.T) { + uu := map[string]struct { + ct string + e string + }{ + "empty": {}, + "happy": { + ct: "ct-1-1", + e: "/tmp/test/cl-1/ct-1-1/plugins.yaml", + }, + "not-exists": { + ct: "fred", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + c := mock.NewMockConfig() + _, _ = c.K9s.ActivateContext(u.ct) + assert.Equal(t, u.e, c.ContextPluginsPath()) + }) + } +} + +func TestConfigLoader(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "happy": { + f: "testdata/configs/k9s.yaml", + }, + "toast": { + f: "testdata/configs/k9s_toast.yaml", + err: `k9s config file "testdata/configs/k9s_toast.yaml" load failed: +Additional property disablePodCounts is not allowed +Additional property shellPods is not allowed +Invalid type. Expected: boolean, given: string`, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := config.NewConfig(nil) + if err := cfg.Load(u.f); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} + +func TestConfigSetCurrentContext(t *testing.T) { + uu := map[string]struct { + cl, ct string + err string + }{ + "happy": { + ct: "ct-1-2", + cl: "cl-1", + }, + "toast": { + ct: "fred", + cl: "cl-1", + err: `set current context failed. no context found for: "fred"`, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := mock.NewMockConfig() + ct, err := cfg.SetCurrentContext(u.ct) + if err != nil { + assert.Equal(t, u.err, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, u.cl, ct.ClusterName) + }) + } +} + +func TestConfigCurrentContext(t *testing.T) { + var ( + cfgFile = "testdata/kubes/test.yaml" + ct2 = "ct-1-2" + ) + + uu := map[string]struct { + flags *genericclioptions.ConfigFlags + err error + context string + cluster string + namespace string + }{ + "override-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Context: &ct2, + }, + cluster: "cl-1", + context: "ct-1-2", + namespace: "ns-2", + }, + "use-current-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: client.DefaultNamespace, }, } @@ -61,8 +386,128 @@ func TestConfigRefine(t *testing.T) { cfg := mock.NewMockConfig() err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags)) - if u.issue { - assert.NotNil(t, err) + assert.NoError(t, err) + ct, err := cfg.CurrentContext() + assert.NoError(t, err) + assert.Equal(t, u.cluster, ct.ClusterName) + assert.Equal(t, u.namespace, ct.Namespace.Active) + }) + } +} + +func TestConfigRefine(t *testing.T) { + var ( + cfgFile = "testdata/kubes/test.yaml" + cl1 = "cl-1" + ct2 = "ct-1-2" + ns1, ns2, nsx = "ns-1", "ns-2", "ns-x" + true = true + ) + + uu := map[string]struct { + flags *genericclioptions.ConfigFlags + k9sFlags *config.Flags + err error + context string + cluster string + namespace string + }{ + "no-override": { + namespace: "default", + }, + "override-cluster": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + ClusterName: &cl1, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: client.DefaultNamespace, + }, + "override-cluster-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + ClusterName: &cl1, + Context: &ct2, + }, + cluster: "cl-1", + context: "ct-1-2", + namespace: "ns-2", + }, + "override-bad-cluster": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + ClusterName: &ns1, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: client.DefaultNamespace, + }, + "override-ns": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Namespace: &ns2, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: "ns-2", + }, + "all-ns": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Namespace: &ns2, + }, + k9sFlags: &config.Flags{ + AllNamespaces: &true, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: client.NamespaceAll, + }, + + "override-bad-ns": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Namespace: &nsx, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: "ns-x", + }, + "override-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Context: &ct2, + }, + cluster: "cl-1", + context: "ct-1-2", + namespace: "ns-2", + }, + "override-bad-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + Context: &ns1, + }, + err: errors.New(`no context found for: "ns-1"`), + }, + "use-current-context": { + flags: &genericclioptions.ConfigFlags{ + KubeConfig: &cfgFile, + }, + cluster: "cl-1", + context: "ct-1-1", + namespace: client.DefaultNamespace, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := mock.NewMockConfig() + + err := cfg.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags)) + if err != nil { + assert.Equal(t, u.err, err) } else { assert.Nil(t, err) assert.Equal(t, u.context, cfg.K9s.ActiveContextName()) @@ -76,35 +521,29 @@ func TestConfigValidate(t *testing.T) { cfg := mock.NewMockConfig() cfg.SetConnection(mock.NewMockConnection()) - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) cfg.Validate() } func TestConfigLoad(t *testing.T) { cfg := mock.NewMockConfig() - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) assert.Equal(t, 2, cfg.K9s.RefreshRate) - assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize) assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount) -} - -func TestConfigLoadOldCfg(t *testing.T) { - cfg := mock.NewMockConfig() - - assert.Nil(t, cfg.Load("testdata/k9s_old.yaml")) + assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize) } func TestConfigLoadCrap(t *testing.T) { cfg := mock.NewMockConfig() - assert.NotNil(t, cfg.Load("testdata/k9s_not_there.yaml")) + assert.NotNil(t, cfg.Load("testdata/configs/k9s_not_there.yaml")) } func TestConfigSaveFile(t *testing.T) { cfg := mock.NewMockConfig() - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) cfg.K9s.RefreshRate = 100 cfg.K9s.ReadOnly = true @@ -113,26 +552,28 @@ func TestConfigSaveFile(t *testing.T) { cfg.Validate() path := filepath.Join("/tmp", "k9s.yaml") - err := cfg.SaveFile(path) - assert.Nil(t, err) + assert.NoError(t, cfg.SaveFile(path)) raw, err := os.ReadFile(path) assert.Nil(t, err) - assert.Equal(t, expectedConfig, string(raw)) + ee, err := os.ReadFile("testdata/configs/expected.yaml") + assert.Nil(t, err) + assert.Equal(t, string(ee), string(raw)) } func TestConfigReset(t *testing.T) { cfg := mock.NewMockConfig() - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) cfg.Reset() cfg.Validate() path := filepath.Join("/tmp", "k9s.yaml") - err := cfg.SaveFile(path) - assert.Nil(t, err) + assert.NoError(t, cfg.SaveFile(path)) - raw, err := os.ReadFile(path) + bb, err := os.ReadFile(path) assert.Nil(t, err) - assert.Equal(t, resetConfig, string(raw)) + ee, err := os.ReadFile("testdata/configs/k9s.yaml") + assert.Nil(t, err) + assert.Equal(t, string(ee), string(bb)) } // Helpers... @@ -147,86 +588,86 @@ func TestSetup(t *testing.T) { // ---------------------------------------------------------------------------- // Test Data... -var expectedConfig = `k9s: - liveViewAutoRefresh: true - screenDumpDir: /tmp - refreshRate: 100 - maxConnRetry: 5 - readOnly: true - noExitOnCtrlC: false - ui: - enableMouse: false - headless: false - logoless: false - crumbsless: false - noIcons: false - skipLatestRevCheck: false - disablePodCounting: false - shellPod: - image: busybox:1.35.0 - namespace: default - limits: - cpu: 100m - memory: 100Mi - imageScans: - enable: false - exclusions: - namespaces: [] - labels: {} - logger: - tail: 500 - buffer: 800 - sinceSeconds: -1 - fullScreenLogs: false - textWrap: false - showTime: false - thresholds: - cpu: - critical: 90 - warn: 70 - memory: - critical: 90 - warn: 70 -` +// var expectedConfig = `k9s: +// liveViewAutoRefresh: true +// screenDumpDir: /tmp/screen-dumps +// refreshRate: 100 +// maxConnRetry: 5 +// readOnly: true +// noExitOnCtrlC: false +// ui: +// enableMouse: false +// headless: false +// logoless: false +// crumbsless: false +// noIcons: false +// skipLatestRevCheck: false +// disablePodCounting: false +// shellPod: +// image: busybox:1.35.0 +// namespace: default +// limits: +// cpu: 100m +// memory: 100Mi +// imageScans: +// enable: false +// exclusions: +// namespaces: [] +// labels: {} +// logger: +// tail: 500 +// buffer: 800 +// sinceSeconds: -1 +// fullScreen: false +// textWrap: false +// showTime: false +// thresholds: +// cpu: +// critical: 90 +// warn: 70 +// memory: +// critical: 90 +// warn: 70 +// ` -var resetConfig = `k9s: - liveViewAutoRefresh: true - screenDumpDir: /tmp - refreshRate: 2 - maxConnRetry: 5 - readOnly: false - noExitOnCtrlC: false - ui: - enableMouse: false - headless: false - logoless: false - crumbsless: false - noIcons: false - skipLatestRevCheck: false - disablePodCounting: false - shellPod: - image: busybox:1.35.0 - namespace: default - limits: - cpu: 100m - memory: 100Mi - imageScans: - enable: false - exclusions: - namespaces: [] - labels: {} - logger: - tail: 200 - buffer: 2000 - sinceSeconds: -1 - fullScreenLogs: false - textWrap: false - showTime: false - thresholds: - cpu: - critical: 90 - warn: 70 - memory: - critical: 90 - warn: 70 -` +// var resetConfig = `k9s: +// liveViewAutoRefresh: true +// screenDumpDir: /tmp/screen-dumps +// refreshRate: 2 +// maxConnRetry: 5 +// readOnly: false +// noExitOnCtrlC: false +// ui: +// enableMouse: false +// headless: false +// logoless: false +// crumbsless: false +// noIcons: false +// skipLatestRevCheck: false +// disablePodCounting: false +// shellPod: +// image: busybox:1.35.0 +// namespace: default +// limits: +// cpu: 100m +// memory: 100Mi +// imageScans: +// enable: false +// exclusions: +// namespaces: [] +// labels: {} +// logger: +// tail: 200 +// buffer: 2000 +// sinceSeconds: -1 +// fullScreen: false +// textWrap: false +// showTime: false +// thresholds: +// cpu: +// critical: 90 +// warn: 70 +// memory: +// critical: 90 +// warn: 70 +// ` diff --git a/internal/config/data/config.go b/internal/config/data/config.go index 3faf5fcc..234adc31 100644 --- a/internal/config/data/config.go +++ b/internal/config/data/config.go @@ -18,27 +18,33 @@ type Config struct { Context *Context `yaml:"k9s"` } +// NewConfig returns a new config. func NewConfig(ct *api.Context) *Config { return &Config{ Context: NewContextFromConfig(ct), } } +// Validate ensures config is in norms. func (c *Config) Validate(conn client.Connection, ks KubeSettings) { + if c.Context == nil { + c.Context = NewContext() + } c.Context.Validate(conn, ks) } +// Dump used for debugging. func (c *Config) Dump(w io.Writer) { bb, _ := yaml.Marshal(&c) fmt.Fprintf(w, "%s\n", string(bb)) } +// Save saves the config to disk. func (c *Config) Save(path string) error { if err := EnsureDirPath(path, DefaultDirMod); err != nil { return err } - cfg, err := yaml.Marshal(c) if err != nil { return err diff --git a/internal/config/data/context.go b/internal/config/data/context.go index 8f38676e..32a77791 100644 --- a/internal/config/data/context.go +++ b/internal/config/data/context.go @@ -4,6 +4,8 @@ package data import ( + "sync" + "github.com/derailed/k9s/internal/client" "k8s.io/client-go/tools/clientcmd/api" ) @@ -14,12 +16,13 @@ const DefaultPFAddress = "localhost" // Context tracks K9s context configuration. type Context struct { ClusterName string `yaml:"cluster,omitempty"` - ReadOnly bool `yaml:"readOnly"` + ReadOnly *bool `yaml:"readOnly,omitempty"` Skin string `yaml:"skin,omitempty"` Namespace *Namespace `yaml:"namespace"` View *View `yaml:"view"` FeatureGates FeatureGates `yaml:"featureGates"` PortForwardAddress string `yaml:"portForwardAddress"` + mx sync.RWMutex } // NewContext creates a new cluster configuration. @@ -32,6 +35,7 @@ func NewContext() *Context { } } +// NewContextFromConfig returns a config based on a kubecontext. func NewContextFromConfig(cfg *api.Context) *Context { return &Context{ Namespace: NewActiveNamespace(cfg.Namespace), @@ -42,12 +46,32 @@ func NewContextFromConfig(cfg *api.Context) *Context { } } -// Validate a context config. +// NewContextFromKubeConfig returns a new instance based on kubesettings or an error. +func NewContextFromKubeConfig(ks KubeSettings) (*Context, error) { + ct, err := ks.CurrentContext() + if err != nil { + return nil, err + } + + return NewContextFromConfig(ct), nil +} + +func (c *Context) GetClusterName() string { + c.mx.RLock() + defer c.mx.RUnlock() + + return c.ClusterName +} + +// Validate ensures a context config is tip top. func (c *Context) Validate(conn client.Connection, ks KubeSettings) { + c.mx.Lock() + defer c.mx.Unlock() + if c.PortForwardAddress == "" { c.PortForwardAddress = DefaultPFAddress } - if cl, err := ks.CurrentClusterName(); err != nil { + if cl, err := ks.CurrentClusterName(); err == nil { c.ClusterName = cl } diff --git a/internal/config/data/dir.go b/internal/config/data/dir.go index d20e5456..8e6c8b17 100644 --- a/internal/config/data/dir.go +++ b/internal/config/data/dir.go @@ -5,9 +5,11 @@ package data import ( "errors" + "fmt" "os" "path/filepath" + "github.com/derailed/k9s/internal/config/json" "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" "k8s.io/client-go/tools/clientcmd/api" @@ -60,6 +62,10 @@ func (d *Dir) loadConfig(path string) (*Config, error) { if err != nil { return nil, err } + if err := JSONValidator.Validate(json.ContextSchema, bb); err != nil { + return nil, fmt.Errorf("validation failed for %q: %w", path, err) + } + var cfg Config if err := yaml.Unmarshal(bb, &cfg); err != nil { return nil, err diff --git a/internal/config/data/helpers_test.go b/internal/config/data/helpers_test.go index ed82e458..0b41d432 100644 --- a/internal/config/data/helpers_test.go +++ b/internal/config/data/helpers_test.go @@ -77,7 +77,7 @@ func TestEnsureDirPathNone(t *testing.T) { func TestEnsureDirPathNoOpt(t *testing.T) { var mod os.FileMode = 0744 dir := filepath.Join("/tmp", "k9s-test") - os.Remove(dir) + assert.NoError(t, os.RemoveAll(dir)) assert.NoError(t, os.Mkdir(dir, mod)) path := filepath.Join(dir, "duh.yaml") diff --git a/internal/config/data/types.go b/internal/config/data/types.go index d798f77b..5d7c0214 100644 --- a/internal/config/data/types.go +++ b/internal/config/data/types.go @@ -6,9 +6,13 @@ package data import ( "os" + "github.com/derailed/k9s/internal/config/json" "k8s.io/client-go/tools/clientcmd/api" ) +// JSONValidator validate yaml configurations. +var JSONValidator = json.NewValidator() + const ( // DefaultDirMod default unix perms for k9s directory. DefaultDirMod os.FileMode = 0744 diff --git a/internal/config/files.go b/internal/config/files.go index 73ee51b6..b4b1e02b 100644 --- a/internal/config/files.go +++ b/internal/config/files.go @@ -80,7 +80,7 @@ var ( AppHotKeysFile string ) -// InitLogsLoc initializes K9s logs location. +// InitLogLoc initializes K9s logs location. func InitLogLoc() error { var appLogDir string switch { @@ -273,5 +273,9 @@ func EnsureHotkeysCfgFile() (string, error) { // SkinFileFromName generate skin file path from spec. func SkinFileFromName(n string) string { + if n == "" { + n = "stock" + } + return filepath.Join(AppSkinsDir, n+".yaml") } diff --git a/internal/config/files_int_test.go b/internal/config/files_int_test.go new file mode 100644 index 00000000..e08766f1 --- /dev/null +++ b/internal/config/files_int_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/adrg/xdg" + "github.com/derailed/k9s/internal/config/data" + "github.com/stretchr/testify/assert" +) + +func Test_initXDGLocs(t *testing.T) { + tmp, err := UserTmpDir() + assert.NoError(t, err) + + os.Unsetenv("XDG_CONFIG_HOME") + os.Unsetenv("XDG_CACHE_HOME") + os.Unsetenv("XDG_STATE_HOME") + os.Unsetenv("XDG_DATA_HOME") + + os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "k9s-xdg", "config")) + os.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, "k9s-xdg", "cache")) + os.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "k9s-xdg", "state")) + os.Setenv("XDG_DATA_HOME", filepath.Join(tmp, "k9s-xdg", "data")) + xdg.Reload() + + uu := map[string]struct { + configDir string + configFile string + benchmarksDir string + contextsDir string + contextHotkeysFile string + contextConfig string + dumpsDir string + benchDir string + hkFile string + }{ + "check-env": { + configDir: filepath.Join(tmp, "k9s-xdg", "config", "k9s"), + configFile: filepath.Join(tmp, "k9s-xdg", "config", "k9s", data.MainConfigFile), + benchmarksDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "benchmarks"), + contextsDir: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters"), + contextHotkeysFile: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters", "cl-1", "ct-1-1", "hotkeys.yaml"), + contextConfig: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters", "cl-1", "ct-1-1", data.MainConfigFile), + dumpsDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "screen-dumps", "cl-1", "ct-1-1"), + benchDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "benchmarks", "cl-1", "ct-1-1"), + hkFile: filepath.Join(tmp, "k9s-xdg", "config", "k9s", "hotkeys.yaml"), + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.NoError(t, initXDGLocs()) + assert.Equal(t, u.configDir, AppConfigDir) + assert.Equal(t, u.configFile, AppConfigFile) + assert.Equal(t, u.benchmarksDir, AppBenchmarksDir) + assert.Equal(t, u.contextsDir, AppContextsDir) + assert.Equal(t, u.contextHotkeysFile, AppContextHotkeysFile("cl-1", "ct-1-1")) + assert.Equal(t, u.contextConfig, AppContextConfig("cl-1", "ct-1-1")) + dir, err := DumpsDir("cl-1", "ct-1-1") + assert.NoError(t, err) + assert.Equal(t, u.dumpsDir, dir) + bdir, err := EnsureBenchmarksDir("cl-1", "ct-1-1") + assert.NoError(t, err) + assert.Equal(t, u.benchDir, bdir) + hk, err := EnsureHotkeysCfgFile() + assert.NoError(t, err) + assert.Equal(t, u.hkFile, hk) + }) + } +} diff --git a/internal/config/files_test.go b/internal/config/files_test.go index fb946ff2..02d53e46 100644 --- a/internal/config/files_test.go +++ b/internal/config/files_test.go @@ -58,10 +58,11 @@ func TestInitLogLoc(t *testing.T) { }) } } + func TestEnsureBenchmarkCfg(t *testing.T) { os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config") assert.NoError(t, config.InitLocs()) - defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir)) + defer assert.NoError(t, os.RemoveAll("/tmp/test-config")) assert.NoError(t, data.EnsureFullPath("/tmp/test-config/clusters/cl-1/ct-2", data.DefaultDirMod)) assert.NoError(t, os.WriteFile("/tmp/test-config/clusters/cl-1/ct-2/benchmarks.yaml", []byte{}, data.DefaultFileMod)) @@ -95,3 +96,28 @@ func TestEnsureBenchmarkCfg(t *testing.T) { }) } } + +func TestSkinFileFromName(t *testing.T) { + config.AppSkinsDir = "/tmp/k9s-test/skins" + defer assert.NoError(t, os.RemoveAll("/tmp/k9s-test/skins")) + + uu := map[string]struct { + n string + e string + }{ + "empty": { + e: "/tmp/k9s-test/skins/stock.yaml", + }, + "happy": { + n: "fred-blee", + e: "/tmp/k9s-test/skins/fred-blee.yaml", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, config.SkinFileFromName(u.n)) + }) + } +} diff --git a/internal/config/flags_test.go b/internal/config/flags_test.go new file mode 100644 index 00000000..907c4795 --- /dev/null +++ b/internal/config/flags_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestNewFlags(t *testing.T) { + config.AppDumpsDir = "/tmp/k9s-test/screen-dumps" + config.AppLogFile = "/tmp/k9s-test/k9s.log" + + f := config.NewFlags() + assert.Equal(t, 2, *f.RefreshRate) + assert.Equal(t, "info", *f.LogLevel) + assert.Equal(t, "/tmp/k9s-test/k9s.log", *f.LogFile) + assert.Equal(t, config.AppDumpsDir, *f.ScreenDumpDir) + assert.Empty(t, *f.Command) + assert.False(t, *f.Headless) + assert.False(t, *f.Logoless) + assert.False(t, *f.AllNamespaces) + assert.False(t, *f.ReadOnly) + assert.False(t, *f.Write) + assert.False(t, *f.Crumbsless) +} diff --git a/internal/config/helpers.go b/internal/config/helpers.go index 752644d7..af04c2fb 100644 --- a/internal/config/helpers.go +++ b/internal/config/helpers.go @@ -13,6 +13,19 @@ import ( v1 "k8s.io/api/core/v1" ) +func isBoolSet(b *bool) bool { + return b != nil && *b +} + +func isStringSet(s *string) bool { + return s != nil && len(*s) > 0 +} + +func isYamlFile(file string) bool { + ext := filepath.Ext(file) + return ext == ".yml" || ext == ".yaml" +} + // isEnvSet checks if env var is set. func isEnvSet(env string) bool { return os.Getenv(env) != "" diff --git a/internal/config/hotkey.go b/internal/config/hotkey.go index b98c7c43..4aec92ac 100644 --- a/internal/config/hotkey.go +++ b/internal/config/hotkey.go @@ -4,8 +4,11 @@ package config import ( + "fmt" "os" + "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" "gopkg.in/yaml.v2" ) @@ -30,19 +33,32 @@ func NewHotKeys() HotKeys { } // Load K9s plugins. -func (h HotKeys) Load() error { - return h.LoadHotKeys(AppHotKeysFile) +func (h HotKeys) Load(path string) error { + if err := h.LoadHotKeys(AppHotKeysFile); err != nil { + return err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + + return h.LoadHotKeys(path) } // LoadHotKeys loads plugins from a given file. func (h HotKeys) LoadHotKeys(path string) error { - f, err := os.ReadFile(path) + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + bb, err := os.ReadFile(path) if err != nil { return err } + if err := data.JSONValidator.Validate(json.HotkeysSchema, bb); err != nil { + return fmt.Errorf("validation failed for %q: %w", path, err) + } var hh HotKeys - if err := yaml.Unmarshal(f, &hh); err != nil { + if err := yaml.Unmarshal(bb, &hh); err != nil { return err } for k, v := range hh.HotKey { diff --git a/internal/config/hotkey_test.go b/internal/config/hotkey_test.go index 28936d34..66c986af 100644 --- a/internal/config/hotkey_test.go +++ b/internal/config/hotkey_test.go @@ -12,7 +12,7 @@ import ( func TestHotKeyLoad(t *testing.T) { h := config.NewHotKeys() - assert.Nil(t, h.LoadHotKeys("testdata/hotkeys.yaml")) + assert.NoError(t, h.LoadHotKeys("testdata/hotkeys/hotkeys.yaml")) assert.Equal(t, 1, len(h.HotKey)) diff --git a/internal/config/json/schemas/aliases.json b/internal/config/json/schemas/aliases.json new file mode 100644 index 00000000..7ab1e6fc --- /dev/null +++ b/internal/config/json/schemas/aliases.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s aliases schema", + "type": "object", + "additionalProperties": false, + "properties": { + "aliases": { + "type": "object", + "additionalProperties": { "type": "string" }, + "required": [] + } + }, + "required": ["aliases"] +} diff --git a/internal/config/json/schemas/context.json b/internal/config/json/schemas/context.json new file mode 100644 index 00000000..e392dd7e --- /dev/null +++ b/internal/config/json/schemas/context.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s context config schema", + "type": "object", + "additionalProperties": false, + "properties": { + "k9s": { + "additionalProperties": false, + "properties": { + "cluster": { "type": "string" }, + "readOnly": {"type": "boolean"}, + "skin": { "type": "string" }, + "portForwardAddress": { "type": "string" }, + "namespace": { + "type": "object", + "additionalProperties": false, + "properties": { + "active": {"type": "string"}, + "lockFavorites": {"type": "boolean"}, + "favorites": { + "type": "array", + "items": {"type": "string"} + } + } + }, + "view": { + "type": "object", + "additionalProperties": false, + "properties": { + "active": { "type": "string" } + } + }, + "featureGates": { + "type": "object", + "additionalProperties": false, + "properties": { + "nodeShell": { "type": "boolean" } + } + } + } + } + }, + "required": ["k9s"] +} \ No newline at end of file diff --git a/internal/config/json/schemas/hotkeys.json b/internal/config/json/schemas/hotkeys.json new file mode 100644 index 00000000..b567d6c9 --- /dev/null +++ b/internal/config/json/schemas/hotkeys.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s hotkeys schema", + "type": "object", + "additionalProperties": false, + "properties": { + "hotKeys": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "shortCut": {"type": "string"}, + "description": {"type": "string"}, + "command": {"type": "string"} + } + } + } + }, + "required": ["hotKeys"] +} diff --git a/internal/config/json/schemas/k9s.json b/internal/config/json/schemas/k9s.json new file mode 100644 index 00000000..42d2cb39 --- /dev/null +++ b/internal/config/json/schemas/k9s.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s config schema", + "type": "object", + "additionalProperties": false, + "properties": { + "k9s": { + "additionalProperties": false, + "properties": { + "liveViewAutoRefresh": { "type": "boolean" }, + "screenDumpDir": {"type": "string"}, + "refreshRate": { "type": "integer" }, + "maxConnRetry": { "type": "integer" }, + "readOnly": { "type": "boolean" }, + "noExitOnCtrlC": { "type": "boolean" }, + "skipLatestRevCheck": { "type": "boolean" }, + "disablePodCounting": { "type": "boolean" }, + "ui": { + "type": "object", + "additionalProperties": false, + "properties": { + "enableMouse": {"type": "boolean"}, + "headless": {"type": "boolean"}, + "logoless": {"type": "boolean"}, + "crumbsless": {"type": "boolean"}, + "noIcons": {"type": "boolean"}, + "reactive": {"type": "boolean"}, + "skin": {"type": "string"} + } + }, + "shellPod": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { "type": "string" }, + "namespace": { "type": "string" }, + "limits": { + "type": "object", + "properties": { + "cpu": { "type": "string" }, + "memory": { "type": "string" } + }, + "required": ["cpu", "memory"] + } + }, + "required": ["image", "namespace", "limits"] + }, + "imageScans": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { "type": "boolean" }, + "namespace": { "type": "string" }, + "exclusions": { + "type": "object", + "properties": { + "namespaces": { + "type": "array", + "items": { "type": "string" } + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + }, + "required": ["enable"] + }, + "logger": { + "type": "object", + "additionalProperties": false, + "properties": { + "tail": {"type": "integer"}, + "buffer": {"type": "integer"}, + "sinceSeconds": {"type": "integer"}, + "fullScreen": {"type": "boolean"}, + "textWrap": {"type": "boolean"}, + "showTime": {"type": "boolean"} + } + }, + "thresholds": { + "type": "object", + "additionalProperties": false, + "properties": { + "cpu": { + "type": "object", + "properties": { + "critical": {"type": "integer"}, + "warn": {"type": "integer"} + } + }, + "memory": { + "type": "object", + "properties": { + "critical": {"type": "integer"}, + "warn": {"type": "integer"} + } + } + } + } + } + } + }, + "required": ["k9s"] +} \ No newline at end of file diff --git a/internal/config/json/schemas/plugins.json b/internal/config/json/schemas/plugins.json new file mode 100644 index 00000000..3445bd16 --- /dev/null +++ b/internal/config/json/schemas/plugins.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s plugins schema", + "type": "object", + "additionalProperties": false, + "properties": { + "plugins": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortCut": { "type": "string" }, + "description": { "type": "string" }, + "confirm": { "type": "boolean" }, + "scopes": { + "type": "array", + "items": { "type": "string" } + }, + "command": { "type": "string" }, + "background": { "type": "boolean" }, + "args": { + "type": "array", + "items": { "type": ["string", "number"] } + } + }, + "required": ["shortCut", "description", "scopes", "command"] + }, + "required": [] + } + }, + "required": ["plugins"] +} diff --git a/internal/config/json/schemas/skin.json b/internal/config/json/schemas/skin.json new file mode 100644 index 00000000..0dd6c07f --- /dev/null +++ b/internal/config/json/schemas/skin.json @@ -0,0 +1,185 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s skin schema", + "type": "object", + "additionalProperties": true, + "properties": { + "k9s": { + "type": "object", + "additionalProperties": false, + "properties": { + "body": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "logoColor": {"type": "string"} + } + }, + "prompt": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "suggestColor": {"type": "string"} + } + }, + "info": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "sectionColor": {"type": "string"} + } + }, + "help": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "keyColor": {"type": "string"}, + "numKeyColor": {"type": "string"}, + "sectionColor": {"type": "string"} + } + }, + "dialog": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "buttonFgColor": {"type": "string"}, + "buttonBgColor": {"type": "string"}, + "buttonFocusFgColor": {"type": "string"}, + "buttonFocusBgColor": {"type": "string"}, + "labelFgColor": {"type": "string"}, + "fieldFgColor": {"type": "string"} + } + }, + "frame": { + "type": "object", + "properties": { + "border": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"} + } + }, + "menu": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "keyColor": {"type": "string"}, + "numKeyColor": {"type": "string"} + } + }, + "crumbs": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "keyColor": {"type": "string"}, + "activeColor": {"type": "string"} + } + }, + "status": { + "type": "object", + "properties": { + "newColor": {"type": "string"}, + "modifyColor": {"type": "string"}, + "addColor:": {"type": "string"}, + "errorColor": {"type": "string"}, + "highlightColor": {"type": "string"}, + "killColor": {"type": "string"}, + "completedColor": {"type": "string"} + } + }, + "title": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor":{"type": "string"}, + "highlightColor": {"type": "string"}, + "counterColor":{"type": "string"}, + "filterColor": {"type": "string"} + } + } + } + }, + "views": { + "type": "object", + "properties": { + "charts": { + "type": "object", + "properties": { + "bgColor": {"type": "string"}, + "defaultDialColors": { + "type": "array", + "items": {"type": "string"} + }, + "defaultChartColors": { + "type": "array", + "items": {"type": "string"} + } + }, + "table": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "cursorFgColor": {"type": "string"}, + "cursorBgColor": {"type": "string"}, + "header": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"} + } + } + } + } + }, + "xray": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "cursorFgColor": {"type": "string"}, + "graphicColor": {"type": "string"}, + "showIcons": {"type": "boolean"} + } + }, + "yaml": { + "type": "object", + "properties": { + "keyColor": {"type": "string"}, + "colonColor": {"type": "string"}, + "valueColor": {"type": "string"} + } + }, + "logs": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "indicator": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "fgColor": {"type": "string"}, + "bgColor": {"type": "string"}, + "toggleOnColor": {"type": "string"}, + "toggleOffColor": {"type": "string"} + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/internal/config/json/schemas/views.json b/internal/config/json/schemas/views.json new file mode 100644 index 00000000..6b3971c5 --- /dev/null +++ b/internal/config/json/schemas/views.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "K9s views schema", + "type": "object", + "additionalProperties": false, + "properties": { + "views": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "sortColumn": { "type": "string" }, + "columns": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["columns"] + } + } + }, + "required": ["views"] +} diff --git a/internal/config/json/testdata/aliases/cool.yaml b/internal/config/json/testdata/aliases/cool.yaml new file mode 100644 index 00000000..60f0867c --- /dev/null +++ b/internal/config/json/testdata/aliases/cool.yaml @@ -0,0 +1,3 @@ +aliases: + blee: duh + fred: zorg diff --git a/internal/config/json/testdata/aliases/toast.yaml b/internal/config/json/testdata/aliases/toast.yaml new file mode 100644 index 00000000..3ba24ef3 --- /dev/null +++ b/internal/config/json/testdata/aliases/toast.yaml @@ -0,0 +1,3 @@ +alias: + blee: duh + fred: zorg diff --git a/internal/config/json/testdata/context/cool.yaml b/internal/config/json/testdata/context/cool.yaml new file mode 100644 index 00000000..fd2c6771 --- /dev/null +++ b/internal/config/json/testdata/context/cool.yaml @@ -0,0 +1,15 @@ +k9s: + cluster: kind-dashb + readOnly: false + skin: nightfox + namespace: + active: default + lockFavorites: false + favorites: + - kube-system + - default + view: + active: pod + featureGates: + nodeShell: false + portForwardAddress: localhost diff --git a/internal/config/json/testdata/context/toast.yaml b/internal/config/json/testdata/context/toast.yaml new file mode 100644 index 00000000..997914b4 --- /dev/null +++ b/internal/config/json/testdata/context/toast.yaml @@ -0,0 +1,16 @@ +k9s: + cluster: kind-dashb + readOnly: false + skin: nightfox + namespaces: + active: default + lockFavorites: false + favorites: + - kube-system + - default + view: + active: pod + fred: blee + featureGates: + nodeShell: false + portForwardAddress: localhost diff --git a/internal/config/json/testdata/hotkeys/cool.yaml b/internal/config/json/testdata/hotkeys/cool.yaml new file mode 100644 index 00000000..9e8e11d6 --- /dev/null +++ b/internal/config/json/testdata/hotkeys/cool.yaml @@ -0,0 +1,33 @@ +hotKey: + shift-0: + shortCut: Shift-0 + description: Popeye + command: popeye + shift-1: + shortCut: Shift-1 + description: View deployments + command: dp + shift-2: + shortCut: Shift-2 + description: View services + command: service + shift-3: + shortCut: Shift-3 + description: View statefulsets + command: sts + shift-4: + shortCut: Shift-4 + description: Xray Deployments + command: xray dp + shift-5: + shortCut: Shift-5 + description: Xray StatefulSets + command: xray sts + shift-6: + shortCut: Shift-6 + description: Xray DaemonSets + command: xray ds + shift-7: + shortCut: Shift-7 + description: Xray Services + command: xray svc diff --git a/internal/config/json/testdata/k9s/cool.yaml b/internal/config/json/testdata/k9s/cool.yaml new file mode 100644 index 00000000..d09da47b --- /dev/null +++ b/internal/config/json/testdata/k9s/cool.yaml @@ -0,0 +1,40 @@ +k9s: + liveViewAutoRefresh: false + screenDumpDir: /Users/fernand/.local/state/k9s/screen-dumps + refreshRate: 2 + maxConnRetry: 5 + readOnly: false + noExitOnCtrlC: false + ui: + enableMouse: false + headless: false + logoless: false + crumbsless: false + noIcons: false + skipLatestRevCheck: false + disablePodCounting: false + shellPod: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 100 + buffer: 5000 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/json/testdata/k9s/toast.yaml b/internal/config/json/testdata/k9s/toast.yaml new file mode 100644 index 00000000..b380e0a3 --- /dev/null +++ b/internal/config/json/testdata/k9s/toast.yaml @@ -0,0 +1,34 @@ +k9s: + liveViewAutoRefresh: false + screenDumpDir: /Users/fernand/.local/state/k9s/screen-dumps + refreshRate: 2 + maxConnRetry: 5 + readOnly: false + noExitOnCtrlC: false + skipLatestRevCheck: false + disablePodCounting: false + shellPods: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 100 + buffer: 5000 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/json/testdata/plugins/cool.yaml b/internal/config/json/testdata/plugins/cool.yaml new file mode 100644 index 00000000..bfc98dcd --- /dev/null +++ b/internal/config/json/testdata/plugins/cool.yaml @@ -0,0 +1,23 @@ +plugins: + 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" diff --git a/internal/config/json/testdata/plugins/toast.yaml b/internal/config/json/testdata/plugins/toast.yaml new file mode 100644 index 00000000..43adeb7a --- /dev/null +++ b/internal/config/json/testdata/plugins/toast.yaml @@ -0,0 +1,21 @@ +plugins: + blee: + shortCuts: g + confirm: false + description: blee + scopes: + - namespaces + command: sh + background: false + args: + - -c + - "blee bla" + duh: + shortCut: h + confirm: true + description: duh + command: sh + background: true + args: + - -c + - "duh fred" diff --git a/internal/config/json/testdata/skins/cool.yaml b/internal/config/json/testdata/skins/cool.yaml new file mode 100644 index 00000000..187d344b --- /dev/null +++ b/internal/config/json/testdata/skins/cool.yaml @@ -0,0 +1,109 @@ +# ----------------------------------------------------------------------------- +# K9s Nightfox Theme +# Based on the Nightfox.nvim color scheme: +# https://github.com/EdenEast/nightfox.nvim +# ----------------------------------------------------------------------------- + +# Styles... +foreground: &foreground "#cdcecf" +background: &background "#192330" +current_line: ¤t_line "#2b3b51" +selection: &selection "#2b3b51" +comment: &comment "#738091" +cyan: &cyan "#63cdcf" +green: &green "#81b29a" +orange: &orange "#f4a261" +magenta: &magenta "#9d79d6" +blue: &blue "#719cd6" +red: &red "#c94f6d" + +# Skin... +k9s: + body: + fgColor: *foreground + bgColor: *background + logoColor: *blue + prompt: + fgColor: *foreground + bgColor: *background + suggestColor: *orange + info: + fgColor: *magenta + sectionColor: *foreground + help: + fgColor: *foreground + bgColor: *background + keyColor: *magenta + numKeyColor: *magenta + sectionColor: *foreground + dialog: + fgColor: *foreground + bgColor: *background + buttonFgColor: *foreground + buttonBgColor: *magenta + buttonFocusFgColor: white + buttonFocusBgColor: *cyan + labelFgColor: *orange + fieldFgColor: *foreground + frame: + border: + fgColor: *selection + focusColor: *current_line + menu: + fgColor: *foreground + keyColor: *magenta + numKeyColor: *magenta + crumbs: + fgColor: *foreground + bgColor: *current_line + activeColor: *current_line + status: + newColor: *cyan + modifyColor: *blue + addColor: *green + errorColor: *red + highlightColor: *orange + killColor: *comment + completedColor: *comment + title: + fgColor: *foreground + bgColor: *current_line + highlightColor: *orange + counterColor: *blue + filterColor: *magenta + views: + charts: + bgColor: default + defaultDialColors: + - *blue + - *red + defaultChartColors: + - *blue + - *red + table: + fgColor: *foreground + bgColor: *background + cursorFgColor: *selection + cursorBgColor: *current_line + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *blue + showIcons: false + yaml: + keyColor: *magenta + colonColor: *blue + valueColor: *foreground + logs: + fgColor: *foreground + bgColor: *background + indicator: + fgColor: *foreground + bgColor: *selection + toggleOnColor: *magenta + toggleOffColor: *blue diff --git a/internal/config/json/testdata/skins/toast.yaml b/internal/config/json/testdata/skins/toast.yaml new file mode 100644 index 00000000..271bf6a5 --- /dev/null +++ b/internal/config/json/testdata/skins/toast.yaml @@ -0,0 +1,103 @@ +# ----------------------------------------------------------------------------- +# K9s Nightfox Theme +# Based on the Nightfox.nvim color scheme: +# https://github.com/EdenEast/nightfox.nvim +# ----------------------------------------------------------------------------- + +# Styles... +foreground: &foreground "#cdcecf" +background: &background "#192330" +current_line: ¤t_line "#2b3b51" +selection: &selection "#2b3b51" +comment: &comment "#738091" +cyan: &cyan "#63cdcf" +green: &green "#81b29a" +orange: &orange "#f4a261" +magenta: &magenta "#9d79d6" +blue: &blue "#719cd6" +red: &red "#c94f6d" + +# Skin... +k9s: + bodys: + fgColor: *foreground + bgColor: *background + logoColor: *blue + prompt: + fgColor: *foreground + bgColor: *background + suggestColor: *orange + info: + fgColor: *magenta + sectionColor: *foreground + dialog: + fgColor: *foreground + bgColor: *background + buttonFgColor: *foreground + buttonBgColor: *magenta + buttonFocusFgColor: white + buttonFocusBgColor: *cyan + labelFgColor: *orange + fieldFgColor: *foreground + frame: + border: + fgColor: *selection + focusColor: *current_line + menu: + fgColor: *foreground + keyColor: *magenta + numKeyColor: *magenta + crumbs: + fgColor: *foreground + bgColor: *current_line + activeColor: *current_line + status: + newColor: *cyan + modifyColor: *blue + addColor: *green + errorColor: *red + highlightColor: *orange + killColor: *comment + completedColor: *comment + title: + fgColor: *foreground + bgColor: *current_line + highlightColor: *orange + counterColor: *blue + filterColor: *magenta + views: + charts: + bgColor: default + defaultDialColors: + - *blue + - *red + defaultChartColors: + - *blue + - *red + table: + fgColor: *foreground + bgColor: *background + cursorFgColor: *selection + cursorBgColor: *current_line + header: + fgColor: *foreground + bgColor: *background + sorterColor: *cyan + xray: + fgColor: *foreground + bgColor: *background + cursorColor: *current_line + graphicColor: *blue + showIcons: false + yaml: + keyColor: *magenta + colonColor: *blue + valueColor: *foreground + logs: + fgColor: *foreground + bgColor: *background + indicator: + fgColor: *foreground + bgColor: *selection + toggleOnColor: *magenta + toggleOffColor: *blue diff --git a/internal/config/json/testdata/views/cool.yaml b/internal/config/json/testdata/views/cool.yaml new file mode 100644 index 00000000..ce89ceb8 --- /dev/null +++ b/internal/config/json/testdata/views/cool.yaml @@ -0,0 +1,12 @@ +views: + v1/nodes: + columns: + - NAME + - IP + v1/endpoints: + sortColumn: AGE:asc + columns: + - NAME + - NAMESPACE + - ENDPOINTS + - AGE diff --git a/internal/config/json/testdata/views/toast.yaml b/internal/config/json/testdata/views/toast.yaml new file mode 100644 index 00000000..42ec1ae4 --- /dev/null +++ b/internal/config/json/testdata/views/toast.yaml @@ -0,0 +1,9 @@ +views: + v1/nodes: + v1/endpoints: + sortCol: AGE:asc + cols: + - NAME + - NAMESPACE + - ENDPOINTS + - AGE diff --git a/internal/config/json/validator.go b/internal/config/json/validator.go new file mode 100644 index 00000000..e9bb0211 --- /dev/null +++ b/internal/config/json/validator.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package json + +import ( + "cmp" + _ "embed" + "errors" + "fmt" + "slices" + + "github.com/rs/zerolog/log" + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" +) + +const ( + // PluginsSchema describes plugins schema. + PluginsSchema = "plugins.json" + + // AliasesSchema describes aliases schema. + AliasesSchema = "aliases.json" + + // ViewsSchema describes views schema. + ViewsSchema = "views.json" + + // HotkeysSchema describes hotkeys schema. + HotkeysSchema = "hotkeys.json" + + // K9sSchema describes k9s config schema. + K9sSchema = "k9s.json" + + // ContextSchema describes context config schema. + ContextSchema = "context.json" + + // SkinSchema describes skin config schema. + SkinSchema = "skin.json" +) + +var ( + //go:embed schemas/plugins.json + pluginSchema string + + //go:embed schemas/aliases.json + aliasSchema string + + //go:embed schemas/views.json + viewsSchema string + + //go:embed schemas/k9s.json + k9sSchema string + + //go:embed schemas/context.json + contextSchema string + + //go:embed schemas/hotkeys.json + hotkeysSchema string + + //go:embed schemas/skin.json + skinSchema string +) + +// Validator tracks schemas validation. +type Validator struct { + schemas map[string]gojsonschema.JSONLoader + loader *gojsonschema.SchemaLoader +} + +// NewValidator returns a new instance. +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), + }, + } + v.register() + + return &v +} + +// Init initializes the schemas. +func (v *Validator) register() { + v.loader = gojsonschema.NewSchemaLoader() + v.loader.Validate = true + for k, s := range v.schemas { + if err := v.loader.AddSchema(k, s); err != nil { + log.Error().Err(err).Msgf("schema initialization failed: %q", k) + } + } +} + +// Validate runs document thru given schema validation. +func (v *Validator) Validate(k string, bb []byte) error { + var m interface{} + err := yaml.Unmarshal(bb, &m) + if err != nil { + return err + } + + s, ok := v.schemas[k] + if !ok { + return fmt.Errorf("no schema found for: %q", k) + } + result, err := gojsonschema.Validate(s, gojsonschema.NewGoLoader(m)) + if err != nil { + return err + } + if result.Valid() { + return nil + } + + slices.SortFunc(result.Errors(), func(a, b gojsonschema.ResultError) int { + return cmp.Compare(a.Description(), b.Description()) + }) + var errs error + for _, re := range result.Errors() { + errs = errors.Join(errs, errors.New(re.Description())) + } + + return errs +} + +func (v *Validator) ValidateObj(k string, o any) error { + s, ok := v.schemas[k] + if !ok { + return fmt.Errorf("no schema found for: %q", k) + } + result, err := gojsonschema.Validate(s, gojsonschema.NewGoLoader(o)) + if err != nil { + return err + } + if result.Valid() { + return nil + } + + slices.SortFunc(result.Errors(), func(a, b gojsonschema.ResultError) int { + return cmp.Compare(a.Description(), b.Description()) + }) + var errs error + for _, re := range result.Errors() { + errs = errors.Join(errs, errors.New(re.Description())) + } + + return errs +} diff --git a/internal/config/json/validator_test.go b/internal/config/json/validator_test.go new file mode 100644 index 00000000..9bab5e07 --- /dev/null +++ b/internal/config/json/validator_test.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package json_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/derailed/k9s/internal/config/json" + "github.com/stretchr/testify/assert" +) + +func TestValidatePluginDir(t *testing.T) { + skinDir := "../../../plugins" + ee, err := os.ReadDir(skinDir) + assert.NoError(t, err) + p := json.NewValidator() + for _, e := range ee { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + if ext == ".md" { + 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.NoError(t, err) + assert.NoError(t, p.Validate(json.PluginsSchema, bb), e.Name()) + } +} + +func TestValidateSkinDir(t *testing.T) { + skinDir := "../../../skins" + ee, err := os.ReadDir(skinDir) + assert.NoError(t, err) + p := json.NewValidator() + for _, e := range ee { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + 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.NoError(t, err) + assert.NoError(t, p.Validate(json.SkinSchema, bb), e.Name()) + } +} + +func TestValidateSkin(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "happy": { + f: "testdata/skins/cool.yaml", + }, + "toast": { + f: "testdata/skins/toast.yaml", + err: `Additional property bodys is not allowed`, + }, + } + + 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.SkinSchema, bb); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} + +func TestValidateK9s(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "happy": { + f: "testdata/k9s/cool.yaml", + }, + "toast": { + f: "testdata/k9s/toast.yaml", + err: `Additional property shellPods is not allowed`, + }, + } + + 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.K9sSchema, bb); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} + +func TestValidateContext(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "happy": { + f: "testdata/context/cool.yaml", + }, + "toast": { + f: "testdata/context/toast.yaml", + err: `Additional property fred is not allowed +Additional property namespaces is not allowed`, + }, + } + + 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.ContextSchema, bb); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} + +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 + err string + }{ + "happy": { + f: "testdata/aliases/cool.yaml", + }, + "toast": { + f: "testdata/aliases/toast.yaml", + err: `Additional property alias is not allowed +aliases 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.AliasesSchema, bb); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} + +func TestValidateViews(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "happy": { + f: "testdata/views/cool.yaml", + }, + "toast": { + f: "testdata/views/toast.yaml", + err: `Additional property cols is not allowed +Additional property sortCol is not allowed +Invalid type. Expected: object, given: null +columns 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.ViewsSchema, bb); err != nil { + assert.Equal(t, u.err, err.Error()) + } + }) + } +} diff --git a/internal/config/k9s.go b/internal/config/k9s.go index 69e4ddd5..09cee753 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -4,8 +4,9 @@ package config import ( - "errors" + "fmt" "path/filepath" + "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" @@ -13,19 +14,19 @@ import ( // K9s tracks K9s configuration options. type K9s struct { - LiveViewAutoRefresh bool `yaml:"liveViewAutoRefresh"` - ScreenDumpDir string `yaml:"screenDumpDir,omitempty"` - RefreshRate int `yaml:"refreshRate"` - MaxConnRetry int `yaml:"maxConnRetry"` - ReadOnly bool `yaml:"readOnly"` - NoExitOnCtrlC bool `yaml:"noExitOnCtrlC"` - UI UI `yaml:"ui"` - SkipLatestRevCheck bool `yaml:"skipLatestRevCheck"` - DisablePodCounting bool `yaml:"disablePodCounting"` - ShellPod *ShellPod `yaml:"shellPod"` - ImageScans *ImageScans `yaml:"imageScans"` - Logger *Logger `yaml:"logger"` - Thresholds Threshold `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"` manualRefreshRate int manualHeadless *bool manualLogoless *bool @@ -38,6 +39,7 @@ type K9s struct { activeConfig *data.Config conn client.Connection ks data.KubeSettings + mx sync.RWMutex } // NewK9s create a new K9s configuration. @@ -57,25 +59,32 @@ func NewK9s(conn client.Connection, ks data.KubeSettings) *K9s { } func (k *K9s) resetConnection(conn client.Connection) { + k.mx.Lock() + defer k.mx.Unlock() + k.conn = conn } // Save saves the k9s config to dis. func (k *K9s) Save() error { - if k.activeConfig != nil { - path := filepath.Join( - AppContextsDir, - data.SanitizeContextSubpath(k.activeConfig.Context.ClusterName, k.activeContextName), - data.MainConfigFile, - ) - return k.activeConfig.Save(path) + if k.activeConfig == nil { + return fmt.Errorf("save failed. no active config detected") } + path := filepath.Join( + AppContextsDir, + data.SanitizeContextSubpath(k.activeConfig.Context.ClusterName, k.activeContextName), + data.MainConfigFile, + ) - return nil + return k.activeConfig.Save(path) } -// Refine merges k9s configs. -func (k *K9s) Refine(k1 *K9s) { +// Merge merges k9s configs. +func (k *K9s) Merge(k1 *K9s) { + if k1 == nil { + return + } + k.LiveViewAutoRefresh = k1.LiveViewAutoRefresh k.ScreenDumpDir = k1.ScreenDumpDir k.RefreshRate = k1.RefreshRate @@ -86,56 +95,31 @@ func (k *K9s) Refine(k1 *K9s) { k.SkipLatestRevCheck = k1.SkipLatestRevCheck k.DisablePodCounting = k1.DisablePodCounting k.ShellPod = k1.ShellPod - k.ImageScans = k1.ImageScans k.Logger = k1.Logger + k.ImageScans = k1.ImageScans k.Thresholds = k1.Thresholds } -// Override overrides k9s config from cli args. -func (k *K9s) Override(k9sFlags *Flags) { - if *k9sFlags.RefreshRate != DefaultRefreshRate { - k.OverrideRefreshRate(*k9sFlags.RefreshRate) +// AppScreenDumpDir fetch screen dumps dir. +func (k *K9s) AppScreenDumpDir() string { + d := k.ScreenDumpDir + if isStringSet(k.manualScreenDumpDir) { + d = *k.manualScreenDumpDir + k.ScreenDumpDir = d + } + if d == "" { + d = AppDumpsDir } - k.OverrideHeadless(*k9sFlags.Headless) - k.OverrideLogoless(*k9sFlags.Logoless) - k.OverrideCrumbsless(*k9sFlags.Crumbsless) - k.OverrideReadOnly(*k9sFlags.ReadOnly) - k.OverrideWrite(*k9sFlags.Write) - k.OverrideCommand(*k9sFlags.Command) - k.OverrideScreenDumpDir(*k9sFlags.ScreenDumpDir) + return d } -// OverrideScreenDumpDir set the screen dump dir manually. -func (k *K9s) OverrideScreenDumpDir(dir string) { - k.manualScreenDumpDir = &dir +// ContextScreenDumpDir fetch context specific screen dumps dir. +func (k *K9s) ContextScreenDumpDir() string { + return filepath.Join(k.AppScreenDumpDir(), k.contextPath()) } -// GetScreenDumpDir fetch screen dumps dir. -func (k *K9s) GetScreenDumpDir() string { - screenDumpDir := k.ScreenDumpDir - if k.manualScreenDumpDir != nil && *k.manualScreenDumpDir != "" { - screenDumpDir = *k.manualScreenDumpDir - } - if screenDumpDir == "" { - screenDumpDir = AppDumpsDir - } - - return screenDumpDir -} - -// Reset resets configuration and context. -func (k *K9s) Reset() { - k.activeConfig, k.activeContextName = nil, "" -} - -// ActiveScreenDumpsDir fetch context specific screen dumps dir. -func (k *K9s) ActiveScreenDumpsDir() string { - return filepath.Join(k.GetScreenDumpDir(), k.ActiveContextDir()) -} - -// ActiveContextDir fetch current cluster/context path. -func (k *K9s) ActiveContextDir() string { +func (k *K9s) contextPath() string { if k.activeConfig == nil { return "na" } @@ -146,33 +130,39 @@ func (k *K9s) ActiveContextDir() string { ) } +// Reset resets configuration and context. +func (k *K9s) Reset() { + k.activeConfig, k.activeContextName = nil, "" +} + // ActiveContextNamespace fetch the context active ns. func (k *K9s) ActiveContextNamespace() (string, error) { - if k.activeConfig != nil { - return k.activeConfig.Context.Namespace.Active, nil + act, err := k.ActiveContext() + if err != nil { + return "", err } - return "", errors.New("context config is not set") + return act.Namespace.Active, nil } // ActiveContextName returns the active context name. func (k *K9s) ActiveContextName() string { + k.mx.RLock() + defer k.mx.RUnlock() + return k.activeContextName } // ActiveContext returns the currently active context. func (k *K9s) ActiveContext() (*data.Context, error) { - if k.activeConfig != nil { - if k.activeConfig.Context == nil { - ct, err := k.ks.CurrentContext() - if err != nil { - return nil, err - } - k.activeConfig.Context = data.NewContextFromConfig(ct) - } - return k.activeConfig.Context, nil - } + var ac *data.Config + k.mx.RLock() + ac = k.activeConfig + k.mx.RUnlock() + if ac != nil && ac.Context != nil { + return ac.Context, nil + } ct, err := k.ActivateContext(k.activeContextName) if err != nil { return nil, err @@ -181,7 +171,7 @@ func (k *K9s) ActiveContext() (*data.Context, error) { return ct, nil } -// ActivateContext initializes the active context is not present. +// ActivateContext initializes the active context if not present. func (k *K9s) ActivateContext(n string) (*data.Context, error) { k.activeContextName = n ct, err := k.ks.GetContext(n) @@ -192,161 +182,128 @@ func (k *K9s) ActivateContext(n string) (*data.Context, error) { if err != nil { return nil, err } - // If the context specifies a default namespace, use it! - if k.conn != nil { - k.Validate(k.conn, k.ks) - if ns := k.conn.ActiveNamespace(); ns != client.BlankNamespace { - k.activeConfig.Context.Namespace.Active = ns - } else { - k.activeConfig.Context.Namespace.Active = client.DefaultNamespace - } + + k.Validate(k.conn, k.ks) + // If the context specifies a namespace, use it! + if ns := ct.Namespace; ns != client.BlankNamespace { + k.activeConfig.Context.Namespace.Active = ns + } else { + k.activeConfig.Context.Namespace.Active = client.DefaultNamespace + } + if k.activeConfig.Context == nil { + return nil, fmt.Errorf("context activation failed for: %s", n) } return k.activeConfig.Context, nil } -// Reload reloads the active config from disk. +// Reload reloads the context config from disk. func (k *K9s) Reload() error { + k.mx.Lock() + defer k.mx.Unlock() + ct, err := k.ks.GetContext(k.activeContextName) if err != nil { return err } - k.activeConfig, err = k.dir.Load(k.activeContextName, ct) if err != nil { return err } + k.activeConfig.Validate(k.conn, k.ks) return nil } -// OverrideRefreshRate set the refresh rate manually. -func (k *K9s) OverrideRefreshRate(r int) { - k.manualRefreshRate = r -} - -// OverrideHeadless toggle the header manually. -func (k *K9s) OverrideHeadless(b bool) { - k.manualHeadless = &b -} - -// OverrideLogoless toggle the k9s logo manually. -func (k *K9s) OverrideLogoless(b bool) { - k.manualLogoless = &b -} - -// OverrideCrumbsless tooh the crumbslessness manually. -func (k *K9s) OverrideCrumbsless(b bool) { - k.manualCrumbsless = &b -} - -// OverrideReadOnly set the readonly mode manually. -func (k *K9s) OverrideReadOnly(b bool) { - if b { - k.manualReadOnly = &b +// Override overrides k9s config from cli args. +func (k *K9s) Override(k9sFlags *Flags) { + if k9sFlags.RefreshRate != nil && *k9sFlags.RefreshRate != DefaultRefreshRate { + k.manualRefreshRate = *k9sFlags.RefreshRate } -} -// OverrideWrite set the write mode manually. -func (k *K9s) OverrideWrite(b bool) { - if b { - var flag bool - k.manualReadOnly = &flag + k.manualHeadless = k9sFlags.Headless + k.manualLogoless = k9sFlags.Logoless + k.manualCrumbsless = k9sFlags.Crumbsless + if k9sFlags.ReadOnly != nil && *k9sFlags.ReadOnly { + k.manualReadOnly = k9sFlags.ReadOnly } -} - -// OverrideCommand set the command manually. -func (k *K9s) OverrideCommand(cmd string) { - k.manualCommand = &cmd + if k9sFlags.Write != nil && *k9sFlags.Write { + var false bool + k.manualReadOnly = &false + } + k.manualCommand = k9sFlags.Command + k.manualScreenDumpDir = k9sFlags.ScreenDumpDir } // IsHeadless returns headless setting. func (k *K9s) IsHeadless() bool { - h := k.UI.Headless - if k.manualHeadless != nil && *k.manualHeadless { - h = *k.manualHeadless + if isBoolSet(k.manualHeadless) { + return true } - return h + return k.UI.Headless } // IsLogoless returns logoless setting. func (k *K9s) IsLogoless() bool { - h := k.UI.Logoless - if k.manualLogoless != nil && *k.manualLogoless { - h = *k.manualLogoless + if isBoolSet(k.manualLogoless) { + return true } - return h + return k.UI.Logoless } // IsCrumbsless returns crumbsless setting. func (k *K9s) IsCrumbsless() bool { - h := k.UI.Crumbsless - if k.manualCrumbsless != nil && *k.manualCrumbsless { - h = *k.manualCrumbsless + if isBoolSet(k.manualCrumbsless) { + return true } - return h + return k.UI.Crumbsless } // GetRefreshRate returns the current refresh rate. func (k *K9s) GetRefreshRate() int { - rate := k.RefreshRate if k.manualRefreshRate != 0 { - rate = k.manualRefreshRate + return k.manualRefreshRate } - return rate + return k.RefreshRate } // IsReadOnly returns the readonly setting. func (k *K9s) IsReadOnly() bool { - readOnly := k.ReadOnly - if k.manualReadOnly != nil { - readOnly = *k.manualReadOnly + k.mx.RLock() + defer k.mx.RUnlock() + + ro := k.ReadOnly + if k.activeConfig != nil && k.activeConfig.Context.ReadOnly != nil { + ro = *k.activeConfig.Context.ReadOnly } - if k.activeConfig != nil && k.activeConfig.Context.ReadOnly { - readOnly = true + if k.manualReadOnly != nil { + ro = true } - return readOnly + return ro } -func (k *K9s) validateDefaults() { +// Validate the current configuration. +func (k *K9s) Validate(c client.Connection, ks data.KubeSettings) { if k.RefreshRate <= 0 { k.RefreshRate = defaultRefreshRate } if k.MaxConnRetry <= 0 { k.MaxConnRetry = defaultMaxConnRetry } -} -// Validate the current configuration. -func (k *K9s) Validate(c client.Connection, ks data.KubeSettings) { - k.validateDefaults() if k.activeConfig == nil { if n, err := ks.CurrentContextName(); err == nil { _, _ = k.ActivateContext(n) } } - if k.ImageScans == nil { - k.ImageScans = NewImageScans() - } - if k.ShellPod == nil { - k.ShellPod = NewShellPod() - } - k.ShellPod.Validate() - - if k.Logger == nil { - k.Logger = NewLogger() - } else { - k.Logger.Validate() - } - if k.Thresholds == nil { - k.Thresholds = NewThreshold() - } - k.Thresholds.Validate() + k.ShellPod = k.ShellPod.Validate() + k.Logger = k.Logger.Validate() + k.Thresholds = k.Thresholds.Validate() if k.activeConfig != nil { k.activeConfig.Validate(c, ks) diff --git a/internal/config/k9s_int_test.go b/internal/config/k9s_int_test.go new file mode 100644 index 00000000..0bee7fcc --- /dev/null +++ b/internal/config/k9s_int_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_k9sOverrides(t *testing.T) { + var ( + true = true + cmd = "po" + dir = "/tmp/blee" + ) + + uu := map[string]struct { + k *K9s + rate int + ro, hl, cl, ll bool + }{ + "plain": { + k: &K9s{ + LiveViewAutoRefresh: false, + ScreenDumpDir: "", + RefreshRate: 10, + MaxConnRetry: 0, + ReadOnly: false, + NoExitOnCtrlC: false, + UI: UI{}, + SkipLatestRevCheck: false, + DisablePodCounting: false, + }, + rate: 10, + }, + "set": { + k: &K9s{ + LiveViewAutoRefresh: false, + ScreenDumpDir: "", + RefreshRate: 10, + MaxConnRetry: 0, + ReadOnly: true, + NoExitOnCtrlC: false, + UI: UI{ + Headless: true, + Logoless: true, + Crumbsless: true, + }, + SkipLatestRevCheck: false, + DisablePodCounting: false, + }, + rate: 10, + ro: true, + hl: true, + ll: true, + cl: true, + }, + "overrides": { + k: &K9s{ + LiveViewAutoRefresh: false, + ScreenDumpDir: "", + RefreshRate: 10, + MaxConnRetry: 0, + ReadOnly: false, + NoExitOnCtrlC: false, + UI: UI{ + Headless: false, + Logoless: false, + Crumbsless: false, + }, + SkipLatestRevCheck: false, + DisablePodCounting: false, + manualRefreshRate: 100, + manualReadOnly: &true, + manualHeadless: &true, + manualLogoless: &true, + manualCrumbsless: &true, + manualCommand: &cmd, + manualScreenDumpDir: &dir, + }, + rate: 100, + ro: true, + hl: true, + ll: true, + cl: true, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.rate, u.k.GetRefreshRate()) + assert.Equal(t, u.ro, u.k.IsReadOnly()) + assert.Equal(t, u.cl, u.k.IsCrumbsless()) + assert.Equal(t, u.hl, u.k.IsHeadless()) + assert.Equal(t, u.ll, u.k.IsLogoless()) + + }) + } +} + +func Test_screenDumpDirOverride(t *testing.T) { + uu := map[string]struct { + dir string + e string + }{ + "empty": { + e: "/tmp/k9s-test/screen-dumps", + }, + "override": { + dir: "/tmp/k9s-test/sd", + e: "/tmp/k9s-test/sd", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + cfg := NewConfig(nil) + assert.NoError(t, cfg.Load("testdata/configs/k9s.yaml")) + + cfg.K9s.manualScreenDumpDir = &u.dir + assert.Equal(t, u.e, cfg.K9s.AppScreenDumpDir()) + }) + } +} diff --git a/internal/config/k9s_test.go b/internal/config/k9s_test.go index 67c1c461..69e76189 100644 --- a/internal/config/k9s_test.go +++ b/internal/config/k9s_test.go @@ -4,40 +4,145 @@ package config_test import ( + "errors" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" + "k8s.io/cli-runtime/pkg/genericclioptions" ) -func TestGetScreenDumpDir(t *testing.T) { - cfg := mock.NewMockConfig() +func TestK9sReload(t *testing.T) { + config.AppConfigDir = "/tmp/k9s-test" - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) - assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir()) + cl, ct := "cl-1", "ct-1-1" + + uu := map[string]struct { + k *config.K9s + cl, ct string + err error + }{ + "no-context": { + k: config.NewK9s( + mock.NewMockConnection(), + mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ + ClusterName: &cl, + Context: &ct, + }), + ), + err: errors.New(`no context found for: ""`), + }, + "set-context": { + k: config.NewK9s( + mock.NewMockConnection(), + mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ + ClusterName: &cl, + Context: &ct, + }), + ), + ct: "ct-1-1", + cl: "cl-1", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + _, _ = u.k.ActivateContext(u.ct) + assert.Equal(t, u.err, u.k.Reload()) + ct, err := u.k.ActiveContext() + assert.Equal(t, u.err, err) + if err == nil { + assert.Equal(t, u.cl, ct.ClusterName) + } + }) + } } -func TestGetScreenDumpDirOverride(t *testing.T) { - cfg := mock.NewMockConfig() +func TestK9sMerge(t *testing.T) { + cl, ct := "cl-1", "ct-1-1" - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) - cfg.K9s.OverrideScreenDumpDir("/override") - assert.Equal(t, "/override", cfg.K9s.GetScreenDumpDir()) + uu := map[string]struct { + k1, k2 *config.K9s + ek *config.K9s + }{ + "no-opt": { + k1: config.NewK9s( + mock.NewMockConnection(), + mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ + ClusterName: &cl, + Context: &ct, + }), + ), + ek: config.NewK9s( + mock.NewMockConnection(), + mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ + ClusterName: &cl, + Context: &ct, + }), + ), + }, + "override": { + k1: &config.K9s{ + LiveViewAutoRefresh: false, + ScreenDumpDir: "", + RefreshRate: 0, + MaxConnRetry: 0, + ReadOnly: false, + NoExitOnCtrlC: false, + UI: config.UI{}, + SkipLatestRevCheck: false, + DisablePodCounting: false, + ShellPod: config.ShellPod{}, + ImageScans: config.ImageScans{}, + Logger: config.Logger{}, + Thresholds: nil, + }, + k2: &config.K9s{ + LiveViewAutoRefresh: true, + MaxConnRetry: 100, + ShellPod: config.NewShellPod(), + }, + ek: &config.K9s{ + LiveViewAutoRefresh: true, + ScreenDumpDir: "", + RefreshRate: 0, + MaxConnRetry: 100, + ReadOnly: false, + NoExitOnCtrlC: false, + UI: config.UI{}, + SkipLatestRevCheck: false, + DisablePodCounting: false, + ShellPod: config.NewShellPod(), + ImageScans: config.ImageScans{}, + Logger: config.Logger{}, + Thresholds: nil, + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + u.k1.Merge(u.k2) + assert.Equal(t, u.ek, u.k1) + }) + } } -func TestGetScreenDumpDirOverrideEmpty(t *testing.T) { +func TestContextScreenDumpDir(t *testing.T) { cfg := mock.NewMockConfig() + _, err := cfg.K9s.ActivateContext("ct-1-1") - assert.Nil(t, cfg.Load("testdata/k9s.yaml")) - cfg.K9s.OverrideScreenDumpDir("") - assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir()) + assert.NoError(t, err) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) + assert.Equal(t, "/tmp/k9s-test/screen-dumps/cl-1/ct-1-1", cfg.K9s.ContextScreenDumpDir()) } -func TestGetScreenDumpDirEmpty(t *testing.T) { +func TestAppScreenDumpDir(t *testing.T) { cfg := mock.NewMockConfig() - assert.Nil(t, cfg.Load("testdata/k9s1.yaml")) - cfg.K9s.OverrideScreenDumpDir("") - assert.Equal(t, config.AppDumpsDir, cfg.K9s.GetScreenDumpDir()) + assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml")) + assert.Equal(t, "/tmp/k9s-test/screen-dumps", cfg.K9s.AppScreenDumpDir()) } diff --git a/internal/config/logger.go b/internal/config/logger.go index 014d724c..88c7653d 100644 --- a/internal/config/logger.go +++ b/internal/config/logger.go @@ -16,17 +16,17 @@ const ( // Logger tracks logger options. type Logger struct { - TailCount int64 `yaml:"tail"` - BufferSize int `yaml:"buffer"` - SinceSeconds int64 `yaml:"sinceSeconds"` - FullScreenLogs bool `yaml:"fullScreenLogs"` - TextWrap bool `yaml:"textWrap"` - ShowTime bool `yaml:"showTime"` + TailCount int64 `json:"tail" yaml:"tail"` + BufferSize int `json:"buffer" yaml:"buffer"` + SinceSeconds int64 `json:"sinceSeconds" yaml:"sinceSeconds"` + FullScreen bool `json:"fullScreen" yaml:"fullScreen"` + TextWrap bool `json:"textWrap" yaml:"textWrap"` + ShowTime bool `json:"showTime" yaml:"showTime"` } // NewLogger returns a new instance. -func NewLogger() *Logger { - return &Logger{ +func NewLogger() Logger { + return Logger{ TailCount: DefaultLoggerTailCount, BufferSize: MaxLogThreshold, SinceSeconds: DefaultSinceSeconds, @@ -34,7 +34,7 @@ func NewLogger() *Logger { } // Validate checks thresholds and make sure we're cool. If not use defaults. -func (l *Logger) Validate() { +func (l Logger) Validate() Logger { if l.TailCount <= 0 { l.TailCount = DefaultLoggerTailCount } @@ -47,4 +47,6 @@ func (l *Logger) Validate() { if l.SinceSeconds == 0 { l.SinceSeconds = DefaultSinceSeconds } + + return l } diff --git a/internal/config/logger_test.go b/internal/config/logger_test.go index 51625df6..753466f4 100644 --- a/internal/config/logger_test.go +++ b/internal/config/logger_test.go @@ -12,7 +12,7 @@ import ( func TestNewLogger(t *testing.T) { l := config.NewLogger() - l.Validate() + l = l.Validate() assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, 5000, l.BufferSize) @@ -20,7 +20,7 @@ func TestNewLogger(t *testing.T) { func TestLoggerValidate(t *testing.T) { var l config.Logger - l.Validate() + l = l.Validate() assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, 5000, l.BufferSize) diff --git a/internal/config/mock/test_helpers.go b/internal/config/mock/test_helpers.go index 49db4a24..a20db334 100644 --- a/internal/config/mock/test_helpers.go +++ b/internal/config/mock/test_helpers.go @@ -33,7 +33,7 @@ func EnsureDir(d string) error { func NewMockConfig() *config.Config { config.AppContextsDir = "/tmp/test" - cl, ct := "cl-1", "ct-1" + cl, ct := "cl-1", "ct-1-1" flags := genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, @@ -63,7 +63,7 @@ func NewMockKubeSettings(f *genericclioptions.ConfigFlags) mockKubeSettings { }, ctId + "-2": { Cluster: *f.ClusterName, - Namespace: "ns-1", + Namespace: "ns-2", }, ctId + "-3": { Cluster: *f.ClusterName, diff --git a/internal/config/plugin.go b/internal/config/plugin.go index b947f866..7b111294 100644 --- a/internal/config/plugin.go +++ b/internal/config/plugin.go @@ -10,6 +10,9 @@ import ( "path/filepath" "strings" + "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" + "github.com/adrg/xdg" "gopkg.in/yaml.v2" ) @@ -47,6 +50,7 @@ func NewPlugins() Plugins { // Load K9s plugins. func (p Plugins) Load(path string) error { var errs error + if err := p.load(AppPluginsFile); err != nil { errs = errors.Join(errs, err) } @@ -74,12 +78,12 @@ func (p Plugins) loadPluginDir(dir string) error { if file.IsDir() || !isYamlFile(file.Name()) { continue } - pluginFile, err := os.ReadFile(filepath.Join(dir, file.Name())) + bb, err := os.ReadFile(filepath.Join(dir, file.Name())) if err != nil { errs = errors.Join(errs, err) } var plugin Plugin - if err = yaml.Unmarshal(pluginFile, &plugin); err != nil { + if err = yaml.Unmarshal(bb, &plugin); err != nil { return err } p.Plugins[strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))] = plugin @@ -92,13 +96,15 @@ func (p *Plugins) load(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } - f, err := os.ReadFile(path) + bb, err := os.ReadFile(path) if err != nil { return err } - + if err := data.JSONValidator.Validate(json.PluginsSchema, bb); err != nil { + return fmt.Errorf("validation failed for %q: %w", path, err) + } var pp Plugins - if err := yaml.Unmarshal(f, &pp); err != nil { + if err := yaml.Unmarshal(bb, &pp); err != nil { return err } for k, v := range pp.Plugins { diff --git a/internal/config/plugin_test.go b/internal/config/plugin_test.go index f0870e01..bd95fec5 100644 --- a/internal/config/plugin_test.go +++ b/internal/config/plugin_test.go @@ -4,8 +4,10 @@ package config import ( + "os" "testing" + "github.com/adrg/xdg" "github.com/stretchr/testify/assert" ) @@ -39,10 +41,24 @@ var test2YmlTestData = Plugin{ Background: true, } +func TestPluginLoad(t *testing.T) { + AppPluginsFile = "/tmp/k9s-test/fred.yaml" + os.Setenv("XDG_DATA_HOME", "/tmp/k9s-test") + xdg.Reload() + + p := NewPlugins() + assert.NoError(t, p.Load("testdata/plugins.yaml")) + + assert.Equal(t, 1, len(p.Plugins)) + k, ok := p.Plugins["blah"] + assert.True(t, ok) + assert.ObjectsAreEqual(pluginYmlTestData, k) +} + func TestSinglePluginFileLoad(t *testing.T) { p := NewPlugins() - assert.Nil(t, p.load("testdata/plugins.yaml")) - assert.Nil(t, p.loadPluginDir("/random/dir/not/exist")) + assert.NoError(t, p.load("testdata/plugins.yaml")) + assert.NoError(t, p.loadPluginDir("/random/dir/not/exist")) assert.Equal(t, 1, len(p.Plugins)) k, ok := p.Plugins["blah"] @@ -53,8 +69,8 @@ func TestSinglePluginFileLoad(t *testing.T) { func TestMultiplePluginFilesLoad(t *testing.T) { p := NewPlugins() - assert.Nil(t, p.load("testdata/plugins.yaml")) - assert.Nil(t, p.loadPluginDir("testdata/plugins")) + assert.NoError(t, p.load("testdata/plugins.yaml")) + assert.NoError(t, p.loadPluginDir("testdata/plugins")) testPlugins := map[string]Plugin{ "blah": pluginYmlTestData, diff --git a/internal/config/scans.go b/internal/config/scans.go index 5731c58e..31aa8ef7 100644 --- a/internal/config/scans.go +++ b/internal/config/scans.go @@ -23,8 +23,8 @@ func (l Labels) exclude(k, val string) bool { // ScanExcludes tracks vul scan exclusions. type ScanExcludes struct { - Namespaces []string `yaml:"namespaces"` - Labels Labels `yaml:"labels"` + Namespaces []string `json:"namespaces" yaml:"namespaces"` + Labels Labels `json:"labels" yaml:"labels"` } func newScanExcludes() ScanExcludes { @@ -50,19 +50,19 @@ func (b ScanExcludes) exclude(ns string, ll map[string]string) bool { // ImageScans tracks vul scans options. type ImageScans struct { - Enable bool `yaml:"enable"` - Exclusions ScanExcludes `yaml:"exclusions"` + Enable bool `json:"enable" yaml:"enable"` + Exclusions ScanExcludes `json:"exclusions" yaml:"exclusions"` } // NewImageScans returns a new instance. -func NewImageScans() *ImageScans { - return &ImageScans{ +func NewImageScans() ImageScans { + return ImageScans{ Exclusions: newScanExcludes(), } } // ShouldExclude checks if scan should be excluder given ns/labels -func (i *ImageScans) ShouldExclude(ns string, ll map[string]string) bool { +func (i ImageScans) ShouldExclude(ns string, ll map[string]string) bool { if !i.Enable { return false } diff --git a/internal/config/scans_test.go b/internal/config/scans_test.go new file mode 100644 index 00000000..d410393c --- /dev/null +++ b/internal/config/scans_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config_test + +import ( + "testing" + + "github.com/derailed/k9s/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestScansShouldExclude(t *testing.T) { + uu := map[string]struct { + sc config.ImageScans + ns string + ll map[string]string + e bool + }{ + "empty": { + sc: config.NewImageScans(), + }, + "exclude-ns": { + sc: config.ImageScans{ + Enable: true, + Exclusions: config.ScanExcludes{ + Namespaces: []string{"ns-1", "ns-2", "ns-3"}, + Labels: config.Labels{ + "app": []string{"fred", "blee"}, + }, + }, + }, + ns: "ns-1", + ll: map[string]string{ + "app": "freddy", + }, + e: true, + }, + "include-ns": { + sc: config.ImageScans{ + Enable: true, + Exclusions: config.ScanExcludes{ + Namespaces: []string{"ns-1", "ns-2", "ns-3"}, + Labels: config.Labels{ + "app": []string{"fred", "blee"}, + }, + }, + }, + ns: "ns-4", + ll: map[string]string{ + "app": "bozo", + }, + }, + "exclude-labels": { + sc: config.ImageScans{ + Enable: true, + Exclusions: config.ScanExcludes{ + Namespaces: []string{"ns-1", "ns-2", "ns-3"}, + Labels: config.Labels{ + "app": []string{"fred", "blee"}, + }, + }, + }, + ns: "ns-4", + ll: map[string]string{ + "app": "fred", + }, + e: true, + }, + "include-labels": { + sc: config.ImageScans{ + Enable: true, + Exclusions: config.ScanExcludes{ + Namespaces: []string{"ns-1", "ns-2", "ns-3"}, + Labels: config.Labels{ + "app": []string{"fred", "blee"}, + }, + }, + }, + ns: "ns-4", + ll: map[string]string{ + "app": "freddy", + }, + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + assert.Equal(t, u.e, u.sc.ShouldExclude(u.ns, u.ll)) + }) + } +} diff --git a/internal/config/shell_pod.go b/internal/config/shell_pod.go index c431bc1b..08540f2c 100644 --- a/internal/config/shell_pod.go +++ b/internal/config/shell_pod.go @@ -14,20 +14,20 @@ type Limits map[v1.ResourceName]string // ShellPod represents k9s shell configuration. type ShellPod struct { - Image string `yaml:"image"` - Command []string `yaml:"command,omitempty"` - Args []string `yaml:"args,omitempty"` - Namespace string `yaml:"namespace"` - Limits Limits `yaml:"limits,omitempty"` - Labels map[string]string `yaml:"labels,omitempty"` - ImagePullSecrets []v1.LocalObjectReference `yaml:"imagePullSecrets,omitempty"` - ImagePullPolicy v1.PullPolicy `yaml:"imagePullPolicy,omitempty"` - TTY bool `yaml:"tty,omitempty"` + Image string `json:"image" yaml:"image"` + Command []string `json:"command,omitempty" yaml:"command,omitempty"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` + Namespace string `json:"namespace" yaml:"namespace"` + Limits Limits `json:"limits,omitempty" yaml:"limits,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + ImagePullSecrets []v1.LocalObjectReference `json:"imagePullSecrets,omitempty" yaml:"imagePullSecrets,omitempty"` + ImagePullPolicy v1.PullPolicy `json:"imagePullPolicy,omitempty" yaml:"imagePullPolicy,omitempty"` + TTY bool `json:"tty,omitempty" yaml:"tty,omitempty"` } // NewShellPod returns a new instance. -func NewShellPod() *ShellPod { - return &ShellPod{ +func NewShellPod() ShellPod { + return ShellPod{ Image: defaultDockerShellImage, Namespace: "default", Limits: defaultLimits(), @@ -35,13 +35,15 @@ func NewShellPod() *ShellPod { } // Validate validates the configuration. -func (s *ShellPod) Validate() { +func (s ShellPod) Validate() ShellPod { if s.Image == "" { s.Image = defaultDockerShellImage } if len(s.Limits) == 0 { s.Limits = defaultLimits() } + + return s } func defaultLimits() Limits { diff --git a/internal/config/styles.go b/internal/config/styles.go index 06690d7d..604cfee9 100644 --- a/internal/config/styles.go +++ b/internal/config/styles.go @@ -4,8 +4,11 @@ package config import ( + "fmt" "os" + "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "gopkg.in/yaml.v2" @@ -18,204 +21,198 @@ type StyleListener interface { } type ( - // Color represents a color. - Color string - - // Colors tracks multiple colors. - Colors []Color - // Styles tracks K9s styling options. Styles struct { - K9s Style `yaml:"k9s"` + K9s Style `json:"k9s" yaml:"k9s"` listeners []StyleListener } // Style tracks K9s styles. Style struct { - Body Body `yaml:"body"` - Prompt Prompt `yaml:"prompt"` - Help Help `yaml:"help"` - Frame Frame `yaml:"frame"` - Info Info `yaml:"info"` - Views Views `yaml:"views"` - Dialog Dialog `yaml:"dialog"` + Body Body `json:"body" yaml:"body"` + Prompt Prompt `json:"prompt" yaml:"prompt"` + Help Help `json:"help" yaml:"help"` + Frame Frame `json:"frame" yaml:"frame"` + Info Info `json:"info" yaml:"info"` + Views Views `json:"views" yaml:"views"` + Dialog Dialog `json:"dialog" yaml:"dialog"` } // Prompt tracks command styles Prompt struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - SuggestColor Color `yaml:"suggestColor"` - Border PromptBorder `yaml:"border"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + SuggestColor Color `json:"" yaml:"suggestColor"` + Border PromptBorder `json:"" yaml:"border"` } // PromptBorder tracks the color of the prompt depending on its kind (e.g., command or filter) PromptBorder struct { - CommandColor Color `yaml:"command"` - DefaultColor Color `yaml:"default"` + CommandColor Color `json:"command" yaml:"command"` + DefaultColor Color `json:"default" yaml:"default"` } // Help tracks help styles. Help struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - SectionColor Color `yaml:"sectionColor"` - KeyColor Color `yaml:"keyColor"` - NumKeyColor Color `yaml:"numKeyColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + SectionColor Color `json:"sectionColor" yaml:"sectionColor"` + KeyColor Color `json:"keyColor" yaml:"keyColor"` + NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"` } // Body tracks body styles. Body struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - LogoColor Color `yaml:"logoColor"` - LogoColorMsg Color `yaml:"logoColorMsg"` - LogoColorInfo Color `yaml:"logoColorInfo"` - LogoColorWarn Color `yaml:"logoColorWarn"` - LogoColorError Color `yaml:"logoColorError"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + LogoColor Color `json:"logoColor" yaml:"logoColor"` + LogoColorMsg Color `json:"logoColorMsg" yaml:"logoColorMsg"` + LogoColorInfo Color `json:"logoColorInfo" yaml:"logoColorInfo"` + LogoColorWarn Color `json:"logoColorWarn" yaml:"logoColorWarn"` + LogoColorError Color `json:"logoColorError" yaml:"logoColorError"` } // Dialog tracks dialog styles. Dialog struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - ButtonFgColor Color `yaml:"buttonFgColor"` - ButtonBgColor Color `yaml:"buttonBgColor"` - ButtonFocusFgColor Color `yaml:"buttonFocusFgColor"` - ButtonFocusBgColor Color `yaml:"buttonFocusBgColor"` - LabelFgColor Color `yaml:"labelFgColor"` - FieldFgColor Color `yaml:"fieldFgColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + ButtonFgColor Color `json:"buttonFgColor" yaml:"buttonFgColor"` + ButtonBgColor Color `json:"buttonBgColor" yaml:"buttonBgColor"` + ButtonFocusFgColor Color `json:"buttonFocusFgColor" yaml:"buttonFocusFgColor"` + ButtonFocusBgColor Color `json:"buttonFocusBgColor" yaml:"buttonFocusBgColor"` + LabelFgColor Color `json:"labelFgColor" yaml:"labelFgColor"` + FieldFgColor Color `json:"fieldFgColor" yaml:"fieldFgColor"` } // Frame tracks frame styles. Frame struct { - Title Title `yaml:"title"` - Border Border `yaml:"border"` - Menu Menu `yaml:"menu"` - Crumb Crumb `yaml:"crumbs"` - Status Status `yaml:"status"` + Title Title `json:"title" yaml:"title"` + Border Border `json:"border" yaml:"border"` + Menu Menu `json:"menu" yaml:"menu"` + Crumb Crumb `json:"crumbs" yaml:"crumbs"` + Status Status `json:"status" yaml:"status"` } // Views tracks individual view styles. Views struct { - Table Table `yaml:"table"` - Xray Xray `yaml:"xray"` - Charts Charts `yaml:"charts"` - Yaml Yaml `yaml:"yaml"` - Picker Picker `yaml:"picker"` - Log Log `yaml:"logs"` + Table Table `json:"table" yaml:"table"` + Xray Xray `json:"xray" yaml:"xray"` + Charts Charts `json:"charts" yaml:"charts"` + Yaml Yaml `json:"yaml" yaml:"yaml"` + Picker Picker `json:"picker" yaml:"picker"` + Log Log `json:"logs" yaml:"logs"` } // Status tracks resource status styles. Status struct { - NewColor Color `yaml:"newColor"` - ModifyColor Color `yaml:"modifyColor"` - AddColor Color `yaml:"addColor"` - PendingColor Color `yaml:"pendingColor"` - ErrorColor Color `yaml:"errorColor"` - HighlightColor Color `yaml:"highlightColor"` - KillColor Color `yaml:"killColor"` - CompletedColor Color `yaml:"completedColor"` + NewColor Color `json:"newColor" yaml:"newColor"` + ModifyColor Color `json:"modifyColor" yaml:"modifyColor"` + AddColor Color `json:"addColor" yaml:"addColor"` + PendingColor Color `json:"pendingColor" yaml:"pendingColor"` + ErrorColor Color `json:"errorColor" yaml:"errorColor"` + HighlightColor Color `json:"highlightColor" yaml:"highlightColor"` + KillColor Color `json:"killColor" yaml:"killColor"` + CompletedColor Color `json:"completedColor" yaml:"completedColor"` } // Log tracks Log styles. Log struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - Indicator LogIndicator `yaml:"indicator"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + Indicator LogIndicator `json:"indicator" yaml:"indicator"` } // Picker tracks color when selecting containers Picker struct { - MainColor Color `yaml:"mainColor"` - FocusColor Color `yaml:"focusColor"` - ShortcutColor Color `yaml:"shortcutColor"` + MainColor Color `json:"mainColor" yaml:"mainColor"` + FocusColor Color `json:"focusColor" yaml:"focusColor"` + ShortcutColor Color `json:"shortcutColor" yaml:"shortcutColor"` } // LogIndicator tracks log view indicator. LogIndicator struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - ToggleOnColor Color `yaml:"toggleOnColor"` - ToggleOffColor Color `yaml:"toggleOffColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + ToggleOnColor Color `json:"toggleOnColor" yaml:"toggleOnColor"` + ToggleOffColor Color `json:"toggleOffColor" yaml:"toggleOffColor"` } // Yaml tracks yaml styles. Yaml struct { - KeyColor Color `yaml:"keyColor"` - ValueColor Color `yaml:"valueColor"` - ColonColor Color `yaml:"colonColor"` + KeyColor Color `json:"keyColor" yaml:"keyColor"` + ValueColor Color `json:"valueColor" yaml:"valueColor"` + ColonColor Color `json:"colonColor" yaml:"colonColor"` } // Title tracks title styles. Title struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - HighlightColor Color `yaml:"highlightColor"` - CounterColor Color `yaml:"counterColor"` - FilterColor Color `yaml:"filterColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + HighlightColor Color `json:"highlightColor" yaml:"highlightColor"` + CounterColor Color `json:"counterColor" yaml:"counterColor"` + FilterColor Color `json:"filterColor" yaml:"filterColor"` } // Info tracks info styles. Info struct { - SectionColor Color `yaml:"sectionColor"` - FgColor Color `yaml:"fgColor"` + SectionColor Color `json:"sectionColor" yaml:"sectionColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` } // Border tracks border styles. Border struct { - FgColor Color `yaml:"fgColor"` - FocusColor Color `yaml:"focusColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + FocusColor Color `json:"focusColor" yaml:"focusColor"` } // Crumb tracks crumbs styles. Crumb struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - ActiveColor Color `yaml:"activeColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + ActiveColor Color `json:"activeColor" yaml:"activeColor"` } // Table tracks table styles. Table struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - CursorFgColor Color `yaml:"cursorFgColor"` - CursorBgColor Color `yaml:"cursorBgColor"` - MarkColor Color `yaml:"markColor"` - Header TableHeader `yaml:"header"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + CursorFgColor Color `json:"cursorFgColor" yaml:"cursorFgColor"` + CursorBgColor Color `json:"cursorBgColor" yaml:"cursorBgColor"` + MarkColor Color `json:"markColor" yaml:"markColor"` + Header TableHeader `json:"header" yaml:"header"` } // TableHeader tracks table header styles. TableHeader struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - SorterColor Color `yaml:"sorterColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + SorterColor Color `json:"sorterColor" yaml:"sorterColor"` } // Xray tracks xray styles. Xray struct { - FgColor Color `yaml:"fgColor"` - BgColor Color `yaml:"bgColor"` - CursorColor Color `yaml:"cursorColor"` - CursorTextColor Color `yaml:"cursorTextColor"` - GraphicColor Color `yaml:"graphicColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + CursorColor Color `json:"cursorColor" yaml:"cursorColor"` + CursorTextColor Color `json:"cursorTextColor" yaml:"cursorTextColor"` + GraphicColor Color `json:"graphicColor" yaml:"graphicColor"` } // Menu tracks menu styles. Menu struct { - FgColor Color `yaml:"fgColor"` - KeyColor Color `yaml:"keyColor"` - NumKeyColor Color `yaml:"numKeyColor"` + FgColor Color `json:"fgColor" yaml:"fgColor"` + KeyColor Color `json:"keyColor" yaml:"keyColor"` + NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"` } // Charts tracks charts styles. Charts struct { - BgColor Color `yaml:"bgColor"` - DialBgColor Color `yaml:"dialBgColor"` - ChartBgColor Color `yaml:"chartBgColor"` - DefaultDialColors Colors `yaml:"defaultDialColors"` - DefaultChartColors Colors `yaml:"defaultChartColors"` - ResourceColors map[string]Colors `yaml:"resourceColors"` + BgColor Color `json:"bgColor" yaml:"bgColor"` + DialBgColor Color `json:"dialBgColor" yaml:"dialBgColor"` + ChartBgColor Color `json:"chartBgColor" yaml:"chartBgColor"` + DefaultDialColors Colors `json:"defaultDialColors" yaml:"defaultDialColors"` + DefaultChartColors Colors `json:"defaultChartColors" yaml:"defaultChartColors"` + ResourceColors map[string]Colors `json:"resourceColors" yaml:"resourceColors"` } ) @@ -442,7 +439,9 @@ func NewStyles() *Styles { // Reset resets styles. func (s *Styles) Reset() { - s.K9s = newStyle() + if err := yaml.Unmarshal(stockSkinTpl, s); err != nil { + s.K9s = newStyle() + } } // FgColor returns the foreground color. @@ -533,11 +532,14 @@ func (s *Styles) Views() Views { // Load K9s configuration from file. func (s *Styles) Load(path string) error { - f, err := os.ReadFile(path) + bb, err := os.ReadFile(path) if err != nil { return err } - if err := yaml.Unmarshal(f, s); err != nil { + if err := data.JSONValidator.Validate(json.SkinSchema, bb); err != nil { + return err + } + if err := yaml.Unmarshal(bb, s); err != nil { return err } @@ -561,3 +563,9 @@ func (s *Styles) Update() { s.fireStylesChanged() } + +// Dump for debug. +func (s *Styles) Dump() { + bb, _ := yaml.Marshal(s) + fmt.Println(string(bb)) +} diff --git a/internal/config/styles_int_test.go b/internal/config/styles_int_test.go new file mode 100644 index 00000000..1845b1f4 --- /dev/null +++ b/internal/config/styles_int_test.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_newStyle(t *testing.T) { + s := newStyle() + + assert.Equal(t, Color("black"), s.Body.BgColor) + assert.Equal(t, Color("cadetblue"), s.Body.FgColor) + assert.Equal(t, Color("lightskyblue"), s.Frame.Status.NewColor) +} diff --git a/internal/config/styles_test.go b/internal/config/styles_test.go index 572581c1..b2ada278 100644 --- a/internal/config/styles_test.go +++ b/internal/config/styles_test.go @@ -12,6 +12,14 @@ import ( "github.com/stretchr/testify/assert" ) +func TestNewStyle(t *testing.T) { + s := config.NewStyles() + + assert.Equal(t, config.Color("black"), s.K9s.Body.BgColor) + assert.Equal(t, config.Color("cadetblue"), s.K9s.Body.FgColor) + assert.Equal(t, config.Color("lightskyblue"), s.K9s.Frame.Status.NewColor) +} + func TestColor(t *testing.T) { uu := map[string]tcell.Color{ "blah": tcell.ColorDefault, @@ -28,22 +36,9 @@ func TestColor(t *testing.T) { } } -func TestSkinNone(t *testing.T) { +func TestSkinHappy(t *testing.T) { s := config.NewStyles() - assert.Nil(t, s.Load("testdata/empty_skin.yaml")) - s.Update() - - assert.Equal(t, "#5f9ea0", s.Body().FgColor.String()) - assert.Equal(t, "#000000", s.Body().BgColor.String()) - assert.Equal(t, "#000000", s.Table().BgColor.String()) - assert.Equal(t, tcell.ColorCadetBlue.TrueColor(), s.FgColor()) - assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor()) - assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor) -} - -func TestSkin(t *testing.T) { - s := config.NewStyles() - assert.Nil(t, s.Load("testdata/black_and_wtf.yaml")) + assert.Nil(t, s.Load("../../skins/black-and-wtf.yaml")) s.Update() assert.Equal(t, "#ffffff", s.Body().FgColor.String()) @@ -54,12 +49,38 @@ func TestSkin(t *testing.T) { assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor) } -func TestSkinNotExits(t *testing.T) { - s := config.NewStyles() - assert.NotNil(t, s.Load("testdata/blee.yaml")) -} +func TestSkinLoad(t *testing.T) { + uu := map[string]struct { + f string + err string + }{ + "not-exist": { + f: "testdata/skins/blee.yaml", + err: "open testdata/skins/blee.yaml: no such file or directory", + }, + "toast": { + f: "testdata/skins/boarked.yaml", + err: `Additional property bgColor is not allowed +Additional property fgColor is not allowed +Additional property logoColor is not allowed +Invalid type. Expected: object, given: array`, + }, + } -func TestSkinBoarked(t *testing.T) { - s := config.NewStyles() - assert.NotNil(t, s.Load("testdata/skin_boarked.yaml")) + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + s := config.NewStyles() + err := s.Load(u.f) + if err != nil { + assert.Equal(t, u.err, err.Error()) + } + assert.Equal(t, "#5f9ea0", s.Body().FgColor.String()) + assert.Equal(t, "#000000", s.Body().BgColor.String()) + assert.Equal(t, "#000000", s.Table().BgColor.String()) + assert.Equal(t, tcell.ColorCadetBlue.TrueColor(), s.FgColor()) + assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor()) + assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor) + }) + } } diff --git a/internal/config/templates/aliases.yaml b/internal/config/templates/aliases.yaml index 81c781f9..ee4d9ec0 100644 --- a/internal/config/templates/aliases.yaml +++ b/internal/config/templates/aliases.yaml @@ -1,9 +1,9 @@ aliases: - dp: apps/v1/deployments + dp: deployments sec: v1/secrets - jo: batch/v1/jobs - cr: rbac.authorization.k8s.io/v1/clusterroles - crb: rbac.authorization.k8s.io/v1/clusterrolebindings - ro: rbac.authorization.k8s.io/v1/roles - rb: rbac.authorization.k8s.io/v1/rolebindings - np: networking.k8s.io/v1/networkpolicies + jo: jobs + cr: clusterroles + crb: clusterrolebindings + ro: roles + rb: rolebindings + np: networkpolicies diff --git a/internal/config/testdata/aliases/aliases.yaml b/internal/config/testdata/aliases/aliases.yaml new file mode 100644 index 00000000..81c781f9 --- /dev/null +++ b/internal/config/testdata/aliases/aliases.yaml @@ -0,0 +1,9 @@ +aliases: + dp: apps/v1/deployments + sec: v1/secrets + jo: batch/v1/jobs + cr: rbac.authorization.k8s.io/v1/clusterroles + crb: rbac.authorization.k8s.io/v1/clusterrolebindings + ro: rbac.authorization.k8s.io/v1/roles + rb: rbac.authorization.k8s.io/v1/rolebindings + np: networking.k8s.io/v1/networkpolicies diff --git a/internal/config/testdata/alias.yaml b/internal/config/testdata/aliases/plain.yaml similarity index 100% rename from internal/config/testdata/alias.yaml rename to internal/config/testdata/aliases/plain.yaml diff --git a/internal/config/testdata/b_containers.yaml b/internal/config/testdata/benchmarks/b_containers.yaml similarity index 100% rename from internal/config/testdata/b_containers.yaml rename to internal/config/testdata/benchmarks/b_containers.yaml diff --git a/internal/config/testdata/b_containers_1.yaml b/internal/config/testdata/benchmarks/b_containers_1.yaml similarity index 100% rename from internal/config/testdata/b_containers_1.yaml rename to internal/config/testdata/benchmarks/b_containers_1.yaml diff --git a/internal/config/testdata/b_good.yaml b/internal/config/testdata/benchmarks/b_good.yaml similarity index 100% rename from internal/config/testdata/b_good.yaml rename to internal/config/testdata/benchmarks/b_good.yaml diff --git a/internal/config/testdata/b_toast.yaml b/internal/config/testdata/benchmarks/b_toast.yaml similarity index 100% rename from internal/config/testdata/b_toast.yaml rename to internal/config/testdata/benchmarks/b_toast.yaml diff --git a/internal/config/testdata/bench-fred.yaml b/internal/config/testdata/benchmarks/bench-fred.yaml similarity index 100% rename from internal/config/testdata/bench-fred.yaml rename to internal/config/testdata/benchmarks/bench-fred.yaml diff --git a/internal/config/testdata/configs/default.yaml b/internal/config/testdata/configs/default.yaml new file mode 100644 index 00000000..3cd46348 --- /dev/null +++ b/internal/config/testdata/configs/default.yaml @@ -0,0 +1,41 @@ +k9s: + liveViewAutoRefresh: false + screenDumpDir: /tmp/k9s-test/screen-dumps + refreshRate: 2 + maxConnRetry: 5 + readOnly: false + noExitOnCtrlC: false + ui: + enableMouse: false + headless: false + logoless: false + crumbsless: false + reactive: false + noIcons: false + skipLatestRevCheck: false + disablePodCounting: false + shellPod: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 100 + buffer: 5000 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/testdata/configs/expected.yaml b/internal/config/testdata/configs/expected.yaml new file mode 100644 index 00000000..00389211 --- /dev/null +++ b/internal/config/testdata/configs/expected.yaml @@ -0,0 +1,41 @@ +k9s: + liveViewAutoRefresh: true + screenDumpDir: /tmp/k9s-test/screen-dumps + refreshRate: 100 + maxConnRetry: 5 + readOnly: true + noExitOnCtrlC: false + ui: + enableMouse: false + headless: false + logoless: false + crumbsless: false + reactive: false + noIcons: false + skipLatestRevCheck: false + disablePodCounting: false + shellPod: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 500 + buffer: 800 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/testdata/configs/k9s.yaml b/internal/config/testdata/configs/k9s.yaml new file mode 100644 index 00000000..050392b6 --- /dev/null +++ b/internal/config/testdata/configs/k9s.yaml @@ -0,0 +1,41 @@ +k9s: + liveViewAutoRefresh: true + screenDumpDir: /tmp/k9s-test/screen-dumps + refreshRate: 2 + maxConnRetry: 5 + readOnly: false + noExitOnCtrlC: false + ui: + enableMouse: false + headless: false + logoless: false + crumbsless: false + reactive: false + noIcons: false + skipLatestRevCheck: false + disablePodCounting: false + shellPod: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 200 + buffer: 2000 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/testdata/configs/k9s_toast.yaml b/internal/config/testdata/configs/k9s_toast.yaml new file mode 100644 index 00000000..00e56a06 --- /dev/null +++ b/internal/config/testdata/configs/k9s_toast.yaml @@ -0,0 +1,40 @@ +k9s: + liveViewAutoRefresh: true + screenDumpDir: /tmp/screen-dumps + refreshRate: 2 + maxConnRetry: 5 + readOnly: false + noExitOnCtrlC: false + ui: + enableMouse: false + headless: false + logoless: false + crumbsless: false + noIcons: false + skipLatestRevCheck: yes + disablePodCounts: false + shellPods: + image: busybox:1.35.0 + namespace: default + limits: + cpu: 100m + memory: 100Mi + imageScans: + enable: false + exclusions: + namespaces: [] + labels: {} + logger: + tail: 200 + buffer: 2000 + sinceSeconds: -1 + fullScreen: false + textWrap: false + showTime: false + thresholds: + cpu: + critical: 90 + warn: 70 + memory: + critical: 90 + warn: 70 diff --git a/internal/config/testdata/empty_skin.yaml b/internal/config/testdata/empty_skin.yaml deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/config/testdata/hotkeys.yaml b/internal/config/testdata/hotkeys/hotkeys.yaml similarity index 100% rename from internal/config/testdata/hotkeys.yaml rename to internal/config/testdata/hotkeys/hotkeys.yaml diff --git a/internal/config/testdata/k9s.yaml b/internal/config/testdata/k9s.yaml deleted file mode 100644 index 8bb5df2e..00000000 --- a/internal/config/testdata/k9s.yaml +++ /dev/null @@ -1,35 +0,0 @@ -k9s: - liveViewAutoRefresh: true - refreshRate: 2 - readOnly: false - logger: - tail: 200 - buffer: 2000 - currentContext: minikube - contexts: - minikube: - cluster: minikube - namespace: - active: kube-system - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: ctx - fred: - cluster: fred - namespace: - active: default - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: po - screenDumpDir: /tmp - disablePodCounting: false diff --git a/internal/config/testdata/k9s1.yaml b/internal/config/testdata/k9s1.yaml deleted file mode 100644 index 99bb975f..00000000 --- a/internal/config/testdata/k9s1.yaml +++ /dev/null @@ -1,8 +0,0 @@ -k9s: - refreshRate: 10 - namespace: - active: fred - favorites: - - blee - - duh - - crap \ No newline at end of file diff --git a/internal/config/testdata/k9s_old.yaml b/internal/config/testdata/k9s_old.yaml deleted file mode 100644 index dade14d9..00000000 --- a/internal/config/testdata/k9s_old.yaml +++ /dev/null @@ -1,13 +0,0 @@ -k9s: - refreshRate: 2 - logBufferSize: 200 - namespace: - active: kube-system - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: ctx \ No newline at end of file diff --git a/internal/config/testdata/k9s_readonly.yaml b/internal/config/testdata/k9s_readonly.yaml deleted file mode 100644 index e3edca14..00000000 --- a/internal/config/testdata/k9s_readonly.yaml +++ /dev/null @@ -1,30 +0,0 @@ -k9s: - refreshRate: 2 - readOnly: true - logger: - tail: 200 - buffer: 2000 - currentContext: minikube - contexts: - minikube: - namespace: - active: kube-system - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: ctx - fred: - namespace: - active: default - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: po diff --git a/internal/config/testdata/k9s_toast.yaml b/internal/config/testdata/k9s_toast.yaml deleted file mode 100644 index ac84a589..00000000 --- a/internal/config/testdata/k9s_toast.yaml +++ /dev/null @@ -1,27 +0,0 @@ -k9s: - refreshRate: 2 - logBufferSize: 200 - currentContext: minikube - contexts: - minikube: - namespace: - active: kube-system - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: ctx - fred: - namespace: - active: default - favorites: - - default - - kube-public - - istio-system - - all - - kube-system - view: - active: po \ No newline at end of file diff --git a/internal/config/testdata/kubeconfig-test.yaml b/internal/config/testdata/kubes/test.yaml similarity index 68% rename from internal/config/testdata/kubeconfig-test.yaml rename to internal/config/testdata/kubes/test.yaml index 725d04bc..759598b0 100644 --- a/internal/config/testdata/kubeconfig-test.yaml +++ b/internal/config/testdata/kubes/test.yaml @@ -5,18 +5,27 @@ clusters: certificate-authority: /Users/test/ca.crt server: https://1.2.3.4:8443 name: cl-1 + - cluster: + certificate-authority: /Users/test/ca.crt + server: https://5.6.7.8:8443 + name: cl-2 contexts: - context: cluster: cl-1 user: user1 namespace: ns-1 - name: ct-1 + name: ct-1-1 - context: cluster: cl-1 user: user2 namespace: ns-2 - name: ct-2 -current-context: ct-1 + name: ct-1-2 + - context: + cluster: cl-2 + user: user2 + namespace: ns-2 + name: ct-2-1 +current-context: ct-1-1 preferences: {} users: - name: user1 diff --git a/internal/config/testdata/black_and_wtf.yaml b/internal/config/testdata/skins/black-and-wtf.yaml similarity index 82% rename from internal/config/testdata/black_and_wtf.yaml rename to internal/config/testdata/skins/black-and-wtf.yaml index 5cef5c95..2ad58452 100644 --- a/internal/config/testdata/black_and_wtf.yaml +++ b/internal/config/testdata/skins/black-and-wtf.yaml @@ -31,11 +31,12 @@ k9s: highlightColor: navajowhite counterColor: navajowhite filterColor: slategray - table: - fgColor: white - bgColor: black - cursorColor: white - header: - fgColor: darkgray + views: + table: + fgColor: white bgColor: black - sorterColor: white + cursorColor: white + header: + fgColor: darkgray + bgColor: black + sorterColor: white diff --git a/internal/config/testdata/skin_boarked.yaml b/internal/config/testdata/skins/boarked.yaml similarity index 100% rename from internal/config/testdata/skin_boarked.yaml rename to internal/config/testdata/skins/boarked.yaml diff --git a/internal/config/testdata/skins/empty.yaml b/internal/config/testdata/skins/empty.yaml new file mode 100644 index 00000000..27de2f65 --- /dev/null +++ b/internal/config/testdata/skins/empty.yaml @@ -0,0 +1,2 @@ +k9s: + body: \ No newline at end of file diff --git a/internal/config/testdata/view_settings.yaml b/internal/config/testdata/view_settings.yaml deleted file mode 100644 index 3ea3050c..00000000 --- a/internal/config/testdata/view_settings.yaml +++ /dev/null @@ -1,8 +0,0 @@ -k9s: - views: - v1/pods: - columns: - - NAMESPACE - - NAME - - AGE - - IP diff --git a/internal/config/testdata/views/views.yaml b/internal/config/testdata/views/views.yaml new file mode 100644 index 00000000..b6debac3 --- /dev/null +++ b/internal/config/testdata/views/views.yaml @@ -0,0 +1,7 @@ +views: + v1/pods: + columns: + - NAMESPACE + - NAME + - AGE + - IP diff --git a/internal/config/threshold.go b/internal/config/threshold.go index e7467755..de01250a 100644 --- a/internal/config/threshold.go +++ b/internal/config/threshold.go @@ -61,7 +61,7 @@ func NewThreshold() Threshold { } // Validate a namespace is setup correctly. -func (t Threshold) Validate() { +func (t Threshold) Validate() Threshold { for _, k := range []string{"cpu", "memory"} { v, ok := t[k] if !ok { @@ -70,6 +70,8 @@ func (t Threshold) Validate() { v.Validate() } } + + return t } // LevelFor returns a defcon level for the current state. diff --git a/internal/config/types.go b/internal/config/types.go index 9e8fea59..3176ea94 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -11,21 +11,24 @@ const ( // UI tracks ui specific configs. type UI struct { // EnableMouse toggles mouse support. - EnableMouse bool `yaml:"enableMouse"` + EnableMouse bool `json:"enableMouse" yaml:"enableMouse"` // Headless toggles top header display. - Headless bool `yaml:"headless"` + Headless bool `json:"headless" yaml:"headless"` // LogoLess toggles k9s logo. - Logoless bool `yaml:"logoless"` + Logoless bool `json:"logoless" yaml:"logoless"` // Crumbsless toggles nav crumb display. - Crumbsless bool `yaml:"crumbsless"` + Crumbsless bool `json:"crumbsless" yaml:"crumbsless"` + + // Reactive toggles reactive ui changes. + Reactive bool `json:"reactive" yaml:"reactive"` // NoIcons toggles icons display. - NoIcons bool `yaml:"noIcons"` + NoIcons bool `json:"noIcons" yaml:"noIcons"` // Skin reference the general k9s skin name. // Can be overridden per context. - Skin string `yaml:"skin,omitempty"` + Skin string `json:"skin" yaml:"skin,omitempty"` } diff --git a/internal/config/views.go b/internal/config/views.go index ae2ad479..87615646 100644 --- a/internal/config/views.go +++ b/internal/config/views.go @@ -4,8 +4,12 @@ package config import ( + "fmt" "os" + "github.com/derailed/k9s/internal/config/data" + "github.com/derailed/k9s/internal/config/json" + "gopkg.in/yaml.v2" ) @@ -21,51 +25,41 @@ type ViewSetting struct { SortColumn string `yaml:"sortColumn"` } -// ViewSettings represent a collection of view configurations. -type ViewSettings struct { - Views map[string]ViewSetting `yaml:"views"` -} - -// NewViewSettings returns a new configuration. -func NewViewSettings() ViewSettings { - return ViewSettings{ - Views: make(map[string]ViewSetting), - } -} - // CustomView represents a collection of view customization. type CustomView struct { - K9s ViewSettings `yaml:"k9s"` + Views map[string]ViewSetting `yaml:"views"` listeners map[string]ViewConfigListener } // NewCustomView returns a views configuration. func NewCustomView() *CustomView { return &CustomView{ - K9s: NewViewSettings(), + Views: make(map[string]ViewSetting), listeners: make(map[string]ViewConfigListener), } } // Reset clears out configurations. func (v *CustomView) Reset() { - for k := range v.K9s.Views { - delete(v.K9s.Views, k) + for k := range v.Views { + delete(v.Views, k) } } // Load loads view configurations. func (v *CustomView) Load(path string) error { - raw, err := os.ReadFile(path) + bb, err := os.ReadFile(path) if err != nil { return err } - + if err := data.JSONValidator.Validate(json.ViewsSchema, bb); err != nil { + return fmt.Errorf("validation failed for %q: %w", path, err) + } var in CustomView - if err := yaml.Unmarshal(raw, &in); err != nil { + if err := yaml.Unmarshal(bb, &in); err != nil { return err } - v.K9s = in.K9s + v.Views = in.Views v.fireConfigChanged() return nil @@ -84,7 +78,7 @@ func (v *CustomView) RemoveListener(gvr string) { func (v *CustomView) fireConfigChanged() { for gvr, list := range v.listeners { - if v, ok := v.K9s.Views[gvr]; ok { + if v, ok := v.Views[gvr]; ok { list.ViewSettingsChanged(v) } else { list.ViewSettingsChanged(ViewSetting{}) diff --git a/internal/config/views_test.go b/internal/config/views_test.go index c883fed9..2afeea28 100644 --- a/internal/config/views_test.go +++ b/internal/config/views_test.go @@ -13,7 +13,7 @@ import ( func TestViewSettingsLoad(t *testing.T) { cfg := config.NewCustomView() - assert.Nil(t, cfg.Load("testdata/view_settings.yaml")) - assert.Equal(t, 1, len(cfg.K9s.Views)) - assert.Equal(t, 4, len(cfg.K9s.Views["v1/pods"].Columns)) + assert.Nil(t, cfg.Load("testdata/views/views.yaml")) + assert.Equal(t, 1, len(cfg.Views)) + assert.Equal(t, 4, len(cfg.Views["v1/pods"].Columns)) } diff --git a/internal/dao/popeye.go b/internal/dao/popeye.go index 43106590..0ec61fd0 100644 --- a/internal/dao/popeye.go +++ b/internal/dao/popeye.go @@ -68,9 +68,9 @@ func (p *Popeye) List(ctx context.Context, ns string) ([]runtime.Object, error) flags.Sections = §ions flags.ActiveNamespace = &ns } - spinach := cfg.YamlExtension(filepath.Join(cfg.K9sHome(), "spinach.yaml")) + spinach := filepath.Join(cfg.AppConfigDir, "spinach.yaml") if c, err := p.GetFactory().Client().Config().CurrentContextName(); err == nil { - spinach = cfg.YamlExtension(filepath.Join(cfg.K9sHome(), fmt.Sprintf("%s_spinach.yaml", c))) + spinach = filepath.Join(cfg.AppConfigDir, fmt.Sprintf("%s_spinach.yaml", c)) } if _, err := os.Stat(spinach); err == nil { flags.Spinach = &spinach diff --git a/internal/model/cluster_info.go b/internal/model/cluster_info.go index ac69ee85..5f1653be 100644 --- a/internal/model/cluster_info.go +++ b/internal/model/cluster_info.go @@ -9,8 +9,11 @@ import ( "errors" "io" "net/http" + "sync" "time" + "github.com/derailed/k9s/internal/config" + "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/rs/zerolog/log" @@ -72,24 +75,25 @@ func (c ClusterMeta) Deltas(n ClusterMeta) bool { // ClusterInfo models cluster metadata. type ClusterInfo struct { - cluster *Cluster - factory dao.Factory - data ClusterMeta - version string - skipLatestRevCheck bool - listeners []ClusterInfoListener - cache *cache.LRUExpireCache + cluster *Cluster + factory dao.Factory + data ClusterMeta + version string + cfg *config.K9s + listeners []ClusterInfoListener + cache *cache.LRUExpireCache + mx sync.RWMutex } // NewClusterInfo returns a new instance. -func NewClusterInfo(f dao.Factory, v string, skipLatestRevCheck bool) *ClusterInfo { +func NewClusterInfo(f dao.Factory, v string, cfg *config.K9s) *ClusterInfo { c := ClusterInfo{ - factory: f, - cluster: NewCluster(f), - data: NewClusterMeta(), - version: v, - skipLatestRevCheck: skipLatestRevCheck, - cache: cache.NewLRUExpireCache(cacheSize), + factory: f, + cluster: NewCluster(f), + data: NewClusterMeta(), + version: v, + cfg: cfg, + cache: cache.NewLRUExpireCache(cacheSize), } return &c @@ -113,7 +117,16 @@ func (c *ClusterInfo) fetchK9sLatestRev() string { // Reset resets context and reload. func (c *ClusterInfo) Reset(f dao.Factory) { - c.cluster, c.data = NewCluster(f), NewClusterMeta() + if f == nil { + return + } + + c.mx.Lock() + { + c.cluster, c.data = NewCluster(f), NewClusterMeta() + } + c.mx.Unlock() + c.Refresh() } @@ -138,7 +151,7 @@ func (c *ClusterInfo) Refresh() { v1 := NewSemVer(data.K9sVer) var latestRev string - if !c.skipLatestRevCheck { + if !c.cfg.SkipLatestRevCheck { latestRev = c.fetchK9sLatestRev() } v2 := NewSemVer(latestRev) @@ -153,7 +166,11 @@ func (c *ClusterInfo) Refresh() { } else { c.fireNoMetaChanged(data) } - c.data = data + c.mx.Lock() + { + c.data = data + } + c.mx.Unlock() } // AddListener adds a new model listener. diff --git a/internal/model/log.go b/internal/model/log.go index 09297b06..d194fcf0 100644 --- a/internal/model/log.go +++ b/internal/model/log.go @@ -108,7 +108,7 @@ func (l *Log) SetSinceSeconds(ctx context.Context, i int64) { } // Configure sets logger configuration. -func (l *Log) Configure(opts *config.Logger) { +func (l *Log) Configure(opts config.Logger) { l.logOptions.Lines = int64(opts.TailCount) l.logOptions.SinceSeconds = opts.SinceSeconds } diff --git a/internal/ui/action.go b/internal/ui/action.go index 66913ba3..3e4b8efd 100644 --- a/internal/ui/action.go +++ b/internal/ui/action.go @@ -15,12 +15,19 @@ type ( // ActionHandler handles a keyboard command. ActionHandler func(*tcell.EventKey) *tcell.EventKey + ActionOpts struct { + Visible bool + Shared bool + Plugin bool + HotKey bool + Dangerous bool + } + // KeyAction represents a keyboard action. KeyAction struct { Description string Action ActionHandler - Visible bool - Shared bool + Opts ActionOpts } // KeyActions tracks mappings between keystrokes and actions. @@ -28,13 +35,32 @@ type ( ) // NewKeyAction returns a new keyboard action. -func NewKeyAction(d string, a ActionHandler, display bool) KeyAction { - return KeyAction{Description: d, Action: a, Visible: display} +func NewKeyAction(d string, a ActionHandler, visible bool) KeyAction { + return NewKeyActionWithOpts(d, a, ActionOpts{ + Visible: visible, + }) } // NewSharedKeyAction returns a new shared keyboard action. -func NewSharedKeyAction(d string, a ActionHandler, display bool) KeyAction { - return KeyAction{Description: d, Action: a, Visible: display, Shared: true} +func NewSharedKeyAction(d string, a ActionHandler, visible bool) KeyAction { + return NewKeyActionWithOpts(d, a, ActionOpts{ + Visible: visible, + Shared: true, + }) +} + +// NewKeyActionWithOpts returns a new keyboard action. +func NewKeyActionWithOpts(d string, a ActionHandler, opts ActionOpts) KeyAction { + return KeyAction{ + Description: d, + Action: a, + Opts: opts, + } +} + +func (a KeyActions) Reset(aa KeyActions) { + a.Clear() + a.Add(aa) } // Add sets up keyboard action listener. @@ -51,6 +77,15 @@ func (a KeyActions) Clear() { } } +// ClearDanger remove all dangerous actions. +func (a KeyActions) ClearDanger() { + for k, v := range a { + if v.Opts.Dangerous { + delete(a, k) + } + } +} + // Set replace actions with new ones. func (a KeyActions) Set(aa KeyActions) { for k, v := range aa { @@ -69,7 +104,7 @@ func (a KeyActions) Delete(kk ...tcell.Key) { func (a KeyActions) Hints() model.MenuHints { kk := make([]int, 0, len(a)) for k := range a { - if !a[k].Shared { + if !a[k].Opts.Shared { kk = append(kk, int(k)) } } @@ -82,7 +117,7 @@ func (a KeyActions) Hints() model.MenuHints { model.MenuHint{ Mnemonic: name, Description: a[tcell.Key(k)].Description, - Visible: a[tcell.Key(k)].Visible, + Visible: a[tcell.Key(k)].Opts.Visible, }, ) } else { diff --git a/internal/ui/app.go b/internal/ui/app.go index 1b843f91..4a3b3e27 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -34,12 +34,11 @@ func NewApp(cfg *config.Config, context string) *App { a := App{ Application: tview.NewApplication(), actions: make(KeyActions), - Configurator: Configurator{Config: cfg}, + Configurator: Configurator{Config: cfg, Styles: config.NewStyles()}, Main: NewPages(), flash: model.NewFlash(model.DefaultFlashDelay), cmdBuff: model.NewFishBuff(':', model.CommandBuffer), } - a.ReloadStyles() a.views = map[string]tview.Primitive{ "menu": NewMenu(a.Styles), @@ -134,11 +133,6 @@ func (a *App) StylesChanged(s *config.Styles) { } } -// ReloadStyles reloads skin file. -func (a *App) ReloadStyles() { - a.RefreshStyles() -} - // Conn returns an api server connection. func (a *App) Conn() client.Connection { return a.Config.GetConnection() diff --git a/internal/ui/config.go b/internal/ui/config.go index 4509280c..27c5cf82 100644 --- a/internal/ui/config.go +++ b/internal/ui/config.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" "github.com/fsnotify/fsnotify" @@ -17,6 +19,8 @@ import ( // Synchronizer manages ui event queue. type synchronizer interface { + Flash() *model.Flash + UpdateClusterInfo() QueueUpdateDraw(func()) QueueUpdate(func()) } @@ -46,7 +50,7 @@ func (c *Configurator) CustomViewsWatcher(ctx context.Context, s synchronizer) e for { select { case evt := <-w.Events: - if evt.Name == config.AppViewsFile { + if evt.Name == config.AppViewsFile && evt.Op != fsnotify.Chmod { s.QueueUpdateDraw(func() { if err := c.RefreshCustomViews(); err != nil { log.Warn().Err(err).Msgf("Custom views refresh failed") @@ -66,10 +70,11 @@ func (c *Configurator) CustomViewsWatcher(ctx context.Context, s synchronizer) e } }() - if err := c.RefreshCustomViews(); err != nil { + if err := w.Add(config.AppViewsFile); err != nil { return err } - return w.Add(config.AppViewsFile) + + return c.RefreshCustomViews() } // RefreshCustomViews load view configuration changes. @@ -85,15 +90,13 @@ func (c *Configurator) RefreshCustomViews() error { // SkinsDirWatcher watches for skin directory file changes. func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) error { - if !c.HasSkin() { - return nil + if _, err := os.Stat(config.AppSkinsDir); os.IsNotExist(err) { + return err } - w, err := fsnotify.NewWatcher() if err != nil { return err } - go func() { for { select { @@ -101,7 +104,7 @@ func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) erro if evt.Name == c.skinFile && evt.Op != fsnotify.Chmod { log.Debug().Msgf("Skin changed: %s", c.skinFile) s.QueueUpdateDraw(func() { - c.RefreshStyles() + c.RefreshStyles(s) }) } case err := <-w.Errors: @@ -133,18 +136,20 @@ func (c *Configurator) ConfigWatcher(ctx context.Context, s synchronizer) error select { case evt := <-w.Events: if evt.Has(fsnotify.Create) || evt.Has(fsnotify.Write) { - log.Debug().Msgf("ConfigWatcher file changed: %s -- %#v", evt.Name, evt.Op.String()) + log.Debug().Msgf("ConfigWatcher file changed: %s", evt.Name) if evt.Name == config.AppConfigFile { if err := c.Config.Load(evt.Name); err != nil { - log.Error().Err(err).Msgf("Config reload failed") + log.Error().Err(err).Msgf("k9s config reload failed") + s.Flash().Warn("k9s config reload failed. Check k9s logs!") } } else { if err := c.Config.K9s.Reload(); err != nil { - log.Error().Err(err).Msgf("Context config reload failed") + log.Error().Err(err).Msgf("k9s context config reload failed") + s.Flash().Warn("Context config reload failed. Check k9s logs!") } } s.QueueUpdateDraw(func() { - c.RefreshStyles() + c.RefreshStyles(s) }) } case err := <-w.Errors: @@ -181,11 +186,18 @@ func (c *Configurator) activeSkin() (string, bool) { return skin, false } - if ct, err := c.Config.K9s.ActiveContext(); err == nil { - skin = ct.Skin + if ct, err := c.Config.K9s.ActiveContext(); err == nil && ct.Skin != "" { + if _, err := os.Stat(config.SkinFileFromName(ct.Skin)); !os.IsNotExist(err) { + skin = ct.Skin + log.Debug().Msgf("[Skin] Loading context skin (%q) from %q", skin, c.Config.K9s.ActiveContextName()) + } } - if skin == "" { - skin = c.Config.K9s.UI.Skin + + if sk := c.Config.K9s.UI.Skin; skin == "" && sk != "" { + if _, err := os.Stat(config.SkinFileFromName(sk)); !os.IsNotExist(err) { + skin = sk + log.Debug().Msgf("[Skin] Loading global skin (%q)", skin) + } } return skin, skin != "" @@ -208,46 +220,53 @@ func (c *Configurator) activeConfig() (cluster string, context string, ok bool) } // RefreshStyles load for skin configuration changes. -func (c *Configurator) RefreshStyles() { +func (c *Configurator) RefreshStyles(s synchronizer) { + s.UpdateClusterInfo() if c.Styles == nil { c.Styles = config.NewStyles() } + defer c.loadSkinFile(s) cl, ct, ok := c.activeConfig() if !ok { - log.Debug().Msgf("No custom skin found. Using stock skin") - c.updateStyles("") return } - + // !!BOZO!! Lame move out! if bc, err := config.EnsureBenchmarksCfgFile(cl, ct); err != nil { log.Warn().Err(err).Msgf("No benchmark config file found: %q@%q", cl, ct) } else { c.BenchFile = bc } +} +func (c *Configurator) loadSkinFile(s synchronizer) { skin, ok := c.activeSkin() if !ok { log.Debug().Msgf("No custom skin found. Using stock skin") c.updateStyles("") return } + skinFile := config.SkinFileFromName(skin) + log.Debug().Msgf("Loading skin file: %q", skinFile) if err := c.Styles.Load(skinFile); err != nil { if errors.Is(err, os.ErrNotExist) { - log.Warn().Msgf("Skin file %q not found in skins dir: %s", skinFile, config.AppSkinsDir) + s.Flash().Warnf("Skin file %q not found in skins dir: %s", filepath.Base(skinFile), config.AppSkinsDir) } else { - log.Error().Msgf("Failed to parse skin file -- %s: %s.", skinFile, err) + s.Flash().Errf("Failed to parse skin file -- %s: %s.", filepath.Base(skinFile), err) } c.updateStyles("") } else { - log.Debug().Msgf("Loading skin file: %q", skinFile) + s.Flash().Infof("Skin file loaded: %q", skinFile) c.updateStyles(skinFile) } } func (c *Configurator) updateStyles(f string) { c.skinFile = f + if f == "" { + c.Styles.Reset() + } c.Styles.Update() render.ModColor = c.Styles.Frame().Status.ModifyColor.Color() diff --git a/internal/ui/config_test.go b/internal/ui/config_test.go index b8a81c27..7117de9c 100644 --- a/internal/ui/config_test.go +++ b/internal/ui/config_test.go @@ -7,10 +7,12 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" + "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" @@ -19,15 +21,14 @@ import ( ) func TestSkinnedContext(t *testing.T) { - os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config") - + os.Setenv(config.K9sEnvConfigDir, "/tmp/k9s-test") assert.NoError(t, config.InitLocs()) defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir)) - sf := filepath.Join("..", "config", "testdata", "black_and_wtf.yaml") + sf := filepath.Join("..", "config", "testdata", "skins", "black-and-wtf.yaml") raw, err := os.ReadFile(sf) assert.NoError(t, err) - tf := filepath.Join(config.AppSkinsDir, "black_and_wtf.yaml") + tf := filepath.Join(config.AppSkinsDir, "black-and-wtf.yaml") assert.NoError(t, os.WriteFile(tf, raw, data.DefaultFileMod)) var cfg ui.Configurator @@ -43,9 +44,8 @@ func TestSkinnedContext(t *testing.T) { mock.NewMockKubeSettings(&flags)) _, err = cfg.Config.K9s.ActivateContext("ct-1-1") assert.NoError(t, err) - cfg.Config.K9s.UI = config.UI{Skin: "black_and_wtf"} - cfg.RefreshStyles() - + cfg.Config.K9s.UI = config.UI{Skin: "black-and-wtf"} + cfg.RefreshStyles(newMockSynchronizer()) assert.True(t, cfg.HasSkin()) assert.Equal(t, tcell.ColorGhostWhite.TrueColor(), render.StdColor) assert.Equal(t, tcell.ColorWhiteSmoke.TrueColor(), render.ErrColor) @@ -60,3 +60,18 @@ func TestBenchConfig(t *testing.T) { assert.NoError(t, error) assert.Equal(t, "/tmp/test-config/clusters/cl-1/ct-1/benchmarks.yaml", bc) } + +// Helpers... + +type synchronizer struct{} + +func newMockSynchronizer() synchronizer { + return synchronizer{} +} + +func (s synchronizer) Flash() *model.Flash { + return model.NewFlash(100 * time.Millisecond) +} +func (s synchronizer) UpdateClusterInfo() {} +func (s synchronizer) QueueUpdateDraw(func()) {} +func (s synchronizer) QueueUpdate(func()) {} diff --git a/internal/ui/logo.go b/internal/ui/logo.go index 1971410c..eb8f8501 100644 --- a/internal/ui/logo.go +++ b/internal/ui/logo.go @@ -6,6 +6,7 @@ package ui import ( "fmt" "strings" + "sync" "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" @@ -17,6 +18,7 @@ type Logo struct { logo, status *tview.TextView styles *config.Styles + mx sync.Mutex } // NewLogo returns a new logo. @@ -89,6 +91,9 @@ func (l *Logo) update(msg string, c config.Color) { } func (l *Logo) refreshStatus(msg string, c config.Color) { + l.mx.Lock() + defer l.mx.Unlock() + l.status.SetBackgroundColor(c.Color()) l.status.SetText( fmt.Sprintf("[%s::b]%s", l.styles.Body().LogoColorMsg, msg), @@ -96,6 +101,8 @@ func (l *Logo) refreshStatus(msg string, c config.Color) { } func (l *Logo) refreshLogo(c config.Color) { + l.mx.Lock() + defer l.mx.Unlock() l.logo.Clear() for i, s := range LogoSmall { fmt.Fprintf(l.logo, "[%s::b]%s", c, s) diff --git a/internal/ui/table.go b/internal/ui/table.go index 4f51a2f9..66b04503 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -367,14 +367,14 @@ func (t *Table) Refresh() { t.Update(data, t.hasMetrics) } -// GetSelectedRow returns the entire selected row. -func (t *Table) GetSelectedRow(path string) (render.Row, bool) { +// GetSelectedRow returns the entire selected row or nil if nothing selected. +func (t *Table) GetSelectedRow(path string) *render.Row { data := t.model.Peek() i, ok := data.RowEvents.FindIndex(path) if !ok { - return render.Row{}, ok + return nil } - return data.RowEvents[i].Row, true + return &data.RowEvents[i].Row } // NameColIndex returns the index of the resource name column. diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 2962de93..f6169abd 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -46,10 +46,11 @@ func TestTableSelection(t *testing.T) { v.Update(m.Peek(), false) v.SelectRow(1, 0, true) - r, ok := v.GetSelectedRow("r1") - assert.True(t, ok) + r := v.GetSelectedRow("r1") + if r != nil { + assert.Equal(t, render.Row{ID: "r1", Fields: render.Fields{"blee", "duh", "fred"}}, *r) + } assert.Equal(t, "r1", v.GetSelectedItem()) - assert.Equal(t, render.Row{ID: "r1", Fields: render.Fields{"blee", "duh", "fred"}}, r) assert.Equal(t, "blee", v.GetSelectedCell(0)) assert.Equal(t, 1, v.GetSelectedRowIndex()) assert.Equal(t, []string{"r1"}, v.GetSelectedItems()) diff --git a/internal/view/actions.go b/internal/view/actions.go index b6c14afc..60007ab7 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -57,21 +57,27 @@ func inScope(scopes, aliases []string) bool { return false } -func hotKeyActions(r Runner, aa ui.KeyActions) { +func hotKeyActions(r Runner, aa ui.KeyActions) error { hh := config.NewHotKeys() - if err := hh.Load(); err != nil { - return + for k, a := range aa { + if a.Opts.HotKey { + delete(aa, k) + } } + var errs error + if err := hh.Load(r.App().Config.ContextHotkeysPath()); err != nil { + errs = errors.Join(errs, err) + } for k, hk := range hh.HotKey { key, err := asKey(hk.ShortCut) if err != nil { - log.Warn().Err(err).Msg("HOT-KEY Unable to map hotkey shortcut to a key") + errs = errors.Join(errs, err) continue } _, ok := aa[key] if ok { - log.Warn().Err(fmt.Errorf("HOT-KEY Doh! you are trying to override an existing command `%s", k)).Msg("Invalid shortcut") + errs = errors.Join(errs, fmt.Errorf("duplicated hotkeys found for %q in %q", hk.ShortCut, k)) continue } @@ -81,11 +87,17 @@ func hotKeyActions(r Runner, aa ui.KeyActions) { continue } - aa[key] = ui.NewSharedKeyAction( + aa[key] = ui.NewKeyActionWithOpts( hk.Description, gotoCmd(r, command, "", !hk.KeepHistory), - false) + ui.ActionOpts{ + Shared: true, + HotKey: true, + }, + ) } + + return errs } func gotoCmd(r Runner, cmd, path string, clearStack bool) ui.ActionHandler { @@ -95,31 +107,42 @@ func gotoCmd(r Runner, cmd, path string, clearStack bool) ui.ActionHandler { } } -func pluginActions(r Runner, aa ui.KeyActions) { +func pluginActions(r Runner, aa ui.KeyActions) error { pp := config.NewPlugins() - if err := pp.Load(r.App().Config.ContextPluginsPath()); err != nil { - return + for k, a := range aa { + if a.Opts.Plugin { + delete(aa, k) + } } + var errs error + if err := pp.Load(r.App().Config.ContextPluginsPath()); err != nil { + errs = errors.Join(errs, err) + } for k, plugin := range pp.Plugins { if !inScope(plugin.Scopes, r.Aliases()) { continue } key, err := asKey(plugin.ShortCut) if err != nil { - log.Warn().Err(err).Msg("Unable to map plugin shortcut to a key") + errs = errors.Join(errs, err) continue } _, ok := aa[key] if ok { - log.Warn().Msgf("Invalid shortcut. You are trying to override an existing command `%s", k) + errs = errors.Join(errs, fmt.Errorf("duplicated plugin key found for %q in %q", plugin.ShortCut, k)) continue } - aa[key] = ui.NewKeyAction( + aa[key] = ui.NewKeyActionWithOpts( plugin.Description, pluginAction(r, plugin), - true) + ui.ActionOpts{ + Visible: true, + Plugin: true, + }) } + + return errs } func pluginAction(r Runner, p config.Plugin) ui.ActionHandler { diff --git a/internal/view/app.go b/internal/view/app.go index b907dced..4c4c1959 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -66,6 +66,7 @@ func NewApp(cfg *config.Config) *App { filterHistory: model.NewHistory(model.MaxHistory), Content: NewPageStack(), } + a.ReloadStyles() a.Views()["statusIndicator"] = ui.NewStatusIndicator(a.App, a.Styles) a.Views()["clusterInfo"] = NewClusterInfo(&a) @@ -73,6 +74,18 @@ func NewApp(cfg *config.Config) *App { return &a } +// ReloadStyles reloads skin file. +func (a *App) ReloadStyles() { + a.RefreshStyles(a) +} + +// UpdateClusterInfo updates clusterInfo panel +func (a *App) UpdateClusterInfo() { + if a.factory != nil { + a.clusterModel.Reset(a.factory) + } +} + // ConOK checks the connection is cool, returns false otherwise. func (a *App) ConOK() bool { return atomic.LoadInt32(&a.conRetry) == 0 @@ -104,7 +117,7 @@ func (a *App) Init(version string, rate int) error { } a.initFactory(ns) - a.clusterModel = model.NewClusterInfo(a.factory, a.version, a.Config.K9s.SkipLatestRevCheck) + a.clusterModel = model.NewClusterInfo(a.factory, a.version, a.Config.K9s) a.clusterModel.AddListener(a.clusterInfo()) a.clusterModel.AddListener(a.statusIndicator()) if a.Conn().ConnectionOK() { @@ -124,6 +137,7 @@ func (a *App) Init(version string, rate int) error { if a.Config.K9s.ImageScans.Enable { a.initImgScanner(version) } + a.ReloadStyles() return nil } @@ -324,17 +338,17 @@ func (a *App) Resume() { ctx, a.cancelFn = context.WithCancel(context.Background()) go a.clusterUpdater(ctx) - if err := a.ConfigWatcher(ctx, a); err != nil { - log.Warn().Err(err).Msgf("ConfigWatcher failed") - } - if err := a.SkinsDirWatcher(ctx, a); err != nil { - log.Warn().Err(err).Msgf("SkinsWatcher failed") - } - if err := a.CustomViewsWatcher(ctx, a); err != nil { - log.Warn().Err(err).Msgf("CustomView watcher failed") - } else { - log.Debug().Msgf("CustomViews watching `%s", config.AppViewsFile) + if a.Config.K9s.UI.Reactive { + if err := a.ConfigWatcher(ctx, a); err != nil { + log.Warn().Err(err).Msgf("ConfigWatcher failed") + } + if err := a.SkinsDirWatcher(ctx, a); err != nil { + log.Warn().Err(err).Msgf("SkinsWatcher failed") + } + if err := a.CustomViewsWatcher(ctx, a); err != nil { + log.Warn().Err(err).Msgf("CustomView watcher failed") + } } } @@ -398,10 +412,12 @@ func (a *App) refreshCluster(context.Context) error { // Reload alias go func() { if err := a.command.Reset(a.Config.ContextAliasesPath(), false); err != nil { - log.Error().Err(err).Msgf("Command reset failed") + log.Warn().Err(err).Msgf("Command reset failed") + a.QueueUpdateDraw(func() { + a.Logo().Warn("Aliases load failed!") + }) } }() - // Update cluster info a.clusterModel.Refresh() diff --git a/internal/view/browser.go b/internal/view/browser.go index 21176d69..9589e99b 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -249,6 +249,13 @@ func (b *Browser) TableDataChanged(data *render.TableData) { b.app.QueueUpdateDraw(func() { b.refreshActions() + if !b.app.Config.K9s.UI.Reactive { + if err := b.app.RefreshCustomViews(); err != nil { + log.Warn().Err(err).Msg("CustomViews load failed") + b.app.Logo().Warn("Views load failed!") + } + } + b.Update(data, b.app.Conn().HasMetrics()) }) } @@ -497,25 +504,41 @@ func (b *Browser) refreshActions() { b.namespaceActions(aa) if !b.app.Config.K9s.IsReadOnly() { if client.Can(b.meta.Verbs, "edit") { - aa[ui.KeyE] = ui.NewKeyAction("Edit", b.editCmd, true) + aa[ui.KeyE] = ui.NewKeyActionWithOpts("Edit", b.editCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }) } if client.Can(b.meta.Verbs, "delete") { - aa[tcell.KeyCtrlD] = ui.NewKeyAction("Delete", b.deleteCmd, true) + aa[tcell.KeyCtrlD] = ui.NewKeyActionWithOpts("Delete", b.deleteCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }) } + } else { + b.Actions().ClearDanger() } } - if !dao.IsK9sMeta(b.meta) { aa[ui.KeyY] = ui.NewKeyAction(yamlAction, b.viewCmd, true) aa[ui.KeyD] = ui.NewKeyAction("Describe", b.describeCmd, true) } - - pluginActions(b, aa) - hotKeyActions(b, aa) for _, f := range b.bindKeysFn { f(aa) } b.Actions().Add(aa) + + if err := pluginActions(b, b.Actions()); err != nil { + log.Warn().Msgf("Plugins load failed: %s", err) + b.app.Logo().Warn("Plugins load failed!") + } + if err := hotKeyActions(b, b.Actions()); err != nil { + log.Warn().Msgf("Hotkeys load failed: %s", err) + b.app.Logo().Warn("HotKeys load failed!") + } + b.app.Menu().HydrateMenu(b.Hints()) } diff --git a/internal/view/cluster_info.go b/internal/view/cluster_info.go index 8b91eff8..e6a24dea 100644 --- a/internal/view/cluster_info.go +++ b/internal/view/cluster_info.go @@ -100,12 +100,25 @@ func (c *ClusterInfo) ClusterInfoUpdated(data model.ClusterMeta) { c.ClusterInfoChanged(data, data) } +func (c *ClusterInfo) warnCell(s string, w bool) string { + if w { + return fmt.Sprintf("[orangered::b]%s", s) + } + + return s +} + // ClusterInfoChanged notifies the cluster meta was changed. func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { c.app.QueueUpdateDraw(func() { + var ic = " ✏️" + if c.app.Config.K9s.IsReadOnly() { + ic = " 🔒" + } + c.Clear() c.layout() - row := c.setCell(0, curr.Context) + row := c.setCell(0, curr.Context+ic) row = c.setCell(row, curr.Cluster) row = c.setCell(row, curr.User) if curr.K9sLatest != "" { @@ -119,8 +132,8 @@ func (c *ClusterInfo) ClusterInfoChanged(prev, curr model.ClusterMeta) { _ = c.setCell(row, ui.AsPercDelta(prev.Mem, curr.Mem)) c.setDefCon(curr.Cpu, curr.Mem) } else { - row = c.setCell(row, "[orangered::b]n/a") - _ = c.setCell(row, "[orangered::b]n/a") + row = c.setCell(row, c.warnCell(render.NAValue, true)) + _ = c.setCell(row, c.warnCell(render.NAValue, true)) } c.updateStyle() }) diff --git a/internal/view/command.go b/internal/view/command.go index 30f2e3cb..97676176 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -96,6 +96,16 @@ func (c *Command) contextCmd(p *cmd.Interpreter) error { return c.exec(p, gvr, c.componentFor(gvr, ct, v), true) } +func (c *Command) aliasCmd(p *cmd.Interpreter) error { + filter, _ := p.FilterArg() + + gvr := client.NewGVR("aliases") + v := NewAlias(gvr) + v.SetFilter(filter) + + return c.exec(p, gvr, v, false) +} + func (c *Command) xrayCmd(p *cmd.Interpreter) error { arg, cns, ok := p.XrayArgs() if !ok { @@ -118,7 +128,6 @@ func (c *Command) xrayCmd(p *cmd.Interpreter) error { if err := c.app.switchNS(ns); err != nil { return err } - if err := c.app.Config.Save(); err != nil { return err } @@ -210,7 +219,9 @@ func (c *Command) specialCmd(p *cmd.Interpreter) bool { case p.IsHelpCmd(): _ = c.app.helpCmd(nil) case p.IsAliasCmd(): - _ = c.app.aliasCmd(nil) + if err := c.aliasCmd(p); err != nil { + c.app.Flash().Err(err) + } case p.IsXrayCmd(): if err := c.xrayCmd(p); err != nil { c.app.Flash().Err(err) diff --git a/internal/view/container.go b/internal/view/container.go index 7a2238a3..3d25a78e 100644 --- a/internal/view/container.go +++ b/internal/view/container.go @@ -15,7 +15,6 @@ import ( "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" - "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" ) @@ -58,8 +57,20 @@ func (c *Container) Name() string { return containerTitle } func (c *Container) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - ui.KeyS: ui.NewKeyAction("Shell", c.shellCmd, true), - ui.KeyA: ui.NewKeyAction("Attach", c.attachCmd, true), + ui.KeyS: ui.NewKeyActionWithOpts( + "Shell", + c.shellCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyA: ui.NewKeyActionWithOpts( + "Attach", + c.attachCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } @@ -80,10 +91,7 @@ func (c *Container) bindKeys(aa ui.KeyActions) { func (c *Container) k9sEnv() Env { path := c.GetTable().GetSelectedItem() - row, ok := c.GetTable().GetSelectedRow(path) - if !ok { - log.Error().Msgf("unable to locate selected row for %q", path) - } + row := c.GetTable().GetSelectedRow(path) env := defaultEnv(c.App().Conn().Config(), path, c.GetTable().GetModel().Peek().Header, row) env["NAMESPACE"], env["POD"] = client.Namespaced(c.GetTable().Path) diff --git a/internal/view/details.go b/internal/view/details.go index 53c9ee8d..2a52e24d 100644 --- a/internal/view/details.go +++ b/internal/view/details.go @@ -297,7 +297,7 @@ func (d *Details) resetCmd(evt *tcell.EventKey) *tcell.EventKey { } func (d *Details) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveYAML(d.app.Config.K9s.ActiveScreenDumpsDir(), d.title, d.text.GetText(true)); err != nil { + if path, err := saveYAML(d.app.Config.K9s.ContextScreenDumpDir(), d.title, d.text.GetText(true)); err != nil { d.app.Flash().Err(err) } else { d.app.Flash().Infof("Log %s saved successfully!", path) diff --git a/internal/view/dir.go b/internal/view/dir.go index a55220a0..dae5ca72 100644 --- a/internal/view/dir.go +++ b/internal/view/dir.go @@ -62,13 +62,23 @@ func (d *Dir) dirContext(ctx context.Context) context.Context { func (d *Dir) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - ui.KeyA: ui.NewKeyAction("Apply", d.applyCmd, true), - ui.KeyD: ui.NewKeyAction("Delete", d.delCmd, true), - ui.KeyE: ui.NewKeyAction("Edit", d.editCmd, true), + ui.KeyA: ui.NewKeyActionWithOpts("Apply", d.applyCmd, ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyD: ui.NewKeyActionWithOpts("Delete", d.delCmd, ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyE: ui.NewKeyActionWithOpts("Edit", d.editCmd, ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } func (d *Dir) bindKeys(aa ui.KeyActions) { + // !!BOZO!! Lame! aa.Delete(ui.KeyShiftA, tcell.KeyCtrlS, tcell.KeyCtrlSpace, ui.KeySpace) aa.Delete(tcell.KeyCtrlW, tcell.KeyCtrlL, tcell.KeyCtrlD, tcell.KeyCtrlZ) if !d.App().Config.K9s.IsReadOnly() { diff --git a/internal/view/exec.go b/internal/view/exec.go index 97d0dc53..e0af76b9 100644 --- a/internal/view/exec.go +++ b/internal/view/exec.go @@ -403,7 +403,7 @@ func k9sShellPodName() string { return fmt.Sprintf("%s-%d", k9sShell, os.Getpid()) } -func k9sShellPod(node string, cfg *config.ShellPod) *v1.Pod { +func k9sShellPod(node string, cfg config.ShellPod) *v1.Pod { var grace int64 var priv bool = true @@ -500,7 +500,7 @@ func pipe(_ context.Context, opts shellOpts, statusChan chan<- string, w, e io.W log.Debug().Msgf("Running Start") err := cmd.Run() - log.Debug().Msgf("Running Done: %s", err) + log.Debug().Msgf("Running Done: %v", err) if err == nil { statusChan <- fmt.Sprintf("Command completed successfully: %q", cmd.String()) } diff --git a/internal/view/helm_history.go b/internal/view/helm_history.go index ce746ff6..0949d5dd 100644 --- a/internal/view/helm_history.go +++ b/internal/view/helm_history.go @@ -83,7 +83,14 @@ func (h *History) getValsCmd(app *App, _ ui.Tabular, _ client.GVR, path string) func (h *History) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - ui.KeyR: ui.NewKeyAction("RollBackTo...", h.rollbackCmd, true), + ui.KeyR: ui.NewKeyActionWithOpts( + "RollBackTo...", + h.rollbackCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + ), }) } diff --git a/internal/view/help.go b/internal/view/help.go index fd9da2ab..06665e7b 100644 --- a/internal/view/help.go +++ b/internal/view/help.go @@ -191,7 +191,7 @@ func (h *Help) showNav() model.MenuHints { func (h *Help) showHotKeys() (model.MenuHints, error) { hh := config.NewHotKeys() - if err := hh.Load(); err != nil { + if err := hh.Load(h.App().Config.ContextHotkeysPath()); err != nil { return nil, fmt.Errorf("no hotkey configuration found") } kk := make(sort.StringSlice, 0, len(hh.HotKey)) diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 818002ae..f45a2cf8 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -25,7 +25,7 @@ func TestHelp(t *testing.T) { assert.Nil(t, v.Init(ctx)) assert.Equal(t, 28, v.GetRowCount()) - assert.Equal(t, 6, v.GetColumnCount()) + assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) } diff --git a/internal/view/helpers.go b/internal/view/helpers.go index 6648b1da..90803dff 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -87,9 +87,12 @@ func k8sEnv(c *client.Config) Env { } } -func defaultEnv(c *client.Config, path string, header render.Header, row render.Row) Env { +func defaultEnv(c *client.Config, path string, header render.Header, row *render.Row) Env { env := k8sEnv(c) env["NAMESPACE"], env["NAME"] = client.Namespaced(path) + if row == nil { + return env + } for _, col := range header.Columns(true) { i := header.IndexOf(col, true) if i >= 0 && i < len(row.Fields) { @@ -154,7 +157,7 @@ func asKey(key string) (tcell.Key, error) { } } - return 0, fmt.Errorf("no matching key found %s", key) + return 0, fmt.Errorf("invalid key specified: %q", key) } // FwFQN returns a fully qualified ns/name:container id. diff --git a/internal/view/helpers_test.go b/internal/view/helpers_test.go index 0278afaa..9ec347c8 100644 --- a/internal/view/helpers_test.go +++ b/internal/view/helpers_test.go @@ -108,7 +108,7 @@ func TestAsKey(t *testing.T) { e tcell.Key }{ "cool": {k: "Ctrl-A", e: tcell.KeyCtrlA}, - "miss": {k: "fred", e: 0, err: errors.New("no matching key found fred")}, + "miss": {k: "fred", e: 0, err: errors.New(`invalid key specified: "fred"`)}, } for k := range uu { @@ -157,7 +157,7 @@ func TestK9sEnv(t *testing.T) { r := render.Row{ Fields: []string{"a1", "b1", "c1"}, } - env := defaultEnv(c, "fred/blee", h, r) + env := defaultEnv(c, "fred/blee", h, &r) assert.Equal(t, 10, len(env)) assert.Equal(t, cl, env["CLUSTER"]) diff --git a/internal/view/live_view.go b/internal/view/live_view.go index d11a092f..980d3742 100644 --- a/internal/view/live_view.go +++ b/internal/view/live_view.go @@ -357,7 +357,7 @@ func (v *LiveView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { func (v *LiveView) saveCmd(evt *tcell.EventKey) *tcell.EventKey { name := fmt.Sprintf("%s--%s", strings.Replace(v.model.GetPath(), "/", "-", 1), strings.ToLower(v.title)) - if _, err := saveYAML(v.app.Config.K9s.ActiveScreenDumpsDir(), name, sanitizeEsc(v.text.GetText(true))); err != nil { + if _, err := saveYAML(v.app.Config.K9s.ContextScreenDumpDir(), name, sanitizeEsc(v.text.GetText(true))); err != nil { v.app.Flash().Err(err) } else { v.app.Flash().Infof("File %q saved successfully!", name) diff --git a/internal/view/log.go b/internal/view/log.go index 88b0cd8b..3fdc49ff 100644 --- a/internal/view/log.go +++ b/internal/view/log.go @@ -407,7 +407,7 @@ func (l *Log) filterCmd(evt *tcell.EventKey) *tcell.EventKey { // SaveCmd dumps the logs to file. func (l *Log) SaveCmd(*tcell.EventKey) *tcell.EventKey { - path, err := saveData(l.app.Config.K9s.ActiveScreenDumpsDir(), l.model.GetPath(), l.logs.GetText(true)) + path, err := saveData(l.app.Config.K9s.ContextScreenDumpDir(), l.model.GetPath(), l.logs.GetText(true)) if err != nil { l.app.Flash().Err(err) return nil diff --git a/internal/view/log_indicator.go b/internal/view/log_indicator.go index 35962b92..6e3f6529 100644 --- a/internal/view/log_indicator.go +++ b/internal/view/log_indicator.go @@ -34,7 +34,7 @@ func NewLogIndicator(cfg *config.Config, styles *config.Styles, allContainers bo TextView: tview.NewTextView(), indicator: make([]byte, 0, 100), scrollStatus: 1, - fullScreen: cfg.K9s.Logger.FullScreenLogs, + fullScreen: cfg.K9s.Logger.FullScreen, textWrap: cfg.K9s.Logger.TextWrap, showTime: cfg.K9s.Logger.ShowTime, shouldDisplayAllContainers: allContainers, diff --git a/internal/view/log_test.go b/internal/view/log_test.go index a55c50d9..6b466cf8 100644 --- a/internal/view/log_test.go +++ b/internal/view/log_test.go @@ -112,7 +112,7 @@ func TestLogViewSave(t *testing.T) { dd := "/tmp/test-dumps/na" assert.NoError(t, ensureDumpDir(dd)) app.Config.K9s.ScreenDumpDir = "/tmp/test-dumps" - dir := app.Config.K9s.ActiveScreenDumpsDir() + dir := app.Config.K9s.ContextScreenDumpDir() c1, err := os.ReadDir(dir) assert.NoError(t, err, fmt.Sprintf("Dir: %q", dir)) v.SaveCmd(nil) diff --git a/internal/view/logger.go b/internal/view/logger.go index 00446b0b..7e0526cf 100644 --- a/internal/view/logger.go +++ b/internal/view/logger.go @@ -154,7 +154,7 @@ func (l *Logger) resetCmd(evt *tcell.EventKey) *tcell.EventKey { } func (l *Logger) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveYAML(l.app.Config.K9s.ActiveScreenDumpsDir(), l.title, l.GetText(true)); err != nil { + if path, err := saveYAML(l.app.Config.K9s.ContextScreenDumpDir(), l.title, l.GetText(true)); err != nil { l.app.Flash().Err(err) } else { l.app.Flash().Infof("Log %s saved successfully!", path) diff --git a/internal/view/node.go b/internal/view/node.go index c5a14b93..e39d4091 100644 --- a/internal/view/node.go +++ b/internal/view/node.go @@ -41,9 +41,30 @@ func (n *Node) nodeContext(ctx context.Context) context.Context { func (n *Node) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - ui.KeyC: ui.NewKeyAction("Cordon", n.toggleCordonCmd(true), true), - ui.KeyU: ui.NewKeyAction("Uncordon", n.toggleCordonCmd(false), true), - ui.KeyR: ui.NewKeyAction("Drain", n.drainCmd, true), + ui.KeyC: ui.NewKeyActionWithOpts( + "Cordon", + n.toggleCordonCmd(true), + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + ), + ui.KeyU: ui.NewKeyActionWithOpts( + "Uncordon", + n.toggleCordonCmd(false), + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + ), + ui.KeyR: ui.NewKeyActionWithOpts( + "Drain", + n.drainCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }, + ), }) ct, err := n.App().Config.K9s.ActiveContext() if err != nil { @@ -66,7 +87,7 @@ func (n *Node) bindKeys(aa ui.KeyActions) { ui.KeyY: ui.NewKeyAction(yamlAction, n.yamlCmd, true), ui.KeyShiftC: ui.NewKeyAction("Sort CPU", n.GetTable().SortColCmd(cpuCol, false), false), ui.KeyShiftM: ui.NewKeyAction("Sort MEM", n.GetTable().SortColCmd(memCol, false), false), - ui.KeyShift0: ui.NewKeyAction("Sort Pods", n.GetTable().SortColCmd("PODS", false), false), + ui.KeyShiftO: ui.NewKeyAction("Sort Pods", n.GetTable().SortColCmd("PODS", false), false), }) } diff --git a/internal/view/pod.go b/internal/view/pod.go index 1ef29bab..6278691c 100644 --- a/internal/view/pod.go +++ b/internal/view/pod.go @@ -72,11 +72,41 @@ func (p *Pod) portForwardIndicator(data *render.TableData) { func (p *Pod) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - tcell.KeyCtrlK: ui.NewKeyAction("Kill", p.killCmd, true), - ui.KeyS: ui.NewKeyAction("Shell", p.shellCmd, true), - ui.KeyA: ui.NewKeyAction("Attach", p.attachCmd, true), - ui.KeyT: ui.NewKeyAction("Transfer", p.transferCmd, true), - ui.KeyZ: ui.NewKeyAction("Sanitize", p.sanitizeCmd, true), + tcell.KeyCtrlK: ui.NewKeyActionWithOpts( + "Kill", + p.killCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyS: ui.NewKeyActionWithOpts( + "Shell", + p.shellCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyA: ui.NewKeyActionWithOpts( + "Attach", + p.attachCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyT: ui.NewKeyActionWithOpts( + "Transfer", + p.transferCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + ui.KeyZ: ui.NewKeyActionWithOpts( + "Sanitize", + p.sanitizeCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } diff --git a/internal/view/restart_extender.go b/internal/view/restart_extender.go index 629e68f0..1e83ced8 100644 --- a/internal/view/restart_extender.go +++ b/internal/view/restart_extender.go @@ -34,7 +34,10 @@ func (r *RestartExtender) bindKeys(aa ui.KeyActions) { return } aa.Add(ui.KeyActions{ - ui.KeyR: ui.NewKeyAction("Restart", r.restartCmd, true), + ui.KeyR: ui.NewKeyActionWithOpts("Restart", r.restartCmd, ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } diff --git a/internal/view/scale_extender.go b/internal/view/scale_extender.go index 86bc7809..14094dcf 100644 --- a/internal/view/scale_extender.go +++ b/internal/view/scale_extender.go @@ -34,7 +34,11 @@ func (s *ScaleExtender) bindKeys(aa ui.KeyActions) { return } aa.Add(ui.KeyActions{ - ui.KeyS: ui.NewKeyAction("Scale", s.scaleCmd, true), + ui.KeyS: ui.NewKeyActionWithOpts("Scale", s.scaleCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } diff --git a/internal/view/screen_dump.go b/internal/view/screen_dump.go index 4e4371c1..83bbca09 100644 --- a/internal/view/screen_dump.go +++ b/internal/view/screen_dump.go @@ -35,7 +35,7 @@ func NewScreenDump(gvr client.GVR) ResourceViewer { } func (s *ScreenDump) dirContext(ctx context.Context) context.Context { - dir := s.App().Config.K9s.ActiveScreenDumpsDir() + dir := s.App().Config.K9s.ContextScreenDumpDir() if err := data.EnsureFullPath(dir, data.DefaultDirMod); err != nil { s.App().Flash().Err(err) return ctx diff --git a/internal/view/table.go b/internal/view/table.go index 8a386933..f1e28b90 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -15,7 +15,6 @@ import ( "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" - "github.com/rs/zerolog/log" ) // Table represents a table viewer. @@ -111,10 +110,7 @@ func (t *Table) EnvFn() EnvFunc { func (t *Table) defaultEnv() Env { path := t.GetSelectedItem() - row, ok := t.GetSelectedRow(path) - if !ok { - log.Error().Msgf("unable to locate selected row for %q", path) - } + row := t.GetSelectedRow(path) env := defaultEnv(t.app.Conn().Config(), path, t.GetModel().Peek().Header, row) env["FILTER"] = t.CmdBuff().GetText() if env["FILTER"] == "" { @@ -172,7 +168,7 @@ func (t *Table) BufferActive(state bool, k model.BufferKind) { } func (t *Table) saveCmd(evt *tcell.EventKey) *tcell.EventKey { - if path, err := saveTable(t.app.Config.K9s.ActiveScreenDumpsDir(), t.GVR().R(), t.Path, t.GetFilteredData()); err != nil { + if path, err := saveTable(t.app.Config.K9s.ContextScreenDumpDir(), t.GVR().R(), t.Path, t.GetFilteredData()); err != nil { t.app.Flash().Err(err) } else { t.app.Flash().Infof("File saved successfully: %q", render.Truncate(filepath.Base(path), 50)) diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index 13b564fc..d87f9e90 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -29,7 +29,7 @@ func TestTableSave(t *testing.T) { v.SetTitle("k9s-test") assert.NoError(t, ensureDumpDir("/tmp/test-dumps")) - dir := v.app.Config.K9s.ActiveScreenDumpsDir() + dir := v.app.Config.K9s.ContextScreenDumpDir() c1, _ := os.ReadDir(dir) v.saveCmd(nil) diff --git a/internal/view/workload.go b/internal/view/workload.go index dd4d43b6..0f66508e 100644 --- a/internal/view/workload.go +++ b/internal/view/workload.go @@ -38,8 +38,20 @@ func NewWorkload(gvr client.GVR) ResourceViewer { func (w *Workload) bindDangerousKeys(aa ui.KeyActions) { aa.Add(ui.KeyActions{ - ui.KeyE: ui.NewKeyAction("Edit", w.editCmd, true), - tcell.KeyCtrlD: ui.NewKeyAction("Delete", w.deleteCmd, true), + ui.KeyE: ui.NewKeyActionWithOpts( + "Edit", + w.editCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), + tcell.KeyCtrlD: ui.NewKeyActionWithOpts( + "Delete", + w.deleteCmd, + ui.ActionOpts{ + Visible: true, + Dangerous: true, + }), }) } diff --git a/internal/view/xray.go b/internal/view/xray.go index 564ed188..694ddd84 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -133,8 +133,12 @@ func (x *Xray) refreshActions() { aa := make(ui.KeyActions) defer func() { - pluginActions(x, aa) - hotKeyActions(x, aa) + if err := pluginActions(x, aa); err != nil { + log.Warn().Err(err).Msg("Plugins load failed") + } + if err := hotKeyActions(x, aa); err != nil { + log.Warn().Err(err).Msg("HotKeys load failed") + } x.Actions().Add(aa) x.app.Menu().HydrateMenu(x.Hints()) diff --git a/internal/vul/scanner.go b/internal/vul/scanner.go index c2256703..01f1f154 100644 --- a/internal/vul/scanner.go +++ b/internal/vul/scanner.go @@ -27,6 +27,7 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/store" "github.com/anchore/grype/grype/vex" + "github.com/anchore/syft/syft/pkg/cataloger" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -40,11 +41,11 @@ type imageScanner struct { scans Scans mx sync.RWMutex initialized bool - config *config.ImageScans + config config.ImageScans } // NewImageScanner returns a new instance. -func NewImageScanner(cfg *config.ImageScans) *imageScanner { +func NewImageScanner(cfg config.ImageScans) *imageScanner { return &imageScanner{ scans: make(Scans), config: cfg, @@ -183,7 +184,7 @@ func getProviderConfig(opts *options.Grype) pkg.ProviderConfig { SyftProviderConfig: pkg.SyftProviderConfig{ RegistryOptions: opts.Registry.ToOptions(), Exclusions: opts.Exclusions, - CatalogingOptions: opts.Search.ToConfig(), + CatalogingOptions: cataloger.DefaultConfig(), Platform: opts.Platform, Name: opts.Name, DefaultImagePullSource: opts.DefaultImagePullSource, diff --git a/plugins/blame.yml b/plugins/blame.yaml similarity index 100% rename from plugins/blame.yml rename to plugins/blame.yaml diff --git a/plugins/helm_values.yaml b/plugins/helm-values.yaml similarity index 100% rename from plugins/helm_values.yaml rename to plugins/helm-values.yaml diff --git a/plugins/job_suspend.yaml b/plugins/job-suspend.yaml similarity index 100% rename from plugins/job_suspend.yaml rename to plugins/job-suspend.yaml diff --git a/plugins/k3d_root_shell.yaml b/plugins/k3d-root-shell.yaml similarity index 100% rename from plugins/k3d_root_shell.yaml rename to plugins/k3d-root-shell.yaml diff --git a/plugins/log_full.yaml b/plugins/log-full.yaml similarity index 100% rename from plugins/log_full.yaml rename to plugins/log-full.yaml diff --git a/plugins/log_jq.yaml b/plugins/log-jq.yaml similarity index 100% rename from plugins/log_jq.yaml rename to plugins/log-jq.yaml diff --git a/plugins/log_stern.yaml b/plugins/log-stern.yaml similarity index 100% rename from plugins/log_stern.yaml rename to plugins/log-stern.yaml diff --git a/plugins/remove_finalizers.yml b/plugins/remove-finalizers.yaml similarity index 100% rename from plugins/remove_finalizers.yml rename to plugins/remove-finalizers.yaml diff --git a/plugins/resource-recommendations.yml b/plugins/resource-recommendations.yaml similarity index 100% rename from plugins/resource-recommendations.yml rename to plugins/resource-recommendations.yaml diff --git a/plugins/schema.json b/plugins/schema.json deleted file mode 100644 index 3dd50016..00000000 --- a/plugins/schema.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Schema for k9s CLI plugin.yml file : https://k9scli.io/topics/plugins", - "type": "object", - "additionalProperties": false, - "properties": { - "plugin": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": false, - "properties": { - "shortCut": { - "description": "Define a mnemonic to invoke the plugin", - "type": "string" - }, - "description": { - "description": "What will be shown on the K9s menu", - "type": "string" - }, - "confirm": { - "description": "See the command that is going to be executed and gives you an option to confirm", - "type": "boolean" - }, - "scopes": { - "type": "array", - "description": "Collections of views that support this shortcut. (You can use `all`)", - "items": { - "type": "string" - } - }, - "command": { - "description": "The command to run upon invocation. Can use Krew plugins here too!", - "type": "string" - }, - "background": { - "description": "Whether or not to run the command in background mode", - "type": "boolean" - }, - "args": { - "description": "Defines the command arguments", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["shortCut", "description", "scopes", "command"] - }, - "required": [] - } - }, - "required": ["plugin"] -} diff --git a/plugins/watch_events.yaml b/plugins/watch-events.yaml similarity index 99% rename from plugins/watch_events.yaml rename to plugins/watch-events.yaml index db96c6eb..24ef63e6 100644 --- a/plugins/watch_events.yaml +++ b/plugins/watch-events.yaml @@ -1,7 +1,6 @@ # watch events on selected resources # requires linux "watch" command # change '-n' to adjust refresh time in seconds - plugins: watch-events: shortCut: Shift-E diff --git a/skins/black-and-wtf.yaml b/skins/black-and-wtf.yaml index 2b903ad7..69fe02ef 100644 --- a/skins/black-and-wtf.yaml +++ b/skins/black-and-wtf.yaml @@ -24,7 +24,7 @@ k9s: prompt: fgColor: *fg bgColor: *bg - suggestColor: &gray + suggestColor: *gray info: fgColor: *text sectionColor: *fg diff --git a/skins/nightfox.yaml b/skins/nightfox.yaml index 6d144c69..61ec7dbd 100644 --- a/skins/nightfox.yaml +++ b/skins/nightfox.yaml @@ -99,5 +99,5 @@ k9s: indicator: fgColor: *foreground bgColor: *selection - toggleOnColor: *margenta + toggleOnColor: *magenta toggleOffColor: *blue diff --git a/skins/transparent.yaml b/skins/transparent.yaml index be0a72e0..9a103a87 100644 --- a/skins/transparent.yaml +++ b/skins/transparent.yaml @@ -7,7 +7,7 @@ k9s: body: bgColor: default - promt: + prompt: bgColor: default info: sectionColor: default diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 2b3acfe1..2e727e15 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: k9s base: core20 -version: 'v0.30.8' +version: 'v0.31.0' 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.