K9s/release v0.30.7 (#2416)

* [Bug] Fix #2413

* [Bug] Fix #2414

* [Bug] Fix #2407

* cleaning up

* Add config files watcher

* rel notes
mine
Fernand Galiana 2024-01-02 23:57:07 -07:00 committed by GitHub
parent d93041b187
commit 982bf6a728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 474 additions and 232 deletions

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.6
VERSION ?= v0.30.7
IMG_NAME := derailed/k9s
IMAGE := ${IMG_NAME}:${VERSION}

View File

@ -0,0 +1,51 @@
<img src="https://raw.githubusercontent.com/derailed/k9s/master/assets/k9s.png" align="center" width="800" height="auto"/>
# Release v0.30.7
## Notes
Thank you to all that contributed with flushing out issues and enhancements for K9s!
I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev
and see if we're happier with some of the fixes!
If you've filed an issue please help me verify and close.
Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated!
Also big thanks to all that have allocated their own time to help others on both slack and on this repo!!
As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey,
please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer)
On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM)
## Maintenance Release!
Thank you all for pitching in and helping flesh out issues!!
---
## Videos Are In The Can!
Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content...
* [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4)
* [Vulnerability Scans](https://youtu.be/ULkl0MsaidU)
---
## Resolved Issues
* [#2414](https://github.com/derailed/k9s/issues/2414) View pods with context filter, along with namespace filter, prompts an error if the namespace exists only in the desired context
* [#2413](https://github.com/derailed/k9s/issues/2413) Typing apply -f in command bar causes k9s to crash
* [#2407](https://github.com/derailed/k9s/issues/2407) Long-running background plugins block UI rendering
---
## 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!!
* [#2415](https://github.com/derailed/k9s/pull/2415) Add boundary check for args parser
* [#2411](https://github.com/derailed/k9s/pull/2411) Use dash as a standard word separator in skin names
<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

@ -352,11 +352,7 @@ func (a *APIClient) Config() *Config {
// HasMetrics checks if the cluster supports metrics.
func (a *APIClient) HasMetrics() bool {
err := a.supportsMetricsResources()
if err != nil {
log.Debug().Msgf("Metrics server detect failed: %s", err)
}
return err == nil
return a.supportsMetricsResources() != nil
}
// DialLogs returns a handle to api server for logs.

View File

@ -84,6 +84,10 @@ func (n *Namespace) addFavNS(ns string) {
}
func (n *Namespace) rmFavNS(ns string) {
if n.LockFavorites {
return
}
victim := -1
for i, f := range n.Favorites {
if f == ns {

View File

@ -177,7 +177,7 @@ func (k *K9s) ActiveContext() (*data.Context, error) {
// ActivateContext initializes the active context is not present.
func (k *K9s) ActivateContext(n string) (*data.Context, error) {
k.activeContextName = n
ct, err := k.ks.GetContext(k.activeContextName)
ct, err := k.ks.GetContext(n)
if err != nil {
return nil, err
}
@ -197,6 +197,21 @@ func (k *K9s) ActivateContext(n string) (*data.Context, error) {
return k.activeConfig.Context, nil
}
// Reload reloads the active config from disk.
func (k *K9s) Reload() error {
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
}
return nil
}
// OverrideRefreshRate set the refresh rate manually.
func (k *K9s) OverrideRefreshRate(r int) {
k.manualRefreshRate = r

View File

@ -104,11 +104,17 @@ func (m mockKubeSettings) ContextNames() (map[string]struct{}, error) {
return mm, nil
}
type mockConnection struct{}
type mockConnection struct {
ct string
}
func NewMockConnection() mockConnection {
return mockConnection{}
}
func NewMockConnectionWithContext(ct string) mockConnection {
return mockConnection{ct: ct}
}
func (m mockConnection) CanI(ns, gvr string, verbs []string) (bool, error) {
return true, nil
}
@ -155,7 +161,7 @@ func (m mockConnection) CheckConnectivity() bool {
return false
}
func (m mockConnection) ActiveContext() string {
return ""
return m.ct
}
func (m mockConnection) ActiveNamespace() string {
return ""

View File

@ -9,8 +9,6 @@ import (
"math"
"regexp"
"github.com/derailed/tview"
runewidth "github.com/mattn/go-runewidth"
"github.com/rs/zerolog/log"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@ -21,7 +19,7 @@ const defaultServiceAccount = "default"
var (
inverseRx = regexp.MustCompile(`\A\!`)
fuzzyRx = regexp.MustCompile(`\A\-f`)
fuzzyRx = regexp.MustCompile(`\A-f\s?([\w-]+)\b`)
)
func inList(ll []string, s string) bool {
@ -41,12 +39,14 @@ func IsInverseSelector(s string) bool {
return inverseRx.MatchString(s)
}
// IsFuzzySelector checks if filter is fuzzy or not.
func IsFuzzySelector(s string) bool {
if s == "" {
return false
// HasFuzzySelector checks if query is fuzzy.
func HasFuzzySelector(s string) (string, bool) {
mm := fuzzyRx.FindStringSubmatch(s)
if len(mm) != 2 {
return "", false
}
return fuzzyRx.MatchString(s)
return mm[1], true
}
func toPerc(v1, v2 float64) float64 {
@ -56,11 +56,6 @@ func toPerc(v1, v2 float64) float64 {
return math.Round((v1 / v2) * 100)
}
// Truncate a string to the given l and suffix ellipsis if needed.
func Truncate(str string, width int) string {
return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis))
}
// ToYAML converts a resource to its YAML representation.
func ToYAML(o runtime.Object, showManaged bool) (string, error) {
if o == nil {

View File

@ -174,8 +174,8 @@ func (l *LogItems) Filter(index int, q string, showTime bool) ([]int, [][]int, e
if q == "" {
return nil, nil, nil
}
if IsFuzzySelector(q) {
mm, ii := l.fuzzyFilter(index, strings.TrimSpace(q[2:]), showTime)
if f, ok := HasFuzzySelector(q); ok {
mm, ii := l.fuzzyFilter(index, f, showTime)
return mm, ii, nil
}
matches, indices, err := l.filterLogs(index, q, showTime)

View File

@ -65,8 +65,8 @@ func (d *Describe) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if dao.IsFuzzySelector(q) {
return d.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
if f, ok := dao.HasFuzzySelector(q); ok {
return d.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)
}

View File

@ -9,8 +9,6 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/derailed/tview"
"github.com/mattn/go-runewidth"
"github.com/sahilm/fuzzy"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@ -28,11 +26,6 @@ func FQN(ns, n string) string {
return ns + "/" + n
}
// Truncate a string to the given l and suffix ellipsis if needed.
func Truncate(str string, width int) string {
return runewidth.Truncate(str, width, string(tview.SemigraphicsHorizontalEllipsis))
}
// NewExpBackOff returns a new exponential backoff timer.
func NewExpBackOff(ctx context.Context, start, max time.Duration) backoff.BackOffContext {
bf := backoff.NewExponentialBackOff()

View File

@ -33,34 +33,3 @@ func TestMetaFQN(t *testing.T) {
})
}
}
func TestTruncate(t *testing.T) {
uu := map[string]struct {
data string
size int
e string
}{
"same": {
data: "fred",
size: 4,
e: "fred",
},
"small": {
data: "fred",
size: 10,
e: "fred",
},
"larger": {
data: "fred",
size: 3,
e: "fr…",
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, model.Truncate(u.data, u.size))
})
}
}

View File

@ -84,8 +84,8 @@ func (v *RevValues) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if dao.IsFuzzySelector(q) {
return v.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
if f, ok := dao.HasFuzzySelector(q); ok {
return v.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)
}

View File

@ -111,8 +111,8 @@ func (t *Text) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if dao.IsFuzzySelector(q) {
return t.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
if f, ok := dao.HasFuzzySelector(q); ok {
return t.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)
}

View File

@ -113,8 +113,8 @@ func (v *Values) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if dao.IsFuzzySelector(q) {
return v.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
if f, ok := dao.HasFuzzySelector(q); ok {
return v.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)
}

View File

@ -74,8 +74,8 @@ func (y *YAML) filter(q string, lines []string) fuzzy.Matches {
if q == "" {
return nil
}
if dao.IsFuzzySelector(q) {
return y.fuzzyFilter(strings.TrimSpace(q[2:]), lines)
if f, ok := dao.HasFuzzySelector(q); ok {
return y.fuzzyFilter(strings.TrimSpace(f), lines)
}
return rxFilter(q, lines)
}

View File

@ -13,7 +13,7 @@ import (
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/vul"
"github.com/derailed/tview"
runewidth "github.com/mattn/go-runewidth"
"github.com/mattn/go-runewidth"
"github.com/rs/zerolog/log"
"golang.org/x/text/language"
"golang.org/x/text/message"

View File

@ -224,18 +224,33 @@ func TestNa(t *testing.T) {
}
func TestTruncate(t *testing.T) {
uu := []struct {
s string
l int
uu := map[string]struct {
data string
size int
e string
}{
{"fred", 3, "fr…"},
{"fred", 2, "f…"},
{"fred", 10, "fred"},
"same": {
data: "fred",
size: 4,
e: "fred",
},
"small": {
data: "fred",
size: 10,
e: "fred",
},
"larger": {
data: "fred",
size: 3,
e: "fr…",
},
}
for _, u := range uu {
assert.Equal(t, u.e, Truncate(u.s, u.l))
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, Truncate(u.data, u.size))
})
}
}

View File

@ -39,7 +39,7 @@ func NewApp(cfg *config.Config, context string) *App {
flash: model.NewFlash(model.DefaultFlashDelay),
cmdBuff: model.NewFishBuff(':', model.CommandBuffer),
}
a.ReloadStyles(context)
a.ReloadStyles()
a.views = map[string]tview.Primitive{
"menu": NewMenu(a.Styles),
@ -135,8 +135,8 @@ func (a *App) StylesChanged(s *config.Styles) {
}
// ReloadStyles reloads skin file.
func (a *App) ReloadStyles(context string) {
a.RefreshStyles(context)
func (a *App) ReloadStyles() {
a.RefreshStyles()
}
// Conn returns an api server connection.

View File

@ -7,6 +7,7 @@ import (
"context"
"errors"
"os"
"path/filepath"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/render"
@ -82,8 +83,8 @@ func (c *Configurator) RefreshCustomViews() error {
return c.CustomView.Load(config.AppViewsFile)
}
// StylesWatcher watches for skin file changes.
func (c *Configurator) StylesWatcher(ctx context.Context, s synchronizer) error {
// SkinsDirWatcher watches for skin directory file changes.
func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) error {
if !c.HasSkin() {
return nil
}
@ -100,7 +101,7 @@ func (c *Configurator) StylesWatcher(ctx context.Context, s synchronizer) error
if evt.Name == c.skinFile && evt.Op != fsnotify.Chmod {
log.Debug().Msgf("Skin changed: %s", c.skinFile)
s.QueueUpdateDraw(func() {
c.RefreshStyles(c.Config.K9s.ActiveContextName())
c.RefreshStyles()
})
}
case err := <-w.Errors:
@ -120,48 +121,127 @@ func (c *Configurator) StylesWatcher(ctx context.Context, s synchronizer) error
return w.Add(config.AppSkinsDir)
}
// RefreshStyles load for skin configuration changes.
func (c *Configurator) RefreshStyles(context string) {
cluster := "na"
if c.Config != nil {
if ct, err := c.Config.K9s.ActiveContext(); err == nil {
cluster = ct.ClusterName
}
// ConfigWatcher watches for skin settings changes.
func (c *Configurator) ConfigWatcher(ctx context.Context, s synchronizer) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
if bc, err := config.EnsureBenchmarksCfgFile(cluster, context); err != nil {
log.Warn().Err(err).Msgf("No benchmark config file found for context: %s", context)
go func() {
for {
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())
if evt.Name == config.AppConfigFile {
if err := c.Config.Load(evt.Name); err != nil {
log.Error().Err(err).Msgf("Config reload failed")
}
} else {
c.BenchFile = bc
if err := c.Config.K9s.Reload(); err != nil {
log.Error().Err(err).Msgf("Context config reload failed")
}
}
s.QueueUpdateDraw(func() {
c.RefreshStyles()
})
}
case err := <-w.Errors:
log.Info().Err(err).Msg("ConfigWatcher failed")
return
case <-ctx.Done():
log.Debug().Msg("ConfigWatcher CANCELED")
if err := w.Close(); err != nil {
log.Error().Err(err).Msg("Canceling ConfigWatcher")
}
return
}
}
}()
log.Debug().Msgf("ConfigWatcher watching: %q", config.AppConfigFile)
if err := w.Add(config.AppConfigFile); err != nil {
return err
}
cl, ct, ok := c.activeConfig()
if !ok {
return nil
}
ctConfigFile := filepath.Join(config.AppContextConfig(cl, ct))
log.Debug().Msgf("ConfigWatcher watching: %q", ctConfigFile)
return w.Add(ctConfigFile)
}
func (c *Configurator) activeSkin() (string, bool) {
var skin string
if c.Config == nil || c.Config.K9s == nil {
return skin, false
}
if ct, err := c.Config.K9s.ActiveContext(); err == nil {
skin = ct.Skin
}
if skin == "" {
skin = c.Config.K9s.UI.Skin
}
return skin, skin != ""
}
func (c *Configurator) activeConfig() (cluster string, context string, ok bool) {
if c.Config == nil || c.Config.K9s == nil {
return
}
ct, err := c.Config.K9s.ActiveContext()
if err != nil {
return
}
cluster, context = ct.ClusterName, c.Config.K9s.ActiveContextName()
if cluster != "" && context != "" {
ok = true
}
return
}
// RefreshStyles load for skin configuration changes.
func (c *Configurator) RefreshStyles() {
if c.Styles == nil {
c.Styles = config.NewStyles()
} else {
c.Styles.Reset()
}
var skin string
if c.Config != nil {
skin = c.Config.K9s.UI.Skin
if ct, err := c.Config.K9s.ActiveContext(); err != nil {
log.Warn().Msgf("No active context found. Using default skin")
} else if ct.Skin != "" {
skin = ct.Skin
cl, ct, ok := c.activeConfig()
if !ok {
return
}
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
}
if skin == "" {
skin, ok := c.activeSkin()
if !ok {
log.Debug().Msgf("No custom skin found. Loading default")
c.updateStyles("")
return
}
var skinFile = config.SkinFileFromName(skin)
skinFile := config.SkinFileFromName(skin)
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)
} else {
log.Error().Msgf("Failed to parse skin file -- %s: %s.", skinFile, err)
}
c.updateStyles("")
} else {
log.Debug().Msgf("Loading skin file: %q", skinFile)
c.updateStyles(skinFile)
}
}

View File

@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package ui
import (
"os"
"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 Test_activeConfig(t *testing.T) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs())
cl, ct := "cl-1", "ct-1-1"
uu := map[string]struct {
cl, ct string
cfg *Configurator
ok bool
}{
"empty": {
cfg: &Configurator{},
},
"plain": {
cfg: &Configurator{Config: config.NewConfig(
mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{
ClusterName: &cl,
Context: &ct,
}))},
cl: cl,
ct: ct,
ok: true,
},
}
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
cfg := u.cfg
if cfg.Config != nil {
_, err := cfg.Config.K9s.ActivateContext(ct)
assert.NoError(t, err)
}
cl, ct, ok := cfg.activeConfig()
assert.Equal(t, u.ok, ok)
if ok {
assert.Equal(t, u.cl, cl)
assert.Equal(t, u.ct, ct)
}
})
}
}

View File

@ -18,18 +18,9 @@ import (
"k8s.io/cli-runtime/pkg/genericclioptions"
)
func TestBenchConfig(t *testing.T) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs())
defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir))
bc, error := config.EnsureBenchmarksCfgFile("cl-1", "ct-1")
assert.NoError(t, error)
assert.Equal(t, "/tmp/test-config/clusters/cl-1/ct-1/benchmarks.yaml", bc)
}
func TestSkinnedContext(t *testing.T) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs())
defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir))
@ -50,10 +41,22 @@ func TestSkinnedContext(t *testing.T) {
cfg.Config.K9s = config.NewK9s(
mock.NewMockConnection(),
mock.NewMockKubeSettings(&flags))
_, err = cfg.Config.K9s.ActivateContext("ct-1-1")
assert.NoError(t, err)
cfg.Config.K9s.UI = config.UI{Skin: "black_and_wtf"}
cfg.RefreshStyles("ct-1")
cfg.RefreshStyles()
assert.True(t, cfg.HasSkin())
assert.Equal(t, tcell.ColorGhostWhite.TrueColor(), render.StdColor)
assert.Equal(t, tcell.ColorWhiteSmoke.TrueColor(), render.ErrColor)
}
func TestBenchConfig(t *testing.T) {
os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")
assert.NoError(t, config.InitLocs())
defer assert.NoError(t, os.RemoveAll(config.K9sEnvConfigDir))
bc, error := config.EnsureBenchmarksCfgFile("cl-1", "ct-1")
assert.NoError(t, error)
assert.Equal(t, "/tmp/test-config/clusters/cl-1/ct-1/benchmarks.yaml", bc)
}

View File

@ -12,6 +12,7 @@ import (
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/dao"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/vul"
@ -407,14 +408,13 @@ func (t *Table) filtered(data *render.TableData) *render.TableData {
}
q := t.cmdBuff.GetText()
if IsFuzzySelector(q) {
return fuzzyFilter(q[2:], filtered)
if f, ok := dao.HasFuzzySelector(q); ok {
return fuzzyFilter(f, filtered)
}
filtered, err := rxFilter(q, IsInverseSelector(q), filtered)
filtered, err := rxFilter(q, dao.IsInverseSelector(q), filtered)
if err != nil {
log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp")
// t.cmdBuff.ClearText(true)
log.Error().Err(errors.New("invalid filter expression")).Msg("Regexp")
}
return filtered
@ -471,9 +471,9 @@ func (t *Table) styleTitle() string {
buff := t.cmdBuff.GetText()
if IsLabelSelector(buff) {
buff = truncate(TrimLabelSelector(buff), maxTruncate)
buff = render.Truncate(TrimLabelSelector(buff), maxTruncate)
} else if l := t.GetModel().GetLabelFilter(); l != "" {
buff = truncate(l, maxTruncate)
buff = render.Truncate(l, maxTruncate)
}
if buff == "" {

View File

@ -43,8 +43,6 @@ const (
var (
// LabelRx identifies a label query.
LabelRx = regexp.MustCompile(`\A\-l`)
inverseRx = regexp.MustCompile(`\A\!`)
fuzzyRx = regexp.MustCompile(`\A\-f`)
)
func mustExtractStyles(ctx context.Context) *config.Styles {
@ -67,9 +65,6 @@ func TrimCell(tv *SelectTable, row, col int) string {
// IsLabelSelector checks if query is a label query.
func IsLabelSelector(s string) bool {
if s == "" {
return false
}
if LabelRx.MatchString(s) {
return true
}
@ -77,22 +72,6 @@ func IsLabelSelector(s string) bool {
return !strings.Contains(s, " ") && cmd.ToLabels(s) != nil
}
// IsFuzzySelector checks if query is fuzzy.
func IsFuzzySelector(s string) bool {
if s == "" {
return false
}
return fuzzyRx.MatchString(s)
}
// IsInverseSelector checks if inverse char has been provided.
func IsInverseSelector(s string) bool {
if s == "" {
return false
}
return inverseRx.MatchString(s)
}
// TrimLabelSelector extracts label query.
func TrimLabelSelector(s string) string {
if strings.Index(s, "-l") == 0 {
@ -102,14 +81,6 @@ func TrimLabelSelector(s string) string {
return s
}
func truncate(s string, max int) string {
if len(s) < max {
return s
}
return s[:max] + "..."
}
// SkinTitle decorates a title.
func SkinTitle(fmat string, style config.Frame) string {
bgColor := style.Title.BgColor

View File

@ -6,6 +6,7 @@ package ui
import (
"testing"
"github.com/derailed/k9s/internal/render"
"github.com/stretchr/testify/assert"
)
@ -16,7 +17,7 @@ func TestTruncate(t *testing.T) {
"empty": {},
"max": {
s: "/app.kubernetes.io/instance=prom,app.kubernetes.io/name=prometheus,app.kubernetes.io/component=server",
e: "/app.kubernetes.io/instance=prom,app.kubernetes.io...",
e: "/app.kubernetes.io/instance=prom,app.kubernetes.i",
},
"less": {
s: "app=fred,env=blee",
@ -27,7 +28,7 @@ func TestTruncate(t *testing.T) {
for k := range uu {
u := uu[k]
t.Run(k, func(t *testing.T) {
assert.Equal(t, u.e, truncate(u.s, 50))
assert.Equal(t, u.e, render.Truncate(u.s, 50))
})
}
}

View File

@ -142,9 +142,9 @@ func pluginAction(r Runner, p config.Plugin) ui.ActionHandler {
pipes: p.Pipes,
args: args,
}
suspend, errChan := run(r.App(), opts)
suspend, errChan, statusChan := run(r.App(), opts)
if !suspend {
r.App().Flash().Info("Plugin command failed!")
r.App().Flash().Infof("Plugin command failed: %q", p.Description)
return
}
var errs error
@ -155,7 +155,12 @@ func pluginAction(r Runner, p config.Plugin) ui.ActionHandler {
r.App().cowCmd(errs.Error())
return
}
r.App().Flash().Info("Plugin command launched successfully!")
go func() {
for st := range statusChan {
r.App().Flash().Infof("Plugin command launched successfully: %q", st)
}
}()
}
if p.Confirm {
msg := fmt.Sprintf("Run?\n%s %s", p.Command, strings.Join(args, " "))

View File

@ -324,8 +324,12 @@ func (a *App) Resume() {
ctx, a.cancelFn = context.WithCancel(context.Background())
go a.clusterUpdater(ctx)
if err := a.StylesWatcher(ctx, a); err != nil {
log.Warn().Err(err).Msgf("Styles watcher failed")
if err := a.ConfigWatcher(ctx, a); err != nil {
log.Warn().Err(err).Msgf("ConfigWatcher failed")
}
if err := a.SkinsDirWatcher(ctx, a); err != nil {
log.Warn().Err(err).Msgf("SkinsWatcher failed")
}
if err := a.CustomViewsWatcher(ctx, a); err != nil {
log.Warn().Err(err).Msgf("CustomView watcher failed")
@ -408,17 +412,9 @@ func (a *App) switchNS(ns string) error {
if a.Config.ActiveNamespace() == ns {
return nil
}
if ns == client.ClusterScope {
ns = client.BlankNamespace
}
ok, err := a.isValidNS(ns)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("switchns - invalid namespace: %q", ns)
}
if err := a.Config.SetActiveNamespace(ns); err != nil {
return err
}
@ -469,6 +465,7 @@ func (a *App) switchContext(ci *cmd.Interpreter) error {
}
ns := a.Config.ActiveNamespace()
if !a.Conn().IsValidNamespace(ns) {
a.Flash().Errf("Unable to validate namespace %q. Using %q namespace", ns, client.DefaultNamespace)
ns = client.DefaultNamespace
if err := a.Config.SetActiveNamespace(ns); err != nil {
return err
@ -481,7 +478,7 @@ func (a *App) switchContext(ci *cmd.Interpreter) error {
log.Debug().Msgf("--> Switching Context %q -- %q -- %q", name, ns, a.Config.ActiveView())
a.Flash().Infof("Switching context to %q::%q", name, ns)
a.ReloadStyles(name)
a.ReloadStyles()
a.gotoResource(a.Config.ActiveView(), "", true)
a.clusterModel.Reset(a.factory)
}

View File

@ -11,6 +11,7 @@ const (
nsKey = "ns"
topicKey = "topic"
filterKey = "filter"
fuzzyKey = "fuzzy"
labelKey = "labels"
contextKey = "context"
)
@ -30,8 +31,12 @@ func newArgs(p *Interpreter, aa []string) args {
args[contextKey] = a[1:]
case strings.Index(a, fuzzyFlag) == 0:
if a == fuzzyFlag {
if i++; i < len(aa) {
args[filterKey] = strings.ToLower(strings.TrimSpace(aa[i]))
args[fuzzyKey] = strings.ToLower(strings.TrimSpace(aa[i]))
}
} else {
args[fuzzyKey] = strings.ToLower(a[2:])
}
case strings.Index(a, filterFlag) == 0:
@ -67,7 +72,8 @@ func newArgs(p *Interpreter, aa []string) args {
func (a args) hasFilters() bool {
_, fok := a[filterKey]
_, zok := a[fuzzyKey]
_, lok := a[labelKey]
return fok || lok
return fok || zok || lok
}

View File

@ -42,7 +42,12 @@ func TestFlagsNew(t *testing.T) {
"fuzzy-filter": {
i: NewInterpreter("po"),
aa: []string{"-f", "fred"},
ll: args{filterKey: "fred"},
ll: args{fuzzyKey: "fred"},
},
"fuzzy-filter-nospace": {
i: NewInterpreter("po"),
aa: []string{"-ffred"},
ll: args{fuzzyKey: "fred"},
},
"filter+ns": {
i: NewInterpreter("po"),
@ -72,23 +77,43 @@ func TestFlagsNew(t *testing.T) {
"full-monty": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg"},
ll: args{filterKey: "zorg", labelKey: "app=fred", nsKey: "ns1"},
ll: args{
filterKey: "zorg",
fuzzyKey: "blee",
labelKey: "app=fred",
nsKey: "ns1",
},
},
"full-monty+ctx": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@ctx1"},
ll: args{filterKey: "zorg", labelKey: "app=fred", nsKey: "ns1", contextKey: "ctx1"},
ll: args{
filterKey: "zorg",
fuzzyKey: "blee",
labelKey: "app=fred",
nsKey: "ns1",
contextKey: "ctx1",
},
},
"caps": {
i: NewInterpreter("po"),
aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@Dev"},
ll: args{filterKey: "zorg", labelKey: "app=fred", nsKey: "ns1", contextKey: "Dev"},
ll: args{
filterKey: "zorg",
fuzzyKey: "blee",
labelKey: "app=fred",
nsKey: "ns1",
contextKey: "Dev"},
},
"ctx": {
i: NewInterpreter("ctx"),
aa: []string{"Dev"},
ll: args{contextKey: "Dev"},
},
"bork": {
i: NewInterpreter("apply -f"),
ll: args{},
},
}
for k := range uu {

View File

@ -11,8 +11,10 @@ import (
)
func ToLabels(s string) map[string]string {
ll := strings.Split(s, ",")
lbls := make(map[string]string, len(ll))
var (
ll = strings.Split(s, ",")
lbls = make(map[string]string, len(ll))
)
for _, l := range ll {
kv := strings.Split(l, "=")
if len(kv) < 2 || kv[0] == "" || kv[1] == "" {
@ -20,7 +22,6 @@ func ToLabels(s string) map[string]string {
}
lbls[kv[0]] = kv[1]
}
if len(lbls) == 0 {
return nil
}

View File

@ -7,12 +7,14 @@ import (
"strings"
)
// Interpreter tracks user prompt input.
type Interpreter struct {
line string
cmd string
args args
}
// NewInterpreter returns a new instance.
func NewInterpreter(s string) *Interpreter {
c := Interpreter{
line: s,
@ -32,20 +34,24 @@ func (c *Interpreter) grok() {
c.args = newArgs(c, ff[1:])
}
// HasNS returns true if ns is present in prompt.
func (c *Interpreter) HasNS() bool {
ns, ok := c.args[nsKey]
return ok && ns != ""
}
// Cmd returns the command.
func (c *Interpreter) Cmd() string {
return c.cmd
}
// IsBlank returns true if prompt is empty.
func (c *Interpreter) IsBlank() bool {
return c.line == ""
}
// Amend merges prompts.
func (c *Interpreter) Amend(c1 *Interpreter) {
c.cmd = c1.cmd
if c.args == nil {
@ -58,6 +64,7 @@ func (c *Interpreter) Amend(c1 *Interpreter) {
}
}
// Reset resets with new command.
func (c *Interpreter) Reset(s string) *Interpreter {
c.line = s
c.grok()
@ -65,50 +72,60 @@ func (c *Interpreter) Reset(s string) *Interpreter {
return c
}
// GetLine teturns the prompt.
func (c *Interpreter) GetLine() string {
return strings.TrimSpace(c.line)
}
// IsCowCmd returns true if cow cmd is detected.
func (c *Interpreter) IsCowCmd() bool {
return c.cmd == cowCmd
}
// IsHelpCmd returns true if help cmd is detected.
func (c *Interpreter) IsHelpCmd() bool {
_, ok := helpCmd[c.cmd]
return ok
}
// IsBailCmd returns true if quit cmd is detected.
func (c *Interpreter) IsBailCmd() bool {
_, ok := bailCmd[c.cmd]
return ok
}
// IsAliasCmd returns true if alias cmd is detected.
func (c *Interpreter) IsAliasCmd() bool {
_, ok := aliasCmd[c.cmd]
return ok
}
// IsXrayCmd returns true if xray cmd is detected.
func (c *Interpreter) IsXrayCmd() bool {
_, ok := xrayCmd[c.cmd]
return ok
}
// IsContextCmd returns true if context cmd is detected.
func (c *Interpreter) IsContextCmd() bool {
_, ok := contextCmd[c.cmd]
return ok
}
// IsDirCmd returns true if dir cmd is detected.
func (c *Interpreter) IsDirCmd() bool {
_, ok := dirCmd[c.cmd]
return ok
}
// IsRBACCmd returns true if rbac cmd is detected.
func (c *Interpreter) IsRBACCmd() bool {
return c.cmd == canCmd
}
// ContextArg returns context cmd arg.
func (c *Interpreter) ContextArg() (string, bool) {
if !c.IsContextCmd() {
return "", false
@ -117,26 +134,32 @@ func (c *Interpreter) ContextArg() (string, bool) {
return c.args[contextKey], true
}
// ResetContextArg deletes context arg.
func (c *Interpreter) ResetContextArg() {
delete(c.args, contextFlag)
}
// DirArg returns the directory is present.
func (c *Interpreter) DirArg() (string, bool) {
if !c.IsDirCmd() || c.args[topicKey] == "" {
if !c.IsDirCmd() {
return "", false
}
d, ok := c.args[topicKey]
return c.args[topicKey], true
return d, ok && d != ""
}
// CowArg returns the cow message.
func (c *Interpreter) CowArg() (string, bool) {
if !c.IsCowCmd() || c.args[nsKey] == "" {
if !c.IsCowCmd() {
return "", false
}
m, ok := c.args[nsKey]
return c.args[nsKey], true
return m, ok && m != ""
}
// RBACArgs returns the subject and topic is any.
func (c *Interpreter) RBACArgs() (string, string, bool) {
if !c.IsRBACCmd() {
return "", "", false
@ -149,6 +172,7 @@ func (c *Interpreter) RBACArgs() (string, string, bool) {
return tt[1], tt[2], true
}
// XRayArgs return the gvr and ns if any.
func (c *Interpreter) XrayArgs() (string, string, bool) {
if !c.IsXrayCmd() {
return "", "", false
@ -169,32 +193,37 @@ func (c *Interpreter) XrayArgs() (string, string, bool) {
}
}
// FilterArg returns the current filter if any.
func (c *Interpreter) FilterArg() (string, bool) {
f, ok := c.args[filterKey]
return f, ok
return f, ok && f != ""
}
// FuzzyArg returns the fuzzy filter if any.
func (c *Interpreter) FuzzyArg() (string, bool) {
f, ok := c.args[fuzzyKey]
return f, ok && f != ""
}
// NSArg returns the current ns if any.
func (c *Interpreter) NSArg() (string, bool) {
ns, ok := c.args[nsKey]
return ns, ok
return ns, ok && ns != ""
}
// HasContext returns the current context if any.
func (c *Interpreter) HasContext() (string, bool) {
ctx, ok := c.args[contextKey]
if !ok || ctx == "" {
return "", false
}
return ctx, ok
return ctx, ok && ctx != ""
}
// LabelsArg return the labels map if any.
func (c *Interpreter) LabelsArg() (map[string]string, bool) {
ll, ok := c.args[labelKey]
if !ok {
return nil, false
}
return ToLabels(ll), true
return ToLabels(ll), ok
}

View File

@ -317,11 +317,6 @@ func TestContextCmd(t *testing.T) {
ctx string
}{
"empty": {},
"plain": {
cmd: "context",
ok: true,
ctx: "",
},
"happy-full": {
cmd: "context ctx1",
ok: true,

View File

@ -79,13 +79,13 @@ func allowedXRay(gvr client.GVR) bool {
}
func (c *Command) contextCmd(p *cmd.Interpreter) error {
ctx, ok := p.ContextArg()
ct, ok := p.ContextArg()
if !ok {
return fmt.Errorf("invalid command use `context xxx`")
}
if ctx != "" {
return useContext(c.app, ctx)
if ct != "" {
return useContext(c.app, ct)
}
gvr, v, err := c.viewMetaFor(p)
@ -93,7 +93,7 @@ func (c *Command) contextCmd(p *cmd.Interpreter) error {
return err
}
return c.exec(p, gvr, c.componentFor(gvr, ctx, v), true)
return c.exec(p, gvr, c.componentFor(gvr, ct, v), true)
}
func (c *Command) xrayCmd(p *cmd.Interpreter) error {
@ -169,6 +169,9 @@ func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack bool) error {
if f, ok := p.FilterArg(); ok {
co.SetFilter(f)
}
if f, ok := p.FuzzyArg(); ok {
co.SetFilter("-f " + f)
}
if ll, ok := p.LabelsArg(); ok {
co.SetLabelFilter(ll)
}

View File

@ -16,6 +16,8 @@ import (
"syscall"
"time"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/config"
"github.com/derailed/k9s/internal/model"
@ -75,7 +77,7 @@ func runK(a *App, opts shellOpts) error {
}
opts.binary = bin
suspended, errChan := run(a, opts)
suspended, errChan, _ := run(a, opts)
if !suspended {
return fmt.Errorf("unable to run command")
}
@ -87,28 +89,29 @@ func runK(a *App, opts shellOpts) error {
return errs
}
func run(a *App, opts shellOpts) (bool, chan error) {
func run(a *App, opts shellOpts) (bool, chan error, chan string) {
errChan := make(chan error, 1)
statusChan := make(chan string, 1)
if opts.background {
if err := execute(opts); err != nil {
if err := execute(opts, statusChan); err != nil {
errChan <- err
a.Flash().Errf("Exec failed %q: %s", opts, err)
}
close(errChan)
return true, errChan
return true, errChan, statusChan
}
a.Halt()
defer a.Resume()
return a.Suspend(func() {
if err := execute(opts); err != nil {
if err := execute(opts, statusChan); err != nil {
errChan <- err
a.Flash().Errf("Exec failed %q: %s", opts, err)
}
close(errChan)
}), errChan
}), errChan, statusChan
}
func edit(a *App, opts shellOpts) bool {
@ -122,18 +125,20 @@ func edit(a *App, opts shellOpts) bool {
}
opts.binary, opts.background = bin, false
suspended, errChan := run(a, opts)
suspended, errChan, _ := run(a, opts)
if !suspended {
a.Flash().Errf("edit command failed")
}
status := true
for e := range errChan {
a.Flash().Err(e)
return false
status = false
}
return true
return status
}
func execute(opts shellOpts) error {
func execute(opts shellOpts, statusChan chan<- string) error {
if opts.clear {
clearScreen()
}
@ -174,7 +179,7 @@ func execute(opts shellOpts) error {
}
var o, e bytes.Buffer
err := pipe(ctx, opts, &o, &e, cmds...)
err := pipe(ctx, opts, statusChan, &o, &e, cmds...)
if err != nil {
log.Err(err).Msgf("Command failed")
return errors.Join(err, fmt.Errorf("%s", e.String()))
@ -458,7 +463,7 @@ func asResource(r config.Limits) v1.ResourceRequirements {
}
}
func pipe(_ context.Context, opts shellOpts, w, e io.Writer, cmds ...*exec.Cmd) error {
func pipe(_ context.Context, opts shellOpts, statusChan chan<- string, w, e io.Writer, cmds ...*exec.Cmd) error {
if len(cmds) == 0 {
return nil
}
@ -466,8 +471,17 @@ func pipe(_ context.Context, opts shellOpts, w, e io.Writer, cmds ...*exec.Cmd)
if len(cmds) == 1 {
cmd := cmds[0]
if opts.background {
go func() {
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, w, e
return cmd.Run()
if err := cmd.Run(); err != nil {
log.Error().Err(err).Msgf("Command failed: %s", err)
} else {
statusChan <- fmt.Sprintf("Command completed successfully: %q", render.Truncate(cmd.String(), 20))
log.Info().Msgf("Command completed successfully: %q", cmd.String())
}
close(statusChan)
}()
return nil
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
_, _ = cmd.Stdout.Write([]byte(opts.banner))
@ -475,6 +489,10 @@ func pipe(_ context.Context, opts shellOpts, w, e io.Writer, cmds ...*exec.Cmd)
log.Debug().Msgf("Running Start")
err := cmd.Run()
log.Debug().Msgf("Running Done: %s", err)
if err == nil {
statusChan <- fmt.Sprintf("Command completed successfully: %q", cmd.String())
}
close(statusChan)
return err
}

View File

@ -4,6 +4,7 @@
package view
import (
"errors"
"runtime"
"strings"
@ -68,7 +69,7 @@ func (s *ImageScan) viewCVE(app *App, _ ui.Tabular, _ client.GVR, path string) {
}
site += cve
ok, errChan := run(app, shellOpts{
ok, errChan, _ := run(app, shellOpts{
background: true,
binary: bin,
args: []string{site},
@ -77,9 +78,11 @@ func (s *ImageScan) viewCVE(app *App, _ ui.Tabular, _ client.GVR, path string) {
app.Flash().Errf("unable to run browser command")
return
}
var errs error
for e := range errChan {
if e != nil {
app.Flash().Err(e)
errs = errors.Join(e)
}
if errs != nil {
app.Flash().Err(errs)
}
}

View File

@ -243,11 +243,11 @@ func (s *Sanitizer) filter(root *xray.TreeNode) *xray.TreeNode {
}
s.UpdateTitle()
if ui.IsFuzzySelector(q) {
return root.Filter(q, fuzzyFilter)
if f, ok := dao.HasFuzzySelector(q); ok {
return root.Filter(f, fuzzyFilter)
}
if ui.IsInverseSelector(q) {
if dao.IsInverseSelector(q) {
return root.Filter(q, rxInverseFilter)
}

View File

@ -5,12 +5,14 @@ package view
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/derailed/k9s/internal"
"github.com/derailed/k9s/internal/client"
"github.com/derailed/k9s/internal/model"
"github.com/derailed/k9s/internal/render"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/tcell/v2"
"github.com/rs/zerolog/log"
@ -173,7 +175,7 @@ func (t *Table) saveCmd(evt *tcell.EventKey) *tcell.EventKey {
if path, err := saveTable(t.app.Config.K9s.ActiveScreenDumpsDir(), t.GVR().R(), t.Path, t.GetFilteredData()); err != nil {
t.app.Flash().Err(err)
} else {
t.app.Flash().Infof("File %s saved successfully!", path)
t.app.Flash().Infof("File saved successfully: %q", render.Truncate(filepath.Base(path), 50))
}
return nil

View File

@ -478,11 +478,11 @@ func (x *Xray) filter(root *xray.TreeNode) *xray.TreeNode {
}
x.UpdateTitle()
if ui.IsFuzzySelector(q) {
return root.Filter(q, fuzzyFilter)
if f, ok := dao.HasFuzzySelector(q); ok {
return root.Filter(f, fuzzyFilter)
}
if ui.IsInverseSelector(q) {
if dao.IsInverseSelector(q) {
return root.Filter(q, rxInverseFilter)
}

View File

@ -67,6 +67,7 @@ func (s *imageScanner) GetScan(img string) (*Scan, bool) {
func (s *imageScanner) setScan(img string, sc *Scan) {
s.mx.Lock()
defer s.mx.Unlock()
s.scans[img] = sc
}

View File

@ -111,7 +111,7 @@ k9s:
indicator:
fgColor: *red
bgColor: *blue
toggleOnColor: *green
toggleOnColor: *yellow
toggleOffColor: *grey
# Chart drawing

View File

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