K9s/release v0.31.0 (#2440)

* cleaning up

* [Bug] Fix #2425 - no context skin issue

* [Bug] Fix #2428

* [Feat] schema validation

* v0.31.0 Release notes
mine
Fernand Galiana 2024-01-08 18:30:49 -07:00 committed by GitHub
parent ffd8d51a8b
commit 6cc4374e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
148 changed files with 3871 additions and 1037 deletions

View File

@ -25,6 +25,9 @@ Steps to reproduce the behavior:
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
**Historical Documents**
When applicable please include any supporting artifacts: k9s debug logs, configurations, resource manifests, ...
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.

View File

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

View File

@ -225,13 +225,11 @@ Binaries for Linux, Windows and Mac are available as tarballs in the [release pa
export TERM=xterm-256color 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 ```shell
# Kubectl edit command will use this env var. # Kubectl edit command will use this env var.
export EDITOR=my_fav_editor export KUBE_EDITOR=my_fav_editor
# Should your editor deal with streamed vs on disk files differently, also set...
export K9S_EDITOR=my_fav_editor
``` ```
* K9s prefers recent kubernetes versions ie 1.28+ * 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 ```yaml
# $XDG_CONFIG_HOME/k9s/views.yaml # $XDG_CONFIG_HOME/k9s/views.yaml
k9s: views:
views: v1/pods:
v1/pods: columns:
columns: - AGE
- AGE - NAMESPACE
- NAMESPACE - NAME
- NAME - IP
- IP - NODE
- NODE - STATUS
- STATUS - READY
- READY v1/services:
v1/services: columns:
columns: - AGE
- AGE - NAMESPACE
- NAMESPACE - NAME
- NAME - TYPE
- TYPE - CLUSTER-IP
- 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: You can also specify a default skin for all contexts in the root k9s config file as so:
```yaml ```yaml
# $XDG_CONFIG_HOME/k9s/config.yaml
k9s: k9s:
liveViewAutoRefresh: false liveViewAutoRefresh: false
screenDumpDir: /tmp/dumps screenDumpDir: /tmp/dumps
@ -910,6 +908,8 @@ k9s:
logoless: false logoless: false
crumbsless: false crumbsless: false
noIcons: 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. # 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 skin: dracula # => assumes the file skins/dracula.yaml is present in the $XDG_DATA_HOME/k9s/skins directory
skipLatestRevCheck: false skipLatestRevCheck: false
@ -929,7 +929,7 @@ k9s:
tail: 100 tail: 100
buffer: 5000 buffer: 5000
sinceSeconds: -1 sinceSeconds: -1
fullScreenLogs: false fullScreen: false
textWrap: false textWrap: false
showTime: false showTime: false
thresholds: thresholds:
@ -942,7 +942,7 @@ k9s:
``` ```
```yaml ```yaml
# $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml # $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml
# Skin InTheNavy! # Skin InTheNavy!
k9s: k9s:
# General K9s styles # General K9s styles

View File

@ -0,0 +1,153 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# 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
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/imhotep_logo.png" width="32" height="auto"/> © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)

View File

@ -69,6 +69,9 @@ func getScreenDumpDirForInfo() string {
log.Error().Err(err).Msgf("Unmarshal k9s config %v", err) log.Error().Err(err).Msgf("Unmarshal k9s config %v", err)
return config.AppDumpsDir return config.AppDumpsDir
} }
if cfg.K9s == nil {
return config.AppDumpsDir
}
return cfg.K9s.GetScreenDumpDir() return cfg.K9s.AppScreenDumpDir()
} }

View File

@ -93,7 +93,7 @@ func run(cmd *cobra.Command, args []string) error {
cfg, err := loadConfiguration() cfg, err := loadConfiguration()
if err != nil { if err != nil {
log.Error().Err(err).Msgf("load configuration failed") return err
} }
app := view.NewApp(cfg) app := view.NewApp(cfg)
if err := app.Init(version, *k9sFlags.RefreshRate); err != nil { if err := app.Init(version, *k9sFlags.RefreshRate); err != nil {
@ -115,11 +115,12 @@ func loadConfiguration() (*config.Config, error) {
k8sCfg := client.NewConfig(k8sFlags) k8sCfg := client.NewConfig(k8sFlags)
k9sCfg := config.NewConfig(k8sCfg) k9sCfg := config.NewConfig(k8sCfg)
if err := k9sCfg.Load(config.AppConfigFile); err != nil { 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) k9sCfg.K9s.Override(k9sFlags)
if err := k9sCfg.Refine(k8sFlags, k9sFlags, k8sCfg); err != nil { 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) conn, err := client.InitConnection(k8sCfg)
k9sCfg.SetConnection(conn) k9sCfg.SetConnection(conn)

4
go.mod
View File

@ -25,8 +25,10 @@ require (
github.com/sahilm/fuzzy v0.1.0 github.com/sahilm/fuzzy v0.1.0
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/text v0.14.0 golang.org/x/text v0.14.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.13.3 helm.sh/helm/v3 v3.13.3
k8s.io/api v0.29.0 k8s.io/api v0.29.0
k8s.io/apiextensions-apiserver 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/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // 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/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xlab/treeprint v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // 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/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/gorm v1.25.5 // indirect gorm.io/gorm v1.25.5 // indirect
k8s.io/apiserver v0.29.0 // indirect k8s.io/apiserver v0.29.0 // indirect
k8s.io/component-base v0.29.0 // indirect k8s.io/component-base v0.29.0 // indirect

View File

@ -517,12 +517,12 @@ func (a *APIClient) supportsMetricsResources() error {
a.cache.Add(cacheMXAPIKey, supported, cacheExpiry) a.cache.Add(cacheMXAPIKey, supported, cacheExpiry)
}() }()
dial, err := a.CachedDiscovery() dial, err := a.Dial()
if err != nil { if err != nil {
log.Warn().Err(err).Msgf("Unable to dial discovery API") log.Warn().Err(err).Msgf("Unable to dial discovery API")
return err return err
} }
apiGroups, err := dial.ServerGroups() apiGroups, err := dial.Discovery().ServerGroups()
if err != nil { if err != nil {
return err return err
} }

View File

@ -90,7 +90,7 @@ func (m *MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsL
func (m *MetricsServer) checkAccess(ns, gvr, msg string) error { func (m *MetricsServer) checkAccess(ns, gvr, msg string) error {
if !m.HasMetrics() { 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) 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] mx, ok := mmx[n]
if !ok { 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 return mx, nil
} }
@ -283,7 +283,7 @@ func (m *MetricsServer) FetchPodMetrics(ctx context.Context, fqn string) (*mv1be
} }
pmx, ok := mmx[fqn] pmx, ok := mmx[fqn]
if !ok { 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 return pmx, nil

View File

@ -117,7 +117,7 @@ type Connection interface {
// HasMetrics checks if metrics server is available. // HasMetrics checks if metrics server is available.
HasMetrics() bool HasMetrics() bool
// ValidNamespaces returns all available namespace names. // ValidNamespaceNames returns all available namespace names.
ValidNamespaceNames() (NamespaceNames, error) ValidNamespaceNames() (NamespaceNames, error)
// IsValidNamespace checks if given namespace is known. // IsValidNamespace checks if given namespace is known.

View File

@ -4,10 +4,12 @@
package config package config
import ( import (
"fmt"
"os" "os"
"sync" "sync"
"github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -120,18 +122,26 @@ func (a *Aliases) LoadFile(path string) error {
if path == "" { if path == "" {
return nil return nil
} }
f, err := os.ReadFile(path) if _, err := os.Stat(path); os.IsNotExist(err) {
if err == nil { return nil
var aa Aliases }
if err := yaml.Unmarshal(f, &aa); err != nil {
return err
}
a.mx.Lock() bb, err := os.ReadFile(path)
defer a.mx.Unlock() if err != nil {
for k, v := range aa.Alias { return err
a.Alias[k] = v }
} 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 return nil

View File

@ -4,25 +4,59 @@
package config_test package config_test
import ( import (
"fmt"
"os"
"slices"
"testing" "testing"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/config/data"
"github.com/stretchr/testify/assert" "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) { func TestAliasDefine(t *testing.T) {
type aliasDef struct { type aliasDef struct {
cmd string cmd string
aliases []string aliases []string
} }
uu := []struct { uu := map[string]struct {
name string
aliases []aliasDef aliases []aliasDef
registeredCommands map[string]string registeredCommands map[string]string
}{ }{
{ "simple": {
name: "simple aliases",
aliases: []aliasDef{ aliases: []aliasDef{
{ {
cmd: "one", cmd: "one",
@ -34,8 +68,7 @@ func TestAliasDefine(t *testing.T) {
"duh": "one", "duh": "one",
}, },
}, },
{ "duplicates": {
name: "duplicated aliases",
aliases: []aliasDef{ aliases: []aliasDef{
{ {
cmd: "one", cmd: "one",
@ -54,9 +87,9 @@ func TestAliasDefine(t *testing.T) {
}, },
} }
for i := range uu { for k := range uu {
u := uu[i] u := uu[k]
t.Run(u.name, func(t *testing.T) { t.Run(k, func(t *testing.T) {
configAlias := config.NewAliases() configAlias := config.NewAliases()
for _, aliases := range u.aliases { for _, aliases := range u.aliases {
for _, a := range aliases.aliases { for _, a := range aliases.aliases {
@ -73,18 +106,35 @@ func TestAliasDefine(t *testing.T) {
} }
func TestAliasesLoad(t *testing.T) { func TestAliasesLoad(t *testing.T) {
config.AppConfigDir = "testdata/aliases"
a := config.NewAliases() a := config.NewAliases()
assert.Nil(t, a.LoadFile("testdata/alias.yaml")) assert.Nil(t, a.Load("testdata/aliases/plain.yaml"))
assert.Equal(t, 2, len(a.Alias)) assert.Equal(t, 56, len(a.Alias))
} }
func TestAliasesSave(t *testing.T) { func TestAliasesSave(t *testing.T) {
a := config.NewAliases() assert.NoError(t, data.EnsureFullPath("/tmp/test-aliases", data.DefaultDirMod))
a.Alias["test"] = "fred" defer assert.NoError(t, os.RemoveAll("/tmp/test-aliases"))
a.Alias["blee"] = "duh"
assert.Nil(t, a.SaveAliases("/tmp/a.yaml")) config.AppAliasesFile = "/tmp/test-aliases/aliases.yaml"
assert.Nil(t, a.LoadFile("/tmp/a.yaml")) a := testAliases()
assert.Equal(t, 2, len(a.Alias)) 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
} }

View File

@ -35,14 +35,14 @@ func TestBenchLoad(t *testing.T) {
coCount int coCount int
}{ }{
"goodConfig": { "goodConfig": {
"testdata/b_good.yaml", "testdata/benchmarks/b_good.yaml",
2, 2,
1000, 1000,
2, 2,
0, 0,
}, },
"malformed": { "malformed": {
"testdata/b_toast.yaml", "testdata/benchmarks/b_toast.yaml",
1, 1,
200, 200,
0, 0,
@ -103,7 +103,7 @@ func TestBenchServiceLoad(t *testing.T) {
for k := range uu { for k := range uu {
u := uu[k] u := uu[k]
t.Run(k, func(t *testing.T) { 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.Nil(t, err)
assert.Equal(t, 2, len(b.Benchmarks.Services)) assert.Equal(t, 2, len(b.Benchmarks.Services))
@ -122,11 +122,11 @@ func TestBenchServiceLoad(t *testing.T) {
} }
func TestBenchReLoad(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.Nil(t, err)
assert.Equal(t, 2, b.Benchmarks.Defaults.C) 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) assert.Equal(t, 20, b.Benchmarks.Defaults.C)
} }
@ -174,7 +174,7 @@ func TestBenchContainerLoad(t *testing.T) {
for k := range uu { for k := range uu {
u := uu[k] u := uu[k]
t.Run(k, func(t *testing.T) { 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.Nil(t, err)
assert.Equal(t, 2, len(b.Benchmarks.Services)) assert.Equal(t, 2, len(b.Benchmarks.Services))

View File

@ -17,6 +17,22 @@ const (
TransparentColor Color = "-" 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. // NewColor returns a new color.
func NewColor(c string) Color { func NewColor(c string) Color {
return Color(c) return Color(c)
@ -50,12 +66,3 @@ func (c Color) Color() tcell.Color {
return tcell.GetColor(string(c)).TrueColor() 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
}

View File

@ -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())
})
}
}

View File

@ -6,12 +6,10 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
@ -19,25 +17,11 @@ import (
// Config tracks K9s configuration options. // Config tracks K9s configuration options.
type Config struct { type Config struct {
K9s *K9s `yaml:"k9s"` K9s *K9s `yaml:"k9s" json:"k9s"`
conn client.Connection conn client.Connection
settings data.KubeSettings 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. // NewConfig creates a new default config.
func NewConfig(ks data.KubeSettings) *Config { func NewConfig(ks data.KubeSettings) *Config {
return &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. // ContextAliasesPath returns a context specific aliases file spec.
func (c *Config) ContextAliasesPath() string { func (c *Config) ContextAliasesPath() string {
ct, err := c.K9s.ActiveContext() ct, err := c.K9s.ActiveContext()
@ -53,13 +47,14 @@ func (c *Config) ContextAliasesPath() string {
return "" return ""
} }
return AppContextAliasesFile(ct.ClusterName, c.K9s.activeContextName) return AppContextAliasesFile(ct.GetClusterName(), c.K9s.activeContextName)
} }
// ContextPluginsPath returns a context specific plugins file spec. // ContextPluginsPath returns a context specific plugins file spec.
func (c *Config) ContextPluginsPath() string { func (c *Config) ContextPluginsPath() string {
ct, err := c.K9s.ActiveContext() ct, err := c.K9s.ActiveContext()
if err != nil { if err != nil {
log.Error().Err(err).Msgf("active context load failed")
return "" return ""
} }
@ -68,7 +63,10 @@ func (c *Config) ContextPluginsPath() string {
// Refine the configuration based on cli args. // Refine the configuration based on cli args.
func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, cfg *client.Config) error { 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 { if _, err := c.K9s.ActivateContext(*flags.Context); err != nil {
return err return err
} }
@ -88,7 +86,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c
switch { switch {
case k9sFlags != nil && IsBoolSet(k9sFlags.AllNamespaces): case k9sFlags != nil && IsBoolSet(k9sFlags.AllNamespaces):
ns = client.NamespaceAll ns = client.NamespaceAll
case isSet(flags.Namespace): case isStringSet(flags.Namespace):
ns = *flags.Namespace ns = *flags.Namespace
default: default:
nss, err := c.K9s.ActiveContextNamespace() nss, err := c.K9s.ActiveContextNamespace()
@ -104,7 +102,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c
return err 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. // 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) { func (c *Config) SetCurrentContext(n string) (*data.Context, error) {
ct, err := c.K9s.ActivateContext(n) ct, err := c.K9s.ActivateContext(n)
if err != nil { 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 return ct, nil
@ -138,21 +136,13 @@ func (c *Config) ActiveNamespace() string {
return ns 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. // FavNamespaces returns fav namespaces in the current context.
func (c *Config) FavNamespaces() []string { func (c *Config) FavNamespaces() []string {
ct, err := c.K9s.ActiveContext() ct, err := c.K9s.ActiveContext()
if err != nil { if err != nil {
return nil return nil
} }
ct.Validate(c.conn, c.settings)
return ct.Namespace.Favorites return ct.Namespace.Favorites
} }
@ -209,23 +199,26 @@ func (c *Config) ActiveContextName() string {
return c.K9s.activeContextName return c.K9s.activeContextName
} }
func (c *Config) Merge(c1 *Config) {
c.K9s.Merge(c1.K9s)
}
// Load loads K9s configuration from file. // Load loads K9s configuration from file.
func (c *Config) Load(path string) error { func (c *Config) Load(path string) error {
f, err := os.ReadFile(path) bb, err := os.ReadFile(path)
if err != nil { if err != nil {
return err 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 var cfg Config
if err := yaml.Unmarshal(f, &cfg); err != nil { if err := yaml.Unmarshal(bb, &cfg); err != nil {
return err return err
} }
if cfg.K9s != nil { c.Merge(&cfg)
c.K9s.Refine(cfg.K9s)
}
if c.K9s.Logger == nil {
c.K9s.Logger = NewLogger()
}
return nil return nil
} }
@ -235,7 +228,11 @@ func (c *Config) Save() error {
if err := c.K9s.Save(); err != nil { if err := c.K9s.Save(); err != nil {
return err 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. // 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) log.Error().Msgf("[Config] Unable to save K9s config file: %v", err)
return err return err
} }
return os.WriteFile(path, cfg, 0644) return os.WriteFile(path, cfg, 0644)
} }
// Validate the configuration. // Validate the configuration.
func (c *Config) Validate() { func (c *Config) Validate() {
if c.K9s == nil {
c.K9s = NewK9s(c.conn, c.settings)
}
c.K9s.Validate(c.conn, c.settings) c.K9s.Validate(c.conn, c.settings)
} }
// Dump debug... // Dump for debug...
func (c *Config) Dump(msg string) { func (c *Config) Dump(msg string) {
ct, err := c.K9s.ActiveContext() ct, err := c.K9s.ActiveContext()
if err != nil { if err == nil {
log.Debug().Msgf("Current Contexts: %s\n", ct.ClusterName) 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"
}

View File

@ -4,12 +4,16 @@
package config_test package config_test
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/adrg/xdg"
"github.com/derailed/k9s/internal/client" "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" "github.com/derailed/k9s/internal/config/mock"
m "github.com/petergtz/pegomock" m "github.com/petergtz/pegomock"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@ -21,37 +25,358 @@ func init() {
zerolog.SetGlobalLevel(zerolog.FatalLevel) 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 ( var (
cfgFile = "testdata/kubeconfig-test.yaml" cfgFile = "testdata/kubes/test.yaml"
ctx, cluster, ns = "ct-1-1", "cl-1", "ns-1" view = "dp"
) )
uu := map[string]struct { uu := map[string]struct {
flags *genericclioptions.ConfigFlags ct string
issue bool flags *genericclioptions.ConfigFlags
context, cluster, namespace string k9sFlags *config.Flags
view string
e string
}{ }{
"overrideNS": { "empty": {
flags: &genericclioptions.ConfigFlags{ view: data.DefaultView,
KubeConfig: &cfgFile, e: data.DefaultView,
Context: &ctx,
ClusterName: &cluster,
Namespace: &ns,
},
issue: false,
context: ctx,
cluster: cluster,
namespace: ns,
}, },
"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{ flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile, KubeConfig: &cfgFile,
Context: &ns,
ClusterName: &cluster,
Namespace: &ns,
}, },
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() cfg := mock.NewMockConfig()
err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags)) err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags))
if u.issue { assert.NoError(t, err)
assert.NotNil(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 { } else {
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, u.context, cfg.K9s.ActiveContextName()) assert.Equal(t, u.context, cfg.K9s.ActiveContextName())
@ -76,35 +521,29 @@ func TestConfigValidate(t *testing.T) {
cfg := mock.NewMockConfig() cfg := mock.NewMockConfig()
cfg.SetConnection(mock.NewMockConnection()) cfg.SetConnection(mock.NewMockConnection())
assert.Nil(t, cfg.Load("testdata/k9s.yaml")) assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.Validate() cfg.Validate()
} }
func TestConfigLoad(t *testing.T) { func TestConfigLoad(t *testing.T) {
cfg := mock.NewMockConfig() 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, 2, cfg.K9s.RefreshRate)
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount) assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount)
} assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
func TestConfigLoadOldCfg(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s_old.yaml"))
} }
func TestConfigLoadCrap(t *testing.T) { func TestConfigLoadCrap(t *testing.T) {
cfg := mock.NewMockConfig() 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) { func TestConfigSaveFile(t *testing.T) {
cfg := mock.NewMockConfig() 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.RefreshRate = 100
cfg.K9s.ReadOnly = true cfg.K9s.ReadOnly = true
@ -113,26 +552,28 @@ func TestConfigSaveFile(t *testing.T) {
cfg.Validate() cfg.Validate()
path := filepath.Join("/tmp", "k9s.yaml") path := filepath.Join("/tmp", "k9s.yaml")
err := cfg.SaveFile(path) assert.NoError(t, cfg.SaveFile(path))
assert.Nil(t, err)
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
assert.Nil(t, err) 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) { func TestConfigReset(t *testing.T) {
cfg := mock.NewMockConfig() cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s.yaml")) assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.Reset() cfg.Reset()
cfg.Validate() cfg.Validate()
path := filepath.Join("/tmp", "k9s.yaml") path := filepath.Join("/tmp", "k9s.yaml")
err := cfg.SaveFile(path) assert.NoError(t, cfg.SaveFile(path))
assert.Nil(t, err)
raw, err := os.ReadFile(path) bb, err := os.ReadFile(path)
assert.Nil(t, err) 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... // Helpers...
@ -147,86 +588,86 @@ func TestSetup(t *testing.T) {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Test Data... // Test Data...
var expectedConfig = `k9s: // var expectedConfig = `k9s:
liveViewAutoRefresh: true // liveViewAutoRefresh: true
screenDumpDir: /tmp // screenDumpDir: /tmp/screen-dumps
refreshRate: 100 // refreshRate: 100
maxConnRetry: 5 // maxConnRetry: 5
readOnly: true // readOnly: true
noExitOnCtrlC: false // noExitOnCtrlC: false
ui: // ui:
enableMouse: false // enableMouse: false
headless: false // headless: false
logoless: false // logoless: false
crumbsless: false // crumbsless: false
noIcons: false // noIcons: false
skipLatestRevCheck: false // skipLatestRevCheck: false
disablePodCounting: false // disablePodCounting: false
shellPod: // shellPod:
image: busybox:1.35.0 // image: busybox:1.35.0
namespace: default // namespace: default
limits: // limits:
cpu: 100m // cpu: 100m
memory: 100Mi // memory: 100Mi
imageScans: // imageScans:
enable: false // enable: false
exclusions: // exclusions:
namespaces: [] // namespaces: []
labels: {} // labels: {}
logger: // logger:
tail: 500 // tail: 500
buffer: 800 // buffer: 800
sinceSeconds: -1 // sinceSeconds: -1
fullScreenLogs: false // fullScreen: false
textWrap: false // textWrap: false
showTime: false // showTime: false
thresholds: // thresholds:
cpu: // cpu:
critical: 90 // critical: 90
warn: 70 // warn: 70
memory: // memory:
critical: 90 // critical: 90
warn: 70 // warn: 70
` // `
var resetConfig = `k9s: // var resetConfig = `k9s:
liveViewAutoRefresh: true // liveViewAutoRefresh: true
screenDumpDir: /tmp // screenDumpDir: /tmp/screen-dumps
refreshRate: 2 // refreshRate: 2
maxConnRetry: 5 // maxConnRetry: 5
readOnly: false // readOnly: false
noExitOnCtrlC: false // noExitOnCtrlC: false
ui: // ui:
enableMouse: false // enableMouse: false
headless: false // headless: false
logoless: false // logoless: false
crumbsless: false // crumbsless: false
noIcons: false // noIcons: false
skipLatestRevCheck: false // skipLatestRevCheck: false
disablePodCounting: false // disablePodCounting: false
shellPod: // shellPod:
image: busybox:1.35.0 // image: busybox:1.35.0
namespace: default // namespace: default
limits: // limits:
cpu: 100m // cpu: 100m
memory: 100Mi // memory: 100Mi
imageScans: // imageScans:
enable: false // enable: false
exclusions: // exclusions:
namespaces: [] // namespaces: []
labels: {} // labels: {}
logger: // logger:
tail: 200 // tail: 200
buffer: 2000 // buffer: 2000
sinceSeconds: -1 // sinceSeconds: -1
fullScreenLogs: false // fullScreen: false
textWrap: false // textWrap: false
showTime: false // showTime: false
thresholds: // thresholds:
cpu: // cpu:
critical: 90 // critical: 90
warn: 70 // warn: 70
memory: // memory:
critical: 90 // critical: 90
warn: 70 // warn: 70
` // `

View File

@ -18,27 +18,33 @@ type Config struct {
Context *Context `yaml:"k9s"` Context *Context `yaml:"k9s"`
} }
// NewConfig returns a new config.
func NewConfig(ct *api.Context) *Config { func NewConfig(ct *api.Context) *Config {
return &Config{ return &Config{
Context: NewContextFromConfig(ct), Context: NewContextFromConfig(ct),
} }
} }
// Validate ensures config is in norms.
func (c *Config) Validate(conn client.Connection, ks KubeSettings) { func (c *Config) Validate(conn client.Connection, ks KubeSettings) {
if c.Context == nil {
c.Context = NewContext()
}
c.Context.Validate(conn, ks) c.Context.Validate(conn, ks)
} }
// Dump used for debugging.
func (c *Config) Dump(w io.Writer) { func (c *Config) Dump(w io.Writer) {
bb, _ := yaml.Marshal(&c) bb, _ := yaml.Marshal(&c)
fmt.Fprintf(w, "%s\n", string(bb)) fmt.Fprintf(w, "%s\n", string(bb))
} }
// Save saves the config to disk.
func (c *Config) Save(path string) error { func (c *Config) Save(path string) error {
if err := EnsureDirPath(path, DefaultDirMod); err != nil { if err := EnsureDirPath(path, DefaultDirMod); err != nil {
return err return err
} }
cfg, err := yaml.Marshal(c) cfg, err := yaml.Marshal(c)
if err != nil { if err != nil {
return err return err

View File

@ -4,6 +4,8 @@
package data package data
import ( import (
"sync"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
) )
@ -14,12 +16,13 @@ const DefaultPFAddress = "localhost"
// Context tracks K9s context configuration. // Context tracks K9s context configuration.
type Context struct { type Context struct {
ClusterName string `yaml:"cluster,omitempty"` ClusterName string `yaml:"cluster,omitempty"`
ReadOnly bool `yaml:"readOnly"` ReadOnly *bool `yaml:"readOnly,omitempty"`
Skin string `yaml:"skin,omitempty"` Skin string `yaml:"skin,omitempty"`
Namespace *Namespace `yaml:"namespace"` Namespace *Namespace `yaml:"namespace"`
View *View `yaml:"view"` View *View `yaml:"view"`
FeatureGates FeatureGates `yaml:"featureGates"` FeatureGates FeatureGates `yaml:"featureGates"`
PortForwardAddress string `yaml:"portForwardAddress"` PortForwardAddress string `yaml:"portForwardAddress"`
mx sync.RWMutex
} }
// NewContext creates a new cluster configuration. // 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 { func NewContextFromConfig(cfg *api.Context) *Context {
return &Context{ return &Context{
Namespace: NewActiveNamespace(cfg.Namespace), 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) { func (c *Context) Validate(conn client.Connection, ks KubeSettings) {
c.mx.Lock()
defer c.mx.Unlock()
if c.PortForwardAddress == "" { if c.PortForwardAddress == "" {
c.PortForwardAddress = DefaultPFAddress c.PortForwardAddress = DefaultPFAddress
} }
if cl, err := ks.CurrentClusterName(); err != nil { if cl, err := ks.CurrentClusterName(); err == nil {
c.ClusterName = cl c.ClusterName = cl
} }

View File

@ -5,9 +5,11 @@ package data
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"github.com/derailed/k9s/internal/config/json"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
@ -60,6 +62,10 @@ func (d *Dir) loadConfig(path string) (*Config, error) {
if err != nil { if err != nil {
return nil, err 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 var cfg Config
if err := yaml.Unmarshal(bb, &cfg); err != nil { if err := yaml.Unmarshal(bb, &cfg); err != nil {
return nil, err return nil, err

View File

@ -77,7 +77,7 @@ func TestEnsureDirPathNone(t *testing.T) {
func TestEnsureDirPathNoOpt(t *testing.T) { func TestEnsureDirPathNoOpt(t *testing.T) {
var mod os.FileMode = 0744 var mod os.FileMode = 0744
dir := filepath.Join("/tmp", "k9s-test") dir := filepath.Join("/tmp", "k9s-test")
os.Remove(dir) assert.NoError(t, os.RemoveAll(dir))
assert.NoError(t, os.Mkdir(dir, mod)) assert.NoError(t, os.Mkdir(dir, mod))
path := filepath.Join(dir, "duh.yaml") path := filepath.Join(dir, "duh.yaml")

View File

@ -6,9 +6,13 @@ package data
import ( import (
"os" "os"
"github.com/derailed/k9s/internal/config/json"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
) )
// JSONValidator validate yaml configurations.
var JSONValidator = json.NewValidator()
const ( const (
// DefaultDirMod default unix perms for k9s directory. // DefaultDirMod default unix perms for k9s directory.
DefaultDirMod os.FileMode = 0744 DefaultDirMod os.FileMode = 0744

View File

@ -80,7 +80,7 @@ var (
AppHotKeysFile string AppHotKeysFile string
) )
// InitLogsLoc initializes K9s logs location. // InitLogLoc initializes K9s logs location.
func InitLogLoc() error { func InitLogLoc() error {
var appLogDir string var appLogDir string
switch { switch {
@ -273,5 +273,9 @@ func EnsureHotkeysCfgFile() (string, error) {
// SkinFileFromName generate skin file path from spec. // SkinFileFromName generate skin file path from spec.
func SkinFileFromName(n string) string { func SkinFileFromName(n string) string {
if n == "" {
n = "stock"
}
return filepath.Join(AppSkinsDir, n+".yaml") return filepath.Join(AppSkinsDir, n+".yaml")
} }

View File

@ -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)
})
}
}

View File

@ -58,10 +58,11 @@ func TestInitLogLoc(t *testing.T) {
}) })
} }
} }
func TestEnsureBenchmarkCfg(t *testing.T) { func TestEnsureBenchmarkCfg(t *testing.T) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config") os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs()) 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, 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)) 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))
})
}
}

View File

@ -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)
}

View File

@ -13,6 +13,19 @@ import (
v1 "k8s.io/api/core/v1" 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. // isEnvSet checks if env var is set.
func isEnvSet(env string) bool { func isEnvSet(env string) bool {
return os.Getenv(env) != "" return os.Getenv(env) != ""

View File

@ -4,8 +4,11 @@
package config package config
import ( import (
"fmt"
"os" "os"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -30,19 +33,32 @@ func NewHotKeys() HotKeys {
} }
// Load K9s plugins. // Load K9s plugins.
func (h HotKeys) Load() error { func (h HotKeys) Load(path string) error {
return h.LoadHotKeys(AppHotKeysFile) 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. // LoadHotKeys loads plugins from a given file.
func (h HotKeys) LoadHotKeys(path string) error { 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 { if err != nil {
return err 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 var hh HotKeys
if err := yaml.Unmarshal(f, &hh); err != nil { if err := yaml.Unmarshal(bb, &hh); err != nil {
return err return err
} }
for k, v := range hh.HotKey { for k, v := range hh.HotKey {

View File

@ -12,7 +12,7 @@ import (
func TestHotKeyLoad(t *testing.T) { func TestHotKeyLoad(t *testing.T) {
h := config.NewHotKeys() 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)) assert.Equal(t, 1, len(h.HotKey))

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"}
}
}
}
}
}
}
}
}
}
}
}
}

View File

@ -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"]
}

View File

@ -0,0 +1,3 @@
aliases:
blee: duh
fred: zorg

View File

@ -0,0 +1,3 @@
alias:
blee: duh
fred: zorg

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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: &current_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

View File

@ -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: &current_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

View File

@ -0,0 +1,12 @@
views:
v1/nodes:
columns:
- NAME
- IP
v1/endpoints:
sortColumn: AGE:asc
columns:
- NAME
- NAMESPACE
- ENDPOINTS
- AGE

View File

@ -0,0 +1,9 @@
views:
v1/nodes:
v1/endpoints:
sortCol: AGE:asc
cols:
- NAME
- NAMESPACE
- ENDPOINTS
- AGE

View File

@ -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
}

View File

@ -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())
}
})
}
}

View File

@ -4,8 +4,9 @@
package config package config
import ( import (
"errors" "fmt"
"path/filepath" "path/filepath"
"sync"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/data"
@ -13,19 +14,19 @@ import (
// K9s tracks K9s configuration options. // K9s tracks K9s configuration options.
type K9s struct { type K9s struct {
LiveViewAutoRefresh bool `yaml:"liveViewAutoRefresh"` LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"`
ScreenDumpDir string `yaml:"screenDumpDir,omitempty"` ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"`
RefreshRate int `yaml:"refreshRate"` RefreshRate int `json:"refreshRate" yaml:"refreshRate"`
MaxConnRetry int `yaml:"maxConnRetry"` MaxConnRetry int `json:"maxConnRetry" yaml:"maxConnRetry"`
ReadOnly bool `yaml:"readOnly"` ReadOnly bool `json:"readOnly" yaml:"readOnly"`
NoExitOnCtrlC bool `yaml:"noExitOnCtrlC"` NoExitOnCtrlC bool `json:"noExitOnCtrlC" yaml:"noExitOnCtrlC"`
UI UI `yaml:"ui"` UI UI `json:"ui" yaml:"ui"`
SkipLatestRevCheck bool `yaml:"skipLatestRevCheck"` SkipLatestRevCheck bool `json:"skipLatestRevCheck" yaml:"skipLatestRevCheck"`
DisablePodCounting bool `yaml:"disablePodCounting"` DisablePodCounting bool `json:"disablePodCounting" yaml:"disablePodCounting"`
ShellPod *ShellPod `yaml:"shellPod"` ShellPod ShellPod `json:"shellPod" yaml:"shellPod"`
ImageScans *ImageScans `yaml:"imageScans"` ImageScans ImageScans `json:"imageScans" yaml:"imageScans"`
Logger *Logger `yaml:"logger"` Logger Logger `json:"logger" yaml:"logger"`
Thresholds Threshold `yaml:"thresholds"` Thresholds Threshold `json:"thresholds" yaml:"thresholds"`
manualRefreshRate int manualRefreshRate int
manualHeadless *bool manualHeadless *bool
manualLogoless *bool manualLogoless *bool
@ -38,6 +39,7 @@ type K9s struct {
activeConfig *data.Config activeConfig *data.Config
conn client.Connection conn client.Connection
ks data.KubeSettings ks data.KubeSettings
mx sync.RWMutex
} }
// NewK9s create a new K9s configuration. // 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) { func (k *K9s) resetConnection(conn client.Connection) {
k.mx.Lock()
defer k.mx.Unlock()
k.conn = conn k.conn = conn
} }
// Save saves the k9s config to dis. // Save saves the k9s config to dis.
func (k *K9s) Save() error { func (k *K9s) Save() error {
if k.activeConfig != nil { if k.activeConfig == nil {
path := filepath.Join( return fmt.Errorf("save failed. no active config detected")
AppContextsDir,
data.SanitizeContextSubpath(k.activeConfig.Context.ClusterName, k.activeContextName),
data.MainConfigFile,
)
return k.activeConfig.Save(path)
} }
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. // Merge merges k9s configs.
func (k *K9s) Refine(k1 *K9s) { func (k *K9s) Merge(k1 *K9s) {
if k1 == nil {
return
}
k.LiveViewAutoRefresh = k1.LiveViewAutoRefresh k.LiveViewAutoRefresh = k1.LiveViewAutoRefresh
k.ScreenDumpDir = k1.ScreenDumpDir k.ScreenDumpDir = k1.ScreenDumpDir
k.RefreshRate = k1.RefreshRate k.RefreshRate = k1.RefreshRate
@ -86,56 +95,31 @@ func (k *K9s) Refine(k1 *K9s) {
k.SkipLatestRevCheck = k1.SkipLatestRevCheck k.SkipLatestRevCheck = k1.SkipLatestRevCheck
k.DisablePodCounting = k1.DisablePodCounting k.DisablePodCounting = k1.DisablePodCounting
k.ShellPod = k1.ShellPod k.ShellPod = k1.ShellPod
k.ImageScans = k1.ImageScans
k.Logger = k1.Logger k.Logger = k1.Logger
k.ImageScans = k1.ImageScans
k.Thresholds = k1.Thresholds k.Thresholds = k1.Thresholds
} }
// Override overrides k9s config from cli args. // AppScreenDumpDir fetch screen dumps dir.
func (k *K9s) Override(k9sFlags *Flags) { func (k *K9s) AppScreenDumpDir() string {
if *k9sFlags.RefreshRate != DefaultRefreshRate { d := k.ScreenDumpDir
k.OverrideRefreshRate(*k9sFlags.RefreshRate) if isStringSet(k.manualScreenDumpDir) {
d = *k.manualScreenDumpDir
k.ScreenDumpDir = d
}
if d == "" {
d = AppDumpsDir
} }
k.OverrideHeadless(*k9sFlags.Headless) return d
k.OverrideLogoless(*k9sFlags.Logoless)
k.OverrideCrumbsless(*k9sFlags.Crumbsless)
k.OverrideReadOnly(*k9sFlags.ReadOnly)
k.OverrideWrite(*k9sFlags.Write)
k.OverrideCommand(*k9sFlags.Command)
k.OverrideScreenDumpDir(*k9sFlags.ScreenDumpDir)
} }
// OverrideScreenDumpDir set the screen dump dir manually. // ContextScreenDumpDir fetch context specific screen dumps dir.
func (k *K9s) OverrideScreenDumpDir(dir string) { func (k *K9s) ContextScreenDumpDir() string {
k.manualScreenDumpDir = &dir return filepath.Join(k.AppScreenDumpDir(), k.contextPath())
} }
// GetScreenDumpDir fetch screen dumps dir. func (k *K9s) contextPath() string {
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 {
if k.activeConfig == nil { if k.activeConfig == nil {
return "na" 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. // ActiveContextNamespace fetch the context active ns.
func (k *K9s) ActiveContextNamespace() (string, error) { func (k *K9s) ActiveContextNamespace() (string, error) {
if k.activeConfig != nil { act, err := k.ActiveContext()
return k.activeConfig.Context.Namespace.Active, nil if err != nil {
return "", err
} }
return "", errors.New("context config is not set") return act.Namespace.Active, nil
} }
// ActiveContextName returns the active context name. // ActiveContextName returns the active context name.
func (k *K9s) ActiveContextName() string { func (k *K9s) ActiveContextName() string {
k.mx.RLock()
defer k.mx.RUnlock()
return k.activeContextName return k.activeContextName
} }
// ActiveContext returns the currently active context. // ActiveContext returns the currently active context.
func (k *K9s) ActiveContext() (*data.Context, error) { func (k *K9s) ActiveContext() (*data.Context, error) {
if k.activeConfig != nil { var ac *data.Config
if k.activeConfig.Context == nil { k.mx.RLock()
ct, err := k.ks.CurrentContext() ac = k.activeConfig
if err != nil { k.mx.RUnlock()
return nil, err
}
k.activeConfig.Context = data.NewContextFromConfig(ct)
}
return k.activeConfig.Context, nil
}
if ac != nil && ac.Context != nil {
return ac.Context, nil
}
ct, err := k.ActivateContext(k.activeContextName) ct, err := k.ActivateContext(k.activeContextName)
if err != nil { if err != nil {
return nil, err return nil, err
@ -181,7 +171,7 @@ func (k *K9s) ActiveContext() (*data.Context, error) {
return ct, nil 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) { func (k *K9s) ActivateContext(n string) (*data.Context, error) {
k.activeContextName = n k.activeContextName = n
ct, err := k.ks.GetContext(n) ct, err := k.ks.GetContext(n)
@ -192,161 +182,128 @@ func (k *K9s) ActivateContext(n string) (*data.Context, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// If the context specifies a default namespace, use it!
if k.conn != nil { k.Validate(k.conn, k.ks)
k.Validate(k.conn, k.ks) // If the context specifies a namespace, use it!
if ns := k.conn.ActiveNamespace(); ns != client.BlankNamespace { if ns := ct.Namespace; ns != client.BlankNamespace {
k.activeConfig.Context.Namespace.Active = ns k.activeConfig.Context.Namespace.Active = ns
} else { } else {
k.activeConfig.Context.Namespace.Active = client.DefaultNamespace 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 return k.activeConfig.Context, nil
} }
// Reload reloads the active config from disk. // Reload reloads the context config from disk.
func (k *K9s) Reload() error { func (k *K9s) Reload() error {
k.mx.Lock()
defer k.mx.Unlock()
ct, err := k.ks.GetContext(k.activeContextName) ct, err := k.ks.GetContext(k.activeContextName)
if err != nil { if err != nil {
return err return err
} }
k.activeConfig, err = k.dir.Load(k.activeContextName, ct) k.activeConfig, err = k.dir.Load(k.activeContextName, ct)
if err != nil { if err != nil {
return err return err
} }
k.activeConfig.Validate(k.conn, k.ks)
return nil return nil
} }
// OverrideRefreshRate set the refresh rate manually. // Override overrides k9s config from cli args.
func (k *K9s) OverrideRefreshRate(r int) { func (k *K9s) Override(k9sFlags *Flags) {
k.manualRefreshRate = r if k9sFlags.RefreshRate != nil && *k9sFlags.RefreshRate != DefaultRefreshRate {
} k.manualRefreshRate = *k9sFlags.RefreshRate
// 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
} }
}
// OverrideWrite set the write mode manually. k.manualHeadless = k9sFlags.Headless
func (k *K9s) OverrideWrite(b bool) { k.manualLogoless = k9sFlags.Logoless
if b { k.manualCrumbsless = k9sFlags.Crumbsless
var flag bool if k9sFlags.ReadOnly != nil && *k9sFlags.ReadOnly {
k.manualReadOnly = &flag k.manualReadOnly = k9sFlags.ReadOnly
} }
} if k9sFlags.Write != nil && *k9sFlags.Write {
var false bool
// OverrideCommand set the command manually. k.manualReadOnly = &false
func (k *K9s) OverrideCommand(cmd string) { }
k.manualCommand = &cmd k.manualCommand = k9sFlags.Command
k.manualScreenDumpDir = k9sFlags.ScreenDumpDir
} }
// IsHeadless returns headless setting. // IsHeadless returns headless setting.
func (k *K9s) IsHeadless() bool { func (k *K9s) IsHeadless() bool {
h := k.UI.Headless if isBoolSet(k.manualHeadless) {
if k.manualHeadless != nil && *k.manualHeadless { return true
h = *k.manualHeadless
} }
return h return k.UI.Headless
} }
// IsLogoless returns logoless setting. // IsLogoless returns logoless setting.
func (k *K9s) IsLogoless() bool { func (k *K9s) IsLogoless() bool {
h := k.UI.Logoless if isBoolSet(k.manualLogoless) {
if k.manualLogoless != nil && *k.manualLogoless { return true
h = *k.manualLogoless
} }
return h return k.UI.Logoless
} }
// IsCrumbsless returns crumbsless setting. // IsCrumbsless returns crumbsless setting.
func (k *K9s) IsCrumbsless() bool { func (k *K9s) IsCrumbsless() bool {
h := k.UI.Crumbsless if isBoolSet(k.manualCrumbsless) {
if k.manualCrumbsless != nil && *k.manualCrumbsless { return true
h = *k.manualCrumbsless
} }
return h return k.UI.Crumbsless
} }
// GetRefreshRate returns the current refresh rate. // GetRefreshRate returns the current refresh rate.
func (k *K9s) GetRefreshRate() int { func (k *K9s) GetRefreshRate() int {
rate := k.RefreshRate
if k.manualRefreshRate != 0 { if k.manualRefreshRate != 0 {
rate = k.manualRefreshRate return k.manualRefreshRate
} }
return rate return k.RefreshRate
} }
// IsReadOnly returns the readonly setting. // IsReadOnly returns the readonly setting.
func (k *K9s) IsReadOnly() bool { func (k *K9s) IsReadOnly() bool {
readOnly := k.ReadOnly k.mx.RLock()
if k.manualReadOnly != nil { defer k.mx.RUnlock()
readOnly = *k.manualReadOnly
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 { if k.manualReadOnly != nil {
readOnly = true 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 { if k.RefreshRate <= 0 {
k.RefreshRate = defaultRefreshRate k.RefreshRate = defaultRefreshRate
} }
if k.MaxConnRetry <= 0 { if k.MaxConnRetry <= 0 {
k.MaxConnRetry = defaultMaxConnRetry k.MaxConnRetry = defaultMaxConnRetry
} }
}
// Validate the current configuration.
func (k *K9s) Validate(c client.Connection, ks data.KubeSettings) {
k.validateDefaults()
if k.activeConfig == nil { if k.activeConfig == nil {
if n, err := ks.CurrentContextName(); err == nil { if n, err := ks.CurrentContextName(); err == nil {
_, _ = k.ActivateContext(n) _, _ = k.ActivateContext(n)
} }
} }
if k.ImageScans == nil { k.ShellPod = k.ShellPod.Validate()
k.ImageScans = NewImageScans() k.Logger = k.Logger.Validate()
} k.Thresholds = k.Thresholds.Validate()
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()
if k.activeConfig != nil { if k.activeConfig != nil {
k.activeConfig.Validate(c, ks) k.activeConfig.Validate(c, ks)

View File

@ -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())
})
}
}

View File

@ -4,40 +4,145 @@
package config_test package config_test
import ( import (
"errors"
"testing" "testing"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/config/mock"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"k8s.io/cli-runtime/pkg/genericclioptions"
) )
func TestGetScreenDumpDir(t *testing.T) { func TestK9sReload(t *testing.T) {
cfg := mock.NewMockConfig() config.AppConfigDir = "/tmp/k9s-test"
assert.Nil(t, cfg.Load("testdata/k9s.yaml")) cl, ct := "cl-1", "ct-1-1"
assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir())
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) { func TestK9sMerge(t *testing.T) {
cfg := mock.NewMockConfig() cl, ct := "cl-1", "ct-1-1"
assert.Nil(t, cfg.Load("testdata/k9s.yaml")) uu := map[string]struct {
cfg.K9s.OverrideScreenDumpDir("/override") k1, k2 *config.K9s
assert.Equal(t, "/override", cfg.K9s.GetScreenDumpDir()) 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() cfg := mock.NewMockConfig()
_, err := cfg.K9s.ActivateContext("ct-1-1")
assert.Nil(t, cfg.Load("testdata/k9s.yaml")) assert.NoError(t, err)
cfg.K9s.OverrideScreenDumpDir("") assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir()) 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() cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s1.yaml")) assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.K9s.OverrideScreenDumpDir("") assert.Equal(t, "/tmp/k9s-test/screen-dumps", cfg.K9s.AppScreenDumpDir())
assert.Equal(t, config.AppDumpsDir, cfg.K9s.GetScreenDumpDir())
} }

View File

@ -16,17 +16,17 @@ const (
// Logger tracks logger options. // Logger tracks logger options.
type Logger struct { type Logger struct {
TailCount int64 `yaml:"tail"` TailCount int64 `json:"tail" yaml:"tail"`
BufferSize int `yaml:"buffer"` BufferSize int `json:"buffer" yaml:"buffer"`
SinceSeconds int64 `yaml:"sinceSeconds"` SinceSeconds int64 `json:"sinceSeconds" yaml:"sinceSeconds"`
FullScreenLogs bool `yaml:"fullScreenLogs"` FullScreen bool `json:"fullScreen" yaml:"fullScreen"`
TextWrap bool `yaml:"textWrap"` TextWrap bool `json:"textWrap" yaml:"textWrap"`
ShowTime bool `yaml:"showTime"` ShowTime bool `json:"showTime" yaml:"showTime"`
} }
// NewLogger returns a new instance. // NewLogger returns a new instance.
func NewLogger() *Logger { func NewLogger() Logger {
return &Logger{ return Logger{
TailCount: DefaultLoggerTailCount, TailCount: DefaultLoggerTailCount,
BufferSize: MaxLogThreshold, BufferSize: MaxLogThreshold,
SinceSeconds: DefaultSinceSeconds, SinceSeconds: DefaultSinceSeconds,
@ -34,7 +34,7 @@ func NewLogger() *Logger {
} }
// Validate checks thresholds and make sure we're cool. If not use defaults. // 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 { if l.TailCount <= 0 {
l.TailCount = DefaultLoggerTailCount l.TailCount = DefaultLoggerTailCount
} }
@ -47,4 +47,6 @@ func (l *Logger) Validate() {
if l.SinceSeconds == 0 { if l.SinceSeconds == 0 {
l.SinceSeconds = DefaultSinceSeconds l.SinceSeconds = DefaultSinceSeconds
} }
return l
} }

View File

@ -12,7 +12,7 @@ import (
func TestNewLogger(t *testing.T) { func TestNewLogger(t *testing.T) {
l := config.NewLogger() l := config.NewLogger()
l.Validate() l = l.Validate()
assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, int64(100), l.TailCount)
assert.Equal(t, 5000, l.BufferSize) assert.Equal(t, 5000, l.BufferSize)
@ -20,7 +20,7 @@ func TestNewLogger(t *testing.T) {
func TestLoggerValidate(t *testing.T) { func TestLoggerValidate(t *testing.T) {
var l config.Logger var l config.Logger
l.Validate() l = l.Validate()
assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, int64(100), l.TailCount)
assert.Equal(t, 5000, l.BufferSize) assert.Equal(t, 5000, l.BufferSize)

View File

@ -33,7 +33,7 @@ func EnsureDir(d string) error {
func NewMockConfig() *config.Config { func NewMockConfig() *config.Config {
config.AppContextsDir = "/tmp/test" config.AppContextsDir = "/tmp/test"
cl, ct := "cl-1", "ct-1" cl, ct := "cl-1", "ct-1-1"
flags := genericclioptions.ConfigFlags{ flags := genericclioptions.ConfigFlags{
ClusterName: &cl, ClusterName: &cl,
Context: &ct, Context: &ct,
@ -63,7 +63,7 @@ func NewMockKubeSettings(f *genericclioptions.ConfigFlags) mockKubeSettings {
}, },
ctId + "-2": { ctId + "-2": {
Cluster: *f.ClusterName, Cluster: *f.ClusterName,
Namespace: "ns-1", Namespace: "ns-2",
}, },
ctId + "-3": { ctId + "-3": {
Cluster: *f.ClusterName, Cluster: *f.ClusterName,

View File

@ -10,6 +10,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"github.com/adrg/xdg" "github.com/adrg/xdg"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -47,6 +50,7 @@ func NewPlugins() Plugins {
// Load K9s plugins. // Load K9s plugins.
func (p Plugins) Load(path string) error { func (p Plugins) Load(path string) error {
var errs error var errs error
if err := p.load(AppPluginsFile); err != nil { if err := p.load(AppPluginsFile); err != nil {
errs = errors.Join(errs, err) errs = errors.Join(errs, err)
} }
@ -74,12 +78,12 @@ func (p Plugins) loadPluginDir(dir string) error {
if file.IsDir() || !isYamlFile(file.Name()) { if file.IsDir() || !isYamlFile(file.Name()) {
continue continue
} }
pluginFile, err := os.ReadFile(filepath.Join(dir, file.Name())) bb, err := os.ReadFile(filepath.Join(dir, file.Name()))
if err != nil { if err != nil {
errs = errors.Join(errs, err) errs = errors.Join(errs, err)
} }
var plugin Plugin var plugin Plugin
if err = yaml.Unmarshal(pluginFile, &plugin); err != nil { if err = yaml.Unmarshal(bb, &plugin); err != nil {
return err return err
} }
p.Plugins[strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))] = plugin 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) { if _, err := os.Stat(path); os.IsNotExist(err) {
return nil return nil
} }
f, err := os.ReadFile(path) bb, err := os.ReadFile(path)
if err != nil { if err != nil {
return err 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 var pp Plugins
if err := yaml.Unmarshal(f, &pp); err != nil { if err := yaml.Unmarshal(bb, &pp); err != nil {
return err return err
} }
for k, v := range pp.Plugins { for k, v := range pp.Plugins {

View File

@ -4,8 +4,10 @@
package config package config
import ( import (
"os"
"testing" "testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -39,10 +41,24 @@ var test2YmlTestData = Plugin{
Background: true, 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) { func TestSinglePluginFileLoad(t *testing.T) {
p := NewPlugins() p := NewPlugins()
assert.Nil(t, p.load("testdata/plugins.yaml")) assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.Nil(t, p.loadPluginDir("/random/dir/not/exist")) assert.NoError(t, p.loadPluginDir("/random/dir/not/exist"))
assert.Equal(t, 1, len(p.Plugins)) assert.Equal(t, 1, len(p.Plugins))
k, ok := p.Plugins["blah"] k, ok := p.Plugins["blah"]
@ -53,8 +69,8 @@ func TestSinglePluginFileLoad(t *testing.T) {
func TestMultiplePluginFilesLoad(t *testing.T) { func TestMultiplePluginFilesLoad(t *testing.T) {
p := NewPlugins() p := NewPlugins()
assert.Nil(t, p.load("testdata/plugins.yaml")) assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.Nil(t, p.loadPluginDir("testdata/plugins")) assert.NoError(t, p.loadPluginDir("testdata/plugins"))
testPlugins := map[string]Plugin{ testPlugins := map[string]Plugin{
"blah": pluginYmlTestData, "blah": pluginYmlTestData,

View File

@ -23,8 +23,8 @@ func (l Labels) exclude(k, val string) bool {
// ScanExcludes tracks vul scan exclusions. // ScanExcludes tracks vul scan exclusions.
type ScanExcludes struct { type ScanExcludes struct {
Namespaces []string `yaml:"namespaces"` Namespaces []string `json:"namespaces" yaml:"namespaces"`
Labels Labels `yaml:"labels"` Labels Labels `json:"labels" yaml:"labels"`
} }
func newScanExcludes() ScanExcludes { func newScanExcludes() ScanExcludes {
@ -50,19 +50,19 @@ func (b ScanExcludes) exclude(ns string, ll map[string]string) bool {
// ImageScans tracks vul scans options. // ImageScans tracks vul scans options.
type ImageScans struct { type ImageScans struct {
Enable bool `yaml:"enable"` Enable bool `json:"enable" yaml:"enable"`
Exclusions ScanExcludes `yaml:"exclusions"` Exclusions ScanExcludes `json:"exclusions" yaml:"exclusions"`
} }
// NewImageScans returns a new instance. // NewImageScans returns a new instance.
func NewImageScans() *ImageScans { func NewImageScans() ImageScans {
return &ImageScans{ return ImageScans{
Exclusions: newScanExcludes(), Exclusions: newScanExcludes(),
} }
} }
// ShouldExclude checks if scan should be excluder given ns/labels // 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 { if !i.Enable {
return false return false
} }

View File

@ -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))
})
}
}

View File

@ -14,20 +14,20 @@ type Limits map[v1.ResourceName]string
// ShellPod represents k9s shell configuration. // ShellPod represents k9s shell configuration.
type ShellPod struct { type ShellPod struct {
Image string `yaml:"image"` Image string `json:"image" yaml:"image"`
Command []string `yaml:"command,omitempty"` Command []string `json:"command,omitempty" yaml:"command,omitempty"`
Args []string `yaml:"args,omitempty"` Args []string `json:"args,omitempty" yaml:"args,omitempty"`
Namespace string `yaml:"namespace"` Namespace string `json:"namespace" yaml:"namespace"`
Limits Limits `yaml:"limits,omitempty"` Limits Limits `json:"limits,omitempty" yaml:"limits,omitempty"`
Labels map[string]string `yaml:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
ImagePullSecrets []v1.LocalObjectReference `yaml:"imagePullSecrets,omitempty"` ImagePullSecrets []v1.LocalObjectReference `json:"imagePullSecrets,omitempty" yaml:"imagePullSecrets,omitempty"`
ImagePullPolicy v1.PullPolicy `yaml:"imagePullPolicy,omitempty"` ImagePullPolicy v1.PullPolicy `json:"imagePullPolicy,omitempty" yaml:"imagePullPolicy,omitempty"`
TTY bool `yaml:"tty,omitempty"` TTY bool `json:"tty,omitempty" yaml:"tty,omitempty"`
} }
// NewShellPod returns a new instance. // NewShellPod returns a new instance.
func NewShellPod() *ShellPod { func NewShellPod() ShellPod {
return &ShellPod{ return ShellPod{
Image: defaultDockerShellImage, Image: defaultDockerShellImage,
Namespace: "default", Namespace: "default",
Limits: defaultLimits(), Limits: defaultLimits(),
@ -35,13 +35,15 @@ func NewShellPod() *ShellPod {
} }
// Validate validates the configuration. // Validate validates the configuration.
func (s *ShellPod) Validate() { func (s ShellPod) Validate() ShellPod {
if s.Image == "" { if s.Image == "" {
s.Image = defaultDockerShellImage s.Image = defaultDockerShellImage
} }
if len(s.Limits) == 0 { if len(s.Limits) == 0 {
s.Limits = defaultLimits() s.Limits = defaultLimits()
} }
return s
} }
func defaultLimits() Limits { func defaultLimits() Limits {

View File

@ -4,8 +4,11 @@
package config package config
import ( import (
"fmt"
"os" "os"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"github.com/derailed/tcell/v2" "github.com/derailed/tcell/v2"
"github.com/derailed/tview" "github.com/derailed/tview"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
@ -18,204 +21,198 @@ type StyleListener interface {
} }
type ( type (
// Color represents a color.
Color string
// Colors tracks multiple colors.
Colors []Color
// Styles tracks K9s styling options. // Styles tracks K9s styling options.
Styles struct { Styles struct {
K9s Style `yaml:"k9s"` K9s Style `json:"k9s" yaml:"k9s"`
listeners []StyleListener listeners []StyleListener
} }
// Style tracks K9s styles. // Style tracks K9s styles.
Style struct { Style struct {
Body Body `yaml:"body"` Body Body `json:"body" yaml:"body"`
Prompt Prompt `yaml:"prompt"` Prompt Prompt `json:"prompt" yaml:"prompt"`
Help Help `yaml:"help"` Help Help `json:"help" yaml:"help"`
Frame Frame `yaml:"frame"` Frame Frame `json:"frame" yaml:"frame"`
Info Info `yaml:"info"` Info Info `json:"info" yaml:"info"`
Views Views `yaml:"views"` Views Views `json:"views" yaml:"views"`
Dialog Dialog `yaml:"dialog"` Dialog Dialog `json:"dialog" yaml:"dialog"`
} }
// Prompt tracks command styles // Prompt tracks command styles
Prompt struct { Prompt struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
SuggestColor Color `yaml:"suggestColor"` SuggestColor Color `json:"" yaml:"suggestColor"`
Border PromptBorder `yaml:"border"` Border PromptBorder `json:"" yaml:"border"`
} }
// PromptBorder tracks the color of the prompt depending on its kind (e.g., command or filter) // PromptBorder tracks the color of the prompt depending on its kind (e.g., command or filter)
PromptBorder struct { PromptBorder struct {
CommandColor Color `yaml:"command"` CommandColor Color `json:"command" yaml:"command"`
DefaultColor Color `yaml:"default"` DefaultColor Color `json:"default" yaml:"default"`
} }
// Help tracks help styles. // Help tracks help styles.
Help struct { Help struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
SectionColor Color `yaml:"sectionColor"` SectionColor Color `json:"sectionColor" yaml:"sectionColor"`
KeyColor Color `yaml:"keyColor"` KeyColor Color `json:"keyColor" yaml:"keyColor"`
NumKeyColor Color `yaml:"numKeyColor"` NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"`
} }
// Body tracks body styles. // Body tracks body styles.
Body struct { Body struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
LogoColor Color `yaml:"logoColor"` LogoColor Color `json:"logoColor" yaml:"logoColor"`
LogoColorMsg Color `yaml:"logoColorMsg"` LogoColorMsg Color `json:"logoColorMsg" yaml:"logoColorMsg"`
LogoColorInfo Color `yaml:"logoColorInfo"` LogoColorInfo Color `json:"logoColorInfo" yaml:"logoColorInfo"`
LogoColorWarn Color `yaml:"logoColorWarn"` LogoColorWarn Color `json:"logoColorWarn" yaml:"logoColorWarn"`
LogoColorError Color `yaml:"logoColorError"` LogoColorError Color `json:"logoColorError" yaml:"logoColorError"`
} }
// Dialog tracks dialog styles. // Dialog tracks dialog styles.
Dialog struct { Dialog struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
ButtonFgColor Color `yaml:"buttonFgColor"` ButtonFgColor Color `json:"buttonFgColor" yaml:"buttonFgColor"`
ButtonBgColor Color `yaml:"buttonBgColor"` ButtonBgColor Color `json:"buttonBgColor" yaml:"buttonBgColor"`
ButtonFocusFgColor Color `yaml:"buttonFocusFgColor"` ButtonFocusFgColor Color `json:"buttonFocusFgColor" yaml:"buttonFocusFgColor"`
ButtonFocusBgColor Color `yaml:"buttonFocusBgColor"` ButtonFocusBgColor Color `json:"buttonFocusBgColor" yaml:"buttonFocusBgColor"`
LabelFgColor Color `yaml:"labelFgColor"` LabelFgColor Color `json:"labelFgColor" yaml:"labelFgColor"`
FieldFgColor Color `yaml:"fieldFgColor"` FieldFgColor Color `json:"fieldFgColor" yaml:"fieldFgColor"`
} }
// Frame tracks frame styles. // Frame tracks frame styles.
Frame struct { Frame struct {
Title Title `yaml:"title"` Title Title `json:"title" yaml:"title"`
Border Border `yaml:"border"` Border Border `json:"border" yaml:"border"`
Menu Menu `yaml:"menu"` Menu Menu `json:"menu" yaml:"menu"`
Crumb Crumb `yaml:"crumbs"` Crumb Crumb `json:"crumbs" yaml:"crumbs"`
Status Status `yaml:"status"` Status Status `json:"status" yaml:"status"`
} }
// Views tracks individual view styles. // Views tracks individual view styles.
Views struct { Views struct {
Table Table `yaml:"table"` Table Table `json:"table" yaml:"table"`
Xray Xray `yaml:"xray"` Xray Xray `json:"xray" yaml:"xray"`
Charts Charts `yaml:"charts"` Charts Charts `json:"charts" yaml:"charts"`
Yaml Yaml `yaml:"yaml"` Yaml Yaml `json:"yaml" yaml:"yaml"`
Picker Picker `yaml:"picker"` Picker Picker `json:"picker" yaml:"picker"`
Log Log `yaml:"logs"` Log Log `json:"logs" yaml:"logs"`
} }
// Status tracks resource status styles. // Status tracks resource status styles.
Status struct { Status struct {
NewColor Color `yaml:"newColor"` NewColor Color `json:"newColor" yaml:"newColor"`
ModifyColor Color `yaml:"modifyColor"` ModifyColor Color `json:"modifyColor" yaml:"modifyColor"`
AddColor Color `yaml:"addColor"` AddColor Color `json:"addColor" yaml:"addColor"`
PendingColor Color `yaml:"pendingColor"` PendingColor Color `json:"pendingColor" yaml:"pendingColor"`
ErrorColor Color `yaml:"errorColor"` ErrorColor Color `json:"errorColor" yaml:"errorColor"`
HighlightColor Color `yaml:"highlightColor"` HighlightColor Color `json:"highlightColor" yaml:"highlightColor"`
KillColor Color `yaml:"killColor"` KillColor Color `json:"killColor" yaml:"killColor"`
CompletedColor Color `yaml:"completedColor"` CompletedColor Color `json:"completedColor" yaml:"completedColor"`
} }
// Log tracks Log styles. // Log tracks Log styles.
Log struct { Log struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
Indicator LogIndicator `yaml:"indicator"` Indicator LogIndicator `json:"indicator" yaml:"indicator"`
} }
// Picker tracks color when selecting containers // Picker tracks color when selecting containers
Picker struct { Picker struct {
MainColor Color `yaml:"mainColor"` MainColor Color `json:"mainColor" yaml:"mainColor"`
FocusColor Color `yaml:"focusColor"` FocusColor Color `json:"focusColor" yaml:"focusColor"`
ShortcutColor Color `yaml:"shortcutColor"` ShortcutColor Color `json:"shortcutColor" yaml:"shortcutColor"`
} }
// LogIndicator tracks log view indicator. // LogIndicator tracks log view indicator.
LogIndicator struct { LogIndicator struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
ToggleOnColor Color `yaml:"toggleOnColor"` ToggleOnColor Color `json:"toggleOnColor" yaml:"toggleOnColor"`
ToggleOffColor Color `yaml:"toggleOffColor"` ToggleOffColor Color `json:"toggleOffColor" yaml:"toggleOffColor"`
} }
// Yaml tracks yaml styles. // Yaml tracks yaml styles.
Yaml struct { Yaml struct {
KeyColor Color `yaml:"keyColor"` KeyColor Color `json:"keyColor" yaml:"keyColor"`
ValueColor Color `yaml:"valueColor"` ValueColor Color `json:"valueColor" yaml:"valueColor"`
ColonColor Color `yaml:"colonColor"` ColonColor Color `json:"colonColor" yaml:"colonColor"`
} }
// Title tracks title styles. // Title tracks title styles.
Title struct { Title struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
HighlightColor Color `yaml:"highlightColor"` HighlightColor Color `json:"highlightColor" yaml:"highlightColor"`
CounterColor Color `yaml:"counterColor"` CounterColor Color `json:"counterColor" yaml:"counterColor"`
FilterColor Color `yaml:"filterColor"` FilterColor Color `json:"filterColor" yaml:"filterColor"`
} }
// Info tracks info styles. // Info tracks info styles.
Info struct { Info struct {
SectionColor Color `yaml:"sectionColor"` SectionColor Color `json:"sectionColor" yaml:"sectionColor"`
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
} }
// Border tracks border styles. // Border tracks border styles.
Border struct { Border struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
FocusColor Color `yaml:"focusColor"` FocusColor Color `json:"focusColor" yaml:"focusColor"`
} }
// Crumb tracks crumbs styles. // Crumb tracks crumbs styles.
Crumb struct { Crumb struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
ActiveColor Color `yaml:"activeColor"` ActiveColor Color `json:"activeColor" yaml:"activeColor"`
} }
// Table tracks table styles. // Table tracks table styles.
Table struct { Table struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
CursorFgColor Color `yaml:"cursorFgColor"` CursorFgColor Color `json:"cursorFgColor" yaml:"cursorFgColor"`
CursorBgColor Color `yaml:"cursorBgColor"` CursorBgColor Color `json:"cursorBgColor" yaml:"cursorBgColor"`
MarkColor Color `yaml:"markColor"` MarkColor Color `json:"markColor" yaml:"markColor"`
Header TableHeader `yaml:"header"` Header TableHeader `json:"header" yaml:"header"`
} }
// TableHeader tracks table header styles. // TableHeader tracks table header styles.
TableHeader struct { TableHeader struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
SorterColor Color `yaml:"sorterColor"` SorterColor Color `json:"sorterColor" yaml:"sorterColor"`
} }
// Xray tracks xray styles. // Xray tracks xray styles.
Xray struct { Xray struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
CursorColor Color `yaml:"cursorColor"` CursorColor Color `json:"cursorColor" yaml:"cursorColor"`
CursorTextColor Color `yaml:"cursorTextColor"` CursorTextColor Color `json:"cursorTextColor" yaml:"cursorTextColor"`
GraphicColor Color `yaml:"graphicColor"` GraphicColor Color `json:"graphicColor" yaml:"graphicColor"`
} }
// Menu tracks menu styles. // Menu tracks menu styles.
Menu struct { Menu struct {
FgColor Color `yaml:"fgColor"` FgColor Color `json:"fgColor" yaml:"fgColor"`
KeyColor Color `yaml:"keyColor"` KeyColor Color `json:"keyColor" yaml:"keyColor"`
NumKeyColor Color `yaml:"numKeyColor"` NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"`
} }
// Charts tracks charts styles. // Charts tracks charts styles.
Charts struct { Charts struct {
BgColor Color `yaml:"bgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"`
DialBgColor Color `yaml:"dialBgColor"` DialBgColor Color `json:"dialBgColor" yaml:"dialBgColor"`
ChartBgColor Color `yaml:"chartBgColor"` ChartBgColor Color `json:"chartBgColor" yaml:"chartBgColor"`
DefaultDialColors Colors `yaml:"defaultDialColors"` DefaultDialColors Colors `json:"defaultDialColors" yaml:"defaultDialColors"`
DefaultChartColors Colors `yaml:"defaultChartColors"` DefaultChartColors Colors `json:"defaultChartColors" yaml:"defaultChartColors"`
ResourceColors map[string]Colors `yaml:"resourceColors"` ResourceColors map[string]Colors `json:"resourceColors" yaml:"resourceColors"`
} }
) )
@ -442,7 +439,9 @@ func NewStyles() *Styles {
// Reset resets styles. // Reset resets styles.
func (s *Styles) Reset() { func (s *Styles) Reset() {
s.K9s = newStyle() if err := yaml.Unmarshal(stockSkinTpl, s); err != nil {
s.K9s = newStyle()
}
} }
// FgColor returns the foreground color. // FgColor returns the foreground color.
@ -533,11 +532,14 @@ func (s *Styles) Views() Views {
// Load K9s configuration from file. // Load K9s configuration from file.
func (s *Styles) Load(path string) error { func (s *Styles) Load(path string) error {
f, err := os.ReadFile(path) bb, err := os.ReadFile(path)
if err != nil { if err != nil {
return err 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 return err
} }
@ -561,3 +563,9 @@ func (s *Styles) Update() {
s.fireStylesChanged() s.fireStylesChanged()
} }
// Dump for debug.
func (s *Styles) Dump() {
bb, _ := yaml.Marshal(s)
fmt.Println(string(bb))
}

View File

@ -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)
}

View File

@ -12,6 +12,14 @@ import (
"github.com/stretchr/testify/assert" "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) { func TestColor(t *testing.T) {
uu := map[string]tcell.Color{ uu := map[string]tcell.Color{
"blah": tcell.ColorDefault, "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() s := config.NewStyles()
assert.Nil(t, s.Load("testdata/empty_skin.yaml")) assert.Nil(t, s.Load("../../skins/black-and-wtf.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"))
s.Update() s.Update()
assert.Equal(t, "#ffffff", s.Body().FgColor.String()) 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) assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor)
} }
func TestSkinNotExits(t *testing.T) { func TestSkinLoad(t *testing.T) {
s := config.NewStyles() uu := map[string]struct {
assert.NotNil(t, s.Load("testdata/blee.yaml")) 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) { for k := range uu {
s := config.NewStyles() u := uu[k]
assert.NotNil(t, s.Load("testdata/skin_boarked.yaml")) 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)
})
}
} }

View File

@ -1,9 +1,9 @@
aliases: aliases:
dp: apps/v1/deployments dp: deployments
sec: v1/secrets sec: v1/secrets
jo: batch/v1/jobs jo: jobs
cr: rbac.authorization.k8s.io/v1/clusterroles cr: clusterroles
crb: rbac.authorization.k8s.io/v1/clusterrolebindings crb: clusterrolebindings
ro: rbac.authorization.k8s.io/v1/roles ro: roles
rb: rbac.authorization.k8s.io/v1/rolebindings rb: rolebindings
np: networking.k8s.io/v1/networkpolicies np: networkpolicies

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,8 +0,0 @@
k9s:
refreshRate: 10
namespace:
active: fred
favorites:
- blee
- duh
- crap

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -5,18 +5,27 @@ clusters:
certificate-authority: /Users/test/ca.crt certificate-authority: /Users/test/ca.crt
server: https://1.2.3.4:8443 server: https://1.2.3.4:8443
name: cl-1 name: cl-1
- cluster:
certificate-authority: /Users/test/ca.crt
server: https://5.6.7.8:8443
name: cl-2
contexts: contexts:
- context: - context:
cluster: cl-1 cluster: cl-1
user: user1 user: user1
namespace: ns-1 namespace: ns-1
name: ct-1 name: ct-1-1
- context: - context:
cluster: cl-1 cluster: cl-1
user: user2 user: user2
namespace: ns-2 namespace: ns-2
name: ct-2 name: ct-1-2
current-context: ct-1 - context:
cluster: cl-2
user: user2
namespace: ns-2
name: ct-2-1
current-context: ct-1-1
preferences: {} preferences: {}
users: users:
- name: user1 - name: user1

View File

@ -31,11 +31,12 @@ k9s:
highlightColor: navajowhite highlightColor: navajowhite
counterColor: navajowhite counterColor: navajowhite
filterColor: slategray filterColor: slategray
table: views:
fgColor: white table:
bgColor: black fgColor: white
cursorColor: white
header:
fgColor: darkgray
bgColor: black bgColor: black
sorterColor: white cursorColor: white
header:
fgColor: darkgray
bgColor: black
sorterColor: white

View File

@ -0,0 +1,2 @@
k9s:
body:

View File

@ -1,8 +0,0 @@
k9s:
views:
v1/pods:
columns:
- NAMESPACE
- NAME
- AGE
- IP

View File

@ -0,0 +1,7 @@
views:
v1/pods:
columns:
- NAMESPACE
- NAME
- AGE
- IP

View File

@ -61,7 +61,7 @@ func NewThreshold() Threshold {
} }
// Validate a namespace is setup correctly. // Validate a namespace is setup correctly.
func (t Threshold) Validate() { func (t Threshold) Validate() Threshold {
for _, k := range []string{"cpu", "memory"} { for _, k := range []string{"cpu", "memory"} {
v, ok := t[k] v, ok := t[k]
if !ok { if !ok {
@ -70,6 +70,8 @@ func (t Threshold) Validate() {
v.Validate() v.Validate()
} }
} }
return t
} }
// LevelFor returns a defcon level for the current state. // LevelFor returns a defcon level for the current state.

View File

@ -11,21 +11,24 @@ const (
// UI tracks ui specific configs. // UI tracks ui specific configs.
type UI struct { type UI struct {
// EnableMouse toggles mouse support. // EnableMouse toggles mouse support.
EnableMouse bool `yaml:"enableMouse"` EnableMouse bool `json:"enableMouse" yaml:"enableMouse"`
// Headless toggles top header display. // Headless toggles top header display.
Headless bool `yaml:"headless"` Headless bool `json:"headless" yaml:"headless"`
// LogoLess toggles k9s logo. // LogoLess toggles k9s logo.
Logoless bool `yaml:"logoless"` Logoless bool `json:"logoless" yaml:"logoless"`
// Crumbsless toggles nav crumb display. // 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 toggles icons display.
NoIcons bool `yaml:"noIcons"` NoIcons bool `json:"noIcons" yaml:"noIcons"`
// Skin reference the general k9s skin name. // Skin reference the general k9s skin name.
// Can be overridden per context. // Can be overridden per context.
Skin string `yaml:"skin,omitempty"` Skin string `json:"skin" yaml:"skin,omitempty"`
} }

View File

@ -4,8 +4,12 @@
package config package config
import ( import (
"fmt"
"os" "os"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
) )
@ -21,51 +25,41 @@ type ViewSetting struct {
SortColumn string `yaml:"sortColumn"` 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. // CustomView represents a collection of view customization.
type CustomView struct { type CustomView struct {
K9s ViewSettings `yaml:"k9s"` Views map[string]ViewSetting `yaml:"views"`
listeners map[string]ViewConfigListener listeners map[string]ViewConfigListener
} }
// NewCustomView returns a views configuration. // NewCustomView returns a views configuration.
func NewCustomView() *CustomView { func NewCustomView() *CustomView {
return &CustomView{ return &CustomView{
K9s: NewViewSettings(), Views: make(map[string]ViewSetting),
listeners: make(map[string]ViewConfigListener), listeners: make(map[string]ViewConfigListener),
} }
} }
// Reset clears out configurations. // Reset clears out configurations.
func (v *CustomView) Reset() { func (v *CustomView) Reset() {
for k := range v.K9s.Views { for k := range v.Views {
delete(v.K9s.Views, k) delete(v.Views, k)
} }
} }
// Load loads view configurations. // Load loads view configurations.
func (v *CustomView) Load(path string) error { func (v *CustomView) Load(path string) error {
raw, err := os.ReadFile(path) bb, err := os.ReadFile(path)
if err != nil { if err != nil {
return err 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 var in CustomView
if err := yaml.Unmarshal(raw, &in); err != nil { if err := yaml.Unmarshal(bb, &in); err != nil {
return err return err
} }
v.K9s = in.K9s v.Views = in.Views
v.fireConfigChanged() v.fireConfigChanged()
return nil return nil
@ -84,7 +78,7 @@ func (v *CustomView) RemoveListener(gvr string) {
func (v *CustomView) fireConfigChanged() { func (v *CustomView) fireConfigChanged() {
for gvr, list := range v.listeners { for gvr, list := range v.listeners {
if v, ok := v.K9s.Views[gvr]; ok { if v, ok := v.Views[gvr]; ok {
list.ViewSettingsChanged(v) list.ViewSettingsChanged(v)
} else { } else {
list.ViewSettingsChanged(ViewSetting{}) list.ViewSettingsChanged(ViewSetting{})

View File

@ -13,7 +13,7 @@ import (
func TestViewSettingsLoad(t *testing.T) { func TestViewSettingsLoad(t *testing.T) {
cfg := config.NewCustomView() cfg := config.NewCustomView()
assert.Nil(t, cfg.Load("testdata/view_settings.yaml")) assert.Nil(t, cfg.Load("testdata/views/views.yaml"))
assert.Equal(t, 1, len(cfg.K9s.Views)) assert.Equal(t, 1, len(cfg.Views))
assert.Equal(t, 4, len(cfg.K9s.Views["v1/pods"].Columns)) assert.Equal(t, 4, len(cfg.Views["v1/pods"].Columns))
} }

View File

@ -68,9 +68,9 @@ func (p *Popeye) List(ctx context.Context, ns string) ([]runtime.Object, error)
flags.Sections = &sections flags.Sections = &sections
flags.ActiveNamespace = &ns 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 { 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 { if _, err := os.Stat(spinach); err == nil {
flags.Spinach = &spinach flags.Spinach = &spinach

View File

@ -9,8 +9,11 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/dao"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -72,24 +75,25 @@ func (c ClusterMeta) Deltas(n ClusterMeta) bool {
// ClusterInfo models cluster metadata. // ClusterInfo models cluster metadata.
type ClusterInfo struct { type ClusterInfo struct {
cluster *Cluster cluster *Cluster
factory dao.Factory factory dao.Factory
data ClusterMeta data ClusterMeta
version string version string
skipLatestRevCheck bool cfg *config.K9s
listeners []ClusterInfoListener listeners []ClusterInfoListener
cache *cache.LRUExpireCache cache *cache.LRUExpireCache
mx sync.RWMutex
} }
// NewClusterInfo returns a new instance. // 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{ c := ClusterInfo{
factory: f, factory: f,
cluster: NewCluster(f), cluster: NewCluster(f),
data: NewClusterMeta(), data: NewClusterMeta(),
version: v, version: v,
skipLatestRevCheck: skipLatestRevCheck, cfg: cfg,
cache: cache.NewLRUExpireCache(cacheSize), cache: cache.NewLRUExpireCache(cacheSize),
} }
return &c return &c
@ -113,7 +117,16 @@ func (c *ClusterInfo) fetchK9sLatestRev() string {
// Reset resets context and reload. // Reset resets context and reload.
func (c *ClusterInfo) Reset(f dao.Factory) { 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() c.Refresh()
} }
@ -138,7 +151,7 @@ func (c *ClusterInfo) Refresh() {
v1 := NewSemVer(data.K9sVer) v1 := NewSemVer(data.K9sVer)
var latestRev string var latestRev string
if !c.skipLatestRevCheck { if !c.cfg.SkipLatestRevCheck {
latestRev = c.fetchK9sLatestRev() latestRev = c.fetchK9sLatestRev()
} }
v2 := NewSemVer(latestRev) v2 := NewSemVer(latestRev)
@ -153,7 +166,11 @@ func (c *ClusterInfo) Refresh() {
} else { } else {
c.fireNoMetaChanged(data) c.fireNoMetaChanged(data)
} }
c.data = data c.mx.Lock()
{
c.data = data
}
c.mx.Unlock()
} }
// AddListener adds a new model listener. // AddListener adds a new model listener.

View File

@ -108,7 +108,7 @@ func (l *Log) SetSinceSeconds(ctx context.Context, i int64) {
} }
// Configure sets logger configuration. // 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.Lines = int64(opts.TailCount)
l.logOptions.SinceSeconds = opts.SinceSeconds l.logOptions.SinceSeconds = opts.SinceSeconds
} }

View File

@ -15,12 +15,19 @@ type (
// ActionHandler handles a keyboard command. // ActionHandler handles a keyboard command.
ActionHandler func(*tcell.EventKey) *tcell.EventKey ActionHandler func(*tcell.EventKey) *tcell.EventKey
ActionOpts struct {
Visible bool
Shared bool
Plugin bool
HotKey bool
Dangerous bool
}
// KeyAction represents a keyboard action. // KeyAction represents a keyboard action.
KeyAction struct { KeyAction struct {
Description string Description string
Action ActionHandler Action ActionHandler
Visible bool Opts ActionOpts
Shared bool
} }
// KeyActions tracks mappings between keystrokes and actions. // KeyActions tracks mappings between keystrokes and actions.
@ -28,13 +35,32 @@ type (
) )
// NewKeyAction returns a new keyboard action. // NewKeyAction returns a new keyboard action.
func NewKeyAction(d string, a ActionHandler, display bool) KeyAction { func NewKeyAction(d string, a ActionHandler, visible bool) KeyAction {
return KeyAction{Description: d, Action: a, Visible: display} return NewKeyActionWithOpts(d, a, ActionOpts{
Visible: visible,
})
} }
// NewSharedKeyAction returns a new shared keyboard action. // NewSharedKeyAction returns a new shared keyboard action.
func NewSharedKeyAction(d string, a ActionHandler, display bool) KeyAction { func NewSharedKeyAction(d string, a ActionHandler, visible bool) KeyAction {
return KeyAction{Description: d, Action: a, Visible: display, Shared: true} 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. // 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. // Set replace actions with new ones.
func (a KeyActions) Set(aa KeyActions) { func (a KeyActions) Set(aa KeyActions) {
for k, v := range aa { for k, v := range aa {
@ -69,7 +104,7 @@ func (a KeyActions) Delete(kk ...tcell.Key) {
func (a KeyActions) Hints() model.MenuHints { func (a KeyActions) Hints() model.MenuHints {
kk := make([]int, 0, len(a)) kk := make([]int, 0, len(a))
for k := range a { for k := range a {
if !a[k].Shared { if !a[k].Opts.Shared {
kk = append(kk, int(k)) kk = append(kk, int(k))
} }
} }
@ -82,7 +117,7 @@ func (a KeyActions) Hints() model.MenuHints {
model.MenuHint{ model.MenuHint{
Mnemonic: name, Mnemonic: name,
Description: a[tcell.Key(k)].Description, Description: a[tcell.Key(k)].Description,
Visible: a[tcell.Key(k)].Visible, Visible: a[tcell.Key(k)].Opts.Visible,
}, },
) )
} else { } else {

View File

@ -34,12 +34,11 @@ func NewApp(cfg *config.Config, context string) *App {
a := App{ a := App{
Application: tview.NewApplication(), Application: tview.NewApplication(),
actions: make(KeyActions), actions: make(KeyActions),
Configurator: Configurator{Config: cfg}, Configurator: Configurator{Config: cfg, Styles: config.NewStyles()},
Main: NewPages(), Main: NewPages(),
flash: model.NewFlash(model.DefaultFlashDelay), flash: model.NewFlash(model.DefaultFlashDelay),
cmdBuff: model.NewFishBuff(':', model.CommandBuffer), cmdBuff: model.NewFishBuff(':', model.CommandBuffer),
} }
a.ReloadStyles()
a.views = map[string]tview.Primitive{ a.views = map[string]tview.Primitive{
"menu": NewMenu(a.Styles), "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. // Conn returns an api server connection.
func (a *App) Conn() client.Connection { func (a *App) Conn() client.Connection {
return a.Config.GetConnection() return a.Config.GetConnection()

View File

@ -9,6 +9,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/render"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@ -17,6 +19,8 @@ import (
// Synchronizer manages ui event queue. // Synchronizer manages ui event queue.
type synchronizer interface { type synchronizer interface {
Flash() *model.Flash
UpdateClusterInfo()
QueueUpdateDraw(func()) QueueUpdateDraw(func())
QueueUpdate(func()) QueueUpdate(func())
} }
@ -46,7 +50,7 @@ func (c *Configurator) CustomViewsWatcher(ctx context.Context, s synchronizer) e
for { for {
select { select {
case evt := <-w.Events: case evt := <-w.Events:
if evt.Name == config.AppViewsFile { if evt.Name == config.AppViewsFile && evt.Op != fsnotify.Chmod {
s.QueueUpdateDraw(func() { s.QueueUpdateDraw(func() {
if err := c.RefreshCustomViews(); err != nil { if err := c.RefreshCustomViews(); err != nil {
log.Warn().Err(err).Msgf("Custom views refresh failed") 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 err
} }
return w.Add(config.AppViewsFile)
return c.RefreshCustomViews()
} }
// RefreshCustomViews load view configuration changes. // RefreshCustomViews load view configuration changes.
@ -85,15 +90,13 @@ func (c *Configurator) RefreshCustomViews() error {
// SkinsDirWatcher watches for skin directory file changes. // SkinsDirWatcher watches for skin directory file changes.
func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) error { func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) error {
if !c.HasSkin() { if _, err := os.Stat(config.AppSkinsDir); os.IsNotExist(err) {
return nil return err
} }
w, err := fsnotify.NewWatcher() w, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return err return err
} }
go func() { go func() {
for { for {
select { select {
@ -101,7 +104,7 @@ func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) erro
if evt.Name == c.skinFile && evt.Op != fsnotify.Chmod { if evt.Name == c.skinFile && evt.Op != fsnotify.Chmod {
log.Debug().Msgf("Skin changed: %s", c.skinFile) log.Debug().Msgf("Skin changed: %s", c.skinFile)
s.QueueUpdateDraw(func() { s.QueueUpdateDraw(func() {
c.RefreshStyles() c.RefreshStyles(s)
}) })
} }
case err := <-w.Errors: case err := <-w.Errors:
@ -133,18 +136,20 @@ func (c *Configurator) ConfigWatcher(ctx context.Context, s synchronizer) error
select { select {
case evt := <-w.Events: case evt := <-w.Events:
if evt.Has(fsnotify.Create) || evt.Has(fsnotify.Write) { 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 evt.Name == config.AppConfigFile {
if err := c.Config.Load(evt.Name); err != nil { 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 { } else {
if err := c.Config.K9s.Reload(); err != nil { 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() { s.QueueUpdateDraw(func() {
c.RefreshStyles() c.RefreshStyles(s)
}) })
} }
case err := <-w.Errors: case err := <-w.Errors:
@ -181,11 +186,18 @@ func (c *Configurator) activeSkin() (string, bool) {
return skin, false return skin, false
} }
if ct, err := c.Config.K9s.ActiveContext(); err == nil { if ct, err := c.Config.K9s.ActiveContext(); err == nil && ct.Skin != "" {
skin = 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 != "" return skin, skin != ""
@ -208,46 +220,53 @@ func (c *Configurator) activeConfig() (cluster string, context string, ok bool)
} }
// RefreshStyles load for skin configuration changes. // RefreshStyles load for skin configuration changes.
func (c *Configurator) RefreshStyles() { func (c *Configurator) RefreshStyles(s synchronizer) {
s.UpdateClusterInfo()
if c.Styles == nil { if c.Styles == nil {
c.Styles = config.NewStyles() c.Styles = config.NewStyles()
} }
defer c.loadSkinFile(s)
cl, ct, ok := c.activeConfig() cl, ct, ok := c.activeConfig()
if !ok { if !ok {
log.Debug().Msgf("No custom skin found. Using stock skin")
c.updateStyles("")
return return
} }
// !!BOZO!! Lame move out!
if bc, err := config.EnsureBenchmarksCfgFile(cl, ct); err != nil { if bc, err := config.EnsureBenchmarksCfgFile(cl, ct); err != nil {
log.Warn().Err(err).Msgf("No benchmark config file found: %q@%q", cl, ct) log.Warn().Err(err).Msgf("No benchmark config file found: %q@%q", cl, ct)
} else { } else {
c.BenchFile = bc c.BenchFile = bc
} }
}
func (c *Configurator) loadSkinFile(s synchronizer) {
skin, ok := c.activeSkin() skin, ok := c.activeSkin()
if !ok { if !ok {
log.Debug().Msgf("No custom skin found. Using stock skin") log.Debug().Msgf("No custom skin found. Using stock skin")
c.updateStyles("") c.updateStyles("")
return return
} }
skinFile := config.SkinFileFromName(skin) skinFile := config.SkinFileFromName(skin)
log.Debug().Msgf("Loading skin file: %q", skinFile)
if err := c.Styles.Load(skinFile); err != nil { if err := c.Styles.Load(skinFile); err != nil {
if errors.Is(err, os.ErrNotExist) { 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 { } 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("") c.updateStyles("")
} else { } else {
log.Debug().Msgf("Loading skin file: %q", skinFile) s.Flash().Infof("Skin file loaded: %q", skinFile)
c.updateStyles(skinFile) c.updateStyles(skinFile)
} }
} }
func (c *Configurator) updateStyles(f string) { func (c *Configurator) updateStyles(f string) {
c.skinFile = f c.skinFile = f
if f == "" {
c.Styles.Reset()
}
c.Styles.Update() c.Styles.Update()
render.ModColor = c.Styles.Frame().Status.ModifyColor.Color() render.ModColor = c.Styles.Frame().Status.ModifyColor.Color()

Some files were not shown because too many files have changed in this diff Show More