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 '....'
4. See error
**Historical Documents**
When applicable please include any supporting artifacts: k9s debug logs, configurations, resource manifests, ...
**Expected behavior**
A clear and concise description of what you expected to happen.

View File

@ -11,7 +11,7 @@ DATE ?= $(shell TZ=UTC date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:
else
DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ")
endif
VERSION ?= v0.30.8
VERSION ?= v0.31.0
IMG_NAME := derailed/k9s
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
```
* In order to issue manifest edit commands make sure your EDITOR env is set.
* In order to issue resource edit commands make sure your EDITOR and KUBE_EDITOR env vars are set.
```shell
# Kubectl edit command will use this env var.
export EDITOR=my_fav_editor
# Should your editor deal with streamed vs on disk files differently, also set...
export K9S_EDITOR=my_fav_editor
export KUBE_EDITOR=my_fav_editor
```
* K9s prefers recent kubernetes versions ie 1.28+
@ -607,24 +605,23 @@ Here is a sample views configuration that customize a pods and services views.
```yaml
# $XDG_CONFIG_HOME/k9s/views.yaml
k9s:
views:
v1/pods:
columns:
- AGE
- NAMESPACE
- NAME
- IP
- NODE
- STATUS
- READY
v1/services:
columns:
- AGE
- NAMESPACE
- NAME
- TYPE
- CLUSTER-IP
views:
v1/pods:
columns:
- AGE
- NAMESPACE
- NAME
- IP
- NODE
- STATUS
- READY
v1/services:
columns:
- AGE
- NAMESPACE
- NAME
- TYPE
- CLUSTER-IP
```
---
@ -897,6 +894,7 @@ k9s:
You can also specify a default skin for all contexts in the root k9s config file as so:
```yaml
# $XDG_CONFIG_HOME/k9s/config.yaml
k9s:
liveViewAutoRefresh: false
screenDumpDir: /tmp/dumps
@ -910,6 +908,8 @@ k9s:
logoless: false
crumbsless: false
noIcons: false
# Toggles reactive UI. This option provide for watching on disk artifacts changes and update the UI live Defaults to false.
reactive: false
# By default all contexts wil use the dracula skin unless explicitly overridden in the context config file.
skin: dracula # => assumes the file skins/dracula.yaml is present in the $XDG_DATA_HOME/k9s/skins directory
skipLatestRevCheck: false
@ -929,7 +929,7 @@ k9s:
tail: 100
buffer: 5000
sinceSeconds: -1
fullScreenLogs: false
fullScreen: false
textWrap: false
showTime: false
thresholds:
@ -942,7 +942,7 @@ k9s:
```
```yaml
# $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml
# $XDG_DATA_HOME/k9s/skins/in_the_navy.yaml
# Skin InTheNavy!
k9s:
# General K9s styles

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)
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()
if err != nil {
log.Error().Err(err).Msgf("load configuration failed")
return err
}
app := view.NewApp(cfg)
if err := app.Init(version, *k9sFlags.RefreshRate); err != nil {
@ -115,11 +115,12 @@ func loadConfiguration() (*config.Config, error) {
k8sCfg := client.NewConfig(k8sFlags)
k9sCfg := config.NewConfig(k8sCfg)
if err := k9sCfg.Load(config.AppConfigFile); err != nil {
log.Warn().Msg("Unable to locate K9s config. Generating new configuration...")
return nil, err
}
k9sCfg.K9s.Override(k9sFlags)
if err := k9sCfg.Refine(k8sFlags, k9sFlags, k8sCfg); err != nil {
log.Error().Err(err).Msgf("refine failed")
log.Error().Err(err).Msgf("config refine failed")
return nil, err
}
conn, err := client.InitConnection(k8sCfg)
k9sCfg.SetConnection(conn)

4
go.mod
View File

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

View File

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

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 {
if !m.HasMetrics() {
return errors.New("No metrics-server detected on cluster")
return errors.New("no metrics-server detected on cluster")
}
auth, err := m.CanI(ns, gvr, ListAccess)
@ -193,7 +193,7 @@ func (m *MetricsServer) FetchNodeMetrics(ctx context.Context, n string) (*mv1bet
mx, ok := mmx[n]
if !ok {
return nil, fmt.Errorf("Unable to retrieve node metrics for %q", n)
return nil, fmt.Errorf("unable to retrieve node metrics for %q", n)
}
return mx, nil
}
@ -283,7 +283,7 @@ func (m *MetricsServer) FetchPodMetrics(ctx context.Context, fqn string) (*mv1be
}
pmx, ok := mmx[fqn]
if !ok {
return nil, fmt.Errorf("Unable to locate pod metrics for pod %q", fqn)
return nil, fmt.Errorf("unable to locate pod metrics for pod %q", fqn)
}
return pmx, nil

View File

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

View File

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

View File

@ -4,25 +4,59 @@
package config_test
import (
"fmt"
"os"
"slices"
"testing"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/config/data"
"github.com/stretchr/testify/assert"
)
func TestAliasClear(t *testing.T) {
a := testAliases()
a.Clear()
assert.Equal(t, 0, len(a.Keys()))
}
func TestAliasKeys(t *testing.T) {
a := testAliases()
kk := a.Keys()
slices.Sort(kk)
assert.Equal(t, []string{"a1", "a11", "a2", "a3"}, kk)
}
func TestAliasShortNames(t *testing.T) {
a := testAliases()
ess := config.ShortNames{
"gvr1": []string{"a1", "a11"},
"gvr2": []string{"a2"},
"gvr3": []string{"a3"},
}
ss := a.ShortNames()
assert.Equal(t, len(ess), len(ss))
for k, v := range ss {
v1, ok := ess[k]
assert.True(t, ok, fmt.Sprintf("missing: %q", k))
slices.Sort(v)
assert.Equal(t, v1, v)
}
}
func TestAliasDefine(t *testing.T) {
type aliasDef struct {
cmd string
aliases []string
}
uu := []struct {
name string
uu := map[string]struct {
aliases []aliasDef
registeredCommands map[string]string
}{
{
name: "simple aliases",
"simple": {
aliases: []aliasDef{
{
cmd: "one",
@ -34,8 +68,7 @@ func TestAliasDefine(t *testing.T) {
"duh": "one",
},
},
{
name: "duplicated aliases",
"duplicates": {
aliases: []aliasDef{
{
cmd: "one",
@ -54,9 +87,9 @@ func TestAliasDefine(t *testing.T) {
},
}
for i := range uu {
u := uu[i]
t.Run(u.name, func(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
configAlias := config.NewAliases()
for _, aliases := range u.aliases {
for _, a := range aliases.aliases {
@ -73,18 +106,35 @@ func TestAliasDefine(t *testing.T) {
}
func TestAliasesLoad(t *testing.T) {
config.AppConfigDir = "testdata/aliases"
a := config.NewAliases()
assert.Nil(t, a.LoadFile("testdata/alias.yaml"))
assert.Equal(t, 2, len(a.Alias))
assert.Nil(t, a.Load("testdata/aliases/plain.yaml"))
assert.Equal(t, 56, len(a.Alias))
}
func TestAliasesSave(t *testing.T) {
a := config.NewAliases()
a.Alias["test"] = "fred"
a.Alias["blee"] = "duh"
assert.NoError(t, data.EnsureFullPath("/tmp/test-aliases", data.DefaultDirMod))
defer assert.NoError(t, os.RemoveAll("/tmp/test-aliases"))
assert.Nil(t, a.SaveAliases("/tmp/a.yaml"))
assert.Nil(t, a.LoadFile("/tmp/a.yaml"))
assert.Equal(t, 2, len(a.Alias))
config.AppAliasesFile = "/tmp/test-aliases/aliases.yaml"
a := testAliases()
c := len(a.Alias)
assert.Equal(t, c, len(a.Alias))
assert.Nil(t, a.Save())
assert.Nil(t, a.LoadFile("/tmp/test-aliases/aliases.yaml"))
assert.Equal(t, c, len(a.Alias))
}
// Helpers...
func testAliases() *config.Aliases {
a := config.NewAliases()
a.Alias["a1"] = "gvr1"
a.Alias["a11"] = "gvr1"
a.Alias["a2"] = "gvr2"
a.Alias["a3"] = "gvr3"
return a
}

View File

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

View File

@ -17,6 +17,22 @@ const (
TransparentColor Color = "-"
)
// Colors tracks multiple colors.
type Colors []Color
// Colors converts series string colors to colors.
func (c Colors) Colors() []tcell.Color {
cc := make([]tcell.Color, 0, len(c))
for _, color := range c {
cc = append(cc, color.Color())
}
return cc
}
// Color represents a color.
type Color string
// NewColor returns a new color.
func NewColor(c string) Color {
return Color(c)
@ -50,12 +66,3 @@ func (c Color) Color() tcell.Color {
return tcell.GetColor(string(c)).TrueColor()
}
// Colors converts series string colors to colors.
func (c Colors) Colors() []tcell.Color {
cc := make([]tcell.Color, 0, len(c))
for _, color := range c {
cc = append(cc, color.Color())
}
return cc
}

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 (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"k8s.io/cli-runtime/pkg/genericclioptions"
@ -19,25 +17,11 @@ import (
// Config tracks K9s configuration options.
type Config struct {
K9s *K9s `yaml:"k9s"`
K9s *K9s `yaml:"k9s" json:"k9s"`
conn client.Connection
settings data.KubeSettings
}
// K9sHome returns k9s configs home directory.
func K9sHome() string {
if isEnvSet(K9sEnvConfigDir) {
return os.Getenv(K9sEnvConfigDir)
}
xdgK9sHome, err := xdg.ConfigFile(AppName)
if err != nil {
log.Fatal().Err(err).Msg("Unable to create configuration directory for k9s")
}
return xdgK9sHome
}
// NewConfig creates a new default config.
func NewConfig(ks data.KubeSettings) *Config {
return &Config{
@ -46,6 +30,16 @@ func NewConfig(ks data.KubeSettings) *Config {
}
}
// ContextHotKeysPath returns a context specific hotkeys file spec.
func (c *Config) ContextHotkeysPath() string {
ct, err := c.K9s.ActiveContext()
if err != nil {
return ""
}
return AppContextHotkeysFile(ct.ClusterName, c.K9s.activeContextName)
}
// ContextAliasesPath returns a context specific aliases file spec.
func (c *Config) ContextAliasesPath() string {
ct, err := c.K9s.ActiveContext()
@ -53,13 +47,14 @@ func (c *Config) ContextAliasesPath() string {
return ""
}
return AppContextAliasesFile(ct.ClusterName, c.K9s.activeContextName)
return AppContextAliasesFile(ct.GetClusterName(), c.K9s.activeContextName)
}
// ContextPluginsPath returns a context specific plugins file spec.
func (c *Config) ContextPluginsPath() string {
ct, err := c.K9s.ActiveContext()
if err != nil {
log.Error().Err(err).Msgf("active context load failed")
return ""
}
@ -68,7 +63,10 @@ func (c *Config) ContextPluginsPath() string {
// Refine the configuration based on cli args.
func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, cfg *client.Config) error {
if isSet(flags.Context) {
if flags == nil {
return nil
}
if isStringSet(flags.Context) {
if _, err := c.K9s.ActivateContext(*flags.Context); err != nil {
return err
}
@ -88,7 +86,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c
switch {
case k9sFlags != nil && IsBoolSet(k9sFlags.AllNamespaces):
ns = client.NamespaceAll
case isSet(flags.Namespace):
case isStringSet(flags.Namespace):
ns = *flags.Namespace
default:
nss, err := c.K9s.ActiveContextNamespace()
@ -104,7 +102,7 @@ func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, c
return err
}
return data.EnsureDirPath(c.K9s.GetScreenDumpDir(), data.DefaultDirMod)
return data.EnsureDirPath(c.K9s.AppScreenDumpDir(), data.DefaultDirMod)
}
// Reset resets the context to the new current context/cluster.
@ -115,7 +113,7 @@ func (c *Config) Reset() {
func (c *Config) SetCurrentContext(n string) (*data.Context, error) {
ct, err := c.K9s.ActivateContext(n)
if err != nil {
return nil, fmt.Errorf("set current context %q failed: %w", n, err)
return nil, fmt.Errorf("set current context failed. %w", err)
}
return ct, nil
@ -138,21 +136,13 @@ func (c *Config) ActiveNamespace() string {
return ns
}
// ValidateFavorites ensure favorite ns are legit.
func (c *Config) ValidateFavorites() {
ct, err := c.K9s.ActiveContext()
if err != nil {
return
}
ct.Validate(c.conn, c.settings)
}
// FavNamespaces returns fav namespaces in the current context.
func (c *Config) FavNamespaces() []string {
ct, err := c.K9s.ActiveContext()
if err != nil {
return nil
}
ct.Validate(c.conn, c.settings)
return ct.Namespace.Favorites
}
@ -209,23 +199,26 @@ func (c *Config) ActiveContextName() string {
return c.K9s.activeContextName
}
func (c *Config) Merge(c1 *Config) {
c.K9s.Merge(c1.K9s)
}
// Load loads K9s configuration from file.
func (c *Config) Load(path string) error {
f, err := os.ReadFile(path)
bb, err := os.ReadFile(path)
if err != nil {
return err
}
if err := data.JSONValidator.Validate(json.K9sSchema, bb); err != nil {
return fmt.Errorf("k9s config file %q load failed:\n%w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(f, &cfg); err != nil {
if err := yaml.Unmarshal(bb, &cfg); err != nil {
return err
}
if cfg.K9s != nil {
c.K9s.Refine(cfg.K9s)
}
if c.K9s.Logger == nil {
c.K9s.Logger = NewLogger()
}
c.Merge(&cfg)
return nil
}
@ -235,7 +228,11 @@ func (c *Config) Save() error {
if err := c.K9s.Save(); err != nil {
return err
}
return c.SaveFile(AppConfigFile)
if _, err := os.Stat(AppConfigFile); os.IsNotExist(err) {
return c.SaveFile(AppConfigFile)
}
return nil
}
// SaveFile K9s configuration to disk.
@ -248,48 +245,26 @@ func (c *Config) SaveFile(path string) error {
log.Error().Msgf("[Config] Unable to save K9s config file: %v", err)
return err
}
return os.WriteFile(path, cfg, 0644)
}
// Validate the configuration.
func (c *Config) Validate() {
if c.K9s == nil {
c.K9s = NewK9s(c.conn, c.settings)
}
c.K9s.Validate(c.conn, c.settings)
}
// Dump debug...
// Dump for debug...
func (c *Config) Dump(msg string) {
ct, err := c.K9s.ActiveContext()
if err != nil {
log.Debug().Msgf("Current Contexts: %s\n", ct.ClusterName)
if err == nil {
bb, _ := yaml.Marshal(ct)
fmt.Printf("Dump: %q\n%s\n", msg, string(bb))
} else {
fmt.Println("BOOM!", err)
}
}
// YamlExtension tries to find the correct extension for a YAML file
func YamlExtension(path string) string {
if !isYamlFile(path) {
log.Error().Msgf("Config: File %s is not a yaml file", path)
return path
}
// Strip any extension, if there is no extension the path will remain unchanged
path = strings.TrimSuffix(path, filepath.Ext(path))
result := path + ".yml"
if _, err := os.Stat(result); os.IsNotExist(err) {
return path + ".yaml"
}
return result
}
// ----------------------------------------------------------------------------
// Helpers...
func isSet(s *string) bool {
return s != nil && len(*s) > 0
}
func isYamlFile(file string) bool {
ext := filepath.Ext(file)
return ext == ".yml" || ext == ".yaml"
}

View File

@ -4,12 +4,16 @@
package config_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/adrg/xdg"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/mock"
m "github.com/petergtz/pegomock"
"github.com/rs/zerolog"
@ -21,37 +25,358 @@ func init() {
zerolog.SetGlobalLevel(zerolog.FatalLevel)
}
func TestConfigRefine(t *testing.T) {
func TestConfigSave(t *testing.T) {
config.AppConfigFile = "/tmp/k9s-test/k9s.yaml"
sd := "/tmp/k9s-test/screen-dumps"
cl, ct := "cl-1", "ct-1-1"
_ = os.RemoveAll(("/tmp/k9s-test"))
uu := map[string]struct {
ct string
flags *genericclioptions.ConfigFlags
k9sFlags *config.Flags
}{
"happy": {
ct: "ct-1-1",
flags: &genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
},
k9sFlags: &config.Flags{
ScreenDumpDir: &sd,
},
},
}
for k := range uu {
xdg.Reload()
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, err := c.K9s.ActivateContext(u.ct)
assert.NoError(t, err)
if u.flags != nil {
c.K9s.Override(u.k9sFlags)
assert.NoError(t, c.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags)))
}
assert.NoError(t, c.Save())
bb, err := os.ReadFile(config.AppConfigFile)
assert.NoError(t, err)
ee, err := os.ReadFile("testdata/configs/default.yaml")
assert.NoError(t, err)
assert.Equal(t, string(ee), string(bb))
})
}
}
func TestSetActiveView(t *testing.T) {
var (
cfgFile = "testdata/kubeconfig-test.yaml"
ctx, cluster, ns = "ct-1-1", "cl-1", "ns-1"
cfgFile = "testdata/kubes/test.yaml"
view = "dp"
)
uu := map[string]struct {
flags *genericclioptions.ConfigFlags
issue bool
context, cluster, namespace string
ct string
flags *genericclioptions.ConfigFlags
k9sFlags *config.Flags
view string
e string
}{
"overrideNS": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ctx,
ClusterName: &cluster,
Namespace: &ns,
},
issue: false,
context: ctx,
cluster: cluster,
namespace: ns,
"empty": {
view: data.DefaultView,
e: data.DefaultView,
},
"badContext": {
"not-exists": {
ct: "fred",
view: data.DefaultView,
e: data.DefaultView,
},
"happy": {
ct: "ct-1-1",
view: "xray",
e: "xray",
},
"cli-override": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ns,
ClusterName: &cluster,
Namespace: &ns,
KubeConfig: &cfgFile,
},
issue: true,
k9sFlags: &config.Flags{
Command: &view,
},
ct: "ct-1-1",
view: "xray",
e: "dp",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
if u.flags != nil {
assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags)))
c.K9s.Override(u.k9sFlags)
}
c.SetActiveView(u.view)
assert.Equal(t, u.e, c.ActiveView())
})
}
}
func TestActiveContextName(t *testing.T) {
var (
cfgFile = "testdata/kubes/test.yaml"
ct2 = "ct-1-2"
)
uu := map[string]struct {
flags *genericclioptions.ConfigFlags
k9sFlags *config.Flags
ct string
e string
}{
"empty": {},
"happy": {
ct: "ct-1-1",
e: "ct-1-1",
},
"cli-override": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ct2,
},
k9sFlags: &config.Flags{},
ct: "ct-1-1",
e: "ct-1-2",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
if u.flags != nil {
assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags)))
c.K9s.Override(u.k9sFlags)
}
assert.Equal(t, u.e, c.ActiveContextName())
})
}
}
func TestActiveView(t *testing.T) {
var (
cfgFile = "testdata/kubes/test.yaml"
view = "dp"
)
uu := map[string]struct {
ct string
flags *genericclioptions.ConfigFlags
k9sFlags *config.Flags
e string
}{
"empty": {
e: data.DefaultView,
},
"not-exists": {
ct: "fred",
e: data.DefaultView,
},
"happy": {
ct: "ct-1-1",
e: data.DefaultView,
},
"cli-override": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
},
k9sFlags: &config.Flags{
Command: &view,
},
e: "dp",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
if u.flags != nil {
assert.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags)))
c.K9s.Override(u.k9sFlags)
}
assert.Equal(t, u.e, c.ActiveView())
})
}
}
func TestFavNamespaces(t *testing.T) {
uu := map[string]struct {
ct string
e []string
}{
"empty": {},
"not-exists": {
ct: "fred",
},
"happy": {
ct: "ct-1-1",
e: []string{client.DefaultNamespace},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
assert.Equal(t, u.e, c.FavNamespaces())
})
}
}
func TestContextAliasesPath(t *testing.T) {
uu := map[string]struct {
ct string
e string
}{
"empty": {},
"not-exists": {
ct: "fred",
},
"happy": {
ct: "ct-1-1",
e: "/tmp/test/cl-1/ct-1-1/aliases.yaml",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
assert.Equal(t, u.e, c.ContextAliasesPath())
})
}
}
func TestContextPluginsPath(t *testing.T) {
uu := map[string]struct {
ct string
e string
}{
"empty": {},
"happy": {
ct: "ct-1-1",
e: "/tmp/test/cl-1/ct-1-1/plugins.yaml",
},
"not-exists": {
ct: "fred",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
c := mock.NewMockConfig()
_, _ = c.K9s.ActivateContext(u.ct)
assert.Equal(t, u.e, c.ContextPluginsPath())
})
}
}
func TestConfigLoader(t *testing.T) {
uu := map[string]struct {
f string
err string
}{
"happy": {
f: "testdata/configs/k9s.yaml",
},
"toast": {
f: "testdata/configs/k9s_toast.yaml",
err: `k9s config file "testdata/configs/k9s_toast.yaml" load failed:
Additional property disablePodCounts is not allowed
Additional property shellPods is not allowed
Invalid type. Expected: boolean, given: string`,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := config.NewConfig(nil)
if err := cfg.Load(u.f); err != nil {
assert.Equal(t, u.err, err.Error())
}
})
}
}
func TestConfigSetCurrentContext(t *testing.T) {
uu := map[string]struct {
cl, ct string
err string
}{
"happy": {
ct: "ct-1-2",
cl: "cl-1",
},
"toast": {
ct: "fred",
cl: "cl-1",
err: `set current context failed. no context found for: "fred"`,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := mock.NewMockConfig()
ct, err := cfg.SetCurrentContext(u.ct)
if err != nil {
assert.Equal(t, u.err, err.Error())
return
}
assert.NoError(t, err)
assert.Equal(t, u.cl, ct.ClusterName)
})
}
}
func TestConfigCurrentContext(t *testing.T) {
var (
cfgFile = "testdata/kubes/test.yaml"
ct2 = "ct-1-2"
)
uu := map[string]struct {
flags *genericclioptions.ConfigFlags
err error
context string
cluster string
namespace string
}{
"override-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ct2,
},
cluster: "cl-1",
context: "ct-1-2",
namespace: "ns-2",
},
"use-current-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: client.DefaultNamespace,
},
}
@ -61,8 +386,128 @@ func TestConfigRefine(t *testing.T) {
cfg := mock.NewMockConfig()
err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags))
if u.issue {
assert.NotNil(t, err)
assert.NoError(t, err)
ct, err := cfg.CurrentContext()
assert.NoError(t, err)
assert.Equal(t, u.cluster, ct.ClusterName)
assert.Equal(t, u.namespace, ct.Namespace.Active)
})
}
}
func TestConfigRefine(t *testing.T) {
var (
cfgFile = "testdata/kubes/test.yaml"
cl1 = "cl-1"
ct2 = "ct-1-2"
ns1, ns2, nsx = "ns-1", "ns-2", "ns-x"
true = true
)
uu := map[string]struct {
flags *genericclioptions.ConfigFlags
k9sFlags *config.Flags
err error
context string
cluster string
namespace string
}{
"no-override": {
namespace: "default",
},
"override-cluster": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
ClusterName: &cl1,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: client.DefaultNamespace,
},
"override-cluster-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
ClusterName: &cl1,
Context: &ct2,
},
cluster: "cl-1",
context: "ct-1-2",
namespace: "ns-2",
},
"override-bad-cluster": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
ClusterName: &ns1,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: client.DefaultNamespace,
},
"override-ns": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Namespace: &ns2,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: "ns-2",
},
"all-ns": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Namespace: &ns2,
},
k9sFlags: &config.Flags{
AllNamespaces: &true,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: client.NamespaceAll,
},
"override-bad-ns": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Namespace: &nsx,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: "ns-x",
},
"override-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ct2,
},
cluster: "cl-1",
context: "ct-1-2",
namespace: "ns-2",
},
"override-bad-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
Context: &ns1,
},
err: errors.New(`no context found for: "ns-1"`),
},
"use-current-context": {
flags: &genericclioptions.ConfigFlags{
KubeConfig: &cfgFile,
},
cluster: "cl-1",
context: "ct-1-1",
namespace: client.DefaultNamespace,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := mock.NewMockConfig()
err := cfg.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags))
if err != nil {
assert.Equal(t, u.err, err)
} else {
assert.Nil(t, err)
assert.Equal(t, u.context, cfg.K9s.ActiveContextName())
@ -76,35 +521,29 @@ func TestConfigValidate(t *testing.T) {
cfg := mock.NewMockConfig()
cfg.SetConnection(mock.NewMockConnection())
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.Validate()
}
func TestConfigLoad(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Equal(t, 2, cfg.K9s.RefreshRate)
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount)
}
func TestConfigLoadOldCfg(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s_old.yaml"))
assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize)
}
func TestConfigLoadCrap(t *testing.T) {
cfg := mock.NewMockConfig()
assert.NotNil(t, cfg.Load("testdata/k9s_not_there.yaml"))
assert.NotNil(t, cfg.Load("testdata/configs/k9s_not_there.yaml"))
}
func TestConfigSaveFile(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.K9s.RefreshRate = 100
cfg.K9s.ReadOnly = true
@ -113,26 +552,28 @@ func TestConfigSaveFile(t *testing.T) {
cfg.Validate()
path := filepath.Join("/tmp", "k9s.yaml")
err := cfg.SaveFile(path)
assert.Nil(t, err)
assert.NoError(t, cfg.SaveFile(path))
raw, err := os.ReadFile(path)
assert.Nil(t, err)
assert.Equal(t, expectedConfig, string(raw))
ee, err := os.ReadFile("testdata/configs/expected.yaml")
assert.Nil(t, err)
assert.Equal(t, string(ee), string(raw))
}
func TestConfigReset(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
cfg.Reset()
cfg.Validate()
path := filepath.Join("/tmp", "k9s.yaml")
err := cfg.SaveFile(path)
assert.Nil(t, err)
assert.NoError(t, cfg.SaveFile(path))
raw, err := os.ReadFile(path)
bb, err := os.ReadFile(path)
assert.Nil(t, err)
assert.Equal(t, resetConfig, string(raw))
ee, err := os.ReadFile("testdata/configs/k9s.yaml")
assert.Nil(t, err)
assert.Equal(t, string(ee), string(bb))
}
// Helpers...
@ -147,86 +588,86 @@ func TestSetup(t *testing.T) {
// ----------------------------------------------------------------------------
// Test Data...
var expectedConfig = `k9s:
liveViewAutoRefresh: true
screenDumpDir: /tmp
refreshRate: 100
maxConnRetry: 5
readOnly: true
noExitOnCtrlC: false
ui:
enableMouse: false
headless: false
logoless: false
crumbsless: false
noIcons: false
skipLatestRevCheck: false
disablePodCounting: false
shellPod:
image: busybox:1.35.0
namespace: default
limits:
cpu: 100m
memory: 100Mi
imageScans:
enable: false
exclusions:
namespaces: []
labels: {}
logger:
tail: 500
buffer: 800
sinceSeconds: -1
fullScreenLogs: false
textWrap: false
showTime: false
thresholds:
cpu:
critical: 90
warn: 70
memory:
critical: 90
warn: 70
`
// var expectedConfig = `k9s:
// liveViewAutoRefresh: true
// screenDumpDir: /tmp/screen-dumps
// refreshRate: 100
// maxConnRetry: 5
// readOnly: true
// noExitOnCtrlC: false
// ui:
// enableMouse: false
// headless: false
// logoless: false
// crumbsless: false
// noIcons: false
// skipLatestRevCheck: false
// disablePodCounting: false
// shellPod:
// image: busybox:1.35.0
// namespace: default
// limits:
// cpu: 100m
// memory: 100Mi
// imageScans:
// enable: false
// exclusions:
// namespaces: []
// labels: {}
// logger:
// tail: 500
// buffer: 800
// sinceSeconds: -1
// fullScreen: false
// textWrap: false
// showTime: false
// thresholds:
// cpu:
// critical: 90
// warn: 70
// memory:
// critical: 90
// warn: 70
// `
var resetConfig = `k9s:
liveViewAutoRefresh: true
screenDumpDir: /tmp
refreshRate: 2
maxConnRetry: 5
readOnly: false
noExitOnCtrlC: false
ui:
enableMouse: false
headless: false
logoless: false
crumbsless: false
noIcons: false
skipLatestRevCheck: false
disablePodCounting: false
shellPod:
image: busybox:1.35.0
namespace: default
limits:
cpu: 100m
memory: 100Mi
imageScans:
enable: false
exclusions:
namespaces: []
labels: {}
logger:
tail: 200
buffer: 2000
sinceSeconds: -1
fullScreenLogs: false
textWrap: false
showTime: false
thresholds:
cpu:
critical: 90
warn: 70
memory:
critical: 90
warn: 70
`
// var resetConfig = `k9s:
// liveViewAutoRefresh: true
// screenDumpDir: /tmp/screen-dumps
// refreshRate: 2
// maxConnRetry: 5
// readOnly: false
// noExitOnCtrlC: false
// ui:
// enableMouse: false
// headless: false
// logoless: false
// crumbsless: false
// noIcons: false
// skipLatestRevCheck: false
// disablePodCounting: false
// shellPod:
// image: busybox:1.35.0
// namespace: default
// limits:
// cpu: 100m
// memory: 100Mi
// imageScans:
// enable: false
// exclusions:
// namespaces: []
// labels: {}
// logger:
// tail: 200
// buffer: 2000
// sinceSeconds: -1
// fullScreen: false
// textWrap: false
// showTime: false
// thresholds:
// cpu:
// critical: 90
// warn: 70
// memory:
// critical: 90
// warn: 70
// `

View File

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

View File

@ -4,6 +4,8 @@
package data
import (
"sync"
"github.com/derailed/k9s/internal/client"
"k8s.io/client-go/tools/clientcmd/api"
)
@ -14,12 +16,13 @@ const DefaultPFAddress = "localhost"
// Context tracks K9s context configuration.
type Context struct {
ClusterName string `yaml:"cluster,omitempty"`
ReadOnly bool `yaml:"readOnly"`
ReadOnly *bool `yaml:"readOnly,omitempty"`
Skin string `yaml:"skin,omitempty"`
Namespace *Namespace `yaml:"namespace"`
View *View `yaml:"view"`
FeatureGates FeatureGates `yaml:"featureGates"`
PortForwardAddress string `yaml:"portForwardAddress"`
mx sync.RWMutex
}
// NewContext creates a new cluster configuration.
@ -32,6 +35,7 @@ func NewContext() *Context {
}
}
// NewContextFromConfig returns a config based on a kubecontext.
func NewContextFromConfig(cfg *api.Context) *Context {
return &Context{
Namespace: NewActiveNamespace(cfg.Namespace),
@ -42,12 +46,32 @@ func NewContextFromConfig(cfg *api.Context) *Context {
}
}
// Validate a context config.
// NewContextFromKubeConfig returns a new instance based on kubesettings or an error.
func NewContextFromKubeConfig(ks KubeSettings) (*Context, error) {
ct, err := ks.CurrentContext()
if err != nil {
return nil, err
}
return NewContextFromConfig(ct), nil
}
func (c *Context) GetClusterName() string {
c.mx.RLock()
defer c.mx.RUnlock()
return c.ClusterName
}
// Validate ensures a context config is tip top.
func (c *Context) Validate(conn client.Connection, ks KubeSettings) {
c.mx.Lock()
defer c.mx.Unlock()
if c.PortForwardAddress == "" {
c.PortForwardAddress = DefaultPFAddress
}
if cl, err := ks.CurrentClusterName(); err != nil {
if cl, err := ks.CurrentClusterName(); err == nil {
c.ClusterName = cl
}

View File

@ -5,9 +5,11 @@ package data
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/derailed/k9s/internal/config/json"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
"k8s.io/client-go/tools/clientcmd/api"
@ -60,6 +62,10 @@ func (d *Dir) loadConfig(path string) (*Config, error) {
if err != nil {
return nil, err
}
if err := JSONValidator.Validate(json.ContextSchema, bb); err != nil {
return nil, fmt.Errorf("validation failed for %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(bb, &cfg); err != nil {
return nil, err

View File

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

View File

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

View File

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

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) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs())
defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir))
defer assert.NoError(t, os.RemoveAll("/tmp/test-config"))
assert.NoError(t, data.EnsureFullPath("/tmp/test-config/clusters/cl-1/ct-2", data.DefaultDirMod))
assert.NoError(t, os.WriteFile("/tmp/test-config/clusters/cl-1/ct-2/benchmarks.yaml", []byte{}, data.DefaultFileMod))
@ -95,3 +96,28 @@ func TestEnsureBenchmarkCfg(t *testing.T) {
})
}
}
func TestSkinFileFromName(t *testing.T) {
config.AppSkinsDir = "/tmp/k9s-test/skins"
defer assert.NoError(t, os.RemoveAll("/tmp/k9s-test/skins"))
uu := map[string]struct {
n string
e string
}{
"empty": {
e: "/tmp/k9s-test/skins/stock.yaml",
},
"happy": {
n: "fred-blee",
e: "/tmp/k9s-test/skins/fred-blee.yaml",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, config.SkinFileFromName(u.n))
})
}
}

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"
)
func isBoolSet(b *bool) bool {
return b != nil && *b
}
func isStringSet(s *string) bool {
return s != nil && len(*s) > 0
}
func isYamlFile(file string) bool {
ext := filepath.Ext(file)
return ext == ".yml" || ext == ".yaml"
}
// isEnvSet checks if env var is set.
func isEnvSet(env string) bool {
return os.Getenv(env) != ""

View File

@ -4,8 +4,11 @@
package config
import (
"fmt"
"os"
"github.com/derailed/k9s/internal/config/data"
"github.com/derailed/k9s/internal/config/json"
"gopkg.in/yaml.v2"
)
@ -30,19 +33,32 @@ func NewHotKeys() HotKeys {
}
// Load K9s plugins.
func (h HotKeys) Load() error {
return h.LoadHotKeys(AppHotKeysFile)
func (h HotKeys) Load(path string) error {
if err := h.LoadHotKeys(AppHotKeysFile); err != nil {
return err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
return h.LoadHotKeys(path)
}
// LoadHotKeys loads plugins from a given file.
func (h HotKeys) LoadHotKeys(path string) error {
f, err := os.ReadFile(path)
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
bb, err := os.ReadFile(path)
if err != nil {
return err
}
if err := data.JSONValidator.Validate(json.HotkeysSchema, bb); err != nil {
return fmt.Errorf("validation failed for %q: %w", path, err)
}
var hh HotKeys
if err := yaml.Unmarshal(f, &hh); err != nil {
if err := yaml.Unmarshal(bb, &hh); err != nil {
return err
}
for k, v := range hh.HotKey {

View File

@ -12,7 +12,7 @@ import (
func TestHotKeyLoad(t *testing.T) {
h := config.NewHotKeys()
assert.Nil(t, h.LoadHotKeys("testdata/hotkeys.yaml"))
assert.NoError(t, h.LoadHotKeys("testdata/hotkeys/hotkeys.yaml"))
assert.Equal(t, 1, len(h.HotKey))

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

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
import (
"errors"
"testing"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/config/mock"
"github.com/stretchr/testify/assert"
"k8s.io/cli-runtime/pkg/genericclioptions"
)
func TestGetScreenDumpDir(t *testing.T) {
cfg := mock.NewMockConfig()
func TestK9sReload(t *testing.T) {
config.AppConfigDir = "/tmp/k9s-test"
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir())
cl, ct := "cl-1", "ct-1-1"
uu := map[string]struct {
k *config.K9s
cl, ct string
err error
}{
"no-context": {
k: config.NewK9s(
mock.NewMockConnection(),
mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
}),
),
err: errors.New(`no context found for: ""`),
},
"set-context": {
k: config.NewK9s(
mock.NewMockConnection(),
mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
}),
),
ct: "ct-1-1",
cl: "cl-1",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
_, _ = u.k.ActivateContext(u.ct)
assert.Equal(t, u.err, u.k.Reload())
ct, err := u.k.ActiveContext()
assert.Equal(t, u.err, err)
if err == nil {
assert.Equal(t, u.cl, ct.ClusterName)
}
})
}
}
func TestGetScreenDumpDirOverride(t *testing.T) {
cfg := mock.NewMockConfig()
func TestK9sMerge(t *testing.T) {
cl, ct := "cl-1", "ct-1-1"
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
cfg.K9s.OverrideScreenDumpDir("/override")
assert.Equal(t, "/override", cfg.K9s.GetScreenDumpDir())
uu := map[string]struct {
k1, k2 *config.K9s
ek *config.K9s
}{
"no-opt": {
k1: config.NewK9s(
mock.NewMockConnection(),
mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
}),
),
ek: config.NewK9s(
mock.NewMockConnection(),
mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
}),
),
},
"override": {
k1: &config.K9s{
LiveViewAutoRefresh: false,
ScreenDumpDir: "",
RefreshRate: 0,
MaxConnRetry: 0,
ReadOnly: false,
NoExitOnCtrlC: false,
UI: config.UI{},
SkipLatestRevCheck: false,
DisablePodCounting: false,
ShellPod: config.ShellPod{},
ImageScans: config.ImageScans{},
Logger: config.Logger{},
Thresholds: nil,
},
k2: &config.K9s{
LiveViewAutoRefresh: true,
MaxConnRetry: 100,
ShellPod: config.NewShellPod(),
},
ek: &config.K9s{
LiveViewAutoRefresh: true,
ScreenDumpDir: "",
RefreshRate: 0,
MaxConnRetry: 100,
ReadOnly: false,
NoExitOnCtrlC: false,
UI: config.UI{},
SkipLatestRevCheck: false,
DisablePodCounting: false,
ShellPod: config.NewShellPod(),
ImageScans: config.ImageScans{},
Logger: config.Logger{},
Thresholds: nil,
},
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
u.k1.Merge(u.k2)
assert.Equal(t, u.ek, u.k1)
})
}
}
func TestGetScreenDumpDirOverrideEmpty(t *testing.T) {
func TestContextScreenDumpDir(t *testing.T) {
cfg := mock.NewMockConfig()
_, err := cfg.K9s.ActivateContext("ct-1-1")
assert.Nil(t, cfg.Load("testdata/k9s.yaml"))
cfg.K9s.OverrideScreenDumpDir("")
assert.Equal(t, "/tmp", cfg.K9s.GetScreenDumpDir())
assert.NoError(t, err)
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Equal(t, "/tmp/k9s-test/screen-dumps/cl-1/ct-1-1", cfg.K9s.ContextScreenDumpDir())
}
func TestGetScreenDumpDirEmpty(t *testing.T) {
func TestAppScreenDumpDir(t *testing.T) {
cfg := mock.NewMockConfig()
assert.Nil(t, cfg.Load("testdata/k9s1.yaml"))
cfg.K9s.OverrideScreenDumpDir("")
assert.Equal(t, config.AppDumpsDir, cfg.K9s.GetScreenDumpDir())
assert.Nil(t, cfg.Load("testdata/configs/k9s.yaml"))
assert.Equal(t, "/tmp/k9s-test/screen-dumps", cfg.K9s.AppScreenDumpDir())
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,8 +4,10 @@
package config
import (
"os"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
)
@ -39,10 +41,24 @@ var test2YmlTestData = Plugin{
Background: true,
}
func TestPluginLoad(t *testing.T) {
AppPluginsFile = "/tmp/k9s-test/fred.yaml"
os.Setenv("XDG_DATA_HOME", "/tmp/k9s-test")
xdg.Reload()
p := NewPlugins()
assert.NoError(t, p.Load("testdata/plugins.yaml"))
assert.Equal(t, 1, len(p.Plugins))
k, ok := p.Plugins["blah"]
assert.True(t, ok)
assert.ObjectsAreEqual(pluginYmlTestData, k)
}
func TestSinglePluginFileLoad(t *testing.T) {
p := NewPlugins()
assert.Nil(t, p.load("testdata/plugins.yaml"))
assert.Nil(t, p.loadPluginDir("/random/dir/not/exist"))
assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.NoError(t, p.loadPluginDir("/random/dir/not/exist"))
assert.Equal(t, 1, len(p.Plugins))
k, ok := p.Plugins["blah"]
@ -53,8 +69,8 @@ func TestSinglePluginFileLoad(t *testing.T) {
func TestMultiplePluginFilesLoad(t *testing.T) {
p := NewPlugins()
assert.Nil(t, p.load("testdata/plugins.yaml"))
assert.Nil(t, p.loadPluginDir("testdata/plugins"))
assert.NoError(t, p.load("testdata/plugins.yaml"))
assert.NoError(t, p.loadPluginDir("testdata/plugins"))
testPlugins := map[string]Plugin{
"blah": pluginYmlTestData,

View File

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

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

View File

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

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"
)
func TestNewStyle(t *testing.T) {
s := config.NewStyles()
assert.Equal(t, config.Color("black"), s.K9s.Body.BgColor)
assert.Equal(t, config.Color("cadetblue"), s.K9s.Body.FgColor)
assert.Equal(t, config.Color("lightskyblue"), s.K9s.Frame.Status.NewColor)
}
func TestColor(t *testing.T) {
uu := map[string]tcell.Color{
"blah": tcell.ColorDefault,
@ -28,22 +36,9 @@ func TestColor(t *testing.T) {
}
}
func TestSkinNone(t *testing.T) {
func TestSkinHappy(t *testing.T) {
s := config.NewStyles()
assert.Nil(t, s.Load("testdata/empty_skin.yaml"))
s.Update()
assert.Equal(t, "#5f9ea0", s.Body().FgColor.String())
assert.Equal(t, "#000000", s.Body().BgColor.String())
assert.Equal(t, "#000000", s.Table().BgColor.String())
assert.Equal(t, tcell.ColorCadetBlue.TrueColor(), s.FgColor())
assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor())
assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor)
}
func TestSkin(t *testing.T) {
s := config.NewStyles()
assert.Nil(t, s.Load("testdata/black_and_wtf.yaml"))
assert.Nil(t, s.Load("../../skins/black-and-wtf.yaml"))
s.Update()
assert.Equal(t, "#ffffff", s.Body().FgColor.String())
@ -54,12 +49,38 @@ func TestSkin(t *testing.T) {
assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor)
}
func TestSkinNotExits(t *testing.T) {
s := config.NewStyles()
assert.NotNil(t, s.Load("testdata/blee.yaml"))
}
func TestSkinLoad(t *testing.T) {
uu := map[string]struct {
f string
err string
}{
"not-exist": {
f: "testdata/skins/blee.yaml",
err: "open testdata/skins/blee.yaml: no such file or directory",
},
"toast": {
f: "testdata/skins/boarked.yaml",
err: `Additional property bgColor is not allowed
Additional property fgColor is not allowed
Additional property logoColor is not allowed
Invalid type. Expected: object, given: array`,
},
}
func TestSkinBoarked(t *testing.T) {
s := config.NewStyles()
assert.NotNil(t, s.Load("testdata/skin_boarked.yaml"))
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
s := config.NewStyles()
err := s.Load(u.f)
if err != nil {
assert.Equal(t, u.err, err.Error())
}
assert.Equal(t, "#5f9ea0", s.Body().FgColor.String())
assert.Equal(t, "#000000", s.Body().BgColor.String())
assert.Equal(t, "#000000", s.Table().BgColor.String())
assert.Equal(t, tcell.ColorCadetBlue.TrueColor(), s.FgColor())
assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor())
assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor)
})
}
}

View File

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

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
server: https://1.2.3.4:8443
name: cl-1
- cluster:
certificate-authority: /Users/test/ca.crt
server: https://5.6.7.8:8443
name: cl-2
contexts:
- context:
cluster: cl-1
user: user1
namespace: ns-1
name: ct-1
name: ct-1-1
- context:
cluster: cl-1
user: user2
namespace: ns-2
name: ct-2
current-context: ct-1
name: ct-1-2
- context:
cluster: cl-2
user: user2
namespace: ns-2
name: ct-2-1
current-context: ct-1-1
preferences: {}
users:
- name: user1

View File

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

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.
func (t Threshold) Validate() {
func (t Threshold) Validate() Threshold {
for _, k := range []string{"cpu", "memory"} {
v, ok := t[k]
if !ok {
@ -70,6 +70,8 @@ func (t Threshold) Validate() {
v.Validate()
}
}
return t
}
// LevelFor returns a defcon level for the current state.

View File

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

View File

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

View File

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

View File

@ -68,9 +68,9 @@ func (p *Popeye) List(ctx context.Context, ns string) ([]runtime.Object, error)
flags.Sections = &sections
flags.ActiveNamespace = &ns
}
spinach := cfg.YamlExtension(filepath.Join(cfg.K9sHome(), "spinach.yaml"))
spinach := filepath.Join(cfg.AppConfigDir, "spinach.yaml")
if c, err := p.GetFactory().Client().Config().CurrentContextName(); err == nil {
spinach = cfg.YamlExtension(filepath.Join(cfg.K9sHome(), fmt.Sprintf("%s_spinach.yaml", c)))
spinach = filepath.Join(cfg.AppConfigDir, fmt.Sprintf("%s_spinach.yaml", c))
}
if _, err := os.Stat(spinach); err == nil {
flags.Spinach = &spinach

View File

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

View File

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

View File

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

View File

@ -34,12 +34,11 @@ func NewApp(cfg *config.Config, context string) *App {
a := App{
Application: tview.NewApplication(),
actions: make(KeyActions),
Configurator: Configurator{Config: cfg},
Configurator: Configurator{Config: cfg, Styles: config.NewStyles()},
Main: NewPages(),
flash: model.NewFlash(model.DefaultFlashDelay),
cmdBuff: model.NewFishBuff(':', model.CommandBuffer),
}
a.ReloadStyles()
a.views = map[string]tview.Primitive{
"menu": NewMenu(a.Styles),
@ -134,11 +133,6 @@ func (a *App) StylesChanged(s *config.Styles) {
}
}
// ReloadStyles reloads skin file.
func (a *App) ReloadStyles() {
a.RefreshStyles()
}
// Conn returns an api server connection.
func (a *App) Conn() client.Connection {
return a.Config.GetConnection()

View File

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

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