diff --git a/Makefile b/Makefile index 99c87e6f..fdbdb0fe 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ DATE ?= $(shell TZ=UTC date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H: else DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ") endif -VERSION ?= v0.50.1 +VERSION ?= v0.50.2 IMG_NAME := derailed/k9s IMAGE := ${IMG_NAME}:${VERSION} diff --git a/change_logs/release_v0.50.2.md b/change_logs/release_v0.50.2.md new file mode 100644 index 00000000..87b6befd --- /dev/null +++ b/change_logs/release_v0.50.2.md @@ -0,0 +1,40 @@ + + +# Release v0.50.2 + +## 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) + +## 5-0, 5-0 HotFix! + +It looks like we've broken a few (more) things in the clean up process 😳 +This is what you get for trying to refresh a ~10 year old code base 🙀 +Apologizes for the `disruption in the farce`. Hopefully much happier on v0.50.2... +Are we there yet? Crossing fingers AND toes... + +☠️ Careful on this upgrade! 🏴‍☠️ +We've gone thru lots of code revamp/refactor in the v0.50.0, so mileage may vary... + +--- + +## Resolved Issues + +* [#3267](https://github.com/derailed/k9s/issues/3267) Show some output or message when no resources are found +* [#3266](https://github.com/derailed/k9s/issues/3266) Command alias :dp fails with "no resource meta defined for deployments" error +* [#3264](https://github.com/derailed/k9s/issues/3264) can't execute get(y) or describe(d) in StorageClass view +* [#3260](https://github.com/derailed/k9s/issues/3260) yaml view of pod will crash the app (Boom!! cannot deep copy int. (Maybe??) + +--- + © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) \ No newline at end of file diff --git a/internal/client/helpers.go b/internal/client/helpers.go index 116e6145..8f677bbe 100644 --- a/internal/client/helpers.go +++ b/internal/client/helpers.go @@ -22,6 +22,14 @@ func IsClusterWide(ns string) bool { return ns == NamespaceAll || ns == BlankNamespace || ns == ClusterScope } +func PrintNamespace(ns string) string { + if IsAllNamespaces(ns) { + return "all" + } + + return ns +} + // CleanseNamespace ensures all ns maps to blank. func CleanseNamespace(ns string) string { if IsAllNamespace(ns) { diff --git a/internal/config/alias.go b/internal/config/alias.go index 2f506ff6..275effd1 100644 --- a/internal/config/alias.go +++ b/internal/config/alias.go @@ -84,16 +84,14 @@ func (a *Aliases) Resolve(command string) (*client.GVR, string, bool) { if !ok { return nil, "", false } - if agvr.IsCommand() { - p := cmd.NewInterpreter(agvr.String()) - gvr, ok := a.Get(p.Cmd()) - if !ok { - return nil, "", false - } - return gvr, p.Args(), true + + p := cmd.NewInterpreter(agvr.String()) + gvr, ok := a.Get(p.Cmd()) + if !ok { + return agvr, "", true } - return agvr, "", true + return gvr, p.Args(), true } // Get retrieves an alias. diff --git a/internal/dao/alias_test.go b/internal/dao/alias_test.go index 7cd41f9a..6738f5df 100644 --- a/internal/dao/alias_test.go +++ b/internal/dao/alias_test.go @@ -18,10 +18,13 @@ import ( func TestAsGVR(t *testing.T) { a := dao.NewAlias(makeFactory()) - a.Define(client.PodGVR, "po", "pod", "pods") + a.Define(client.PodGVR, "po", "pipo", "pod") + a.Define(client.PodGVR, client.PodGVR.String()) + a.Define(client.PodGVR, client.PodGVR.AsResourceName()) a.Define(client.WkGVR, client.WkGVR.String(), "workload", "wkl") a.Define(client.NewGVR("pod default"), "pp") - a.Define(client.NewGVR("pod default @fred"), "ppc") + a.Define(client.NewGVR("pipo default"), "ppo") + a.Define(client.NewGVR("pod default app=fred @fred"), "ppc") uu := map[string]struct { cmd string @@ -29,40 +32,59 @@ func TestAsGVR(t *testing.T) { gvr *client.GVR exp string }{ - "ok": { + "gvr": { + cmd: "v1/pods", + ok: true, + gvr: client.PodGVR, + }, + + "r": { cmd: "pods", ok: true, gvr: client.PodGVR, }, - "ok-short": { + "alias1": { cmd: "po", ok: true, gvr: client.PodGVR, }, + "alias-2": { + cmd: "pipo", + ok: true, + gvr: client.PodGVR, + }, + "missing": { cmd: "zorg", }, - "alias": { + "no-args": { cmd: "wkl", ok: true, gvr: client.WkGVR, }, - "ns-alias": { + "ns-arg": { cmd: "pp", ok: true, gvr: client.PodGVR, exp: "default", }, + "ns-inception": { + cmd: "ppo", + ok: true, + gvr: client.PodGVR, + exp: "default", + }, + "full-alias": { cmd: "ppc", ok: true, gvr: client.PodGVR, - exp: "default @fred", + exp: "default app=fred @fred", }, } diff --git a/internal/dao/helpers.go b/internal/dao/helpers.go index 1bf6531b..5e280107 100644 --- a/internal/dao/helpers.go +++ b/internal/dao/helpers.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "math" "github.com/derailed/k9s/internal/client" @@ -85,15 +86,22 @@ func ToYAML(o runtime.Object, showManaged bool) (string, error) { if o == nil { return "", errors.New("no object to yamlize") } + u, ok := o.(*unstructured.Unstructured) + if !ok { + return "", fmt.Errorf("expecting unstructured but got %T", o) + } + if u.Object == nil { + return "", fmt.Errorf("expecting unstructured object but got nil") + } + mm := u.Object var ( buff bytes.Buffer p printers.YAMLPrinter ) if !showManaged { - o = o.DeepCopyObject() - uo := o.(*unstructured.Unstructured).Object - if meta, ok := uo["metadata"].(map[string]any); ok { + mm = maps.Clone(mm) + if meta, ok := mm["metadata"].(map[string]any); ok { delete(meta, "managedFields") } } diff --git a/internal/dao/registry.go b/internal/dao/registry.go index bd9e5edf..3471240d 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -331,6 +331,9 @@ func loadPreferred(f Factory, m ResourceMetas) error { if !isStandardGroup(r.GroupVersion) { res.Categories = append(res.Categories, crdCat) } + if isScalable(gvr) { + res.Categories = append(res.Categories, scaleCat) + } m[gvr] = &res } } @@ -342,6 +345,12 @@ func isStandardGroup(gv string) bool { return stdGroups.Has(gv) || strings.Contains(gv, ".k8s.io") } +func isScalable(gvr *client.GVR) bool { + ss := sets.New(client.DpGVR, client.StsGVR) + + return ss.Has(gvr) +} + var deprecatedGVRs = sets.New( client.NewGVR("v1/events"), client.NewGVR("extensions/v1beta1/ingresses"), diff --git a/internal/dao/table.go b/internal/dao/table.go index 0952dc89..7b0240fe 100644 --- a/internal/dao/table.go +++ b/internal/dao/table.go @@ -6,12 +6,9 @@ package dao import ( "context" "fmt" - "log/slog" - "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -72,7 +69,6 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { if err != nil { return nil, err } - ti := time.Now() o, err := c.Get(). SetHeader("Accept", header). Param("includeObject", includeObject). @@ -86,7 +82,6 @@ func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { if err != nil { return nil, err } - slog.Debug("Q Time", slogs.Elapsed, time.Since(ti)) namespaced := true if res, e := MetaAccess.MetaFor(t.gvr); e == nil && !res.Namespaced { diff --git a/internal/model/registry.go b/internal/model/registry.go index d9bb7682..f62a6c3b 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -43,7 +43,7 @@ var Registry = map[string]ResourceMeta{ Renderer: new(render.Container), TreeRenderer: new(xray.Container), }, - client.ScGVR.String(): { + client.ScnGVR.String(): { DAO: new(dao.ImageScan), Renderer: new(render.ImageScan), }, diff --git a/internal/model/table.go b/internal/model/table.go index 06a9827a..5283c401 100644 --- a/internal/model/table.go +++ b/internal/model/table.go @@ -26,6 +26,9 @@ const initRefreshRate = 300 * time.Millisecond // TableListener represents a table model listener. type TableListener interface { + // TableNoData notifies listener no data was found. + TableNoData(*model1.TableData) + // TableDataChanged notifies the model data changed. TableDataChanged(*model1.TableData) @@ -232,7 +235,12 @@ func (t *Table) refresh(ctx context.Context) error { if err := t.reconcile(ctx); err != nil { return err } - t.fireTableChanged(t.Peek()) + data := t.Peek() + if data.RowCount() == 0 { + t.fireNoData(data) + } else { + t.fireTableChanged(data) + } return nil } @@ -292,6 +300,17 @@ func (t *Table) fireTableChanged(data *model1.TableData) { } } +func (t *Table) fireNoData(data *model1.TableData) { + var ll []TableListener + t.mx.RLock() + ll = t.listeners + t.mx.RUnlock() + + for _, l := range ll { + l.TableNoData(data) + } +} + func (t *Table) fireTableLoadFailed(err error) { var ll []TableListener t.mx.RLock() diff --git a/internal/model/table_test.go b/internal/model/table_test.go index a877c5b8..a883af5e 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -76,6 +76,8 @@ type tableListener struct { count, errs int } +func (*tableListener) TableNoData(*model1.TableData) {} + func (l *tableListener) TableDataChanged(*model1.TableData) { l.count++ } diff --git a/internal/view/browser.go b/internal/view/browser.go index 92dd4009..9f8ba17a 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/derailed/k9s/internal" @@ -39,6 +40,7 @@ type Browser struct { cancelFn context.CancelFunc mx sync.RWMutex updating bool + firstView atomic.Int32 } // NewBrowser returns a new browser. @@ -279,6 +281,36 @@ func (b *Browser) Aliases() sets.Set[string] { // ---------------------------------------------------------------------------- // Model Protocol... +// TableNoData notifies view no data is available. +func (b *Browser) TableNoData(data *model1.TableData) { + var cancel context.CancelFunc + b.mx.RLock() + cancel = b.cancelFn + b.mx.RUnlock() + + if !b.app.ConOK() || cancel == nil || !b.app.IsRunning() { + return + } + if b.firstView.Load() == 0 { + b.firstView.Add(1) + return + } + + cdata := b.Update(data, b.app.Conn().HasMetrics()) + b.app.QueueUpdateDraw(func() { + if b.getUpdating() { + return + } + b.setUpdating(true) + defer b.setUpdating(false) + if b.GetColumnCount() == 0 { + b.app.Flash().Warnf("No resources found for %s in namespace %s", b.GVR(), client.PrintNamespace(b.GetNamespace())) + } + b.refreshActions() + b.UpdateUI(cdata, data) + }) +} + // TableDataChanged notifies view new data is available. func (b *Browser) TableDataChanged(data *model1.TableData) { var cancel context.CancelFunc @@ -297,6 +329,9 @@ func (b *Browser) TableDataChanged(data *model1.TableData) { } b.setUpdating(true) defer b.setUpdating(false) + if b.GetColumnCount() == 0 { + b.app.Flash().Infof("Viewing %s in namespace %s", b.GVR(), client.PrintNamespace(b.GetNamespace())) + } b.refreshActions() b.UpdateUI(cdata, data) }) @@ -488,7 +523,7 @@ func (b *Browser) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } b.setNamespace(ns) - b.app.Flash().Infof("Viewing namespace `%s`...", ns) + b.app.Flash().Infof("Viewing %s in namespace `%s`...", b.GVR(), client.PrintNamespace(ns)) b.refresh() b.UpdateTitle() b.SelectRow(1, 0, true) @@ -528,7 +563,7 @@ func (b *Browser) defaultContext() context.Context { } func (b *Browser) refreshActions() { - if b.App().Content.Top() != nil && b.App().Content.Top().Name() != b.Name() { + if top := b.App().Content.Top(); top != nil && top.Name() != b.Name() { return } aa := ui.NewKeyActionsFromMap(ui.KeyMap{ diff --git a/internal/view/command.go b/internal/view/command.go index 2e746477..9b3cacf0 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -344,7 +344,6 @@ func (c *Command) exec(p *cmd.Interpreter, gvr *client.GVR, comp model.Component } comp.SetCommand(p) - c.app.Flash().Infof("Viewing %s...", gvr) if clearStack { cmd := contextRX.ReplaceAllString(p.GetLine(), "") c.app.Config.SetActiveView(cmd) diff --git a/internal/view/registrar.go b/internal/view/registrar.go index 655a4db6..b540183b 100644 --- a/internal/view/registrar.go +++ b/internal/view/registrar.go @@ -69,7 +69,7 @@ func miscViewers(vv MetaViewers) { vv[client.CoGVR] = MetaViewer{ viewerFn: NewContainer, } - vv[client.ScGVR] = MetaViewer{ + vv[client.ScnGVR] = MetaViewer{ viewerFn: NewImageScan, } vv[client.PfGVR] = MetaViewer{ diff --git a/internal/view/vul_extender.go b/internal/view/vul_extender.go index fa6f3ca8..6c8c7eb8 100644 --- a/internal/view/vul_extender.go +++ b/internal/view/vul_extender.go @@ -35,7 +35,7 @@ func (v *VulnerabilityExtender) bindKeys(aa *ui.KeyActions) { } func (v *VulnerabilityExtender) showVulCmd(*tcell.EventKey) *tcell.EventKey { - isv := NewImageScan(client.ScGVR) + isv := NewImageScan(client.ScnGVR) isv.SetContextFn(v.selContext) if err := v.App().inject(isv, false); err != nil { v.App().Flash().Err(err) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 2dff457e..551c1973 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: k9s base: core22 -version: 'v0.50.1' +version: 'v0.50.2' 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.