diff --git a/Makefile b/Makefile index 1c7b0d0a..2443b487 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ NAME := k9s -VERSION ?= v0.50.7 +VERSION ?= v0.50.8 PACKAGE := github.com/derailed/$(NAME) OUTPUT_BIN ?= execs/${NAME} GO_FLAGS ?= diff --git a/README.md b/README.md index 5ca29107..1f6280a5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Your donations will go a long way in keeping our servers lights on and beers in [![Go Report Card](https://goreportcard.com/badge/github.com/derailed/k9s?)](https://goreportcard.com/report/github.com/derailed/k9s) [![golangci badge](https://github.com/golangci/golangci-web/blob/master/src/assets/images/badge_a_plus_flat.svg)](https://golangci.com/r/github.com/derailed/k9s) [![codebeat badge](https://codebeat.co/badges/89e5a80e-dfe8-4426-acf6-6be781e0a12e)](https://codebeat.co/projects/github-com-derailed-k9s-master) -[![Build Status](https://api.travis-ci.com/derailed/k9s.svg?branch=master)](https://travis-ci.com/derailed/k9s) [![Docker Repository on Quay](https://quay.io/repository/derailed/k9s/status "Docker Repository on Quay")](https://quay.io/repository/derailed/k9s) [![release](https://img.shields.io/github/release-pre/derailed/k9s.svg)](https://github.com/derailed/k9s/releases) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/mum4k/termdash/blob/master/LICENSE) @@ -77,17 +76,6 @@ Wanna discuss K9s features with your fellow `K9sers` or simply show your support --- -## 🥳 A Word From Our Rhodium Sponsors... - -Below are organizations that have opted to show their support and sponsor K9s. - -
-panfactum -
-
- ---- - ## Installation K9s is available on Linux, macOS and Windows platforms. @@ -407,6 +395,10 @@ You can now override the context portForward default address configuration by se k9s: # Enable periodic refresh of resource browser windows. Default false liveViewAutoRefresh: false + # !!New!! v0.50.8... + # Extends the list of supported GPU vendors. The key is the vendor name, the value must correspond to k8s resource driver designation. + gpuVendors: + bozo: bozo/gpu # The path to screen dump. Default: '%temp_dir%/k9s-screens-%username%' (k9s info) screenDumpDir: /tmp/dumps # Represents ui poll intervals in seconds. Default 2secs diff --git a/assets/sponsors/panfactum.png b/assets/sponsors/panfactum.png deleted file mode 100644 index 43fc667d..00000000 Binary files a/assets/sponsors/panfactum.png and /dev/null differ diff --git a/change_logs/release_v0.50.8.md b/change_logs/release_v0.50.8.md new file mode 100644 index 00000000..76a14a23 --- /dev/null +++ b/change_logs/release_v0.50.8.md @@ -0,0 +1,41 @@ + + +# Release v0.50.8 + +## 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/zt-3360a389v-ElLHrb0Dp1kAXqYUItSAFA) + +## Maintenance Release! + +--- + +## Resolved Issues + +* [#3453](https://github.com/derailed/k9s/issues/3453) [Feature Request] Add GPU column to pod/container view +* [#3451](https://github.com/derailed/k9s/issues/3451) Weirdness when filtering namespaces +* [#3439](https://github.com/derailed/k9s/issues/3438) Allow KnownGPUVendors customization + +--- + +## 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!! + +* [#3437](https://github.com/derailed/k9s/pull/3437) feat: Add GPU usage to pod view +* [#3421](https://github.com/derailed/k9s/pull/3421) Fix #3421 - can't switch namespaces in helm view +* [#3356](https://github.com/derailed/k9s/pull/3356) allow skin to be selected via K9S_SKIN env var + +--- + © 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/go.mod b/go.mod index 5efa6d57..487f028d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/derailed/k9s -go 1.24.4 +go 1.24.3 require ( github.com/adrg/xdg v0.5.3 @@ -29,7 +29,7 @@ require ( golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 - helm.sh/helm/v3 v3.18.3 + helm.sh/helm/v3 v3.18.4 k8s.io/api v0.33.2 k8s.io/apiextensions-apiserver v0.33.2 k8s.io/apimachinery v0.33.2 diff --git a/go.sum b/go.sum index decc5d05..d92aa1be 100644 --- a/go.sum +++ b/go.sum @@ -2604,8 +2604,8 @@ gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= -helm.sh/helm/v3 v3.18.3 h1:+cvyGKgs7Jt7BN3Klmb4SsG4IkVpA7GAZVGvMz6VO4I= -helm.sh/helm/v3 v3.18.3/go.mod h1:wUc4n3txYBocM7S9RjTeZBN9T/b5MjffpcSsWEjSIpw= +helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ= +helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/client/client.go b/internal/client/client.go index a779cd2a..a012781a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -270,6 +270,7 @@ func (a *APIClient) ValidNamespaceNames() (NamespaceNames, error) { ok, err := a.CanI(ClusterScope, NsGVR, "", ListAccess) if !ok || err != nil { + a.cache.Add(cacheNSKey, NamespaceNames{}, cacheExpiry) return nil, fmt.Errorf("user not authorized to list all namespaces") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f4504a0c..2d9c479d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -561,6 +561,9 @@ func TestConfigSaveFile(t *testing.T) { require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) cfg.K9s.RefreshRate = 100 + cfg.K9s.GPUVendors = map[string]string{ + "bozo": "bozo/gpu.com", + } cfg.K9s.APIServerTimeout = "30s" cfg.K9s.ReadOnly = true cfg.K9s.Logger.TailCount = 500 diff --git a/internal/config/json/schemas/k9s.json b/internal/config/json/schemas/k9s.json index e19099a3..e1f7954f 100644 --- a/internal/config/json/schemas/k9s.json +++ b/internal/config/json/schemas/k9s.json @@ -8,6 +8,17 @@ "additionalProperties": false, "properties": { "liveViewAutoRefresh": { "type": "boolean" }, + "gpuVendors": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "vendor": { "type": "string" }, + "model": { "type": "string" } + }, + "required": ["vendor", "model"] + } + }, "screenDumpDir": {"type": "string"}, "refreshRate": { "type": "integer" }, "apiServerTimeout": { "type": "string" }, diff --git a/internal/config/k9s.go b/internal/config/k9s.go index a3e78d50..3c77b5eb 100644 --- a/internal/config/k9s.go +++ b/internal/config/k9s.go @@ -19,7 +19,12 @@ import ( "github.com/derailed/k9s/internal/slogs" ) -var KnownGPUVendors = map[string]string{ +type gpuVendors map[string]string + +// KnownGPUVendors tracks a set of known GPU vendors. +var KnownGPUVendors = defaultGPUVendors + +var defaultGPUVendors = gpuVendors{ "nvidia": "nvidia.com/gpu", "amd": "amd.com/gpu", "intel": "gpu.intel.com/i915", @@ -28,6 +33,7 @@ var KnownGPUVendors = map[string]string{ // K9s tracks K9s configuration options. type K9s struct { LiveViewAutoRefresh bool `json:"liveViewAutoRefresh" yaml:"liveViewAutoRefresh"` + GPUVendors gpuVendors `json:"gpuVendors" yaml:"gpuVendors"` ScreenDumpDir string `json:"screenDumpDir" yaml:"screenDumpDir,omitempty"` RefreshRate int `json:"refreshRate" yaml:"refreshRate"` APIServerTimeout string `json:"apiServerTimeout" yaml:"apiServerTimeout"` @@ -60,6 +66,7 @@ type K9s struct { func NewK9s(conn client.Connection, ks data.KubeSettings) *K9s { return &K9s{ RefreshRate: defaultRefreshRate, + GPUVendors: make(gpuVendors), MaxConnRetry: defaultMaxConnRetry, APIServerTimeout: client.DefaultCallTimeoutDuration.String(), ScreenDumpDir: AppDumpsDir, @@ -121,6 +128,10 @@ func (k *K9s) Merge(k1 *K9s) { return } + for k, v := range k1.GPUVendors { + KnownGPUVendors[k] = v + } + k.LiveViewAutoRefresh = k1.LiveViewAutoRefresh k.DefaultView = k1.DefaultView k.ScreenDumpDir = k1.ScreenDumpDir diff --git a/internal/config/testdata/configs/default.yaml b/internal/config/testdata/configs/default.yaml index f4b21aaf..90d200e0 100644 --- a/internal/config/testdata/configs/default.yaml +++ b/internal/config/testdata/configs/default.yaml @@ -1,5 +1,6 @@ k9s: liveViewAutoRefresh: false + gpuVendors: {} screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 2 apiServerTimeout: 15s diff --git a/internal/config/testdata/configs/expected.yaml b/internal/config/testdata/configs/expected.yaml index 51cded39..66dce0af 100644 --- a/internal/config/testdata/configs/expected.yaml +++ b/internal/config/testdata/configs/expected.yaml @@ -1,5 +1,7 @@ k9s: liveViewAutoRefresh: true + gpuVendors: + bozo: bozo/gpu.com screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 100 apiServerTimeout: 30s diff --git a/internal/config/testdata/configs/k9s.yaml b/internal/config/testdata/configs/k9s.yaml index c8c23aa6..0404a17d 100644 --- a/internal/config/testdata/configs/k9s.yaml +++ b/internal/config/testdata/configs/k9s.yaml @@ -1,5 +1,6 @@ k9s: liveViewAutoRefresh: true + gpuVendors: {} screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 2 apiServerTimeout: 10s diff --git a/internal/dao/registry.go b/internal/dao/registry.go index 3471240d..090b19ba 100644 --- a/internal/dao/registry.go +++ b/internal/dao/registry.go @@ -112,6 +112,16 @@ func (m *Meta) GVK2GVR(gv schema.GroupVersion, kind string) (*client.GVR, bool, return client.NoGVR, false, false } +// IsNamespaced checks if a given resource is namespaced. +func (m *Meta) IsNamespaced(gvr *client.GVR) (bool, error) { + res, err := m.MetaFor(gvr) + if err != nil { + return false, err + } + + return res.Namespaced, nil +} + // MetaFor returns a resource metadata for a given gvr. func (m *Meta) MetaFor(gvr *client.GVR) (*metav1.APIResource, error) { m.mx.RLock() diff --git a/internal/model/table_int_test.go b/internal/model/table_int_test.go index 19677f5f..db3ad2ec 100644 --- a/internal/model/table_int_test.go +++ b/internal/model/table_int_test.go @@ -36,7 +36,7 @@ func TestTableReconcile(t *testing.T) { err := ta.reconcile(ctx) require.NoError(t, err) data := ta.Peek() - assert.Equal(t, 25, data.HeaderCount()) + assert.Equal(t, 26, data.HeaderCount()) assert.Equal(t, 1, data.RowCount()) assert.Equal(t, client.NamespaceAll, data.GetNamespace()) } diff --git a/internal/model/table_test.go b/internal/model/table_test.go index a883af5e..6099da2f 100644 --- a/internal/model/table_test.go +++ b/internal/model/table_test.go @@ -37,7 +37,7 @@ func TestTableRefresh(t *testing.T) { ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) require.NoError(t, ta.Refresh(ctx)) data := ta.Peek() - assert.Equal(t, 25, data.HeaderCount()) + assert.Equal(t, 26, data.HeaderCount()) assert.Equal(t, 1, data.RowCount()) assert.Equal(t, client.NamespaceAll, data.GetNamespace()) assert.Equal(t, 1, l.count) diff --git a/internal/render/container.go b/internal/render/container.go index 0039e9a6..5a7e5e32 100644 --- a/internal/render/container.go +++ b/internal/render/container.go @@ -87,13 +87,14 @@ var defaultCOHeader = model1.Header{ model1.HeaderColumn{Name: "RESTARTS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "PROBES(L:R:S)"}, model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "CPU/RL", Attrs: model1.Attrs{Align: tview.AlignRight}}, - model1.HeaderColumn{Name: "MEM/RL", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "%CPU/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "%CPU/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM/RL", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "%MEM/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "%MEM/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "GPU/RL", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "PORTS"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, @@ -110,7 +111,7 @@ func (c Container) Render(o any, _ string, row *model1.Row) error { } func (c Container) defaultRow(cr ContainerRes, r *model1.Row) error { - cur, res := gatherMetrics(cr.Container, cr.MX) + cur, res := gatherContainerMX(cr.Container, cr.MX) ready, state, restarts := falseStr, MissingValue, "0" if cr.Status != nil { ready, state, restarts = boolToStr(cr.Status.Ready), ToContainerState(cr.Status.State), strconv.Itoa(int(cr.Status.RestartCount)) @@ -127,13 +128,14 @@ func (c Container) defaultRow(cr ContainerRes, r *model1.Row) error { restarts, probe(cr.Container.LivenessProbe) + ":" + probe(cr.Container.ReadinessProbe) + ":" + probe(cr.Container.StartupProbe), toMc(cur.cpu), - toMi(cur.mem), toMc(res.cpu) + ":" + toMc(res.lcpu), - toMi(res.mem) + ":" + toMi(res.lmem), client.ToPercentageStr(cur.cpu, res.cpu), client.ToPercentageStr(cur.cpu, res.lcpu), + toMi(cur.mem), + toMi(res.mem) + ":" + toMi(res.lmem), client.ToPercentageStr(cur.mem, res.mem), client.ToPercentageStr(cur.mem, res.lmem), + toMc(res.gpu) + ":" + toMc(res.lgpu), ToContainerPorts(cr.Container.Ports), AsStatus(c.diagnose(state, ready)), ToAge(cr.Age), @@ -170,26 +172,36 @@ func containerRequests(co *v1.Container) v1.ResourceList { return nil } -func gatherMetrics(co *v1.Container, mx *mv1beta1.ContainerMetrics) (c, r metric) { +func gatherContainerMX(co *v1.Container, mx *mv1beta1.ContainerMetrics) (c, r metric) { rList, lList := containerRequests(co), co.Resources.Limits - if rList.Cpu() != nil { - r.cpu = rList.Cpu().MilliValue() + + if q := rList.Cpu(); q != nil { + r.cpu = q.MilliValue() } - if rList.Memory() != nil { - r.mem = rList.Memory().Value() + if q := lList.Cpu(); q != nil { + r.lcpu = q.MilliValue() } - if lList.Cpu() != nil { - r.lcpu = lList.Cpu().MilliValue() + + if q := rList.Memory(); q != nil { + r.mem = q.Value() } - if lList.Memory() != nil { - r.lmem = lList.Memory().Value() + if q := lList.Memory(); q != nil { + r.lmem = q.Value() } + + if q := extractGPU(rList); q != nil { + r.gpu = q.Value() + } + if q := extractGPU(lList); q != nil { + r.lgpu = q.Value() + } + if mx != nil { - if mx.Usage.Cpu() != nil { - c.cpu = mx.Usage.Cpu().MilliValue() + if q := mx.Usage.Cpu(); q != nil { + c.cpu = q.MilliValue() } - if mx.Usage.Memory() != nil { - c.mem = mx.Usage.Memory().Value() + if q := mx.Usage.Memory(); q != nil { + c.mem = q.Value() } } diff --git a/internal/render/container_int_test.go b/internal/render/container_int_test.go new file mode 100644 index 00000000..d1fae2f9 --- /dev/null +++ b/internal/render/container_int_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package render + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" +) + +func Test_gatherContainerMX(t *testing.T) { + uu := map[string]struct { + container v1.Container + mx *mv1beta1.ContainerMetrics + c, r metric + }{ + "empty": {}, + + "amd-request": { + container: v1.Container{ + Name: "fred", + Image: "img", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + "nvidia.com/gpu": resource.MustParse("1"), + }, + }, + }, + mx: &mv1beta1.ContainerMetrics{ + Name: "fred", + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + }, + }, + c: metric{ + cpu: 10, + mem: 20971520, + }, + r: metric{ + cpu: 10, + gpu: 1, + mem: 20971520, + }, + }, + + "amd-both": { + container: v1.Container{ + Name: "fred", + Image: "img", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + "nvidia.com/gpu": resource.MustParse("1"), + }, + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("50m"), + v1.ResourceMemory: resource.MustParse("100Mi"), + "nvidia.com/gpu": resource.MustParse("2"), + }, + }, + }, + mx: &mv1beta1.ContainerMetrics{ + Name: "fred", + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + }, + }, + c: metric{ + cpu: 10, + mem: 20971520, + }, + r: metric{ + cpu: 10, + gpu: 1, + mem: 20971520, + lcpu: 50, + lgpu: 2, + lmem: 104857600, + }, + }, + + "amd-limits": { + container: v1.Container{ + Name: "fred", + Image: "img", + Resources: v1.ResourceRequirements{ + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("50m"), + v1.ResourceMemory: resource.MustParse("100Mi"), + "nvidia.com/gpu": resource.MustParse("2"), + }, + }, + }, + mx: &mv1beta1.ContainerMetrics{ + Name: "fred", + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + }, + }, + c: metric{ + cpu: 10, + mem: 20971520, + }, + r: metric{ + cpu: 50, + gpu: 2, + mem: 104857600, + lcpu: 50, + lgpu: 2, + lmem: 104857600, + }, + }, + + "amd-no-mx": { + container: v1.Container{ + Name: "fred", + Image: "img", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("10m"), + v1.ResourceMemory: resource.MustParse("20Mi"), + "nvidia.com/gpu": resource.MustParse("1"), + }, + Limits: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("50m"), + v1.ResourceMemory: resource.MustParse("100Mi"), + "nvidia.com/gpu": resource.MustParse("2"), + }, + }, + }, + r: metric{ + cpu: 10, + gpu: 1, + mem: 20971520, + lcpu: 50, + lgpu: 2, + lmem: 104857600, + }, + }, + } + + for k, u := range uu { + t.Run(k, func(t *testing.T) { + c, r := gatherContainerMX(&u.container, u.mx) + assert.Equal(t, u.c, c) + assert.Equal(t, u.r, r) + }) + } +} diff --git a/internal/render/container_test.go b/internal/render/container_test.go index 46169743..b941491e 100644 --- a/internal/render/container_test.go +++ b/internal/render/container_test.go @@ -40,13 +40,14 @@ func TestContainer(t *testing.T) { "0", "off:off:off", "10", - "20", "20:20", + "50", + "50", + "20", "100:100", - "50", - "50", "20", "20", + "0:0", "", "container is not ready", }, diff --git a/internal/render/helpers.go b/internal/render/helpers.go index 3d180869..48ebcf82 100644 --- a/internal/render/helpers.go +++ b/internal/render/helpers.go @@ -265,6 +265,14 @@ func mapToIfc(m any) (s string) { return } +func toMu(v int64) string { + if v == 0 { + return NAValue + } + + return strconv.Itoa(int(v)) +} + func toMc(v int64) string { if v == 0 { return ZeroValue diff --git a/internal/render/helpers_test.go b/internal/render/helpers_test.go index 2bd2fe05..b3c4f23f 100644 --- a/internal/render/helpers_test.go +++ b/internal/render/helpers_test.go @@ -56,7 +56,7 @@ func TestTableHydrate(t *testing.T) { re := NewPod() require.NoError(t, model1.Hydrate("blee", oo, rr, re)) assert.Len(t, rr, 1) - assert.Len(t, rr[0].Fields, 25) + assert.Len(t, rr[0].Fields, 26) } func TestToAge(t *testing.T) { diff --git a/internal/render/node.go b/internal/render/node.go index d29b779e..5636ed15 100644 --- a/internal/render/node.go +++ b/internal/render/node.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/derailed/k9s/internal/client" - "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tview" @@ -42,12 +41,13 @@ var defaultNOHeader = model1.Header{ model1.HeaderColumn{Name: "EXTERNAL-IP", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "PODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "%CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "%MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "CPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "%CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "MEM/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "GPU"}, + model1.HeaderColumn{Name: "%MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "GPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "GPU/C", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, @@ -97,6 +97,7 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error { iIP, eIP = missing(iIP), missing(eIP) c, a := gatherNodeMX(&no, nwm.MX) + statuses := make(sort.StringSlice, 10) status(no.Status.Conditions, no.Spec.Unschedulable, statuses) sort.Sort(statuses) @@ -122,12 +123,13 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error { eIP, podCount, toMc(c.cpu), - toMi(c.mem), - client.ToPercentageStr(c.cpu, a.cpu), - client.ToPercentageStr(c.mem, a.mem), toMc(a.cpu), + client.ToPercentageStr(c.cpu, a.cpu), + toMi(c.mem), toMi(a.mem), - n.gpuSpec(no.Status.Capacity, no.Status.Allocatable), + client.ToPercentageStr(c.mem, a.mem), + toMu(a.gpu), + toMu(c.gpu), mapToStr(no.Labels), AsStatus(n.diagnose(statuses)), ToAge(no.GetCreationTimestamp()), @@ -136,21 +138,6 @@ func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error { return nil } -func (Node) gpuSpec(capacity, allocatable v1.ResourceList) string { - spec := NAValue - for k, v := range config.KnownGPUVendors { - key := v1.ResourceName(v) - if capacity, ok := capacity[key]; ok { - if allocs, ok := allocatable[key]; ok { - spec = fmt.Sprintf("%s/%s (%s)", capacity.String(), allocs.String(), k) - break - } - } - } - - return spec -} - // Healthy checks component health. func (n Node) Healthy(_ context.Context, o any) error { nwm, ok := o.(*NodeWithMetrics) @@ -216,16 +203,21 @@ func (n *NodeWithMetrics) DeepCopyObject() runtime.Object { } type metric struct { - cpu, mem int64 - lcpu, lmem int64 + cpu, gpu, mem int64 + lcpu, lgpu, lmem int64 } func gatherNodeMX(no *v1.Node, mx *mv1beta1.NodeMetrics) (c, a metric) { - a.cpu, a.mem = no.Status.Allocatable.Cpu().MilliValue(), no.Status.Allocatable.Memory().Value() + a.cpu = no.Status.Allocatable.Cpu().MilliValue() + a.mem = no.Status.Allocatable.Memory().Value() if mx != nil { - c.cpu, c.mem = mx.Usage.Cpu().MilliValue(), mx.Usage.Memory().Value() + c.cpu = mx.Usage.Cpu().MilliValue() + c.mem = mx.Usage.Memory().Value() } + a.gpu = extractGPU(no.Status.Allocatable).Value() + c.gpu = extractGPU(no.Status.Capacity).Value() + return } diff --git a/internal/render/node_int_test.go b/internal/render/node_int_test.go index 74cc904d..dab462cd 100644 --- a/internal/render/node_int_test.go +++ b/internal/render/node_int_test.go @@ -6,73 +6,122 @@ import ( "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) -func Test_gpuSpec(t *testing.T) { +func Test_gatherNodeMX(t *testing.T) { uu := map[string]struct { - capacity v1.ResourceList - allocatable v1.ResourceList - e string + node v1.Node + nMX *mv1beta1.NodeMetrics + ec, ea metric }{ - "empty": { - e: NAValue, - }, + "empty": {}, "nvidia": { - capacity: v1.ResourceList{ - v1.ResourceName("nvidia.com/gpu"): resource.MustParse("2"), + node: v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nvidia", + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("3"), + v1.ResourceMemory: resource.MustParse("4Gi"), + v1.ResourceName("nvidia.com/gpu"): resource.MustParse("2"), + }, + Allocatable: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("8"), + v1.ResourceMemory: resource.MustParse("8Gi"), + v1.ResourceName("nvidia.com/gpu"): resource.MustParse("4"), + }, + }, }, - allocatable: v1.ResourceList{ - v1.ResourceName("nvidia.com/gpu"): resource.MustParse("4"), + nMX: &mv1beta1.NodeMetrics{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nvidia", + }, + Usage: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("3"), + v1.ResourceMemory: resource.MustParse("4Gi"), + v1.ResourceName("nvidia.com/gpu"): resource.MustParse("2"), + }, + }, + ea: metric{ + cpu: 8000, + mem: 8589934592, + gpu: 4, + }, + ec: metric{ + cpu: 3000, + mem: 4294967296, + gpu: 2, }, - e: "2/4 (nvidia)", }, "intel": { - capacity: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("2"), + node: v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "intel", + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("3"), + v1.ResourceMemory: resource.MustParse("4Gi"), + v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("2"), + }, + Allocatable: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("8"), + v1.ResourceMemory: resource.MustParse("8Gi"), + v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("4"), + }, + }, }, - allocatable: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("4"), + ea: metric{ + cpu: 8000, + mem: 8589934592, + gpu: 4, + }, + ec: metric{ + cpu: 0, + mem: 0, + gpu: 2, }, - e: "2/4 (intel)", }, - "amd": { - capacity: v1.ResourceList{ - v1.ResourceName("amd.com/gpu"): resource.MustParse("2"), + "unknown-vendor": { + node: v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "amd", + }, + Status: v1.NodeStatus{ + Capacity: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("3"), + v1.ResourceMemory: resource.MustParse("4Gi"), + v1.ResourceName("bozo/gpu"): resource.MustParse("2"), + }, + Allocatable: v1.ResourceList{ + v1.ResourceCPU: resource.MustParse("8"), + v1.ResourceMemory: resource.MustParse("8Gi"), + v1.ResourceName("bozo/gpu"): resource.MustParse("4"), + }, + }, }, - allocatable: v1.ResourceList{ - v1.ResourceName("amd.com/gpu"): resource.MustParse("4"), + ea: metric{ + cpu: 8000, + mem: 8589934592, + gpu: 0, }, - e: "2/4 (amd)", - }, - - "toast-cap": { - capacity: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/iBOZO"): resource.MustParse("2"), + ec: metric{ + gpu: 0, }, - allocatable: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("4"), - }, - e: NAValue, - }, - - "toast-alloc": { - capacity: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("2"), - }, - allocatable: v1.ResourceList{ - v1.ResourceName("gpu.intel.com/iBOZO"): resource.MustParse("4"), - }, - e: NAValue, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { - var n Node - assert.Equal(t, u.e, n.gpuSpec(u.capacity, u.allocatable)) + c, a := gatherNodeMX(&u.node, u.nMX) + assert.Equal(t, u.ec, c) + assert.Equal(t, u.ea, a) }) } } diff --git a/internal/render/node_test.go b/internal/render/node_test.go index b361d96d..20eba52b 100644 --- a/internal/render/node_test.go +++ b/internal/render/node_test.go @@ -26,8 +26,8 @@ func TestNodeRender(t *testing.T) { require.NoError(t, err) assert.Equal(t, "minikube", r.ID) - e := model1.Fields{"minikube", "Ready", "master", "amd64", "0", "v1.15.2", "Buildroot 2018.05.3", "4.15.0", "192.168.64.107", "", "0", "10", "20", "0", "0", "4000", "7874", "n/a"} - assert.Equal(t, e, r.Fields[:18]) + e := model1.Fields{"minikube", "Ready", "master", "amd64", "0", "v1.15.2", "Buildroot 2018.05.3", "4.15.0", "192.168.64.107", "", "0", "10", "4000", "0", "20", "7874", "0", "n/a", "n/a"} + assert.Equal(t, e, r.Fields[:19]) } func BenchmarkNodeRender(b *testing.B) { diff --git a/internal/render/pod.go b/internal/render/pod.go index 21bbcc23..0d11ad98 100644 --- a/internal/render/pod.go +++ b/internal/render/pod.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/derailed/k9s/internal/client" + "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tcell/v2" @@ -60,13 +61,14 @@ var defaultPodHeader = model1.Header{ model1.HeaderColumn{Name: "RESTARTS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "LAST RESTART", Attrs: model1.Attrs{Align: tview.AlignRight, Time: true, Wide: true}}, model1.HeaderColumn{Name: "CPU", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, - model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "CPU/RL", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, - model1.HeaderColumn{Name: "MEM/RL", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, model1.HeaderColumn{Name: "%CPU/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "%CPU/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "MEM/RL", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, model1.HeaderColumn{Name: "%MEM/R", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "%MEM/L", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, + model1.HeaderColumn{Name: "GPU/RL", Attrs: model1.Attrs{Align: tview.AlignRight, Wide: true}}, model1.HeaderColumn{Name: "IP"}, model1.HeaderColumn{Name: "NODE"}, model1.HeaderColumn{Name: "SERVICE-ACCOUNT", Attrs: model1.Attrs{Wide: true}}, @@ -168,7 +170,7 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error { if pwm.MX != nil { ccmx = pwm.MX.Containers } - c, r := gatherCoMX(spec, ccmx) + c, r := gatherPodMX(spec, ccmx) phase := p.Phase(dt, spec, &st) ns, n := pwm.Raw.GetNamespace(), pwm.Raw.GetName() @@ -184,13 +186,14 @@ func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error { strconv.Itoa(cRestarts + iRestarts), ToAge(lastRestart), toMc(c.cpu), - toMi(c.mem), toMc(r.cpu) + ":" + toMc(r.lcpu), - toMi(r.mem) + ":" + toMi(r.lmem), client.ToPercentageStr(c.cpu, r.cpu), client.ToPercentageStr(c.cpu, r.lcpu), + toMi(c.mem), + toMi(r.mem) + ":" + toMi(r.lmem), client.ToPercentageStr(c.mem, r.mem), client.ToPercentageStr(c.mem, r.lmem), + toMc(r.gpu) + ":" + toMc(r.lgpu), na(st.PodIP), na(spec.NodeName), na(spec.ServiceAccountName), @@ -295,16 +298,16 @@ func (p *PodWithMetrics) DeepCopyObject() runtime.Object { return p } -func gatherCoMX(spec *v1.PodSpec, ccmx []mv1beta1.ContainerMetrics) (c, r metric) { +func gatherPodMX(spec *v1.PodSpec, ccmx []mv1beta1.ContainerMetrics) (c, r metric) { cc := make([]v1.Container, 0, len(spec.InitContainers)+len(spec.Containers)) cc = append(cc, filterSidecarCO(spec.InitContainers)...) cc = append(cc, spec.Containers...) - rcpu, rmem := cosRequests(cc) - r.cpu, r.mem = rcpu.MilliValue(), rmem.Value() + rcpu, rmem, rgpu := cosRequests(cc) + r.cpu, r.mem, r.gpu = rcpu.MilliValue(), rmem.Value(), rgpu.Value() - lcpu, lmem := cosLimits(cc) - r.lcpu, r.lmem = lcpu.MilliValue(), lmem.Value() + lcpu, lmem, lgpu := cosLimits(cc) + r.lcpu, r.lmem, r.lgpu = lcpu.MilliValue(), lmem.Value(), lgpu.Value() ccpu, cmem := currentRes(ccmx) c.cpu, c.mem = ccpu.MilliValue(), cmem.Value() @@ -312,52 +315,69 @@ func gatherCoMX(spec *v1.PodSpec, ccmx []mv1beta1.ContainerMetrics) (c, r metric return } -func cosLimits(cc []v1.Container) (cpuQ, memQ resource.Quantity) { - cpu, mem := new(resource.Quantity), new(resource.Quantity) +func cosLimits(cc []v1.Container) (cpuQ, memQ, gpuQ *resource.Quantity) { + cpuQ, gpuQ, memQ = new(resource.Quantity), new(resource.Quantity), new(resource.Quantity) for i := range cc { limits := cc[i].Resources.Limits if len(limits) == 0 { continue } - if limits.Cpu() != nil { - cpu.Add(*limits.Cpu()) + if q := limits.Cpu(); q != nil { + cpuQ.Add(*q) } - if limits.Memory() != nil { - mem.Add(*limits.Memory()) + if q := limits.Memory(); q != nil { + memQ.Add(*q) + } + if q := extractGPU(limits); q != nil { + gpuQ.Add(*q) } } - return *cpu, *mem + return } -func cosRequests(cc []v1.Container) (cpuQ, memQ resource.Quantity) { - cpu, mem := new(resource.Quantity), new(resource.Quantity) +func cosRequests(cc []v1.Container) (cpuQ, memQ, gpuQ *resource.Quantity) { + cpuQ, gpuQ, memQ = new(resource.Quantity), new(resource.Quantity), new(resource.Quantity) for i := range cc { co := cc[i] rl := containerRequests(&co) - if rl.Cpu() != nil { - cpu.Add(*rl.Cpu()) + if q := rl.Cpu(); q != nil { + cpuQ.Add(*q) } - if rl.Memory() != nil { - mem.Add(*rl.Memory()) + if q := rl.Memory(); q != nil { + memQ.Add(*q) + } + if q := extractGPU(rl); q != nil { + gpuQ.Add(*q) } } - return *cpu, *mem + return } -func currentRes(ccmx []mv1beta1.ContainerMetrics) (cpuQ, memQ resource.Quantity) { - cpu, mem := new(resource.Quantity), new(resource.Quantity) +func extractGPU(rl v1.ResourceList) *resource.Quantity { + for _, v := range config.KnownGPUVendors { + if q, ok := rl[v1.ResourceName(v)]; ok { + return &q + } + } + + return &resource.Quantity{Format: resource.DecimalSI} +} + +func currentRes(ccmx []mv1beta1.ContainerMetrics) (cpuQ, memQ *resource.Quantity) { + cpuQ = new(resource.Quantity) + memQ = new(resource.Quantity) if ccmx == nil { - return *cpu, *mem + return } for _, co := range ccmx { c, m := co.Usage.Cpu(), co.Usage.Memory() - cpu.Add(*c) - mem.Add(*m) + cpuQ.Add(*c) + memQ.Add(*m) } - return *cpu, *mem + return } func (*Pod) mapQOS(class v1.PodQOSClass) string { @@ -396,7 +416,7 @@ func (*Pod) ContainerStats(cc []v1.ContainerStatus) (readyCnt, terminatedCnt, re func (*Pod) initContainerStats(cc []v1.Container, cos []v1.ContainerStatus) (ready, total, restart int) { for i := range cos { - if !IsSideCarContainer(cc[i].RestartPolicy) { + if !isSideCarContainer(cc[i].RestartPolicy) { continue } total++ @@ -462,7 +482,7 @@ func (*Pod) initContainerPhase(spec *v1.PodSpec, pst *v1.PodStatus, status strin sidecars := sets.New[string]() for i := range spec.InitContainers { co := spec.InitContainers[i] - if IsSideCarContainer(co.RestartPolicy) { + if isSideCarContainer(co.RestartPolicy) { sidecars.Insert(co.Name) } } @@ -589,16 +609,15 @@ func hasPodReadyCondition(conditions []v1.PodCondition) bool { return false } -func IsSideCarContainer(p *v1.ContainerRestartPolicy) bool { +func isSideCarContainer(p *v1.ContainerRestartPolicy) bool { return p != nil && *p == v1.ContainerRestartPolicyAlways } func filterSidecarCO(cc []v1.Container) []v1.Container { rcc := make([]v1.Container, 0, len(cc)) for i := range cc { - c := cc[i] - if c.RestartPolicy != nil && *c.RestartPolicy == v1.ContainerRestartPolicyAlways { - rcc = append(rcc, c) + if isSideCarContainer(cc[i].RestartPolicy) { + rcc = append(rcc, cc[i]) } } diff --git a/internal/render/pod_int_test.go b/internal/render/pod_int_test.go index 7eef0224..226ce08a 100644 --- a/internal/render/pod_int_test.go +++ b/internal/render/pod_int_test.go @@ -27,6 +27,7 @@ func Test_checkInitContainerStatus(t *testing.T) { "none": { e: "Init:0/0", }, + "restart": { status: v1.ContainerStatus{ Name: "ic1", @@ -36,6 +37,7 @@ func Test_checkInitContainerStatus(t *testing.T) { restart: true, e: "Init:0/0", }, + "no-restart": { status: v1.ContainerStatus{ Name: "ic1", @@ -44,6 +46,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:0/0", }, + "terminated-reason": { status: v1.ContainerStatus{ Name: "ic1", @@ -56,6 +59,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:blah", }, + "terminated-signal": { status: v1.ContainerStatus{ Name: "ic1", @@ -68,6 +72,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:Signal:9", }, + "terminated-code": { status: v1.ContainerStatus{ Name: "ic1", @@ -79,6 +84,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:ExitCode:1", }, + "terminated-restart": { status: v1.ContainerStatus{ Name: "ic1", @@ -89,6 +95,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, }, }, + "waiting": { status: v1.ContainerStatus{ Name: "ic1", @@ -100,6 +107,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:blah", }, + "waiting-init": { status: v1.ContainerStatus{ Name: "ic1", @@ -111,6 +119,7 @@ func Test_checkInitContainerStatus(t *testing.T) { }, e: "Init:0/0", }, + "running": { status: v1.ContainerStatus{ Name: "ic1", @@ -137,11 +146,13 @@ func Test_containerPhase(t *testing.T) { ok bool }{ "none": {}, + "empty": { status: v1.PodStatus{ Phase: PhaseUnknown, }, }, + "waiting": { status: v1.PodStatus{ Phase: PhaseUnknown, @@ -166,6 +177,7 @@ func Test_containerPhase(t *testing.T) { }, e: "waiting", }, + "terminated": { status: v1.PodStatus{ Phase: PhaseUnknown, @@ -190,6 +202,7 @@ func Test_containerPhase(t *testing.T) { }, e: "done", }, + "terminated-sig": { status: v1.PodStatus{ Phase: PhaseUnknown, @@ -214,6 +227,7 @@ func Test_containerPhase(t *testing.T) { }, e: "Signal:9", }, + "terminated-code": { status: v1.PodStatus{ Phase: PhaseUnknown, @@ -238,6 +252,7 @@ func Test_containerPhase(t *testing.T) { }, e: "ExitCode:2", }, + "running": { status: v1.PodStatus{ Phase: PhaseUnknown, @@ -274,18 +289,20 @@ func Test_containerPhase(t *testing.T) { } } -func Test_restartableInitCO(t *testing.T) { +func Test_isSideCarContainer(t *testing.T) { always, never := v1.ContainerRestartPolicyAlways, v1.ContainerRestartPolicy("never") uu := map[string]struct { p *v1.ContainerRestartPolicy e bool }{ "empty": {}, - "set": { + + "sidecar": { p: &always, e: true, }, - "unset": { + + "no-sidecar": { p: &never, }, } @@ -293,7 +310,7 @@ func Test_restartableInitCO(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - assert.Equal(t, u.e, IsSideCarContainer(u.p)) + assert.Equal(t, u.e, isSideCarContainer(u.p)) }) } } @@ -308,6 +325,7 @@ func Test_filterSidecarCO(t *testing.T) { cc: []v1.Container{}, ecc: []v1.Container{}, }, + "restartable": { cc: []v1.Container{ { @@ -322,6 +340,7 @@ func Test_filterSidecarCO(t *testing.T) { }, }, }, + "not-restartable": { cc: []v1.Container{ { @@ -330,6 +349,7 @@ func Test_filterSidecarCO(t *testing.T) { }, ecc: []v1.Container{}, }, + "mixed": { cc: []v1.Container{ { @@ -433,7 +453,7 @@ func Test_lastRestart(t *testing.T) { } } -func Test_gatherPodMx(t *testing.T) { +func Test_gatherPodMX(t *testing.T) { uu := map[string]struct { spec *v1.PodSpec mx []mv1beta1.ContainerMetrics @@ -452,15 +472,19 @@ func Test_gatherPodMx(t *testing.T) { c: metric{ cpu: 1, mem: 22 * client.MegaByte, + gpu: 1, }, r: metric{ cpu: 10, mem: 1 * client.MegaByte, + gpu: 1, lcpu: 20, lmem: 2 * client.MegaByte, + lgpu: 1, }, perc: "10", }, + "multi": { spec: &v1.PodSpec{ Containers: []v1.Container{ @@ -471,8 +495,10 @@ func Test_gatherPodMx(t *testing.T) { }, r: metric{ cpu: 11 + 93 + 11, + gpu: 1, mem: (22 + 1402 + 34) * client.MegaByte, lcpu: 111 + 0 + 0, + lgpu: 1, lmem: (44 + 2804 + 69) * client.MegaByte, }, mx: []mv1beta1.ContainerMetrics{ @@ -482,10 +508,12 @@ func Test_gatherPodMx(t *testing.T) { }, c: metric{ cpu: 1 + 51 + 1, + gpu: 1, mem: (22 + 1275 + 27) * client.MegaByte, }, perc: "46", }, + "sidecar": { spec: &v1.PodSpec{ Containers: []v1.Container{ @@ -497,8 +525,10 @@ func Test_gatherPodMx(t *testing.T) { }, r: metric{ cpu: 11 + 93, + gpu: 1, mem: (22 + 1402) * client.MegaByte, lcpu: 111 + 0, + lgpu: 1, lmem: (44 + 2804) * client.MegaByte, }, mx: []mv1beta1.ContainerMetrics{ @@ -507,6 +537,7 @@ func Test_gatherPodMx(t *testing.T) { }, c: metric{ cpu: 1 + 51, + gpu: 1, mem: (22 + 1275) * client.MegaByte, }, perc: "50", @@ -516,16 +547,19 @@ func Test_gatherPodMx(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - c, r := gatherCoMX(u.spec, u.mx) + c, r := gatherPodMX(u.spec, u.mx) assert.Equal(t, u.c.cpu, c.cpu) assert.Equal(t, u.c.mem, c.mem) assert.Equal(t, u.c.lcpu, c.lcpu) assert.Equal(t, u.c.lmem, c.lmem) + assert.Equal(t, u.c.lgpu, c.lgpu) assert.Equal(t, u.r.cpu, r.cpu) assert.Equal(t, u.r.mem, r.mem) assert.Equal(t, u.r.lcpu, r.lcpu) assert.Equal(t, u.r.lmem, r.lmem) + assert.Equal(t, u.r.gpu, r.gpu) + assert.Equal(t, u.r.lgpu, r.lgpu) assert.Equal(t, u.perc, client.ToPercentageStr(c.cpu, r.cpu)) }) @@ -555,9 +589,10 @@ func Test_podLimits(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - c, m := cosLimits(u.cc) + c, m, g := cosLimits(u.cc) assert.True(t, c.Equal(*u.l.Cpu())) assert.True(t, m.Equal(*u.l.Memory())) + assert.True(t, g.Equal(*extractGPU(u.l))) }) } } @@ -565,29 +600,31 @@ func Test_podLimits(t *testing.T) { func Test_podRequests(t *testing.T) { uu := map[string]struct { cc []v1.Container - l v1.ResourceList + e v1.ResourceList }{ "plain": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), }, - l: makeRes("10m", "1Mi"), + e: makeRes("10m", "1Mi"), }, + "multi-co": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), makeContainer("c2", false, "10m", "1Mi", "40m", "4Mi"), }, - l: makeRes("20m", "2Mi"), + e: makeRes("20m", "2Mi"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - c, m := cosRequests(u.cc) - assert.True(t, c.Equal(*u.l.Cpu())) - assert.True(t, m.Equal(*u.l.Memory())) + c, m, g := cosRequests(u.cc) + assert.True(t, c.Equal(*u.e.Cpu())) + assert.True(t, m.Equal(*u.e.Memory())) + assert.True(t, g.Equal(*extractGPU(u.e))) }) } } @@ -611,10 +648,12 @@ func makeContainer(n string, restartable bool, rc, rm, lc, lm string) v1.Contain func makeRes(c, m string) v1.ResourceList { cpu, _ := res.ParseQuantity(c) mem, _ := res.ParseQuantity(m) + gpu, _ := res.ParseQuantity(c) return v1.ResourceList{ - v1.ResourceCPU: cpu, - v1.ResourceMemory: mem, + v1.ResourceCPU: cpu, + v1.ResourceMemory: mem, + v1.ResourceName("nvidia.com/gpu"): gpu, } } diff --git a/internal/render/pod_test.go b/internal/render/pod_test.go index 63251daa..efd11add 100644 --- a/internal/render/pod_test.go +++ b/internal/render/pod_test.go @@ -164,8 +164,8 @@ func TestPodRender(t *testing.T) { require.NoError(t, err) assert.Equal(t, "default/nginx", r.ID) - e := model1.Fields{"default", "nginx", "0", "●", "1/1", "Running", "0", "", "100", "50", "100:0", "70:170", "100", "n/a", "71", "29", "172.17.0.6", "minikube", "default", ""} - assert.Equal(t, e, r.Fields[:20]) + e := model1.Fields{"default", "nginx", "0", "●", "1/1", "Running", "0", "", "100", "100:0", "100", "n/a", "50", "70:170", "71", "29", "0:0", "172.17.0.6", "minikube", "default", ""} + assert.Equal(t, e, r.Fields[:21]) } func BenchmarkPodRender(b *testing.B) { @@ -195,8 +195,8 @@ func TestPodInitRender(t *testing.T) { require.NoError(t, err) assert.Equal(t, "default/nginx", r.ID) - e := model1.Fields{"default", "nginx", "0", "●", "1/1", "Init:0/1", "0", "", "10", "10", "100:0", "70:170", "10", "n/a", "14", "5", "172.17.0.6", "minikube", "default", ""} - assert.Equal(t, e, r.Fields[:20]) + e := model1.Fields{"default", "nginx", "0", "●", "1/1", "Init:0/1", "0", "", "10", "100:0", "10", "n/a", "10", "70:170", "14", "5", "0:0", "172.17.0.6", "minikube", "default", ""} + assert.Equal(t, e, r.Fields[:21]) } func TestPodSidecarRender(t *testing.T) { @@ -211,8 +211,8 @@ func TestPodSidecarRender(t *testing.T) { require.NoError(t, err) assert.Equal(t, "default/sleep", r.ID) - e := model1.Fields{"default", "sleep", "0", "●", "2/2", "Running", "0", "", "100", "40", "50:250", "50:80", "200", "40", "80", "50", "10.244.0.8", "kind-control-plane", "default", ""} - assert.Equal(t, e, r.Fields[:20]) + e := model1.Fields{"default", "sleep", "0", "●", "2/2", "Running", "0", "", "100", "50:250", "200", "40", "40", "50:80", "80", "50", "0:0", "10.244.0.8", "kind-control-plane", "default", ""} + assert.Equal(t, e, r.Fields[:21]) } func TestCheckPodStatus(t *testing.T) { diff --git a/internal/view/browser.go b/internal/view/browser.go index 6b25be6d..ac207e3f 100644 --- a/internal/view/browser.go +++ b/internal/view/browser.go @@ -174,7 +174,12 @@ func (b *Browser) Start() { b.Table.Start() b.CmdBuff().AddListener(b) if err := b.GetModel().Watch(b.prepareContext()); err != nil { - b.App().Flash().Errf("Watcher failed for %s -- %s", b.GVR(), err) + go func() { + time.Sleep(500 * time.Millisecond) + b.app.QueueUpdateDraw(func() { + b.App().Flash().Errf("Watcher failed for %s -- %s", b.GVR(), err) + }) + }() } } @@ -335,7 +340,11 @@ func (b *Browser) TableDataChanged(mdata *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())) + if client.IsClusterScoped(b.GetNamespace()) { + b.app.Flash().Infof("Viewing %s...", b.GVR()) + } else { + b.app.Flash().Infof("Viewing %s in namespace %s", b.GVR(), client.PrintNamespace(b.GetNamespace())) + } } b.refreshActions() b.UpdateUI(cdata, mdata) @@ -518,7 +527,7 @@ func (b *Browser) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { auth, err := b.App().factory.Client().CanI(ns, b.GVR(), "", client.ListAccess) if !auth { if err == nil { - err = fmt.Errorf("current user can't access namespace %s", ns) + err = fmt.Errorf("access denied for user on: %s/%s", ns, b.GVR()) } b.App().Flash().Err(err) return nil @@ -529,7 +538,11 @@ func (b *Browser) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } b.setNamespace(ns) - b.app.Flash().Infof("Viewing %s in namespace `%s`...", b.GVR(), client.PrintNamespace(ns)) + if client.IsClusterScoped(ns) { + b.app.Flash().Infof("Viewing %s...", b.GVR()) + } else { + b.app.Flash().Infof("Viewing %s in namespace `%s`...", b.GVR(), client.PrintNamespace(ns)) + } b.refresh() b.UpdateTitle() b.SelectRow(1, 0, true) @@ -628,9 +641,12 @@ func (b *Browser) namespaceActions(aa *ui.KeyActions) { aa.Add(ui.KeyN, ui.NewKeyAction("Copy Namespace", b.cpNsCmd, false)) b.namespaces = make(map[int]string, data.MaxFavoritesNS) - aa.Add(ui.Key0, ui.NewKeyAction(client.NamespaceAll, b.switchNamespaceCmd, true)) - b.namespaces[0] = client.NamespaceAll - index := 1 + var index int + if ok, _ := b.app.Conn().CanI(client.NamespaceAll, client.NsGVR, "", client.ListAccess); ok { + aa.Add(ui.Key0, ui.NewKeyAction(client.NamespaceAll, b.switchNamespaceCmd, true)) + b.namespaces[0] = client.NamespaceAll + index = 1 + } favNamespaces := b.app.Config.FavNamespaces() for _, ns := range favNamespaces { if ns == client.NamespaceAll { diff --git a/internal/view/cmd/interpreter.go b/internal/view/cmd/interpreter.go index 27b74158..22bf0fca 100644 --- a/internal/view/cmd/interpreter.go +++ b/internal/view/cmd/interpreter.go @@ -27,13 +27,24 @@ func NewInterpreter(s string) *Interpreter { return &c } -func (c *Interpreter) TrimNS() string { +// ClearNS clears the current namespace if any. +func (c *Interpreter) ClearNS() { if !c.HasNS() { - return c.line + return } - ns, _ := c.NSArg() + if ons, ok := c.NSArg(); ok { + c.Reset(strings.TrimSpace(strings.Replace(c.line, " "+ons, "", 1))) + } +} - return strings.TrimSpace(strings.Replace(c.line, ns, "", 1)) +// SwitchNS replaces the current namespace with the provided one. +func (c *Interpreter) SwitchNS(ns string) { + if !c.HasNS() { + c.Reset(c.line + " " + ns) + } + if ons, ok := c.NSArg(); ok { + c.Reset(strings.TrimSpace(strings.Replace(c.line, ons, ns, 1))) + } } func (c *Interpreter) grok() { diff --git a/internal/view/cmd/interpreter_test.go b/internal/view/cmd/interpreter_test.go index 9296911b..2672bf36 100644 --- a/internal/view/cmd/interpreter_test.go +++ b/internal/view/cmd/interpreter_test.go @@ -77,6 +77,76 @@ func TestNsCmd(t *testing.T) { } } +func TestSwitchNS(t *testing.T) { + uu := map[string]struct { + cmd string + ns string + e string + }{ + "empty": {}, + + "no-op": { + cmd: "pod fred", + ns: "blee", + e: "pod blee", + }, + + "no-ns": { + cmd: "pod", + ns: "blee", + e: "pod blee", + }, + + "happy": { + cmd: "pod app=blee @zorg fred", + ns: "blee", + e: "pod app=blee @zorg blee", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + p := cmd.NewInterpreter(u.cmd) + p.SwitchNS(u.ns) + assert.Equal(t, u.e, p.GetLine()) + }) + } +} + +func TestClearNS(t *testing.T) { + uu := map[string]struct { + cmd string + e string + }{ + "empty": {}, + + "no-op": { + cmd: "pod fred", + e: "pod", + }, + + "no-ns": { + cmd: "pod", + e: "pod", + }, + + "happy": { + cmd: "pod app=blee @zorg zorg", + e: "pod app=blee @zorg", + }, + } + + for k := range uu { + u := uu[k] + t.Run(k, func(t *testing.T) { + p := cmd.NewInterpreter(u.cmd) + p.ClearNS() + assert.Equal(t, u.e, p.GetLine()) + }) + } +} + func TestFilterCmd(t *testing.T) { uu := map[string]struct { cmd string diff --git a/internal/view/command.go b/internal/view/command.go index 66cf5b98..0e4f49f5 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -9,6 +9,7 @@ import ( "log/slog" "regexp" "runtime/debug" + "strings" "sync" "github.com/derailed/k9s/internal/client" @@ -114,7 +115,7 @@ func (*Command) namespaceCmd(p *cmd.Interpreter) bool { } if ns != "" { - _ = p.Reset("pod " + ns) + _ = p.Reset(client.PodGVR.String()) } return false @@ -194,8 +195,13 @@ func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack, pushCmd bool) if cns, ok := p.NSArg(); ok { ns = cns } - if err := c.app.switchNS(ns); err != nil { - return err + if ok, err := dao.MetaAccess.IsNamespaced(gvr); ok && err == nil { + if err := c.app.switchNS(ns); err != nil { + return err + } + p.SwitchNS(ns) + } else { + p.ClearNS() } co := c.componentFor(gvr, fqn, v) @@ -354,7 +360,7 @@ func (c *Command) exec(p *cmd.Interpreter, gvr *client.GVR, comp model.Component if pushCmd { c.app.cmdHistory.Push(p.GetLine()) } - slog.Debug("History", slogs.Stack, c.app.cmdHistory.List()) + slog.Debug("History", slogs.Stack, strings.Join(c.app.cmdHistory.List(), "|")) return } diff --git a/internal/view/ns.go b/internal/view/ns.go index 352de125..04c671f8 100644 --- a/internal/view/ns.go +++ b/internal/view/ns.go @@ -7,7 +7,6 @@ import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/ui" - cmd2 "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/util/sets" ) @@ -43,14 +42,8 @@ func (n *Namespace) bindKeys(aa *ui.KeyActions) { func (n *Namespace) switchNs(app *App, _ ui.Tabular, _ *client.GVR, path string) { n.useNamespace(path) - cmd, ok := app.cmdHistory.Last(2) - if !ok || cmd == "" { - cmd = client.PodGVR.String() - } else { - i := cmd2.NewInterpreter(cmd) - cmd = i.TrimNS() - } - app.gotoResource(cmd, "", false, true) + _, ns := client.Namespaced(path) + app.gotoResource(client.PodGVR.String()+" "+ns, "", false, true) } func (n *Namespace) useNsCmd(*tcell.EventKey) *tcell.EventKey { diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 2e82b101..c6a9674e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: k9s base: core22 -version: 'v0.50.7' +version: 'v0.50.8' 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.