Repository: derailed/k9s Branch: master Commit: 96342c5b7ac3 Files: 1027 Total size: 2.8 MB Directory structure: gitextract_p45lhan2/ ├── .codebeatsettings ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── lint.yml │ ├── stales-issues.yml │ ├── stales-prs.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .semaphore/ │ └── semaphore.yml ├── .travis.yml ├── CNAME ├── COPYING ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── change_logs/ │ ├── release_0.1.1.md │ ├── release_0.1.10.md │ ├── release_0.1.11.md │ ├── release_0.1.2.md │ ├── release_0.1.3.md │ ├── release_0.1.4.md │ ├── release_0.1.5.md │ ├── release_0.1.6.md │ ├── release_0.1.7.md │ ├── release_0.1.8.md │ ├── release_0.1.9.md │ ├── release_0.10.0.md │ ├── release_0.10.1.md │ ├── release_0.10.2.md │ ├── release_0.10.3.md │ ├── release_0.10.4.md │ ├── release_0.10.5.md │ ├── release_0.10.6.md │ ├── release_0.10.7.md │ ├── release_0.10.8.md │ ├── release_0.10.9.md │ ├── release_0.11.0.md │ ├── release_0.11.1.md │ ├── release_0.11.2.md │ ├── release_0.11.3.md │ ├── release_0.12.0.md │ ├── release_0.2.0.md │ ├── release_0.2.1.md │ ├── release_0.2.2.md │ ├── release_0.2.3.md │ ├── release_0.2.4.md │ ├── release_0.2.5.md │ ├── release_0.2.6.md │ ├── release_0.3.0.md │ ├── release_0.3.1.md │ ├── release_0.3.2.md │ ├── release_0.3.3.md │ ├── release_0.4.0.md │ ├── release_0.4.1.md │ ├── release_0.4.2.md │ ├── release_0.4.3.md │ ├── release_0.4.4.md │ ├── release_0.4.5.md │ ├── release_0.4.6.md │ ├── release_0.4.7.md │ ├── release_0.4.8.md │ ├── release_0.5.0.md │ ├── release_0.5.1.md │ ├── release_0.5.2.md │ ├── release_0.6.0.md │ ├── release_0.6.1.md │ ├── release_0.6.2.md │ ├── release_0.6.3.md │ ├── release_0.6.4.md │ ├── release_0.6.5.md │ ├── release_0.6.6.md │ ├── release_0.6.7.md │ ├── release_0.7.0.md │ ├── release_0.7.1.md │ ├── release_0.7.10.md │ ├── release_0.7.11.md │ ├── release_0.7.12.md │ ├── release_0.7.13.md │ ├── release_0.7.2.md │ ├── release_0.7.3.md │ ├── release_0.7.4.md │ ├── release_0.7.5.md │ ├── release_0.7.6.md │ ├── release_0.7.7.md │ ├── release_0.7.8.md │ ├── release_0.7.9.md │ ├── release_0.8.0.md │ ├── release_0.8.1.md │ ├── release_0.8.2.md │ ├── release_0.8.3.md │ ├── release_0.8.4.md │ ├── release_0.9.0.md │ ├── release_0.9.1.md │ ├── release_0.9.2.md │ ├── release_0.9.3.md │ ├── release_v0.13.0.md │ ├── release_v0.13.1.md │ ├── release_v0.13.2.md │ ├── release_v0.13.3.md │ ├── release_v0.13.4.md │ ├── release_v0.13.5.md │ ├── release_v0.13.6.md │ ├── release_v0.13.7.md │ ├── release_v0.13.8.md │ ├── release_v0.14.0.md │ ├── release_v0.14.1.md │ ├── release_v0.15.0.md │ ├── release_v0.15.1.md │ ├── release_v0.15.2.md │ ├── release_v0.16.0.md │ ├── release_v0.16.1.md │ ├── release_v0.17.0.md │ ├── release_v0.17.1.md │ ├── release_v0.17.2.md │ ├── release_v0.17.3.md │ ├── release_v0.17.4.md │ ├── release_v0.17.5.md │ ├── release_v0.17.6.md │ ├── release_v0.17.7.md │ ├── release_v0.18.0.md │ ├── release_v0.18.1.md │ ├── release_v0.19.0.md │ ├── release_v0.19.1.md │ ├── release_v0.19.2.md │ ├── release_v0.19.3.md │ ├── release_v0.19.4.md │ ├── release_v0.19.5.md │ ├── release_v0.19.6.md │ ├── release_v0.19.7.md │ ├── release_v0.20.0.md │ ├── release_v0.20.1.md │ ├── release_v0.20.2.md │ ├── release_v0.20.3.md │ ├── release_v0.20.4.md │ ├── release_v0.20.5.md │ ├── release_v0.21.0.md │ ├── release_v0.21.1.md │ ├── release_v0.21.10.md │ ├── release_v0.21.2.md │ ├── release_v0.21.3.md │ ├── release_v0.21.4.md │ ├── release_v0.21.5.md │ ├── release_v0.21.6.md │ ├── release_v0.21.7.md │ ├── release_v0.21.8.md │ ├── release_v0.21.9.md │ ├── release_v0.22.0.md │ ├── release_v0.22.1.md │ ├── release_v0.23.0.md │ ├── release_v0.23.1.md │ ├── release_v0.23.10.md │ ├── release_v0.23.2.md │ ├── release_v0.23.3.md │ ├── release_v0.23.4.md │ ├── release_v0.23.5.md │ ├── release_v0.23.6.md │ ├── release_v0.23.7.md │ ├── release_v0.23.8.md │ ├── release_v0.23.9.md │ ├── release_v0.24.0.md │ ├── release_v0.24.1.md │ ├── release_v0.24.10.md │ ├── release_v0.24.11.md │ ├── release_v0.24.12.md │ ├── release_v0.24.13.md │ ├── release_v0.24.14.md │ ├── release_v0.24.15.md │ ├── release_v0.24.2.md │ ├── release_v0.24.3.md │ ├── release_v0.24.4.md │ ├── release_v0.24.5.md │ ├── release_v0.24.6.md │ ├── release_v0.24.7.md │ ├── release_v0.24.8.md │ ├── release_v0.24.9.md │ ├── release_v0.25.0.md │ ├── release_v0.25.1.md │ ├── release_v0.25.10.md │ ├── release_v0.25.11.md │ ├── release_v0.25.12.md │ ├── release_v0.25.13.md │ ├── release_v0.25.14.md │ ├── release_v0.25.15.md │ ├── release_v0.25.16.md │ ├── release_v0.25.17.md │ ├── release_v0.25.18.md │ ├── release_v0.25.19.md │ ├── release_v0.25.2.md │ ├── release_v0.25.20.md │ ├── release_v0.25.21.md │ ├── release_v0.25.3.md │ ├── release_v0.25.4.md │ ├── release_v0.25.5.md │ ├── release_v0.25.6.md │ ├── release_v0.25.7.md │ ├── release_v0.25.8.md │ ├── release_v0.25.9.md │ ├── release_v0.26.0.md │ ├── release_v0.26.1.md │ ├── release_v0.26.2.md │ ├── release_v0.26.3.md │ ├── release_v0.26.4.md │ ├── release_v0.26.5.md │ ├── release_v0.26.6.md │ ├── release_v0.26.7.md │ ├── release_v0.27.0.md │ ├── release_v0.27.1.md │ ├── release_v0.27.2.md │ ├── release_v0.27.3.md │ ├── release_v0.27.4.md │ ├── release_v0.28.0.md │ ├── release_v0.28.1.md │ ├── release_v0.28.2.md │ ├── release_v0.29.0.md │ ├── release_v0.29.1.md │ ├── release_v0.30.0.md │ ├── release_v0.30.1.md │ ├── release_v0.30.2.md │ ├── release_v0.30.3.md │ ├── release_v0.30.4.md │ ├── release_v0.30.5.md │ ├── release_v0.30.6.md │ ├── release_v0.30.7.md │ ├── release_v0.30.8.md │ ├── release_v0.31.0.md │ ├── release_v0.31.1.md │ ├── release_v0.31.2.md │ ├── release_v0.31.3.md │ ├── release_v0.31.4.md │ ├── release_v0.31.5.md │ ├── release_v0.31.6.md │ ├── release_v0.31.7.md │ ├── release_v0.31.8.md │ ├── release_v0.31.9.md │ ├── release_v0.32.0.md │ ├── release_v0.32.1.md │ ├── release_v0.32.2.md │ ├── release_v0.32.3.md │ ├── release_v0.32.4.md │ ├── release_v0.32.5.md │ ├── release_v0.32.6.md │ ├── release_v0.32.7.md │ ├── release_v0.40.0.md │ ├── release_v0.40.1.md │ ├── release_v0.40.10.md │ ├── release_v0.40.11.md │ ├── release_v0.40.2.md │ ├── release_v0.40.3.md │ ├── release_v0.40.4.md │ ├── release_v0.40.5.md │ ├── release_v0.40.6.md │ ├── release_v0.40.7.md │ ├── release_v0.40.8.md │ ├── release_v0.40.9.md │ ├── release_v0.50.0.md │ ├── release_v0.50.1.md │ ├── release_v0.50.10.md │ ├── release_v0.50.11.md │ ├── release_v0.50.12.md │ ├── release_v0.50.13.md │ ├── release_v0.50.14.md │ ├── release_v0.50.15.md │ ├── release_v0.50.16.md │ ├── release_v0.50.17.md │ ├── release_v0.50.18.md │ ├── release_v0.50.2.md │ ├── release_v0.50.3.md │ ├── release_v0.50.4.md │ ├── release_v0.50.5.md │ ├── release_v0.50.6.md │ ├── release_v0.50.7.md │ ├── release_v0.50.8.md │ └── release_v0.50.9.md ├── cmd/ │ ├── info.go │ ├── info_test.go │ ├── root.go │ ├── testdata/ │ │ ├── k9s.yaml │ │ └── k9s1.yaml │ └── version.go ├── go.mod ├── go.sum ├── internal/ │ ├── client/ │ │ ├── client.go │ │ ├── client_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── errors.go │ │ ├── gvr.go │ │ ├── gvr_test.go │ │ ├── gvrs.go │ │ ├── helper_test.go │ │ ├── helpers.go │ │ ├── metrics.go │ │ ├── metrics_test.go │ │ ├── switch_context_test.go │ │ ├── testdata/ │ │ │ ├── config │ │ │ ├── config.1 │ │ │ └── config.2 │ │ └── types.go │ ├── color/ │ │ ├── colorize.go │ │ └── colorize_test.go │ ├── config/ │ │ ├── alias.go │ │ ├── alias_test.go │ │ ├── benchmark.go │ │ ├── benchmark_test.go │ │ ├── color.go │ │ ├── color_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── data/ │ │ │ ├── config.go │ │ │ ├── context.go │ │ │ ├── context_int_test.go │ │ │ ├── context_test.go │ │ │ ├── dir.go │ │ │ ├── dir_test.go │ │ │ ├── feature_gate.go │ │ │ ├── helpers.go │ │ │ ├── helpers_test.go │ │ │ ├── ns.go │ │ │ ├── ns_test.go │ │ │ ├── proxy.go │ │ │ ├── testdata/ │ │ │ │ ├── configs/ │ │ │ │ │ ├── aws_ct.yaml │ │ │ │ │ ├── ct-1-1.yaml │ │ │ │ │ ├── ct-1-2.yaml │ │ │ │ │ ├── ct-2-1.yaml │ │ │ │ │ └── def_ct.yaml │ │ │ │ └── data/ │ │ │ │ └── k9s/ │ │ │ │ ├── cl-1/ │ │ │ │ │ ├── ct-1-1/ │ │ │ │ │ │ └── config.yaml │ │ │ │ │ └── ct-1-2/ │ │ │ │ │ └── config.yaml │ │ │ │ └── cl-2/ │ │ │ │ └── ct-2-1/ │ │ │ │ └── config.yaml │ │ │ ├── types.go │ │ │ ├── view.go │ │ │ └── view_test.go │ │ ├── files.go │ │ ├── files_int_test.go │ │ ├── files_test.go │ │ ├── flags.go │ │ ├── flags_test.go │ │ ├── helpers.go │ │ ├── hotkey.go │ │ ├── hotkey_test.go │ │ ├── json/ │ │ │ ├── schemas/ │ │ │ │ ├── aliases.json │ │ │ │ ├── context.json │ │ │ │ ├── hotkeys.json │ │ │ │ ├── k9s.json │ │ │ │ ├── plugin-multi.json │ │ │ │ ├── plugin.json │ │ │ │ ├── plugins.json │ │ │ │ ├── skin.json │ │ │ │ └── views.json │ │ │ ├── testdata/ │ │ │ │ ├── aliases/ │ │ │ │ │ ├── cool.yaml │ │ │ │ │ └── toast.yaml │ │ │ │ ├── context/ │ │ │ │ │ ├── cool.yaml │ │ │ │ │ └── toast.yaml │ │ │ │ ├── hotkeys/ │ │ │ │ │ └── cool.yaml │ │ │ │ ├── k9s/ │ │ │ │ │ ├── cool.yaml │ │ │ │ │ └── toast.yaml │ │ │ │ ├── plugins/ │ │ │ │ │ ├── cool.yaml │ │ │ │ │ ├── snippet.yaml │ │ │ │ │ ├── snippets.yaml │ │ │ │ │ └── toast.yaml │ │ │ │ ├── skins/ │ │ │ │ │ ├── cool.yaml │ │ │ │ │ └── toast.yaml │ │ │ │ └── views/ │ │ │ │ ├── cool.yaml │ │ │ │ └── toast.yaml │ │ │ ├── validator.go │ │ │ └── validator_test.go │ │ ├── k9s.go │ │ ├── k9s_int_test.go │ │ ├── k9s_test.go │ │ ├── logger.go │ │ ├── logger_test.go │ │ ├── mock/ │ │ │ └── test_helpers.go │ │ ├── plugin.go │ │ ├── plugin_test.go │ │ ├── refresh_rate_test.go │ │ ├── scans.go │ │ ├── scans_test.go │ │ ├── shell_pod.go │ │ ├── styles.go │ │ ├── styles_int_test.go │ │ ├── styles_test.go │ │ ├── templates/ │ │ │ ├── aliases.yaml │ │ │ ├── benchmarks.yaml │ │ │ ├── hotkeys.yaml │ │ │ └── stock-skin.yaml │ │ ├── testdata/ │ │ │ ├── aliases/ │ │ │ │ ├── aliases.yaml │ │ │ │ └── plain.yaml │ │ │ ├── benchmarks/ │ │ │ │ ├── b_containers.yaml │ │ │ │ ├── b_containers_1.yaml │ │ │ │ ├── b_good.yaml │ │ │ │ ├── b_toast.yaml │ │ │ │ └── bench-fred.yaml │ │ │ ├── configs/ │ │ │ │ ├── default.yaml │ │ │ │ ├── expected.yaml │ │ │ │ ├── k9s.yaml │ │ │ │ └── k9s_toast.yaml │ │ │ ├── hotkeys/ │ │ │ │ └── hotkeys.yaml │ │ │ ├── k8s.yaml │ │ │ ├── kubes/ │ │ │ │ └── test.yaml │ │ │ ├── plugins/ │ │ │ │ ├── dir/ │ │ │ │ │ ├── snippet.1.yaml │ │ │ │ │ ├── snippet.2.yaml │ │ │ │ │ └── snippet.multi.yaml │ │ │ │ ├── plugins-toast.yaml │ │ │ │ └── plugins.yaml │ │ │ ├── skins/ │ │ │ │ ├── black-and-wtf.yaml │ │ │ │ ├── boarked.yaml │ │ │ │ └── empty.yaml │ │ │ └── views/ │ │ │ └── views.yaml │ │ ├── threshold.go │ │ ├── threshold_test.go │ │ ├── types.go │ │ ├── views.go │ │ ├── views_int_test.go │ │ └── views_test.go │ ├── dao/ │ │ ├── accessor.go │ │ ├── alias.go │ │ ├── alias_test.go │ │ ├── benchmark.go │ │ ├── benchmark_test.go │ │ ├── cluster.go │ │ ├── cm.go │ │ ├── container.go │ │ ├── container_test.go │ │ ├── context.go │ │ ├── crd.go │ │ ├── cronjob.go │ │ ├── cruiser.go │ │ ├── cruiser_test.go │ │ ├── describe.go │ │ ├── dir.go │ │ ├── dir_test.go │ │ ├── dp.go │ │ ├── ds.go │ │ ├── dynamic.go │ │ ├── generic.go │ │ ├── helm_chart.go │ │ ├── helm_history.go │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── img_scan.go │ │ ├── job.go │ │ ├── log_item.go │ │ ├── log_item_test.go │ │ ├── log_items.go │ │ ├── log_items_test.go │ │ ├── log_options.go │ │ ├── log_options_test.go │ │ ├── node.go │ │ ├── non_resource.go │ │ ├── ns.go │ │ ├── patch.go │ │ ├── patch_test.go │ │ ├── pod.go │ │ ├── pod_test.go │ │ ├── port_forward.go │ │ ├── port_forward_test.go │ │ ├── port_forwarder.go │ │ ├── pulse.go │ │ ├── rbac.go │ │ ├── rbac_policy.go │ │ ├── rbac_policy_test.go │ │ ├── rbac_subject.go │ │ ├── recorder.go │ │ ├── reference.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── resource.go │ │ ├── rest_mapper.go │ │ ├── rs.go │ │ ├── scalable.go │ │ ├── screen_dump.go │ │ ├── secret.go │ │ ├── secret_test.go │ │ ├── sts.go │ │ ├── svc.go │ │ ├── table.go │ │ ├── testdata/ │ │ │ ├── bench/ │ │ │ │ └── default_fred_1577308050814961000.txt │ │ │ ├── benchspec.yaml │ │ │ ├── config │ │ │ ├── config.1 │ │ │ ├── crb.json │ │ │ ├── dir/ │ │ │ │ ├── a/ │ │ │ │ │ └── b.yaml │ │ │ │ └── a.yaml │ │ │ ├── dr.json │ │ │ ├── n1.json │ │ │ ├── p1.json │ │ │ └── secret.json │ │ ├── types.go │ │ ├── utils_test.go │ │ └── workload.go │ ├── health/ │ │ ├── check.go │ │ ├── check_test.go │ │ └── types.go │ ├── helpers.go │ ├── helpers_test.go │ ├── keys.go │ ├── model/ │ │ ├── cluster.go │ │ ├── cluster_info.go │ │ ├── cluster_info_test.go │ │ ├── cmd_buff.go │ │ ├── cmd_buff_test.go │ │ ├── describe.go │ │ ├── fish_buff.go │ │ ├── fish_buff_test.go │ │ ├── flash.go │ │ ├── flash_test.go │ │ ├── helpers.go │ │ ├── helpers_int_test.go │ │ ├── helpers_test.go │ │ ├── hint.go │ │ ├── hint_test.go │ │ ├── history.go │ │ ├── history_test.go │ │ ├── log.go │ │ ├── log_int_test.go │ │ ├── log_test.go │ │ ├── menu_hint.go │ │ ├── menu_hint_test.go │ │ ├── pulse.go │ │ ├── pulse_health.go │ │ ├── registry.go │ │ ├── rev_values.go │ │ ├── semver.go │ │ ├── semver_test.go │ │ ├── stack.go │ │ ├── stack_test.go │ │ ├── table.go │ │ ├── table_int_test.go │ │ ├── table_test.go │ │ ├── testdata/ │ │ │ └── p1.json │ │ ├── text.go │ │ ├── text_test.go │ │ ├── tree.go │ │ ├── types.go │ │ ├── values.go │ │ └── yaml.go │ ├── model1/ │ │ ├── color.go │ │ ├── color_test.go │ │ ├── delta.go │ │ ├── delta_test.go │ │ ├── fields.go │ │ ├── header.go │ │ ├── header_test.go │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── pool.go │ │ ├── pool_test.go │ │ ├── row.go │ │ ├── row_event.go │ │ ├── row_event_test.go │ │ ├── row_test.go │ │ ├── rows.go │ │ ├── table_data.go │ │ ├── table_data_test.go │ │ ├── test_helper_test.go │ │ └── types.go │ ├── perf/ │ │ └── benchmark.go │ ├── pool.go │ ├── pool_test.go │ ├── port/ │ │ ├── ann.go │ │ ├── ann_test.go │ │ ├── co_portspec.go │ │ ├── co_portspec_test.go │ │ ├── pf.go │ │ ├── pf_test.go │ │ ├── pfs.go │ │ ├── pfs_test.go │ │ ├── tunnel.go │ │ └── tunnel_test.go │ ├── render/ │ │ ├── alias.go │ │ ├── alias_test.go │ │ ├── base.go │ │ ├── benchmark.go │ │ ├── benchmark_int_test.go │ │ ├── cm.go │ │ ├── container.go │ │ ├── container_int_test.go │ │ ├── container_test.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── cr.go │ │ ├── cr_test.go │ │ ├── crb.go │ │ ├── crb_test.go │ │ ├── crd.go │ │ ├── crd_test.go │ │ ├── cronjob.go │ │ ├── cronjob_test.go │ │ ├── cust_col.go │ │ ├── cust_col_test.go │ │ ├── cust_cols.go │ │ ├── cust_cols_test.go │ │ ├── dir.go │ │ ├── dp.go │ │ ├── dp_test.go │ │ ├── ds.go │ │ ├── ds_test.go │ │ ├── ep.go │ │ ├── ep_test.go │ │ ├── eps.go │ │ ├── eps_test.go │ │ ├── ev.go │ │ ├── generic.go │ │ ├── helm/ │ │ │ ├── chart.go │ │ │ └── history.go │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── hpa.go │ │ ├── hpa_test.go │ │ ├── img_scan.go │ │ ├── job.go │ │ ├── job_test.go │ │ ├── node.go │ │ ├── node_int_test.go │ │ ├── node_test.go │ │ ├── np.go │ │ ├── np_test.go │ │ ├── ns.go │ │ ├── ns_test.go │ │ ├── pdb.go │ │ ├── pdb_test.go │ │ ├── pod.go │ │ ├── pod_int_test.go │ │ ├── pod_test.go │ │ ├── policy.go │ │ ├── policy_int_test.go │ │ ├── policy_test.go │ │ ├── port_forward_test.go │ │ ├── portforward.go │ │ ├── pv.go │ │ ├── pv_test.go │ │ ├── pvc.go │ │ ├── pvc_test.go │ │ ├── rbac.go │ │ ├── reference.go │ │ ├── reference_test.go │ │ ├── render_test.go │ │ ├── ro.go │ │ ├── ro_test.go │ │ ├── rob.go │ │ ├── rob_test.go │ │ ├── rs.go │ │ ├── rs_test.go │ │ ├── sa.go │ │ ├── sa_test.go │ │ ├── sc.go │ │ ├── sc_test.go │ │ ├── screen_dump.go │ │ ├── screen_dump_test.go │ │ ├── secret.go │ │ ├── section.go │ │ ├── sts.go │ │ ├── sts_test.go │ │ ├── subject.go │ │ ├── svc.go │ │ ├── svc_test.go │ │ ├── table.go │ │ ├── table_int_test.go │ │ ├── table_test.go │ │ ├── testdata/ │ │ │ ├── b1.txt │ │ │ ├── b2.txt │ │ │ ├── b3.txt │ │ │ ├── b4.txt │ │ │ ├── cj.json │ │ │ ├── cm.json │ │ │ ├── cr.json │ │ │ ├── crb.json │ │ │ ├── crd.json │ │ │ ├── dp.json │ │ │ ├── ds.json │ │ │ ├── ep.json │ │ │ ├── eps.json │ │ │ ├── ev.json │ │ │ ├── hpa.json │ │ │ ├── ing.json │ │ │ ├── job.json │ │ │ ├── no.json │ │ │ ├── np.json │ │ │ ├── ns.json │ │ │ ├── p1.json │ │ │ ├── pdb.json │ │ │ ├── po.json │ │ │ ├── po_init.json │ │ │ ├── po_sidecar.json │ │ │ ├── pv.json │ │ │ ├── pv_terminating.json │ │ │ ├── pvc.json │ │ │ ├── rb.json │ │ │ ├── ro.json │ │ │ ├── rs.json │ │ │ ├── sa.json │ │ │ ├── sc.json │ │ │ ├── sec.json │ │ │ ├── sts.json │ │ │ └── svc.json │ │ ├── types.go │ │ └── workload.go │ ├── slogs/ │ │ ├── child.go │ │ └── keys.go │ ├── tchart/ │ │ ├── component.go │ │ ├── component_int_test.go │ │ ├── component_test.go │ │ ├── dot_matrix.go │ │ ├── dot_matrix_test.go │ │ ├── gauge.go │ │ ├── gauge_int_test.go │ │ ├── gauge_test.go │ │ ├── series.go │ │ ├── series_test.go │ │ └── sparkline.go │ ├── ui/ │ │ ├── action.go │ │ ├── action_test.go │ │ ├── app.go │ │ ├── app_test.go │ │ ├── config.go │ │ ├── config_int_test.go │ │ ├── config_test.go │ │ ├── crumbs.go │ │ ├── crumbs_test.go │ │ ├── deltas.go │ │ ├── deltas_test.go │ │ ├── dialog/ │ │ │ ├── confirm.go │ │ │ ├── confirm_test.go │ │ │ ├── delete.go │ │ │ ├── delete_test.go │ │ │ ├── error.go │ │ │ ├── error_test.go │ │ │ ├── plugin_inputs.go │ │ │ ├── prompt.go │ │ │ ├── prompt_test.go │ │ │ ├── restart.go │ │ │ ├── selection.go │ │ │ └── transfer.go │ │ ├── flash.go │ │ ├── flash_test.go │ │ ├── indicator.go │ │ ├── indicator_test.go │ │ ├── key.go │ │ ├── logo.go │ │ ├── logo_test.go │ │ ├── menu.go │ │ ├── menu_test.go │ │ ├── modal_list.go │ │ ├── padding.go │ │ ├── padding_test.go │ │ ├── pages.go │ │ ├── pages_test.go │ │ ├── prompt.go │ │ ├── prompt_test.go │ │ ├── prompt_validation_test.go │ │ ├── select_table.go │ │ ├── splash.go │ │ ├── splash_test.go │ │ ├── table.go │ │ ├── table_helper.go │ │ ├── table_helper_test.go │ │ ├── table_test.go │ │ ├── tree.go │ │ └── types.go │ ├── view/ │ │ ├── actions.go │ │ ├── actions_test.go │ │ ├── alias.go │ │ ├── alias_test.go │ │ ├── app.go │ │ ├── app_test.go │ │ ├── benchmark.go │ │ ├── browser.go │ │ ├── cluster_info.go │ │ ├── cm.go │ │ ├── cm_test.go │ │ ├── cmd/ │ │ │ ├── args.go │ │ │ ├── args_test.go │ │ │ ├── helpers.go │ │ │ ├── helpers_test.go │ │ │ ├── interpreter.go │ │ │ ├── interpreter_test.go │ │ │ └── types.go │ │ ├── command.go │ │ ├── command_test.go │ │ ├── container.go │ │ ├── container_test.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── cow.go │ │ ├── crd.go │ │ ├── cronjob.go │ │ ├── details.go │ │ ├── dir.go │ │ ├── dir_int_test.go │ │ ├── dir_test.go │ │ ├── dp.go │ │ ├── dp_test.go │ │ ├── drain_dialog.go │ │ ├── ds.go │ │ ├── ds_test.go │ │ ├── env.go │ │ ├── env_test.go │ │ ├── event.go │ │ ├── exec.go │ │ ├── group.go │ │ ├── helm_chart.go │ │ ├── helm_history.go │ │ ├── help.go │ │ ├── help_test.go │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── image_extender.go │ │ ├── img_scan.go │ │ ├── job.go │ │ ├── live_view.go │ │ ├── live_view_test.go │ │ ├── log.go │ │ ├── log_indicator.go │ │ ├── log_indicator_test.go │ │ ├── log_int_test.go │ │ ├── log_test.go │ │ ├── logger.go │ │ ├── logs_extender.go │ │ ├── node.go │ │ ├── ns.go │ │ ├── ns_test.go │ │ ├── owner_extender.go │ │ ├── page_stack.go │ │ ├── pf.go │ │ ├── pf_dialog.go │ │ ├── pf_dialog_test.go │ │ ├── pf_extender.go │ │ ├── pf_extender_test.go │ │ ├── pf_test.go │ │ ├── picker.go │ │ ├── pod.go │ │ ├── pod_int_test.go │ │ ├── pod_test.go │ │ ├── policy.go │ │ ├── priorityclass.go │ │ ├── priorityclass_test.go │ │ ├── pulse.go │ │ ├── pvc.go │ │ ├── pvc_test.go │ │ ├── rbac.go │ │ ├── rbac_test.go │ │ ├── reference.go │ │ ├── reference_test.go │ │ ├── registrar.go │ │ ├── restart_extender.go │ │ ├── rs.go │ │ ├── sa.go │ │ ├── scale_extender.go │ │ ├── screen_dump.go │ │ ├── screen_dump_test.go │ │ ├── secret.go │ │ ├── secret_test.go │ │ ├── sts.go │ │ ├── sts_test.go │ │ ├── svc.go │ │ ├── svc_test.go │ │ ├── table.go │ │ ├── table_helper.go │ │ ├── table_int_test.go │ │ ├── testdata/ │ │ │ ├── fred/ │ │ │ │ └── kmanifests/ │ │ │ │ ├── cm.yaml │ │ │ │ └── kustomization.yaml │ │ │ ├── k1manifests/ │ │ │ │ ├── cm.yaml │ │ │ │ └── kustomization.yml │ │ │ ├── k2manifests/ │ │ │ │ ├── Kustomization │ │ │ │ └── cm.yaml │ │ │ ├── kmanifests/ │ │ │ │ ├── cm.yaml │ │ │ │ └── kustomization.yaml │ │ │ └── manifests/ │ │ │ ├── cm.yaml │ │ │ └── kustomization.yaml │ │ ├── types.go │ │ ├── user.go │ │ ├── value_extender.go │ │ ├── vul_extender.go │ │ ├── workload.go │ │ ├── xray.go │ │ ├── yaml.go │ │ └── yaml_test.go │ ├── vul/ │ │ ├── scan.go │ │ ├── scanner.go │ │ ├── scorer.go │ │ ├── scorer_test.go │ │ ├── table.go │ │ ├── table_test.go │ │ ├── tally.go │ │ ├── tally_test.go │ │ ├── testdata/ │ │ │ ├── sort/ │ │ │ │ ├── dups/ │ │ │ │ │ ├── sc1.text │ │ │ │ │ └── sc2.text │ │ │ │ ├── full/ │ │ │ │ │ ├── sc1.text │ │ │ │ │ └── sc2.text │ │ │ │ └── no_dups/ │ │ │ │ ├── sc1.text │ │ │ │ └── sc2.text │ │ │ └── sort_sev/ │ │ │ ├── dups/ │ │ │ │ ├── sc1.text │ │ │ │ └── sc2.text │ │ │ ├── full/ │ │ │ │ ├── sc1.text │ │ │ │ └── sc2.text │ │ │ └── no_dups/ │ │ │ ├── sc1.text │ │ │ └── sc2.text │ │ └── types.go │ ├── watch/ │ │ ├── factory.go │ │ ├── forwarders.go │ │ ├── forwarders_test.go │ │ └── helper.go │ └── xray/ │ ├── container.go │ ├── container_test.go │ ├── dp.go │ ├── dp_test.go │ ├── ds.go │ ├── ds_test.go │ ├── generic.go │ ├── generic_test.go │ ├── ns.go │ ├── ns_test.go │ ├── pod.go │ ├── pod_test.go │ ├── rs.go │ ├── rs_test.go │ ├── sa.go │ ├── sa_test.go │ ├── section.go │ ├── sts.go │ ├── sts_test.go │ ├── svc.go │ ├── svc_test.go │ ├── testdata/ │ │ ├── cilium.json │ │ ├── dp.json │ │ ├── ds.json │ │ ├── init.json │ │ ├── ns.json │ │ ├── po.json │ │ ├── rs.json │ │ ├── sa.json │ │ ├── sts.json │ │ └── svc.json │ ├── tree_node.go │ └── tree_node_test.go ├── main.go ├── plugins/ │ ├── README.md │ ├── ai-incident-investigation.yaml │ ├── argo-rollouts-powershell.yaml │ ├── argo-rollouts.yaml │ ├── argo-workflows.yaml │ ├── argocd.yaml │ ├── blame.yaml │ ├── carvel.yaml │ ├── cert-manager.yaml │ ├── crd-wizard.yaml │ ├── crossplane.yaml │ ├── current-ctx-terminal.yaml │ ├── debug-container.yaml │ ├── dive.yaml │ ├── dup.yaml │ ├── duplik8s.yaml │ ├── eks-node-viewer.yaml │ ├── external-secrets.yaml │ ├── flux.yaml │ ├── get-all-namespace-resources.yaml │ ├── get-all.yaml │ ├── helm-default-values.yaml │ ├── helm-diff.yaml │ ├── helm-purge.yaml │ ├── helm-values.yaml │ ├── job-suspend.yaml │ ├── k3d-root-shell.yaml │ ├── keda-toggle.yaml │ ├── kube-metrics.yaml │ ├── kubectl/ │ │ └── kubectl-purge │ ├── kubectl-get-in-shell.yaml │ ├── kubectl-plugins/ │ │ └── kubectl-jq │ ├── liveMigration.yaml │ ├── log-bunyan.yaml │ ├── log-full.yaml │ ├── log-jq.yaml │ ├── log-loki.yaml │ ├── log-stern.yaml │ ├── node-root-shell.yaml │ ├── openssl.yaml │ ├── pvc-debug-container.yaml │ ├── remove-finalizers.yaml │ ├── resource-recommendations.yaml │ ├── rm-ns.yaml │ ├── spark-operator.yaml │ ├── start-alpine.yaml │ ├── szero.yaml │ ├── trace-dns.yaml │ ├── vector-dev-top.yaml │ └── watch-events.yaml ├── skins/ │ ├── axual.yaml │ ├── black-and-wtf.yaml │ ├── dracula.yaml │ ├── everforest-dark.yaml │ ├── everforest-light.yaml │ ├── gruvbox-dark-hard.yaml │ ├── gruvbox-dark.yaml │ ├── gruvbox-light-hard.yaml │ ├── gruvbox-light.yaml │ ├── gruvbox-material-dark-hard.yaml │ ├── gruvbox-material-dark-medium.yaml │ ├── gruvbox-material-dark-soft.yaml │ ├── gruvbox-material-light-hard.yaml │ ├── gruvbox-material-light-medium.yaml │ ├── gruvbox-material-light-soft.yaml │ ├── in-the-navy.yaml │ ├── kanagawa.yaml │ ├── kiss.yaml │ ├── monokai.yaml │ ├── narsingh.yaml │ ├── nightfox.yaml │ ├── nord.yaml │ ├── one-dark.yaml │ ├── red.yaml │ ├── rose-pine-dawn.yaml │ ├── rose-pine-moon.yaml │ ├── rose-pine.yaml │ ├── snazzy.yaml │ ├── solarized-16.yaml │ ├── solarized-dark.yaml │ ├── solarized-light.yaml │ ├── stock.yaml │ ├── transparent.yaml │ └── vercel.yaml ├── snap/ │ └── snapcraft.yaml └── testdata/ └── aliases/ └── aliases.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codebeatsettings ================================================ { "GOLANG": { "TOO_MANY_IVARS": [ 15, 20, 22, 25, ], "LOC": [ 50, 60, 70, 80, ], "TOTAL_LOC": [ 350, 400, 500, 600 ], "TOO_MANY_FUNCTIONS": [ 40, 50, 55, 60, ], } } ================================================ FILE: .dockerignore ================================================ /k8s /change_logs .github .semaphore .vscode assets /dist /execs /notes /skins README.md LICENSE cov.out /k9s .travis.yml ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [derailed] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' ---


**Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Historical Documents** When applicable please include any supporting artifacts: k9s debug logs, configurations, resource manifests, ... **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Versions (please complete the following information):** - OS: [e.g. OSX] - K9s: [e.g. 0.1.0] - K8s: [e.g. 1.11.0] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' ---


**Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ --- version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: weekly # Maintain dependencies for docker - package-ecosystem: "docker" directory: "/" schedule: interval: weekly ================================================ FILE: .github/workflows/lint.yml ================================================ name: K9s Lint on: pull_request: branches: [ master ] jobs: golangci: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v6.0.2 - name: Install Go uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod cache-dependency-path: go.sum - name: Lint uses: golangci/golangci-lint-action@v9.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} version: v2.6 ================================================ FILE: .github/workflows/stales-issues.yml ================================================ name: Closeout Stale Issues on: schedule: - cron: "0 2 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v10 with: days-before-issue-stale: 30 days-before-issue-close: 14 stale-issue-label: "stale" stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/stales-prs.yml ================================================ name: Closeout Stale PRs on: schedule: - cron: "0 2 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v10 with: days-before-pr-stale: 30 days-before-pr-close: 14 stale-pr-label: "stale" stale-pr-message: "This PR is stale because it has been open for 30 days with no activity." close-pr-message: "This PR was closed because it has been inactive for 14 days since being marked as stale." repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: K9s Test on: workflow_dispatch: push: branches: - master tags: - rc* - v* pull_request: branches: - master jobs: build: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v6.0.2 - name: Install Go uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod cache-dependency-path: go.sum - name: Setup GO env run: go env -w CGO_ENABLED=0 - name: Run Tests run: make test env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .vscode *.out .idea .envrc cov.out execs /k9s /k8s dist notes vendor go.mod1 go.mod2 gen.sh *.test *.log *~ pod1.go .project faas .settings/* demos /code kind *.snap /stresser __debug_bin* fg.yaml ================================================ FILE: .golangci.yml ================================================ version: "2" run: allow-parallel-runners: true # timeout for analysis, e.g. 30s, 5m, default is 1m timeout: 5m # exit code when at least one issue was found, default is 1 issues-exit-code: 1 tests: true linters: disable: - staticcheck enable: - sloglint - bodyclose - copyloopvar - depguard - errcheck - errorlint - gocheckcompilerdirectives - gocritic - godox - goprintffuncname - gosec - govet - intrange - ineffassign - misspell - noctx - nolintlint - revive - staticcheck - testifylint - unconvert - unparam - unused - whitespace - gocyclo - funlen - goconst - dogsled - lll settings: dogsled: max-blank-identifiers: 3 gosec: excludes: - G109 - G115 - G204 - G303 sloglint: no-mixed-args: true kv-only: true attr-only: false no-global: "" context: "" static-msg: false no-raw-keys: true key-naming-case: camel forbidden-keys: - time - level - msg - source args-on-sep-lines: false depguard: rules: logger: deny: # logging is allowed only by logutils.Log, - pkg: "github.com/sirupsen/logrus" desc: logging is allowed only by logutils.Log. - pkg: "github.com/pkg/errors" desc: Should be replaced by standard lib errors package. - pkg: "github.com/instana/testify" desc: It's a fork of github.com/stretchr/testify. files: - "!**/pkg/logutils/**.go" dupl: threshold: 100 funlen: lines: -1 statements: 60 goconst: min-len: 2 min-occurrences: 3 ignore-string-values: - blee - duh - cl-1 - ct-1-1 gocritic: enabled-tags: - diagnostic - experimental - opinionated - performance - style disabled-checks: - dupImport # https://github.com/go-critic/go-critic/issues/845 - ifElseChain - octalLiteral - whyNoLint gocyclo: min-complexity: 35 godox: keywords: - FIXME mnd: checks: - argument - case - condition - return ignored-numbers: - '0' - '1' - '2' - '3' ignored-functions: - strings.SplitN govet: settings: printf: funcs: - (github.com/golangci/golangci-lint/v2/pkg/logutils.Log).Infof - (github.com/golangci/golangci-lint/v2/pkg/logutils.Log).Warnf - (github.com/golangci/golangci-lint/v2/pkg/logutils.Log).Errorf - (github.com/golangci/golangci-lint/v2/pkg/logutils.Log).Fatalf enable: - nilness - shadow errorlint: asserts: false lll: line-length: 170 misspell: locale: US ignore-rules: - "importas" nolintlint: allow-unused: false require-explanation: false require-specific: true revive: rules: - name: indent-error-flow - name: unexported-return disabled: true - name: unused-parameter - name: unused-receiver exclusions: presets: - comments - std-error-handling - common-false-positives - legacy paths: - test/testdata_etc # test files - internal/go # extracted from Go code - internal/x # extracted from x/tools code - pkg/goformatters/gci/internal # extracted from gci code - pkg/goanalysis/runner_checker.go # extracted from x/tools code rules: - path: (.+)_test\.go linters: - dupl - mnd - lll # Based on existing code, the modifications should be limited to make maintenance easier. - path: pkg/golinters/unused/unused.go linters: [gocritic] text: "rangeValCopy: each iteration copies 160 bytes \\(consider pointers or indexing\\)" # Related to the result of computation but divided multiple times by 1024. - path: test/bench/bench_test.go linters: [gosec] text: "G115: integer overflow conversion uint64 -> int" # The files created during the tests don't need to be secured. - path: scripts/website/expand_templates/linters_test.go linters: [gosec] text: "G306: Expect WriteFile permissions to be 0600 or less" # Related to migration command. - path: pkg/commands/internal/migrate/two/ linters: - lll # Related to migration command. - path: pkg/commands/internal/migrate/ linters: - gocritic text: "hugeParam:" # The codes are close but this is not duplication. - path: pkg/commands/(formatters|linters).go linters: - dupl formatters: enable: - gci - gofmt - goimports settings: gofmt: rewrite-rules: - pattern: 'interface{}' replacement: 'any' goimports: local-prefixes: - github.com/golangci/golangci-lint/v2 exclusions: paths: - test/testdata_etc # test files - internal/go # extracted from Go code - internal/x # extracted from x/tools code - pkg/goformatters/gci/internal # extracted from gci code - pkg/goanalysis/runner_checker.go # extracted from x/tools code ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: k9s before: hooks: - go mod download - go generate ./... release: prerelease: false env: - CGO_ENABLED=0 builds: - id: linux goos: - linux goarch: - amd64 - arm64 - arm - ppc64le - s390x goarm: - 7 flags: - -trimpath ldflags: - -s -w -X github.com/derailed/k9s/cmd.version=v{{.Version}} - -s -w -X github.com/derailed/k9s/cmd.commit={{.Commit}} - -s -w -X github.com/derailed/k9s/cmd.date={{.Date}} - id: freebsd goos: - freebsd goarch: - amd64 - arm64 goarm: - 7 flags: - -trimpath ldflags: - -s -w -X github.com/derailed/k9s/cmd.version=v{{.Version}} - -s -w -X github.com/derailed/k9s/cmd.commit={{.Commit}} - -s -w -X github.com/derailed/k9s/cmd.date={{.Date}} - id: osx goos: - darwin goarch: - amd64 - arm64 flags: - -trimpath ldflags: - -s -w -X github.com/derailed/k9s/cmd.version=v{{.Version}} - -s -w -X github.com/derailed/k9s/cmd.commit={{.Commit}} - -s -w -X github.com/derailed/k9s/cmd.date={{.Date}} - id: windows goos: - windows goarch: - amd64 - arm64 flags: - -trimpath ldflags: - -s -w -X github.com/derailed/k9s/cmd.version=v{{.Version}} - -s -w -X github.com/derailed/k9s/cmd.commit={{.Commit}} - -s -w -X github.com/derailed/k9s/cmd.date={{.Date}} archives: - name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}amd64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} format_overrides: - goos: windows formats: ["zip"] checksum: name_template: "checksums.sha256" snapshot: version_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - "^docs:" - "^test:" brews: - name: k9s repository: owner: derailed name: homebrew-k9s commit_author: name: derailed email: fernand@imhotep.io directory: Formula homepage: https://k9scli.io/ description: Kubernetes CLI To Manage Your Clusters In Style! test: | system "k9s version" nfpms: - file_name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' maintainer: Fernand Galiana homepage: https://k9scli.io description: Kubernetes CLI To Manage Your Clusters In Style! license: "Apache-2.0" formats: - deb - rpm - apk bindir: /usr/bin section: utils contents: - src: ./LICENSE dst: /usr/share/doc/k9s/copyright file_info: mode: 0644 sboms: - artifacts: archive ================================================ FILE: .semaphore/semaphore.yml ================================================ version: v1.0 name: First pipeline example agent: machine: type: e1-standard-2 os_image: ubuntu1804 blocks: - name: "Build" task: env_vars: - name: APP_ENV value: prod jobs: - name: Docker build commands: - checkout - ls -1 - echo $APP_ENV - echo "Docker build..." - echo "done" - name: "Smoke tests" task: jobs: - name: Smoke commands: - checkout - echo "make smoke" - name: "Unit tests" task: jobs: - name: RSpec commands: - checkout - echo "make rspec" - name: Lint code commands: - checkout - echo "make lint" - name: Check security commands: - checkout - echo "make security" - name: "Integration tests" task: jobs: - name: Cucumber commands: - checkout - echo "make cucumber" - name: "Push Image" task: jobs: - name: Push commands: - checkout - echo "make docker.push" ================================================ FILE: .travis.yml ================================================ language: go go_import_path: github.com/derailed/k9s go: - "1.23" jobs: include: - os: linux arch: amd64 - os: linux arch: ppc64le - os: osx arch: amd64 dist: trusty sudo: false install: true script: - go build - go test ./... ================================================ FILE: CNAME ================================================ k9scli.io ================================================ FILE: COPYING ================================================ Copyright © 2019, Imhotep Software LLC and other contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Dockerfile ================================================ # ----------------------------------------------------------------------------- # The base image for building the k9s binary FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine3.21 AS build ARG TARGETOS ARG TARGETARCH ENV GOOS=$TARGETOS ENV GOARCH=$TARGETARCH WORKDIR /k9s COPY go.mod go.sum main.go Makefile ./ COPY internal internal COPY cmd cmd RUN apk --no-cache add --update make libx11-dev git gcc libc-dev curl \ && make build # ----------------------------------------------------------------------------- # Build the final Docker image FROM --platform=$BUILDPLATFORM alpine:3.23.3 ARG KUBECTL_VERSION="v1.32.2" COPY --from=build /k9s/execs/k9s /bin/k9s RUN apk --no-cache add --update ca-certificates \ && apk --no-cache add --update -t deps curl vim \ && TARGET_ARCH=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \ && curl -f -L https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGET_ARCH}/kubectl -o /usr/local/bin/kubectl \ && chmod +x /usr/local/bin/kubectl \ && apk del --purge deps ENTRYPOINT [ "/bin/k9s" ] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: Makefile ================================================ NAME := k9s VERSION ?= v0.50.18 PACKAGE := github.com/derailed/$(NAME) OUTPUT_BIN ?= execs/${NAME} GO_FLAGS ?= GO_TAGS ?= netgo CGO_ENABLED ?=0 GIT_REV ?= $(shell git rev-parse --short HEAD) IMG_NAME := derailed/k9s IMAGE := ${IMG_NAME}:${VERSION} BUILD_PLATFORMS ?= linux/amd64,linux/arm64 SOURCE_DATE_EPOCH ?= $(shell date +%s) ifeq ($(shell uname), Darwin) DATE ?= $(shell TZ=UTC /bin/date -j -f "%s" ${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ") else DATE ?= $(shell date -u -d @${SOURCE_DATE_EPOCH} +"%Y-%m-%dT%H:%M:%SZ") endif default: help test: ## Run all tests @go clean --testcache && go test ./... cover: ## Run test coverage suite @go test ./... --coverprofile=cov.out @go tool cover --html=cov.out build: ## Builds the CLI @CGO_ENABLED=${CGO_ENABLED} go build ${GO_FLAGS} \ -ldflags "-w -s -X ${PACKAGE}/cmd.version=${VERSION} -X ${PACKAGE}/cmd.commit=${GIT_REV} -X ${PACKAGE}/cmd.date=${DATE}" \ -a -tags=${GO_TAGS} -o ${OUTPUT_BIN} main.go kubectl-stable-version: ## Get kubectl latest stable version @curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt imgx: ## Build Docker Image @docker buildx build --platform ${BUILD_PLATFORMS} --rm -t ${IMAGE} --load . pushx: ## Push Docker image to registry @docker buildx build --platform ${BUILD_PLATFORMS} --rm -t ${IMAGE} --push . help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":[^:]*?## "}; {printf "\033[38;5;69m%-30s\033[38;5;38m %s\033[0m\n", $$1, $$2}' ================================================ FILE: README.md ================================================ k9s ## K9s - Kubernetes CLI To Manage Your Clusters In Style! K9s provides a terminal UI to interact with your Kubernetes clusters. The aim of this project is to make it easier to navigate, observe and manage your applications in the wild. K9s continually watches Kubernetes for changes and offers subsequent commands to interact with your observed resources. --- ## Note... K9s is not pimped out by a big corporation with deep pockets. It is a complex OSS project that demands a lot of my time to maintain and support. K9s will always remain OSS and therefore free! That said, if you feel k9s makes your day to day Kubernetes journey a tad brighter, saves you time and makes you more productive, please consider [sponsoring us!](https://github.com/sponsors/derailed) Your donations will go a long way in keeping our servers lights on and beers in our fridge! **Thank you!** --- [![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) [![Docker Pulls](https://img.shields.io/docker/pulls/derailed/k9s.svg?maxAge=604800)](https://hub.docker.com/r/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) [![Releases](https://img.shields.io/github/downloads/derailed/k9s/total.svg)](https://github.com/derailed/k9s/releases) --- ## Screenshots 1. Pods 2. Logs 3. Deployments --- ## Demo Videos/Recordings * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) * [K9s v0.29.0](https://youtu.be/oiU3wmoAkBo) * [K9s v0.21.3](https://youtu.be/wG8KCwDAhnw) * [K9s v0.19.X](https://youtu.be/kj-WverKZ24) * [K9s v0.18.0](https://www.youtube.com/watch?v=zMnD5e53yRw) * [K9s v0.17.0](https://www.youtube.com/watch?v=7S33CNLAofk&feature=youtu.be) * [K9s Pulses](https://asciinema.org/a/UbXKPal6IWpTaVAjBBFmizcGN) * [K9s v0.15.1](https://youtu.be/7Fx4XQ2ftpM) * [K9s v0.13.0](https://www.youtube.com/watch?v=qaeR2iK7U0o&t=15s) * [K9s v0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I) * [K9s v0.7.0 Features](https://youtu.be/83jYehwlql8) * [K9s v0 Demo](https://youtu.be/k7zseUhaXeU) --- ## Documentation Please refer to our [K9s documentation](https://k9scli.io) site for installation, usage, customization and tips. --- ## Slack Channel Wanna discuss K9s features with your fellow `K9sers` or simply show your support for this tool? * Channel: [K9sersSlack](https://k9sers.slack.com/) * Invite: [K9slackers Invite](https://join.slack.com/t/k9sers/shared_invite/zt-3360a389v-ElLHrb0Dp1kAXqYUItSAFA) --- ## Installation K9s is available on Linux, macOS and Windows platforms. Binaries for Linux, Windows and Mac are available as tarballs in the [release page](https://github.com/derailed/k9s/releases). * Via [Homebrew](https://brew.sh/) for macOS or Linux ```shell brew install derailed/k9s/k9s ``` * Via [MacPorts](https://www.macports.org) ```shell sudo port install k9s ``` * Via [snap](https://snapcraft.io/k9s) for Linux ```shell snap install k9s --devmode ``` * On Arch Linux ```shell pacman -S k9s ``` * On OpenSUSE Linux distribution ```shell zypper install k9s ``` * On FreeBSD ```shell pkg install k9s ``` * On Ubuntu ```shell wget https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.deb && sudo apt install ./k9s_linux_amd64.deb && rm k9s_linux_amd64.deb ``` * On Fedora (42+) ```shell dnf install k9s ``` * Via [Winget](https://github.com/microsoft/winget-cli) for Windows ```shell winget install k9s ``` * Via [Scoop](https://scoop.sh) for Windows ```shell scoop install k9s ``` * Via [Chocolatey](https://chocolatey.org/packages/k9s) for Windows ```shell choco install k9s ``` * Via a GO install ```shell # NOTE: The dev version will be in effect! go install github.com/derailed/k9s@latest ``` * Via [Webi](https://webinstall.dev) for Linux and macOS ```shell curl -sS https://webinstall.dev/k9s | bash ``` * Via [pkgx](https://pkgx.dev/pkgs/k9scli.io/) for Linux and macOS ```shell pkgx k9s ``` * Via [gah](https://github.com/marverix/gah) for Linux and macOS ```shell gah install k9s ``` * Via [Webi](https://webinstall.dev) for Windows ```shell curl.exe -A MS https://webinstall.dev/k9s | powershell ``` * As a [Docker Desktop Extension](https://docs.docker.com/desktop/extensions/) (for the Docker Desktop built in Kubernetes Server) ```shell docker extension install spurin/k9s-dd-extension:latest ``` --- ## Building From Source K9s is currently using GO v1.23.X or above. In order to build K9s from source you must: 1. Clone the repo 2. Build and run the executable ```shell make build && ./execs/k9s ``` --- ## Running with Docker ### Running the official Docker image You can run k9s as a Docker container by mounting your `KUBECONFIG`: ```shell docker run --rm -it -v $KUBECONFIG:/root/.kube/config derailed/k9s ``` For default path it would be: ```shell docker run --rm -it -v ~/.kube/config:/root/.kube/config derailed/k9s ``` ### Building your own Docker image You can build your own Docker image of k9s from the [Dockerfile](Dockerfile) with the following: ```shell docker build -t k9s-docker:v0.0.1 . ``` You can get the latest stable `kubectl` version and pass it to the `docker build` command with the `--build-arg` option. You can use the `--build-arg` option to pass any valid `kubectl` version (like `v1.18.0` or `v1.19.1`). ```shell KUBECTL_VERSION=$(make kubectl-stable-version 2>/dev/null) docker build --build-arg KUBECTL_VERSION=${KUBECTL_VERSION} -t k9s-docker:0.1 . ``` Run your container: ```shell docker run --rm -it -v ~/.kube/config:/root/.kube/config k9s-docker:0.1 ``` --- ## PreFlight Checks * K9s uses 256 colors terminal mode. On `Nix system make sure TERM is set accordingly. ```shell export TERM=xterm-256color ``` * In order to issue resource edit commands make sure your EDITOR and KUBE_EDITOR env vars are set. ```shell # Kubectl edit command will use this env var. export KUBE_EDITOR=my_fav_editor ``` * K9s prefers recent kubernetes versions ie 1.28+ --- ## K8S Compatibility Matrix | k9s | k8s client | | ------------------ | ---------- | | >= v0.27.0 | 1.26.1 | | v0.26.7 - v0.26.6 | 1.25.3 | | v0.26.5 - v0.26.4 | 1.25.1 | | v0.26.3 - v0.26.1 | 1.24.3 | | v0.26.0 - v0.25.19 | 1.24.2 | | v0.25.18 - v0.25.3 | 1.22.3 | | v0.25.2 - v0.25.0 | 1.22.0 | | <= v0.24 | 1.21.3 | --- ## The Command Line ```shell # List current version k9s version # To get info about K9s runtime (logs, configs, etc..) k9s info # List all available CLI options k9s help # To run K9s in a given namespace k9s -n mycoolns # Start K9s in an existing KubeConfig context k9s --context coolCtx # Start K9s in readonly mode - with all cluster modification commands disabled k9s --readonly ``` ## Logs And Debug Logs Given the nature of the ui k9s does produce logs to a specific location. To view the logs and turn on debug mode, use the following commands: ```shell # Find out where the logs are stored k9s info ``` ```text ____ __.________ | |/ _/ __ \______ | < \____ / ___/ | | \ / /\___ \ |____|__ \ /____//____ > \/ \/ Version: vX.Y.Z Config: /Users/fernand/.config/k9s/config.yaml Logs: /Users/fernand/.local/state/k9s/k9s.log Dumps dir: /Users/fernand/.local/state/k9s/screen-dumps Benchmarks dir: /Users/fernand/.local/state/k9s/benchmarks Skins dir: /Users/fernand/.local/share/k9s/skins Contexts dir: /Users/fernand/.local/share/k9s/clusters Custom views file: /Users/fernand/.local/share/k9s/views.yaml Plugins file: /Users/fernand/.local/share/k9s/plugins.yaml Hotkeys file: /Users/fernand/.local/share/k9s/hotkeys.yaml Alias file: /Users/fernand/.local/share/k9s/aliases.yaml ``` ### View K9s logs ```shell tail -f /Users/fernand/.local/data/k9s/k9s.log ``` ### Start K9s in debug mode ```shell k9s -l debug ``` ### Customize logs destination You can override the default log file destination either with the `--logFile` argument: ```shell k9s --logFile /tmp/k9s.log less /tmp/k9s.log ``` Or through the `K9S_LOGS_DIR` environment variable: ```shell K9S_LOGS_DIR=/var/log k9s less /var/log/k9s.log ``` ## Key Bindings K9s uses aliases to navigate most K8s resources. | Action | Command | Comment | |---------------------------------------------------------------------------------|-------------------------------|------------------------------------------------------------------------| | Show active keyboard mnemonics and help | `?` | | | Show all available resource alias | `ctrl-a` | | | To bail out of K9s | `:quit`, `:q`, `ctrl-c` | | | To go up/back to the previous view | `esc` | If you have crumbs on, this will go to the previous one | | View a Kubernetes resource using singular/plural or short-name | `:`pod⏎ | accepts singular, plural, short-name or alias ie pod or pods | | View a Kubernetes resource in a given namespace | `:`pod ns-x⏎ | | | View filtered pods (New v0.30.0!) | `:`pod /fred⏎ | View all pods filtered by fred | | View labeled pods (New v0.30.0!) | `:`pod app=fred,env=dev⏎ | View all pods with labels matching app=fred and env=dev | | View pods in a given context (New v0.30.0!) | `:`pod @ctx1⏎ | View all pods in context ctx1. Switches out your current k9s context! | | Filter out a resource view given a filter | `/`filter⏎ | Regex2 supported ie `fred|blee` to filter resources named fred or blee | | Inverse regex filter | `/`! filter⏎ | Keep everything that *doesn't* match. | | Filter resource view by labels | `/`-l label-selector⏎ | | | Fuzzy find a resource given a filter | `/`-f filter⏎ | | | Bails out of view/command/filter mode | `` | | | To view and switch to another Kubernetes context (Pod view) | `:`ctx⏎ | | | To view and switch directly to another Kubernetes context (Last used view) | `:`ctx context-name⏎ | | | To view and switch to another Kubernetes namespace | `:`ns⏎ | | | To switch back to the last active command (like how "cd -" works) | `-` | Navigation that adds breadcrumbs to the bottom are not commands | | To go back and forward through the command history | back: `[`, forward: `]` | Same as above | | To view all saved resources | `:`screendump or sd⏎ | | | To delete a resource (TAB and ENTER to confirm) | `ctrl-d` | | | To kill a resource (no confirmation dialog, equivalent to kubectl delete --now) | `ctrl-k` | | | Launch pulses view | `:`pulses or pu⏎ | | | Launch XRay view | `:`xray RESOURCE [NAMESPACE]⏎ | RESOURCE can be one of po, svc, dp, rs, sts, ds, NAMESPACE is optional | | Launch Popeye view | `:`popeye or pop⏎ | See [popeye](#popeye) | | Mark resource | `space` | | | Mark range of resources | `ctrl-space` | | | Clear all marks | `ctrl-\` | | | Save resources to file | `ctrl-s` | | | Toggle faults/error display | `ctrl-z` | | | Toggle wide columns | `ctrl-w` | | | Toggle header | `ctrl-e` | | | Toggle breadcrumbs | `ctrl-g` | | | Move selected column left | `shift-left arrow` | | | Move selected column right | `shift-right arrow` | | | Sort by selected column | `shift-o` | | | Sort by Name | `shift-n` | | | Sort by Age | `shift-a` | | | Sort by Namespace | `shift-p` | Only when viewing all namespaces | | Sort by Status | `shift-s` | | | Copy resource name | `c` | | | Copy namespace | `n` | | | View YAML | `y` | | | View logs | `l` | Resource specific | | View previous logs | `p` | Resource specific | | Shell into container | `s` | Pods only | | Attach to container | `a` | Pods only | | Describe resource | `d` | | | Edit resource | `e` | Not available in read-only mode | | Show port-forwards | `f` | Pods/Services/Containers | | Port forward | `shift-f` | Pods/Services/Containers | | Warp to namespace | `w` | When namespace column is available | | Jump to owner | `shift-j` | When resource has an owner | | Use/switch namespace | `u` | Namespace view | | UsedBy (show resources using this) | `u` | ServiceAccounts/PVCs/Secrets/ConfigMaps | | Benchmark (run/stop) | `b` | Services/Port-forwards | | Toggle text wrap | `w` | Log view | | Toggle timestamp | `t` | Log view | | Toggle fullscreen | `f` | Log/YAML/Details view | | Refresh/reload view | `ctrl-r` | | | Trigger (CronJob) | `t` | CronJob view | | Cordon/Uncordon node | `u` | Node view | | Drain node | `r` | Node view | | Restart resource | `r` | Deployments/DaemonSets/StatefulSets | | Rollback resource | `ctrl-l` | ReplicaSets | | View ReplicaSets | `z` | Deployment view | --- ## K9s Configuration K9s keeps its configurations as YAML files inside of a `k9s` directory and the location depends on your operating system. K9s leverages [XDG](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) to load its various configurations files. For information on the default locations for your OS please see [this link](https://github.com/adrg/xdg/blob/master/README.md). If you are still confused a quick `k9s info` will reveal where k9s is loading its configurations from. Alternatively, you can set `K9S_CONFIG_DIR` to tell K9s the directory location to pull its configurations from. | Unix | macOS | Windows | |-----------------|------------------------------------|-----------------------| | `~/.config/k9s` | `~/Library/Application Support/k9s` | `%LOCALAPPDATA%\k9s` | > NOTE: This is still in flux and will change while in pre-release stage! You can now override the context portForward default address configuration by setting an env variable that can override all clusters portForward local address using `K9S_DEFAULT_PF_ADDRESS=a.b.c.d` ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml 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. # Default known GPU vendors: # nvidia: nvidia.com/gpu # nvidia-shared: nvidia.com/gpu.shared # amd: amd.com/gpu # intel: gpu.intel.com/i915 gpuVendors: bozo: bozo/gpu # extends the gpu vendor and add "bozo" # The path to screen dump. Default: '%temp_dir%/k9s-screens-%username%' (k9s info) screenDumpDir: /tmp/dumps # Represents ui poll intervals in seconds. Default 2.0 secs. Minimum value is 2.0 - values below will be capped to the minimum. refreshRate: 2 # Overrides the default k8s api server requests timeout. Defaults 120s apiServerTimeout: 15s # Number of retries once the connection to the api-server is lost. Default 15. maxConnRetry: 5 # Indicates whether modification commands like delete/kill/edit are disabled. Default is false readOnly: false # This setting allows users to specify the default view, but it is not set by default. defaultView: "" # Toggles whether k9s should exit when CTRL-C is pressed. When set to true, you will need to exit k9s via the :quit command. Default is false. noExitOnCtrlC: false #UI settings ui: # Enable mouse support. Default false enableMouse: false # Set to true to hide K9s header. Default false headless: false # Set to true to hide the K9S logo Default false logoless: false # Set to true to hide K9s crumbs. Default false crumbsless: false # Set to true to suppress the K9s splash screen on start. Default false. Note that for larger clusters or higher latency connections, there may be no resources visible initially until local caches have finished populating. splashless: false # Toggles icons display as not all terminal support these chars. Default: true noIcons: false # Toggles reactive UI. This option provide for watching on disk artifacts changes and update the UI live Defaults to false. reactive: false # By default all contexts will use the dracula skin unless explicitly overridden in the context config file. skin: dracula # => assumes the file skins/dracula.yaml is present in the $XDG_DATA_HOME/k9s/skins directory. Can be overriden with K9S_SKIN. # Convert dark skins to light, or vice versa, preserving hue. Default: false invert: false # Allows to set certain views default fullscreen mode. (yaml, helm history, describe, value_extender, details, logs) Default false defaultsToFullScreen: false # Show full resource GVR (Group/Version/Resource) vs just R. Default: false. useFullGVRTitle: false # Toggles icons display as not all terminal support these chars. noIcons: false # Toggles whether k9s should check for the latest revision from the GitHub repository releases. Default is false. skipLatestRevCheck: false # When altering kubeconfig or using multiple kube configs, k9s will clean up clusters configurations that are no longer in use. Setting this flag to true will keep k9s from cleaning up inactive cluster configs. Defaults to false. keepMissingClusters: false # Logs configuration logger: # Defines the number of lines to return. Default 100 tail: 200 # Defines the total number of log lines to allow in the view. Default 1000 buffer: 500 # Represents how far to go back in the log timeline in seconds. Setting to -1 will tail logs. Default is -1. sinceSeconds: 300 # => tail the last 5 mins. # Toggles log line wrap. Default false textWrap: false # Autoscroll in logs will be disabled. Default is false. disableAutoscroll: false # Enable column locking when autoscroll is enabled. Default is false. columnLock: false # Toggles log line timestamp info. Default false showTime: false # Provide shell pod customization when nodeShell feature gate is enabled! shellPod: # The shell pod image to use. image: killerAdmin # The namespace to launch to shell pod into. namespace: default # The resource limit to set on the shell pod. limits: cpu: 100m memory: 100Mi # Enable TTY tty: true hostPathVolume: - name: docker-socket # Mount the Docker socket into the shell pod mountPath: /var/run/docker.sock # The path on the host to mount hostPath: /var/run/docker.sock readOnly: true ``` --- ## Popeye Configuration K9s has integration with [Popeye](https://popeyecli.io/), which is a Kubernetes cluster sanitizer. Popeye itself uses a configuration called `spinach.yml`, but when integrating with K9s the cluster-specific file should be name `$XDG_CONFIG_HOME/share/k9s/clusters/clusterX/contextY/spinach.yml`. This allows you to have a different spinach config per cluster. --- ## Node Shell By enabling the nodeShell feature gate on a given cluster, K9s allows you to shell into your cluster nodes. Once enabled, you will have a new `s` for `shell` menu option while in node view. K9s will launch a pod on the selected node using a special k9s_shell pod. Furthermore, you can refine your shell pod by using a custom docker image preloaded with the shell tools you love. By default k9s uses a BusyBox image, but you can configure it as follows: Alternatively, you can now override the context configuration by setting an env variable that can override all clusters node shell gate using `K9S_FEATURE_GATE_NODE_SHELL=true|false` ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: # You can also further tune the shell pod specification shellPod: image: cool_kid_admin:42 namespace: blee limits: cpu: 100m memory: 100Mi ``` Then in your cluster configuration file... ```yaml # $XDG_DATA_HOME/k9s/clusters/cluster-1/context-1 k9s: cluster: cluster-1 readOnly: false namespace: active: default lockFavorites: false favorites: - kube-system - default view: active: po featureGates: nodeShell: true # => Enable this feature gate to make nodeShell available on this cluster portForwardAddress: localhost ``` ### Customizing the Shell Pod You can also customize the shell pod by adding a `hostPathVolume` to your shell pod. This allows you to mount a local directory or file into the shell pod. For example, if you want to mount the Docker socket into the shell pod, you can do so as follows: ```yaml k9s: shellPod: hostPathVolume: - name: docker-socket # Mount the Docker socket into the shell pod mountPath: /var/run/docker.sock # The path on the host to mount hostPath: /var/run/docker.sock readOnly: true ``` This will mount the Docker socket into the shell pod at `/var/run/docker.sock` and make it read-only. You can also mount any other directory or file in a similar way. --- ## Command Aliases In K9s, you can define your very own command aliases (shortnames) to access your resources. In your `$HOME/.config/k9s` define a file called `aliases.yaml`. A K9s alias defines pairs of alias:gvr. A gvr (Group/Version/Resource) represents a fully qualified Kubernetes resource identifier. Here is an example of an alias file: ```yaml # $XDG_DATA_HOME/k9s/aliases.yaml aliases: pp: v1/pods crb: rbac.authorization.k8s.io/v1/clusterrolebindings # As of v0.30.0 you can also refer to another command alias... fred: pod fred app=blee # => view pods in namespace fred with labels matching app=blee ``` Using this aliases file, you can now type `:pp` or `:crb` or `:fred` to activate their respective commands. --- ## HotKey Support Entering the command mode and typing a resource name or alias, could be cumbersome for navigating thru often used resources. We're introducing hotkeys that allow users to define their own key combination to activate their favorite resource views. Additionally, you can define context specific hotkeys by add a context level configuration file in `$XDG_DATA_HOME/k9s/clusters/clusterX/contextY/hotkeys.yaml` In order to surface hotkeys globally please follow these steps: 1. Create a file named `$XDG_CONFIG_HOME/k9s/hotkeys.yaml` 2. Add the following to your `hotkeys.yaml`. You can use resource name/short name to specify a command ie same as typing it while in command mode. ```yaml # $XDG_CONFIG_HOME/k9s/hotkeys.yaml hotKeys: # Hitting Shift-0 navigates to your pod view shift-0: shortCut: Shift-0 description: Viewing pods command: pods # Hitting Shift-1 navigates to your deployments shift-1: shortCut: Shift-1 description: View deployments command: dp # Hitting Shift-2 navigates to your xray deployments shift-2: shortCut: Shift-2 description: Xray Deployments command: xray deploy # Hitting Shift-S view the resources in the namespace of your current selection shift-s: shortCut: Shift-S override: true # => will override the default shortcut related action if set to true (default to false) description: Namespaced resources command: "$RESOURCE_NAME $NAMESPACE" keepHistory: true # whether you can return to the previous view ``` Not feeling so hot? Your custom hotkeys will be listed in the help view `?`. Also your hotkeys file will be automatically reloaded so you can readily use your hotkeys as you define them. You can choose any keyboard shortcuts that make sense to you, provided they are not part of the standard K9s shortcuts list. Similarly, referencing environment variables in hotkeys is also supported. The available environment variables can refer to the description in the [Plugins](#plugins) section. > NOTE: This feature/configuration might change in future releases! --- ## Port Forwarding over websockets K9s follows `kubectl` feature flag environment variables to enable/disable port-forwarding over websockets. (default enabled in >1.30) To disable Websocket support, set `KUBECTL_PORT_FORWARD_WEBSOCKETS=false` --- ## FastForwards As of v0.25.0, you can leverage the `FastForwards` feature to tell K9s how to default port-forwards. In situations where you are dealing with multiple containers or containers exposing multiple ports, it can be cumbersome to specify the desired port-forward from the dialog as in most cases, you already know which container/port tuple you desire. For these use cases, you can now annotate your manifests with the following annotations: @ `k9scli.io/auto-port-forwards` activates one or more port-forwards directly bypassing the port-forward dialog all together. @ `k9scli.io/port-forwards` pre-selects one or more port-forwards when launching the port-forward dialog. The annotation value takes on the shape `container-name::[local-port:]container-port` > NOTE: for either cases above you can specify the container port by name or number in your annotation! ### Example ```yaml # Pod fred apiVersion: v1 kind: Pod metadata: name: fred annotations: k9scli.io/auto-port-forwards: zorg::5556 # => will default to container zorg port 5556 and local port 5566. No port-forward dialog will be shown. # Or... k9scli.io/port-forwards: bozo::9090:p1 # => launches the port-forward dialog selecting default port-forward on container bozo port named p1(8081) # mapping to local port 9090. ... spec: containers: - name: zorg ports: - name: p1 containerPort: 5556 ... - name: bozo ports: - name: p1 containerPort: 8081 - name: p2 containerPort: 5555 ... ``` The annotation value must specify a container to forward to as well as a local port and container port. The container port may be specified as either a port number or port name. If the local port is omitted then the local port will default to the container port number. Here are a few examples: 1. bozo::http - creates a pf on container `bozo` with port name http. If http specifies port number 8080 then the local port will be 8080 as well. 2. bozo::9090:http - creates a pf on container `bozo` mapping local port 9090->http(8080) 3. bozo::9090:8080 - creates a pf on container `bozo` mapping local port 9090->8080 --- ## Custom Views [SneakCast v0.17.0 on The Beach! - Yup! sound is sucking but what a setting!](https://youtu.be/7S33CNLAofk) You can change which columns shows up for a given resource via custom views. To surface this feature, you will need to create a new configuration file, namely `$XDG_CONFIG_HOME/k9s/views.yaml`. This file leverages GVR (Group/Version/Resource) to configure the associated table view columns. If no GVR is found for a view the default rendering will take over (ie what we have now). Going wide will add all the remaining columns that are available on the given resource after your custom columns. To boot, you can edit your views config file and tune your resources views live! 📢 🎉 As of `release v0.40.0` you can specify json parse expressions to further customize your resources rendering. The new column syntax is as follows: > COLUMN_NAME<:json_parse_expression><|column_attributes> Where `:json_parse_expression` represents an expression to pull a specific snippet out of the resource manifest. Similar to `kubectl -o custom-columns` command. This expression is optional. > IMPORTANT! Columns must be valid YAML strings. Thus if your column definition contains non-alpha chars > they must figure with either single/double quotes or escaped via `\` > NOTE! Be sure to watch k9s logs as any issues with the custom views specification are only surfaced in the logs. Additionally, you can specify column attributes to further tailor the column rendering. To use this you will need to add a `|` indicator followed by your rendering bits. You can have one or more of the following attributes: * `T` -> time column indicator * `N` -> number column indicator * `W` -> turns on wide column aka only shows while in wide mode. Defaults to the standard resource definition when present. * `S` -> Ensures a column is visible and not wide. Overrides `wide` std resource definition if present. * `H` -> Hides the column * `L` -> Left align (default) * `R` -> Right align Here is a sample views configuration that customize a pods and services views. ```yaml # $XDG_CONFIG_HOME/k9s/views.yaml views: v1/pods: columns: - AGE - NAMESPACE|WR # => 🌚 Specifies the NAMESPACE column to be right aligned and only visible while in wide mode - ZORG:.metadata.labels.fred\.io\.kubernetes\.blee # => 🌚 extract fred.io.kubernetes.blee label into it's own column - BLEE:.metadata.annotations.blee|R # => 🌚 extract annotation blee into it's own column and right align it - NAME - IP - NODE - STATUS - READY - MEM/RL|S # => 🌚 Overrides std resource default wide attribute via `S` for `Show` - '%MEM/R|' # => NOTE! column names with non alpha names need to be quoted as columns must be strings! v1/pods@fred: # => 🌚 New v0.40.6! Customize columns for a given resource and namespace! columns: - AGE - NAME|WR v1/pods@kube*: # => 🌚 New v0.40.6! You can also specify a namespace using a regular expression. columns: - NAME - AGE - LABELS cool-kid: # => 🌚 New v0.40.8! You can also reference a specific alias and display a custom view for it columns: - AGE - NAMESPACE|WR v1/services: columns: - AGE - NAMESPACE - NAME - TYPE - CLUSTER-IP ``` > 🩻 NOTE: This is experimental and will most likely change as we iron this out! --- ## Plugins K9s allows you to extend your command line and tooling by defining your very own cluster commands via plugins. Minimally we look at `$XDG_CONFIG_HOME/k9s/plugins.yaml` to locate all available plugins. Additionally, K9s will scan the following directories for additional plugins: * `$XDG_CONFIG_HOME/k9s/plugins` * `$XDG_DATA_HOME/k9s/plugins` * `$XDG_DATA_DIRS/k9s/plugins` The plugin file content can be either a single plugin snippet, a collections of snippets or a complete plugins definition (see examples below...). A plugin is defined as follows: * Shortcut option represents the key combination a user would type to activate the plugin. Valid values are [a-z], Shift-[A-Z], Ctrl-[A-Z]. * Override option make that the default action related to the shortcut will be overridden by the plugin * Confirm option (when enabled) lets you see the command that is going to be executed and gives you an option to confirm or prevent execution * Description will be printed next to the shortcut in the k9s menu * Scopes defines a collection of resources names/short-names for the views associated with the plugin. You can specify `all` to provide this shortcut for all views. * Command represents ad-hoc commands the plugin runs upon activation * Background specifies whether or not the command runs in the background * Args specifies the various arguments that should apply to the command above * OverwriteOutput boolean option allows plugin developers to provide custom messages on plugin stdout execution. See example in [#2644](https://github.com/derailed/k9s/pull/2644) * Dangerous boolean option enables disabling the plugin when read-only mode is set. See [#2604](https://github.com/derailed/k9s/issues/2604) * Inputs defines a list of input fields to prompt the user for before executing the plugin (see below) #### Plugin Inputs Plugins can define input fields that prompt users for values before execution. This is useful when you need dynamic values like replica counts, environment variables, or profile selections. A maximum of 5 inputs per plugin is allowed. Each input has the following properties: * `name` (required) -- the input identifier used to reference the value in args as `$INPUT_` (uppercase) * `label` -- the label shown to the user in the input dialog * `type` (required) -- the input type: `string`, `number`, `bool`, or `dropdown` * `required` -- when true, the user must provide a value before the plugin can execute * `default` -- a default value pre-filled in the input field (must be a valid option for `dropdown`, `"true"`/`"false"` for `bool`, or a valid number for `number`) * `options` -- for `dropdown` type only, defines the list of available choices Input values are available in plugin args using the format `$INPUT_` where `` is the uppercase version of the input name. **Input Types:** | Type | Description | UI Element | |------|-------------|------------| | `string` | Free-form text input | Text field | | `number` | Numeric input (integers and floats) | Text field with numeric validation | | `bool` | Boolean toggle | Checkbox | | `dropdown` | Selection from predefined options | Dropdown menu | **Example:** ```yaml plugins: demo-inputs: shortCut: Ctrl-Y description: Demo all input types scopes: - po command: bash background: false args: - -c - >- echo "=== Plugin input demo ===" && echo "" && echo "Pod: $NAME" && echo "Namespace: $NAMESPACE" && echo "Context: $CONTEXT" && echo "" && echo "=== Your inputs ===" && if [ -n "$INPUT_MESSAGE" ]; then echo "Message: $INPUT_MESSAGE (set)"; else echo "Message: (not set)"; fi && if [ -n "$INPUT_COUNT" ]; then echo "Count: $INPUT_COUNT (set)"; else echo "Count: (not set)"; fi && if [ -n "$INPUT_ENABLED" ]; then echo "Enabled: $INPUT_ENABLED (set)"; else echo "Enabled: (not set)"; fi && if [ -n "$INPUT_ENVIRONMENT" ]; then echo "Environment: $INPUT_ENVIRONMENT (set)"; else echo "Environment: (not set)"; fi && echo "" && read -p "Press Enter to return to k9s..." inputs: - name: message label: Enter a message type: string required: true default: hello world - name: count label: Enter a number type: number required: true default: 3 - name: enabled label: Enable feature type: bool required: false default: true - name: environment label: Select environment type: dropdown required: true default: staging options: - development - staging - production ``` K9s does provide additional environment variables for you to customize your plugins arguments. Currently, the available environment variables are as follows: * `$RESOURCE_GROUP` -- the selected resource group * `$RESOURCE_VERSION` -- the selected resource api version * `$RESOURCE_NAME` -- the selected resource name * `$NAMESPACE` -- the selected resource namespace * `$NAME` -- the selected resource name * `$CONTAINER` -- the current container if applicable * `$FILTER` -- the current filter if any * `$KUBECONFIG` -- the KubeConfig location. * `$CLUSTER` the active cluster name * `$CONTEXT` the active context name * `$USER` the active user * `$GROUPS` the active groups * `$POD` while in a container view * `$COL-` use a given column name for a viewed resource. Must be prefixed by `COL-`! Curly braces can be used to embed an environment variable inside another string, or if the column name contains special characters. (e.g. `${NAME}-example` or `${COL-%CPU/L}`) ### Plugin Examples Define several plugins and host them in a single file. These can leave in the K9s root config so that they are available on any clusters. Additionally, you can define cluster/context specific plugins for your clusters of choice by adding clusterA/contextB/plugins.yaml file. The following defines a plugin for viewing logs on a selected pod using `ctrl-l` as shortcut. ```yaml # Define several plugins in a single file in the K9s root configuration # $XDG_DATA_HOME/k9s/plugins.yaml plugins: # Defines a plugin to provide a `ctrl-l` shortcut to tail the logs while in pod view. fred: shortCut: Ctrl-L override: false overwriteOutput: false confirm: false dangerous: false description: Pod logs scopes: - pods command: kubectl background: false args: - logs - -f - $NAME - -n - $NAMESPACE - --context - $CONTEXT ``` Similarly you can define the plugin above in a directory using either a file per plugin or several plugins per files as follow... The following defines two plugins namely fred and zorg. ```yaml # Multiple plugins in a single file... # Note: as of v0.40.9 you can have ad-hoc plugin dirs # Loads plugins fred and zorg # $XDG_DATA_HOME/k9s/plugins/misc-plugins/blee.yaml fred: shortCut: Shift-B description: Bozo scopes: - deploy command: bozo zorg: shortCut: Shift-Z description: Pod logs scopes: - svc command: zorg ``` Lastly you can define plugin snippets in their own file. The snippet will be named from the file name. In this case, we define a `bozo` plugin using a plugin snippet. ```yaml # $XDG_DATA_HOME/k9s/plugins/schtuff/bozo.yaml shortCut: Shift-B description: Bozo scopes: - deploy command: bozo ``` > NOTE: This is an experimental feature! Options and layout may change in future K9s releases as this feature solidifies. --- ## Benchmark Your Applications K9s integrates [Hey](https://github.com/rakyll/hey) from the brilliant and super talented [Jaana Dogan](https://github.com/rakyll). `Hey` is a CLI tool to benchmark HTTP endpoints similar to AB bench. This preliminary feature currently supports benchmarking port-forwards and services (Read the paint on this is way fresh!). To setup a port-forward, you will need to navigate to the PodView, select a pod and a container that exposes a given port. Using `SHIFT-F` a dialog comes up to allow you to specify a local port to forward. Once acknowledged, you can navigate to the PortForward view (alias `pf`) listing out your active port-forwards. Selecting a port-forward and using `CTRL-B` will run a benchmark on that HTTP endpoint. To view the results of your benchmark runs, go to the Benchmarks view (alias `be`). You should now be able to select a benchmark and view the run stats details by pressing ``. NOTE: Port-forwards only last for the duration of the K9s session and will be terminated upon exit. Initially, the benchmarks will run with the following defaults: * Concurrency Level: 1 * Number of Requests: 200 * HTTP Verb: GET * Path: / The PortForward view is backed by a new K9s config file namely: `$XDG_DATA_HOME/k9s/clusters/clusterX/contextY/benchmarks.yaml`. Each cluster you connect to will have its own bench config file, containing the name of the K8s context for the cluster. Changes to this file should automatically update the PortForward view to indicate how you want to run your benchmarks. Benchmarks result reports are stored in `$XDG_STATE_HOME/k9s/clusters/clusterX/contextY` Here is a sample benchmarks.yaml configuration. Please keep in mind this file will likely change in subsequent releases! ```yaml # This file resides in $XDG_DATA_HOME/k9s/clusters/clusterX/contextY/benchmarks.yaml benchmarks: # Indicates the default concurrency and number of requests setting if a container or service rule does not match. defaults: # One concurrent connection concurrency: 1 # Number of requests that will be sent to an endpoint requests: 500 containers: # Containers section allows you to configure your http container's endpoints and benchmarking settings. # NOTE: the container ID syntax uses namespace/pod-name:container-name default/nginx:nginx: # Benchmark a container named nginx using POST HTTP verb using http://localhost:port/bozo URL and headers. concurrency: 1 requests: 10000 http: path: /bozo method: POST body: {"fred":"blee"} header: Accept: - text/html Content-Type: - application/json services: # Similarly you can Benchmark an HTTP service exposed either via NodePort, LoadBalancer types. # Service ID is ns/svc-name default/nginx: # Set the concurrency level concurrency: 5 # Number of requests to be sent requests: 500 http: method: GET # This setting will depend on whether service is NodePort or LoadBalancer. NodePort may require vendor port tunneling setting. # Set this to a node if NodePort or LB if applicable. IP or dns name. host: A.B.C.D path: /bumblebeetuna auth: user: jean-baptiste-emmanuel password: Zorg! ``` --- ## K9s RBAC FU On RBAC enabled clusters, you would need to give your users/groups capabilities so that they can use K9s to explore their Kubernetes cluster. K9s needs minimally read privileges at both the cluster and namespace level to display resources and metrics. These rules below are just suggestions. You will need to customize them based on your environment policies. If you need to edit/delete resources extra Fu will be necessary. > NOTE! Cluster/Namespace access may change in the future as K9s evolves. > NOTE! We expect K9s to keep running even in atrophied clusters/namespaces. Please file issues if this is not the case! ### Cluster RBAC scope ```yaml --- # K9s Reader ClusterRole kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 metadata: name: k9s rules: # Grants RO access to cluster resources node and namespace - apiGroups: [""] resources: ["nodes", "namespaces"] verbs: ["get", "list", "watch"] # Grants RO access to RBAC resources - apiGroups: ["rbac.authorization.k8s.io"] resources: ["clusterroles", "roles", "clusterrolebindings", "rolebindings"] verbs: ["get", "list", "watch"] # Grants RO access to CRD resources - apiGroups: ["apiextensions.k8s.io"] resources: ["customresourcedefinitions"] verbs: ["get", "list", "watch"] # Grants RO access to metric server (if present) - apiGroups: ["metrics.k8s.io"] resources: ["nodes", "pods"] verbs: ["get", "list", "watch"] --- # Sample K9s user ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: k9s subjects: - kind: User name: fernand apiGroup: rbac.authorization.k8s.io roleRef: kind: ClusterRole name: k9s apiGroup: rbac.authorization.k8s.io ``` ### Namespace RBAC scope If your users are constrained to certain namespaces, K9s will need to following role to enable read access to namespaced resources. ```yaml --- # K9s Reader Role (default namespace) kind: Role apiVersion: rbac.authorization.k8s.io/v1 metadata: name: k9s namespace: default rules: # Grants RO access to most namespaced resources - apiGroups: ["", "apps", "autoscaling", "batch", "extensions"] resources: ["*"] verbs: ["get", "list", "watch"] # Grants RO access to metric server - apiGroups: ["metrics.k8s.io"] resources: ["pods", "nodes"] verbs: - get - list - watch --- # Sample K9s user RoleBinding apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: k9s namespace: default subjects: - kind: User name: fernand apiGroup: rbac.authorization.k8s.io roleRef: kind: Role name: k9s apiGroup: rbac.authorization.k8s.io ``` --- ## Skins Example: Dracula Skin ;) Dracula Skin You can style K9s based on your own sense of look and style. Skins are YAML files, that enable a user to change the K9s presentation layer. See this repo `skins` directory for examples. You can skin k9s by default by specifying a UI.skin attribute. You can also change K9s skins based on the context you are connecting too. In this case, you can specify a skin field on your cluster config aka `skin: dracula` (just the name of the skin file without the extension!) and copy this repo `skins/dracula.yaml` to `$XDG_CONFIG_HOME/k9s/skins/` directory. You can also change the skin by setting `K9S_SKIN` in the environment, e.g. `export K9S_SKIN="dracula"`. In the case where your cluster spans several contexts, you can add a skin context configuration to your context configuration. This is a collection of {context_name, skin} tuples (please see example below!) Colors can be defined by name or using a hex representation. Of recent, we've added a color named `default` to indicate a transparent background color to preserve your terminal background color settings if so desired. > NOTE: This is very much an experimental feature at this time, more will be added/modified if this feature has legs so thread accordingly! > NOTE: Please see [K9s Skins](https://k9scli.io/topics/skins/) for a list of available colors. To skin a specific context and provided the file `in-the-navy.yaml` is present in your skins directory. ```yaml # $XDG_DATA_HOME/k9s/clusters/clusterX/contextY/config.yaml k9s: cluster: clusterX skin: in-the-navy readOnly: false namespace: active: default lockFavorites: false favorites: - kube-system - default view: active: po featureGates: nodeShell: false portForwardAddress: localhost ``` You can also specify a default skin for all contexts in the root k9s config file as so: ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false screenDumpDir: /tmp/dumps refreshRate: 2 maxConnRetry: 5 readOnly: false noExitOnCtrlC: false ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false noIcons: false invert: false # Toggles reactive UI. This option provide for watching on disk artifacts changes and update the UI live Defaults to false. reactive: false # By default all contexts will use the dracula skin unless explicitly overridden in the context config file. skin: dracula # => assumes the file skins/dracula.yaml is present in the $XDG_DATA_HOME/k9s/skins directory defaultsToFullScreen: false skipLatestRevCheck: false disablePodCounting: false shellPod: image: busybox namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 100 buffer: 5000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 ``` ```yaml # $XDG_DATA_HOME/k9s/skins/in-the-navy.yaml # Skin InTheNavy! k9s: # General K9s styles body: fgColor: dodgerblue bgColor: '#ffffff' logoColor: '#0000ff' # ClusterInfoView styles. info: fgColor: lightskyblue sectionColor: steelblue # Help panel styles help: fgColor: white bgColor: black keyColor: cyan numKeyColor: blue sectionColor: gray frame: # Borders styles. border: fgColor: dodgerblue focusColor: aliceblue # MenuView attributes and styles. menu: fgColor: darkblue # Style of menu text. Supported options are "dim" (default), "normal", and "bold" fgStyle: dim keyColor: cornflowerblue # Used for favorite namespaces numKeyColor: cadetblue # CrumbView attributes for history navigation. crumbs: fgColor: white bgColor: steelblue activeColor: skyblue # Resource status and update styles status: newColor: '#00ff00' modifyColor: powderblue addColor: lightskyblue errorColor: indianred highlightcolor: royalblue killColor: slategray completedColor: gray # Border title styles. title: fgColor: aqua bgColor: white highlightColor: skyblue counterColor: slateblue filterColor: slategray views: # TableView attributes. table: fgColor: blue bgColor: darkblue cursorColor: aqua # Header row styles. header: fgColor: white bgColor: darkblue sorterColor: orange selectedSortColumnColor: lightskyblue # YAML info styles. yaml: keyColor: steelblue colonColor: blue valueColor: royalblue # Logs styles. logs: fgColor: lightskyblue bgColor: black indicator: fgColor: dodgerblue bgColor: black toggleOnColor: limegreen toggleOffColor: gray ``` --- ## Contributors Without the contributions from these fine folks, this project would be a total dud! --- ## Known Issues This is still work in progress! If something is broken or there's a feature that you want, please file an issue and if so inclined submit a PR! K9s will most likely blow up if... 1. You're running older versions of Kubernetes. K9s works best on later Kubernetes versions. 2. You don't have enough RBAC fu to manage your cluster. --- ## ATTA Girls/Boys! K9s sits on top of many open source projects and libraries. Our *sincere* appreciations to all the OSS contributors that work nights and weekends to make this project a reality! --- ## Meet The Core Team! If you have chops in GO and K8s and would like to offer your time to help maintain and enhance this project, please reach out to me. * [Fernand Galiana](https://github.com/derailed) * email fernand@imhotep.io * twitter [@kitesurfer](https://twitter.com/kitesurfer?lang=en) We always enjoy hearing from folks who benefit from our work! ## Contributions Guideline * File an issue first prior to submitting a PR! * Ensure all exported items are properly commented * If applicable, submit a test suite against your PR --- Imhotep  © 2026 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.1.1.md ================================================ # Release v0.1.1
--- ## Notes Thank you to all that contributed with flushing out issues with 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!
--- ## Change Logs + Added config file to tracks K9s configuration ~/.k9s/config.yml + Change log file location to use Go tmp dir stdlib package. Check the log destination and config file location using ```shell k9s info ``` + Removed 9 namespaces limitation by allowing user to manage namespaces using the namespace view or the dotfile configuration. + Updated keyboard navigation on log view. Up/Down, PageUp/PageDown + Added configuration to manage buffer size while viewing container logs + Added fail early countermeasures. Hopefully will help us figure out non starts?? + Beefed up CLI arguments + Changed help command to just ? + Changed back command to just Esc + Added filtering feature to trim down viewed resources Use **/**term or **Esc** to kill filtering
--- ## Resolved Bugs + [Issue 17] Multi user log usage. Added user descriptor on log files + [Issue 18] Non starts due to color. Added preflight item on README. + [Issue 13] ? does not do anything. + [Issue 8] Don't reset selection after deletion. + [Issue 1,7] Limit available namespaces. Added config file to manage top 5 namespaces and also added a switch command while in the namespace resource view. + [Issue 6] Sorting/filtering. Added preliminary filtering capability. Raw search on table item using /filter_me command. Use Esc to turn off filtering. + [Issue 5] Scrolling in log view. Added up/down/pageUp/pageDown. + [Issue 3] No output when failing. Added fail early countermeasures. Hopefully will give us a heads up now to track down config issues?? ================================================ FILE: change_logs/release_0.1.10.md ================================================ # Release v0.1.10 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs --- ## Resolved Bugs * [Issue #92](https://github.com/derailed/k9s/issues/92) ================================================ FILE: change_logs/release_0.1.11.md ================================================ # Release v0.1.11 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs --- ## Resolved Bugs * [Issue #81](https://github.com/derailed/k9s/issues/81) * [Issue #96](https://github.com/derailed/k9s/issues/96) * [Issue #95](https://github.com/derailed/k9s/issues/95) * [Issue #93](https://github.com/derailed/k9s/issues/93) ================================================ FILE: change_logs/release_0.1.2.md ================================================ # Release v0.1.2
--- ## Notes Thank you to all that contributed with flushing out issues with 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!
--- ## Change Logs + Navigation changed! Thanks to [Teppei Fukuda](https://github.com/knqyf263) for hinting about the different modes ie command vs navigation. Now in order to navigate to a specific kubernetes resource you need to issue this command to say see all pods (using key `>`): ```text >po ``` + Similarly to filter on a given resource you can use `/` and type your filter. + In both instances `` will back you out of command mode and into navigation mode.
--- ## Resolved Bugs + [Issue #23](https://github.com/derailed/k9s/issues/23) + [Issue #19](https://github.com/derailed/k9s/issues/19) ================================================ FILE: change_logs/release_0.1.3.md ================================================ # Release v0.1.3
--- ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!!
--- ## Change Logs
+ IMPORTANT: Changed HotKeys to single chars for most non destructive operations For **command** mode use the <:> key For **search** mode use the key + Revert Delete to Ctrl-D. (Sorry for the brain fart on this!) + IMPORTANT! Breaking change! The K9s config has changed to handle multi-clusters. If K9s does not launch, please move over .k9s/config.yml. + Added Resource for ReplicaController + Added auth support for cloud provider using the same auth options as kubectl --- ## Resolved Bugs + [Issue #50](https://github.com/derailed/k9s/issues/50) + [Issue #44](https://github.com/derailed/k9s/issues/44) + [Issue #42](https://github.com/derailed/k9s/issues/42) + [Issue #38](https://github.com/derailed/k9s/issues/38) + [Issue #36](https://github.com/derailed/k9s/issues/36) + [Issue #28](https://github.com/derailed/k9s/issues/28) + [Issue #24](https://github.com/derailed/k9s/issues/24) + [Issue #24](https://github.com/derailed/k9s/issues/3) ================================================ FILE: change_logs/release_0.1.4.md ================================================ # Release v0.1.4
--- ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!!
--- ## Change Logs
+ IMPORTANT! Breaking change! The K9s config was changed If K9s does not launch, move over .k9s/config.yml + Reworked CLI args to better support contexts --- ## Resolved Bugs + [Issue #67](https://github.com/derailed/k9s/issues/67) + [Issue #65](https://github.com/derailed/k9s/issues/65) + [Issue #64](https://github.com/derailed/k9s/issues/64) + [Issue #60](https://github.com/derailed/k9s/issues/60) + [Issue #57](https://github.com/derailed/k9s/issues/57) + [Issue #56](https://github.com/derailed/k9s/issues/56) ================================================ FILE: change_logs/release_0.1.5.md ================================================ # Release v0.1.5
--- ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!!
--- ## Change Logs
+ [Feature #54](https://github.com/derailed/k9s/issues/54) Changed pod colorer to not show error status while initializing Tx! [jawahars16](https://github.com/jawahars16), [jmreicha](https://github.com/jmreicha) --- ## Resolved Bugs - Fixed release version not showing up on execs ================================================ FILE: change_logs/release_0.1.6.md ================================================ # Release v0.1.6
--- ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!!
--- ## Change Logs
+ [Feature request #43](https://github.com/derailed/k9s/issues/43) Add CronJob Manual Trigger. All of this work is attributed to [dzoeteman](https://github.com/dzoeteman). Thank you! + Added ability to view logs on Job resource. + [Feature request #37](https://github.com/derailed/k9s/issues/37) Added Describe on resources as in kubectl describe xxx + NOTE! Changed alias to `job` and `cron` vs `jo` and `cjo` --- ## Resolved Bugs - Fix issue with ServiceAccounts not displaying ================================================ FILE: change_logs/release_0.1.7.md ================================================ # Release v0.1.7 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs * [Feature request #73](https://github.com/derailed/k9s/issues/73) Add support for search/filter * [Feature request #48](https://github.com/derailed/k9s/issues/48) Add support to filter alias view * [Feature request #30](https://github.com/derailed/k9s/issues/30) Add support for init containers * Major cleanup + refactor. Might have introduced some instability... --- ## Resolved Bugs * [Issue #71](https://github.com/derailed/k9s/issues/71) K9s no longer assumes a metrics server is running. Better if it is but should not prevent from viewing resources. * [Issue #77](https://github.com/derailed/k9s/issues/77) ================================================ FILE: change_logs/release_0.1.8.md ================================================ # Release v0.1.8 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs --- ## Resolved Bugs * [Issue #79](https://github.com/derailed/k9s/issues/79) ================================================ FILE: change_logs/release_0.1.9.md ================================================ # Release v0.1.9 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs --- ## Resolved Bugs * [Issue #83](https://github.com/derailed/k9s/issues/83) * [Issue #84](https://github.com/derailed/k9s/issues/84) ================================================ FILE: change_logs/release_0.10.0.md ================================================ # Release v0.10.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs First off, Happy 2020 to you and yours!! Best wishes for good health and good fortune! This release represents a major overall of K9s core. It's been a long time coming and indeed a long day in the saddle. There has been many code changes and hopefully improvements from previous releases. I think some of it is better but I've probably borked a bunch of functionality in the process ;( I look to you to help me flesh out issues and bugs, so we can move on to bigger and exciting features in 2020! Please do thread lightly on this one and make sure to keep a previous release handy just in case... This was a boatload of work to make this happen, my hope is you'll enjoy some of the improvements... In any case, and as always, if you feel they're better ways or imperfections by all means please pipe in! I would also like to take this opportunity to thank all of you for your kind PRs and issues and for your support and patience with K9s. I understand this release might be a bit torked, but I will work hard to make sure we reach stability quickly in the next few drops. Thank you for your understanding!! ## VatDoesDisDo? Most of the refactors are around K8s resource fetching and viewing as well as navigation changes. Based on our observations this release might load resources a bit slower than usual but should make navigation much faster once the cache is primed. We've made some improvements to be more consistent with navigation, menus and shortcuts management. We've got ride off the breadcrumbs navigation ie no more `p` to nav back. Crumbs are now just tracking a natural resource navigation ie pod -> containers -> logs and no longer commands history. Each new command will now load a brand new breadcrumb. You can press `` to nav back to the previous page. We're also introducing a new hotkeys feature, that efforts creating shortcuts to navigate to your favorite resources ie shift-0 -> view pods, shift-1 -> view deployments (See HotKey section below). I know there were many outstanding PRS (Thank you to all that I've submitted!) and given the extent of the changes, I've resolved to incorporate them in manually vs having to deal with merge conflicts. ## Custom Skins Per Cluster In this release, we've added support for skins at the cluster level. Do you want K9s to look differently based on which cluster you're connecting to? All you'll need is to name the skin file in the K9s home directory as follows `mycluster_skin.yml`. If no cluster specific skin file is found, the standard `skin.yml` file will be loaded if present. Please checkout the `skins` directory in this repo or PR me if you have cool skins you'd like to share with your fellow K9sers as they will be featured in these release notes and in the project README. ## Hot(Ness)? Feeling like you want to be able to quickly switch around your favorite resources with your very own shortcut? Wouldn't it be dandy to navigate to your deployments via a shortcut vs entering a command `:deploy`? Here is what you'll need to do to add HotKeys to your K9s sessions: 1. In your .k9s home directory create a file named `hotkey.yml` 2. For example add the following to your `hotkey.yml`. You can use short names or resource name to specify a command ie same as typing it in command mode. ```yaml hotKey: shift-0: shortCut: Shift-0 description: View pods command: pods shift-1: shortCut: Shift-1 description: View deployments command: dp shift-2: shortCut: Shift-2 description: View services command: service shift-3: shortCut: Shift-3 description: View statefulsets command: statefulsets ``` Not feeling too `Hot`? No worried, your custom hotkeys list will be listed in the help view.``. You can choose any keyboard shortcuts that make sense to you, provided they are not part of the standard K9s shortcuts list. ## PullRequests * [PR #447](https://github.com/derailed/k9s/pull/447) K9s MacPorts support. Thank you! [Nils Breunese](https://github.com/breun) * [PR #446](https://github.com/derailed/k9s/pull/446) Same key invert sort. Big thanks!! [James Hiew](https://github.com/jameshiew) * [PR #445](https://github.com/derailed/k9s/pull/445) Use `?` to toggle help. Major thanks!! [Ramz](https://github.com/ageekymonk) * [PR #443](https://github.com/derailed/k9s/pull/443) Hex color skin support. Great work! [Gavin Ray](https://github.com/gavinray97) * [PR #442](https://github.com/derailed/k9s/pull/442) Full screen/Wrap support on log view. ATTA BOY! [Shiv3](https://github.com/shiv3) * [PR #412](https://github.com/derailed/k9s/pull/412) Simplify cruder interface. ATTA BOY!! (as always)[Gustavo Silva Paiva](https://github.com/paivagustavo) * [PR #350](https://github.com/derailed/k9s/pull/350) Sanitize file name before saving. All credits to [Tuomo Syvänperä](https://github.com/syvanpera) --- ## Resolved Bugs/Features * [Issue #437](https://github.com/derailed/k9s/issues/437) Error when viewing cluster role on a role binding. * [Issue #434](https://github.com/derailed/k9s/issues/434) Same key `?` toggle help. * [Issue #432](https://github.com/derailed/k9s/issues/432) Add address field to port forwards. * [Issue #431](https://github.com/derailed/k9s/issues/431) Add LimitRange resource support. * [Issue #430](https://github.com/derailed/k9s/issues/430) Add HotKey support. * [Issue #426](https://github.com/derailed/k9s/issues/426) Address slow scroll while in table view. * [Issue #417](https://github.com/derailed/k9s/issues/417) Ensure code lints correctly. Thank you Gustavo!! * [Issue #415](https://github.com/derailed/k9s/issues/415) Add provisions to support longer clusterinfo/namespace header. * [Issue #408](https://github.com/derailed/k9s/issues/408) Same key toggle inverse sort. * [Issue #402](https://github.com/derailed/k9s/issues/402) Add `all` support to plugin scope. * [Issue #401](https://github.com/derailed/k9s/issues/401) Add support for custom plugins on all views. * [Issue #397](https://github.com/derailed/k9s/issues/397) Support HPA v2beta1 + v2beta2. --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.1.md ================================================ # Release v0.10.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! First casualties... As my pappy used to say `can't cook without making a big mess`. Also reminds me of his very last words `A Truck?`... --- ## Resolved Bugs/Features * [Issue #450](https://github.com/derailed/k9s/issues/450) Skins work messed up the UI. Thank you [Julio H Morimoto](https://github.com/juliohm1978)!! --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.2.md ================================================ # Release v0.10.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #451](https://github.com/derailed/k9s/issues/451) * [Issue #415](https://github.com/derailed/k9s/issues/415) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.3.md ================================================ # Release v0.10.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! Thank you all for kicking the tires on these new drops and in making sure we get back to nominal quickly. You guys ROCK!! --- ## Resolved Bugs/Features * [Issue #455](https://github.com/derailed/k9s/issues/455) * [Issue #454](https://github.com/derailed/k9s/issues/454) * [Issue #453](https://github.com/derailed/k9s/issues/453) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.4.md ================================================ # Release v0.10.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #456](https://github.com/derailed/k9s/issues/456) * [Issue #452](https://github.com/derailed/k9s/issues/452) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.5.md ================================================ # Release v0.10.5 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! Starting the new year with a ... Bug! Hopefully moving the needle a bit more in the right direction?? --- ## Resolved Bugs/Features * [Issue #457](https://github.com/derailed/k9s/issues/457) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.6.md ================================================ # Release v0.10.6 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #452](https://github.com/derailed/k9s/issues/452) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.7.md ================================================ # Release v0.10.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #458](https://github.com/derailed/k9s/issues/458) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.8.md ================================================ # Release v0.10.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #460](https://github.com/derailed/k9s/issues/460) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.10.9.md ================================================ # Release v0.10.9 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release! --- ## Resolved Bugs/Features * [Issue #461](https://github.com/derailed/k9s/issues/461) * [Issue #462](https://github.com/derailed/k9s/issues/462) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.11.0.md ================================================ # Release v0.11.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Anyone At The Helm? K9s now offers preliminary support for Helm 3 charts! It's been a long time coming and I know a few early users had mentioned the need, but I wanted to see where Helm3 was going first. This is a preview release to see how we fair in Helm land. Besides managing your installed charts, you will be able to perform the following operations: * Uninstall a chart * View chart release notes * View deployed manifests #### How to use? Simply enter `:charts` K9s alias command to view the deployed Helm3 charts on your cluster. If you're using Helm3 in your current clusters, please give it a rip and also pipe in for potential features/enhancements. Mind the gap here as the paint on this feature is totally fresh... ### Bring Out Your Deads... There are also a few bugs fixes from the refactor aftermath that made this drop. I know this was a bit of a brutal transition, so thank you all for your patience and for filing issues! I am hopeful that K9s will stabilize quickly so we can move on to bigger things. --- ## Resolved Bugs/Features --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.11.1.md ================================================ # Release v0.11.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- Maintenance Release! --- ## Resolved Bugs/Features * [Issue #466](https://github.com/derailed/k9s/issues/466) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.11.2.md ================================================ # Release v0.11.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- Maintenance Release! --- ## Resolved Bugs/Features * [Issue #469](https://github.com/derailed/k9s/issues/469) * [Issue #468](https://github.com/derailed/k9s/issues/468) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.11.3.md ================================================ # Release v0.11.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- Maintenance Release! ### Speedy Gonzales? In this drop, we took a bit of a perf pass in light of recent issues and thanks to [Chris Werner Rau](https://github.com/cwrau) pushing me and keeping me up to speed, I've digged a bit deeper and found that there might be some seemingly innocent calls that sucked a bit of cycles during K9s refreshes. Long story short, I think this drop will improve perf by a factor of ~10x in some instances. Typically the initial load will be slower but subsequent loads should be much faster. Famous last words right? Anyhow, can't really take credit for this one as the awesome [Gustavo Silva Paiva](https://github.com/paivagustavo) suggested doing this a while back, but since I was already in flight with the refactor decided to punt until back online. And here we are now... Hopefully these findings will coalesce with yours?? If not, please forward bulk Prozac patches at the address below ;) Thanks Chris! Was up all night trying to figure out and what was the deal with K9s and your specific clusters. Hopefully this time for sure?? --- ## Resolved Bugs/Features * [Issue #475](https://github.com/derailed/k9s/issues/475) * [Issue #473](https://github.com/derailed/k9s/issues/473) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.12.0.md ================================================ # Release v0.12.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ### Searchable Logs There has been quite a few demands for this feature. It should now be generally available in this drop. It works the same as the resource view ie `/fred`, you can also specify a fuzzy filter using `/-f blee-duh`. The paint is still fresh on that deal and not super confident that it will work nominally as I had to rework the logs to enable. So totally possible I've hosed something in the process. ### APIServer Dud At times, it could be you've lost your api server connection while K9s was up which resulted in the `K9s screen of death` or in other words a hosed terminal session ;(. K9s should now detect this condition and close out. Once again no super sure about this implementation on that deal. So if you see K9s close out under normal condition, that means I would need to go back to the drawing board. ### FullScreen Logs I've been told having a flag to set fullScreen mode preference while viewing the logs would be `awesome`. Thanks [Fardin Khanjani](https://github.com/fardin01)! So there is now a new K9s config flag available to set your fullscreen logs `drathers` in your .k9s/config.yml. This flag defaults to false if not set. Here is a snippet: ```yaml # .k9s/config.yml k9s: refreshRate: 2 headless: false currentContext: crashandburn666 currentCluster: slowassnot fullScreenLogs: true ... ``` ### K9s Slackers I've enabled a [K9s slack channel](https://join.slack.com/t/k9sers/shared_invite/enQtOTAzNTczMDYwNjc5LWJlZjRkNzE2MzgzYWM0MzRiYjZhYTE3NDc1YjNhYmM2NTk2MjUxMWNkZGMzNjJiYzEyZmJiODBmZDYzOGQ5NWM) dedicated to all K9sers. This would be a place for us to meet and discuss ideas and use cases. I'll be honest here I am not a big slack aficionado as I don't do very well with interrupt drive workflows. But I think it would be a great resource for us all. NOTE: Please be kind to each others and threat everyone with respect as I would like this to be a safe and fun place for folks to hangout. Thank you for you support and understanding!! NOTE: I'll admit my slackFU is pretty low, so if you're a slack master, feel free to advise me for best practices around setup and management, etc... Thank you! --- ## Resolved Bugs/Features * [Issue #484](https://github.com/derailed/k9s/issues/484) * [Issue #481](https://github.com/derailed/k9s/issues/481) * [Issue #480](https://github.com/derailed/k9s/issues/480) * [Issue #479](https://github.com/derailed/k9s/issues/479) * [Issue #477](https://github.com/derailed/k9s/issues/477) * [Issue #476](https://github.com/derailed/k9s/issues/476) * [Issue #468](https://github.com/derailed/k9s/issues/468) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.2.0.md ================================================ # Release v0.2.0 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs + [Feature #97](https://github.com/derailed/k9s/issues/97) Changed log view to now use kubectl logs shell command. There were some issues with the previous implementation with missing info and panics. NOTE! User must type Ctrl-C to exit the logs and navigate back to K9s + Reordered containers to show spec.containers first vs spec.initcontainers. + [Feature #29](https://github.com/derailed/k9s/issues/29) Side effect of #97 Log coloring if present, will now show in the terminal. --- ## Resolved Bugs * [Issue #99](https://github.com/derailed/k9s/issues/99) * [Issue #100](https://github.com/derailed/k9s/issues/100) ================================================ FILE: change_logs/release_0.2.1.md ================================================ # Release v0.2.1 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs + Bad call on the log view! Reverted to original and on the radar for a rewrite. This will most likely introduce some disturbance in the log force, but the previous solution would not work for init containers and not running pods. + Added live resource filters + alias view + Surfaced elevator control for table view navigation --- ## Resolved Bugs ================================================ FILE: change_logs/release_0.2.2.md ================================================ # Release v0.2.2 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs + [Feature #98](https://github.com/derailed/k9s/issues/98) Pod view with node name. + [Feature #29](https://github.com/derailed/k9s/issues/29) Support ANSI colors in logs. + [Feature #105](https://github.com/derailed/k9s/issues/29) [Experimental] Add support for manual refresh. --- ## Resolved Bugs + [Issue #102](https://github.com/derailed/k9s/issues/102) + [Issue #104](https://github.com/derailed/k9s/issues/104) ================================================ FILE: change_logs/release_0.2.3.md ================================================ # Release v0.2.3 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs --- ## Resolved Bugs + [Issue #109](https://github.com/derailed/k9s/issues/109) ================================================ FILE: change_logs/release_0.2.4.md ================================================ # Release v0.2.4 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs + [Issue #87](https://github.com/derailed/k9s/issues/87) Added confirmation dialog on resource deletion. Either hit or button to bail out of deletion. --- ## Resolved Bugs ================================================ FILE: change_logs/release_0.2.5.md ================================================ # Release v0.2.5 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs + Added a help view to show available key bindings. Use `` to access it. + Alias view is now accessible via key `` + Pressing `` while on the namespace/context views will navigate directly to the pods view. + Added resource view breadcrumbs to easily navigate in history. Use key `

` to navigate back. + Added configuration `logBufferSize` to limit the size of the log view while viewing chatty or big logs. --- ## Resolved Bugs + [Issue #116](https://github.com/derailed/k9s/issues/116) + [Issue #113](https://github.com/derailed/k9s/issues/113) + [Issue #111](https://github.com/derailed/k9s/issues/111) + [Issue #110](https://github.com/derailed/k9s/issues/110) ================================================ FILE: change_logs/release_0.2.6.md ================================================ # Release v0.2.6 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs 1. Preliminary drop on sorting by resource columns 2. Add sort by namespace, name and age for all views 3. Add invert sort functionality on all sortable views 4. Add sort on pod views for metrics and most other columns 5. For all other views we will add custom sort on a per request basis --- ## Resolved Bugs + [Issue #117](https://github.com/derailed/k9s/issues/117) Was filtering out inactive ns which need to be there for all to see anyway! + [Issue #59](https://github.com/derailed/k9s/issues/59) ================================================ FILE: change_logs/release_0.3.0.md ================================================ # Release v0.3.0 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs 1. [Feature #127](https://github.com/derailed/k9s/issues/127) Added PodDisruptionBudgets initial support. --- ## Resolved Bugs + [Issue #126](https://github.com/derailed/k9s/issues/126) + [Issue #125](https://github.com/derailed/k9s/issues/125) + [Issue #122](https://github.com/derailed/k9s/issues/122) With feeling! ================================================ FILE: change_logs/release_0.3.1.md ================================================ # Release v0.3.1 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs 1. Refactored a lot of code! So please watch for disturbance in the force! 1. Changed cronjob and job aliases names to `cj` and `jo` respectively 1. *JobView*: Added new columns 1. Completions 2. Containers 3. Images 1. *NodeView* Added the following columns: 1. Available CPU/Mem 2. Capacity CPU/Mem 1. *NodeView* Added sort fields for cpu and mem --- ## Resolved Bugs + [Issue #133](https://github.com/derailed/k9s/issues/133) + [Issue #132](https://github.com/derailed/k9s/issues/132) + [Issue #129](https://github.com/derailed/k9s/issues/129) The easiest bug fix to date ;) ================================================ FILE: change_logs/release_0.3.2.md ================================================ # Release v0.3.2 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs 1. [Feature #124](https://github.com/derailed/k9s/issues/124) 1. *NodeView* Add current cpu/memory percentages to track current load on nodes. 2. *NodeView* Add requested cpu/memory percentages to track how many container resources are requested on the cluster. 3. *NodeView* Add requested cpu/memory raw metrics 4. *NodeView* Add corresponding column sorters --- ## Resolved Bugs + None ================================================ FILE: change_logs/release_0.3.3.md ================================================ # Release v0.3.3 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support!! --- ## Change Logs 1. [Feature #131](https://github.com/derailed/k9s/issues/131) Preliminary support for snapcraft builds ie read trying this out... 2. [Feature #118](https://github.com/derailed/k9s/issues/118) Add arm 32/64 bit builds. NOTE: will need help vetting this out as my RPi cluster is currently down. --- ## Resolved Bugs + [Feature #132](https://github.com/derailed/k9s/issues/132). With feelings! ================================================ FILE: change_logs/release_0.4.0.md ================================================ # Release v0.4.0 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! --- ## Change Logs > NOTE! Lots of changes here, please report any disturbances in the force. Thank you! 1. [Feature #82](https://github.com/derailed/k9s/issues/82) 1. Added ability to view RBAC policies while in clusterrole or role view. 2. The RBAC view will auto-refresh just like any K9s views hence showing live RBAC updates 3. RBAC view supports standard K8s verbs ie get,list,deletecollection,watch,create,patch,update,delete. 4. Any verbs not in this standard K8s verb list, will end up in the EXTRAS column. 5. For non resource URLS, we map standard REST verbs to K8s verbs ie post=create patch=update, etc. 6. Added initial sorts by name and group while in RBAC view. 7. Usage: To activate, enter command mode via `:cr` or `:ro` for clusterrole(cr)/role(ro), select a row and press `` 8. To bail out of the view and return to previous use `p` or `` 2. One feature that was mentioned in the comments for the RBAC feature above Tx [faheem-cliqz](https://github.com/faheem-cliqz)! was the ability to check RBAC rules for a given user. Namely reverse RBAC lookup 1. Added a new view, code name *Fu* view to show all the clusterroles/roles associated with a given user. 2. The view also supports for checking RBAC Fu for a user, a group or an app via a serviceaccount. 3. To activate: Enter command mode via `:fu` followed by u|g|s:subject + ``. For example: To view user *fred* Fu enter `:fu u:fred` + `` will show all clusterroles/roles and verbs associated with the user *fred* 4. For group Fu lookup, use the same command as above and substitute `u:fred` with `g:fred` 5. For ServiceAccount *fred* Fu check: use `s:fred` 3. Eliminated jitter while scrolling tables --- ## Resolved Bugs + None ================================================ FILE: change_logs/release_0.4.1.md ================================================ # Release v0.4.1 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! --- ## Change Logs ### o Subject View You can now view users/groups that are bound by RBAC rules without having to type to full subject name. To activate use the following command mode ```text # For users :usr # For groups :grp ``` These commands will pull all the available cluster and role bindings associated with these subject types. Use select + `` to see the associated RBAC policy rules. You can also filter/sort, like in any other K9s views with the added bonus of auto updates when new user/group bindings come into your clusters. To see ServiceAccount RBAC policies, you can navigate to the serviceaccount view aka `:sa` and select + `` to view the associated policy rules. ### o ~~FuView~~ is now PolicyView The Fu command has been deprecated for pol(icy) command to see all RBAC policies available on a subject. You can use `pol` (instead of `fu`) to list out RBAC policies associated with a user/group or serviceaccount. ```text # To list out all the RBAC policies associated with user `fernand` :pol u:fernand ``` ### Enter. Yes Please! Pressing `` on most resource views will now describe the resource by default. --- ## Resolved Bugs + RBAC long subject names [Issue #143](https://github.com/derailed/k9s/issues/143) + Support HPA v1 [Issue #140](https://github.com/derailed/k9s/issues/140) > NOTE: Describe on v1 HPA is busted just like it is when running kubectl v1.13 > against an older cluster. --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.2.md ================================================ # Release v0.4.2 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### o Decode Secrets On Demand Secrets can now be base64 decoded to view their actual content. In the secret view you can use `ctrl-x` to decode a selected secret. [Feature #123](https://github.com/derailed/k9s/issues/123) ### o YAML Highlighter Describe and YAML commands will now yield syntax highlighted view. [Feature #142](https://github.com/derailed/k9s/issues/142) --- ## Resolved Bugs + Sort by age busted [Issue #145](https://github.com/derailed/k9s/issues/145) + Logs not escaped correctly [Issue #137](https://github.com/derailed/k9s/issues/137) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.3.md ================================================ # Release v0.4.3 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + Sort by age busted (with feeling edition!) [Issue #145](https://github.com/derailed/k9s/issues/145) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.4.md ================================================ # Release v0.4.4 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Exiting K9s There are a few debates about drathers on K9s key bindings. I have caved in and decided to give up my beloved 'q' for quit which will no longer be bound. As of this release quitting K9s must be done via `:q` or `ctrl-c`. ### Container Logs [Feature #147](https://github.com/derailed/k9s/issues/147). The default behavior was to pick the first available container. Which meant if the pod has an init container, the log view would choose that. The view will now choose the first non init container. Most likely it would be the wrong choice in pod's sidecar scenarios, but for the time being showing log on one of the init containers just did not make much sense. You can still pick other containers via the menu options. We will implement a better solution for this soon... ### Delete Dialog [Feature #146](https://github.com/derailed/k9s/issues/146) Tx @dperique! Pressing `` on the delete dialog would delete the resource. Now `cancel` is the default button. Hence you must use `` or `->` to select `OK` then press `` to delete. --- ## Resolved Bugs + None --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.5.md ================================================ # Release v0.4.5 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Multi containers There was an [issue](https://github.com/derailed/k9s/issues/135) where we ran into limitations with the container selection keyboard shortcuts only allowing up to 10 containers. In this release, we've changed to a pick list vs the menu to select containers for both shell and logs access. This gives K9s the ability to select up to 26 containers now. This is not in any way an *encouragement* to have so many containers per pods!! ### Alias View ShortCut The change above entailed having to move the alias shortcut to `A` vs `a` as the pick list shortcuts conflicted with the alias view keyboard activation. --- ## Resolved Bugs + [Issue #152](https://github.com/derailed/k9s/issues/152) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.6.md ================================================ # Release v0.4.6 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + Node overview column ordering [Issue #153](https://github.com/derailed/k9s/issues/153) + Changed foreground color on container pick list [Issue #132](https://github.com/derailed/k9s/issues/132) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.7.md ================================================ # Release v0.4.7 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Popeye Support Managing and operating a cluster is the wild is hard and getting harder. I've created [Popeye](https://github.com/derailed/popeye) to help with cluster sanitation and best practices. Since K9s folks are so awesome, you're getting a sneak peek! I figured why not integrate it with K9s? No need to install yet another CLI right? Provided I did not mess this up too much, you should now be able to use command mod `:popeye` to access Popeye sanitizer reports within K9s and scan your clusters. You can read more about it [here](https://medium.com/@fernand.galiana/k8s-clusters-oh-biff-em-popeye-637e9312963) and if you like so give it a clap or two ;) NOTE: In a K9s environment, if you'd like to specify a spinach config file, you must set it in your $HOME/.k9s/spinach.yml. NOTE: There is a bit more that need to be done on this integration to be complete. Please file an issue if something does not work as expected. NOTE: Popeye will run its own course and K9s is just a viewer for it, so if you'd like additional sanitation or find Popeye related issues, please tune to the corresponding repo! Let us know if you dig it? And share your before/after clusters scores! --- ## Resolved Bugs + Great find! Thank you @swe-covis! Moved alias view to `Ctrl-A` [Issue #156](https://github.com/derailed/k9s/issues/156) + Added toggle autoscroll via `s` key [Issue #155](https://github.com/derailed/k9s/issues/155) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.4.8.md ================================================ # Release v0.4.8 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + [Issue #159](https://github.com/derailed/k9s/issues/159) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.5.0.md ================================================ # Release v0.5.0 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs I am super excited about this drop of K9s. Lots of cool improvements based on K9s friends excellent feedback! ### Popeye Turns out [Popeye](https://github.com/derailed/popeye) is in too much flux at present, thus I've decided to remove it from K9s for the time being. ### ContainerView Added a container view to list all the containers available on a given pod. On a selected pod, you can now press `` to view all of it's associated containers. Once in container view pressing `` on a selected container, will show the container logs. ### Resource Traversals > Ever wanted to know where your pods originated from? Fear not, K9s has got your back! Some folks have expressed desires to navigate from a deployment to its pods or see which pods are running on a given node. Whether you are starting from a Node, a Deployment, ReplicaSet, DaemonSet or StatefulSet, you can now simply `` of a selected item a view the associated pods. [Issue #149](https://github.com/derailed/k9s/issues/149) ### RollingBack ReplicaSets You can now select a ReplicaSet and rollback your Deployment to that version. Enter the command mode via `:rs` to view ReplicaSets, select the replica you want to rollback to and use `Ctrl-B` to rollback your deployment to that revision. --- ## Resolved Bugs + [Issue #163](https://github.com/derailed/k9s/issues/163) + [Issue #162](https://github.com/derailed/k9s/issues/162) + [Issue #39](https://github.com/derailed/k9s/issues/39) + [Issue #27](https://github.com/derailed/k9s/issues/27) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.5.1.md ================================================ # Release v0.5.1 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Minor code cleanup and some display bug fixes. --- ## Resolved Bugs + [Issue #168](https://github.com/derailed/k9s/issues/168) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.5.2.md ================================================ # Release v0.5.2 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + [Issue #171](https://github.com/derailed/k9s/issues/171) + [Issue #173](https://github.com/derailed/k9s/issues/173) + [Issue #174](https://github.com/derailed/k9s/issues/174) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.0.md ================================================ # Release v0.6.0 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### K9s Got Skins You can now skin K9s based on your own sense of style. Skinning, is currently limited to color variations and is still very much experimental. More details on how to achieve this is provided in the README and skins sample directory on this repo. This could be a prime opportunity for someone to contribute to this project and help us come up with some cooler looks and share with all K9s folks. Any cool skins contributed will be added and featured in this repo 🐶! ### Possible instability Just spent my birthday weekend tracking down a weird synchronization issue ;( I might have introduced some instability in the process. So please thread lightly and please report any weirdness. Thank you!! --- ## Resolved Bugs + [Issue #169](https://github.com/derailed/k9s/issues/169) + [Issue #171](https://github.com/derailed/k9s/issues/171) + [Issue #172](https://github.com/derailed/k9s/issues/172) + [Issue #175](https://github.com/derailed/k9s/issues/175) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.1.md ================================================ # Release v0.6.1 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + [Issue #171](https://github.com/derailed/k9s/issues/171) With feelings... --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.2.md ================================================ # Release v0.6.2 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Performance In our attempt to remediate screens lock outs, it looks like K9s performance on certain clusters took a major dive. In this drop we've taken a peek at improving some of the perf issues tho much more investigating does remain. Big ATTA Boys! in effect this week to @eldada and @despairblue for kind support in helping me track down some of these issues. We're not done yet but hopefully this drop will be a bit of an improvement in the 0.6.x lineage. Thank you all for your patience and support!! --- ## Resolved Bugs + [Issue #176](https://github.com/derailed/k9s/issues/171) Fingers crossed it's a better drop 🙏🐭? --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.3.md ================================================ # Release v0.6.3 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Performance... With feelings! Ran thru another perf pass and hope I've pushed the needle in the right direction? K9s is now leveraging informers which I think came out of CRDs work. Our initial assessments shows numbers to μsecond updates, down from milliseconds 🎉. Hopefully the outputs are still correct as I have the tendency to make things much faster with incorrect results ;( We hope to hear back from you with a report from your clusters and assessments and brace for good news? This was a deep cycle thru K9s core and more perf will be gained, once we get a chance to vet this new strategy. I'd like to take this opportunity to thank you all for your patience and incredible kindness and support. We certainly hope this drop won't turn out to be a dud as I am fresh out of prozac patches 😩 --- ## Resolved Bugs + [Issue #176](https://github.com/derailed/k9s/issues/171) Fingers crossed it's a better drop 🙏🐭? --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.4.md ================================================ # Release v0.6.4 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Aftermath... Various bug fixes and cleanup items. --- ## Resolved Bugs --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.5.md ================================================ # Release v0.6.5 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + [Issue #178](https://github.com/derailed/k9s/issues/178) + [Issue #179](https://github.com/derailed/k9s/issues/179) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.6.md ================================================ # Release v0.6.6 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs + [Issue #180](https://github.com/derailed/k9s/issues/180) + [Issue #181](https://github.com/derailed/k9s/issues/181) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.6.7.md ================================================ # Release v0.6.7 ## Notes Thank you to all that contributed with flushing out issues with 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. Thank you so much for your support and awesome suggestions to make K9s better!! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs This is a maintenance release to mainly resolve outstanding issues and bugs. ### Tracking Percentages Added two new columns to track cpu/memory utilization on metrics-server enabled clusters. These apply to pod,container and node view. --- ## Resolved Bugs + [Issue #192](https://github.com/derailed/k9s/issues/192) + [Issue #190](https://github.com/derailed/k9s/issues/190) + [Issue #189](https://github.com/derailed/k9s/issues/189) + [Issue #185](https://github.com/derailed/k9s/issues/185) + [Issue #171](https://github.com/derailed/k9s/issues/171) + [Issue #155](https://github.com/derailed/k9s/issues/155) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.0.md ================================================ # Release v0.7.0 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Labor Day Weekend? I always seem to get this wrong... Does Labor Day weekend mean you get to work on your OSS projects all weekend? I am very excited about this drop and hopefully won't be unanimous (?) on this? 🐭 For the impatient watch this! [K9s v0.7.0 Features](https://youtu.be/83jYehwlql8) ### Service Traversals Provided your K8s services are head(Full), you can now navigate to the pods that match the service selector. So you will be able to traverse Pods/Containers directly from a service just like other resources like deployment, cron, sts... ### Moving Forward! In this drop, we've added support for port-forwarding that allows you to exercise your container from your local machine. To setup a port-forward, from the Pod view drill down to a selected Pod's containers, select the container that exposes the port of interest and hit `Ctrl-F`. A dialog will popup allowing you to configure a localhost port to forward to. Once set up, K9s will take you to the new PortForward view aka `pf`. Pending your terminal feature and container setup, you should be able to pop the forwarded URL directly into your browse. On iTerm2 me think `command+click` does the trick? Big thanks and ATTABOY! in full effect all week to [Brent](https://github.com/brentco) for filing this initial issue. Please keep in mind, these port-forward babies are a bit expensive to run, so make sure you choose wisely and delete any superfluous PFs!! This feature is still work in progress. It does require a new config file to help assist with URL configurations. As it stands, your PortForwards are in effect for the current K9s session and will be terminated on exit. Please thread lightly and checkout the README under the Benchmarking section. Your feedback on this as always, is welcome and encouraged! ### Hey now! This is one feature that I think is, pardon my french.., totally `Bitch'n`! K9s now bundles [Hey](https://github.com/rakyll/hey) CLI tool from the extremely talented Jaana Dogan of Google fame. Hey allows you to benchmark HTTP service endpoints similar to apache bench. Benchmarking is enabled via keyboard shortcuts `Ctrl-B` and `Alt-B` to activate/cancel a benchmark while in PortForward and Service view. Benchmarking a service assumes the HTTP service is exposed either as NodePort or LoadBalancer. To view your benchmarks, navigate to the new Benchmark view aka `:be` to list your benchmarks and runs statistics. So you now have the ability to stretch out your cluster legs by benchmarking your pods and services and gather all kind of interesting statistics directly in K9s. Generating load on your resources will help you tune your cluster resources, exercise your auto scalers, compare Canary builds perf, etc... Please keep in mind, this is very much a moving target at this point and will change. Ingress support will come next once we solidify the existing feature. Also checkout the README for additional configuration for this feature. With the understanding the Full Monty is coming, please help us solidify these features as these are the base ingredients to even cooler things coming down the line... > NOTE! As with anything in life `Aim small, Miss small!`. You could totally overwhelm K9s with over-zealous benchmarks and port-forwards, so please start small say C:1 N:1000, measure and go from there. --- ## Resolved Bugs/Features + [Issue #198](https://github.com/derailed/k9s/issues/198) + [Issue #197](https://github.com/derailed/k9s/issues/197) + [Issue #195](https://github.com/derailed/k9s/issues/195) Thanks to the awesome [Sebastiaan](https://github.com/tammert). You Rock Sir!! + [Issue #194](https://github.com/derailed/k9s/issues/194) + [Issue #187](https://github.com/derailed/k9s/issues/187) + [Issue #119](https://github.com/derailed/k9s/issues/119) Added `Ctrl-S` shortcut to dump table data as csv and log data as text. + [Issue #69](https://github.com/derailed/k9s/issues/69) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.1.md ================================================ # Release v0.7.1 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### AfterMath Looks like I've broken some stuff in the excitement of 0.7.0! As I ran thru the video this am, I noticed the last minute screen dumps might not be a viable feature. As [Norbert](https://github.com/ncsibra) correctly points out, in issue #187 (Thanks Norbert!!), retrieving screen dumps was a pain. So I've put together a quick ScreenDump view alias `sd` to view the screen snapshots and allows to pop your editor of choice upon selection to view the screen dump file content. NOTE: You will need to use an EDITOR env var to tell K9s which editor you want to use. ```shell # For example... export EDITOR=vim ``` This is a quick turn around, hopefully I did not hose anything else in the process ;( --- ## Resolved Bugs/Features + [Issue #200](https://github.com/derailed/k9s/issues/200) + [Issue #187](https://github.com/derailed/k9s/issues/187) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.10.md ================================================ # Release v0.7.10 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Meow! Looks like v0.7.9 hosed the logger ;) Sorry!! --- ## Resolved Bugs/Features + [Issue #245](https://github.com/derailed/k9s/issues/245) + [Issue #231](https://github.com/derailed/k9s/issues/231) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.11.md ================================================ # Release v0.7.11 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release. Just code clean up and small bug fixes. --- ## Resolved Bugs/Features --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.12.md ================================================ # Release v0.7.12 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release. Just code clean up and bug fixes. --- ## Resolved Bugs/Features + [Issue #259](https://github.com/derailed/k9s/issues/259) + [Issue #258](https://github.com/derailed/k9s/issues/258) + [Issue #256](https://github.com/derailed/k9s/issues/256) + [Issue #255](https://github.com/derailed/k9s/issues/255) + [Issue #252](https://github.com/derailed/k9s/issues/252) + [Issue #250](https://github.com/derailed/k9s/issues/250) + [Issue #246](https://github.com/derailed/k9s/issues/246) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.13.md ================================================ # Release v0.7.13 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release bug fixes --- ## Resolved Bugs/Features + [Issue #266](https://github.com/derailed/k9s/issues/266) + [Issue #262](https://github.com/derailed/k9s/issues/262) + [Issue #246](https://github.com/derailed/k9s/issues/246) Thank you @mcristina422! --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.2.md ================================================ # Release v0.7.2 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Bug Fix Drop Removed requirement that enforces node access. In the case RBAC rules are in effect and user does not have enough RBAC-Fu to list/watch cluster nodes. --- ## Resolved Bugs/Features + [Issue #202](https://github.com/derailed/k9s/issues/202) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.3.md ================================================ # Release v0.7.3 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs --- ## Resolved Bugs/Features + [Issue #210](https://github.com/derailed/k9s/issues/210) + [Issue #209](https://github.com/derailed/k9s/issues/209) + [Issue #206](https://github.com/derailed/k9s/issues/206) Thank you @carlowouters!! --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.4.md ================================================ # Release v0.7.4 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release. --- ## Resolved Bugs/Features + [Issue #211](https://github.com/derailed/k9s/issues/210) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.5.md ================================================ # Release v0.7.5 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Rats, looks like 0.7.4 is a dud! Sorry my fault, feeling burned out ;( Please upgrade to 0.7.5. Thank you for your patience and support! --- ## Resolved Bugs/Features + [Issue #211](https://github.com/derailed/k9s/issues/210) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.6.md ================================================ # Release v0.7.6 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### MultiLogs Initial Support This is an experimental enhancement to allow to view logs for associated resources ie view logs for all containers in a pod or view container logs for pods fronted by a service, deployment, etc... directly in K9s. We've contemplated integrating the excellent `stern` CLI for this which is more full featured than the current implementation, but decided that shelling out for logs was at this juncture not ideal. Based on your feedback, we might revisit in future releases should this feature be a total dud... ### Delete Dialog The resource delete dialog was updated to provide affordance for force and cascade deletes. This should now provide an on par behavior with the `kubectl` CLI. Cascade and force options are checkboxes, please use `` to toggle the flags. --- ## Resolved Bugs/Features + [Feature #193](https://github.com/derailed/k9s/issues/193) + [Issue #205](https://github.com/derailed/k9s/issues/205) + [Issue #212](https://github.com/derailed/k9s/issues/212) + [Issue #215](https://github.com/derailed/k9s/issues/215) + [Issue #220](https://github.com/derailed/k9s/issues/220) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.7.md ================================================ # Release v0.7.7 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### Labels Filters K9s now provides an affordance to filter Kubernetes resources by label (Feature #233. Thank you [Chad Hanley](https://github.com/cchanley2003)). In order to enable filtering by labels, enter the filter mode via `/` on any resource table and enter your label filter via `-l app=fred,env=prod` + ``. --- ## Resolved Bugs/Features + [Feature #233](https://github.com/derailed/k9s/issues/233) + [Issue #232](https://github.com/derailed/k9s/issues/232) + [Issue #230](https://github.com/derailed/k9s/issues/230) + [Issue #229](https://github.com/derailed/k9s/issues/229) + [Issue #226](https://github.com/derailed/k9s/issues/226) Thank you for the excellent PR [Yves Blusseau](https://github.com/JrCs) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.8.md ================================================ # Release v0.7.8 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs This is mainly a maintenance release a few bugs were fixed. ### Breaking Change! We've changed the benchmarks and skins file formats in this release. Please take a peek at the README and sample skin files for the deltas. ### RBAC Checks There was a few issues regarding running K9s on RBAC enabled clusters. It turns out some of the permission checks were faulty. In this release, we hope these are now fixed. Please send us issues if that is not the case. --- ## Resolved Bugs/Features + [Issue #242](https://github.com/derailed/k9s/issues/242) + [Issue #241](https://github.com/derailed/k9s/issues/241) + [Issue #201](https://github.com/derailed/k9s/issues/201) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.7.9.md ================================================ # Release v0.7.9 ## Notes Thank you to all that contributed with flushing out issues with 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 is as always very much appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release a few bugs and code cleanup items. --- ## Resolved Bugs/Features --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.8.0.md ================================================ # Release v0.8.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Pretty excited about this drop! I am as ever humbled by all the cool comments and suggestions you guys are coming up with. There are a few features that were requested that are simply excellent! Thank you all for your support, feedback and observations 👏 Now that said, some features might be more or less baked, so there might be some disturbance in the force with this drop since much code churned. So please file issues or PRs 🥰 if you notice anything that no longer works as expected. ### Client Update In the mist of the next Kubernetes 1.16 drop, deprecating some old apis, we've decided to update K9s to support 1.15.1 client. We don't forsee any issues here but please make sure all is cool with this K9s drop on your clusters. If not please let us know so we can address. Thank you!! ### Scaling Pods This was feature #12 filed by [Tyler Lewis](https://github.com/alairock) many moons ago. So big thanks to Tyler!! To be honest I was on the fence with this feature as I am not a big fan of one offs when it comes to cluster management. However I think it's a great way to validate adequate HPA settings while putting your cluster under load and use K9s to figure out what reasonable number of pods might be. Now this feature was not my own implementation so all kudos on this one goes to [Nathan Piper](https://github.com/nathanpiper) for spending the time to make this a reality for all of us. So many thanks to you Nathan!! By Nathan's implementation you can now leverage the `s` shortcut for scale deployments, replication controllers and statefulsets. Very cool! ### FuzzBuzz! Another enhancement request came this time from [Arthur Koziel](https://github.com/arthurk) and I think you guys will dig this one. So big thanks to Arthur for this report!! K9s now leverages a fuzzy finder to be able to search for resources. Previous implementation just used regex to locate matches. For example with this addition you can now type `promse` while in search mode `/` to locate all prometheus-server-5d5f6db7cc-XXX pods. That's so cool! Once this implementation is vetted, we will enable fuzzy searching on other views as well. ### ClipBoarding This feature comes out of [Raman Gupta](https://github.com/rocketraman) report. Thank you Raman!! This allows a K9s operator to now just hit `c` while on a resource table view to copy the currently selected resource name to the clipboard. This allows you to navigate between K9s and other tools to search, grep/etc.. thru the currently selected resource. We may want to improve on this some but the basic implementation is now available. ### OldiesButGoodies? So the initial few releases of K9s did not have any failsafe counter measures while deleting resources. So we've beefed the deletion logic to make sure you did not inadvertently blow something away by leveraging dialogs. This was totally a reasonable thing to do! However in case of managed pods, one may want to quickly cycle on or more pod perhaps to pickup a new image or configuration. For this purpose we've introduced an alternate deletion mechanism to delete pod under `alt-k` for kill. Thanks to my fellow frenchma [ftorto](https://github.com/ftorto) for this one ;) ### HairPlugs! This one is cool! I think this thought came about from (Markus)[https://github.com/Makusi75]. Thank you Markus!! This feature allows K9s users to now customize K9s with their own plugin commands. You will be able to add a new menu shortcut to the K9s menu and fire off a custom command on a selected resource. Some of you might be leveraging kubectl plugins and now you will be able to fire these off directly from K9s along with many other shell commands. In order to specify a custom plugin command, you will need to modify your .k9s/config.yml file. For example here is a sample extension to list out all the pods in the `fred` namespace while in a pod or deployment view. When this plugin is available a new command `` will show only while in pod and deploy view. ```yaml plugins: cmd1: # The menu mnemonic to trigger the command. Valid values are [a-z], Shift-[A-Z], Ctrl-[A-Z] or Alt-[A-Z] # Note! Mind the cases!!! shortCut: Alt-P scopes: # View names are typically matching the resource shortname ie po for pod, deploy for deployment, svc for service etc... If no shortname is available use the resource name. - po - deploy description: ViewPods # => Name to show on K9s menu command: kubectl # => The binary to use. Must be on your $PATH. # Arguments on per line preceded with a dash! This will run > kubectl get pods -n fred args: - get - pods - -n - fred ``` Ok so this is pretty cool but what if I want to run a command to leverage the current pod name, namespace, container or other? You bet! Here is a more elaborated example. Say per Markus's report, I want to run my ksniff kubectl plugin from within K9s. So now I can hit `S` while in container view with a selected pod and sniff out incoming traffic. Here is an example plugin config for this. ```yaml plugins: ksniff: # Enable `S` on the K9s menu while in container view shortCut: Shift-S scopes: - co description: Sniff # NOTE! Ksniff has been installed as a kubectl extension! command: kubectl # Run this command in the background so that I can still do K9s stuff... background: true args: - sniff # Use a K9s env var to extract the pod name from the current view. - $POD - -n # Use K9s current namespace - $NAMESPACE # Oh and pick out the container name from column 0 on that table. Nice!! - -c - $COL-0 # Use $COL-[0-9] to pick up the value from the desired resource table column. ``` NOTE: This is experimental and the schema/behavior WILL change in the future, so please thread lightly! ### That's a wrap! We hope you will find some of these features useful on your day to day work with K9s. We know they are now more vendors coming into this space. Hence more choices for you to assess which of these tools makes you most happy and productive. My goal is to continue improving, speeding up and stabilizing K9s. My fuel is to see folks using it, file reports, contribute and seeing that occasional ATTA BOY! (which I must say is much more rewarding to me than money or fame...). Many thanks to all of you for your time, ideas, contributions and support!! --- ## Resolved Bugs/Features + [Issue #274](https://github.com/derailed/k9s/issues/274) + [Issue #273](https://github.com/derailed/k9s/issues/273) + [Issue #272](https://github.com/derailed/k9s/issues/272) + [Issue #271](https://github.com/derailed/k9s/issues/271) + [Issue #267](https://github.com/derailed/k9s/issues/267) + [Issue #247](https://github.com/derailed/k9s/issues/247) + [Issue #203](https://github.com/derailed/k9s/issues/203) + [Issue #12](https://github.com/derailed/k9s/issues/12) Thank you Nathan!! --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.8.1.md ================================================ # Release v0.8.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### FuzzBuster! So it looks like going all fuzzy was a mistake as we've lost some nice searchability feature with the regex counterpart. No worries tho Fuzzy is still around! The logic for searching will default to regex like all prior K9s version. To enable fuzzy logic, I figured we will use the same idea as we did with label filters using `/-lapp=bobo` but instead using `/-fpromset` ### Location, Location, Location! There was a few issues related to screen `real estate` with K9s or more specifically the lack of it! Some folks flat out decided not to use K9s just because of the ASCII Logo ;( WTF! In this drop, I'd like to introduce a new presentation mode aka `Headless`. Using the following command you can now run K9s headless: ```shell k9s --headless # => Launch K9s without the header rows ``` NOTE! If you forgot your K9s shortcuts already, fear not! I've also updated the help menu so `?` will remind you of all the available options. Lastly if you really dig the headless mode, you can sneak an extra `headless: true` in your ./k9s/config.yml like so: ```yaml k9s: refreshRate: 2 headless: false ... ``` ### Menu Shortcuts Some folks correctly pointed out the issue with the `Alt-XXX`. Totally my bad as my external mac keyboard unlike my MBP keyboard shows `option` and `alt` as the same key. So I've added a check to make sure the correct mnemonic is displayed based on you OS. Big Thanks for the call out to Ming, Eldad, Raman and Andrew!! Hopefully it did not hose the menu options in the process... 🙏 --- ## Resolved Bugs/Features + [Issue #286](https://github.com/derailed/k9s/issues/286) + [Issue #285](https://github.com/derailed/k9s/issues/285) + [Issue #270](https://github.com/derailed/k9s/issues/270) + [Issue #223](https://github.com/derailed/k9s/issues/223) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.8.2.md ================================================ # Release v0.8.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release. In this quick drop, we've opted to nuke any menu shortcut using the infamous `Alt` key. This includes the new pod kill command that is now `Ctrl-K` and for the most part the column sorting shortcuts for CPU% and MEMORY%. My apologizes to all on this fiasco as it turns out I had remapped opt->alt on my local dev machine and space it while trying to offer different key mappings. Will revisit this in the future when things simmer down a bit. Thank you to all that reported on this! --- ## Resolved Bugs/Features + Nuked Alt-XXX menu mnemonic [Issue #285](https://github.com/derailed/k9s/issues/285) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.8.3.md ================================================ # Release v0.8.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs ### NetworkPolicy NetworkPolicy resource is now available under the command `np` while in command mode. It behaves like other K9s resource view. You will get a bit more information in K9s vs `kubectl` as it includes information about ingress and egress rules. ### Arrrggg! New CLI Argument There is a new K9s command option available on the CLI that affords for launching K9s with a given resource. For example using `k9s -c svc` will launch K9s with a preloaded service view. You can use the same aliases as you would while in K9s to navigate a resources. For all supports resource aliases please view the `Alias View` using `Ctrl-A`. ### CRDS! We've beefed up CRD support to allow users to navigate to the CRD instances view more readily. So you can now navigate between CRD schema and CRD instances by just hitting `ENTER` while in the `crd` view. --- ## Resolved Bugs/Features + CRD Navigation [Issue #295](https://github.com/derailed/k9s/issues/295) + Terminal colors [Issue #294](https://github.com/derailed/k9s/issues/294) + Help menu typo [Issue #291](https://github.com/derailed/k9s/issues/291) + NetworkPolicy Support [Issue #289](https://github.com/derailed/k9s/issues/289) + Scaling replicas start count [Issue #288](https://github.com/derailed/k9s/issues/288) + CLI command arg support [Issue #283](https://github.com/derailed/k9s/issues/283) + YAML screen dump support [Issue #275](https://github.com/derailed/k9s/issues/275) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.8.4.md ================================================ # Release v0.8.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release. --- ## Resolved Bugs/Features --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.9.0.md ================================================ # Release v0.9.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs A lots of changes here in 0.9.0!! Please watch out for potential disturbance in the force as much code changed on this drop... Figured, I'll put a quick video out for you to checkout the latest [K9s V0.9.0](https://www.youtube.com/watch?v=bxKfqumjW4I) ### Support K8s 1.16 As you might have heard K8s had a big drop with 1.16 so we've added client/server support this new kubernetes release. ### Alias Alas! K9s now supports standard kubernetes short name. Major shoutout to [Gustavo](https://github.com/paivagustavo) for making this painful change happen! With this change is place you can now use all standard K8s short names along with defining your own. You can now define a new alias file aka `alias.yml` in your k9s home directory `$HOME/.k9s`. An alias is made up of a command and a group/version/resource aka GVR specification as follows: ```yaml alias: fred: apps/v1/deployments # Typing fred while in command mode will list out deployments pp: v1/pods # Typing pp while in command mode will list out pods ``` ### Plug For Plugins As of this release and based on some users feedback we've moved the plugin section that used to live in the main K9s configuration file out to its own file. So as of this release we've added a new file in K9s home dir called `plugin.yml`. This is where you will define/share your K9s plugins and define your own commands and menu mnemonics. Here is an example for defining a custom command to show logs. ```yaml # plugin.yml plugin: fred: shortCut: Ctrl-L description: "Pod logs" scopes: - po command: /usr/local/bin/kubectl background: false args: - logs - -f - $NAME - -n - $NAMESPACE - --context - $CONTEXT ``` Special K9s env vars you will have access to are currently for your commands or shell scripts are as follows: * NAMESPACE * NAME * CLUSTER * CONTEXT * USER * GROUPS * COL[0-9+] I will setup a plugin/alias repo so we can share these with all K9sers. Please ping me if interested in contributing/sharing your commands. Thank you!! ### Aye Aye Capt'ain!! Hopefully improved overall navigation... #### Real Estate This release allows you to maximize screen real estate via 2 combos. First, the command/filter prompt is now hidden. To enter commands or filters you can type `:` or `/` to type your commands. Second, you can toggle the header using `CTRL-H`. #### Bett'a ShortCuts You can now use commands like `svc fred` while in command mode to directly navigate to a resource in a given namespace. Likewise to switch contexts you can now enter `ctx blee` to switch out clusters. #### Sticky Filters You can now keep filters sticky allowing you to filter a view bases on regex, fuzzy or labels and keep the filter live while switching resources. This provides for a horizontal navigation to view the various resources for a given application. Thank you so much [Nobert](https://github.com/ncsibra) for your continuous awesome feedback!! ### New Resources Added support for StorageClass, you can now view this resource and describe it directly in K9s. Major shoutout to [Oscar F](https://github.com/fridokus), zero go chops and yet managed to push this PR thru with minimal support. You Sir, blew me away. Thank you!! --- ## Resolved Bugs/Features * [Issue #318](https://github.com/derailed/k9s/issues/318) * [Issue #303](https://github.com/derailed/k9s/issues/303) * [Issue #301](https://github.com/derailed/k9s/issues/301) * [Issue #300](https://github.com/derailed/k9s/issues/300) * [Issue #276](https://github.com/derailed/k9s/issues/276) * [Issue #268](https://github.com/derailed/k9s/issues/268) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.9.1.md ================================================ # Release v0.9.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release --- ## Resolved Bugs/Features * [Issue #325](https://github.com/derailed/k9s/issues/325) * [Issue #326](https://github.com/derailed/k9s/issues/326) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.9.2.md ================================================ # Release v0.9.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs I am absolutely blown away by your support and excitement about K9s! As I can recall, this is the first drop since we've launched K9s back in January 2019 that I've seen some many external contributions and PRs. Thank you!! This is both super exciting and humbling. ### Core +1 As you may have noticed, there is a new voice on the project. [Gustavo Silva Paiva](https://github.com/paivagustavo) kindly accepted to become a K9s core member. Gustavo has been following and contributing to K9s for a while now and have patiently plowed thru my code ;( Raising issues, fixing them, improving code and test coverage, he has demonstrated a genuine interest on making sure K9s is better for all of us. Actually, I can say enough about Gustavo since I don't know him that well yet ;) But I can tell from my interactions with him that he is a great human being, smart, kind and consensus and hence an awesome K9s addition. Please help me in welcoming him to the K9s pac! ### Breaking Bad There was an issue with the header toggle mnemonic `Ctrl-H` and it has been changed in this release to just `h`. Thank you for the heads up [Swe Covis](https://github.com/swe-covis)!! ## Merged PRs * [PR #365](https://github.com/derailed/k9s/pull/365) Fix Alias columns sorting. * [PR #363](https://github.com/derailed/k9s/issues/363) Change Terminated to Terminating * [PR #360](https://github.com/derailed/k9s/pull/360) Header toggle while typing commands * [PR #359](https://github.com/derailed/k9s/pull/359) Add support for CRD v1beta1 * [PR #356](https://github.com/derailed/k9s/pull/356) Remove Object field from CRD yaml * [PR #347](https://github.com/derailed/k9s/pull/347) Sort node roles * [PR #346](https://github.com/derailed/k9s/pull/346) Optimize configmap and secret rendering * [PR #342](https://github.com/derailed/k9s/pull/342) Add copy YAML to clipboard * [PR #338](https://github.com/derailed/k9s/pull/338) Escape describe text * [PR #330](https://github.com/derailed/k9s/pull/330) Don't override standard K8s short names * [PR #324](https://github.com/derailed/k9s/pull/324) Leverage cached client to speed up K9s --- ## Resolved Bugs/Features * [Issue #361](https://github.com/derailed/k9s/issues/361) * [Issue #341](https://github.com/derailed/k9s/issues/341) * [Issue #335](https://github.com/derailed/k9s/issues/335) * [Issue #331](https://github.com/derailed/k9s/issues/331) * [Issue #323](https://github.com/derailed/k9s/issues/323) * [Issue #280](https://github.com/derailed/k9s/issues/280) --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_0.9.3.md ================================================ # Release v0.9.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ## Change Logs Maintenance release ## Merged PRs * [PR #385](https://github.com/derailed/k9s/pull/385) Remove debugging calls from HPA * [PR #384](https://github.com/derailed/k9s/issues/384) Invalidate cache when switching context * [PR #372](https://github.com/derailed/k9s/pull/372) Fix race when switching context --- ## Resolved Bugs/Features * [Issue #392](https://github.com/derailed/k9s/issues/392) * [Issue #389](https://github.com/derailed/k9s/issues/389) * [Issue #386](https://github.com/derailed/k9s/issues/386) * [Issue #383](https://github.com/derailed/k9s/issues/383) * [Issue #382](https://github.com/derailed/k9s/issues/382) * [Issue #336](https://github.com/derailed/k9s/issues/336) NOTE: Sticky filters have been removed for now until we have a better plan. --- © 2019 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.0.md ================================================ # Release v0.13.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ### GitHub Sponsors I'd like to personally thank the following folks for their support and efforts with this project as I know some of you have been around since it's inception almost a year ago! * [Norbert Csibra](https://github.com/ncsibra) * [Andrew Roth](https://github.com/RothAndrew) * [James Smith](https://github.com/sedders123) * [Daniel Koopmans](https://github.com/fsdaniel) Big thanks in full effect to you all, I am so humbled and honored by your kind actions! ### Dracula Skin Since we're in the thank you phase, might as well lasso in [Josh Symonds](https://github.com/Veraticus) for contributing the `Dracula` K9s skin that is now available in this repo under the skins directory. Here is a sneak peek of what K9s looks like under that skin. I am hopeful that like minded `graphically` inclined K9sers will contribute cool skins for this project for us to share/use in our Kubernetes clusters. ### XRay Vision! Since we've launched K9s, we've longed for a view that would display the relationships among resources. For instance, pods may reference configmaps/secrets directly via volumes or indirectly with containers referencing configmaps/secrets via say env vars. Having the ability to know which pods/deployments use a given configmap may involve some serious `kubectl` wizardry. K9s now has xray vision which allows one to view and traverse these relationships/associations as well as check for referential integrity. For this, we are introducing a new command aka `xray`. Xray initially supports the following resources (more to come later...) 1. Deployments 2. Services 3. StatefulSets 4. DaemonSets To enable cluster xray vision for deployments simply type `:xray deploy`. You can also enter the resource aliases/shortnames or use the alias `x` for `xray`. Some of the commands available in table view mode are available here ie describe, view, shell, logs, delete, etc... Xray not only will tell you when a resource is considered `TOAST` ie the resource is in a bad state, but also will tell you if a dependency is actually broken via `TOAST_REF` status. For example a pod referencing a configmap that has been deleted from the cluster. Xray view also supports for filtering the resources by leveraging regex, labels or fuzzy filters. This affords for getting more of an application `cross-cut` among several resources. As it stands Xray will check for following resource dependencies: * pods * containers * configmaps * secrets * serviceaccounts * persistentvolumeclaims Keep in mind these can be expensive traversals and the view is eventually consistent as dependent resources will be lazy loaded. We hope you'll find this feature useful? Keep in mind this is an initial drop and more will be coming in this area in subsequent releases. As always, your comments/suggestions are encouraged and welcomed. ### Breaking Change Header Toggle It turns out the 'h' to toggle header was a bad move as it is use by the view navigation. So we changed that shortcut to `Ctrl-h` to toggle the header expansion/collapse. --- ## Resolved Bugs/Features * [Issue #494](https://github.com/derailed/k9s/issues/494) * [Issue #490](https://github.com/derailed/k9s/issues/490) * [Issue #488](https://github.com/derailed/k9s/issues/488) * [Issue #486](https://github.com/derailed/k9s/issues/486) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.1.md ================================================ # Release v0.13.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ### XRay Reloaded? In the last release excitement, forgot to link the video update. Check it out! [K9s Xray](https://www.youtube.com/watch?v=qaeR2iK7U0o). Yup, got a cold... The joy of transports on the flying Petri dishes we call airplanes ;( Based on some reported issues, decided to axe the xray icons in this drop for portability sake and also added support for replicasets. So here is the official list of supported Xray resources. 1. Deployments 2. Services 3. StatefulSets 4. DaemonSets 5. ReplicaSets (New!) Still work in progress... so please proceed with caution! --- ## Resolved Bugs/Features * [Issue #498](https://github.com/derailed/k9s/issues/498) * [Issue #497](https://github.com/derailed/k9s/issues/497) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.2.md ================================================ # Release v0.13.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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ### XRay Reloaded. Part Duh! Found a waffle thin issue in the Beryllium(Be) core causing K9s xray vision to only operate on one eye ;) Should be all betta' now... The `xray` command now takes an **optional** third argument for the target namespace ie `:xray dp fred` will show the Xray view for deployments in the `fred` namespace. Supported resources: * Pods * Deployments * Services * StatefulSets * DaemonSets * ReplicaSets Still watch out for that overbite!! hence please proceed with caution... --- ## Resolved Bugs/Features * [Issue #500](https://github.com/derailed/k9s/issues/500) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.3.md ================================================ # Release v0.13.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- ### XRay Now With Lipstick? Call me old school, but Xray without icons made me a bit sad ;( Just like any engineer would, I do fancy eye candy once in a while... So I've decided to revive the xray `icon` mode for the some of us that are not stuck with what I'd like to call `Jurassic` terminals. To date, there was no way to skin the Xray view, so I've added a new xray skin config section that `currently` looks like this: ```yaml # $HOME/.k9s/skin.yml k9s: body: fgColor: dodgerblue bgColor: black logoColor: orange ... xray: fgColor: blue bgColor: black cursorColor: aqua graphicColor: darkgoldenrod # NOTE! Show xray in icon mode. Defaults to false!! showIcons: true ``` So if your terminal does not support emoji's we're still cool... --- ## Resolved Bugs/Features * [Issue #505](https://github.com/derailed/k9s/issues/505) * [Issue #504](https://github.com/derailed/k9s/issues/504) * [Issue #503](https://github.com/derailed/k9s/issues/503) * [Issue #501](https://github.com/derailed/k9s/issues/501) * [Issue #499](https://github.com/derailed/k9s/issues/499) * [Issue #493](https://github.com/derailed/k9s/issues/493) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.4.md ================================================ # Release v0.13.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) --- Maintenance Release! ## GH Sponsors A Big Thank You to the following folks that I've decided to dig in and give back!! 👏🙏🎊 Thank you for your gesture of kindness and for supporting K9s!! (not to mention for replenishing my liquids during oh-dark-thirty hours 🍺🍹🍸) * [w11d](https://github.com/w11d) * [vglen](https://github.com/vglen) ## CPU/MEM Metrics A small change here based on [Benjamin](https://github.com/binarycoded) excellent PR! We've added 2 new columns for pod/container views to indicate percentages of resources request/limits if set on the containers. The columns have been renamed to represent the resources requests/limits as follows: | Name | Description | Sort Keys | |--------|--------------------------------|-----------| | %CPU/R | Percentage of requested cpu | shift-x | | %MEM/R | Percentage of requested memory | shift-z | | %CPU/L | Percentage of limited cpu | ctrl-x | | %MEM/L | Percentage of limited memory | ctrl-z | --- ## Resolved Bugs/Features * [Issue #507](https://github.com/derailed/k9s/issues/507) ??May be?? * [PR #489](https://github.com/derailed/k9s/issues/489) ATTA Boy! [Benjamin](https://github.com/binarycoded) * [PR #491](https://github.com/derailed/k9s/issues/491) Big Thanks! [Bjoern](https://github.com/bjoernmichaelsen) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.5.md ================================================ # Release v0.13.5 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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! --- ## Resolved Bugs/Features * [Issue #507](https://github.com/derailed/k9s/issues/507) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.6.md ================================================ # Release v0.13.6 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ### GH Sponsorships WOOT!! Big Thank you in this release to [shiv3](https://github.com/shiv3) for your contributions and support for K9s! Duly noted and so much appreciated!! --- ### Bow Or Stern? Some of you had voiced wanting to enable the multi pod logger [Stern](https://github.com/wercker/stern) from the good folks at [Wercker](https://github.com/wercker). Well now you can! To make this work the awesome [Tuomo Syvänperä](https://github.com/syvanpera) contributed a PR to enable to plug this in with K9s. Thank you Tuomo!! By default the filter will be set to the currently selected pod. If you need to change the filter, simply filter the pod view to using your own regex and that's the filter K9s will use. Here is a sample plugin that defines a new K9s shortcut to launch Stern provided of course it is installed on your box... ```yaml # K9s plugin.yml plugin: stern: shortCut: Ctrl-L description: "Logs (Stern)" scopes: - pods command: /usr/local/bin/stern # NOTE! Look for the command at this location. background: false args: - --tail - 50 - $FILTER # NOTE! Pulls the filter out of the pod view. - -n - $NAMESPACE - --context - $CONTEXT ``` --- ## Resolved Bugs/Features/PRs * [Issue #507](https://github.com/derailed/k9s/issues/507) * [PR #510](https://github.com/derailed/k9s/pull/510) Thank you!! [Vimal Kumar](https://github.com/vimalk78) * [PR #340](https://github.com/derailed/k9s/pull/340) ATTA Boy! [Tuomo Syvänperä](https://github.com/syvanpera) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.7.md ================================================ # Release v0.13.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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ### GH Sponsorships WOOT!! Big Thank you in this release to [Matthew Davis](https://github.com/mateothegreat) for your contributions and support for K9s! --- ## Resolved Bugs/Features/PRs * [Issue #520](https://github.com/derailed/k9s/issues/520) * [Issue #518](https://github.com/derailed/k9s/issues/518) * [Issue #517](https://github.com/derailed/k9s/issues/517) * [Issue #516](https://github.com/derailed/k9s/issues/516) * [Issue #506](https://github.com/derailed/k9s/issues/506) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.13.8.md ================================================ # Release v0.13.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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ### GH Sponsorships WOOT!! Big Thank you to [Mark Baumann](https://github.com/mtreeman) for your contributions and support for K9s! --- ## Resolved Bugs/Features/PRs * [Issue #523](https://github.com/derailed/k9s/issues/523) * [Issue #522](https://github.com/derailed/k9s/issues/522) * [Issue #521](https://github.com/derailed/k9s/issues/521) * [PR #524](https://github.com/derailed/k9s/pull/524) Big Thanks!! [Joscha](https://github.com/joscha-alisch) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.14.0.md ================================================ # Release v0.14.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## Happy Birthday K9s!! 🎉🥳🎊 Doh! Almost missed it... 🎉🥳🎊 Yes sir, it's been a year (already...) since K9s was first launched 🎉. I can't tell you what a year this has been 🙀. Difficult? sure. However, you guys are making this project a total gas, by your candor, kindness and for giving back via your creative issues, prs, sponsorships, slack channel help to name a few... I do think, you've all been all too quiet tho 🐭... So if K9s helps make your K8s life bett'a on a day to day basis, please reach out for your shoe-phones and dial up [@kitesurfer](https://twitter.com/kitesurfer) or write an article/blog and share it! Lastly I am so humbled by this... but we're closing on 5k stars/136k downloads in this repo, so please invite 28 of your closest friends soon... Major Thanks to all of you for you patience and for making this project a reality to all our K8s friends! You're all redefining awesomeness!! Also I'd like to take this opportunity to recognize and thank a few folks that have willingly volunteered their own time to track down issues and help improve K9s for all of us!! * [Gustavo Silva Paiva](https://github.com/paivagustavo) * [Joscha Alisch](https://github.com/joscha-alisch) * [Michael Christina](https://github.com/mcristina422) * [Bruno Meneguello](https://github.com/bkmeneguello) * [Tuomo Syvänperä](https://github.com/syvanpera) * [Oskar F](https://github.com/fridokus) * [Bruno Ohms](https://github.com/brunohms) * [IgorRamalho](https://github.com/IgorRamalho) * [Benjamin](https://github.com/binarycoded) * [Norbert Csibra](https://github.com/ncsibra) * [Andrew Roth](https://github.com/RothAndrew) * [Sgandon](https://github.com/sgandon) * [Chris Werner Rau](https://github.com/cwrau) * [Eldad Assis](https://github.com/eldada) * [Tobias](https://github.com/mycrEEpy) * [Helge Sychla](https://github.com/hsychla) * [Makusi75](https://github.com/Makusi75) * [Swe-Covis](https://github.com/swe-covis) * [Evgeniy Shubin](https://github.com/com30n) ## Search Enabled For Describe/YAML views In this drop we made the Describe/YAML views searchable. So you no longer need to plow thru your resource configurations and get directly to the gist of it by using the search command ie `/elvis` + `enter`. You can use the familiar keys `n` and `N` to nav back and forth to the next occurrence in a circular buffer fashion once you've reached the BOF/EOF. It's the little things in life... ## And On Another Note... More bugz...😿 ## Resolved Bugs/Features/PRs * [Issue #536](https://github.com/derailed/k9s/issues/536) * [Issue #526](https://github.com/derailed/k9s/issues/526) * [Issue #464](https://github.com/derailed/k9s/issues/464) * [PR #532](https://github.com/derailed/k9s/pull/532) Thank you!! [Joscha Alisch](https://github.com/joscha-alisch) * [PR #525](https://github.com/derailed/k9s/pull/525) Big Thanks!! [darklore](https://github.com/darklore) * [PR #524](https://github.com/derailed/k9s/pull/524) Thank you (Again)!! [Joscha Alisch](https://github.com/joscha-alisch) * [PR #514](https://github.com/derailed/k9s/pull/514) ATTA Boy!! [Alexander F. Rødseth](https://github.com/xyproto) * [PR #483](https://github.com/derailed/k9s/pull/483) Thank you!! [Paul Varache](https://github.com/paulvarache) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.14.1.md ================================================ # Release v0.14.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## Term Color Part Duh! Some folks had reported issues with skins and wanting to preserve their terminal background colors while in K9s. In this drop, we're introducing a new skin setting called `default` that should enable the skin to keep the original terminal background color. Here is a sample skin snippet that should achieve just that: ```yaml # .k9s/pale_rider.yml # Styles... fg: &fg "#ff00ff" bg: &bg "default" # default keeps your current terminal window background color. # Skin... k9s: body: fgColor: *fg bgColor: "default" #... ``` ## Resolved Bugs/Features/PRs * [Issue #539](https://github.com/derailed/k9s/issues/539) * [Issue #538](https://github.com/derailed/k9s/issues/538) Fingers crossed! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.15.0.md ================================================ # Release v0.15.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## Seen This Fez Before? The awesome and ever so smart and creative [Alex Ellis](https://github.com/alexellis) of [OpenFaas Fame](https://www.openfaas.com) fame, had pinged me when I had launched K9s to add support for OpenFaas functions. It's been a long time coming indeed, but we now have a very (VERY!) primitive integration with this very cool framework. The current approach is to enable a few environment variables to tell K9s that you have an OpenFaas cluster available namely: ```shell OPENFAAS_GATEWAY=http://YOUR_CLUSTER_IP:31112 OPENFAAS_TLS_INSECURE=false OPENFAAS_JWT_TOKEN=YOUR_TOKEN ``` These will tell K9s that an OpenFaas gateway is available and exposed on a given nodeport. Next you can navigate to your OpenFaas function view by entering command mode `:openfaas` or using aliases `:ofaas` or `ofa` If functions are present in the given namespace they will be displayed here just like any other K8s resources. The following operations are currently supported: * Describe and YAML to view function definitions (Note: currently yields same results!) * Enter to view all pods instances associated with the selected function * Delete a function * Editing, shelling, logs, etc... are all supported by navigating to the underlying pods Keep in mind, the paint is way fresh here and this feature could be a complete dud, but figure will give it a rinse on this drop and Alex can pipe in and helps us ironing this out. > NOTE! It's been a while since I've played with OpenFaas so if some of you are more versed in this space by all means please do land a hand so we can make this feature more awesome! ## Moving Forward! A few folks had mentioned the eagerness to port-forward directly from a pod or a service. Well now you can! Port Forwarding is now available on both the pod view and services view. Note! at the end of the day, you are still port-forwarding to a container! So the port-forward dialog is a bit different for these views as there might be several container ports available now when looking at this from a pod perspective. So the first field in the dialog is a combo-box that allows one to pick their desired ports. The rest of the dialog works the same as the container port-forward dialog. ## Resolved Bugs/Features/PRs * [Issue #546](https://github.com/derailed/k9s/issues/546) BREAKING NEWS! Use `t` vs `ctrl-h` now to toggle the K9s header * [Issue #541](https://github.com/derailed/k9s/issues/541) * [Issue #227](https://github.com/derailed/k9s/issues/227) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.15.1.md ================================================ # Release v0.15.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## OpenFeZ Reloaded? 🙀With feelings and one less bugZ! The awesome and ever so smart and creative [Alex Ellis](https://github.com/alexellis) of [OpenFaas Fame](https://www.openfaas.com) fame, had pinged me when I had launched K9s to add support for OpenFaas functions. It's been a long time coming indeed, but we now have a very (VERY!) primitive integration with this very cool framework. The current approach is to enable a few environment variables to tell K9s that you have an OpenFaas cluster available namely: ```shell OPENFAAS_GATEWAY=http://YOUR_CLUSTER_IP:31112 OPENFAAS_TLS_INSECURE=false OPENFAAS_JWT_TOKEN=YOUR_TOKEN ``` These will tell K9s that an OpenFaas gateway is available and exposed on a given nodeport. Next you can navigate to your OpenFaas function view by entering command mode `:openfaas` or using aliases `:ofaas` or `ofa` If functions are present in the given namespace they will be displayed here just like any other K8s resources. The following operations are currently supported: * Describe and YAML to view function definitions (Note: currently yields same results!) * Enter to view all pods instances associated with the selected function * Delete a function * Editing, shelling, logs, etc... are all supported by navigating to the underlying pods Keep in mind, the paint is way fresh here and this feature could be a complete dud, but figure will give it a rinse on this drop and Alex can pipe in and helps us ironing this out. > NOTE! It's been a while since I've played with OpenFaas so if some of you are more versed in this space by all means please do land a hand so we can make this feature more awesome! ## Moving Forward! A few folks had mentioned the eagerness to port-forward directly from a pod or a service. Well now you can! Port Forwarding is now available on both the pod view and services view. Note! at the end of the day, you are still port-forwarding to a container! So the port-forward dialog is a bit different for these views as there might be several container ports available now when looking at this from a pod perspective. So the first field in the dialog is a combo-box that allows one to pick their desired ports. The rest of the dialog works the same as the container port-forward dialog. ## Resolved Bugs/Features/PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.15.2.md ================================================ # Release v0.15.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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## Mo PortForwards... While putting together the [OpenFeeZ video](https://youtu.be/7Fx4XQ2ftpM), I've noticed a few issues with port-forwards and benchmarks. While I was doing surgery on that carp, figured why not go pull a full monty on port-forwards and enable for other controller like resources such as deployments, statefulsets and daemonsets. So now you can set up port-forwards on any of these using `shift-f`. This exhibits the same mechanics as service based port-forwards ie pick a container port from pods matching the controller selector. ## Resolved Bugs/Features/PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.16.0.md ================================================ # Release v0.16.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- This is one of these drops that may make you wonder if you'll go from zero to hero or likely the reverse?? Will see how this goes... Please proceed with caution on this one as there could very well be much disturbances in the force... Lots of code churns so could have totally hose some stuff, but like my GranPappy used to say `can't cook without making a mess!` ## Going Wide? In this drop, we've enabled a new shortcut namely `wide` as `Ctrl-w`. On table views, you will be able to see more information about the resources such as labels or others depending on the viewed resource. This mnemonic works as a toggle so you can `narrow` the view by hitting it again. ## Zoom, Zoom, Zoom! While viewing some resources that may contain errors, sorting on columns may not achieve the results you're seeking ie `show me all resources in an error state`. We've added a new option to achieve just that aka `zoom errors` as `ctrl-z`. This works as a toggle and will unveil resources that are need of some TLC on your part ;) ## Does Your Cluster Have A Pulse 💓? In this drop, we're introducing a brand new view aka `K9s Pulses` 💓. This is a summary view listing the most salient resources in your clusters and their current states. This view tracks two main metrics ie Ok and Toast on a 5sec beat. This view affords cluster activity and failure rates. BTW this is the zero to hero deal 🙀 Hopefully you'll dig it as this was much work to put together and I personally think it's the `ducks nuts`... If you like, please give me some luving on social or via GH sponsors as batteries are running low... To active, enter command mode by typing in `:pulse` aliases are `pu`, `pulses` or `hz` To navigate thru the various pulses, you can use `tab`/`backtab` or use the menu index (just like namespaces selectors). Once on a pulse view, you can press `enter` to see the associated resource table view. Pressing `esc` will nav you back. As I've may have mentioned before, my front-end/UX FU is weak, so I've also added a way for you to skin the charts via skins yaml to your own liking. Please see the skin section below for an example on how to skin the pulses dials. BONUS you should be able to skin K9s live! How cool is that 😻? NOTE: Pulses are very much experimental and could totally bomb on your clusters! So please thread carefully and please do report (kindly!) back. ## BReaking Bad! In this drop I've broken a few things (that I know of...), here is the list as I can recall... 1. Toggle header aka `my red headed step child`. Key moved (again!) now `Ctrl-e` 2. Skin yaml layout CHANGED! Moved table and xray sections under views and added charts section. ## Skins Updates! The skin file format CHANGE! If you are running skins with K9s, please make sure to update your skin file. If not K9s could bomb coming up! NOTE: I don't think I'll get around to update all the contributed skins in this repo `skins` dir. If you're looking for a way to help out and are UI inclined, please take a peek and make them cool! ```yaml # my_cluster_skin.yml # Styles... foreground: &foreground "#f8f8f2" background: &background "#282a36" current_line: ¤t_line "#44475a" selection: &selection "#44475a" comment: &comment "#6272a4" cyan: &cyan "#8be9fd" green: &green "#50fa7b" orange: &orange "#ffb86c" pink: &pink "#ff79c6" purple: &purple "#bd93f9" red: &red "#ff5555" yellow: &yellow "#f1fa8c" # Skin... k9s: # General K9s styles body: fgColor: *foreground bgColor: *background logoColor: *purple # ClusterInfoView styles. info: fgColor: *pink sectionColor: *foreground frame: # Borders styles. border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *pink # Used for favorite namespaces numKeyColor: *purple # CrumbView attributes for history navigation. crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line # Resource status and update styles status: newColor: *cyan modifyColor: *purple addColor: *green errorColor: *red highlightcolor: *orange killColor: *comment completedColor: *comment # Border title styles. title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *purple filterColor: *pink views: charts: bgColor: *background dialBgColor: "#0A2239" chartBgColor: "#0A2239" defaultDialColors: - "#1E3888" - "#820101" defaultChartColors: - "#1E3888" - "#820101" resourceColors: batch/v1/jobs: - "#5D737E" - "#820101" v1/persistentvolumes: - "#3E554A" - "#820101" cpu: - "#6EA4BF" - "#820101" mem: - "#17505B" - "#820101" v1/events: - "#073B3A" - "#820101" v1/pods: - "#487FFF" - "#820101" # TableView attributes. table: fgColor: *foreground bgColor: *background cursorColor: *current_line # Header row styles. header: fgColor: *foreground bgColor: *background sorterColor: *cyan # Xray view attributes. xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *purple showIcons: true # YAML info styles. yaml: keyColor: *pink colonColor: *purple valueColor: *foreground # Logs styles. logs: fgColor: *foreground bgColor: *background ``` ## Resolved Bugs/Features/PRs - [Issue #557](https://github.com/derailed/k9s/issues/557) - [Issue #555](https://github.com/derailed/k9s/issues/555) - [Issue #554](https://github.com/derailed/k9s/issues/554) - [Issue #553](https://github.com/derailed/k9s/issues/553) - [Issue #552](https://github.com/derailed/k9s/issues/552) - [Issue #551](https://github.com/derailed/k9s/issues/551) - [Issue #549](https://github.com/derailed/k9s/issues/549) A start with pulses... - [Issue #540](https://github.com/derailed/k9s/issues/540) - [Issue #421](https://github.com/derailed/k9s/issues/421) - [Issue #351](https://github.com/derailed/k9s/issues/351) Solved by Pulses? - [Issue #25](https://github.com/derailed/k9s/issues/25) Pulses? Oldie but goodie! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.16.1.md ================================================ # Release v0.16.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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! ## Resolved Bugs/Features/PRs - [Issue #561](https://github.com/derailed/k9s/issues/561) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.0.md ================================================ # Release v0.17.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please 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) --- ## Custom Columns? Yes Please!! [SneakCast v0.17.0 on The Beach! - Yup! sound is sucking but what a setting!](https://youtu.be/7S33CNLAofk) In this drop I've reworked the rendering engine to provide for custom columns support. Now, you should be able to not only tell K9s which columns you would like to display but also which order they should be in. To surface this feature, you will need to create a new configuration file, namely `$HOME/.k9s/views.yml`. This file leverages GVR (Group/Version/Resource) to configure the associated table view columns. If no GVR is found for a view the default rendering will take over (ie what we have now). Going wide will add all the remaining columns that are available on the given resource after your custom columns. To boot, you can edit your views config file and tune your resources views live! > NOTE: This is experimental and will most likely change as we iron this out! Here is a sample views configuration that customize a pods and services views. ```yaml # $HOME/.k9s/views.yml k9s: views: v1/pods: columns: - AGE - NAMESPACE - NAME - IP - NODE - STATUS - READY v1/services: columns: - AGE - NAMESPACE - NAME - TYPE - CLUSTER-IP ``` ## Resolved Bugs/Features/PRs - [Issue #581](https://github.com/derailed/k9s/issues/581) - [Issue #576](https://github.com/derailed/k9s/issues/576) - [Issue #574](https://github.com/derailed/k9s/issues/574) - [Issue #573](https://github.com/derailed/k9s/issues/573) - [Issue #571](https://github.com/derailed/k9s/issues/571) - [Issue #566](https://github.com/derailed/k9s/issues/566) - [Issue #563](https://github.com/derailed/k9s/issues/563) - [Issue #562](https://github.com/derailed/k9s/issues/562) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.1.md ================================================ # Release v0.17.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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! ## Resolved Bugs/Features/PRs - [Issue #584](https://github.com/derailed/k9s/issues/584) - [Issue #583](https://github.com/derailed/k9s/issues/583) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.2.md ================================================ # Release v0.17.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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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! ## Resolved Bugs/Features/PRs - [Issue #592](https://github.com/derailed/k9s/issues/592) - [Issue #591](https://github.com/derailed/k9s/issues/591) - [Issue #590](https://github.com/derailed/k9s/issues/590) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.3.md ================================================ # Release v0.17.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please 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! - Reworked Pulses view counters and layout. - Switching context now will take you to that context last view if available vs the pod view. - Reworked info/version layout. ## Resolved Bugs/Features/PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.4.md ================================================ # Release v0.17.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## Pulses Part Duh! In this drop, we've updated pulses to now show used/allocatable resources for cpu and mem as recommended by the awesome and kind [Eldad Assis](https://github.com/eldada)! We've also added the concept of threshold to alert you when things in your clusters are going south. These currently come in the shape of cpu and mem thresholds. They are set at the cluster level. K9s will now let you know when these limits are reached or surpassed. As it stands, the k9s logo will change color and a flash message will appear to let you know which resource threshold was exceeded. Once the load subsumes the logo/flash will return to their original states. In order to override the default thresholds (cpu/mem: 80% ), you will need to modify your `$HOME/.k9s/config.yml` using the new config section named `thresholds` as follows: ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 headless: false ... # Specify resources thresholds percentages thresholds: cpu: 80 # default is 80 memory: 55 # default is 80 ... ``` ## Resolved Bugs/Features/PRs - [Issue #596](https://github.com/derailed/k9s/issues/596) - [Issue #593](https://github.com/derailed/k9s/issues/593) - [Issue #560](https://github.com/derailed/k9s/issues/560) - NOTE!! All credits here goes to [Bruno Meneguello](https://github.com/bkmeneguello) and [Michael Cristina](https://github.com/mcristina422) for making this possible in K9s! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.5.md ================================================ # Release v0.17.5 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## Thresholds Reloaded! In the previous k9s release, we've introduced the notion of thresholds to provide with an alert mechanism when either the cpu or memory goes high on your clusters. Looking at the current solution, we felt we needed a bit more granularity in the severity levels thanks to [Eldad Assis](https://github.com/eldada) feedback on this one! So here is the new configuration for cluster thresholds. Please keep in mind this feature is still in flux! ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 headless: false ... # Specify resources thresholds in percent - defaults: critical=90, warn=70 thresholds: cpu: critical: 85 warn: 75 memory: critical: 80 warn: 70 ... ``` ## Resolved Bugs/Features/PRs - [Issue #604](https://github.com/derailed/k9s/issues/604) - [Issue #601](https://github.com/derailed/k9s/issues/601) Thank you [Christian Vent](https://github.com/christian-vent) - [Issue #598](https://github.com/derailed/k9s/issues/598) `Ctrl-l` will now trigger the benchmarking toggle! - [Issue #593](https://github.com/derailed/k9s/issues/593) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.6.md ================================================ # Release v0.17.6 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## Get A Rope! This was in the backlogs for a while, so I've decided to give it a bit of TLC. Thank you [Mitchell Maler](https://github.com/mitchellmaler) for this issue! 🏝Feeling your clusters could use a bit of spring cleaning 🧽🧼? As of this drop, you can now perform direct cluster nodes maintenance by leveraging cordon `c`, uncordon `u` and drain `d` while in node view! Each operation comes with a dialog to either configure the options or confirm the operation. You dig? ## Resolved Bugs/Features/PRs - [Issue #612](https://github.com/derailed/k9s/issues/612) - [Issue #608](https://github.com/derailed/k9s/issues/608) - [Issue #606](https://github.com/derailed/k9s/issues/606) - [Issue #237](https://github.com/derailed/k9s/issues/237) - [PR #607](https://github.com/derailed/k9s/pull/607) ATTA Boy! [Jeff Widman](https://github.com/jeffwidman) - [PR #570](https://github.com/derailed/k9s/pull/570) Thank you [Ludovico Rosso](https://github.com/ludusrusso)! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.17.7.md ================================================ # Release v0.17.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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## 🙀(PLUGIN-19) [Br]eaking [Ba]d on K9s plugins! In previous releases, we used the COL semantic to reference view column data in the plugin extensions. In this drop, we've axed this in favor of column name vs column index. This makes K9s plugin more readable and usable. Also, in light of custom columns, this old semantic just did not jive to well. To boot, all columns available on the viewed resource, regardless of display preferences or order are now free game to plugin authors. So for folks currently leveraging K9s plugins, this drop will break you I am hopeful you guys dig this approach betta'?? Here is a sample plugin file that highlights the new functionality. Please see the updated docs for additional information! ```yaml plugin: toggleCronJob: shortCut: Ctrl-T scopes: - cj description: Suspend/Resume command: kubectl background: true args: - patch - cronjobs - $NAME - -n - $NAMESPACE - --context - $CONTEXT - -p - '{"spec" : {"suspend" : $!COL-SUSPEND }}' # => Used to be COL3! ``` ## Resolved Bugs/Features/PRs - [Issue #616](https://github.com/derailed/k9s/issues/616) - [Issue #615](https://github.com/derailed/k9s/issues/615) - [Issue #614](https://github.com/derailed/k9s/issues/614) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.18.0.md ================================================ # Release v0.18.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## GH Sponsors Big `ThankYou` to the following folks that I've decided to dig in and give back!! 👏🙏🎊 Thank you for your gesture of kindness and for supporting K9s!! * [Bob Johnson](https://github.com/bbobjohnson) * [Poundex](https://github.com/Poundex) * [thllxb](https://github.com/thllxb) If you've contributed $25 or more please reach out to me on slack with your earth coordinates so I can send you your K9s swags! NOTE: I am one not to pressure folks into giving. However, it does make me sad to see postings out there with clear indications that K9s is being used and yet zero mentions of the web site nor this repo. K9s marketing budget relies entirely on word of mouth and is not pimped out by big corps. So if you publish your work and leverage K9s, please give us a shoutout or at the very least reference this repo or website! --- ## AutoSuggestions K9s command mode now provides for auto complete. Suggestions are pulled from available kubernetes resources and custom aliases. The command mode supports the following keyboard triggers: | Key | Description | |---------------------|------------------------------------------| | ⬆️ ⬇️ | Navigate up or down thru the suggestions | | `Ctrl-w`, `Ctrl-u` | Clear out the command | | `Tab`, `Ctrl-f`, ➡️ | Accept the suggestion | ## Logs Revisited Breaking Change! This drop changes how logs are viewed and configured. The log view now support for pulling logs based on the log timeline current settings are: all, 1m, 5m, 15m and 1h. The following log configuration is in effect as of this drop: ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 readOnly: false # NOTE: New logger configuration! logger: tail: 200 # Tail the last 100 lines. Default 100 buffer: 5000 # Max number of lines displayed in the view. Default 1000 sinceSeconds: 900 # Displays the last x seconds from the logs timeline. Default 5m ... ``` ## Resolved Bugs/Features/PRs * [Issue #628](https://github.com/derailed/k9s/issues/628) * [Issue #623](https://github.com/derailed/k9s/issues/623) * [Issue #622](https://github.com/derailed/k9s/issues/622) * [Issue #565](https://github.com/derailed/k9s/issues/565) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.18.1.md ================================================ # Release v0.18.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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! ## Resolved Bugs/Features/PRs * [Issue #632](https://github.com/derailed/k9s/issues/632) * [Issue #631](https://github.com/derailed/k9s/issues/631) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.0.md ================================================ # Release v0.19.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## A Word From Our Sponsors... It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program. Big Thank You! to [hornbech](https://github.com/hornbech) for joining our sponsors! ## K8s v1.18.0 Support As you might have heard, the good Kubernetes folks just dropped some big features in this new release. ATTA Girls/Boys!! We've (painfully) updated K9s to now link with the latest and greatest apis. Likely more work will need to take place here as I am still trying to catch up with the latest enhancements. This is great to see and excellent for all our Kubernetes friends! ## Oh Biffs'em And Buffs'em Popeye! As you may know, I am the author of [Popeye](https://popeyecli.io) a Kubernetes cluster linter/sanitizer. Popeye scans your clusters live and reports potential issues, things like: referential integrity, misconfiguration, resource usage, etc... In this drop, we've integrated K9s and Popeye to produce what I believe is a killer combo. Not only can you manage/observe your cluster resources in the wild, but you can now assert that your resources are indeed cool and potentially get rid of dead weights that might add up to your monthly cloud service bills. How cool is that? In order to run your sanitization and produce reports, you can enter a new command `:popeye`. Once your cluster sanitization is complete, you can use familiar keyboard shortcuts to sort columns and view the sanitization reports by pressing `enter` on a given resource linter. Popeye also supports a configuration file namely `spinach.yml`, this file provides for customizing what resources get scanned as well as setting different severity levels to your own company policies. Please read the Popeye docs on how to customize your reports. The spinach.yml file will be read from K9s home directory `$HOME/.k9s/MY_CLUSTER_CONTEXT_NAME_spinach.yml` NOTE! This is very much still experimental, so you may experience some disturbances in the force! And remember PRs are always open ;) ## Command History Support K9s now supports for command history. Entering command mode via `:` you can now up/down arrow to navigate thru your command history. Pressing `tab` or `ctrl-e` or `->` will activate the selected command upon `enter`. ## K9s Icons Some terminals often don't offer icon support. In this release there is a new option `noIcons` available to enable/disable K9s icons. By default this option is set `false`. You can now set your icon preference in the K9s config file as follows: ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 headless: false readOnly: false noIcons: true # Enable/Disable K9s icons display. ``` ## Videos! * [video v0.19.0](https://www.youtube.com/watch?v=kj-WverKZ24) * [video v0.18.0](https://www.youtube.com/watch?v=zMnD5e53yRw) ## Resolved Bugs/Features/PRs * [Issue #647](https://github.com/derailed/k9s/issues/647) * [Issue #645](https://github.com/derailed/k9s/issues/645) * [Issue #640](https://github.com/derailed/k9s/issues/640) * [Issue #639](https://github.com/derailed/k9s/issues/639) * [Issue #635](https://github.com/derailed/k9s/issues/635) * [Issue #634](https://github.com/derailed/k9s/issues/634) Thank you!! [David Němec](https://github.com/davidnemec) * [Issue #626](https://github.com/derailed/k9s/issues/626) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.1.md ================================================ # Release v0.19.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## A Word From Our Sponsors... It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program. Big Thank You! to [Azar](https://github.com/azarudeena) for joining our sponsors! Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #649](https://github.com/derailed/k9s/issues/649) * [PR #638](https://github.com/derailed/k9s/pull/638) Thank you! [Shang Yuanchun](https://github.com/ideal) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.2.md ================================================ # Release v0.19.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 is as ever very much noticed and appreciated! Also if you dig this tool, please consider sponsoring 👆us 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) --- ## A Word From Our Sponsors... It makes me always very happy to hear folks are digging this effort and using K9s daily! If you feel this way please tell us and consider joining our [sponsorship](https://github.com/sponsors/derailed) program. Big Thank You! to the following folks for joining our program: * [Nick Hobart](https://github.com/nwhobart) * [Shopeonarope](https://github.com/shopeonarope) Maintenance Release! NOTE! During K9s update to support the latest version of Kubernetes (v1.18), K9s Helm charts support took one for the team ;( At this time Helm as yet to be released k8s v1.18 support. We will track for updates and enable this feature once HelmV3 releases it. ## Resolved Bugs/Features/PRs * [Issue #665](https://github.com/derailed/k9s/issues/665) * [Issue #662](https://github.com/derailed/k9s/issues/662) * [PR #660](https://github.com/derailed/k9s/pull/660) Thank you! [Tomáš Pospíšek](https://github.com/tpo) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.3.md ================================================ # Release v0.19.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## Look Who Is Back? Thanks to the good Helm folks, we're now back on par with the Helm charts support feature. As you may recall, when we've updated to K8s v1.18, the Helm feature took one for the team ;( as they had yet to upgrade to the latest k8s rev. So K9s Helm chart feature is back in this drop! On that note, we've added new aliases to allow you to view your currently installed Helm charts aka `helm` | `hm` | `chart` | `charts`. ## Boh-Bye Windows 386! As of this drop, I've decided to axe Windows 386 support. Our good friend [Guy Barrette](https://github.com/guybarrette) reported K9s Windows-386 binary is tripping his virus scanner. After double checking my installed SHAs/binaries/dependencies/etc... and performing vulnerability scans on various win-i386 K9s binaries, I just could not figure out which dependencies are causing the exec to bomb on the scans?? Note: This does not necessary entails that there is a deliberate or malicious intent with the software, but likely a false positive thrown by the Windows virus scanner. This has been [reported](https://golang.org/doc/faq#virus) with other GO binaries on windows as well ;( That said, I've repeatedly scanned the K9s Windows-x64 and ended up with a clean bill of health on every single scans. So I've decided to drop the 386 windows support for the time being. If that causes you some grief, please land a hand as I am fresh out of ideas... ## And Now For Something A Bit More... Controversial? There has been a lot of requests for K9s to support shelling directly into cluster nodes. I was resisting the temptation to support this useful feature as depending on your cluster hosting solution, this involved less than ideal solutions. My clusters are provisioned in a multitude of platforms ranging from bare metal to cloud vendor self/managed hosting. I wanted the same experience shelling into an GKE/AWS node as a local KiND cluster node. To this end, we've opted to experimentally support shelling into nodes using the following approach: 1. While in the Node view, we are introducing a new `s` mnemonic to shell into nodes on your cluster. 2. K9s will spin up a `k9s-shell` pod in the `default` namespace with an official Busybox container running in `privileged` mode. This may require extra RBAC and PSPs (This will need Docs!) 3. Once shelled-in, you can poke around any of your nodes. 4. Upon exiting the node shell, K9s will automatically delete the `k9s-shell` pod for that node. This feature is `OPT-IN` only ie you will need to manually enable the feature gate to make this functionality available to K9s on a specific cluster as follows: ```yaml # $HOME/.k9s/config.yml k9s: ... clusters: fred: namespace: active: "default" favorites: - default view: active: po featureGates: nodeShell: true # Defaults to false! ``` Please let us know if you dig this feature? This very much experimental and we're open to your suggestions. Thank you! ## New Sheriff In Town K9S_EDITOR As you may know K9s currently uses your `EDITOR` env var to launch an editor while editing a k8s resource or viewing a screen dump or a performance benchmark. So folks voiced they are using some editors that require different CLI args when editing k8s resources vs files on disk. In this drop, we're introducing a new env var `K9S_EDITOR` to provide an affordance to deal with these discrepancies. If you are using emacs/vi/nano no action should be required. K9s will now check for `K9S_EDITOR` existence to view K9s artifacts such as screen_dumps. K9s still honors `KUBE_EDITOR` or `EDITOR` for K8s resource edits. K9s will fallback to the `EDITOR` env var if `K9S_EDITOR` is not set. ## Resolved Bugs/Features/PRs * [Issue #669](https://github.com/derailed/k9s/issues/669) * [Issue #677](https://github.com/derailed/k9s/issues/677) * [Issue #673](https://github.com/derailed/k9s/issues/673) * [Issue #671](https://github.com/derailed/k9s/issues/671) * [Issue #670](https://github.com/derailed/k9s/issues/670) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.4.md ================================================ # Release v0.19.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## A Word From Out Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Jason Vance](https://github.com/jasonvance) * [Jacob Gillespie](https://github.com/jacobwgillespie) Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #692](https://github.com/derailed/k9s/issues/692) * [Issue #689](https://github.com/derailed/k9s/issues/689) * [Issue #685](https://github.com/derailed/k9s/issues/685) * [Issue #684](https://github.com/derailed/k9s/issues/684) * [Issue #670](https://github.com/derailed/k9s/issues/670) * [PR #688](https://github.com/derailed/k9s/pull/688) All credits goes to [David Němec](https://github.com/davidnemec)!! * [PR #676](https://github.com/derailed/k9s/pull/676) Big Thanks to [Agrim Asthana](https://github.com/agrimrules)! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.5.md ================================================ # Release v0.19.5 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## A Word From Out Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Tommy Dejbjerg Pedersen](https://github.com/tpedersen123) * [Matt Welke](https://github.com/mattwelke) ## Disruption In The Force During this drop, I've gotten totally slammed by other forces ;( I've had so many disruptions that affected my `quasi` normal flow hence this drop might be a bit wonky ;( So please proceed with caution!! As always please help me flush/report issues and I'll address them promptly! Thank you so much for your understanding and patience!! 🙏👨‍❤️‍👨😍 ## Improved Node Shell Usability In this drop we've changed the configuration of the node shell action that lets you shell into nodes. Big thanks to [Patrick Decat](https://github.com/pdecat) for helping us flesh out this beta feature! We've added configuration to not only customize the image but also the resources and namespace on how to run these K9s pods on your clusters. The new configuration is set at the cluster scope level. Here is an example of the new pod shell config options: ```yaml # $HOME/.k9s/config.yml k9s: clusters: blee: featureGates: # You must enable the nodeShell feature gate to enable shelling into nodes nodeShell: true # NEW! You can now tune the pod specification: currently image, namespace and resources shellPod: image: cool_kid_admin:42 namespace: blee limits: cpu: 100m memory: 100Mi ``` ## Resolved Bugs/Features/PRs * [Issue #714](https://github.com/derailed/k9s/issues/714) * [Issue #713](https://github.com/derailed/k9s/issues/713) * [Issue #708](https://github.com/derailed/k9s/issues/708) * [Issue #707](https://github.com/derailed/k9s/issues/707) * [Issue #705](https://github.com/derailed/k9s/issues/705) * [Issue #704](https://github.com/derailed/k9s/issues/704) * [Issue #702](https://github.com/derailed/k9s/issues/702) * [Issue #700](https://github.com/derailed/k9s/issues/700) Fingers and toes crossed ;) * [Issue #694](https://github.com/derailed/k9s/issues/694) * [Issue #663](https://github.com/derailed/k9s/issues/663) Partially - should be better launching in a given namespace ie k9s -n fred?? * [Issue #702](https://github.com/derailed/k9s/issues/702) * [PR #709](https://github.com/derailed/k9s/pull/709) All credits goes to [Namco](https://github.com/namco1992)!! * [PR #706](https://github.com/derailed/k9s/pull/706) Big Thanks to [M. Tarık Yurt](https://github.com/mtyurt)! * [PR #704](https://github.com/derailed/k9s/pull/704) Atta Boy!! [psvo](https://github.com/psvo) * [PR #696](https://github.com/derailed/k9s/pull/696) Thank you! Credits to [Christian Köhn](https://github.com/ckoehn) * [PR #691](https://github.com/derailed/k9s/pull/691) Mega Thanks To [Pavel Tumik](https://github.com/sagor999)! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.6.md ================================================ # Release v0.19.6 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [danysirota](https://github.com/danysirota) * [lampapetrol](https://github.com/lampapetrol) Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #719](https://github.com/derailed/k9s/issues/719) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.19.7.md ================================================ # Release v0.19.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 is as ever very much noticed and appreciated! Also if you dig this tool, 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! ## Resolved Bugs/Features/PRs * [Issue #726](https://github.com/derailed/k9s/issues/726) * [Issue #724](https://github.com/derailed/k9s/issues/724) * [Issue #722](https://github.com/derailed/k9s/issues/722) * [Issue #721](https://github.com/derailed/k9s/issues/721) * [Issue #720](https://github.com/derailed/k9s/issues/720) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.0.md ================================================ # Release v0.20.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## ♫ The Sound Behind The Release ♭ And now for something a `beat` different? I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure right? I've just discovered this Turkish band, that I dig and figured I'll share it with you while you read these release notes... [Ruh - She Past Away](https://www.youtube.com/watch?v=B7f-opGKOyI) NOTE! Mind you I grew up on the `The Cure`, so likely not for everyone here 🙀 ## PortForward Revisited While performing port-forwards, it could be convenient to specify a given IP address vs 'localhost' for the forwarding host. For this reason, we are introducing a configuration setting that allows you to set the host IP address for the port-forward dialog on a per cluster basis. The IP address currently defaults to `localhost`. Big Thanks and all credits goes to [Stowe4077](https://github.com/Stowe4077) (and that very cute dog!) for raising this issue in the first place!! In order to change the configuration, edit your k9s config file as follows: ```yaml k9s: ... clusters: blee: namespace: active: "" favorites: - fred - default view: active: po portForwardAddress: 1.2.3.4 ``` ## And We've Got A Floater! I've been noodling on this feature for a while and thought it might be time to `float` this over to you guys... While operating on a cluster you may ask yourself: "Hum... wonder which resources use configmap `fred`?" Sure a quick grep through your manifests on disk will do fine, but what about the resources actually deployed on your cluster? Well my friends wonder no m'o, K9s knows! While navigating to your ConfigMap View a new option will appear `UsedBy` pressing `u` will reveal any resources that are currently referencing that ConfigMap. As of this drop, this feature will be available for the usual suspects namely: ConfigMaps, Secrets and ServiceAccounts. K9s scans managing resources and locate references from Env vars, Volumes or ServiceAccounts. NOTE: This feature is expensive to produce and might take a while to fully resolve on larger clusters! Also K9s referential scans might not be full proof and the paint is still fresh on this one so trade carefully! More resources refs checks will be enabled once we've rinse and repeat on this deal. We hope you'll find this feature useful, if so, please make some noise! ## Lastly... There has been quick a bit of surgery going on with this drop, so this release could be a bit unstable. Please watch out for that carp overbite! As always, Thank You All for your understanding, support and patience!! ## Resolved Bugs/Features/PRs - [Issue #734](https://github.com/derailed/k9s/issues/734) - [Issue #733](https://github.com/derailed/k9s/issues/733) - [Issue #716](https://github.com/derailed/k9s/issues/716) - [Issue #693](https://github.com/derailed/k9s/issues/693) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.1.md ================================================ # Release v0.20.1 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## ♫ The Sound Behind The Release ♭ And now for something a `beat` different? I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure right? I've just discovered this Turkish band, that I dig and figured I'll share it with you while you read these release notes... [Ruh - She Past Away](https://www.youtube.com/watch?v=B7f-opGKOyI) NOTE! Mind you I grew up on the `The Cure`, so likely not for everyone here 🙀 ## PortForward Revisited While performing port-forwards, it could be convenient to specify a given IP address vs 'localhost' for the forwarding host. For this reason, we are introducing a configuration setting that allows you to set the host IP address for the port-forward dialog on a per cluster basis. The IP address currently defaults to `localhost`. Big Thanks and all credits goes to [Stowe4077](https://github.com/Stowe4077) (and that very cute dog!) for raising this issue in the first place!! In order to change the configuration, edit your k9s config file as follows: ```yaml k9s: ... clusters: blee: namespace: active: "" favorites: - fred - default view: active: po portForwardAddress: 1.2.3.4 ``` ## And We've Got A Floater! I've been noodling on this feature for a while and thought it might be time to `float` this over to you guys... While operating on a cluster you may ask yourself: "Hum... wonder which resources use configmap `fred`?" Sure a quick grep through your manifests on disk will do fine, but what about the resources actually deployed on your cluster? Well my friends wonder no m'o, K9s knows! While navigating to your ConfigMap View a new option will appear `UsedBy` pressing `u` will reveal any resources that are currently referencing that ConfigMap. As of this drop, this feature will be available for the usual suspects namely: ConfigMaps, Secrets and ServiceAccounts. K9s scans managing resources and locate references from Env vars, Volumes or ServiceAccounts. NOTE: This feature is expensive to produce and might take a while to fully resolve on larger clusters! Also K9s referential scans might not be full proof and the paint is still fresh on this one so trade carefully! More resources refs checks will be enabled once we've rinse and repeat on this deal. We hope you'll find this feature useful, if so, please make some noise! ## Lastly... There has been quick a bit of surgery going on with this drop, so this release could be a bit unstable. Please watch out for that carp overbite! As always, Thank You All for your understanding, support and patience!! ## Resolved Bugs/Features/PRs - [Issue #734](https://github.com/derailed/k9s/issues/734) - [Issue #733](https://github.com/derailed/k9s/issues/733) - [Issue #716](https://github.com/derailed/k9s/issues/716) - [Issue #693](https://github.com/derailed/k9s/issues/693) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.2.md ================================================ # Release v0.20.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 is as ever very much noticed and appreciated! Also if you dig this tool, 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! Fixing a few issues in the v0.20 aftermath ;( Thank you all for reporting these issues and for your patience! ## Selection Marker In this drop, we're adding the ability to set row mark ranges. There are situations where you've filtered a resource and need to delete part or all of the rows. In previous releases, you had to mark each rows one by one. Now you have the ability to select the beginning and end range and all rows in between will now be marked! To mark a single row, you can use `space`. To select rows between your initial mark to the current selection use `Ctrl-space`. To nuke all marked rows use `Ctrl-\`. All credits and ATTA BOY! goes to [Ryan Richard](https://github.com/cfryanr) for suggesting this feature! Thank you Ryan!! ## Logs Got Some TLC! Per [Raman Gupta](https://github.com/rocketraman) excellent suggestion, we've added a way to add a separator to your chatty logs to easily see the latest incoming logs. While in log view, you can now press `m` for mark to add the separator to the log stream. If you don't care about the log history and just want to see the latest incoming logs, pressing `c` will clear out the log viewer. ## Resolved Bugs/Features/PRs - [Issue #741](https://github.com/derailed/k9s/issues/741) - [Issue #740](https://github.com/derailed/k9s/issues/740) - [Issue #739](https://github.com/derailed/k9s/issues/739) - [Issue #727](https://github.com/derailed/k9s/issues/727) - [Issue #723](https://github.com/derailed/k9s/issues/723) - [PR #725](https://github.com/derailed/k9s/pull/725) Big Thanks To [Soupyt](https://github.com/soupyt)!! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.3.md ================================================ # Release v0.20.3 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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!** ## Resolved Bugs/Features/PRs - [Issue #752](https://github.com/derailed/k9s/issues/752) - [Issue #677](https://github.com/derailed/k9s/issues/677) Once again with feelings this time ;( --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.4.md ================================================ # Release v0.20.4 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## PersistentVolumeClaims Reference Tracking In continuation with the resource usage check feature added in v0.20, we've added reference checks on the PVC view. If you ever wonder which resources on your cluster are referencing a given PVC, simply press `u` for `UsedBy` and k9s will tell you. ## New Config On The Block Some folks voiced concerns with K9s config dir littering their home directory with yet another `.dir`. In this drop, we're introducing a new env variable `K9SCONFIG` that tells K9s where to look for its configurations. If `K9SCONFIG` is not set K9s will look in the usual place aka `$HOME/.k9s`. ## Resolved Bugs/Features/PRs - [Issue #754](https://github.com/derailed/k9s/issues/754) - [Issue #753](https://github.com/derailed/k9s/issues/753) - [Issue #743](https://github.com/derailed/k9s/issues/743) - [Issue #728](https://github.com/derailed/k9s/issues/728) - [Issue #718](https://github.com/derailed/k9s/issues/718) - [Issue #643](https://github.com/derailed/k9s/issues/643) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.20.5.md ================================================ # Release v0.20.5 ## 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 is as ever very much noticed and appreciated! Also if you dig this tool, 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) --- ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [João Costa](https://github.com/JD557) Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #756](https://github.com/derailed/k9s/issues/756) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.0.md ================================================ # Release v0.21.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better is as ever very much noticed and appreciated! 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) --- ## First A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Remo Eichenberger](https://github.com/remoe) * [Ken Ahrens](https://github.com/kenahrens) ## Moving Forward! In this drop, we've added a port-forward indicator to visually see if a port-forward is active on a pod/container. You can also navigate directly to the port-forward view using the new shortcut `f` available in pod and container view. ## Manifest That! Ever wanted to manipulate your Kubernetes manifests directly in K9s? `Yes Please!!` We are introducing a new view namely `directory` aka `dir`. Using this command you can list/traverse a given directory structure containing your Kubernetes manifests using a new `:dir /fred` command. From there you can view/edit your manifests and also deploy or delete these resources for your cluster directly from K9s. Just like `kubectl` you can apply/delete an entire directory or a single manifest. How cool is that? ## Resolved Bugs/Features/PRs * [Issue #778](https://github.com/derailed/k9s/issues/778) * [Issue #774](https://github.com/derailed/k9s/issues/774) * [Issue #761](https://github.com/derailed/k9s/issues/761) * [Issue #759](https://github.com/derailed/k9s/issues/759) * [Issue #758](https://github.com/derailed/k9s/issues/758) * [PR #746](https://github.com/derailed/k9s/pull/746) Big Thanks to [Groselt](https://github.com/groselt)! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.1.md ================================================ # Release v0.21.1 ## 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 is as ever very much noticed and appreciated! 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) --- ## A Word From Our Sponsors... I would like to send a `Big Thank You` to the following generous K9s friend for joining our sponsorship program and supporting this project! * [Joao Azevedo](https://github.com/jcazevedo) Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #791](https://github.com/derailed/k9s/issues/791) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.10.md ================================================ # Release v0.21.10 ## 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! 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! ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Martin Kemp](https://github.com/MartiUK) Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you and part of your daily lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## I Should've known better Seems like I've broken the golden rule ie never add a feature without providing an option to turn it off ;( It looks like enable mouse support for K9s had unexpected side effects. So in this drop, we're introducing a new configuration aka `enableMouse` that defaults to `false`. You can opt-in mouse support, by enabling it in the K9s config file. That said when mouse support is enabled, you can still use terminal selection using either `Shift/Option` for Windows/Mac. ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 enableMouse: true # Defaults to false if not set headless: false ... ``` ## Resolved Issues/Features * [Issue #874](https://github.com/derailed/k9s/issues/874) Latest version broke selecting text by mouse ## Resolved PRs * [PR #877](https://github.com/derailed/k9s/pull/877) Change character used for X in RBAC view. Thank you! [Torjus](https://github.com/torjue) * [PR #876](https://github.com/derailed/k9s/pull/876) Migrate to new sortorder import path. Big thanks to [fbbommel](https://github.com/fvbommel) * [PR #873](https://github.com/derailed/k9s/pull/873) Fix default logger config, same as README. Thank you! [darklore](https://github.com/darklore) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.2.md ================================================ # Release v0.21.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 is as ever very much noticed and appreciated! 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) --- ## First A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Remo Eichenberger](https://github.com/remoe) * [Ken Ahrens](https://github.com/kenahrens) Maintenance Release! ## Resolved Bugs/Features/PRs * [Issue #790](https://github.com/derailed/k9s/issues/790) My bad! Must get mo' sleep ;( --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.3.md ================================================ # Release v0.21.3 ## 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 is as ever very much noticed and appreciated! 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) --- ## ♫ The Sound Behind The Release ♭ And now for something a `beat` different? I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure right? [Funkin' for Jamaica](https://www.youtube.com/watch?v=uuUy2ShGLyo) by the most awesome Tom Browne! Maintenance Release! Lots of bugs fix in this drop and perf improvements... NOTE! You may experience some disturbance in the force in this drop, so please proceed with caution and do land a hand flushing out potential issues. Thank you!! ## Video Tutorial [Who Let The Pods Out (v0.21.3)](https://youtu.be/wG8KCwDAhnw) ## Resolved Bugs/Features/PRs * [Issue #816](https://github.com/derailed/k9s/issues/816) * [Issue #813](https://github.com/derailed/k9s/issues/813) * [Issue #812](https://github.com/derailed/k9s/issues/812) * [Issue #810](https://github.com/derailed/k9s/issues/810) * [Issue #807](https://github.com/derailed/k9s/issues/807) * [Issue #806](https://github.com/derailed/k9s/issues/806) * [Issue #805](https://github.com/derailed/k9s/issues/805) * [Issue #800](https://github.com/derailed/k9s/issues/800) * [Issue #799](https://github.com/derailed/k9s/issues/799) * [Issue #709](https://github.com/derailed/k9s/issues/709) Crossing fingers and toes ;) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.4.md ================================================ # Release v0.21.4 ## 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 is as ever very much noticed and appreciated! 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! The aftermath... ## Resolved Bugs/Features/PRs * [Issue #819](https://github.com/derailed/k9s/issues/819) * [Issue #818](https://github.com/derailed/k9s/issues/818) * [Issue #797](https://github.com/derailed/k9s/issues/797) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.5.md ================================================ # Release v0.21.5 ## 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! 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) --- ## First A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Drew](https://github.com/ScubaDrew) * [Vladimir Rybas](https://github.com/vrybas) Contrarily to popular belief, OSS is not free! We've now reached 8k stars and 270k downloads! As you all know, this project is not pimped out by a big company with deep pockets or a large team. This project is complex and does demand a lot of my time. So if k9s is useful to you and part of your daily lifecycle. Please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us! Don't let OSS by individual contributors become an oxymoron... ## New Skins On The Block! In this drop, big thanks are in effect for [Dan Mikita](https://github.com/danmikita) for contributing a new K9s [solarized theme](https://github.com/derailed/k9s/tree/master/skins)! Also we've added a new skin configuration for table's cursor namely `cursorFgColor` and `cursorBgColor`: ```yaml # skin.yml ... views: table: fgColor: *foreground bgColor: *background cursorFgColor: *foreground cursorBgColor: *current_line header: fgColor: white bgColor: *background sorterColor: *cyan ... ``` ## Resolved Bugs/Features/PRs * [Issue #826](https://github.com/derailed/k9s/issues/826) * [Issue #824](https://github.com/derailed/k9s/issues/824) * [Issue #823](https://github.com/derailed/k9s/issues/823) * [Issue #821](https://github.com/derailed/k9s/issues/821) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.6.md ================================================ # Release v0.21.6 ## 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! 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) --- ## New Skins On The Block. Part Duh! In this drop, we've added a new skin configuration for table's cursor namely `cursorFgColor` and `cursorBgColor` as well as the ability to skin your dialogs: ```yaml # skin.yml k9s: ... # Note: You can now skin your dialogs. dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground ... views: table: fgColor: *foreground bgColor: *background # Note! new tags cursorFgColor: *foreground cursorBgColor: *current_line header: fgColor: white bgColor: *background sorterColor: *cyan ... ``` ## Resolved Bugs/Features/PRs * [Issue #795](https://github.com/derailed/k9s/issues/795) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.7.md ================================================ # Release v0.21.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! 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! ## Resolved Bugs/Features/PRs * [Issue #281](https://github.com/derailed/k9s/issues/281) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.8.md ================================================ # Release v0.21.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! 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! ## ♫ The Sound Behind The Release ♭ I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure while viewing this release notes right? [Strange Ritual - David Byrne](https://www.youtube.com/watch?v=gsramZ3sOjI) ;) ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Jean-Luc Geering](https://github.com/jlgeering) * [Takafumi Ikeda](https://github.com/ikeike443) Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you and part of your daily lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## Resolved Issues/Features * [Issue #871](https://github.com/derailed/k9s/issues/871) K9s memory leak when shell that launched k9s is terminated. * [Issue #857](https://github.com/derailed/k9s/issues/857) Working in readonly mode. * [Issue #855](https://github.com/derailed/k9s/issues/855) Some mouse support. * [Issue #849](https://github.com/derailed/k9s/issues/849) Xray highlight color. * [Issue #845](https://github.com/derailed/k9s/issues/845) CronJob trigger checks wrong permission. * [Issue #837](https://github.com/derailed/k9s/issues/837) Hang after running plugin. ## Resolved PRs * [PR #866](https://github.com/derailed/k9s/pull/866) Go 1.15 support convert int to string failure. Thank you [Trung](https://github.com/runlevel5)! * [PR #864](https://github.com/derailed/k9s/pull/864) Add ppc64le target. Thank you once again [Trung](https://github.com/runlevel5)! * [PR #863](https://github.com/derailed/k9s/pull/863) Update images in Dockerfile. Big thanks to [Peter Sutter](https://github.com/petersutter)! * [PR #841](https://github.com/derailed/k9s/pull/841) Fix a type in bug report template. Thanks to [Jinsu Park](https://github.com/umi0410)! * [PR #834](https://github.com/derailed/k9s/pull/834) Add Chocolatey installation. Thanks to [Romain](https://github.com/romch007)! * [PR #828](https://github.com/derailed/k9s/pull/828) Add solarized dark skin. Big Thanks to [Dan Mikita](https://github.com/danmikita)! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.21.9.md ================================================ # Release v0.21.9 ## 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! 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! ## ♫ The Sound Behind The Release ♭ I figured, why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure while viewing this release notes right? [Strange Ritual - David Byrne](https://www.youtube.com/watch?v=gsramZ3sOjI) ;) ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Jean-Luc Geering](https://github.com/jlgeering) * [Takafumi Ikeda](https://github.com/ikeike443) Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you and part of your daily lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## Resolved Issues/Features * [Issue #871](https://github.com/derailed/k9s/issues/871) K9s memory leak when shell that launched k9s is terminated (With feeling!) * [Issue #849](https://github.com/derailed/k9s/issues/849) Xray highlight color (with feeling!) ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.22.0.md ================================================ # Release v0.22.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are as ever very much noted and appreciated! 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! ## A Word From Our Sponsors... First off, I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Martin Kemp](https://github.com/MartiUK) Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a large team. K9s is complex and does demand a lot of my time. So if this tool is useful to you and part of your daily lifecycle, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## I Should've known better Seems like I've broken the golden rule ie never add a feature without providing an option to turn it off ;( It looks like enable mouse support for K9s had unexpected side effects. So in this drop, we're introducing a new configuration aka `enableMouse` that defaults to `false`. You can opt-in mouse support, by enabling it in the K9s config file. That said when mouse support is enabled, you can still use terminal selection using either `Shift/Option` for Windows/Mac. ```yaml # $HOME/.k9s/config.yml k9s: refreshRate: 2 enableMouse: true # Defaults to false if not set headless: false ... ``` ## Resolved Issues/Features * [Issue #874](https://github.com/derailed/k9s/issues/874) Latest version broke selecting text by mouse ## Resolved PRs * [PR #877](https://github.com/derailed/k9s/pull/877) Change character used for X in RBAC view. Thank you! [Torjus](https://github.com/torjue) * [PR #876](https://github.com/derailed/k9s/pull/876) Migrate to new sortorder import path. Big thanks to [fbbommel](https://github.com/fvbommel) * [PR #873](https://github.com/derailed/k9s/pull/873) Fix default logger config, same as README. Thank you! [darklore](https://github.com/darklore) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.22.1.md ================================================ # Release v0.22.1 ## 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! 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! ## Resolved Issues/Features * [Issue #882](https://github.com/derailed/k9s/issues/882) After filtering objects cannot enter them anymore * [Issue #881](https://github.com/derailed/k9s/issues/881) CPU limit percentage in pod view counts containers without limits * [Issue #880](https://github.com/derailed/k9s/issues/880) filtering/search doesn't take all columns into account anymore ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.0.md ================================================ # Release v0.23.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are as ever very much noted and appreciated! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ I figured why not share one of the tunes I was spinning when powering thru teh bugs? Might as well share the pain/pleasure while viewing these release notes! * [On An Island - David Gilmour With Crosby&Nash](https://www.youtube.com/watch?v=kEa__0wtIRo) * [Cause We've Ended As Lovers - Jeff Beck](https://www.youtube.com/watch?v=VC02wGj5gPw) ## Our Release Heroes Please join me in recognizing and applauding this drop contributors that went the extra mile to make sure K9s is better and more useful for all of us!! Big ATTA BOY/GIRL! in full effect this week to the good folks below for their efforts and contributions to K9s!! * [Michael Albers](https://github.com/michaeljohnalbers) * [Wi1dcard](https://github.com/wi1dcard) * [Saskia Keil](https://github.com/SaskiaKeil) * [Tomasz Lipinski](https://github.com/tlipinski) * [Antoine Méausoone](https://github.com/Ameausoone) * [Emeric Martineau](https://github.com/emeric-martineau) * [Eldad Assis](https://github.com/eldada) * [David Arnold](https://github.com/blaggacao) * [Peter Parente](https://github.com/parente) ## A Word From Our Sponsors... First off I would like to send a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [William Alexander](https://github.com/carpetfuz) * [Jiri Valnoha](https://github.com/waldauf) * [Pavel Tumik](https://github.com/sagor999) * [Bart Plasmeijer](https://github.com/bplasmeijer) * [Matt Welke](https://github.com/mattwelke) * [Stefan Mikolajczyk](https://github.com/stefanmiko) Contrarily to popular belief, OSS is not free! We've now reached ~9k stars and 300k downloads! As you all know, this project is not pimped out by a big company with deep pockets and a big dev team. K9s is complex and does demand lots of my time. So if this tool is useful to you and your organization and part of your daily Kubernetes flow, please contribute! Your contribution whether financial, PRs, issues or shout-outs on social/blogs are crucial to keep K9s growing and powerful for all of us. Don't let OSS by individual contributors become an oxymoron! ## Describe/YAML goes FullMonty!! We've added a new option to enable full screen while describing or viewing a resource YAML. Similarly to the full screen toggle option in the log view, pressing `f` will now toggle full-screen for both YAML and Describe views. Additionally, the YAML and Describe view are now reactive! YAML/Describe views will now watch for changes to the underlying resource manifests. I'll admit this was a feature I was missing, but decided to punt as it required a bit of re-org to make it happen correctly. So BIG thanks to [Fabian-K](https://github.com/Fabian-K) for entering this issue and for the boost!! Not cool enough for Ya? the YAML view now also affords for getting ride of those pesky `managedFields` while viewing a resource. Use the `m` key to toggle visibility on the managedFields. ## Best Effort... Not! In this drop, we've added 2 new columns namely `CPU/R:L` and `MEM/R:L`. These represents the current request:limit specified on containers. They are available in node, pod and container views. While in Pod view, you will need to volunteer them and use the `Go Wide` option `Ctrl-W` to see the columns. These columns will be display by default for Node/Container views. In the node view, they tally the total amount of resources for all pods hosted a given node. If that's inadequate, you can also leverage K9s [Custom Column](https://github.com/derailed/k9s#resource-custom-columns) feature to volunteer them or not. ## Set Container Images You will have the ability to tweak your container images for experimentation, using the new SetImage binding aka `i`. This feature is available for un-managed pods, deployments, statefulsets and daemonsets. With a resource selected, pressing `i` will provision an edit dialog listing all init/container images. So you will have to ability to tweak the images and update your containers. Big Thanks to [Antoine Méausoone](https://github.com/Ameausoone) for making this feature available to all of us!! NOTE! This is a one shot commands applied directly against your cluster and won't survive a new resource deployment. ## Crumbs On...Crumbs Off, Caterpillar We've added a new configuration to turn off the crumbs via `crumbsLess` configuration option. You can also toggle the crumbs via the new key option `Ctrl-g`. You can enable/disable this option in your ~/.k9s/config.yml or via command line using `--crumbsless` CLI option. ```yaml k9s: refreshRate: 2 headless: false crumbsless: false readOnly: true ... ``` ## BANG FILTERS! Some folks have voiced the desire to use inverse filters to refine content while in resource table views. Appending a `!` to your filter will now enable an inverse filtering operation For example, in order to see all pods that do not contain `fred` in their name, you can now use `/!fred` as your filtering command. If you dig this implementation, please make sure to give a big thank you to [Michael Albers](https://github.com/michaeljohnalbers) for the swift implementation! ## New Conf On the Block... In this release, we've made some changes to the retry policies when things fail on your cluster and the api-server is suffering from an hearing impediment. The current policy was to check for connection issues every 15secs and retry 15 times before exiting K9s. This rules were not configurable and could yield for overtaxing the api-server. So we've implemented exponential back-off so that K9s can attempt to remediate or bail out of the session if not. To this end, there is a new config option namely `maxConnRetry` to will be added to your K9s config to set the retry policy. The default is currently set to 5 retries. NOTE: This is likely an ongoing story and more will come based on your feedback! Sample K9s configuration ```yaml k9s: refreshRate: 2 # Set the maximum attempt to reconnect with the api-server in case of failures. maxConnRetry: 5 ... ``` ## 🏁 Start Your Engines... As you can see, this is a pretty big drop and likely we've created some new issues in the process 🙀 Please make sure to file issues/PRs if things are not working as expected so we can improve on these features. 👻 Happy Halloween To All!! (as if 2020 is not scary enough 🙈) Thank you all for your being fans and supporting K9s!! --- ## Resolved Issues/Features * [Issue #906](https://github.com/derailed/k9s/issues/906) Print resources in pod view * [Issue #903](https://github.com/derailed/k9s/issues/903) Slow down reconnection rate on auth failures * [Issue #901](https://github.com/derailed/k9s/issues/901) Logs page for any pod/container shows Waiting for logs... * [Issue #900](https://github.com/derailed/k9s/issues/900) Support sort by pending status * [Issue #895](https://github.com/derailed/k9s/issues/895) Wrong highlight position when filtering logs * [Issue #892](https://github.com/derailed/k9s/issues/892) tacit kustomize & kpt support * [Issue #889](https://github.com/derailed/k9s/issues/889) Disable read only config via command line flag * [Issue #887](https://github.com/derailed/k9s/issues/887) Ability to call out a separate program to parse/filter logs * [Issue #886](https://github.com/derailed/k9s/issues/886) Full screen mode or remove borders in YAML view for easy copy/paste * [Issue #884](https://github.com/derailed/k9s/issues/884) Refresh for describe & yaml view * [Issue #883](https://github.com/derailed/k9s/issues/883) View logs quickly scrolls through entire logs when initially loading * [Issue #875](https://github.com/derailed/k9s/issues/875) Lazy filter * [Issue #848](https://github.com/derailed/k9s/issues/848) Support an inverse operator on filtered search * [Issue #820](https://github.com/derailed/k9s/issues/820) Log file spammed despite K9s not running * [Issue #794](https://github.com/derailed/k9s/issues/794) Events view ## Resolved PRs * [PR #909](https://github.com/derailed/k9s/pull/909) Add support for inverse filtering * [PR #908](https://github.com/derailed/k9s/pull/908) Remove trailing delta from the scale dialog when replicas are in flux * [PR #907](https://github.com/derailed/k9s/pull/907) Improve docs on sinceSeconds logger option * [PR #904](https://github.com/derailed/k9s/pull/904) PVC `UsedBy` list irrelevant statefulsets * [PR #898](https://github.com/derailed/k9s/pull/898) Use config.CallTimeout in APIClient * [PR #897](https://github.com/derailed/k9s/pull/897) Use DefaultColorer for aliases rendering * [PR #896](https://github.com/derailed/k9s/pull/896) Allow remove crumbs * [PR #894](https://github.com/derailed/k9s/pull/894) Execute plugins and pass context * [PR #891](https://github.com/derailed/k9s/pull/891) Add command to get the latest stable kubectl version and support for KUBECTL_VERSION as Dockerfile ARG * [PR #847](https://github.com/derailed/k9s/pull/847) Add ability to set container images --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.1.md ================================================ # Release v0.23.1 ## 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! 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! --- ## Resolved Issues/Features * [Issue #918](https://github.com/derailed/k9s/issues/918) NPE setting image. Totally on me ;( ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.10.md ================================================ # Release v0.23.10 ## 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! 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! --- ## Resolved Issues/Features * [Issue #933](https://github.com/derailed/k9s/issues/933) Unable to cordon node starting in v0.23.8 * [Issue #932](https://github.com/derailed/k9s/issues/932) Won't start if api.github.com is inaccessible * [Issue #931](https://github.com/derailed/k9s/issues/931) Describe ingress not showing labels ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.2.md ================================================ # Release v0.23.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! 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) --- ### Write Mode K9s is writable by default, meaning you can interact with your cluster and make changes using one shot commands ie edit, delete, scale, etc... There `readOnly` config option that can be specified in the configuration or via a cli arg to override this behavior. In this drop, we're introducing a symmetrical command line arg aka `--write` that overrides a K9s session and make it writable tho the readOnly config option is set to true. ## Inverse Log Filtering In the last drop, we've introduces reverse filters to filter out resources from table views. Now you will be able to apply inverse filtering on your log views as well via `/!fred` --- ## Resolved Issues/Features * [Issue #906](https://github.com/derailed/k9s/issues/906) Print resources in pod view. With Feelings. Thanks Claudio! * [Issue #889](https://github.com/derailed/k9s/issues/889) Disable readOnly config * [Issue #564](https://github.com/derailed/k9s/issues/564) Invert filter mode on logs ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.3.md ================================================ # Release v0.23.3 ## 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! 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! Arg.. Must get m'o sleep!! --- ## Resolved Issues/Features * [Issue #889](https://github.com/derailed/k9s/issues/889) Disable readOnly config ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.4.md ================================================ # Release v0.23.4 ## 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! 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! --- ## Resolved Issues/Features * [Issue #920](https://github.com/derailed/k9s/issues/920) Timestamp stopped working * [Issue #663](https://github.com/derailed/k9s/issues/663) Perf issues in v0.23.X - Better?? ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.5.md ================================================ # Release v0.23.5 ## 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! 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! --- ## Resolved Issues/Features * [Issue #928](https://github.com/derailed/k9s/issues/928) Auto complete is too aggressive * [Issue #663](https://github.com/derailed/k9s/issues/663) Perf issues in v0.23.X - With feelings?? ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.6.md ================================================ # Release v0.23.6 ## 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! 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! Boyee! Having an awesome week here at the ranch! It feels like k9s v0.23.X is plagued with as many election wining scenarios as the CNN's magic screen ;) Time to lay off the pipe... but before I do, here is another drop! ### Use The Farce Luke! I've figured it might be a good time to come up with some notification when a new release is available. To this end, when a new k9w version has been released, you should see an indicator next to the k9s top screen `K9s Rev` section indicating an updated version is ready for mass consumption. ### Thank you! I'd like to extend a big thank you to all that have reported issues with the drops and for being patient! I get the rapid k9s rev might be an issue for some, but I do try my best to make sure pri-1 issues are resolved quickly in order to make k9s better for all of us. Thank you all for your understanding, kindness and support!! --- ## Resolved Issues/Features * [Issue #929](https://github.com/derailed/k9s/issues/929) Crash on startup with no metrics-server detected * [Issue #926](https://github.com/derailed/k9s/issues/926) JSON Do you have plans to apply ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.7.md ================================================ # Release v0.23.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! 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! --- ## Resolved Issues/Features * [Issue #930](https://github.com/derailed/k9s/issues/930) Version checker is not reporting a new release correctly ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.8.md ================================================ # Release v0.23.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! 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! --- ## Resolved Issues/Features * [Issue #663](https://github.com/derailed/k9s/issues/663) K9s is slow on large clusters (With feelings and crossing both fingers and toes) ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.23.9.md ================================================ # Release v0.23.9 ## 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! 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! --- ## Resolved Issues/Features * [Issue #930](https://github.com/derailed/k9s/issues/930) Version checker is not reporting a new release correctly (With feelings...) ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.0.md ================================================ # Release v0.24.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are as ever very much noted and appreciated! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Mother Protect - Niki & The Dove](https://www.youtube.com/watch?v=P5W2hjwBsFk) * [Dark Star - POLIÇA](https://www.youtube.com/watch?v=2pD3hJc-8xg) ## A Word From Our Sponsors... First and foremost, I would like to extend a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! Your sponsorships efforts are vital to keep this project alive and evolving. So please do give back! * [Lopeg](https://github.com/lopeg) * [Gerhard Lazu](https://github.com/gerhard) --- ## Resolved Issues/Features * [Issue #953](https://github.com/derailed/k9s/issues/953) Pdb with percentages show as "0". * [Issue #947](https://github.com/derailed/k9s/issues/947) Selection is applied for nonexistent items. * [Issue #944](https://github.com/derailed/k9s/issues/944) Can not launch ksniff. * [Issue #940](https://github.com/derailed/k9s/issues/940) Indeterminate search results when filtering with numbers. * [Issue #914](https://github.com/derailed/k9s/issues/914) Unable to edit resources with colliding singular names. ## Resolved PRs * [PR #941](https://github.com/derailed/k9s/pull/941) Add Monokai skin. My new favorite skin! Big Thanks to [Mike SigsWorth](https://github.com/mikesigs)!! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.1.md ================================================ # Release v0.24.1 ## 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! 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! ☡ IMPORTANT!! v0.24.0 is a bad drop!! Apparently while upgrading the dependencies in the v0.24.0, I've managed to hose the dialog's buttons focus hence producing the incorrect default button behavior. So please upgrade to v0.24.1 ASAP!! --- ## Resolved Issues/Features * [Issue #821](https://github.com/derailed/k9s/issues/821) Default color is no longer transparent. * [Issue #933](https://github.com/derailed/k9s/issues/933) Unable to cordon node. ## Resolved PRs * [PR #941](https://github.com/derailed/k9s/pull/941) Add Monokai skin. My new favorite skin! Big Thanks to [Mike SigsWorth](https://github.com/mikesigs)!! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.10.md ================================================ # Release v0.24.10 ## 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! 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! --- ## Resolved Issues * [Issue #1123](https://github.com/derailed/k9s/issues/1123) Cannot respond to keyboard strike after exit pod shell in windows 10 --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.11.md ================================================ # Release v0.24.11 ## 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! 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! > NOTE: Made a mistake with the last release binaries including a release tag. My bad as his caused a headache for the good folks managing the release upstream. Reverted the change on this drop! --- ## Resolved Issues * [Issue #1163](https://github.com/derailed/k9s/issues/1163) Color for autocomplete text * [Issue #1153](https://github.com/derailed/k9s/issues/1153) Crash when scaling a deployment with a custom view * [Issue #1151](https://github.com/derailed/k9s/issues/1151) k9s does not use current namespace of current context * [Issue #1140](https://github.com/derailed/k9s/issues/1140) Can no longer trigger cronjobs manually * [Issue #1137](https://github.com/derailed/k9s/issues/1137) Unreadable container name * [Issue #1132](https://github.com/derailed/k9s/issues/1132) Searching for regex not always working * [Issue #1131](https://github.com/derailed/k9s/issues/1131) Changed release filenames starting k9s v0.24.10 --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.12.md ================================================ # Release v0.24.12 ## 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! 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) ## Prompt GOT Styles! Added a new configuration for styling your command/search prompts. So you can now specify foreground/background and suggestion color to your heart content. For example: ```yaml # $HOME/.k9s/skin.yml k9s: body: fgColor: aqua bgColor: black logoColor: purple # Prompt styles prompt: fgColor: blue bgColor: black suggestColor: orange ... ``` --- ## Resolved Issues * [Issue #1169](https://github.com/derailed/k9s/issues/1169) Scaling last deployment errors out * [Issue #1167](https://github.com/derailed/k9s/issues/1167) Cronjob trigger busted * [Issue #1163](https://github.com/derailed/k9s/issues/1163) Color for autocomplete text --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.13.md ================================================ # Release v0.24.13 ## 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! 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! ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and pay it forward! * [Stephan Skydan](https://github.com/sskydan) * [Azar](https://github.com/azarudeena) * [Tim Orling](https://github.com/moto-timo) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Thank you!! --- ## Resolved Issues * [Issue #1182](https://github.com/derailed/k9s/issues/1169) Cronjob suspend does not work 0.24.12 * [Issue #1167](https://github.com/derailed/k9s/issues/1167) Cronjob trigger busted with Feelings! ## Resolved PRs * [PR #1141](https://github.com/derailed/k9s/pull/1141) Big Thanks to [Raul Cabello Martin](https://github.com/Raullllll) in making K9s better of all of us!! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.14.md ================================================ # Release v0.24.14 ## 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! 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! --- ## Resolved Issues * [Issue #1186](https://github.com/derailed/k9s/issues/1186) Viewing previous logs does not work * [Issue #1167](https://github.com/derailed/k9s/issues/1167) Cronjob trigger busted with feelings! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.15.md ================================================ # Release v0.24.15 ## 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! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Paradise Delay - Marteria, DJ Kose](https://www.youtube.com/watch?v=eM-xTN8ggOs) * [Fool For Your Stockings - ZZ Top - Sadly this one is a tribute to Dusty Hill ;(](https://www.youtube.com/watch?v=UExKTZ3veB8) --- ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Viacheslav Moskin](https://github.com/viacheslavmoskin) * [Thomas Peter Bernsten](https://github.com/tpberntsen) * [EMR-Bear](https://github.com/emrbear) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Thank you!! --- ## !!BREAKING CHANGE!!... We've moved! As of this drop, k9s home directory is now configurable via [XDG](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). Please see the specification depending on your platform of choice. You will now need to set or use the default for `$XDG_CONFIG_HOME` if not already present on your system. This is now the de facto replacement for`HOME/.k9s` as K9s will no longer honor this directory to load artifacts such as config, skins, views, etc... If you have existing customizations, you will need to move those over to your `$XDG_CONFIG_HOME/k9s` dir. This feature is still fresh and we could have totally missed a piece, so please proceed with caution and keep that issue tracker handy... Please join me in giving a Big Thank you! to [Arthur](https://github.com/pysen) for making this happen for us! --- ## Resolved Issues * [Issue #1209](https://github.com/derailed/k9s/issues/1209) K9s - Popeye run instructions * [Issue #1203](https://github.com/derailed/k9s/issues/1203) K9s does not remember last view I was in when switching contexts * [Issue #1181](https://github.com/derailed/k9s/issues/1181) Cannot list roles --- ## PRs * [PR #1213](https://github.com/derailed/k9s/pull/1213) Big Thanks to [Takumasa Sakao](https://github.com/sachaos)! * [PR #1205](https://github.com/derailed/k9s/pull/1205) Great catch from [David Alger](https://github.com/davidalger)! * [PR #1198](https://github.com/derailed/k9s/pull/1198) Once again [Takumasa Sakao](https://github.com/sachaos) to the rescue!! * [PR #1196](https://github.com/derailed/k9s/pull/1196) ATTA Boy! [Daniel Lee Harple](https://github.com/dlh) * [PR #1025](https://github.com/derailed/k9s/pull/1025) Big Thanks to [Arthur](https://github.com/pysen) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.2.md ================================================ # Release v0.24.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! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) ## ♫ Sounds Behind The Release ♭ * [ZZ Top - My Head's in Mississippi](https://www.youtube.com/watch?v=Gp2PosHepzg) ## A Word From Our Sponsors... I would like to extend a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Tim Orling](https://github.com/moto-timo) * [Jiri Valnoha](https://github.com/waldauf) * [Osx2000](https://github.com/osx2000) ## Our Release Heroes Major ATTA BOY/GIRL! in full effect this week to the good folks below for their efforts and contributions in making sure K9s is better for all of us! * [Ainslie Hsu](https://github.com/ainslie-hsu) * [Lucas Teligioridis](https://github.com/lucasteligioridis) * [Gergely Tankovics](https://github.com/gtankovics) * [Michal Kuratczyk](https://github.com/mkuratczyk) * [Simon Caron](https://github.com/simoncaron) ## She Can't Take Much More Capt'n!! ### Background Thanks to all of you for supporting K9s and being avid fans. I am truly humbled and amazed by your continued kindness and support!! As we're nearing K9s second anniversary, the project has reached over 10k stars and 384k downloads! That said, while these numbers sound stunning, there is another number on this project that is not so and that is number of sponsors 😿. As I understand it, there are a several organizations leveraging K9s productivity to better their bottom line, without much care for ours... As you all know, K9s is a complex tool in a continually evolving space and we find ourselves spending a lot of our free time, thinking, experimenting and supporting K9s to continually improve the offering. As it stands, there is currently a very small fraction of you that actively sponsor this project either financially or by filing issues/PRs while the rest are benefiting from these efforts. This just does not sound like a fair deal and if we were in the music business it would be a total outrage! ### There Are Some That Call Me... Alpha! To this end, I'd like to introduce a new member of the K9s pack, the main dog, aka `k9sAlpha`. This is going to be a licensed version of K9s. The current plan is to offer a tiered license scheme starting at `$10/month` for a license. K9s𝞪 will provide fixes, enhancements, further integrations and a bunch of new features that have been sitting in the back burner... ### So what does this entail? 1. The current k9s branch will be in feature freeze 1. K9s𝞪 users will need to purchase a license from our store 1. Active sponsors get a K9s𝞪 license 1. Documentation, binaries, issue trackers, will be provisioned under a new K9s𝞪 site Given any license schemes are meant to be hacked/broken, we're not going to over complicate things with calling out to license servers and such to ensure the keys are legit. The current plan is to email out your license keys and trusting our `Gentlemen Agreement` that you will not share or distribute your keys to other folks. In the current economic climate, if you can't afford a K9s𝞪 license, we will provide you one on a case by case basis. The process should be simple: 1. Acquire a license 1. Get a key via email 1. Store your key somewhere on disk 1. Download the K9s𝞪 binary 1. Administer your Kubernetes clusters with K9s𝞪 1. Rinse and repeat when your license expires ### K9s𝞪 Needs You! To this end, I'd like to enlist a few of you to help me validate license keys, K9s𝞪 store and site to ensure the flow well... flows! If you are so inclined, please reach out for your `shoephones` and send me an email with why you want to participate. Folks with K9s chops in multi clusters env would be preferred. It should not take too much of your time to ensure all is cool, but want to make sure I have at least another 5 pairs of eyes to help out with the K9s𝞪 drop. My hope is to get an initial K9s𝞪 revision dropped before Santa comes around... ### Pipe In! By all means, this is a democracy and not a dictatorship! So... if you have better/other ideas or concerns please pipe in! Open an issue on the repo so we can track, discuss, opiniate and figure out the best course of action that will be fair to both K9s maintainers and users alike. --- ## Resolved Issues/Features * [Issue #972](https://github.com/derailed/k9s/issues/972) Default color is no longer transparent. * [Issue #933](https://github.com/derailed/k9s/issues/933) Unable to cordon node. ## Resolved PRs * [PR #982](https://github.com/derailed/k9s/pull/982) Fix typo * [PR #976](https://github.com/derailed/k9s/pull/976) Add OneDark color theme * [PR #975](https://github.com/derailed/k9s/pull/982) Handling non json lines as raw with red color * [PR #968](https://github.com/dserailed/k9s/pull/968) Disable filtering on help screen ... and broke the build ;) * [PR #960](https://github.com/derailed/k9s/pull/960) Handle empty port list in PortForward view --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.3.md ================================================ # Release v0.24.3 ## 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! 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) ## A Word From Our Sponsors... I would like to extend a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! * [Levkov](https://github.com/levkov) * [Michael McCafferty](https://github.com/mikemcc) * [Stephan Skydan](https://github.com/sskydan) * [Terrac Skiens](https://github.com/bluefishforsale) * [Zafer Abo-Samra](https://github.com/Inbiten) * [Gabriel Martinez](https://github.com/GMartinez-Sisti) * [Pierre Lebrun](https://github.com/pierreyves-lebrun) * [Luc Suryo](https://github.com/my10c) * [Sean O'Brien](https://github.com/sob) ## Maintenance Release! o Update Kubernetes to v0.20.5 ## There are some that call me... Alpha! K9s is still and will remain an open source software. As such it is free and we will continue to maintain this repo! That said in order to support our efforts, we've recently launched [K9sAlpha](https://k9salpha.io) which is a freemium version of K9s. K9sAlpha unlocks additional features and enhancement. If you would like to support us, you can either join our github sponsors or purchase a K9sAlpha license. If you are an active member of our github sponsorship program, you are eligible for a free K9sAlpha license. Please reach out for your shoe-phone and contact us for your personalized license key. --- ## Resolved Issues * [Issue #1038](https://github.com/derailed/k9s/issues/1038) Release Cronjob API * [Issue #1035](https://github.com/derailed/k9s/issues/1035) Update Ingress API Group * [Issue #1028](https://github.com/derailed/k9s/issues/1028) Go compile * [Issue #1024](https://github.com/derailed/k9s/issues/1024) Add Pod Readiness/Nominated cols * [Issue #1013](https://github.com/derailed/k9s/issues/1013) Panic string negative repeat count * [Issue #1005](https://github.com/derailed/k9s/issues/1005) No x86_64 binaries * [Issue #735](https://github.com/derailed/k9s/issues/735) Shell into windows containers ## Resolved PRs * [PR #1022](https://github.com/derailed/k9s/pull/1022) Update release * [PR #1012](https://github.com/derailed/k9s/pull/1012) Fix typo for cluster based skins * [PR #1009](https://github.com/derailed/k9s/pull/1009) Add webi installer info * [PR #1004](https://github.com/derailed/k9s/pull/1004) Correction CronJob ApiVersion * [PR #1026](https://github.com/derailed/k9s/pull/1026) Add option to hide logo * [PR #997](https://github.com/derailed/k9s/pull/997) Shell into windows containers --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.4.md ================================================ # Release v0.24.4 ## 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! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) ## ♫ Sounds Behind The Release ♭ * [The Dream - Albert Collins/Robert Cray](https://www.youtube.com/watch?v=XLkjF4s2Ms0) ## A Word From Our Sponsors! I would like to extend a `Big Thank You` to the following generous K9s friends for joining our sponsorship program and supporting this project! Without your support this project will be another cadaver in GitHub's infamous `Dead Program Society`. Thank you!! * 😻 [Antoine Meaussone](https://github.com/Ameausoone) ## Maintenance Release! ## There are some that call me... Alpha! K9s is still and will remain an open source software. As such it is free and we will continue to maintain this repo! That said in order to support our efforts, we've recently launched [K9sAlpha](https://k9salpha.io) which is a freemium version of K9s. K9sAlpha unlocks additional features and enhancement. If you would like to support us, you can either join our github sponsors or purchase a K9sAlpha license. If you are an active member of our github sponsorship program, you are eligible for a free K9sAlpha license. Please reach out for your shoe-phone and contact us for your personalized license key. --- ## Resolved Issues * [Issue #1056](https://github.com/derailed/k9s/issues/1056) K9s hangs on edits * [Issue #1024](https://github.com/derailed/k9s/issues/1024) Add Pod Readiness/Nominated cols. With feelings! ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.5.md ================================================ # Release v0.24.5 ## 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! 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! ## There are some that call me... Alpha! K9s is still and will remain an open source software. As such it is free and we will continue to maintain this repo! That said in order to support our efforts, we've recently launched [K9sAlpha](https://k9salpha.io) which is a freemium version of K9s. K9sAlpha unlocks additional features and enhancements. If you would like to support us, you can either join our github sponsors or purchase a K9sAlpha license. If you are an active member of our github sponsorship program, you are eligible for a free K9sAlpha license. Please reach out for your shoe-phone and contact us for your personalized license key. --- ## Resolved Issues * [Issue #1063](https://github.com/derailed/k9s/issues/1063) Weird colors on windows (Don't do windows so please help verify!) * [Issue #1061](https://github.com/derailed/k9s/issues/1061) Container shell Windows (Don't do windows so please help verify!) * [Issue #1059](https://github.com/derailed/k9s/issues/1059) Monokai skin broken\ * [Issue #177](https://github.com/derailed/k9s/issues/177) Shell first character lost ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.6.md ================================================ # Release v0.24.6 ## 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! 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! ## Our Release Heroes Major ATTA BOY/GIRL! in full effect this week to the good folks below for their efforts and contributions in making sure K9s is better for all of us! * 🙏 [Arash Outadi](https://github.com/arashout) ## There are some that call me... Alpha! K9s is still and will remain an open source software. As such it is free and we will continue to maintain this repo! That said in order to support our efforts, we've recently launched [K9sAlpha](https://k9salpha.io) which is a freemium version of K9s. K9sAlpha unlocks additional features and enhancements. If you would like to support us, you can either join our github sponsors or purchase a K9sAlpha license. If you are an active member of our github sponsorship program, you are eligible for a free K9sAlpha license. Please reach out for your shoe-phone and contact us for your personalized license key. --- ## Resolved Issues * [Issue #1063](https://github.com/derailed/k9s/issues/1063) Weird colors on windows (Don't do windows so please help verify!) * [Issue #1061](https://github.com/derailed/k9s/issues/1061) Container shell Windows (Don't do windows so please help verify!) ## Resolved PRs * [PR #1062](https://github.com/derailed/k9s/pull/1062) Add auto-refresh toggle for yaml and describe views. Now defaults to no refresh! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.7.md ================================================ # Release v0.24.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! 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! ## Disturbance In The Farce.. Windows! Splendid! So I had to borrow my neighbors kids 20 pounds windows `gaming` laptop for this one ;( Recent K9s drops are looking less than optimal on windows due to dependencies changes. I was able to narrow it down to named colors are no longer being respected on Windows platforms. I'll keep digging on this but if you find yourself in the situation where K9s is looking less than optimal on Windows, for the short term please either use a custom skin with hex colors or change the stock skin to use hex color values vs named colors. Thank you! ## There are some that call me... Alpha! K9s is still and will remain an open source software. As such it is free and we will continue to maintain this repo! That said in order to support our efforts, we've recently launched [K9sAlpha](https://k9salpha.io) which is a freemium version of K9s. K9sAlpha unlocks additional features and enhancements. If you would like to support us, you can either join our github sponsors or purchase a K9sAlpha license. If you are an active member of our github sponsorship program, you are eligible for a free K9sAlpha license. Please reach out for your shoe-phone and contact us for your personalized license key. --- ## Resolved Issues * [Issue #1067](https://github.com/derailed/k9s/issues/1067) Increase HPA target column display * [Issue #1061](https://github.com/derailed/k9s/issues/1061) Container shell Windows (Don't do windows so please help verify!) * [Issue #1060](https://github.com/derailed/k9s/issues/1060) Exception when setting container image ## Resolved PRs --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.8.md ================================================ # Release v0.24.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! 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! ### NodeShell args In this drop, we've added additional configurations to the k9s node shell so you override the command and args on the node shell containers. ```yaml # $HOME/.k9s/config.yml ... minikube: view: active: pod featureGates: nodeShell: true shellPod: image: busybox:1.31 # New! command: ["/bin/sh", "-c"] # New! args: ["ls -al"] namespace: default limits: cpu: 100m memory: 100Mi ... ``` --- ## Resolved Issues * [Issue #1106](https://github.com/derailed/k9s/issues/1106) Remove padding while in full screen * [Issue #1104](https://github.com/derailed/k9s/issues/1104) Config args for shellPod * [Issue #1102](https://github.com/derailed/k9s/issues/1102) Explicitly announce no metrics are available * [Issue #1097](https://github.com/derailed/k9s/issues/1097) Delete resource dialog stopped working * [Issue #1093](https://github.com/derailed/k9s/issues/1094) Leading comma in command column * [Issue #1094](https://github.com/derailed/k9s/issues/1094) Screendumps empty on EKS * [Issue #1060](https://github.com/derailed/k9s/issues/1060) Exception when setting container image * [Issue #1081](https://github.com/derailed/k9s/issues/1081) Color issue on startup * [Issue #1078](https://github.com/derailed/k9s/issues/1078) Nord skin * [Issue #1075](https://github.com/derailed/k9s/issues/1075) Crash on mouse click out of main window * [Issue #1070](https://github.com/derailed/k9s/issues/1070) lose cursor on windows 10 * [Issue #1068](https://github.com/derailed/k9s/issues/1068) Build error 0.24.7 * [Issue #1063](https://github.com/derailed/k9s/issues/1063) Weird colour scheme on windows ## Resolved PRs * [PR #1101](https://github.com/derailed/k9s/pull/1101) propagate insecure-skip-tls-verify --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.24.9.md ================================================ # Release v0.24.9 ## 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! 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! --- ## Resolved Issues * [Issue #1111](https://github.com/derailed/k9s/issues/1111) -A switch doesn't work as advertised * [Issue #1109](https://github.com/derailed/k9s/issues/1109) 0.24.8 edit needs an extra keystroke to process. (Crossing fingers AND toes!!) * [Issue #1104](https://github.com/derailed/k9s/issues/1104) Configure args for shellPod ## Resolved PRs * [PR #1103](https://github.com/derailed/k9s/pull/1103) Dynamically load style for help. Big Thanks To [Louis Garman](https://github.com/leg100) --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.0.md ================================================ # Release v0.25.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are as ever very much noted and appreciated! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [High Fidelity - By Elvis Costello (Yup! he started is career as a computer operator. Can u tell??)](https://www.youtube.com/watch?v=DJS-2kacmpU) * [Walk With A Big Stick - Foster The People](https://www.youtube.com/watch?v=XMY1VMTyl8s) * [Beirut - Steps Ahead -- Love this band!! with the ever so talented and sadly late Michael Brecker ;(](https://www.youtube.com/watch?v=UExKTZ3veB8) --- ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Andrew Regan](https://github.com/poblish) * [Bruno Brito](https://github.com/brunohbrito) * [ScubaDrew](https://github.com/ScubaDrew) * [mike-code](https://github.com/mike-code) * [Andrew Aadland](https://github.com/DaemonDude23) * [Michael Albers](https://github.com/michaeljohnalbers) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## Personal Note... I had so many distractions this cycle so expect some `disturbance in the farce!` on this drop. To boot rat holed quiet a bit on improving speed. So I might have drop some stuff on the floor in the process... Please report back if that's the case and we will address shortly. Tx!! ## Port It Forward?? Ever been in a situation where you need to constantly port-forward on a given pod with multiple containers or exposing multiple ports? If so it might be cumbersome to have to type in the full container:port specification to activate a forward. If you fall in this use cases, you can now specify which container and port you would rather port-forward to by default. In this drop, we introduce a new annotation that you can use to specify and container/port to forward to by default. If set, the port-forward dialog will know to default to your settings. > NOTE: you can either use a container port name or number in your annotation! ```yaml # Pod fred apiVersion: v1 kind: Pod metadata: name: fred annotations: k9scli.io/auto-portforwards: zorg::5556 # => will default to container zorg port 5556 and local port 5566. No port-forward dialog will be shown. # Or... k9scli.io/portforward: bozo::6666:p1 # => launches the port-forward dialog selecting default port-forward on container bozo port named p1(8081) # mapping to local port 6666. ... spec: containers: - name: zorg ports: - name: p1 containerPort: 5556 ... - name: bozo ports: - name: p1 containerPort: 8081 - name: p2 containerPort: 5555 ... ``` The annotation value must specify a container to forward to as well as a local port and container port. The container port may be specified as either a port number or port name. If the local port is omitted then the local port will default to the container port number. Here are a few examples: 1. bozo::http - creates a pf on container `bozo` with port name http. If http specifies port number 8080 then the local port will be 8080 as well. 2. bozo::9090:http - creates a pf on container `bozo` mapping local port 9090->http(8080) 3. bozo::9090:8080 - creates a pf on container `bozo` mapping local port 9090->8080 --- ## Resolved Issues * [Issue #1299](https://github.com/derailed/k9s/issues/1299) After upgrade to 0.24.15 sorting shortcuts not working * [Issue #1298](https://github.com/derailed/k9s/issues/1298) Install K9s through go get reporting ambiguous import error * [Issue #1296](https://github.com/derailed/k9s/issues/1296) Crash when clicking between border of K9s and terminal pane * [Issue #1289](https://github.com/derailed/k9s/issues/1289) Homebrew calling bottle :unneeded is deprecated! There is no replacement * [Issue #1273](https://github.com/derailed/k9s/issues/1273) Not loading config from correct default location when XDG_CONFIG_HOME is unset * [Issue #1268](https://github.com/derailed/k9s/issues/1268) Age sorting wrong for years * [Issue #1258](https://github.com/derailed/k9s/issues/1258) Configurable or recent use based port-forward * [Issue #1257](https://github.com/derailed/k9s/issues/1257) Why is the latest chocolatey on 0.24.10 * [Issue #1243](https://github.com/derailed/k9s/issues/1243) Port forward fails in kind on windows 10 --- ## PRs * [PR #1300](https://github.com/derailed/k9s/pull/1300) move from io/ioutil to io/os packages * [PR #1287](https://github.com/derailed/k9s/pull/1287) Add missing styles to kiss * [PR #1286](https://github.com/derailed/k9s/pull/1286) Some small color modifications * [PR #1284](https://github.com/derailed/k9s/pull/1284) Fix a small typo which comes from cluster view info * [PR #1271](https://github.com/derailed/k9s/pull/1271) Removed cursor colors that are too light to read * [PR #1266](https://github.com/derailed/k9s/pull/1266) Skin to preserve your terminal session background color * [PR #1264](https://github.com/derailed/k9s/pull/1205) Adding note on popeye config * [PR #1261](https://github.com/derailed/k9s/pull/1261) Blurry logo * [PR #1250](https://github.com/derailed/k9s/pull/1250) Gruvbox dark skin * [PR #1249](https://github.com/derailed/k9s/pull/1249) Node shell pod tolerate all taints * [PR #1232](https://github.com/derailed/k9s/pull/1232) Add red skin for production env * [PR #1227](https://github.com/derailed/k9s/pull/1227) Add abbreviation ReadWriteOncePod PV access mode --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.1.md ================================================ # Release v0.25.1 ## 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! 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! Looks like we've broken a few little thingies... May need a few rapid fires to regain some sanity so please bare with us and thank you for your reports!! --- ## Resolved Issues * [Issue #1308](https://github.com/derailed/k9s/issues/1308) Command auto-complete suggestions disappear after screen refresh interval #1308 * [Issue #1307](https://github.com/derailed/k9s/issues/1307) Displayed Cluster name is always read from current-context * [Issue #1296](https://github.com/derailed/k9s/issues/1244) Scoobie-Doo was not a cow - NOTE: Switch to dialog to keep live context! --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.10.md ================================================ # Release v0.25.10 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Joshua Kapellen](https://github.com/joshuakapellen) * [Qdentity](https://github.com/qdentity) * [Maxim](https://github.com/bsod90) * [Sönke Schau](https://github.com/xgcssch) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## Maintenance Release! Doh! Sorry ;( with feelings... --- ## Resolved Issues * [Issue #1361](https://github.com/derailed/k9s/issues/1361) Pulses not displaying graphs * [Issue #1358](https://github.com/derailed/k9s/issues/1358) Namespace list is empty * [Issue #1357](https://github.com/derailed/k9s/issues/1357) Benchmarks doesn't work on windows * [Issue #1355](https://github.com/derailed/k9s/issues/1355) Trace log level does not exist * [Issue #1345](https://github.com/derailed/k9s/issues/1345) Access denied after context switch --- ## PRs * [PR #1363](https://github.com/derailed/k9s/pull/1363) Add rose-pine skin. [Sergio Soria](https://github.com/sasoria) * [PR #1356](https://github.com/derailed/k9s/pull/1356) Add flux trace shortcut to flux plugin. [Guillaume Berche](https://github.com/gberche-orange) * [PR #1321](https://github.com/derailed/k9s/pull/1321) Add customizable dump directory property. [Vlasov Artem](https://github.com/VlasovArtem) © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.11.md ================================================ # Release v0.25.11 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Joshua Kapellen](https://github.com/joshuakapellen) * [Qdentity](https://github.com/qdentity) * [Maxim](https://github.com/bsod90) * [Sönke Schau](https://github.com/xgcssch) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## Maintenance Release! Hoy! end of year suck... Feeling the burn ;( Apologize for the disruptions... --- ## Resolved Issues * [Issue #1374](https://github.com/derailed/k9s/issues/1374) --all-namespaces does not work v0.25.10 * [Issue #1376](https://github.com/derailed/k9s/issues/1376) Events not sorted correctly by dates * [Issue #1373](https://github.com/derailed/k9s/issues/1373) change namespace not possible © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.12.md ================================================ # Release v0.25.12 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Joshua Kapellen](https://github.com/joshuakapellen) * [Qdentity](https://github.com/qdentity) * [Maxim](https://github.com/bsod90) * [Sönke Schau](https://github.com/xgcssch) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## ♫ Sounds Behind The Release ♭ * [Border Patrol - Eek A Mouse](https://www.youtube.com/watch?v=pQVNzolpoII) * [All Mine - Portishead](https://www.youtube.com/watch?v=cuclNJiE8NY) * [Come on up to the house - Tom Waits](https://www.youtube.com/watch?v=9XVGAatyeNk) ## Maintenance Release! Hoy! end of year is... sucking! Feeling the burn ;( Apologies for the disruptions!! `You're either a pigeon or... the statue!` --- ## Resolved Issues * [Issue #1378](https://github.com/derailed/k9s/issues/1378) Regression: Namespace filters are no longer applied on startup * [Issue #1376](https://github.com/derailed/k9s/issues/1376) Events not sorted correctly by dates * [Issue #1375](https://github.com/derailed/k9s/issues/1375) Unable to show port forwards * [Issue #1374](https://github.com/derailed/k9s/issues/1374) --all-namespaces does not work v0.25.10 * [Issue #1373](https://github.com/derailed/k9s/issues/1373) change namespace not possible --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.13.md ================================================ # Release v0.25.13 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [uderik](https://github.com/uderik) * [Daimler](https://github.com/Daimler) wOOt!! Mercedes Benz sponsorship! How cool is that? So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## ♫ Sounds Behind The Release ♭ * [Gash Dem - Chuck Fenda](https://www.youtube.com/watch?v=Y4NSYW4wusI) ## Maintenance Release! --- ## Resolved Issues * [Issue #1382](https://github.com/derailed/k9s/issues/1382) Watcher failed for screendumps * [Issue #1381](https://github.com/derailed/k9s/issues/1381) --request-timeout affects logs streaming * [Issue #1380](https://github.com/derailed/k9s/issues/1380) :pulse returning error: expecting a TableRow but got *v1.Table * [Issue #1376](https://github.com/derailed/k9s/issues/1376) Events are not sorted correctly by dates - with feelings... * [Issue #1291](https://github.com/derailed/k9s/issues/1291) K9s do not show any error when is unable to get logs, just do not show anything. --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.14.md ================================================ # Release v0.25.14 ## 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! 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! Doh! Hot fix on the way... --- ## Resolved Issues * [Issue #1384](https://github.com/derailed/k9s/issues/1384) Leaving Logs View Causes Crash: "panic: send on closed channel" --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.15.md ================================================ # Release v0.25.15 ## 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! 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! Aye! Hot fix on the way... --- ## Resolved Issues * [Issue #1384](https://github.com/derailed/k9s/issues/1384) Leaving Logs View Causes Crash: "panic: send on closed channel" - with feelings! --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.16.md ================================================ # Release v0.25.16 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Sebastian Racs](https://github.com/sebracs) * [Timothy C. Arland](https://github.com/tcarland) * [Julie Ng](https://github.com/julie-ng) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## ♫ Sounds Behind The Release ♭ [Blue Christmas - Fats Domino](https://www.youtube.com/watch?v=7jeo09zAskc) [Mele Kalikimaka - Bing Crosby](https://www.youtube.com/watch?v=hEvGKUXW0iI) [Cause - Rodriguez -- Spreading The Holiday Cheer! 🤨](https://www.youtube.com/watch?v=oKFkc19T3Dk) --- ## 🎅🎄 !!Merry Christmas To All!! 🎄🎅 I hope you will take this time of the year to relax, re-source and spend quality time with your loved ones. I know it's been a `tad rocky` of recent ;( as I've gotten seriously slammed with work in the last few months... The fine folks here on this channel have been nothing but kind, patient and willing to help, this humbles me! I feel truly blessed to be affiliated with our great `k9sers` community! Next month, we'll celebrate our anniversary as we've started out in this venture back in Jan 2019 (Yikes!) so get crack'in and iron out those bow ties already!! Best wishes for great health, happiness and continued success for 2022 to you all!! -Fernand --- ## A Christmas Story... As of this drop, we've added a new feature to override the sort column and order for a given Kubernetes resource. This feature piggy backs of custom column views and add a new attribute namely `sortColumn`. For example say you'd like to set the default sort for pods to age descending vs name/namespace, you can now do the following in your `views.yml` file in the k9s config directory: NOTE: This file is live thus you can nav to your fav resource, change the column config and view the resource columns and sort changes... Woot!! ```yaml k9s: views: v1/endpoints: columns: - NAME - NAMESPACE - ENDPOINTS - AGE v1/pods: sortColumn: AGE:desc # => suffix [:asc|:desc] for ascending or descending order. v1/services: ... ``` --- ## Resolved Issues * [Issue #1398](https://github.com/derailed/k9s/issues/1398) Pod logs containing brackets not in k9s logs output * [Issue #1397](https://github.com/derailed/k9s/issues/1397) Regression: k9s no longer starts in current context namespace since v0.25.12 * [Issue #1358](https://github.com/derailed/k9s/issues/1358) Namespaces list is empty * [Issue #956](https://github.com/derailed/k9s/issues/956) Feature request : Default column sort (by resource view) --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.17.md ================================================ # Release v0.25.17 ## 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! 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 --- ## Resolved Issues * [Issue #1402](https://github.com/derailed/k9s/issues/1402) Sort functionality does not work properly on v0.25.16 * [Issue #1401](https://github.com/derailed/k9s/issues/1401) Nothing selected when last item deleted --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.18.md ================================================ # Release v0.25.18 ## 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! 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 --- ## Resolved Issues * [Issue #1402](https://github.com/derailed/k9s/issues/1402) Sort functionality does not work properly on v0.25.16. With Feelings! --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.19.md ================================================ # Release v0.25.19 ## 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 this repo!! 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 --- ## Resolved Issues * [Issue #1609](https://github.com/derailed/k9s/issues/1609) K9s fails to launch when active view does not exist * [Issue #1593](https://github.com/derailed/k9s/issues/1593) Selection of namespace is changed automatically * [Issue #1572](https://github.com/derailed/k9s/issues/1572) Wrong resource configuration being display after updating ingress * [Issue #1569](https://github.com/derailed/k9s/issues/1569) Slight wording error when port forward already existS! * [Issue #1565](https://github.com/derailed/k9s/issues/1565) Popeye stopped working ## Resolved PR * [PR #1601](https://github.com/derailed/k9s/pull/1601) Ensure correct text in prompt when suspending cronjob * [PR #1600](https://github.com/derailed/k9s/pull/1600) Fix typo in fastforwards annotation name * [PR #1566](https://github.com/derailed/k9s/pull/1566) Correct typo in skins * [PR #1555](https://github.com/derailed/k9s/pull/1555) Update benchmark command in readme * [PR #1553](https://github.com/derailed/k9s/pull/1553) Allow `all` deletion propagation policy * [PR #1539](https://github.com/derailed/k9s/pull/1539) Plugin to allow default chart values retrieval * [PR #1529](https://github.com/derailed/k9s/pull/1529) Update example k9s config file * [PR #1518](https://github.com/derailed/k9s/pull/1518) Add Helm values support * [PR #1493](https://github.com/derailed/k9s/pull/1493) Fix padding is not 0 in fullscreen * [PR #1422](https://github.com/derailed/k9s/pull/1422) Fix typo in README --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.2.md ================================================ # Release v0.25.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! 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! Looks like we've broken a few little thingies... May need a few rapid fires to regain some sanity so please bare with us and thank you for your reports!! --- ## Resolved Issues * [Issue #1311](https://github.com/derailed/k9s/issues/1311) Pressing '?' in logs view (no logs) crashes on nil dereference * [Issue #1310](https://github.com/derailed/k9s/issues/1310) PV/PVC accessMode getting exception * [Issue #1293](https://github.com/derailed/k9s/issues/1293) Broken rollouts for dp/sts/ds with multiple ports of the same number --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.20.md ================================================ # Release v0.25.20 ## 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 this repo!! 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 Hoy!! Cleaning up the kitchen countertops ;( Thank you all for piping in on the latest drop! --- ## Resolved Issues * [Issue #1620](https://github.com/derailed/k9s/issues/1620) popeye view shows duplicate pdb * [Issue #1616](https://github.com/derailed/k9s/issues/1616) Age in nodes view are n/a --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.21.md ================================================ # Release v0.25.21 ## 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 this repo!! 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 --- ## Resolved Issues * [Issue #1634](https://github.com/derailed/k9s/issues/1634) Namespace view all has the age field in strange format * [Issue #1633](https://github.com/derailed/k9s/issues/1633) Nodes sort by age has wrong order ## Resolved PR * [PR #1632](https://github.com/derailed/k9s/pull/1632) Fix delete dialog dropdown styling * [PR #1629](https://github.com/derailed/k9s/pull/1629) Fix reference to base image in dockerfile * [PR #1627](https://github.com/derailed/k9s/pull/1627) Fix TestToAge * [PR #1624](https://github.com/derailed/k9s/pull/1624) Change makefile version --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.3.md ================================================ # Release v0.25.3 ## 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! 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! Addressing broken windows builds ;( --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.4.md ================================================ # Release v0.25.4 ## 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! 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! --- ## Resolved Issues * [Issue #1319](https://github.com/derailed/k9s/issues/1319) Namespace filters are no longer applied on startup * [Issue #1317](https://github.com/derailed/k9s/issues/1317) port forwarding broke with multiple exposed ports * [Issue #1316](https://github.com/derailed/k9s/issues/1316) Configuration for macOS is using wrong path --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.5.md ================================================ # Release v0.25.5 ## 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! 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! --- ## Resolved Issues * [Issue #1327](https://github.com/derailed/k9s/issues/1327) Switching K8s resource changes view to all namespace * [Issue #1326](https://github.com/derailed/k9s/issues/1326) Port forwarding not possible because of "invalid container port" * [Issue #1325](https://github.com/derailed/k9s/issues/1325) Meaning of number in brackets after context name is unclear * [Issue #1324](https://github.com/derailed/k9s/issues/1324) Problem with Configuration for macOS is can't find configuration directory --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.6.md ================================================ # Release v0.25.6 ## 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! 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! And the bit goes on... --- ## Resolved Issues * [Issue #1333](https://github.com/derailed/k9s/issues/1333) Log level not showing in k9s * [Issue #1253](https://github.com/derailed/k9s/issues/1253) Namespace filter automatically applied after viewing a deployment --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.7.md ================================================ # Release v0.25.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! 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! Happy (`Wild`) Turkey Day Everyone!! --- ## Resolved Issues * [Issue #1341](https://github.com/derailed/k9s/issues/1341) Colored container logs are not displayed correctly. --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.8.md ================================================ # Release v0.25.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! 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! --- ## Resolved Issues * [Issue #1349](https://github.com/derailed/k9s/issues/1349) Support events.k8s.io Event v1 * [Issue #1345](https://github.com/derailed/k9s/issues/1345) Access denied after context switch * [Issue #1344](https://github.com/derailed/k9s/issues/1344) Use "Port forward",but "invalid container port" * [Issue #1342](https://github.com/derailed/k9s/issues/1342) Log screen refreshed every second --- © 2020 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.25.9.md ================================================ # Release v0.25.9 ## 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! 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) ### A Word From Our Sponsors... I want to recognize the following folks that have been kind enough to join our sponsorship program and opted to `pay it forward`! * [Joshua Kapellen](https://github.com/joshuakapellen) * [Qdentity](https://github.com/qdentity) * [Maxim](https://github.com/bsod90) * [Sönke Schau](https://github.com/xgcssch) So if you feel K9s is helping with your productivity while administering your Kubernetes clusters, please consider pitching in as it will go a long way in ensuring a thriving environment for this repo and our K9sers community at large. Also please take some time and give a huge shoot out to all the good folks below that have spent time plowing thru the code to help improve K9s for all of us! Thank you!! --- ## Maintenance Release! --- ## Resolved Issues * [Issue #1361](https://github.com/derailed/k9s/issues/1361) Pulses not displaying graphs * [Issue #1358](https://github.com/derailed/k9s/issues/1358) Namespace list is empty * [Issue #1357](https://github.com/derailed/k9s/issues/1357) Benchmarks doesn't work on windows * [Issue #1355](https://github.com/derailed/k9s/issues/1355) Trace log level does not exist * [Issue #1345](https://github.com/derailed/k9s/issues/1345) Access denied after context switch --- ## PRs * [PR #1363](https://github.com/derailed/k9s/pull/1363) Add rose-pine skin. [Sergio Soria](https://github.com/sasoria) * [PR #1356](https://github.com/derailed/k9s/pull/1356) Add flux trace shortcut to flux plugin. [Guillaume Berche](https://github.com/gberche-orange) * [PR #1321](https://github.com/derailed/k9s/pull/1321) Add customizable dump directory property. [Vlasov Artem](https://github.com/VlasovArtem) © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.0.md ================================================ # Release v0.26.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are as ever very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! If you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Sugar Water - Cibo Matto](https://www.youtube.com/watch?v=EN9auBn6Jys) * [Midnight To Stevens - The Clash](https://www.youtube.com/watch?v=9suQJthS6to) * [Cool & Proper - Natty Nation](https://www.youtube.com/watch?v=9q337zn7bpI) --- ## Maintenance Release Please join me in giving a big THANK YOU and ATTA BOY!! to [Aleksei Romanenko](https://github.com/slimus) for allocating his personal time in helping out his fellow K9sers with issues, PRs and slack!! Also in the last drop, I'd updated k8s API's to the latest which caused some `disturbance in the farce!` and hosed AWS cluster connections in the same swop ;( Please see [Issue#119](https://github.com/derailed/k9s/issues/1619) for `a` resolve... I did not catch it early enough hence the release bump on this drop. My bad!! --- ## Resolved Issues * [Issue #1655](https://github.com/derailed/k9s/issues/1655) Text not appearing in context windows * [Issue #1654](https://github.com/derailed/k9s/issues/1654) K9s crash on m1 with index out of range [0] with length 0 * [Issue #1652](https://github.com/derailed/k9s/issues/1652) HPA with custom metrics has "Target%" column showing "unknown/unknown" * [Issue #1639](https://github.com/derailed/k9s/issues/1639) Helm releases view broken after interacting with 0.25.21 ## Resolved PR * [PR #1656](https://github.com/derailed/k9s/pull/156) Fix PF and RS dialog colors * [PR #163](https://github.com/derailed/k9s/pull/1636) Fix #1636: can't switch context with --kubeconfig flag --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.1.md ================================================ # Release v0.26.1 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ Oldies but goodies... * [Love In Vain - Rolling Stones](https://www.youtube.com/watch?v=ryRDcE2sB2A) * [Old Love - Eric Clapton](https://www.youtube.com/watch?v=qv63M6XXgGE) * [Warm Weather - Pieces Of A Dream](https://www.youtube.com/watch?v=hYm6fR1Zjm4) * [Funerailles d'antan - George Brassens](https://www.youtube.com/watch?v=-mOalHzOCCM) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you! * [Jacky Nguyen](https://github.com/nktpro) * [Aleksei Romanenko](https://github.com/slimus) * [Aljoscha Pörtner](https://github.com/AljoschaP) * [Mario Bris](https://github.com/mariobris) * [Thorsten Schifferdecker](https://github.com/curx) * [Lungdart](https://github.com/lungdart) * [Azar](https://github.com/azarudeena) --- ## Maintenance Release --- ## Resolved Issues * [Issue #1684](https://github.com/derailed/k9s/issues/1684) Crash when viewing logs index out of range [2] with length 2 * [Issue #1680](https://github.com/derailed/k9s/issues/1680) Changing to pod kill grace period from 0 to 1 * [Issue #1661](https://github.com/derailed/k9s/issues/1661) ClusterRole with wrong privilege list display * [Issue #1677](https://github.com/derailed/k9s/issues/1677) UsedBy function on priorityclass * [Issue #1657](https://github.com/derailed/k9s/issues/1657) Cannot delete port forwarding created inside k9s * [Issue #1420](https://github.com/derailed/k9s/issues/1420) Unable to delete port forward ## Resolved PR * [PR #1682](https://github.com/derailed/k9s/pull/1682) Fix: persistentvolumes not showing terminating status. * [PR #1672](https://github.com/derailed/k9s/pull/1672) Feat: allow to disable ctrl-c behavior * [PR #1666](https://github.com/derailed/k9s/pull/1666) Feat: show usedBy for priorityclasses * [PR #1668](https://github.com/derailed/k9s/pull/1668) Fix: PF delete with no container --- © 2021 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.2.md ================================================ # Release v0.26.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) --- ## Maintenance Release Doh! Looks like I've broken windows on this last drop ;( NOTE: I currently don't have access to a windows/m1 box. So if you do please report back and help us zoom in on the issues below... Thank you!! --- ## Resolved Issues (Wishfully...) * [Issue #1690](https://github.com/derailed/k9s/issues/1690) 0.26.1 stuck after exit from container shell, panels refreshed but arrow keys not works windows 10. * [Issue #1673](https://github.com/derailed/k9s/issues/1673) Screen goes blank after existing shell while running k9s on M1 --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.3.md ================================================ # Release v0.26.3 ## 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 --- ## Resolved Issues (Wishfully...) * [Issue #1690](https://github.com/derailed/k9s/issues/1690) 0.26.1 stuck after exit from container shell, panels refreshed but arrow keys not works windows 10. --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.4.md ================================================ # Release v0.26.4 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Love's Got Me High - Terrence Parker](https://www.youtube.com/watch?v=1KuLU6lpMT8) * [New money - Calvin Harris](https://www.youtube.com/watch?v=TUVw1PTO6Sc) * [Shrine - Jeff Beck](https://www.youtube.com/watch?v=-zBtluqp8l8) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Subshell](https://github.com/subshell) * [Dan Anglin](https://github.com/dananglin) * [Jacob Lorenzen](https://github.com/Jaxwood) * [Benjamin Herbert](https://github.com/BenjaminHerbert) * [Brandon G](https://github.com/gannicottb) * [Damyan Yordanov](https://github.com/damyan) * [Luiz Marques](https://github.com/luizfnunesmarques) * [Argonaut](https://github.com/argonautdev) * [Marcin Jasion](https://github.com/mjasion) --- ## Maintenance Release --- ## Resolved Issues * [Issue #1742](https://github.com/derailed/k9s/issues/1742) Edit and shell not working on Arch linux * [Issue #1724](https://github.com/derailed/k9s/issues/1724) redundant conversion exists * [Issue #1714](https://github.com/derailed/k9s/issues/1714) Cronjob: don't highlight changes in `last schedule` * [Issue #1711](https://github.com/derailed/k9s/issues/1711) Unable to see CRDs * [Issue #1700](https://github.com/derailed/k9s/issues/1700) Ctrl+D removes a pod instantly --- ## Contributed PRs (Thank you!!) * [PR #1759](https://github.com/derailed/k9s/pull/1759) Fix typo in cronjob * [PR #1755](https://github.com/derailed/k9s/pull/1755) List all helm releases by default * [PR #1753](https://github.com/derailed/k9s/pull/1753) Fix flux plugin to properly handle trace * [PR #1744](https://github.com/derailed/k9s/pull/1744) README: correct (auto-)port-forwards annotations * [PR #1739](https://github.com/derailed/k9s/pull/1739) Fix GracePeriodSeconds * [PR #1725](https://github.com/derailed/k9s/pull/1725) fix redundant type conversion code * [PR #1721](https://github.com/derailed/k9s/pull/1721) Replace keyboard package * [PR #1711](https://github.com/derailed/k9s/pull/1711) Fix get CustomResourceDefinition * [PR #1709](https://github.com/derailed/k9s/pull/1709) Plugin for opening a root shell to k3d container --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.5.md ================================================ # Release v0.26.5 ## 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 So it looks like replacing the clipboard package was indeed a dud ;( While I was not keen on either running with cgo or taking on external dependencies, after further investigation it looks like the clipboard + wsl issue in the old package was [resolved](https://github.com/atotto/clipboard/pull/42). I don't run WSL so I can't test it but if that's not the case please reopen and we will figure out another solution. For the time being, I've opted for the reversal. Thank you!! --- ## Resolved Issues * [Issue #1742](https://github.com/derailed/k9s/issues/1770) copy to clipboard throw panic error * [Issue #1768](https://github.com/derailed/k9s/issues/1768) build fails due to new clipboard package --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.6.md ================================================ # Release v0.26.6 ## 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 --- ## Resolved Issues * [Issue #1773](https://github.com/derailed/k9s/issues/1773) CustomResourceDefinition does not display ## Contributed PRs (Thank you!!) * [PR #1777](https://github.com/derailed/k9s/pull/1777) Fix directory path when viewing screendump * [PR #1776](https://github.com/derailed/k9s/pull/1776) Add a closing tag when showing timestamp in log view * [PR #1775](https://github.com/derailed/k9s/pull/1775) Log toggles: add a space after "on" in logs view * [PR #1772](https://github.com/derailed/k9s/pull/1772) docs: update homebrew installation note --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.26.7.md ================================================ # Release v0.26.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 --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Microsoft](https://github.com/microsoft) * [Audun V. Nes](https://github.com/avnes) * [Marco Aurelio Caldas Miranda](https://github.com/macmiranda) * [Jon Waltom](https://github.com/jon-walton) * [Eckl, Máté](https://github.com/ecklm) * [Iguanasoft](https://github.com/iguanasoft) --- ## Resolved Issues * [Issue #1805](https://github.com/derailed/k9s/issues/1805) CronJobs: allow sorting by LAST_SCHEDULE ## Contributed PRs (Thank you!!) * [PR #1804](https://github.com/derailed/k9s/pull/1804) Allow multiple port forwards * [PR #1797](https://github.com/derailed/k9s/pull/1797) README - use go install * [PR #1793](https://github.com/derailed/k9s/pull/1793) Update CronJob version to v1 --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.27.0.md ================================================ # Release v0.27.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## Maintenance Release --- ## ♫ Sounds Behind The Release ♭ I'd like to dedicate this release to `Jeff Beck` one of my all time favorite musicians that sadly passed away this last week ;( * [The Pump - Jeff Beck](https://www.youtube.com/watch?v=xiDYrQp9wFQ) * [Brush With The Blues - Jeff Beck](https://www.youtube.com/watch?v=O640IGLjnfs) * [Cause We've Ended As Lovers - Jeff Beck](https://www.youtube.com/watch?v=VC02wGj5gPw) * [Where Were You - Jeff Beck](https://www.youtube.com/watch?v=howz7gVecjE) * [Rockabilly Set At Ronnie Scott](https://www.youtube.com/watch?v=_3aIEzXHBWw) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Vibin reddy](https://github.com/vibin) * [Maciek Albin](https://github.com/mckk) * [Dherraj Yennam](https://github.com/dyennam) * [Alan Ream](https://github.com/aream2006) * [djheap](https://github.com/djheap) * [MaterializeInc](https://github.com/MaterializeInc) * [Jeff Evans](https://github.com/jeff303) --- ## Resolved Issues * [Issue #1917](https://github.com/derailed/k9s/issues/1917) Crash on open single ingress from list * [Issue #1906](https://github.com/derailed/k9s/issues/1680) k9s exits silently if screenDumpDir cannot be created * [Issue #1661](https://github.com/derailed/k9s/issues/1661) ClusterRole with wrong privilege list display * [Issue #1680](https://github.com/derailed/k9s/issues/1680) Change pod kill grace period for 0 to 1 ## Contributed PRs Please give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [PR #1910](https://github.com/derailed/k9s/pull/1910) Replace x86_64 to amd64 build * [PR #1877](https://github.com/derailed/k9s/pull/1877) Bug: portforward custom containers not showing * [PR #1874](https://github.com/derailed/k9s/pull/1874) Feat: Add noLatestRevCheck config option * [PR #1872](https://github.com/derailed/k9s/pull/1872) Docs: Add k8s client compatibility matrix * [PR #1871](https://github.com/derailed/k9s/pull/1871) Bug: update scanSA calls to account for blank service accounts * [PR #1866](https://github.com/derailed/k9s/pull/1866) Bug: Fix order of arguments for CanI function call * [PR #1859](https://github.com/derailed/k9s/pull/1859) FEAT: Add vim-like quit force option * [PR #1849](https://github.com/derailed/k9s/pull/1849) Bug: Fix build date for OSX * [PR #1847](https://github.com/derailed/k9s/pull/1847) FEAT: Add labels configuration for shell node pod * [PR #1840](https://github.com/derailed/k9s/pull/1840) FEAT: Add policy view to service accounts * [PR #1837](https://github.com/derailed/k9s/pull/1837) FEAT: Use default terminal colors for better readability * [PR #1830](https://github.com/derailed/k9s/pull/1830) FEAT: Plugin support for carvel kapp CR * [PR #1829](https://github.com/derailed/k9s/pull/1829) FEAT: flux.yml plugin new displays stderr messages --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.27.1.md ================================================ # Release v0.27.1 ## 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 --- ## Resolved Issues * [Issue #1943](https://github.com/derailed/k9s/issues/1943) k9s display is broken after switching to v0.27.0 * [Issue #1935](https://github.com/derailed/k9s/issues/1935) Active namespace is dropped after accessing forbidden resources * [Issue #1913](https://github.com/derailed/k9s/issues/1913) Exit edit mode deadlock * [Issue #1895](https://github.com/derailed/k9s/issues/1895) AWS workspace. K9s fails on startup with unknown userid error * [Issue #1842](https://github.com/derailed/k9s/issues/1842) Strange one - brew installed k9s --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.27.2.md ================================================ # Release v0.27.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) --- ## Maintenance Release With feelings... Broke brew installer ;( © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.27.3.md ================================================ # Release v0.27.3 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Bitches Brew - Miles Davis](https://www.youtube.com/watch?v=50fB5L1vmn8) * [Sordid Affair - Röyksopp](https://www.youtube.com/watch?v=ECL5zO6ImsA) * [Love Inc - Booka Shade](https://www.youtube.com/watch?v=sgLxTcok8kQ) * [Twisted - Kaz James,Nick Morgan](https://www.youtube.com/watch?v=oOsYJ-Co8Y4) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Astraea](https://github.com/s22) * [Arnaud Bienvenu](https://github.com/abienvenu) * [Eric Caleb](https://github.com/iamcaleberic) * [Sean Williams](https://github.com/SeanThomasWilliams) * [Federico Ragona](https://github.com/fedragon) > Sponsorship cancellations since the last release: `7` ;( --- ## Maintenance Release --- ## Resolved Issues * [Issue #1968](https://github.com/derailed/k9s/issues/1968) Some skins are missing the definitions for the help menu * [Issue #1967](https://github.com/derailed/k9s/issues/1967) Helm cve-2023-25165 * [Issue #1964](https://github.com/derailed/k9s/issues/1964) logger.sinceSeconds config setting inconsistent with README * [Issue #1955](https://github.com/derailed/k9s/issues/1955) K9s crashes with empty resources and/or verbs in RBAC * [Issue #1954](https://github.com/derailed/k9s/issues/1954) Open very slow * [Issue #1883](https://github.com/derailed/k9s/issues/1883) Fix force deletion * [Issue #1788](https://github.com/derailed/k9s/issues/1788) Draining nodes cannot be forced * [Issue #1150](https://github.com/derailed/k9s/issues/1150) Add a persistent popup for drain failures --- ## Contributed PRs Please give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [PR #1969](https://github.com/derailed/k9s/pull/1969) fix: Add missing help menu to gruvbox-dark skin * [PR #1966](https://github.com/derailed/k9s/pull/1966) fix: Show meaningful error message when kubectl exec fails * [PR #1965](https://github.com/derailed/k9s/pull/1965) set default sinceSeconds to 300 * [PR #1961](https://github.com/derailed/k9s/pull/1961) feat: Add sort by pod count on node view * [PR #1960](https://github.com/derailed/k9s/pull/1960) [Misc] Add Nightfox-theme --- © 2022 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.27.4.md ================================================ # Release v0.27.4 ## 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) --- ## Core Team... Please help me welcome Aleksei Romanenko(https://github.com/slimus) to the K9s contributor team!! Alex is very knowledgeable in this space, kind and a great human being! He has been instrumental with issues, prs and fielding questions in forums and slack. 🎉 Welcome Alex!!🎉 --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Jon Walton](https://github.com/jon-walton) * [gmbnomis](https://github.com/gmbnomis) * [Alex Viscreanu](https://github.com/aexvir) * [Björn Petersen](https://github.com/BjoernPetersen) * [Tanner Watson](https://github.com/tannerwatson) * [Jabunovoty](https://github.com/jabunovoty) * [Joey Guerra](https://github.com/joeyguerra) * [Materialize Inc](https://github.com/MaterializeInc) * [Kijana Woodard](https://github.com/kijanawoodard) * [Tom Saleeba](https://github.com/tomsaleeba) * [William Alexander](https://github.com/carpetfuz) * [Süddeutsche Zeitung](https://github.com/sueddeutsche) > Sponsorship cancellations since the last release: `12` ;( --- ## Maintenance Release --- ## Resolved Issues * [Issue #2072](https://github.com/derailed/k9s/issues/2072) Triggered Job from cronjob is missing annotations * [Issue #2024](https://github.com/derailed/k9s/issues/2024) Allow customization of log indicators with skin theme * [Issue #1971](https://github.com/derailed/k9s/issues/1971) Zip binary for windows --- ## Contributed PRs Please give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [PR #2073](https://github.com/derailed/k9s/pull/2073) Fix for missing Job annotations created from Cronjob * [PR #2069](https://github.com/derailed/k9s/pull/2069) Unify all go version to 1.20 * [PR #2067](https://github.com/derailed/k9s/pull/2067) Create narsingh skin * [PR #2054](https://github.com/derailed/k9s/pull/2054) Update setup-go action, with caching * [PR #2045](https://github.com/derailed/k9s/pull/2045) Fix: (views) use saved context view when switching * [PR #2041](https://github.com/derailed/k9s/pull/2041) Feat: allow customization of log indicator toggles * [PR #2030](https://github.com/derailed/k9s/pull/2030) Updated monokai skin with help styles, and more monokai appropriate colors * [PR #2027](https://github.com/derailed/k9s/pull/2027) Roles are rendered using same colorer function from skin * [PR #2045](https://github.com/derailed/k9s/pull/2045) Fix: (views) use saved context view when switching\ * [PR #2011](https://github.com/derailed/k9s/pull/2011) Fix #2007: Remove debug command --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.28.0.md ================================================ # Release v0.28.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Moonlight Invasions - TribalNeed](https://www.youtube.com/watch?v=mJBnMSNIJL4&list=RDmJBnMSNIJL4&start_radio=1) * [Teardrops - Neil Frances](https://www.youtube.com/watch?v=823_KoZr4mo) * [Memory - Øystein Sevåg](https://www.youtube.com/watch?v=GKEM6lgkogY) * [Tell me straight - Rolling Stones (Generated by KeithGPT 🐭)](https://www.youtube.com/watch?v=YxcxLi-Ld3E) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Hyeon Woo Jo](https://github.com/dokdo2013) * [Artsiom Kaval](https://github.com/lezeroq) * [Grant Linville](https://github.com/g-linville) * [Andrew Brown](https://github.com/andrew-werdna) * [Patrik Votoček](https://github.com/Vrtak-CZ) * [Erik Hebisch](https://github.com/flegelleicht) * [Juliet Boyd](https://github.com/julietrb1) * [Chris Vertonghen](https://github.com/chrisv) * [Acsone](https://github.com/acsone) * [Alex Viscreanu](https://github.com/aexvir) * [Joey Guerra](https://github.com/joeyguerra) * [Kijana Woodard](https://github.com/kijanawoodard) * [Tom Saleeba](https://github.com/tomsaleeba) > Sponsorship cancellations since the last release: `11` ;( --- ## Feature Release ### File Transfers in Da House! Added ability to exchange files from your local machine to a pod or from a pod to your local machine. The pod view now surfaces a new command `t` to initiate the download/upload file transfers. --- ## Resolved Issues * [Issue #2249](https://github.com/derailed/k9s/issues/2249) Sort on the capacity column should consider Gi and Mb also * [Issue #2225](https://github.com/derailed/k9s/issues/2225) View logs of all pods of a given deployment * [Issue #2195](https://github.com/derailed/k9s/issues/2195) Some pod logs are not displayed. But I can display it when I use the command * [Issue #2194](https://github.com/derailed/k9s/issues/2194) 0.27.4 broke custom sort orders via views.yml * [Issue #2185](https://github.com/derailed/k9s/issues/2185) No binaries for Linux_x86_64 * [Issue #2169](https://github.com/derailed/k9s/issues/2169) Add namespace name in ServiceAccount view with RoleBinding * [Issue #2152](https://github.com/derailed/k9s/issues/2152) Latest opened namespace not being saved between k9s sessions * [Issue #2131](https://github.com/derailed/k9s/issues/2131) deployments are not showing up, whereas kubectl gives a list * [Issue #2130](https://github.com/derailed/k9s/issues/2130) Pending pods show 0/0 Ready instead of 0/x Ready * [Issue #2128](https://github.com/derailed/k9s/issues/2128) k9s command not found after snap install * [Issue #2121](https://github.com/derailed/k9s/issues/2121) colors for crds * [Issue #2120](https://github.com/derailed/k9s/issues/2120) kustomize deletion not working as expected * [Issue #2106](https://github.com/derailed/k9s/issues/2106) k9s delete behaves differently with kubectl * [Issue #2085](https://github.com/derailed/k9s/issues/2085) When specifying the context command via the -c flag, selecting a cluster always returns to the context view * [Issue #658](https://github.com/derailed/k9s/issues/658) Feature request: Easy way to copy/download files from a pod/pv to your local PC --- ## Contributed PRs Please give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [PR #2258](https://github.com/derailed/k9s/pull/2258) fix fsnotify watcher not fully working * [PR #2253](https://github.com/derailed/k9s/pull/2253) fix manual sorting not working when sortColumn is configured * [PR #2252](https://github.com/derailed/k9s/pull/2252) consider units when sorting capacity of pv and pvc * [PR #2243](https://github.com/derailed/k9s/pull/2243) fix(typo): pdb header typo * [PR #2239](https://github.com/derailed/k9s/pull/2239) fix: honor defaults from drain dialog in request * [PR #2235](https://github.com/derailed/k9s/pull/2235) docs: add plugin.yml JSON schema * [PR #2229](https://github.com/derailed/k9s/pull/2229) fix(log): clear bold log format after timestamp * [PR #2188](https://github.com/derailed/k9s/pull/2188) Alias qa to quit * [PR #2180](https://github.com/derailed/k9s/pull/2180) feat: Added support for arm in dockerfile * [PR #2179](https://github.com/derailed/k9s/pull/2179) Focus command bar if active on startup * [PR #2170](https://github.com/derailed/k9s/pull/2170) Add namespace for rolebinding on a clusterrole * [PR #2161](https://github.com/derailed/k9s/pull/2161) Only apply keyConv to mnemonic in menus * [PR #2158](https://github.com/derailed/k9s/pull/2158) Show the default container as the first entry * [PR #2153](https://github.com/derailed/k9s/pull/2153) Changed checksums extension to checksums.sha256 * [PR #2158](https://github.com/derailed/k9s/pull/2158) Show the default container as the first entry * [PR #2151](https://github.com/derailed/k9s/pull/2151) chore: pkg imported more than once * [PR #2147](https://github.com/derailed/k9s/pull/2147) feat: plugin for adding an ephemeral debug container * [PR #2141](https://github.com/derailed/k9s/pull/2141) Update plugin flux.yml with shortcuts for helm repo and oci repos * [PR #2137](https://github.com/derailed/k9s/pull/2137) Correctly display the numbers in the Ready column of the pods view * [PR #2136](https://github.com/derailed/k9s/pull/2136) Prompt window uses border styles * [PR #2134](https://github.com/derailed/k9s/pull/2134) Remove unsupported key binding on users view * [PR #2124](https://github.com/derailed/k9s/pull/2124) fix: add correct flags when deleting resources from Dir * [PR #2119](https://github.com/derailed/k9s/pull/2119) feat: add indicator to title if toast is toggled * [PR #2117](https://github.com/derailed/k9s/pull/2117) Add instruction how to install k9s through winget * [PR #2112](https://github.com/derailed/k9s/pull/2112) Fix for styles * [PR #2105](https://github.com/derailed/k9s/pull/2105) Fix the wrong/redundant icon in the prompt bar * [PR #2103](https://github.com/derailed/k9s/pull/2103) Update carvel.yml to include contexts * [PR #2096](https://github.com/derailed/k9s/pull/2096) fix: (config) only respect the --command flag once * [PR #2091](https://github.com/derailed/k9s/pull/2091) Add get-all plugin specific for namespace view * [PR #2089](https://github.com/derailed/k9s/pull/2089) Resources are rendered using skin.yaml colors * [PR #2082](https://github.com/derailed/k9s/pull/2082) Fix typo introduced in #2045 --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.28.1.md ================================================ # Release v0.28.1 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [If Trouble Was Money - Albert Collins](https://www.youtube.com/watch?v=cz6LbWWqX-g) * [Old Love - Eric Clapton](https://www.youtube.com/watch?v=EklciRHZnUQ) * [Touch And GO - The Cars](https://www.youtube.com/watch?v=L7Gpr_Auz8Y) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Bradley Heilbrun](https://github.com/bheilbrun) > Sponsorship cancellations since the last release: `2` ;( --- ## Feature Release ### Sanitize Me! Over time, you might end up with a lot of pod cruft on your cluster. Pods that might be completed, erroring out, etc... Once you've completed your pod analysis it could be useful to clear out these pods from your cluster. In this drop, we introduce a new command `sanitize` aka `z` available on pod views otherwise known as `The Axe!`. This command performs a clean up of all pods that are in either in completed, crashloopBackoff or failed state. This could be especially handy if you run workflows jobs or commands on your cluster that might leave lots of `turd` pods. Tho this has a `phat` fail safe dialog please be careful with this one as it is a blunt tool! --- ## Resolved Issues * [Issue #2281](https://github.com/derailed/k9s/issues/2281) Can't run Node shell * [Issue #2277](https://github.com/derailed/k9s/issues/2277) bulk actions applied to power filters * [Issue #2273](https://github.com/derailed/k9s/issues/2273) Error when draining node that is cordoned bug * [Issue #2233](https://github.com/derailed/k9s/issues/2233) Invalid port-forwarding status displayed over the k9s UI --- ## 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!! * [PR #2280](https://github.com/derailed/k9s/pull/2280) chore: replace github.com/ghodss/yaml with sigs.k8s. * [PR #2278](https://github.com/derailed/k9s/pull/2278) README.md: fix typo in netshoot URL * [PR #2275](https://github.com/derailed/k9s/pull/2275) check if the Node already cordoned when executing Drain * [PR #2247](https://github.com/derailed/k9s/pull/2247) Delete port forwards when pods get deleted --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.28.2.md ================================================ # Release v0.28.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) --- ## ♫ Sounds Behind The Release ♭ * [If Trouble Was Money - Albert Collins](https://www.youtube.com/watch?v=cz6LbWWqX-g) * [Old Love - Eric Clapton](https://www.youtube.com/watch?v=EklciRHZnUQ) * [Touch And GO - The Cars](https://www.youtube.com/watch?v=L7Gpr_Auz8Y) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Bradley Heilbrun](https://github.com/bheilbrun) > Sponsorship cancellations since the last release: `2` ;( --- ## Feature Release ### Sanitize Me! Over time, you might end up with a lot of pod cruft on your cluster. Pods that might be completed, erroring out, etc... Once you've completed your pod analysis it could be useful to clear out these pods from your cluster. In this drop, we introduce a new command `sanitize` aka `z` available on pod views otherwise known as `The Axe!`. This command performs a clean up of all pods that are in either in completed, crashloopBackoff or failed state. This could be especially handy if you run workflows jobs or commands on your cluster that might leave lots of `turd` pods. Tho this has a `phat` fail safe dialog please be careful with this one as it is a blunt tool! --- ## Resolved Issues * [Issue #2281](https://github.com/derailed/k9s/issues/2281) Can't run Node shell * [Issue #2277](https://github.com/derailed/k9s/issues/2277) bulk actions applied to power filters * [Issue #2273](https://github.com/derailed/k9s/issues/2273) Error when draining node that is cordoned bug * [Issue #2233](https://github.com/derailed/k9s/issues/2233) Invalid port-forwarding status displayed over the k9s UI --- ## 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!! * [PR #2280](https://github.com/derailed/k9s/pull/2280) chore: replace github.com/ghodss/yaml with sigs.k8s. * [PR #2278](https://github.com/derailed/k9s/pull/2278) README.md: fix typo in netshoot URL * [PR #2275](https://github.com/derailed/k9s/pull/2275) check if the Node already cordoned when executing Drain * [PR #2247](https://github.com/derailed/k9s/pull/2247) Delete port forwards when pods get deleted --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.29.0.md ================================================ # Release v0.29.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Snowbound - Donald Fagen](https://www.youtube.com/watch?v=bj8ZdBdKsfo) * [Pilgrim - Eric Clapton](https://www.youtube.com/watch?v=8V9tSQuIzbQ) * [Lucky Number - Lene Lovich](https://www.youtube.com/watch?v=KnIJOO__jVo) --- ## 🦃 Happy (Belated!) ThanksGiving To All! 🦃 Hope you and yours had a wonderful holiday!! Hopefully this drop won't be a cold turkey 😳 I'd like to take this opportunity to honor two very special folks: * [Alexandru Placinta](https://github.com/placintaalexandru) * [Jayson Wang](https://github.com/wjiec) These guys have been relentless in fishing out bugs, helping out with support and addressing issues, not to mention enduring my code! 🙀 They dedicate a lot of their time to make `k9s` better for all of us! So if you happen to run into them live/virtual, please be sure to `Thank` them and give them a huge hug! 🤗 I am thankful for all of you for being kind, patient, understanding and one of the coolest OSS community on the web!! Feeling blessed and ever so humbled to be part of it. Thank you!! --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Marco Stuurman](https://github.com/fe-ax) * [Paul Sweeney](https://github.com/Kolossi) * [Cayla Fauver](https://github.com/cayla) * [alemanek](https://github.com/alemanek) * [Danske Commodities A/S](https://github.com/DanskeCommodities) > Sponsorship cancellations since the last release: **8** ;( --- ## 🎉 Feature Release 🎈👯 --- ### Breaking Bad! WARNING! There are breaking change on this drop! 1. NodeShell configuration has moved up in the k9s config file from the context section to the top level config. More than likely, one uses the same nodeShell image with all the fixins to introspect nodes no matter the cluster. This update DRY's up k9s config and still allows one to opt in/out of nodeShell via the context specific feature gate. Please see README for the details. > NOTE: If you haven't customize the shellPod images on your contexts, the app will move the nodeShell config section to > it's new location and update your clusters information accordingly. > If not, you will need to edit the nodeShell section and manage it from a single location! 1. Log view used to default to the last 5mins aka `sinceSeconds: 300`. Changed the default to tail logs instead aka `sinceSeconds: -1` 1. Skins loading changed! In this release, we do away with the context specific skin files. You can now directly specify the skin to use for a given cluster directly in the k9s config file under the cluster configuration. K9s now expects a skins directory in the k9s config home with your skin files. You can use your custom skins and copy them to the `skins` directory or use the contributes skins found on this repo root. Specify the name of the skin in the config file and now your cluster will load the specified skin. For example: create a `skins` dir your k9s config home and add one_dark.yml skin file from this repo. Then edit your k9s config file as follows: ```yaml k9s: ... clusters: fred: # Override the default skin and use this skin for this cluster. skin: one_dark # -> Look for a skin file in ~/.config/k9s/skins/one_dark.yml namespace: ... view: active: pod featureGates: nodeShell: false portForwardAddress: localhost ``` The `fred` cluster will now load with the specified skin name. Rinse and repeat for other clusters of your liking. In the case where neither the skin dir or skin file are present, k9s will still honor the global skin aka `skin.yml` in your k9s config home directory to skin all your clusters. --- ### Walk Of SHelm... Added a `Releases` view to Helm! This provides the ability for Helm users to manage their releases directly from k9s. You can now press `enter` on a selected Helm install and view all associated releases. While in the releases view, you can also rollback an install to a previous revision. --- ### Spock! Are You Out Of Your VulScan Mind? Tired of having malignant folks shoot holes in your prod clusters or failing compliance testing? Added ability to run image vulnerability scans directly from k9s. You can now monitor your security stance in dev/staging/... clusters prior to proclaiming `It's Open Season...` in prod! As it stands Pod, Deployment, StatefulSet, DaemonSet, CronJob, Job views will feature a new column for Vulnerability Scan aka `VS`. > NOTE! This feature is gated so you'll need to manually opt in/out by modifying your k9s config file like so: ```yaml k9s: liveViewAutoRefresh: false enableImageScan: true # <- Yes Please!! headless: false ... ``` Once enabled, a new column `VS` (aka Vulnerability Score) should be present on the aforementioned views where you will see your vulnerability scores (*Still work in progress!!*). The `VS` column displays a bit vector aka Sev-1|Sev-2|Sev-3|Sev-4|Sev-5|Sev-Unknown. When the bit is high it indicate the presence of the severity in the scans. Higher order bits = Higher severity For instance, the following vector `110001` indicates the presence of both critical (Sev-1) and high (Sev-2) and an unclassified severity (aka Sev-Unknown) issues in the scan. Sev-U indicates no classification currently exist in our vulnerability database. The image scans are run async, rendering the views eventually consistent, hence you may have to give the scores a few cycles for the dust to settle... Once the caches are primed, subsequent loads should be faster 🤞 You can sort the views by vulnerability score using `ShiftV`. Additionally, you can view the full scans report by pressing `v` on a selected resource. I've synced my entire Thanksgiving holiday break on this ding dang deal, so hopefully it works for most of you?? Also if you dig this new feature, please make some noise! 😍 💘 This is an experimental feature and likely will require additional TLC 💘 > NOTE! The lib we use to scan for vulnerabilities only supports macOS and Linux!! > NOTE: I have yet to test this feature on larger clusters, so likely this may break?? > Please take these reports with a grain of salt as likely your mileage will vary and help us > validate the accuracy of the report ie if we cry `Wolf`, is it actually there? The paint is still fresh on this deal!! ### Do You Tube? My plan is to begin (again!) putting out short k9s episodes with how-tos, tips, tricks and features previews. Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... The first drop should be up by the time you read this! * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2308](https://github.com/derailed/k9s/issues/2308) Unable to list CRs for crd with only list and get verb without watch verb * [#2301](https://github.com/derailed/k9s/issues/2301) Add imagePullPolicy and imagePullSecrets on shell_pod for internal registry uses * [#2298](https://github.com/derailed/k9s/issues/2298) Weird color after plugin usage * [#2297](https://github.com/derailed/k9s/issues/2297) Select nodes with space does not work anymore * [#2290](https://github.com/derailed/k9s/issues/2290) Provide release assets for freebsd amd64/arm64 * [#2283](https://github.com/derailed/k9s/issues/2283) Adding auto complete in search bar * [#2219](https://github.com/derailed/k9s/issues/2219) Add tty: true to the node shell pod manifest * [#2167](https://github.com/derailed/k9s/issues/2167) Show wrong Configmap data * [#2166](https://github.com/derailed/k9s/issues/2166) Taint count for the nodes view * [#2165](https://github.com/derailed/k9s/issues/2165) Restart counter for init containers * [#2162](https://github.com/derailed/k9s/issues/2162) Make edit work when describing a resource * [#2154](https://github.com/derailed/k9s/issues/2154) Help and h command does not work if typed into cmdbuff * [#2036](https://github.com/derailed/k9s/issues/2036) Crashed while do filtering * [#2009](https://github.com/derailed/k9s/issues/2009) Ctrl-s: Name of file (Describe-....) * [#1513](https://github.com/derailed/k9s/issues/1513) Problem regarding showing the logs - it hangs/slow on pods which are running for long time NOTE: Better but not cured! Perf improvements while viewing large cm (7k lines) from 26s->9s * [#568](https://github.com/derailed/k9s/issues/568) Allow both .yaml and .yml yaml config files --- ## 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!! * [#2322](https://github.com/derailed/k9s/pull/2322) Check if the service provides selectors * [#2319](https://github.com/derailed/k9s/pull/2319) Proper handling of help commands (fixes #2154) * [#2315](https://github.com/derailed/k9s/pull/2315) Fix namespace suggestion error on context switch * [#2313](https://github.com/derailed/k9s/pull/2313) Should not clear screen when executing plugin command * [#2310](https://github.com/derailed/k9s/pull/2310) chore: Mot recommended to use k8s.io/kubernetes as a dependency * [#2303](https://github.com/derailed/k9s/pull/2303) Clean up items * [#2301](https://github.com/derailed/k9s/pull/2301) feat: Add imagePullSecrets and imagePullPolicy configuration for shellpod * [#2289](https://github.com/derailed/k9s/pull/2289) Clean up issues introduced in #2125 * [#2288](https://github.com/derailed/k9s/pull/2288) Fix merge issues from PR #2168 * [#2284](https://github.com/derailed/k9s/issues/2284) Allow both .yaml and .yml yaml config files --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.29.1.md ================================================ # Release v0.29.1 ## 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 --- ## Resolved Issues * [#2330](https://github.com/derailed/k9s/issues/2330) Skins don't work v0.29.0 * [#2329](https://github.com/derailed/k9s/issues/2329) New skin system in v0.29.0 doesn't work if you use different k8s context files * [#2327](https://github.com/derailed/k9s/issues/2327) [Bug] Item highlighting broke in v0.29.0 --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.0.md ================================================ # Release v0.30.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ Going back to the classics... * [Home For Christmas - Fats Domino](https://www.youtube.com/watch?v=ykAVdPz8o1Q) * [Our Love - Al Jarreau](https://www.youtube.com/watch?v=9ztMe6GIwi8) * [Body And Soul - Louis Armstrong](https://www.youtube.com/watch?v=2Gnz69TbqHQ) * [On The Dunes - Donald Fagen](https://www.youtube.com/watch?v=QoVT3XcMVvk) * [Ciao - Lucio Dalla](https://www.youtube.com/watch?v=qcqXcmKu_I4) * [Basin Street Blues - Louis Prima](https://www.youtube.com/watch?v=IijXXXpUefM&list=RDIijXXXpUefM&start_radio=1) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Bojan](https://github.com/rbojan) > Sponsorship cancellations since the last release: **5!** 🥹 --- ## 🎄 Feature Release! 🎄 🎅 Merry Christmas to all and Best wishes for the new year!!🧑‍🎄 --- ### 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) --- ### Breaking Bad! > ☢️ !!Prior to installing v0.30.0!! Please be sure to backup your k9s configs directories or move them somewhere safe!! > ☢️ Please watch the v0.30.0 Sneak peek series (links below) for detailed information. > > ☢️ Most K9s configuration files have either split or changed location or names on this drop!! > We recommend moving your current k9s config dirs to another location and start k9s from scratch and let it create and initialize the various configs > to their new spec and location. You can then use your existing setup and patch with the new layout/spec. > As of v0.30.0 all config files now use the `*.yaml` extension. We did our best to update all the docs to match the new version. > If you find doc issues either file an issue or better yet submit a PR! Some of you might say: `You're on the roll their bud! Two breaking changes drops in a row!!` Per the wise words of my beloved Grand mama! `One can't cook a decent meal without creating a mess!` Not to mention we're still at v0.x.y so `Open season on breaking changes` is very much in full effect. Tho I have tested this drop quite a bit, there is a strong chance that I've broken some stuff. The key here is to walk the fine line of improving k9s code base and features set with minimal impact to you. As you know by now, I am committed to ease the pain and resolve issues quickly to get you all back up and running. From the scope changes in this release, I would caution that this drop will likely break you! If so, worry not! We will fix the duds so we are `Happy as a Hippo` once again. There was a few issues with the way K9s persists it's configuration and various artifacts. So we rewrote it! First and foremost all k9s related YAML resources, will now use the standard ".yaml" extension. I think we've bloated the code checking for both extensions with no real actionable value! As it stands the main K9s configuration `config.yml` will now be static. These settings are now readonly! All the dynamic configurations that K9s manages now live in a new directory aka `clusters`. The clusters directory manages your k8s cluster/context configurations. So things like active view, namespace, favorites, etc... now live in this directory. K9s configurations are still managed using either xdg `XDG_CONFIG_HOME` or you can set `K9S_CONFIG_DIR` to specify your preferred k9s configs location. Also all config files will now use the ".yaml" extension vs ".yml"!! So the main k9s configuration (static) now looks like this: ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml # File will be autogenerated with all the default fixins if not found in the config specification. k9s: liveViewAutoRefresh: false refreshRate: 2 maxConnRetry: 5 readOnly: false noExitOnCtrlC: false ui: # NOTE! New level!! enableMouse: false headless: false logoless: false crumbsless: false noIcons: false skipLatestRevCheck: false disablePodCounting: false # ShellPod configuration applies to all your clusters shellPod: image: busybox:1.35.0 namespace: default limits: cpu: 100m memory: 100Mi # ImageScan config changed from v0.29.0! imageScans: enable: false # Now figures exclusions ie excludes certain namespaces or specific workload labels exclusions: # Exclude the following namespaces for image vulscans! namespaces: - kube-system - fred # Exclude the following labels from image vulscans! labels: k8s-app: - kindnet - bozo env: - dev logger: tail: 100 buffer: 5000 sinceSeconds: -1 fullScreenLogs: false textWrap: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 ``` Next context specific configurations that are managed by you and k9s live in the XDG data directory i.e `$XDG_DATA_HOME/k9s/clusters` or `$K9S_CONFIG_DIR/clusters` if the env var is set. ```text $XDG_DATA_HOME/k9s // Clusters tracks visited kubeconfig cluster/contexts ├── clusters │ ├── fred │ │ └── bozo │ │ └── config.yaml │ ├── bozorg │ │ ├── kind-bozo-1 │ │ │ └── config.yaml │ │ ├── kind-bozo-2 │ │ │ └── config.yaml │ │ └── kind-bozo-3 │ │ └── config.yaml │ └── bumblebeetuna │ └── blee │ └── config.yaml └── skins ├── black_and_wtf.yaml ├── dracula.yaml ├── in_the_navy.yml ├── ... ``` Now looking at a given context configuration i.e cluster-1/context-1/config.yaml ```yaml # $XDG_DATA_HOME/k9s/clusters/bumblebeetuna/blee/config.yaml k9s: cluster: bumblebeetuna readOnly: false # [New!] you can now single out a given context and make it readonly. Woof! skin: in_the_navy # [NEW!] you can also skin individual contexts. Woof Woof! namespace: active: all lockFavorites: false favorites: - all - kube-system - default view: active: dp featureGates: nodeShell: false portForwardAddress: localhost ``` Transient artifacts ie k9s logs, screen-dumps, benchmarks etc now live in the state config dir. ```text $XDG_STATE_HOME/k9s ├── k9s.log # K9s log files └── screen-dumps └── bumblebeetuna # Screen dumps location for context blee └── blee └── deployments-kube-system-1703018199222861000.csv ``` If you get stuck or if my instructions are just `clear as mud`... `k9s info` is always your friend!! I feel this is an improvement (tho I might be unanimous on this!) especially for folks dealing with multi-clusters or swapping out there kubeconfigs... > NOTE! Paint is still fresh on this deal. Proceed with caution and please help us flush this feature out! --- # Got Prompt? In this drop, we've also given the k9s command prompt aka `:xxx` some love. You have the ability to specify filter directly in the prompt. So for example, you can now run something like `:po /fred` to run pod view with a filter to just show pods containing `fred`. Likewise `:po k8s-app=fred,env=blee` to filter by labels. And now for the`Krampus` special... you can see pods in a different context all together via `:pod @ctx-2`. Finally you can combo and send the `whole enchilada` via `:po k8s-app=fred /blee ns-1 @ctx-x` Did I mention with completion where applicable? Yes Please!! Compliments of [Jayson Wang](https://github.com/wjiec). Be sure to thank him!! Put these frequent flyers command in an alias and now you can nav your clusters with `even more style`! --- # All Is Love? 🎵 `On The twentieth day of Christmas my true love gave to me... Ten workloads a-leaping??...` 🎵 This is a feature reported by many of you and its (finally!) here. As of this drop, we intro the `workload` view aka `wk` which is similar to `kubectl get all`. I was reluctant to intro it given the potential hazards on larger clusters but figured why not? YOLO. I think using it in combo with the prompt updates it could pack a serious punch to observe workload related artifacts. --- # Vulnerability Scan Exclusions... As it seems customary with all k9s new features, folks want to turn them off ;( The `Vulscan` feature did not get out unscathed ;( As it was rightfully so pointed out, you may want to opted out scans for images that you do not control. Tho I think it might be a good idea to run wide open once in a while to see if your cluster has any holes?? For this reason, we've opted to intro an exclusion section under the image scan configuration to exclude certain images from the scans. Here is a sample configuration: ```yaml k9s: liveViewAutoRefresh: false refreshRate: 2 ui: enableMouse: false headless: false logoless: false crumbsless: false noIcons: false imageScans: enable: true exclusions: # Skip scans on these namespaces namespaces: - ns-1 - ns-2 # Skip scans for pods matching these labels labels: - app: - fred - blee - duh - env: - dev ``` This is a bit of a blur now, but I think that it! We hope you guys will dig this drop or at least the concepts as likely this is going to be `Open Season` on bugs ;( 🎵 `On The second day of Christmas my true love gave to me... Eleven buggers bugging??...` 🎵 Lastly looks like the sponsorship stream is down to an alarming trickle so if you dig this project and find it useful be sure `to give til it hurts!` --- 🎅 Best wishes to you and yours for good health and happiness this holiday season!! 🎉 AndJoy! Fernand --- ## Resolved Issues * [#2346](https://github.com/derailed/k9s/issues/2346) k9s should not write state to config.yaml * [#2335](https://github.com/derailed/k9s/issues/2335) Restore 0.28 column order on pod view bug * [#2331](https://github.com/derailed/k9s/issues/2331) Set a shortcut key to run Vuln Scanning on a resource. Don't scan every resource at every startup. * [#2283](https://github.com/derailed/k9s/issues/2283) Adding auto complete in search bar --- ## 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!! * [#2357](https://github.com/derailed/k9s/pull/2357) Added ln check for snap * [#2350](https://github.com/derailed/k9s/pull/2350) Add symlink into snap * [#2348](https://github.com/derailed/k9s/pull/2348) Fix(misc plugins): split up multiline commands, use less -K everywhere * [#2343](https://github.com/derailed/k9s/pull/2343) Passing on the correct suggestion parameters * [#2341](https://github.com/derailed/k9s/pull/2340) Adding value, yaml and describe views to helm-history * [#2340](https://github.com/derailed/k9s/pull/2340) Add pkgx to installation section --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.1.md ================================================ # Release v0.30.1 ## 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! 🎄 🎵 `On The eleventh day of Christmas my true love gave to me... Bugs!!` 🎵 Got to love the aftermath... Thank you all for pitch'in in and help flesh out bugs!! The gift that keeps on... giving? 🎅 Merry Christmas to all and Best wishes for the new year!!🧑‍🎄 --- ### 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 * [#2368](https://github.com/derailed/k9s/issues/2368) Pod CPU and MEM columns are empty in 0.30.0 * [#2367](https://github.com/derailed/k9s/issues/2367) k9s 0.30.0 issue loading plugins * [#2366](https://github.com/derailed/k9s/issues/2366) List pods of deployment is now impossible * [#2364](https://github.com/derailed/k9s/issues/2364) k9s 0.30.0 fields and values missed in action in the "namespace view" * [#2363](https://github.com/derailed/k9s/issues/2363) Default 0.30.0 default skin on macOS is no good --- ## 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!! * [#2360](https://github.com/derailed/k9s/pull/2360) adding cancelable launch prompts to NodeShell --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.2.md ================================================ # Release v0.30.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) ## 🎄 Maintenance Release! 🎄 🎵 `On The eleventh day of Christmas my true love gave to me... More Bugs!!` 🎵 Thank you all for pitching in and help flesh out bugs!! --- ## [!!FEATURE NAME CHANGED!!] Vulnerability Scan Exclusions... As it seems customary with all k9s new features, folks want to turn them off ;( The `Vulscan` feature did not get out unscathed ;( As it was rightfully so pointed out, you may want to opted out scans for images that you do not control. Tho I think it might be a good idea to run wide open once in a while to see if your cluster has any holes?? For this reason, we've opted to intro an exclusion section under the image scan configuration to exclude certain images from the scans. Here is a sample configuration: ```yaml k9s: liveViewAutoRefresh: false refreshRate: 2 ui: enableMouse: false headless: false logoless: false crumbsless: false noIcons: false imageScans: enable: true # MOTE!! Field Name changed!! exclusions: # Skip scans on these namespaces namespaces: - ns-1 - ns-2 # Skip scans for pods matching these labels labels: - app: - fred - blee - duh - env: - dev ``` --- ## 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 * [#2374](https://github.com/derailed/k9s/issues/2374) The headless parameter does not function properly (v0.30.1) * [#2372](https://github.com/derailed/k9s/issues/2372) Unable to set default resource to load (v0.30.1) * [#2371](https://github.com/derailed/k9s/issues/2371) --write cli option does not work (0.30.X) * [#2370](https://github.com/derailed/k9s/issues/2370) Wrong list of pods on node (0.30.X) * [#2362](https://github.com/derailed/k9s/issues/2362) blackList: Use inclusive language alternatives --- ## 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!! * [#2375](https://github.com/derailed/k9s/pull/2375) get node filtering params from matching context values * [#2373](https://github.com/derailed/k9s/pull/2373) fix command line flags not working --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.3.md ================================================ # Release v0.30.3 ## 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! 🎄 🎵 `On The twelfth day of Christmas my true love gave to me... More Bugs!!` 🎵 Thank you all for pitching in and help 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 * [#2379](https://github.com/derailed/k9s/issues/2379) Filtering with equal sign (=) does not work in 0.30.X * [#2378](https://github.com/derailed/k9s/issues/2378) Logs directory not created in the k9s config/home dir 0.30.1 * [#2377](https://github.com/derailed/k9s/issues/2377) Opening AWS EKS contexts create two directories per cluster 0.30.1 --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.4.md ================================================ # Release v0.30.4 ## 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 * [#2391](https://github.com/derailed/k9s/issues/2391) Version 0.30.* has issues with : chars in the cluster names from AWS * [#2397](https://github.com/derailed/k9s/issues/2387) Error: invalid namespace xxx * [#2389](https://github.com/derailed/k9s/issues/2389) Mixed-case named contexts cannot be switched to from contexts view * [#2382](https://github.com/derailed/k9s/issues/2382) Header always shows Cluster from kubeconfig current-context --- ## 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!! * [#2390](https://github.com/derailed/k9s/pull/2390) case sensitive for specific command args and flags --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.5.md ================================================ # Release v0.30.5 ## 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 * [#2394](https://github.com/derailed/k9s/issues/2394) Allow setting custom log dir * [#2393](https://github.com/derailed/k9s/issues/2393) When switching contexts k9s does not switch to cluster's pod/namespaces/other k8s kinds view * [#2387](https://github.com/derailed/k9s/issues/2387) Invalid namespace xxx - with feelings! --- ## 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!! * [#2396](https://github.com/derailed/k9s/pull/2396) feat: allow to customize logs dir through environment variable * [#2395](https://github.com/derailed/k9s/pull/2395) fix: create user tmp directory before the app one --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.6.md ================================================ # Release v0.30.6 ## 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 * [#2401](https://github.com/derailed/k9s/issues/2401) Context completion broken with mixed case context names * [#2400](https://github.com/derailed/k9s/issues/2400) Panic on start if dns lookup fails * [#2387](https://github.com/derailed/k9s/issues/2387) Invalid namespace xxx - with feelings?? --- © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.7.md ================================================ # 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 © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.30.8.md ================================================ # Release v0.30.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/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 * [#2423](https://github.com/derailed/k9s/issues/2423) CPU and MEM counters of AKS clusters show not available * [#2418](https://github.com/derailed/k9s/issues/2418) Boom! runtime error: invalid memory address or nil pointer dereference --- ## 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!! * [#2424](https://github.com/derailed/k9s/pull/2424) fix the check for whether the cluster supports metrics © 2023 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.0.md ================================================ # Release v0.31.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Border Crossing - Eek A Mouse](https://www.youtube.com/watch?v=KaAC9dBPcOM) * [The Weight - The Band](https://www.youtube.com/watch?v=FFqb1I-hiHE) * [Wonderin' - Neil Young](https://www.youtube.com/watch?v=h0PlwVPbM5k) * [When Your Lover Has Gone - Louis Armstrong](https://www.youtube.com/watch?v=1tdfIj0fvlA) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Jacky Nguyen](https://github.com/nktpro) * [Eckl, Máté](https://github.com/ecklm) * [Jörgen](https://github.com/wthrbtn) * [kmath313](https://github.com/kmath313) * [a-thomas-22](https://github.com/a-thomas-22) * [wpbeckwith](https://github.com/wpbeckwith) * [Dima Altukhov](https://github.com/alt-dima) * [Shoshin Nikita](https://github.com/ShoshinNikita) * [Tu Hoang](https://github.com/rebyn) * [Andreas Frangopoulos](https://github.com/qubeio) > Sponsorship cancellations since the last release: **7!** 🥹 ## Feature Release! 😳 Found a few issues in the neutrino drive... This is another fairly heavy drop so bracing for impact 😱 Be sure to dial in the v0.31.0 SneakPeek video below for the gory details! 😵 Hopefully we've move the needle in the right direction on this drop... 🤞 Thank you all for your kindness, feedback and assistance in flushing out issues!! ### Hold My Hand... In this drop, we've added schema validation to ensure various configs are setup as expected. K9s will now run validation checks on the following configurations: 1. K9s main configuration (config.yaml) 2. Context specific configs (clusterX/contextY/config.yaml) 3. Skins 4. Aliases 5. HotKeys 6. Plugins 7. Views K9s behavior changed in this release if the main configuration does not match schema expectations. In the past, the configuration will be validated, updated and saved should validation checks failed. Now the app will stop and report validation issues. The schemas are set to be a bit loose for the time being. Once we/ve vetted they are cool, we could publish them out (with additional TLC!) so k9s users can leverage them in their favorite editors. In the meantime, you'll need to keep k9s logs handy, to check for validation errors. The validation messages can be somewhat cryptic at times and so please be sure to include your debug logs and config settings when reporting issues which might be plenty ;(. ### Breaking Bad! Configuration changes: 1. DRY fullScreenLogs -> fullScreens (k9s root config.yaml) ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false logger: sinceSeconds: -1 fullScreen: false # => Was fullScreenLogs ... ``` 2. Views Configuration. To match other configurations the root is now `views:` vs `k9s: views:` ```yaml # $XDG_CONFIG_HOME/k9s/views.yaml views: # => Was k9s:\n views: v1/pods: columns: - AGE - NAMESPACE ... ``` ### Serenity Now! You can now opt in/out of the `reactive ui` feature. This feature enable users to make change to some configurations and see changes reflected live in the ui. This feature is now disabled by default and one must opt-in to enable via `k9s.UI.reactive` Reactive UI provides for monitoring various config files on disk and update the UI when changes to those files occur. This is handy while tuning skins, plugins, aliases, hotkeys and benchmarks parameters. ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false UI: ... reactive: true # => enable/disable reactive UI ... ``` --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2434](https://github.com/derailed/k9s/issues/2434) readOnly: true in config.yaml doesn't get overridden by readOnly: false in cluster config * [#2430](https://github.com/derailed/k9s/issues/2430) Referencing a namespace with the name of an alias inside an alias causes infinite loop * [#2428](https://github.com/derailed/k9s/issues/2428) Boom!! runtime error: invalid memory address or nil pointer dereference - v0.30.8 * [#2421](https://github.com/derailed/k9s/issues/2421) k9s/config.yaml configuration file is overwritten on launch --- ## Contributed PRs Please be sure to give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [#2433](https://github.com/derailed/k9s/pull/2433) switch contexts only when needed * [#2429](https://github.com/derailed/k9s/pull/2429) Reference correct configuration ENV var in README * [#2426](https://github.com/derailed/k9s/pull/2426) Update carvel plugin kick to shift K * [#2420](https://github.com/derailed/k9s/pull/2420) supports referencing envs in hotkeys * [#2419](https://github.com/derailed/k9s/pull/2419) fix typo © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.1.md ================================================ # Release v0.31.1 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Border Crossing - Eek A Mouse](https://www.youtube.com/watch?v=KaAC9dBPcOM) * [The Weight - The Band](https://www.youtube.com/watch?v=FFqb1I-hiHE) * [Wonderin' - Neil Young](https://www.youtube.com/watch?v=h0PlwVPbM5k) * [When Your Lover Has Gone - Louis Armstrong](https://www.youtube.com/watch?v=1tdfIj0fvlA) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Jacky Nguyen](https://github.com/nktpro) * [Eckl, Máté](https://github.com/ecklm) * [Jörgen](https://github.com/wthrbtn) * [kmath313](https://github.com/kmath313) * [a-thomas-22](https://github.com/a-thomas-22) * [wpbeckwith](https://github.com/wpbeckwith) * [Dima Altukhov](https://github.com/alt-dima) * [Shoshin Nikita](https://github.com/ShoshinNikita) * [Tu Hoang](https://github.com/rebyn) * [Andreas Frangopoulos](https://github.com/qubeio) > Sponsorship cancellations since the last release: **7!** 🥹 ## Feature Release! 😳 Found a few issues in the neutrino drive... This is another fairly heavy drop so bracing for impact 😱 Be sure to dial in the v0.31.0 SneakPeek video below for the gory details! 😵 Hopefully we've move the needle in the right direction on this drop... 🤞 Thank you all for your kindness, feedback and assistance in flushing out issues!! > ☢️ Repeating v0.31.0 release notes here as we tweaked the initial drop ☢️ ### Hold My Hand... In this drop, we've added schema validation to ensure various configs are setup as expected. K9s will now run validation checks on the following configurations: 1. K9s main configuration (config.yaml) 2. Context specific configs (clusterX/contextY/config.yaml) 3. Skins 4. Aliases 5. HotKeys 6. Plugins 7. Views K9s behavior changed in this release if the main configuration does not match schema expectations. In the past, the configuration will be validated, updated and saved should validation checks failed. Now the app will stop and report validation issues. The schemas are set to be a bit loose for the time being. Once we/ve vetted they are cool, we could publish them out (with additional TLC!) so k9s users can leverage them in their favorite editors. In the meantime, you'll need to keep k9s logs handy, to check for validation errors. The validation messages can be somewhat cryptic at times and so please be sure to include your debug logs and config settings when reporting issues which might be plenty ;(. ### Breaking Bad! With this release, k9s may not start correctly if the config.yaml configurations are incorrect! Configuration changes: 1. DRY fullScreenLogs -> fullScreens (k9s root config.yaml) ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false logger: sinceSeconds: -1 fullScreen: false # => Was fullScreenLogs ... ``` 2. Views Configuration. To match other configurations the root is now `views:` vs `k9s: views:` ```yaml # $XDG_CONFIG_HOME/k9s/views.yaml views: # => Was k9s:\n views: v1/pods: columns: - AGE - NAMESPACE ... ``` ### Serenity Now! You can now opt in/out of the `reactive ui` feature. This feature enable users to make change to some configurations and see changes reflected live in the ui. This feature is now disabled by default and one must opt-in to enable via `k9s.UI.reactive` Reactive UI provides for monitoring various config files on disk and update the UI when changes to those files occur. This is handy while tuning skins, plugins, aliases, hotkeys and benchmarks parameters. ```yaml # $XDG_CONFIG_HOME/k9s/config.yaml k9s: liveViewAutoRefresh: false UI: ... reactive: true # => enable/disable reactive UI ... ``` --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2434](https://github.com/derailed/k9s/issues/2434) readOnly: true in config.yaml doesn't get overridden by readOnly: false in cluster config * [#2430](https://github.com/derailed/k9s/issues/2430) Referencing a namespace with the name of an alias inside an alias causes infinite loop * [#2428](https://github.com/derailed/k9s/issues/2428) Boom!! runtime error: invalid memory address or nil pointer dereference - v0.30.8 * [#2421](https://github.com/derailed/k9s/issues/2421) k9s/config.yaml configuration file is overwritten on launch --- ## Contributed PRs Please be sure to give `Big Thanks!` and `ATTA Girls/Boys!` to all the fine contributors for making K9s better for all of us!! * [#2433](https://github.com/derailed/k9s/pull/2433) switch contexts only when needed * [#2429](https://github.com/derailed/k9s/pull/2429) Reference correct configuration ENV var in README * [#2426](https://github.com/derailed/k9s/pull/2426) Update carvel plugin kick to shift K * [#2420](https://github.com/derailed/k9s/pull/2420) supports referencing envs in hotkeys * [#2419](https://github.com/derailed/k9s/pull/2419) fix typo © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.2.md ================================================ # Release v0.31.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) ## Maintenance Release! Yikes! The aftermath... Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` doesn't really help us zero in. Everyone has slightly different settings/platforms so every little bits of info helps with the resolves. Thank you!! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2449](https://github.com/derailed/k9s/issues/2449) [Bug]: views.yaml columns not respected on startup * [#2448](https://github.com/derailed/k9s/issues/2448) Missing '.thresholds' in config.yaml result in 'assignment to entry in nil map' * [#2446](https://github.com/derailed/k9s/issues/2446) Context Switch unreliable/not working --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.3.md ================================================ # Release v0.31.3 ## 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! The aftermath... Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` doesn't really help us zero in. Everyone has slightly different settings/platforms so every little bits of info helps with the resolves. Thank you!! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2459](https://github.com/derailed/k9s/issues/2459) No permission to see deployments/statefulsets even though I have them * [#2458](https://github.com/derailed/k9s/issues/2458) panic on run without current context * [#2454](https://github.com/derailed/k9s/issues/2454) Invoking K9s ends in panic question * [#2435](https://github.com/derailed/k9s/issues/2435) "yaml: line 15: could not find expected ':'" error bug question (May be??) --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.4.md ================================================ # Release v0.31.4 ## 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! More aftermath... Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really help us zero in. Everyone has slightly different settings/platforms so every little bits of info helps with the resolves. Thank you!! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2463](https://github.com/derailed/k9s/issues/2463) v0.31.3 (Linux_amd64) gives runtime error on startup --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.5.md ================================================ # Release v0.31.5 ## 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! 😱 More aftermath... 😱 Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really cut it for us to zero in ;( Everyone has slightly different settings/platforms so every little bits of info helps with the resolves even if seemingly irrelevant. Thank you!! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2466](https://github.com/derailed/k9s/issues/2466) Panic: index out of range [0] with length 0 * [#2465](https://github.com/derailed/k9s/issues/2465) v0.31.4 - panic; no client connection detected - with feelings!! --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.6.md ================================================ # Release v0.31.6 ## 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! 😱 More aftermath... 😱 Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really cut it for us to zero in ;( Everyone has slightly different settings/platforms so every little bits of info helps with the resolves even if seemingly irrelevant. --- ## NOTE In this drop, we've made k9s a bit more resilient (hopefully!) to configuration issues and in most cases k9s will come up but may exhibit `limp mode` behaviors. Please double check your k9s logs if things don't work as expected and file an issue with the `gory` details! ☢️ This drop may cause `some disturbance in the farce!` ☢️ Please proceed with caution with this one as we did our best to attempt to address potential context config file corruption by eliminating race conditions. It's late and I am operating on minimal sleep so I may have hosed some behaviors 🫣 If you experience k9s locking up or misbehaving, as per the above👆 you know what to do now and as customary we will do our best to address them quickly to get you back up and running! Thank you for your support, kindness and patience! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2476](https://github.com/derailed/k9s/issues/2476) Pods are not displayed for the selected namespace. Hopefully! * [#2471](https://github.com/derailed/k9s/issues/2471) Shell autocomplete functions do not work correctly --- ## 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!! * [#2480](https://github.com/derailed/k9s/pull/2480) Adding system arch to nodes view * [#2477](https://github.com/derailed/k9s/pull/2477) Shell autocomplete for k8s flags --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.7.md ================================================ # Release v0.31.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! 😱 More aftermath... 😱 Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really cut it for us to zero in ;( Everyone has slightly different settings/platforms so every little bits of info helps with the resolves even if seemingly irrelevant. --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2488](https://github.com/derailed/k9s/issues/2488) linux_amd64 "--kubeconfig" not working on v0.31.6 --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.8.md ================================================ # Release v0.31.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/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) ## Maintenance Release! Thank you all for pitching in and helping flesh out issues!! Please make sure to add gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really cut it for us to zero in ;( Everyone has slightly different settings/platforms so every little bits of info helps with the resolves even if seemingly irrelevant. --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## ♫ Sounds Behind The Release ♭ Going back to the classics... * [Ambulance Blues - Neil Young](https://www.youtube.com/watch?v=bCQisTEdBwY) * [Christopher Columbus - Burning Spear](https://www.youtube.com/watch?v=5qbMKTY_Cr0) * [Feelin' the Same - Clinton Fearon](https://www.youtube.com/watch?v=aRPF2Yta_cs) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Andreas Frangopoulos](https://github.com/qubeio) * [Tu Hoang](https://github.com/rebyn) * [Shoshin Nikita](https://github.com/ShoshinNikita) * [Dima Altukhov](https://github.com/alt-dima) * [wpbeckwith](https://github.com/wpbeckwith) * [a-thomas-22](https://github.com/a-thomas-22) * [kmath313](https://github.com/kmath313) * [Jörgen](https://github.com/wthrbtn) * [Eckl, Máté](https://github.com/ecklm) * [Jacky Nguyen](https://github.com/nktpro) * [Chris Bradley](https://github.com/chrisbradleydev) * [Vytautas Kubilius](https://github.com/vytautaskubilius) * [Patrick Christensen](https://github.com/BuriedStPatrick) * [Ollie Lowson](https://github.com/ollielowson-wcbs) * [Mike Macaulay](https://github.com/mmacaula) * [David Birks](https://github.com/dbirks) * [James Hounshell](https://github.com/jameshounshell) * [elapse2039](https://github.com/elapse2039) * [Vinicius Xavier](https://github.com/vinixaavier) * [Phuc Phung](https://github.com/Foxhound401) * [ollielowson](https://github.com/ollielowson) > Sponsorship cancellations since the last release: **4!** 🥹 --- ## Resolved Issues * [#2527](https://github.com/derailed/k9s/issues/2527) Multiple k9s panels open in parallel for the same cluster breaks config.yaml * [#2520](https://github.com/derailed/k9s/issues/2520) pods with init container with restartPolicy: Always stay in Init status * [#2501](https://github.com/derailed/k9s/issues/2501) Cannot add plugins to helm scope bug * [#2492](https://github.com/derailed/k9s/issues/2492) API Resources "carry over" between contexts, causing errors if they share shortnames * [#1158](https://github.com/derailed/k9s/issues/1158) Removing a helm release incorrectly determines the namespace of resources * [#1033](https://github.com/derailed/k9s/issues/1033) Helm delete deletes only the helm entry but not the deployment --- ## 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!! * [#2509](https://github.com/derailed/k9s/pull/2509) Fix Toggle Faults filtering * [#2511](https://github.com/derailed/k9s/pull/2511) adding the f command to pf extender view * [#2518](https://github.com/derailed/k9s/pull/2518) Added defaultsToFullScreen flag for Live/Details view,logs --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.31.9.md ================================================ # Release v0.31.9 ## 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! ```text S .-'-. o __| F `\ S `-,-`--._ `\ [] .->' X `|-' `=/ (__/_ / \_, ` _) `----; | ``` ⛔️ WE HAVE A PIPER DOWN! I REPEAT PIPER IS DOWN!! ⛔️ Popeye is undergoing heavy surgery at the moment so I had to break the bridge. If you dig Popeye please run the binary separately for the time being. I'll post another message here once the spinach formula upgrade is successful! Also please make sure to add the gory details to issues ie relevant configs, debug logs, etc... Comments like: `same here!` or `me to!` doesn't really cut it for us to zero in ;( Everyone has slightly different settings/platforms so every little bits of info helps with the resolves even if seemingly irrelevant. 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.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## ♫ Sounds Behind The Release ♭ Ushered or Taylored out? * [Rough God Goes Riding - Van Morrison](https://www.youtube.com/watch?v=-kGrwRlJxcM) * [Walk On - John Hiatt](https://www.youtube.com/watch?v=YVdMyeTQCkw) * [On The Beach - Neil Young](https://www.youtube.com/watch?v=KBVde75e4sU) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Francis Lalonde](https://github.com/f-lalonde) * [e-conomic a/s](https://github.com/e-conomic) > Sponsorship cancellations since the last release: **2!** 🥹 --- ## Resolved Issues * [#2540](https://github.com/derailed/k9s/issues/2540) Option --write not functional * [#2538](https://github.com/derailed/k9s/issues/2538) Opening screen dumps (sd) in K9s results in Failed to launch editor error message * [#2536](https://github.com/derailed/k9s/issues/2536) Recent namespaces are lost when changing context * [#2535](https://github.com/derailed/k9s/issues/2535) Namespaced configmap edit fails for user with RoleBinding to a role that allows it * [#2532](https://github.com/derailed/k9s/issues/2532) Sporadic crashes (Maybe??) --- ## 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!! * [#2541](https://github.com/derailed/k9s/pull/2541) Add Rose Pine moon and dawn variants to skins * [#2531](https://github.com/derailed/k9s/pull/2531) fix the --write flag * [#2516](https://github.com/derailed/k9s/pull/2516) Added defaultsToFullScreen flag for Live/Details view,logs --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.0.md ================================================ # Release v0.32.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) ## Maintenance Release! A lot of refactors, perf improvements (crossing fingers+toes!) and general spring cleaning items in this release. Thus I expect a bit of `disturbance in the farce` given the major code churns, so please beware! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Justin Reid](https://github.com/jmreid) * [Danni](https://github.com/danninov) * [Robert Krahn](https://github.com/rksm) * [Hao Ke](https://github.com/kehao95) * [PH](https://github.com/raphael-com-ph) > Sponsorship cancellations since the last release: **9!!** 🥹 --- ## Resolved Issues * [#2569](https://github.com/derailed/k9s/issues/2569) k9s panics on start if the main config file (config.yml) is owned by root * [#2568](https://github.com/derailed/k9s/issues/2568) kube context in running k9s is no longer sticky, during kubectx context switch * [#2560](https://github.com/derailed/k9s/issues/2560) Namespace/Settings keeps resetting * [#2557](https://github.com/derailed/k9s/issues/2557) [Feature]: Sort CRDs by their group * [#1462](https://github.com/derailed/k9s/issues/1462) k9s running very slowly when opening namespace with 13k pods (maybe??) --- ## 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!! * [#2564](https://github.com/derailed/k9s/pull/2564) Add everforest skins * [#2558](https://github.com/derailed/k9s/pull/2558) feat: sort by role in node list view * [#2554](https://github.com/derailed/k9s/pull/2554) Added context to the debug command for debug-container plugin * [#2554](https://github.com/derailed/k9s/pull/2554) Correctly respect the KUBECACHEDIR env var * [#2546](https://github.com/derailed/k9s/pull/2546) Use configured log fgColor to print log markers --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.1.md ================================================ # Release v0.32.1 ## 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! The aftermath ;( --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2584](https://github.com/derailed/k9s/issues/2584) Transfer of file doesn't detect corruption * [#2579](https://github.com/derailed/k9s/issues/2579) Default sorting behavior changed to descending sort bug --- ## 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!! * [#2586](https://github.com/derailed/k9s/pull/2586) Properly initialize key actions in picker --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.2.md ================================================ # Release v0.32.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) ## Maintenance Release! Mo aftermath ;( --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2582](https://github.com/derailed/k9s/issues/2582) Slowness due to client-side throttling in v0.32.0 (Maybe??) * [#2593](https://github.com/derailed/k9s/issues/2593) Popeye not working in 0.32.X --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.3.md ================================================ # Release v0.32.3 ## 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! Look like v0.32.2 drop release bins are toast. So m'o aftermath ;( --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2584](https://github.com/derailed/k9s/issues/2584) Transfer of file doesn't detect corruption (with feelings!) --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.4.md ================================================ # Release v0.32.4 ## 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! --- ## ♫ Sounds Behind The Release ♭ Thinking of all you at KubeCon Paris!! May I suggest a nice glass of `cold Merlote` or other fine grape juices from my country? * [Le Gorille - George Brassens](https://www.youtube.com/watch?v=KVfwvk_yVyA) * [Les Funerailles D'antan (Love this guy!) - George Brassens](https://www.youtube.com/watch?v=bwb5k4k2EMc) * [Poinconneur Des Lilas - Serge Gainsbourg](https://www.youtube.com/watch?v=eWkWCFzkOvU) * [Mon Legionaire (Yup! same guy??) - Serge Gainsbourg](https://www.youtube.com/watch?v=gl8gopryqWI) * [Les Cornichons - Nino Ferrer](https://www.youtube.com/watch?v=N7JSW4NhM8I) * [Paris s'eveille - Jacques Dutronc](https://www.youtube.com/watch?v=3WcCg6rm3uM) --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2608](https://github.com/derailed/k9s/issues/2608) Make the sanitize feature easier to use * [#2605](https://github.com/derailed/k9s/issues/2605) Built-in shortcuts being overridden by plugins result in excessive logging * [#2604](https://github.com/derailed/k9s/issues/2604) Ability to mark a plugin as Dangerous/destructive * [#2592](https://github.com/derailed/k9s/issues/2592) "list access denied" when switching contexts within k9s since 0.32.0 --- ## 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!! * [#2621](https://github.com/derailed/k9s/pull/2621) Fix snap build --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.5.md ================================================ # Release v0.32.5 ## 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! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2734](https://github.com/derailed/k9s/issues/2734) Incorrect pod containers displayed when using custom resource columns * [#2733](https://github.com/derailed/k9s/issues/2733) Toggle Wide and Toggle Faults broken for PDB view * [#2656](https://github.com/derailed/k9s/issues/2656) nil pointer dereference when switching contexts * [#2617](https://github.com/derailed/k9s/issues/2617) Plugin command execution output --- ## 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!! * [#2736](https://github.com/derailed/k9s/pull/2736) fix view sorting being reset * [#2732](https://github.com/derailed/k9s/pull/2732) use policy/v1 instead of policy/v1beta1 * [#2728](https://github.com/derailed/k9s/pull/2728) feat: add pool col to node view * [#2718](https://github.com/derailed/k9s/pull/2718) fix: jump to namespaceless owner reference * [#2711](https://github.com/derailed/k9s/pull/2711) Add plugins for argo-rollouts * [#2700](https://github.com/derailed/k9s/pull/2700) feat: allow jumping to the owner of the resource * [#2699](https://github.com/derailed/k9s/pull/2699) Added cert-manager and openssl plugins * [#2711](https://github.com/derailed/k9s/pull/2711) Add plugins for argo-rollouts * [#2698](https://github.com/derailed/k9s/pull/2698) fix: job color based on failures (#2686) * [#2685](https://github.com/derailed/k9s/pull/2685) feat: support cluster and cmp view * [#2678](https://github.com/derailed/k9s/pull/2678) fix: do not hard-code path to kubectl in jq plugin * [#2676](https://github.com/derailed/k9s/pull/2676) Add kanagawa skin * [#2666](https://github.com/derailed/k9s/pull/2666) save config when closing k9s with ctrl-c * [#2644](https://github.com/derailed/k9s/pull/2644) Allow overwriting plugin output with command's stdout --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.6.md ================================================ # Release v0.32.6 ## 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! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2947](https://github.com/derailed/k9s/issues/2947) CTRL+Z causes k9s to crash * [#2938](https://github.com/derailed/k9s/issues/2938) Critical Vulnerability CVE-2024-41110 in v26.0.1 of docker included in k9s * [#2929](https://github.com/derailed/k9s/issues/2929) conflicting plugins shortcuts * [#2896](https://github.com/derailed/k9s/issues/2896) Add a plugin to disable/enable a keda ScaledObject * [#2811](https://github.com/derailed/k9s/issues/2811) Dockerfile build step fails due to misaligned Go versions (1.21.5 vs 1.22.0) * [#2767](https://github.com/derailed/k9s/issues/2767) Manually triggered jobs don't get automatically cleaned up * [#2761](https://github.com/derailed/k9s/issues/2761) Enable "jump to owner" for more kinds * [#2754](https://github.com/derailed/k9s/issues/2754) Plugins not loaded/shown in UI * [#2747](https://github.com/derailed/k9s/issues/2747) Combining context and namespace switching only works sporadically (e.g. ":pod foo-ns @ctx-dev") * [#2746](https://github.com/derailed/k9s/issues/2746) k9s does not display "[::]" string in its logs * [#2738](https://github.com/derailed/k9s/issues/2738) "Faults" view should show all Terminating pods --- ## 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!! * [#2937](https://github.com/derailed/k9s/pull/2937) Adding Argo Rollouts plugin version for PowerShell * [#2935](https://github.com/derailed/k9s/pull/2935) fix: show all terminating pods in Faults view (#2738) * [#2933](https://github.com/derailed/k9s/pull/2933) chore: broken url in build-status tag in the readme.md * [#2932](https://github.com/derailed/k9s/pull/2932) fix: add kubeconfig if k9s is launched with --kubeconfig * [#2930](https://github.com/derailed/k9s/pull/2930) fixed conflicting plugin shortcuts, and added 2 new plugins * [#2927](https://github.com/derailed/k9s/pull/2927) Fix "Mark Range": reduce maximum namespaces in favorites, fix shadowing of ctrl+space * [#2926](https://github.com/derailed/k9s/pull/2926) chore(plugins,remove-finalizers): make sure the resources api group is respected * [#2921](https://github.com/derailed/k9s/pull/2921) feat: Add plugins for kubectl node-shell * [#2920](https://github.com/derailed/k9s/pull/2920) eat: added StartupProbes status (S) to the PROBES column in the container render * [#2914](https://github.com/derailed/k9s/pull/2914) Adding eks-node-viewer plugin * [#2898](https://github.com/derailed/k9s/pull/2898) Add argocd plugin to community plugins * [#2896](https://github.com/derailed/k9s/pull/2896) feat(2896): Add toggle keda plugin * [#2890](https://github.com/derailed/k9s/pull/2890) Update README.md * [#2881](https://github.com/derailed/k9s/pull/2881) Fix Mark-Range command: ensure that NS Favorite doesn't exceed the limit * [#2861](https://github.com/derailed/k9s/pull/2861) chore: fix function name * [#2856](https://github.com/derailed/k9s/pull/2856) fix internal/render/hpa.go merge issue * [#2848](https://github.com/derailed/k9s/pull/2848) Include sidecar containers requests and limits * [#2844](https://github.com/derailed/k9s/pull/2844) Update README GO Version Required * [#2830](https://github.com/derailed/k9s/pull/2830) update tview to fix log escaping problem completely * [#2822](https://github.com/derailed/k9s/pull/2822) Adding HolmesGPT plugin * [#2821](https://github.com/derailed/k9s/pull/2821) Add a spark-operator plugin * [#2817](https://github.com/derailed/k9s/pull/2817) Add comment about Escape keybinding * [#2812](https://github.com/derailed/k9s/pull/2812) fix: align build image Go version with go.mod * [#2795](https://github.com/derailed/k9s/pull/2795) add new plugin current-ctx-terminal * [#2791](https://github.com/derailed/k9s/pull/2791) Add leading space to Kubernetes context suggestions * [#2789](https://github.com/derailed/k9s/pull/2789) Create kubectl-get-in-shell.yaml * [#2788](https://github.com/derailed/k9s/pull/2788) Update README.md plugin format * [#2787](https://github.com/derailed/k9s/pull/2787) Update helm-purge.yaml * [#2786](https://github.com/derailed/k9s/pull/2786) Update README.md with plugin dangerous field * [#2780](https://github.com/derailed/k9s/pull/2780) install copyright file into correct location * [#2775](https://github.com/derailed/k9s/pull/2775) fix freebsd build failure * [#2780](https://github.com/derailed/k9s/pull/2780) install copyright file into correct location * [#2772](https://github.com/derailed/k9s/pull/2772) proper handle OwnerReference for manually created job * [#2771](https://github.com/derailed/k9s/pull/2771) feat: add duplik8s plugin * [#2770](https://github.com/derailed/k9s/pull/2770) feat: allow plugins block in plugin files * [#2765](https://github.com/derailed/k9s/pull/2765) fix: Shellin -> ShellIn * [#2763](https://github.com/derailed/k9s/pull/2763) enable "jump to owner" for more kinds * [#2755](https://github.com/derailed/k9s/pull/2755) Loki plugin * [#2751](https://github.com/derailed/k9s/pull/2751) container logs should be escaped when printed * [#2750](https://github.com/derailed/k9s/pull/2750) fix: should switching ctx before ns --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.32.7.md ================================================ # Release v0.32.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! --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#2970](https://github.com/derailed/k9s/issues/2970) Ctrl-z on events view causes runtime error in v0.32.6 * [#2969](https://github.com/derailed/k9s/issues/2969) When using impersonation user information and permissions not preserved when switching context * [#2966](https://github.com/derailed/k9s/issues/2966) Go to the Contexts page and filter, contexts that are matched will be filtered ou * [#2962](https://github.com/derailed/k9s/issues/2962) Small colour/filtering related bug * [#2961](https://github.com/derailed/k9s/issues/2961) Drain node with the -disable-eviction * [#2958](https://github.com/derailed/k9s/issues/2958) Restart count in container view associated with the wrong container * [#2945](https://github.com/derailed/k9s/issues/2945) Could we add ServiceAccount Column in v1/POD view --- ## 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!! * [#2968](https://github.com/derailed/k9s/pull/2968) Update go version to 1.23.X in README * [#2964](https://github.com/derailed/k9s/pull/2964) feat(dao,used-by-cmd): check imagePullSecrets as well * [#2960](https://github.com/derailed/k9s/pull/2960) Put log levels in order in cmd help --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.0.md ================================================ # Release v0.40.0 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Glory Box - Portishead](https://www.youtube.com/watch?v=4qQyUi4zfDs) * [Hit Me With Your Rhythm Stick - Ian Dury And The BlockHeads](https://www.youtube.com/watch?v=0WGVgfjnLqc) * [Cupidon s'en fout! - George Brassens](https://www.youtube.com/watch?v=a-RlZLfIeKM) * [Shipbuilding - Elvis Costello](https://www.youtube.com/watch?v=dVhjRqBM5uw) * [Low Sun - Hermanos Gutierrez](https://www.youtube.com/watch?v=ubaJbw7hkeQ) --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Panfactum](https://github.com/Panfactum) * [Bastian Pätzold](https://github.com/bastianpaetzold) * [Mikita Vazhnik](https://github.com/Vazhnik) * [Jacob Salway](https://github.com/jacobsalway) * [Eckard Mühlich](https://github.com/eckardnet) * [Luke](https://github.com/lukepatrick) * [tomasbanet](https://github.com/tomasbanet) * [Robin Opletal](https://github.com/fourstepper) * [Euroblaze](https://github.com/euroblaze) * [Jack Daniels](https://github.com/dkr91) * [decafcode](https://github.com/decafcode) * [Guillaume Copin](https://github.com/GuillaumeCo) * [Lokalise](https://github.com/lokalise) * [Gustavo Bini](https://github.com/gustavobini) * [JMSwag](https://github.com/JMSwag) * [Daniel Gospodinow](https://github.com/danielgospodinow) * [Klaviyo](https://github.com/klaviyo) * [Paul Farver](https://github.com/PaulFarver) > Sponsorship cancellations since the last release: **12!** 🥹 ## 🎉 Feature Release code name: Colon Blow! 🎈 We are pretty stocked about this drop (hopefully...) as we've fully enabled custom columns support in K9s! Historically, one could customize the view for a given resource by adding a definition in `views.yaml`. From there one could change sort order and re-arrange the standard column layout. Several folks voiced the need to add a column for a given label/annotation or any other fields available on a resource. To date, this wasn't possible 😳 So... without further ado, let see what we can now do with `Custom Views` ding dang deal! It all starts with a few new directives available in `views.yaml` ### A Refresher... Customize a pod view and ensure age, ns and name appear first and sort by age descending. > NOTE! You no longer need to list out all columns. > The remaining columns will be automatically filled from the standard columns. ```yaml # Usual biz... views: v1/pods: # specify the gvr you want to customize aka group/version/resource sortColumn: AGE:desc # set the default ordering to ascending (asc) or descending (desc) columns: # tell the view which columns to display and in which order - AGE # ensure age, ns and name are the first 3 cols and backfill the rest - NAMESPACE - NAME - READY|H # => NEW! Do not display the READY column - NODE|W # => NEW! Show node column only on wide - IP|WR # => NEW! Pull the ip column and right align it in wide mode only ``` ## Colon Blow! Say your pods comes standard with a label `blee` and you want to show it while in pod view. ```yaml # Pull labels/annotations views: v3/freds: sortColumn: NAMESPACE:dsc columns: - NAMESPACE - NAME - BLEE:.metadata.labels.blee # => NEW! Pull values from a label or an annotation using json parser # expression similar mechanic as kubectl -o custom-columns - ZORG:.spec.zips[?(@.type == 'zorg')].ip|WR # => NEW! Same deal with a json exp + but align right and show wide only ``` ## TLDR... As you can see the CustomView feature adds a few new semantics on this drop. You can now use the following shape for columns definition `COL_NAME<:json_parse_expression><|column attributes>` The `:json_parse_expression` is optional. The column attributes are as follows: * `T` -> time column indicator * `N` -> number column indicator * `W` -> turns on wide column aka only shows while in wide mode. Defaults to the standard resource definition when present. * `H` -> Hides the column * `L` -> Left align (default) * `R` -> Right align When certain columns are not present in the custom view, K9s will pull the standard column definition and merge the columns. This allows user to specify and order which columns they want to see first without having to define every single columns from the default resource representation. If you do not wish to see all these columns you can add them to your custom view definition and either specify `|W` or `|H` to `wide` it or `hide` it. > 📢 Still work in progress so your mileage may vary! > This feature will likely need additional TLC. > Your feedback on this will be much appreciated and we will iterate as usual to ensure it vorks as prescribed... 🙀 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 Colon Blow Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3064](https://github.com/derailed/k9s/issues/3064) Question: brew formula k9s vs derailed/k9s/k9s * [#3061](https://github.com/derailed/k9s/issues/3061) k9s not opening active namespace or namespace specified via -n * [#3044](https://github.com/derailed/k9s/issues/3044) CRDs are loaded incorrectly into metadata registry, cause sporadic "Jump Owner" issues * [#2995](https://github.com/derailed/k9s/issues/2995) Latest image on quay.io contains "failed" kubectl binary --- ## 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!! * [#3065](https://github.com/derailed/k9s/pull/3065) Fixed trimming of favorite namespaces in Config * [#3063](https://github.com/derailed/k9s/pull/3063) Updating CVE dependencies * [#3062](https://github.com/derailed/k9s/pull/3062) feat: use kubectl events for plugin watch-events * [#3060](https://github.com/derailed/k9s/pull/3060) Rename "delete local data" checkbox description in drain dialog * [#3046](https://github.com/derailed/k9s/pull/3046) Strict unmarshal for plugin files * [#3045](https://github.com/derailed/k9s/pull/3045) fix: CRD loading: trim group suffix from CRD name * [#3043](https://github.com/derailed/k9s/pull/3043) Fix K9S_EDITOR * [#3041](https://github.com/derailed/k9s/pull/3041) Fix Flux trace plugin command * [#3038](https://github.com/derailed/k9s/pull/2038) fix check e != nil but return a nil value error err * [#3026](https://github.com/derailed/k9s/pull/3026) Fix typos * [#3018](https://github.com/derailed/k9s/pull/3018) fix: coloring of rose-pine for values of log options * [#3017](https://github.com/derailed/k9s/pull/3017) feat: add helm diff plugin * [#3009](https://github.com/derailed/k9s/pull/3009) fix(argo-rollouts plugin): resolve improper piping in watch command * [#2996](https://github.com/derailed/k9s/pull/2996) Bump version of netshoot image in debug-container plugin * [#2994](https://github.com/derailed/k9s/pull/2994) fix kubectl url and fail build on download errors * [#2986](https://github.com/derailed/k9s/pull/2986) plugin/trace-dns: Trace DNS requests using Inspektor Gadget * [#2985](https://github.com/derailed/k9s/pull/2985) feat(plugins/crossplane): change to crossplane cli & add crossplane-watch * [#2986](https://github.com/derailed/k9s/pull/2986) plugin/trace-dns: Trace DNS requests using Inspektor Gadget --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.1.md ================================================ # Release v0.40.1 ## 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! 😳 Aye! Buzz kill on the 0.40.0 aftermath... 🙀 👻 Likely additional `disturbance in the farce` might be observed. Thank you all for giving v0.40.0 a rinse and reporting back!! 😍 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3113](https://github.com/derailed/k9s/issues/3113) 0.40.0 can't retain temporary view sort * [#3111](https://github.com/derailed/k9s/issues/3111) k9s can't describe or print YAML for HPAs in all namespaces view * [#2966](https://github.com/derailed/k9s/issues/2966) Go to the Contexts page and filter, contexts that are matched will be filtered ou * [#2962](https://github.com/derailed/k9s/issues/2962) Small colour/filtering related bug * [#2961](https://github.com/derailed/k9s/issues/2961) Drain node with the -disable-eviction * [#2958](https://github.com/derailed/k9s/issues/2958) Restart count in container view associated with the wrong container * [#2945](https://github.com/derailed/k9s/issues/2945) Could we add ServiceAccount Column in v1/POD view --- ## 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!! * [#3094](https://github.com/derailed/k9s/pull/3094) Log in as root to the node. * [#3033](https://github.com/derailed/k9s/pull/3033) Skip cache invalidation on failed connection * [#2965](https://github.com/derailed/k9s/pull/2965) Make menu foreground style configurable through skins * [#2952](https://github.com/derailed/k9s/pull/2952) A modest attempt to improve the logo aesthetics * [#2833](https://github.com/derailed/k9s/pull/2833) allow scaling custom resource * [#2799](https://github.com/derailed/k9s/pull/2799) feat(app): add history navigation with [ and ], most recent command with - * [#2719](https://github.com/derailed/k9s/pull/2719) fix: stop table header cells from being selectable * [#2865](https://github.com/derailed/k9s/pull/2865) Feature/DisableAutoscroll --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.10.md ================================================ # Release v0.40.10 ## 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! Sounds like I did hose plugins after all... With feelings! * Refactored plugins implementation, hopefully we didn't hose them 😳 * Updated plugins docs * Apparently when it comes to icons, I've chosen... poorly 🙀 Updated `write` icon 🔓->✍️, hopefully for the better 👀?? ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3202](https://github.com/derailed/k9s/issues/3202) 0.40.8 breaks plugins loading --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.11.md ================================================ # Release v0.40.11 ## 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! ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3226](https://github.com/derailed/k9s/issues/3226) Filter view will show mess when filtering some string * [#3224](https://github.com/derailed/k9s/issues/3224) Respect kubectl.kubernetes.io/default-container annotation * [#3222](https://github.com/derailed/k9s/issues/3222) Option to Display Resource Names Without API Version Prefix * [#3210](https://github.com/derailed/k9s/issues/3210) Description line is buggy --- ## 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!! * [#3237](https://github.com/derailed/k9s/pull/3237) fix: List CRDs which has k8s.io in their names * [#3223](https://github.com/derailed/k9s/pull/3223) Fixed skin config ref of in_the_navy to in-the-navy * [#3110](https://github.com/derailed/k9s/pull/3110) feat: add splashless option to suppress splash screen on start --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.2.md ================================================ # Release v0.40.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) ## Maintenance Release! 😳 Aye! Buzz kill on the 0.40.0 aftermath ;( Hot fix in progress...🙀 👻 Likely additional `disturbance in the farce` might be observed. Thank you all for giving this drop a rinse and reporting back!! 😍 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3116](https://github.com/derailed/k9s/issues/3116) Cannot list custom CRD's since v0.40.1 --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.3.md ================================================ # Release v0.40.3 ## 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! 😳 Aye! Buzz kill on the 0.40.0 aftermath ;( Hot fix in progress...🙀 👻 Likely additional `disturbance in the farce` might be observed. Thank you all for giving this drop a rinse and reporting back!! 😍 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3116](https://github.com/derailed/k9s/issues/3116) Cannot list custom CRD's since v0.40.1 (with feelings!) --- © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.4.md ================================================ # Release v0.40.4 ## 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! 😳 Aye! Continued Buzz kill on the 0.40.0 aftermath 🙀 👻 Likely additional `disturbance in the farce` might be observed. Thank you all for giving this drop a rinse and reporting back!! 😍 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3122](https://github.com/derailed/k9s/issues/3122) Viewing events is no longer sorted by LAST SEEN * [#3120](https://github.com/derailed/k9s/issues/3120) Custom View Column Mismatch in K9s: Shuffled Values in Pods View * [#3119](https://github.com/derailed/k9s/issues/3119) Custom Views Fail to Load with % in Column Names * [#3118](https://github.com/derailed/k9s/issues/3118) selecting an alias, the wrong resources are being shown --- ## 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!! * [#3123](https://github.com/derailed/k9s/pull/3123) update regex to allow '%' and '/' in column names © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.5.md ================================================ # Release v0.40.5 ## 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! 😳 Aye! Continued Buzz kill on the 0.40.0 aftermath 🙀 👻 Likely additional `disturbance in the farce` might be observed. Thank you all for giving this drop a rinse and reporting back!! 😍 --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3131](https://github.com/derailed/k9s/issues/3131) Singular versions of native Kubernetes resource names no longer work * [#3119](https://github.com/derailed/k9s/issues/3119) Custom Views Fail to Load with % in Column Names (with feelings!) --- ## 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!! * [#3123](https://github.com/derailed/k9s/pull/3123) update regex to allow '%' and '/' in column names © 2024 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.6.md ================================================ # Release v0.40.6 ## 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! ### Breaking change Moved `portForwardAddress` out of clusterXXX/contextYYY/config.yaml and into the main K9s config file. This is a global preference based on your setup vs a cluster/context specific attribute. K9s will nag you in the logs if a specific context config still contains this attribute but should not prevent the configuration load. ### Column Blow Reloaded! We've added another property to the custom view. You can now also specify namespace specific column definition for a given resource. For instance, view pods in any namespace using one configuration and view pods in `fred` namespace using an alternate configuration. ```yaml # views.yaml views: # Using this for all pods... v1/pods: columns: - AGE - NAMESPACE|WR # => 🌚 Specifies the NAMESPACE column to be right aligned and only visible while in wide mode - ZORG:.metadata.labels.fred\.io\.kubernetes\.blee # => 🌚 extract fred.io.kubernetes.blee label into it's own column - BLEE:.metadata.annotations.blee|R # => 🌚 extract annotation blee into it's own column and right align it - NAME - IP - NODE - STATUS - READY - MEM/RL|S # => 🌚 Overrides std resource default wide attribute via `S` for `Show` - '%MEM/R|' # => NOTE! column names with non alpha names need to be quoted as columns must be strings! # Use this instead for pods in namespace `fred` v1/pods@fred: # => 🌚 New v0.40.6! Customize columns for a given resource and namespace! columns: - AGE - NAMESPACE|WR ``` Additionally, we've added a new column attribute aka `Show` -> `S`. This allows you to now override the default resource column `wide` attribute when set. --- ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3179](https://github.com/derailed/k9s/issues/3179) Resource name with full api or group displayed (somewhere and sometimes) * [#3178](https://github.com/derailed/k9s/issues/3178) Cronjobs with the same name in different namespaces appear together * [#3176](https://github.com/derailed/k9s/issues/3176) Trigger all marked cronjobs * [#3162](https://github.com/derailed/k9s/issues/3162) Context configs: context directory created under wrong cluster after context switch * [#3161](https://github.com/derailed/k9s/issues/3161) Force wide-only columns to appear outside of wide view * [#3147](https://github.com/derailed/k9s/issues/3147) Prompt style is overriden by body * [#3139](https://github.com/derailed/k9s/issues/3139) CPU/R:L and MEM/R:L columns invalid in views.yaml * [#3138](https://github.com/derailed/k9s/issues/3138) Subresources are not shown correctly in the RBAC view --- ## 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!! * [#3182](https://github.com/derailed/k9s/pull/3182) fix: Use the latest version when downloading the Ubuntu deb file * [#3168](https://github.com/derailed/k9s/pull/3168) fix(history): handle cases where special commands add their command their command to the history * [#3159](https://github.com/derailed/k9s/pull/3159) Added hard contrast gruvbox skins * [#3149](https://github.com/derailed/k9s/pull/3149) fix: Pass grv on gotoResource as a String to fix non-default apiGroup list * [#3149](https://github.com/derailed/k9s/pull/3149) Add externalsecrets plugin * [#3140](https://github.com/derailed/k9s/pull/3140) fix: Avoid false positive matches in enableRegion (#3093) © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.7.md ================================================ # Release v0.40.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! 🙀 Hoy! Hosed custom view loading in v0.40.6... ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues --- ## 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!! * [#3186](https://github.com/derailed/k9s/pull/3186) fix: allow absolute paths for the 'dir' command © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.8.md ================================================ # Release v0.40.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/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) ## Maintenance Release! ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3193](https://github.com/derailed/k9s/issues/3193) Feature Request: View aliases with custom columns * [#3192](https://github.com/derailed/k9s/issues/3192) Allow readonly indicator respect the noIcons configuration * [#3153](https://github.com/derailed/k9s/issues/3153) Add support for bunyan logging --- ## 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!! * [#3186](https://github.com/derailed/k9s/pull/3186) fix: allow absolute paths for the 'dir' command * [#3152](https://github.com/derailed/k9s/pull/3152) Feat: Add plugin support for parsing logs with bunyan cli #3153 © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.40.9.md ================================================ # Release v0.40.9 ## 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! * Refactored plugins implementation, hopefully we didn't hose them 😳 * Updated plugins docs * Apparently when it comes to icons, I've chosen... poorly 🙀 Updated `write` icon 🔓->✍️, hopefully for the better 👀?? ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3202](https://github.com/derailed/k9s/issues/3202) 0.40.8 breaks plugins loading --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.0.md ================================================ # Release v0.50 ## Notes Thank you to all that contributed with flushing out issues and enhancements for K9s! I'll try to mark some of these issues as fixed. But if you don't mind grab the latest rev and see if we're happier with some of the fixes! If you've filed an issue please help me verify and close. Your support, kindness and awesome suggestions to make K9s better are, as ever, very much noted and appreciated! Also big thanks to all that have allocated their own time to help others on both slack and on this repo!! As you may know, K9s is not pimped out by corps with deep pockets, thus if you feel K9s is helping your Kubernetes journey, please consider joining our [sponsorship program](https://github.com/sponsors/derailed) and/or make some noise on social! [@kitesurfer](https://twitter.com/kitesurfer) On Slack? Please join us [K9slackers](https://join.slack.com/t/k9sers/shared_invite/enQtOTA5MDEyNzI5MTU0LWQ1ZGI3MzliYzZhZWEyNzYxYzA3NjE0YTk1YmFmNzViZjIyNzhkZGI0MmJjYzhlNjdlMGJhYzE2ZGU1NjkyNTM) --- ## ♫ Sounds Behind The Release ♭ * [Afterimage - Justice](https://www.youtube.com/watch?v=9zBJlLbkfzA) * [This Is The Day - The The](https://www.youtube.com/watch?v=qBF3YqUzYRc) ## 5-O, 5-0... Spring Cleaning In Effect! ☠️ Careful on this upgrade! 🏴‍☠️ We've gone thru lots of code revamp/refactor on this drop, so mileage may vary!! ### K9s Slow? It looks like K9s performance took a dive in the wrong direction circa v0.40.x releases. Took a big perf/cleanup pass to improve perf and think this release should help a lot (famous last words...) > NOTE! As my dear granny use to say: `You can't cook a great meal without trashing the kitchen`, > So likely I have broken a few things in the process. So thread carefully and report back! ### Now with Super Column Blow! By general demand, juice up custom views! In a feature we like to refer to as `Super Column Blow...` As of this drop, you can go full `Chuck Norris` and sprinkle some of your JQ_FU with you custom views. For example... ```yaml # views.yaml views: v1/pods: sortColumn: NAME:asc columns: - AGE - NAMESPACE - NAME - IMG-VERSION:.spec.containers[0].image|split(":")|.[-1]|R # => Grab the main container image name and pull the image version # => out into the `IMG-VERSION` right aligned column ``` > NOTE: ☢️ This is very much experimental! Not all JQ queries features are supported! > (See https://github.com/itchyny/gojq for the details!) ## Videos Are In The Can! Please dial [K9s Channel](https://www.youtube.com/channel/UC897uwPygni4QIjkPCpgjmw) for up coming content... * [K9s v0.40.0 -Column Blow- Sneak peek](https://youtu.be/iy6RDozAM4A) * [K9s v0.31.0 Configs+Sneak peek](https://youtu.be/X3444KfjguE) * [K9s v0.30.0 Sneak peek](https://youtu.be/mVBc1XneRJ4) * [Vulnerability Scans](https://youtu.be/ULkl0MsaidU) --- ## Resolved Issues * [#3226](https://github.com/derailed/k9s/issues/3226) Filter view will show mess when filtering some string * [#3224](https://github.com/derailed/k9s/issues/3224) Respect kubectl.kubernetes.io/default-container annotation * [#3222](https://github.com/derailed/k9s/issues/3222) Option to Display Resource Names Without API Version Prefix * [#3210](https://github.com/derailed/k9s/issues/3210) Description line is buggy --- ## 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!! * [#3237](https://github.com/derailed/k9s/pull/3237) fix: List CRDs which has k8s.io in their names * [#3223](https://github.com/derailed/k9s/pull/3223) Fixed skin config ref of in_the_navy to in-the-navy * [#3110](https://github.com/derailed/k9s/pull/3110) feat: add splashless option to suppress splash screen on start --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.1.md ================================================ # Release v0.51 ## 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 things in the clean up process 😳 Apologizes for the `disruption in the farce`. Hopefully happier on v0.50.1... 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 * [#3262](https://github.com/derailed/k9s/issues/3262) Crash when no shellPod is defined in config file * [#3261](https://github.com/derailed/k9s/issues/3261) aliases with namespace and/or labels produce an error * [#3258](https://github.com/derailed/k9s/issues/3258) mac silicon 0.50.0 runtime error * [#3257](https://github.com/derailed/k9s/issues/3257) pods are reported to run on nodes they are not running on * [#3256](https://github.com/derailed/k9s/issues/3256) Pods view seems broken in 0.50.0 * [#3255](https://github.com/derailed/k9s/issues/3255) Custom view does not work randomly --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.10.md ================================================ # Release v0.50.10 ## 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! --- ## A Word From Our Sponsors... To all the good folks below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [rufusshrestha](https://github.com/rufusshrestha) * [Ovidijus Balkauskas](https://github.com/Stogas) * [Konrad Konieczny](https://github.com/Psyhackological) * [Serit Tromsø](https://github.com/serit) * [Dennis](https://github.com/dennisTGC) * [LinPr](https://github.com/LinPr) * [franzXaver987](https://github.com/franzXaver987) * [Drew Showalter](https://github.com/one19) * [Sandylen](https://github.com/Sandylen) * [Uriah Carpenter](https://github.com/uriahcarpenter) * [Vector Group](https://github.com/vectorgrp) * [Stefan Roman](https://github.com/katapultcloud) * [Phillip](https://github.com/Loki-Afro) * [Lasse Bang Mikkelsen](https://github.com/lassebm) > Sponsorship cancellations since the last release: **19!** 🥹 --- ## Resolved Issues * [#3541](https://github.com/derailed/k9s/issues/3541) ServiceAccount RBAC Rules not displayed if RoleBinding subject doesn't specify namespace * [#3535](https://github.com/derailed/k9s/issues/3535) Current Release process will cause code changes been reverted * [#3525](https://github.com/derailed/k9s/issues/3525) k9s suspends when launching foreground plugin * [#3495](https://github.com/derailed/k9s/issues/3495) Regression: filtering no long works with aliases * [#3478](https://github.com/derailed/k9s/issues/3478) High Disk and CPU usage when imageScans Is enabled in K9s * [#3470](https://github.com/derailed/k9s/issues/3470) Aliases for pods with unequal (!=) label filters not working * [#3466](https://github.com/derailed/k9s/issues/3466) Shared GPU (nvidia.com/gpu.shared) is shown as n/a on K9s node view * [#3455](https://github.com/derailed/k9s/issues/3455) memory command not found --- ## 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!! * [#3558](https://github.com/derailed/k9s/pull/3558) refactor(duplik8s): consolidate duplicate resource commands and updat… * [#3555](https://github.com/derailed/k9s/pull/3555) feat: add dup plugin * [#3543](https://github.com/derailed/k9s/pull/3543) Make "flux trace" more generic * [#3536](https://github.com/derailed/k9s/pull/3536) Add flux-operator resources to flux plugin * [#3528](https://github.com/derailed/k9s/pull/3528) feat(plugins): add pvc debug container plugin * [#3517](https://github.com/derailed/k9s/pull/3517) Feature/refresh rate * [#3516](https://github.com/derailed/k9s/pull/3516) Fixes flickering/jumping issue in context suggestions caused by inconsistent spacing behavior * [#3515](https://github.com/derailed/k9s/pull/3515) Fix/suppress init no resources warning * [#3513](https://github.com/derailed/k9s/pull/3513) fix: Color PV row according to its STATUS column * [#3513](https://github.com/derailed/k9s/pull/3513) fix: Color PV row according to its STATUS column * [#3505](https://github.com/derailed/k9s/pull/3505) docs: Add installation method with gah * [#3503](https://github.com/derailed/k9s/pull/3503) fix(logs): enhance log streaming with retry mechanism and error handling * [#3489](https://github.com/derailed/k9s/pull/3489) feat: Add context deletion functionality * [#3487](https://github.com/derailed/k9s/pull/3487) fsupport core group resources in k9s/plugins/watch-events.yaml * [#3485](https://github.com/derailed/k9s/pull/3485) Add disable-self-subject-access-reviews flag to disable can-i check… * [#3464](https://github.com/derailed/k9s/pull/3464) fix: get-all command in get all plugin --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.11.md ================================================ # Release v0.50.11 ## 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! Oh dear! Hopefully we're happier on this drop?? Apologizes for the `disturbance in the farce`... ## Resolved Issues * [#3567](https://github.com/derailed/k9s/issues/3567) Extra slash '/' added when filtering from the command prompt * [#3566](https://github.com/derailed/k9s/issues/3566) unable to switch context or use k9s after upgrade to 0.50.10 --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.12.md ================================================ # Release v0.50.12 ## 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 * [#3570](https://github.com/derailed/k9s/issues/3570) 0.50.11 could not display any resources * [#3562](https://github.com/derailed/k9s/issues/3562) Can't delete namespace * [#3547](https://github.com/derailed/k9s/issues/3547) Error message from admission controller --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.13.md ================================================ # Release v0.50.13 ## 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 * [#3587](https://github.com/derailed/k9s/issues/3587) UI doesn't show any updates when restarting a Deployment * [#3585](https://github.com/derailed/k9s/issues/3585) abbreviation sec for secret not working * [#3584](https://github.com/derailed/k9s/issues/3584) Show managed fields doesn't show them * [#3583](https://github.com/derailed/k9s/issues/3583) Cannot open shell to pods without node read access as of 0.50.12 * [#3577](https://github.com/derailed/k9s/issues/3577) Log view is broken as of v0.50.10 * [#3574](https://github.com/derailed/k9s/issues/3574) Aliases for pods with label filters not working --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.14.md ================================================ # Release v0.50.14 ## 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 big corporations with deep pockets, thus if you feel K9s is helping in 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! Sponsorships are dropping at an alarming rate which puts this project in the red. This is becoming a concern and sad not to mention unsustainable ;( If you dig `k9s` and want to help the project, please consider `paying it forward!` and don't become just another `satisfied, non paying customer!`. K9s does take a lot of my `free` time to maintain, enhance and keep the light on. Many cool ideas are making it straight to the `freezer` as I just can't budget them in. I know many of you work for big corporations, so please put in the word/work and have them help us out via sponsorships or other means. Thank you! ## Resolved Issues * [#3608](https://github.com/derailed/k9s/issues/3608) k9s crashes when :namespaces used * [#3606](https://github.com/derailed/k9s/issues/3606) Xray not working anymore on (possible) v0.50.X * [#3594](https://github.com/derailed/k9s/issues/3594) Show pod yaml - Boom!! cannot deep copy int * [#3591](https://github.com/derailed/k9s/issues/3591) Accept suggestion with enter (without having to "tab") * [#3576](https://github.com/derailed/k9s/issues/3576) Custom alias/view not working anymore since v0.50.10 --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.15.md ================================================ # Release v0.50.15 ## 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 big corporations with deep pockets, thus if you feel K9s is helping in 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! Sponsorships are dropping at an alarming rate which puts this project in the red. This is becoming a concern and sad not to mention unsustainable ;( If you dig `k9s` and want to help the project, please consider `paying it forward!` and don't become just another `satisfied, non paying customer!`. K9s does take a lot of my `free` time to maintain, enhance and keep the light on. Many cool ideas are making it straight to the `freezer` as I just can't budget them in. I know many of you work for big corporations, so please put in the word/work and have them help us out via sponsorships or other means. Thank you! ## Resolved Issues * [#3591](https://github.com/derailed/k9s/issues/3591) REVERTED! Accept suggestion with enter (without having to "tab") --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.16.md ================================================ # Release v0.50.16 ## 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 big corporations with deep pockets, thus if you feel K9s is helping in 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! Sponsorships are dropping at an alarming rate which puts this project in the red. This is becoming a concern and sad not to mention unsustainable ;( If you dig `k9s` and want to help the project, please consider `paying it forward!` and don't become just another `satisfied, non paying customer!`. K9s does take a lot of my `free` time to maintain, enhance and keep the light on. Many cool ideas are making it straight to the `freezer` as I just can't budget them in. I know many of you work for big corporations, so please put in the word/work and have them help us out via sponsorships or other means. Thank you! ### Warp Speed Scotty! As of this drop, we are introducing `namespace warp` via shortcut `w`. This affords to view all resources of that type based on the currently selected resource namespace. This command is only available on namespaced resources. For example, if you are in pod view and select pod-xxx in namespace `bozo`, hitting `w` will `warp` you to view all pods in namespace `bozo`. ## Resolved Issues * [#3629](https://github.com/derailed/k9s/issues/3629) vulnerability in k9s project * [#3621](https://github.com/derailed/k9s/issues/3621) Switching to ":Deploy" sends you to deployments from namespace "deploy" * [#3620](https://github.com/derailed/k9s/issues/3620) Trying to show pod yaml using custom views.yaml crashes k9s * [#3608](https://github.com/derailed/k9s/issues/3608) k9s crashes when :namespaces used * [#3601](https://github.com/derailed/k9s/issues/3601) Can't delete namespace * [#3595](https://github.com/derailed/k9s/issues/3595) Toggle Namespace Filter in Pods View with 'n' Key * [#3576](https://github.com/derailed/k9s/issues/3576) Custom alias/view not working anymore since v0.50.10 --- ## 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!! * [#3625](https://github.com/derailed/k9s/pull/3625) fix: debug-container plugin when KUBECONFIG has multiple files * [#3623](https://github.com/derailed/k9s/pull/3623) bugfix: fix panic in BenchmarkPodRender by using NewPod() constructor * [#3619](https://github.com/derailed/k9s/pull/3619) feat: plugin to list all resources by namespace * [#3605](https://github.com/derailed/k9s/pull/3605) browser: do not prevent redraw when connection unavailable * [#3600](https://github.com/derailed/k9s/pull/3600) fix(shell): set linux when OS detection fails * [#3588](https://github.com/derailed/k9s/pull/3588) fix: do not error out of shellIn if OS detection fails --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.17.md ================================================ # Release v0.50.18 ## Notes 🥳🎉 Happy new year fellow k9ers!🎊🍾 Hoping 2026 will bring good health and great success to you and yours... 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 big corporations with deep pockets, thus if you feel K9s is helping in 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) --- ## ♫ Sounds Behind The Release ♭ * [A cool new way - Joe Satriani](https://www.youtube.com/watch?v=4apA948yOF0) * [Song for you - Ray Charles](https://www.youtube.com/watch?v=CzAkTrDiXxg) * [Kill the pain - SYZGYX](https://www.youtube.com/watch?v=5XuvMhHZorw&list=RD5XuvMhHZorw&start_radio=1) --- ## Maintenance Release! Sponsorships are dropping at an alarming rate which puts this project in the red. This is becoming a concern and sad not to mention unsustainable ;( If you dig `k9s` and want to help the project, please consider `paying it forward!` and don't become just another `satisfied, non paying customer!`. K9s does take a lot of my `free` time to maintain, enhance and keep the light on. Many cool ideas are making it straight to the `freezer` as I just can't budget them in. I know many of you work for big corporations, so please put in the word/work and have them help us out via sponsorships or other means. Thank you! ## A Word From Our Sponsors... To all the good folks and orgs below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Philomena Yeboah](https://github.com/PhilomenaYeboah1989) * [Kilian](https://github.com/kaerbr) * [TVRiddle](https://github.com/TVRiddle) * [Tom Morelly](https://github.com/FalcoSuessgott) * [Nikhil Narayen](https://github.com/nnarayen) * [Andrew Aadland](https://github.com/DaemonDude23) * [Radek](https://github.com/radvym) * [Timothée Gerber](https://github.com/TimotheeGerber) * [Matthias](https://github.com/maetthu) * [DKB](https://github.com/dkb-bank) ❤️ * [Kraken Tech](https://github.com/kraken-tech) * [Daniel](https://github.com/sherlock7402) * [Fred Loucks](https://github.com/fullmetal-fred) * [Patricia Mascaros](https://github.com/ccong2586) * [Qube Research & Technologies](https://github.com/qube-rt) * [Michel Jung](https://github.com/micheljung) * [Ümüt Özalp](https://github.com/uozalp) * [Nathan Papapietro](https://github.com/npapapietro) * [Oleksandr Podze](https://github.com/dasdy) * [Lee Jones](https://github.com/leejones) * [tsahlif](https://github.com/tshalif) * [Jean-Christophe Amiel](https://github.com/jcamiel) * [Lightspark](https://github.com/lightsparkdev) * [egs-hub](https://github.com/egs-hub) ❤️ * [Sergey](https://github.com/malsatin) * [Wynter Inc](https://github.com/copytesting) * [Jen Norris](https://github.com/tnorris) * [Joakim-Byg](https://github.com/Joakim-Byg) * [Oleksandr Podze](https://github.com/dasdy) * [Lee Jones](https://github.com/leejones) > Sponsorship cancellations since the last release: **17!** 🥹 ## Resolved Issues * [#3765](https://github.com/derailed/k9s/issues/3765) quay.io docker images not up to date but referenced in README.md * [#3762](https://github.com/derailed/k9s/issues/3762) Copy multiple selected items * [#3751](https://github.com/derailed/k9s/issues/3751) Improve visual distinction for cordoned nodes in Node view * [#3735](https://github.com/derailed/k9s/issues/3735) Cannot decode secret if there is no get permissions for all secrets * [#3708](https://github.com/derailed/k9s/issues/3708) Editing a single Namespace opens the editor with a list of all Namespaces * [#3731](https://github.com/derailed/k9s/issues/3731) feat: add neat plugin * [#3735](https://github.com/derailed/k9s/issues/3735) Cannot decode secret if there is no get permissions for all secrets * [#3708](https://github.com/derailed/k9s/issues/3708) Editing a single Namespace opens the editor with a list of all Namespaces --- ## 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!! * [#3763](https://github.com/derailed/k9s/pull/3763) feat: enable copying multiple resource, namespace names to clipboard * [#3760](https://github.com/derailed/k9s/pull/3760) fix: Editing a single Namespace opens the editor with a list of all Namespaces * [#3756](https://github.com/derailed/k9s/pull/3756) feat: Add reconcile plugin for Flux instances * [#3755](https://github.com/derailed/k9s/pull/3755) fix: panic on 'jump to owner' of reflect.Value.Elem on zero Value * [#3753](https://github.com/derailed/k9s/pull/3553) feat: add plugins for argo workflows * [#3750](https://github.com/derailed/k9s/pull/3750) fix: Flux trace plugin shortcut conflict by changing to Shift-Q * [#3749](https://github.com/derailed/k9s/pull/3749) feat: add dark/light theme inversion using Oklch * [#3739](https://github.com/derailed/k9s/pull/3739) chore: refine LabelsSelector comment to match function behavior * [#3738](https://github.com/derailed/k9s/pull/3738) feat: add symlink handle for plugin directory * [#3720](https://github.com/derailed/k9s/pull/3720) fix(internal/render): ensure object is deep copied before realization in Render method * [#3704](https://github.com/derailed/k9s/pull/3704) Allow k9s to start without a valid Kubernetes context * [#3699](https://github.com/derailed/k9s/pull/3699) feat(pulse): map hjkl to navigate as help shows * [#3697](https://github.com/derailed/k9s/pull/3697) Issue 3667 Fix * [#3696](https://github.com/derailed/k9s/pull/3696) fix for scale option appearing on non-scalable resources * [#3690](https://github.com/derailed/k9s/pull/3690) feat: add support for scaling HPA targets * [#3671](https://github.com/derailed/k9s/pull/3671) fix fails to modify or delete namespaces using RBAC * [#3669](https://github.com/derailed/k9s/pull/3669) feat: logs column lock * [#3663](https://github.com/derailed/k9s/pull/3663) Map Q to "Back" * [#3859](https://github.com/derailed/k9s/pull/3859) fix: update busybox image version to 1.37.0 in configuration files * [#3650](https://github.com/derailed/k9s/pull/3650) Sort all columns * [#3458](https://github.com/derailed/k9s/pull/3458) Document how to install on Fedora --- © 2026 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.18.md ================================================ # Release v0.50.18 ## Notes 🥳🎉 Happy new year fellow k9ers!🎊🍾 Hoping 2026 will bring good health and great success to you and yours... 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 big corporations with deep pockets, thus if you feel K9s is helping in 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) --- ## ♫ Sounds Behind The Release ♭ * [A cool new way - Joe Satriani](https://www.youtube.com/watch?v=4apA948yOF0) * [Song for you - Ray Charles](https://www.youtube.com/watch?v=CzAkTrDiXxg) * [Kill the pain - SYZGYX](https://www.youtube.com/watch?v=5XuvMhHZorw&list=RD5XuvMhHZorw&start_radio=1) --- ## Maintenance Release! Oops! I've missed a PR in the v0.50.17 excitement ;( Dropping v0.50.18 with feelings... Sponsorships are dropping at an alarming rate which puts this project in the red. This is becoming a concern and sad not to mention unsustainable ;( If you dig `k9s` and want to help the project, please consider `paying it forward!` and don't become just another `satisfied, non paying customer!`. K9s does take a lot of my `free` time to maintain, enhance and keep the light on. Many cool ideas are making it straight to the `freezer` as I just can't budget them in. I know many of you work for big corporations, so please put in the word/work and have them help us out via sponsorships or other means. Thank you! --- ## A Word From Our Sponsors... To all the good folks and orgs below that opted to `pay it forward` and join our sponsorship program, I salute you!! * [Philomena Yeboah](https://github.com/PhilomenaYeboah1989) * [Kilian](https://github.com/kaerbr) * [TVRiddle](https://github.com/TVRiddle) * [Tom Morelly](https://github.com/FalcoSuessgott) * [Nikhil Narayen](https://github.com/nnarayen) * [Andrew Aadland](https://github.com/DaemonDude23) * [Radek](https://github.com/radvym) * [Timothée Gerber](https://github.com/TimotheeGerber) * [Matthias](https://github.com/maetthu) * [DKB](https://github.com/dkb-bank) ❤️ * [Kraken Tech](https://github.com/kraken-tech) * [Daniel](https://github.com/sherlock7402) * [Fred Loucks](https://github.com/fullmetal-fred) * [Patricia Mascaros](https://github.com/ccong2586) * [Qube Research & Technologies](https://github.com/qube-rt) * [Michel Jung](https://github.com/micheljung) * [Ümüt Özalp](https://github.com/uozalp) * [Nathan Papapietro](https://github.com/npapapietro) * [Oleksandr Podze](https://github.com/dasdy) * [Lee Jones](https://github.com/leejones) * [tsahlif](https://github.com/tshalif) * [Jean-Christophe Amiel](https://github.com/jcamiel) * [Lightspark](https://github.com/lightsparkdev) * [egs-hub](https://github.com/egs-hub) ❤️ * [Sergey](https://github.com/malsatin) * [Wynter Inc](https://github.com/copytesting) * [Jen Norris](https://github.com/tnorris) * [Joakim-Byg](https://github.com/Joakim-Byg) * [Oleksandr Podze](https://github.com/dasdy) * [Lee Jones](https://github.com/leejones) > Sponsorship cancellations since the last release: **17!** 🥹 ## Resolved Issues * [#3765](https://github.com/derailed/k9s/issues/3765) quay.io docker images not up to date but referenced in README.md * [#3762](https://github.com/derailed/k9s/issues/3762) Copy multiple selected items * [#3751](https://github.com/derailed/k9s/issues/3751) Improve visual distinction for cordoned nodes in Node view * [#3735](https://github.com/derailed/k9s/issues/3735) Cannot decode secret if there is no get permissions for all secrets * [#3708](https://github.com/derailed/k9s/issues/3708) Editing a single Namespace opens the editor with a list of all Namespaces * [#3731](https://github.com/derailed/k9s/issues/3731) feat: add neat plugin * [#3735](https://github.com/derailed/k9s/issues/3735) Cannot decode secret if there is no get permissions for all secrets * [#3708](https://github.com/derailed/k9s/issues/3708) Editing a single Namespace opens the editor with a list of all Namespaces * [#3649](https://github.com/derailed/k9s/issues/3649) Improved Column Sorting --- ## 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!! * [#3763](https://github.com/derailed/k9s/pull/3763) feat: enable copying multiple resource, namespace names to clipboard * [#3760](https://github.com/derailed/k9s/pull/3760) fix: Editing a single Namespace opens the editor with a list of all Namespaces * [#3756](https://github.com/derailed/k9s/pull/3756) feat: Add reconcile plugin for Flux instances * [#3755](https://github.com/derailed/k9s/pull/3755) fix: panic on 'jump to owner' of reflect.Value.Elem on zero Value * [#3753](https://github.com/derailed/k9s/pull/3553) feat: add plugins for argo workflows * [#3750](https://github.com/derailed/k9s/pull/3750) fix: Flux trace plugin shortcut conflict by changing to Shift-Q * [#3749](https://github.com/derailed/k9s/pull/3749) feat: add dark/light theme inversion using Oklch * [#3739](https://github.com/derailed/k9s/pull/3739) chore: refine LabelsSelector comment to match function behavior * [#3738](https://github.com/derailed/k9s/pull/3738) feat: add symlink handle for plugin directory * [#3720](https://github.com/derailed/k9s/pull/3720) fix(internal/render): ensure object is deep copied before realization in Render method * [#3704](https://github.com/derailed/k9s/pull/3704) Allow k9s to start without a valid Kubernetes context * [#3699](https://github.com/derailed/k9s/pull/3699) feat(pulse): map hjkl to navigate as help shows * [#3697](https://github.com/derailed/k9s/pull/3697) Issue 3667 Fix * [#3696](https://github.com/derailed/k9s/pull/3696) fix for scale option appearing on non-scalable resources * [#3690](https://github.com/derailed/k9s/pull/3690) feat: add support for scaling HPA targets * [#3671](https://github.com/derailed/k9s/pull/3671) fix fails to modify or delete namespaces using RBAC * [#3669](https://github.com/derailed/k9s/pull/3669) feat: logs column lock * [#3663](https://github.com/derailed/k9s/pull/3663) Map Q to "Back" * [#3661](https://github.com/derailed/k9s/pull/3661) refactor: remove unused sorting key bindings from various views * [#3859](https://github.com/derailed/k9s/pull/3859) fix: update busybox image version to 1.37.0 in configuration files * [#3650](https://github.com/derailed/k9s/pull/3650) Sort all columns * [#3458](https://github.com/derailed/k9s/pull/3458) Document how to install on Fedora --- © 2026 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.2.md ================================================ # 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) ================================================ FILE: change_logs/release_v0.50.3.md ================================================ # Release v0.50.3 ## 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! A bit more code spring cleaning/TLC and address a few bugs: 1. [RBAC View] Fix issue bombing out on RBAC cluster roles 2. [Custom Views] Fix issue with parsing `jq` filters and bombing out (Big Thanks to Pierre for flagging it!) --- ## 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!! * [#3273](https://github.com/derailed/k9s/pull/3273) k9s plugin scopes containers issue * [#3169](https://github.com/derailed/k9s/pull/3169) feat: pass context and token flags to kubectl exec commands --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.4.md ================================================ # Release v0.50.4 ## 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 * [#3288](https://github.com/derailed/k9s/issues/3288) Resource search doesn't filter by name in custom view * [#3286](https://github.com/derailed/k9s/issues/3286) K9S doesn't understand matchExpressions selector in Deployment to Pod navigation * [#3285](https://github.com/derailed/k9s/issues/3285) Rollout Restart method conflicts with GitOps (Flux, ArgoCD) * [#3283](https://github.com/derailed/k9s/issues/3283) Deployment status showing wrong ready state * [#3278](https://github.com/derailed/k9s/issues/3278) k9s doesn't honor the --namespace parameter --- ## 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!! * [#3292](https://github.com/derailed/k9s/pull/3292) fix: respect insecure flag when switch context * [#3277](https://github.com/derailed/k9s/pull/3277) feat: add hostPathVolume (docker) * [#3253](https://github.com/derailed/k9s/pull/3253) fix: set default request timeout to 120 seconds * [#2866](https://github.com/derailed/k9s/pull/2866) Feature/default_view --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.5.md ================================================ # Release v0.50.5 ## 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 * [#3328](https://github.com/derailed/k9s/issues/3328) Pod overview shows wrong number of running containers with sidecar init-container * [#3309](https://github.com/derailed/k9s/issues/3309) [0.50.4] k9s crashes when attempting to load logs * [#3301](https://github.com/derailed/k9s/issues/3301) Port Forward deleted without UI notification when forwarding to wrong port * [#3294](https://github.com/derailed/k9s/issues/3294) [0.50.4] k9s crashes when filtering based on labels * [#3278](https://github.com/derailed/k9s/issues/3278) k9s doesn't honor the --namespace parameter --- ## 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!! * [#3311](https://github.com/derailed/k9s/pull/3311) Fix concurrent read writes * [#3310](https://github.com/derailed/k9s/pull/3310) fix: use full path of date to avoid conflict * [#3308](https://github.com/derailed/k9s/pull/3308) Show replicasets from deployment view * [#3300](https://github.com/derailed/k9s/pull/3300) fix: truncate label selector input to max length * [#3296](https://github.com/derailed/k9s/pull/3296) fix: update time format in logging to 24-hour format --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.6.md ================================================ # Release v0.50.6 ## 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 * [#3334](https://github.com/derailed/k9s/issues/3334) Watcher failed for events.k8s.io/v1/events -- expecting a meta table but got *unstructured.Unstructure --- ## 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!! * [#3332](https://github.com/derailed/k9s/pull/3332) fix: pre-check for get permissions only on port-forward * [#3311](https://github.com/derailed/k9s/pull/3311) Fix concurrent read writes * [#3310](https://github.com/derailed/k9s/pull/3310) fix: use full path of date to avoid conflict --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0) ================================================ FILE: change_logs/release_v0.50.7.md ================================================ # Release v0.50.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/zt-3360a389v-ElLHrb0Dp1kAXqYUItSAFA) ## Maintenance Release! --- ## Resolved Issues * [#3435](https://github.com/derailed/k9s/issues/3435) noExitOnCtrlC * [#3434](https://github.com/derailed/k9s/issues/3434) Pulses - navigation selection is invisible * [#3424](https://github.com/derailed/k9s/issues/3424) feat: Add GPUs to nodes view * [#3422](https://github.com/derailed/k9s/issues/3422) Changing ns should keep current kind * [#3412](https://github.com/derailed/k9s/issues/3412) "Toggle Decode" for secret has no effect * [#3406](https://github.com/derailed/k9s/issues/3406) History navigation: new view after going back should truncate forward history * [#3398](https://github.com/derailed/k9s/issues/3398) Improve the UX of FieldManager field on restart * [#3383](https://github.com/derailed/k9s/issues/3383) Triggering a CronJob fails as Unauthorized since v0.50 * [#3406](https://github.com/derailed/k9s/issues/3406) History navigation: new view after going back should truncate forward history --- ## 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!! * [#3433](https://github.com/derailed/k9s/pull/3433) feat(plugins): add kube-metrics plugin * [#3371](https://github.com/derailed/k9s/pull/3371) Add context to condition in keda-toggle plugin * [#3347](https://github.com/derailed/k9s/pull/3347) Fix GVR Title option in readme * [#3346](https://github.com/derailed/k9s/pull/3346) revert: #3322 --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: change_logs/release_v0.50.8.md ================================================ # 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)# ================================================ FILE: change_logs/release_v0.50.9.md ================================================ # Release v0.50.9 ## 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 * [#3459](https://github.com/derailed/k9s/issues/3459) Update the tablewriter dependency + implementation * [#3458](https://github.com/derailed/k9s/issues/3458) Unable to switch namespaces with 0.50.8 --- ## 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!! * [#3460](https://github.com/derailed/k9s/pull/3460) update to tablewriter v1 apis --- © 2025 Imhotep Software LLC. All materials licensed under [Apache v2.0](http://www.apache.org/licenses/LICENSE-2.0)# ================================================ FILE: cmd/info.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "fmt" "log/slog" "os" "github.com/derailed/k9s/internal/color" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) func infoCmd() *cobra.Command { return &cobra.Command{ Use: "info", Short: "List K9s configurations info", RunE: printInfo, } } func printInfo(*cobra.Command, []string) error { if err := config.InitLocs(); err != nil { return err } const fmat = "%-27s %s\n" printLogo(color.Cyan) printTuple(fmat, "Version", version, color.Cyan) printTuple(fmat, "Config", config.AppConfigFile, color.Cyan) printTuple(fmat, "Custom Views", config.AppViewsFile, color.Cyan) printTuple(fmat, "Plugins", config.AppPluginsFile, color.Cyan) printTuple(fmat, "Hotkeys", config.AppHotKeysFile, color.Cyan) printTuple(fmat, "Aliases", config.AppAliasesFile, color.Cyan) printTuple(fmat, "Skins", config.AppSkinsDir, color.Cyan) printTuple(fmat, "Context Configs", config.AppContextsDir, color.Cyan) printTuple(fmat, "Logs", config.AppLogFile, color.Cyan) printTuple(fmat, "Benchmarks", config.AppBenchmarksDir, color.Cyan) printTuple(fmat, "ScreenDumps", getScreenDumpDirForInfo(), color.Cyan) return nil } func printLogo(c color.Paint) { for _, l := range ui.LogoSmall { _, _ = fmt.Fprintln(out, color.Colorize(l, c)) } _, _ = fmt.Fprintln(out) } // getScreenDumpDirForInfo get default screen dump config dir or from config.K9sConfigFile configuration. func getScreenDumpDirForInfo() string { if config.AppConfigFile == "" { return config.AppDumpsDir } f, err := os.ReadFile(config.AppConfigFile) if err != nil { slog.Error("Unable to reads k9s config file", slogs.Error, err) return config.AppDumpsDir } var cfg config.Config if err := yaml.Unmarshal(f, &cfg); err != nil { slog.Error("Unable to unmarshal k9s config file", slogs.Error, err) return config.AppDumpsDir } if cfg.K9s == nil { return config.AppDumpsDir } return cfg.K9s.AppScreenDumpDir() } ================================================ FILE: cmd/info_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func Test_getScreenDumpDirForInfo(t *testing.T) { tests := map[string]struct { k9sConfigFile string expectedScreenDumpDir string }{ "withK9sConfigFile": { k9sConfigFile: "testdata/k9s.yaml", expectedScreenDumpDir: "/tmp", }, "withEmptyK9sConfigFile": { k9sConfigFile: "", expectedScreenDumpDir: config.AppDumpsDir, }, "withInvalidK9sConfigFilePath": { k9sConfigFile: "invalid", expectedScreenDumpDir: config.AppDumpsDir, }, "withScreenDumpDirEmptyInK9sConfigFile": { k9sConfigFile: "testdata/k9s1.yaml", expectedScreenDumpDir: config.AppDumpsDir, }, } for k := range tests { u := tests[k] t.Run(k, func(t *testing.T) { initK9sConfigFile := config.AppConfigFile config.AppConfigFile = u.k9sConfigFile assert.Equal(t, u.expectedScreenDumpDir, getScreenDumpDirForInfo()) config.AppConfigFile = initK9sConfigFile }) } } ================================================ FILE: cmd/root.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "errors" "fmt" "log/slog" "os" "runtime/debug" "strings" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/color" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/view" "github.com/lmittmann/tint" "github.com/mattn/go-colorable" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/tools/clientcmd/api" ) const ( appName = config.AppName shortAppDesc = "A graphical CLI for your Kubernetes cluster management." longAppDesc = "K9s is a CLI to view and manage your Kubernetes clusters." ) var _ data.KubeSettings = (*client.Config)(nil) var ( version, commit, date = "dev", "dev", client.NA k9sFlags *config.Flags k8sFlags *genericclioptions.ConfigFlags rootCmd = &cobra.Command{ Use: appName, Short: shortAppDesc, Long: longAppDesc, RunE: run, } out = colorable.NewColorableStdout() ) type flagError struct{ err error } func (e flagError) Error() string { return e.err.Error() } func init() { if err := config.InitLogLoc(); err != nil { fmt.Printf("Fail to init k9s logs location %s\n", err) } rootCmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return flagError{err: err} }) rootCmd.AddCommand(versionCmd(), infoCmd()) initK9sFlags() initK8sFlags() } // Execute root command. func Execute() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func run(*cobra.Command, []string) error { if err := config.InitLocs(); err != nil { return err } logFile, err := os.OpenFile( *k9sFlags.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, data.DefaultFileMod, ) if err != nil { return fmt.Errorf("log file %q init failed: %w", *k9sFlags.LogFile, err) } defer func() { if logFile != nil { _ = logFile.Close() } }() defer func() { if err := recover(); err != nil { slog.Error("Boom!! k9s init failed", slogs.Error, err) slog.Error("", slogs.Stack, string(debug.Stack())) printLogo(color.Red) fmt.Printf("%s", color.Colorize("Boom!! ", color.Red)) fmt.Printf("%v.\n", err) } }() slog.SetDefault(slog.New(tint.NewHandler(logFile, &tint.Options{ Level: parseLevel(*k9sFlags.LogLevel), TimeFormat: time.RFC3339, }))) cfg, err := loadConfiguration() if err != nil { slog.Warn("Fail to load global/context configuration", slogs.Error, err) } app := view.NewApp(cfg) if app.Config.K9s.DefaultView != "" { app.Config.SetActiveView(app.Config.K9s.DefaultView) } if err := app.Init(version, int(*k9sFlags.RefreshRate)); err != nil { return err } if err := app.Run(); err != nil { return err } if view.ExitStatus != "" { return fmt.Errorf("view exit status %s", view.ExitStatus) } return nil } func loadConfiguration() (*config.Config, error) { slog.Info("🐶 K9s starting up...") k8sCfg := client.NewConfig(k8sFlags) k9sCfg := config.NewConfig(k8sCfg) var errs error conn, err := client.InitConnection(k8sCfg, slog.Default()) if err != nil { errs = errors.Join(errs, err) } k9sCfg.SetConnection(conn) if err := k9sCfg.Load(config.AppConfigFile, false); err != nil { errs = errors.Join(errs, err) } k9sCfg.K9s.Override(k9sFlags) if err := k9sCfg.Refine(k8sFlags, k9sFlags, k8sCfg); err != nil { slog.Error("Fail to refine k9s config", slogs.Error, err) errs = errors.Join(errs, err) } // Try to access server version if that fail. Connectivity issue? if !conn.CheckConnectivity() { errs = errors.Join(errs, fmt.Errorf("cannot connect to context: %s", k9sCfg.K9s.ActiveContextName())) } if !conn.ConnectionOK() { slog.Warn("💣 Kubernetes connectivity toast!") errs = errors.Join(errs, fmt.Errorf("k8s connection failed for context: %s", k9sCfg.K9s.ActiveContextName())) } else { slog.Info("✅ Kubernetes connectivity OK") } if err := k9sCfg.Save(false); err != nil { slog.Error("K9s config save failed", slogs.Error, err) errs = errors.Join(errs, err) } return k9sCfg, errs } func parseLevel(level string) slog.Level { switch level { case "debug": return slog.LevelDebug case "warn": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } } func initK9sFlags() { k9sFlags = config.NewFlags() rootCmd.Flags().Float32VarP( k9sFlags.RefreshRate, "refresh", "r", config.DefaultRefreshRate, "Specify the default refresh rate as a float (sec)", ) rootCmd.Flags().StringVarP( k9sFlags.LogLevel, "logLevel", "l", config.DefaultLogLevel, "Specify a log level (error, warn, info, debug)", ) rootCmd.Flags().StringVarP( k9sFlags.LogFile, "logFile", "", config.AppLogFile, "Specify the log file", ) rootCmd.Flags().BoolVar( k9sFlags.Headless, "headless", false, "Turn K9s header off", ) rootCmd.Flags().BoolVar( k9sFlags.Logoless, "logoless", false, "Turn K9s logo off", ) rootCmd.Flags().BoolVar( k9sFlags.Crumbsless, "crumbsless", false, "Turn K9s crumbs off", ) rootCmd.Flags().BoolVar( k9sFlags.Splashless, "splashless", false, "Turn K9s splash screen off", ) rootCmd.Flags().BoolVar( k9sFlags.Invert, "invert", false, "Invert skin (dark to light, light to dark), preserving colors", ) rootCmd.Flags().BoolVarP( k9sFlags.AllNamespaces, "all-namespaces", "A", false, "Launch K9s in all namespaces", ) rootCmd.Flags().StringVarP( k9sFlags.Command, "command", "c", config.DefaultCommand, "Overrides the default resource to load when the application launches", ) rootCmd.Flags().BoolVar( k9sFlags.ReadOnly, "readonly", false, "Sets readOnly mode by overriding readOnly configuration setting", ) rootCmd.Flags().BoolVar( k9sFlags.Write, "write", false, "Sets write mode by overriding the readOnly configuration setting", ) rootCmd.Flags().StringVar( k9sFlags.ScreenDumpDir, "screen-dump-dir", "", "Sets a path to a dir for a screen dumps", ) rootCmd.Flags() } func initK8sFlags() { k8sFlags = genericclioptions.NewConfigFlags(client.UsePersistentConfig) rootCmd.Flags().StringVar( k8sFlags.KubeConfig, "kubeconfig", "", "Path to the kubeconfig file to use for CLI requests", ) rootCmd.Flags().StringVar( k8sFlags.Timeout, "request-timeout", "", "The length of time to wait before giving up on a single server request", ) rootCmd.Flags().StringVar( k8sFlags.Context, "context", "", "The name of the kubeconfig context to use", ) rootCmd.Flags().StringVar( k8sFlags.ClusterName, "cluster", "", "The name of the kubeconfig cluster to use", ) rootCmd.Flags().StringVar( k8sFlags.AuthInfoName, "user", "", "The name of the kubeconfig user to use", ) rootCmd.Flags().StringVarP( k8sFlags.Namespace, "namespace", "n", "", "If present, the namespace scope for this CLI request", ) initAsFlags() initCertFlags() initK8sFlagCompletion() } func initAsFlags() { rootCmd.Flags().StringVar( k8sFlags.Impersonate, "as", "", "Username to impersonate for the operation", ) rootCmd.Flags().StringArrayVar( k8sFlags.ImpersonateGroup, "as-group", []string{}, "Group to impersonate for the operation", ) } func initCertFlags() { rootCmd.Flags().BoolVar( k8sFlags.Insecure, "insecure-skip-tls-verify", false, "If true, the server's caCertFile will not be checked for validity", ) rootCmd.Flags().StringVar( k8sFlags.CAFile, "certificate-authority", "", "Path to a cert file for the certificate authority", ) rootCmd.Flags().StringVar( k8sFlags.KeyFile, "client-key", "", "Path to a client key file for TLS", ) rootCmd.Flags().StringVar( k8sFlags.CertFile, "client-certificate", "", "Path to a client certificate file for TLS", ) rootCmd.Flags().StringVar( k8sFlags.BearerToken, "token", "", "Bearer token for authentication to the API server", ) } type ( k8sPickerFn[T any] func(cfg *api.Config) map[string]T completeFn func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) ) func initK8sFlagCompletion() { _ = rootCmd.RegisterFlagCompletionFunc("context", k8sFlagCompletion(func(cfg *api.Config) map[string]*api.Context { return cfg.Contexts })) _ = rootCmd.RegisterFlagCompletionFunc("cluster", k8sFlagCompletion(func(cfg *api.Config) map[string]*api.Cluster { return cfg.Clusters })) _ = rootCmd.RegisterFlagCompletionFunc("user", k8sFlagCompletion(func(cfg *api.Config) map[string]*api.AuthInfo { return cfg.AuthInfos })) _ = rootCmd.RegisterFlagCompletionFunc("namespace", func(_ *cobra.Command, _ []string, s string) ([]string, cobra.ShellCompDirective) { conn := client.NewConfig(k8sFlags) if c, err := client.InitConnection(conn, slog.Default()); err == nil { if nss, err := c.ValidNamespaceNames(); err == nil { return filterFlagCompletions(nss, s) } } return nil, cobra.ShellCompDirectiveError }) } func k8sFlagCompletion[T any](picker k8sPickerFn[T]) completeFn { return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { conn := client.NewConfig(k8sFlags) cfg, err := conn.RawConfig() if err != nil { slog.Error("K8s raw config getter failed", slogs.Error, err) } return filterFlagCompletions(picker(&cfg), toComplete) } } func filterFlagCompletions[T any](m map[string]T, s string) ([]string, cobra.ShellCompDirective) { cc := make([]string, 0, len(m)) for name := range m { if strings.HasPrefix(name, s) { cc = append(cc, name) } } return cc, cobra.ShellCompDirectiveNoFileComp } ================================================ FILE: cmd/testdata/k9s.yaml ================================================ k9s: refreshRate: 2 readOnly: false logger: tail: 200 buffer: 2000 currentContext: minikube currentCluster: minikube clusters: minikube: namespace: active: kube-system favorites: - default - kube-public - istio-system - all - kube-system view: active: ctx fred: namespace: active: default favorites: - default - kube-public - istio-system - all - kube-system view: active: po screenDumpDir: /tmp ================================================ FILE: cmd/testdata/k9s1.yaml ================================================ k9s: refreshRate: 10 namespace: active: fred favorites: - blee - duh - crap ================================================ FILE: cmd/version.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "fmt" "github.com/derailed/k9s/internal/color" "github.com/spf13/cobra" ) func versionCmd() *cobra.Command { var short bool command := cobra.Command{ Use: "version", Short: "Print version/build info", Long: "Print version/build information", Run: func(*cobra.Command, []string) { printVersion(short) }, } command.PersistentFlags().BoolVarP(&short, "short", "s", false, "Prints K9s version info in short format") return &command } func printVersion(short bool) { const fmat = "%-20s %s\n" var outputColor color.Paint if short { outputColor = -1 } else { outputColor = color.Cyan printLogo(outputColor) } printTuple(fmat, "Version", version, outputColor) printTuple(fmat, "Commit", commit, outputColor) printTuple(fmat, "Date", date, outputColor) } func printTuple(fmat, section, value string, outputColor color.Paint) { if outputColor != -1 { _, _ = fmt.Fprintf(out, fmat, color.Colorize(section+":", outputColor), value) return } _, _ = fmt.Fprintf(out, fmat, section, value) } ================================================ FILE: go.mod ================================================ module github.com/derailed/k9s go 1.25.1 require ( github.com/adrg/xdg v0.5.3 github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 github.com/anchore/grype v0.109.0 github.com/anchore/syft v1.42.1 github.com/atotto/clipboard v0.1.4 github.com/cenkalti/backoff/v4 v4.3.0 github.com/derailed/tcell/v2 v2.3.1-rc.4 github.com/derailed/tview v0.8.5 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 github.com/fvbommel/sortorder v1.1.0 github.com/go-errors/errors v1.5.1 github.com/itchyny/gojq v0.12.18 github.com/karrick/godirwalk v1.17.0 github.com/lmittmann/tint v1.1.3 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-runewidth v0.0.19 github.com/olekukonko/tablewriter v1.1.3 github.com/petergtz/pegomock v2.9.0+incompatible github.com/rakyll/hey v0.1.5 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.20.0 k8s.io/api v0.35.2 k8s.io/apiextensions-apiserver v0.35.2 k8s.io/apimachinery v0.35.2 k8s.io/cli-runtime v0.35.1 k8s.io/client-go v0.35.2 k8s.io/klog/v2 v2.140.0 k8s.io/kubectl v0.35.0 k8s.io/metrics v0.35.2 sigs.k8s.io/yaml v1.6.0 ) require ( cel.dev/expr v0.24.0 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.58.0 // indirect cyphar.com/go-pathrs v0.2.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/CycloneDX/cyclonedx-go v0.10.0 // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/Intevation/gval v1.3.0 // indirect github.com/Intevation/jsonpath v0.2.1 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 // indirect github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c // indirect github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect github.com/anchore/go-lzo v0.1.0 // indirect github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331 // indirect github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec // indirect github.com/anchore/go-struct-converter v0.1.0 // indirect github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect github.com/anchore/stereoscope v0.1.20 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aquasecurity/go-pep440-version v0.0.1 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 // indirect github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v2 v2.0.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.5 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/containerd v1.7.30 // indirect github.com/containerd/containerd/api v1.10.0 // indirect github.com/containerd/containerd/v2 v2.2.1 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect github.com/containerd/plugin v1.0.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/creack/pty v1.1.20 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect github.com/diskfs/go-diskfs v1.7.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.4 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/phpserialize v1.4.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/facebookincubator/nvdtools v0.1.5 // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/github/go-spdx/v2 v2.3.6 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/go-git/go-git/v5 v5.16.5 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gocsaf/csaf/v3 v3.5.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.7 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gookit/color v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gpustack/gguf-parser-go v0.23.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-getter v1.8.4 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mholt/archives v0.1.5 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/onsi/ginkgo v1.16.5 // indirect github.com/onsi/gomega v1.38.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/openvex/go-vex v0.2.7 // indirect github.com/owenrumney/go-sarif v1.1.2-0.20231003122901-1000f5e05554 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect github.com/pandatix/go-cvss v0.6.2 // indirect github.com/pborman/indent v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sassoftware/go-rpmutils v0.4.0 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb // indirect github.com/spdx/tools-golang v0.5.7 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/sylabs/sif/v2 v2.22.0 // indirect github.com/sylabs/squashfs v1.0.6 // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/go-mtree v0.7.0 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/vifraa/gopom v1.0.0 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b // indirect github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zclconf/go-cty v1.16.3 // indirect github.com/zeebo/errs v1.4.0 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.16.0 // indirect google.golang.org/api v0.256.0 // indirect google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gorm.io/gorm v1.31.1 // indirect gotest.tools/v3 v3.4.0 // indirect k8s.io/apiserver v0.35.2 // indirect k8s.io/component-base v0.35.2 // indirect k8s.io/component-helpers v0.35.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.44.3 // indirect oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) ================================================ FILE: go.sum ================================================ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo= cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CycloneDX/cyclonedx-go v0.10.0 h1:7xyklU7YD+CUyGzSFIARG18NYLsKVn4QFg04qSsu+7Y= github.com/CycloneDX/cyclonedx-go v0.10.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Intevation/gval v1.3.0 h1:+Ze5sft5MmGbZrHj06NVUbcxCb67l9RaPTLMNr37mjw= github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o= github.com/Intevation/jsonpath v0.2.1 h1:rINNQJ0Pts5XTFEG+zamtdL7l9uuE1z0FBA+r55Sw+A= github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/acobaugh/osrelease v0.1.0 h1:Yb59HQDGGNhCj4suHaFQQfBps5wyoKLSSX/J/+UifRE= github.com/acobaugh/osrelease v0.1.0/go.mod h1:4bFEs0MtgHNHBrmHCt67gNisnabCRAlzdVasCEGHTWY= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 h1:7DUAXEdAxoANPlDgxYiaSRKnWnTygvdrrWhnmvEjNLg= github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084/go.mod h1:42dWox8z4//b898OIELsQnSdYq9q1aCXkwp5fKF+BEU= github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 h1:aVC6r9h5wGNh8BYTW3CXxOdPoZzY/bBRWne1NvSTlO8= github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232/go.mod h1:Zees1AEKNpXIRgdVAMYWITncarLFiPOtEQ7rl45V/h0= github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c h1:eoJXyC0n7DZ4YvySG/ETdYkTar2Due7eH+UmLK6FbrA= github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8= github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d h1:gT69osH9AsdpOfqxbRwtxcNnSZ1zg4aKy2BevO3ZBdc= github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d/go.mod h1:PhSnuFYknwPZkOWKB1jXBNToChBA+l0FjwOxtViIc50= github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4VyBzhjLkRF/3gDrcpUBj8LjvvO6OOM= github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw= github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331 h1:fWPHXkH3FQGVCyPkFMqNvMjQvdNMfkylBTsDqZC4lE4= github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331/go.mod h1:DYvTRnWrlJ//6YOR83SiewmJiNFDEMRaOTnrzgco9FA= github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec h1:SjjPMOXTzpuU1ZME4XeoHyek+dry3/C7I8gzaCo02eg= github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU= github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 h1:UK1SWZf2xD5jq8QVeDdpt6wW31cO3RckBvPmGlDrTkg= github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1/go.mod h1:hd0Ol9qFM8tRDdF50a+DpZEoB0HFNaEnCp/BSVyBRlg= github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 h1:VzprUTpc0vW0nnNKJfJieyH/TZ9UYAnTZs5/gHTdAe8= github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04/go.mod h1:6dK64g27Qi1qGQZ67gFmBFvEHScy0/C8qhQhNe5B5pQ= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 h1:rmZG77uXgE+o2gozGEBoUMpX27lsku+xrMwlmBZJtbg= github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/grype v0.109.0 h1:CRknQY4Mbpxh57E1O7Q/vSul9ltgzHqeSzza6alErME= github.com/anchore/grype v0.109.0/go.mod h1:4YbSZ8quer0N1CFJ8LQPFfcHIerXo6ccBTLlhd6IYGY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= github.com/anchore/stereoscope v0.1.20 h1:32720yZ/YtvzF5tvsoRL/ibdAJzOdIaR444fDXW4arQ= github.com/anchore/stereoscope v0.1.20/go.mod h1:6Ef0xQAuN2Ito7eV9A9pYjD1x/0cX5fy56MwgEGyrB4= github.com/anchore/syft v1.42.1 h1:aZvkRXzclT2VrQUfu6tsyiixqusGJk9DeoOJktcQBrU= github.com/anchore/syft v1.42.1/go.mod h1:uo2xEPi6gyc/qabZFv0Oni6W2pL0gE7sshAyZJCnHNg= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/aquasecurity/go-pep440-version v0.0.1 h1:8VKKQtH2aV61+0hovZS3T//rUF+6GDn18paFTVS0h0M= github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4= github.com/aquasecurity/go-version v0.0.1 h1:4cNl516agK0TCn5F7mmYN+xVs1E3S45LkgZk3cbaW2E= github.com/aquasecurity/go-version v0.0.1/go.mod h1:s1UU6/v2hctXcOa3OLwfj5d9yoXHa3ahf+ipSwEvGT0= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 h1:lBg3tHGquFySSblLi9zNi2iGNmVLRHBzVal2fqphCM8= github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607/go.mod h1:9iglf1GG4oNRJ39bZ5AZrjgAFD2RwQbXw6Qf7Cs47wo= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar/v2 v2.0.4 h1:6I6oUiT/sU27eE2OFcWqBhL1SwjyvQuOssxT4a1yidI= github.com/bmatcuk/doublestar/v2 v2.0.4/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk= github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y= github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4= github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb h1:4W/2rQ3wzEimF5s+J6OY3ODiQtJZ5W1sForSgogVXkY= github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb/go.mod h1:B3tI9iGHi4imdLi4Asdha1Sc6feLMTfPLXh9IUYmysk= github.com/derailed/tcell/v2 v2.3.1-rc.4 h1:hrBFOQjcmt1I86Cvcq/NKP7sdRHH+6ibXWBFl0Hn3jY= github.com/derailed/tcell/v2 v2.3.1-rc.4/go.mod h1:nf68BEL8fjmXQHJT3xZjoZFs2uXOzyJcNAQqGUEMrFY= github.com/derailed/tview v0.8.5 h1:pogM/OnWlgDo6j4zyzdiIXh7E7+eT7D4CPfBnyaETug= github.com/derailed/tview v0.8.5/go.mod h1:q+odnnhO6QDPpBT+0dqaWj+X+uoJ6MJehXj9shgP+Cw= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8= github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k= github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI= github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 h1:EHZfspsnLAz8Hzccd67D5abwLiqoqym2jz/jOS39mCk= github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3HMDI8hG2OY= github.com/elliotchance/phpserialize v1.4.0/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk= github.com/facebookincubator/nvdtools v0.1.5 h1:jbmDT1nd6+k+rlvKhnkgMokrCAzHoASWE5LtHbX2qFQ= github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrEKH4iB0OJby3N8GRJO4= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/go-spdx/v2 v2.3.6 h1:9flm625VmmTlWXi0YH5W9V8FdMfulvxalHdYnUfoqxc= github.com/github/go-spdx/v2 v2.3.6/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-snaps v0.5.19 h1:hUJlCQOpTt1M+kSisMwioDWZDWpDtdAvUhvWCx1YGW0= github.com/gkampitakis/go-snaps v0.5.19/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gocsaf/csaf/v3 v3.5.1 h1:jTA1fLrK0/JIczPs7itTD53qANoO4tn2VaGvUeitePc= github.com/gocsaf/csaf/v3 v3.5.1/go.mod h1:pga89lE+iWJm7smTdzYcXuetYUbgY8caXfaIP4BJG98= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gpustack/gguf-parser-go v0.23.1 h1:0U7DOrsi7ryx2L/dlMy+BSQ5bJV4AuMEIgGBs4RK46A= github.com/gpustack/gguf-parser-go v0.23.1/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70/go.mod h1:fm2FdDCzJdtbXF7WKAMvBb5NEPouXPHFbGNYs9ShFns= github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.8.4 h1:hGEd2xsuVKgwkMtPVufq73fAmZU/x65PPcqH3cb0D9A= github.com/hashicorp/go-getter v1.8.4/go.mod h1:x27pPGSg9kzoB147QXI8d/nDvp2IgYGcwuRjpaXE9Yg= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 h1:WdAeg/imY2JFPc/9CST4bZ80nNJbiBFCAdSZCSgrS5Y= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953/go.mod h1:6o+UrvuZWc4UTyBhQf0LGjW9Ld7qJxLz/OqvSOWWlEc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f h1:GvCU5GXhHq+7LeOzx/haG7HSIZokl3/0GkoUFzsRJjg= github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f/go.mod h1:q59u9px8b7UTj0nIjEjvmTWekazka6xIt6Uogz5Dm+8= github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 h1:dWzdsqjh1p2gNtRKqNwuBvKqMNwnLOPLzVZT1n6DK7s= github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23/go.mod h1:lUaIXCWzf7BRKTY5iEcrYy1TfgbYLYVIS/B2vPkJzOc= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a h1:eLvAzVoRfHEOl64OxFhepPf3vj7SKvXY/tFc3BS0b7s= github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 h1:kpt9ZfKcm+EDG4s40hMwE//d5SBgDjUOrITReV2u4aA= github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1/go.mod h1:qgCw4bBKZX8qMgGeEZzGFVT3notl42dBjNqO2jut0M0= github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 h1:NHrXEjTNQY7P0Zfx1aMrNhpgxHmow66XQtm0aQLY0AE= github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/openvex/go-vex v0.2.7 h1:/pN3bqvS4QOc6WkkL0hbKzJuAtsUD9vmvk9IZkzD3Zc= github.com/openvex/go-vex v0.2.7/go.mod h1:ZyQC3NXl9jjS53JOpBG3LAUXySkW8IlJ/GIhsnf5D54= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/owenrumney/go-sarif v1.1.2-0.20231003122901-1000f5e05554 h1:FvA4bwjKpPqik5WsQ8+4z4DKWgA1tO1RTTtNKr5oYNA= github.com/owenrumney/go-sarif v1.1.2-0.20231003122901-1000f5e05554/go.mod h1:n73K/hcuJ50MiVznXyN4rde6fZY7naGKWBXOLFTyc94= github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs= github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= github.com/pandatix/go-cvss v0.6.2 h1:TFiHlzUkT67s6UkelHmK6s1INKVUG7nlKYiWWDTITGI= github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/petergtz/pegomock v2.9.0+incompatible h1:BKfb5XfkJfehe5T+O1xD4Zm26Sb9dnRj7tHxLYwUPiI= github.com/petergtz/pegomock v2.9.0+incompatible/go.mod h1:nuBLWZpVyv/fLo56qTwt/AUau7jgouO1h7bEvZCq82o= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rakyll/hey v0.1.5 h1:oc3QhpT8ETXcr5xIE2xgWYNSNA/Z52XA20ku9hWCchY= github.com/rakyll/hey v0.1.5/go.mod h1:tLUK++7gal6z92HvVEaP4QfIhEb5cYywEJgrUqmqt7Y= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c h1:8gOLsYwaY2JwlTMT4brS5/9XJdrdIbmk2obvQ748CC0= github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c/go.mod h1:kwM/7r/rVluTE8qJbHAffduuqmSv4knVQT2IajGvSiA= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d h1:3VwvTjiRPA7cqtgOWddEL+JrcijMlXUmj99c/6YyZoY= github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d/go.mod h1:tAG61zBM1DYRaGIPloumExGvScf08oHuo0kFoOqdbT0= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb h1:7G2Czq97VORM5xNRrD8tSQdhoXPRs8s+Otlc7st9TS0= github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/sylabs/sif/v2 v2.22.0 h1:Y+xXufp4RdgZe02SR3nWEg7S6q4tPWN237WHYzkDSKA= github.com/sylabs/sif/v2 v2.22.0/go.mod h1:W1XhWTmG1KcG7j5a3KSYdMcUIFvbs240w/MMVW627hs= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/go-mtree v0.7.0 h1:ytmOc3MTRidZiBi9VBCyZ2BHe4fZS47L5v7BVXDWW4E= github.com/vbatts/go-mtree v0.7.0/go.mod h1:EjdpFC+LZy1TXbRGNa1MKKgjQ+7ew3foMFJK8o4/TdY= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vifraa/gopom v1.0.0 h1:L9XlKbyvid8PAIK8nr0lihMApJQg/12OBvMA28BcWh0= github.com/vifraa/gopom v1.0.0/go.mod h1:oPa1dcrGrtlO37WPDBm5SqHAT+wTgF8An1Q71Z6Vv4o= github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b h1:uWNQ0khA6RdFzODOMwKo9XXu7fuewnnkHykUtuKru8s= github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b/go.mod h1:ewlIKbKV8l+jCj8rkdXIs361ocR5x3qGyoCSca47Gx8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4= google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= helm.sh/helm/v3 v3.20.0 h1:2M+0qQwnbI1a2CxN7dbmfsWHg/MloeaFMnZCY56as50= helm.sh/helm/v3 v3.20.0/go.mod h1:rTavWa0lagZOxGfdhu4vgk1OjH2UYCnrDKE2PVC4N0o= 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= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.2 h1:rb52v0CZGEL0FkhjS+I6jHflAp7fZ4MIaKcEHX7wmDk= k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= k8s.io/component-base v0.35.2 h1:btgR+qNrpWuRSuvWSnQYsZy88yf5gVwemvz0yw79pGc= k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= k8s.io/component-helpers v0.35.0 h1:wcXv7HJRksgVjM4VlXJ1CNFBpyDHruRI99RrBtrJceA= k8s.io/component-helpers v0.35.0/go.mod h1:ahX0m/LTYmu7fL3W8zYiIwnQ/5gT28Ex4o2pymF63Co= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubectl v0.35.0 h1:cL/wJKHDe8E8+rP3G7avnymcMg6bH6JEcR5w5uo06wc= k8s.io/kubectl v0.35.0/go.mod h1:VR5/TSkYyxZwrRwY5I5dDq6l5KXmiCb+9w8IKplk3Qo= k8s.io/metrics v0.35.2 h1:PJRP88qeadR5evg4ZKJAh3NR3ICchwM51/Aidd0LHjc= k8s.io/metrics v0.35.2/go.mod h1:w1pJmSu2j8ftVI26MGcJtMnpmZ06oKwb4Enm+xVl06Q= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: internal/client/client.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "strings" "sync" "time" "github.com/derailed/k9s/internal/slogs" authorizationv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/cache" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery/cached/disk" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" metricsapi "k8s.io/metrics/pkg/apis/metrics" "k8s.io/metrics/pkg/client/clientset/versioned" ) const ( cacheSize = 100 cacheExpiry = 5 * time.Minute cacheMXAPIKey = "metricsAPI" serverVersion = "serverVersion" cacheNSKey = "validNamespaces" ) var supportedMetricsAPIVersions = []string{"v1beta1"} // NamespaceNames tracks a collection of namespace names. type NamespaceNames map[string]struct{} // APIClient represents a Kubernetes api client. type APIClient struct { client, logClient kubernetes.Interface dClient dynamic.Interface nsClient dynamic.NamespaceableResourceInterface mxsClient *versioned.Clientset cachedClient *disk.CachedDiscoveryClient config *Config mx sync.RWMutex cache *cache.LRUExpireCache connOK bool log *slog.Logger } // NewTestAPIClient for testing ONLY!! func NewTestAPIClient() *APIClient { return &APIClient{ config: NewConfig(nil), cache: cache.NewLRUExpireCache(cacheSize), } } // InitConnection initialize connection from command line args. // Checks for connectivity with the api server. func InitConnection(config *Config, log *slog.Logger) (*APIClient, error) { a := APIClient{ config: config, cache: cache.NewLRUExpireCache(cacheSize), connOK: true, log: log.With(slogs.Subsys, "client"), } if err := a.supportsMetricsResources(); err != nil { slog.Warn("Fail to locate metrics-server", slogs.Error, err) if !errors.Is(err, noMetricServerErr) && !errors.Is(err, metricsUnsupportedErr) { a.connOK = false return &a, err } } return &a, nil } // ConnectionOK returns connection status. func (a *APIClient) ConnectionOK() bool { return a.connOK } func makeSAR(ns string, gvr *GVR, name string) *authorizationv1.SelfSubjectAccessReview { if ns == ClusterScope { ns = BlankNamespace } res := gvr.GVR() return &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: ns, Group: res.Group, Version: res.Version, Resource: res.Resource, Subresource: gvr.SubResource(), Name: name, }, }, } } func makeCacheKey(ns string, gvr *GVR, n string, vv []string) string { return ns + ":" + gvr.String() + ":" + n + "::" + strings.Join(vv, ",") } // ActiveContext returns the current context name. func (a *APIClient) ActiveContext() string { c, err := a.config.CurrentContextName() if err != nil { slog.Error("unable to located active cluster", slogs.Error, err) return "" } return c } // IsActiveNamespace returns true if namespaces matches. func (a *APIClient) IsActiveNamespace(ns string) bool { if a.ActiveNamespace() == BlankNamespace { return true } return a.ActiveNamespace() == ns } // ActiveNamespace returns the current namespace. func (a *APIClient) ActiveNamespace() string { if ns, err := a.CurrentNamespaceName(); err == nil { return ns } return BlankNamespace } func (a *APIClient) clearCache() { for _, k := range a.cache.Keys() { a.cache.Remove(k) } } // CanI checks if user has access to a certain resource. func (a *APIClient) CanI(ns string, gvr *GVR, name string, verbs []string) (auth bool, err error) { if !a.getConnOK() { return false, errors.New("ACCESS -- No API server connection") } if gvr == NsGVR { // The name of the namespace is required to check permissions in some cases ns = name } if IsClusterWide(ns) { ns = BlankNamespace } if gvr == HmGVR { // helm stores release data in secrets gvr = SecGVR } key := makeCacheKey(ns, gvr, name, verbs) if v, ok := a.cache.Get(key); ok { if auth, ok = v.(bool); ok { return auth, nil } } clog := a.log.With(slogs.Subsys, "can") dial, err := a.Dial() if err != nil { return false, err } client, sar := dial.AuthorizationV1().SelfSubjectAccessReviews(), makeSAR(ns, gvr, name) ctx, cancel := context.WithTimeout(context.Background(), a.config.CallTimeout()) defer cancel() for _, v := range verbs { sar.Spec.ResourceAttributes.Verb = v resp, err := client.Create(ctx, sar, metav1.CreateOptions{}) clog.Debug("[CAN] access", slogs.GVR, gvr, slogs.Namespace, ns, slogs.ResName, name, slogs.Verb, verbs, ) if resp != nil { clog.Debug("[CAN] response", slogs.AuthStatus, resp.Status.Allowed, slogs.AuthReason, resp.Status.Reason, ) } if err != nil { clog.Warn("Auth request failed", slogs.Error, err) a.cache.Add(key, false, cacheExpiry) return auth, err } if !resp.Status.Allowed { a.cache.Add(key, false, cacheExpiry) return auth, fmt.Errorf("(%s) access denied for user on resource %q:%s in namespace %q", v, name, gvr, ns) } } auth = true a.cache.Add(key, true, cacheExpiry) return } // CurrentNamespaceName return namespace name set via either cli arg or cluster config. func (a *APIClient) CurrentNamespaceName() (string, error) { return a.config.CurrentNamespaceName() } // ServerVersion returns the current server version info. func (a *APIClient) ServerVersion() (*version.Info, error) { if v, ok := a.cache.Get(serverVersion); ok { if vi, ok := v.(*version.Info); ok { return vi, nil } } dial, err := a.CachedDiscovery() if err != nil { return nil, err } info, err := dial.ServerVersion() if err != nil { return nil, err } a.cache.Add(serverVersion, info, cacheExpiry) return info, nil } func (a *APIClient) IsValidNamespace(ns string) bool { ok, err := a.isValidNamespace(ns) if err != nil { slog.Warn("Namespace validation failed", slogs.Namespace, ns, slogs.Error, err, ) } return ok } func (a *APIClient) isValidNamespace(n string) (bool, error) { if IsClusterWide(n) || n == NotNamespaced { return true, nil } nn, err := a.ValidNamespaceNames() if err != nil { return false, err } _, ok := nn[n] return ok, nil } // ValidNamespaceNames returns all available namespaces. func (a *APIClient) ValidNamespaceNames() (NamespaceNames, error) { if a == nil { return nil, fmt.Errorf("validNamespaces: no available client found") } if nn, ok := a.cache.Get(cacheNSKey); ok { if nss, ok := nn.(NamespaceNames); ok { return nss, nil } } 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") } dial, err := a.Dial() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), a.config.CallTimeout()) defer cancel() nn, err := dial.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } nns := make(NamespaceNames, len(nn.Items)) for i := range nn.Items { nns[nn.Items[i].Name] = struct{}{} } a.cache.Add(cacheNSKey, nns, cacheExpiry) return nns, nil } // CheckConnectivity return true if api server is cool or false otherwise. func (a *APIClient) CheckConnectivity() bool { defer func() { if err := recover(); err != nil { a.setConnOK(false) } if !a.getConnOK() { a.clearCache() } }() cfg, err := a.config.RESTConfig() if err != nil { slog.Error("RestConfig load failed", slogs.Error, err) a.connOK = false return a.connOK } cfg.Timeout = a.config.CallTimeout() client, err := kubernetes.NewForConfig(cfg) if err != nil { slog.Error("Unable to connect to api server", slogs.Error, err) a.setConnOK(false) return a.getConnOK() } if _, err := client.ServerVersion(); err == nil { a.setClient(client) if !a.getConnOK() { a.reset() } } else { slog.Error("Unable to fetch server version", slogs.Error, err) a.setConnOK(false) } return a.getConnOK() } // Config return a kubernetes configuration. func (a *APIClient) Config() *Config { return a.config } // HasMetrics checks if the cluster supports metrics. func (a *APIClient) HasMetrics() bool { return a.supportsMetricsResources() == nil } func (a *APIClient) getMxsClient() *versioned.Clientset { a.mx.RLock() defer a.mx.RUnlock() return a.mxsClient } func (a *APIClient) setMxsClient(c *versioned.Clientset) { a.mx.Lock() defer a.mx.Unlock() a.mxsClient = c } func (a *APIClient) getCachedClient() *disk.CachedDiscoveryClient { a.mx.RLock() defer a.mx.RUnlock() return a.cachedClient } func (a *APIClient) setCachedClient(c *disk.CachedDiscoveryClient) { a.mx.Lock() defer a.mx.Unlock() a.cachedClient = c } func (a *APIClient) getDClient() dynamic.Interface { a.mx.RLock() defer a.mx.RUnlock() return a.dClient } func (a *APIClient) setDClient(c dynamic.Interface) { a.mx.Lock() defer a.mx.Unlock() a.dClient = c } func (a *APIClient) getConnOK() bool { a.mx.RLock() defer a.mx.RUnlock() return a.connOK } func (a *APIClient) setConnOK(b bool) { a.mx.Lock() defer a.mx.Unlock() a.connOK = b } func (a *APIClient) setLogClient(k kubernetes.Interface) { a.mx.Lock() defer a.mx.Unlock() a.logClient = k } func (a *APIClient) getLogClient() kubernetes.Interface { a.mx.RLock() defer a.mx.RUnlock() return a.logClient } func (a *APIClient) setClient(k kubernetes.Interface) { a.mx.Lock() defer a.mx.Unlock() a.client = k } func (a *APIClient) getClient() kubernetes.Interface { a.mx.RLock() defer a.mx.RUnlock() return a.client } // DialLogs returns a handle to api server for logs. func (a *APIClient) DialLogs() (kubernetes.Interface, error) { if !a.getConnOK() { return nil, errors.New("dialLogs - no connection to dial") } if clt := a.getLogClient(); clt != nil { return clt, nil } cfg, err := a.RestConfig() if err != nil { return nil, err } cfg.Timeout = 0 c, err := kubernetes.NewForConfig(cfg) if err != nil { return nil, err } a.setLogClient(c) return a.getLogClient(), nil } // Dial returns a handle to api server or die. func (a *APIClient) Dial() (kubernetes.Interface, error) { if !a.getConnOK() { return nil, errors.New("no connection to dial") } if c := a.getClient(); c != nil { return c, nil } cfg, err := a.RestConfig() if err != nil { return nil, err } c, err := kubernetes.NewForConfig(cfg) if err != nil { return nil, err } a.setClient(c) return a.getClient(), nil } // RestConfig returns a rest api client. func (a *APIClient) RestConfig() (*restclient.Config, error) { return a.config.RESTConfig() } // CachedDiscovery returns a cached discovery client. func (a *APIClient) CachedDiscovery() (*disk.CachedDiscoveryClient, error) { if !a.getConnOK() { return nil, errors.New("no connection to cached dial") } if c := a.getCachedClient(); c != nil { return c, nil } cfg, err := a.RestConfig() if err != nil { return nil, err } baseCacheDir := os.Getenv("KUBECACHEDIR") if baseCacheDir == "" { baseCacheDir = filepath.Join(mustHomeDir(), ".kube", "cache") } httpCacheDir := filepath.Join(baseCacheDir, "http") discCacheDir := filepath.Join(baseCacheDir, "discovery", toHostDir(cfg.Host)) c, err := disk.NewCachedDiscoveryClientForConfig(cfg, discCacheDir, httpCacheDir, cacheExpiry) if err != nil { return nil, err } a.setCachedClient(c) return a.getCachedClient(), nil } // DynDial returns a handle to a dynamic interface. func (a *APIClient) DynDial() (dynamic.Interface, error) { if c := a.getDClient(); c != nil { return c, nil } cfg, err := a.RestConfig() if err != nil { return nil, err } c, err := dynamic.NewForConfig(cfg) if err != nil { return nil, err } a.setDClient(c) return a.getDClient(), nil } // MXDial returns a handle to the metrics server. func (a *APIClient) MXDial() (*versioned.Clientset, error) { if c := a.getMxsClient(); c != nil { return c, nil } cfg, err := a.RestConfig() if err != nil { return nil, err } c, err := versioned.NewForConfig(cfg) if err != nil { return nil, err } a.setMxsClient(c) return a.getMxsClient(), err } func (a *APIClient) invalidateCache() error { dial, err := a.CachedDiscovery() if err != nil { return err } dial.Invalidate() return nil } // SwitchContext handles kubeconfig context switches. func (a *APIClient) SwitchContext(name string) error { slog.Debug("Switching context", slogs.Context, name) if err := a.config.SwitchContext(name); err != nil { return err } a.reset() ResetMetrics() a.config = NewConfig(a.config.flags) if !a.CheckConnectivity() { slog.Warn("SwitchContext: connectivity check failed", slogs.Context, name) } if _, err := a.DynDial(); err != nil { slog.Warn("SwitchContext: DynDial pre-warm failed", slogs.Error, err) } return a.invalidateCache() } func (a *APIClient) reset() { a.config.reset() a.cache = cache.NewLRUExpireCache(cacheSize) a.nsClient = nil a.setDClient(nil) a.setMxsClient(nil) a.setCachedClient(nil) a.setClient(nil) a.setLogClient(nil) a.setConnOK(true) } func (a *APIClient) checkCacheBool(key string) (state, ok bool) { v, found := a.cache.Get(key) if !found { return } state, ok = v.(bool) return } func (a *APIClient) supportsMetricsResources() error { supported, ok := a.checkCacheBool(cacheMXAPIKey) if ok { if supported { return nil } return noMetricServerErr } defer func() { a.cache.Add(cacheMXAPIKey, supported, cacheExpiry) }() dial, err := a.Dial() if err != nil { slog.Warn("Unable to dial API client for metrics", slogs.Error, err) return err } apiGroups, err := dial.Discovery().ServerGroups() if err != nil { return err } for i := range apiGroups.Groups { if apiGroups.Groups[i].Name != metricsapi.GroupName { continue } if checkMetricsVersion(&(apiGroups.Groups[i])) { supported = true return nil } } return metricsUnsupportedErr } func checkMetricsVersion(grp *metav1.APIGroup) bool { for _, v := range grp.Versions { for _, supportedVersion := range supportedMetricsAPIVersions { if v.Version == supportedVersion { return true } } } return false } ================================================ FILE: internal/client/client_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "testing" "time" "github.com/stretchr/testify/assert" authorizationv1 "k8s.io/api/authorization/v1" ) func TestMakeSAR(t *testing.T) { uu := map[string]struct { ns string gvr *GVR sar *authorizationv1.SelfSubjectAccessReview }{ "all-pods": { ns: NamespaceAll, gvr: PodGVR, sar: &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: NamespaceAll, Version: "v1", Resource: "pods", }, }, }, }, "ns-pods": { ns: "fred", gvr: PodGVR, sar: &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: "fred", Version: "v1", Resource: "pods", }, }, }, }, "clusterscope-ns": { ns: ClusterScope, gvr: NsGVR, sar: &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Version: "v1", Resource: "namespaces", }, }, }, }, "subres-pods": { ns: "fred", gvr: NewGVR("v1/pods:logs"), sar: &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ ResourceAttributes: &authorizationv1.ResourceAttributes{ Namespace: "fred", Version: "v1", Resource: "pods", Subresource: "logs", }, }, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.sar, makeSAR(u.ns, u.gvr, "")) }) } } func TestIsValidNamespace(t *testing.T) { c := NewTestAPIClient() uu := map[string]struct { ns string cache NamespaceNames ok bool }{ "all-ns": { ns: NamespaceAll, cache: NamespaceNames{ DefaultNamespace: {}, }, ok: true, }, "blank-ns": { ns: BlankNamespace, cache: NamespaceNames{ DefaultNamespace: {}, }, ok: true, }, "cluster-ns": { ns: ClusterScope, cache: NamespaceNames{ DefaultNamespace: {}, }, ok: true, }, "no-ns": { ns: NotNamespaced, cache: NamespaceNames{ DefaultNamespace: {}, }, ok: true, }, "default-ns": { ns: DefaultNamespace, cache: NamespaceNames{ DefaultNamespace: {}, }, ok: true, }, "valid-ns": { ns: "fred", cache: NamespaceNames{ "fred": {}, }, ok: true, }, "invalid-ns": { ns: "fred", cache: NamespaceNames{ DefaultNamespace: {}, }, }, } expiry := 1 * time.Millisecond for k := range uu { u := uu[k] c.cache.Add("validNamespaces", u.cache, expiry) t.Run(k, func(t *testing.T) { assert.Equal(t, u.ok, c.IsValidNamespace(u.ns)) }) } } func TestCheckCacheBool(t *testing.T) { c := NewTestAPIClient() const key = "fred" uu := map[string]struct { key string val any found, actual, sleep bool }{ "setTrue": { key: key, val: true, found: true, actual: true, }, "setFalse": { key: key, val: false, found: true, }, "missing": { key: "blah", val: false, }, "expired": { key: key, val: true, sleep: true, }, } expiry := 1 * time.Millisecond for k := range uu { u := uu[k] c.cache.Add(key, u.val, expiry) if u.sleep { time.Sleep(expiry) } t.Run(k, func(t *testing.T) { val, ok := c.checkCacheBool(u.key) assert.Equal(t, u.found, ok) assert.Equal(t, u.actual, val) }) } } ================================================ FILE: internal/client/config.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "errors" "fmt" "net/http" "net/url" "strings" "sync" "time" "k8s.io/cli-runtime/pkg/genericclioptions" restclient "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" ) const ( // DefaultCallTimeoutDuration is the default api server call timeout duration. DefaultCallTimeoutDuration time.Duration = 120 * time.Second // UsePersistentConfig caches client config to avoid reloads. UsePersistentConfig = true ) // Config tracks a kubernetes configuration. type Config struct { flags *genericclioptions.ConfigFlags mx sync.RWMutex proxy func(*http.Request) (*url.URL, error) } // NewConfig returns a new k8s config or an error if the flags are invalid. func NewConfig(f *genericclioptions.ConfigFlags) *Config { return &Config{ flags: f, } } // CallTimeout returns the call timeout if set or the default if not set. func (c *Config) CallTimeout() time.Duration { if !isSet(c.flags.Timeout) { return DefaultCallTimeoutDuration } dur, err := time.ParseDuration(*c.flags.Timeout) if err != nil { return DefaultCallTimeoutDuration } return dur } func (c *Config) RESTConfig() (*restclient.Config, error) { cfg, err := c.clientConfig().ClientConfig() if err != nil { return nil, err } if c.proxy != nil { cfg.Proxy = c.proxy } return cfg, nil } // Flags returns configuration flags. func (c *Config) Flags() *genericclioptions.ConfigFlags { return c.flags } func (c *Config) RawConfig() (api.Config, error) { return c.clientConfig().RawConfig() } func (c *Config) clientConfig() clientcmd.ClientConfig { return c.flags.ToRawKubeConfigLoader() } func (*Config) reset() {} // SwitchContext changes the kubeconfig context to a new cluster. func (c *Config) SwitchContext(name string) error { ct, err := c.GetContext(name) if err != nil { return fmt.Errorf("context %q does not exist", name) } // !!BOZO!! Do you need to reset the flags? flags := genericclioptions.NewConfigFlags(UsePersistentConfig) flags.Context, flags.ClusterName = &name, &ct.Cluster flags.Namespace = c.flags.Namespace flags.Timeout = c.flags.Timeout flags.KubeConfig = c.flags.KubeConfig flags.Impersonate = c.flags.Impersonate flags.ImpersonateGroup = c.flags.ImpersonateGroup flags.ImpersonateUID = c.flags.ImpersonateUID flags.Insecure = c.flags.Insecure flags.BearerToken = c.flags.BearerToken c.flags = flags return nil } func (c *Config) Clone(ns string) (*genericclioptions.ConfigFlags, error) { flags := genericclioptions.NewConfigFlags(false) ct, err := c.CurrentContextName() if err != nil { return nil, err } cl, err := c.CurrentClusterName() if err != nil { return nil, err } flags.Context, flags.ClusterName = &ct, &cl flags.Namespace = &ns flags.Timeout = c.Flags().Timeout flags.KubeConfig = c.Flags().KubeConfig return flags, nil } // CurrentClusterName returns the currently active cluster name. func (c *Config) CurrentClusterName() (string, error) { if isSet(c.flags.ClusterName) { return *c.flags.ClusterName, nil } cfg, err := c.RawConfig() if err != nil { return "", err } ct, ok := cfg.Contexts[cfg.CurrentContext] if !ok { return "", fmt.Errorf("invalid current context specified: %q", cfg.CurrentContext) } if isSet(c.flags.Context) { ct, ok = cfg.Contexts[*c.flags.Context] if !ok { return "", fmt.Errorf("current-cluster - invalid context specified: %q", *c.flags.Context) } } return ct.Cluster, nil } // CurrentContextName returns the currently active config context. func (c *Config) CurrentContextName() (string, error) { if isSet(c.flags.Context) { return *c.flags.Context, nil } cfg, err := c.RawConfig() if err != nil { return "", fmt.Errorf("fail to load rawConfig: %w", err) } return cfg.CurrentContext, nil } func (c *Config) CurrentContextNamespace() (string, error) { name, err := c.CurrentContextName() if err != nil { return "", err } context, err := c.GetContext(name) if err != nil { return "", err } return context.Namespace, nil } // CurrentContext returns the current context configuration. func (c *Config) CurrentContext() (*api.Context, error) { n, err := c.CurrentContextName() if err != nil { return nil, err } return c.GetContext(n) } // GetContext fetch a given context or error if it does not exist. func (c *Config) GetContext(n string) (*api.Context, error) { cfg, err := c.RawConfig() if err != nil { return nil, err } if c, ok := cfg.Contexts[n]; ok { return c, nil } return nil, fmt.Errorf("getcontext - invalid context specified: %q", n) } // SetProxy sets the proxy function. func (c *Config) SetProxy(proxy func(*http.Request) (*url.URL, error)) { c.proxy = proxy } // Contexts fetch all available contexts. func (c *Config) Contexts() (map[string]*api.Context, error) { cfg, err := c.RawConfig() if err != nil { return nil, err } return cfg.Contexts, nil } // DelContext remove a given context from the configuration. func (c *Config) DelContext(n string) error { cfg, err := c.RawConfig() if err != nil { return err } delete(cfg.Contexts, n) acc, err := c.ConfigAccess() if err != nil { return err } return clientcmd.ModifyConfig(acc, cfg, true) } // RenameContext renames a context. func (c *Config) RenameContext(oldCtx, newCtx string) error { cfg, err := c.RawConfig() if err != nil { return err } if _, ok := cfg.Contexts[newCtx]; ok { return fmt.Errorf("context with name %s already exists", newCtx) } cfg.Contexts[newCtx] = cfg.Contexts[oldCtx] delete(cfg.Contexts, oldCtx) acc, err := c.ConfigAccess() if err != nil { return err } if e := clientcmd.ModifyConfig(acc, cfg, true); e != nil { return e } current, err := c.CurrentContextName() if err != nil { return err } if current == oldCtx { return c.SwitchContext(newCtx) } return nil } // ContextNames fetch all available contexts. func (c *Config) ContextNames() (map[string]struct{}, error) { cfg, err := c.RawConfig() if err != nil { return nil, err } cc := make(map[string]struct{}, len(cfg.Contexts)) for n := range cfg.Contexts { cc[n] = struct{}{} } return cc, nil } // CurrentGroupNames retrieves the active group names. func (c *Config) CurrentGroupNames() ([]string, error) { if areSet(c.flags.ImpersonateGroup) { return *c.flags.ImpersonateGroup, nil } return []string{}, errors.New("unable to locate current group") } // ImpersonateGroups retrieves the active groups if set on the CLI. func (c *Config) ImpersonateGroups() (string, error) { if areSet(c.flags.ImpersonateGroup) { return strings.Join(*c.flags.ImpersonateGroup, ","), nil } return "", errors.New("no groups set") } // ImpersonateUser retrieves the active user name if set on the CLI. func (c *Config) ImpersonateUser() (string, error) { if isSet(c.flags.Impersonate) { return *c.flags.Impersonate, nil } return "", errors.New("no user set") } // CurrentUserName retrieves the active user name. func (c *Config) CurrentUserName() (string, error) { if isSet(c.flags.Impersonate) { return *c.flags.Impersonate, nil } if isSet(c.flags.AuthInfoName) { return *c.flags.AuthInfoName, nil } cfg, err := c.RawConfig() if err != nil { return "", err } current := cfg.CurrentContext if isSet(c.flags.Context) { current = *c.flags.Context } if ctx, ok := cfg.Contexts[current]; ok { return ctx.AuthInfo, nil } return "", errors.New("unable to locate current user") } // CurrentNamespaceName retrieves the active namespace. func (c *Config) CurrentNamespaceName() (string, error) { ns, overridden, err := c.clientConfig().Namespace() if err != nil { return BlankNamespace, err } // Checks if ns is passed is in args. if overridden { return ns, nil } // Return ns set in context if any?? return c.CurrentContextNamespace() } // ConfigAccess return the current kubeconfig api server access configuration. func (c *Config) ConfigAccess() (clientcmd.ConfigAccess, error) { c.mx.RLock() defer c.mx.RUnlock() return c.clientConfig().ConfigAccess(), nil } // ---------------------------------------------------------------------------- // Helpers... func isSet(s *string) bool { return s != nil && *s != "" } func areSet(ss *[]string) bool { return ss != nil && len(*ss) != 0 } ================================================ FILE: internal/client/config_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client_test import ( "errors" "log/slog" "os" "testing" "time" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" ) var kubeConfig = "./testdata/config" func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestCallTimeout(t *testing.T) { uu := map[string]struct { t string e time.Duration }{ "custom": { t: "1m", e: 1 * time.Minute, }, "default": { e: client.DefaultCallTimeoutDuration, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { flags := genericclioptions.NewConfigFlags(false) flags.Timeout = &u.t cfg := client.NewConfig(flags) assert.Equal(t, u.e, cfg.CallTimeout()) }) } } func TestConfigCurrentContext(t *testing.T) { uu := map[string]struct { context string e string }{ "default": { e: "fred", }, "custom": { context: "blee", e: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { flags := genericclioptions.NewConfigFlags(false) flags.KubeConfig = &kubeConfig if u.context != "" { flags.Context = &u.context } cfg := client.NewConfig(flags) ctx, err := cfg.CurrentContextName() require.NoError(t, err) assert.Equal(t, u.e, ctx) }) } } func TestConfigCurrentCluster(t *testing.T) { name := "blee" uu := map[string]struct { flags *genericclioptions.ConfigFlags cluster string }{ "default": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, }, cluster: "zorg", }, "custom": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, Context: &name, }, cluster: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := client.NewConfig(u.flags) ct, err := cfg.CurrentClusterName() require.NoError(t, err) assert.Equal(t, u.cluster, ct) }) } } func TestConfigCurrentUser(t *testing.T) { name := "blee" uu := map[string]struct { flags *genericclioptions.ConfigFlags user string }{ "default": { flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, user: "fred", }, "custom": { flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, AuthInfoName: &name}, user: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := client.NewConfig(u.flags) ctx, err := cfg.CurrentUserName() require.NoError(t, err) assert.Equal(t, u.user, ctx) }) } } func TestConfigCurrentNamespace(t *testing.T) { bleeNS, bleeCTX := "blee", "blee" uu := map[string]struct { flags *genericclioptions.ConfigFlags namespace string }{ "default": { flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig}, namespace: "", }, "withContext": { flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Context: &bleeCTX}, namespace: "zorg", }, "withNS": { flags: &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig, Namespace: &bleeNS}, namespace: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := client.NewConfig(u.flags) ns, err := cfg.CurrentNamespaceName() if ns != "" { require.NoError(t, err) } assert.Equal(t, u.namespace, ns) }) } } func TestConfigGetContext(t *testing.T) { uu := map[string]struct { cluster string err error }{ "default": { cluster: "blee", }, "custom": { cluster: "bozo", err: errors.New(`getcontext - invalid context specified: "bozo"`), }, } flags := &genericclioptions.ConfigFlags{KubeConfig: &kubeConfig} cfg := client.NewConfig(flags) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ctx, err := cfg.GetContext(u.cluster) if err != nil { assert.Equal(t, u.err, err) } else { assert.NotNil(t, ctx) assert.Equal(t, u.cluster, ctx.Cluster) } }) } } func TestConfigSwitchContext(t *testing.T) { cluster := "duh" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, Context: &cluster, } cfg := client.NewConfig(&flags) err := cfg.SwitchContext("blee") require.NoError(t, err) ctx, err := cfg.CurrentContextName() require.NoError(t, err) assert.Equal(t, "blee", ctx) } func TestConfigAccess(t *testing.T) { context := "duh" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, Context: &context, } cfg := client.NewConfig(&flags) acc, err := cfg.ConfigAccess() require.NoError(t, err) assert.NotEmpty(t, acc.GetDefaultFilename()) } func TestConfigContextNames(t *testing.T) { cluster := "duh" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, Context: &cluster, } cfg := client.NewConfig(&flags) cc, err := cfg.ContextNames() require.NoError(t, err) assert.Len(t, cc, 3) } func TestConfigContexts(t *testing.T) { context := "duh" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, Context: &context, } cfg := client.NewConfig(&flags) cc, err := cfg.Contexts() require.NoError(t, err) assert.Len(t, cc, 3) } func TestConfigDelContext(t *testing.T) { require.NoError(t, cp("./testdata/config.2", "./testdata/config.1")) context, kubeCfg := "duh", "./testdata/config.1" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeCfg, Context: &context, } cfg := client.NewConfig(&flags) err := cfg.DelContext("fred") require.NoError(t, err) cc, err := cfg.ContextNames() require.NoError(t, err) assert.Len(t, cc, 1) _, ok := cc["blee"] assert.True(t, ok) } func TestConfigRestConfig(t *testing.T) { flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, } cfg := client.NewConfig(&flags) rc, err := cfg.RESTConfig() require.NoError(t, err) assert.Equal(t, "https://localhost:3002", rc.Host) } func TestConfigBadConfig(t *testing.T) { kubeConfig := "./testdata/bork_config" flags := genericclioptions.ConfigFlags{ KubeConfig: &kubeConfig, } cfg := client.NewConfig(&flags) _, err := cfg.RESTConfig() assert.Error(t, err) } // Helpers... func cp(src, dst string) error { data, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dst, data, 0600) } ================================================ FILE: internal/client/errors.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import metricsapi "k8s.io/metrics/pkg/apis/metrics" // Error represents an error. type Error string // Error returns the error text. func (e Error) Error() string { return string(e) } const ( noMetricServerErr = Error("No metrics-server detected") metricsUnsupportedErr = Error("No metrics api group " + metricsapi.GroupName + " found on cluster") ) ================================================ FILE: internal/client/gvr.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "fmt" "log/slog" "path" "strings" "sync" "github.com/derailed/k9s/internal/slogs" "github.com/fvbommel/sortorder" "gopkg.in/yaml.v3" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) var NoGVR = new(GVR) // GVR represents a kubernetes resource schema as a string. // Format is group/version/resources:subresource. type GVR struct { raw, g, v, r, sr string } type gvrCache struct { data map[string]*GVR sync.RWMutex } func (c *gvrCache) add(gvr *GVR) { if c.get(gvr.String()) == nil { c.Lock() c.data[gvr.String()] = gvr c.Unlock() } } func (c *gvrCache) get(gvrs string) *GVR { c.RLock() defer c.RUnlock() if gvr, ok := c.data[gvrs]; ok { return gvr } return nil } var gvrsCache = gvrCache{ data: make(map[string]*GVR), } // NewGVR builds a new gvr from a group, version, resource. func NewGVR(s string) *GVR { raw := s tokens := strings.Split(s, ":") var g, v, r, sr string if len(tokens) == 2 { raw, sr = tokens[0], tokens[1] } tokens = strings.Split(raw, "/") switch len(tokens) { case 3: g, v, r = tokens[0], tokens[1], tokens[2] case 2: v, r = tokens[0], tokens[1] case 1: r = tokens[0] default: slog.Error("GVR init failed!", slogs.Error, fmt.Errorf("can't parse GVR %q", s)) } gvr := GVR{raw: s, g: g, v: v, r: r, sr: sr} if cgvr := gvrsCache.get(gvr.String()); cgvr != nil { return cgvr } gvrsCache.add(&gvr) return &gvr } func (g *GVR) IsAlias() bool { return !g.IsK8sRes() } func (g *GVR) IsK8sRes() bool { return g != nil && ((!strings.Contains(g.raw, " ") && strings.Contains(g.raw, "/") && !strings.Contains(g.raw, " /")) || reservedGVRs.Has(g)) } // WithSubResource builds a new gvr with a sub resource. func (g *GVR) WithSubResource(sub string) *GVR { return NewGVR(g.String() + ":" + sub) } // NewGVRFromMeta builds a gvr from resource metadata. func NewGVRFromMeta(a *metav1.APIResource) *GVR { return NewGVR(path.Join(a.Group, a.Version, a.Name)) } // NewGVRFromCRD builds a gvr from a custom resource definition. func NewGVRFromCRD(crd *apiext.CustomResourceDefinition) map[*GVR]*apiext.CustomResourceDefinitionVersion { mm := make(map[*GVR]*apiext.CustomResourceDefinitionVersion, len(crd.Spec.Versions)) for _, v := range crd.Spec.Versions { if v.Served && !v.Deprecated { gvr := NewGVRFromMeta(&metav1.APIResource{ Kind: crd.Spec.Names.Kind, Group: crd.Spec.Group, Name: crd.Spec.Names.Plural, Version: v.Name, }) mm[gvr] = &v } } return mm } // FromGVAndR builds a gvr from a group/version and resource. func FromGVAndR(gv, r string) *GVR { return NewGVR(path.Join(gv, r)) } // FQN returns a fully qualified resource name. func (g *GVR) FQN(n string) string { return path.Join(g.AsResourceName(), n) } // AsResourceName returns a resource . separated descriptor in the shape of kind.version.group. func (g *GVR) AsResourceName() string { if g.g == "" { return g.r } return g.r + "." + g.v + "." + g.g } // SubResource returns a sub resource if available. func (g *GVR) SubResource() string { return g.sr } // String returns gvr as string. func (g *GVR) String() string { return g.raw } // GV returns the group version scheme representation. func (g *GVR) GV() schema.GroupVersion { return schema.GroupVersion{ Group: g.g, Version: g.v, } } // GVK returns a full schema representation. func (g *GVR) GVK() schema.GroupVersionKind { return schema.GroupVersionKind{ Group: g.G(), Version: g.V(), Kind: g.R(), } } // GVR returns a full schema representation. func (g *GVR) GVR() schema.GroupVersionResource { return schema.GroupVersionResource{ Group: g.G(), Version: g.V(), Resource: g.R(), } } // GVSub returns group vervion sub path. func (g *GVR) GVSub() string { if g.G() == "" { return g.V() } return g.G() + "/" + g.V() } // GR returns a full schema representation. func (g *GVR) GR() *schema.GroupResource { return &schema.GroupResource{ Group: g.G(), Resource: g.R(), } } // V returns the resource version. func (g *GVR) V() string { return g.v } // RG returns the resource and group. func (g *GVR) RG() (resource, group string) { return g.r, g.g } // R returns the resource name. func (g *GVR) R() string { return g.r } // G returns the resource group name. func (g *GVR) G() string { return g.g } // IsDecodable checks if the k8s resource has a decodable view func (g *GVR) IsDecodable() bool { return g == SecGVR } var _ = yaml.Marshaler((*GVR)(nil)) var _ = yaml.Unmarshaler((*GVR)(nil)) func (g *GVR) MarshalYAML() (any, error) { return g.String(), nil } func (g *GVR) UnmarshalYAML(n *yaml.Node) error { *g = *NewGVR(n.Value) return nil } // GVRs represents a collection of gvr. type GVRs []*GVR // Len returns the list size. func (g GVRs) Len() int { return len(g) } // Swap swaps list values. func (g GVRs) Swap(i, j int) { g[i], g[j] = g[j], g[i] } // Less returns true if i < j. func (g GVRs) Less(i, j int) bool { g1, g2 := g[i].G(), g[j].G() return sortorder.NaturalLess(g1, g2) } // Helper... // Can determines the available actions for a given resource. func Can(verbs []string, v string) bool { if verbs == nil { return true } if len(verbs) == 0 { return false } for _, verb := range verbs { candidates, err := mapVerb(v) if err != nil { slog.Error("Access verb mapping failed", slogs.Error, err) return false } for _, c := range candidates { if verb == c { return true } } } return false } func mapVerb(v string) ([]string, error) { switch v { case "describe": return []string{"get"}, nil case "view": return []string{"get", "list"}, nil case "delete": return []string{"delete"}, nil case "edit": return []string{"patch", "update"}, nil default: return []string{}, fmt.Errorf("no standard verb for %q", v) } } ================================================ FILE: internal/client/gvr_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client_test import ( "path" "sort" "testing" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/runtime/schema" ) func TestGVRSort(t *testing.T) { gg := client.GVRs{ client.PodGVR, client.SvcGVR, client.DpGVR, } sort.Sort(gg) assert.Equal(t, client.GVRs{ client.PodGVR, client.SvcGVR, client.DpGVR, }, gg) } func TestGVRCan(t *testing.T) { uu := map[string]struct { vv []string v string e bool }{ "describe": {[]string{"get"}, "describe", true}, "view": {[]string{"get", "list", "watch"}, "view", true}, "delete": {[]string{"delete", "list", "watch"}, "delete", true}, "no_delete": {[]string{"get", "list", "watch"}, "delete", false}, "edit": {[]string{"path", "update", "watch"}, "edit", true}, "no_edit": {[]string{"get", "list", "watch"}, "edit", false}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.Can(u.vv, u.v)) }) } } func TestGVR(t *testing.T) { uu := map[string]struct { gvr string e schema.GroupVersionResource }{ "full": {client.DpGVR.String(), schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}}, "core": {client.PodGVR.String(), schema.GroupVersionResource{Version: "v1", Resource: "pods"}}, "bork": {client.UsrGVR.String(), schema.GroupVersionResource{Resource: "users"}}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).GVR()) }) } } func TestAsGV(t *testing.T) { uu := map[string]struct { gvr string e schema.GroupVersion }{ "full": {client.DpGVR.String(), schema.GroupVersion{Group: "apps", Version: "v1"}}, "core": {client.PodGVR.String(), schema.GroupVersion{Version: "v1"}}, "bork": {client.UsrGVR.String(), schema.GroupVersion{}}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).GV()) }) } } func TestNewGVR(t *testing.T) { uu := map[string]struct { g, v, r string e string }{ "full": {"apps", "v1", "deployments", client.DpGVR.String()}, "core": {"", "v1", "pods", client.PodGVR.String()}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(path.Join(u.g, u.v, u.r)).String()) }) } } func TestGVRAsResourceName(t *testing.T) { uu := map[string]struct { gvr string e string }{ "full": {client.DpGVR.String(), "deployments.v1.apps"}, "core": {client.PodGVR.String(), "pods"}, "k9s": {client.UsrGVR.String(), "users"}, "empty": {"", ""}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).AsResourceName()) }) } } func TestToR(t *testing.T) { uu := map[string]struct { gvr string e string }{ "full": {client.DpGVR.String(), "deployments"}, "core": {client.PodGVR.String(), "pods"}, "k9s": {client.UsrGVR.String(), "users"}, "empty": {"", ""}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).R()) }) } } func TestToG(t *testing.T) { uu := map[string]struct { gvr string e string }{ "full": {client.DpGVR.String(), "apps"}, "core": {client.PodGVR.String(), ""}, "k9s": {client.UsrGVR.String(), ""}, "empty": {"", ""}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).G()) }) } } func TestToV(t *testing.T) { uu := map[string]struct { gvr string e string }{ "full": {client.DpGVR.String(), "v1"}, "core": {"v1beta1/pods", "v1beta1"}, "k9s": {client.UsrGVR.String(), ""}, "empty": {"", ""}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.NewGVR(u.gvr).V()) }) } } func TestToString(t *testing.T) { uu := map[string]struct { gvr string }{ "full": {client.DpGVR.String()}, "core": {"v1beta1/pods"}, "k9s": {client.UsrGVR.String()}, "empty": {""}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.gvr, client.NewGVR(u.gvr).String()) }) } } ================================================ FILE: internal/client/gvrs.go ================================================ package client import "k8s.io/apimachinery/pkg/util/sets" var ( // Apps... DpGVR = NewGVR("apps/v1/deployments") StsGVR = NewGVR("apps/v1/statefulsets") DsGVR = NewGVR("apps/v1/daemonsets") RsGVR = NewGVR("apps/v1/replicasets") RcGVR = NewGVR("apps/v1/replicationcontrollers") // Core... SaGVR = NewGVR("v1/serviceaccounts") PvcGVR = NewGVR("v1/persistentvolumeclaims") PvGVR = NewGVR("v1/persistentvolumes") CmGVR = NewGVR("v1/configmaps") SecGVR = NewGVR("v1/secrets") EvGVR = NewGVR("events.k8s.io/v1/events") EpGVR = NewGVR("v1/endpoints") PodGVR = NewGVR("v1/pods") NsGVR = NewGVR("v1/namespaces") NodeGVR = NewGVR("v1/nodes") SvcGVR = NewGVR("v1/services") // Discovery... EpsGVR = NewGVR("discovery.k8s.io/v1/endpointslices") // Autoscaling... HpaGVR = NewGVR("autoscaling/v1/horizontalpodautoscalers") // Batch... CjGVR = NewGVR("batch/v1/cronjobs") JobGVR = NewGVR("batch/v1/jobs") // Misc... CrdGVR = NewGVR("apiextensions.k8s.io/v1/customresourcedefinitions") PcGVR = NewGVR("scheduling.k8s.io/v1/priorityclasses") NpGVR = NewGVR("networking.k8s.io/v1/networkpolicies") ScGVR = NewGVR("storage.k8s.io/v1/storageclasses") // Policy... PdbGVR = NewGVR("policy/v1/poddisruptionbudgets") PspGVR = NewGVR("policy/v1beta1/podsecuritypolicies") IngGVR = NewGVR("networking.k8s.io/v1/ingresses") // Metrics... NmxGVR = NewGVR("metrics.k8s.io/v1beta1/nodes") PmxGVR = NewGVR("metrics.k8s.io/v1beta1/pods") // K9s... CpuGVR = NewGVR("cpu") MemGVR = NewGVR("memory") WkGVR = NewGVR("workloads") CoGVR = NewGVR("containers") CtGVR = NewGVR("contexts") RefGVR = NewGVR("references") PuGVR = NewGVR("pulses") ScnGVR = NewGVR("scans") DirGVR = NewGVR("dirs") PfGVR = NewGVR("portforwards") SdGVR = NewGVR("screendumps") BeGVR = NewGVR("benchmarks") AliGVR = NewGVR("aliases") XGVR = NewGVR("xrays") HlpGVR = NewGVR("help") QGVR = NewGVR("quit") // Helm... HmGVR = NewGVR("helm") HmhGVR = NewGVR("helm-history") // RBAC... RbacGVR = NewGVR("rbac") PolGVR = NewGVR("policy") UsrGVR = NewGVR("users") GrpGVR = NewGVR("groups") CrGVR = NewGVR("rbac.authorization.k8s.io/v1/clusterroles") CrbGVR = NewGVR("rbac.authorization.k8s.io/v1/clusterrolebindings") RoGVR = NewGVR("rbac.authorization.k8s.io/v1/roles") RobGVR = NewGVR("rbac.authorization.k8s.io/v1/rolebindings") ) var reservedGVRs = sets.New( CpuGVR, MemGVR, WkGVR, CoGVR, CtGVR, RefGVR, PuGVR, ScnGVR, DirGVR, PfGVR, SdGVR, BeGVR, AliGVR, XGVR, HlpGVR, QGVR, HmGVR, HmhGVR, RbacGVR, PolGVR, UsrGVR, GrpGVR, ) ================================================ FILE: internal/client/helper_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestMetaFQN(t *testing.T) { uu := map[string]struct { meta metav1.ObjectMeta e string }{ "empty": { e: "-/", }, "full": { meta: metav1.ObjectMeta{Name: "blee", Namespace: "ns1"}, e: "ns1/blee", }, "no-ns": { meta: metav1.ObjectMeta{Name: "blee"}, e: "-/blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.MetaFQN(&u.meta)) }) } } func TestCoFQN(t *testing.T) { uu := map[string]struct { meta metav1.ObjectMeta co string e string }{ "empty": { e: "-/:", }, "full": { meta: metav1.ObjectMeta{Name: "blee", Namespace: "ns1"}, co: "fred", e: "ns1/blee:fred", }, "no-co": { meta: metav1.ObjectMeta{Name: "blee", Namespace: "ns1"}, e: "ns1/blee:", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.CoFQN(&u.meta, u.co)) }) } } func TestIsClusterScoped(t *testing.T) { uu := map[string]struct { ns string e bool }{ "empty": {}, "all": { ns: client.NamespaceAll, }, "none": { ns: client.BlankNamespace, }, "custom": { ns: "fred", }, "scoped": { ns: "-", e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.IsClusterScoped(u.ns)) }) } } func TestIsNamespaced(t *testing.T) { uu := map[string]struct { ns string e bool }{ "empty": {}, "all": { ns: client.NamespaceAll, }, "cluster": { ns: client.ClusterScope, }, "none": { ns: client.BlankNamespace, }, "custom": { ns: "fred", e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.IsNamespaced(u.ns)) }) } } func TestIsAllNamespaces(t *testing.T) { uu := map[string]struct { ns string e bool }{ "empty": { e: true, }, "all": { ns: client.NamespaceAll, e: true, }, "none": { ns: client.BlankNamespace, e: true, }, "custom": { ns: "fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.IsAllNamespaces(u.ns)) }) } } func TestIsAllNamespace(t *testing.T) { uu := map[string]struct { ns string e bool }{ "empty": {}, "all": { ns: client.NamespaceAll, e: true, }, "custom": { ns: "fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.IsAllNamespace(u.ns)) }) } } func TestCleanseNamespace(t *testing.T) { uu := map[string]struct { ns, e string }{ "empty": {}, "all": { ns: client.NamespaceAll, e: client.BlankNamespace, }, "custom": { ns: "fred", e: "fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, client.CleanseNamespace(u.ns)) }) } } func TestNamespaced(t *testing.T) { uu := []struct { p, ns, n string }{ {"fred/blee", "fred", "blee"}, {"blee", "", "blee"}, } for _, u := range uu { ns, n := client.Namespaced(u.p) assert.Equal(t, u.ns, ns) assert.Equal(t, u.n, n) } } func TestFQN(t *testing.T) { uu := []struct { ns, n string e string }{ {"fred", "blee", "fred/blee"}, {"", "blee", "blee"}, } for _, u := range uu { assert.Equal(t, u.e, client.FQN(u.ns, u.n)) } } ================================================ FILE: internal/client/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "log/slog" "os" "os/user" "path" "regexp" "strings" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var toFileName = regexp.MustCompile(`[^(\w/.)]`) // IsClusterWide returns true if ns designates cluster scope, false otherwise. 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) { return BlankNamespace } return ns } // IsAllNamespace returns true if ns == all. func IsAllNamespace(ns string) bool { return ns == NamespaceAll } // IsAllNamespaces returns true if all namespaces, false otherwise. func IsAllNamespaces(ns string) bool { return ns == NamespaceAll || ns == BlankNamespace } // IsNamespaced returns true if a specific ns is given. func IsNamespaced(ns string) bool { return !IsAllNamespaces(ns) && !IsClusterScoped(ns) } // IsClusterScoped returns true if resource is not namespaced. func IsClusterScoped(ns string) bool { return ns == ClusterScope } // Namespaced converts a resource path to namespace and resource name. func Namespaced(p string) (ns, name string) { ns, name = path.Split(p) return strings.Trim(ns, "/"), name } // CoFQN returns a fully qualified container name. func CoFQN(m *metav1.ObjectMeta, co string) string { return MetaFQN(m) + ":" + co } // FQN returns a fully qualified resource name. func FQN(ns, n string) string { if ns == "" { return n } return ns + "/" + n } // MetaFQN returns a fully qualified resource name. func MetaFQN(m *metav1.ObjectMeta) string { if m.Namespace == "" { return FQN(ClusterScope, m.Name) } return FQN(m.Namespace, m.Name) } func mustHomeDir() string { usr, err := user.Current() if err != nil { slog.Error("Die getting user home directory", slogs.Error, err) os.Exit(1) } return usr.HomeDir } func toHostDir(host string) string { h := strings.Replace( strings.Replace(host, "https://", "", 1), "http://", "", 1, ) return toFileName.ReplaceAllString(h, "_") } ================================================ FILE: internal/client/metrics.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "context" "errors" "fmt" "math" "strconv" "time" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/cache" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) const ( mxCacheSize = 100 mxCacheExpiry = 1 * time.Minute ) // MetricsDial tracks global metric server handle. var MetricsDial *MetricsServer // DialMetrics dials the metrics server. func DialMetrics(c Connection) *MetricsServer { if MetricsDial == nil { MetricsDial = NewMetricsServer(c) } return MetricsDial } // ResetMetrics resets the metric server handle. func ResetMetrics() { MetricsDial = nil } // MetricsServer serves cluster metrics for nodes and pods. type MetricsServer struct { Connection cache *cache.LRUExpireCache } // NewMetricsServer return a metric server instance. func NewMetricsServer(c Connection) *MetricsServer { return &MetricsServer{ Connection: c, cache: cache.NewLRUExpireCache(mxCacheSize), } } // ClusterLoad retrieves all cluster nodes metrics. func (*MetricsServer) ClusterLoad(nos *v1.NodeList, nmx *mv1beta1.NodeMetricsList, mx *ClusterMetrics) error { if nos == nil || nmx == nil { return fmt.Errorf("invalid node or node metrics lists") } nodeMetrics := make(NodesMetrics, len(nos.Items)) for i := range nos.Items { nodeMetrics[nos.Items[i].Name] = NodeMetrics{ AllocatableCPU: nos.Items[i].Status.Allocatable.Cpu().MilliValue(), AllocatableMEM: nos.Items[i].Status.Allocatable.Memory().Value(), } } for i := range nmx.Items { if node, ok := nodeMetrics[nmx.Items[i].Name]; ok { node.CurrentCPU = nmx.Items[i].Usage.Cpu().MilliValue() node.CurrentMEM = nmx.Items[i].Usage.Memory().Value() nodeMetrics[nmx.Items[i].Name] = node } } var ccpu, cmem, tcpu, tmem int64 for _, mx := range nodeMetrics { ccpu += mx.CurrentCPU cmem += mx.CurrentMEM tcpu += mx.AllocatableCPU tmem += mx.AllocatableMEM } mx.PercCPU, mx.PercMEM = ToPercentage(ccpu, tcpu), ToPercentage(cmem, tmem) return nil } func (m *MetricsServer) checkAccess(ns string, gvr *GVR, msg string) error { if !m.HasMetrics() { return errors.New("no metrics-server detected on cluster") } auth, err := m.CanI(ns, gvr, "", ListAccess) if err != nil { return err } if !auth { return errors.New(msg) } return nil } // NodesMetrics retrieves metrics for a given set of nodes. func (*MetricsServer) NodesMetrics(nodes *v1.NodeList, metrics *mv1beta1.NodeMetricsList, mmx NodesMetrics) { if nodes == nil || metrics == nil { return } for i := range nodes.Items { mmx[nodes.Items[i].Name] = NodeMetrics{ AllocatableCPU: nodes.Items[i].Status.Allocatable.Cpu().MilliValue(), AllocatableMEM: ToMB(nodes.Items[i].Status.Allocatable.Memory().Value()), AllocatableEphemeral: ToMB(nodes.Items[i].Status.Allocatable.StorageEphemeral().Value()), TotalCPU: nodes.Items[i].Status.Capacity.Cpu().MilliValue(), TotalMEM: ToMB(nodes.Items[i].Status.Capacity.Memory().Value()), TotalEphemeral: ToMB(nodes.Items[i].Status.Capacity.StorageEphemeral().Value()), } } for i := range metrics.Items { mx, ok := mmx[metrics.Items[i].Name] if !ok { continue } mx.CurrentCPU = metrics.Items[i].Usage.Cpu().MilliValue() mx.CurrentMEM = ToMB(metrics.Items[i].Usage.Memory().Value()) mx.AvailableCPU = mx.AllocatableCPU - mx.CurrentCPU mx.AvailableMEM = mx.AllocatableMEM - mx.CurrentMEM mmx[metrics.Items[i].Name] = mx } } // FetchNodesMetricsMap fetch node metrics as a map. func (m *MetricsServer) FetchNodesMetricsMap(ctx context.Context) (NodesMetricsMap, error) { mm, err := m.FetchNodesMetrics(ctx) if err != nil { return nil, err } hh := make(NodesMetricsMap, len(mm.Items)) for i := range mm.Items { mx := mm.Items[i] hh[mx.Name] = &mx } return hh, nil } // FetchNodesMetrics return all metrics for nodes. func (m *MetricsServer) FetchNodesMetrics(ctx context.Context) (*mv1beta1.NodeMetricsList, error) { const msg = "user is not authorized to list node metrics" mx := new(mv1beta1.NodeMetricsList) if err := m.checkAccess(ClusterScope, NmxGVR, msg); err != nil { return mx, err } const key = "nodes" if entry, ok := m.cache.Get(key); ok && entry != nil { mxList, ok := entry.(*mv1beta1.NodeMetricsList) if !ok { return nil, fmt.Errorf("expected nodemetricslist but got %T", entry) } return mxList, nil } client, err := m.MXDial() if err != nil { return mx, err } mxList, err := client.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{}) if err != nil { return mx, err } m.cache.Add(key, mxList, mxCacheExpiry) return mxList, nil } // FetchNodeMetrics return all metrics for nodes. func (m *MetricsServer) FetchNodeMetrics(ctx context.Context, n string) (*mv1beta1.NodeMetrics, error) { const msg = "user is not authorized to list node metrics" mx := new(mv1beta1.NodeMetrics) if err := m.checkAccess(ClusterScope, NmxGVR, msg); err != nil { return mx, err } mmx, err := m.FetchNodesMetricsMap(ctx) if err != nil { return nil, err } mx, ok := mmx[n] if !ok { return nil, fmt.Errorf("unable to retrieve node metrics for %q", n) } return mx, nil } // FetchPodsMetricsMap fetch pods metrics as a map. func (m *MetricsServer) FetchPodsMetricsMap(ctx context.Context, ns string) (PodsMetricsMap, error) { mm, err := m.FetchPodsMetrics(ctx, ns) if err != nil { return nil, err } hh := make(PodsMetricsMap, len(mm.Items)) for i := range mm.Items { mx := mm.Items[i] hh[FQN(mx.Namespace, mx.Name)] = &mx } return hh, nil } // FetchPodsMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodsMetrics(ctx context.Context, ns string) (*mv1beta1.PodMetricsList, error) { mx := new(mv1beta1.PodMetricsList) const msg = "user is not authorized to list pods metrics" if ns == NamespaceAll { ns = BlankNamespace } if err := m.checkAccess(ns, PmxGVR, msg); err != nil { return mx, err } key := FQN(ns, "pods") if entry, ok := m.cache.Get(key); ok { mxList, ok := entry.(*mv1beta1.PodMetricsList) if !ok { return mx, fmt.Errorf("expected PodMetricsList but got %T", entry) } return mxList, nil } client, err := m.MXDial() if err != nil { return mx, err } mxList, err := client.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{}) if err != nil { return mx, err } m.cache.Add(key, mxList, mxCacheExpiry) return mxList, err } // FetchContainersMetrics returns a pod's containers metrics. func (m *MetricsServer) FetchContainersMetrics(ctx context.Context, fqn string) (ContainersMetrics, error) { mm, err := m.FetchPodMetrics(ctx, fqn) if err != nil { return nil, err } cmx := make(ContainersMetrics, len(mm.Containers)) for i := range mm.Containers { c := mm.Containers[i] cmx[c.Name] = &c } return cmx, nil } // FetchPodMetrics return all metrics for pods in a given namespace. func (m *MetricsServer) FetchPodMetrics(ctx context.Context, fqn string) (*mv1beta1.PodMetrics, error) { var mx *mv1beta1.PodMetrics const msg = "user is not authorized to list pod metrics" ns, _ := Namespaced(fqn) if ns == NamespaceAll { ns = BlankNamespace } if err := m.checkAccess(ns, PmxGVR, msg); err != nil { return mx, err } mmx, err := m.FetchPodsMetricsMap(ctx, ns) if err != nil { return nil, err } pmx, ok := mmx[fqn] if !ok { return nil, fmt.Errorf("unable to locate pod metrics for pod %q", fqn) } return pmx, nil } // PodsMetrics retrieves metrics for all pods in a given namespace. func (*MetricsServer) PodsMetrics(pods *mv1beta1.PodMetricsList, mmx PodsMetrics) { if pods == nil { return } // Compute all pod's containers metrics. for i := range pods.Items { var mx PodMetrics for _, c := range pods.Items[i].Containers { mx.CurrentCPU += c.Usage.Cpu().MilliValue() mx.CurrentMEM += ToMB(c.Usage.Memory().Value()) } mmx[pods.Items[i].Namespace+"/"+pods.Items[i].Name] = mx } } // ---------------------------------------------------------------------------- // Helpers... // MegaByte represents a megabyte. const MegaByte = 1024 * 1024 // ToMB converts bytes to megabytes. func ToMB(v int64) int64 { return v / MegaByte } // ToPercentage computes percentage as string otherwise n/aa. func ToPercentage(v, dv int64) int { if dv == 0 { return 0 } return int(math.Floor((float64(v) / float64(dv)) * 100)) } // ToPercentageStr computes percentage, but if v2 is 0, it will return NAValue instead of 0. func ToPercentageStr(v, dv int64) string { if dv == 0 { return NA } return strconv.Itoa(ToPercentage(v, dv)) } ================================================ FILE: internal/client/metrics_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client_test import ( "testing" "github.com/derailed/k9s/internal/client" "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" v1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) func TestToPercentage(t *testing.T) { uu := []struct { v1, v2 int64 e int }{ {0, 0, 0}, {100, 200, 50}, {200, 100, 200}, {224, 4000, 5}, } for _, u := range uu { assert.Equal(t, u.e, client.ToPercentage(u.v1, u.v2)) } } func TestToMB(t *testing.T) { uu := []struct { v int64 e int64 }{ {0, 0}, {2 * client.MegaByte, 2}, {10 * client.MegaByte, 10}, } for _, u := range uu { assert.Equal(t, u.e, client.ToMB(u.v)) } } func TestPodsMetrics(t *testing.T) { uu := map[string]struct { metrics *v1beta1.PodMetricsList eSize int e client.PodsMetrics }{ "dud": { eSize: 0, }, "ok": { metrics: &v1beta1.PodMetricsList{ Items: []v1beta1.PodMetrics{ *makeMxPod("p1", "1", "4Gi"), *makeMxPod("p2", "50m", "1Mi"), }, }, eSize: 2, e: client.PodsMetrics{ "default/p1": client.PodMetrics{ CurrentCPU: 3000, CurrentMEM: 12288, }, }, }, } m := client.NewMetricsServer(nil) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { mmx := make(client.PodsMetrics) m.PodsMetrics(u.metrics, mmx) assert.Len(t, mmx, u.eSize) if u.eSize == 0 { return } mx, ok := mmx["default/p1"] assert.True(t, ok) assert.Equal(t, u.e["default/p1"], mx) }) } } func BenchmarkPodsMetrics(b *testing.B) { m := client.NewMetricsServer(nil) metrics := v1beta1.PodMetricsList{ Items: []v1beta1.PodMetrics{ *makeMxPod("p1", "1", "4Gi"), *makeMxPod("p2", "50m", "1Mi"), *makeMxPod("p3", "50m", "1Mi"), }, } mmx := make(client.PodsMetrics, 3) b.ResetTimer() b.ReportAllocs() for range b.N { m.PodsMetrics(&metrics, mmx) } } func TestNodesMetrics(t *testing.T) { uu := map[string]struct { nodes *v1.NodeList metrics *v1beta1.NodeMetricsList eSize int e client.NodesMetrics }{ "duds": { eSize: 0, }, "no_nodes": { metrics: &v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "10", "8Gi"), *makeMxNode("n2", "50m", "1Mi"), }, }, eSize: 0, }, "no_metrics": { nodes: &v1.NodeList{ Items: []v1.Node{ makeNode("n1", "32", "128Gi", "50m", "2Mi"), makeNode("n2", "8", "4Gi", "50m", "10Mi"), }, }, eSize: 0, }, "ok": { nodes: &v1.NodeList{ Items: []v1.Node{ makeNode("n1", "32", "128Gi", "32", "128Gi"), makeNode("n2", "8", "4Gi", "8", "4Gi"), }, }, metrics: &v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "10", "8Gi"), *makeMxNode("n2", "50m", "1Mi"), }, }, eSize: 2, e: client.NodesMetrics{ "n1": client.NodeMetrics{ TotalCPU: 32000, TotalMEM: 131072, AllocatableCPU: 32000, AllocatableMEM: 131072, AvailableCPU: 22000, AvailableMEM: 122880, CurrentMetrics: client.CurrentMetrics{ CurrentCPU: 10000, CurrentMEM: 8192, }, }, }, }, } m := client.NewMetricsServer(nil) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { mmx := make(client.NodesMetrics) m.NodesMetrics(u.nodes, u.metrics, mmx) assert.Len(t, mmx, u.eSize) if u.eSize == 0 { return } mx, ok := mmx["n1"] assert.True(t, ok) assert.Equal(t, u.e["n1"], mx) }) } } func BenchmarkNodesMetrics(b *testing.B) { nodes := v1.NodeList{ Items: []v1.Node{ makeNode("n1", "100m", "4Mi", "100m", "2Mi"), makeNode("n2", "100m", "4Mi", "100m", "2Mi"), }, } metrics := v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "50m", "1Mi"), *makeMxNode("n2", "50m", "1Mi"), }, } m := client.NewMetricsServer(nil) mmx := make(client.NodesMetrics) b.ResetTimer() b.ReportAllocs() for range b.N { m.NodesMetrics(&nodes, &metrics, mmx) } } func TestClusterLoad(t *testing.T) { uu := map[string]struct { nodes *v1.NodeList metrics *v1beta1.NodeMetricsList eSize int e client.ClusterMetrics }{ "duds": { eSize: 0, }, "no_nodes": { metrics: &v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "10", "8Gi"), *makeMxNode("n2", "50m", "1Mi"), }, }, eSize: 0, }, "no_metrics": { nodes: &v1.NodeList{ Items: []v1.Node{ makeNode("n1", "32", "128Gi", "50m", "2Mi"), makeNode("n2", "8", "4Gi", "50m", "10Mi"), }, }, eSize: 0, }, "ok": { nodes: &v1.NodeList{ Items: []v1.Node{ makeNode("n1", "100m", "4Mi", "50m", "2Mi"), makeNode("n2", "100m", "4Mi", "50m", "2Mi"), }, }, metrics: &v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "50m", "1Mi"), *makeMxNode("n2", "50m", "1Mi"), }, }, eSize: 2, e: client.ClusterMetrics{ PercCPU: 100.0, PercMEM: 50.0, }, }, } m := client.NewMetricsServer(nil) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { var cmx client.ClusterMetrics _ = m.ClusterLoad(u.nodes, u.metrics, &cmx) assert.Equal(t, u.e, cmx) }) } } func BenchmarkClusterLoad(b *testing.B) { nodes := v1.NodeList{ Items: []v1.Node{ makeNode("n1", "100m", "4Mi", "50m", "2Mi"), makeNode("n2", "100m", "4Mi", "50m", "2Mi"), }, } metrics := v1beta1.NodeMetricsList{ Items: []v1beta1.NodeMetrics{ *makeMxNode("n1", "50m", "1Mi"), *makeMxNode("n2", "50m", "1Mi"), }, } m := client.NewMetricsServer(nil) var mx client.ClusterMetrics b.ResetTimer() b.ReportAllocs() for range b.N { _ = m.ClusterLoad(&nodes, &metrics, &mx) } } // ---------------------------------------------------------------------------- // Helpers... func makeMxPod(name, cpu, mem string) *v1beta1.PodMetrics { return &v1beta1.PodMetrics{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, Containers: []v1beta1.ContainerMetrics{ {Usage: makeRes(cpu, mem)}, {Usage: makeRes(cpu, mem)}, {Usage: makeRes(cpu, mem)}, }, } } func makeNode(name, tcpu, tmem, acpu, amem string) v1.Node { return v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: name, }, Status: v1.NodeStatus{ Capacity: makeRes(tcpu, tmem), Allocatable: makeRes(acpu, amem), }, } } func makeMxNode(name, cpu, mem string) *v1beta1.NodeMetrics { return &v1beta1.NodeMetrics{ ObjectMeta: metav1.ObjectMeta{ Name: name, }, Usage: makeRes(cpu, mem), } } func makeRes(c, m string) v1.ResourceList { cpu, _ := resource.ParseQuantity(c) mem, _ := resource.ParseQuantity(m) return v1.ResourceList{ v1.ResourceCPU: cpu, v1.ResourceMemory: mem, } } ================================================ FILE: internal/client/switch_context_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "encoding/json" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/cache" "k8s.io/apimachinery/pkg/version" "k8s.io/cli-runtime/pkg/genericclioptions" ) const ( testContext1 = "context1" testContext2 = "context2" ) func newFakeK8sServer(t *testing.T) (*httptest.Server, *atomic.Int32) { t.Helper() versionCalls := &atomic.Int32{} mux := http.NewServeMux() mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { versionCalls.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(version.Info{ Major: "1", Minor: "28", GitVersion: "v1.28.0", }) }) mux.HandleFunc("/api", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"]}`)) }) mux.HandleFunc("/apis", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`)) }) return httptest.NewServer(mux), versionCalls } func writeSwitchTestKubeconfig(t *testing.T, server1URL, server2URL string) string { t.Helper() dir := t.TempDir() kubeconfig := filepath.Join(dir, "config") content := `apiVersion: v1 kind: Config current-context: context1 contexts: - context: cluster: cluster1 user: user1 namespace: default name: context1 - context: cluster: cluster2 user: user1 namespace: kube-system name: context2 clusters: - cluster: server: ` + server1URL + ` name: cluster1 - cluster: server: ` + server2URL + ` name: cluster2 users: - name: user1 user: token: test-token ` require.NoError(t, os.WriteFile(kubeconfig, []byte(content), 0600)) return kubeconfig } func setupSwitchTest(t *testing.T) (*httptest.Server, *atomic.Int32, *APIClient) { t.Helper() srv, versionCalls := newFakeK8sServer(t) t.Cleanup(srv.Close) t.Setenv("HOME", t.TempDir()) kubeconfig := writeSwitchTestKubeconfig(t, srv.URL, srv.URL) flags := genericclioptions.NewConfigFlags(false) flags.KubeConfig = &kubeconfig ctx := testContext1 flags.Context = &ctx a := &APIClient{ config: NewConfig(flags), cache: cache.NewLRUExpireCache(cacheSize), connOK: true, log: slog.Default(), } return srv, versionCalls, a } func TestSwitchContextSuccess(t *testing.T) { _, _, a := setupSwitchTest(t) err := a.SwitchContext(testContext2) require.NoError(t, err) ctx, err := a.config.CurrentContextName() require.NoError(t, err) assert.Equal(t, testContext2, ctx) assert.True(t, a.getConnOK()) } func TestSwitchContextReusesConnectivityClient(t *testing.T) { _, _, a := setupSwitchTest(t) err := a.SwitchContext(testContext2) require.NoError(t, err) assert.NotNil(t, a.getClient(), "SwitchContext should store the connectivity-check client for reuse by Dial()") } func TestSwitchContextPreWarmsDynDial(t *testing.T) { _, _, a := setupSwitchTest(t) err := a.SwitchContext(testContext2) require.NoError(t, err) assert.NotNil(t, a.getDClient(), "SwitchContext should pre-warm the dynamic client so gotoResource reuses it") } func TestSwitchContextDialAfterSwitch(t *testing.T) { _, _, a := setupSwitchTest(t) err := a.SwitchContext(testContext2) require.NoError(t, err) storedClient := a.getClient() require.NotNil(t, storedClient, "SwitchContext should store client") dialedClient, err := a.Dial() require.NoError(t, err) assert.Same(t, storedClient, dialedClient, "Dial() should return the stored connectivity client, not create a new one") } func TestSwitchContextMinimalVersionCalls(t *testing.T) { _, versionCalls, a := setupSwitchTest(t) err := a.SwitchContext(testContext2) require.NoError(t, err) assert.Equal(t, int32(1), versionCalls.Load(), "SwitchContext should call ServerVersion exactly once") } func TestSwitchContextInvalidContext(t *testing.T) { _, _, a := setupSwitchTest(t) err := a.SwitchContext("nonexistent") assert.Error(t, err) } func TestInitConnectionMetricsUnsupported(t *testing.T) { srv, _, _ := setupSwitchTest(t) kubeconfig := writeSwitchTestKubeconfig(t, srv.URL, srv.URL) flags := genericclioptions.NewConfigFlags(false) flags.KubeConfig = &kubeconfig ctx := testContext1 flags.Context = &ctx a, err := InitConnection(NewConfig(flags), slog.Default()) require.NoError(t, err) assert.True(t, a.ConnectionOK(), "InitConnection should succeed when metrics-server is absent") } func TestInitConnectionStoresDialClient(t *testing.T) { srv, _, _ := setupSwitchTest(t) kubeconfig := writeSwitchTestKubeconfig(t, srv.URL, srv.URL) flags := genericclioptions.NewConfigFlags(false) flags.KubeConfig = &kubeconfig ctx := testContext1 flags.Context = &ctx a, err := InitConnection(NewConfig(flags), slog.Default()) require.NoError(t, err) assert.NotNil(t, a.getClient(), "InitConnection should store a Dial client for reuse") } ================================================ FILE: internal/client/testdata/config ================================================ apiVersion: v1 kind: Config preferences: {} clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3000 name: fred - cluster: insecure-skip-tls-verify: true server: https://localhost:3001 name: blee - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 name: zorg contexts: - context: cluster: zorg user: fred name: fred - context: cluster: blee user: blee namespace: zorg name: blee - context: cluster: duh user: duh name: duh current-context: fred users: - name: fred user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: blee user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: duh user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== ================================================ FILE: internal/client/testdata/config.1 ================================================ apiVersion: v1 clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3001 name: blee - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 name: fred contexts: - context: cluster: blee user: blee name: blee current-context: blee kind: Config users: null ================================================ FILE: internal/client/testdata/config.2 ================================================ apiVersion: v1 clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3001 name: blee - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 name: fred contexts: - context: cluster: blee user: blee name: blee - context: cluster: fred user: fred name: fred current-context: blee kind: Config preferences: {} users: null ================================================ FILE: internal/client/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package client import ( "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery/cached/disk" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" versioned "k8s.io/metrics/pkg/client/clientset/versioned" ) const ( // NA Not available. NA = "n/a" // NamespaceAll designates the fictional all namespace. NamespaceAll = "all" // BlankNamespace designates no namespace. BlankNamespace = "" // DefaultNamespace designates the default namespace. DefaultNamespace = "default" // ClusterScope designates a resource is not namespaced. ClusterScope = "-" // NotNamespaced designates a non resource namespace. NotNamespaced = "*" // CreateVerb represents create access on a resource. CreateVerb = "create" // UpdateVerb represents an update access on a resource. UpdateVerb = "update" // PatchVerb represents a patch access on a resource. PatchVerb = "patch" // DeleteVerb represents a delete access on a resource. DeleteVerb = "delete" // GetVerb represents a get access on a resource. GetVerb = "get" // ListVerb represents a list access on a resource. ListVerb = "list" // WatchVerb represents a watch access on a resource. WatchVerb = "watch" ) var ( // PatchAccess patch a resource. PatchAccess = []string{PatchVerb} // GetAccess reads a resource. GetAccess = []string{GetVerb} // ListAccess list resources. ListAccess = []string{ListVerb} // MonitorAccess monitors a collection of resources. MonitorAccess = []string{ListVerb, WatchVerb} // ReadAllAccess represents an all read access to a resource. ReadAllAccess = []string{GetVerb, ListVerb, WatchVerb} ) // ContainersMetrics tracks containers metrics. type ContainersMetrics map[string]*mv1beta1.ContainerMetrics // NodesMetricsMap tracks node metrics. type NodesMetricsMap map[string]*mv1beta1.NodeMetrics // PodsMetricsMap tracks pod metrics. type PodsMetricsMap map[string]*mv1beta1.PodMetrics // Authorizer checks what a user can or cannot do to a resource. type Authorizer interface { // CanI returns true if the user can use these actions for a given resource. CanI(ns string, gvr *GVR, n string, verbs []string) (bool, error) } // Connection represents a Kubernetes apiserver connection. type Connection interface { Authorizer // Config returns current config. Config() *Config // ConnectionOK checks api server connection status. ConnectionOK() bool // Dial connects to api server. Dial() (kubernetes.Interface, error) // DialLogs connects to api server for logs. DialLogs() (kubernetes.Interface, error) // SwitchContext switches cluster based on context. SwitchContext(ctx string) error // CachedDiscovery connects to discovery client. CachedDiscovery() (*disk.CachedDiscoveryClient, error) // RestConfig connects to rest client. RestConfig() (*restclient.Config, error) // MXDial connects to metrics server. MXDial() (*versioned.Clientset, error) // DynDial connects to dynamic client. DynDial() (dynamic.Interface, error) // HasMetrics checks if metrics server is available. HasMetrics() bool // ValidNamespaceNames returns all available namespace names. ValidNamespaceNames() (NamespaceNames, error) // IsValidNamespace checks if given namespace is known. IsValidNamespace(string) bool // ServerVersion returns current server version. ServerVersion() (*version.Info, error) // CheckConnectivity checks if api server connection is happy or not. CheckConnectivity() bool // ActiveContext returns the current context name. ActiveContext() string // ActiveNamespace returns the current namespace. ActiveNamespace() string // IsActiveNamespace checks if given ns is active. IsActiveNamespace(string) bool } // CurrentMetrics tracks current cpu/mem. type CurrentMetrics struct { CurrentCPU, CurrentMEM, CurrentEphemeral int64 } // PodMetrics represent an aggregation of all pod containers metrics. type PodMetrics CurrentMetrics // NodeMetrics describes raw node metrics. type NodeMetrics struct { CurrentMetrics AllocatableCPU, AllocatableMEM, AllocatableEphemeral int64 AvailableCPU, AvailableMEM, AvailableEphemeral int64 TotalCPU, TotalMEM, TotalEphemeral int64 } // ClusterMetrics summarizes total node metrics as percentages. type ClusterMetrics struct { PercCPU, PercMEM, PercEphemeral int } // NodesMetrics tracks usage metrics per nodes. type NodesMetrics map[string]NodeMetrics // PodsMetrics tracks usage metrics per pods. type PodsMetrics map[string]PodMetrics ================================================ FILE: internal/color/colorize.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package color import ( "fmt" "strconv" ) const colorFmt = "\x1b[%dm%s\x1b[0m" // Paint describes a terminal color. type Paint int // Defines basic ANSI colors. const ( Black Paint = iota + 30 // 30 Red // 31 Green // 32 Yellow // 33 Blue // 34 Magenta // 35 Cyan // 36 LightGray // 37 DarkGray = 90 Bold = 1 ) // Colorize returns an ASCII colored string based on given color. func Colorize(s string, c Paint) string { if c == 0 { return s } return fmt.Sprintf(colorFmt, c, s) } // ANSIColorize colors a string. func ANSIColorize(text string, color int) string { return "\033[38;5;" + strconv.Itoa(color) + "m" + text + "\033[0m" } // Highlight colorize bytes at given indices. func Highlight(bb []byte, ii []int, c int) []byte { if len(ii) == 0 { return bb } result := make([]byte, 0, len(bb)+len(ii)*20) // Extra space for color codes // Create a map of byte positions that should be highlighted highlightMap := make(map[int]bool) for _, pos := range ii { highlightMap[pos] = true } // Process each byte for i := 0; i < len(bb); i++ { if highlightMap[i] { // Check if this is the start of a UTF-8 character if (bb[i] & 0xC0) != 0x80 { // This is the start of a character, find the end charStart := i charEnd := i + 1 for charEnd < len(bb) && (bb[charEnd]&0xC0) == 0x80 { charEnd++ } // Colorize the entire character char := string(bb[charStart:charEnd]) colored := ANSIColorize(char, c) result = append(result, []byte(colored)...) i = charEnd - 1 // Skip the rest of the character bytes } else { // This is a continuation byte, skip it (already handled) continue } } else { result = append(result, bb[i]) } } return result } ================================================ FILE: internal/color/colorize_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package color_test import ( "testing" "github.com/derailed/k9s/internal/color" "github.com/stretchr/testify/assert" ) func TestColorize(t *testing.T) { uu := map[string]struct { s string c color.Paint e string }{ "white": {"blee", color.LightGray, "\x1b[37mblee\x1b[0m"}, "black": {"blee", color.Black, "\x1b[30mblee\x1b[0m"}, "default": {"blee", 0, "blee"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, color.Colorize(u.s, u.c)) }) } } func TestHighlight(t *testing.T) { uu := map[string]struct { text []byte indices []int color int e string }{ "white": { text: []byte("the brown fox"), color: 209, indices: []int{4, 5, 6, 7, 8}, e: "the \x1b[38;5;209mb\x1b[0m\x1b[38;5;209mr\x1b[0m\x1b[38;5;209mo\x1b[0m\x1b[38;5;209mw\x1b[0m\x1b[38;5;209mn\x1b[0m fox", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, string(color.Highlight(u.text, u.indices, u.color))) }) } } ================================================ FILE: internal/config/alias.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "errors" "io/fs" "log/slog" "os" "strings" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/view/cmd" "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/util/sets" ) type ( // Alias tracks shortname to GVR mappings. Alias map[string]*client.GVR // ShortNames represents a collection of shortnames for aliases. ShortNames map[*client.GVR][]string // Aliases represents a collection of aliases. Aliases struct { Alias Alias `yaml:"aliases"` mx sync.RWMutex } ) // NewAliases return a new alias. func NewAliases() *Aliases { return &Aliases{ Alias: make(Alias, 50), } } func (a *Aliases) AliasesFor(gvr *client.GVR) sets.Set[string] { a.mx.RLock() defer a.mx.RUnlock() ss := sets.New[string]() for alias, aliasGVR := range a.Alias { if aliasGVR == gvr { ss.Insert(alias) } } return ss } // ShortNames return all shortnames. func (a *Aliases) ShortNames() ShortNames { a.mx.RLock() defer a.mx.RUnlock() m := make(ShortNames, len(a.Alias)) for alias, gvr := range a.Alias { if v, ok := m[gvr]; ok { m[gvr] = append(v, alias) } else { m[gvr] = []string{alias} } } return m } // Clear remove all aliases. func (a *Aliases) Clear() { a.mx.Lock() defer a.mx.Unlock() for k := range a.Alias { delete(a.Alias, k) } } func (a *Aliases) Resolve(p *cmd.Interpreter) (*client.GVR, bool) { gvr, ok := a.Get(p.Cmd()) if !ok { return nil, false } if gvr.IsK8sRes() { p.Reset(strings.Replace(p.GetLine(), p.Cmd(), gvr.String(), 1), p.Cmd()) return gvr, true } for gvr.IsAlias() { ap := cmd.NewInterpreter(gvr.String()) gvr, ok = a.Get(ap.Cmd()) if !ok { return gvr, false } ap.Merge(p) p.Reset(strings.Replace(ap.GetLine(), ap.Cmd(), gvr.String(), 1), ap.Cmd()) } return gvr, true } // Get retrieves an alias. func (a *Aliases) Get(alias string) (*client.GVR, bool) { a.mx.RLock() defer a.mx.RUnlock() gvr, ok := a.Alias[alias] return gvr, ok } // Define declares a new alias. func (a *Aliases) Define(gvr *client.GVR, aliases ...string) { a.mx.Lock() defer a.mx.Unlock() for _, alias := range aliases { if _, ok := a.Alias[alias]; !ok && alias != "" { a.Alias[alias] = gvr } } } // Load K9s aliases. func (a *Aliases) Load(path string) error { a.loadDefaultAliases() f, err := EnsureAliasesCfgFile() if err != nil { slog.Error("Unable to gen config aliases", slogs.Error, err) } // load global alias file if err := a.LoadFile(f); err != nil { return err } // load context specific aliases if any return a.LoadFile(path) } // LoadFile loads alias from a given file. func (a *Aliases) LoadFile(path string) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil } bb, err := os.ReadFile(path) if err != nil { return err } if err := data.JSONValidator.Validate(json.AliasesSchema, bb); err != nil { slog.Warn("Aliases validation failed", slogs.Error, err) } a.mx.Lock() if err := yaml.Unmarshal(bb, a); err != nil { return err } for k, v := range a.Alias { a.Alias[k] = client.NewGVR(v.String()) } defer a.mx.Unlock() return nil } func (a *Aliases) declare(gvr *client.GVR, aliases ...string) { a.Alias[gvr.String()] = gvr for _, alias := range aliases { a.Alias[alias] = gvr } } func (a *Aliases) loadDefaultAliases() { a.mx.Lock() defer a.mx.Unlock() a.declare(client.HlpGVR, "h", "?") a.declare(client.QGVR, "q", "q!", "qa", "Q") a.declare(client.AliGVR, "alias", "a") a.declare(client.HmGVR, "charts", "chart", "hm") a.declare(client.DirGVR, "dir", "d") a.declare(client.CtGVR, "context", "ctx") a.declare(client.UsrGVR, "user", "usr") a.declare(client.GrpGVR, "group", "grp") a.declare(client.PfGVR, "portforward", "pf") a.declare(client.BeGVR, "benchmark", "bench") a.declare(client.SdGVR, "screendump", "sd") a.declare(client.PuGVR, "pulse", "pu", "hz") a.declare(client.XGVR, "xray", "x") a.declare(client.WkGVR, "workload", "wk") } // Save alias to disk. func (a *Aliases) Save() error { slog.Debug("Saving Aliases...") a.mx.RLock() defer a.mx.RUnlock() return a.saveAliases(AppAliasesFile) } // SaveAliases saves aliases to a given file. func (a *Aliases) saveAliases(path string) error { if err := data.EnsureDirPath(path, data.DefaultDirMod); err != nil { return err } return data.SaveYAML(path, a) } ================================================ FILE: internal/config/alias_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "maps" "os" "path" "slices" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/view/cmd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAliasClear(t *testing.T) { a := testAliases() a.Clear() assert.Empty(t, slices.Collect(maps.Keys(a.Alias))) } func TestAliasKeys(t *testing.T) { a := testAliases() kk := maps.Keys(a.Alias) assert.Equal(t, []string{"a1", "a11", "a2", "a3"}, slices.Sorted(kk)) } func TestAliasShortNames(t *testing.T) { a := testAliases() ess := config.ShortNames{ gvr1: []string{"a1", "a11"}, gvr2: []string{"a2"}, gvr3: []string{"a3"}, } ss := a.ShortNames() assert.Len(t, ss, len(ess)) for k, v := range ss { v1, ok := ess[k] assert.True(t, ok, "missing: %q", k) slices.Sort(v) assert.Equal(t, v1, v) } } func TestAliasDefine(t *testing.T) { type aliasDef struct { gvr *client.GVR aliases []string } uu := map[string]struct { aliases []aliasDef registeredCommands map[string]*client.GVR }{ "simple": { aliases: []aliasDef{ { gvr: client.NewGVR("one"), aliases: []string{"blee", "duh"}, }, }, registeredCommands: map[string]*client.GVR{ "blee": client.NewGVR("one"), "duh": client.NewGVR("one"), }, }, "duplicates": { aliases: []aliasDef{ { gvr: client.NewGVR("one"), aliases: []string{"blee", "duh"}, }, { gvr: client.NewGVR("two"), aliases: []string{"blee", "duh", "fred", "zorg"}, }, }, registeredCommands: map[string]*client.GVR{ "blee": client.NewGVR("one"), "duh": client.NewGVR("one"), "fred": client.NewGVR("two"), "zorg": client.NewGVR("two"), }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { configAlias := config.NewAliases() for _, aliases := range u.aliases { for _, a := range aliases.aliases { configAlias.Define(aliases.gvr, a) } } for alias, cmd := range u.registeredCommands { v, ok := configAlias.Get(alias) assert.True(t, ok) assert.Equal(t, cmd, v, "Wrong command for alias "+alias) } }) } } func TestAliasesLoad(t *testing.T) { config.AppConfigDir = "testdata/aliases" a := config.NewAliases() require.NoError(t, a.Load(path.Join(config.AppConfigDir, "plain.yaml"))) assert.Len(t, a.Alias, 55) } func TestAliasesSave(t *testing.T) { require.NoError(t, data.EnsureFullPath("/tmp/test-aliases", data.DefaultDirMod)) defer require.NoError(t, os.RemoveAll("/tmp/test-aliases")) config.AppAliasesFile = "/tmp/test-aliases/aliases.yaml" a := testAliases() c := len(a.Alias) assert.Len(t, a.Alias, c) require.NoError(t, a.Save()) require.NoError(t, a.LoadFile(config.AppAliasesFile)) assert.Len(t, a.Alias, c) } func TestAliasResolve(t *testing.T) { uu := map[string]struct { exp string ok bool gvr *client.GVR cmd *cmd.Interpreter }{ "gvr": { exp: "v1/pods", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods"), }, "kind": { exp: "pod", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods"), }, "plural": { exp: "pods", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods"), }, "short-name": { exp: "po", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods"), }, "short-name-with-args": { exp: "po 'a in (b,c)' @zorb bozo", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods 'a in (b,c)' @zorb bozo"), }, "alias": { exp: "pipo", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods"), }, "toast-command": { exp: "zorg", }, "alias-no-args": { exp: "wkl", ok: true, gvr: client.WkGVR, cmd: cmd.NewInterpreter("workloads"), }, "alias-ns-arg": { exp: "pp", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods default"), }, "multi-alias-ns-inception": { exp: "ppo", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods 'a=b,b=c' default"), }, "full-alias": { exp: "ppc", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods @fred 'app=fred' default"), }, "plain-filter": { exp: "po /fred @bozo ns-1", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods /fred @bozo ns-1"), }, "alias-filter": { exp: "pipo /fred @bozo ns-1", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods /fred @bozo ns-1"), }, "complex-filter": { exp: "ppc /fred @bozo ns-1", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods @bozo /fred 'app=fred' ns-1"), }, "filtered": { exp: "pc", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods /cilium kube-system"), }, "labels-in": { exp: "ppp", ok: true, gvr: client.PodGVR, cmd: cmd.NewInterpreter("v1/pods 'app in (be,fe)'"), }, } a := config.NewAliases() 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("pipo a=b,b=c default"), "ppo") a.Define(client.NewGVR("pod default app=fred @fred"), "ppc") a.Define(client.NewGVR("pod /cilium kube-system"), "pc") a.Define(client.NewGVR("pod 'app in (be,fe)'"), "ppp") for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.exp) gvr, ok := a.Resolve(p) assert.Equal(t, u.ok, ok) if ok { assert.Equal(t, u.gvr, gvr) assert.Equal(t, u.cmd.GetLine(), p.GetLine()) } }) } } // Helpers... var ( gvr1 = client.NewGVR("gvr1") gvr2 = client.NewGVR("gvr2") gvr3 = client.NewGVR("gvr3") ) func testAliases() *config.Aliases { a := config.NewAliases() a.Alias["a1"] = gvr1 a.Alias["a11"] = gvr1 a.Alias["a2"] = gvr2 a.Alias["a3"] = gvr3 return a } ================================================ FILE: internal/config/benchmark.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "net/http" "os" "gopkg.in/yaml.v3" ) // K9sBench the name of the benchmarks config file. var K9sBench = "bench" type ( // Bench tracks K9s styling options. Bench struct { Benchmarks *Benchmarks `yaml:"benchmarks"` } // Benchmarks tracks K9s benchmarks configuration. Benchmarks struct { Defaults Benchmark `yaml:"defaults"` Services map[string]BenchConfig `yam':"services"` Containers map[string]BenchConfig `yam':"containers"` } // Auth basic auth creds. Auth struct { User string `yaml:"user"` Password string `yaml:"password"` } // Benchmark represents a generic benchmark. Benchmark struct { C int `yaml:"concurrency"` N int `yaml:"requests"` } // HTTP represents an http request. HTTP struct { Method string `yaml:"method"` Host string `yaml:"host"` Path string `yaml:"path"` HTTP2 bool `yaml:"http2"` Body string `yaml:"body"` Headers http.Header `yaml:"headers"` } // BenchConfig represents a service benchmark. BenchConfig struct { Name string C int `yaml:"concurrency"` N int `yaml:"requests"` Auth Auth `yaml:"auth"` HTTP HTTP `yaml:"http"` } ) const ( // DefaultC default concurrency. DefaultC = 1 // DefaultN default number of requests. DefaultN = 200 // DefaultMethod default http verb. DefaultMethod = "GET" ) // DefaultBenchSpec returns a default bench spec. func DefaultBenchSpec() BenchConfig { return BenchConfig{ C: DefaultC, N: DefaultN, HTTP: HTTP{ Method: DefaultMethod, Path: "/", }, } } func newBenchmark() Benchmark { return Benchmark{ C: DefaultC, N: DefaultN, } } // Empty checks if the benchmark is set. func (b Benchmark) Empty() bool { return b.C == 0 && b.N == 0 } func newBenchmarks() *Benchmarks { return &Benchmarks{ Defaults: newBenchmark(), } } // NewBench creates a new default config. func NewBench(path string) (*Bench, error) { s := &Bench{Benchmarks: newBenchmarks()} err := s.load(path) return s, err } // Reload update the configuration from disk. func (s *Bench) Reload(path string) error { return s.load(path) } // Load K9s benchmark configs from file. func (s *Bench) load(path string) error { f, err := os.ReadFile(path) if err != nil { return err } return yaml.Unmarshal(f, &s) } ================================================ FILE: internal/config/benchmark_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBenchEmpty(t *testing.T) { uu := map[string]struct { b Benchmark e bool }{ "empty": {Benchmark{}, true}, "notEmpty": {newBenchmark(), false}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.b.Empty()) }) } } func TestBenchLoad(t *testing.T) { uu := map[string]struct { file string c, n int svcCount int coCount int }{ "goodConfig": { "testdata/benchmarks/b_good.yaml", 2, 1000, 2, 0, }, "malformed": { "testdata/benchmarks/b_toast.yaml", 1, 200, 0, 0, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { b, err := NewBench(u.file) require.NoError(t, err) assert.Equal(t, u.c, b.Benchmarks.Defaults.C) assert.Equal(t, u.n, b.Benchmarks.Defaults.N) assert.Len(t, b.Benchmarks.Services, u.svcCount) assert.Len(t, b.Benchmarks.Containers, u.coCount) }) } } func TestBenchServiceLoad(t *testing.T) { uu := map[string]struct { key string c, n int method, host, path string http2 bool body string auth Auth headers http.Header }{ "s1": { "default/nginx", 2, 1000, "GET", "10.10.10.10", "/", true, `{"fred": "blee"}`, Auth{"fred", "blee"}, http.Header{"Accept": []string{"text/html"}, "Content-Type": []string{"application/json"}}, }, "s2": { "blee/fred", 10, 1500, "POST", "20.20.20.20", "/zorg", false, `{"fred": "blee"}`, Auth{"fred", "blee"}, http.Header{"Accept": []string{"text/html"}, "Content-Type": []string{"application/json"}}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { b, err := NewBench("testdata/benchmarks/b_good.yaml") require.NoError(t, err) assert.Len(t, b.Benchmarks.Services, 2) svc := b.Benchmarks.Services[u.key] assert.Equal(t, u.c, svc.C) assert.Equal(t, u.n, svc.N) assert.Equal(t, u.method, svc.HTTP.Method) assert.Equal(t, u.host, svc.HTTP.Host) assert.Equal(t, u.path, svc.HTTP.Path) assert.Equal(t, u.http2, svc.HTTP.HTTP2) assert.Equal(t, u.body, svc.HTTP.Body) assert.Equal(t, u.auth, svc.Auth) assert.Equal(t, u.headers, svc.HTTP.Headers) }) } } func TestBenchReLoad(t *testing.T) { b, err := NewBench("testdata/benchmarks/b_containers.yaml") require.NoError(t, err) assert.Equal(t, 2, b.Benchmarks.Defaults.C) require.NoError(t, b.Reload("testdata/benchmarks/b_containers_1.yaml")) assert.Equal(t, 20, b.Benchmarks.Defaults.C) } func TestBenchLoadToast(t *testing.T) { _, err := NewBench("testdata/toast.yaml") assert.Error(t, err) } func TestBenchContainerLoad(t *testing.T) { uu := map[string]struct { key string c, n int method, host, path string http2 bool body string auth Auth headers http.Header }{ "c1": { "c1", 2, 1000, "GET", "10.10.10.10", "/duh", true, `{"fred": "blee"}`, Auth{"fred", "blee"}, http.Header{"Accept": []string{"text/html"}, "Content-Type": []string{"application/json"}}, }, "c2": { "c2", 10, 1500, "POST", "20.20.20.20", "/fred", false, `{"fred": "blee"}`, Auth{"fred", "blee"}, http.Header{"Accept": []string{"text/html"}, "Content-Type": []string{"application/json"}}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { b, err := NewBench("testdata/benchmarks/b_containers.yaml") require.NoError(t, err) assert.Len(t, b.Benchmarks.Services, 2) co := b.Benchmarks.Containers[u.key] assert.Equal(t, u.c, co.C) assert.Equal(t, u.n, co.N) assert.Equal(t, u.method, co.HTTP.Method) assert.Equal(t, u.host, co.HTTP.Host) assert.Equal(t, u.path, co.HTTP.Path) assert.Equal(t, u.http2, co.HTTP.HTTP2) assert.Equal(t, u.body, co.HTTP.Body) assert.Equal(t, u.auth, co.Auth) assert.Equal(t, u.headers, co.HTTP.Headers) }) } } ================================================ FILE: internal/config/color.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "fmt" "github.com/derailed/tcell/v2" "github.com/lucasb-eyer/go-colorful" ) const ( // DefaultColor represents a default color. DefaultColor Color = "default" // TransparentColor represents the terminal bg color. TransparentColor Color = "-" ) // Colors tracks multiple colors. type Colors []Color // Colors converts series string colors to colors. func (c Colors) Colors() []tcell.Color { cc := make([]tcell.Color, 0, len(c)) for _, color := range c { cc = append(cc, color.Color()) } return cc } // Invert returns a new Colors with all colors inverted. func (c Colors) Invert() Colors { inverted := make(Colors, len(c)) for i, color := range c { inverted[i] = color.InvertColor() } return inverted } // Color represents a color. type Color string // NewColor returns a new color. func NewColor(c string) Color { return Color(c) } // String returns color as string. func (c Color) String() string { if c.isHex() { return string(c) } if c == DefaultColor { return "-" } col := c.Color().TrueColor().Hex() if col < 0 { return "-" } return fmt.Sprintf("#%06x", col) } func (c Color) isHex() bool { return len(c) == 7 && c[0] == '#' } // Color returns a view color. func (c Color) Color() tcell.Color { if c == DefaultColor { return tcell.ColorDefault } return tcell.GetColor(string(c)).TrueColor() } // maxChromaForLH finds the maximum chroma at a given lightness and hue // that stays within the sRGB gamut using binary search. func maxChromaForLH(l, h float64) float64 { lo, hi := 0.0, 0.4 for hi-lo > 0.001 { mid := (lo + hi) / 2 col := colorful.OkLch(l, mid, h) if col.IsValid() { lo = mid } else { hi = mid } } return lo } // chromaPreserveFactor controls how much original chroma to preserve during // inversion. 0.5 means we try to keep 50% of the original chroma, which // provides a good balance between color differentiation and L inversion. const chromaPreserveFactor = 0.5 // closestLForChroma finds the L value closest to targetL that can support // the given chroma at the given hue. It searches toward 0.5 first (where // gamut is typically larger), then away from 0.5 if needed. func closestLForChroma(targetL, c, h float64) float64 { if maxChromaForLH(targetL, h) >= c { return targetL } // Search toward 0.5 first (where gamut is larger) if targetL < 0.5 { for ll := targetL; ll <= 0.5; ll += 0.01 { if maxChromaForLH(ll, h) >= c { return ll } } // Continue searching above 0.5 if needed for ll := 0.51; ll <= 0.95; ll += 0.01 { if maxChromaForLH(ll, h) >= c { return ll } } } else { for ll := targetL; ll >= 0.5; ll -= 0.01 { if maxChromaForLH(ll, h) >= c { return ll } } // Continue searching below 0.5 if needed for ll := 0.49; ll >= 0.05; ll -= 0.01 { if maxChromaForLH(ll, h) >= c { return ll } } } return targetL } // InvertColor inverts the color's lightness in Oklch space while preserving // chroma (saturation). For chromatic colors, L is adjusted toward 0.5 only // as needed to preserve a fraction of the original chroma (set by // chromaPreserveFactor), since the sRGB gamut has less room for chroma at // extreme lightness values. // Special colors (default, transparent) are returned unchanged. func (c Color) InvertColor() Color { if c == DefaultColor || c == TransparentColor || c == "" { return c } tc := c.Color() if tc == tcell.ColorDefault { return c } hex := tc.TrueColor().Hex() if hex < 0 { return c } col, err := colorful.Hex(fmt.Sprintf("#%06x", hex)) if err != nil { return c } L, C, h := col.OkLch() // For achromatic colors, simply invert L if C < 0.01 { return NewColor(colorful.OkLch(1.0-L, 0, h).Clamped().Hex()) } // For chromatic colors, find L closest to inverted that preserves // at least chromaPreserveFactor of the original chroma targetL := 1.0 - L minC := C * chromaPreserveFactor actualL := closestLForChroma(targetL, minC, h) // Use as much of the original chroma as the gamut allows at actualL maxC := maxChromaForLH(actualL, h) actualC := C if maxC < C { actualC = maxC } inverted := colorful.OkLch(actualL, actualC, h).Clamped() return NewColor(inverted.Hex()) } ================================================ FILE: internal/config/color_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "math" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/tcell/v2" "github.com/lucasb-eyer/go-colorful" "github.com/stretchr/testify/assert" ) func TestColors(t *testing.T) { uu := map[string]struct { cc []string ee []tcell.Color }{ "empty": { ee: []tcell.Color{}, }, "default": { cc: []string{"default"}, ee: []tcell.Color{tcell.ColorDefault}, }, "multi": { cc: []string{ "default", "transparent", "blue", "green", }, ee: []tcell.Color{ tcell.ColorDefault, tcell.ColorDefault, tcell.ColorBlue.TrueColor(), tcell.ColorGreen.TrueColor(), }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cc := make(config.Colors, 0, len(u.cc)) for _, c := range u.cc { cc = append(cc, config.NewColor(c)) } assert.Equal(t, u.ee, cc.Colors()) }) } } func TestColorString(t *testing.T) { uu := map[string]struct { c string e string }{ "empty": { e: "-", }, "default": { c: "default", e: "-", }, "transparent": { c: "-", e: "-", }, "blue": { c: "blue", e: "#0000ff", }, "lightgray": { c: "lightgray", e: "#d3d3d3", }, "hex": { c: "#00ff00", e: "#00ff00", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := config.NewColor(u.c) assert.Equal(t, u.e, c.String()) }) } } func TestColorToColor(t *testing.T) { uu := map[string]struct { c string e tcell.Color }{ "default": { c: "default", e: tcell.ColorDefault, }, "transparent": { c: "-", e: tcell.ColorDefault, }, "aqua": { c: "aqua", e: tcell.ColorAqua.TrueColor(), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := config.NewColor(u.c) assert.Equal(t, u.e, c.Color()) }) } } // getOkch returns c, h for a hex color string. func getOkch(hex string) (c, h float64) { col, err := colorful.Hex(hex) if err != nil { return 0, 0 } _, c, h = col.OkLch() return c, h } // huesEqual checks if two hues are equal within tolerance, handling wraparound. func huesEqual(h1, h2, tolerance float64) bool { diff := math.Abs(h1 - h2) if diff > 180 { diff = 360 - diff } return diff < tolerance } func TestInvertColor(t *testing.T) { uu := map[string]struct { c string expect string }{ "default": { c: "default", expect: "default", }, "transparent": { c: "-", expect: "-", }, "empty": { c: "", expect: "", }, "black_to_white": { c: "#000000", expect: "#ffffff", }, "white_to_black": { c: "#ffffff", expect: "#000000", }, "red_to_dark": { // L=0.628, C=0.258, h=29.2 c: "#ff0000", expect: "#7e0000", }, "blue_to_light": { // L=0.452, C=0.313, h=264.1 -> L adjusted to 0.55 to preserve chroma c: "#0000ff", expect: "#1f5bff", }, "green_to_dark": { // L=0.866, C=0.295, h=142.5 -> L adjusted to 0.44 to preserve chroma c: "#00ff00", expect: "#006600", }, "yellow_to_dark": { // L=0.968, C=0.211, h=109.8 -> L adjusted to 0.49 to preserve chroma c: "#ffff00", expect: "#656501", }, "cyan_to_dark": { // L=0.905, C=0.155, h=194.8 -> L adjusted to 0.46 to preserve chroma c: "#00ffff", expect: "#016464", }, "dark_gray_to_light": { c: "#333333", expect: "#989898", }, "light_gray_to_dark": { c: "#cccccc", expect: "#0c0c0c", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := config.NewColor(u.c) inverted := c.InvertColor() assert.Equal(t, u.expect, string(inverted)) }) } } func TestInvertColorPreservesHue(t *testing.T) { // Verify that hue is preserved during inversion for chromatic colors. // Note: Hue preservation depends on the inverted color having sufficient chroma // and not being clamped by go-colorful's Clamped() method. Colors near the // gamut boundary may have hue shifts after clamping. uu := map[string]struct { c string h float64 // expected hue }{ "red": { // L=0.628, C=0.258, h=29.2 -> inverted to L=0.372 with good chroma c: "#ff0000", h: 29.2, }, "blue": { // L=0.452, C=0.313, h=264.1 -> inverted to L=0.548 with good chroma c: "#0000ff", h: 264.1, }, "purple": { // L=0.420, C=0.161, h=328.4 -> mid-lightness, stable hue c: "#800080", h: 328.4, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { original := config.NewColor(u.c) inverted := original.InvertColor() _, hOrig := getOkch(u.c) cInv, hInv := getOkch(string(inverted)) // Only check hue if inverted color has meaningful chroma (C > 0.05) // Below this threshold, sRGB quantization causes hue instability if cInv > 0.05 { assert.True(t, huesEqual(hOrig, hInv, 1.0), "hue should be preserved: original h=%.1f, inverted h=%.1f", hOrig, hInv) } }) } } func TestInvertGrayRoundTrip(t *testing.T) { // Achromatic colors (grays) round-trip perfectly because they have no chroma // to lose during gamut-constrained scaling. colors := []string{ "#000000", "#ffffff", "#808080", "#333333", "#cccccc", "#555555", "#636363", // L=0.5 in Oklch } for _, c := range colors { t.Run(c, func(t *testing.T) { original := config.NewColor(c) inverted := original.InvertColor() reinverted := inverted.InvertColor() assert.Equal(t, original.String(), string(reinverted), "double inversion should return to original for achromatic colors") }) } } func TestInvertColorSelfInverting(t *testing.T) { // Colors with L=0.5 in Oklch invert to themselves. // For achromatic grays, L=0.5 corresponds to approximately #636363 in sRGB. selfInverting := []string{ "#636363", } for _, c := range selfInverting { t.Run(c, func(t *testing.T) { original := config.NewColor(c) inverted := original.InvertColor() assert.Equal(t, original.String(), string(inverted), "color with L=0.5 should invert to itself") }) } } func TestInvertColorOutOfGamut(t *testing.T) { // These highly saturated colors would produce out-of-gamut results if we // simply inverted L without adjustment. The chroma-preserving approach // finds an L closer to 0.5 where sufficient chroma is available. // // For colors with very high L (yellow, cyan), the ideal inverted L would // be very low where max chroma is tiny. Instead, L is adjusted toward 0.5 // to preserve chromaPreserveFactor (0.5) of the original chroma. uu := map[string]struct { c string expect string }{ "saturated_yellow": { // L=0.968, C=0.211 -> L adjusted to 0.49 to preserve 50% chroma c: "#ffff00", expect: "#656501", }, "saturated_cyan": { // L=0.905, C=0.155 -> L adjusted to 0.46 to preserve 50% chroma c: "#00ffff", expect: "#016464", }, "saturated_blue": { // L=0.452, C=0.313 -> L adjusted to 0.55 to preserve chroma c: "#0000ff", expect: "#1f5bff", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { original := config.NewColor(u.c) inverted := original.InvertColor() // Verify the inverted color matches expected invertedStr := string(inverted) assert.Equal(t, u.expect, invertedStr) // Verify the inverted color is valid hex assert.Regexp(t, `^#[0-9a-f]{6}$`, invertedStr, "inverted color should be valid hex") // Verify it differs from original (these are not L=0.5 colors) assert.NotEqual(t, original.String(), invertedStr, "saturated color should not invert to itself") // Only check hue preservation for colors with meaningful inverted chroma (C > 0.05) // Below this threshold, sRGB quantization causes hue instability cInv, hInv := getOkch(invertedStr) if cInv > 0.05 { _, hOrig := getOkch(u.c) assert.True(t, huesEqual(hOrig, hInv, 1.0), "hue should be preserved: original h=%.1f, inverted h=%.1f", hOrig, hInv) } }) } } ================================================ FILE: internal/config/config.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "errors" "fmt" "io/fs" "log/slog" "os" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/view/cmd" "gopkg.in/yaml.v3" "k8s.io/cli-runtime/pkg/genericclioptions" ) // Config tracks K9s configuration options. type Config struct { K9s *K9s `yaml:"k9s" json:"k9s"` conn client.Connection settings data.KubeSettings } // NewConfig creates a new default config. func NewConfig(ks data.KubeSettings) *Config { return &Config{ settings: ks, K9s: NewK9s(nil, ks), } } // IsReadOnly returns true if K9s is running in read-only mode. func (c *Config) IsReadOnly() bool { return c.K9s.IsReadOnly() } // ActiveClusterName returns the corresponding cluster name. func (c *Config) ActiveClusterName(contextName string) (string, error) { ct, err := c.settings.GetContext(contextName) if err != nil { return "", err } return ct.Cluster, nil } // ContextHotkeysPath returns a context specific hotkeys file spec. func (c *Config) ContextHotkeysPath() string { ct, err := c.K9s.ActiveContext() if err != nil { return "" } return AppContextHotkeysFile(ct.ClusterName, c.K9s.activeContextName) } // ContextAliasesPath returns a context specific aliases file spec. func (c *Config) ContextAliasesPath() string { ct, err := c.K9s.ActiveContext() if err != nil { return "" } return AppContextAliasesFile(ct.GetClusterName(), c.K9s.activeContextName) } // ContextPluginsPath returns a context specific plugins file spec. func (c *Config) ContextPluginsPath() (string, error) { ct, err := c.K9s.ActiveContext() if err != nil { return "", err } return AppContextPluginsFile(ct.GetClusterName(), c.K9s.activeContextName), nil } func setK8sTimeout(flags *genericclioptions.ConfigFlags, d time.Duration) { v := d.String() flags.Timeout = &v } // Refine the configuration based on cli args. func (c *Config) Refine(flags *genericclioptions.ConfigFlags, k9sFlags *Flags, cfg *client.Config) error { if flags == nil { return nil } if !isStringSet(flags.Timeout) { if d, err := time.ParseDuration(c.K9s.APIServerTimeout); err == nil { setK8sTimeout(flags, d) } else { setK8sTimeout(flags, client.DefaultCallTimeoutDuration) } } if isStringSet(flags.Context) { if _, err := c.K9s.ActivateContext(*flags.Context); err != nil { return fmt.Errorf("k8sflags. unable to activate context %q: %w", *flags.Context, err) } } else { n, err := cfg.CurrentContextName() if err != nil { return fmt.Errorf("unable to retrieve kubeconfig current context %q: %w", n, err) } _, err = c.K9s.ActivateContext(n) if err != nil { return fmt.Errorf("unable to activate context %q: %w", n, err) } } slog.Debug("Using active context", slogs.Context, c.K9s.ActiveContextName()) var ns string switch { case k9sFlags != nil && IsBoolSet(k9sFlags.AllNamespaces): ns = client.NamespaceAll c.ResetActiveView() case isStringSet(flags.Namespace): ns = *flags.Namespace c.ResetActiveView() default: nss, err := c.K9s.ActiveContextNamespace() if err != nil { return err } ns = nss } if ns == "" { ns = client.DefaultNamespace } if err := c.SetActiveNamespace(ns); err != nil { return err } return data.EnsureDirPath(c.K9s.AppScreenDumpDir(), data.DefaultDirMod) } // Reset resets the context to the new current context/cluster. func (c *Config) Reset() { c.K9s.Reset() } func (c *Config) ActivateContext(n string) (*data.Context, error) { ct, err := c.K9s.ActivateContext(n) if err != nil { return nil, fmt.Errorf("set current context failed. %w", err) } return ct, nil } // CurrentContext fetch the configuration active context. func (c *Config) CurrentContext() (*data.Context, error) { return c.K9s.ActiveContext() } // ActiveNamespace returns the active namespace in the current context. // If none found return the empty ns. func (c *Config) ActiveNamespace() string { ns, err := c.K9s.ActiveContextNamespace() if err != nil { slog.Error("Unable to assert active namespace. Using default", slogs.Error, err) ns = client.DefaultNamespace } return ns } // FavNamespaces returns fav namespaces in the current context. func (c *Config) FavNamespaces() []string { ct, err := c.K9s.ActiveContext() if err != nil { return nil } ct.Validate(c.conn, c.K9s.getActiveContextName(), ct.ClusterName) return ct.Namespace.Favorites } // SetActiveNamespace set the active namespace in the current context. func (c *Config) SetActiveNamespace(ns string) error { if ns == client.NotNamespaced { slog.Debug("No namespace given. skipping!", slogs.Namespace, ns) return nil } ct, err := c.K9s.ActiveContext() if err != nil { return err } return ct.Namespace.SetActive(ns, c.settings) } // ActiveView returns the active view in the current context. func (c *Config) ActiveView() string { ct, err := c.K9s.ActiveContext() if err != nil { return data.DefaultView } v := ct.View.Active if c.K9s.manualCommand != nil && *c.K9s.manualCommand != "" { v = *c.K9s.manualCommand // We reset the manualCommand property because // the command-line switch should only be considered once, // on startup. *c.K9s.manualCommand = "" } return v } func (c *Config) ResetActiveView() { if isStringSet(c.K9s.manualCommand) { return } v := c.ActiveView() if v == "" { return } p := cmd.NewInterpreter(v) if p.HasNS() { c.SetActiveView(p.Cmd()) } } // SetActiveView sets current context active view. func (c *Config) SetActiveView(view string) { if ct, err := c.K9s.ActiveContext(); err == nil { ct.View.Active = view } } // GetConnection return an api server connection. func (c *Config) GetConnection() client.Connection { return c.conn } // SetConnection set an api server connection. func (c *Config) SetConnection(conn client.Connection) { c.conn = conn if conn != nil { c.K9s.resetConnection(conn) } } func (c *Config) ActiveContextName() string { return c.K9s.activeContextName } func (c *Config) Merge(c1 *Config) { c.K9s.Merge(c1.K9s) } // Load loads K9s configuration from file. func (c *Config) Load(path string, force bool) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { if err := c.Save(force); err != nil { return err } } bb, err := os.ReadFile(path) if err != nil { return err } var errs error if err := data.JSONValidator.Validate(json.K9sSchema, bb); err != nil { errs = errors.Join(errs, fmt.Errorf("k9s config file %q load failed:\n%w", path, err)) } var cfg Config if err := yaml.Unmarshal(bb, &cfg); err != nil { errs = errors.Join(errs, fmt.Errorf("main config.yaml load failed: %w", err)) } c.Merge(&cfg) return errs } // Save configuration to disk. func (c *Config) Save(force bool) error { contextName := c.K9s.ActiveContextName() clusterName, err := c.ActiveClusterName(contextName) if err != nil { return fmt.Errorf("unable to locate associated cluster for context %q: %w", contextName, err) } c.Validate(contextName, clusterName) if err := c.K9s.Save(contextName, clusterName, force); err != nil { return err } if _, err := os.Stat(AppConfigFile); errors.Is(err, fs.ErrNotExist) { return c.SaveFile(AppConfigFile) } return nil } // SaveFile K9s configuration to disk. func (c *Config) SaveFile(path string) error { if err := data.EnsureDirPath(path, data.DefaultDirMod); err != nil { return err } if err := data.SaveYAML(path, c); err != nil { slog.Error("Unable to save K9s config file", slogs.Error, err) return err } slog.Info("[CONFIG] Saving K9s config to disk", slogs.Path, path) return nil } // Validate the configuration. func (c *Config) Validate(contextName, clusterName string) { if c.K9s == nil { c.K9s = NewK9s(c.conn, c.settings) } c.K9s.Validate(c.conn, contextName, clusterName) } // Dump for debug... func (c *Config) Dump(msg string) { ct, err := c.K9s.ActiveContext() if err == nil { bb, _ := yaml.Marshal(ct) fmt.Printf("Dump: %q\n%s\n", msg, string(bb)) } else { fmt.Println("BOOM!", err) } } ================================================ FILE: internal/config/config_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "errors" "fmt" "log/slog" "os" "path/filepath" "testing" "github.com/adrg/xdg" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" m "github.com/petergtz/pegomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestConfigSave(t *testing.T) { config.AppConfigFile = "/tmp/k9s-test/k9s.yaml" sd := "/tmp/k9s-test/screen-dumps" cl, ct := "cl-1", "ct-1-1" _ = os.RemoveAll(("/tmp/k9s-test")) uu := map[string]struct { ct string flags *genericclioptions.ConfigFlags k9sFlags *config.Flags }{ "happy": { ct: "ct-1-1", flags: &genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, }, k9sFlags: &config.Flags{ ScreenDumpDir: &sd, }, }, } for k, u := range uu { xdg.Reload() t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, err := c.K9s.ActivateContext(u.ct) require.NoError(t, err) if u.flags != nil { c.K9s.Override(u.k9sFlags) require.NoError(t, c.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags))) } require.NoError(t, c.Save(true)) bb, err := os.ReadFile(config.AppConfigFile) require.NoError(t, err) ee, err := os.ReadFile("testdata/configs/default.yaml") require.NoError(t, err) assert.Equal(t, string(ee), string(bb)) }) } } func TestSetActiveView(t *testing.T) { var ( cfgFile = "testdata/kubes/test.yaml" view = "dp" ) uu := map[string]struct { ct string flags *genericclioptions.ConfigFlags k9sFlags *config.Flags view string e string }{ "empty": { view: data.DefaultView, e: data.DefaultView, }, "not-exists": { ct: "fred", view: data.DefaultView, e: data.DefaultView, }, "happy": { ct: "ct-1-1", view: "xray", e: "xray", }, "cli-override": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, }, k9sFlags: &config.Flags{ Command: &view, }, ct: "ct-1-1", view: "xray", e: "dp", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) if u.flags != nil { require.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) c.K9s.Override(u.k9sFlags) } c.SetActiveView(u.view) assert.Equal(t, u.e, c.ActiveView()) }) } } func TestActiveContextName(t *testing.T) { var ( cfgFile = "testdata/kubes/test.yaml" ct2 = "ct-1-2" ) uu := map[string]struct { flags *genericclioptions.ConfigFlags k9sFlags *config.Flags ct string e string }{ "empty": {}, "happy": { ct: "ct-1-1", e: "ct-1-1", }, "cli-override": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Context: &ct2, }, k9sFlags: &config.Flags{}, ct: "ct-1-1", e: "ct-1-2", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) if u.flags != nil { require.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) c.K9s.Override(u.k9sFlags) } assert.Equal(t, u.e, c.ActiveContextName()) }) } } func TestActiveView(t *testing.T) { var ( cfgFile = "testdata/kubes/test.yaml" view = "dp" ) uu := map[string]struct { ct string flags *genericclioptions.ConfigFlags k9sFlags *config.Flags e string }{ "empty": { e: data.DefaultView, }, "not-exists": { ct: "fred", e: data.DefaultView, }, "happy": { ct: "ct-1-1", e: data.DefaultView, }, "happy1": { ct: "ct-1-2", e: data.DefaultView, }, "cli-override": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, }, k9sFlags: &config.Flags{ Command: &view, }, e: "dp", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) if u.flags != nil { require.NoError(t, c.Refine(u.flags, nil, client.NewConfig(u.flags))) c.K9s.Override(u.k9sFlags) } assert.Equal(t, u.e, c.ActiveView()) }) } } func TestFavNamespaces(t *testing.T) { uu := map[string]struct { ct string e []string }{ "empty": {}, "not-exists": { ct: "fred", }, "happy": { ct: "ct-1-1", e: []string{client.DefaultNamespace}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) assert.Equal(t, u.e, c.FavNamespaces()) }) } } func TestContextAliasesPath(t *testing.T) { uu := map[string]struct { ct string e string }{ "empty": {}, "not-exists": { ct: "fred", }, "happy": { ct: "ct-1-1", e: "/tmp/test/cl-1/ct-1-1/aliases.yaml", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) assert.Equal(t, u.e, c.ContextAliasesPath()) }) } } func TestContextPluginsPath(t *testing.T) { uu := map[string]struct { ct, e string err error }{ "empty": { err: errors.New(`no context found for: ""`), }, "happy": { ct: "ct-1-1", e: "/tmp/test/cl-1/ct-1-1/plugins.yaml", }, "not-exists": { ct: "fred", err: errors.New(`no context found for: "fred"`), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := mock.NewMockConfig(t) _, _ = c.K9s.ActivateContext(u.ct) s, err := c.ContextPluginsPath() if err != nil { assert.Equal(t, u.err, err) } assert.Equal(t, u.e, s) }) } } func TestConfigLoader(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/configs/k9s.yaml", }, "toast": { f: "testdata/configs/k9s_toast.yaml", err: `k9s config file "testdata/configs/k9s_toast.yaml" load failed: Additional property disablePodCounts is not allowed Additional property shellPods is not allowed Invalid type. Expected: boolean, given: string`, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := config.NewConfig(nil) if err := cfg.Load(u.f, true); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestConfigActivateContext(t *testing.T) { uu := map[string]struct { cl, ct string err string }{ "happy": { ct: "ct-1-2", cl: "cl-1", }, "toast": { ct: "fred", cl: "cl-1", err: `set current context failed. no context found for: "fred"`, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := mock.NewMockConfig(t) ct, err := cfg.ActivateContext(u.ct) if err != nil { assert.Equal(t, u.err, err.Error()) return } require.NoError(t, err) assert.Equal(t, u.cl, ct.ClusterName) }) } } func TestConfigCurrentContext(t *testing.T) { var ( cfgFile = "testdata/kubes/test.yaml" ct2 = "ct-1-2" ) uu := map[string]struct { flags *genericclioptions.ConfigFlags err error context string cluster string namespace string }{ "override-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Context: &ct2, }, cluster: "cl-1", context: "ct-1-2", namespace: "ns-2", }, "use-current-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, }, cluster: "cl-1", context: "ct-1-1", namespace: client.DefaultNamespace, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := mock.NewMockConfig(t) err := cfg.Refine(u.flags, nil, client.NewConfig(u.flags)) require.NoError(t, err) ct, err := cfg.CurrentContext() require.NoError(t, err) assert.Equal(t, u.cluster, ct.ClusterName) assert.Equal(t, u.namespace, ct.Namespace.Active) }) } } func TestConfigRefine(t *testing.T) { var ( cfgFile = "testdata/kubes/test.yaml" cl1 = "cl-1" ct2 = "ct-1-2" ns1, ns2, nsx = "ns-1", "ns-2", "ns-x" trueVal = true ) uu := map[string]struct { flags *genericclioptions.ConfigFlags k9sFlags *config.Flags err string context string cluster string namespace string }{ "no-override": { namespace: "default", }, "override-cluster": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, ClusterName: &cl1, }, cluster: "cl-1", context: "ct-1-1", namespace: client.DefaultNamespace, }, "override-cluster-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, ClusterName: &cl1, Context: &ct2, }, cluster: "cl-1", context: "ct-1-2", namespace: "ns-2", }, "override-bad-cluster": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, ClusterName: &ns1, }, cluster: "cl-1", context: "ct-1-1", namespace: client.DefaultNamespace, }, "override-ns": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Namespace: &ns2, }, cluster: "cl-1", context: "ct-1-1", namespace: "ns-2", }, "all-ns": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Namespace: &ns2, }, k9sFlags: &config.Flags{ AllNamespaces: &trueVal, }, cluster: "cl-1", context: "ct-1-1", namespace: client.NamespaceAll, }, "override-bad-ns": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Namespace: &nsx, }, cluster: "cl-1", context: "ct-1-1", namespace: "ns-x", }, "override-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Context: &ct2, }, cluster: "cl-1", context: "ct-1-2", namespace: "ns-2", }, "override-bad-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, Context: &ns1, }, err: `k8sflags. unable to activate context "ns-1": no context found for: "ns-1"`, }, "use-current-context": { flags: &genericclioptions.ConfigFlags{ KubeConfig: &cfgFile, }, cluster: "cl-1", context: "ct-1-1", namespace: client.DefaultNamespace, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := mock.NewMockConfig(t) err := cfg.Refine(u.flags, u.k9sFlags, client.NewConfig(u.flags)) if err != nil { assert.Equal(t, u.err, err.Error()) } else { require.NoError(t, err) assert.Equal(t, u.context, cfg.K9s.ActiveContextName()) assert.Equal(t, u.namespace, cfg.ActiveNamespace()) } }) } } func TestConfigValidate(t *testing.T) { cfg := mock.NewMockConfig(t) cfg.SetConnection(mock.NewMockConnection()) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) cfg.Validate("ct-1-1", "cl-1") } func TestConfigLoad(t *testing.T) { cfg := mock.NewMockConfig(t) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) assert.InDelta(t, 2.0, cfg.K9s.RefreshRate, 0.001) assert.Equal(t, int64(200), cfg.K9s.Logger.TailCount) assert.Equal(t, 2000, cfg.K9s.Logger.BufferSize) } func TestConfigLoadCrap(t *testing.T) { cfg := mock.NewMockConfig(t) assert.Error(t, cfg.Load("testdata/configs/k9s_not_there.yaml", true)) } func TestConfigSaveFile(t *testing.T) { cfg := mock.NewMockConfig(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 cfg.K9s.Logger.BufferSize = 800 cfg.K9s.UI.UseFullGVRTitle = true cfg.Validate("ct-1-1", "cl-1") path := filepath.Join(os.TempDir(), "k9s.yaml") require.NoError(t, cfg.SaveFile(path)) raw, err := os.ReadFile(path) require.NoError(t, err) ee, err := os.ReadFile("testdata/configs/expected.yaml") require.NoError(t, err) assert.Equal(t, string(ee), string(raw)) } func TestConfigReset(t *testing.T) { cfg := mock.NewMockConfig(t) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) cfg.Reset() cfg.Validate("ct-1-1", "cl-1") path := filepath.Join(os.TempDir(), "k9s.yaml") require.NoError(t, cfg.SaveFile(path)) bb, err := os.ReadFile(path) require.NoError(t, err) ee, err := os.ReadFile("testdata/configs/k9s.yaml") require.NoError(t, err) assert.Equal(t, string(ee), string(bb)) } // Helpers... func TestSetup(t *testing.T) { m.RegisterMockTestingT(t) m.RegisterMockFailHandler(func(m string, i ...int) { fmt.Println("Boom!", m, i) }) } ================================================ FILE: internal/config/data/config.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "fmt" "io" "sync" "github.com/derailed/k9s/internal/client" "gopkg.in/yaml.v3" "k8s.io/client-go/tools/clientcmd/api" ) // Config tracks a context configuration. type Config struct { Context *Context `yaml:"k9s"` mx sync.RWMutex } // NewConfig returns a new config. func NewConfig(ct *api.Context) *Config { return &Config{ Context: NewContextFromConfig(ct), } } // Merge merges configs and updates receiver. func (c *Config) Merge(c1 *Config) { if c1 == nil { return } if c.Context != nil && c1.Context != nil { c.Context.merge(c1.Context) } } // Validate ensures config is in norms. func (c *Config) Validate(conn client.Connection, contextName, clusterName string) { c.mx.Lock() defer c.mx.Unlock() if c.Context == nil { c.Context = NewContext() } c.Context.Validate(conn, contextName, clusterName) } // Dump used for debugging. func (c *Config) Dump(w io.Writer) { bb, _ := yaml.Marshal(&c) _, _ = fmt.Fprintf(w, "%s\n", string(bb)) } ================================================ FILE: internal/config/data/context.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "os" "sync" "github.com/derailed/k9s/internal/client" "k8s.io/client-go/tools/clientcmd/api" ) // Context tracks K9s context configuration. type Context struct { ClusterName string `yaml:"cluster,omitempty"` ReadOnly *bool `yaml:"readOnly,omitempty"` Skin string `yaml:"skin,omitempty"` Namespace *Namespace `yaml:"namespace"` View *View `yaml:"view"` FeatureGates FeatureGates `yaml:"featureGates"` Proxy *Proxy `yaml:"proxy"` mx sync.RWMutex } // NewContext creates a new cluster configuration. func NewContext() *Context { return &Context{ Namespace: NewNamespace(), View: NewView(), FeatureGates: NewFeatureGates(), } } // NewContextFromConfig returns a config based on a kubecontext. func NewContextFromConfig(cfg *api.Context) *Context { ct := NewContext() ct.Namespace, ct.ClusterName = NewActiveNamespace(cfg.Namespace), cfg.Cluster return ct } // NewContextFromKubeConfig returns a new instance based on kubesettings or an error. func NewContextFromKubeConfig(ks KubeSettings) (*Context, error) { ct, err := ks.CurrentContext() if err != nil { return nil, err } return NewContextFromConfig(ct), nil } func (c *Context) merge(old *Context) { if old == nil || old.Namespace == nil { return } if c.Namespace == nil { c.Namespace = NewNamespace() } c.Namespace.merge(old.Namespace) } func (c *Context) GetClusterName() string { c.mx.RLock() defer c.mx.RUnlock() return c.ClusterName } // Validate ensures a context config is tip top. func (c *Context) Validate(conn client.Connection, _, clusterName string) { c.mx.Lock() defer c.mx.Unlock() c.ClusterName = clusterName if b := os.Getenv(envFGNodeShell); b != "" { c.FeatureGates.NodeShell = defaultFGNodeShell() } if c.Namespace == nil { c.Namespace = NewNamespace() } c.Namespace.Validate(conn) if c.View == nil { c.View = NewView() } c.View.Validate() } ================================================ FILE: internal/config/data/context_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "testing" "github.com/stretchr/testify/assert" ) func Test_contextMerge(t *testing.T) { uu := map[string]struct { c1, c2, e *Context }{ "empty": {}, "nil": { c1: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3"}, }, }, e: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3"}, }, }, }, "deltas": { c1: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3"}, }, }, c2: &Context{ Namespace: &Namespace{ Active: "ns10", Favorites: []string{"ns10", "ns11", "ns12"}, }, }, e: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3", "ns10", "ns11", "ns12"}, }, }, }, "deltas-locked": { c1: &Context{ Namespace: &Namespace{ Active: "ns1", LockFavorites: true, Favorites: []string{"ns1", "ns2", "ns3"}, }, }, c2: &Context{ Namespace: &Namespace{ Active: "ns10", Favorites: []string{"ns10", "ns11", "ns12"}, }, }, e: &Context{ Namespace: &Namespace{ Active: "ns1", LockFavorites: true, Favorites: []string{"ns1", "ns2", "ns3"}, }, }, }, "no-namespace": { c1: NewContext(), c2: &Context{}, e: NewContext(), }, "too-many-favs": { c1: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3", "ns4", "ns5", "ns6", "ns7", "ns8", "ns9"}, }, }, c2: &Context{ Namespace: &Namespace{ Active: "ns10", Favorites: []string{"ns10", "ns11", "ns12"}, }, }, e: &Context{ Namespace: &Namespace{ Active: "ns1", Favorites: []string{"ns1", "ns2", "ns3", "ns4", "ns5", "ns6", "ns7", "ns8", "ns9"}, }, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { u.c1.merge(u.c2) assert.Equal(t, u.e, u.c1) }) } } ================================================ FILE: internal/config/data/context_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data_test import ( "testing" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" ) func TestClusterValidate(t *testing.T) { c := data.NewContext() c.Validate(mock.NewMockConnection(), "ct-1", "cl-1") assert.Equal(t, data.DefaultView, c.View.Active) assert.Equal(t, "default", c.Namespace.Active) assert.Len(t, c.Namespace.Favorites, 1) assert.Equal(t, []string{"default"}, c.Namespace.Favorites) } func TestClusterValidateEmpty(t *testing.T) { c := data.NewContext() c.Validate(mock.NewMockConnection(), "ct-1", "cl-1") assert.Equal(t, data.DefaultView, c.View.Active) assert.Equal(t, "default", c.Namespace.Active) assert.Len(t, c.Namespace.Favorites, 1) assert.Equal(t, []string{"default"}, c.Namespace.Favorites) } ================================================ FILE: internal/config/data/dir.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "sync" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "gopkg.in/yaml.v3" "k8s.io/client-go/tools/clientcmd/api" ) // Dir tracks context configurations. type Dir struct { root string mx sync.Mutex } // NewDir returns a new instance. func NewDir(root string) *Dir { return &Dir{ root: root, } } // Load loads context configuration. func (d *Dir) Load(contextName string, ct *api.Context) (*Config, error) { if ct == nil { return nil, errors.New("api.Context must not be nil") } path := filepath.Join(d.root, SanitizeContextSubpath(ct.Cluster, contextName), MainConfigFile) slog.Debug("[CONFIG] Loading context config from disk", slogs.Path, path, slogs.Cluster, ct.Cluster, slogs.Context, contextName) f, err := os.Stat(path) if errors.Is(err, fs.ErrPermission) { return nil, err } if errors.Is(err, fs.ErrNotExist) || (f != nil && f.Size() == 0) { slog.Debug("Context config not found! Generating..", slogs.Path, path) return d.genConfig(path, ct) } if err != nil { return nil, err } return d.loadConfig(path) } func (d *Dir) genConfig(path string, ct *api.Context) (*Config, error) { cfg := NewConfig(ct) if err := d.Save(path, cfg); err != nil { return nil, err } return cfg, nil } func (d *Dir) Save(path string, c *Config) error { if cfg, err := d.loadConfig(path); err == nil { c.Merge(cfg) } d.mx.Lock() defer d.mx.Unlock() if err := EnsureDirPath(path, DefaultDirMod); err != nil { return err } return SaveYAML(path, c) } func (d *Dir) loadConfig(path string) (*Config, error) { d.mx.Lock() defer d.mx.Unlock() bb, err := os.ReadFile(path) if err != nil { return nil, err } if err := JSONValidator.Validate(json.ContextSchema, bb); err != nil { slog.Warn("Validation failed. Please update your config and restart!", slogs.Path, path, slogs.Error, err, ) } var cfg Config if err := yaml.Unmarshal(bb, &cfg); err != nil { return nil, fmt.Errorf("context-config yaml load failed: %w\n%s", err, string(bb)) } return &cfg, nil } ================================================ FILE: internal/config/data/dir_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data_test import ( "log/slog" "os" "strings" "testing" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "k8s.io/cli-runtime/pkg/genericclioptions" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestDirLoad(t *testing.T) { uu := map[string]struct { dir string flags *genericclioptions.ConfigFlags err error cfg *data.Config }{ "happy-cl-1-ct-1": { dir: "testdata/data/k9s", flags: makeFlags("cl-1", "ct-1-1"), cfg: mustLoadConfig("testdata/configs/ct-1-1.yaml"), }, "happy-cl-1-ct2": { dir: "testdata/data/k9s", flags: makeFlags("cl-1", "ct-1-2"), cfg: mustLoadConfig("testdata/configs/ct-1-2.yaml"), }, "happy-cl-2": { dir: "testdata/data/k9s", flags: makeFlags("cl-2", "ct-2-1"), cfg: mustLoadConfig("testdata/configs/ct-2-1.yaml"), }, "toast": { dir: "/tmp/data/k9s", flags: makeFlags("cl-test", "ct-test-1"), cfg: mustLoadConfig("testdata/configs/def_ct.yaml"), }, "non-sanitized-path": { dir: "/tmp/data/k9s", flags: makeFlags("arn:aws:eks:eu-central-1:xxx:cluster/fred-blee", "fred-blee"), cfg: mustLoadConfig("testdata/configs/aws_ct.yaml"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.NotNil(t, u.cfg, "test config must not be nil") if u.cfg == nil { return } ks := mock.NewMockKubeSettings(u.flags) if strings.Index(u.dir, "/tmp") == 0 { require.NoError(t, mock.EnsureDir(u.dir)) } d := data.NewDir(u.dir) ct, err := ks.CurrentContext() require.NoError(t, err) if err != nil { return } cfg, err := d.Load(*u.flags.Context, ct) assert.Equal(t, u.err, err) if u.err == nil { assert.Equal(t, u.cfg, cfg) } }) } } // Helpers... func makeFlags(cl, ct string) *genericclioptions.ConfigFlags { return &genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, } } func mustLoadConfig(cfg string) *data.Config { bb, err := os.ReadFile(cfg) if err != nil { return nil } var ct data.Config if err = yaml.Unmarshal(bb, &ct); err != nil { return nil } return &ct } ================================================ FILE: internal/config/data/feature_gate.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data // FeatureGates represents K9s opt-in features. type FeatureGates struct { NodeShell bool `yaml:"nodeShell"` } // NewFeatureGates returns a new feature gate. func NewFeatureGates() FeatureGates { return FeatureGates{} } ================================================ FILE: internal/config/data/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "bytes" "errors" "io/fs" "os" "path/filepath" "regexp" "gopkg.in/yaml.v3" ) const envFGNodeShell = "K9S_FEATURE_GATE_NODE_SHELL" var invalidPathCharsRX = regexp.MustCompile(`[:/]+`) // SanitizeContextSubpath ensure cluster/context produces a valid path. func SanitizeContextSubpath(cluster, context string) string { return filepath.Join(SanitizeFileName(cluster), SanitizeFileName(context)) } // SanitizeFileName ensure file spec is valid. func SanitizeFileName(name string) string { return invalidPathCharsRX.ReplaceAllString(name, "-") } func defaultFGNodeShell() bool { if a := os.Getenv(envFGNodeShell); a != "" { return a == "true" } return false } // EnsureDirPath ensures a directory exist from the given path. func EnsureDirPath(path string, mod os.FileMode) error { return EnsureFullPath(filepath.Dir(path), mod) } // EnsureFullPath ensures a directory exist from the given path. func EnsureFullPath(path string, mod os.FileMode) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { if e := os.MkdirAll(path, mod); e != nil { return e } } return nil } // WriteYAML writes a yaml file to bytes. func WriteYAML(content any) ([]byte, error) { buff := bytes.NewBuffer(nil) ec := yaml.NewEncoder(buff) ec.SetIndent(2) if err := ec.Encode(content); err != nil { return nil, err } return buff.Bytes(), nil } // SaveYAML writes a yaml file to disk. func SaveYAML(path string, content any) error { f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, DefaultFileMod) if err != nil { return err } ec := yaml.NewEncoder(f) ec.SetIndent(2) return ec.Encode(content) } ================================================ FILE: internal/config/data/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data_test import ( "os" "path/filepath" "slices" "testing" "github.com/derailed/k9s/internal/config/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSanitizeFileName(t *testing.T) { uu := map[string]struct { file, e string }{ "empty": {}, "plain": { file: "bumble-bee-tuna", e: "bumble-bee-tuna", }, "slash": { file: "bumble/bee/tuna", e: "bumble-bee-tuna", }, "column": { file: "bumble::bee:tuna", e: "bumble-bee-tuna", }, "eks": { file: "arn:aws:eks:us-east-1:123456789:cluster/us-east-1-app-dev-common-eks", e: "arn-aws-eks-us-east-1-123456789-cluster-us-east-1-app-dev-common-eks", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, data.SanitizeFileName(u.file)) }) } } func TestHelperInList(t *testing.T) { uu := []struct { item string list []string expected bool }{ {"a", []string{}, false}, {"", []string{}, false}, {"", []string{""}, true}, {"a", []string{"a", "b", "c", "d"}, true}, {"z", []string{"a", "b", "c", "d"}, false}, } for _, u := range uu { assert.Equal(t, u.expected, slices.Contains(u.list, u.item)) } } func TestEnsureDirPathNone(t *testing.T) { const mod = 0744 dir := filepath.Join(os.TempDir(), "k9s-test") _ = os.Remove(dir) path := filepath.Join(dir, "duh.yaml") require.NoError(t, data.EnsureDirPath(path, mod)) p, err := os.Stat(dir) require.NoError(t, err) assert.Equal(t, "drwxr--r--", p.Mode().String()) } func TestEnsureDirPathNoOpt(t *testing.T) { var mod os.FileMode = 0744 dir := filepath.Join(os.TempDir(), "k9s-test") require.NoError(t, os.RemoveAll(dir)) require.NoError(t, os.Mkdir(dir, mod)) path := filepath.Join(dir, "duh.yaml") require.NoError(t, data.EnsureDirPath(path, mod)) p, err := os.Stat(dir) require.NoError(t, err) assert.Equal(t, "drwxr--r--", p.Mode().String()) } ================================================ FILE: internal/config/data/ns.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "log/slog" "slices" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" ) const ( // MaxFavoritesNS number # favorite namespaces to keep in the configuration. MaxFavoritesNS = 9 ) // Namespace tracks active and favorites namespaces. type Namespace struct { Active string `yaml:"active"` LockFavorites bool `yaml:"lockFavorites"` Favorites []string `yaml:"favorites"` mx sync.RWMutex } // NewNamespace create a new namespace configuration. func NewNamespace() *Namespace { return NewActiveNamespace(client.DefaultNamespace) } func NewActiveNamespace(n string) *Namespace { if n == client.BlankNamespace { n = client.DefaultNamespace } return &Namespace{ Active: n, Favorites: []string{client.DefaultNamespace}, } } func (n *Namespace) merge(old *Namespace) { n.mx.Lock() defer n.mx.Unlock() if n.LockFavorites { return } for _, fav := range old.Favorites { if slices.Contains(n.Favorites, fav) { continue } n.Favorites = append(n.Favorites, fav) } n.trimFavNs() } // Validate validates a namespace is setup correctly. func (n *Namespace) Validate(conn client.Connection) { n.mx.RLock() defer n.mx.RUnlock() if conn == nil || !conn.IsValidNamespace(n.Active) { return } for _, ns := range n.Favorites { if !conn.IsValidNamespace(ns) { slog.Debug("Invalid favorite found", slogs.Namespace, ns, slogs.AllNS, n.isAllNamespaces(), ) n.rmFavNS(ns) } } n.trimFavNs() } // SetActive set the active namespace. func (n *Namespace) SetActive(ns string, _ KubeSettings) error { if n == nil { n = NewActiveNamespace(ns) } n.mx.Lock() defer n.mx.Unlock() if ns == client.BlankNamespace { ns = client.NamespaceAll } n.Active = ns if ns != "" && !n.LockFavorites { n.addFavNS(ns) } return nil } func (n *Namespace) isAllNamespaces() bool { return n.Active == client.NamespaceAll || n.Active == "" } func (n *Namespace) addFavNS(ns string) { if slices.Contains(n.Favorites, ns) { return } nfv := make([]string, 0, MaxFavoritesNS) nfv = append(nfv, ns) for i := range n.Favorites { if i+1 < MaxFavoritesNS { nfv = append(nfv, n.Favorites[i]) } } n.Favorites = nfv } func (n *Namespace) rmFavNS(ns string) { if n.LockFavorites { return } victim := -1 for i, f := range n.Favorites { if f == ns { victim = i break } } if victim < 0 { return } n.Favorites = append(n.Favorites[:victim], n.Favorites[victim+1:]...) } func (n *Namespace) trimFavNs() { if len(n.Favorites) > MaxFavoritesNS { slog.Debug("Number of favorite exceeds hard limit. Trimming.", slogs.Max, MaxFavoritesNS) n.Favorites = n.Favorites[:MaxFavoritesNS] } } ================================================ FILE: internal/config/data/ns_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data_test import ( "testing" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNSValidate(t *testing.T) { ns := data.NewNamespace() ns.Validate(mock.NewMockConnection()) assert.Equal(t, "default", ns.Active) assert.Equal(t, []string{"default"}, ns.Favorites) } func TestNSValidateMissing(t *testing.T) { ns := data.NewNamespace() ns.Validate(mock.NewMockConnection()) assert.Equal(t, "default", ns.Active) assert.Equal(t, []string{"default"}, ns.Favorites) } func TestNSValidateNoNS(t *testing.T) { ns := data.NewNamespace() ns.Validate(mock.NewMockConnection()) assert.Equal(t, "default", ns.Active) assert.Equal(t, []string{"default"}, ns.Favorites) } func TestNsValidateMaxNS(t *testing.T) { allNS := []string{"ns9", "ns8", "ns7", "ns6", "ns5", "ns4", "ns3", "ns2", "ns1", "all", "default"} ns := data.NewNamespace() ns.Favorites = allNS ns.Validate(mock.NewMockConnection()) assert.Len(t, ns.Favorites, data.MaxFavoritesNS) } func TestNSSetActive(t *testing.T) { allNS := []string{"ns4", "ns3", "ns2", "ns1", "all", "default"} uu := []struct { ns string fav []string }{ {"all", []string{"all", "default"}}, {"ns1", []string{"ns1", "all", "default"}}, {"ns2", []string{"ns2", "ns1", "all", "default"}}, {"ns3", []string{"ns3", "ns2", "ns1", "all", "default"}}, {"ns4", allNS}, } mk := mock.NewMockKubeSettings(makeFlags("cl-1", "ct-1")) ns := data.NewNamespace() for _, u := range uu { err := ns.SetActive(u.ns, mk) require.NoError(t, err) assert.Equal(t, u.ns, ns.Active) assert.Equal(t, u.fav, ns.Favorites) } } func TestNSValidateRmFavs(t *testing.T) { ns := data.NewNamespace() ns.Favorites = []string{"default", "fred"} ns.Validate(mock.NewMockConnection()) assert.Equal(t, []string{"default", "fred"}, ns.Favorites) } ================================================ FILE: internal/config/data/proxy.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data // Proxy tracks a context's proxy configuration. type Proxy struct { Address string `yaml:"address"` } ================================================ FILE: internal/config/data/testdata/configs/aws_ct.yaml ================================================ k9s: cluster: arn:aws:eks:eu-central-1:xxx:cluster/fred-blee namespace: active: default lockFavorites: false favorites: - default view: active: po featureGates: nodeShell: false ================================================ FILE: internal/config/data/testdata/configs/ct-1-1.yaml ================================================ k9s: cluster: cl-1 skin: skin-1 readOnly: false namespace: active: ns-1 lockFavorites: true favorites: - default - ns-1 - ns-2 view: active: dp featureGates: nodeShell: true ================================================ FILE: internal/config/data/testdata/configs/ct-1-2.yaml ================================================ k9s: cluster: cl-1 skin: in_the_navy readOnly: true namespace: active: default lockFavorites: false favorites: - default view: active: po featureGates: nodeShell: false ================================================ FILE: internal/config/data/testdata/configs/ct-2-1.yaml ================================================ k9s: cluster: cl-2 skin: skin-2 readOnly: true namespace: active: ns-2 lockFavorites: true favorites: - ns-1 - ns-2 view: active: svc featureGates: nodeShell: true ================================================ FILE: internal/config/data/testdata/configs/def_ct.yaml ================================================ k9s: cluster: cl-test namespace: active: default lockFavorites: false favorites: - default view: active: po featureGates: nodeShell: false ================================================ FILE: internal/config/data/testdata/data/k9s/cl-1/ct-1-1/config.yaml ================================================ k9s: cluster: cl-1 skin: skin-1 readOnly: false namespace: active: ns-1 lockFavorites: true favorites: - default - ns-1 - ns-2 view: active: dp featureGates: nodeShell: true ================================================ FILE: internal/config/data/testdata/data/k9s/cl-1/ct-1-2/config.yaml ================================================ k9s: cluster: cl-1 skin: in_the_navy readOnly: true namespace: active: default lockFavorites: false favorites: - default view: active: po featureGates: nodeShell: false ================================================ FILE: internal/config/data/testdata/data/k9s/cl-2/ct-2-1/config.yaml ================================================ k9s: cluster: cl-2 skin: skin-2 readOnly: true namespace: active: ns-2 lockFavorites: true favorites: - ns-1 - ns-2 view: active: svc featureGates: nodeShell: true ================================================ FILE: internal/config/data/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data import ( "net/http" "net/url" "os" "github.com/derailed/k9s/internal/config/json" "k8s.io/client-go/tools/clientcmd/api" ) // JSONValidator validate yaml configurations. var JSONValidator = json.NewValidator() const ( // DefaultDirMod default unix perms for k9s directory. DefaultDirMod os.FileMode = 0744 // DefaultFileMod default unix perms for k9s files. DefaultFileMod os.FileMode = 0600 // MainConfigFile track main configuration file. MainConfigFile = "config.yaml" ) // KubeSettings exposes kubeconfig context information. type KubeSettings interface { // CurrentContextName returns the name of the current context. CurrentContextName() (string, error) // CurrentClusterName returns the name of the current cluster. CurrentClusterName() (string, error) // CurrentNamespaceName returns the name of the current namespace. CurrentNamespaceName() (string, error) // ContextNames returns all available context names. ContextNames() (map[string]struct{}, error) // CurrentContext returns the current context configuration. CurrentContext() (*api.Context, error) // GetContext returns a given context configuration or err if not found. GetContext(string) (*api.Context, error) // SetProxy sets the proxy for the active context, if present SetProxy(proxy func(*http.Request) (*url.URL, error)) } ================================================ FILE: internal/config/data/view.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data const DefaultView = "po" // View tracks view configuration options. type View struct { Active string `yaml:"active"` } // NewView creates a new view configuration. func NewView() *View { return &View{Active: DefaultView} } // Validate a view configuration. func (v *View) Validate() { if v.Active == "" { v.Active = DefaultView } } ================================================ FILE: internal/config/data/view_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package data_test import ( "testing" "github.com/derailed/k9s/internal/config/data" "github.com/stretchr/testify/assert" ) func TestViewValidate(t *testing.T) { v := data.NewView() v.Validate() assert.Equal(t, "po", v.Active) v.Active = "fred" v.Validate() assert.Equal(t, "fred", v.Active) } func TestViewValidateBlank(t *testing.T) { var v data.View v.Validate() assert.Equal(t, "po", v.Active) } ================================================ FILE: internal/config/files.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( _ "embed" "errors" "io/fs" "log/slog" "os" "path/filepath" "github.com/adrg/xdg" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" ) const ( // K9sEnvConfigDir represents k9s configuration dir env var. K9sEnvConfigDir = "K9S_CONFIG_DIR" // K9sEnvLogsDir represents k9s logs dir env var. K9sEnvLogsDir = "K9S_LOGS_DIR" // AppName tracks k9s app name. AppName = "k9s" K9sLogsFile = "k9s.log" ) var ( //go:embed templates/benchmarks.yaml // benchmarkTpl tracks benchmark default config template benchmarkTpl []byte //go:embed templates/aliases.yaml // aliasesTpl tracks aliases default config template aliasesTpl []byte //go:embed templates/hotkeys.yaml // hotkeysTpl tracks hotkeys default config template hotkeysTpl []byte //go:embed templates/stock-skin.yaml // stockSkinTpl tracks stock skin template stockSkinTpl []byte ) var ( // AppConfigDir tracks main k9s config home directory. AppConfigDir string // AppSkinsDir tracks skins data directory. AppSkinsDir string // AppBenchmarksDir tracks benchmarks results directory. AppBenchmarksDir string // AppDumpsDir tracks screen dumps data directory. AppDumpsDir string // AppContextsDir tracks contexts data directory. AppContextsDir string // AppConfigFile tracks k9s config file. AppConfigFile string // AppLogFile tracks k9s logs file. AppLogFile string // AppViewsFile tracks custom views config file. AppViewsFile string // AppAliasesFile tracks aliases config file. AppAliasesFile string // AppPluginsFile tracks plugins config file. AppPluginsFile string // AppHotKeysFile tracks hotkeys config file. AppHotKeysFile string ) // InitLogLoc initializes K9s logs location. func InitLogLoc() error { var appLogDir string switch { case isEnvSet(K9sEnvLogsDir): appLogDir = os.Getenv(K9sEnvLogsDir) case isEnvSet(K9sEnvConfigDir): tmpDir, err := UserTmpDir() if err != nil { return err } appLogDir = tmpDir default: var err error appLogDir, err = xdg.StateFile(AppName) if err != nil { return err } } if err := data.EnsureFullPath(appLogDir, data.DefaultDirMod); err != nil { return err } AppLogFile = filepath.Join(appLogDir, K9sLogsFile) return nil } // InitLocs initializes k9s artifacts locations. func InitLocs() error { if isEnvSet(K9sEnvConfigDir) { return initK9sEnvLocs() } return initXDGLocs() } func initK9sEnvLocs() error { AppConfigDir = os.Getenv(K9sEnvConfigDir) if err := data.EnsureFullPath(AppConfigDir, data.DefaultDirMod); err != nil { return err } AppDumpsDir = filepath.Join(AppConfigDir, "screen-dumps") if err := data.EnsureFullPath(AppDumpsDir, data.DefaultDirMod); err != nil { slog.Warn("Unable to create screen-dumps dir", slogs.Dir, AppDumpsDir, slogs.Error, err) } AppBenchmarksDir = filepath.Join(AppConfigDir, "benchmarks") if err := data.EnsureFullPath(AppBenchmarksDir, data.DefaultDirMod); err != nil { slog.Warn("Unable to create benchmarks dir", slogs.Dir, AppBenchmarksDir, slogs.Error, err, ) } AppSkinsDir = filepath.Join(AppConfigDir, "skins") if err := data.EnsureFullPath(AppSkinsDir, data.DefaultDirMod); err != nil { slog.Warn("Unable to create skins dir", slogs.Dir, AppSkinsDir, slogs.Error, err, ) } AppContextsDir = filepath.Join(AppConfigDir, "clusters") if err := data.EnsureFullPath(AppContextsDir, data.DefaultDirMod); err != nil { slog.Warn("Unable to create clusters dir", slogs.Dir, AppContextsDir, slogs.Error, err, ) } AppConfigFile = filepath.Join(AppConfigDir, data.MainConfigFile) AppHotKeysFile = filepath.Join(AppConfigDir, "hotkeys.yaml") AppAliasesFile = filepath.Join(AppConfigDir, "aliases.yaml") AppPluginsFile = filepath.Join(AppConfigDir, "plugins.yaml") AppViewsFile = filepath.Join(AppConfigDir, "views.yaml") return nil } func initXDGLocs() error { var err error AppConfigDir, err = xdg.ConfigFile(AppName) if err != nil { return err } AppConfigFile, err = xdg.ConfigFile(filepath.Join(AppName, data.MainConfigFile)) if err != nil { return err } AppHotKeysFile = filepath.Join(AppConfigDir, "hotkeys.yaml") AppAliasesFile = filepath.Join(AppConfigDir, "aliases.yaml") AppPluginsFile = filepath.Join(AppConfigDir, "plugins.yaml") AppViewsFile = filepath.Join(AppConfigDir, "views.yaml") AppSkinsDir = filepath.Join(AppConfigDir, "skins") if e := data.EnsureFullPath(AppSkinsDir, data.DefaultDirMod); e != nil { slog.Warn("No skins dir detected", slogs.Error, e) } AppDumpsDir, err = xdg.StateFile(filepath.Join(AppName, "screen-dumps")) if err != nil { return err } AppBenchmarksDir, err = xdg.StateFile(filepath.Join(AppName, "benchmarks")) if err != nil { slog.Warn("No benchmarks dir detected", slogs.Dir, AppBenchmarksDir, slogs.Error, err, ) } dataDir, err := xdg.DataFile(AppName) if err != nil { return err } AppContextsDir = filepath.Join(dataDir, "clusters") if err := data.EnsureFullPath(AppContextsDir, data.DefaultDirMod); err != nil { slog.Warn("No context dir detected", slogs.Dir, AppContextsDir, slogs.Error, err, ) } return nil } // AppContextDir generates a valid context config dir. func AppContextDir(cluster, context string) string { return filepath.Join(AppContextsDir, data.SanitizeContextSubpath(cluster, context)) } // AppContextAliasesFile generates a valid context specific aliases file path. func AppContextAliasesFile(cluster, context string) string { return filepath.Join(AppContextsDir, data.SanitizeContextSubpath(cluster, context), "aliases.yaml") } // AppContextPluginsFile generates a valid context specific plugins file path. func AppContextPluginsFile(cluster, context string) string { return filepath.Join(AppContextsDir, data.SanitizeContextSubpath(cluster, context), "plugins.yaml") } // AppContextHotkeysFile generates a valid context specific hotkeys file path. func AppContextHotkeysFile(cluster, context string) string { return filepath.Join(AppContextsDir, data.SanitizeContextSubpath(cluster, context), "hotkeys.yaml") } // AppContextConfig generates a valid context config file path. func AppContextConfig(cluster, context string) string { return filepath.Join(AppContextDir(cluster, context), data.MainConfigFile) } // DumpsDir generates a valid context dump directory. func DumpsDir(cluster, context string) (string, error) { dir := filepath.Join(AppDumpsDir, data.SanitizeContextSubpath(cluster, context)) return dir, data.EnsureDirPath(dir, data.DefaultDirMod) } // EnsureBenchmarksDir generates a valid benchmark results directory. func EnsureBenchmarksDir(cluster, context string) (string, error) { dir := filepath.Join(AppBenchmarksDir, data.SanitizeContextSubpath(cluster, context)) return dir, data.EnsureDirPath(dir, data.DefaultDirMod) } // EnsureBenchmarksCfgFile generates a valid benchmark file. func EnsureBenchmarksCfgFile(cluster, context string) (string, error) { f := filepath.Join(AppContextDir(cluster, context), "benchmarks.yaml") if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil { return "", err } if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) { return f, os.WriteFile(f, benchmarkTpl, data.DefaultFileMod) } return f, nil } // EnsureAliasesCfgFile generates a valid aliases file. func EnsureAliasesCfgFile() (string, error) { f := filepath.Join(AppConfigDir, "aliases.yaml") if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil { return "", err } if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) { return f, os.WriteFile(f, aliasesTpl, data.DefaultFileMod) } return f, nil } // EnsureHotkeysCfgFile generates a valid hotkeys file. func EnsureHotkeysCfgFile() (string, error) { f := filepath.Join(AppConfigDir, "hotkeys.yaml") if err := data.EnsureDirPath(f, data.DefaultDirMod); err != nil { return "", err } if _, err := os.Stat(f); errors.Is(err, fs.ErrNotExist) { return f, os.WriteFile(f, hotkeysTpl, data.DefaultFileMod) } return f, nil } // SkinFileFromName generate skin file path from spec. func SkinFileFromName(n string) string { if n == "" { n = "stock" } return filepath.Join(AppSkinsDir, n+".yaml") } ================================================ FILE: internal/config/files_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "os" "path/filepath" "testing" "github.com/adrg/xdg" "github.com/derailed/k9s/internal/config/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_initXDGLocs(t *testing.T) { tmp, err := UserTmpDir() require.NoError(t, err) require.NoError(t, os.Unsetenv("XDG_CONFIG_HOME")) require.NoError(t, os.Unsetenv("XDG_CACHE_HOME")) require.NoError(t, os.Unsetenv("XDG_STATE_HOME")) require.NoError(t, os.Unsetenv("XDG_DATA_HOME")) require.NoError(t, os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "k9s-xdg", "config"))) require.NoError(t, os.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, "k9s-xdg", "cache"))) require.NoError(t, os.Setenv("XDG_STATE_HOME", filepath.Join(tmp, "k9s-xdg", "state"))) require.NoError(t, os.Setenv("XDG_DATA_HOME", filepath.Join(tmp, "k9s-xdg", "data"))) xdg.Reload() uu := map[string]struct { configDir string configFile string benchmarksDir string contextsDir string contextHotkeysFile string contextConfig string dumpsDir string benchDir string hkFile string }{ "check-env": { configDir: filepath.Join(tmp, "k9s-xdg", "config", "k9s"), configFile: filepath.Join(tmp, "k9s-xdg", "config", "k9s", data.MainConfigFile), benchmarksDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "benchmarks"), contextsDir: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters"), contextHotkeysFile: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters", "cl-1", "ct-1-1", "hotkeys.yaml"), contextConfig: filepath.Join(tmp, "k9s-xdg", "data", "k9s", "clusters", "cl-1", "ct-1-1", data.MainConfigFile), dumpsDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "screen-dumps", "cl-1", "ct-1-1"), benchDir: filepath.Join(tmp, "k9s-xdg", "state", "k9s", "benchmarks", "cl-1", "ct-1-1"), hkFile: filepath.Join(tmp, "k9s-xdg", "config", "k9s", "hotkeys.yaml"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { require.NoError(t, initXDGLocs()) assert.Equal(t, u.configDir, AppConfigDir) assert.Equal(t, u.configFile, AppConfigFile) assert.Equal(t, u.benchmarksDir, AppBenchmarksDir) assert.Equal(t, u.contextsDir, AppContextsDir) assert.Equal(t, u.contextHotkeysFile, AppContextHotkeysFile("cl-1", "ct-1-1")) assert.Equal(t, u.contextConfig, AppContextConfig("cl-1", "ct-1-1")) dir, err := DumpsDir("cl-1", "ct-1-1") require.NoError(t, err) assert.Equal(t, u.dumpsDir, dir) bdir, err := EnsureBenchmarksDir("cl-1", "ct-1-1") require.NoError(t, err) assert.Equal(t, u.benchDir, bdir) hk, err := EnsureHotkeysCfgFile() require.NoError(t, err) assert.Equal(t, u.hkFile, hk) }) } } ================================================ FILE: internal/config/files_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "os" "path/filepath" "testing" "github.com/adrg/xdg" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestInitLogLoc(t *testing.T) { tmp, err := config.UserTmpDir() require.NoError(t, err) uu := map[string]struct { dir string e string }{ "log-env": { dir: "/tmp/test/k9s/logs", e: "/tmp/test/k9s/logs/k9s.log", }, "xdg-env": { dir: "/tmp/test/xdg-state", e: "/tmp/test/xdg-state/k9s/k9s.log", }, "cfg-env": { dir: "/tmp/test/k9s-test", e: filepath.Join(tmp, "k9s.log"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { require.NoError(t, os.Unsetenv(config.K9sEnvLogsDir)) require.NoError(t, os.Unsetenv("XDG_STATE_HOME")) require.NoError(t, os.Unsetenv(config.K9sEnvConfigDir)) switch k { case "log-env": require.NoError(t, os.Setenv(config.K9sEnvLogsDir, u.dir)) case "xdg-env": require.NoError(t, os.Setenv("XDG_STATE_HOME", u.dir)) xdg.Reload() case "cfg-env": require.NoError(t, os.Setenv(config.K9sEnvConfigDir, u.dir)) } err := config.InitLogLoc() require.NoError(t, err) assert.Equal(t, u.e, config.AppLogFile) require.NoError(t, os.RemoveAll(config.AppLogFile)) }) } } func TestEnsureBenchmarkCfg(t *testing.T) { require.NoError(t, os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")) require.NoError(t, config.InitLocs()) defer require.NoError(t, os.RemoveAll("/tmp/test-config")) require.NoError(t, data.EnsureFullPath("/tmp/test-config/clusters/cl-1/ct-2", data.DefaultDirMod)) require.NoError(t, os.WriteFile("/tmp/test-config/clusters/cl-1/ct-2/benchmarks.yaml", []byte{}, data.DefaultFileMod)) uu := map[string]struct { cluster, context string f, e string }{ "not-exist": { cluster: "cl-1", context: "ct-1", f: "/tmp/test-config/clusters/cl-1/ct-1/benchmarks.yaml", e: "benchmarks:\n defaults:\n concurrency: 2\n requests: 200", }, "exist": { cluster: "cl-1", context: "ct-2", f: "/tmp/test-config/clusters/cl-1/ct-2/benchmarks.yaml", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { f, err := config.EnsureBenchmarksCfgFile(u.cluster, u.context) require.NoError(t, err) assert.Equal(t, u.f, f) bb, err := os.ReadFile(f) require.NoError(t, err) assert.Equal(t, u.e, string(bb)) }) } } func TestSkinFileFromName(t *testing.T) { config.AppSkinsDir = "/tmp/k9s-test/skins" defer require.NoError(t, os.RemoveAll("/tmp/k9s-test/skins")) uu := map[string]struct { n string e string }{ "empty": { e: "/tmp/k9s-test/skins/stock.yaml", }, "happy": { n: "fred-blee", e: "/tmp/k9s-test/skins/fred-blee.yaml", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, config.SkinFileFromName(u.n)) }) } } ================================================ FILE: internal/config/flags.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config const ( // DefaultRefreshRate represents the refresh interval. DefaultRefreshRate float32 = 2.0 // secs // DefaultLogLevel represents the default log level. DefaultLogLevel = "info" // DefaultCommand represents the default command to run. DefaultCommand = "" ) // Flags represents K9s configuration flags. type Flags struct { RefreshRate *float32 LogLevel *string LogFile *string Headless *bool Logoless *bool Command *string AllNamespaces *bool ReadOnly *bool Write *bool Crumbsless *bool Splashless *bool Invert *bool ScreenDumpDir *string } // NewFlags returns new configuration flags. func NewFlags() *Flags { return &Flags{ RefreshRate: float32Ptr(DefaultRefreshRate), LogLevel: strPtr(DefaultLogLevel), LogFile: strPtr(AppLogFile), Headless: boolPtr(false), Logoless: boolPtr(false), Command: strPtr(DefaultCommand), AllNamespaces: boolPtr(false), ReadOnly: boolPtr(false), Write: boolPtr(false), Crumbsless: boolPtr(false), Splashless: boolPtr(false), Invert: boolPtr(false), ScreenDumpDir: strPtr(AppDumpsDir), } } func boolPtr(b bool) *bool { return &b } func float32Ptr(f float32) *float32 { return &f } func strPtr(s string) *string { return &s } ================================================ FILE: internal/config/flags_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func TestNewFlags(t *testing.T) { config.AppDumpsDir = "/tmp/k9s-test/screen-dumps" config.AppLogFile = "/tmp/k9s-test/k9s.log" f := config.NewFlags() assert.InDelta(t, 2.0, *f.RefreshRate, 0.001) assert.Equal(t, "info", *f.LogLevel) assert.Equal(t, "/tmp/k9s-test/k9s.log", *f.LogFile) assert.Equal(t, config.AppDumpsDir, *f.ScreenDumpDir) assert.Empty(t, *f.Command) assert.False(t, *f.Headless) assert.False(t, *f.Logoless) assert.False(t, *f.AllNamespaces) assert.False(t, *f.ReadOnly) assert.False(t, *f.Write) assert.False(t, *f.Crumbsless) assert.False(t, *f.Splashless) } ================================================ FILE: internal/config/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "log/slog" "os" "os/user" "path/filepath" "github.com/derailed/k9s/internal/slogs" ) const ( envPFAddress = "K9S_DEFAULT_PF_ADDRESS" defaultPortFwdAddress = "localhost" ) // IsBoolSet checks if a bool ptr is set. func IsBoolSet(b *bool) bool { return b != nil && *b } func isStringSet(s *string) bool { return s != nil && *s != "" } func isYamlFile(file string) bool { ext := filepath.Ext(file) return ext == ".yml" || ext == ".yaml" } // isEnvSet checks if env var is set. func isEnvSet(env string) bool { return os.Getenv(env) != "" } // UserTmpDir returns the temp dir with the current user name. func UserTmpDir() (string, error) { u, err := user.Current() if err != nil { return "", err } dir := filepath.Join(os.TempDir(), u.Username, AppName) return dir, nil } // MustK9sUser establishes current user identity or fail. func MustK9sUser() string { usr, err := user.Current() if err != nil { envUsr := os.Getenv("USER") if envUsr != "" { return envUsr } envUsr = os.Getenv("LOGNAME") if envUsr != "" { return envUsr } slog.Error("Die on retrieving user info", slogs.Error, err) os.Exit(1) } return usr.Username } func defaultPFAddress() string { if a := os.Getenv(envPFAddress); a != "" { return a } return defaultPortFwdAddress } ================================================ FILE: internal/config/hotkey.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "errors" "io/fs" "log/slog" "os" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "gopkg.in/yaml.v3" ) // HotKeys represents a collection of plugins. type HotKeys struct { HotKey map[string]HotKey `yaml:"hotKeys"` } // HotKey describes a K9s hotkey. type HotKey struct { ShortCut string `yaml:"shortCut"` Override bool `yaml:"override"` Description string `yaml:"description"` Command string `yaml:"command"` KeepHistory bool `yaml:"keepHistory"` } // NewHotKeys returns a new plugin. func NewHotKeys() HotKeys { return HotKeys{ HotKey: make(map[string]HotKey), } } // Load K9s plugins. func (h HotKeys) Load(path string) error { if err := h.LoadHotKeys(AppHotKeysFile); err != nil { return err } if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil } return h.LoadHotKeys(path) } // LoadHotKeys loads plugins from a given file. func (h HotKeys) LoadHotKeys(path string) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil } bb, err := os.ReadFile(path) if err != nil { return err } if err := data.JSONValidator.Validate(json.HotkeysSchema, bb); err != nil { slog.Warn("Validation failed. Please update your config and restart.", slogs.Path, path, slogs.Error, err, ) } var hh HotKeys if err := yaml.Unmarshal(bb, &hh); err != nil { return err } for k, v := range hh.HotKey { h.HotKey[k] = v } return nil } ================================================ FILE: internal/config/hotkey_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHotKeyLoad(t *testing.T) { h := config.NewHotKeys() require.NoError(t, h.LoadHotKeys("testdata/hotkeys/hotkeys.yaml")) assert.Len(t, h.HotKey, 1) k, ok := h.HotKey["pods"] assert.True(t, ok) assert.Equal(t, "shift-0", k.ShortCut) assert.Equal(t, "Launch pod view", k.Description) assert.Equal(t, "pods", k.Command) assert.True(t, k.KeepHistory) } ================================================ FILE: internal/config/json/schemas/aliases.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s aliases schema", "type": "object", "additionalProperties": false, "properties": { "aliases": { "type": "object", "additionalProperties": { "type": "string" }, "required": [] } }, "required": ["aliases"] } ================================================ FILE: internal/config/json/schemas/context.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s context config schema", "type": "object", "additionalProperties": false, "properties": { "k9s": { "additionalProperties": false, "properties": { "cluster": { "type": "string" }, "readOnly": {"type": "boolean"}, "skin": { "type": "string" }, "proxy": { "oneOf": [ { "type": "null" }, { "type": "object", "additionalProperties": false, "properties": { "address": {"type": "string"} } } ] }, "namespace": { "type": "object", "additionalProperties": false, "properties": { "active": {"type": "string"}, "lockFavorites": {"type": "boolean"}, "favorites": { "type": "array", "items": {"type": "string"} } } }, "view": { "type": "object", "additionalProperties": false, "properties": { "active": { "type": "string" } } }, "featureGates": { "type": "object", "additionalProperties": false, "properties": { "nodeShell": { "type": "boolean" } } } } } }, "required": ["k9s"] } ================================================ FILE: internal/config/json/schemas/hotkeys.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s hotkeys schema", "type": "object", "additionalProperties": false, "properties": { "hotKeys": { "type": "object", "additionalProperties": { "type": "object", "properties": { "shortCut": {"type": "string"}, "override": { "type": "boolean" }, "description": {"type": "string"}, "command": {"type": "string"}, "keepHistory": {"type": "boolean"} } } } }, "required": ["hotKeys"] } ================================================ FILE: internal/config/json/schemas/k9s.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s config schema", "type": "object", "additionalProperties": false, "properties": { "k9s": { "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": "number" }, "apiServerTimeout": { "type": "string" }, "maxConnRetry": { "type": "integer" }, "readOnly": { "type": "boolean" }, "noExitOnCtrlC": { "type": "boolean" }, "skipLatestRevCheck": { "type": "boolean" }, "disablePodCounting": { "type": "boolean" }, "defaultView": { "type": "string" }, "portForwardAddress": { "type": "string" }, "ui": { "type": "object", "additionalProperties": false, "properties": { "enableMouse": {"type": "boolean"}, "headless": {"type": "boolean"}, "logoless": {"type": "boolean"}, "crumbsless": {"type": "boolean"}, "splashless": {"type": "boolean"}, "noIcons": {"type": "boolean"}, "reactive": {"type": "boolean"}, "skin": {"type": "string"}, "defaultsToFullScreen": {"type": "boolean"}, "useFullGVRTitle": {"type": "boolean"}, "invert": {"type": "boolean"} } }, "shellPod": { "type": "object", "additionalProperties": true, "properties": { "image": { "type": "string" }, "command": { "type": "array", "items": { "type": "string"} }, "args": { "type": "array", "items": { "type": "string"} }, "namespace": { "type": "string" }, "limits": { "type": "object", "properties": { "cpu": { "type": "string" }, "memory": { "type": "string" } }, "required": ["cpu", "memory"] }, "labels": { "type": "object", "additionalProperties": { "type": "string" }, "required": [] }, "tty": { "type": "boolean" }, "imagePullPolicy": { "type": "string" }, "imagePullSecrets": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" } } } } }, "required": ["image", "namespace", "limits"] }, "imageScans": { "type": "object", "additionalProperties": false, "properties": { "enable": { "type": "boolean" }, "namespace": { "type": "string" }, "exclusions": { "type": "object", "properties": { "namespaces": { "type": "array", "items": { "type": "string" } }, "labels": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } } } }, "required": ["enable"] }, "logger": { "type": "object", "additionalProperties": false, "properties": { "tail": {"type": "integer"}, "buffer": {"type": "integer"}, "sinceSeconds": {"type": "integer"}, "textWrap": {"type": "boolean"}, "disableAutoscroll": {"type": "boolean"}, "columnLock": {"type": "boolean"}, "showTime": {"type": "boolean"} } }, "thresholds": { "type": "object", "additionalProperties": false, "properties": { "cpu": { "type": "object", "properties": { "critical": {"type": "integer"}, "warn": {"type": "integer"} } }, "memory": { "type": "object", "properties": { "critical": {"type": "integer"}, "warn": {"type": "integer"} } } } } } } }, "required": ["k9s"] } ================================================ FILE: internal/config/json/schemas/plugin-multi.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s plugin-multi schema", "type": "object", "additionalProperties": { "properties": { "shortCut": { "type": "string" }, "override": { "type": "boolean" }, "description": { "type": "string" }, "confirm": { "type": "boolean" }, "dangerous": { "type": "boolean" }, "scopes": { "type": "array", "items": { "type": "string" } }, "command": { "type": "string" }, "background": { "type": "boolean" }, "overwriteOutput": { "type": "boolean" }, "args": { "type": "array", "items": { "type": ["string", "number"] } }, "inputs": { "type": "array", "maxItems": 5, "items": { "type": "object", "properties": { "name": { "type": "string" }, "label": { "type": "string" }, "type": { "type": "string", "enum": ["string", "number", "bool", "dropdown"] }, "required": { "type": "boolean" }, "default": { "type": ["string", "number", "boolean"] }, "options": { "type": "array", "items": { "type": "string" } } }, "required": ["name", "type"], "if": { "required": ["default"] }, "then": { "properties": { "required": { "const": true } }, "required": ["required"] } } } }, "required": ["shortCut", "description", "scopes", "command"] } } ================================================ FILE: internal/config/json/schemas/plugin.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s plugin schema", "type": "object", "additionalProperties": false, "properties": { "shortCut": { "type": "string" }, "override": { "type": "boolean" }, "description": { "type": "string" }, "confirm": { "type": "boolean" }, "dangerous": { "type": "boolean" }, "scopes": { "type": "array", "items": { "type": "string" } }, "command": { "type": "string" }, "background": { "type": "boolean" }, "overwriteOutput": { "type": "boolean" }, "args": { "type": "array", "items": { "type": ["string", "number"] } }, "inputs": { "type": "array", "maxItems": 5, "items": { "type": "object", "properties": { "name": { "type": "string" }, "label": { "type": "string" }, "type": { "type": "string", "enum": ["string", "number", "bool", "dropdown"] }, "required": { "type": "boolean" }, "default": { "type": ["string", "number", "boolean"] }, "options": { "type": "array", "items": { "type": "string" } } }, "required": ["name", "type"], "if": { "required": ["default"] }, "then": { "properties": { "required": { "const": true } }, "required": ["required"] } } } }, "required": ["shortCut", "description", "scopes", "command"] } ================================================ FILE: internal/config/json/schemas/plugins.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s plugins schema", "type": "object", "additionalProperties": false, "properties": { "plugins": { "type": "object", "additionalProperties": { "properties": { "shortCut": { "type": "string" }, "override": { "type": "boolean" }, "description": { "type": "string" }, "confirm": { "type": "boolean" }, "dangerous": { "type": "boolean" }, "scopes": { "type": "array", "items": { "type": "string" } }, "command": { "type": "string" }, "background": { "type": "boolean" }, "overwriteOutput": { "type": "boolean" }, "args": { "type": "array", "items": { "type": ["string", "number"] } }, "inputs": { "type": "array", "maxItems": 5, "items": { "type": "object", "properties": { "name": { "type": "string" }, "label": { "type": "string" }, "type": { "type": "string", "enum": ["string", "number", "bool", "dropdown"] }, "required": { "type": "boolean" }, "default": { "type": ["string", "number", "boolean"] }, "options": { "type": "array", "items": { "type": "string" } } }, "required": ["name", "type"], "if": { "required": ["default"] }, "then": { "properties": { "required": { "const": true } }, "required": ["required"] } } } }, "required": ["shortCut", "description", "scopes", "command"] }, "required": [] } }, "required": ["plugins"] } ================================================ FILE: internal/config/json/schemas/skin.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s skin schema", "type": "object", "additionalProperties": true, "properties": { "k9s": { "type": "object", "additionalProperties": false, "properties": { "body": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "logoColor": {"type": "string"} } }, "prompt": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "suggestColor": {"type": "string"} } }, "info": { "type": "object", "properties": { "fgColor": {"type": "string"}, "sectionColor": {"type": "string"}, "k9sRevColor": {"type": "string"}, "cpuColor": {"type": "string"}, "memColor": {"type": "string"} } }, "help": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "keyColor": {"type": "string"}, "numKeyColor": {"type": "string"}, "sectionColor": {"type": "string"} } }, "dialog": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "buttonFgColor": {"type": "string"}, "buttonBgColor": {"type": "string"}, "buttonFocusFgColor": {"type": "string"}, "buttonFocusBgColor": {"type": "string"}, "labelFgColor": {"type": "string"}, "fieldFgColor": {"type": "string"} } }, "frame": { "type": "object", "properties": { "border": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"} } }, "menu": { "type": "object", "properties": { "fgColor": {"type": "string"}, "keyColor": {"type": "string"}, "numKeyColor": {"type": "string"} } }, "crumbs": { "type": "object", "properties": { "fgColor": {"type": "string"}, "keyColor": {"type": "string"}, "activeColor": {"type": "string"} } }, "status": { "type": "object", "properties": { "newColor": {"type": "string"}, "modifyColor": {"type": "string"}, "addColor:": {"type": "string"}, "errorColor": {"type": "string"}, "highlightColor": {"type": "string"}, "killColor": {"type": "string"}, "completedColor": {"type": "string"} } }, "title": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor":{"type": "string"}, "highlightColor": {"type": "string"}, "counterColor":{"type": "string"}, "filterColor": {"type": "string"} } } } }, "views": { "type": "object", "properties": { "charts": { "type": "object", "properties": { "bgColor": {"type": "string"}, "defaultDialColors": { "type": "array", "items": {"type": "string"} }, "defaultChartColors": { "type": "array", "items": {"type": "string"} } }, "table": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "cursorFgColor": {"type": "string"}, "cursorBgColor": {"type": "string"}, "header": { "type": "object", "additionalProperties": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"} } } } } }, "xray": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "cursorFgColor": {"type": "string"}, "graphicColor": {"type": "string"}, "showIcons": {"type": "boolean"} } }, "yaml": { "type": "object", "properties": { "keyColor": {"type": "string"}, "colonColor": {"type": "string"}, "valueColor": {"type": "string"} } }, "logs": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "indicator": { "type": "object", "additionalProperties": { "type": "object", "properties": { "fgColor": {"type": "string"}, "bgColor": {"type": "string"}, "toggleOnColor": {"type": "string"}, "toggleOffColor": {"type": "string"} } } } } } } } } } } } } ================================================ FILE: internal/config/json/schemas/views.json ================================================ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "K9s views schema", "type": "object", "additionalProperties": false, "properties": { "views": { "type": "object", "additionalProperties": { "type": "object", "additionalProperties": false, "properties": { "sortColumn": { "type": "string" }, "columns": { "type": "array", "items": { "type": "string" } } }, "required": ["columns"] } } }, "required": ["views"] } ================================================ FILE: internal/config/json/testdata/aliases/cool.yaml ================================================ aliases: blee: duh fred: zorg ================================================ FILE: internal/config/json/testdata/aliases/toast.yaml ================================================ alias: blee: duh fred: zorg ================================================ FILE: internal/config/json/testdata/context/cool.yaml ================================================ k9s: cluster: kind-dashb readOnly: false skin: nightfox namespace: active: default lockFavorites: false favorites: - kube-system - default view: active: pod featureGates: nodeShell: false ================================================ FILE: internal/config/json/testdata/context/toast.yaml ================================================ k9s: cluster: kind-dashb readOnly: false skin: nightfox namespaces: active: default lockFavorites: false favorites: - kube-system - default view: active: pod fred: blee featureGates: nodeShell: false ================================================ FILE: internal/config/json/testdata/hotkeys/cool.yaml ================================================ hotKey: shift-0: shortCut: Shift-0 description: Popeye command: popeye shift-1: shortCut: Shift-1 description: View deployments command: dp shift-2: shortCut: Shift-2 description: View services command: service shift-3: shortCut: Shift-3 description: View statefulsets command: sts shift-4: shortCut: Shift-4 description: Xray Deployments command: xray dp shift-5: shortCut: Shift-5 description: Xray StatefulSets command: xray sts shift-6: shortCut: Shift-6 description: Xray DaemonSets command: xray ds shift-7: shortCut: Shift-7 description: Xray Services command: xray svc ================================================ FILE: internal/config/json/testdata/k9s/cool.yaml ================================================ k9s: liveViewAutoRefresh: false screenDumpDir: /Users/fernand/.local/state/k9s/screen-dumps refreshRate: 2 maxConnRetry: 5 readOnly: false noExitOnCtrlC: false ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false noIcons: false skipLatestRevCheck: false disablePodCounting: false shellPod: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 100 buffer: 5000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 ================================================ FILE: internal/config/json/testdata/k9s/toast.yaml ================================================ k9s: liveViewAutoRefresh: false screenDumpDir: /Users/fernand/.local/state/k9s/screen-dumps refreshRate: 2 maxConnRetry: 5 readOnly: false noExitOnCtrlC: false skipLatestRevCheck: false disablePodCounting: false shellPods: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 100 buffer: 5000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 ================================================ FILE: internal/config/json/testdata/plugins/cool.yaml ================================================ plugins: blee: shortCut: g confirm: false description: blee scopes: - namespaces command: sh background: false args: - -c - "blee bla" duh: shortCut: h confirm: true description: duh scopes: - all command: sh background: true args: - -c - "duh fred" ================================================ FILE: internal/config/json/testdata/plugins/snippet.yaml ================================================ shortCut: g confirm: false description: blee scopes: - namespaces command: sh background: false args: - -c - "blee bla" ================================================ FILE: internal/config/json/testdata/plugins/snippets.yaml ================================================ blee: shortCut: g confirm: false description: blee scopes: - namespaces command: sh background: false args: - -c - "blee bla" duh: shortCut: h confirm: true description: duh scopes: - all command: sh background: true args: - -c - "duh fred" ================================================ FILE: internal/config/json/testdata/plugins/toast.yaml ================================================ plugins: blee: shortCuts: g confirm: false description: blee scopes: - namespaces command: sh background: false args: - -c - "blee bla" duh: shortCut: h confirm: true description: duh command: sh background: true args: - -c - "duh fred" ================================================ FILE: internal/config/json/testdata/skins/cool.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Nightfox Theme # Based on the Nightfox.nvim color scheme: # https://github.com/EdenEast/nightfox.nvim # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#cdcecf" background: &background "#192330" current_line: ¤t_line "#2b3b51" selection: &selection "#2b3b51" comment: &comment "#738091" cyan: &cyan "#63cdcf" green: &green "#81b29a" orange: &orange "#f4a261" magenta: &magenta "#9d79d6" blue: &blue "#719cd6" red: &red "#c94f6d" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *magenta sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *selection cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *cyan xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: internal/config/json/testdata/skins/toast.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Nightfox Theme # Based on the Nightfox.nvim color scheme: # https://github.com/EdenEast/nightfox.nvim # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#cdcecf" background: &background "#192330" current_line: ¤t_line "#2b3b51" selection: &selection "#2b3b51" comment: &comment "#738091" cyan: &cyan "#63cdcf" green: &green "#81b29a" orange: &orange "#f4a261" magenta: &magenta "#9d79d6" blue: &blue "#719cd6" red: &red "#c94f6d" # Skin... k9s: bodys: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *selection cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *cyan xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: internal/config/json/testdata/views/cool.yaml ================================================ views: v1/nodes: columns: - NAME - IP v1/endpoints: sortColumn: AGE:asc columns: - NAME - NAMESPACE - ENDPOINTS - AGE ================================================ FILE: internal/config/json/testdata/views/toast.yaml ================================================ views: v1/nodes: v1/endpoints: sortCol: AGE:asc cols: - NAME - NAMESPACE - ENDPOINTS - AGE ================================================ FILE: internal/config/json/validator.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package json import ( "cmp" _ "embed" "errors" "fmt" "log/slog" "slices" "github.com/derailed/k9s/internal/slogs" "github.com/xeipuuv/gojsonschema" "gopkg.in/yaml.v3" ) const ( // PluginsSchema describes plugins schema. PluginsSchema = "plugins.json" // PluginSchema describes a plugin snippet schema. PluginSchema = "plugin.json" // PluginMultiSchema describes plugin snippets schema. PluginMultiSchema = "plugin-multi.json" // AliasesSchema describes aliases schema. AliasesSchema = "aliases.json" // ViewsSchema describes views schema. ViewsSchema = "views.json" // HotkeysSchema describes hotkeys schema. HotkeysSchema = "hotkeys.json" // K9sSchema describes k9s config schema. K9sSchema = "k9s.json" // ContextSchema describes context config schema. ContextSchema = "context.json" // SkinSchema describes skin config schema. SkinSchema = "skin.json" ) var ( //go:embed schemas/plugins.json pluginsSchema string //go:embed schemas/plugin.json pluginSchema string //go:embed schemas/plugin-multi.json pluginMultiSchema string //go:embed schemas/aliases.json aliasSchema string //go:embed schemas/views.json viewsSchema string //go:embed schemas/k9s.json k9sSchema string //go:embed schemas/context.json contextSchema string //go:embed schemas/hotkeys.json hotkeysSchema string //go:embed schemas/skin.json skinSchema string ) // Validator tracks schemas validation. type Validator struct { schemas map[string]gojsonschema.JSONLoader loader *gojsonschema.SchemaLoader } // NewValidator returns a new instance. func NewValidator() *Validator { v := Validator{ schemas: map[string]gojsonschema.JSONLoader{ K9sSchema: gojsonschema.NewStringLoader(k9sSchema), ContextSchema: gojsonschema.NewStringLoader(contextSchema), AliasesSchema: gojsonschema.NewStringLoader(aliasSchema), ViewsSchema: gojsonschema.NewStringLoader(viewsSchema), PluginsSchema: gojsonschema.NewStringLoader(pluginsSchema), PluginSchema: gojsonschema.NewStringLoader(pluginSchema), PluginMultiSchema: gojsonschema.NewStringLoader(pluginMultiSchema), HotkeysSchema: gojsonschema.NewStringLoader(hotkeysSchema), SkinSchema: gojsonschema.NewStringLoader(skinSchema), }, } v.register() return &v } // Init initializes the schemas. func (v *Validator) register() { v.loader = gojsonschema.NewSchemaLoader() v.loader.Validate = true clog := slog.With(slogs.Subsys, "schema") for k, s := range v.schemas { if err := v.loader.AddSchema(k, s); err != nil { clog.Error("Schema initialization failed", slogs.SchemaFile, k, slogs.Error, err, ) } } } // ValidatePlugins validates plugins schema. // Checks for full, snippet and multi snippets schemas. func (v *Validator) ValidatePlugins(bb []byte) (string, error) { var errs error for _, k := range []string{PluginsSchema, PluginSchema, PluginMultiSchema} { if err := v.Validate(k, bb); err != nil { errs = errors.Join(errs, err) continue } return k, nil } return "", errs } // Validate runs document thru given schema validation. func (v *Validator) Validate(k string, bb []byte) error { var m any err := yaml.Unmarshal(bb, &m) if err != nil { return err } s, ok := v.schemas[k] if !ok { return fmt.Errorf("no schema found for: %q", k) } result, err := gojsonschema.Validate(s, gojsonschema.NewGoLoader(m)) if err != nil { return err } if result.Valid() { return nil } slices.SortFunc(result.Errors(), func(a, b gojsonschema.ResultError) int { return cmp.Compare(a.Description(), b.Description()) }) var errs error for _, re := range result.Errors() { errs = errors.Join(errs, errors.New(re.Description())) } return errs } func (v *Validator) ValidateObj(k string, o any) error { s, ok := v.schemas[k] if !ok { return fmt.Errorf("no schema found for: %q", k) } result, err := gojsonschema.Validate(s, gojsonschema.NewGoLoader(o)) if err != nil { return err } if result.Valid() { return nil } slices.SortFunc(result.Errors(), func(a, b gojsonschema.ResultError) int { return cmp.Compare(a.Description(), b.Description()) }) var errs error for _, re := range result.Errors() { errs = errors.Join(errs, errors.New(re.Description())) } return errs } ================================================ FILE: internal/config/json/validator_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package json_test import ( "os" "path/filepath" "testing" "github.com/derailed/k9s/internal/config/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestValidatePluginSnippet(t *testing.T) { plugPath := "testdata/plugins/snippet.yaml" bb, err := os.ReadFile(plugPath) require.NoError(t, err) p := json.NewValidator() require.NoError(t, p.Validate(json.PluginSchema, bb), plugPath) } func TestValidatePlugins(t *testing.T) { uu := map[string]struct { path, schema string err string }{ "cool": { path: "testdata/plugins/cool.yaml", schema: json.PluginsSchema, }, "toast": { path: "testdata/plugins/toast.yaml", schema: json.PluginsSchema, err: "scopes is required\nshortCut is required", }, "cool-snippet": { path: "testdata/plugins/snippet.yaml", schema: json.PluginSchema, }, "cool-snippets": { path: "testdata/plugins/snippets.yaml", schema: json.PluginMultiSchema, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.path) require.NoError(t, err) v := json.NewValidator() if err := v.Validate(u.schema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestValidatePluginDir(t *testing.T) { plugDir := "../../../plugins" ee, err := os.ReadDir(plugDir) require.NoError(t, err) for _, e := range ee { if e.IsDir() { continue } ext := filepath.Ext(e.Name()) if ext == ".md" { continue } assert.Equal(t, ".yaml", ext, "expected yaml file: %q", e.Name()) assert.NotContains(t, "_", e.Name(), "underscore in: %q", e.Name()) bb, err := os.ReadFile(filepath.Join(plugDir, e.Name())) require.NoError(t, err) p := json.NewValidator() require.NoError(t, p.Validate(json.PluginsSchema, bb), e.Name()) } } func TestValidateSkinDir(t *testing.T) { skinDir := "../../../skins" ee, err := os.ReadDir(skinDir) require.NoError(t, err) p := json.NewValidator() for _, e := range ee { if e.IsDir() { continue } ext := filepath.Ext(e.Name()) assert.Equal(t, ".yaml", ext, "expected yaml file: %q", e.Name()) assert.NotContains(t, "_", e.Name(), "underscore in: %q", e.Name()) bb, err := os.ReadFile(filepath.Join(skinDir, e.Name())) require.NoError(t, err) require.NoError(t, p.Validate(json.SkinSchema, bb), e.Name()) } } func TestValidateSkin(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/skins/cool.yaml", }, "toast": { f: "testdata/skins/toast.yaml", err: `Additional property bodys is not allowed`, }, } v := json.NewValidator() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.f) require.NoError(t, err) if err := v.Validate(json.SkinSchema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestValidateK9s(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/k9s/cool.yaml", }, "toast": { f: "testdata/k9s/toast.yaml", err: `Additional property shellPods is not allowed`, }, } v := json.NewValidator() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.f) require.NoError(t, err) if err := v.Validate(json.K9sSchema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestValidateContext(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/context/cool.yaml", }, "toast": { f: "testdata/context/toast.yaml", err: `Additional property fred is not allowed Additional property namespaces is not allowed`, }, } v := json.NewValidator() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.f) require.NoError(t, err) if err := v.Validate(json.ContextSchema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestValidateAliases(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/aliases/cool.yaml", }, "toast": { f: "testdata/aliases/toast.yaml", err: `Additional property alias is not allowed aliases is required`, }, } v := json.NewValidator() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.f) require.NoError(t, err) if err := v.Validate(json.AliasesSchema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } func TestValidateViews(t *testing.T) { uu := map[string]struct { f string err string }{ "happy": { f: "testdata/views/cool.yaml", }, "toast": { f: "testdata/views/toast.yaml", err: `Additional property cols is not allowed Additional property sortCol is not allowed Invalid type. Expected: object, given: null columns is required`, }, } v := json.NewValidator() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { bb, err := os.ReadFile(u.f) require.NoError(t, err) if err := v.Validate(json.ViewsSchema, bb); err != nil { assert.Equal(t, u.err, err.Error()) } }) } } ================================================ FILE: internal/config/k9s.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "errors" "fmt" "io/fs" "log/slog" "net/http" "net/url" "os" "path/filepath" "sync" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" ) type gpuVendors map[string]string // KnownGPUVendors tracks a set of known GPU vendors. var KnownGPUVendors = defaultGPUVendors var defaultGPUVendors = gpuVendors{ "nvidia": "nvidia.com/gpu", "nvidia-shared": "nvidia.com/gpu.shared", "amd": "amd.com/gpu", "intel": "gpu.intel.com/i915", } // 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 float32 `json:"refreshRate" yaml:"refreshRate"` APIServerTimeout string `json:"apiServerTimeout" yaml:"apiServerTimeout"` MaxConnRetry int32 `json:"maxConnRetry" yaml:"maxConnRetry"` ReadOnly bool `json:"readOnly" yaml:"readOnly"` NoExitOnCtrlC bool `json:"noExitOnCtrlC" yaml:"noExitOnCtrlC"` PortForwardAddress string `yaml:"portForwardAddress"` UI UI `json:"ui" yaml:"ui"` SkipLatestRevCheck bool `json:"skipLatestRevCheck" yaml:"skipLatestRevCheck"` DisablePodCounting bool `json:"disablePodCounting" yaml:"disablePodCounting"` ShellPod *ShellPod `json:"shellPod" yaml:"shellPod"` ImageScans ImageScans `json:"imageScans" yaml:"imageScans"` Logger Logger `json:"logger" yaml:"logger"` Thresholds Threshold `json:"thresholds" yaml:"thresholds"` DefaultView string `json:"defaultView" yaml:"defaultView"` manualRefreshRate float32 manualReadOnly *bool manualCommand *string manualScreenDumpDir *string refreshRateWarned bool dir *data.Dir activeContextName string activeConfig *data.Config conn client.Connection ks data.KubeSettings mx sync.RWMutex contextSwitch bool } // NewK9s create a new K9s configuration. func NewK9s(conn client.Connection, ks data.KubeSettings) *K9s { return &K9s{ RefreshRate: defaultRefreshRate, GPUVendors: make(gpuVendors), MaxConnRetry: defaultMaxConnRetry, APIServerTimeout: client.DefaultCallTimeoutDuration.String(), ScreenDumpDir: AppDumpsDir, Logger: NewLogger(), Thresholds: NewThreshold(), PortForwardAddress: defaultPFAddress(), ShellPod: NewShellPod(), ImageScans: NewImageScans(), dir: data.NewDir(AppContextsDir), conn: conn, ks: ks, } } func (k *K9s) ToggleContextSwitch(b bool) { k.mx.Lock() defer k.mx.Unlock() k.contextSwitch = b } func (k *K9s) getContextSwitch() bool { k.mx.Lock() defer k.mx.Unlock() return k.contextSwitch } func (k *K9s) resetConnection(conn client.Connection) { k.mx.Lock() defer k.mx.Unlock() k.conn = conn } // Save saves the k9s config to disk. func (k *K9s) Save(contextName, clusterName string, force bool) error { path := filepath.Join( AppContextsDir, data.SanitizeContextSubpath(clusterName, contextName), data.MainConfigFile, ) if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) || force { slog.Debug("[CONFIG] Saving context config to disk", slogs.Path, path, slogs.Cluster, k.getActiveConfig().Context.GetClusterName(), slogs.Context, k.getActiveContextName(), ) return k.dir.Save(path, k.getActiveConfig()) } return nil } // Merge merges k9s configs. func (k *K9s) Merge(k1 *K9s) { if k1 == nil { return } for k, v := range k1.GPUVendors { KnownGPUVendors[k] = v } k.LiveViewAutoRefresh = k1.LiveViewAutoRefresh k.DefaultView = k1.DefaultView k.ScreenDumpDir = k1.ScreenDumpDir k.RefreshRate = k1.RefreshRate k.APIServerTimeout = k1.APIServerTimeout k.MaxConnRetry = k1.MaxConnRetry k.ReadOnly = k1.ReadOnly k.NoExitOnCtrlC = k1.NoExitOnCtrlC k.PortForwardAddress = k1.PortForwardAddress k.UI = k1.UI k.SkipLatestRevCheck = k1.SkipLatestRevCheck k.DisablePodCounting = k1.DisablePodCounting k.ShellPod = k1.ShellPod k.Logger = k1.Logger k.ImageScans = k1.ImageScans if k1.Thresholds != nil { k.Thresholds = k1.Thresholds } } // AppScreenDumpDir fetch screen dumps dir. func (k *K9s) AppScreenDumpDir() string { d := k.ScreenDumpDir if isStringSet(k.manualScreenDumpDir) { d = *k.manualScreenDumpDir k.ScreenDumpDir = d } if d == "" { d = AppDumpsDir } return d } // ContextScreenDumpDir fetch context specific screen dumps dir. func (k *K9s) ContextScreenDumpDir() string { return filepath.Join(k.AppScreenDumpDir(), k.contextPath()) } func (k *K9s) contextPath() string { if k.getActiveConfig() == nil { return "na" } return data.SanitizeContextSubpath( k.getActiveConfig().Context.GetClusterName(), k.ActiveContextName(), ) } // Reset resets configuration and context. func (k *K9s) Reset() { k.setActiveConfig(nil) k.setActiveContextName("") } // ActiveContextNamespace fetch the context active ns. func (k *K9s) ActiveContextNamespace() (string, error) { act, err := k.ActiveContext() if err != nil { return "", err } return act.Namespace.Active, nil } // ActiveContextName returns the active context name. func (k *K9s) ActiveContextName() string { return k.getActiveContextName() } // ActiveContext returns the currently active context. func (k *K9s) ActiveContext() (*data.Context, error) { if cfg := k.getActiveConfig(); cfg != nil && cfg.Context != nil { return cfg.Context, nil } ct, err := k.ActivateContext(k.ActiveContextName()) return ct, err } func (k *K9s) setActiveConfig(c *data.Config) { k.mx.Lock() defer k.mx.Unlock() k.activeConfig = c } func (k *K9s) getActiveConfig() *data.Config { k.mx.RLock() defer k.mx.RUnlock() return k.activeConfig } func (k *K9s) setActiveContextName(n string) { k.mx.Lock() defer k.mx.Unlock() k.activeContextName = n } func (k *K9s) getActiveContextName() string { k.mx.RLock() defer k.mx.RUnlock() return k.activeContextName } // ActivateContext initializes the active context if not present. func (k *K9s) ActivateContext(contextName string) (*data.Context, error) { k.setActiveContextName(contextName) ct, err := k.ks.GetContext(contextName) if err != nil { return nil, err } cfg, err := k.dir.Load(contextName, ct) if err != nil { return nil, err } k.setActiveConfig(cfg) if cfg.Context.Proxy != nil { k.ks.SetProxy(func(*http.Request) (*url.URL, error) { slog.Debug("Using proxy address", slogs.Address, cfg.Context.Proxy.Address) return url.Parse(cfg.Context.Proxy.Address) }) if k.conn != nil && k.conn.Config() != nil { // We get on this branch when the user switches the context and k9s // already has an API connection object so we just set the proxy to // avoid recreation using client.InitConnection k.conn.Config().SetProxy(func(*http.Request) (*url.URL, error) { slog.Debug("Setting proxy address", slogs.Address, cfg.Context.Proxy.Address) return url.Parse(cfg.Context.Proxy.Address) }) if !k.conn.CheckConnectivity() { return nil, fmt.Errorf("unable to connect to context %q", contextName) } } } k.Validate(k.conn, contextName, ct.Cluster) // If the context specifies a namespace, use it! if ns := ct.Namespace; ns != client.BlankNamespace { k.getActiveConfig().Context.Namespace.Active = ns } else if k.getActiveConfig().Context.Namespace.Active == "" { k.getActiveConfig().Context.Namespace.Active = client.DefaultNamespace } if k.getActiveConfig().Context == nil { return nil, fmt.Errorf("context activation failed for: %s", contextName) } return k.getActiveConfig().Context, nil } // Reload reloads the context config from disk. func (k *K9s) Reload() error { // Switching context skipping reload... if k.getContextSwitch() { return nil } ct, err := k.ks.GetContext(k.getActiveContextName()) if err != nil { return err } cfg, err := k.dir.Load(k.getActiveContextName(), ct) if err != nil { return err } k.setActiveConfig(cfg) k.getActiveConfig().Validate(k.conn, k.getActiveContextName(), ct.Cluster) return nil } // Override overrides k9s config from cli args. func (k *K9s) Override(k9sFlags *Flags) { if k9sFlags.RefreshRate != nil && *k9sFlags.RefreshRate != DefaultRefreshRate { k.manualRefreshRate = float32(*k9sFlags.RefreshRate) } k.UI.manualHeadless = k9sFlags.Headless k.UI.manualLogoless = k9sFlags.Logoless k.UI.manualCrumbsless = k9sFlags.Crumbsless k.UI.manualSplashless = k9sFlags.Splashless k.UI.manualInvert = k9sFlags.Invert if k9sFlags.ReadOnly != nil && *k9sFlags.ReadOnly { k.manualReadOnly = k9sFlags.ReadOnly } if k9sFlags.Write != nil && *k9sFlags.Write { var falseVal bool k.manualReadOnly = &falseVal } k.manualCommand = k9sFlags.Command k.manualScreenDumpDir = k9sFlags.ScreenDumpDir } // IsHeadless returns headless setting. func (k *K9s) IsHeadless() bool { if IsBoolSet(k.UI.manualHeadless) { return true } return k.UI.Headless } // IsLogoless returns logoless setting. func (k *K9s) IsLogoless() bool { if IsBoolSet(k.UI.manualLogoless) { return true } return k.UI.Logoless } // IsCrumbsless returns crumbsless setting. func (k *K9s) IsCrumbsless() bool { if IsBoolSet(k.UI.manualCrumbsless) { return true } return k.UI.Crumbsless } // IsSplashless returns splashless setting. func (k *K9s) IsSplashless() bool { if IsBoolSet(k.UI.manualSplashless) { return true } return k.UI.Splashless } // IsInvert returns invert setting. func (k *K9s) IsInvert() bool { if IsBoolSet(k.UI.manualInvert) { return true } return k.UI.Invert } // GetRefreshRate returns the current refresh rate. func (k *K9s) GetRefreshRate() float32 { k.mx.Lock() defer k.mx.Unlock() rate := k.RefreshRate if k.manualRefreshRate != 0 { rate = k.manualRefreshRate } if rate < DefaultRefreshRate { if !k.refreshRateWarned { slog.Warn("Refresh rate is below minimum, capping to minimum value", slogs.Requested, float64(rate), slogs.Minimum, float64(DefaultRefreshRate)) k.refreshRateWarned = true } return DefaultRefreshRate } return rate } // RefreshDuration returns the refresh rate as a time.Duration. func (k *K9s) RefreshDuration() time.Duration { return time.Duration(k.GetRefreshRate() * float32(time.Second)) } // IsReadOnly returns the readonly setting. func (k *K9s) IsReadOnly() bool { ro := k.ReadOnly if cfg := k.getActiveConfig(); cfg != nil && cfg.Context.ReadOnly != nil { ro = *cfg.Context.ReadOnly } if k.manualReadOnly != nil { ro = *k.manualReadOnly } return ro } // Validate the current configuration. func (k *K9s) Validate(c client.Connection, contextName, clusterName string) { if k.RefreshRate <= 0 { k.RefreshRate = defaultRefreshRate } if k.MaxConnRetry <= 0 { k.MaxConnRetry = defaultMaxConnRetry } if a := os.Getenv(envPFAddress); a != "" { k.PortForwardAddress = a } if k.PortForwardAddress == "" { k.PortForwardAddress = defaultPFAddress() } if k.getActiveConfig() == nil { _, _ = k.ActivateContext(contextName) } if k.ShellPod != nil { k.ShellPod.Validate() } k.Logger = k.Logger.Validate() k.Thresholds = k.Thresholds.Validate() if cfg := k.getActiveConfig(); cfg != nil { cfg.Validate(c, contextName, clusterName) } } ================================================ FILE: internal/config/k9s_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_k9sOverrides(t *testing.T) { var ( trueVal = true cmd = "po" dir = "/tmp/blee" ) uu := map[string]struct { k *K9s rate float32 ro, hl, cl, sl, ll bool }{ "plain": { k: &K9s{ LiveViewAutoRefresh: false, ScreenDumpDir: "", RefreshRate: 10.0, MaxConnRetry: 0, ReadOnly: false, NoExitOnCtrlC: false, UI: UI{}, SkipLatestRevCheck: false, DisablePodCounting: false, }, rate: 10.0, }, "sub-second": { k: &K9s{ LiveViewAutoRefresh: false, ScreenDumpDir: "", RefreshRate: 0.5, MaxConnRetry: 0, ReadOnly: false, NoExitOnCtrlC: false, UI: UI{}, SkipLatestRevCheck: false, DisablePodCounting: false, }, rate: 2.0, // minimum enforced }, "set": { k: &K9s{ LiveViewAutoRefresh: false, ScreenDumpDir: "", RefreshRate: 10.0, MaxConnRetry: 0, ReadOnly: true, NoExitOnCtrlC: false, UI: UI{ Headless: true, Logoless: true, Crumbsless: true, Splashless: true, }, SkipLatestRevCheck: false, DisablePodCounting: false, }, rate: 10.0, ro: true, hl: true, ll: true, cl: true, sl: true, }, "overrides": { k: &K9s{ LiveViewAutoRefresh: false, ScreenDumpDir: "", RefreshRate: 10.0, MaxConnRetry: 0, ReadOnly: false, NoExitOnCtrlC: false, UI: UI{ Headless: false, Logoless: false, Crumbsless: false, manualHeadless: &trueVal, manualLogoless: &trueVal, manualCrumbsless: &trueVal, manualSplashless: &trueVal, }, SkipLatestRevCheck: false, DisablePodCounting: false, manualRefreshRate: 100.0, manualReadOnly: &trueVal, manualCommand: &cmd, manualScreenDumpDir: &dir, }, rate: 100.0, ro: true, hl: true, ll: true, cl: true, sl: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.InDelta(t, u.rate, u.k.GetRefreshRate(), 0.001) assert.Equal(t, u.ro, u.k.IsReadOnly()) assert.Equal(t, u.cl, u.k.IsCrumbsless()) assert.Equal(t, u.sl, u.k.IsSplashless()) assert.Equal(t, u.hl, u.k.IsHeadless()) assert.Equal(t, u.ll, u.k.IsLogoless()) }) } } func Test_screenDumpDirOverride(t *testing.T) { uu := map[string]struct { dir string e string }{ "empty": { e: "/tmp/k9s-test/screen-dumps", }, "override": { dir: "/tmp/k9s-test/sd", e: "/tmp/k9s-test/sd", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { cfg := NewConfig(nil) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) cfg.K9s.manualScreenDumpDir = &u.dir assert.Equal(t, u.e, cfg.K9s.AppScreenDumpDir()) }) } } ================================================ FILE: internal/config/k9s_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "errors" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" ) func TestK9sReload(t *testing.T) { config.AppConfigDir = "/tmp/k9s-test" cl, ct := "cl-1", "ct-1-1" uu := map[string]struct { k *config.K9s cl, ct string err error }{ "no-context": { k: config.NewK9s( mock.NewMockConnection(), mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, }), ), err: errors.New(`no context found for: ""`), }, "set-context": { k: config.NewK9s( mock.NewMockConnection(), mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, }), ), ct: "ct-1-1", cl: "cl-1", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { _, _ = u.k.ActivateContext(u.ct) assert.Equal(t, u.err, u.k.Reload()) ct, err := u.k.ActiveContext() assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.cl, ct.ClusterName) } }) } } func TestK9sMerge(t *testing.T) { cl, ct := "cl-1", "ct-1-1" uu := map[string]struct { k1, k2 *config.K9s ek *config.K9s }{ "no-opt": { k1: config.NewK9s( mock.NewMockConnection(), mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, }), ), ek: config.NewK9s( mock.NewMockConnection(), mock.NewMockKubeSettings(&genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, }), ), }, "override": { k1: &config.K9s{ LiveViewAutoRefresh: false, ScreenDumpDir: "", RefreshRate: 0, MaxConnRetry: 0, ReadOnly: false, NoExitOnCtrlC: false, UI: config.UI{}, SkipLatestRevCheck: false, DisablePodCounting: false, ShellPod: new(config.ShellPod), ImageScans: config.ImageScans{}, Logger: config.Logger{}, Thresholds: nil, }, k2: &config.K9s{ LiveViewAutoRefresh: true, MaxConnRetry: 100, ShellPod: config.NewShellPod(), }, ek: &config.K9s{ LiveViewAutoRefresh: true, ScreenDumpDir: "", RefreshRate: 0, MaxConnRetry: 100, ReadOnly: false, NoExitOnCtrlC: false, UI: config.UI{}, SkipLatestRevCheck: false, DisablePodCounting: false, ShellPod: config.NewShellPod(), ImageScans: config.ImageScans{}, Logger: config.Logger{}, Thresholds: nil, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.k1.Merge(u.k2) assert.Equal(t, u.ek, u.k1) }) } } func TestContextScreenDumpDir(t *testing.T) { cfg := mock.NewMockConfig(t) _, err := cfg.K9s.ActivateContext("ct-1-1") require.NoError(t, err) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) assert.Equal(t, "/tmp/k9s-test/screen-dumps/cl-1/ct-1-1", cfg.K9s.ContextScreenDumpDir()) } func TestAppScreenDumpDir(t *testing.T) { cfg := mock.NewMockConfig(t) require.NoError(t, cfg.Load("testdata/configs/k9s.yaml", true)) assert.Equal(t, "/tmp/k9s-test/screen-dumps", cfg.K9s.AppScreenDumpDir()) } ================================================ FILE: internal/config/logger.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config const ( // DefaultLoggerTailCount tracks default log tail size. DefaultLoggerTailCount = 100 // MaxLogThreshold sets the max value for log size. MaxLogThreshold = 5_000 // DefaultSinceSeconds tracks default log age. DefaultSinceSeconds = -1 // tail logs by default ) // Logger tracks logger options. type Logger struct { TailCount int64 `json:"tail" yaml:"tail"` BufferSize int `json:"buffer" yaml:"buffer"` SinceSeconds int64 `json:"sinceSeconds" yaml:"sinceSeconds"` TextWrap bool `json:"textWrap" yaml:"textWrap"` DisableAutoscroll bool `json:"disableAutoscroll" yaml:"disableAutoscroll"` ColumnLock bool `json:"columnLock" yaml:"columnLock"` ShowTime bool `json:"showTime" yaml:"showTime"` } // NewLogger returns a new instance. func NewLogger() Logger { return Logger{ TailCount: DefaultLoggerTailCount, BufferSize: MaxLogThreshold, SinceSeconds: DefaultSinceSeconds, } } // Validate checks thresholds and make sure we're cool. If not use defaults. func (l Logger) Validate() Logger { if l.TailCount <= 0 { l.TailCount = DefaultLoggerTailCount } if l.TailCount > MaxLogThreshold { l.TailCount = MaxLogThreshold } if l.BufferSize <= 0 || l.BufferSize > MaxLogThreshold { l.BufferSize = MaxLogThreshold } if l.SinceSeconds == 0 { l.SinceSeconds = DefaultSinceSeconds } return l } ================================================ FILE: internal/config/logger_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func TestNewLogger(t *testing.T) { l := config.NewLogger() l = l.Validate() assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, 5000, l.BufferSize) } func TestLoggerValidate(t *testing.T) { var l config.Logger l = l.Validate() assert.Equal(t, int64(100), l.TailCount) assert.Equal(t, 5000, l.BufferSize) } ================================================ FILE: internal/config/mock/test_helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package mock import ( "errors" "fmt" "io/fs" "net/http" "net/url" "os" "strings" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/require" version "k8s.io/apimachinery/pkg/version" "k8s.io/cli-runtime/pkg/genericclioptions" disk "k8s.io/client-go/discovery/cached/disk" dynamic "k8s.io/client-go/dynamic" kubernetes "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd/api" versioned "k8s.io/metrics/pkg/client/clientset/versioned" ) func EnsureDir(d string) error { if _, err := os.Stat(d); errors.Is(err, fs.ErrNotExist) { return os.MkdirAll(d, 0700) } if err := os.RemoveAll(d); err != nil { return err } return os.MkdirAll(d, 0700) } func NewMockConfig(t testing.TB) *config.Config { if _, err := os.Stat("/tmp/test"); err == nil { if e := os.RemoveAll("/tmp/test"); e != nil { require.NoError(t, e) } } config.AppContextsDir = "/tmp/test" cl, ct := "cl-1", "ct-1-1" flags := genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, } cfg := config.NewConfig( NewMockKubeSettings(&flags), ) return cfg } type mockKubeSettings struct { flags *genericclioptions.ConfigFlags cts map[string]*api.Context } func NewMockKubeSettings(f *genericclioptions.ConfigFlags) mockKubeSettings { _, idx, _ := strings.Cut(*f.ClusterName, "-") ctId := "ct-" + idx return mockKubeSettings{ flags: f, cts: map[string]*api.Context{ ctId + "-1": { Cluster: *f.ClusterName, Namespace: "", }, ctId + "-2": { Cluster: *f.ClusterName, Namespace: "ns-2", }, ctId + "-3": { Cluster: *f.ClusterName, Namespace: client.DefaultNamespace, }, "fred-blee": { Cluster: "arn:aws:eks:eu-central-1:xxx:cluster/fred-blee", Namespace: client.DefaultNamespace, }, }, } } func (m mockKubeSettings) CurrentContextName() (string, error) { return *m.flags.Context, nil } func (m mockKubeSettings) CurrentClusterName() (string, error) { return *m.flags.ClusterName, nil } func (mockKubeSettings) CurrentNamespaceName() (string, error) { return "default", nil } func (m mockKubeSettings) GetContext(s string) (*api.Context, error) { ct, ok := m.cts[s] if !ok { return nil, fmt.Errorf("no context found for: %q", s) } return ct, nil } func (m mockKubeSettings) CurrentContext() (*api.Context, error) { return m.GetContext(*m.flags.Context) } func (m mockKubeSettings) ContextNames() (map[string]struct{}, error) { mm := make(map[string]struct{}, len(m.cts)) for k := range m.cts { mm[k] = struct{}{} } return mm, nil } func (mockKubeSettings) SetProxy(func(*http.Request) (*url.URL, error)) {} type mockConnection struct { ct string } func NewMockConnection() mockConnection { return mockConnection{} } func NewMockConnectionWithContext(ct string) mockConnection { return mockConnection{ct: ct} } func (mockConnection) CanI(string, *client.GVR, string, []string) (bool, error) { return true, nil } func (mockConnection) Config() *client.Config { return nil } func (mockConnection) ConnectionOK() bool { return false } func (mockConnection) Dial() (kubernetes.Interface, error) { return nil, nil } func (mockConnection) DialLogs() (kubernetes.Interface, error) { return nil, nil } func (mockConnection) SwitchContext(string) error { return nil } func (mockConnection) CachedDiscovery() (*disk.CachedDiscoveryClient, error) { return nil, nil } func (mockConnection) RestConfig() (*restclient.Config, error) { return nil, nil } func (mockConnection) MXDial() (*versioned.Clientset, error) { return nil, nil } func (mockConnection) DynDial() (dynamic.Interface, error) { return nil, nil } func (mockConnection) HasMetrics() bool { return false } func (mockConnection) ValidNamespaceNames() (client.NamespaceNames, error) { return nil, nil } func (mockConnection) IsValidNamespace(string) bool { return true } func (mockConnection) ServerVersion() (*version.Info, error) { return nil, nil } func (mockConnection) CheckConnectivity() bool { return false } func (m mockConnection) ActiveContext() string { return m.ct } func (mockConnection) ActiveNamespace() string { return "" } func (mockConnection) IsActiveNamespace(string) bool { return false } ================================================ FILE: internal/config/plugin.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "bytes" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "slices" "strconv" "strings" "github.com/adrg/xdg" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "github.com/karrick/godirwalk" "gopkg.in/yaml.v3" ) type plugins map[string]Plugin // Plugins represents a collection of plugins. type Plugins struct { Plugins plugins `yaml:"plugins"` } // PluginInputType represents the type of input field. type PluginInputType string const ( InputTypeString PluginInputType = "string" InputTypeNumber PluginInputType = "number" InputTypeBool PluginInputType = "bool" InputTypeDropdown PluginInputType = "dropdown" ) // PluginInput describes an input field for a plugin. type PluginInput struct { Name string `yaml:"name"` Label string `yaml:"label"` Type PluginInputType `yaml:"type"` Required bool `yaml:"required"` Default string `yaml:"default"` Options []string `yaml:"options"` } // Plugin describes a K9s plugin. type Plugin struct { Scopes []string `yaml:"scopes"` Args []string `yaml:"args"` ShortCut string `yaml:"shortCut"` Override bool `yaml:"override"` Pipes []string `yaml:"pipes"` Description string `yaml:"description"` Command string `yaml:"command"` Confirm *bool `yaml:"confirm"` Background bool `yaml:"background"` Dangerous bool `yaml:"dangerous"` OverwriteOutput bool `yaml:"overwriteOutput"` Inputs []PluginInput `yaml:"inputs"` } func (p Plugin) String() string { return fmt.Sprintf("[%s] %s(%s)", p.ShortCut, p.Command, strings.Join(p.Args, " ")) } // ShouldConfirm returns whether the plugin should show a confirmation dialog. // Defaults to true when inputs are defined, false otherwise. func (p *Plugin) ShouldConfirm() bool { if p.Confirm != nil { return *p.Confirm } return len(p.Inputs) > 0 } // Validate checks the plugin configuration for errors. func (p *Plugin) Validate() error { seen := make(map[string]struct{}, len(p.Inputs)) for _, input := range p.Inputs { if _, ok := seen[input.Name]; ok { return fmt.Errorf("duplicate input name %q", input.Name) } seen[input.Name] = struct{}{} if input.Default == "" { continue } switch input.Type { case InputTypeDropdown: if !slices.Contains(input.Options, input.Default) { return fmt.Errorf("default value %q for input %q is not a valid option", input.Default, input.Name) } case InputTypeBool: if input.Default != "true" && input.Default != "false" { return fmt.Errorf("default value %q for bool input %q must be \"true\" or \"false\"", input.Default, input.Name) } case InputTypeNumber: if _, err := strconv.ParseFloat(input.Default, 64); err != nil { return fmt.Errorf("default value %q for number input %q is not a valid number", input.Default, input.Name) } } } return nil } // NewPlugins returns a new plugin. func NewPlugins() Plugins { return Plugins{ Plugins: make(map[string]Plugin), } } // Load K9s plugins. func (p Plugins) Load(path string, loadExtra bool) error { var errs error // Load from global config file if err := p.load(AppPluginsFile); err != nil { errs = errors.Join(errs, err) } // Load from cluster/context config if err := p.load(path); err != nil { errs = errors.Join(errs, err) } if !loadExtra { return errs } // Load from XDG dirs const k9sPluginsDir = "k9s/plugins" for _, dir := range append(xdg.DataDirs, xdg.DataHome, xdg.ConfigHome) { path := filepath.Join(dir, k9sPluginsDir) if err := p.loadDir(path); err != nil { errs = errors.Join(errs, err) } } return errs } func (p *Plugins) load(path string) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil } bb, err := os.ReadFile(path) if err != nil { return err } scheme, err := data.JSONValidator.ValidatePlugins(bb) if err != nil { slog.Warn("Plugin schema validation failed", slogs.Path, path, slogs.Error, err, ) return fmt.Errorf("plugin validation failed for %s: %w", path, err) } d := yaml.NewDecoder(bytes.NewReader(bb)) d.KnownFields(true) switch scheme { case json.PluginSchema: var o Plugin if err := yaml.Unmarshal(bb, &o); err != nil { return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err) } if err := o.Validate(); err != nil { return fmt.Errorf("plugin validation failed for %s: %w", path, err) } p.Plugins[strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))] = o case json.PluginsSchema: var oo Plugins if err := yaml.Unmarshal(bb, &oo); err != nil { return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err) } for k := range oo.Plugins { plug := oo.Plugins[k] if err := plug.Validate(); err != nil { return fmt.Errorf("plugin %q validation failed for %s: %w", k, path, err) } p.Plugins[k] = plug } case json.PluginMultiSchema: var oo plugins if err := yaml.Unmarshal(bb, &oo); err != nil { return fmt.Errorf("plugin unmarshal failed for %s: %w", path, err) } for k := range oo { plug := oo[k] if err := plug.Validate(); err != nil { return fmt.Errorf("plugin %q validation failed for %s: %w", k, path, err) } p.Plugins[k] = plug } } return nil } func (p Plugins) loadDir(dir string) error { if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { return nil } var errs error errs = errors.Join(errs, godirwalk.Walk(dir, &godirwalk.Options{ FollowSymbolicLinks: true, Callback: func(path string, de *godirwalk.Dirent) error { if de.IsDir() || !isYamlFile(de.Name()) { return nil } errs = errors.Join(errs, p.load(path)) return nil }, ErrorCallback: func(osPathname string, err error) godirwalk.ErrorAction { slog.Warn("Error at %s: %v - skipping node", slogs.Path, osPathname, slogs.Error, err) return godirwalk.SkipNode }, })) return errs } ================================================ FILE: internal/config/plugin_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPluginLoad(t *testing.T) { uu := map[string]struct { path string err string ee Plugins }{ "snippet": { path: "testdata/plugins/dir/snippet.1.yaml", ee: Plugins{ Plugins: plugins{ "snippet.1": Plugin{ Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, ShortCut: "shift-s", Description: "blee", Command: "duh", Confirm: boolPtr(true), OverwriteOutput: true, }, }, }, }, "multi-snippets": { path: "testdata/plugins/dir/snippet.multi.yaml", ee: Plugins{ Plugins: plugins{ "crapola": Plugin{ ShortCut: "Shift-1", Command: "crapola", Description: "crapola", Scopes: []string{"pods"}, }, "bozo": Plugin{ ShortCut: "Shift-2", Description: "bozo", Command: "bozo", Scopes: []string{"pods", "svc"}, }, }, }, }, "full": { path: "testdata/plugins/plugins.yaml", ee: Plugins{ Plugins: plugins{ "blah": Plugin{ Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, ShortCut: "shift-s", Description: "blee", Command: "duh", Confirm: boolPtr(true), }, }, }, }, "toast-no-file": { path: "testdata/plugins/plugins-bozo.yaml", ee: NewPlugins(), }, "toast-invalid": { path: "testdata/plugins/plugins-toast.yaml", ee: NewPlugins(), err: "plugin validation failed for testdata/plugins/plugins-toast.yaml: scopes is required\nAdditional property plugins is not allowed\ncommand is required\ndescription is required\nscopes is required\nshortCut is required\ncommand is required\ndescription is required\nscopes is required\nshortCut is required", }, } for k, u := range uu { t.Run(k, func(t *testing.T) { p := NewPlugins() err := p.Load(u.path, false) if err != nil { assert.Equal(t, u.err, err.Error()) } assert.Equal(t, u.ee, p) }) } } func TestSinglePluginFileLoad(t *testing.T) { e := Plugin{ Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, ShortCut: "shift-s", Description: "blee", Command: "duh", Confirm: boolPtr(true), } p := NewPlugins() require.NoError(t, p.load("testdata/plugins/plugins.yaml")) require.NoError(t, p.loadDir("/random/dir/not/exist")) assert.Len(t, p.Plugins, 1) v, ok := p.Plugins["blah"] assert.True(t, ok) assert.ObjectsAreEqual(e, v) } func TestMultiplePluginFilesLoad(t *testing.T) { uu := map[string]struct { path string dir string ee Plugins }{ "empty": { path: "testdata/plugins/plugins.yaml", dir: "testdata/plugins/dir", ee: Plugins{ Plugins: plugins{ "blah": { Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, ShortCut: "shift-s", Description: "blee", Command: "duh", Confirm: boolPtr(true), }, "snippet.1": { ShortCut: "shift-s", Command: "duh", Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, Description: "blee", Confirm: boolPtr(true), OverwriteOutput: true, }, "snippet.2": { Scopes: []string{"svc", "ing"}, Args: []string{"-n", "$NAMESPACE", "-oyaml"}, ShortCut: "shift-r", Description: "bla", Command: "duha", Confirm: boolPtr(false), Background: true, }, "crapola": { Scopes: []string{"pods"}, Command: "crapola", Description: "crapola", ShortCut: "Shift-1", }, "bozo": { Scopes: []string{"pods", "svc"}, Command: "bozo", Description: "bozo", ShortCut: "Shift-2", }, }, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { p := NewPlugins() require.NoError(t, p.load(u.path)) require.NoError(t, p.loadDir(u.dir)) assert.Equal(t, u.ee, p) }) } } func TestPluginLoadSymlink(t *testing.T) { tmp := t.TempDir() linkFile := filepath.Join(tmp, "plugins-symlink.yaml") wd, err := os.Getwd() require.NoError(t, err) require.NoError(t, os.Symlink(filepath.Join(wd, "testdata", "plugins", "plugins.yaml"), linkFile)) linkDir := filepath.Join(tmp, "plugins-dir-symlink") require.NoError(t, os.Symlink(filepath.Join(wd, "testdata", "plugins", "dir"), linkDir)) // Add a symlink with an infinite loop loopDir := filepath.Join(tmp, "loop") require.NoError(t, os.Mkdir(loopDir, 0o755)) require.NoError(t, os.Symlink(loopDir, filepath.Join(loopDir, "self"))) p := NewPlugins() require.NoError(t, p.loadDir(tmp)) ee := Plugins{ Plugins: plugins{ "blah": Plugin{ Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, ShortCut: "shift-s", Description: "blee", Command: "duh", Confirm: boolPtr(true), }, "snippet.1": { ShortCut: "shift-s", Command: "duh", Scopes: []string{"po", "dp"}, Args: []string{"-n", "$NAMESPACE", "-boolean"}, Description: "blee", Confirm: boolPtr(true), OverwriteOutput: true, }, "snippet.2": { Scopes: []string{"svc", "ing"}, Args: []string{"-n", "$NAMESPACE", "-oyaml"}, ShortCut: "shift-r", Description: "bla", Command: "duha", Confirm: boolPtr(false), Background: true, }, "crapola": { Scopes: []string{"pods"}, Command: "crapola", Description: "crapola", ShortCut: "Shift-1", }, "bozo": { Scopes: []string{"pods", "svc"}, Command: "bozo", Description: "bozo", ShortCut: "Shift-2", }, }, } assert.Equal(t, ee, p) } ================================================ FILE: internal/config/refresh_rate_test.go ================================================ package config import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) func TestRefreshRateBackwardCompatibility(t *testing.T) { tests := map[string]struct { yamlContent string expected float32 }{ "integer_value": { yamlContent: `refreshRate: 2`, expected: 2.0, }, "float_value": { yamlContent: `refreshRate: 2.5`, expected: 2.5, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { var k K9s err := yaml.Unmarshal([]byte(test.yamlContent), &k) require.NoError(t, err) assert.InDelta(t, test.expected, k.RefreshRate, 0.001) }) } } func TestGetRefreshRateMinimum(t *testing.T) { tests := map[string]struct { refreshRate float32 manualRefreshRate float32 expected float32 }{ "below_minimum": { refreshRate: 0.5, expected: 2.0, }, "at_minimum": { refreshRate: 2.0, expected: 2.0, }, "above_minimum": { refreshRate: 3.5, expected: 3.5, }, "manual_below_minimum": { refreshRate: 3.0, manualRefreshRate: 0.5, expected: 2.0, }, "manual_above_minimum": { refreshRate: 2.0, manualRefreshRate: 4.0, expected: 4.0, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { k := K9s{ RefreshRate: test.refreshRate, manualRefreshRate: test.manualRefreshRate, } assert.InDelta(t, test.expected, k.GetRefreshRate(), 0.001) }) } } ================================================ FILE: internal/config/scans.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config // Labels tracks a collection of labels. type Labels map[string][]string func (l Labels) exclude(k, val string) bool { vv, ok := l[k] if !ok { return false } for _, v := range vv { if v == val { return true } } return false } // ScanExcludes tracks vul scan exclusions. type ScanExcludes struct { Namespaces []string `json:"namespaces" yaml:"namespaces"` Labels Labels `json:"labels" yaml:"labels"` } func newScanExcludes() ScanExcludes { return ScanExcludes{ Labels: make(Labels), } } func (b ScanExcludes) exclude(ns string, ll map[string]string) bool { for _, nss := range b.Namespaces { if nss == ns { return true } } for k, v := range ll { if b.Labels.exclude(k, v) { return true } } return false } // ImageScans tracks vul scans options. type ImageScans struct { Enable bool `json:"enable" yaml:"enable"` Exclusions ScanExcludes `json:"exclusions" yaml:"exclusions"` } // NewImageScans returns a new instance. func NewImageScans() ImageScans { return ImageScans{ Exclusions: newScanExcludes(), } } // ShouldExclude checks if scan should be excluded given ns/labels func (i ImageScans) ShouldExclude(ns string, ll map[string]string) bool { if !i.Enable { return false } return i.Exclusions.exclude(ns, ll) } ================================================ FILE: internal/config/scans_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func TestScansShouldExclude(t *testing.T) { uu := map[string]struct { sc config.ImageScans ns string ll map[string]string e bool }{ "empty": { sc: config.NewImageScans(), }, "exclude-ns": { sc: config.ImageScans{ Enable: true, Exclusions: config.ScanExcludes{ Namespaces: []string{"ns-1", "ns-2", "ns-3"}, Labels: config.Labels{ "app": []string{"fred", "blee"}, }, }, }, ns: "ns-1", ll: map[string]string{ "app": "freddy", }, e: true, }, "include-ns": { sc: config.ImageScans{ Enable: true, Exclusions: config.ScanExcludes{ Namespaces: []string{"ns-1", "ns-2", "ns-3"}, Labels: config.Labels{ "app": []string{"fred", "blee"}, }, }, }, ns: "ns-4", ll: map[string]string{ "app": "bozo", }, }, "exclude-labels": { sc: config.ImageScans{ Enable: true, Exclusions: config.ScanExcludes{ Namespaces: []string{"ns-1", "ns-2", "ns-3"}, Labels: config.Labels{ "app": []string{"fred", "blee"}, }, }, }, ns: "ns-4", ll: map[string]string{ "app": "fred", }, e: true, }, "include-labels": { sc: config.ImageScans{ Enable: true, Exclusions: config.ScanExcludes{ Namespaces: []string{"ns-1", "ns-2", "ns-3"}, Labels: config.Labels{ "app": []string{"fred", "blee"}, }, }, }, ns: "ns-4", ll: map[string]string{ "app": "freddy", }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.sc.ShouldExclude(u.ns, u.ll)) }) } } ================================================ FILE: internal/config/shell_pod.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( v1 "k8s.io/api/core/v1" ) const defaultDockerShellImage = "busybox:1.37.0" // Limits represents resource limits. type Limits map[v1.ResourceName]string // ShellPod represents k9s shell configuration. type ShellPod struct { Image string `json:"image" yaml:"image"` Command []string `json:"command,omitempty" yaml:"command,omitempty"` Args []string `json:"args,omitempty" yaml:"args,omitempty"` Namespace string `json:"namespace" yaml:"namespace"` Limits Limits `json:"limits,omitempty" yaml:"limits,omitempty"` Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` ImagePullSecrets []v1.LocalObjectReference `json:"imagePullSecrets,omitempty" yaml:"imagePullSecrets,omitempty"` ImagePullPolicy v1.PullPolicy `json:"imagePullPolicy,omitempty" yaml:"imagePullPolicy,omitempty"` TTY bool `json:"tty,omitempty" yaml:"tty,omitempty"` HostPathVolume []hostPathVolume `json:"hostPathVolume,omitempty" yaml:"hostPathVolume,omitempty"` } type hostPathVolume struct { Name string `json:"name" yaml:"name"` MountPath string `json:"mountPath" yaml:"mountPath"` HostPath string `json:"hostPath" yaml:"hostPath"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` } // NewShellPod returns a new instance. func NewShellPod() *ShellPod { return &ShellPod{ Image: defaultDockerShellImage, Namespace: "default", Limits: defaultLimits(), } } // Validate validates the configuration. func (s *ShellPod) Validate() { if s.Image == "" { s.Image = defaultDockerShellImage } if len(s.Limits) == 0 { s.Limits = defaultLimits() } } func defaultLimits() Limits { return Limits{ v1.ResourceCPU: "100m", v1.ResourceMemory: "100Mi", } } ================================================ FILE: internal/config/styles.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "fmt" "os" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "gopkg.in/yaml.v3" ) // StyleListener represents a skin's listener. type StyleListener interface { // StylesChanged notifies listener the skin changed. StylesChanged(*Styles) } // TextStyle tracks text styles. type TextStyle string const ( // TextStyleNormal is the default text style. TextStyleNormal TextStyle = "normal" // TextStyleBold is the bold text style. TextStyleBold TextStyle = "bold" // TextStyleDim is the dim text style. TextStyleDim TextStyle = "dim" ) // ToShortString returns a short string representation of the text style. func (ts TextStyle) ToShortString() string { switch ts { case TextStyleNormal: return "-" case TextStyleBold: return "b" case TextStyleDim: return "d" default: return "d" } } type ( // Styles tracks K9s styling options. Styles struct { K9s Style `json:"k9s" yaml:"k9s"` listeners []StyleListener } // Style tracks K9s styles. Style struct { Body Body `json:"body" yaml:"body"` Prompt Prompt `json:"prompt" yaml:"prompt"` Help Help `json:"help" yaml:"help"` Frame Frame `json:"frame" yaml:"frame"` Info Info `json:"info" yaml:"info"` Views Views `json:"views" yaml:"views"` Dialog Dialog `json:"dialog" yaml:"dialog"` } // Prompt tracks command styles Prompt struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` SuggestColor Color `json:"" yaml:"suggestColor"` Border PromptBorder `json:"" yaml:"border"` } // PromptBorder tracks the color of the prompt depending on its kind (e.g., command or filter) PromptBorder struct { CommandColor Color `json:"command" yaml:"command"` DefaultColor Color `json:"default" yaml:"default"` } // Help tracks help styles. Help struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` SectionColor Color `json:"sectionColor" yaml:"sectionColor"` KeyColor Color `json:"keyColor" yaml:"keyColor"` NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"` } // Body tracks body styles. Body struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` LogoColor Color `json:"logoColor" yaml:"logoColor"` LogoColorMsg Color `json:"logoColorMsg" yaml:"logoColorMsg"` LogoColorInfo Color `json:"logoColorInfo" yaml:"logoColorInfo"` LogoColorWarn Color `json:"logoColorWarn" yaml:"logoColorWarn"` LogoColorError Color `json:"logoColorError" yaml:"logoColorError"` } // Dialog tracks dialog styles. Dialog struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` ButtonFgColor Color `json:"buttonFgColor" yaml:"buttonFgColor"` ButtonBgColor Color `json:"buttonBgColor" yaml:"buttonBgColor"` ButtonFocusFgColor Color `json:"buttonFocusFgColor" yaml:"buttonFocusFgColor"` ButtonFocusBgColor Color `json:"buttonFocusBgColor" yaml:"buttonFocusBgColor"` LabelFgColor Color `json:"labelFgColor" yaml:"labelFgColor"` FieldFgColor Color `json:"fieldFgColor" yaml:"fieldFgColor"` } // Frame tracks frame styles. Frame struct { Title Title `json:"title" yaml:"title"` Border Border `json:"border" yaml:"border"` Menu Menu `json:"menu" yaml:"menu"` Crumb Crumb `json:"crumbs" yaml:"crumbs"` Status Status `json:"status" yaml:"status"` } // Views tracks individual view styles. Views struct { Table Table `json:"table" yaml:"table"` Xray Xray `json:"xray" yaml:"xray"` Charts Charts `json:"charts" yaml:"charts"` Yaml Yaml `json:"yaml" yaml:"yaml"` Picker Picker `json:"picker" yaml:"picker"` Log Log `json:"logs" yaml:"logs"` } // Status tracks resource status styles. Status struct { NewColor Color `json:"newColor" yaml:"newColor"` ModifyColor Color `json:"modifyColor" yaml:"modifyColor"` AddColor Color `json:"addColor" yaml:"addColor"` PendingColor Color `json:"pendingColor" yaml:"pendingColor"` ErrorColor Color `json:"errorColor" yaml:"errorColor"` HighlightColor Color `json:"highlightColor" yaml:"highlightColor"` KillColor Color `json:"killColor" yaml:"killColor"` CompletedColor Color `json:"completedColor" yaml:"completedColor"` } // Log tracks Log styles. Log struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` Indicator LogIndicator `json:"indicator" yaml:"indicator"` } // Picker tracks color when selecting containers Picker struct { MainColor Color `json:"mainColor" yaml:"mainColor"` FocusColor Color `json:"focusColor" yaml:"focusColor"` ShortcutColor Color `json:"shortcutColor" yaml:"shortcutColor"` } // LogIndicator tracks log view indicator. LogIndicator struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` ToggleOnColor Color `json:"toggleOnColor" yaml:"toggleOnColor"` ToggleOffColor Color `json:"toggleOffColor" yaml:"toggleOffColor"` } // Yaml tracks yaml styles. Yaml struct { KeyColor Color `json:"keyColor" yaml:"keyColor"` ValueColor Color `json:"valueColor" yaml:"valueColor"` ColonColor Color `json:"colonColor" yaml:"colonColor"` } // Title tracks title styles. Title struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` HighlightColor Color `json:"highlightColor" yaml:"highlightColor"` CounterColor Color `json:"counterColor" yaml:"counterColor"` FilterColor Color `json:"filterColor" yaml:"filterColor"` } // Info tracks info styles. Info struct { SectionColor Color `json:"sectionColor" yaml:"sectionColor"` FgColor Color `json:"fgColor" yaml:"fgColor"` CPUColor Color `json:"cpuColor" yaml:"cpuColor"` MEMColor Color `json:"memColor" yaml:"memColor"` K9sRevColor Color `json:"k9sRevColor" yaml:"k9sRevColor"` } // Border tracks border styles. Border struct { FgColor Color `json:"fgColor" yaml:"fgColor"` FocusColor Color `json:"focusColor" yaml:"focusColor"` } // Crumb tracks crumbs styles. Crumb struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` ActiveColor Color `json:"activeColor" yaml:"activeColor"` } // Table tracks table styles. Table struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` CursorFgColor Color `json:"cursorFgColor" yaml:"cursorFgColor"` CursorBgColor Color `json:"cursorBgColor" yaml:"cursorBgColor"` MarkColor Color `json:"markColor" yaml:"markColor"` Header TableHeader `json:"header" yaml:"header"` } // TableHeader tracks table header styles. TableHeader struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` SorterColor Color `json:"sorterColor" yaml:"sorterColor"` SelectedSortColumnColor Color `json:"selectedSortColumnColor" yaml:"selectedSortColumnColor"` } // Xray tracks xray styles. Xray struct { FgColor Color `json:"fgColor" yaml:"fgColor"` BgColor Color `json:"bgColor" yaml:"bgColor"` CursorColor Color `json:"cursorColor" yaml:"cursorColor"` CursorTextColor Color `json:"cursorTextColor" yaml:"cursorTextColor"` GraphicColor Color `json:"graphicColor" yaml:"graphicColor"` } // Menu tracks menu styles. Menu struct { FgColor Color `json:"fgColor" yaml:"fgColor"` FgStyle TextStyle `json:"fgStyle" yaml:"fgStyle"` KeyColor Color `json:"keyColor" yaml:"keyColor"` NumKeyColor Color `json:"numKeyColor" yaml:"numKeyColor"` } // Charts tracks charts styles. Charts struct { BgColor Color `json:"bgColor" yaml:"bgColor"` DialBgColor Color `json:"dialBgColor" yaml:"dialBgColor"` ChartBgColor Color `json:"chartBgColor" yaml:"chartBgColor"` DefaultDialColors Colors `json:"defaultDialColors" yaml:"defaultDialColors"` DefaultChartColors Colors `json:"defaultChartColors" yaml:"defaultChartColors"` ResourceColors map[string]Colors `json:"resourceColors" yaml:"resourceColors"` FocusFgColor Color `yaml:"focusFgColor"` FocusBgColor Color `yaml:"focusBgColor"` } ) func newStyle() Style { return Style{ Body: newBody(), Prompt: newPrompt(), Help: newHelp(), Frame: newFrame(), Info: newInfo(), Views: newViews(), Dialog: newDialog(), } } func newDialog() Dialog { return Dialog{ FgColor: "cadetblue", BgColor: "black", ButtonBgColor: "darkslateblue", ButtonFgColor: "black", ButtonFocusBgColor: "dodgerblue", ButtonFocusFgColor: "black", LabelFgColor: "white", FieldFgColor: "white", } } func newPrompt() Prompt { return Prompt{ FgColor: "cadetblue", BgColor: "black", SuggestColor: "dodgerblue", Border: PromptBorder{ DefaultColor: "seagreen", CommandColor: "aqua", }, } } func newCharts() Charts { return Charts{ BgColor: "black", DialBgColor: "black", ChartBgColor: "black", DefaultDialColors: Colors{Color("palegreen"), Color("orangered")}, DefaultChartColors: Colors{Color("palegreen"), Color("orangered")}, ResourceColors: map[string]Colors{ CPU: {Color("dodgerblue"), Color("darkslateblue")}, MEM: {Color("yellow"), Color("goldenrod")}, }, FocusFgColor: "white", FocusBgColor: "orange", } } func newViews() Views { return Views{ Table: newTable(), Xray: newXray(), Charts: newCharts(), Yaml: newYaml(), Picker: newPicker(), Log: newLog(), } } func newFrame() Frame { return Frame{ Title: newTitle(), Border: newBorder(), Menu: newMenu(), Crumb: newCrumb(), Status: newStatus(), } } func newHelp() Help { return Help{ FgColor: "cadetblue", BgColor: "black", SectionColor: "green", KeyColor: "dodgerblue", NumKeyColor: "fuchsia", } } func newBody() Body { return Body{ FgColor: "cadetblue", BgColor: "black", LogoColor: "orange", LogoColorMsg: "white", LogoColorInfo: "green", LogoColorWarn: "mediumvioletred", LogoColorError: "red", } } func newStatus() Status { return Status{ NewColor: "lightskyblue", ModifyColor: "greenyellow", AddColor: "dodgerblue", PendingColor: "darkorange", ErrorColor: "orangered", HighlightColor: "aqua", KillColor: "mediumpurple", CompletedColor: "lightslategray", } } func newPicker() Picker { return Picker{ MainColor: "white", FocusColor: "aqua", ShortcutColor: "aqua", } } func newLog() Log { return Log{ FgColor: "lightskyblue", BgColor: "black", Indicator: newLogIndicator(), } } func newLogIndicator() LogIndicator { return LogIndicator{ FgColor: "dodgerblue", BgColor: "black", ToggleOnColor: "limegreen", ToggleOffColor: "gray", } } func newYaml() Yaml { return Yaml{ KeyColor: "steelblue", ColonColor: "white", ValueColor: "papayawhip", } } func newTitle() Title { return Title{ FgColor: "aqua", BgColor: "black", HighlightColor: "fuchsia", CounterColor: "papayawhip", FilterColor: "seagreen", } } func newInfo() Info { return Info{ SectionColor: "white", FgColor: "orange", CPUColor: "lawngreen", MEMColor: "darkturquoise", K9sRevColor: "aqua", } } func newXray() Xray { return Xray{ FgColor: "aqua", BgColor: "black", CursorColor: "dodgerblue", CursorTextColor: "black", GraphicColor: "cadetblue", } } func newTable() Table { return Table{ FgColor: "aqua", BgColor: "black", CursorFgColor: "black", CursorBgColor: "aqua", MarkColor: "palegreen", Header: newTableHeader(), } } func newTableHeader() TableHeader { return TableHeader{ FgColor: "white", BgColor: "black", SorterColor: "aqua", SelectedSortColumnColor: "lightskyblue", } } func newCrumb() Crumb { return Crumb{ FgColor: "black", BgColor: "aqua", ActiveColor: "orange", } } func newBorder() Border { return Border{ FgColor: "dodgerblue", FocusColor: "lightskyblue", } } func newMenu() Menu { return Menu{ FgColor: "white", KeyColor: "dodgerblue", NumKeyColor: "fuchsia", } } // NewStyles creates a new default config. func NewStyles() *Styles { var s Styles if err := yaml.Unmarshal(stockSkinTpl, &s); err == nil { return &s } return &Styles{ K9s: newStyle(), } } // Reset resets styles. func (s *Styles) Reset(invert bool) { if err := yaml.Unmarshal(stockSkinTpl, s); err != nil { s.K9s = newStyle() } if invert { s.K9s.Invert() } } // FgColor returns the foreground color. func (s *Styles) FgColor() tcell.Color { return s.Body().FgColor.Color() } // BgColor returns the background color. func (s *Styles) BgColor() tcell.Color { return s.Body().BgColor.Color() } // AddListener registers a new listener. func (s *Styles) AddListener(l StyleListener) { s.listeners = append(s.listeners, l) } // RemoveListener removes a listener. func (s *Styles) RemoveListener(l StyleListener) { victim := -1 for i, lis := range s.listeners { if lis == l { victim = i break } } if victim == -1 { return } s.listeners = append(s.listeners[:victim], s.listeners[victim+1:]...) } func (s *Styles) fireStylesChanged() { for _, list := range s.listeners { list.StylesChanged(s) } } // Body returns body styles. func (s *Styles) Body() Body { return s.K9s.Body } // Prompt returns prompt styles. func (s *Styles) Prompt() Prompt { return s.K9s.Prompt } // Frame returns frame styles. func (s *Styles) Frame() Frame { return s.K9s.Frame } // Crumb returns crumb styles. func (s *Styles) Crumb() Crumb { return s.Frame().Crumb } // Title returns title styles. func (s *Styles) Title() Title { return s.Frame().Title } // Charts returns charts styles. func (s *Styles) Charts() Charts { return s.K9s.Views.Charts } // Dialog returns dialog styles. func (s *Styles) Dialog() Dialog { return s.K9s.Dialog } // Table returns table styles. func (s *Styles) Table() Table { return s.K9s.Views.Table } // Xray returns xray styles. func (s *Styles) Xray() Xray { return s.K9s.Views.Xray } // Views returns views styles. func (s *Styles) Views() Views { return s.K9s.Views } // Invert inverts all colors in the Style. func (s *Style) Invert() { s.Body.Invert() s.Prompt.Invert() s.Help.Invert() s.Frame.Invert() s.Info.Invert() s.Views.Invert() s.Dialog.Invert() } // Invert inverts all colors in Body. func (b *Body) Invert() { b.FgColor = b.FgColor.InvertColor() b.BgColor = b.BgColor.InvertColor() b.LogoColor = b.LogoColor.InvertColor() b.LogoColorMsg = b.LogoColorMsg.InvertColor() b.LogoColorInfo = b.LogoColorInfo.InvertColor() b.LogoColorWarn = b.LogoColorWarn.InvertColor() b.LogoColorError = b.LogoColorError.InvertColor() } // Invert inverts all colors in Prompt. func (p *Prompt) Invert() { p.FgColor = p.FgColor.InvertColor() p.BgColor = p.BgColor.InvertColor() p.SuggestColor = p.SuggestColor.InvertColor() p.Border.Invert() } // Invert inverts all colors in PromptBorder. func (p *PromptBorder) Invert() { p.CommandColor = p.CommandColor.InvertColor() p.DefaultColor = p.DefaultColor.InvertColor() } // Invert inverts all colors in Help. func (h *Help) Invert() { h.FgColor = h.FgColor.InvertColor() h.BgColor = h.BgColor.InvertColor() h.SectionColor = h.SectionColor.InvertColor() h.KeyColor = h.KeyColor.InvertColor() h.NumKeyColor = h.NumKeyColor.InvertColor() } // Invert inverts all colors in Dialog. func (d *Dialog) Invert() { d.FgColor = d.FgColor.InvertColor() d.BgColor = d.BgColor.InvertColor() d.ButtonFgColor = d.ButtonFgColor.InvertColor() d.ButtonBgColor = d.ButtonBgColor.InvertColor() d.ButtonFocusFgColor = d.ButtonFocusFgColor.InvertColor() d.ButtonFocusBgColor = d.ButtonFocusBgColor.InvertColor() d.LabelFgColor = d.LabelFgColor.InvertColor() d.FieldFgColor = d.FieldFgColor.InvertColor() } // Invert inverts all colors in Frame. func (f *Frame) Invert() { f.Title.Invert() f.Border.Invert() f.Menu.Invert() f.Crumb.Invert() f.Status.Invert() } // Invert inverts all colors in Title. func (t *Title) Invert() { t.FgColor = t.FgColor.InvertColor() t.BgColor = t.BgColor.InvertColor() t.HighlightColor = t.HighlightColor.InvertColor() t.CounterColor = t.CounterColor.InvertColor() t.FilterColor = t.FilterColor.InvertColor() } // Invert inverts all colors in Border. func (b *Border) Invert() { b.FgColor = b.FgColor.InvertColor() b.FocusColor = b.FocusColor.InvertColor() } // Invert inverts all colors in Menu. func (m *Menu) Invert() { m.FgColor = m.FgColor.InvertColor() m.KeyColor = m.KeyColor.InvertColor() m.NumKeyColor = m.NumKeyColor.InvertColor() } // Invert inverts all colors in Crumb. func (c *Crumb) Invert() { c.FgColor = c.FgColor.InvertColor() c.BgColor = c.BgColor.InvertColor() c.ActiveColor = c.ActiveColor.InvertColor() } // Invert inverts all colors in Status. func (s *Status) Invert() { s.NewColor = s.NewColor.InvertColor() s.ModifyColor = s.ModifyColor.InvertColor() s.AddColor = s.AddColor.InvertColor() s.PendingColor = s.PendingColor.InvertColor() s.ErrorColor = s.ErrorColor.InvertColor() s.HighlightColor = s.HighlightColor.InvertColor() s.KillColor = s.KillColor.InvertColor() s.CompletedColor = s.CompletedColor.InvertColor() } // Invert inverts all colors in Info. func (i *Info) Invert() { i.SectionColor = i.SectionColor.InvertColor() i.FgColor = i.FgColor.InvertColor() i.CPUColor = i.CPUColor.InvertColor() i.MEMColor = i.MEMColor.InvertColor() i.K9sRevColor = i.K9sRevColor.InvertColor() } // Invert inverts all colors in Views. func (v *Views) Invert() { v.Table.Invert() v.Xray.Invert() v.Charts.Invert() v.Yaml.Invert() v.Picker.Invert() v.Log.Invert() } // Invert inverts all colors in Table. func (t *Table) Invert() { t.FgColor = t.FgColor.InvertColor() t.BgColor = t.BgColor.InvertColor() t.CursorFgColor = t.CursorFgColor.InvertColor() t.CursorBgColor = t.CursorBgColor.InvertColor() t.MarkColor = t.MarkColor.InvertColor() t.Header.Invert() } // Invert inverts all colors in TableHeader. func (t *TableHeader) Invert() { t.FgColor = t.FgColor.InvertColor() t.BgColor = t.BgColor.InvertColor() t.SorterColor = t.SorterColor.InvertColor() t.SelectedSortColumnColor = t.SelectedSortColumnColor.InvertColor() } // Invert inverts all colors in Xray. func (x *Xray) Invert() { x.FgColor = x.FgColor.InvertColor() x.BgColor = x.BgColor.InvertColor() x.CursorColor = x.CursorColor.InvertColor() x.CursorTextColor = x.CursorTextColor.InvertColor() x.GraphicColor = x.GraphicColor.InvertColor() } // Invert inverts all colors in Charts. func (c *Charts) Invert() { c.BgColor = c.BgColor.InvertColor() c.DialBgColor = c.DialBgColor.InvertColor() c.ChartBgColor = c.ChartBgColor.InvertColor() c.FocusFgColor = c.FocusFgColor.InvertColor() c.FocusBgColor = c.FocusBgColor.InvertColor() c.DefaultDialColors = c.DefaultDialColors.Invert() c.DefaultChartColors = c.DefaultChartColors.Invert() for k, v := range c.ResourceColors { c.ResourceColors[k] = v.Invert() } } // Invert inverts all colors in Yaml. func (y *Yaml) Invert() { y.KeyColor = y.KeyColor.InvertColor() y.ValueColor = y.ValueColor.InvertColor() y.ColonColor = y.ColonColor.InvertColor() } // Invert inverts all colors in Picker. func (p *Picker) Invert() { p.MainColor = p.MainColor.InvertColor() p.FocusColor = p.FocusColor.InvertColor() p.ShortcutColor = p.ShortcutColor.InvertColor() } // Invert inverts all colors in Log. func (l *Log) Invert() { l.FgColor = l.FgColor.InvertColor() l.BgColor = l.BgColor.InvertColor() l.Indicator.Invert() } // Invert inverts all colors in LogIndicator. func (l *LogIndicator) Invert() { l.FgColor = l.FgColor.InvertColor() l.BgColor = l.BgColor.InvertColor() l.ToggleOnColor = l.ToggleOnColor.InvertColor() l.ToggleOffColor = l.ToggleOffColor.InvertColor() } // Load K9s configuration from file. func (s *Styles) Load(path string, invert bool) error { bb, err := os.ReadFile(path) if err != nil { return err } if err := data.JSONValidator.Validate(json.SkinSchema, bb); err != nil { return err } if err := yaml.Unmarshal(bb, s); err != nil { return err } if invert { s.K9s.Invert() } return nil } // Update apply terminal colors based on styles. func (s *Styles) Update() { tview.Styles.PrimitiveBackgroundColor = s.BgColor() tview.Styles.ContrastBackgroundColor = s.BgColor() tview.Styles.MoreContrastBackgroundColor = s.BgColor() tview.Styles.PrimaryTextColor = s.FgColor() tview.Styles.BorderColor = s.K9s.Frame.Border.FgColor.Color() tview.Styles.FocusColor = s.K9s.Frame.Border.FocusColor.Color() tview.Styles.TitleColor = s.FgColor() tview.Styles.GraphicsColor = s.FgColor() tview.Styles.SecondaryTextColor = s.FgColor() tview.Styles.TertiaryTextColor = s.FgColor() tview.Styles.InverseTextColor = s.FgColor() tview.Styles.ContrastSecondaryTextColor = s.FgColor() s.fireStylesChanged() } // Dump for debug. func (s *Styles) Dump() { bb, _ := yaml.Marshal(s) fmt.Println(string(bb)) } ================================================ FILE: internal/config/styles_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "testing" "github.com/stretchr/testify/assert" ) func Test_newStyle(t *testing.T) { s := newStyle() assert.Equal(t, Color("black"), s.Body.BgColor) assert.Equal(t, Color("cadetblue"), s.Body.FgColor) assert.Equal(t, Color("lightskyblue"), s.Frame.Status.NewColor) } ================================================ FILE: internal/config/styles_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewStyle(t *testing.T) { s := config.NewStyles() assert.Equal(t, config.Color("black"), s.K9s.Body.BgColor) assert.Equal(t, config.Color("cadetblue"), s.K9s.Body.FgColor) assert.Equal(t, config.Color("lightskyblue"), s.K9s.Frame.Status.NewColor) } func TestColor(t *testing.T) { uu := map[string]tcell.Color{ "blah": tcell.ColorDefault, "blue": tcell.ColorBlue.TrueColor(), "#ffffff": tcell.NewHexColor(16777215), "#ff0000": tcell.NewHexColor(16711680), } for k := range uu { c, u := k, uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u, config.NewColor(c).Color()) }) } } func TestSkinHappy(t *testing.T) { s := config.NewStyles() require.NoError(t, s.Load("../../skins/black-and-wtf.yaml", false)) s.Update() assert.Equal(t, "#ffffff", s.Body().FgColor.String()) assert.Equal(t, "#000000", s.Body().BgColor.String()) assert.Equal(t, "#000000", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorWhite.TrueColor(), s.FgColor()) assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor()) assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor) } func TestSkinLoad(t *testing.T) { uu := map[string]struct { f string err string }{ "not-exist": { f: "testdata/skins/blee.yaml", err: "open testdata/skins/blee.yaml: no such file or directory", }, "toast": { f: "testdata/skins/boarked.yaml", err: `Additional property bgColor is not allowed Additional property fgColor is not allowed Additional property logoColor is not allowed Invalid type. Expected: object, given: array`, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := config.NewStyles() err := s.Load(u.f, false) if err != nil { assert.Equal(t, u.err, err.Error()) } assert.Equal(t, "#5f9ea0", s.Body().FgColor.String()) assert.Equal(t, "#000000", s.Body().BgColor.String()) assert.Equal(t, "#000000", s.Table().BgColor.String()) assert.Equal(t, tcell.ColorCadetBlue.TrueColor(), s.FgColor()) assert.Equal(t, tcell.ColorBlack.TrueColor(), s.BgColor()) assert.Equal(t, tcell.ColorBlack.TrueColor(), tview.Styles.PrimitiveBackgroundColor) }) } } ================================================ FILE: internal/config/templates/aliases.yaml ================================================ aliases: dp: deployments sec: v1/secrets jo: jobs cr: clusterroles crb: clusterrolebindings ro: roles rb: rolebindings np: networkpolicies ================================================ FILE: internal/config/templates/benchmarks.yaml ================================================ benchmarks: defaults: concurrency: 2 requests: 200 ================================================ FILE: internal/config/templates/hotkeys.yaml ================================================ hotKeys: # Examples... # shift-0: # shortCut: Shift-0 # description: View Workloads # command: wk k8s-app=cilium ================================================ FILE: internal/config/templates/stock-skin.yaml ================================================ # ----------------------------------------------------------------------------- # Stock skin # ----------------------------------------------------------------------------- # Skin... k9s: body: fgColor: cadetblue bgColor: black logoColor: orange logoColorMsg: white logoColorInfo: green logoColorWarn: mediumvioletred logoColorError: red prompt: fgColor: cadetblue bgColor: black suggestColor: dodgerblue border: command: aqua default: seagreen help: fgColor: cadetblue bgColor: black sectionColor: green keyColor: dodgerblue numKeyColor: fuchsia frame: title: fgColor: aqua bgColor: black highlightColor: fuchsia counterColor: papayawhip filterColor: seagreen border: fgColor: dodgerblue focusColor: lightskyblue menu: fgColor: white keyColor: dodgerblue numKeyColor: fuchsia crumbs: fgColor: black bgColor: aqua activeColor: orange status: newColor: lightskyblue modifyColor: greenyellow addColor: dodgerblue pendingColor: darkorange errorColor: orangered highlightColor: aqua killColor: mediumpurple completedColor: lightslategray info: sectionColor: white fgColor: orange views: table: fgColor: aqua bgColor: black cursorFgColor: black cursorBgColor: aqua markColor: palegreen header: fgColor: white bgColor: black sorterColor: aqua selectedSortColumnColor: lightskyblue xray: fgColor: aqua bgColor: black cursorColor: dodgerblue cursorTextColor: black graphicColor: cadetblue charts: bgColor: black dialBgColor: black chartBgColor: black focusFgColor: white focusBgColor: orange defaultDialColors: - palegreen - orangered defaultChartColors: - palegreen - orangered resourceColors: cpu: - dodgerblue - darkslateblue mem: - yellow - goldenrod yaml: keyColor: steelblue valueColor: papayawhip colonColor: white picker: mainColor: white focusColor: aqua shortcutColor: aqua logs: fgColor: lightskyblue bgColor: black indicator: fgColor: dodgerblue bgColor: black toggleOnColor: limegreen toggleOffColor: gray dialog: fgColor: cadetblue bgColor: black buttonFgColor: black buttonBgColor: darkslateblue buttonFocusFgColor: black buttonFocusBgColor: dodgerblue labelFgColor: white fieldFgColor: white ================================================ FILE: internal/config/testdata/aliases/aliases.yaml ================================================ aliases: dp: apps/v1/deployments sec: v1/secrets jo: batch/v1/jobs cr: rbac.authorization.k8s.io/v1/clusterroles crb: rbac.authorization.k8s.io/v1/clusterrolebindings ro: rbac.authorization.k8s.io/v1/roles rb: rbac.authorization.k8s.io/v1/rolebindings np: networking.k8s.io/v1/networkpolicies ================================================ FILE: internal/config/testdata/aliases/plain.yaml ================================================ aliases: dp: "apps/v1/deployments" pe: "v1/pods" ================================================ FILE: internal/config/testdata/benchmarks/b_containers.yaml ================================================ benchmarks: defaults: concurrency: 2 requests: 1000 containers: c1: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: /duh body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" c2: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /fred body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" services: default/nginx: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: / body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" blee/fred: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /blee body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" ================================================ FILE: internal/config/testdata/benchmarks/b_containers_1.yaml ================================================ benchmarks: defaults: concurrency: 20 requests: 100 containers: c1: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: /duh body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" c2: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /fred body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" services: default/nginx: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: / body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" blee/fred: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /blee body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" ================================================ FILE: internal/config/testdata/benchmarks/b_good.yaml ================================================ benchmarks: defaults: concurrency: 2 requests: 1000 services: default/nginx: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: / body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" blee/fred: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /zorg body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" ================================================ FILE: internal/config/testdata/benchmarks/b_toast.yaml ================================================ benchmarks: service: - default/nginx: concurrency: 1 http: requests: 100 http2: true method: GET url: http://35.224.16.201/ body: |- {"fred": "blee"} headers: - "Accept: text/html" - "Content-Type: application/json" auth: user: "fred" password: "blee" ================================================ FILE: internal/config/testdata/benchmarks/bench-fred.yaml ================================================ benchmarks: defaults: concurrency: 2 requests: 1000 services: default/nginx: concurrency: 2 requests: 1000 http: method: GET http2: true host: 10.10.10.10 path: / body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" blee/fred: concurrency: 10 requests: 1500 http: method: POST http2: false host: 20.20.20.20 path: /zorg body: |- {"fred": "blee"} headers: Accept: - text/html Content-Type: - application/json auth: user: "fred" password: "blee" ================================================ FILE: internal/config/testdata/configs/default.yaml ================================================ k9s: liveViewAutoRefresh: false gpuVendors: {} screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 2 apiServerTimeout: 2m0s maxConnRetry: 5 readOnly: false noExitOnCtrlC: false portForwardAddress: localhost ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false reactive: false noIcons: false invert: false defaultsToFullScreen: false useFullGVRTitle: false skipLatestRevCheck: false disablePodCounting: false shellPod: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 100 buffer: 5000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 defaultView: "" ================================================ FILE: internal/config/testdata/configs/expected.yaml ================================================ k9s: liveViewAutoRefresh: true gpuVendors: bozo: bozo/gpu.com screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 100 apiServerTimeout: 30s maxConnRetry: 5 readOnly: true noExitOnCtrlC: false portForwardAddress: localhost ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false reactive: false noIcons: false invert: false defaultsToFullScreen: false useFullGVRTitle: true skipLatestRevCheck: false disablePodCounting: false shellPod: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 500 buffer: 800 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 defaultView: "" ================================================ FILE: internal/config/testdata/configs/k9s.yaml ================================================ k9s: liveViewAutoRefresh: true gpuVendors: {} screenDumpDir: /tmp/k9s-test/screen-dumps refreshRate: 2 apiServerTimeout: 10s maxConnRetry: 5 readOnly: false noExitOnCtrlC: false portForwardAddress: localhost ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false reactive: false noIcons: false invert: false defaultsToFullScreen: false useFullGVRTitle: false skipLatestRevCheck: false disablePodCounting: false shellPod: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 200 buffer: 2000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 defaultView: "" ================================================ FILE: internal/config/testdata/configs/k9s_toast.yaml ================================================ k9s: liveViewAutoRefresh: true screenDumpDir: /tmp/screen-dumps refreshRate: 2 maxConnRetry: 5 readOnly: false noExitOnCtrlC: false ui: enableMouse: false headless: false logoless: false crumbsless: false splashless: false noIcons: false invert: false skipLatestRevCheck: yes disablePodCounts: false shellPods: image: busybox:1.37.0 namespace: default limits: cpu: 100m memory: 100Mi imageScans: enable: false exclusions: namespaces: [] labels: {} logger: tail: 200 buffer: 2000 sinceSeconds: -1 textWrap: false disableAutoscroll: false columnLock: false showTime: false thresholds: cpu: critical: 90 warn: 70 memory: critical: 90 warn: 70 defaultView: "" ================================================ FILE: internal/config/testdata/hotkeys/hotkeys.yaml ================================================ hotKeys: pods: shortCut: shift-0 description: Launch pod view command: pods keepHistory: true ================================================ FILE: internal/config/testdata/k8s.yaml ================================================ apiVersion: v1 kind: Config preferences: {} clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:6443 name: docker-for-desktop-cluster contexts: - context: cluster: docker-for-desktop-cluster user: docker-for-desktop name: docker-for-desktop current-context: docker-for-desktop users: - name: docker-for-desktop user: client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM5RENDQWR5Z0F3SUJBZ0lJWFNHb3I3ZlJlOHN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T0RBNU1ESXlNREkyTlRaYUZ3MHlNREF5TURreE5qQXhNVGhhTURZeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sc3dHUVlEVlFRREV4SmtiMk5yWlhJdFptOXlMV1JsCmMydDBiM0F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRGxBTWZKVUUvWUIwb0UKWmN5TmE1S0dVMkZpRmNYLys2dFJQUlpETkhZSkE1ZGtaME40UC9kVVdZeWJGRlVYc0E3UU1Sbm1mS280Q25MTQptK28wS2NUd3NRMnY3UzlPejVJYlJCOVZGVnFqNDJmNW9mVFFDcnZNN20wWVovNlRzcjhtSDE0QVYzWkRZaWtsCkF1VjlqRUgvczF5WWppbG0rODVlbm02RUZYYkJMV2czcXZkQ3VxNmlMa2FjWFptWTJYVXVtTWVOTVZnQllrS1UKVi84czJ0VlhyTlhvaU9qZVFMZlIvYmpvYkFZbzlMM0JWZFczYUxjanBwcDYzWmE0YlZITHYyQ2ZXMDcwNjNvbQpYZ0syM1hHWjQwdFFGaElxbUlvZktYZ0lVSWk2YVV3UVI0WFVRd3RMeTk4aHRDazZ2ckl5UUpMWkdKV29WVlU4CitJclVtZFRyQWdNQkFBR2pKekFsTUE0R0ExVWREd0VCL3dRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFRRzlVaFVjUDdJQzdyZmRyK1pxTTBKbGxITzY3MzZoTgpVRkpvNmZSRUdDbjlxclN6SW44K0RZV1N3RnF4ZVRhZlNFK3VJZHFGREQ1ais1bWhEQzBzZUV2WWlNQ09CZFJDCk4xT3RRK1lrQndndnNKU3RxZGdzNTRXdkJwLzFiS09leFNLS1laTzJPaUJLd3NRV1ZXeksrQ0VjOXhRSm1jN2MKZGhlK0tNZVNTeC9LSmR0bHc4VWVSUkVCOU00WjZjRHpLYzQ2cjhBd04zWkxibzdsYzVCNE0wb2lXMUVwR0wwQgpKUXYrT1FDblV4K0d1dVcvTGdNT1JQRVFXaXF1UjFvWXlJQjlRb0wxRXFCTDZHejhTWjVtbTE1ellWSVZXTHh6ClNQLzVyd2VjNDY0Z216RDR4MHJVUHBIaWlRSVJzUjk1WXBIMjNxWkl2QlVwd2dnTjJnd2hTUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBNVFESHlWQlAyQWRLQkdYTWpXdVNobE5oWWhYRi8vdXJVVDBXUXpSMkNRT1haR2RECmVELzNWRm1NbXhSVkY3QU8wREVaNW55cU9BcHl6SnZxTkNuRThMRU5yKzB2VHMrU0cwUWZWUlZhbytObithSDAKMEFxN3pPNXRHR2YrazdLL0poOWVBRmQyUTJJcEpRTGxmWXhCLzdOY21JNHBadnZPWHA1dWhCVjJ3UzFvTjZyMwpRcnF1b2k1R25GMlptTmwxTHBqSGpURllBV0pDbEZmL0xOclZWNnpWNklqbzNrQzMwZjI0Nkd3R0tQUzl3VlhWCnQyaTNJNmFhZXQyV3VHMVJ5NzlnbjF0TzlPdDZKbDRDdHQxeG1lTkxVQllTS3BpS0h5bDRDRkNJdW1sTUVFZUYKMUVNTFM4dmZJYlFwT3I2eU1rQ1MyUmlWcUZWVlBQaUsxSm5VNndJREFRQUJBb0lCQUZSY29EenlZQ2VXTDlkRQo1VUVuNHRlbk9kWFhiWlNxMHViZm1TYnkyWlRpaE5BUkZwTGpCYXRHUGYwWFZXMmZoeVY5SVN4K3VucGdwdi9uClpEVUpPaXJ0SHJ5enBOemtyTTlzbmhwSy9wUW5mek5BVFo2aWhhS3VKdlI1d3hnSUhsRGQ5MVFxNUQ5WWx3MnkKYm5aOHlBZDV2Ti9hWnpnd0JVdG9GQkNHazdQLzRxK0JlbHZoNWd1SzdzS0dvRi94dGMwWlp6RGtYMkw5VHJlZQowSE5nTmJlYm91SHhlVHBkcGNLQzZ2TENST2tqb0RTdDY1ZEo2ajBGTzhzTERVcWRrWkxNa0ducTdoZWhYV2JwClBtRVI5dWc3Qk1HVUFtcHhpbGdGVHM0MnRSNnoxZXdvSWs5bC92V3ZMTFpZbEE3OUo4YkU0UTNPZGpXc2Nza1YKV0ZpakZ0a0NnWUVBNXU2SnV3b3A0Z2QzTjNhUHVhakpHNEpjYTZNWFZQNEVVay9vY2pobloyRDF5cjd2ZXhZSApVaE1WN2p6dzJUQ1FJa2JtMWZlSTZpa1llUzNVNXprZDhKSm9VaXV4T3ZsS0VJSlFrTEROQyttMHFuN0xEamU1Cmx0SkE3Sm9IdDhSTzNMS1JBTFhvQTVsV0l0NWNQLzVuTW1IMTlDZGQ3L294MU0xRXFFYUxNMmNDZ1lFQS9keWsKMExyR0VtbTg0SlU0dDIvZDVzNm5ERnAxOWxhUXJlbFY1bWRsZEFKNGRPVHkxZXV4dHNFeS8xSFExWXBLa25aSgpTa2Q2RTJzYk1MUncxdzFGWTZpczI0ektmaWtLV0N5SXBPMTkvQWh5UkpweGxKTlN4a2hjK1FpVXVSd1lsVmtMCi9XQ3dFUFVVaDI1VHJRNE9LRTNKazh3VmVmdFlwdkZNRDhvaHc5MENnWUF5ZTkxQ05XT1lsUmM3MmNCcnp2ay8KK1V5by96dGZpalI1cGh4anMrN3ZDNlJRRVZPYkxlS2x6NlJRczZQWFp5VnJTT0szemVoeGdGQm9WVnVndkx6TgoxY1BXaXRTdzFzU1pQVlBOZmNrbG5JNnhZd3lTN0IyM1dmbDFmK3JHQXJWV3kvYWxHQjlEZ2lieGNuanFTSHhZCjZFOXpjNU8yblpSOU4rNlZkdTZCYXdLQmdDV3Vtc2hnOFFYS3JENnA1OEZTMloxcEQyTEdDcnlHSFBPenJ3eUUKVElycjB2V0hCb1M2ZDZhcEJ1amZQQ0IyWnB0Vzg0b1RFZ3ZQMmpsZ2oxOWNtUEF5R1haOWI1RktoajZRWGJnZAppSlhncXhXRDExZzJoaExvcXVSTVljY1laSTNHcWdEeVdUQXJNT0RwZjRJd2srbG5vb1JOeHVKVWJOUmEvTzliCkVhZ0JBb0dBUE9VZk15M3JPSWRBRTV4Q0VxOUlza1hXblFrcmRwYktKVDVzbVFyVUhuRFh4QWM5L0libm1jWmwKOWN4Y3czdktMbWVZU3loWFF5ZWF1VXo3amZhdjZ3TGhnVi9KK1NYdlBlUng2aDFmb0lsVUF4WDJMdDFSOEduZApNejhqdHJxN29ycE1EQU9xOHNyaGxzZkZDYnJtUFZKSnNTd1J4R3ZJN3ZLTm54VXRXVFE9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== ================================================ FILE: internal/config/testdata/kubes/test.yaml ================================================ apiVersion: v1 kind: Config clusters: - cluster: certificate-authority: /Users/test/ca.crt server: https://1.2.3.4:8443 name: cl-1 - cluster: certificate-authority: /Users/test/ca.crt server: https://5.6.7.8:8443 name: cl-2 contexts: - context: cluster: cl-1 user: user1 namespace: ns-1 name: ct-1-1 - context: cluster: cl-1 user: user2 namespace: ns-2 name: ct-1-2 - context: cluster: cl-2 user: user2 namespace: ns-2 name: ct-2-1 current-context: ct-1-1 preferences: {} users: - name: user1 user: client-certificate: /Users/test/client.crt client-key: /Users/test/client.key - name: user2 user: client-certificate: /Users/test/client.crt client-key: /Users/test/client.key ================================================ FILE: internal/config/testdata/plugins/dir/snippet.1.yaml ================================================ shortCut: shift-s description: blee scopes: - po - dp command: duh args: - -n - $NAMESPACE - -boolean background: false confirm: true overwriteOutput: true ================================================ FILE: internal/config/testdata/plugins/dir/snippet.2.yaml ================================================ shortCut: shift-r confirm: false description: bla scopes: - svc - ing command: duha background: true args: - -n - $NAMESPACE - -oyaml ================================================ FILE: internal/config/testdata/plugins/dir/snippet.multi.yaml ================================================ crapola: shortCut: Shift-1 description: crapola scopes: - pods command: crapola background: false bozo: shortCut: Shift-2 description: bozo scopes: - pods - svc command: bozo ================================================ FILE: internal/config/testdata/plugins/plugins-toast.yaml ================================================ plugins: blah: shortCut: shift-s confirm: true description: blee scoped: - po - dp command: duh background: false args: - -n - $NAMESPACE - -boolean ================================================ FILE: internal/config/testdata/plugins/plugins.yaml ================================================ plugins: blah: shortCut: shift-s confirm: true description: blee scopes: - po - dp command: duh background: false args: - -n - $NAMESPACE - -boolean ================================================ FILE: internal/config/testdata/skins/black-and-wtf.yaml ================================================ k9s: body: fgColor: white bgColor: black logoColor: white info: fgColor: navajowhite sectionColor: white frame: border: fgColor: white focusColor: white menu: fgColor: white keyColor: white numKeyColor: navajowhite crumb: fgColor: black bgColor: navajowhite activeColor: whitesmoke status: newColor: ghostwhite modifyColor: navajowhite addColor: darkslategray errorColor: whitesmoke highlightcolor: dimgray killColor: slategray completedColor: gray title: fgColor: ghostwhite highlightColor: navajowhite counterColor: navajowhite filterColor: slategray views: table: fgColor: white bgColor: black cursorColor: white header: fgColor: darkgray bgColor: black sorterColor: white ================================================ FILE: internal/config/testdata/skins/boarked.yaml ================================================ k9s: fgColor: blee bgColor: black logoColor: white info: - fgColor: fred - sectionColor: white ================================================ FILE: internal/config/testdata/skins/empty.yaml ================================================ k9s: body: ================================================ FILE: internal/config/testdata/views/views.yaml ================================================ views: v1/pods: columns: - NAMESPACE - NAME - AGE - IP v1/pods@default: columns: - NAME - IP - AGE v1/pods@ns*: columns: - AGE - NAME - IP bozo: columns: - DUH - BLAH - BLEE ================================================ FILE: internal/config/threshold.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config const ( // SeverityLow tracks low severity. SeverityLow SeverityLevel = iota // SeverityMedium tracks medium severity level. SeverityMedium // SeverityHigh tracks high severity level. SeverityHigh ) // SeverityLevel tracks severity levels. type SeverityLevel int // Severity tracks a resource severity levels. type Severity struct { Critical int `yaml:"critical"` Warn int `yaml:"warn"` } // NewSeverity returns a new instance. func NewSeverity() *Severity { return &Severity{ Critical: 90, Warn: 70, } } // Validate checks all thresholds and make sure we're cool. If not use defaults. func (s *Severity) Validate() { norm := NewSeverity() if !validateRange(s.Warn) { s.Warn = norm.Warn } if !validateRange(s.Critical) { s.Critical = norm.Critical } } func validateRange(v int) bool { if v <= 0 || v > 100 { return false } return true } // Threshold tracks threshold to alert user when exceeded. type Threshold map[string]*Severity // NewThreshold returns a new threshold. func NewThreshold() Threshold { return Threshold{ CPU: NewSeverity(), MEM: NewSeverity(), } } // Validate a namespace is setup correctly. func (t Threshold) Validate() Threshold { for _, k := range []string{CPU, MEM} { v, ok := t[k] if !ok { t[k] = NewSeverity() } else { v.Validate() } } return t } // LevelFor returns a defcon level for the current state. func (t Threshold) LevelFor(k string, v int) SeverityLevel { s, ok := t[k] if !ok || v < 0 || v > 100 { return SeverityLow } if v >= s.Critical { return SeverityHigh } if v >= s.Warn { return SeverityMedium } return SeverityLow } // SeverityColor returns a defcon level associated level. func (t *Threshold) SeverityColor(k string, v int) string { //nolint:exhaustive switch t.LevelFor(k, v) { case SeverityHigh: return "red" case SeverityMedium: return "orangered" default: return "green" } } ================================================ FILE: internal/config/threshold_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func TestSeverityValidate(t *testing.T) { uu := map[string]struct { d, e *config.Severity }{ "default": { d: config.NewSeverity(), e: config.NewSeverity(), }, "toast": { d: &config.Severity{Warn: 10}, e: &config.Severity{Warn: 10, Critical: 90}, }, "negative": { d: &config.Severity{Warn: -1}, e: config.NewSeverity(), }, "out-of-range": { d: &config.Severity{Warn: 150}, e: config.NewSeverity(), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.d.Validate() assert.Equal(t, u.e, u.d) }) } } func TestLevelFor(t *testing.T) { uu := map[string]struct { k string v int e config.SeverityLevel }{ "normal": { k: config.CPU, v: 0, e: config.SeverityLow, }, "4": { k: config.CPU, v: 71, e: config.SeverityMedium, }, "3": { k: config.CPU, v: 75, e: config.SeverityMedium, }, "2": { k: config.CPU, v: 80, e: config.SeverityMedium, }, "1": { k: config.CPU, v: 100, e: config.SeverityHigh, }, "over": { k: config.CPU, v: 150, e: config.SeverityLow, }, "over-mem": { k: config.MEM, v: 150, e: config.SeverityLow, }, } o := config.NewThreshold() for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, o.LevelFor(u.k, u.v)) }) } } ================================================ FILE: internal/config/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config const ( defaultRefreshRate = 2 defaultMaxConnRetry = 5 // CPU tracks cpu usage. CPU = "cpu" // MEM tracks memory usage. MEM = "memory" ) // UI tracks ui specific configs. type UI struct { // EnableMouse toggles mouse support. EnableMouse bool `json:"enableMouse" yaml:"enableMouse"` // Headless toggles top header display. Headless bool `json:"headless" yaml:"headless"` // LogoLess toggles k9s logo. Logoless bool `json:"logoless" yaml:"logoless"` // Crumbsless toggles nav crumb display. Crumbsless bool `json:"crumbsless" yaml:"crumbsless"` // Splashless disables the splash screen on startup. Splashless bool `json:"splashless" yaml:"splashless"` // Reactive toggles reactive ui changes. Reactive bool `json:"reactive" yaml:"reactive"` // NoIcons toggles icons display. NoIcons bool `json:"noIcons" yaml:"noIcons"` // Invert inverts all skin colors using Oklch lightness inversion. Invert bool `json:"invert" yaml:"invert"` // Skin reference the general k9s skin name. // Can be overridden per context. Skin string `json:"skin" yaml:"skin,omitempty"` // DefaultsToFullScreen toggles fullscreen on views like logs, yaml, details. DefaultsToFullScreen bool `json:"defaultsToFullScreen" yaml:"defaultsToFullScreen"` // UseFullGVRTitle toggles the display of full GVR (group/version/resource) vs R in views title. UseFullGVRTitle bool `json:"useFullGVRTitle" yaml:"useFullGVRTitle"` manualHeadless *bool manualLogoless *bool manualCrumbsless *bool manualSplashless *bool manualInvert *bool } ================================================ FILE: internal/config/views.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "cmp" "errors" "fmt" "io/fs" "log/slog" "maps" "os" "regexp" "slices" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/json" "github.com/derailed/k9s/internal/slogs" "gopkg.in/yaml.v3" ) // ViewConfigListener represents a view config listener. type ViewConfigListener interface { // ViewSettingsChanged notifies listener the view configuration changed. ViewSettingsChanged(*ViewSetting) // GetNamespace return the view namespace GetNamespace() string } // ViewSetting represents a view configuration. type ViewSetting struct { Columns []string `yaml:"columns"` SortColumn string `yaml:"sortColumn"` } func (v *ViewSetting) HasCols() bool { return len(v.Columns) > 0 } func (v *ViewSetting) IsBlank() bool { return v == nil || (len(v.Columns) == 0 && v.SortColumn == "") } func (v *ViewSetting) SortCol() (name string, asc bool, err error) { if v == nil || v.SortColumn == "" { return "", false, fmt.Errorf("no sort column specified") } tt := strings.Split(v.SortColumn, ":") if len(tt) < 2 { return "", false, fmt.Errorf("invalid sort column spec: %q. must be col-name:asc|desc", v.SortColumn) } return tt[0], tt[1] == "asc", nil } // Equals checks if two view settings are equal. func (v *ViewSetting) Equals(vs *ViewSetting) bool { if v == nil && vs == nil { return true } if v == nil || vs == nil { return false } if c := slices.Compare(v.Columns, vs.Columns); c != 0 { return false } return cmp.Compare(v.SortColumn, vs.SortColumn) == 0 } // CustomView represents a collection of view customization. type CustomView struct { Views map[string]ViewSetting `yaml:"views"` listeners map[string]ViewConfigListener } // NewCustomView returns a views configuration. func NewCustomView() *CustomView { return &CustomView{ Views: make(map[string]ViewSetting), listeners: make(map[string]ViewConfigListener), } } // Reset clears out configurations. func (v *CustomView) Reset() { for k := range v.Views { delete(v.Views, k) } } // Load loads view configurations. func (v *CustomView) Load(path string) error { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return nil } bb, err := os.ReadFile(path) if err != nil { return err } if err := data.JSONValidator.Validate(json.ViewsSchema, bb); err != nil { slog.Warn("Validation failed. Please update your config and restart!", slogs.Path, path, slogs.Error, err, ) } var in CustomView if err := yaml.Unmarshal(bb, &in); err != nil { return err } v.Views = in.Views v.fireConfigChanged() return nil } // AddListeners registers a new listener for various commands. func (v *CustomView) AddListeners(l ViewConfigListener, cmds ...string) { for _, cmd := range cmds { if cmd != "" { v.listeners[cmd] = l } } v.fireConfigChanged() } // AddListener registers a new listener. func (v *CustomView) AddListener(cmd string, l ViewConfigListener) { v.listeners[cmd] = l v.fireConfigChanged() } // RemoveListener unregister a listener. func (v *CustomView) RemoveListener(l ViewConfigListener) { for k, list := range v.listeners { if list == l { delete(v.listeners, k) } } } func (v *CustomView) fireConfigChanged() { cmds := slices.Collect(maps.Keys(v.listeners)) slices.SortFunc(cmds, func(a, b string) int { switch { case strings.Contains(a, "/") && !strings.Contains(b, "/"): return 1 case !strings.Contains(a, "/") && strings.Contains(b, "/"): return -1 default: return strings.Compare(a, b) } }) type tuple struct { cmd string vs *ViewSetting } var victim tuple for _, cmd := range cmds { if vs := v.getVS(cmd, v.listeners[cmd].GetNamespace()); vs != nil { slog.Debug("Reloading custom view settings", slogs.Command, cmd) victim = tuple{cmd, vs} break } victim = tuple{cmd, nil} } if victim.cmd != "" { v.listeners[victim.cmd].ViewSettingsChanged(victim.vs) } } func (v *CustomView) getVS(gvr, ns string) *ViewSetting { if client.IsAllNamespaces(ns) { ns = client.NamespaceAll } k := gvr kk := slices.Collect(maps.Keys(v.Views)) slices.SortFunc(kk, strings.Compare) slices.Reverse(kk) for _, key := range kk { if !strings.HasPrefix(key, gvr) && !strings.HasPrefix(gvr, key) { continue } switch { case strings.Contains(key, "@"): tt := strings.Split(key, "@") if len(tt) != 2 { break } nsk := gvr if ns != "" { nsk += "@" + ns } if rx, err := regexp.Compile(tt[1]); err == nil && rx.MatchString(nsk) { vs := v.Views[key] return &vs } case strings.HasPrefix(k, key): kk := strings.Fields(k) if len(kk) == 2 { if v, ok := v.Views[kk[0]+"@"+kk[1]]; ok { return &v } if key == kk[0] { vs := v.Views[key] return &vs } } fallthrough case key == k: vs := v.Views[key] return &vs } } return nil } ================================================ FILE: internal/config/views_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCustomView_getVS(t *testing.T) { uu := map[string]struct { cv *CustomView gvr, ns string e *ViewSetting }{ "empty": {}, "miss": { gvr: "zorg", }, "gvr": { gvr: client.PodGVR.String(), e: &ViewSetting{ Columns: []string{"NAMESPACE", "NAME", "AGE", "IP"}, }, }, "gvr+ns": { gvr: client.PodGVR.String(), ns: "default", e: &ViewSetting{ Columns: []string{"NAME", "IP", "AGE"}, }, }, "rx": { gvr: client.PodGVR.String(), ns: "ns-fred", e: &ViewSetting{ Columns: []string{"AGE", "NAME", "IP"}, }, }, "alias": { gvr: "bozo", e: &ViewSetting{ Columns: []string{"DUH", "BLAH", "BLEE"}, }, }, "toast-no-ns": { gvr: client.PodGVR.String(), ns: "zorg", e: &ViewSetting{ Columns: []string{"NAMESPACE", "NAME", "AGE", "IP"}, }, }, "toast-no-res": { gvr: client.SvcGVR.String(), ns: "zorg", }, } v := NewCustomView() require.NoError(t, v.Load("testdata/views/views.yaml")) for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, v.getVS(u.gvr, u.ns)) }) } } ================================================ FILE: internal/config/views_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package config_test import ( "log/slog" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestCustomViewLoad(t *testing.T) { uu := map[string]struct { cv *config.CustomView path string key string e []string }{ "empty": {}, "gvr": { path: "testdata/views/views.yaml", key: client.PodGVR.String(), e: []string{"NAMESPACE", "NAME", "AGE", "IP"}, }, "gvr+ns": { path: "testdata/views/views.yaml", key: "v1/pods@default", e: []string{"NAME", "IP", "AGE"}, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { cfg := config.NewCustomView() require.NoError(t, cfg.Load(u.path)) assert.Equal(t, u.e, cfg.Views[u.key].Columns) }) } } func TestViewSettingEquals(t *testing.T) { uu := map[string]struct { v1, v2 *config.ViewSetting e bool }{ "v1-nil-v2-nil": { e: true, }, "v1-v2-empty": { v1: new(config.ViewSetting), v2: new(config.ViewSetting), e: true, }, "v1-nil": { v1: new(config.ViewSetting), }, "nil-v2": { v2: new(config.ViewSetting), }, "v1-v2-blank": { v1: &config.ViewSetting{ Columns: []string{"A"}, }, v2: new(config.ViewSetting), }, "v1-v2-nil": { v1: &config.ViewSetting{ Columns: []string{"A"}, }, }, "same": { v1: &config.ViewSetting{ Columns: []string{"A", "B", "C"}, }, v2: &config.ViewSetting{ Columns: []string{"A", "B", "C"}, }, e: true, }, "order": { v1: &config.ViewSetting{ Columns: []string{"C", "A", "B"}, }, v2: &config.ViewSetting{ Columns: []string{"A", "B", "C"}, }, }, "delta": { v1: &config.ViewSetting{ Columns: []string{"A", "B", "C"}, }, v2: &config.ViewSetting{ Columns: []string{"B"}, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equalf(t, u.e, u.v1.Equals(u.v2), "%#v and %#v", u.v1, u.v2) }) } } ================================================ FILE: internal/dao/accessor.go ================================================ package dao import ( "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" ) var accessors = Accessors{ client.WkGVR: new(Workload), client.CtGVR: new(Context), client.CoGVR: new(Container), client.ScnGVR: new(ImageScan), client.SdGVR: new(ScreenDump), client.BeGVR: new(Benchmark), client.PfGVR: new(PortForward), client.DirGVR: new(Dir), client.SvcGVR: new(Service), client.PodGVR: new(Pod), client.NodeGVR: new(Node), client.NsGVR: new(Namespace), client.CmGVR: new(ConfigMap), client.SecGVR: new(Secret), client.DpGVR: new(Deployment), client.DsGVR: new(DaemonSet), client.StsGVR: new(StatefulSet), client.RsGVR: new(ReplicaSet), client.CjGVR: new(CronJob), client.JobGVR: new(Job), client.HmGVR: new(HelmChart), client.HmhGVR: new(HelmHistory), client.CrdGVR: new(CustomResourceDefinition), } // Accessors represents a collection of dao accessors. type Accessors map[*client.GVR]Accessor // AccessorFor returns a client accessor for a resource if registered. // Otherwise it returns a generic accessor. // Customize here for non resource types or types with metrics or logs. func AccessorFor(f Factory, gvr *client.GVR) (Accessor, error) { r, ok := accessors[gvr] if !ok { r = new(Scaler) slog.Debug("No DAO registry entry. Using generics!", slogs.GVR, gvr) } r.Init(f, gvr) return r, nil } ================================================ FILE: internal/dao/alias.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "sort" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" ) var _ Accessor = (*Alias)(nil) // Alias tracks standard and custom command aliases. type Alias struct { NonResource *config.Aliases } // NewAlias returns a new set of aliases. func NewAlias(f Factory) *Alias { a := Alias{ Aliases: config.NewAliases(), } a.Init(f, client.AliGVR) return &a } // AliasesFor returns a set of aliases for a given gvr. func (a *Alias) AliasesFor(gvr *client.GVR) sets.Set[string] { return a.Aliases.AliasesFor(gvr) } // List returns a collection of aliases. func (*Alias) List(ctx context.Context, _ string) ([]runtime.Object, error) { aa, ok := ctx.Value(internal.KeyAliases).(*Alias) if !ok { return nil, fmt.Errorf("expecting *Alias but got %T", ctx.Value(internal.KeyAliases)) } m := aa.ShortNames() oo := make([]runtime.Object, 0, len(m)) for gvr, aliases := range m { sort.StringSlice(aliases).Sort() oo = append(oo, render.AliasRes{ GVR: gvr, Aliases: aliases, }) } return oo, nil } // Get fetch a resource. func (*Alias) Get(_ context.Context, _ string) (runtime.Object, error) { return nil, errors.New("nyi") } // Ensure makes sure alias are loaded. func (a *Alias) Ensure(path string) (config.Alias, error) { if err := MetaAccess.LoadResources(a.Factory); err != nil { return config.Alias{}, err } return a.Alias, a.load(path) } func (a *Alias) load(path string) error { if err := a.Load(path); err != nil { return err } crdGVRS := make(client.GVRs, 0, 50) for _, gvr := range MetaAccess.AllGVRs() { meta, err := MetaAccess.MetaFor(gvr) if err != nil { return err } if IsK9sMeta(meta) { continue } if IsCRD(meta) { crdGVRS = append(crdGVRS, gvr) continue } a.Define(gvr, gvr.AsResourceName()) // Allow single shot commands for k8s resources only expect for metrics resource which override pods and nodes ;(! if isStandardGroup(gvr.GVSub()) && gvr.G() != "metrics.k8s.io" { a.Define(gvr, meta.Name, meta.SingularName) } if len(meta.ShortNames) > 0 { a.Define(gvr, meta.ShortNames...) } a.Define(gvr, gvr.String()) } for _, gvr := range crdGVRS { meta, err := MetaAccess.MetaFor(gvr) if err != nil { return err } a.Define(gvr, strings.ToLower(meta.Kind), meta.Name) a.Define(gvr, meta.SingularName) if len(meta.ShortNames) > 0 { a.Define(gvr, meta.ShortNames...) } a.Define(gvr, gvr.String()) a.Define(gvr, meta.Name+"."+meta.Group) } return nil } ================================================ FILE: internal/dao/alias_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "context" "testing" "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/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAliasList(t *testing.T) { a := dao.Alias{} a.Init(makeFactory(), client.AliGVR) ctx := context.WithValue(context.Background(), internal.KeyAliases, makeAliases()) oo, err := a.List(ctx, "-") require.NoError(t, err) assert.Len(t, oo, 2) assert.Len(t, oo[0].(render.AliasRes).Aliases, 2) } // ---------------------------------------------------------------------------- // Helpers... func makeAliases() *dao.Alias { gvr1 := client.NewGVR("v1/fred") gvr2 := client.NewGVR("v1/blee") return &dao.Alias{ Aliases: &config.Aliases{ Alias: config.Alias{ "fred": gvr1, "f": gvr1, "blee": gvr2, "b": gvr2, }, }, } } ================================================ FILE: internal/dao/benchmark.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "os" "path/filepath" "regexp" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/render" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Benchmark)(nil) _ Nuker = (*Benchmark)(nil) BenchRx = regexp.MustCompile(`[:|]+`) ) // Benchmark represents a benchmark resource. type Benchmark struct { NonResource } // Delete nukes a resource. func (*Benchmark) Delete(_ context.Context, path string, _ *metav1.DeletionPropagation, _ Grace) error { return os.Remove(path) } // Get returns a resource. func (*Benchmark) Get(context.Context, string) (runtime.Object, error) { panic("NYI") } // List returns a collection of resources. func (*Benchmark) List(ctx context.Context, _ string) ([]runtime.Object, error) { dir, ok := ctx.Value(internal.KeyDir).(string) if !ok { return nil, errors.New("no benchmark dir found in context") } path, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("no path specified in context") } pathMatch := BenchRx.ReplaceAllString(strings.Replace(path, "/", "_", 1), "_") ff, err := os.ReadDir(dir) if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(ff)) for _, f := range ff { if !strings.HasPrefix(f.Name(), pathMatch) { continue } if fi, err := f.Info(); err == nil { oo = append(oo, render.BenchInfo{File: fi, Path: filepath.Join(dir, f.Name())}) } } return oo, nil } ================================================ FILE: internal/dao/benchmark_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBenchmarkList(t *testing.T) { a := dao.Benchmark{} a.Init(makeFactory(), client.BeGVR) ctx := context.WithValue(context.Background(), internal.KeyDir, "testdata/bench") ctx = context.WithValue(ctx, internal.KeyPath, "") oo, err := a.List(ctx, "-") require.NoError(t, err) assert.Len(t, oo, 1) assert.Equal(t, "testdata/bench/default_fred_1577308050814961000.txt", oo[0].(render.BenchInfo).Path) } ================================================ FILE: internal/dao/cluster.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "sync" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" ) // RefScanner represents a resource reference scanner. type RefScanner interface { // Init initializes the scanner Init(Factory, *client.GVR) // Scan scan the resource for references. Scan(ctx context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) // ScanSA scan the resource for serviceaccount references. ScanSA(ctx context.Context, fqn string, wait bool) (Refs, error) } // Ref represents a resource reference. type Ref struct { GVR string FQN string } // Refs represents a collection of resource references. type Refs []Ref var ( _ RefScanner = (*Deployment)(nil) _ RefScanner = (*StatefulSet)(nil) _ RefScanner = (*DaemonSet)(nil) _ RefScanner = (*Job)(nil) _ RefScanner = (*CronJob)(nil) ) func scanners() map[*client.GVR]RefScanner { return map[*client.GVR]RefScanner{ client.DpGVR: new(Deployment), client.DsGVR: new(DaemonSet), client.StsGVR: new(StatefulSet), client.CjGVR: new(CronJob), client.JobGVR: new(Job), } } // ScanForRefs scans cluster resources for resource references. func ScanForRefs(ctx context.Context, f Factory) (Refs, error) { rgvr, ok := ctx.Value(internal.KeyGVR).(*client.GVR) if !ok { return nil, errors.New("expecting context GVR") } fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("expecting context Path") } wait, ok := ctx.Value(internal.KeyWait).(bool) if !ok { slog.Warn("Expecting context Wait key. Using default") } var wg sync.WaitGroup out := make(chan Refs) for gvr, scanner := range scanners() { wg.Add(1) go func(ctx context.Context, gvr *client.GVR, s RefScanner, out chan Refs, wait bool) { defer wg.Done() s.Init(f, gvr) refs, err := s.Scan(ctx, rgvr, fqn, wait) if err != nil { slog.Error("Reference scan failed for", slogs.RefType, fmt.Sprintf("%T", s), slogs.Error, err, ) return } select { case out <- refs: case <-ctx.Done(): return } }(ctx, gvr, scanner, out, wait) } go func() { wg.Wait() close(out) }() res := make(Refs, 0, 10) for refs := range out { res = append(res, refs...) } return res, nil } // ScanForSARefs scans cluster resources for serviceaccount refs. func ScanForSARefs(ctx context.Context, f Factory) (Refs, error) { fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("expecting context Path") } wait, ok := ctx.Value(internal.KeyWait).(bool) if !ok { return nil, errors.New("expecting context Wait") } var wg sync.WaitGroup out := make(chan Refs) for gvr, scanner := range scanners() { wg.Add(1) go func(ctx context.Context, gvr *client.GVR, s RefScanner, out chan Refs, wait bool) { defer wg.Done() s.Init(f, gvr) refs, err := s.ScanSA(ctx, fqn, wait) if err != nil { slog.Error("ServiceAccount scan failed", slogs.RefType, fmt.Sprintf("%T", s), slogs.Error, err, ) return } select { case out <- refs: case <-ctx.Done(): return } }(ctx, gvr, scanner, out, wait) } go func() { wg.Wait() close(out) }() res := make(Refs, 0, 10) for refs := range out { res = append(res, refs...) } return res, nil } ================================================ FILE: internal/dao/cm.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao var _ Accessor = (*ConfigMap)(nil) // ConfigMap represents a configmap resource. type ConfigMap struct { Resource } ================================================ FILE: internal/dao/container.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "strconv" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) var ( _ Accessor = (*Container)(nil) _ Loggable = (*Container)(nil) ) const ( initIDX = "I" mainIDX = "M" ephIDX = "E" ) // Container represents a pod's container dao. type Container struct { NonResource } // List returns a collection of containers. func (c *Container) List(ctx context.Context, _ string) ([]runtime.Object, error) { fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, fmt.Errorf("no context path for %q", c.gvr) } var ( cmx client.ContainersMetrics err error ) if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); ok && withMx { cmx, _ = client.DialMetrics(c.Client()).FetchContainersMetrics(ctx, fqn) } po, err := c.fetchPod(fqn) if err != nil { return nil, err } res := make([]runtime.Object, 0, len(po.Spec.InitContainers)+len(po.Spec.Containers)+len(po.Spec.EphemeralContainers)) for i := range po.Spec.InitContainers { res = append(res, makeContainerRes( initIDX, i, &(po.Spec.InitContainers[i]), po, cmx[po.Spec.InitContainers[i].Name]), ) } for i := range po.Spec.Containers { res = append(res, makeContainerRes( mainIDX, i, &(po.Spec.Containers[i]), po, cmx[po.Spec.Containers[i].Name]), ) } for i := range po.Spec.EphemeralContainers { co := v1.Container(po.Spec.EphemeralContainers[i].EphemeralContainerCommon) res = append(res, makeContainerRes( ephIDX, i, &co, po, cmx[co.Name]), ) } return res, nil } // TailLogs tails a given container logs. func (c *Container) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { po := Pod{} po.Init(c.Factory, client.PodGVR) return po.TailLogs(ctx, opts) } // ---------------------------------------------------------------------------- // Helpers... func makeContainerRes(kind string, idx int, co *v1.Container, po *v1.Pod, cmx *mv1beta1.ContainerMetrics) render.ContainerRes { return render.ContainerRes{ Idx: kind + strconv.Itoa(idx+1), Container: co, Status: getContainerStatus(kind, co.Name, &po.Status), MX: cmx, Age: po.GetCreationTimestamp(), } } func getContainerStatus(kind, name string, status *v1.PodStatus) *v1.ContainerStatus { switch kind { case mainIDX: for i := range status.ContainerStatuses { if status.ContainerStatuses[i].Name == name { return &status.ContainerStatuses[i] } } case initIDX: for i := range status.InitContainerStatuses { if status.InitContainerStatuses[i].Name == name { return &status.InitContainerStatuses[i] } } case ephIDX: for i := range status.EphemeralContainerStatuses { if status.EphemeralContainerStatuses[i].Name == name { return &status.EphemeralContainerStatuses[i] } } } return nil } func (c *Container) fetchPod(fqn string) (*v1.Pod, error) { o, err := c.getFactory().Get(client.PodGVR, fqn, true, labels.Everything()) if err != nil { return nil, fmt.Errorf("failed to locate pod %q: %w", fqn, err) } var po v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &po) return &po, err } ================================================ FILE: internal/dao/container_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery/cached/disk" "k8s.io/client-go/dynamic" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" versioned "k8s.io/metrics/pkg/client/clientset/versioned" "sigs.k8s.io/yaml" ) func TestContainerList(t *testing.T) { c := dao.Container{} c.Init(makePodFactory(), client.CoGVR) ctx := context.WithValue(context.Background(), internal.KeyPath, "fred/p1") oo, err := c.List(ctx, "") require.NoError(t, err) assert.Len(t, oo, 1) } // ---------------------------------------------------------------------------- // Helpers... type conn struct{} func makeConn() *conn { return &conn{} } func (*conn) Config() *client.Config { return nil } func (*conn) Dial() (kubernetes.Interface, error) { return nil, nil } func (*conn) DialLogs() (kubernetes.Interface, error) { return nil, nil } func (*conn) ConnectionOK() bool { return true } func (*conn) SwitchContext(string) error { return nil } func (*conn) CachedDiscovery() (*disk.CachedDiscoveryClient, error) { return nil, nil } func (*conn) RestConfig() (*restclient.Config, error) { return nil, nil } func (*conn) MXDial() (*versioned.Clientset, error) { return nil, nil } func (*conn) DynDial() (dynamic.Interface, error) { return nil, nil } func (*conn) HasMetrics() bool { return false } func (*conn) CheckConnectivity() bool { return false } func (*conn) IsNamespaced(string) bool { return false } func (*conn) SupportsResource(string) bool { return false } func (*conn) ValidNamespaces() ([]v1.Namespace, error) { return nil, nil } func (*conn) SupportsRes(string, []string) (a string, b bool, e error) { return "", false, nil } func (*conn) ServerVersion() (*version.Info, error) { return nil, nil } func (*conn) CurrentNamespaceName() (string, error) { return "", nil } func (*conn) CanI(string, *client.GVR, string, []string) (bool, error) { return true, nil } func (*conn) ActiveContext() string { return "" } func (*conn) ActiveNamespace() string { return "" } func (*conn) IsValidNamespace(string) bool { return true } func (*conn) ValidNamespaceNames() (client.NamespaceNames, error) { return nil, nil } func (*conn) IsActiveNamespace(string) bool { return false } type podFactory struct{} var _ dao.Factory = &testFactory{} func (podFactory) Client() client.Connection { return makeConn() } func (podFactory) Get(*client.GVR, string, bool, labels.Selector) (runtime.Object, error) { var m map[string]any if err := yaml.Unmarshal([]byte(poYaml()), &m); err != nil { return nil, err } return &unstructured.Unstructured{Object: m}, nil } func (podFactory) List(*client.GVR, string, bool, labels.Selector) ([]runtime.Object, error) { return nil, nil } func (podFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (podFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (podFactory) WaitForCacheSync() {} func (podFactory) Forwarders() watch.Forwarders { return nil } func (podFactory) DeleteForwarder(string) {} func makePodFactory() dao.Factory { return podFactory{} } func poYaml() string { return `apiVersion: v1 kind: Pod metadata: creationTimestamp: "2018-12-14T17:36:43Z" labels: blee: duh name: fred namespace: blee spec: containers: - env: - name: fred value: "1" valueFrom: configMapKeyRef: key: blee image: blee name: fred resources: {} priority: 1 priorityClassName: bozo volumes: - hostPath: path: /blee type: Directory name: fred status: containerStatuses: - image: "" imageID: "" lastState: {} name: fred ready: false restartCount: 0 state: running: startedAt: null phase: Running ` } ================================================ FILE: internal/dao/context.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Context)(nil) _ Switchable = (*Context)(nil) ) // Context represents a kubernetes context. type Context struct { NonResource } func (c *Context) config() *client.Config { return c.getFactory().Client().Config() } // Get a Context. func (c *Context) Get(_ context.Context, path string) (runtime.Object, error) { co, err := c.config().GetContext(path) if err != nil { return nil, err } return &render.NamedContext{Name: path, Context: co}, nil } // List all Contexts on the current cluster. func (c *Context) List(context.Context, string) ([]runtime.Object, error) { ctxs, err := c.config().Contexts() if err != nil { return nil, err } cc := make([]runtime.Object, 0, len(ctxs)) for k, v := range ctxs { cc = append(cc, render.NewNamedContext(c.config(), k, v)) } return cc, nil } // MustCurrentContextName return the active context name. func (c *Context) MustCurrentContextName() string { cl, err := c.config().CurrentContextName() if err != nil { slog.Error("Fetching current context", slogs.Error, err) } return cl } // Switch to another context. func (c *Context) Switch(ctx string) error { return c.getFactory().Client().SwitchContext(ctx) } ================================================ FILE: internal/dao/crd.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao var ( _ Accessor = (*CustomResourceDefinition)(nil) _ Nuker = (*CustomResourceDefinition)(nil) ) // CustomResourceDefinition represents a CRD resource model. type CustomResourceDefinition struct { Resource } ================================================ FILE: internal/dao/cronjob.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/rand" ) const maxJobNameSize = 42 var ( _ Accessor = (*CronJob)(nil) _ Runnable = (*CronJob)(nil) _ ImageLister = (*CronJob)(nil) ) // CronJob represents a cronjob K8s resource. type CronJob struct { Generic } // ListImages lists container images. func (c *CronJob) ListImages(_ context.Context, fqn string) ([]string, error) { cj, err := c.GetInstance(fqn) if err != nil { return nil, err } return render.ExtractImages(&cj.Spec.JobTemplate.Spec.Template.Spec), nil } // Run a CronJob. func (c *CronJob) Run(path string) error { ns, n := client.Namespaced(path) auth, err := c.Client().CanI(ns, client.JobGVR, n, []string{client.GetVerb, client.CreateVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to run jobs") } o, err := c.getFactory().Get(c.gvr, path, true, labels.Everything()) if err != nil { return err } var cj batchv1.CronJob err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cj) if err != nil { return errors.New("expecting CronJob resource") } jobName := cj.Name if len(cj.Name) >= maxJobNameSize { jobName = cj.Name[0:maxJobNameSize] } trueVal := true job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName + "-manual-" + rand.String(3), Namespace: ns, Labels: cj.Spec.JobTemplate.Labels, Annotations: cj.Spec.JobTemplate.Annotations, OwnerReferences: []metav1.OwnerReference{ { APIVersion: c.gvr.GV().String(), Kind: "CronJob", BlockOwnerDeletion: &trueVal, Controller: &trueVal, Name: cj.Name, UID: cj.UID, }, }, }, Spec: cj.Spec.JobTemplate.Spec, } dial, err := c.Client().Dial() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), c.Client().Config().CallTimeout()) defer cancel() _, err = dial.BatchV1().Jobs(ns).Create(ctx, job, metav1.CreateOptions{}) return err } // ScanSA scans for serviceaccount refs. func (c *CronJob) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := c.getFactory().List(c.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var cj batchv1.CronJob err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cj) if err != nil { return nil, errors.New("expecting CronJob resource") } if serviceAccountMatches(cj.Spec.JobTemplate.Spec.Template.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: c.GVR(), FQN: client.FQN(cj.Namespace, cj.Name), }) } } return refs, nil } // GetInstance fetch a matching cronjob. func (c *CronJob) GetInstance(fqn string) (*batchv1.CronJob, error) { o, err := c.getFactory().Get(c.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var cj batchv1.CronJob err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cj) if err != nil { return nil, errors.New("expecting cronjob resource") } return &cj, nil } // ToggleSuspend toggles suspend/resume on a CronJob. func (c *CronJob) ToggleSuspend(ctx context.Context, path string) error { ns, n := client.Namespaced(path) auth, err := c.Client().CanI(ns, c.gvr, n, []string{client.GetVerb, client.UpdateVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to (un)suspend cronjobs") } dial, err := c.Client().Dial() if err != nil { return err } cj, err := dial.BatchV1().CronJobs(ns).Get(ctx, n, metav1.GetOptions{}) if err != nil { return err } if cj.Spec.Suspend != nil { current := !*cj.Spec.Suspend cj.Spec.Suspend = ¤t } else { trueVal := true cj.Spec.Suspend = &trueVal } _, err = dial.BatchV1().CronJobs(ns).Update(ctx, cj, metav1.UpdateOptions{}) return err } // Scan scans for cluster resource refs. func (c *CronJob) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := c.getFactory().List(c.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var cj batchv1.CronJob err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cj) if err != nil { return nil, errors.New("expecting CronJob resource") } switch gvr { case client.CmGVR: if !hasConfigMap(&cj.Spec.JobTemplate.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: c.GVR(), FQN: client.FQN(cj.Namespace, cj.Name), }) case client.SecGVR: found, err := hasSecret(c.Factory, &cj.Spec.JobTemplate.Spec.Template.Spec, cj.Namespace, n, wait) if err != nil { slog.Warn("Failed to locate secret", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: c.GVR(), FQN: client.FQN(cj.Namespace, cj.Name), }) case client.PcGVR: if !hasPC(&cj.Spec.JobTemplate.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: c.GVR(), FQN: client.FQN(cj.Namespace, cj.Name), }) } } return refs, nil } ================================================ FILE: internal/dao/cruiser.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "fmt" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) func mustMap(o runtime.Object, field string) map[string]any { u, ok := o.(*unstructured.Unstructured) if !ok { panic("no unstructured") } m, ok := u.Object[field].(map[string]any) if !ok { panic(fmt.Sprintf("map extract failed for %q", field)) } return m } func mustSlice(o runtime.Object, field string) []any { u, ok := o.(*unstructured.Unstructured) if !ok { return nil } s, ok := u.Object[field].([]any) if !ok { return nil } return s } func mustField(o map[string]any, field string) any { f, ok := o[field] if !ok { panic(fmt.Sprintf("no field for %q", field)) } return f } ================================================ FILE: internal/dao/cruiser_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "encoding/json" "fmt" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) func TestCruiserMeta(t *testing.T) { o := loadJSON(t, "crb") m := mustMap(o, "metadata") assert.Equal(t, "blee", mustField(m, "name")) } func TestCruiserSlice(t *testing.T) { o := loadJSON(t, "crb") s := mustSlice(o, "subjects") assert.Len(t, s, 1) assert.Equal(t, "fernand", mustField(s[0].(map[string]any), "name")) assert.Equal(t, "User", mustField(s[0].(map[string]any), "kind")) } // Helpers... func loadJSON(t require.TestingT, n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) require.NoError(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) require.NoError(t, err) return &o } ================================================ FILE: internal/dao/describe.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" "k8s.io/kubectl/pkg/describe" ) // Describe describes a resource. func Describe(c client.Connection, gvr *client.GVR, path string) (string, error) { mapper := RestMapper{Connection: c} m, err := mapper.ToRESTMapper() if err != nil { slog.Error("No REST mapper for resource", slogs.GVR, gvr, slogs.Error, err, ) return "", err } gvk, err := m.KindFor(gvr.GVR()) if err != nil { slog.Error("No GVK for resource %s", slogs.GVR, gvr, slogs.Error, err, ) return "", err } ns, n := client.Namespaced(path) if client.IsClusterScoped(ns) { ns = client.BlankNamespace } mapping, err := mapper.ResourceFor(gvr.AsResourceName(), gvk.Kind) if err != nil { slog.Error("Unable to find mapper", slogs.GVR, gvr, slogs.ResName, n, slogs.Error, err, ) return "", err } d, err := describe.Describer(c.Config().Flags(), mapping) if err != nil { slog.Error("Unable to find describer", slogs.GVR, gvr.AsResourceName(), slogs.Error, err, ) return "", err } return d.Describe(ns, n, describe.DescriberSettings{ShowEvents: true}) } ================================================ FILE: internal/dao/dir.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "os" "path/filepath" "regexp" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "k8s.io/apimachinery/pkg/runtime" ) var _ Accessor = (*Dir)(nil) // Dir tracks standard and custom command aliases. type Dir struct { NonResource } // NewDir returns a new set of aliases. func NewDir(f Factory) *Dir { var a Dir a.Init(f, client.DirGVR) return &a } var yamlRX = regexp.MustCompile(`.*\.(yml|yaml|json)`) // List returns a collection of aliases. func (*Dir) List(ctx context.Context, _ string) ([]runtime.Object, error) { dir, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("no dir in context") } files, err := os.ReadDir(dir) if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(files)) for _, f := range files { if strings.HasPrefix(f.Name(), ".") || !f.IsDir() && !yamlRX.MatchString(f.Name()) { continue } oo = append(oo, render.DirRes{ Path: filepath.Join(dir, f.Name()), Entry: f, }) } return oo, err } // Get fetch a resource. func (*Dir) Get(_ context.Context, _ string) (runtime.Object, error) { return nil, errors.New("nyi") } ================================================ FILE: internal/dao/dir_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewDir(t *testing.T) { d := dao.NewDir(nil) ctx := context.WithValue(context.Background(), internal.KeyPath, "testdata/dir") oo, err := d.List(ctx, "") require.NoError(t, err) assert.Len(t, oo, 2) } ================================================ FILE: internal/dao/dp.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/kubectl/pkg/polymorphichelpers" "k8s.io/kubectl/pkg/scheme" ) var ( _ Accessor = (*Deployment)(nil) _ Nuker = (*Deployment)(nil) _ Loggable = (*Deployment)(nil) _ Restartable = (*Deployment)(nil) _ Scalable = (*Deployment)(nil) _ Controller = (*Deployment)(nil) _ ContainsPodSpec = (*Deployment)(nil) _ ImageLister = (*Deployment)(nil) ) // Deployment represents a deployment K8s resource. type Deployment struct { Resource } // ListImages lists container images. func (d *Deployment) ListImages(_ context.Context, fqn string) ([]string, error) { dp, err := d.GetInstance(fqn) if err != nil { return nil, err } return render.ExtractImages(&dp.Spec.Template.Spec), nil } // Scale a Deployment. func (d *Deployment) Scale(ctx context.Context, path string, replicas int32) error { return scaleRes(ctx, d.getFactory(), client.DpGVR, path, replicas) } // Restart a Deployment rollout. func (d *Deployment) Restart(ctx context.Context, path string, opts *metav1.PatchOptions) error { return restartRes[*appsv1.Deployment](ctx, d.getFactory(), client.DpGVR, path, opts) } // TailLogs tail logs for all pods represented by this Deployment. func (d *Deployment) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { dp, err := d.GetInstance(opts.Path) if err != nil { return nil, err } if dp.Spec.Selector == nil || len(dp.Spec.Selector.MatchLabels) == 0 { return nil, fmt.Errorf("no valid selector found on deployment: %s", opts.Path) } return podLogs(ctx, dp.Spec.Selector.MatchLabels, opts) } // Pod returns a pod victim by name. func (d *Deployment) Pod(fqn string) (string, error) { dp, err := d.GetInstance(fqn) if err != nil { return "", err } return podFromSelector(d.Factory, dp.Namespace, dp.Spec.Selector.MatchLabels) } // GetInstance fetch a matching deployment. func (d *Deployment) GetInstance(fqn string) (*appsv1.Deployment, error) { o, err := d.Factory.Get(d.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var dp appsv1.Deployment err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) if err != nil { return nil, errors.New("expecting Deployment resource") } return &dp, nil } // ScanSA scans for serviceaccount refs. func (d *Deployment) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := d.getFactory().List(d.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var dp appsv1.Deployment err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) if err != nil { return nil, errors.New("expecting Deployment resource") } if serviceAccountMatches(dp.Spec.Template.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(dp.Namespace, dp.Name), }) } } return refs, nil } // Scan scans for resource references. func (d *Deployment) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := d.getFactory().List(d.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var dp appsv1.Deployment err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &dp) if err != nil { return nil, errors.New("expecting Deployment resource") } switch gvr { case client.CmGVR: if !hasConfigMap(&dp.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(dp.Namespace, dp.Name), }) case client.SecGVR: found, err := hasSecret(d.Factory, &dp.Spec.Template.Spec, dp.Namespace, n, wait) if err != nil { slog.Warn("Fail to locate secret", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(dp.Namespace, dp.Name), }) case client.PvcGVR: if !hasPVC(&dp.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(dp.Namespace, dp.Name), }) case client.PcGVR: if !hasPC(&dp.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(dp.Namespace, dp.Name), }) } } return refs, nil } // GetPodSpec returns a pod spec given a resource. func (d *Deployment) GetPodSpec(path string) (*v1.PodSpec, error) { dp, err := d.GetInstance(path) if err != nil { return nil, err } podSpec := dp.Spec.Template.Spec return &podSpec, nil } // SetImages sets container images. func (d *Deployment) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { ns, n := client.Namespaced(path) auth, err := d.Client().CanI(ns, d.gvr, n, client.PatchAccess) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to patch a deployment") } jsonPatch, err := GetTemplateJsonPatch(imageSpecs) if err != nil { return err } dial, err := d.Client().Dial() if err != nil { return err } _, err = dial.AppsV1().Deployments(ns).Patch( ctx, n, types.StrategicMergePatchType, jsonPatch, metav1.PatchOptions{}, ) return err } // Helpers... func hasPVC(spec *v1.PodSpec, name string) bool { for i := range spec.Volumes { if spec.Volumes[i].PersistentVolumeClaim != nil && spec.Volumes[i].PersistentVolumeClaim.ClaimName == name { return true } } return false } func hasPC(spec *v1.PodSpec, name string) bool { return spec.PriorityClassName == name } func hasConfigMap(spec *v1.PodSpec, name string) bool { for i := range spec.InitContainers { if containerHasConfigMap(spec.InitContainers[i].EnvFrom, spec.InitContainers[i].Env, name) { return true } } for i := range spec.Containers { if containerHasConfigMap(spec.Containers[i].EnvFrom, spec.Containers[i].Env, name) { return true } } for i := range spec.EphemeralContainers { if containerHasConfigMap(spec.EphemeralContainers[i].EnvFrom, spec.EphemeralContainers[i].Env, name) { return true } } for i := range spec.Volumes { if cm := spec.Volumes[i].ConfigMap; cm != nil { if cm.Name == name { return true } } } return false } func hasSecret(f Factory, spec *v1.PodSpec, ns, name string, wait bool) (bool, error) { for i := range spec.InitContainers { if containerHasSecret(spec.InitContainers[i].EnvFrom, spec.InitContainers[i].Env, name) { return true, nil } } for i := range spec.Containers { if containerHasSecret(spec.Containers[i].EnvFrom, spec.Containers[i].Env, name) { return true, nil } } for i := range spec.EphemeralContainers { if containerHasSecret(spec.EphemeralContainers[i].EnvFrom, spec.EphemeralContainers[i].Env, name) { return true, nil } } for _, s := range spec.ImagePullSecrets { if s.Name == name { return true, nil } } if saName := spec.ServiceAccountName; saName != "" { o, err := f.Get(client.SaGVR, client.FQN(ns, saName), wait, labels.Everything()) if err != nil { return false, err } var sa v1.ServiceAccount err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sa) if err != nil { return false, errors.New("expecting ServiceAccount resource") } for _, ref := range sa.Secrets { if ref.Namespace == ns && ref.Name == name { return true, nil } } } for i := range spec.Volumes { if sec := spec.Volumes[i].Secret; sec != nil { if sec.SecretName == name { return true, nil } } } return false, nil } func containerHasSecret(envFrom []v1.EnvFromSource, env []v1.EnvVar, name string) bool { for _, e := range envFrom { if e.SecretRef != nil && e.SecretRef.Name == name { return true } } for _, e := range env { if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { continue } if e.ValueFrom.SecretKeyRef.Name == name { return true } } return false } func containerHasConfigMap(envFrom []v1.EnvFromSource, env []v1.EnvVar, name string) bool { for _, e := range envFrom { if e.ConfigMapRef != nil && e.ConfigMapRef.Name == name { return true } } for _, e := range env { if e.ValueFrom == nil || e.ValueFrom.ConfigMapKeyRef == nil { continue } if e.ValueFrom.ConfigMapKeyRef.Name == name { return true } } return false } func scaleRes(ctx context.Context, f Factory, gvr *client.GVR, path string, replicas int32) error { ns, n := client.Namespaced(path) auth, err := f.Client().CanI(ns, client.NewGVR(gvr.String()+":scale"), n, []string{client.GetVerb, client.UpdateVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to scale: %s", gvr) } dial, err := f.Client().Dial() if err != nil { return err } switch gvr { case client.DpGVR: scale, e := dial.AppsV1().Deployments(ns).GetScale(ctx, n, metav1.GetOptions{}) if e != nil { return e } scale.Spec.Replicas = replicas _, e = dial.AppsV1().Deployments(ns).UpdateScale(ctx, n, scale, metav1.UpdateOptions{}) return e case client.StsGVR: scale, e := dial.AppsV1().StatefulSets(ns).GetScale(ctx, n, metav1.GetOptions{}) if e != nil { return e } scale.Spec.Replicas = replicas _, e = dial.AppsV1().StatefulSets(ns).UpdateScale(ctx, n, scale, metav1.UpdateOptions{}) return e default: return fmt.Errorf("unsupported resource for scaling: %s", gvr) } } func restartRes[T runtime.Object](ctx context.Context, f Factory, gvr *client.GVR, path string, opts *metav1.PatchOptions) error { o, err := f.Get(gvr, path, true, labels.Everything()) if err != nil { return err } var r = new(T) err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, r) if err != nil { return err } ns, n := client.Namespaced(path) auth, err := f.Client().CanI(ns, gvr, n, client.PatchAccess) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to restart %q", gvr) } dial, err := f.Client().Dial() if err != nil { return err } before, err := runtime.Encode(scheme.Codecs.LegacyCodec(appsv1.SchemeGroupVersion), *r) if err != nil { return err } after, err := polymorphichelpers.ObjectRestarterFn(*r) if err != nil { return err } diff, err := strategicpatch.CreateTwoWayMergePatch(before, after, *r) if err != nil { return err } switch gvr { case client.DpGVR: _, err = dial.AppsV1().Deployments(ns).Patch( ctx, n, types.StrategicMergePatchType, diff, *opts, ) case client.DsGVR: _, err = dial.AppsV1().DaemonSets(ns).Patch( ctx, n, types.StrategicMergePatchType, diff, *opts, ) case client.StsGVR: _, err = dial.AppsV1().StatefulSets(ns).Patch( ctx, n, types.StrategicMergePatchType, diff, *opts, ) } return err } ================================================ FILE: internal/dao/ds.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/watch" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) var ( _ Accessor = (*DaemonSet)(nil) _ Nuker = (*DaemonSet)(nil) _ Loggable = (*DaemonSet)(nil) _ Restartable = (*DaemonSet)(nil) _ Controller = (*DaemonSet)(nil) _ ContainsPodSpec = (*DaemonSet)(nil) _ ImageLister = (*DaemonSet)(nil) ) // DaemonSet represents a K8s daemonset. type DaemonSet struct { Resource } // ListImages lists container images. func (d *DaemonSet) ListImages(_ context.Context, fqn string) ([]string, error) { ds, err := d.GetInstance(fqn) if err != nil { return nil, err } return render.ExtractImages(&ds.Spec.Template.Spec), nil } // Restart a DaemonSet rollout. func (d *DaemonSet) Restart(ctx context.Context, path string, opts *metav1.PatchOptions) error { return restartRes[*appsv1.DaemonSet](ctx, d.getFactory(), client.DsGVR, path, opts) } // TailLogs tail logs for all pods represented by this DaemonSet. func (d *DaemonSet) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { ds, err := d.GetInstance(opts.Path) if err != nil { return nil, err } if ds.Spec.Selector == nil || len(ds.Spec.Selector.MatchLabels) == 0 { return nil, fmt.Errorf("no valid selector found on daemonset %q", opts.Path) } return podLogs(ctx, ds.Spec.Selector.MatchLabels, opts) } func podLogs(ctx context.Context, sel map[string]string, opts *LogOptions) ([]LogChan, error) { f, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { return nil, errors.New("expecting a context factory") } ls, err := metav1.ParseToLabelSelector(toSelector(sel)) if err != nil { return nil, err } lsel, err := metav1.LabelSelectorAsSelector(ls) if err != nil { return nil, err } ns, _ := client.Namespaced(opts.Path) oo, err := f.List(client.PodGVR, ns, true, lsel) if err != nil { return nil, err } opts.MultiPods = true var po Pod po.Init(f, client.PodGVR) outs := make([]LogChan, 0, len(oo)) for _, o := range oo { u, ok := o.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("expected unstructured got %t", o) } opts = opts.Clone() opts.Path = client.FQN(u.GetNamespace(), u.GetName()) cc, err := po.TailLogs(ctx, opts) if err != nil { return nil, err } outs = append(outs, cc...) } return outs, nil } // Pod returns a pod victim by name. func (d *DaemonSet) Pod(fqn string) (string, error) { ds, err := d.GetInstance(fqn) if err != nil { return "", err } return podFromSelector(d.Factory, ds.Namespace, ds.Spec.Selector.MatchLabels) } // GetInstance returns a daemonset instance. func (d *DaemonSet) GetInstance(fqn string) (*appsv1.DaemonSet, error) { o, err := d.getFactory().Get(d.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var ds appsv1.DaemonSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) if err != nil { return nil, errors.New("expecting DaemonSet resource") } return &ds, nil } // ScanSA scans for serviceaccount refs. func (d *DaemonSet) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := d.getFactory().List(d.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var ds appsv1.DaemonSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) if err != nil { return nil, errors.New("expecting DaemonSet resource") } if serviceAccountMatches(ds.Spec.Template.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(ds.Namespace, ds.Name), }) } } return refs, nil } // Scan scans for cluster refs. func (d *DaemonSet) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := d.getFactory().List(d.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var ds appsv1.DaemonSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ds) if err != nil { return nil, errors.New("expecting StatefulSet resource") } switch gvr { case client.CmGVR: if !hasConfigMap(&ds.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(ds.Namespace, ds.Name), }) case client.SecGVR: found, err := hasSecret(d.Factory, &ds.Spec.Template.Spec, ds.Namespace, n, wait) if err != nil { slog.Warn("Unable to locate secret", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(ds.Namespace, ds.Name), }) case client.PvcGVR: if !hasPVC(&ds.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(ds.Namespace, ds.Name), }) case client.PcGVR: if !hasPC(&ds.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: d.GVR(), FQN: client.FQN(ds.Namespace, ds.Name), }) } } return refs, nil } // GetPodSpec returns a pod spec given a resource. func (d *DaemonSet) GetPodSpec(path string) (*v1.PodSpec, error) { ds, err := d.GetInstance(path) if err != nil { return nil, err } podSpec := ds.Spec.Template.Spec return &podSpec, nil } // SetImages sets container images. func (d *DaemonSet) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { ns, n := client.Namespaced(path) auth, err := d.Client().CanI(ns, d.gvr, n, client.PatchAccess) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to patch a daemonset") } jsonPatch, err := GetTemplateJsonPatch(imageSpecs) if err != nil { return err } dial, err := d.Client().Dial() if err != nil { return err } _, err = dial.AppsV1().DaemonSets(ns).Patch( ctx, n, types.StrategicMergePatchType, jsonPatch, metav1.PatchOptions{}, ) return err } // ---------------------------------------------------------------------------- // Helpers... func toSelector(m map[string]string) string { s := make([]string, 0, len(m)) for k, v := range m { s = append(s, k+"="+v) } return strings.Join(s, ",") } ================================================ FILE: internal/dao/dynamic.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/rest" cmdutil "k8s.io/kubectl/pkg/cmd/util" ) type Dynamic struct { Generic } // Get returns a given resource as a table object. func (d *Dynamic) Get(ctx context.Context, path string) (runtime.Object, error) { oo, err := d.toTable(ctx, path) if err != nil || len(oo) == 0 { return nil, err } return oo[0], nil } // List returns a collection of resources as one or more table objects. func (d *Dynamic) List(ctx context.Context, ns string) ([]runtime.Object, error) { return d.toTable(ctx, ns+"/") } func (d *Dynamic) toTable(ctx context.Context, fqn string) ([]runtime.Object, error) { sel := labels.Everything() if s, ok := ctx.Value(internal.KeyLabels).(labels.Selector); ok { sel = s } opts := []string{d.gvr.AsResourceName()} ns, n := client.Namespaced(fqn) if n != "" { opts = append(opts, n) } allNS := client.IsAllNamespaces(ns) flags := cmdutil.NewMatchVersionFlags(d.getFactory().Client().Config().Flags()) f := cmdutil.NewFactory(flags) b := f.NewBuilder(). Unstructured(). NamespaceParam(ns).DefaultNamespace().AllNamespaces(allNS). LabelSelectorParam(sel.String()). FieldSelectorParam(""). RequestChunksOf(0). ResourceTypeOrNameArgs(true, opts...). ContinueOnError(). Latest(). Flatten(). TransformRequests(d.transformRequests). Do() if err := b.Err(); err != nil { return nil, err } infos, err := b.Infos() if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(infos)) for _, info := range infos { o, err := decodeIntoTable(info.Object, allNS) if err != nil { return nil, err } oo = append(oo, o.(*metav1.Table)) } return oo, nil } var recognizedTableVersions = map[schema.GroupVersionKind]bool{ metav1beta1.SchemeGroupVersion.WithKind("Table"): true, metav1.SchemeGroupVersion.WithKind("Table"): true, } func decodeIntoTable(obj runtime.Object, allNs bool) (runtime.Object, error) { event, isEvent := obj.(*metav1.WatchEvent) if isEvent { obj = event.Object.Object } if !recognizedTableVersions[obj.GetObjectKind().GroupVersionKind()] { return nil, fmt.Errorf("attempt to decode non-Table object: %v", obj.GetObjectKind().GroupVersionKind()) } u, ok := obj.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("attempt to decode non-Unstructured object") } var table metav1.Table if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &table); err != nil { return nil, err } if allNs { defs := make([]metav1.TableColumnDefinition, 0, len(table.ColumnDefinitions)+1) defs = append(defs, metav1.TableColumnDefinition{Name: "Namespace", Type: "string"}) defs = append(defs, table.ColumnDefinitions...) table.ColumnDefinitions = defs } for i := range table.Rows { row := &table.Rows[i] if row.Object.Raw == nil || row.Object.Object != nil { continue } converted, err := runtime.Decode(unstructured.UnstructuredJSONScheme, row.Object.Raw) if err != nil { return nil, err } row.Object.Object = converted var m metav1.Object if obj := row.Object.Object; obj != nil { m, _ = meta.Accessor(obj) } var ns string if m != nil { ns = m.GetNamespace() } if allNs { cells := make([]any, 0, len(row.Cells)+1) cells = append(cells, ns) cells = append(cells, row.Cells...) row.Cells = cells } } if isEvent { event.Object.Object = &table return event, nil } return &table, nil } func (d *Dynamic) transformRequests(req *rest.Request) { req.SetHeader("Accept", strings.Join([]string{ fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName), fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName), "application/json", }, ",")) if d.includeObj { req.Param("includeObject", "Object") } } ================================================ FILE: internal/dao/generic.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/dynamic" ) type Grace int64 const ( // DefaultGrace uses delete default termination policy. DefaultGrace Grace = -1 // ForceGrace sets delete grace-period to 0. ForceGrace Grace = 0 // NowGrace set delete grace-period to 1, NowGrace Grace = 1 ) var _ Describer = (*Generic)(nil) // Generic represents a generic resource. type Generic struct { NonResource } // List returns a collection of resources. // BOZO!! no auth check?? func (g *Generic) List(ctx context.Context, ns string) ([]runtime.Object, error) { labelSel, ok := ctx.Value(internal.KeyLabels).(labels.Selector) if !ok { labelSel = labels.Everything() } if client.IsAllNamespace(ns) { ns = client.BlankNamespace } dial, err := g.dynClient() if err != nil { return nil, err } opts := metav1.ListOptions{LabelSelector: labelSel.String()} var ll *unstructured.UnstructuredList if client.IsClusterScoped(ns) { ll, err = dial.List(ctx, opts) } else { ll, err = dial.Namespace(ns).List(ctx, opts) } if err != nil { return nil, err } oo := make([]runtime.Object, len(ll.Items)) for i := range ll.Items { oo[i] = &ll.Items[i] } return oo, nil } // Get returns a given resource. func (g *Generic) Get(ctx context.Context, path string) (runtime.Object, error) { ns, n := client.Namespaced(path) dial, err := g.dynClient() if err != nil { return nil, err } var opts metav1.GetOptions if client.IsClusterScoped(ns) { return dial.Get(ctx, n, opts) } return dial.Namespace(ns).Get(ctx, n, opts) } // Describe describes a resource. func (g *Generic) Describe(path string) (string, error) { return Describe(g.Client(), g.gvr, path) } // ToYAML returns a resource yaml. func (g *Generic) ToYAML(path string, showManaged bool) (string, error) { o, err := g.Get(context.Background(), path) if err != nil { return "", err } raw, err := ToYAML(o, showManaged) if err != nil { return "", fmt.Errorf("unable to marshal resource %w", err) } return raw, nil } // Delete deletes a resource. func (g *Generic) Delete(ctx context.Context, path string, propagation *metav1.DeletionPropagation, grace Grace) error { ns, n := client.Namespaced(path) auth, err := g.Client().CanI(ns, g.gvr, n, []string{client.DeleteVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to delete %s", path) } var gracePeriod *int64 if grace != DefaultGrace { gracePeriod = (*int64)(&grace) } opts := metav1.DeleteOptions{ PropagationPolicy: propagation, GracePeriodSeconds: gracePeriod, } dial, err := g.dynClient() if err != nil { return err } if client.IsClusterScoped(ns) { return dial.Delete(ctx, n, opts) } ctx, cancel := context.WithTimeout(ctx, g.Client().Config().CallTimeout()) defer cancel() return dial.Namespace(ns).Delete(ctx, n, opts) } func (g *Generic) dynClient() (dynamic.NamespaceableResourceInterface, error) { dial, err := g.Client().DynDial() if err != nil { return nil, err } return dial.Resource(g.gvr.GVR()), nil } ================================================ FILE: internal/dao/helm_chart.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "log/slog" "os" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/render/helm" "github.com/derailed/k9s/internal/slogs" "helm.sh/helm/v3/pkg/action" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/genericclioptions" ) var ( _ Accessor = (*HelmChart)(nil) _ Nuker = (*HelmChart)(nil) _ Describer = (*HelmChart)(nil) _ Valuer = (*HelmChart)(nil) ) // HelmChart represents a helm chart. type HelmChart struct { NonResource } // List returns a collection of resources. func (h *HelmChart) List(_ context.Context, ns string) ([]runtime.Object, error) { cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return nil, err } list := action.NewList(cfg) list.All = true list.SetStateMask() rr, err := list.Run() if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(rr)) for _, r := range rr { oo = append(oo, helm.ReleaseRes{Release: r}) } return oo, nil } // Get returns a resource. func (h *HelmChart) Get(_ context.Context, path string) (runtime.Object, error) { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return nil, err } resp, err := action.NewGet(cfg).Run(n) if err != nil { return nil, err } return helm.ReleaseRes{Release: resp}, nil } // GetValues returns values for a release func (h *HelmChart) GetValues(path string, allValues bool) ([]byte, error) { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return nil, err } vals := action.NewGetValues(cfg) vals.AllValues = allValues resp, err := vals.Run(n) if err != nil { return nil, err } return data.WriteYAML(resp) } // Describe returns the chart notes. func (h *HelmChart) Describe(path string) (string, error) { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return "", err } resp, err := action.NewGet(cfg).Run(n) if err != nil { return "", err } return resp.Info.Notes, nil } // ToYAML returns the chart manifest. func (h *HelmChart) ToYAML(path string, _ bool) (string, error) { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return "", err } resp, err := action.NewGet(cfg).Run(n) if err != nil { return "", err } return resp.Manifest, nil } // Delete uninstall a HelmChart. func (h *HelmChart) Delete(_ context.Context, path string, _ *metav1.DeletionPropagation, _ Grace) error { return h.Uninstall(path, false) } // Uninstall uninstalls a HelmChart. func (h *HelmChart) Uninstall(path string, keepHist bool) error { ns, n := client.Namespaced(path) flags := h.Client().Config().Flags() cfg, err := ensureHelmConfig(flags, ns) if err != nil { return err } u := action.NewUninstall(cfg) u.KeepHistory = keepHist res, err := u.Run(n) if err != nil { return err } if res != nil && res.Info != "" { return fmt.Errorf("%s", res.Info) } return nil } // ensureHelmConfig return a new configuration. func ensureHelmConfig(flags *genericclioptions.ConfigFlags, ns string) (*action.Configuration, error) { settings := &genericclioptions.ConfigFlags{ Namespace: &ns, Context: flags.Context, BearerToken: flags.BearerToken, APIServer: flags.APIServer, CAFile: flags.CAFile, KubeConfig: flags.KubeConfig, Impersonate: flags.Impersonate, Insecure: flags.Insecure, TLSServerName: flags.TLSServerName, ImpersonateGroup: flags.ImpersonateGroup, WrapConfigFn: flags.WrapConfigFn, } cfg := new(action.Configuration) err := cfg.Init(settings, ns, os.Getenv("HELM_DRIVER"), helmLogger) return cfg, err } func helmLogger(fmat string, args ...any) { slog.Debug("Log", slogs.Log, fmt.Sprintf(fmat, args...), slogs.Subsys, "helm", ) } ================================================ FILE: internal/dao/helm_history.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "strconv" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/render/helm" "helm.sh/helm/v3/pkg/action" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*HelmHistory)(nil) _ Nuker = (*HelmHistory)(nil) _ Describer = (*HelmHistory)(nil) _ Valuer = (*HelmHistory)(nil) ) // HelmHistory represents a helm chart. type HelmHistory struct { NonResource } // List returns a collection of resources. func (h *HelmHistory) List(ctx context.Context, _ string) ([]runtime.Object, error) { path, ok := ctx.Value(internal.KeyFQN).(string) if !ok { return nil, fmt.Errorf("expecting FQN in context") } ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return nil, err } hh, err := action.NewHistory(cfg).Run(n) if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(hh)) for _, r := range hh { oo = append(oo, helm.ReleaseRes{Release: r}) } return oo, nil } // Get returns a resource. func (h *HelmHistory) Get(_ context.Context, path string) (runtime.Object, error) { fqn, rev, found := strings.Cut(path, ":") if !found || rev == "" { return nil, fmt.Errorf("invalid path %q", path) } ns, n := client.Namespaced(fqn) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return nil, err } getter := action.NewGet(cfg) getter.Version, err = strconv.Atoi(rev) if err != nil { return nil, err } resp, err := getter.Run(n) if err != nil { return nil, err } return helm.ReleaseRes{Release: resp}, nil } // Describe returns the chart notes. func (h *HelmHistory) Describe(path string) (string, error) { rel, err := h.Get(context.Background(), path) if err != nil { return "", err } resp, ok := rel.(helm.ReleaseRes) if !ok { return "", fmt.Errorf("expected helm.ReleaseRes, but got %T", rel) } return resp.Release.Info.Notes, nil } // ToYAML returns the chart manifest. func (h *HelmHistory) ToYAML(path string, _ bool) (string, error) { rel, err := h.Get(context.Background(), path) if err != nil { return "", err } resp, ok := rel.(helm.ReleaseRes) if !ok { return "", fmt.Errorf("expected helm.ReleaseRes, but got %T", rel) } return resp.Release.Manifest, nil } // GetValues return the config for this chart. func (h *HelmHistory) GetValues(path string, allValues bool) ([]byte, error) { rel, err := h.Get(context.Background(), path) if err != nil { return nil, err } resp, ok := rel.(helm.ReleaseRes) if !ok { return nil, fmt.Errorf("expected helm.ReleaseRes, but got %T", rel) } var content any if allValues { content = resp.Release.Chart.Values } else { content = resp.Release.Config } return data.WriteYAML(content) } func (h *HelmHistory) Rollback(_ context.Context, path, rev string) error { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return err } ver, err := strconv.Atoi(rev) if err != nil { return fmt.Errorf("could not convert revision to a number: %w", err) } clt := action.NewRollback(cfg) clt.Version = ver return clt.Run(n) } // Delete uninstall a Helm. func (h *HelmHistory) Delete(_ context.Context, path string, _ *metav1.DeletionPropagation, _ Grace) error { ns, n := client.Namespaced(path) cfg, err := ensureHelmConfig(h.Client().Config().Flags(), ns) if err != nil { return err } res, err := action.NewUninstall(cfg).Run(n) if err != nil { return err } if res != nil && res.Info != "" { return fmt.Errorf("%s", res.Info) } return nil } ================================================ FILE: internal/dao/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "bytes" "errors" "fmt" "log/slog" "math" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/printers" ) const ( defaultServiceAccount = "default" // DefaultContainerAnnotation represents the annotation key for the default container. DefaultContainerAnnotation = "kubectl.kubernetes.io/default-container" ) // GetDefaultContainer returns a container name if specified in an annotation. func GetDefaultContainer(m *metav1.ObjectMeta, spec *v1.PodSpec) (string, bool) { defaultContainer, ok := m.Annotations[DefaultContainerAnnotation] if !ok { return "", false } for i := range spec.Containers { if spec.Containers[i].Name == defaultContainer { return defaultContainer, true } } slog.Warn("Container not found. Annotation ignored", slogs.Container, defaultContainer, slogs.Annotation, DefaultContainerAnnotation, ) return "", false } func extractFQN(o runtime.Object) string { u, ok := o.(*unstructured.Unstructured) if !ok { slog.Error("Expecting unstructured", slogs.ResType, fmt.Sprintf("%T", o)) return client.NA } return FQN(u.GetNamespace(), u.GetName()) } // FQN returns a fully qualified resource name. func FQN(ns, n string) string { if ns == "" { return n } return ns + "/" + n } func inList(ll []string, s string) bool { for _, l := range ll { if l == s { return true } } return false } func toPerc(v, dv float64) float64 { if dv == 0 { return 0 } return math.Round((v / dv) * 100) } // ToYAML converts a resource to its YAML representation. func ToYAML(o runtime.Object, showManaged bool) (string, error) { if o == nil { return "", errors.New("no object to yamlize") } var p printers.ResourcePrinter = &printers.YAMLPrinter{} if !showManaged { o = o.DeepCopyObject() p = &printers.OmitManagedFieldsPrinter{Delegate: p} } var buff bytes.Buffer if err := p.PrintObj(o, &buff); err != nil { slog.Error("PrintObj failed", slogs.Error, err) return "", err } return buff.String(), nil } // serviceAccountMatches validates that the ServiceAccount referenced in the PodSpec matches the incoming // ServiceAccount. If the PodSpec ServiceAccount is blank kubernetes will use the "default" ServiceAccount // when deploying the pod, so if the incoming SA is "default" and podSA is an empty string that is also a match. func serviceAccountMatches(podSA, saName string) bool { if podSA == "" { podSA = defaultServiceAccount } return podSA == saName } // ContinuousRanges takes a sorted slice of integers and returns a slice of // sub-slices representing continuous ranges of integers. func ContinuousRanges(indexes []int) [][]int { var ranges [][]int for i, p := 1, 0; i <= len(indexes); i++ { if i == len(indexes) || indexes[i]-indexes[p] != i-p { ranges = append(ranges, []int{indexes[p], indexes[i-1] + 1}) p = i } } return ranges } ================================================ FILE: internal/dao/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "testing" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" ) func TestToPerc(t *testing.T) { uu := []struct { v1, v2, e float64 }{ {0, 0, 0}, {100, 200, 50}, {200, 100, 200}, } for _, u := range uu { //nolint:testifylint assert.Equal(t, u.e, toPerc(u.v1, u.v2)) } } func TestServiceAccountMatches(t *testing.T) { uu := []struct { podTemplate *v1.PodSpec saName string expect bool }{ {podTemplate: &v1.PodSpec{ ServiceAccountName: "", }, saName: defaultServiceAccount, expect: true, }, {podTemplate: &v1.PodSpec{ ServiceAccountName: "", }, saName: "foo", expect: false, }, {podTemplate: &v1.PodSpec{ ServiceAccountName: "foo", }, saName: "foo", expect: true, }, {podTemplate: &v1.PodSpec{ ServiceAccountName: "foo", }, saName: "bar", expect: false, }, } for _, u := range uu { assert.Equal(t, u.expect, serviceAccountMatches(u.podTemplate.ServiceAccountName, u.saName)) } } func TestContinuousRanges(t *testing.T) { tests := []struct { Indexes []int Ranges [][]int }{ { Indexes: []int{0}, Ranges: [][]int{{0, 1}}, }, { Indexes: []int{1}, Ranges: [][]int{{1, 2}}, }, { Indexes: []int{0, 1, 2}, Ranges: [][]int{{0, 3}}, }, { Indexes: []int{4, 5, 6}, Ranges: [][]int{{4, 7}}, }, { Indexes: []int{0, 2, 4, 5, 6}, Ranges: [][]int{{0, 1}, {2, 3}, {4, 7}}, }, } for _, tt := range tests { assert.Equal(t, tt.Ranges, ContinuousRanges(tt.Indexes)) } } ================================================ FILE: internal/dao/img_scan.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/vul" "k8s.io/apimachinery/pkg/runtime" ) var _ Accessor = (*ImageScan)(nil) // ImageScan represents vulnerability scans. type ImageScan struct { NonResource } func (is *ImageScan) listImages(ctx context.Context, gvr *client.GVR, path string) ([]string, error) { res, err := AccessorFor(is.Factory, gvr) if err != nil { return nil, err } s, ok := res.(ImageLister) if !ok { return nil, fmt.Errorf("resource %s is not image lister: %T", gvr, res) } return s.ListImages(ctx, path) } // List returns a collection of scans. func (is *ImageScan) List(ctx context.Context, _ string) ([]runtime.Object, error) { fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, fmt.Errorf("no context path for %q", is.gvr) } gvr, ok := ctx.Value(internal.KeyGVR).(*client.GVR) if !ok { return nil, fmt.Errorf("no context gvr for %q", is.gvr) } ii, err := is.listImages(ctx, gvr, fqn) if err != nil { return nil, err } res := make([]runtime.Object, 0, len(ii)) for _, img := range ii { s, ok := vul.ImgScanner.GetScan(img) if !ok { continue } for _, r := range s.Table.Rows { res = append(res, render.ImageScanRes{Image: img, Row: r}) } } return res, nil } ================================================ FILE: internal/dao/job.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" batchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Job)(nil) _ Nuker = (*Job)(nil) _ Loggable = (*Job)(nil) _ ImageLister = (*Deployment)(nil) ) // Job represents a K8s job resource. type Job struct { Resource } // ListImages lists container images. func (j *Job) ListImages(_ context.Context, fqn string) ([]string, error) { job, err := j.GetInstance(fqn) if err != nil { return nil, err } return render.ExtractImages(&job.Spec.Template.Spec), nil } // List returns a collection of resources. func (j *Job) List(ctx context.Context, ns string) ([]runtime.Object, error) { oo, err := j.Resource.List(ctx, ns) if err != nil { return nil, err } ctrl, _ := ctx.Value(internal.KeyPath).(string) _, n := client.Namespaced(ctrl) ll := make([]runtime.Object, 0, 10) for _, o := range oo { var j batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &j) if err != nil { return nil, errors.New("expecting Job resource") } if n == "" { ll = append(ll, o) continue } for _, r := range j.OwnerReferences { if r.Name == n { ll = append(ll, o) } } } return ll, nil } // TailLogs tail logs for all pods represented by this Job. func (j *Job) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { o, err := j.getFactory().Get(j.gvr, opts.Path, true, labels.Everything()) if err != nil { return nil, err } var job batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &job) if err != nil { return nil, errors.New("expecting a job resource") } if job.Spec.Selector == nil || len(job.Spec.Selector.MatchLabels) == 0 { return nil, fmt.Errorf("no valid selector found for job: %s", opts.Path) } return podLogs(ctx, job.Spec.Selector.MatchLabels, opts) } func (j *Job) GetInstance(fqn string) (*batchv1.Job, error) { o, err := j.getFactory().Get(j.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var job batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &job) if err != nil { return nil, errors.New("expecting a job resource") } return &job, nil } // ScanSA scans for serviceaccount refs. func (j *Job) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := j.getFactory().List(j.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var job batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &job) if err != nil { return nil, errors.New("expecting Job resource") } if serviceAccountMatches(job.Spec.Template.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: j.GVR(), FQN: client.FQN(job.Namespace, job.Name), }) } } return refs, nil } // Scan scans for resource references. func (j *Job) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := j.getFactory().List(j.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var job batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &job) if err != nil { return nil, errors.New("expecting Job resource") } switch gvr { case client.CmGVR: if !hasConfigMap(&job.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: j.GVR(), FQN: client.FQN(job.Namespace, job.Name), }) case client.SecGVR: found, err := hasSecret(j.Factory, &job.Spec.Template.Spec, job.Namespace, n, wait) if err != nil { slog.Warn("Locate secret failed", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: j.GVR(), FQN: client.FQN(job.Namespace, job.Name), }) case client.PcGVR: if !hasPC(&job.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: j.GVR(), FQN: client.FQN(job.Namespace, job.Name), }) } } return refs, nil } ================================================ FILE: internal/dao/log_item.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "bytes" ) // LogChan represents a channel for logs. type LogChan chan *LogItem var ItemEOF = new(LogItem) // LogItem represents a container log line. type LogItem struct { Pod, Container string SingleContainer bool Bytes []byte IsError bool } // NewLogItem returns a new item. func NewLogItem(bb []byte) *LogItem { return &LogItem{ Bytes: bb, } } // NewLogItemFromString returns a new item. func NewLogItemFromString(s string) *LogItem { return &LogItem{ Bytes: []byte(s), } } // ID returns pod and or container based id. func (l *LogItem) ID() string { if l.Pod != "" { return l.Pod } return l.Container } // GetTimestamp fetch log lime timestamp func (l *LogItem) GetTimestamp() string { index := bytes.Index(l.Bytes, []byte{' '}) if index < 0 { return "" } return string(l.Bytes[:index]) } // Info returns pod and container information. func (l *LogItem) Info() string { return l.Pod + "::" + l.Container } // IsEmpty checks if the entry is empty. func (l *LogItem) IsEmpty() bool { return len(l.Bytes) == 0 } // Size returns the size of the item. func (l *LogItem) Size() int { return 100 + len(l.Bytes) + len(l.Pod) + len(l.Container) } // Render returns a log line as string. func (l *LogItem) Render(paint string, showTime bool, bb *bytes.Buffer) { index := bytes.Index(l.Bytes, []byte{' '}) if showTime && index > 0 { bb.WriteString("[gray::b]") bb.Write(l.Bytes[:index]) bb.WriteString(" ") if l := 30 - len(l.Bytes[:index]); l > 0 { bb.Write(bytes.Repeat([]byte{' '}, l)) } bb.WriteString("[-::-]") } if l.Pod != "" { bb.WriteString("[" + paint + "::]" + l.Pod) } if !l.SingleContainer && l.Container != "" { if l.Pod != "" { bb.WriteString(" ") } bb.WriteString("[" + paint + "::b]" + l.Container + "[-::-] ") } else if l.Pod != "" { bb.WriteString("[-::] ") } if index > 0 { bb.Write(l.Bytes[index+1:]) } else { bb.Write(l.Bytes) } } ================================================ FILE: internal/dao/log_item_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "bytes" "fmt" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestLogItemEmpty(t *testing.T) { uu := map[string]struct { s string e bool }{ "empty": {s: "", e: true}, "full": {s: "Testing 1,2,3..."}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { i := dao.NewLogItemFromString(u.s) assert.Equal(t, u.e, i.IsEmpty()) }) } } func TestLogItemRender(t *testing.T) { uu := map[string]struct { opts dao.LogOptions log string e string }{ "empty": { opts: dao.LogOptions{}, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."), e: "Testing 1,2,3...\n", }, "container": { opts: dao.LogOptions{ Container: "fred", }, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."), e: "[yellow::b]fred[-::-] Testing 1,2,3...\n", }, "pod": { opts: dao.LogOptions{ Path: "blee/fred", Container: "blee", SingleContainer: true, }, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."), e: "[yellow::]fred [yellow::b]blee[-::-] Testing 1,2,3...\n", }, "full": { opts: dao.LogOptions{ Path: "blee/fred", Container: "blee", SingleContainer: true, ShowTimestamp: true, }, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."), e: "[gray::b]2018-12-14T10:36:43.326972-07:00 [-::-][yellow::]fred [yellow::b]blee[-::-] Testing 1,2,3...\n", }, "log-level": { opts: dao.LogOptions{ Path: "blee/fred", Container: "", SingleContainer: false, ShowTimestamp: false, }, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "2021-10-28T13:06:37Z [INFO] [blah-blah] Testing 1,2,3..."), e: "[yellow::]fred[-::] 2021-10-28T13:06:37Z [INFO[] [blah-blah[] Testing 1,2,3...\n", }, "escape": { opts: dao.LogOptions{ Path: "blee/fred", Container: "", SingleContainer: false, ShowTimestamp: false, }, log: fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", `{"foo":["bar"]} Server listening on: [::]:5000`), e: `[yellow::]fred[-::] {"foo":["bar"[]} Server listening on: [::[]:5000` + "\n", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { i := dao.NewLogItem([]byte(tview.Escape(u.log))) _, n := client.Namespaced(u.opts.Path) i.Pod, i.Container = n, u.opts.Container bb := bytes.NewBuffer(make([]byte, 0, i.Size())) i.Render("yellow", u.opts.ShowTimestamp, bb) assert.Equal(t, u.e, bb.String()) }) } } func BenchmarkLogItemRenderTS(b *testing.B) { s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3...")) i := dao.NewLogItem(s) i.Pod, i.Container = "fred", "blee" b.ResetTimer() b.ReportAllocs() for range b.N { bb := bytes.NewBuffer(make([]byte, 0, i.Size())) i.Render("yellow", true, bb) } } func BenchmarkLogItemRenderNoTS(b *testing.B) { s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3...")) i := dao.NewLogItem(s) i.Pod, i.Container = "fred", "blee" b.ResetTimer() b.ReportAllocs() for range b.N { bb := bytes.NewBuffer(make([]byte, 0, i.Size())) i.Render("yellow", false, bb) } } ================================================ FILE: internal/dao/log_items.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "bytes" "fmt" "regexp" "strings" "sync" "github.com/derailed/k9s/internal" "github.com/sahilm/fuzzy" ) type podColors map[string]string var podPalette = []string{ "teal", "green", "purple", "lime", "blue", "yellow", "fushia", "aqua", } // LogItems represents a collection of log items. type LogItems struct { items []*LogItem podColors podColors mx sync.RWMutex } // NewLogItems returns a new instance. func NewLogItems() *LogItems { return &LogItems{ podColors: make(map[string]string), } } // Items returns the log items. func (l *LogItems) Items() []*LogItem { l.mx.RLock() defer l.mx.RUnlock() return l.items } // Len returns the items length. func (l *LogItems) Len() int { l.mx.RLock() defer l.mx.RUnlock() return len(l.items) } // Clear removes all items. func (l *LogItems) Clear() { l.mx.Lock() defer l.mx.Unlock() l.items = l.items[:0] for k := range l.podColors { delete(l.podColors, k) } } // Shift scrolls the lines by one. func (l *LogItems) Shift(i *LogItem) { l.mx.Lock() defer l.mx.Unlock() l.items = append(l.items[1:], i) } // Subset return a subset of logitems. func (l *LogItems) Subset(index int) *LogItems { l.mx.RLock() defer l.mx.RUnlock() return &LogItems{ items: l.items[index:], podColors: l.podColors, } } // Merge merges two logitems list. func (l *LogItems) Merge(n *LogItems) { l.mx.Lock() defer l.mx.Unlock() l.items = append(l.items, n.items...) for k, v := range n.podColors { l.podColors[k] = v } } // Add augments the items. func (l *LogItems) Add(ii ...*LogItem) { l.mx.Lock() defer l.mx.Unlock() l.items = append(l.items, ii...) } func (l *LogItems) podColorFor(id string) string { color, ok := l.podColors[id] if ok { return color } var idx int for i, r := range id { idx += i * int(r) } l.podColors[id] = podPalette[idx%len(podPalette)] return l.podColors[id] } // Lines returns a collection of log lines. func (l *LogItems) Lines(index int, showTime bool, ll [][]byte) { l.mx.Lock() defer l.mx.Unlock() for i, item := range l.items[index:] { bb := bytes.NewBuffer(make([]byte, 0, item.Size())) item.Render(l.podColorFor(item.ID()), showTime, bb) ll[i] = bb.Bytes() } } // StrLines returns a collection of log lines. func (l *LogItems) StrLines(index int, showTime bool) []string { l.mx.Lock() defer l.mx.Unlock() ll := make([]string, len(l.items[index:])) for i, item := range l.items[index:] { bb := bytes.NewBuffer(make([]byte, 0, item.Size())) item.Render(l.podColorFor(item.ID()), showTime, bb) ll[i] = bb.String() } return ll } // Render returns logs as a collection of strings. func (l *LogItems) Render(index int, showTime bool, ll [][]byte) { for i, item := range l.items[index:] { bb := bytes.NewBuffer(make([]byte, 0, item.Size())) item.Render(l.podColorFor(item.ID()), showTime, bb) ll[i] = bb.Bytes() } } // DumpDebug for debugging. func (l *LogItems) DumpDebug(m string) { fmt.Println(m + strings.Repeat("-", 50)) for i, line := range l.items { fmt.Println(i, string(line.Bytes)) } } // Filter filters out log items based on given filter. func (l *LogItems) Filter(index int, q string, showTime bool) (matches []int, indices [][]int, err error) { if q == "" { return } if f, ok := internal.IsFuzzySelector(q); ok { matches, indices = l.fuzzyFilter(index, f, showTime) return } matches, indices, err = l.filterLogs(index, q, showTime) if err != nil { return } return matches, indices, nil } func (l *LogItems) fuzzyFilter(index int, q string, showTime bool) (matches []int, indices [][]int) { q = strings.TrimSpace(q) matches, indices = make([]int, 0, len(l.items)), make([][]int, 0, len(l.items)) mm := fuzzy.Find(q, l.StrLines(index, showTime)) for _, m := range mm { matches = append(matches, m.Index) indices = append(indices, m.MatchedIndexes) } return matches, indices } func (l *LogItems) filterLogs(index int, q string, showTime bool) (matches []int, indices [][]int, err error) { var invert bool if internal.IsInverseSelector(q) { invert = true q = q[1:] } rx, err := regexp.Compile(`(?i)` + q) if err != nil { return nil, nil, err } matches, indices = make([]int, 0, len(l.items)), make([][]int, 0, len(l.items)) ll := make([][]byte, len(l.items[index:])) l.Lines(index, showTime, ll) for i, line := range ll { locs := rx.FindAllIndex(line, -1) if locs != nil && invert { continue } if locs == nil && !invert { continue } matches = append(matches, i) ii := make([]int, 0, 10) for _, loc := range locs { for j := loc[0]; j < loc[1]; j++ { ii = append(ii, j) } } indices = append(indices, ii) } return matches, indices, nil } ================================================ FILE: internal/dao/log_items_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "fmt" "log/slog" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestLogItemsFilter(t *testing.T) { uu := map[string]struct { q string opts dao.LogOptions e []int indices [][]int err error }{ "empty": { opts: dao.LogOptions{}, }, "pod-name": { q: "blee", opts: dao.LogOptions{ Path: "fred/blee", Container: "c1", }, e: []int{0, 1, 2}, }, "container-name": { q: "c1", opts: dao.LogOptions{ Path: "fred/blee", Container: "c1", }, e: []int{0, 1, 2}, indices: [][]int{{26, 27}, {26, 27}, {26, 27}}, // matches container name "c1" at positions 26-27 in rendered format each line }, "message": { q: "zorg", opts: dao.LogOptions{ Path: "fred/blee", Container: "c1", }, e: []int{2}, }, "fuzzy": { q: "-f zorg", opts: dao.LogOptions{ Path: "fred/blee", Container: "c1", }, e: []int{2}, }, "multi-origin-text-match": { q: "will", opts: dao.LogOptions{ Path: "fred/blee", Container: "c1", }, e: []int{1, 2}, indices: [][]int{{45, 46, 47, 48, 59, 60, 61, 62}, {64, 65, 66, 67, 70, 71, 72, 73, 76, 77, 78, 79}}, }, } for k := range uu { u := uu[k] ii := dao.NewLogItems() ii.Add( dao.NewLogItem([]byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3..."))), dao.NewLogItemFromString("Bumble bee tuna. will be back. will win."), dao.NewLogItemFromString("Jean Batiste Emmanuel Zorg. wili, will. will, will"), ) t.Run(k, func(t *testing.T) { _, n := client.Namespaced(u.opts.Path) for _, i := range ii.Items() { i.Pod, i.Container = n, u.opts.Container } res, indices, err := ii.Filter(0, u.q, false) assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.e, res) if u.indices != nil { assert.Equal(t, u.indices, indices) } } }) } } func TestLogItemsRender(t *testing.T) { uu := map[string]struct { opts dao.LogOptions e string }{ "empty": { opts: dao.LogOptions{}, e: "Testing 1,2,3...\n", }, "container": { opts: dao.LogOptions{ Container: "fred", }, e: "[teal::b]fred[-::-] Testing 1,2,3...\n", }, "pod-container": { opts: dao.LogOptions{ Path: "blee/fred", Container: "blee", }, e: "[teal::]fred [teal::b]blee[-::-] Testing 1,2,3...\n", }, "full": { opts: dao.LogOptions{ Path: "blee/fred", Container: "blee", ShowTimestamp: true, }, e: "[gray::b]2018-12-14T10:36:43.326972-07:00 [-::-][teal::]fred [teal::b]blee[-::-] Testing 1,2,3...\n", }, } s := []byte(fmt.Sprintf("%s %s\n", "2018-12-14T10:36:43.326972-07:00", "Testing 1,2,3...")) for k := range uu { ii := dao.NewLogItems() ii.Add(dao.NewLogItem(s)) u := uu[k] _, n := client.Namespaced(u.opts.Path) ii.Items()[0].Pod, ii.Items()[0].Container = n, u.opts.Container t.Run(k, func(t *testing.T) { res := make([][]byte, 1) ii.Render(0, u.opts.ShowTimestamp, res) assert.Equal(t, u.e, string(res[0])) }) } } ================================================ FILE: internal/dao/log_options.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "fmt" "time" "github.com/derailed/k9s/internal/client" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // LogOptions represents logger options. type LogOptions struct { CreateDuration time.Duration Path string Container string DefaultContainer string SinceTime string Lines int64 SinceSeconds int64 Head bool Previous bool SingleContainer bool MultiPods bool ShowTimestamp bool AllContainers bool } // Info returns the option pod and container info. func (o *LogOptions) Info() string { if o.Container != "" { return fmt.Sprintf("%s (%s)", o.Path, o.Container) } return o.Path } // Clone clones options. func (o *LogOptions) Clone() *LogOptions { return &LogOptions{ Path: o.Path, Container: o.Container, DefaultContainer: o.DefaultContainer, Lines: o.Lines, Previous: o.Previous, Head: o.Head, SingleContainer: o.SingleContainer, MultiPods: o.MultiPods, ShowTimestamp: o.ShowTimestamp, SinceTime: o.SinceTime, SinceSeconds: o.SinceSeconds, AllContainers: o.AllContainers, } } // HasContainer checks if a container is present. func (o *LogOptions) HasContainer() bool { return o.Container != "" } // ToggleAllContainers toggles single or all-containers if possible. func (o *LogOptions) ToggleAllContainers() { if o.SingleContainer { return } o.AllContainers = !o.AllContainers if o.AllContainers { o.DefaultContainer, o.Container = o.Container, "" return } if o.DefaultContainer != "" { o.Container = o.DefaultContainer } } // ToPodLogOptions returns pod log options. func (o *LogOptions) ToPodLogOptions() *v1.PodLogOptions { opts := v1.PodLogOptions{ Follow: true, Timestamps: true, Container: o.Container, Previous: o.Previous, TailLines: &o.Lines, } if o.Head { var maxBytes int64 = 5000 opts.Follow = false opts.TailLines, opts.SinceSeconds, opts.SinceTime = nil, nil, nil opts.LimitBytes = &maxBytes return &opts } if o.SinceSeconds < 0 { return &opts } if o.SinceSeconds != 0 { opts.SinceSeconds, opts.SinceTime = &o.SinceSeconds, nil return &opts } if o.SinceTime == "" { return &opts } if t, err := time.Parse(time.RFC3339, o.SinceTime); err == nil { opts.SinceTime = &metav1.Time{Time: t.Add(time.Second)} } return &opts } // ToLogItem add a log header to display po/co information along with the log message. func (o *LogOptions) ToLogItem(bytes []byte) *LogItem { item := NewLogItem(bytes) if len(bytes) == 0 { return item } item.SingleContainer = o.SingleContainer if item.SingleContainer { item.Container = o.Container } if o.MultiPods { _, pod := client.Namespaced(o.Path) item.Pod, item.Container = pod, o.Container } else { item.Container = o.Container } return item } func (*LogOptions) ToErrLogItem(err error) *LogItem { t := time.Now().UTC().Format(time.RFC3339Nano) item := NewLogItem([]byte(fmt.Sprintf("%s [orange::b]%s[::-]\n", t, err))) item.IsError = true return item } ================================================ FILE: internal/dao/log_options_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "testing" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" ) func TestLogOptionsToggleAllContainers(t *testing.T) { uu := map[string]struct { opts dao.LogOptions co string want bool }{ "empty": { opts: dao.LogOptions{}, want: true, }, "container": { opts: dao.LogOptions{Container: "blee"}, want: true, }, "default-container": { opts: dao.LogOptions{AllContainers: true}, co: "blee", }, "single-container": { opts: dao.LogOptions{Container: "blee", SingleContainer: true}, co: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.opts.DefaultContainer = "blee" u.opts.ToggleAllContainers() assert.Equal(t, u.want, u.opts.AllContainers) assert.Equal(t, u.co, u.opts.Container) }) } } ================================================ FILE: internal/dao/node.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "io" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" "k8s.io/kubectl/pkg/drain" "k8s.io/kubectl/pkg/scheme" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) var ( _ Accessor = (*Node)(nil) _ NodeMaintainer = (*Node)(nil) ) // NodeMetricsFunc retrieves node metrics. type NodeMetricsFunc func() (*mv1beta1.NodeMetricsList, error) // Node represents a node model. type Node struct { Resource } // ToggleCordon toggles cordon/uncordon a node. func (n *Node) ToggleCordon(fqn string, cordon bool) error { slog.Debug("Toggle cordon on node", slogs.GVR, n.GVR(), slogs.FQN, fqn, slogs.Bool, cordon, ) o, err := FetchNode(context.Background(), n.Factory, fqn) if err != nil { return err } h, err := drain.NewCordonHelperFromRuntimeObject(o, scheme.Scheme, n.gvr.GVK()) if err != nil { slog.Debug("Fail to toggle cordon on node", slogs.FQN, fqn, slogs.Error, err, ) return err } if !h.UpdateIfRequired(cordon) { if cordon { return fmt.Errorf("node is already cordoned") } return fmt.Errorf("node is already uncordoned") } dial, err := n.getFactory().Client().Dial() if err != nil { return err } err, patchErr := h.PatchOrReplace(dial, false) if patchErr != nil { return patchErr } if err != nil { return err } return nil } func (o DrainOptions) toDrainHelper(k kubernetes.Interface, w io.Writer) drain.Helper { return drain.Helper{ Client: k, GracePeriodSeconds: o.GracePeriodSeconds, Timeout: o.Timeout, DeleteEmptyDirData: o.DeleteEmptyDirData, IgnoreAllDaemonSets: o.IgnoreAllDaemonSets, DisableEviction: o.DisableEviction, Out: w, ErrOut: w, Force: o.Force, } } // Drain drains a node. func (n *Node) Drain(path string, opts DrainOptions, w io.Writer) error { cordoned, err := n.ensureCordoned(path) if err != nil { return err } if !cordoned { if e := n.ToggleCordon(path, true); e != nil { return e } } dial, err := n.getFactory().Client().Dial() if err != nil { return err } h := opts.toDrainHelper(dial, w) dd, errs := h.GetPodsForDeletion(path) if len(errs) != 0 { for _, e := range errs { if _, err := fmt.Fprintf(h.ErrOut, "[%s] %s\n", path, e.Error()); err != nil { return err } } return errors.Join(errs...) } if err := h.DeleteOrEvictPods(dd.Pods()); err != nil { return err } _, _ = fmt.Fprintf(h.Out, "Node %s drained!", path) return nil } // Get returns a node resource. func (n *Node) Get(ctx context.Context, path string) (runtime.Object, error) { oo, err := n.Resource.List(ctx, "") if err != nil { return nil, err } var raw *unstructured.Unstructured for _, o := range oo { if u, ok := o.(*unstructured.Unstructured); ok && u.GetName() == path { raw = u } } if raw == nil { return nil, fmt.Errorf("unable to locate node %s", path) } var nmx *mv1beta1.NodeMetrics if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); ok && withMx { nmx, _ = client.DialMetrics(n.Client()).FetchNodeMetrics(ctx, path) } return &render.NodeWithMetrics{Raw: raw, MX: nmx}, nil } // List returns a collection of node resources. func (n *Node) List(ctx context.Context, ns string) ([]runtime.Object, error) { oo, err := n.Resource.List(ctx, ns) if err != nil { return oo, err } var nmx client.NodesMetricsMap if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); withMx || !ok { nmx, _ = client.DialMetrics(n.Client()).FetchNodesMetricsMap(ctx) } shouldCountPods, _ := ctx.Value(internal.KeyPodCounting).(bool) var pods []runtime.Object if shouldCountPods { pods, err = n.getFactory().List(client.PodGVR, client.BlankNamespace, false, labels.Everything()) if err != nil { slog.Error("Unable to list pods", slogs.Error, err) } } res := make([]runtime.Object, 0, len(oo)) for _, o := range oo { u, ok := o.(*unstructured.Unstructured) if !ok { return res, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) } fqn := extractFQN(o) _, name := client.Namespaced(fqn) podCount := -1 if shouldCountPods { podCount, err = n.CountPods(pods, name) if err != nil { slog.Error("Unable to get pods count", slogs.ResName, name, slogs.Error, err, ) } } res = append(res, &render.NodeWithMetrics{ Raw: u, MX: nmx[name], PodCount: podCount, }) } return res, nil } // CountPods counts the pods scheduled on a given node. func (*Node) CountPods(oo []runtime.Object, nodeName string) (int, error) { var count int for _, o := range oo { u, ok := o.(*unstructured.Unstructured) if !ok { return count, fmt.Errorf("expecting *Unstructured but got `%T", o) } spec, ok := u.Object["spec"].(map[string]any) if !ok { return count, fmt.Errorf("expecting spec interface map but got `%T", o) } if node, ok := spec["nodeName"]; ok && node == nodeName { count++ } } return count, nil } // GetPods returns all pods running on given node. func (n *Node) GetPods(nodeName string) ([]*v1.Pod, error) { oo, err := n.getFactory().List(client.PodGVR, client.BlankNamespace, false, labels.Everything()) if err != nil { return nil, err } pp := make([]*v1.Pod, 0, len(oo)) for _, o := range oo { po := new(v1.Pod) if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, po); err != nil { return nil, err } if po.Spec.NodeName == nodeName { pp = append(pp, po) } } return pp, nil } // ensureCordoned returns whether the given node has been cordoned func (n *Node) ensureCordoned(path string) (bool, error) { o, err := FetchNode(context.Background(), n.Factory, path) if err != nil { return false, err } return o.Spec.Unschedulable, nil } // ---------------------------------------------------------------------------- // Helpers... // FetchNode retrieves a node. func FetchNode(_ context.Context, f Factory, path string) (*v1.Node, error) { _, n := client.Namespaced(path) auth, err := f.Client().CanI(client.ClusterScope, client.NodeGVR, n, client.GetAccess) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to list nodes") } o, err := f.Get(client.NodeGVR, client.FQN(client.ClusterScope, path), true, labels.Everything()) if err != nil { return nil, err } var node v1.Node err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &node) if err != nil { return nil, err } return &node, nil } // FetchNodes retrieves all nodes. func FetchNodes(_ context.Context, f Factory, _ string) (*v1.NodeList, error) { auth, err := f.Client().CanI(client.ClusterScope, client.NodeGVR, "", client.ListAccess) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to list nodes") } oo, err := f.List(client.NodeGVR, "", false, labels.Everything()) if err != nil { return nil, err } nn := make([]v1.Node, 0, len(oo)) for _, o := range oo { var node v1.Node err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &node) if err != nil { return nil, err } nn = append(nn, node) } return &v1.NodeList{Items: nn}, nil } ================================================ FILE: internal/dao/non_resource.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "sync" "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/runtime" ) // NonResource represents a non k8s resource. type NonResource struct { Factory gvr *client.GVR mx sync.RWMutex includeObj bool } // Init initializes the resource. func (n *NonResource) Init(f Factory, gvr *client.GVR) { n.mx.Lock() n.Factory, n.gvr = f, gvr n.mx.Unlock() } // SetIncludeObject sets if resource object should be included in the api server response. func (n *NonResource) SetIncludeObject(f bool) { n.includeObj = f } func (n *NonResource) gvrStr() string { n.mx.RLock() defer n.mx.RUnlock() return n.gvr.String() } func (n *NonResource) getFactory() Factory { n.mx.RLock() defer n.mx.RUnlock() return n.Factory } // GVR returns a gvr. func (n *NonResource) GVR() string { n.mx.RLock() defer n.mx.RUnlock() return n.gvrStr() } // Get returns the given resource. func (*NonResource) Get(context.Context, string) (runtime.Object, error) { return nil, fmt.Errorf("nyi") } ================================================ FILE: internal/dao/ns.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao var _ Accessor = (*Namespace)(nil) // Namespace represents a namespace resource. type Namespace struct { Resource } ================================================ FILE: internal/dao/patch.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "encoding/json" ) // ImageSpec represents a container image. type ImageSpec struct { Index int Name, DockerImage string Init bool } // ImageSpecs represents a collection of container images. type ImageSpecs []ImageSpec // JsonPatch track pod spec updates. type JsonPatch struct { Spec Spec `json:"spec"` } // Spec represents a pod template. type Spec struct { Template PodSpec `json:"template"` } // PodSpec represents a collection of container images. type PodSpec struct { Spec ImagesSpec `json:"spec"` } // ImagesSpec tracks container image updates. type ImagesSpec struct { SetElementOrderContainers []Element `json:"$setElementOrder/containers,omitempty"` SetElementOrderInitContainers []Element `json:"$setElementOrder/initContainers,omitempty"` Containers []Element `json:"containers,omitempty"` InitContainers []Element `json:"initContainers,omitempty"` } // Element tracks a given container image. type Element struct { Image string `json:"image,omitempty"` Name string `json:"name"` } // GetTemplateJsonPatch builds a json patch string to update PodSpec images. func GetTemplateJsonPatch(imageSpecs ImageSpecs) ([]byte, error) { jsonPatch := JsonPatch{ Spec: Spec{ Template: getPatchPodSpec(imageSpecs), }, } return json.Marshal(jsonPatch) } // GetJsonPatch returns container image patch. func GetJsonPatch(imageSpecs ImageSpecs) ([]byte, error) { podSpec := getPatchPodSpec(imageSpecs) return json.Marshal(podSpec) } func getPatchPodSpec(imageSpecs ImageSpecs) PodSpec { initElementsOrders, initElements, elementsOrders, elements := extractElements(imageSpecs) podSpec := PodSpec{ Spec: ImagesSpec{ SetElementOrderInitContainers: initElementsOrders, InitContainers: initElements, SetElementOrderContainers: elementsOrders, Containers: elements, }, } return podSpec } func extractElements(imageSpecs ImageSpecs) (initElementsOrders, initElements, elementsOrders, elements []Element) { for _, spec := range imageSpecs { if spec.Init { initElementsOrders = append(initElementsOrders, Element{Name: spec.Name}) initElements = append(initElements, Element{Name: spec.Name, Image: spec.DockerImage}) } else { elementsOrders = append(elementsOrders, Element{Name: spec.Name}) elements = append(elements, Element{Name: spec.Name, Image: spec.DockerImage}) } } return initElementsOrders, initElements, elementsOrders, elements } ================================================ FILE: internal/dao/patch_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "testing" "github.com/stretchr/testify/require" ) func TestGetTemplateJsonPatch(t *testing.T) { type args struct { imageSpecs ImageSpecs } uu := map[string]struct { args args want string wantErr bool }{ "simple": { args: args{ imageSpecs: ImageSpecs{ ImageSpec{ Index: 0, Name: "init", DockerImage: "busybox:latest", Init: true, }, ImageSpec{ Index: 0, Name: "nginx", DockerImage: "nginx:latest", Init: false, }, }, }, want: `{"spec":{"template":{"spec":{"$setElementOrder/initContainers":[{"name":"init"}],"$setElementOrder/containers":[{"name":"nginx"}],"initContainers":[{"image":"busybox:latest","name":"init"}],"containers":[{"image":"nginx:latest","name":"nginx"}]}}}}`, wantErr: false, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { got, err := GetTemplateJsonPatch(u.args.imageSpecs) if (err != nil) != u.wantErr { t.Errorf("GetTemplateJsonPatch() error = %v, wantErr %v", err, u.wantErr) return } require.JSONEq(t, u.want, string(got), "Json strings should be equal") }) } } func TestGetJsonPatch(t *testing.T) { type args struct { imageSpecs ImageSpecs } uu := map[string]struct { args args want string wantErr bool }{ "simple": { args: args{ imageSpecs: ImageSpecs{ ImageSpec{ Index: 0, Name: "init", DockerImage: "busybox:latest", Init: true, }, ImageSpec{ Index: 0, Name: "nginx", DockerImage: "nginx:latest", Init: false, }, }, }, want: `{"spec":{"$setElementOrder/initContainers":[{"name":"init"}],"initContainers":[{"image":"busybox:latest","name":"init"}],"$setElementOrder/containers":[{"name":"nginx"}],"containers":[{"image":"nginx:latest","name":"nginx"}]}}`, wantErr: false, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { got, err := GetJsonPatch(u.args.imageSpecs) if (err != nil) != u.wantErr { t.Errorf("GetTemplateJsonPatch() error = %v, wantErr %v", err, u.wantErr) return } require.JSONEq(t, u.want, string(got), "Json strings should be equal") }) } } ================================================ FILE: internal/dao/pod.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "bufio" "context" "errors" "fmt" "io" "log/slog" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/watch" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" restclient "k8s.io/client-go/rest" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) var ( _ Accessor = (*Pod)(nil) _ Nuker = (*Pod)(nil) _ Loggable = (*Pod)(nil) _ Controller = (*Pod)(nil) _ ContainsPodSpec = (*Pod)(nil) _ ImageLister = (*Pod)(nil) ) type streamResult int const ( logRetryCount = 20 logBackoffInitial = 500 * time.Millisecond logBackoffMax = 30 * time.Second logChannelBuffer = 50 // Buffer size for log channel to reduce drops streamEOF streamResult = iota // legit container log close (no retry) streamError // retryable error (network, auth, etc.) streamCanceled // context canceled ) // Pod represents a pod resource. type Pod struct { Resource } // shouldStopRetrying checks if we should stop retrying log streaming based on pod status. func (p *Pod) shouldStopRetrying(path string) bool { pod, err := p.GetInstance(path) if err != nil { return true } if pod.DeletionTimestamp != nil { return true } switch pod.Status.Phase { case v1.PodSucceeded, v1.PodFailed: return true default: return false } } // Get returns a resource instance if found, else an error. func (p *Pod) Get(ctx context.Context, path string) (runtime.Object, error) { o, err := p.Resource.Get(ctx, path) if err != nil { return o, err } u, ok := o.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) } var pmx *mv1beta1.PodMetrics if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); ok && withMx { pmx, _ = client.DialMetrics(p.Client()).FetchPodMetrics(ctx, path) } return &render.PodWithMetrics{Raw: u, MX: pmx}, nil } // ListImages lists container images. func (p *Pod) ListImages(_ context.Context, path string) ([]string, error) { pod, err := p.GetInstance(path) if err != nil { return nil, err } return render.ExtractImages(&pod.Spec), nil } // List returns a collection of nodes. func (p *Pod) List(ctx context.Context, ns string) ([]runtime.Object, error) { oo, err := p.Resource.List(ctx, ns) if err != nil { return oo, err } var pmx client.PodsMetricsMap if withMx, ok := ctx.Value(internal.KeyWithMetrics).(bool); ok && withMx { pmx, _ = client.DialMetrics(p.Client()).FetchPodsMetricsMap(ctx, ns) } sel, _ := ctx.Value(internal.KeyFields).(string) fsel, err := labels.ConvertSelectorToLabelsMap(sel) if err != nil { return nil, err } nodeName := fsel["spec.nodeName"] res := make([]runtime.Object, 0, len(oo)) for _, o := range oo { u, ok := o.(*unstructured.Unstructured) if !ok { return res, fmt.Errorf("expecting *unstructured.Unstructured but got `%T", o) } fqn := extractFQN(o) if nodeName == "" { res = append(res, &render.PodWithMetrics{Raw: u, MX: pmx[fqn]}) continue } spec, ok := u.Object["spec"].(map[string]any) if !ok { return res, fmt.Errorf("expecting interface map but got `%T", o) } if spec["nodeName"] == nodeName { res = append(res, &render.PodWithMetrics{Raw: u, MX: pmx[fqn]}) } } return res, nil } // Logs fetch container logs for a given pod and container. func (p *Pod) Logs(path string, opts *v1.PodLogOptions) (*restclient.Request, error) { ns, n := client.Namespaced(path) auth, err := p.Client().CanI(ns, client.NewGVR(client.PodGVR.String()+":log"), n, client.GetAccess) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to view pod logs") } dial, err := p.Client().DialLogs() if err != nil { return nil, err } return dial.CoreV1().Pods(ns).GetLogs(n, opts), nil } // Containers returns all container names on pod. func (p *Pod) Containers(path string, includeInit bool) ([]string, error) { pod, err := p.GetInstance(path) if err != nil { return nil, err } cc := make([]string, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) for i := range pod.Spec.Containers { cc = append(cc, pod.Spec.Containers[i].Name) } if includeInit { for i := range pod.Spec.InitContainers { cc = append(cc, pod.Spec.InitContainers[i].Name) } } return cc, nil } // Pod returns a pod victim by name. func (*Pod) Pod(fqn string) (string, error) { return fqn, nil } // GetInstance returns a pod instance. func (p *Pod) GetInstance(fqn string) (*v1.Pod, error) { o, err := p.getFactory().Get(p.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, err } return &pod, nil } // TailLogs tails a given container logs. func (p *Pod) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { fac, ok := ctx.Value(internal.KeyFactory).(*watch.Factory) if !ok { return nil, errors.New("no factory in context") } o, err := fac.Get(p.gvr, opts.Path, true, labels.Everything()) if err != nil { return nil, err } var po v1.Pod if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &po); err != nil { return nil, err } coCounts := len(po.Spec.InitContainers) + len(po.Spec.Containers) + len(po.Spec.EphemeralContainers) if coCounts == 1 { opts.SingleContainer = true } outs := make([]LogChan, 0, coCounts) if co, ok := GetDefaultContainer(&po.ObjectMeta, &po.Spec); ok && !opts.AllContainers { opts.DefaultContainer = co return append(outs, tailLogs(ctx, p, opts)), nil } if opts.HasContainer() && !opts.AllContainers { return append(outs, tailLogs(ctx, p, opts)), nil } for i := range po.Spec.InitContainers { cfg := opts.Clone() cfg.Container = po.Spec.InitContainers[i].Name outs = append(outs, tailLogs(ctx, p, cfg)) } for i := range po.Spec.Containers { cfg := opts.Clone() cfg.Container = po.Spec.Containers[i].Name outs = append(outs, tailLogs(ctx, p, cfg)) } for i := range po.Spec.EphemeralContainers { cfg := opts.Clone() cfg.Container = po.Spec.EphemeralContainers[i].Name outs = append(outs, tailLogs(ctx, p, cfg)) } return outs, nil } // ScanSA scans for ServiceAccount refs. func (p *Pod) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := p.getFactory().List(p.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, errors.New("expecting Deployment resource") } // Just pick controller less pods... if len(pod.OwnerReferences) > 0 { continue } if serviceAccountMatches(pod.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: p.GVR(), FQN: client.FQN(pod.Namespace, pod.Name), }) } } return refs, nil } // Scan scans for cluster resource refs. func (p *Pod) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := p.getFactory().List(p.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, errors.New("expecting Pod resource") } // Just pick controller less pods... if len(pod.OwnerReferences) > 0 { continue } switch gvr { case client.CmGVR: if !hasConfigMap(&pod.Spec, n) { continue } refs = append(refs, Ref{ GVR: p.GVR(), FQN: client.FQN(pod.Namespace, pod.Name), }) case client.SecGVR: found, err := hasSecret(p.Factory, &pod.Spec, pod.Namespace, n, wait) if err != nil { slog.Warn("Locate secret failed", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: p.GVR(), FQN: client.FQN(pod.Namespace, pod.Name), }) case client.PvcGVR: if !hasPVC(&pod.Spec, n) { continue } refs = append(refs, Ref{ GVR: p.GVR(), FQN: client.FQN(pod.Namespace, pod.Name), }) case client.PcGVR: if !hasPC(&pod.Spec, n) { continue } refs = append(refs, Ref{ GVR: p.GVR(), FQN: client.FQN(pod.Namespace, pod.Name), }) } } return refs, nil } // ---------------------------------------------------------------------------- // Helpers... func tailLogs(ctx context.Context, logger Logger, opts *LogOptions) LogChan { out := make(LogChan, logChannelBuffer) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() podOpts := opts.ToPodLogOptions() // Setup exponential backoff following project pattern bf := backoff.NewExponentialBackOff() bf.InitialInterval = logBackoffInitial bf.MaxElapsedTime = 0 bf.MaxInterval = logBackoffMax / 2 backoffCtx := backoff.WithContext(bf, ctx) delay := logBackoffInitial for range logRetryCount { req, err := logger.Logs(opts.Path, podOpts) if err != nil { slog.Error("Log request failed", slogs.Container, opts.Info(), slogs.Error, err, ) // Check if we should stop retrying based on pod status if pod, ok := logger.(*Pod); ok && pod.shouldStopRetrying(opts.Path) { slog.Debug("Stopping log retry - pod is terminating or deleted", slogs.Container, opts.Info(), ) return } select { case <-ctx.Done(): return case <-time.After(delay): if delay = backoffCtx.NextBackOff(); delay == backoff.Stop { return } } continue } stream, e := req.Stream(ctx) if e != nil { slog.Error("Stream logs failed", slogs.Error, e, slogs.Container, opts.Info(), ) // Check if we should stop retrying based on pod status if pod, ok := logger.(*Pod); ok && pod.shouldStopRetrying(opts.Path) { slog.Debug("Stopping log retry - pod is terminating or deleted", slogs.Container, opts.Info(), ) return } select { case <-ctx.Done(): return case <-time.After(delay): if delay = backoffCtx.NextBackOff(); delay == backoff.Stop { return } } continue } // Process logs until completion result := readLogs(ctx, stream, out, opts) switch result { case streamEOF: slog.Debug("Log stream ended cleanly", slogs.Container, opts.Info(), ) return case streamError: // Check if we should stop retrying based on pod status if pod, ok := logger.(*Pod); ok && pod.shouldStopRetrying(opts.Path) { slog.Debug("Stopping log retry after stream error - pod is terminating or deleted", slogs.Container, opts.Info(), ) return } slog.Debug("Log stream error, retrying", slogs.Container, opts.Info(), ) select { case <-ctx.Done(): return case <-time.After(delay): if delay = backoffCtx.NextBackOff(); delay == backoff.Stop { return } } continue case streamCanceled: return } // Reset backoff and delay on successful connection bf.Reset() delay = logBackoffInitial } // Out of retries out <- opts.ToErrLogItem(fmt.Errorf("failed to maintain log stream after %d retries", logRetryCount)) }() go func() { wg.Wait() close(out) }() return out } func readLogs(ctx context.Context, stream io.ReadCloser, out chan<- *LogItem, opts *LogOptions) streamResult { defer func() { if err := stream.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { slog.Error("Failed to close stream", slogs.Container, opts.Info(), slogs.Error, err, ) } }() r := bufio.NewReader(stream) for { bytes, err := r.ReadBytes('\n') if err == nil { item := opts.ToLogItem(tview.EscapeBytes(bytes)) select { case <-ctx.Done(): return streamCanceled case out <- item: default: // Avoid deadlock if consumer is too slow slog.Warn("Dropping log line due to slow consumer", slogs.Container, opts.Info(), ) } continue } if errors.Is(err, io.EOF) { if len(bytes) > 0 { // Emit trailing partial line before EOF out <- opts.ToLogItem(tview.EscapeBytes(bytes)) } slog.Debug("Log reader reached EOF", slogs.Container, opts.Info()) out <- opts.ToErrLogItem(fmt.Errorf("stream closed: %w for %s", err, opts.Info())) return streamEOF } // Non-EOF error slog.Debug("Log stream error, will retry connection", slogs.Container, opts.Info(), slogs.Error, fmt.Errorf("stream error: %w for %s", err, opts.Info()), ) // Don't send stream errors to user - they will be retried // Only final retry exhaustion message is shown return streamError } } // MetaFQN returns a fully qualified resource name. func MetaFQN(m *metav1.ObjectMeta) string { if m.Namespace == "" { return m.Name } return FQN(m.Namespace, m.Name) } // GetPodSpec returns a pod spec given a resource. func (p *Pod) GetPodSpec(path string) (*v1.PodSpec, error) { pod, err := p.GetInstance(path) if err != nil { return nil, err } podSpec := pod.Spec return &podSpec, nil } // SetImages sets container images. func (p *Pod) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { ns, n := client.Namespaced(path) auth, err := p.Client().CanI(ns, p.gvr, n, client.PatchAccess) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to patch a deployment") } manager, isManaged, err := p.isControlled(path) if err != nil { return err } if isManaged { return fmt.Errorf("unable to set image. This pod is managed by %s. Please set the image on the controller", manager) } jsonPatch, err := GetJsonPatch(imageSpecs) if err != nil { return err } dial, err := p.Client().Dial() if err != nil { return err } _, err = dial.CoreV1().Pods(ns).Patch( ctx, n, types.StrategicMergePatchType, jsonPatch, metav1.PatchOptions{}, ) return err } func (p *Pod) isControlled(path string) (fqn string, ok bool, err error) { pod, err := p.GetInstance(path) if err != nil { return "", false, err } references := pod.GetObjectMeta().GetOwnerReferences() if len(references) > 0 { return fmt.Sprintf("%s/%s", references[0].Kind, references[0].Name), true, nil } return "", false, nil } var toastPhases = sets.New( render.PhaseCompleted, render.PhasePending, render.PhaseCrashLoop, render.PhaseError, render.PhaseImagePullBackOff, render.PhaseContainerStatusUnknown, render.PhaseEvicted, render.PhaseOOMKilled, ) func (p *Pod) Sanitize(ctx context.Context, ns string) (int, error) { oo, err := p.Resource.List(ctx, ns) if err != nil { return 0, err } var count int for _, o := range oo { u, ok := o.(*unstructured.Unstructured) if !ok { continue } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &pod) if err != nil { continue } if toastPhases.Has(render.PodStatus(&pod)) { // !!BOZO!! Might need to bump timeout otherwise rev limit if too many?? fqn := client.FQN(pod.Namespace, pod.Name) slog.Debug("Sanitizing resource", slogs.FQN, fqn) if err := p.Delete(ctx, fqn, nil, 0); err != nil { slog.Debug("Aborted! Sanitizer delete failed", slogs.FQN, fqn, slogs.Count, count, slogs.Error, err, ) return count, err } count++ } } slog.Debug("Sanitizer deleted pods", slogs.Count, count) return count, nil } ================================================ FILE: internal/dao/pod_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "testing" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestGetDefaultContainer(t *testing.T) { uu := map[string]struct { po *v1.Pod wantContainer string wantOk bool }{ "no_annotation": { po: &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{{Name: "container1"}}, }, }, wantContainer: "", wantOk: false, }, "container_not_present": { po: &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{DefaultContainerAnnotation: "container1"}, }, }, wantContainer: "", wantOk: false, }, "container_found": { po: &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{DefaultContainerAnnotation: "container1"}, }, Spec: v1.PodSpec{ Containers: []v1.Container{{Name: "container1"}}, }, }, wantContainer: "container1", wantOk: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { container, ok := GetDefaultContainer(&u.po.ObjectMeta, &u.po.Spec) assert.Equal(t, u.wantContainer, container) assert.Equal(t, u.wantOk, ok) }) } } ================================================ FILE: internal/dao/port_forward.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "log/slog" "regexp" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*PortForward)(nil) _ Nuker = (*PortForward)(nil) ) // PortForward represents a port forward dao. type PortForward struct { NonResource } // Delete deletes a portforward. func (p *PortForward) Delete(_ context.Context, path string, _ *metav1.DeletionPropagation, _ Grace) error { p.getFactory().DeleteForwarder(path) return nil } // List returns a collection of port forwards. func (p *PortForward) List(ctx context.Context, _ string) ([]runtime.Object, error) { benchFile, ok := ctx.Value(internal.KeyBenchCfg).(string) if !ok || benchFile == "" { return nil, fmt.Errorf("no benchmark config file found in context") } path, _ := ctx.Value(internal.KeyPath).(string) bcfg, err := config.NewBench(benchFile) if err != nil { slog.Debug("No custom benchmark config file found", slogs.FileName, benchFile) } ff, cc := p.getFactory().Forwarders(), bcfg.Benchmarks.Containers oo := make([]runtime.Object, 0, len(ff)) for k, f := range ff { if !strings.HasPrefix(k, path) { continue } cfg := render.BenchCfg{ C: bcfg.Benchmarks.Defaults.C, N: bcfg.Benchmarks.Defaults.N, } if cust, ok := cc[PodToKey(k)]; ok { cfg.C, cfg.N = cust.C, cust.N cfg.Host, cfg.Path = cust.HTTP.Host, cust.HTTP.Path } oo = append(oo, render.ForwardRes{ Forwarder: f, Config: cfg, }) } return oo, nil } // ---------------------------------------------------------------------------- // Helpers... var podNameRX = regexp.MustCompile(`\A(.+)\-(\w{10})\-(\w{5})\z`) // PodToKey converts a pod path to a generic bench config key. func PodToKey(path string) string { tokens := strings.Split(path, "|") ns, po := client.Namespaced(tokens[0]) sections := podNameRX.FindStringSubmatch(po) if len(sections) >= 1 { po = sections[1] } return client.FQN(ns, po) + ":" + tokens[1] } // BenchConfigFor returns a custom bench spec if defined otherwise returns the default one. func BenchConfigFor(benchFile, path string) config.BenchConfig { def := config.DefaultBenchSpec() cust, err := config.NewBench(benchFile) if err != nil { slog.Debug("No custom benchmark config file found. Using default", slogs.FileName, benchFile, slogs.Error, err, ) return def } if b, ok := cust.Benchmarks.Containers[PodToKey(path)]; ok { return b } def.C, def.N = cust.Benchmarks.Defaults.C, cust.Benchmarks.Defaults.N return def } ================================================ FILE: internal/dao/port_forward_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" ) func TestBenchForConfig(t *testing.T) { uu := map[string]struct { file, key string spec config.BenchConfig }{ "no_file": {file: "", key: "", spec: config.DefaultBenchSpec()}, "spec": {file: "testdata/benchspec.yaml", key: "default/nginx-123-456|nginx", spec: config.BenchConfig{ C: 2, N: 3000, HTTP: config.HTTP{ Method: "GET", Path: "/", }, }}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.NotNil(t, u.spec, dao.BenchConfigFor(u.file, u.key)) }) } } ================================================ FILE: internal/dao/port_forwarder.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "fmt" "net/http" "net/url" "strings" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/port" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" cmdutil "k8s.io/kubectl/pkg/cmd/util" ) const defaultTimeout = 30 * time.Second // PortForwarder tracks a port forward stream. type PortForwarder struct { Factory genericclioptions.IOStreams stopChan, readyChan chan struct{} active bool path string tunnel port.PortTunnel age time.Time } // NewPortForwarder returns a new port forward streamer. func NewPortForwarder(f Factory) *PortForwarder { return &PortForwarder{ Factory: f, stopChan: make(chan struct{}), readyChan: make(chan struct{}), } } // String dumps as string. func (p *PortForwarder) String() string { return fmt.Sprintf("%s|%s", p.path, p.tunnel) } // Age returns the port forward age. func (p *PortForwarder) Age() time.Time { return p.age } // Active returns the forward status. func (p *PortForwarder) Active() bool { return p.active } // SetActive mark a portforward as active. func (p *PortForwarder) SetActive(b bool) { p.active = b } // Port returns the port mapping. func (p *PortForwarder) Port() string { return p.tunnel.PortMap() } // Address returns the port Address. func (p *PortForwarder) Address() string { return p.tunnel.Address } // ContainerPort returns the container port. func (p *PortForwarder) ContainerPort() string { return p.tunnel.ContainerPort } // LocalPort returns the local port. func (p *PortForwarder) LocalPort() string { return p.tunnel.LocalPort } // ID returns a pf id. func (p *PortForwarder) ID() string { return PortForwardID(p.path, p.tunnel.Container, p.tunnel.PortMap()) } // Container returns the target's container. func (p *PortForwarder) Container() string { return p.tunnel.Container } // Stop terminates a port forward. func (p *PortForwarder) Stop() { p.active = false if p.stopChan != nil { close(p.stopChan) p.stopChan = nil } } // FQN returns the portforward unique id. func (p *PortForwarder) FQN() string { return p.path + ":" + p.tunnel.Container } // HasPortMapping checks if port mapping is defined for this fwd. func (p *PortForwarder) HasPortMapping(portMap string) bool { return p.tunnel.PortMap() == portMap } // Start initiates a port forward session for a given pod and ports. func (p *PortForwarder) Start(path string, tt port.PortTunnel) (*portforward.PortForwarder, error) { p.path, p.tunnel, p.age = path, tt, time.Now() ns, n := client.Namespaced(path) auth, err := p.Client().CanI(ns, client.PodGVR, n, client.GetAccess) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to get pods") } podName := strings.Split(n, "|")[0] var res Pod res.Init(p, client.PodGVR) pod, err := res.GetInstance(client.FQN(ns, podName)) if err != nil { return nil, err } if pod.Status.Phase != v1.PodRunning { return nil, fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase) } auth, err = p.Client().CanI(ns, client.PodGVR.WithSubResource("portforward"), "", []string{client.CreateVerb}) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to update portforward") } cfg, err := p.Client().RestConfig() if err != nil { return nil, err } cfg.GroupVersion = &schema.GroupVersion{Group: "", Version: "v1"} cfg.APIPath = "/api" codec, _ := codec() cfg.NegotiatedSerializer = codec.WithoutConversion() clt, err := rest.RESTClientFor(cfg) if err != nil { return nil, err } req := clt.Post(). Resource("pods"). Namespace(ns). Name(podName). SubResource("portforward") return p.forwardPorts("POST", req.URL(), tt.Address, tt.PortMap()) } func (p *PortForwarder) forwardPorts(method string, u *url.URL, addr, portMap string) (*portforward.PortForwarder, error) { cfg, err := p.Client().Config().RESTConfig() if err != nil { return nil, err } transport, upgrader, err := spdy.RoundTripperFor(cfg) if err != nil { return nil, err } dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport, Timeout: defaultTimeout}, method, u) if !cmdutil.PortForwardWebsockets.IsDisabled() { tunnelingDialer, err := portforward.NewSPDYOverWebsocketDialer(u, cfg) if err != nil { return nil, err } // First attempt tunneling (websocket) dialer, then fallback to spdy dialer. dialer = portforward.NewFallbackDialer(tunnelingDialer, dialer, func(err error) bool { return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) }) } return portforward.NewOnAddresses(dialer, []string{addr}, []string{portMap}, p.stopChan, p.readyChan, p.Out, p.ErrOut) } // ---------------------------------------------------------------------------- // Helpers... // PortForwardID computes port-forward identifier. func PortForwardID(path, co, portMap string) string { if strings.Contains(path, "|") { return path + "|" + portMap } return path + "|" + co + "|" + portMap } func codec() (serializer.CodecFactory, runtime.ParameterCodec) { scheme := runtime.NewScheme() gv := schema.GroupVersion{Group: "", Version: "v1"} metav1.AddToGroupVersion(scheme, gv) scheme.AddKnownTypes(gv, &metav1.Table{}, &metav1.TableOptions{}) scheme.AddKnownTypes(metav1.SchemeGroupVersion, &metav1.Table{}, &metav1.TableOptions{}) return serializer.NewCodecFactory(scheme), runtime.NewParameterCodec(scheme) } ================================================ FILE: internal/dao/pulse.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "k8s.io/apimachinery/pkg/runtime" ) // Pulse tracks pulses. type Pulse struct { NonResource } // List lists out pulses. func (*Pulse) List(context.Context, string) ([]runtime.Object, error) { return nil, fmt.Errorf("NYI") } ================================================ FILE: internal/dao/rbac.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Rbac)(nil) _ Nuker = (*Rbac)(nil) ) // Rbac represents a model for listing rbac resources. type Rbac struct { Resource } // List lists out rbac resources. func (r *Rbac) List(ctx context.Context, ns string) ([]runtime.Object, error) { gvr, ok := ctx.Value(internal.KeyGVR).(*client.GVR) if !ok { return nil, fmt.Errorf("expecting a context gvr") } path, ok := ctx.Value(internal.KeyPath).(string) if !ok || path == "" { return r.Resource.List(ctx, ns) } switch gvr.R() { case "clusterrolebindings": return r.loadClusterRoleBinding(path) case "rolebindings": return r.loadRoleBinding(path) case "clusterroles": return r.loadClusterRole(path) case "roles": return r.loadRole(path) default: return nil, fmt.Errorf("expecting clusterrole/role but found %s", gvr.R()) } } func (r *Rbac) loadClusterRoleBinding(path string) ([]runtime.Object, error) { crbo, err := r.getFactory().Get(client.CrbGVR, path, true, labels.Everything()) if err != nil { return nil, err } var crb rbacv1.ClusterRoleBinding err = runtime.DefaultUnstructuredConverter.FromUnstructured(crbo.(*unstructured.Unstructured).Object, &crb) if err != nil { return nil, err } cro, err := r.getFactory().Get(client.CrGVR, client.FQN("-", crb.RoleRef.Name), true, labels.Everything()) if err != nil { return nil, err } var cr rbacv1.ClusterRole err = runtime.DefaultUnstructuredConverter.FromUnstructured(cro.(*unstructured.Unstructured).Object, &cr) if err != nil { return nil, err } return asRuntimeObjects(parseRules(client.ClusterScope, "-", cr.Rules)), nil } func (r *Rbac) loadRoleBinding(path string) ([]runtime.Object, error) { rbo, err := r.getFactory().Get(client.RobGVR, path, true, labels.Everything()) if err != nil { return nil, err } var rb rbacv1.RoleBinding if e := runtime.DefaultUnstructuredConverter.FromUnstructured(rbo.(*unstructured.Unstructured).Object, &rb); e != nil { return nil, e } if rb.RoleRef.Kind == "ClusterRole" { cro, e := r.getFactory().Get(client.CrGVR, client.FQN("-", rb.RoleRef.Name), true, labels.Everything()) if e != nil { return nil, e } var cr rbacv1.ClusterRole err = runtime.DefaultUnstructuredConverter.FromUnstructured(cro.(*unstructured.Unstructured).Object, &cr) if err != nil { return nil, err } return asRuntimeObjects(parseRules(client.ClusterScope, "-", cr.Rules)), nil } ro, err := r.getFactory().Get(client.RoGVR, client.FQN(rb.Namespace, rb.RoleRef.Name), true, labels.Everything()) if err != nil { return nil, err } var role rbacv1.Role err = runtime.DefaultUnstructuredConverter.FromUnstructured(ro.(*unstructured.Unstructured).Object, &role) if err != nil { return nil, err } return asRuntimeObjects(parseRules(client.ClusterScope, "-", role.Rules)), nil } func (r *Rbac) loadClusterRole(fqn string) ([]runtime.Object, error) { o, err := r.getFactory().Get(client.CrGVR, fqn, true, labels.Everything()) if err != nil { return nil, err } var cr rbacv1.ClusterRole err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cr) if err != nil { return nil, err } return asRuntimeObjects(parseRules(client.ClusterScope, "-", cr.Rules)), nil } func (r *Rbac) loadRole(path string) ([]runtime.Object, error) { o, err := r.getFactory().Get(client.RoGVR, path, true, labels.Everything()) if err != nil { return nil, err } var ro rbacv1.Role err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ro) if err != nil { return nil, err } return asRuntimeObjects(parseRules(client.ClusterScope, "-", ro.Rules)), nil } func asRuntimeObjects(rr render.Policies) []runtime.Object { oo := make([]runtime.Object, len(rr)) for i, r := range rr { oo[i] = r } return oo } func parseRules(ns, binding string, rules []rbacv1.PolicyRule) render.Policies { pp := make(render.Policies, 0, len(rules)) for _, rule := range rules { for _, grp := range rule.APIGroups { if grp == "" { grp = "core" } for _, res := range rule.Resources { for _, na := range rule.ResourceNames { pp = pp.Upsert(render.NewPolicyRes(ns, binding, FQN(res, na), grp, rule.Verbs)) } pp = pp.Upsert(render.NewPolicyRes(ns, binding, FQN(grp, res), grp, rule.Verbs)) } } for _, nres := range rule.NonResourceURLs { if nres[0] != '/' { nres = "/" + nres } pp = pp.Upsert(render.NewPolicyRes(ns, binding, nres, client.NA, rule.Verbs)) } } return pp } ================================================ FILE: internal/dao/rbac_policy.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Policy)(nil) _ Nuker = (*Policy)(nil) ) // Policy represent rbac policy. type Policy struct { Resource } // List returns available policies. func (p *Policy) List(ctx context.Context, _ string) ([]runtime.Object, error) { kind, ok := ctx.Value(internal.KeySubjectKind).(string) if !ok { return nil, fmt.Errorf("expecting a context subject kind") } name, ok := ctx.Value(internal.KeySubjectName).(string) if !ok { return nil, fmt.Errorf("expecting a context subject name") } crps, err := p.loadClusterRoleBinding(kind, name) if err != nil { return nil, err } rps, err := p.loadRoleBinding(kind, name) if err != nil { return nil, err } oo := make([]runtime.Object, 0, len(crps)+len(rps)) for _, p := range crps { oo = append(oo, p) } for _, p := range rps { oo = append(oo, p) } return oo, nil } func (p *Policy) loadClusterRoleBinding(kind, name string) (render.Policies, error) { crbs, err := fetchClusterRoleBindings(p.Factory) if err != nil { return nil, err } ns, n := client.Namespaced(name) var nn []string for i := range crbs { for _, s := range crbs[i].Subjects { if isSameSubject(kind, ns, crbs[i].Namespace, n, &s) { nn = append(nn, crbs[i].RoleRef.Name) } } } crs, err := p.fetchClusterRoles() if err != nil { return nil, err } rows := make(render.Policies, 0, len(nn)) for i := range crs { if !inList(nn, crs[i].Name) { continue } rows = append(rows, parseRules(client.NotNamespaced, "CR:"+crs[i].Name, crs[i].Rules)...) } return rows, nil } func (p *Policy) loadRoleBinding(kind, name string) (render.Policies, error) { rbsMap, err := p.fetchRoleBindingNamespaces(kind, name) if err != nil { return nil, err } crs, err := p.fetchClusterRoles() if err != nil { return nil, err } rows := make(render.Policies, 0, len(crs)) for i := range crs { if rbNs, ok := rbsMap["ClusterRole:"+crs[i].Name]; ok { slog.Debug("Loading rules for clusterrole", slogs.Namespace, rbNs, slogs.ResName, crs[i].Name, ) rows = append(rows, parseRules(rbNs, "CR:"+crs[i].Name, crs[i].Rules)...) } } ros, err := p.fetchRoles() if err != nil { return nil, err } for i := range ros { if _, ok := rbsMap["Role:"+ros[i].Name]; !ok { continue } slog.Debug("Loading rules for role", slogs.Namespace, ros[i].Namespace, slogs.ResName, ros[i].Name, ) rows = append(rows, parseRules(ros[i].Namespace, "RO:"+ros[i].Name, ros[i].Rules)...) } return rows, nil } func fetchClusterRoleBindings(f Factory) ([]rbacv1.ClusterRoleBinding, error) { oo, err := f.List(client.CrbGVR, client.ClusterScope, false, labels.Everything()) if err != nil { return nil, err } crbs := make([]rbacv1.ClusterRoleBinding, len(oo)) for i, o := range oo { var crb rbacv1.ClusterRoleBinding if e := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &crb); e != nil { return nil, e } crbs[i] = crb } return crbs, nil } func fetchRoleBindings(f Factory) ([]rbacv1.RoleBinding, error) { oo, err := f.List(client.RobGVR, client.ClusterScope, false, labels.Everything()) if err != nil { return nil, err } rbs := make([]rbacv1.RoleBinding, 0, len(oo)) for _, o := range oo { var rb rbacv1.RoleBinding if e := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rb); e != nil { return nil, e } rbs = append(rbs, rb) } return rbs, nil } func (p *Policy) fetchRoleBindingNamespaces(kind, name string) (map[string]string, error) { rbs, err := fetchRoleBindings(p.Factory) if err != nil { return nil, err } ns, n := client.Namespaced(name) ss := make(map[string]string, len(rbs)) for i := range rbs { for _, s := range rbs[i].Subjects { if isSameSubject(kind, ns, rbs[i].Namespace, n, &s) { ss[rbs[i].RoleRef.Kind+":"+rbs[i].RoleRef.Name] = rbs[i].Namespace } } } return ss, nil } // isSameSubject verifies if the incoming type name and namespace match a subject from a // cluster/roleBinding. A ServiceAccount will always have a namespace and needs to be validated to ensure // we don't display permissions for a ServiceAccount with the same name in a different namespace func isSameSubject(kind, ns, bns, name string, subject *rbacv1.Subject) bool { if subject.Kind != kind || subject.Name != name { return false } if kind == rbacv1.ServiceAccountKind { // Kind and name were checked above, check the namespace cns := subject.Namespace if cns == "" { cns = bns } return client.IsAllNamespaces(ns) || cns == ns } return true } func (p *Policy) fetchClusterRoles() ([]rbacv1.ClusterRole, error) { oo, err := p.getFactory().List(client.CrGVR, client.ClusterScope, false, labels.Everything()) if err != nil { return nil, err } crs := make([]rbacv1.ClusterRole, len(oo)) for i, o := range oo { var cr rbacv1.ClusterRole if e := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cr); e != nil { return nil, e } crs[i] = cr } return crs, nil } func (p *Policy) fetchRoles() ([]rbacv1.Role, error) { oo, err := p.getFactory().List(client.RoGVR, client.BlankNamespace, false, labels.Everything()) if err != nil { return nil, err } rr := make([]rbacv1.Role, len(oo)) for i, o := range oo { var ro rbacv1.Role if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &ro); err != nil { return nil, err } rr[i] = ro } return rr, nil } ================================================ FILE: internal/dao/rbac_policy_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "testing" "github.com/stretchr/testify/assert" rbacv1 "k8s.io/api/rbac/v1" ) func TestIsSameSubject(t *testing.T) { uu := map[string]struct { kind string namespace string name string subject rbacv1.Subject want bool }{ "kind-name-match": { kind: rbacv1.UserKind, name: "foo", subject: rbacv1.Subject{ Kind: rbacv1.UserKind, Name: "foo", }, want: true, }, "name-does-not-match": { kind: rbacv1.UserKind, name: "foo", subject: rbacv1.Subject{ Kind: rbacv1.UserKind, Name: "bar", }, want: false, }, "kind-does-not-match": { kind: rbacv1.GroupKind, name: "foo", subject: rbacv1.Subject{ Kind: rbacv1.UserKind, Name: "foo", }, want: false, }, "serviceAccount-all-match": { kind: rbacv1.ServiceAccountKind, name: "foo", namespace: "bar", subject: rbacv1.Subject{ Kind: rbacv1.ServiceAccountKind, Name: "foo", Namespace: "bar", }, want: true, }, "serviceAccount-namespace-no-match": { kind: rbacv1.ServiceAccountKind, name: "foo", namespace: "bar", subject: rbacv1.Subject{ Kind: rbacv1.ServiceAccountKind, Name: "foo", Namespace: "bazz", }, want: false, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { same := isSameSubject(u.kind, u.namespace, u.namespace, u.name, &u.subject) assert.Equal(t, u.want, same) }) } } ================================================ FILE: internal/dao/rbac_subject.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/render" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Subject)(nil) _ Nuker = (*Subject)(nil) ) // Subject represents a subject model. type Subject struct { Resource } // List returns a collection of subjects. func (s *Subject) List(ctx context.Context, _ string) ([]runtime.Object, error) { kind, ok := ctx.Value(internal.KeySubjectKind).(string) if !ok { return nil, errors.New("expecting a SubjectKind") } crbs, err := s.listClusterRoleBindings(kind) if err != nil { return nil, err } rbs, err := s.listRoleBindings(kind) if err != nil { return nil, err } for _, rb := range rbs { crbs = crbs.Upsert(rb) } oo := make([]runtime.Object, len(crbs)) for i, o := range crbs { oo[i] = o } return oo, nil } func (s *Subject) listClusterRoleBindings(kind string) (render.Subjects, error) { crbs, err := fetchClusterRoleBindings(s.Factory) if err != nil { return nil, err } oo := make(render.Subjects, 0, len(crbs)) for i := range crbs { for _, su := range crbs[i].Subjects { if su.Kind != kind { continue } oo = oo.Upsert(render.SubjectRes{ Name: su.Name, Kind: "ClusterRoleBinding", FirstLocation: crbs[i].Name, }) } } return oo, nil } func (s *Subject) listRoleBindings(kind string) (render.Subjects, error) { rbs, err := fetchRoleBindings(s.Factory) if err != nil { return nil, err } oo := make(render.Subjects, 0, len(rbs)) for i := range rbs { for _, su := range rbs[i].Subjects { if su.Kind != kind { continue } oo = oo.Upsert(render.SubjectRes{ Name: su.Name, Kind: "RoleBinding", FirstLocation: rbs[i].Name, }) } } return oo, nil } ================================================ FILE: internal/dao/recorder.go ================================================ package dao import ( "context" "errors" "fmt" "log/slog" "sync" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/cache" ) var MxRecorder *Recorder const ( seriesCacheSize = 600 seriesCacheExpiry = 3 * time.Hour seriesRecordRate = 1 * time.Minute nodeMetrics = "node" podMetrics = "pod" ) type MetricsChan chan TimeSeries type TimeSeries []Point type Point struct { Time time.Time Tags map[string]string Value client.NodeMetrics } type Recorder struct { conn client.Connection series *cache.LRUExpireCache mxChan MetricsChan mx sync.RWMutex } func DialRecorder(c client.Connection) *Recorder { if MxRecorder != nil { return MxRecorder } MxRecorder = &Recorder{ conn: c, series: cache.NewLRUExpireCache(seriesCacheSize), } return MxRecorder } func ResetRecorder(c client.Connection) { MxRecorder = nil DialRecorder(c) } func (r *Recorder) Clear() { r.mx.Lock() defer r.mx.Unlock() kk := r.series.Keys() for _, k := range kk { r.series.Remove(k) } } func (r *Recorder) dispatchSeries(kind, ns string) { if r.mxChan == nil { return } kk := r.series.Keys() hour := time.Now().Add(-1 * time.Hour) ts := make(TimeSeries, 0, len(kk)) for _, k := range kk { if v, ok := r.series.Get(k); ok { if pt, cool := v.(Point); cool { if pt.Tags["type"] != kind || pt.Time.Sub(hour) < 0 { continue } switch kind { case nodeMetrics: ts = append(ts, pt) case podMetrics: if client.IsAllNamespaces(ns) || pt.Tags["namespace"] == ns { ts = append(ts, pt) } } } } } if len(ts) > 0 { r.mxChan <- ts } } func (r *Recorder) Watch(ctx context.Context, ns string) MetricsChan { r.mx.Lock() if r.mxChan != nil { close(r.mxChan) r.mxChan = nil } r.mxChan = make(MetricsChan, 2) r.mx.Unlock() go func() { kind := podMetrics if client.IsAllNamespaces(ns) { kind = nodeMetrics } switch kind { case podMetrics: if err := r.recordPodMetrics(ctx, ns); err != nil { slog.Error("Record pod metrics failed", slogs.Error, err) } case nodeMetrics: if err := r.recordNodeMetrics(ctx); err != nil { slog.Error("Record node metrics failed", slogs.Error, err) } } r.dispatchSeries(kind, ns) <-ctx.Done() r.mx.Lock() if r.mxChan != nil { close(r.mxChan) r.mxChan = nil } r.mx.Unlock() }() return r.mxChan } func (r *Recorder) Record(ctx context.Context) error { if err := r.recordNodeMetrics(ctx); err != nil { return err } return r.recordPodMetrics(ctx, client.NamespaceAll) } func (r *Recorder) recordNodeMetrics(ctx context.Context) error { f, ok := ctx.Value(internal.KeyFactory).(Factory) if !ok { return errors.New("expecting factory in context") } nn, err := FetchNodes(ctx, f, "") if err != nil { return err } go func() { r.recordClusterMetrics(ctx, nn) for { select { case <-ctx.Done(): return case <-time.After(seriesRecordRate): r.recordClusterMetrics(ctx, nn) } } }() return nil } func (r *Recorder) recordClusterMetrics(ctx context.Context, nn *v1.NodeList) { dial := client.DialMetrics(r.conn) nmx, err := dial.FetchNodesMetrics(ctx) if err != nil { slog.Error("Fetch node metrics failed", slogs.Error, err) return } mx := make(client.NodesMetrics, len(nn.Items)) dial.NodesMetrics(nn, nmx, mx) var cmx client.NodeMetrics for _, m := range mx { cmx.CurrentCPU += m.CurrentCPU cmx.CurrentMEM += m.CurrentMEM cmx.AllocatableCPU += m.AllocatableCPU cmx.AllocatableMEM += m.AllocatableMEM cmx.TotalCPU += m.TotalCPU cmx.TotalMEM += m.TotalMEM } pt := Point{ Time: time.Now(), Value: cmx, Tags: map[string]string{ "type": nodeMetrics, }, } if len(nn.Items) > 0 { r.series.Add(pt.Time, pt, seriesCacheExpiry) } r.mx.Lock() defer r.mx.Unlock() if r.mxChan != nil { r.mxChan <- TimeSeries{pt} } } func (r *Recorder) recordPodMetrics(ctx context.Context, ns string) error { go func() { if err := r.recordPodsMetrics(ctx, ns); err != nil { slog.Error("Record pod metrics failed", slogs.Error, err) } for { select { case <-ctx.Done(): return case <-time.After(seriesRecordRate): // case <-time.After(5 * time.Second): if err := r.recordPodsMetrics(ctx, ns); err != nil { slog.Error("Record pod metrics failed", slogs.Error, err) } } } }() return nil } func (r *Recorder) recordPodsMetrics(ctx context.Context, ns string) error { f, ok := ctx.Value(internal.KeyFactory).(Factory) if !ok { return errors.New("expecting factory in context") } pp, err := FetchPods(ctx, f, ns) if err != nil { return err } pt := Point{ Time: time.Now(), Value: client.NodeMetrics{}, Tags: map[string]string{ "namespace": ns, "type": podMetrics, }, } dial := client.DialMetrics(r.conn) for i := range pp.Items { p := pp.Items[i] fqn := client.FQN(p.Namespace, p.Name) pmx, err := dial.FetchPodMetrics(ctx, fqn) if err != nil { continue } for _, c := range pmx.Containers { pt.Value.CurrentCPU += c.Usage.Cpu().MilliValue() pt.Value.CurrentMEM += client.ToMB(c.Usage.Memory().Value()) } } if len(pp.Items) > 0 { pt.Value.AllocatableCPU = pt.Value.CurrentCPU pt.Value.AllocatableMEM = pt.Value.CurrentMEM r.series.Add(pt.Time, pt, seriesCacheExpiry) r.mx.Lock() defer r.mx.Unlock() if r.mxChan != nil { r.mxChan <- TimeSeries{pt} } } return nil } // FetchPods retrieves all pods in a given namespace. func FetchPods(_ context.Context, f Factory, ns string) (*v1.PodList, error) { auth, err := f.Client().CanI(ns, client.PodGVR, "pods", []string{client.ListVerb}) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("user is not authorized to list pods") } oo, err := f.List(client.PodGVR, ns, false, labels.Everything()) if err != nil { return nil, err } pp := make([]v1.Pod, 0, len(oo)) for _, o := range oo { var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, err } pp = append(pp, pod) } return &v1.PodList{Items: pp}, nil } ================================================ FILE: internal/dao/reference.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "k8s.io/apimachinery/pkg/runtime" ) var _ Accessor = (*Reference)(nil) // Reference represents cluster resource references. type Reference struct { NonResource } // List collects all references. func (r *Reference) List(ctx context.Context, _ string) ([]runtime.Object, error) { gvr, ok := ctx.Value(internal.KeyGVR).(*client.GVR) if !ok { return nil, errors.New("no context for gvr found") } switch gvr { case client.SaGVR: return r.ScanSA(ctx) default: return r.Scan(ctx) } } // Get fetch a given reference. func (*Reference) Get(context.Context, string) (runtime.Object, error) { panic("NYI") } // Scan scan cluster resources for references. func (r *Reference) Scan(ctx context.Context) ([]runtime.Object, error) { refs, err := ScanForRefs(ctx, r.Factory) if err != nil { return nil, err } fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("expecting context Path") } ns, _ := client.Namespaced(fqn) oo := make([]runtime.Object, 0, len(refs)) for _, ref := range refs { _, n := client.Namespaced(ref.FQN) oo = append(oo, render.ReferenceRes{ Namespace: ns, Name: n, GVR: ref.GVR, }) } return oo, nil } // ScanSA scans for serviceaccount refs. func (r *Reference) ScanSA(ctx context.Context) ([]runtime.Object, error) { refs, err := ScanForSARefs(ctx, r.Factory) if err != nil { return nil, err } fqn, ok := ctx.Value(internal.KeyPath).(string) if !ok { return nil, errors.New("expecting context Path") } ns, _ := client.Namespaced(fqn) oo := make([]runtime.Object, 0, len(refs)) for _, ref := range refs { _, n := client.Namespaced(ref.FQN) oo = append(oo, render.ReferenceRes{ Namespace: ns, Name: n, GVR: ref.GVR, }) } return oo, nil } ================================================ FILE: internal/dao/registry.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "fmt" "log/slog" "maps" "slices" "strings" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" ) const ( crdCat = "crd" k9sCat = "k9s" helmCat = "helm" scaleCat = "scale" ) var stdGroups = sets.New[string]( "apps/v1", "autoscaling/v1", "autoscaling/v2", "autoscaling/v2beta1", "autoscaling/v2beta2", "batch/v1", "batch/v1beta1", "extensions/v1beta1", "policy/v1beta1", "policy/v1", "v1", ) var scalableRes = sets.New(client.DpGVR, client.StsGVR, client.RsGVR, client.RcGVR) // ResourceMetas represents a collection of resource metadata. type ResourceMetas map[*client.GVR]*metav1.APIResource func (m ResourceMetas) clear() { for k := range m { delete(m, k) } } // MetaAccess tracks resources metadata. var MetaAccess = NewMeta() // Meta represents available resource metas. type Meta struct { resMetas ResourceMetas mx sync.RWMutex } // NewMeta returns a resource meta. func NewMeta() *Meta { return &Meta{resMetas: make(ResourceMetas)} } func (m *Meta) Lookup(cmd string) *client.GVR { m.mx.RLock() defer m.mx.RUnlock() for gvr, meta := range m.resMetas { if slices.Contains(meta.ShortNames, cmd) { return gvr } if meta.Name == cmd || meta.SingularName == cmd || meta.Kind == cmd { return gvr } } return client.NoGVR } // RegisterMeta registers a new resource meta object. func (m *Meta) RegisterMeta(gvr string, res *metav1.APIResource) { m.mx.Lock() defer m.mx.Unlock() m.resMetas[client.NewGVR(gvr)] = res } // AllGVRs returns all sorted cluster resources. func (m *Meta) AllGVRs() client.GVRs { m.mx.RLock() defer m.mx.RUnlock() kk := slices.Collect(maps.Keys(m.resMetas)) return client.GVRs(kk) } // GVK2GVR convert gvk to gvr func (m *Meta) GVK2GVR(gv schema.GroupVersion, kind string) (*client.GVR, bool, bool) { m.mx.RLock() defer m.mx.RUnlock() for gvr, meta := range m.resMetas { if gv.Group == meta.Group && gv.Version == meta.Version && kind == meta.Kind { return gvr, meta.Namespaced, true } } 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() defer m.mx.RUnlock() if meta, ok := m.resMetas[gvr]; ok { return meta, nil } return new(metav1.APIResource), fmt.Errorf("no resource meta defined for\n %q", gvr) } // IsCRD checks if resource represents a CRD func IsCRD(r *metav1.APIResource) bool { return slices.Contains(r.Categories, crdCat) } // IsK8sMeta checks for non resource meta. func IsK8sMeta(m *metav1.APIResource) bool { return !slices.ContainsFunc(m.Categories, func(category string) bool { return category == k9sCat || category == helmCat }) } // IsK9sMeta checks for non resource meta. func IsK9sMeta(m *metav1.APIResource) bool { return slices.Contains(m.Categories, k9sCat) } // IsScalable check if the resource can be scaled func IsScalable(m *metav1.APIResource) bool { return slices.Contains(m.Categories, scaleCat) } // LoadResources hydrates server preferred+CRDs resource metadata. func (m *Meta) LoadResources(f Factory) error { m.mx.Lock() defer m.mx.Unlock() m.resMetas.clear() if err := loadPreferred(f, m.resMetas); err != nil { return err } loadNonResource(m.resMetas) // We've actually loaded all the CRDs in loadPreferred, and we're now adding // some additional CRD properties on top of that. loadCRDs(f, m.resMetas) return nil } // BOZO!! Need countermeasures for direct commands! func loadNonResource(m ResourceMetas) { loadK9s(m) loadRBAC(m) loadHelm(m) } func loadK9s(m ResourceMetas) { m[client.WkGVR] = &metav1.APIResource{ Name: "workloads", Kind: "Workload", SingularName: "workload", Namespaced: true, ShortNames: []string{"wk"}, Categories: []string{k9sCat}, } m[client.PuGVR] = &metav1.APIResource{ Name: "pulses", Kind: "Pulse", SingularName: "pulse", ShortNames: []string{"hz", "pu"}, Categories: []string{k9sCat}, } m[client.DirGVR] = &metav1.APIResource{ Name: "dirs", Kind: "Dir", SingularName: "dir", Categories: []string{k9sCat}, } m[client.XGVR] = &metav1.APIResource{ Name: "xrays", Kind: "XRays", SingularName: "xray", Categories: []string{k9sCat}, } m[client.RefGVR] = &metav1.APIResource{ Name: "references", Kind: "References", SingularName: "reference", Verbs: []string{}, Categories: []string{k9sCat}, } m[client.AliGVR] = &metav1.APIResource{ Name: "aliases", Kind: "Aliases", SingularName: "alias", Verbs: []string{}, Categories: []string{k9sCat}, } m[client.CtGVR] = &metav1.APIResource{ Name: client.CtGVR.String(), Kind: "Contexts", SingularName: "context", ShortNames: []string{"ctx"}, Verbs: []string{}, Categories: []string{k9sCat}, } m[client.SdGVR] = &metav1.APIResource{ Name: "screendumps", Kind: "ScreenDumps", SingularName: "screendump", ShortNames: []string{"sd"}, Verbs: []string{"delete"}, Categories: []string{k9sCat}, } m[client.BeGVR] = &metav1.APIResource{ Name: "benchmarks", Kind: "Benchmarks", SingularName: "benchmark", ShortNames: []string{"be"}, Verbs: []string{"delete"}, Categories: []string{k9sCat}, } m[client.PfGVR] = &metav1.APIResource{ Name: "portforwards", Namespaced: true, Kind: "PortForwards", SingularName: "portforward", ShortNames: []string{"pf"}, Verbs: []string{"delete"}, Categories: []string{k9sCat}, } m[client.CoGVR] = &metav1.APIResource{ Name: "containers", Kind: "Containers", SingularName: "container", Verbs: []string{}, Categories: []string{k9sCat}, } m[client.ScnGVR] = &metav1.APIResource{ Name: "scans", Kind: "Scans", SingularName: "scan", Verbs: []string{}, Categories: []string{k9sCat}, } } func loadHelm(m ResourceMetas) { m[client.HmGVR] = &metav1.APIResource{ Name: "helm", Kind: "Helm", Namespaced: true, Verbs: []string{"delete"}, Categories: []string{helmCat}, } m[client.HmhGVR] = &metav1.APIResource{ Name: "history", Kind: "History", Namespaced: true, Verbs: []string{"delete"}, Categories: []string{helmCat}, } } func loadRBAC(m ResourceMetas) { m[client.RbacGVR] = &metav1.APIResource{ Name: "rbacs", Kind: "Rules", Categories: []string{k9sCat}, } m[client.PolGVR] = &metav1.APIResource{ Name: "policies", Kind: "Rules", Namespaced: true, Categories: []string{k9sCat}, } m[client.UsrGVR] = &metav1.APIResource{ Name: "users", Kind: "User", Categories: []string{k9sCat}, } m[client.GrpGVR] = &metav1.APIResource{ Name: "groups", Kind: "Group", Categories: []string{k9sCat}, } } func loadPreferred(f Factory, m ResourceMetas) error { if f == nil || f.Client() == nil || !f.Client().ConnectionOK() { slog.Error("Load cluster resources - No API server connection") return nil } dial, err := f.Client().CachedDiscovery() if err != nil { return err } rr, err := dial.ServerPreferredResources() if err != nil { slog.Debug("Failed to load preferred resources", slogs.Error, err) } for _, r := range rr { for i := range r.APIResources { res := r.APIResources[i] gvr := client.FromGVAndR(r.GroupVersion, res.Name) if isDeprecated(gvr) { continue } res.Group, res.Version = gvr.G(), gvr.V() if res.SingularName == "" { res.SingularName = strings.ToLower(res.Kind) } if !isStandardGroup(r.GroupVersion) { res.Categories = append(res.Categories, crdCat) } if isScalable(gvr) { res.Categories = append(res.Categories, scaleCat) } m[gvr] = &res } } return nil } func isStandardGroup(gv string) bool { return stdGroups.Has(gv) || strings.Contains(gv, ".k8s.io") } func isScalable(gvr *client.GVR) bool { return scalableRes.Has(gvr) } var deprecatedGVRs = sets.New( client.NewGVR("v1/events"), client.NewGVR("extensions/v1beta1/ingresses"), ) func isDeprecated(gvr *client.GVR) bool { return deprecatedGVRs.Has(gvr) || gvr.V() == "" } // loadCRDs Wait for the cache to synced and then add some additional properties to CRD. func loadCRDs(f Factory, m ResourceMetas) { if f == nil || f.Client() == nil || !f.Client().ConnectionOK() { return } oo, err := f.List(client.CrdGVR, client.ClusterScope, true, labels.Everything()) if err != nil { slog.Warn("CRDs load Fail", slogs.Error, err) return } for _, o := range oo { var crd apiext.CustomResourceDefinition err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &crd) if err != nil { slog.Error("CRD conversion failed", slogs.Error, err) continue } for gvr, version := range client.NewGVRFromCRD(&crd) { if meta, ok := m[gvr]; ok && version.Subresources != nil && version.Subresources.Scale != nil { if !slices.Contains(meta.Categories, scaleCat) { meta.Categories = append(meta.Categories, scaleCat) m[gvr] = meta } } } } } ================================================ FILE: internal/dao/registry_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "errors" "testing" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestMetaFor(t *testing.T) { uu := map[string]struct { gvr *client.GVR err error e metav1.APIResource }{ "xray-gvr": { gvr: client.XGVR, e: metav1.APIResource{ Name: "xrays", Kind: "XRays", SingularName: "xray", Categories: []string{k9sCat}, }, }, "xray": { gvr: client.NewGVR("xrays"), e: metav1.APIResource{ Name: "xrays", Kind: "XRays", SingularName: "xray", Categories: []string{k9sCat}, }, }, "policy": { gvr: client.NewGVR("policy"), e: metav1.APIResource{ Name: "policies", Kind: "Rules", Namespaced: true, Categories: []string{k9sCat}, }, }, "helm": { gvr: client.NewGVR("helm"), e: metav1.APIResource{ Name: "helm", Kind: "Helm", Namespaced: true, Verbs: []string{"delete"}, Categories: []string{helmCat}, }, }, "toast": { gvr: client.NewGVR("blah"), err: errors.New("no resource meta defined for\n \"blah\""), }, } m := NewMeta() require.NoError(t, m.LoadResources(nil)) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { meta, err := m.MetaFor(u.gvr) assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, &u.e, meta) } }) } } ================================================ FILE: internal/dao/resource.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "github.com/derailed/k9s/internal" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Resource)(nil) _ Describer = (*Resource)(nil) _ Nuker = (*Resource)(nil) ) // Resource represents an informer based resource. type Resource struct { Generic } // List returns a collection of resources. func (r *Resource) List(ctx context.Context, ns string) ([]runtime.Object, error) { lsel := labels.Everything() if sel, ok := ctx.Value(internal.KeyLabels).(labels.Selector); ok { lsel = sel } return r.getFactory().List(r.gvr, ns, false, lsel) } // Get returns a resource instance if found, else an error. func (r *Resource) Get(_ context.Context, path string) (runtime.Object, error) { return r.getFactory().Get(r.gvr, path, true, labels.Everything()) } // ToYAML returns a resource yaml. func (r *Resource) ToYAML(path string, showManaged bool) (string, error) { o, err := r.Get(context.Background(), path) if err != nil { return "", err } raw, err := ToYAML(o, showManaged) if err != nil { return "", fmt.Errorf("unable to marshal resource %w", err) } return raw, nil } ================================================ FILE: internal/dao/rest_mapper.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "fmt" "strings" "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/restmapper" ) // RestMapping holds k8s resource mapping. var RestMapping = &RestMapper{} // RestMapper map resource to REST mapping ie kind, group, version. type RestMapper struct { client.Connection } // ToRESTMapper map resources to kind, and map kind and version to interfaces for manipulating K8s objects. func (r *RestMapper) ToRESTMapper() (meta.RESTMapper, error) { dial, err := r.CachedDiscovery() if err != nil { return nil, err } mapper := restmapper.NewDeferredDiscoveryRESTMapper(dial) expander := restmapper.NewShortcutExpander(mapper, dial, nil) return expander, nil } // ResourceFor produces a rest mapping from a given resource. // Support full res name ie deployment.v1.apps. func (r *RestMapper) ResourceFor(resourceArg, kind string) (*meta.RESTMapping, error) { res, err := r.resourceFor(resourceArg) if err != nil { return nil, err } return r.toRESTMapping(res, kind), nil } func (r *RestMapper) resourceFor(resourceArg string) (schema.GroupVersionResource, error) { if resourceArg == "*" { return schema.GroupVersionResource{Resource: resourceArg}, nil } var ( gvr schema.GroupVersionResource err error ) mapper, err := r.ToRESTMapper() if err != nil { return gvr, err } fullGVR, gr := schema.ParseResourceArg(strings.ToLower(resourceArg)) if fullGVR != nil { return mapper.ResourceFor(*fullGVR) } gvr, err = mapper.ResourceFor(gr.WithVersion("")) if err != nil { if gr.Group == "" { return gvr, fmt.Errorf("the server doesn't have a resource type '%s'", gr.Resource) } return gvr, fmt.Errorf("the server doesn't have a resource type '%s' in group '%s'", gr.Resource, gr.Group) } return gvr, nil } func (*RestMapper) toRESTMapping(gvr schema.GroupVersionResource, kind string) *meta.RESTMapping { return &meta.RESTMapping{ Resource: gvr, GroupVersionKind: schema.GroupVersionKind{ Group: gvr.Group, Version: gvr.Version, Kind: kind, }, Scope: RestMapping, } } // Name protocol returns rest scope name. func (*RestMapper) Name() meta.RESTScopeName { return meta.RESTScopeNameNamespace } ================================================ FILE: internal/dao/rs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" cmdutil "k8s.io/kubectl/pkg/cmd/util" "k8s.io/kubectl/pkg/polymorphichelpers" ) var ( _ ImageLister = (*ReplicaSet)(nil) ) // ReplicaSet represents a replicaset K8s resource. type ReplicaSet struct { Resource } // ListImages lists container images. func (r *ReplicaSet) ListImages(_ context.Context, fqn string) ([]string, error) { rs, err := r.Load(r.Factory, fqn) if err != nil { return nil, err } return render.ExtractImages(&rs.Spec.Template.Spec), nil } // Load returns a given instance. func (*ReplicaSet) Load(f Factory, path string) (*appsv1.ReplicaSet, error) { o, err := f.Get(client.RsGVR, path, true, labels.Everything()) if err != nil { return nil, err } var rs appsv1.ReplicaSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &rs) if err != nil { return nil, err } return &rs, nil } func getRSRevision(rs *appsv1.ReplicaSet) (int64, error) { revision := rs.Annotations["deployment.kubernetes.io/revision"] if rs.Status.Replicas != 0 { return 0, errors.New("can not rollback current replica") } vers, err := strconv.Atoi(revision) if err != nil { return 0, errors.New("revision conversion failed") } return int64(vers), nil } func controllerInfo(rs *appsv1.ReplicaSet) (name, kind, group string, err error) { for _, ref := range rs.OwnerReferences { if ref.Controller == nil { continue } group, tokens := ref.APIVersion, strings.Split(ref.APIVersion, "/") if len(tokens) == 2 { group = tokens[0] } return ref.Name, ref.Kind, group, nil } return "", "", "", fmt.Errorf("unable to find controller for replicaset: %s", rs.Name) } // Rollback reverses the last deployment. func (r *ReplicaSet) Rollback(fqn string) error { rs, err := r.Load(r.Factory, fqn) if err != nil { return err } version, err := getRSRevision(rs) if err != nil { return err } name, kind, apiGroup, err := controllerInfo(rs) if err != nil { return err } dial, err := r.Client().Dial() if err != nil { return err } rb, err := polymorphichelpers.RollbackerFor(schema.GroupKind{ Group: apiGroup, Kind: kind, }, dial, ) if err != nil { return err } var ddp Deployment ddp.Init(r.Factory, client.DpGVR) dp, err := ddp.GetInstance(client.FQN(rs.Namespace, name)) if err != nil { return err } _, err = rb.Rollback(dp, map[string]string{}, version, cmdutil.DryRunNone) if err != nil { return err } return nil } ================================================ FILE: internal/dao/scalable.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" "k8s.io/client-go/restmapper" "k8s.io/client-go/scale" ) var ( _ Scalable = (*Scaler)(nil) _ ReplicasGetter = (*Scaler)(nil) ) // Scaler represents a generic resource with scaling. type Scaler struct { Generic } // Replicas returns the number of replicas for the resource located at the given path. func (s *Scaler) Replicas(ctx context.Context, path string) (int32, error) { scaleClient, err := s.scaleClient() if err != nil { return 0, err } ns, name := client.Namespaced(path) currScale, err := scaleClient.Scales(ns).Get(ctx, *s.gvr.GR(), name, metav1.GetOptions{}) if err != nil { return 0, err } return currScale.Spec.Replicas, nil } // Scale modifies the number of replicas for a given resource specified by the path. func (s *Scaler) Scale(ctx context.Context, path string, replicas int32) error { ns, name := client.Namespaced(path) scaleClient, err := s.scaleClient() if err != nil { return err } currentScale, err := scaleClient.Scales(ns).Get(ctx, *s.gvr.GR(), name, metav1.GetOptions{}) if err != nil { return err } currentScale.Spec.Replicas = replicas updatedScale, err := scaleClient.Scales(ns).Update(ctx, *s.gvr.GR(), currentScale, metav1.UpdateOptions{}) if err != nil { return err } slog.Debug("Scaled resource", slogs.FQN, path, slogs.Replicas, updatedScale.Spec.Replicas, ) return nil } func (s *Scaler) scaleClient() (scale.ScalesGetter, error) { cfg, err := s.Client().RestConfig() if err != nil { return nil, err } discoveryClient, err := s.Client().CachedDiscovery() if err != nil { return nil, err } mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient) scaleKindResolver := scale.NewDiscoveryScaleKindResolver(discoveryClient) return scale.NewForConfig(cfg, mapper, dynamic.LegacyAPIPathResolverFunc, scaleKindResolver) } ================================================ FILE: internal/dao/screen_dump.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "os" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/render" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*ScreenDump)(nil) _ Nuker = (*ScreenDump)(nil) ) // ScreenDump represents a scraped resources. type ScreenDump struct { NonResource } // Delete a ScreenDump. func (*ScreenDump) Delete(_ context.Context, path string, _ *metav1.DeletionPropagation, _ Grace) error { return os.Remove(path) } // List returns a collection of screen dumps. func (*ScreenDump) List(ctx context.Context, _ string) ([]runtime.Object, error) { dir, ok := ctx.Value(internal.KeyDir).(string) if !ok { return nil, errors.New("no screendump dir found in context") } ff, err := os.ReadDir(dir) if err != nil { return nil, err } oo := make([]runtime.Object, len(ff)) for i, f := range ff { if fi, err := f.Info(); err == nil { oo[i] = render.FileRes{File: fi, Dir: dir} } } return oo, nil } ================================================ FILE: internal/dao/secret.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "bytes" "context" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/printers" ) // Secret represents a secret K8s resource. type Secret struct { Resource decodeData bool } // Describe describes a secret that can be encoded or decoded. func (s *Secret) Describe(path string) (string, error) { encodedDescription, err := s.Generic.Describe(path) if err != nil { return "", err } if s.decodeData { return s.Decode(encodedDescription, path) } return encodedDescription, nil } // ToYAML returns a resource yaml. func (s *Secret) ToYAML(path string, showManaged bool) (string, error) { if s.decodeData { return s.decodeYAML(path, showManaged) } return s.Generic.ToYAML(path, showManaged) } func (s *Secret) decodeYAML(path string, showManaged bool) (string, error) { o, err := s.Get(context.Background(), path) if err != nil { return "", err } o = o.DeepCopyObject() 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") } if !showManaged { if meta, ok := u.Object["metadata"].(map[string]any); ok { delete(meta, "managedFields") } } if decoded, err := ExtractSecrets(o); err == nil { u.Object["data"] = decoded } var ( buff bytes.Buffer p printers.YAMLPrinter ) if err := p.PrintObj(o, &buff); err != nil { slog.Error("PrintObj failed", slogs.Error, err) return "", err } return buff.String(), nil } // SetDecodeData toggles decode mode. func (s *Secret) SetDecodeData(b bool) { s.decodeData = b } // Decode removes the encoded part from the secret's description and appends the // secret's decoded data. func (s *Secret) Decode(encodedDescription, path string) (string, error) { dataEndIndex := strings.Index(encodedDescription, "====") if dataEndIndex == -1 { return "", fmt.Errorf("unable to find data section in secret description") } dataEndIndex += 4 if dataEndIndex >= len(encodedDescription) { return "", fmt.Errorf("data section in secret description is invalid") } // Remove the encoded part from k8s's describe API // More details about the reasoning of index: https://github.com/kubernetes/kubectl/blob/v0.29.0/pkg/describe/describe.go#L2542 body := encodedDescription[0:dataEndIndex] o, err := s.Get(context.Background(), path) if err != nil { return "", err } data, err := ExtractSecrets(o) if err != nil { return "", err } decodedSecrets := make([]string, 0, len(data)) for k, v := range data { line := fmt.Sprintf("%s: %s", k, v) decodedSecrets = append(decodedSecrets, strings.TrimSpace(line)) } return body + "\n" + strings.Join(decodedSecrets, "\n"), nil } // ExtractSecrets takes an unstructured object and attempts to convert it into a // Kubernetes Secret. // It returns a map where the keys are the secret data keys and the values are // the corresponding secret data values. // If the conversion fails, it returns an error. func ExtractSecrets(o runtime.Object) (map[string]string, error) { u, ok := o.(*unstructured.Unstructured) if !ok { return nil, fmt.Errorf("expecting *unstructured.Unstructured but got %T", o) } var secret v1.Secret err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &secret) if err != nil { return nil, err } secretData := make(map[string]string, len(secret.Data)) for k, val := range secret.Data { secretData[k] = string(val) } return secretData, nil } ================================================ FILE: internal/dao/secret_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEncodedSecretDescribe(t *testing.T) { var s dao.Secret s.Init(makeFactory(), client.SecGVR) encodedString := ` Name: bootstrap-token-abcdef Namespace: kube-system Labels: Annotations: Type: generic Data ==== token-secret: 24 bytes` expected := "\nName: bootstrap-token-abcdef\n" + "Namespace: kube-system\n" + "Labels: \n" + "Annotations: \n" + "\n" + "Type: generic\n" + "\n" + "Data\n" + "====\n" + "token-secret: 0123456789abcdef" decodedDescription, err := s.Decode(encodedString, "kube-system/bootstrap-token-abcdef") require.NoError(t, err) assert.Equal(t, expected, decodedDescription) } ================================================ FILE: internal/dao/sts.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) var ( _ Accessor = (*StatefulSet)(nil) _ Nuker = (*StatefulSet)(nil) _ Loggable = (*StatefulSet)(nil) _ Restartable = (*StatefulSet)(nil) _ Scalable = (*StatefulSet)(nil) _ Controller = (*StatefulSet)(nil) _ ContainsPodSpec = (*StatefulSet)(nil) _ ImageLister = (*StatefulSet)(nil) ) // StatefulSet represents a K8s sts. type StatefulSet struct { Resource } // ListImages lists container images. func (s *StatefulSet) ListImages(_ context.Context, fqn string) ([]string, error) { sts, err := s.GetInstance(s.Factory, fqn) if err != nil { return nil, err } return render.ExtractImages(&sts.Spec.Template.Spec), nil } // Scale a StatefulSet. func (s *StatefulSet) Scale(ctx context.Context, path string, replicas int32) error { return scaleRes(ctx, s.getFactory(), client.StsGVR, path, replicas) } // Restart a StatefulSet rollout. func (s *StatefulSet) Restart(ctx context.Context, path string, opts *metav1.PatchOptions) error { return restartRes[*appsv1.StatefulSet](ctx, s.getFactory(), client.StsGVR, path, opts) } // GetInstance returns a statefulset instance. func (*StatefulSet) GetInstance(f Factory, fqn string) (*appsv1.StatefulSet, error) { o, err := f.Get(client.StsGVR, fqn, true, labels.Everything()) if err != nil { return nil, err } var sts appsv1.StatefulSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) if err != nil { return nil, errors.New("expecting Statefulset resource") } return &sts, nil } // TailLogs tail logs for all pods represented by this StatefulSet. func (s *StatefulSet) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { sts, err := s.getStatefulSet(opts.Path) if err != nil { return nil, errors.New("expecting StatefulSet resource") } if sts.Spec.Selector == nil || len(sts.Spec.Selector.MatchLabels) == 0 { return nil, fmt.Errorf("no valid selector found on statefulset: %s", opts.Path) } return podLogs(ctx, sts.Spec.Selector.MatchLabels, opts) } // Pod returns a pod victim by name. func (s *StatefulSet) Pod(fqn string) (string, error) { sts, err := s.getStatefulSet(fqn) if err != nil { return "", err } return podFromSelector(s.Factory, sts.Namespace, sts.Spec.Selector.MatchLabels) } func (s *StatefulSet) getStatefulSet(fqn string) (*appsv1.StatefulSet, error) { o, err := s.getFactory().Get(s.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var sts appsv1.StatefulSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) if err != nil { return nil, errors.New("expecting Service resource") } return &sts, nil } // ScanSA scans for serviceaccount refs. func (s *StatefulSet) ScanSA(_ context.Context, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := s.getFactory().List(s.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var sts appsv1.StatefulSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) if err != nil { return nil, errors.New("expecting StatefulSet resource") } if serviceAccountMatches(sts.Spec.Template.Spec.ServiceAccountName, n) { refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) } } return refs, nil } // Scan scans for cluster resource refs. func (s *StatefulSet) Scan(_ context.Context, gvr *client.GVR, fqn string, wait bool) (Refs, error) { ns, n := client.Namespaced(fqn) oo, err := s.getFactory().List(s.gvr, ns, wait, labels.Everything()) if err != nil { return nil, err } refs := make(Refs, 0, len(oo)) for _, o := range oo { var sts appsv1.StatefulSet err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &sts) if err != nil { return nil, errors.New("expecting StatefulSet resource") } switch gvr { case client.CmGVR: if !hasConfigMap(&sts.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) case client.SecGVR: found, err := hasSecret(s.Factory, &sts.Spec.Template.Spec, sts.Namespace, n, wait) if err != nil { slog.Warn("Locate secret failed", slogs.FQN, fqn, slogs.Error, err, ) continue } if !found { continue } refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) case client.PvcGVR: for i := range sts.Spec.VolumeClaimTemplates { if !strings.HasPrefix(n, sts.Spec.VolumeClaimTemplates[i].Name+"-"+sts.Name) { continue } refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) } if !hasPVC(&sts.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) case client.PcGVR: if !hasPC(&sts.Spec.Template.Spec, n) { continue } refs = append(refs, Ref{ GVR: s.GVR(), FQN: client.FQN(sts.Namespace, sts.Name), }) } } return refs, nil } // GetPodSpec returns a pod spec given a resource. func (s *StatefulSet) GetPodSpec(path string) (*v1.PodSpec, error) { sts, err := s.getStatefulSet(path) if err != nil { return nil, err } podSpec := sts.Spec.Template.Spec return &podSpec, nil } // SetImages sets container images. func (s *StatefulSet) SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error { ns, n := client.Namespaced(path) auth, err := s.Client().CanI(ns, client.StsGVR, n, client.PatchAccess) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to patch a statefulset") } jsonPatch, err := GetTemplateJsonPatch(imageSpecs) if err != nil { return err } dial, err := s.Client().Dial() if err != nil { return err } _, err = dial.AppsV1().StatefulSets(ns).Patch( ctx, n, types.StrategicMergePatchType, jsonPatch, metav1.PatchOptions{}, ) return err } ================================================ FILE: internal/dao/svc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "errors" "fmt" "github.com/derailed/k9s/internal/client" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) var ( _ Accessor = (*Service)(nil) _ Loggable = (*Service)(nil) _ Controller = (*Service)(nil) ) // Service represents a k8s service. type Service struct { Resource } // TailLogs tail logs for all pods represented by this Service. func (s *Service) TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) { svc, err := s.GetInstance(opts.Path) if err != nil { return nil, err } if len(svc.Spec.Selector) == 0 { return nil, fmt.Errorf("no valid selector found on Service %s", opts.Path) } return podLogs(ctx, svc.Spec.Selector, opts) } // Pod returns a pod victim by name. func (s *Service) Pod(fqn string) (string, error) { svc, err := s.GetInstance(fqn) if err != nil { return "", err } return podFromSelector(s.Factory, svc.Namespace, svc.Spec.Selector) } // GetInstance returns a service instance. func (s *Service) GetInstance(fqn string) (*v1.Service, error) { o, err := s.getFactory().Get(s.gvr, fqn, true, labels.Everything()) if err != nil { return nil, err } var svc v1.Service err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) if err != nil { return nil, errors.New("expecting Service resource") } return &svc, nil } // ---------------------------------------------------------------------------- // Helpers... func podFromSelector(f Factory, ns string, sel map[string]string) (string, error) { oo, err := f.List(client.PodGVR, ns, true, labels.Set(sel).AsSelector()) if err != nil { return "", err } if len(oo) == 0 { return "", fmt.Errorf("no matching pods for %v", sel) } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(oo[0].(*unstructured.Unstructured).Object, &pod) if err != nil { return "", err } return client.FQN(pod.Namespace, pod.Name), nil } ================================================ FILE: internal/dao/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/client-go/rest" ) const ( gvFmt = "application/json;as=Table;v=%s;g=%s, application/json" includeMeta = "Metadata" includeObj = "Object" includeNone = "None" header = "application/json;as=Table;v=v1;g=meta.k8s.io,application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/json" ) var genScheme = runtime.NewScheme() // Table retrieves K8s resources as tabular data. type Table struct { Generic } // Get returns a given resource. func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { f, p := t.codec() c, err := t.getClient(f) if err != nil { return nil, err } ns, n := client.Namespaced(path) a := fmt.Sprintf(gvFmt, metav1.SchemeGroupVersion.Version, metav1.GroupName) req := c.Get(). SetHeader("Accept", a). Name(n). Resource(t.gvr.R()). VersionedParams(&metav1.TableOptions{}, p) if ns != client.ClusterScope { req = req.Namespace(ns) } return req.Do(ctx).Get() } // List all Resources in a given namespace. func (t *Table) List(ctx context.Context, ns string) ([]runtime.Object, error) { sel := labels.Everything() if labelSel, ok := ctx.Value(internal.KeyLabels).(labels.Selector); ok { sel = labelSel } fieldSel, _ := ctx.Value(internal.KeyFields).(string) includeObject := includeMeta if t.includeObj { includeObject = includeObj } f, _ := t.codec() c, err := t.getClient(f) if err != nil { return nil, err } o, err := c.Get(). SetHeader("Accept", header). Param("includeObject", includeObject). Namespace(ns). Resource(t.gvr.R()). VersionedParams(&metav1.ListOptions{ LabelSelector: sel.String(), FieldSelector: fieldSel, }, metav1.ParameterCodec). Do(ctx).Get() if err != nil { return nil, err } namespaced := true if res, e := MetaAccess.MetaFor(t.gvr); e == nil && !res.Namespaced { namespaced = false } ta, err := decodeTable(ctx, o.(*metav1.Table), namespaced) if err != nil { return nil, err } return []runtime.Object{ta}, nil } // ---------------------------------------------------------------------------- // Helpers... func decodeTable(ctx context.Context, table *metav1.Table, namespaced bool) (runtime.Object, error) { if namespaced { table.ColumnDefinitions = append([]metav1.TableColumnDefinition{{Name: "Namespace", Type: "string"}}, table.ColumnDefinitions...) } pool := internal.NewWorkerPool(ctx, internal.DefaultPoolSize) for i := range table.Rows { pool.Add(func(_ context.Context) error { row := &table.Rows[i] if row.Object.Raw == nil || row.Object.Object != nil { return nil } converted, err := runtime.Decode(unstructured.UnstructuredJSONScheme, row.Object.Raw) if err != nil { return err } row.Object.Object = converted var m metav1.Object if obj := row.Object.Object; obj != nil { m, _ = meta.Accessor(obj) } var ns string if m != nil { ns = m.GetNamespace() } if namespaced { row.Cells = append([]any{ns}, row.Cells...) } return nil }) } errs := pool.Drain() if len(errs) > 0 { return nil, fmt.Errorf("failed to decode table rows: %w", errs[0]) } return table, nil } func (t *Table) getClient(f serializer.CodecFactory) (*rest.RESTClient, error) { cfg, err := t.Client().RestConfig() if err != nil { return nil, err } gv := t.gvr.GV() cfg.GroupVersion = &gv cfg.APIPath = "/apis" if t.gvr.G() == "" { cfg.APIPath = "/api" } cfg.NegotiatedSerializer = f.WithoutConversion() crRestClient, err := rest.RESTClientFor(cfg) if err != nil { return nil, err } return crRestClient, nil } func (t *Table) codec() (serializer.CodecFactory, runtime.ParameterCodec) { var tt metav1.Table opts := metav1.TableOptions{IncludeObject: metav1.IncludeObject} gv := t.gvr.GV() metav1.AddToGroupVersion(genScheme, gv) genScheme.AddKnownTypes(gv, &tt, &opts) genScheme.AddKnownTypes(metav1.SchemeGroupVersion, &tt, &opts) return serializer.NewCodecFactory(genScheme), runtime.NewParameterCodec(genScheme) } ================================================ FILE: internal/dao/testdata/bench/default_fred_1577308050814961000.txt ================================================ Summary: Total: 816.6403 secs Slowest: 0.0000 secs Fastest: 0.0000 secs Average: NaN secs Requests/sec: 0.0122 Response time histogram: Latency distribution: Details (average, fastest, slowest): DNS+dialup: NaN secs, 0.0000 secs, 0.0000 secs DNS-lookup: NaN secs, 0.0000 secs, 0.0000 secs req write: NaN secs, 0.0000 secs, 0.0000 secs resp wait: NaN secs, 0.0000 secs, 0.0000 secs resp read: NaN secs, 0.0000 secs, 0.0000 secs Status code distribution: Error distribution: [10] Get http://192.168.64.126:30805/: dial tcp 192.168.64.126:30805: connect: operation timed out ================================================ FILE: internal/dao/testdata/benchspec.yaml ================================================ benchmarks: defaults: concurrency: 2 requests: 500 containers: default/nginx:nginx: concurrency: 2 requests: 3000 http: method: GET path: / services: default/nginx: concurrency: 1 requests: 666 http: method: GET host: 192.168.64.1 path: / ================================================ FILE: internal/dao/testdata/config ================================================ apiVersion: v1 kind: Config preferences: {} clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3000 name: fred - cluster: insecure-skip-tls-verify: true server: https://localhost:3001 name: blee - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 name: duh contexts: - context: cluster: fred user: fred name: fred - context: cluster: blee namespace: zorg user: blee name: blee - context: cluster: duh user: duh name: duh current-context: fred users: - name: fred user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: blee user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: duh user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== ================================================ FILE: internal/dao/testdata/config.1 ================================================ apiVersion: v1 clusters: - cluster: insecure-skip-tls-verify: true server: https://localhost:3001 name: blee - cluster: insecure-skip-tls-verify: true server: https://localhost:3002 name: duh - cluster: insecure-skip-tls-verify: true server: https://localhost:3000 name: fred contexts: - context: cluster: blee user: blee name: blee - context: cluster: duh user: duh name: duh current-context: fred kind: Config preferences: {} users: - name: blee user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: duh user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== - name: fred user: client-certificate-data: ZnJlZA== client-key-data: ZnJlZA== ================================================ FILE: internal/dao/testdata/crb.json ================================================ { "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRoleBinding", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"rbac.authorization.k8s.io/v1\",\"kind\":\"ClusterRoleBinding\",\"metadata\":{\"annotations\":{},\"name\":\"blee\"},\"roleRef\":{\"apiGroup\":\"rbac.authorization.k8s.io\",\"kind\":\"ClusterRole\",\"name\":\"blee\"},\"subjects\":[{\"apiGroup\":\"rbac.authorization.k8s.io\",\"kind\":\"User\",\"name\":\"fernand\"}]}\n" }, "creationTimestamp": "2019-06-04T16:48:35Z", "name": "blee", "resourceVersion": "26689100", "selfLink": "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/blee", "uid": "97e5f84d-86e8-11e9-a8e8-42010a80015b" }, "roleRef": { "apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", "name": "blee" }, "subjects": [ { "apiGroup": "rbac.authorization.k8s.io", "kind": "User", "name": "fernand" } ] } ================================================ FILE: internal/dao/testdata/dir/a/b.yaml ================================================ ================================================ FILE: internal/dao/testdata/dir/a.yaml ================================================ ================================================ FILE: internal/dao/testdata/dr.json ================================================ { "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", "metadata": { "annotations": { "helm.sh/resource-policy": "keep", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apiextensions.k8s.io/v1\",\"kind\":\"CustomResourceDefinition\",\"metadata\":{\"annotations\":{\"helm.sh/resource-policy\":\"keep\"},\"labels\":{\"app\":\"istio-pilot\",\"chart\":\"istio\",\"heritage\":\"Tiller\",\"release\":\"istio\"},\"name\":\"destinationrules.networking.istio.io\"},\"spec\":{\"additionalPrinterColumns\":[{\"JSONPath\":\".spec.host\",\"description\":\"The name of a service from the service registry\",\"name\":\"Host\",\"type\":\"string\"},{\"JSONPath\":\".metadata.creationTimestamp\",\"description\":\"CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\\n\\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata\",\"name\":\"Age\",\"type\":\"date\"}],\"group\":\"networking.istio.io\",\"names\":{\"categories\":[\"istio-io\",\"networking-istio-io\"],\"kind\":\"DestinationRule\",\"listKind\":\"DestinationRuleList\",\"plural\":\"destinationrules\",\"shortNames\":[\"dr\"],\"singular\":\"destinationrule\"},\"scope\":\"Namespaced\",\"version\":\"v1alpha3\"}}\n" }, "creationTimestamp": "2019-12-30T16:13:02Z", "generation": 1, "labels": { "app": "istio-pilot", "chart": "istio", "heritage": "Tiller", "release": "istio" }, "name": "destinationrules.networking.istio.io", "resourceVersion": "2773373", "selfLink": "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/destinationrules.networking.istio.io", "uid": "123a30f8-8fcf-44b5-84b7-35f8c7869828" }, "spec": { "conversion": { "strategy": "None" }, "group": "networking.istio.io", "version": "v1alpha3", "names": { "categories": [ "istio-io", "networking-istio-io" ], "kind": "DestinationRule", "listKind": "DestinationRuleList", "plural": "destinationrules", "shortNames": [ "dr" ], "singular": "destinationrule" }, "preserveUnknownFields": true, "scope": "Namespaced", "versions": [ { "additionalPrinterColumns": [ { "description": "The name of a service from the service registry", "jsonPath": ".spec.host", "name": "Host", "type": "string" }, { "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata", "jsonPath": ".metadata.creationTimestamp", "name": "Age", "type": "date" } ], "name": "v1alpha3", "served": true, "storage": true } ] }, "status": { "acceptedNames": { "categories": [ "istio-io", "networking-istio-io" ], "kind": "DestinationRule", "listKind": "DestinationRuleList", "plural": "destinationrules", "shortNames": [ "dr" ], "singular": "destinationrule" }, "conditions": [ { "lastTransitionTime": "2019-12-30T16:13:02Z", "message": "no conflicts found", "reason": "NoConflicts", "status": "True", "type": "NamesAccepted" }, { "lastTransitionTime": "2019-12-30T16:13:02Z", "message": "the initial names have been accepted", "reason": "InitialNamesAccepted", "status": "True", "type": "Established" } ], "storedVersions": [ "v1alpha3" ] } } ================================================ FILE: internal/dao/testdata/n1.json ================================================ { "apiVersion": "v1", "kind": "Node", "metadata": { "annotations": { "kubeadm.alpha.kubernetes.io/cri-socket": "/var/run/dockershim.sock", "node.alpha.kubernetes.io/ttl": "0", "volumes.kubernetes.io/controller-managed-attach-detach": "true" }, "creationTimestamp": "2019-12-31T20:49:21Z", "labels": { "beta.kubernetes.io/arch": "amd64", "beta.kubernetes.io/os": "linux", "kubernetes.io/arch": "amd64", "kubernetes.io/hostname": "minikube", "kubernetes.io/os": "linux", "node-role.kubernetes.io/master": "" }, "name": "minikube", "resourceVersion": "214450", "selfLink": "/api/v1/nodes/minikube", "uid": "a33a26f0-7688-47b6-8dbf-5a04ea7f43d4" }, "spec": {}, "status": { "addresses": [ { "address": "192.168.64.6", "type": "InternalIP" }, { "address": "minikube", "type": "Hostname" } ], "allocatable": { "cpu": "4", "ephemeral-storage": "16954240Ki", "hugepages-2Mi": "0", "memory": "8163684Ki", "pods": "110" }, "capacity": { "cpu": "4", "ephemeral-storage": "16954240Ki", "hugepages-2Mi": "0", "memory": "8163684Ki", "pods": "110" }, "conditions": [ { "lastHeartbeatTime": "2020-01-01T22:05:55Z", "lastTransitionTime": "2019-12-31T20:49:18Z", "message": "kubelet has sufficient memory available", "reason": "KubeletHasSufficientMemory", "status": "False", "type": "MemoryPressure" }, { "lastHeartbeatTime": "2020-01-01T22:05:55Z", "lastTransitionTime": "2019-12-31T20:49:18Z", "message": "kubelet has no disk pressure", "reason": "KubeletHasNoDiskPressure", "status": "False", "type": "DiskPressure" }, { "lastHeartbeatTime": "2020-01-01T22:05:55Z", "lastTransitionTime": "2019-12-31T20:49:18Z", "message": "kubelet has sufficient PID available", "reason": "KubeletHasSufficientPID", "status": "False", "type": "PIDPressure" }, { "lastHeartbeatTime": "2020-01-01T22:05:55Z", "lastTransitionTime": "2019-12-31T20:49:22Z", "message": "kubelet is posting ready status", "reason": "KubeletReady", "status": "True", "type": "Ready" } ], "daemonEndpoints": { "kubeletEndpoint": { "Port": 10250 } }, "images": [ { "names": [ "quay.io/kubernetes-ingress-controller/nginx-ingress-controller@sha256:d0b22f715fcea5598ef7f869d308b55289a3daaa12922fa52a1abf17703c88e7", "quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.26.1" ], "sizeBytes": 483167446 }, { "names": [ "istio/proxyv2@sha256:236527816ff67f8492d7286775e09c28e207aee2f6f3c3d9258cd2248af4afa5", "istio/proxyv2:1.2.2" ], "sizeBytes": 369614978 }, { "names": [ "quay.io/kiali/kiali@sha256:60ceb57682e95fa3fb7c6e12d797f21c9e242c5583fa024a859d1085d0985c7b", "quay.io/kiali/kiali:v0.20" ], "sizeBytes": 344083595 }, { "names": [ "istio/kubectl@sha256:a94f8f992bc1e996319a58ff934f9c5e6658e2338fb59e1d937f919b8146d050", "istio/kubectl:1.2.2" ], "sizeBytes": 341145787 }, { "names": [ "istio/galley@sha256:786bb02b6d425697826ce740d723664beababf7a513eb8d4c95b42b35a99e91d", "istio/galley:1.2.2" ], "sizeBytes": 306543175 }, { "names": [ "istio/pilot@sha256:ab08845a7f4d1fd44c8481b35161a8da0cbf880f3d4f690740aec27350758a95", "istio/pilot:1.2.2" ], "sizeBytes": 303914365 }, { "names": [ "k8s.gcr.io/etcd@sha256:4afb99b4690b418ffc2ceb67e1a17376457e441c1f09ab55447f0aaf992fa646", "k8s.gcr.io/etcd:3.4.3-0" ], "sizeBytes": 288426917 }, { "names": [ "grafana/grafana@sha256:d66b41cf7e0586274ca3e15e03299e4cfde48019fd756bb97cc9db57da9b0c86", "grafana/grafana:6.1.6" ], "sizeBytes": 245005426 }, { "names": [ "k8s.gcr.io/kube-apiserver@sha256:e3ec33d533257902ad9ebe3d399c17710e62009201a7202aec941e351545d662", "k8s.gcr.io/kube-apiserver:v1.17.0" ], "sizeBytes": 170957331 }, { "names": [ "k8s.gcr.io/kube-controller-manager@sha256:0438efb5098a2ca634ea8c6b0d804742b733d0d13fd53cf62c73e32c659a3c39", "k8s.gcr.io/kube-controller-manager:v1.17.0" ], "sizeBytes": 160877075 }, { "names": [ "k8s.gcr.io/kube-proxy@sha256:b2ba9441af30261465e5c41be63e462d0050b09ad280001ae731f399b2b00b75", "k8s.gcr.io/kube-proxy:v1.17.0" ], "sizeBytes": 115960823 }, { "names": [ "k8s.gcr.io/nginx-slim@sha256:8b4501fe0fe221df663c22e16539f399e89594552f400408303c42f3dd8d0e52", "k8s.gcr.io/nginx-slim:0.8" ], "sizeBytes": 110487599 }, { "names": [ "prom/prometheus@sha256:1224ee30a3be668e0b22444773c4c1b750778af492094b6cd375c780c7526e22", "prom/prometheus:v2.8.0" ], "sizeBytes": 108629897 }, { "names": [ "istio/mixer@sha256:886726967363477eeba4cbf48675b058bcf833c932763b0964db80390fc06ceb", "istio/mixer:1.2.2" ], "sizeBytes": 97783922 }, { "names": [ "k8s.gcr.io/kube-scheduler@sha256:5215c4216a65f7e76c1895ba951a12dc1c947904a91810fc66a544ff1d7e87db", "k8s.gcr.io/kube-scheduler:v1.17.0" ], "sizeBytes": 94431763 }, { "names": [ "kubernetesui/dashboard:v2.0.0-beta8" ], "sizeBytes": 90835427 }, { "names": [ "k8s.gcr.io/kube-addon-manager:v9.0.2" ], "sizeBytes": 83076028 }, { "names": [ "gcr.io/k8s-minikube/storage-provisioner:v1.8.1" ], "sizeBytes": 80815640 }, { "names": [ "istio/citadel@sha256:1e8065b277cb79a32ef617f7af468f9afe5b21ec2e0b42245d029c59fe3ce435", "istio/citadel:1.2.2" ], "sizeBytes": 68454561 }, { "names": [ "istio/sidecar_injector@sha256:c8f6f5fb1bb2434f68199e06b124e85dc58a3879bf1275a4d39c400836bd3ca4", "istio/sidecar_injector:1.2.2" ], "sizeBytes": 63917960 }, { "names": [ "k8s.gcr.io/metrics-server-amd64@sha256:49a9f12f7067d11f42c803dbe61ed2c1299959ad85cb315b25ff7eef8e6b8892", "k8s.gcr.io/metrics-server-amd64:v0.2.1" ], "sizeBytes": 42541759 }, { "names": [ "k8s.gcr.io/coredns@sha256:7ec975f167d815311a7136c32e70735f0d00b73781365df1befd46ed35bd4fe7", "k8s.gcr.io/coredns:1.6.5" ], "sizeBytes": 41578211 }, { "names": [ "kubernetesui/metrics-scraper:v1.0.2" ], "sizeBytes": 40101552 }, { "names": [ "jaegertracing/all-in-one@sha256:29c921747eddfa96c97cf96aac0180e97bfdfcbea25e230daef09711103d1f61", "jaegertracing/all-in-one:1.9" ], "sizeBytes": 37328894 }, { "names": [ "k8s.gcr.io/pause@sha256:f78411e19d84a252e53bff71a4407a5686c46983a2c2eeed83929b888179acea", "k8s.gcr.io/pause:3.1" ], "sizeBytes": 742472 } ], "nodeInfo": { "architecture": "amd64", "bootID": "478c895b-009b-4b6e-9115-63502eaa68cb", "containerRuntimeVersion": "docker://19.3.5", "kernelVersion": "4.19.81", "kubeProxyVersion": "v1.17.0", "kubeletVersion": "v1.17.0", "machineID": "6c484e2bfebf46f2ac854c484bcfa392", "operatingSystem": "linux", "osImage": "Buildroot 2019.02.7", "systemUUID": "dbc511ea-0000-0000-a42f-acde48001122" } } } ================================================ FILE: internal/dao/testdata/p1.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/restartedAt": "2019-12-31T12:26:47-07:00" }, "creationTimestamp": "2019-12-31T19:27:22Z", "generateName": "nginx-7fb78fb6d8-", "labels": { "app": "nginx", "pod-template-hash": "7fb78fb6d8" }, "name": "nginx-7fb78fb6d8-2w75j", "namespace": "default", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "ReplicaSet", "name": "nginx-7fb78fb6d8", "uid": "7ccd0600-2c03-11ea-883f-42010a800044" } ], "resourceVersion": "87290191", "selfLink": "/api/v1/namespaces/default/pods/nginx-7fb78fb6d8-2w75j", "uid": "91bb1cf2-2c03-11ea-883f-42010a800044" }, "spec": { "containers": [ { "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "cpu": "200m", "memory": "20Mi" }, "requests": { "cpu": "200m", "memory": "20Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-dsl46", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "gke-k9s-default-pool-0fa2fb89-lbtf", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "default-token-dsl46", "secret": { "defaultMode": 420, "secretName": "default-token-dsl46" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:23Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:22Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://90e0abf7a779dd76d36038883312baed57a8351428a1d6340df3cff698f51809", "image": "k8s.gcr.io/nginx-slim:0.8", "imageID": "docker-pullable://k8s.gcr.io/nginx-slim@sha256:8b4501fe0fe221df663c22e16539f399e89594552f400408303c42f3dd8d0e52", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-12-31T19:27:24Z" } } } ], "hostIP": "10.128.0.15", "phase": "Running", "podIP": "10.44.0.229", "qosClass": "Guaranteed", "startTime": "2019-12-31T19:27:23Z" } } ================================================ FILE: internal/dao/testdata/secret.json ================================================ { "apiVersion": "v1", "data": { "token-secret": "MDEyMzQ1Njc4OWFiY2RlZg==" }, "kind": "Secret", "metadata": { "creationTimestamp": "2024-01-15T18:19:00Z", "name": "bootstrap-token-abcdef", "namespace": "kube-system", "resourceVersion": "243", "uid": "6f5695d4-c0f4-4b65-890a-b1115ffd1f3b" }, "type": "bootstrap.kubernetes.io/token" } ================================================ FILE: internal/dao/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "io" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/watch" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" restclient "k8s.io/client-go/rest" ) // Factory represents a resource factory. type Factory interface { // Client retrieves an api client. Client() client.Connection // Get fetch a given resource. Get(gvr *client.GVR, path string, wait bool, sel labels.Selector) (runtime.Object, error) // List fetch a collection of resources. List(gvr *client.GVR, ns string, wait bool, sel labels.Selector) ([]runtime.Object, error) // ForResource fetch an informer for a given resource. ForResource(ns string, gvr *client.GVR) (informers.GenericInformer, error) // CanForResource fetch an informer for a given resource if authorized CanForResource(ns string, gvr *client.GVR, verbs []string) (informers.GenericInformer, error) // WaitForCacheSync synchronize the cache. WaitForCacheSync() // DeleteForwarder deletes a pod forwarder. DeleteForwarder(path string) // Forwarders returns all portforwards. Forwarders() watch.Forwarders } // ImageLister tracks resources with container images. type ImageLister interface { // ListImages lists container images. ListImages(ctx context.Context, path string) ([]string, error) } // Getter represents a resource getter. type Getter interface { // Get return a given resource. Get(ctx context.Context, path string) (runtime.Object, error) } // Lister represents a resource lister. type Lister interface { // List returns a resource collection. List(ctx context.Context, ns string) ([]runtime.Object, error) } // Accessor represents an accessible k8s resource. type Accessor interface { Lister Getter // Init the resource with a factory object. Init(Factory, *client.GVR) // GVR returns a gvr a string. GVR() string // SetIncludeObject toggles object inclusion. SetIncludeObject(bool) } // DrainOptions tracks drain attributes. type DrainOptions struct { GracePeriodSeconds int Timeout time.Duration IgnoreAllDaemonSets bool DeleteEmptyDirData bool Force bool DisableEviction bool } // NodeMaintainer performs node maintenance operations. type NodeMaintainer interface { // ToggleCordon toggles cordon/uncordon a node. ToggleCordon(path string, cordon bool) error // Drain drains the given node. Drain(path string, opts DrainOptions, w io.Writer) error } // Loggable represents resources with logs. type Loggable interface { // TailLogs streams resource logs. TailLogs(ctx context.Context, opts *LogOptions) ([]LogChan, error) } // Describer describes a resource. type Describer interface { // Describe describes a resource. Describe(path string) (string, error) // ToYAML dumps a resource to YAML. ToYAML(path string, showManaged bool) (string, error) } // Scalable represents resources that can scale. type Scalable interface { // Scale scales a resource up or down. Scale(ctx context.Context, path string, replicas int32) error } // ReplicasGetter represents a resource with replicas. type ReplicasGetter interface { // Replicas returns the number of replicas for the resource located at the given path. Replicas(ctx context.Context, path string) (int32, error) } // Controller represents a pod controller. type Controller interface { // Pod returns a pod instance matching the selector. Pod(path string) (string, error) } // Nuker represents a resource deleter. type Nuker interface { // Delete removes a resource from the api server. Delete(context.Context, string, *metav1.DeletionPropagation, Grace) error } // Switchable represents a switchable resource. type Switchable interface { // Switch changes the active context. Switch(ctx string) error } // Restartable represents a restartable resource. type Restartable interface { // Restart performs a rollout restart. Restart(context.Context, string, *metav1.PatchOptions) error } // Runnable represents a runnable resource. type Runnable interface { // Run triggers a run. Run(path string) error } // Logger represents a resource that exposes logs. type Logger interface { // Logs tails a resource logs. Logs(path string, opts *v1.PodLogOptions) (*restclient.Request, error) } // ContainsPodSpec represents a resource with a pod template. type ContainsPodSpec interface { // GetPodSpec returns a podspec for the resource. GetPodSpec(path string) (*v1.PodSpec, error) // SetImages sets container image. SetImages(ctx context.Context, path string, imageSpecs ImageSpecs) error } // Sanitizer represents a resource sanitizer. type Sanitizer interface { // Sanitize nukes all resources in unhappy state. Sanitize(context.Context, string) (int, error) } // Valuer represents a resource with values. type Valuer interface { // GetValues returns values for a resource. GetValues(path string, allValues bool) ([]byte, error) } ================================================ FILE: internal/dao/utils_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao_test import ( "encoding/json" "fmt" "os" "path" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/watch" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) type testFactory struct { inventory map[string]map[*client.GVR][]runtime.Object } func makeFactory() dao.Factory { return &testFactory{ inventory: map[string]map[*client.GVR][]runtime.Object{ "kube-system": { client.SecGVR: { load("secret"), }, }, }, } } var _ dao.Factory = &testFactory{} func (*testFactory) Client() client.Connection { return nil } func (f *testFactory) Get(gvr *client.GVR, fqn string, _ bool, _ labels.Selector) (runtime.Object, error) { ns, po := path.Split(fqn) ns = strings.Trim(ns, "/") for _, o := range f.inventory[ns][gvr] { if o.(*unstructured.Unstructured).GetName() == po { return o, nil } } return nil, nil } func (f *testFactory) List(gvr *client.GVR, ns string, _ bool, _ labels.Selector) ([]runtime.Object, error) { return f.inventory[ns][gvr], nil } func (*testFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (*testFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (*testFactory) WaitForCacheSync() {} func (*testFactory) Forwarders() watch.Forwarders { return nil } func (*testFactory) DeleteForwarder(string) {} func load(n string) *unstructured.Unstructured { raw, _ := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) var o unstructured.Unstructured _ = json.Unmarshal(raw, &o) return &o } ================================================ FILE: internal/dao/workload.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dao import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strconv" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) const ( StatusOK = "OK" DegradedStatus = "DEGRADED" ) var resList = []*client.GVR{ client.PodGVR, client.SvcGVR, client.DsGVR, client.StsGVR, client.DpGVR, client.RsGVR, } // Workload tracks a select set of resources in a given namespace. type Workload struct { Table } func (w *Workload) Delete(ctx context.Context, path string, propagation *metav1.DeletionPropagation, grace Grace) error { gvr, _ := ctx.Value(internal.KeyGVR).(*client.GVR) ns, n := client.Namespaced(path) auth, err := w.Client().CanI(ns, gvr, n, []string{client.DeleteVerb}) if err != nil { return err } if !auth { return fmt.Errorf("user is not authorized to delete %s", path) } var gracePeriod *int64 if grace != DefaultGrace { gracePeriod = (*int64)(&grace) } opts := metav1.DeleteOptions{ PropagationPolicy: propagation, GracePeriodSeconds: gracePeriod, } ctx, cancel := context.WithTimeout(ctx, w.Client().Config().CallTimeout()) defer cancel() d, err := w.Client().DynDial() if err != nil { return err } dial := d.Resource(gvr.GVR()) if client.IsClusterScoped(ns) { return dial.Delete(ctx, n, opts) } return dial.Namespace(ns).Delete(ctx, n, opts) } func (a *Workload) fetch(ctx context.Context, gvr *client.GVR, ns string) (*metav1.Table, error) { a.gvr = gvr oo, err := a.Table.List(ctx, ns) if err != nil { return nil, err } if len(oo) == 0 { return nil, fmt.Errorf("no table found for gvr: %s", gvr) } tt, ok := oo[0].(*metav1.Table) if !ok { return nil, errors.New("not a metav1.Table") } return tt, nil } // List fetch workloads. func (a *Workload) List(ctx context.Context, ns string) ([]runtime.Object, error) { oo := make([]runtime.Object, 0, 100) for _, gvr := range resList { table, err := a.fetch(ctx, gvr, ns) if err != nil { return nil, err } var ( ns string ts metav1.Time ) for _, r := range table.Rows { if obj := r.Object.Object; obj != nil { if m, err := meta.Accessor(obj); err == nil { ns, ts = m.GetNamespace(), m.GetCreationTimestamp() } } else { var m metav1.PartialObjectMetadata if err := json.Unmarshal(r.Object.Raw, &m); err == nil { ns, ts = m.GetNamespace(), m.CreationTimestamp } } stat := status(gvr, &r, table.ColumnDefinitions) oo = append(oo, &render.WorkloadRes{Row: metav1.TableRow{Cells: []any{ gvr.String(), ns, r.Cells[indexOf("Name", table.ColumnDefinitions)], stat, readiness(gvr, &r, table.ColumnDefinitions), validity(stat), ts, }}}) } } return oo, nil } // Helpers... func readiness(gvr *client.GVR, r *metav1.TableRow, h []metav1.TableColumnDefinition) string { switch gvr { case client.PodGVR, client.DpGVR, client.StsGVR: return r.Cells[indexOf("Ready", h)].(string) case client.RsGVR, client.DsGVR: c := r.Cells[indexOf("Ready", h)].(int64) d := r.Cells[indexOf("Desired", h)].(int64) return fmt.Sprintf("%d/%d", c, d) case client.SvcGVR: return "" } return render.NAValue } func status(gvr *client.GVR, r *metav1.TableRow, h []metav1.TableColumnDefinition) string { switch gvr { case client.PodGVR: if status := r.Cells[indexOf("Status", h)]; status == render.PhaseCompleted { return StatusOK } else if !isReady(r.Cells[indexOf("Ready", h)].(string)) || status != render.PhaseRunning { return DegradedStatus } case client.DpGVR, client.StsGVR: if !isReady(r.Cells[indexOf("Ready", h)].(string)) { return DegradedStatus } case client.RsGVR, client.DsGVR: rd, ok1 := r.Cells[indexOf("Ready", h)].(int64) de, ok2 := r.Cells[indexOf("Desired", h)].(int64) if ok1 && ok2 { if !isReady(fmt.Sprintf("%d/%d", rd, de)) { return DegradedStatus } break } rds, oks1 := r.Cells[indexOf("Ready", h)].(string) des, oks2 := r.Cells[indexOf("Desired", h)].(string) if oks1 && oks2 { if !isReady(fmt.Sprintf("%s/%s", rds, des)) { return DegradedStatus } } case client.SvcGVR: default: return render.MissingValue } return StatusOK } func validity(status string) string { if status != "DEGRADED" { return "" } return status } func isReady(s string) bool { tt := strings.Split(s, "/") if len(tt) != 2 { return false } r, err := strconv.Atoi(tt[0]) if err != nil { slog.Error("Invalid ready count", slogs.Error, err, slogs.Count, tt[0], ) return false } c, err := strconv.Atoi(tt[1]) if err != nil { slog.Error("invalid expected count: %q", slogs.Error, err, slogs.Count, tt[1], ) return false } if c == 0 { return true } return r == c } func indexOf(n string, defs []metav1.TableColumnDefinition) int { for i, d := range defs { if d.Name == n { return i } } return -1 } ================================================ FILE: internal/health/check.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package health import ( "github.com/derailed/k9s/internal/client" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Check tracks resource health. type Check struct { Counts GVR *client.GVR } // Checks represents a collection of health checks. type Checks []*Check // NewCheck returns a new health check. func NewCheck(gvr *client.GVR) *Check { return &Check{ GVR: gvr, Counts: make(Counts), } } // Set sets a health metric. func (c *Check) Set(l Level, v int64) { c.Counts[l] = v } // Inc increments a health metric. func (c *Check) Inc(l Level) { c.Counts[l]++ } // Total stores a metric total. func (c *Check) Total(n int64) { c.Counts[Corpus] = n } // Tally retrieves a given health metric. func (c *Check) Tally(l Level) int64 { return c.Counts[l] } // GetObjectKind returns a schema object. func (Check) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (c Check) DeepCopyObject() runtime.Object { return c } ================================================ FILE: internal/health/check_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package health_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/health" "github.com/stretchr/testify/assert" ) func TestCheck(t *testing.T) { var cc health.Checks c := health.NewCheck(client.NewGVR("test")) n := 0 for range 10 { c.Inc(health.S1) cc = append(cc, c) n++ } c.Total(int64(n)) assert.Len(t, cc, 10) assert.Equal(t, int64(10), c.Tally(health.Corpus)) assert.Equal(t, int64(10), c.Tally(health.S1)) assert.Equal(t, int64(0), c.Tally(health.S2)) } ================================================ FILE: internal/health/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package health // Level tracks health count categories. type Level int const ( // Unknown represents no health level. Unknown Level = 1 << iota // Corpus tracks total health. Corpus // S1 tracks series 1. S1 // S2 tracks series 2. S2 // S3 tracks series 3. S3 ) // Message represents a health message. type Message struct { Level Level Message string GVR string FQN string } // Messages tracks a collection of messages. type Messages []Message // Counts tracks health counts by category. type Counts map[Level]int64 // Vital tracks a resource vitals. type Vital struct { Resource string Total, OK, Toast int } // Vitals tracks a collection of resource health. type Vitals []Vital ================================================ FILE: internal/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package internal import ( "regexp" "strings" "github.com/derailed/k9s/internal/view/cmd" ) var ( fuzzyRx = regexp.MustCompile(`\A-f\s?([\w-]+)\b`) labelRx = regexp.MustCompile(`\A\-l`) ) // Helpers... // IsInverseSelector checks if inverse char has been provided. func IsInverseSelector(s string) bool { if s == "" { return false } return s[0] == '!' } // IsLabelSelector checks if query is a label query. func IsLabelSelector(s string) bool { if labelRx.MatchString(s) { return true } return !strings.Contains(s, " ") && cmd.ToLabels(s) != nil } // IsFuzzySelector checks if query is fuzzy. func IsFuzzySelector(s string) (string, bool) { mm := fuzzyRx.FindStringSubmatch(s) if len(mm) != 2 { return "", false } return mm[1], true } ================================================ FILE: internal/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package internal_test import ( "testing" "github.com/derailed/k9s/internal" "github.com/stretchr/testify/assert" ) func TestIsLabelSelector(t *testing.T) { uu := map[string]struct { s string ok bool }{ "empty": {s: ""}, "cool": {s: "-l app=fred,env=blee", ok: true}, "no-flag": {s: "app=fred,env=blee", ok: true}, "no-space": {s: "-lapp=fred,env=blee", ok: true}, "wrong-flag": {s: "-f app=fred,env=blee"}, "missing-key": {s: "=fred"}, "missing-val": {s: "fred="}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.ok, internal.IsLabelSelector(u.s)) }) } } ================================================ FILE: internal/keys.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package internal // ContextKey represents context key. type ContextKey string // A collection of context keys. const ( KeyFactory ContextKey = "factory" KeyLabels ContextKey = "labels" KeyFields ContextKey = "fields" KeyTable ContextKey = "table" KeyDir ContextKey = "dir" KeyPath ContextKey = "path" KeySubject ContextKey = "subject" KeyGVR ContextKey = "gvr" KeyFQN ContextKey = "fqn" KeyForwards ContextKey = "forwards" KeyContainers ContextKey = "containers" KeyBenchCfg ContextKey = "benchcfg" KeyAliases ContextKey = "aliases" KeyUID ContextKey = "uid" KeySubjectKind ContextKey = "subjectKind" KeySubjectName ContextKey = "subjectName" KeyNamespace ContextKey = "namespace" KeyCluster ContextKey = "cluster" KeyApp ContextKey = "app" KeyStyles ContextKey = "styles" KeyMetrics ContextKey = "metrics" KeyHasMetrics ContextKey = "has-metrics" KeyToast ContextKey = "toast" KeyWithMetrics ContextKey = "withMetrics" KeyViewConfig ContextKey = "viewConfig" KeyWait ContextKey = "wait" KeyPodCounting ContextKey = "podCounting" KeyEnableImgScan ContextKey = "vulScan" ) ================================================ FILE: internal/model/cluster.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "errors" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/cache" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) const ( clusterCacheSize = 100 clusterCacheExpiry = 1 * time.Minute clusterNodesKey = "nodes" ) type ( // MetricsServer gather metrics information from pods and nodes. MetricsServer interface { MetricsService ClusterLoad(*v1.NodeList, *mv1beta1.NodeMetricsList, *client.ClusterMetrics) error NodesMetrics(*v1.NodeList, *mv1beta1.NodeMetricsList, client.NodesMetrics) PodsMetrics(*mv1beta1.PodMetricsList, client.PodsMetrics) } // MetricsService calls the metrics server for metrics info. MetricsService interface { HasMetrics() bool FetchNodesMetrics(ctx context.Context) (*mv1beta1.NodeMetricsList, error) FetchPodsMetrics(ctx context.Context, ns string) (*mv1beta1.PodMetricsList, error) } // Cluster represents a kubernetes resource. Cluster struct { factory dao.Factory mx MetricsServer cache *cache.LRUExpireCache } ) // NewCluster returns a new cluster info resource. func NewCluster(f dao.Factory) *Cluster { return &Cluster{ factory: f, mx: client.DialMetrics(f.Client()), cache: cache.NewLRUExpireCache(clusterCacheSize), } } // Version returns the current K8s cluster version. func (c *Cluster) Version() string { info, err := c.factory.Client().ServerVersion() if err != nil || info == nil { return client.NA } return info.GitVersion } // ContextName returns the context name. func (c *Cluster) ContextName() string { n, err := c.factory.Client().Config().CurrentContextName() if err != nil { return client.NA } return n } // ClusterName returns the context name. func (c *Cluster) ClusterName() string { n, err := c.factory.Client().Config().CurrentClusterName() if err != nil { return client.NA } return n } // UserName returns the user name. func (c *Cluster) UserName() string { n, err := c.factory.Client().Config().CurrentUserName() if err != nil { return client.NA } return n } // Metrics gathers node level metrics and compute utilization percentages. func (c *Cluster) Metrics(ctx context.Context, mx *client.ClusterMetrics) error { var ( nn *v1.NodeList err error ) if v, ok := c.cache.Get(clusterNodesKey); ok { if nl, ok := v.(*v1.NodeList); ok { nn = nl } } else { if nn, err = dao.FetchNodes(ctx, c.factory, ""); err != nil { return err } } if nn == nil { return errors.New("unable to fetch nodes list") } if len(nn.Items) > 0 { c.cache.Add(clusterNodesKey, nn, clusterCacheExpiry) } var nmx *mv1beta1.NodeMetricsList if nmx, err = c.mx.FetchNodesMetrics(ctx); err != nil { return err } return c.mx.ClusterLoad(nn, nmx, mx) } ================================================ FILE: internal/model/cluster_info.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "sync" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/util/cache" ) const ( k9sGitURL = "https://api.github.com/repos/derailed/k9s/releases/latest" cacheSize = 10 cacheExpiry = 1 * time.Hour k9sLatestRevKey = "k9sRev" ) // ClusterInfoListener registers a listener for model changes. type ClusterInfoListener interface { // ClusterInfoChanged notifies the cluster meta was changed. ClusterInfoChanged(prev, curr *ClusterMeta) // ClusterInfoUpdated notifies the cluster meta was updated. ClusterInfoUpdated(*ClusterMeta) } // ClusterMeta represents cluster meta data. type ClusterMeta struct { Context, Cluster string User string K9sVer, K9sLatest string K8sVer string Cpu, Mem, Ephemeral int } // NewClusterMeta returns a new instance. func NewClusterMeta() *ClusterMeta { return &ClusterMeta{ Context: client.NA, Cluster: client.NA, User: client.NA, K9sVer: client.NA, K8sVer: client.NA, Cpu: 0, Mem: 0, Ephemeral: 0, } } // Deltas diffs cluster meta return true if different, false otherwise. func (c *ClusterMeta) Deltas(n *ClusterMeta) bool { if c.Cpu != n.Cpu || c.Mem != n.Mem || c.Ephemeral != n.Ephemeral { return true } return c.Context != n.Context || c.Cluster != n.Cluster || c.User != n.User || c.K8sVer != n.K8sVer || c.K9sVer != n.K9sVer || c.K9sLatest != n.K9sLatest } // ClusterInfo models cluster metadata. type ClusterInfo struct { cluster *Cluster factory dao.Factory data *ClusterMeta version string cfg *config.K9s listeners []ClusterInfoListener cache *cache.LRUExpireCache mx sync.RWMutex } // NewClusterInfo returns a new instance. func NewClusterInfo(f dao.Factory, v string, cfg *config.K9s) *ClusterInfo { c := ClusterInfo{ factory: f, cluster: NewCluster(f), data: NewClusterMeta(), version: v, cfg: cfg, cache: cache.NewLRUExpireCache(cacheSize), } return &c } func (c *ClusterInfo) fetchK9sLatestRev() string { rev, ok := c.cache.Get(k9sLatestRevKey) if ok { return rev.(string) } latestRev, err := fetchLatestRev() if err != nil { slog.Warn("k9s latest rev fetch failed", slogs.Error, err) } else { c.cache.Add(k9sLatestRevKey, latestRev, cacheExpiry) } return latestRev } // Reset resets context and reload. func (c *ClusterInfo) Reset(f dao.Factory) { if f == nil { return } c.mx.Lock() c.cluster, c.data = NewCluster(f), NewClusterMeta() c.mx.Unlock() c.Refresh() } // Refresh fetches the latest cluster meta. func (c *ClusterInfo) Refresh() { data := NewClusterMeta() if c.factory.Client().ConnectionOK() { data.Context = c.cluster.ContextName() data.Cluster = c.cluster.ClusterName() data.User = c.cluster.UserName() data.K8sVer = c.cluster.Version() ctx, cancel := context.WithTimeout(context.Background(), c.cluster.factory.Client().Config().CallTimeout()) defer cancel() var mx client.ClusterMetrics if err := c.cluster.Metrics(ctx, &mx); err == nil { data.Cpu, data.Mem, data.Ephemeral = mx.PercCPU, mx.PercMEM, mx.PercEphemeral } } data.K9sVer = c.version v1 := NewSemVer(data.K9sVer) var latestRev string if !c.cfg.SkipLatestRevCheck { latestRev = c.fetchK9sLatestRev() } v2 := NewSemVer(latestRev) data.K9sVer, data.K9sLatest = v1.String(), v2.String() if v1.IsCurrent(v2) { data.K9sLatest = "" } if c.data.Deltas(data) { c.fireMetaChanged(c.data, data) } else { c.fireNoMetaChanged(data) } c.mx.Lock() c.data = data c.mx.Unlock() } // AddListener adds a new model listener. func (c *ClusterInfo) AddListener(l ClusterInfoListener) { c.listeners = append(c.listeners, l) } // RemoveListener delete a listener from the list. func (c *ClusterInfo) RemoveListener(l ClusterInfoListener) { victim := -1 for i, lis := range c.listeners { if lis == l { victim = i break } } if victim >= 0 { c.listeners = append(c.listeners[:victim], c.listeners[victim+1:]...) } } func (c *ClusterInfo) fireMetaChanged(prev, cur *ClusterMeta) { for _, l := range c.listeners { l.ClusterInfoChanged(prev, cur) } } func (c *ClusterInfo) fireNoMetaChanged(data *ClusterMeta) { for _, l := range c.listeners { l.ClusterInfoUpdated(data) } } // Helpers... func fetchLatestRev() (string, error) { slog.Debug("Fetching latest k9s rev...") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, k9sGitURL, http.NoBody) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer func() { if resp.Body != nil { _ = resp.Body.Close() } }() b, err := io.ReadAll(resp.Body) if err != nil { return "", err } m := make(map[string]any, 20) if err := json.Unmarshal(b, &m); err != nil { return "", err } if v, ok := m["name"]; ok { slog.Debug("K9s latest rev", slogs.Revision, v.(string)) return v.(string), nil } return "", errors.New("no version found") } ================================================ FILE: internal/model/cluster_info_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "log/slog" "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestClusterMetaDelta(t *testing.T) { uu := map[string]struct { o, n *model.ClusterMeta e bool }{ "empty": { o: model.NewClusterMeta(), n: model.NewClusterMeta(), }, "same": { o: makeClusterMeta("fred"), n: makeClusterMeta("fred"), }, "diff": { o: makeClusterMeta("fred"), n: makeClusterMeta("freddie"), e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.o.Deltas(u.n)) }) } } // Helpers... func makeClusterMeta(cluster string) *model.ClusterMeta { m := model.NewClusterMeta() m.Cluster = cluster m.Cpu, m.Mem = 10, 20 return m } ================================================ FILE: internal/model/cmd_buff.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "sync" "time" ) const ( maxBuff = 10 keyEntryDelay = 100 * time.Millisecond // CommandBuffer represents a command buffer. CommandBuffer BufferKind = 1 << iota // FilterBuffer represents a filter buffer. FilterBuffer ) type ( // BufferKind indicates a buffer type. BufferKind int8 // BuffWatcher represents a command buffer listener. BuffWatcher interface { // BufferCompleted indicates input was accepted. BufferCompleted(text, suggestion string) // BufferChanged indicates the buffer was changed. BufferChanged(text, suggestion string) // BufferActive indicates the buff activity changed. BufferActive(state bool, kind BufferKind) } ) // CmdBuff represents user command input. type CmdBuff struct { buff []rune suggestion string listeners map[BuffWatcher]struct{} hotKey rune kind BufferKind active bool cancel context.CancelFunc mx sync.RWMutex } // NewCmdBuff returns a new command buffer. func NewCmdBuff(key rune, kind BufferKind) *CmdBuff { return &CmdBuff{ hotKey: key, kind: kind, buff: make([]rune, 0, maxBuff), listeners: make(map[BuffWatcher]struct{}), } } // InCmdMode checks if a command exists and the buffer is active. func (c *CmdBuff) InCmdMode() bool { c.mx.RLock() defer c.mx.RUnlock() if !c.active { return false } return len(c.buff) > 0 } // IsActive checks if command buffer is active. func (c *CmdBuff) IsActive() bool { c.mx.RLock() defer c.mx.RUnlock() return c.active } // SetActive toggles cmd buffer active state. func (c *CmdBuff) SetActive(b bool) { c.mx.Lock() c.active = b c.mx.Unlock() c.fireActive(c.active) } // GetText returns the current text. func (c *CmdBuff) GetText() string { c.mx.RLock() defer c.mx.RUnlock() return string(c.buff) } // GetKind returns the buffer kind. func (c *CmdBuff) GetKind() BufferKind { c.mx.RLock() defer c.mx.RUnlock() return c.kind } // GetSuggestion returns the current suggestion. func (c *CmdBuff) GetSuggestion() string { c.mx.RLock() defer c.mx.RUnlock() return c.suggestion } func (c *CmdBuff) hasCancel() bool { c.mx.RLock() defer c.mx.RUnlock() return c.cancel != nil } func (c *CmdBuff) setCancel(f context.CancelFunc) { c.mx.Lock() c.cancel = f c.mx.Unlock() } func (c *CmdBuff) resetCancel() { c.mx.Lock() c.cancel = nil c.mx.Unlock() } // SetText initializes the buffer with a command. func (c *CmdBuff) SetText(text, suggestion string, wipe bool) { c.mx.Lock() if wipe { c.buff, c.suggestion = []rune(text), suggestion } else { c.buff, c.suggestion = append(c.buff, []rune(text)...), suggestion } c.mx.Unlock() c.fireBufferCompleted(c.GetText(), c.GetSuggestion()) } // Add adds a new character to the buffer. func (c *CmdBuff) Add(r rune) { c.mx.Lock() c.buff = append(c.buff, r) c.mx.Unlock() c.fireBufferChanged(c.GetText(), c.GetSuggestion()) if c.hasCancel() { return } ctx, cancel := context.WithTimeout(context.Background(), keyEntryDelay) c.setCancel(cancel) go func() { <-ctx.Done() c.fireBufferCompleted(c.GetText(), c.GetSuggestion()) c.resetCancel() }() } // Delete removes the last character from the buffer. func (c *CmdBuff) Delete() { if c.Empty() { return } c.SetText(string(c.buff[:len(c.buff)-1]), "", true) c.fireBufferChanged(c.GetText(), c.GetSuggestion()) if c.hasCancel() { return } ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) c.setCancel(cancel) go func() { <-ctx.Done() c.fireBufferCompleted(c.GetText(), c.GetSuggestion()) c.resetCancel() }() } // ClearText clears out command buffer. func (c *CmdBuff) ClearText(fire bool) { c.mx.Lock() c.buff, c.suggestion = c.buff[:0], "" c.mx.Unlock() if fire { c.fireBufferCompleted(c.GetText(), c.GetSuggestion()) } } // Reset clears out the command buffer and deactivates it. func (c *CmdBuff) Reset() { c.ClearText(true) c.SetActive(false) c.fireBufferCompleted(c.GetText(), c.GetSuggestion()) } // Empty returns true if no cmd, false otherwise. func (c *CmdBuff) Empty() bool { c.mx.RLock() defer c.mx.RUnlock() return len(c.buff) == 0 } // ---------------------------------------------------------------------------- // Event Listeners... // AddListener registers a cmd buffer listener. func (c *CmdBuff) AddListener(w BuffWatcher) { c.mx.Lock() c.listeners[w] = struct{}{} c.mx.Unlock() } // RemoveListener removes a listener. func (c *CmdBuff) RemoveListener(l BuffWatcher) { c.mx.Lock() delete(c.listeners, l) c.mx.Unlock() } func (c *CmdBuff) fireBufferCompleted(t, s string) { for l := range c.listeners { l.BufferCompleted(t, s) } } func (c *CmdBuff) fireBufferChanged(t, s string) { for l := range c.listeners { l.BufferChanged(t, s) } } func (c *CmdBuff) fireActive(b bool) { for l := range c.listeners { l.BufferActive(b, c.GetKind()) } } ================================================ FILE: internal/model/cmd_buff_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) type testListener struct { text, suggestion string act int inact int } func (l *testListener) BufferChanged(t, s string) { l.text, l.suggestion = t, s } func (l *testListener) BufferCompleted(t, s string) { l.text, l.suggestion = t, s } func (l *testListener) BufferActive(s bool, _ model.BufferKind) { if s { l.act++ return } l.inact++ } func TestCmdBuffActivate(t *testing.T) { b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{} b.AddListener(&l) b.SetActive(true) assert.Equal(t, 1, l.act) assert.Equal(t, 0, l.inact) assert.True(t, b.IsActive()) } func TestCmdBuffDeactivate(t *testing.T) { b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{} b.AddListener(&l) b.SetActive(false) assert.Equal(t, 0, l.act) assert.Equal(t, 1, l.inact) assert.False(t, b.IsActive()) } func TestCmdBuffChanged(t *testing.T) { b, l := model.NewCmdBuff('>', model.CommandBuffer), testListener{} b.AddListener(&l) b.Add('b') assert.Equal(t, 0, l.act) assert.Equal(t, 0, l.inact) assert.Equal(t, "b", l.text) assert.Equal(t, "b", b.GetText()) b.Delete() assert.Equal(t, 0, l.act) assert.Equal(t, 0, l.inact) assert.Empty(t, l.text) assert.Empty(t, b.GetText()) b.Add('c') b.ClearText(true) assert.Equal(t, 0, l.act) assert.Equal(t, 0, l.inact) assert.Empty(t, l.text) assert.Empty(t, b.GetText()) b.Add('c') b.Reset() assert.Equal(t, 0, l.act) assert.Equal(t, 1, l.inact) assert.Empty(t, l.text) assert.Empty(t, b.GetText()) assert.True(t, b.Empty()) } func TestCmdBuffAdd(t *testing.T) { b := model.NewCmdBuff('>', model.CommandBuffer) uu := []struct { runes []rune cmd string }{ {[]rune{}, ""}, {[]rune{'a'}, "a"}, {[]rune{'a', 'b', 'c'}, "abc"}, } for _, u := range uu { for _, r := range u.runes { b.Add(r) } assert.Equal(t, u.cmd, b.GetText()) b.Reset() } } func TestCmdBuffDel(t *testing.T) { b := model.NewCmdBuff('>', model.CommandBuffer) uu := []struct { runes []rune cmd string }{ {[]rune{}, ""}, {[]rune{'a'}, ""}, {[]rune{'a', 'b', 'c'}, "ab"}, } for _, u := range uu { for _, r := range u.runes { b.Add(r) } b.Delete() assert.Equal(t, u.cmd, b.GetText()) b.Reset() } } func TestCmdBuffEmpty(t *testing.T) { b := model.NewCmdBuff('>', model.CommandBuffer) uu := []struct { runes []rune empty bool }{ {[]rune{}, true}, {[]rune{'a'}, false}, {[]rune{'a', 'b', 'c'}, false}, } for _, u := range uu { for _, r := range u.runes { b.Add(r) } assert.Equal(t, u.empty, b.Empty()) b.Reset() } } ================================================ FILE: internal/model/describe.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "reflect" "strings" "sync/atomic" "time" backoff "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/sahilm/fuzzy" ) // Describe tracks describable resources. type Describe struct { gvr *client.GVR inUpdate int32 path string query string lines []string refreshRate time.Duration listeners []ResourceViewerListener decode bool } // NewDescribe returns a new describe resource model. func NewDescribe(gvr *client.GVR, path string) *Describe { return &Describe{ gvr: gvr, path: path, refreshRate: defaultReaderRefreshRate, } } // GVR returns the resource gvr. func (d *Describe) GVR() *client.GVR { return d.gvr } // GetPath returns the active resource path. func (d *Describe) GetPath() string { return d.path } // SetOptions toggle model options. func (*Describe) SetOptions(context.Context, ViewerToggleOpts) {} // Filter filters the model. func (d *Describe) Filter(q string) { d.query = q d.filterChanged(d.lines) } func (d *Describe) filterChanged(lines []string) { d.fireResourceChanged(lines, d.filter(d.query, lines)) } func (d *Describe) filter(q string, lines []string) fuzzy.Matches { if q == "" { return nil } if f, ok := internal.IsFuzzySelector(q); ok { return d.fuzzyFilter(strings.TrimSpace(f), lines) } return rxFilter(q, lines) } func (*Describe) fuzzyFilter(q string, lines []string) fuzzy.Matches { return fuzzy.Find(q, lines) } func (d *Describe) fireResourceChanged(lines []string, matches fuzzy.Matches) { for _, l := range d.listeners { l.ResourceChanged(lines, matches) } } func (d *Describe) fireResourceFailed(err error) { for _, l := range d.listeners { l.ResourceFailed(err) } } // ClearFilter clear out the filter. func (*Describe) ClearFilter() { } // Peek returns current model state. func (d *Describe) Peek() []string { return d.lines } // Refresh updates model data. func (d *Describe) Refresh(ctx context.Context) error { return d.refresh(ctx) } // Watch watches for describe data changes. func (d *Describe) Watch(ctx context.Context) error { if err := d.refresh(ctx); err != nil { return err } go d.updater(ctx) return nil } func (d *Describe) updater(ctx context.Context) { defer slog.Debug("Describe canceled", slogs.GVR, d.gvr) backOff := NewExpBackOff(ctx, defaultReaderRefreshRate, maxReaderRetryInterval) delay := defaultReaderRefreshRate for { select { case <-ctx.Done(): return case <-time.After(delay): if err := d.refresh(ctx); err != nil { d.fireResourceFailed(err) if delay = backOff.NextBackOff(); delay == backoff.Stop { slog.Error("Describe gave up!", slogs.Error, err) return } } else { backOff.Reset() delay = defaultReaderRefreshRate } } } } func (d *Describe) refresh(ctx context.Context) error { if !atomic.CompareAndSwapInt32(&d.inUpdate, 0, 1) { slog.Debug("Dropping update...") return nil } defer atomic.StoreInt32(&d.inUpdate, 0) if err := d.reconcile(ctx); err != nil { slog.Error("reconcile failed", slogs.GVR, d.gvr, slogs.Error, err, ) d.fireResourceFailed(err) return err } return nil } func (d *Describe) reconcile(ctx context.Context) error { s, err := d.describe(ctx, d.gvr, d.path) if err != nil { return err } lines := strings.Split(s, "\n") if reflect.DeepEqual(lines, d.lines) { return nil } d.lines = lines d.fireResourceChanged(d.lines, d.filter(d.query, d.lines)) return nil } // Describe describes a given resource. func (d *Describe) describe(ctx context.Context, gvr *client.GVR, path string) (string, error) { meta, err := getMeta(ctx, gvr) if err != nil { return "", err } desc, ok := meta.DAO.(dao.Describer) if !ok { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } if desc, ok := meta.DAO.(*dao.Secret); ok { desc.SetDecodeData(d.decode) } return desc.Describe(path) } // AddListener adds a new model listener. func (d *Describe) AddListener(l ResourceViewerListener) { d.listeners = append(d.listeners, l) } // RemoveListener delete a listener from the list. func (d *Describe) RemoveListener(l ResourceViewerListener) { victim := -1 for i, lis := range d.listeners { if lis == l { victim = i break } } if victim >= 0 { d.listeners = append(d.listeners[:victim], d.listeners[victim+1:]...) } } // Toggle toggles the decode flag. func (d *Describe) Toggle() { d.decode = !d.decode } ================================================ FILE: internal/model/fish_buff.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import "sort" // SuggestionListener listens for suggestions. type SuggestionListener interface { BuffWatcher // SuggestionChanged notifies suggestion changes. SuggestionChanged(text, sugg string) } // SuggestionFunc produces suggestions. type SuggestionFunc func(text string) sort.StringSlice // FishBuff represents a suggestion buffer. type FishBuff struct { *CmdBuff suggestionFn SuggestionFunc suggestions []string suggestionIndex int } // NewFishBuff returns a new command buffer. func NewFishBuff(key rune, kind BufferKind) *FishBuff { return &FishBuff{ CmdBuff: NewCmdBuff(key, kind), suggestionIndex: -1, } } // PrevSuggestion returns the prev suggestion. func (f *FishBuff) PrevSuggestion() (string, bool) { if len(f.suggestions) == 0 { return "", false } if f.suggestionIndex < 0 { f.suggestionIndex = 0 } else { f.suggestionIndex-- } if f.suggestionIndex < 0 { f.suggestionIndex = len(f.suggestions) - 1 } return f.suggestions[f.suggestionIndex], true } // NextSuggestion returns the next suggestion. func (f *FishBuff) NextSuggestion() (string, bool) { if len(f.suggestions) == 0 { return "", false } if f.suggestionIndex < 0 { f.suggestionIndex = 0 } else { f.suggestionIndex++ } if f.suggestionIndex >= len(f.suggestions) { f.suggestionIndex = 0 } return f.suggestions[f.suggestionIndex], true } // ClearSuggestions clear out all suggestions. func (f *FishBuff) ClearSuggestions() { if len(f.suggestions) > 0 { f.suggestions = f.suggestions[:0] } f.suggestionIndex = -1 } // CurrentSuggestion returns the current suggestion. func (f *FishBuff) CurrentSuggestion() (string, bool) { if len(f.suggestions) == 0 || f.suggestionIndex < 0 || f.suggestionIndex >= len(f.suggestions) { return "", false } return f.suggestions[f.suggestionIndex], true } // AutoSuggests returns true if model implements auto suggestions. func (*FishBuff) AutoSuggests() bool { return true } // Suggestions returns suggestions. func (f *FishBuff) Suggestions() []string { if f.suggestionFn != nil { return f.suggestionFn(string(f.buff)) } return nil } // SetSuggestionFn sets up suggestions. func (f *FishBuff) SetSuggestionFn(fn SuggestionFunc) { f.suggestionFn = fn } // Notify publish suggestions to all listeners. func (f *FishBuff) Notify(_ bool) { if f.suggestionFn == nil { return } f.fireSuggestionChanged(f.suggestionFn(string(f.buff))) } // Add adds a new character to the buffer. func (f *FishBuff) Add(r rune) { f.CmdBuff.Add(r) f.Notify(false) } // Delete removes the last character from the buffer. func (f *FishBuff) Delete() { f.CmdBuff.Delete() f.Notify(true) } func (f *FishBuff) fireSuggestionChanged(ss []string) { f.suggestions, f.suggestionIndex = ss, 0 var suggest string if len(ss) == 0 { f.suggestionIndex = -1 } else { suggest = ss[f.suggestionIndex] } f.SetText(f.GetText(), suggest, true) } ================================================ FILE: internal/model/fish_buff_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "sort" "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestFishAdd(t *testing.T) { m := mockSuggestionListener{} f := model.NewFishBuff(' ', model.FilterBuffer) f.AddListener(&m) f.SetSuggestionFn(func(string) sort.StringSlice { return sort.StringSlice{"blee", "brew"} }) f.Add('b') f.SetActive(true) assert.True(t, m.active) assert.Equal(t, 1, m.changeCount) assert.Equal(t, 1, m.suggCount) assert.Equal(t, "blee", m.suggestion) c, ok := f.CurrentSuggestion() assert.True(t, ok) assert.Equal(t, "blee", c) c, ok = f.NextSuggestion() assert.True(t, ok) assert.Equal(t, "brew", c) c, ok = f.PrevSuggestion() assert.True(t, ok) assert.Equal(t, "blee", c) } func TestFishDelete(t *testing.T) { m := mockSuggestionListener{} f := model.NewFishBuff(' ', model.FilterBuffer) f.AddListener(&m) f.SetSuggestionFn(func(string) sort.StringSlice { return sort.StringSlice{"blee", "duh"} }) f.Add('a') f.Delete() f.SetActive(true) assert.Equal(t, 2, m.changeCount) assert.Equal(t, 3, m.suggCount) assert.True(t, m.active) assert.Equal(t, "blee", m.suggestion) c, ok := f.CurrentSuggestion() assert.True(t, ok) assert.Equal(t, "blee", c) c, ok = f.NextSuggestion() assert.True(t, ok) assert.Equal(t, "duh", c) c, ok = f.PrevSuggestion() assert.True(t, ok) assert.Equal(t, "blee", c) } // Helpers... type mockSuggestionListener struct { changeCount, suggCount int suggestion, text string active bool } func (m *mockSuggestionListener) BufferChanged(_, _ string) { m.changeCount++ } func (m *mockSuggestionListener) BufferCompleted(text, suggest string) { if m.suggestion != suggest { m.suggCount++ } m.text, m.suggestion = text, suggest } func (m *mockSuggestionListener) BufferActive(state bool, _ model.BufferKind) { m.active = state } func (m *mockSuggestionListener) SuggestionChanged(_, sugg string) { m.suggestion = sugg m.suggCount++ } ================================================ FILE: internal/model/flash.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "time" "github.com/derailed/k9s/internal/slogs" ) const ( // DefaultFlashDelay sets the flash clear delay. DefaultFlashDelay = 6 * time.Second // FlashInfo represents an info message. FlashInfo FlashLevel = iota // FlashWarn represents an warning message. FlashWarn // FlashErr represents an error message. FlashErr ) // LevelMessage tracks a message and severity. type LevelMessage struct { Level FlashLevel Text string } func newClearMessage() LevelMessage { return LevelMessage{} } // IsClear returns true if message is empty. func (l LevelMessage) IsClear() bool { return l.Text == "" } // FlashLevel represents flash message severity. type FlashLevel int // FlashChan represents a flash event channel. type FlashChan chan LevelMessage // FlashListener represents a text model listener. type FlashListener interface { // FlashChanged notifies the model changed. FlashChanged(FlashLevel, string) // FlashCleared notifies when the filter changed. FlashCleared() } // Flash represents a flash message model. type Flash struct { msg LevelMessage cancel context.CancelFunc delay time.Duration msgChan chan LevelMessage } // NewFlash returns a new instance. func NewFlash(dur time.Duration) *Flash { return &Flash{ delay: dur, msgChan: make(FlashChan, 3), } } // Channel returns the flash channel. func (f *Flash) Channel() FlashChan { return f.msgChan } // Info displays an info flash message. func (f *Flash) Info(msg string) { f.SetMessage(FlashInfo, msg) } // Infof displays a formatted info flash message. func (f *Flash) Infof(fmat string, args ...any) { f.Info(fmt.Sprintf(fmat, args...)) } // Warn displays a warning flash message. func (f *Flash) Warn(msg string) { slog.Warn(msg) f.SetMessage(FlashWarn, msg) } // Warnf displays a formatted warning flash message. func (f *Flash) Warnf(fmat string, args ...any) { f.Warn(fmt.Sprintf(fmat, args...)) } // Err displays an error flash message. func (f *Flash) Err(err error) { slog.Error("Flash error", slogs.Error, err) f.SetMessage(FlashErr, err.Error()) } // Errf displays a formatted error flash message. func (f *Flash) Errf(fmat string, args ...any) { var err error for _, a := range args { if e, ok := a.(error); ok { err = e } } slog.Error("Flash error", slogs.Error, err, slogs.Message, fmt.Sprintf(fmat, args...), ) f.SetMessage(FlashErr, fmt.Sprintf(fmat, args...)) } // Clear clears the flash message. func (f *Flash) Clear() { f.fireCleared() } // SetMessage sets the flash level message. func (f *Flash) SetMessage(level FlashLevel, msg string) { if f.cancel != nil { f.cancel() f.cancel = nil } f.setLevelMessage(LevelMessage{Level: level, Text: msg}) f.fireFlashChanged() ctx := context.Background() ctx, f.cancel = context.WithCancel(ctx) go f.refresh(ctx) } func (f *Flash) refresh(ctx context.Context) { for { select { case <-ctx.Done(): return case <-time.After(f.delay): f.fireCleared() return } } } func (f *Flash) setLevelMessage(msg LevelMessage) { f.msg = msg } func (f *Flash) fireFlashChanged() { f.msgChan <- f.msg } func (f *Flash) fireCleared() { f.msgChan <- newClearMessage() } ================================================ FILE: internal/model/flash_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "errors" "fmt" "testing" "time" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestFlash(t *testing.T) { const delay = 1 * time.Millisecond uu := map[string]struct { level model.FlashLevel e string }{ "info": {level: model.FlashInfo, e: "blee"}, "warn": {level: model.FlashWarn, e: "blee"}, "err": {level: model.FlashErr, e: "blee"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { f := model.NewFlash(delay) v := newFlash() go v.listen(f.Channel()) switch u.level { case model.FlashInfo: f.Info(u.e) case model.FlashWarn: f.Warn(u.e) case model.FlashErr: f.Err(errors.New(u.e)) } time.Sleep(5 * delay) s, l, m := v.getMetrics() assert.Equal(t, 1, s) assert.Equal(t, u.level, l) assert.Equal(t, u.e, m) }) } } func TestFlashBurst(t *testing.T) { const delay = 1 * time.Millisecond f := model.NewFlash(delay) v := newFlash() go v.listen(f.Channel()) count := 5 for i := 1; i <= count; i++ { f.Info(fmt.Sprintf("test-%d", i)) } time.Sleep(5 * delay) s, l, m := v.getMetrics() assert.Equal(t, count, s) assert.Equal(t, model.FlashInfo, l) assert.Equal(t, fmt.Sprintf("test-%d", count), m) } type flash struct { set, clear int level model.FlashLevel msg string } func newFlash() *flash { return &flash{} } func (f *flash) getMetrics() (val int, lvl model.FlashLevel, msg string) { return f.set, f.level, f.msg } func (f *flash) listen(c model.FlashChan) { for m := range c { if m.IsClear() { f.clear++ } else { f.set++ f.level, f.msg = m.Level, m.Text } } } ================================================ FILE: internal/model/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "regexp" "time" "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/sahilm/fuzzy" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func getMeta(ctx context.Context, gvr *client.GVR) (ResourceMeta, error) { meta := resourceMeta(gvr) factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) } meta.DAO.Init(factory, gvr) return meta, nil } func resourceMeta(gvr *client.GVR) ResourceMeta { meta, ok := Registry[gvr] if !ok { meta = ResourceMeta{ DAO: new(dao.Table), Renderer: new(render.Table), } } if meta.DAO == nil { meta.DAO = new(dao.Resource) } return meta } // MetaFQN returns a fully qualified resource name. func MetaFQN(m *metav1.ObjectMeta) string { return FQN(m.Namespace, m.Name) } // FQN returns a fully qualified resource name. func FQN(ns, n string) string { if ns == "" { return n } return ns + "/" + n } // NewExpBackOff returns a new exponential backoff timer. func NewExpBackOff(ctx context.Context, start, maxVal time.Duration) backoff.BackOffContext { bf := backoff.NewExponentialBackOff() bf.InitialInterval, bf.MaxElapsedTime = start, maxVal return backoff.WithContext(bf, ctx) } func rxFilter(q string, lines []string) fuzzy.Matches { rx, err := regexp.Compile(`(?i)` + q) if err != nil { return nil } matches := make(fuzzy.Matches, 0, len(lines)) for i, l := range lines { locs := rx.FindAllStringIndex(l, -1) for _, loc := range locs { indexes := make([]int, 0, loc[1]-loc[0]) for v := loc[0]; v < loc[1]; v++ { indexes = append(indexes, v) } matches = append(matches, fuzzy.Match{Str: q, Index: i, MatchedIndexes: indexes}) } } return matches } ================================================ FILE: internal/model/helpers_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "testing" "github.com/sahilm/fuzzy" "github.com/stretchr/testify/assert" ) func Test_rxFilter(t *testing.T) { uu := map[string]struct { q string lines []string e fuzzy.Matches }{ "empty-lines": { q: "foo", e: fuzzy.Matches{}, }, "no-match": { q: "foo", lines: []string{"bar"}, e: fuzzy.Matches{}, }, "single-match": { q: "foo", lines: []string{"foo", "bar", "baz"}, e: fuzzy.Matches{ { Str: "foo", Index: 0, MatchedIndexes: []int{0, 1, 2}, }, }, }, "start-rx-match": { q: "(?i)^foo", lines: []string{"foo", "fob", "barfoo"}, e: fuzzy.Matches{ { Str: "(?i)^foo", Index: 0, MatchedIndexes: []int{0, 1, 2}, }, }, }, "end-rx-match": { q: "foo$", lines: []string{"foo", "fob", "barfoo"}, e: fuzzy.Matches{ { Str: "foo$", Index: 0, MatchedIndexes: []int{0, 1, 2}, }, { Str: "foo$", Index: 2, MatchedIndexes: []int{3, 4, 5}, }, }, }, "multiple-matches": { q: "foo", lines: []string{"foo", "bar", "foo bar foo", "baz"}, e: fuzzy.Matches{ { Str: "foo", Index: 0, MatchedIndexes: []int{0, 1, 2}, }, { Str: "foo", Index: 2, MatchedIndexes: []int{0, 1, 2}, }, { Str: "foo", Index: 2, MatchedIndexes: []int{8, 9, 10}, }, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, rxFilter(u.q, u.lines)) }) } } ================================================ FILE: internal/model/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestMetaFQN(t *testing.T) { uu := map[string]struct { meta metav1.ObjectMeta e string }{ "all_namespaces": { meta: metav1.ObjectMeta{Name: "fred"}, e: "fred", }, "namespaced": { meta: metav1.ObjectMeta{Name: "fred", Namespace: "blee"}, e: "blee/fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, model.MetaFQN(&u.meta)) }) } } ================================================ FILE: internal/model/hint.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model // HintListener represents a menu hints listener. type HintListener interface { HintsChanged(MenuHints) } // Hint represent a hint model. type Hint struct { data MenuHints listeners []HintListener } // NewHint return new hint model. func NewHint() *Hint { return &Hint{} } // RemoveListener deletes a listener. func (h *Hint) RemoveListener(l HintListener) { victim := -1 for i, lis := range h.listeners { if lis == l { victim = i break } } if victim == -1 { return } h.listeners = append(h.listeners[:victim], h.listeners[victim+1:]...) } // AddListener adds a hint listener. func (h *Hint) AddListener(l HintListener) { h.listeners = append(h.listeners, l) } // SetHints set model hints. func (h *Hint) SetHints(hh MenuHints) { h.data = hh h.fireChanged() } // Peek returns the model data. func (h *Hint) Peek() MenuHints { return h.data } func (h *Hint) fireChanged() { for _, l := range h.listeners { l.HintsChanged(h.data) } } ================================================ FILE: internal/model/hint_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestHint(t *testing.T) { uu := map[string]struct { hh model.MenuHints e int }{ "none": { model.MenuHints{}, 0, }, "hints": { model.MenuHints{ {Mnemonic: "a", Description: "blee"}, {Mnemonic: "b", Description: "fred"}, }, 2, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { h := model.NewHint() l := hintL{count: -1} h.AddListener(&l) h.SetHints(u.hh) assert.Equal(t, u.e, l.count) assert.Len(t, h.Peek(), u.e) }) } } func TestHintRemoveListener(t *testing.T) { h := model.NewHint() l1, l2, l3 := &hintL{}, &hintL{}, &hintL{} h.AddListener(l1) h.AddListener(l2) h.RemoveListener(l2) h.RemoveListener(l3) h.RemoveListener(l1) h.SetHints(model.MenuHints{ model.MenuHint{Mnemonic: "a", Description: "Blee"}, }) assert.Equal(t, 0, l1.count) assert.Equal(t, 0, l2.count) assert.Equal(t, 0, l3.count) } // ---------------------------------------------------------------------------- // Helpers... type hintL struct { count int } func (h *hintL) HintsChanged(hh model.MenuHints) { h.count++ h.count += len(hh) } ================================================ FILE: internal/model/history.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "log/slog" "strings" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/view/cmd" ) // MaxHistory tracks max command history. const MaxHistory = 20 // History represents a command history. type History struct { commands []string limit int currentIdx int } // NewHistory returns a new instance. func NewHistory(limit int) *History { return &History{ limit: limit, currentIdx: -1, } } // List returns the command history. func (h *History) List() []string { return h.commands } // Top returns the last command in the history if present. func (h *History) Top() (string, bool) { h.currentIdx = len(h.commands) - 1 return h.at(h.currentIdx) } func (h *History) SwitchNS(ns string) { c, ok := h.Top() if !ok { return } i := cmd.NewInterpreter(c) i.SwitchNS(ns) line := i.GetLine() if _, ok := i.NSArg(); ok && line != c { h.Push(line) slog.Debug("History (switch-ns)", slogs.Stack, strings.Join(h.List(), "|")) return } } // Last returns the nth command prior to last. func (h *History) Last(idx int) (string, bool) { h.currentIdx = len(h.commands) - idx return h.at(h.currentIdx) } func (h *History) at(idx int) (string, bool) { if idx < 0 || idx >= len(h.commands) { return "", false } return h.commands[idx], true } // Back moves the history position index back by one. func (h *History) Back() (string, bool) { if h.Empty() || h.currentIdx <= 0 { return "", false } h.currentIdx-- return h.at(h.currentIdx) } // Forward moves the history position index forward by one func (h *History) Forward() (string, bool) { h.currentIdx++ if h.Empty() || h.currentIdx >= len(h.commands) { return "", false } return h.at(h.currentIdx) } // Pop removes the single most recent history item // and returns a bool if the list changed. func (h *History) Pop() bool { return h.popN(1) } // PopN removes the N most recent history item // and returns a bool if the list changed. // Argument specifies how many to remove from the history func (h *History) popN(n int) bool { pop := len(h.commands) - n if h.Empty() || pop < 0 { return false } h.commands = h.commands[:pop] h.currentIdx = len(h.commands) - 1 return true } // Push adds a new item. func (h *History) Push(c string) { if c == "" || len(h.commands) >= h.limit { return } if t, ok := h.Top(); ok && t == c { return } if h.currentIdx < len(h.commands)-1 { h.commands = h.commands[:h.currentIdx+1] } h.commands = append(h.commands, strings.ToLower(c)) h.currentIdx = len(h.commands) - 1 } // Clear clears out the stack. func (h *History) Clear() { h.commands = nil h.currentIdx = -1 } // Empty returns true if no history. func (h *History) Empty() bool { return len(h.commands) == 0 } ================================================ FILE: internal/model/history_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "fmt" "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestHistoryClear(t *testing.T) { h := model.NewHistory(3) for i := 1; i < 5; i++ { h.Push(fmt.Sprintf("cmd%d", i)) } assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List()) h.Clear() assert.True(t, h.Empty()) } func TestHistoryPush(t *testing.T) { h := model.NewHistory(3) for i := 1; i < 4; i++ { h.Push(fmt.Sprintf("cmd%d", i)) } h.Push("cmd1") h.Push("") assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List()) } func TestHistoryTop(t *testing.T) { uu := map[string]struct { push []string pop int cmd string ok bool }{ "empty": {}, "no-one-left": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 3, }, "last": { push: []string{"cmd1", "cmd2", "cmd3"}, cmd: "cmd3", ok: true, }, "middle": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 1, cmd: "cmd2", ok: true, }, "first": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 2, cmd: "cmd1", ok: true, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { h := model.NewHistory(3) for _, cmd := range u.push { h.Push(cmd) } for range u.pop { _ = h.Pop() } cmd, ok := h.Top() assert.Equal(t, u.ok, ok) assert.Equal(t, u.cmd, cmd) }) } } func TestHistoryBack(t *testing.T) { uu := map[string]struct { push []string pop int cmd string ok bool }{ "empty": {}, "pop-all": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 3, }, "pop-none": { push: []string{"cmd1", "cmd2", "cmd3"}, cmd: "cmd2", ok: true, }, "pop-one": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 1, cmd: "cmd1", ok: true, }, "pop-to-first": { push: []string{"cmd1", "cmd2", "cmd3"}, pop: 2, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { h := model.NewHistory(3) for _, cmd := range u.push { h.Push(cmd) } for range u.pop { _ = h.Pop() } cmd, ok := h.Back() assert.Equal(t, u.ok, ok) assert.Equal(t, u.cmd, cmd) }) } } func TestHistoryForward(t *testing.T) { uu := map[string]struct { push []string back int cmd string ok bool }{ "empty": {}, "back-2": { push: []string{"cmd1", "cmd2", "cmd3"}, back: 2, cmd: "cmd2", ok: true, }, "back-1": { push: []string{"cmd1", "cmd2", "cmd3"}, back: 1, cmd: "cmd3", ok: true, }, "back-all": { push: []string{"cmd1", "cmd2", "cmd3"}, back: 3, cmd: "cmd2", ok: true, }, "back-none": { push: []string{"cmd1", "cmd2", "cmd3"}, back: 0, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { h := model.NewHistory(3) for _, cmd := range u.push { h.Push(cmd) } for range u.back { _, _ = h.Back() } cmd, ok := h.Forward() assert.Equal(t, u.ok, ok) assert.Equal(t, u.cmd, cmd) }) } } ================================================ FILE: internal/model/log.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "sync" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/color" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" ) // LogsListener represents a log model listener. type LogsListener interface { // LogChanged notifies the model changed. LogChanged([][]byte) // LogCleared indicates logs are cleared. LogCleared() // LogFailed indicates a log failure. LogFailed(error) // LogStop indicates logging was canceled. LogStop() // LogResume indicates logging has resumed. LogResume() // LogCanceled indicates no more logs will come. LogCanceled() } // Log represents a resource logger. type Log struct { factory dao.Factory lines *dao.LogItems listeners []LogsListener gvr *client.GVR logOptions *dao.LogOptions cancelFn context.CancelFunc mx sync.RWMutex filter string lastSent int flushTimeout time.Duration } // NewLog returns a new model. func NewLog(gvr *client.GVR, opts *dao.LogOptions, flushTimeout time.Duration) *Log { return &Log{ gvr: gvr, logOptions: opts, lines: dao.NewLogItems(), flushTimeout: flushTimeout, } } func (l *Log) GVR() *client.GVR { return l.gvr } func (l *Log) LogOptions() *dao.LogOptions { return l.logOptions } // SinceSeconds returns since seconds option. func (l *Log) SinceSeconds() int64 { l.mx.RLock() defer l.mx.RUnlock() return l.logOptions.SinceSeconds } // IsHead returns log head option. func (l *Log) IsHead() bool { l.mx.RLock() defer l.mx.RUnlock() return l.logOptions.Head } // ToggleShowTimestamp toggles to logs timestamps. func (l *Log) ToggleShowTimestamp(b bool) { l.logOptions.ShowTimestamp = b l.Refresh() } func (l *Log) Head(ctx context.Context) { l.mx.Lock() l.logOptions.Head = true l.mx.Unlock() l.Restart(ctx) } // SetSinceSeconds sets the logs retrieval time. func (l *Log) SetSinceSeconds(ctx context.Context, i int64) { l.logOptions.SinceSeconds, l.logOptions.Head = i, false l.Restart(ctx) } // Configure sets logger configuration. func (l *Log) Configure(opts config.Logger) { l.logOptions.Lines = opts.TailCount l.logOptions.SinceSeconds = opts.SinceSeconds } // GetPath returns resource path. func (l *Log) GetPath() string { return l.logOptions.Path } // GetContainer returns the resource container if any or "" otherwise. func (l *Log) GetContainer() string { return l.logOptions.Container } // HasDefaultContainer returns true if the pod has a default container, false otherwise. func (l *Log) HasDefaultContainer() bool { return l.logOptions.DefaultContainer != "" } // Init initializes the model. func (l *Log) Init(f dao.Factory) { l.factory = f } // Clear the logs. func (l *Log) Clear() { l.mx.Lock() l.lines.Clear() l.lastSent = 0 l.mx.Unlock() l.fireLogCleared() } // Refresh refreshes the logs. func (l *Log) Refresh() { l.fireLogCleared() ll := make([][]byte, l.lines.Len()) l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } // Restart restarts the logger. func (l *Log) Restart(ctx context.Context) { l.Stop() l.Clear() l.fireLogResume() l.Start(ctx) } // Start starts logging. func (l *Log) Start(ctx context.Context) { if err := l.load(ctx); err != nil { slog.Error("Tail logs failed!", slogs.Error, err) l.fireLogError(err) } } // Stop terminates logging. func (l *Log) Stop() { l.cancel() } // Set sets the log lines (for testing only!) func (l *Log) Set(lines *dao.LogItems) { l.mx.Lock() l.lines.Merge(lines) l.mx.Unlock() l.fireLogCleared() ll := make([][]byte, l.lines.Len()) l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } // ClearFilter resets the log filter if any. func (l *Log) ClearFilter() { l.mx.Lock() l.filter = "" l.mx.Unlock() l.fireLogCleared() ll := make([][]byte, l.lines.Len()) l.lines.Render(0, l.logOptions.ShowTimestamp, ll) l.fireLogChanged(ll) } // Filter filters the model using either fuzzy or regexp. func (l *Log) Filter(q string) { l.mx.Lock() l.filter = q l.mx.Unlock() l.fireLogCleared() l.fireLogBuffChanged(0) } func (l *Log) cancel() { l.mx.Lock() defer l.mx.Unlock() if l.cancelFn != nil { l.cancelFn() l.cancelFn = nil } } func (l *Log) load(ctx context.Context) error { accessor, err := dao.AccessorFor(l.factory, l.gvr) if err != nil { return err } loggable, ok := accessor.(dao.Loggable) if !ok { return fmt.Errorf("resource %s is not Loggable", l.gvr) } l.cancel() ctx = context.WithValue(ctx, internal.KeyFactory, l.factory) ctx, l.cancelFn = context.WithCancel(ctx) cc, err := loggable.TailLogs(ctx, l.logOptions) if err != nil { slog.Error("Tail logs failed", slogs.Error, err) l.cancel() l.fireLogError(err) } for _, c := range cc { go l.updateLogs(ctx, c) } return nil } // Append adds a log line. func (l *Log) Append(line *dao.LogItem) { if line == nil || line.IsEmpty() { return } l.mx.Lock() defer l.mx.Unlock() l.logOptions.SinceTime = line.GetTimestamp() if l.lines.Len() < int(l.logOptions.Lines) { l.lines.Add(line) return } l.lines.Shift(line) l.lastSent-- if l.lastSent < 0 { l.lastSent = 0 } } // Notify fires of notifications to the listeners. func (l *Log) Notify() { l.mx.Lock() defer l.mx.Unlock() if l.lastSent < l.lines.Len() { l.fireLogBuffChanged(l.lastSent) l.lastSent = l.lines.Len() } } // ToggleAllContainers toggles to show all containers logs. func (l *Log) ToggleAllContainers(ctx context.Context) { l.logOptions.ToggleAllContainers() l.Restart(ctx) } func (l *Log) updateLogs(ctx context.Context, c dao.LogChan) { for { select { case item, ok := <-c: if !ok { l.Append(item) l.Notify() return } if item == dao.ItemEOF { l.fireCanceled() return } l.Append(item) var overflow bool l.mx.RLock() overflow = int64(l.lines.Len()-l.lastSent) > l.logOptions.Lines l.mx.RUnlock() if overflow { l.Notify() } case <-time.After(l.flushTimeout): l.Notify() case <-ctx.Done(): return } } } // AddListener adds a new model listener. func (l *Log) AddListener(listener LogsListener) { l.mx.Lock() defer l.mx.Unlock() l.listeners = append(l.listeners, listener) } // RemoveListener delete a listener from the list. func (l *Log) RemoveListener(listener LogsListener) { l.mx.Lock() defer l.mx.Unlock() victim := -1 for i, lis := range l.listeners { if lis == listener { victim = i break } } if victim >= 0 { l.listeners = append(l.listeners[:victim], l.listeners[victim+1:]...) } } func (l *Log) applyFilter(index int, q string) ([][]byte, error) { if q == "" { return nil, nil } matches, indices, err := l.lines.Filter(index, q, l.logOptions.ShowTimestamp) if err != nil { return nil, err } // No filter! if matches == nil { ll := make([][]byte, l.lines.Len()) l.lines.Render(index, l.logOptions.ShowTimestamp, ll) return ll, nil } // Blank filter if len(matches) == 0 { return nil, nil } filtered := make([][]byte, 0, len(matches)) ll := make([][]byte, l.lines.Len()) l.lines.Lines(index, l.logOptions.ShowTimestamp, ll) for i, idx := range matches { filtered = append(filtered, color.Highlight(ll[idx], indices[i], 209)) } return filtered, nil } func (l *Log) fireLogBuffChanged(index int) { ll := make([][]byte, l.lines.Len()-index) if l.filter == "" { l.lines.Render(index, l.logOptions.ShowTimestamp, ll) } else { ff, err := l.applyFilter(index, l.filter) if err != nil { l.fireLogError(err) return } ll = ff } if len(ll) > 0 { l.fireLogChanged(ll) } } func (l *Log) fireLogResume() { for _, lis := range l.listeners { lis.LogResume() } } func (l *Log) fireCanceled() { for _, lis := range l.listeners { lis.LogCanceled() } } func (l *Log) fireLogError(err error) { for _, lis := range l.listeners { lis.LogFailed(err) } } func (l *Log) fireLogChanged(lines [][]byte) { for _, lis := range l.listeners { lis.LogChanged(lines) } } func (l *Log) fireLogCleared() { var ll []LogsListener l.mx.RLock() ll = l.listeners l.mx.RUnlock() for _, lis := range ll { lis.LogCleared() } } ================================================ FILE: internal/model/log_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "strconv" "testing" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/stretchr/testify/assert" ) func TestUpdateLogs(t *testing.T) { size := 100 m := NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newMockLogView() m.AddListener(v) c := make(dao.LogChan, 2) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go m.updateLogs(ctx, c) for i := range 2 * size { c <- dao.NewLogItemFromString("line" + strconv.Itoa(i)) } time.Sleep(2 * time.Second) assert.Equal(t, size, v.count) } func BenchmarkUpdateLogs(b *testing.B) { size := 100 m := NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newMockLogView() m.AddListener(v) c := make(dao.LogChan) go func() { m.updateLogs(context.Background(), c) }() item := dao.NewLogItem([]byte("\033[0;38m2018-12-14T10:36:43.326972-07:00 \033[0;32mblee line")) b.ReportAllocs() b.ResetTimer() for range b.N { c <- item } close(c) } // Helpers... func makeLogOpts(count int) *dao.LogOptions { return &dao.LogOptions{ Path: "fred", Container: "blee", Lines: int64(count), } } type mockLogView struct { count int } func newMockLogView() *mockLogView { return &mockLogView{} } func (t *mockLogView) LogChanged(ll [][]byte) { t.count += len(ll) } func (*mockLogView) LogStop() {} func (*mockLogView) LogCanceled() {} func (*mockLogView) LogResume() {} func (*mockLogView) LogCleared() {} func (*mockLogView) LogFailed(error) {} ================================================ FILE: internal/model/log_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "context" "fmt" "log/slog" "strconv" "testing" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestLogFullBuffer(t *testing.T) { size := 4 m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) data := dao.NewLogItems() for i := range 2 * size { data.Add(dao.NewLogItemFromString("line" + strconv.Itoa(i))) m.Append(data.Items()[i]) } m.Notify() assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 0, v.clearCalled) assert.Equal(t, 0, v.errCalled) } func TestLogFilter(t *testing.T) { uu := map[string]struct { q string e int }{ "plain": { q: "line-1", e: 2, }, "regexp": { q: `pod-line-[1-3]{1}`, e: 4, }, "invert": { q: `!pod-line-1`, e: 8, }, "fuzzy": { q: `-f po-l1`, e: 2, }, } size := 10 for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(size), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) m.Filter(u.q) data := dao.NewLogItems() for i := range size { data.Add(dao.NewLogItemFromString(fmt.Sprintf("pod-line-%d", i+1))) m.Append(data.Items()[i]) } m.Notify() assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Len(t, v.data, u.e) m.ClearFilter() assert.Equal(t, 2, v.dataCalled) assert.Equal(t, 2, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Len(t, v.data, size) }) } } func TestLogStartStop(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) ctx, cancel := context.WithCancel(context.Background()) defer cancel() m.Start(ctx) data := dao.NewLogItems() data.Add(dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")) for _, d := range data.Items() { m.Append(d) } m.Notify() m.Stop() assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 0, v.clearCalled) assert.Equal(t, 1, v.errCalled) assert.Len(t, v.data, 2) } func TestLogClear(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) assert.Equal(t, "fred", m.GetPath()) assert.Equal(t, "blee", m.GetContainer()) v := newTestView() m.AddListener(v) data := dao.NewLogItems() data.Add(dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")) for _, d := range data.Items() { m.Append(d) } m.Notify() m.Clear() assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) assert.Empty(t, v.data) } func TestLogBasic(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(2), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) data := dao.NewLogItems() data.Add(dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2")) m.Set(data) assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) ll := make([][]byte, data.Len()) data.Lines(0, false, ll) assert.Equal(t, ll, v.data) } func TestLogAppend(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 5*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) items := dao.NewLogItems() items.Add(dao.NewLogItemFromString("blah blah")) m.Set(items) ll := make([][]byte, items.Len()) items.Lines(0, false, ll) assert.Equal(t, ll, v.data) data := dao.NewLogItems() data.Add( dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2"), ) for _, d := range data.Items() { m.Append(d) } assert.Equal(t, 1, v.dataCalled) ll = make([][]byte, items.Len()) items.Lines(0, false, ll) assert.Equal(t, ll, v.data) m.Notify() assert.Equal(t, 2, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) } func TestLogTimedout(t *testing.T) { m := model.NewLog(client.NewGVR("fred"), makeLogOpts(4), 10*time.Millisecond) m.Init(makeFactory()) v := newTestView() m.AddListener(v) m.Filter("line1") data := dao.NewLogItems() data.Add( dao.NewLogItemFromString("line1"), dao.NewLogItemFromString("line2"), dao.NewLogItemFromString("line3"), dao.NewLogItemFromString("line4"), ) for _, d := range data.Items() { m.Append(d) } m.Notify() assert.Equal(t, 1, v.dataCalled) assert.Equal(t, 1, v.clearCalled) assert.Equal(t, 0, v.errCalled) const e = "\x1b[38;5;209ml\x1b[0m\x1b[38;5;209mi\x1b[0m\x1b[38;5;209mn\x1b[0m\x1b[38;5;209me\x1b[0m\x1b[38;5;209m1\x1b[0m" assert.Equal(t, e, string(v.data[0])) } func TestToggleAllContainers(t *testing.T) { opts := makeLogOpts(1) opts.DefaultContainer = "duh" m := model.NewLog(client.NewGVR(""), opts, 10*time.Millisecond) m.Init(makeFactory()) assert.Equal(t, "blee", m.GetContainer()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() m.ToggleAllContainers(ctx) assert.Empty(t, m.GetContainer()) m.ToggleAllContainers(ctx) assert.Equal(t, "blee", m.GetContainer()) } // ---------------------------------------------------------------------------- // Helpers... func makeLogOpts(count int) *dao.LogOptions { return &dao.LogOptions{ Path: "fred", Container: "blee", Lines: int64(count), } } // ---------------------------------------------------------------------------- type testView struct { data [][]byte dataCalled int clearCalled int errCalled int } func newTestView() *testView { return &testView{} } func (*testView) LogCanceled() {} func (*testView) LogStop() {} func (*testView) LogResume() {} func (t *testView) LogChanged(ll [][]byte) { t.data = ll t.dataCalled++ } func (t *testView) LogCleared() { t.clearCalled++ t.data = nil } func (t *testView) LogFailed(error) { t.errCalled++ } // ---------------------------------------------------------------------------- type testFactory struct{} var _ dao.Factory = testFactory{} func (testFactory) Client() client.Connection { return nil } func (testFactory) Get(*client.GVR, string, bool, labels.Selector) (runtime.Object, error) { return nil, nil } func (testFactory) List(*client.GVR, string, bool, labels.Selector) ([]runtime.Object, error) { return nil, nil } func (testFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (testFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (testFactory) WaitForCacheSync() {} func (testFactory) Forwarders() watch.Forwarders { return nil } func (testFactory) DeleteForwarder(string) {} func makeFactory() dao.Factory { return testFactory{} } ================================================ FILE: internal/model/menu_hint.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "strconv" ) // MenuHint represents keyboard mnemonic. type MenuHint struct { Mnemonic string Description string Visible bool } // IsBlank checks if menu hint is a place holder. func (m MenuHint) IsBlank() bool { return m.Mnemonic == "" && m.Description == "" && !m.Visible } // String returns a string representation. func (m MenuHint) String() string { return m.Mnemonic } // MenuHints represents a collection of hints. type MenuHints []MenuHint // Len returns the hints length. func (h MenuHints) Len() int { return len(h) } // Swap swaps to elements. func (h MenuHints) Swap(i, j int) { h[i], h[j] = h[j], h[i] } // Less returns true if first hint is less than second. func (h MenuHints) Less(i, j int) bool { n, err1 := strconv.Atoi(h[i].Mnemonic) m, err2 := strconv.Atoi(h[j].Mnemonic) if err1 == nil && err2 == nil { return n < m } if err1 == nil && err2 != nil { return true } if err1 != nil && err2 == nil { return false } return h[i].Description < h[j].Description } ================================================ FILE: internal/model/menu_hint_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "sort" "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestMenuHintsSort(t *testing.T) { uu := map[string]struct { hh model.MenuHints e []int }{ "mixed": { hh: model.MenuHints{ model.MenuHint{Mnemonic: "2", Description: "Bubba"}, model.MenuHint{Mnemonic: "b", Description: "Duh"}, model.MenuHint{Mnemonic: "a", Description: "Blee"}, model.MenuHint{Mnemonic: "1", Description: "Zorg"}, }, e: []int{3, 0, 2, 1}, }, "all_strs": { hh: model.MenuHints{ model.MenuHint{Mnemonic: "b", Description: "Bob"}, model.MenuHint{Mnemonic: "a", Description: "Abby"}, model.MenuHint{Mnemonic: "c", Description: "Chris"}, }, e: []int{1, 0, 2}, }, "all_ints": { hh: model.MenuHints{ model.MenuHint{Mnemonic: "3", Description: "Bob"}, model.MenuHint{Mnemonic: "2", Description: "Abby"}, model.MenuHint{Mnemonic: "1", Description: "Chris"}, }, e: []int{2, 1, 0}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { o := make(model.MenuHints, len(u.hh)) copy(o, u.hh) sort.Sort(u.hh) for i, idx := range u.e { assert.Equal(t, o[idx], u.hh[i]) } }) } } func TestMenuHintBlank(t *testing.T) { uu := map[string]struct { hint model.MenuHint e bool }{ "yes": {hint: model.MenuHint{}, e: true}, "no": {hint: model.MenuHint{Mnemonic: "a", Description: "blee"}}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.hint.IsBlank()) }) } } ================================================ FILE: internal/model/pulse.go ================================================ package model import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/health" ) // PulseListener represents a health model listener. type PulseListener interface { // PulseChanged notifies the model data changed. PulseChanged(*health.Check) // PulseFailed notifies the health check failed. PulseFailed(error) // MetricsChanged update metrics time series. MetricsChanged(dao.TimeSeries) } // Pulse tracks multiple resources health. type Pulse struct { gvr *client.GVR namespace string listeners []PulseListener health *PulseHealth } // NewPulse returns a new pulse. func NewPulse(gvr *client.GVR) *Pulse { return &Pulse{ gvr: gvr, } } type HealthChan chan HealthPoint // Watch monitors pulses. func (p *Pulse) Watch(ctx context.Context) (HealthChan, dao.MetricsChan, error) { f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return nil, nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) } if p.health == nil { p.health = NewPulseHealth(f) } healthChan := p.health.Watch(ctx, p.namespace) metricsChan := dao.DialRecorder(f.Client()).Watch(ctx, p.namespace) return healthChan, metricsChan, nil } // Refresh update the model now. func (*Pulse) Refresh(context.Context) {} // GetNamespace returns the model namespace. func (p *Pulse) GetNamespace() string { return p.namespace } // SetNamespace sets up model namespace. func (p *Pulse) SetNamespace(ns string) { if client.IsAllNamespaces(ns) { ns = client.BlankNamespace } p.namespace = ns } // AddListener adds a listener. func (p *Pulse) AddListener(l PulseListener) { p.listeners = append(p.listeners, l) } // RemoveListener delete a listener. func (p *Pulse) RemoveListener(l PulseListener) { victim := -1 for i, lis := range p.listeners { if lis == l { victim = i break } } if victim >= 0 { p.listeners = append(p.listeners[:victim], p.listeners[victim+1:]...) } } ================================================ FILE: internal/model/pulse_health.go ================================================ package model import ( "context" "log/slog" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) const pulseRate = 10 * time.Second type HealthPoint struct { GVR *client.GVR Total, Faults int } type GVRs []*client.GVR var PulseGVRs = client.GVRs{ client.NodeGVR, client.NsGVR, client.SvcGVR, client.EvGVR, client.PodGVR, client.DpGVR, client.StsGVR, client.DsGVR, client.JobGVR, client.CjGVR, client.PvGVR, client.PvcGVR, client.HpaGVR, client.IngGVR, client.NpGVR, client.SaGVR, } func (g GVRs) First() *client.GVR { return g[0] } func (g GVRs) Last() *client.GVR { return g[len(g)-1] } func (g GVRs) Index(gvr *client.GVR) int { for i := range g { if g[i] == gvr { return i } } return -1 } // PulseHealth tracks resources health. type PulseHealth struct { factory dao.Factory } // NewPulseHealth returns a new instance. func NewPulseHealth(f dao.Factory) *PulseHealth { return &PulseHealth{factory: f} } func (h *PulseHealth) Watch(ctx context.Context, ns string) HealthChan { c := make(HealthChan, 2) ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) go func(ctx context.Context, ns string, c HealthChan) { if err := h.checkPulse(ctx, ns, c); err != nil { slog.Error("Pulse check failed", slogs.Error, err) } for { select { case <-ctx.Done(): close(c) return case <-time.After(pulseRate): if err := h.checkPulse(ctx, ns, c); err != nil { slog.Error("Pulse check failed", slogs.Error, err) } } } }(ctx, ns, c) return c } func (h *PulseHealth) checkPulse(ctx context.Context, ns string, c HealthChan) error { slog.Debug("Checking pulses...") for _, gvr := range PulseGVRs { check, err := h.check(ctx, ns, gvr) if err != nil { return err } c <- check } return nil } func (h *PulseHealth) check(ctx context.Context, ns string, gvr *client.GVR) (HealthPoint, error) { meta, ok := Registry[gvr] if !ok { meta = ResourceMeta{ DAO: new(dao.Table), Renderer: new(render.Table), } } if meta.DAO == nil { meta.DAO = &dao.Resource{} } meta.DAO.Init(h.factory, gvr) oo, err := meta.DAO.List(ctx, ns) if err != nil { return HealthPoint{}, err } c := HealthPoint{GVR: gvr, Total: len(oo)} if isTable(oo) { ta := oo[0].(*metav1.Table) c.Total = len(ta.Rows) for _, row := range ta.Rows { if err := meta.Renderer.Healthy(ctx, row); err != nil { c.Faults++ } } } else { for _, o := range oo { if err := meta.Renderer.Healthy(ctx, o); err != nil { c.Faults++ } } } slog.Debug("Checked", slogs.GVR, gvr, slogs.Config, c) return c, nil } func isTable(oo []runtime.Object) bool { if len(oo) == 0 || len(oo) > 1 { return false } _, ok := oo[0].(*metav1.Table) return ok } ================================================ FILE: internal/model/registry.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/render/helm" "github.com/derailed/k9s/internal/xray" ) // Registry tracks resources metadata. // BOZO!! Break up deps and merge into single registrar. var Registry = map[*client.GVR]ResourceMeta{ // Custom... client.WkGVR: { DAO: new(dao.Workload), Renderer: new(render.Workload), }, client.RefGVR: { DAO: new(dao.Reference), Renderer: new(render.Reference), }, client.DirGVR: { DAO: new(dao.Dir), Renderer: new(render.Dir), }, client.PuGVR: { DAO: new(dao.Pulse), }, client.HmGVR: { DAO: new(dao.HelmChart), Renderer: new(helm.Chart), }, client.HmhGVR: { DAO: new(dao.HelmHistory), Renderer: new(helm.History), }, client.CoGVR: { DAO: new(dao.Container), Renderer: new(render.Container), TreeRenderer: new(xray.Container), }, client.ScnGVR: { DAO: new(dao.ImageScan), Renderer: new(render.ImageScan), }, client.CtGVR: { DAO: new(dao.Context), Renderer: new(render.Context), }, client.SdGVR: { DAO: new(dao.ScreenDump), Renderer: new(render.ScreenDump), }, client.RbacGVR: { DAO: new(dao.Rbac), Renderer: new(render.Rbac), }, client.PolGVR: { DAO: new(dao.Policy), Renderer: new(render.Policy), }, client.UsrGVR: { DAO: new(dao.Subject), Renderer: new(render.Subject), }, client.GrpGVR: { DAO: new(dao.Subject), Renderer: new(render.Subject), }, client.PfGVR: { DAO: new(dao.PortForward), Renderer: new(render.PortForward), }, client.BeGVR: { DAO: new(dao.Benchmark), Renderer: new(render.Benchmark), }, client.AliGVR: { DAO: new(dao.Alias), Renderer: new(render.Alias), }, // Discovery... client.EpsGVR: { Renderer: new(render.EndpointSlice), }, // Core... client.EpGVR: { Renderer: new(render.Endpoints), }, client.PodGVR: { DAO: new(dao.Pod), Renderer: render.NewPod(), TreeRenderer: new(xray.Pod), }, client.NsGVR: { DAO: new(dao.Namespace), Renderer: new(render.Namespace), }, client.SecGVR: { DAO: new(dao.Secret), Renderer: new(render.Secret), }, client.CmGVR: { DAO: new(dao.ConfigMap), Renderer: new(render.ConfigMap), }, client.NodeGVR: { DAO: new(dao.Node), Renderer: new(render.Node), }, client.SvcGVR: { DAO: new(dao.Service), Renderer: new(render.Service), TreeRenderer: new(xray.Service), }, client.SaGVR: { Renderer: new(render.ServiceAccount), }, client.PvGVR: { Renderer: new(render.PersistentVolume), }, client.PvcGVR: { Renderer: new(render.PersistentVolumeClaim), }, client.EvGVR: { DAO: new(dao.Table), Renderer: new(render.Event), }, // Apps... client.DpGVR: { DAO: new(dao.Deployment), Renderer: new(render.Deployment), TreeRenderer: new(xray.Deployment), }, client.RsGVR: { Renderer: new(render.ReplicaSet), TreeRenderer: new(xray.ReplicaSet), }, client.StsGVR: { DAO: new(dao.StatefulSet), Renderer: new(render.StatefulSet), TreeRenderer: new(xray.StatefulSet), }, client.DsGVR: { DAO: new(dao.DaemonSet), Renderer: new(render.DaemonSet), TreeRenderer: new(xray.DaemonSet), }, // Extensions... client.NpGVR: { Renderer: &render.NetworkPolicy{}, }, // Batch... client.CjGVR: { DAO: new(dao.CronJob), Renderer: new(render.CronJob), }, client.JobGVR: { DAO: new(dao.Job), Renderer: new(render.Job), }, // CRDs... client.CrdGVR: { DAO: new(dao.CustomResourceDefinition), Renderer: new(render.CustomResourceDefinition), }, // Storage... client.ScGVR: { Renderer: &render.StorageClass{}, }, // Policy... client.PdbGVR: { Renderer: &render.PodDisruptionBudget{}, }, // RBAC... client.CrGVR: { DAO: new(dao.Rbac), Renderer: new(render.ClusterRole), }, client.CrbGVR: { Renderer: new(render.ClusterRoleBinding), }, client.RoGVR: { Renderer: new(render.Role), }, client.RobGVR: { Renderer: new(render.RoleBinding), }, } ================================================ FILE: internal/model/rev_values.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "errors" "log/slog" "strings" "sync/atomic" "time" backoff "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/sahilm/fuzzy" ) // RevValues tracks Helm values representations. type RevValues struct { gvr *client.GVR inUpdate int32 path string rev string query string lines []string allValues bool listeners []ResourceViewerListener options ViewerToggleOpts } // NewRevValues return a new Helm values resource model. func NewRevValues(gvr *client.GVR, path, rev string) *RevValues { return &RevValues{ gvr: gvr, path: path, rev: rev, allValues: false, lines: getRevValues(path, rev), } } func getHelmHistDao() *dao.HelmHistory { return Registry[client.HmhGVR].DAO.(*dao.HelmHistory) } func getRevValues(path, _ string) []string { vals, err := getHelmHistDao().GetValues(path, true) if err != nil { slog.Error("Failed to get Helm values", slogs.Error, err) } return strings.Split(string(vals), "\n") } // GVR returns the resource gvr. func (v *RevValues) GVR() *client.GVR { return v.gvr } // GetPath returns the active resource path. func (v *RevValues) GetPath() string { return v.path } // SetOptions toggle model options. func (v *RevValues) SetOptions(ctx context.Context, opts ViewerToggleOpts) { v.options = opts if err := v.refresh(ctx); err != nil { v.fireResourceFailed(err) } } // Filter filters the model. func (v *RevValues) Filter(q string) { v.query = q v.filterChanged(v.lines) } func (v *RevValues) filterChanged(lines []string) { v.fireResourceChanged(lines, v.filter(v.query, lines)) } func (v *RevValues) filter(q string, lines []string) fuzzy.Matches { if q == "" { return nil } if f, ok := internal.IsFuzzySelector(q); ok { return v.fuzzyFilter(strings.TrimSpace(f), lines) } return rxFilter(q, lines) } func (*RevValues) fuzzyFilter(q string, lines []string) fuzzy.Matches { return fuzzy.Find(q, lines) } func (v *RevValues) fireResourceChanged(lines []string, matches fuzzy.Matches) { for _, l := range v.listeners { l.ResourceChanged(lines, matches) } } func (v *RevValues) fireResourceFailed(err error) { for _, l := range v.listeners { l.ResourceFailed(err) } } // ClearFilter clear out the filter. func (v *RevValues) ClearFilter() { v.query = "" } // Peek returns the current model data. func (v *RevValues) Peek() []string { return v.lines } // Refresh updates model data. func (v *RevValues) Refresh(ctx context.Context) error { return v.refresh(ctx) } // Watch watches for Values changes. func (v *RevValues) Watch(ctx context.Context) error { if err := v.refresh(ctx); err != nil { return err } go v.updater(ctx) return nil } func (v *RevValues) updater(ctx context.Context) { defer slog.Debug("YAML canceled", slogs.GVR, v.gvr) backOff := NewExpBackOff(ctx, defaultReaderRefreshRate, maxReaderRetryInterval) delay := defaultReaderRefreshRate for { select { case <-ctx.Done(): return case <-time.After(delay): if err := v.refresh(ctx); err != nil { v.fireResourceFailed(err) if delay = backOff.NextBackOff(); delay == backoff.Stop { slog.Error("Giving up retrieving chart values", slogs.Error, err) return } } else { backOff.Reset() delay = defaultReaderRefreshRate } } } } func (v *RevValues) refresh(context.Context) error { if !atomic.CompareAndSwapInt32(&v.inUpdate, 0, 1) { slog.Debug("Dropping update...") return errors.New("refresh in progress, dropping") } defer atomic.StoreInt32(&v.inUpdate, 0) v.reconcile() return nil } func (v *RevValues) reconcile() { v.fireResourceChanged(v.lines, v.filter(v.query, v.lines)) } // AddListener adds a new model listener. func (v *RevValues) AddListener(l ResourceViewerListener) { v.listeners = append(v.listeners, l) } // RemoveListener delete a listener from the list. func (v *RevValues) RemoveListener(l ResourceViewerListener) { victim := -1 for i, lis := range v.listeners { if lis == l { victim = i break } } if victim >= 0 { v.listeners = append(v.listeners[:victim], v.listeners[victim+1:]...) } } ================================================ FILE: internal/model/semver.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "fmt" "regexp" "strconv" ) var versionRX = regexp.MustCompile(`\Av(\d+)\.(\d+)\.(\d+)\z`) // SemVer represents a semantic version. type SemVer struct { Major, Minor, Patch int } // NewSemVer returns a new semantic version. func NewSemVer(version string) *SemVer { var v SemVer v.Major, v.Minor, v.Patch = v.parse(NormalizeVersion(version)) return &v } // String returns version as a string. func (v *SemVer) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) } func (*SemVer) parse(version string) (major, minor, patch int) { mm := versionRX.FindStringSubmatch(version) if len(mm) < 4 { return } major, _ = strconv.Atoi(mm[1]) minor, _ = strconv.Atoi(mm[2]) patch, _ = strconv.Atoi(mm[3]) return } // NormalizeVersion ensures the version starts with a v. func NormalizeVersion(version string) string { if version == "" { return version } if version[0] == 'v' { return version } return "v" + version } // IsCurrent asserts if at latest release. func (v *SemVer) IsCurrent(latest *SemVer) bool { return v.Major >= latest.Major && v.Minor >= latest.Minor && v.Patch >= latest.Patch } ================================================ FILE: internal/model/semver_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/stretchr/testify/assert" ) func TestNewSemVer(t *testing.T) { uu := map[string]struct { version string major, minor, patch int }{ "plain": { version: "0.11.1", major: 0, minor: 11, patch: 1, }, "normalized": { version: "v10.11.12", major: 10, minor: 11, patch: 12, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { v := model.NewSemVer(u.version) assert.Equal(t, u.major, v.Major) assert.Equal(t, u.minor, v.Minor) assert.Equal(t, u.patch, v.Patch) }) } } func TestSemVerIsCurrent(t *testing.T) { uu := map[string]struct { current, latest string e bool }{ "same": { current: "0.11.1", latest: "0.11.1", e: true, }, "older": { current: "v10.11.12", latest: "v10.11.13", }, "newer": { current: "10.11.13", latest: "10.11.12", e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { v1, v2 := model.NewSemVer(u.current), model.NewSemVer(u.latest) assert.Equal(t, u.e, v1.IsCurrent(v2)) }) } } ================================================ FILE: internal/model/stack.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "fmt" "log/slog" "sync" "github.com/derailed/k9s/internal/slogs" ) const ( // StackPush denotes an add on the stack. StackPush StackAction = 1 << iota // StackPop denotes a delete on the stack. StackPop ) // StackAction represents an action on the stack. type StackAction int // StackEvent represents an operation on a view stack. type StackEvent struct { // Kind represents the event condition. Action StackAction // Item represents the targeted item. Component Component } // StackListener represents a stack listener. type StackListener interface { // StackPushed indicates a new item was added. StackPushed(Component) // StackPopped indicates an item was deleted StackPopped(old, new Component) // StackTop indicates the top of the stack StackTop(Component) } // Stack represents a stacks of components. type Stack struct { components []Component listeners []StackListener mx sync.RWMutex } // NewStack returns a new initialized stack. func NewStack() *Stack { return &Stack{} } // Flatten returns a string representation of the component stack. func (s *Stack) Flatten() []string { s.mx.RLock() defer s.mx.RUnlock() ss := make([]string, len(s.components)) for i, c := range s.components { ss[i] = c.Name() } return ss } // RemoveListener removes a listener. func (s *Stack) RemoveListener(l StackListener) { victim := -1 for i, lis := range s.listeners { if lis == l { victim = i break } } if victim == -1 { return } s.listeners = append(s.listeners[:victim], s.listeners[victim+1:]...) } // AddListener registers a stack listener. func (s *Stack) AddListener(l StackListener) { s.listeners = append(s.listeners, l) if !s.Empty() { l.StackTop(s.Top()) } } // Push adds a new item. func (s *Stack) Push(c Component) { if top := s.Top(); top != nil { top.Stop() } s.mx.Lock() s.components = append(s.components, c) s.mx.Unlock() s.notify(StackPush, c) } // Pop removed the top item and returns it. func (s *Stack) Pop() (Component, bool) { if s.Empty() { return nil, false } var c Component s.mx.Lock() c = s.components[len(s.components)-1] c.Stop() s.components = s.components[:len(s.components)-1] s.mx.Unlock() s.notify(StackPop, c) return c, true } // Peek returns stack state. func (s *Stack) Peek() []Component { s.mx.RLock() defer s.mx.RUnlock() return s.components } // Clear clear out the stack using pops. func (s *Stack) Clear() { for range s.components { s.Pop() } } // Empty returns true if the stack is empty. func (s *Stack) Empty() bool { s.mx.RLock() defer s.mx.RUnlock() return len(s.components) == 0 } // IsLast indicates if stack only has one item left. func (s *Stack) IsLast() bool { return len(s.components) == 1 } // Previous returns the previous component if any. func (s *Stack) Previous() Component { if s.Empty() { return nil } if s.IsLast() { return s.Top() } return s.components[len(s.components)-2] } // Top returns the top most item or nil if the stack is empty. func (s *Stack) Top() Component { if s.Empty() { return nil } s.mx.RLock() defer s.mx.RUnlock() return s.components[len(s.components)-1] } func (s *Stack) notify(a StackAction, c Component) { for _, l := range s.listeners { switch a { case StackPush: l.StackPushed(c) case StackPop: l.StackPopped(c, s.Top()) } } } // ---------------------------------------------------------------------------- // Helpers... // Dump prints out the stack. func (s *Stack) Dump() { slog.Debug("Stack Dump", slogs.Stack, fmt.Sprintf("%p", s)) for i, c := range s.components { slog.Debug(fmt.Sprintf("%d -- %s -- %#v", i, c.Name(), c)) } slog.Debug("------------------") } ================================================ FILE: internal/model/stack_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "context" "log/slog" "testing" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/labels" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestStackClear(t *testing.T) { comps := []model.Component{makeC("c1"), makeC("c2"), makeC("c3")} uu := map[string]struct { items []model.Component }{ "empty": { items: []model.Component{}, }, "items": { items: comps, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, c := range u.items { s.Push(c) } s.Clear() assert.True(t, s.Empty()) }) } } func TestStackPrevious(t *testing.T) { comps := []model.Component{makeC("c1"), makeC("c2"), makeC("c3")} uu := map[string]struct { items []model.Component pops int e model.Component }{ "empty": { items: []model.Component{}, pops: 0, e: nil, }, "one_left": { items: comps, pops: 1, e: comps[0], }, "none_left": { items: comps, pops: 2, e: comps[0], }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, c := range u.items { s.Push(c) } for range u.pops { s.Pop() } assert.Equal(t, u.e, s.Previous()) }) } } func TestStackIsLast(t *testing.T) { uu := map[string]struct { items []model.Component pops int e bool }{ "empty": { items: []model.Component{}, }, "normal": { items: []model.Component{makeC("c1"), makeC("c2"), makeC("c3")}, pops: 1, }, "last": { items: []model.Component{makeC("c1"), makeC("c2"), makeC("c3")}, pops: 2, e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, c := range u.items { s.Push(c) } for range u.pops { s.Pop() } assert.Equal(t, u.e, s.IsLast()) }) } } func TestStackFlatten(t *testing.T) { uu := map[string]struct { items []model.Component e []string }{ "empty": { items: []model.Component{}, e: []string{}, }, "normal": { items: []model.Component{makeC("c1"), makeC("c2"), makeC("c3")}, e: []string{"c1", "c2", "c3"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, c := range u.items { s.Push(c) } assert.Equal(t, u.e, s.Flatten()) assert.Len(t, s.Peek(), len(u.e)) }) } } func TestStackPush(t *testing.T) { top := c{} uu := map[string]struct { items []model.Component pop int e bool top model.Component }{ "empty": { items: []model.Component{}, pop: 3, e: true, }, "full": { items: []model.Component{c{}, c{}, top}, pop: 3, e: true, }, "pop": { items: []model.Component{c{}, c{}, top}, pop: 2, e: false, top: top, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, c := range u.items { s.Push(c) } for range u.pop { s.Pop() } assert.Equal(t, u.e, s.Empty()) if !u.e { assert.Equal(t, u.top, s.Top()) } }) } } func TestStackTop(t *testing.T) { top := c{} uu := map[string]struct { items []model.Component e model.Component }{ "blank": { items: []model.Component{}, }, "push3": { items: []model.Component{c{}, c{}, top}, e: top, }, "push1": { items: []model.Component{top}, e: top, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s := model.NewStack() for _, item := range u.items { s.Push(item) } v := s.Top() assert.Equal(t, u.e, v) }) } } func TestStackAddListener(t *testing.T) { items := []model.Component{c{}, c{}, c{}} s := model.NewStack() l := stackL{} s.AddListener(&l) for _, item := range items { s.Push(item) } assert.Equal(t, 3, l.count) for range items { s.Pop() } assert.Equal(t, 0, l.count) } func TestStackAddListenerAfter(t *testing.T) { items := []model.Component{c{}, c{}, c{}} s := model.NewStack() l := stackL{} for _, item := range items { s.Push(item) } s.AddListener(&l) assert.Equal(t, 1, l.tops) assert.Equal(t, 0, l.count) } func TestStackRemoveListener(t *testing.T) { s := model.NewStack() l1, l2, l3 := &stackL{}, &stackL{}, &stackL{} s.AddListener(l1) s.AddListener(l2) s.RemoveListener(l2) s.RemoveListener(l3) s.RemoveListener(l1) s.Push(c{}) assert.Equal(t, 0, l1.count) assert.Equal(t, 0, l2.count) assert.Equal(t, 0, l3.count) } // ---------------------------------------------------------------------------- // Helpers... type stackL struct { count, tops int } func (s *stackL) StackPushed(model.Component) { s.count++ } func (s *stackL) StackPopped(_, _ model.Component) { s.count-- } func (s *stackL) StackTop(model.Component) { s.tops++ } type c struct { name string } func makeC(n string) c { return c{name: n} } func (c) InCmdMode() bool { return false } func (c c) Name() string { return c.name } func (c) SetCommand(*cmd.Interpreter) {} func (c) Hints() model.MenuHints { return nil } func (c) HasFocus() bool { return false } func (c) ExtraHints() map[string]string { return nil } func (c) Draw(tcell.Screen) {} func (c) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) { return nil } func (c) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { return nil } func (c) SetRect(int, int, int, int) {} func (c) GetRect() (a, b, c, d int) { return 0, 0, 0, 0 } func (c) GetFocusable() tview.Focusable { return nil } func (c) Focus(func(tview.Primitive)) {} func (c) Blur() {} func (c) Start() {} func (c) Stop() {} func (c) Init(context.Context) error { return nil } func (c) SetFilter(string, bool) {} func (c) SetLabelSelector(labels.Selector, bool) {} ================================================ FILE: internal/model/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "sync" "sync/atomic" "time" backoff "github.com/cenkalti/backoff/v4" "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/model1" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) 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) // TableLoadFailed notifies the load failed. TableLoadFailed(error) } // Table represents a table model. type Table struct { gvr *client.GVR data *model1.TableData listeners []TableListener inUpdate int32 refreshRate time.Duration instance string labelSelector labels.Selector mx sync.RWMutex vs *config.ViewSetting } // NewTable returns a new table model. func NewTable(gvr *client.GVR) *Table { return &Table{ gvr: gvr, data: model1.NewTableData(gvr), refreshRate: 2 * time.Second, } } func (t *Table) SetViewSetting(ctx context.Context, vs *config.ViewSetting) { t.mx.Lock() t.vs = vs t.mx.Unlock() if ctx != context.Background() { if err := t.reconcile(ctx); err != nil { slog.Error("Refresh failed", slogs.GVR, t.gvr) } } } // SetLabelSelector sets the labels selector. func (t *Table) SetLabelSelector(sel labels.Selector) { t.mx.Lock() defer t.mx.Unlock() t.labelSelector = sel } // GetLabelSelector sets the labels selector. func (t *Table) GetLabelSelector() labels.Selector { t.mx.Lock() defer t.mx.Unlock() return t.labelSelector } // SetInstance sets a single entry table. func (t *Table) SetInstance(path string) { t.instance = path } // AddListener adds a new model listener. func (t *Table) AddListener(l TableListener) { t.mx.Lock() defer t.mx.Unlock() t.listeners = append(t.listeners, l) } // RemoveListener delete a listener from the list. func (t *Table) RemoveListener(l TableListener) { victim := -1 for i, lis := range t.listeners { if lis == l { victim = i break } } if victim >= 0 { t.mx.Lock() t.listeners = append(t.listeners[:victim], t.listeners[victim+1:]...) t.mx.Unlock() } } // Watch initiates model updates. func (t *Table) Watch(ctx context.Context) error { if err := t.refresh(ctx); err != nil { return err } go t.updater(ctx) return nil } // Refresh updates the table content. func (t *Table) Refresh(ctx context.Context) error { return t.refresh(ctx) } // Get returns a resource instance if found, else an error. func (t *Table) Get(ctx context.Context, path string) (runtime.Object, error) { meta, err := getMeta(ctx, t.gvr) if err != nil { return nil, err } return meta.DAO.Get(ctx, path) } // Delete deletes a resource. func (t *Table) Delete(ctx context.Context, path string, propagation *metav1.DeletionPropagation, grace dao.Grace) error { meta, err := getMeta(ctx, t.gvr) if err != nil { return err } nuker, ok := meta.DAO.(dao.Nuker) if !ok { return fmt.Errorf("no nuker for %q", meta.DAO.GVR()) } return nuker.Delete(ctx, path, propagation, grace) } // GetNamespace returns the model namespace. func (t *Table) GetNamespace() string { return t.data.GetNamespace() } // SetNamespace sets up model namespace. func (t *Table) SetNamespace(ns string) { t.data.Reset(ns) } // InNamespace checks if current namespace matches desired namespace. func (t *Table) InNamespace(ns string) bool { return t.data.GetNamespace() == ns && !t.data.Empty() } // SetRefreshRate sets model refresh duration. func (t *Table) SetRefreshRate(d time.Duration) { t.refreshRate = d } // ClusterWide checks if resource is scope for all namespaces. func (t *Table) ClusterWide() bool { return client.IsClusterWide(t.data.GetNamespace()) } // Empty returns true if no model data. func (t *Table) Empty() bool { return t.data.Empty() } // RowCount returns the row count. func (t *Table) RowCount() int { return t.data.RowCount() } // Peek returns model data. func (t *Table) Peek() *model1.TableData { t.mx.RLock() defer t.mx.RUnlock() return t.data.Clone() } func (t *Table) updater(ctx context.Context) { bf := backoff.NewExponentialBackOff() bf.InitialInterval, bf.MaxElapsedTime = initRefreshRate, maxReaderRetryInterval rate := initRefreshRate for { select { case <-ctx.Done(): return case <-time.After(rate): rate = t.refreshRate err := backoff.Retry(func() error { if err := t.refresh(ctx); err != nil { slog.Error("Refresh failed", slogs.GVR, t.gvr) return err } return nil }, backoff.WithContext(bf, ctx)) if err != nil { slog.Warn("Reconciler exited", slogs.Error, err) t.fireTableLoadFailed(err) return } } } } func (t *Table) refresh(ctx context.Context) error { if !atomic.CompareAndSwapInt32(&t.inUpdate, 0, 1) { slog.Debug("Dropping update...") return nil } defer atomic.StoreInt32(&t.inUpdate, 0) if err := t.reconcile(ctx); err != nil { return err } data := t.Peek() if data.RowCount() == 0 { t.fireNoData(data) } else { t.fireTableChanged(data) } return nil } func (t *Table) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, error) { factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) } a.Init(factory, t.gvr) t.mx.RLock() ctx = context.WithValue(ctx, internal.KeyLabels, t.labelSelector) t.mx.RUnlock() ns := client.CleanseNamespace(t.data.GetNamespace()) if client.IsClusterScoped(ns) { ns = client.BlankNamespace } return a.List(ctx, ns) } func (t *Table) reconcile(ctx context.Context) error { var ( oo []runtime.Object err error ) meta := resourceMeta(t.gvr) if t.vs != nil { meta.DAO.SetIncludeObject(true) } ctx = context.WithValue(ctx, internal.KeyLabels, t.labelSelector) if t.instance == "" { oo, err = t.list(ctx, meta.DAO) } else { o, e := t.Get(ctx, t.instance) oo, err = []runtime.Object{o}, e } if err != nil { return err } r := meta.Renderer r.SetViewSetting(t.vs) return t.data.Render(ctx, meta.Renderer, oo) } func (t *Table) fireTableChanged(data *model1.TableData) { var ll []TableListener t.mx.RLock() ll = t.listeners t.mx.RUnlock() for _, l := range ll { l.TableDataChanged(data) } } 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() ll = t.listeners t.mx.RUnlock() for _, l := range ll { l.TableLoadFailed(err) } } ================================================ FILE: internal/model/table_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "encoding/json" "fmt" "os" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) func TestTableReconcile(t *testing.T) { ta := NewTable(client.PodGVR) ta.SetNamespace(client.NamespaceAll) f := makeFactory() f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) err := ta.reconcile(ctx) require.NoError(t, err) data := ta.Peek() assert.Equal(t, 26, data.HeaderCount()) assert.Equal(t, 1, data.RowCount()) assert.Equal(t, client.NamespaceAll, data.GetNamespace()) } func TestTableList(t *testing.T) { ta := NewTable(client.PodGVR) ta.SetNamespace("blee") acc := accessor{} ctx := context.WithValue(context.Background(), internal.KeyFactory, makeFactory()) rows, err := ta.list(ctx, &acc) require.NoError(t, err) assert.Len(t, rows, 1) } func TestTableGet(t *testing.T) { ta := NewTable(client.PodGVR) ta.SetNamespace("blee") f := makeFactory() f.rows = []runtime.Object{load(t, "p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) row, err := ta.Get(ctx, "fred") require.NoError(t, err) assert.NotNil(t, row) assert.Len(t, row.(*render.PodWithMetrics).Raw.Object, 5) } func TestTableMeta(t *testing.T) { uu := map[string]struct { gvr *client.GVR accessor dao.Accessor renderer model1.Renderer }{ "generic": { gvr: client.CoGVR, accessor: &dao.Container{}, renderer: &render.Container{}, }, "node": { gvr: client.NodeGVR, accessor: &dao.Node{}, renderer: &render.Node{}, }, } for k := range uu { u := uu[k] ta := NewTable(u.gvr) m := resourceMeta(ta.gvr) assert.Equal(t, u.accessor, m.DAO) assert.Equal(t, u.renderer, m.Renderer) } } // ---------------------------------------------------------------------------- // Helpers... func mustLoad(n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } var o unstructured.Unstructured if err = json.Unmarshal(raw, &o); err != nil { panic(err) } return &o } func load(t *testing.T, n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) require.NoError(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) require.NoError(t, err) return &o } // ---------------------------------------------------------------------------- func makeFactory() testFactory { return testFactory{} } type testFactory struct { rows []runtime.Object } var _ dao.Factory = testFactory{} func (testFactory) Client() client.Connection { return client.NewTestAPIClient() } func (f testFactory) Get(*client.GVR, string, bool, labels.Selector) (runtime.Object, error) { if len(f.rows) > 0 { return f.rows[0], nil } return nil, nil } func (f testFactory) List(*client.GVR, string, bool, labels.Selector) ([]runtime.Object, error) { if len(f.rows) > 0 { return f.rows, nil } return nil, nil } func (testFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (testFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (testFactory) WaitForCacheSync() {} func (testFactory) Forwarders() watch.Forwarders { return nil } func (testFactory) DeleteForwarder(string) {} // ---------------------------------------------------------------------------- type accessor struct { gvr *client.GVR } var _ dao.Accessor = (*accessor)(nil) func (*accessor) SetIncludeObject(bool) {} func (*accessor) List(context.Context, string) ([]runtime.Object, error) { return []runtime.Object{&render.PodWithMetrics{Raw: mustLoad("p1")}}, nil } func (*accessor) Get(context.Context, string) (runtime.Object, error) { return &render.PodWithMetrics{Raw: mustLoad("p1")}, nil } func (a *accessor) Init(_ dao.Factory, gvr *client.GVR) { a.gvr = gvr } func (a *accessor) GVR() string { return a.gvr.String() } ================================================ FILE: internal/model/table_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "context" "encoding/json" "fmt" "os" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) func TestTableRefresh(t *testing.T) { ta := model.NewTable(client.PodGVR) ta.SetNamespace(client.NamespaceAll) l := tableListener{} ta.AddListener(&l) f := makeTableFactory() f.rows = []runtime.Object{mustLoad("p1")} ctx := context.WithValue(context.Background(), internal.KeyFactory, f) ctx = context.WithValue(ctx, internal.KeyFields, "") ctx = context.WithValue(ctx, internal.KeyWithMetrics, false) require.NoError(t, ta.Refresh(ctx)) data := ta.Peek() 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) assert.Equal(t, 0, l.errs) } func TestTableNS(t *testing.T) { ta := model.NewTable(client.PodGVR) ta.SetNamespace("blee") assert.Equal(t, "blee", ta.GetNamespace()) assert.False(t, ta.ClusterWide()) assert.False(t, ta.InNamespace("zorg")) } func TestTableAddListener(t *testing.T) { ta := model.NewTable(client.PodGVR) ta.SetNamespace("blee") assert.True(t, ta.Empty()) l := tableListener{} ta.AddListener(&l) } func TestTableRmListener(*testing.T) { ta := model.NewTable(client.PodGVR) ta.SetNamespace("blee") l := tableListener{} ta.RemoveListener(&l) } // Helpers... type tableListener struct { count, errs int } func (*tableListener) TableNoData(*model1.TableData) {} func (l *tableListener) TableDataChanged(*model1.TableData) { l.count++ } func (l *tableListener) TableLoadFailed(error) { l.errs++ } type tableFactory struct { rows []runtime.Object } var _ dao.Factory = tableFactory{} func (tableFactory) Client() client.Connection { return client.NewTestAPIClient() } func (f tableFactory) Get(*client.GVR, string, bool, labels.Selector) (runtime.Object, error) { if len(f.rows) > 0 { return f.rows[0], nil } return nil, nil } func (f tableFactory) List(*client.GVR, string, bool, labels.Selector) ([]runtime.Object, error) { if len(f.rows) > 0 { return f.rows, nil } return nil, nil } func (tableFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (tableFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (tableFactory) WaitForCacheSync() {} func (tableFactory) Forwarders() watch.Forwarders { return nil } func (tableFactory) DeleteForwarder(string) {} func makeTableFactory() tableFactory { return tableFactory{} } func mustLoad(n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) if err != nil { panic(err) } var o unstructured.Unstructured if err = json.Unmarshal(raw, &o); err != nil { panic(err) } return &o } ================================================ FILE: internal/model/testdata/p1.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/restartedAt": "2019-12-31T12:26:47-07:00" }, "creationTimestamp": "2019-12-31T19:27:22Z", "generateName": "nginx-7fb78fb6d8-", "labels": { "app": "nginx", "pod-template-hash": "7fb78fb6d8" }, "name": "nginx-7fb78fb6d8-2w75j", "namespace": "default", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "ReplicaSet", "name": "nginx-7fb78fb6d8", "uid": "7ccd0600-2c03-11ea-883f-42010a800044" } ], "resourceVersion": "87290191", "selfLink": "/api/v1/namespaces/default/pods/nginx-7fb78fb6d8-2w75j", "uid": "91bb1cf2-2c03-11ea-883f-42010a800044" }, "spec": { "containers": [ { "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "cpu": "200m", "memory": "20Mi" }, "requests": { "cpu": "200m", "memory": "20Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-dsl46", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "gke-k9s-default-pool-0fa2fb89-lbtf", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "default-token-dsl46", "secret": { "defaultMode": 420, "secretName": "default-token-dsl46" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:23Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:22Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://90e0abf7a779dd76d36038883312baed57a8351428a1d6340df3cff698f51809", "image": "k8s.gcr.io/nginx-slim:0.8", "imageID": "docker-pullable://k8s.gcr.io/nginx-slim@sha256:8b4501fe0fe221df663c22e16539f399e89594552f400408303c42f3dd8d0e52", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-12-31T19:27:24Z" } } } ], "hostIP": "10.128.0.15", "phase": "Running", "podIP": "10.44.0.229", "qosClass": "Guaranteed", "startTime": "2019-12-31T19:27:23Z" } } ================================================ FILE: internal/model/text.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "strings" "github.com/derailed/k9s/internal" "github.com/sahilm/fuzzy" ) // Filterable represents an entity that can be filtered. type Filterable interface { Filter(string) ClearFilter() } // Textable represents a text resource. type Textable interface { Peek() []string SetText(string) AddListener(TextListener) RemoveListener(TextListener) } // TextListener represents a text model listener. type TextListener interface { // TextChanged notifies the model changed. TextChanged([]string) // TextFiltered notifies when the filter changed. TextFiltered([]string, fuzzy.Matches) } // Text represents a text model. type Text struct { lines []string listeners []TextListener query string } // NewText returns a new model. func NewText() *Text { return &Text{} } // Peek returns the current model state. func (t *Text) Peek() []string { return t.lines } // ClearFilter clear out filter. func (t *Text) ClearFilter() { t.query = "" t.filterChanged(t.lines) } // Filter filters out the text. func (t *Text) Filter(q string) { t.query = q t.filterChanged(t.lines) } // SetText sets the current model content. func (t *Text) SetText(buff string) { t.lines = strings.Split(buff, "\n") t.fireTextChanged(t.lines) } // AddListener adds a new model listener. func (t *Text) AddListener(listener TextListener) { t.listeners = append(t.listeners, listener) } // RemoveListener delete a listener from the list. func (t *Text) RemoveListener(listener TextListener) { victim := -1 for i, lis := range t.listeners { if lis == listener { victim = i break } } if victim >= 0 { t.listeners = append(t.listeners[:victim], t.listeners[victim+1:]...) } } func (t *Text) filterChanged(lines []string) { t.fireTextFiltered(lines, t.filter(t.query, lines)) } func (t *Text) fireTextChanged(lines []string) { for _, lis := range t.listeners { lis.TextChanged(lines) } } func (t *Text) fireTextFiltered(lines []string, matches fuzzy.Matches) { for _, lis := range t.listeners { lis.TextFiltered(lines, matches) } } // ---------------------------------------------------------------------------- // Helpers... func (t *Text) filter(q string, lines []string) fuzzy.Matches { if q == "" { return nil } if f, ok := internal.IsFuzzySelector(q); ok { return t.fuzzyFilter(strings.TrimSpace(f), lines) } return rxFilter(q, lines) } func (*Text) fuzzyFilter(q string, lines []string) fuzzy.Matches { return fuzzy.Find(q, lines) } ================================================ FILE: internal/model/text_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/sahilm/fuzzy" "github.com/stretchr/testify/assert" ) func TestNewText(t *testing.T) { m := model.NewText() lis := textLis{} m.AddListener(&lis) m.SetText("Hello World\nBumbleBeeTuna") assert.Equal(t, 1, lis.changed) assert.Equal(t, 2, lis.lines) assert.Equal(t, 0, lis.filtered) assert.Equal(t, 0, lis.matches) } func TestTextFilterRXMatch(t *testing.T) { m := model.NewText() lis := textLis{} m.AddListener(&lis) m.SetText("Hello World\nBumbleBeeTuna") m.Filter("world") assert.Equal(t, 1, lis.changed) assert.Equal(t, 2, lis.lines) assert.Equal(t, 1, lis.filtered) assert.Equal(t, 1, lis.matches) assert.Equal(t, 6, lis.index) } func TestTextFilterFuzzyMatch(t *testing.T) { m := model.NewText() lis := textLis{} m.AddListener(&lis) m.SetText("Hello World\nBumbleBeeTuna") m.Filter("-f world") assert.Equal(t, 1, lis.changed) assert.Equal(t, 2, lis.lines) assert.Equal(t, 1, lis.filtered) assert.Equal(t, 1, lis.matches) assert.Equal(t, 6, lis.index) } func TestTextFilterNoMatch(t *testing.T) { m := model.NewText() lis := textLis{} m.AddListener(&lis) m.SetText("Hello World\nBumbleBeeTuna") m.Filter("blee") assert.Equal(t, 1, lis.changed) assert.Equal(t, 2, lis.lines) assert.Equal(t, 1, lis.filtered) assert.Equal(t, 0, lis.matches) assert.Equal(t, 0, lis.index) } // Helpers... type textLis struct { changed, filtered, matches, lines, index int } func (l *textLis) TextChanged(ll []string) { l.lines = len(ll) l.changed++ } func (l *textLis) TextFiltered(_ []string, mm fuzzy.Matches) { l.matches = len(mm) l.filtered++ if len(mm) > 0 && len(mm[0].MatchedIndexes) > 0 { l.index = mm[0].MatchedIndexes[0] } } ================================================ FILE: internal/model/tree.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "regexp" "strings" "sync/atomic" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/xray" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) const initTreeRefreshRate = 500 * time.Millisecond // TreeListener represents a tree model listener. type TreeListener interface { // TreeChanged notifies the model data changed. TreeChanged(*xray.TreeNode) // TreeLoadFailed notifies the load failed. TreeLoadFailed(error) } // Tree represents a tree model. type Tree struct { gvr *client.GVR namespace string root *xray.TreeNode listeners []TreeListener inUpdate int32 refreshRate time.Duration query string } // NewTree returns a new model. func NewTree(gvr *client.GVR) *Tree { return &Tree{ gvr: gvr, refreshRate: 2 * time.Second, } } // ClearFilter clears out active filter. func (t *Tree) ClearFilter() { t.query = "" } // SetFilter sets the current filter. func (t *Tree) SetFilter(q string) { t.query = q } // AddListener adds a listener. func (t *Tree) AddListener(l TreeListener) { t.listeners = append(t.listeners, l) } // RemoveListener delete a listener. func (t *Tree) RemoveListener(l TreeListener) { victim := -1 for i, lis := range t.listeners { if lis == l { victim = i break } } if victim >= 0 { t.listeners = append(t.listeners[:victim], t.listeners[victim+1:]...) } } // Watch initiates model updates. func (t *Tree) Watch(ctx context.Context) { t.Refresh(ctx) go t.updater(ctx) } // Refresh update the model now. func (t *Tree) Refresh(ctx context.Context) { t.refresh(ctx) } // GetNamespace returns the model namespace. func (t *Tree) GetNamespace() string { return t.namespace } // SetNamespace sets up model namespace. func (t *Tree) SetNamespace(ns string) { t.namespace = ns if t.root == nil { return } t.root.Clear() } // SetRefreshRate sets model refresh duration. func (t *Tree) SetRefreshRate(d time.Duration) { t.refreshRate = d } // ClusterWide checks if resource is scope for all namespaces. func (t *Tree) ClusterWide() bool { return client.IsClusterWide(t.namespace) } // InNamespace checks if current namespace matches desired namespace. func (t *Tree) InNamespace(ns string) bool { return t.namespace == ns } // Empty return true if no model data. func (t *Tree) Empty() bool { return t.root.IsLeaf() } // Peek returns model data. func (t *Tree) Peek() *xray.TreeNode { return t.root } // Describe describes a given resource. func (t *Tree) Describe(ctx context.Context, gvr *client.GVR, path string) (string, error) { meta, err := t.getMeta(ctx, gvr) if err != nil { return "", err } desc, ok := meta.DAO.(dao.Describer) if !ok { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } return desc.Describe(path) } // ToYAML returns a resource yaml. func (t *Tree) ToYAML(ctx context.Context, gvr *client.GVR, path string) (string, error) { meta, err := t.getMeta(ctx, gvr) if err != nil { return "", err } desc, ok := meta.DAO.(dao.Describer) if !ok { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } return desc.ToYAML(path, false) } func (t *Tree) updater(ctx context.Context) { defer slog.Debug("Tree-model canceled", slogs.GVR, t.gvr) rate := initTreeRefreshRate for { select { case <-ctx.Done(): t.root = nil return case <-time.After(rate): rate = t.refreshRate t.refresh(ctx) } } } func (t *Tree) refresh(ctx context.Context) { if !atomic.CompareAndSwapInt32(&t.inUpdate, 0, 1) { slog.Debug("Dropping update...") return } defer atomic.StoreInt32(&t.inUpdate, 0) if err := t.reconcile(ctx); err != nil { slog.Error("Reconcile failed", slogs.Error, err) t.fireTreeLoadFailed(err) return } } func (t *Tree) list(ctx context.Context, a dao.Accessor) ([]runtime.Object, error) { factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return nil, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) } a.Init(factory, t.gvr) return a.List(ctx, client.CleanseNamespace(t.namespace)) } func (t *Tree) reconcile(ctx context.Context) error { meta := t.resourceMeta() oo, err := t.list(ctx, meta.DAO) if err != nil { return err } ns := client.CleanseNamespace(t.namespace) root := xray.NewTreeNode(t.gvr, t.gvr.R()) ctx = context.WithValue(ctx, xray.KeyParent, root) if _, ok := meta.TreeRenderer.(*xray.Generic); ok { table, ok := oo[0].(*metav1.Table) if !ok { return fmt.Errorf("expecting a Table but got %T", oo[0]) } if err := genericTreeHydrate(ctx, ns, table, meta.TreeRenderer); err != nil { return err } } else if err := treeHydrate(ctx, ns, oo, meta.TreeRenderer); err != nil { return err } root.Sort() if t.query != "" { t.root = root.Filter(t.query, rxMatch) } if t.root == nil || t.root.Diff(root) { t.root = root t.fireTreeChanged(t.root) } return nil } func (t *Tree) resourceMeta() ResourceMeta { meta, ok := Registry[t.gvr] if !ok { meta = ResourceMeta{ DAO: &dao.Table{}, Renderer: &render.Table{}, } } if meta.DAO == nil { meta.DAO = &dao.Resource{} } return meta } func (t *Tree) fireTreeChanged(root *xray.TreeNode) { for _, l := range t.listeners { l.TreeChanged(root) } } func (t *Tree) fireTreeLoadFailed(err error) { for _, l := range t.listeners { l.TreeLoadFailed(err) } } func (t *Tree) getMeta(ctx context.Context, gvr *client.GVR) (ResourceMeta, error) { meta := t.resourceMeta() factory, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return ResourceMeta{}, fmt.Errorf("expected Factory in context but got %T", ctx.Value(internal.KeyFactory)) } meta.DAO.Init(factory, gvr) return meta, nil } // ---------------------------------------------------------------------------- // Helpers... func rxMatch(q, path string) bool { rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, "::") for _, t := range tokens { if rx.MatchString(t) { return true } } return false } func treeHydrate(ctx context.Context, ns string, oo []runtime.Object, re TreeRenderer) error { if re == nil { return fmt.Errorf("no tree renderer defined for this resource") } pool := internal.NewWorkerPool(ctx, internal.DefaultPoolSize) for _, o := range oo { pool.Add(func(_ context.Context) error { return re.Render(ctx, ns, o) }) } errs := pool.Drain() if len(errs) > 0 { return errs[0] } return nil } func genericTreeHydrate(ctx context.Context, ns string, table *metav1.Table, re TreeRenderer) error { tre, ok := re.(*xray.Generic) if !ok { return fmt.Errorf("expecting xray.Generic renderer but got %T", re) } tre.SetTable(ns, table) // BOZO!! Need table row sorter!! for _, row := range table.Rows { if err := tre.Render(ctx, ns, row); err != nil { return err } } return nil } ================================================ FILE: internal/model/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) const ( maxReaderRetryInterval = 2 * time.Minute defaultReaderRefreshRate = 5 * time.Second ) // ResourceViewerListener listens to viewing resource events. type ResourceViewerListener interface { ResourceChanged(lines []string, matches fuzzy.Matches) ResourceFailed(error) } // ViewerToggleOpts represents a collection of viewing options. type ViewerToggleOpts map[string]bool // ResourceViewer represents a viewed resource. type ResourceViewer interface { GetPath() string Filter(string) GVR() *client.GVR ClearFilter() Peek() []string SetOptions(context.Context, ViewerToggleOpts) Watch(context.Context) error Refresh(context.Context) error AddListener(ResourceViewerListener) RemoveListener(ResourceViewerListener) } // EncDecResourceViewer interface extends the ResourceViewer interface and // adds a `Toggle` that allows the user to switch between encoded or decoded // state of the view. type EncDecResourceViewer interface { ResourceViewer Toggle() } // Igniter represents a runnable view. type Igniter interface { // Start starts a component. Init(ctx context.Context) error // Start starts a component. Start() // Stop terminates a component. Stop() } // Hinter represent a menu mnemonic provider. type Hinter interface { // Hints returns a collection of menu hints. Hints() MenuHints // ExtraHints returns additional hints. ExtraHints() map[string]string } // Primitive represents a UI primitive. type Primitive interface { tview.Primitive // Name returns the view name. Name() string } // Commander tracks prompt status. type Commander interface { // InCmdMode checks if prompt is active. InCmdMode() bool } // Component represents a ui component. type Component interface { Primitive Igniter Hinter Commander Filterer Viewer } // Viewer represents a resource viewer. type Viewer interface { // SetCommand sets the current command. SetCommand(*cmd.Interpreter) } // Filterer represents a filterable component. type Filterer interface { // SetFilter sets the filter text. SetFilter(string, bool) // SetLabelSelector sets the label selector. SetLabelSelector(labels.Selector, bool) } // Cruder performs crud operations. type Cruder interface { // List returns a collection of resources. List(ctx context.Context, ns string) ([]runtime.Object, error) // Get returns a resource instance. Get(ctx context.Context, path string) (runtime.Object, error) } // Lister represents a resource lister. type Lister interface { Cruder // Init initializes a resource. Init(ns, gvr string, f dao.Factory) } // Describer represents a resource describer. type Describer interface { // ToYAML return resource yaml. ToYAML(ctx context.Context, path string) (string, error) // Describe returns a resource description. Describe(client client.Connection, gvr, path string) (string, error) } // TreeRenderer represents an xray node. type TreeRenderer interface { Render(ctx context.Context, ns string, o any) error } // ResourceMeta represents model info about a resource. type ResourceMeta struct { DAO dao.Accessor Renderer model1.Renderer TreeRenderer TreeRenderer } ================================================ FILE: internal/model/values.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "strings" "sync/atomic" "time" backoff "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/sahilm/fuzzy" ) // Values tracks Helm values representations. type Values struct { factory dao.Factory gvr *client.GVR inUpdate int32 path string query string lines []string allValues bool listeners []ResourceViewerListener options ViewerToggleOpts } // NewValues return a new Helm values resource model. func NewValues(gvr *client.GVR, path string) *Values { return &Values{ gvr: gvr, path: path, allValues: false, } } // Init initializes the model. func (v *Values) Init(f dao.Factory) error { v.factory = f var err error v.lines, err = v.getValues() return err } func (v *Values) getValues() ([]string, error) { accessor, err := dao.AccessorFor(v.factory, v.gvr) if err != nil { return nil, err } valuer, ok := accessor.(dao.Valuer) if !ok { return nil, fmt.Errorf("resource %s is not Valuer", v.gvr) } values, err := valuer.GetValues(v.path, v.allValues) if err != nil { return nil, err } return strings.Split(string(values), "\n"), nil } // GVR returns the resource gvr. func (v *Values) GVR() *client.GVR { return v.gvr } // ToggleValues toggles between user supplied values and computed values. func (v *Values) ToggleValues() error { v.allValues = !v.allValues lines, err := v.getValues() if err != nil { return err } v.lines = lines return nil } // GetPath returns the active resource path. func (v *Values) GetPath() string { return v.path } // SetOptions toggle model options. func (v *Values) SetOptions(ctx context.Context, opts ViewerToggleOpts) { v.options = opts if err := v.refresh(ctx); err != nil { v.fireResourceFailed(err) } } // Filter filters the model. func (v *Values) Filter(q string) { v.query = q v.filterChanged(v.lines) } func (v *Values) filterChanged(lines []string) { v.fireResourceChanged(lines, v.filter(v.query, lines)) } func (v *Values) filter(q string, lines []string) fuzzy.Matches { if q == "" { return nil } if f, ok := internal.IsFuzzySelector(q); ok { return v.fuzzyFilter(strings.TrimSpace(f), lines) } return rxFilter(q, lines) } func (*Values) fuzzyFilter(q string, lines []string) fuzzy.Matches { return fuzzy.Find(q, lines) } func (v *Values) fireResourceChanged(lines []string, matches fuzzy.Matches) { for _, l := range v.listeners { l.ResourceChanged(lines, matches) } } func (v *Values) fireResourceFailed(err error) { for _, l := range v.listeners { l.ResourceFailed(err) } } // ClearFilter clear out the filter. func (v *Values) ClearFilter() { v.query = "" } // Peek returns the current model data. func (v *Values) Peek() []string { return v.lines } // Refresh updates model data. func (v *Values) Refresh(ctx context.Context) error { return v.refresh(ctx) } // Watch watches for Values changes. func (v *Values) Watch(ctx context.Context) error { if err := v.refresh(ctx); err != nil { return err } go v.updater(ctx) return nil } func (v *Values) updater(ctx context.Context) { defer slog.Debug("YAML canceled", slogs.GVR, v.gvr) backOff := NewExpBackOff(ctx, defaultReaderRefreshRate, maxReaderRetryInterval) delay := defaultReaderRefreshRate for { select { case <-ctx.Done(): return case <-time.After(delay): if err := v.refresh(ctx); err != nil { v.fireResourceFailed(err) if delay = backOff.NextBackOff(); delay == backoff.Stop { slog.Error("Giving up retrieving chart values", slogs.Error, err) return } } else { backOff.Reset() delay = defaultReaderRefreshRate } } } } func (v *Values) refresh(context.Context) error { if !atomic.CompareAndSwapInt32(&v.inUpdate, 0, 1) { slog.Debug("Dropping update...") return fmt.Errorf("reconcile in progress. Dropping update") } defer atomic.StoreInt32(&v.inUpdate, 0) v.reconcile() return nil } func (v *Values) reconcile() { v.fireResourceChanged(v.lines, v.filter(v.query, v.lines)) } // AddListener adds a new model listener. func (v *Values) AddListener(l ResourceViewerListener) { v.listeners = append(v.listeners, l) } // RemoveListener delete a listener from the list. func (v *Values) RemoveListener(l ResourceViewerListener) { victim := -1 for i, lis := range v.listeners { if lis == l { victim = i break } } if victim >= 0 { v.listeners = append(v.listeners[:victim], v.listeners[victim+1:]...) } } ================================================ FILE: internal/model/yaml.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model import ( "context" "fmt" "log/slog" "reflect" "strings" "sync/atomic" "time" backoff "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/sahilm/fuzzy" ) // ManagedFieldsOpts tracks managed fields. const ManagedFieldsOpts = "ManagedFields" // YAML tracks yaml resource representations. type YAML struct { gvr *client.GVR inUpdate int32 path string query string lines []string listeners []ResourceViewerListener options ViewerToggleOpts decode bool } // NewYAML return a new yaml resource model. func NewYAML(gvr *client.GVR, path string) *YAML { return &YAML{ gvr: gvr, path: path, } } // GVR returns the resource gvr. func (y *YAML) GVR() *client.GVR { return y.gvr } // GetPath returns the active resource path. func (y *YAML) GetPath() string { return y.path } // SetOptions toggle model options. func (y *YAML) SetOptions(ctx context.Context, opts ViewerToggleOpts) { y.options = opts if err := y.refresh(ctx); err != nil { y.fireResourceFailed(err) } } // Filter filters the model. func (y *YAML) Filter(q string) { y.query = q y.filterChanged(y.lines) } func (y *YAML) filterChanged(lines []string) { y.fireResourceChanged(lines, y.filter(y.query, lines)) } func (y *YAML) filter(q string, lines []string) fuzzy.Matches { if q == "" { return nil } if f, ok := internal.IsFuzzySelector(q); ok { return y.fuzzyFilter(strings.TrimSpace(f), lines) } return rxFilter(q, lines) } func (*YAML) fuzzyFilter(q string, lines []string) fuzzy.Matches { return fuzzy.Find(q, lines) } func (y *YAML) fireResourceChanged(lines []string, matches fuzzy.Matches) { for _, l := range y.listeners { l.ResourceChanged(lines, matches) } } func (y *YAML) fireResourceFailed(err error) { for _, l := range y.listeners { l.ResourceFailed(err) } } // ClearFilter clear out the filter. func (y *YAML) ClearFilter() { y.query = "" } // Peek returns the current model data. func (y *YAML) Peek() []string { return y.lines } // Refresh updates model data. func (y *YAML) Refresh(ctx context.Context) error { return y.refresh(ctx) } // Watch watches for YAML changes. func (y *YAML) Watch(ctx context.Context) error { if err := y.refresh(ctx); err != nil { return err } go y.updater(ctx) return nil } func (y *YAML) updater(ctx context.Context) { defer slog.Debug("YAML canceled", slogs.GVR, y.gvr) backOff := NewExpBackOff(ctx, defaultReaderRefreshRate, maxReaderRetryInterval) delay := defaultReaderRefreshRate for { select { case <-ctx.Done(): return case <-time.After(delay): if err := y.refresh(ctx); err != nil { y.fireResourceFailed(err) if delay = backOff.NextBackOff(); delay == backoff.Stop { slog.Error("YAML gave up!", slogs.Error, err) return } } else { backOff.Reset() delay = defaultReaderRefreshRate } } } } func (y *YAML) refresh(ctx context.Context) error { if !atomic.CompareAndSwapInt32(&y.inUpdate, 0, 1) { slog.Debug("Dropping update...", slogs.GVR, y.gvr) return nil } defer atomic.StoreInt32(&y.inUpdate, 0) if err := y.reconcile(ctx); err != nil { return err } return nil } func (y *YAML) reconcile(ctx context.Context) error { s, err := y.ToYAML(ctx, y.gvr, y.path, y.options[ManagedFieldsOpts]) if err != nil { return err } lines := strings.Split(s, "\n") if reflect.DeepEqual(lines, y.lines) { return nil } y.lines = lines y.fireResourceChanged(y.lines, y.filter(y.query, y.lines)) return nil } // AddListener adds a new model listener. func (y *YAML) AddListener(l ResourceViewerListener) { y.listeners = append(y.listeners, l) } // RemoveListener delete a listener from the list. func (y *YAML) RemoveListener(l ResourceViewerListener) { victim := -1 for i, lis := range y.listeners { if lis == l { victim = i break } } if victim >= 0 { y.listeners = append(y.listeners[:victim], y.listeners[victim+1:]...) } } // ToYAML returns a resource yaml. func (y *YAML) ToYAML(ctx context.Context, gvr *client.GVR, path string, showManaged bool) (string, error) { meta, err := getMeta(ctx, gvr) if err != nil { return "", err } desc, ok := meta.DAO.(dao.Describer) if !ok { return "", fmt.Errorf("no describer for %q", meta.DAO.GVR()) } if desc, ok := meta.DAO.(*dao.Secret); ok { desc.SetDecodeData(y.decode) } return desc.ToYAML(path, showManaged) } // Toggle toggles the decode flag. func (y *YAML) Toggle() { y.decode = !y.decode } ================================================ FILE: internal/model1/color.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import "github.com/derailed/tcell/v2" var ( // ModColor row modified color. ModColor tcell.Color // AddColor row added color. AddColor tcell.Color // PendingColor row added color. PendingColor tcell.Color // ErrColor row err color. ErrColor tcell.Color // StdColor row default color. StdColor tcell.Color // HighlightColor row highlight color. HighlightColor tcell.Color // KillColor row deleted color. KillColor tcell.Color // CompletedColor row completed color. CompletedColor tcell.Color ) // DefaultColorer set the default table row colors. func DefaultColorer(ns string, h Header, re *RowEvent) tcell.Color { if !IsValid(ns, h, re.Row) { return ErrColor } //nolint:exhaustive switch re.Kind { case EventAdd: return AddColor case EventUpdate: return ModColor case EventDelete: return KillColor default: return StdColor } } ================================================ FILE: internal/model1/color_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" ) func TestDefaultColorer(t *testing.T) { uu := map[string]struct { re model1.RowEvent e tcell.Color }{ "add": { model1.RowEvent{ Kind: model1.EventAdd, }, model1.AddColor, }, "update": { model1.RowEvent{ Kind: model1.EventUpdate, }, model1.ModColor, }, "delete": { model1.RowEvent{ Kind: model1.EventDelete, }, model1.KillColor, }, "no-change": { model1.RowEvent{ Kind: model1.EventUnchanged, }, model1.StdColor, }, "invalid": { model1.RowEvent{ Kind: model1.EventUnchanged, Row: model1.Row{ Fields: model1.Fields{"", "", "blah"}, }, }, model1.ErrColor, }, } h := model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "VALID"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, model1.DefaultColorer("", h, &u.re)) }) } } ================================================ FILE: internal/model1/delta.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import "reflect" // DeltaRow represents a collection of row deltas between old and new row. type DeltaRow []string // NewDeltaRow computes the delta between 2 rows. func NewDeltaRow(o, n Row, h Header) DeltaRow { deltas := make(DeltaRow, len(o.Fields)) for i, old := range o.Fields { if i >= len(n.Fields) { continue } if old != "" && old != n.Fields[i] && !h.IsTimeCol(i) { deltas[i] = old } } return deltas } // Labelize returns a new deltaRow based on labels. func (d DeltaRow) Labelize(cols []int, labelCol int) DeltaRow { if len(d) == 0 { return d } _, vals := sortLabels(labelize(d[labelCol])) out := make(DeltaRow, 0, len(cols)+len(vals)) for _, i := range cols { out = append(out, d[i]) } for _, v := range vals { out = append(out, v) } return out } // Diff returns true if deltas differ or false otherwise. func (d DeltaRow) Diff(r DeltaRow, ageCol int) bool { if len(d) != len(r) { return true } if ageCol < 0 || ageCol >= len(d) { return !reflect.DeepEqual(d, r) } if !reflect.DeepEqual(d[:ageCol], r[:ageCol]) { return true } if ageCol+1 >= len(d) { return false } return !reflect.DeepEqual(d[ageCol+1:], r[ageCol+1:]) } // Customize returns a subset of deltas. func (d DeltaRow) Customize(cols []int, out DeltaRow) { if d.IsBlank() { return } for i, c := range cols { if c < 0 { continue } if c < len(d) && i < len(out) { out[i] = d[c] } } } // IsBlank asserts a row has no values in it. func (d DeltaRow) IsBlank() bool { if len(d) == 0 { return true } for _, v := range d { if v != "" { return false } } return true } // Clone returns a delta copy. func (d DeltaRow) Clone() DeltaRow { res := make(DeltaRow, len(d)) copy(res, d) return res } ================================================ FILE: internal/model1/delta_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" ) func TestDeltaLabelize(t *testing.T) { uu := map[string]struct { o model1.Row n model1.Row e model1.DeltaRow }{ "same": { o: model1.Row{ Fields: model1.Fields{"a", "b", "blee=fred,doh=zorg"}, }, n: model1.Row{ Fields: model1.Fields{"a", "b", "blee=fred1,doh=zorg"}, }, e: model1.DeltaRow{"", "", "fred", "zorg"}, }, } hh := model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { d := model1.NewDeltaRow(u.o, u.n, hh) d = d.Labelize([]int{0, 1}, 2) assert.Equal(t, u.e, d) }) } } func TestDeltaCustomize(t *testing.T) { uu := map[string]struct { r1, r2 model1.Row cols []int e model1.DeltaRow }{ "same": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, cols: []int{0, 1, 2}, e: model1.DeltaRow{"", "", ""}, }, "empty": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, e: model1.DeltaRow{}, }, "diff-full": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a1", "b1", "c1"}, }, cols: []int{0, 1, 2}, e: model1.DeltaRow{"a", "b", "c"}, }, "diff-reverse": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a1", "b1", "c1"}, }, cols: []int{2, 1, 0}, e: model1.DeltaRow{"c", "b", "a"}, }, "diff-skip": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a1", "b1", "c1"}, }, cols: []int{2, 0}, e: model1.DeltaRow{"c", "a"}, }, "diff-missing": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a1", "b1", "c1"}, }, cols: []int{2, 10, 0}, e: model1.DeltaRow{"c", "", "a"}, }, "diff-negative": { r1: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, r2: model1.Row{ Fields: model1.Fields{"a1", "b1", "c1"}, }, cols: []int{2, -1, 0}, e: model1.DeltaRow{"c", "", "a"}, }, } hh := model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { d := model1.NewDeltaRow(u.r1, u.r2, hh) out := make(model1.DeltaRow, len(u.cols)) d.Customize(u.cols, out) assert.Equal(t, u.e, out) }) } } func TestDeltaNew(t *testing.T) { uu := map[string]struct { o model1.Row n model1.Row blank bool e model1.DeltaRow }{ "same": { o: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, n: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, blank: true, e: model1.DeltaRow{"", "", ""}, }, "diff": { o: model1.Row{ Fields: model1.Fields{"a1", "b", "c"}, }, n: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, e: model1.DeltaRow{"a1", "", ""}, }, "diff2": { o: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, n: model1.Row{ Fields: model1.Fields{"a", "b1", "c"}, }, e: model1.DeltaRow{"", "b", ""}, }, "diffLast": { o: model1.Row{ Fields: model1.Fields{"a", "b", "c"}, }, n: model1.Row{ Fields: model1.Fields{"a", "b", "c1"}, }, e: model1.DeltaRow{"", "", "c"}, }, } hh := model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { d := model1.NewDeltaRow(u.o, u.n, hh) assert.Equal(t, u.e, d) assert.Equal(t, u.blank, d.IsBlank()) }) } } func TestDeltaBlank(t *testing.T) { uu := map[string]struct { r model1.DeltaRow e bool }{ "empty": { r: model1.DeltaRow{}, e: true, }, "blank": { r: model1.DeltaRow{"", "", ""}, e: true, }, "notblank": { r: model1.DeltaRow{"", "", "z"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.r.IsBlank()) }) } } func TestDeltaDiff(t *testing.T) { uu := map[string]struct { d1, d2 model1.DeltaRow ageCol int e bool }{ "empty": { d1: model1.DeltaRow{"f1", "f2", "f3"}, ageCol: 2, e: true, }, "same": { d1: model1.DeltaRow{"f1", "f2", "f3"}, d2: model1.DeltaRow{"f1", "f2", "f3"}, ageCol: -1, }, "diff": { d1: model1.DeltaRow{"f1", "f2", "f3"}, d2: model1.DeltaRow{"f1", "f2", "f13"}, ageCol: -1, e: true, }, "diff-age-first": { d1: model1.DeltaRow{"f1", "f2", "f3"}, d2: model1.DeltaRow{"f1", "f2", "f13"}, ageCol: 0, e: true, }, "diff-age-last": { d1: model1.DeltaRow{"f1", "f2", "f3"}, d2: model1.DeltaRow{"f1", "f2", "f13"}, ageCol: 2, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.d1.Diff(u.d2, u.ageCol)) }) } } ================================================ FILE: internal/model1/fields.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import "reflect" // Fields represents a collection of row fields. type Fields []string // Customize returns a subset of fields. func (f Fields) Customize(cols []int, out Fields) { for i, c := range cols { if c < 0 { out[i] = NAValue continue } if c < len(f) { out[i] = f[c] } } } // Diff returns true if fields differ or false otherwise. func (f Fields) Diff(ff Fields, ageCol int) bool { if ageCol < 0 { return !reflect.DeepEqual(f[:len(f)-1], ff[:len(ff)-1]) } if !reflect.DeepEqual(f[:ageCol], ff[:ageCol]) { return true } return !reflect.DeepEqual(f[ageCol+1:], ff[ageCol+1:]) } // Clone returns a copy of the fields. func (f Fields) Clone() Fields { cp := make(Fields, len(f)) copy(cp, f) return cp } ================================================ FILE: internal/model1/header.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "fmt" "log/slog" "reflect" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/util/sets" ) const ageCol = "AGE" type Attrs struct { Align int Decorator DecoratorFunc Wide bool Show bool MX bool MXC, MXM bool Time bool Capacity bool VS bool Hide bool } func (a Attrs) Merge(b Attrs) Attrs { a.MX = b.MX a.MXC = b.MXC a.MXM = b.MXM a.Decorator = b.Decorator a.VS = b.VS if a.Align == 0 { a.Align = b.Align } if !a.Hide { a.Hide = b.Hide } if !a.Show && !a.Wide { a.Wide = b.Wide } if !a.Time { a.Time = b.Time } if !a.Capacity { a.Capacity = b.Capacity } return a } // HeaderColumn represent a table header. type HeaderColumn struct { Attrs Name string } func (h HeaderColumn) String() string { return fmt.Sprintf("%s [%d::%t::%t::%t]", h.Name, h.Align, h.Wide, h.MX, h.Time) } // Clone copies a header. func (h HeaderColumn) Clone() HeaderColumn { return h } // ---------------------------------------------------------------------------- // Header represents a table header. type Header []HeaderColumn func (h Header) Clear() Header { h = h[:0] return h } // Clone duplicates a header. func (h Header) Clone() Header { he := make(Header, 0, len(h)) for _, h := range h { he = append(he, h.Clone()) } return he } // Labelize returns a new Header based on labels. func (h Header) Labelize(cols []int, labelCol int, rr *RowEvents) Header { header := make(Header, 0, len(cols)+1) for _, c := range cols { header = append(header, h[c]) } cc := rr.ExtractHeaderLabels(labelCol) for _, c := range cc { header = append(header, HeaderColumn{Name: c}) } return header } // MapIndices returns a collection of mapped column indices based of the requested columns. func (h Header) MapIndices(cols []string, wide bool) []int { ii := make([]int, 0, len(cols)) cc := make(map[int]struct{}, len(cols)) for _, col := range cols { idx, ok := h.IndexOf(col, true) if !ok { slog.Warn("Column not found on resource", slogs.ColName, col) } ii, cc[idx] = append(ii, idx), struct{}{} } if !wide { return ii } for i := range h { if _, ok := cc[i]; ok { continue } ii = append(ii, i) } return ii } // Customize builds a header from custom col definitions. func (h Header) Customize(cols []string, wide bool) Header { if len(cols) == 0 { return h } cc := make(Header, 0, len(h)) xx := make(map[int]struct{}, len(h)) for _, c := range cols { idx, ok := h.IndexOf(c, true) if !ok { slog.Warn("Column is not available on this resource", slogs.ColName, c) cc = append(cc, HeaderColumn{Name: c}) continue } xx[idx] = struct{}{} col := h[idx].Clone() col.Wide = false cc = append(cc, col) } if !wide { return cc } for i, c := range h { if _, ok := xx[i]; ok { continue } col := c.Clone() col.Wide = true cc = append(cc, col) } return cc } // Diff returns true if the header changed. func (h Header) Diff(header Header) bool { if len(h) != len(header) { return true } return !reflect.DeepEqual(h, header) } // FilterColIndices return viewable col header indices. func (h Header) FilterColIndices(ns string, wide bool) sets.Set[int] { if len(h) == 0 { return nil } nsed := client.IsNamespaced(ns) cc := sets.New[int]() for i, c := range h { if c.Name == "AGE" || !wide && c.Wide || c.Hide || (nsed && c.Name == "NAMESPACE") { continue } cc.Insert(i) } return cc } // ColumnNames return header col names func (h Header) ColumnNames(wide bool) []string { if len(h) == 0 { return nil } cc := make([]string, 0, len(h)) for _, c := range h { if !wide && c.Wide { continue } cc = append(cc, c.Name) } return cc } // HasAge returns true if table has an age column. func (h Header) HasAge() bool { _, ok := h.IndexOf(ageCol, true) return ok } // IsMetricsCol checks if given column index represents metrics. func (h Header) IsMetricsCol(col int) bool { if col < 0 || col >= len(h) { return false } return h[col].MX } // IsTimeCol checks if given column index represents a timestamp. func (h Header) IsTimeCol(col int) bool { if col < 0 || col >= len(h) { return false } return h[col].Time } // IsCapacityCol checks if given column index represents a capacity. func (h Header) IsCapacityCol(col int) bool { if col < 0 || col >= len(h) { return false } return h[col].Capacity } // IndexOf returns the col index or -1 if none. func (h Header) IndexOf(colName string, includeWide bool) (int, bool) { for i, c := range h { if c.Wide && !includeWide { continue } if c.Name == colName { return i, true } } return -1, false } // Dump for debugging. func (h Header) Dump() { slog.Debug("HEADER") for i, c := range h { slog.Debug(fmt.Sprintf("%d %q -- %t", i, c.Name, c.Wide)) } } ================================================ FILE: internal/model1/header_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" ) func TestHeaderMapIndices(t *testing.T) { uu := map[string]struct { h1 model1.Header cols []string wide bool e []int }{ "all": { h1: makeHeader(), cols: []string{"A", "B", "C"}, e: []int{0, 1, 2}, }, "reverse": { h1: makeHeader(), cols: []string{"C", "B", "A"}, e: []int{2, 1, 0}, }, "missing": { h1: makeHeader(), cols: []string{"Duh", "B", "A"}, e: []int{-1, 1, 0}, }, "skip": { h1: makeHeader(), cols: []string{"C", "A"}, e: []int{2, 0}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ii := u.h1.MapIndices(u.cols, u.wide) assert.Equal(t, u.e, ii) }) } } func TestHeaderIndexOf(t *testing.T) { uu := map[string]struct { h model1.Header name string wide, ok bool e int }{ "shown": { h: makeHeader(), name: "A", e: 0, ok: true, }, "hidden": { h: makeHeader(), name: "B", e: -1, }, "hidden-wide": { h: makeHeader(), name: "B", wide: true, e: 1, ok: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { idx, ok := u.h.IndexOf(u.name, u.wide) assert.Equal(t, u.ok, ok) assert.Equal(t, u.e, idx) }) } } func TestHeaderCustomize(t *testing.T) { uu := map[string]struct { h model1.Header cols []string wide bool e model1.Header }{ "default": { h: makeHeader(), e: makeHeader(), }, "default-wide": { h: makeHeader(), wide: true, e: makeHeader(), }, "reverse": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "A"}, e: model1.Header{ model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "A"}, }, }, "reverse-wide": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "A"}, wide: true, e: model1.Header{ model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, }, }, "toggle-wide": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"C", "B"}, wide: true, e: model1.Header{ model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: false}}, model1.HeaderColumn{Name: "A", Attrs: model1.Attrs{Wide: true}}, }, }, "missing": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, cols: []string{"BLEE", "A"}, wide: true, e: model1.Header{ model1.HeaderColumn{Name: "BLEE"}, model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C", Attrs: model1.Attrs{Wide: true}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.h.Customize(u.cols, u.wide)) }) } } func TestHeaderDiff(t *testing.T) { uu := map[string]struct { h1, h2 model1.Header e bool }{ "same": { h1: makeHeader(), h2: makeHeader(), }, "size": { h1: makeHeader(), h2: makeHeader()[1:], e: true, }, "differ-wide": { h1: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, h2: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, e: true, }, "differ-order": { h1: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, }, h2: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, }, e: true, }, "differ-name": { h1: model1.Header{ model1.HeaderColumn{Name: "A"}, }, h2: model1.Header{ model1.HeaderColumn{Name: "B"}, }, e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.h1.Diff(u.h2)) }) } } func TestHeaderHasAge(t *testing.T) { uu := map[string]struct { h model1.Header age, e bool }{ "no-age": { h: model1.Header{}, }, "age": { h: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, e: true, age: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.h.HasAge()) assert.Equal(t, u.e, u.h.IsTimeCol(2)) }) } } func TestHeaderColumns(t *testing.T) { uu := map[string]struct { h model1.Header wide bool e []string }{ "empty": { h: model1.Header{}, }, "regular": { h: makeHeader(), e: []string{"A", "C"}, }, "wide": { h: makeHeader(), e: []string{"A", "B", "C"}, wide: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.h.ColumnNames(u.wide)) }) } } func TestHeaderClone(t *testing.T) { uu := map[string]struct { h model1.Header }{ "empty": { h: model1.Header{}, }, "full": { h: makeHeader(), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := u.h.Clone() assert.Len(t, u.h, len(c)) if len(u.h) > 0 { u.h[0].Name = "blee" assert.Equal(t, "A", c[0].Name) } }) } } // ---------------------------------------------------------------------------- // Helpers... func makeHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "C"}, } } ================================================ FILE: internal/model1/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "context" "fmt" "log/slog" "math" "sort" "strings" "github.com/fvbommel/sortorder" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) const poolSize = 10 func Hydrate(ns string, oo []runtime.Object, rr Rows, re Renderer) error { pool := NewWorkerPool(context.Background(), poolSize) for i, o := range oo { pool.Add(func(ctx context.Context) error { select { case <-ctx.Done(): slog.Debug("Worker canceled") return nil default: return re.Render(o, ns, &rr[i]) } }) } errs := pool.Drain() if len(errs) > 0 { return errs[0] } return nil } func GenericHydrate(ns string, table *metav1.Table, rr Rows, re Renderer) error { gr, ok := re.(Generic) if !ok { return fmt.Errorf("expecting generic renderer but got %T", re) } gr.SetTable(ns, table) pool := NewWorkerPool(context.Background(), poolSize) for i, row := range table.Rows { pool.Add(func(ctx context.Context) error { select { case <-ctx.Done(): slog.Debug("Worker canceled") return nil default: return gr.Render(row, ns, &rr[i]) } }) } errs := pool.Drain() if len(errs) > 0 { return errs[0] } return nil } // IsValid returns true if resource is valid, false otherwise. func IsValid(_ string, h Header, r Row) bool { if len(r.Fields) == 0 { return true } idx, ok := h.IndexOf("VALID", true) if !ok || idx >= len(r.Fields) { return true } return strings.TrimSpace(r.Fields[idx]) == "" || strings.ToLower(strings.TrimSpace(r.Fields[idx])) == "true" } func sortLabels(m map[string]string) (keys, vals []string) { for k := range m { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { vals = append(vals, m[k]) } return } // Converts labels string to map. func labelize(labels string) map[string]string { ll := strings.Split(labels, ",") data := make(map[string]string, len(ll)) for _, l := range ll { tokens := strings.Split(l, "=") if len(tokens) == 2 { data[tokens[0]] = tokens[1] } } return data } func durationToSeconds(duration string) int64 { if duration == "" { return 0 } if duration == NAValue { return math.MaxInt64 } num := make([]rune, 0, 5) var n, m int64 for _, r := range duration { switch r { case 'y': m = 365 * 24 * 60 * 60 case 'd': m = 24 * 60 * 60 case 'h': m = 60 * 60 case 'm': m = 60 case 's': m = 1 default: num = append(num, r) continue } n, num = n+runesToNum(num)*m, num[:0] } return n } func runesToNum(rr []rune) int64 { var r int64 var m int64 = 1 for i := len(rr) - 1; i >= 0; i-- { v := int64(rr[i] - '0') r += v * m m *= 10 } return r } func capacityToNumber(capacity string) int64 { if strings.TrimSpace(capacity) == "" { return 0 } quantity := resource.MustParse(capacity) return quantity.Value() } // Less return true if c1 <= c2. func Less(isNumber, isDuration, isCapacity bool, id1, id2, v1, v2 string) bool { var less bool switch { case isNumber: less = lessNumber(v1, v2) case isDuration: less = lessDuration(v1, v2) case isCapacity: less = lessCapacity(v1, v2) default: less = sortorder.NaturalLess(v1, v2) } if v1 == v2 { return sortorder.NaturalLess(id1, id2) } return less } func lessDuration(s1, s2 string) bool { d1, d2 := durationToSeconds(s1), durationToSeconds(s2) return d1 <= d2 } func lessCapacity(s1, s2 string) bool { c1, c2 := capacityToNumber(s1), capacityToNumber(s2) return c1 <= c2 } func lessNumber(s1, s2 string) bool { v1, v2 := strings.ReplaceAll(s1, ",", ""), strings.ReplaceAll(s2, ",", "") return sortorder.NaturalLess(v1, v2) } ================================================ FILE: internal/model1/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "math" "testing" "github.com/stretchr/testify/assert" ) func TestSortLabels(t *testing.T) { uu := map[string]struct { labels string e [][]string }{ "simple": { labels: "a=b,c=d", e: [][]string{ {"a", "c"}, {"b", "d"}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { hh, vv := sortLabels(labelize(u.labels)) assert.Equal(t, u.e[0], hh) assert.Equal(t, u.e[1], vv) }) } } func TestLabelize(t *testing.T) { uu := map[string]struct { labels string e map[string]string }{ "simple": { labels: "a=b,c=d", e: map[string]string{"a": "b", "c": "d"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, labelize(u.labels)) }) } } func TestIsValid(t *testing.T) { uu := map[string]struct { ns string h Header r Row e bool }{ "empty": { ns: "blee", h: Header{}, r: Row{}, e: true, }, "valid": { ns: "blee", h: Header{HeaderColumn{Name: "VALID"}}, r: Row{Fields: Fields{"true"}}, e: true, }, "invalid": { ns: "blee", h: Header{HeaderColumn{Name: "VALID"}}, r: Row{Fields: Fields{"false"}}, e: false, }, "valid_capital_case": { ns: "blee", h: Header{HeaderColumn{Name: "VALID"}}, r: Row{Fields: Fields{"True"}}, e: true, }, "valid_all_caps": { ns: "blee", h: Header{HeaderColumn{Name: "VALID"}}, r: Row{Fields: Fields{"TRUE"}}, e: true, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { valid := IsValid(u.ns, u.h, u.r) assert.Equal(t, u.e, valid) }) } } func TestDurationToSecond(t *testing.T) { uu := map[string]struct { s string e int64 }{ "seconds": {s: "22s", e: 22}, "minutes": {s: "22m", e: 1320}, "hours": {s: "12h", e: 43200}, "days": {s: "3d", e: 259200}, "day_hour": {s: "3d9h", e: 291600}, "day_hour_minute": {s: "2d22h3m", e: 252180}, "day_hour_minute_seconds": {s: "2d22h3m50s", e: 252230}, "year": {s: "3y", e: 94608000}, "year_day": {s: "1y2d", e: 31708800}, "n/a": {s: NAValue, e: math.MaxInt64}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, durationToSeconds(u.s)) }) } } func TestCapacityToNumber(t *testing.T) { uu := map[string]struct { s string e int64 }{ "empty": {s: "", e: 0}, "blank": {s: " ", e: 0}, "1Gi": {s: "1Gi", e: 1073741824}, "10Mi": {s: "10Mi", e: 10485760}, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, capacityToNumber(u.s)) }) } } func BenchmarkDurationToSecond(b *testing.B) { t := "2d22h3m50s" b.ReportAllocs() b.ResetTimer() for range b.N { durationToSeconds(t) } } ================================================ FILE: internal/model1/pool.go ================================================ package model1 import ( "context" "log/slog" "sync" "github.com/derailed/k9s/internal/slogs" ) type jobFn func(ctx context.Context) error type WorkerPool struct { semC chan struct{} errC chan error ctx context.Context cancelFn context.CancelFunc mx sync.RWMutex wg sync.WaitGroup wge sync.WaitGroup errs []error } func NewWorkerPool(ctx context.Context, size int) *WorkerPool { _, cancelFn := context.WithCancel(ctx) p := WorkerPool{ semC: make(chan struct{}, size), errC: make(chan error, 1), cancelFn: cancelFn, ctx: ctx, } p.wge.Add(1) go func(wg *sync.WaitGroup) { defer wg.Done() for err := range p.errC { if err != nil { p.mx.Lock() p.errs = append(p.errs, err) p.mx.Unlock() } } }(&p.wge) return &p } func (p *WorkerPool) Add(job jobFn) { p.semC <- struct{}{} p.wg.Add(1) go func(ctx context.Context, wg *sync.WaitGroup, semC <-chan struct{}, errC chan<- error) { defer func() { <-semC wg.Done() }() if err := job(ctx); err != nil { slog.Error("Worker error", slogs.Error, err) errC <- err } }(p.ctx, &p.wg, p.semC, p.errC) } func (p *WorkerPool) Drain() []error { if p.cancelFn != nil { p.cancelFn() p.cancelFn = nil } p.wg.Wait() close(p.semC) close(p.errC) p.wge.Wait() p.mx.RLock() defer p.mx.RUnlock() return p.errs } ================================================ FILE: internal/model1/pool_test.go ================================================ package model1_test import ( "context" "fmt" "sync/atomic" "testing" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" ) func TestWorkerPoolPlain(t *testing.T) { p := model1.NewWorkerPool(context.Background(), 2) var c atomic.Int32 for range 10 { p.Add(func(ctx context.Context) error { select { case <-ctx.Done(): fmt.Println("Worker canceled") return nil default: c.Add(1) return nil } }) } errs := p.Drain() assert.Equal(t, 10, int(c.Load())) assert.Empty(t, errs) } func TestWorkerPoolWithError(t *testing.T) { ctx := context.Background() p := model1.NewWorkerPool(ctx, 2) var c atomic.Int32 for i := range 10 { p.Add(func(ctx context.Context) error { select { case <-ctx.Done(): fmt.Println("Worker canceled") return nil default: if i%2 == 0 { return fmt.Errorf("BOOM%d", i) } c.Add(1) return nil } }) } errs := p.Drain() assert.Equal(t, 5, int(c.Load())) assert.Len(t, errs, 5) } ================================================ FILE: internal/model1/row.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 // Row represents a collection of columns. type Row struct { ID string Fields Fields } // NewRow returns a new row with initialized fields. func NewRow(size int) Row { return Row{Fields: make([]string, size)} } // Labelize returns a new row based on labels. func (r Row) Labelize(cols []int, labelCol int, labels []string) Row { out := NewRow(len(cols) + len(labels)) for _, col := range cols { out.Fields = append(out.Fields, r.Fields[col]) } m := labelize(r.Fields[labelCol]) for _, label := range labels { out.Fields = append(out.Fields, m[label]) } return out } // Customize returns a row subset based on given col indices. func (r Row) Customize(cols []int) Row { out := NewRow(len(cols)) r.Fields.Customize(cols, out.Fields) out.ID = r.ID return out } // Diff returns true if row differ or false otherwise. func (r Row) Diff(ro Row, ageCol int) bool { if r.ID != ro.ID { return true } return r.Fields.Diff(ro.Fields, ageCol) } // Clone copies a row. func (r Row) Clone() Row { return Row{ ID: r.ID, Fields: r.Fields.Clone(), } } // Len returns the length of the row. func (r Row) Len() int { return len(r.Fields) } // ---------------------------------------------------------------------------- // RowSorter sorts rows. type RowSorter struct { Rows Rows Index int IsNumber bool IsDuration bool IsCapacity bool Asc bool } func (s RowSorter) Len() int { return len(s.Rows) } func (s RowSorter) Swap(i, j int) { s.Rows[i], s.Rows[j] = s.Rows[j], s.Rows[i] } func (s RowSorter) Less(i, j int) bool { v1, v2 := s.Rows[i].Fields[s.Index], s.Rows[j].Fields[s.Index] id1, id2 := s.Rows[i].ID, s.Rows[j].ID less := Less(s.IsNumber, s.IsDuration, s.IsCapacity, id1, id2, v1, v2) if s.Asc { return less } return !less } // ---------------------------------------------------------------------------- // Helpers... ================================================ FILE: internal/model1/row_event.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "fmt" "log/slog" "sort" ) type ReRangeFn func(int, RowEvent) bool // ResEvent represents a resource event. type ResEvent int // RowEvent tracks resource instance events. type RowEvent struct { Kind ResEvent Row Row Deltas DeltaRow } // NewRowEvent returns a new row event. func NewRowEvent(kind ResEvent, row Row) RowEvent { return RowEvent{ Kind: kind, Row: row, } } // NewRowEventWithDeltas returns a new row event with deltas. func NewRowEventWithDeltas(row Row, delta DeltaRow) RowEvent { return RowEvent{ Kind: EventUpdate, Row: row, Deltas: delta, } } // Clone returns a row event deep copy. func (r RowEvent) Clone() RowEvent { return RowEvent{ Kind: r.Kind, Row: r.Row.Clone(), Deltas: r.Deltas.Clone(), } } // Customize returns a new subset based on the given column indices. func (r RowEvent) Customize(cols []int) RowEvent { delta := r.Deltas if !r.Deltas.IsBlank() { delta = make(DeltaRow, len(cols)) r.Deltas.Customize(cols, delta) } return RowEvent{ Kind: r.Kind, Deltas: delta, Row: r.Row.Customize(cols), } } // ExtractHeaderLabels extract collection of fields into header. func (r RowEvent) ExtractHeaderLabels(labelCol int) []string { hh, _ := sortLabels(labelize(r.Row.Fields[labelCol])) return hh } // Labelize returns a new row event based on labels. func (r RowEvent) Labelize(cols []int, labelCol int, labels []string) RowEvent { return RowEvent{ Kind: r.Kind, Deltas: r.Deltas.Labelize(cols, labelCol), Row: r.Row.Labelize(cols, labelCol, labels), } } // Diff returns true if the row changed. func (r RowEvent) Diff(re RowEvent, ageCol int) bool { if r.Kind != re.Kind { return true } if r.Deltas.Diff(re.Deltas, ageCol) { return true } return r.Row.Diff(re.Row, ageCol) } // ---------------------------------------------------------------------------- type reIndex map[string]int // RowEvents a collection of row events. type RowEvents struct { events []RowEvent index reIndex } func NewRowEvents(size int) *RowEvents { return &RowEvents{ events: make([]RowEvent, 0, size), index: make(reIndex, size), } } func NewRowEventsWithEvts(ee ...RowEvent) *RowEvents { re := NewRowEvents(len(ee)) for _, e := range ee { re.Add(e) } return re } func (r *RowEvents) reindex() { for i, e := range r.events { r.index[e.Row.ID] = i } } func (r *RowEvents) At(i int) (RowEvent, bool) { if i < 0 || i > len(r.events) { return RowEvent{}, false } return r.events[i], true } func (r *RowEvents) Set(i int, re RowEvent) { r.events[i] = re r.index[re.Row.ID] = i } func (r *RowEvents) Add(re RowEvent) { r.events = append(r.events, re) r.index[re.Row.ID] = len(r.events) - 1 } // ExtractHeaderLabels extract header labels. func (r *RowEvents) ExtractHeaderLabels(labelCol int) []string { ll := make([]string, 0, 10) for _, re := range r.events { ll = append(ll, re.ExtractHeaderLabels(labelCol)...) } return ll } // Labelize converts labels into a row event. func (r *RowEvents) Labelize(cols []int, labelCol int, labels []string) *RowEvents { out := make([]RowEvent, 0, len(r.events)) for _, re := range r.events { out = append(out, re.Labelize(cols, labelCol, labels)) } return NewRowEventsWithEvts(out...) } // Customize returns custom row events based on columns layout. func (r *RowEvents) Customize(cols []int) *RowEvents { ee := make([]RowEvent, 0, len(cols)) for _, re := range r.events { ee = append(ee, re.Customize(cols)) } return NewRowEventsWithEvts(ee...) } // Diff returns true if the event changed. func (r *RowEvents) Diff(re *RowEvents, ageCol int) bool { if len(r.events) != len(re.events) { return true } for i := range r.events { if r.events[i].Diff(re.events[i], ageCol) { return true } } return false } // Clone returns a deep copy. func (r *RowEvents) Clone() *RowEvents { re := make([]RowEvent, 0, len(r.events)) for _, e := range r.events { re = append(re, e.Clone()) } return NewRowEventsWithEvts(re...) } // Upsert add or update a row if it exists. func (r *RowEvents) Upsert(re RowEvent) { if idx, ok := r.FindIndex(re.Row.ID); ok { r.events[idx] = re } else { r.Add(re) } } // Delete removes an element by id. func (r *RowEvents) Delete(fqn string) error { victim, ok := r.FindIndex(fqn) if !ok { return fmt.Errorf("unable to delete row with fqn: %q", fqn) } r.events = append(r.events[0:victim], r.events[victim+1:]...) delete(r.index, fqn) r.reindex() return nil } func (r *RowEvents) Len() int { return len(r.events) } func (r *RowEvents) Empty() bool { return len(r.events) == 0 } // Clear delete all row events. func (r *RowEvents) Clear() { r.events = r.events[:0] for k := range r.index { delete(r.index, k) } } func (r *RowEvents) Range(f ReRangeFn) { for i, e := range r.events { if !f(i, e) { return } } } func (r *RowEvents) Get(id string) (RowEvent, bool) { i, ok := r.index[id] if !ok { return RowEvent{}, false } return r.At(i) } // FindIndex locates a row index by id. Returns false is not found. func (r *RowEvents) FindIndex(id string) (int, bool) { i, ok := r.index[id] return i, ok } // Sort rows based on column index and order. func (r *RowEvents) Sort(ns string, sortCol int, isDuration, numCol, isCapacity, asc bool) { if sortCol == -1 || r == nil { return } t := RowEventSorter{ NS: ns, Events: r, Index: sortCol, Asc: asc, IsNumber: numCol, IsDuration: isDuration, IsCapacity: isCapacity, } sort.Sort(t) r.reindex() } // For debugging... func (re RowEvents) Dump(msg string) { slog.Debug("[DEBUG] RowEvents" + msg) for _, r := range re.events { slog.Debug(fmt.Sprintf(" %#v", r)) } } // ---------------------------------------------------------------------------- // RowEventSorter sorts row events by a given colon. type RowEventSorter struct { Events *RowEvents Index int NS string IsNumber bool IsDuration bool IsCapacity bool Asc bool } func (r RowEventSorter) Len() int { return len(r.Events.events) } func (r RowEventSorter) Swap(i, j int) { r.Events.events[i], r.Events.events[j] = r.Events.events[j], r.Events.events[i] } func (r RowEventSorter) Less(i, j int) bool { f1, f2 := r.Events.events[i].Row.Fields, r.Events.events[j].Row.Fields id1, id2 := r.Events.events[i].Row.ID, r.Events.events[j].Row.ID less := Less(r.IsNumber, r.IsDuration, r.IsCapacity, id1, id2, f1[r.Index], f2[r.Index]) if r.Asc { return less } return !less } ================================================ FILE: internal/model1/row_event_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "testing" "time" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRowEventCustomize(t *testing.T) { uu := map[string]struct { re1, e model1.RowEvent cols []int }{ "empty": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{}}, }, }, "full": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, cols: []int{0, 1, 2}, }, "deltas": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, Deltas: model1.DeltaRow{"a", "b", "c"}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, Deltas: model1.DeltaRow{"a", "b", "c"}, }, cols: []int{0, 1, 2}, }, "deltas-skip": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, Deltas: model1.DeltaRow{"a", "b", "c"}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "1"}}, Deltas: model1.DeltaRow{"c", "a"}, }, cols: []int{2, 0}, }, "reverse": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "2", "1"}}, }, cols: []int{2, 1, 0}, }, "skip": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "1"}}, }, cols: []int{2, 0}, }, "miss": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, e: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "", "1"}}, }, cols: []int{2, 10, 0}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.re1.Customize(u.cols)) }) } } func TestRowEventDiff(t *testing.T) { uu := map[string]struct { re1, re2 model1.RowEvent e bool }{ "same": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, re2: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, }, "diff-kind": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, re2: model1.RowEvent{ Kind: model1.EventDelete, Row: model1.Row{ID: "B", Fields: model1.Fields{"1", "2", "3"}}, }, e: true, }, "diff-delta": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, Deltas: model1.DeltaRow{"1", "2", "3"}, }, re2: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, Deltas: model1.DeltaRow{"10", "2", "3"}, }, e: true, }, "diff-id": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, re2: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "B", Fields: model1.Fields{"1", "2", "3"}}, }, e: true, }, "diff-field": { re1: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}, }, re2: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ID: "A", Fields: model1.Fields{"10", "2", "3"}}, }, e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.re1.Diff(u.re2, -1)) }) } } func TestRowEventsDiff(t *testing.T) { uu := map[string]struct { re1, re2 *model1.RowEvents ageCol int e bool }{ "same": { re1: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re2: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), ageCol: -1, }, "diff-len": { re1: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re2: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), ageCol: -1, e: true, }, "diff-id": { re1: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re2: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "D", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), ageCol: -1, e: true, }, "diff-order": { re1: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re2: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), ageCol: -1, e: true, }, "diff-withAge": { re1: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re2: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "13"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), ageCol: 1, e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.re1.Diff(u.re2, u.ageCol)) }) } } func TestRowEventsUpsert(t *testing.T) { uu := map[string]struct { ee, e *model1.RowEvents re model1.RowEvent }{ "add": { ee: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), re: model1.RowEvent{ Row: model1.Row{ID: "D", Fields: model1.Fields{"f1", "f2", "f3"}}, }, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "D", Fields: model1.Fields{"f1", "f2", "f3"}}}, ), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.ee.Upsert(u.re) assert.Equal(t, u.e, u.ee) }) } } func TestRowEventsCustomize(t *testing.T) { uu := map[string]struct { re, e *model1.RowEvents cols []int }{ "same": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), cols: []int{0, 1, 2}, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), }, "reverse": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), cols: []int{2, 1, 0}, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"3", "2", "1"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"3", "2", "0"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"3", "2", "10"}}}, ), }, "skip": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), cols: []int{1, 0}, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"2", "1"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"2", "0"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"2", "10"}}}, ), }, "missing": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), cols: []int{1, 0, 4}, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"2", "1", ""}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"2", "0", ""}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"2", "10", ""}}}, ), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.re.Customize(u.cols)) }) } } func TestRowEventsDelete(t *testing.T) { uu := map[string]struct { re, e *model1.RowEvents id string }{ "first": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), id: "A", e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), }, "middle": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), id: "B", e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), }, "last": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), id: "C", e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, ), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { require.NoError(t, u.re.Delete(u.id)) assert.Equal(t, u.e, u.re) }) } } func TestRowEventsSort(t *testing.T) { uu := map[string]struct { re, e *model1.RowEvents col int duration, num, asc bool capacity bool }{ "age_time": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", testTime().Add(20 * time.Second).String()}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", testTime().Add(10 * time.Second).String()}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", testTime().String()}}}, ), col: 2, asc: true, duration: true, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", testTime().String()}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", testTime().Add(10 * time.Second).String()}}}, model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", testTime().Add(20 * time.Second).String()}}}, ), }, "col0": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), col: 0, asc: true, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "B", Fields: model1.Fields{"0", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "A", Fields: model1.Fields{"1", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "C", Fields: model1.Fields{"10", "2", "3"}}}, ), }, "id_preserve": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}}, ), col: 1, asc: true, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}}, ), }, "capacity": { re: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3", "1Gi"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3", "1.1G"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3", "0.5Ti"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3", "12e6"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3", "1234"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3", "0.1Ei"}}}, ), col: 3, asc: true, capacity: true, e: model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3", "1234"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3", "12e6"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3", "1Gi"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3", "1.1G"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3", "0.5Ti"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3", "0.1Ei"}}}, ), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.re.Sort("", u.col, u.duration, u.num, u.capacity, u.asc) assert.Equal(t, u.e, u.re) }) } } func TestRowEventsClone(t *testing.T) { uu := map[string]struct { r *model1.RowEvents }{ "empty": { r: model1.NewRowEventsWithEvts(), }, "full": { r: makeRowEvents(), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { c := u.r.Clone() assert.Equal(t, u.r.Len(), c.Len()) if !u.r.Empty() { r, ok := u.r.At(0) assert.True(t, ok) r.Row.Fields[0] = "blee" cr, ok := c.At(0) assert.True(t, ok) assert.Equal(t, "A", cr.Row.Fields[0]) } }) } } // Helpers... func makeRowEvents() *model1.RowEvents { return model1.NewRowEventsWithEvts( model1.RowEvent{Row: model1.Row{ID: "ns1/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns1/C", Fields: model1.Fields{"C", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/A", Fields: model1.Fields{"A", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/B", Fields: model1.Fields{"B", "2", "3"}}}, model1.RowEvent{Row: model1.Row{ID: "ns2/C", Fields: model1.Fields{"C", "2", "3"}}}, ) } ================================================ FILE: internal/model1/row_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "fmt" "reflect" "testing" "time" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" ) func BenchmarkRowCustomize(b *testing.B) { row := model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}} cols := []int{0, 1, 2} b.ReportAllocs() b.ResetTimer() for range b.N { _ = row.Customize(cols) } } func TestFieldCustomize(t *testing.T) { uu := map[string]struct { fields model1.Fields cols []int e model1.Fields }{ "empty": { fields: model1.Fields{}, cols: []int{0, 1, 2}, e: model1.Fields{"", "", ""}, }, "no-cols": { fields: model1.Fields{"f1", "f2", "f3"}, cols: []int{}, e: model1.Fields{}, }, "reverse": { fields: model1.Fields{"f1", "f2", "f3"}, cols: []int{1, 0}, e: model1.Fields{"f2", "f1"}, }, "missing": { fields: model1.Fields{"f1", "f2", "f3"}, cols: []int{10, 0}, e: model1.Fields{"", "f1"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ff := make(model1.Fields, len(u.cols)) u.fields.Customize(u.cols, ff) assert.Equal(t, u.e, ff) }) } } func TestFieldClone(t *testing.T) { f := model1.Fields{"a", "b", "c"} f1 := f.Clone() assert.True(t, reflect.DeepEqual(f, f1)) assert.NotEqual(t, fmt.Sprintf("%p", f), fmt.Sprintf("%p", f1)) } func TestRowLabelize(t *testing.T) { uu := map[string]struct { row model1.Row cols []int e model1.Row }{ "empty": { row: model1.Row{}, cols: []int{0, 1, 2}, e: model1.Row{ID: "", Fields: model1.Fields{"", "", ""}}, }, "no-cols-no-data": { row: model1.Row{}, cols: []int{}, e: model1.Row{ID: "", Fields: model1.Fields{}}, }, "no-cols-data": { row: model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}}, cols: []int{}, e: model1.Row{ID: "fred", Fields: model1.Fields{}}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { row := u.row.Customize(u.cols) assert.Equal(t, u.e, row) }) } } func TestRowCustomize(t *testing.T) { uu := map[string]struct { row model1.Row cols []int e model1.Row }{ "empty": { row: model1.Row{}, cols: []int{0, 1, 2}, e: model1.Row{ID: "", Fields: model1.Fields{"", "", ""}}, }, "no-cols-no-data": { row: model1.Row{}, cols: []int{}, e: model1.Row{ID: "", Fields: model1.Fields{}}, }, "no-cols-data": { row: model1.Row{ID: "fred", Fields: model1.Fields{"f1", "f2", "f3"}}, cols: []int{}, e: model1.Row{ID: "fred", Fields: model1.Fields{}}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { row := u.row.Customize(u.cols) assert.Equal(t, u.e, row) }) } } func TestRowsDelete(t *testing.T) { uu := map[string]struct { rows model1.Rows id string e model1.Rows }{ "first": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, id: "a", e: model1.Rows{ {ID: "b", Fields: []string{"albert", "blee"}}, }, }, "last": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, id: "b", e: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, }, }, "middle": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, {ID: "c", Fields: []string{"fred", "zorg"}}, }, id: "b", e: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "c", Fields: []string{"fred", "zorg"}}, }, }, "missing": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, id: "zorg", e: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { rows := u.rows.Delete(u.id) assert.Equal(t, u.e, rows) }) } } func TestRowsUpsert(t *testing.T) { uu := map[string]struct { rows model1.Rows row model1.Row e model1.Rows }{ "add": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, row: model1.Row{ID: "c", Fields: []string{"f1", "f2"}}, e: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, {ID: "c", Fields: []string{"f1", "f2"}}, }, }, "update": { rows: model1.Rows{ {ID: "a", Fields: []string{"blee", "duh"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, row: model1.Row{ID: "a", Fields: []string{"f1", "f2"}}, e: model1.Rows{ {ID: "a", Fields: []string{"f1", "f2"}}, {ID: "b", Fields: []string{"albert", "blee"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { rows := u.rows.Upsert(u.row) assert.Equal(t, u.e, rows) }) } } func TestRowsSortText(t *testing.T) { uu := map[string]struct { rows model1.Rows col int asc, num bool e model1.Rows }{ "plainAsc": { rows: model1.Rows{ {Fields: []string{"blee", "duh"}}, {Fields: []string{"albert", "blee"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{"albert", "blee"}}, {Fields: []string{"blee", "duh"}}, }, }, "plainDesc": { rows: model1.Rows{ {Fields: []string{"blee", "duh"}}, {Fields: []string{"albert", "blee"}}, }, col: 0, asc: false, e: model1.Rows{ {Fields: []string{"blee", "duh"}}, {Fields: []string{"albert", "blee"}}, }, }, "numericAsc": { rows: model1.Rows{ {Fields: []string{"10", "duh"}}, {Fields: []string{"1", "blee"}}, }, col: 0, num: true, asc: true, e: model1.Rows{ {Fields: []string{"1", "blee"}}, {Fields: []string{"10", "duh"}}, }, }, "numericDesc": { rows: model1.Rows{ {Fields: []string{"10", "duh"}}, {Fields: []string{"1", "blee"}}, }, col: 0, num: true, asc: false, e: model1.Rows{ {Fields: []string{"10", "duh"}}, {Fields: []string{"1", "blee"}}, }, }, "composite": { rows: model1.Rows{ {Fields: []string{"blee-duh", "duh"}}, {Fields: []string{"blee", "blee"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{"blee", "blee"}}, {Fields: []string{"blee-duh", "duh"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.rows.Sort(u.col, u.asc, u.num, false, false) assert.Equal(t, u.e, u.rows) }) } } func TestRowsSortDuration(t *testing.T) { uu := map[string]struct { rows model1.Rows col int asc bool e model1.Rows }{ "fred": { rows: model1.Rows{ {Fields: []string{"2m24s", "blee"}}, {Fields: []string{"2m12s", "duh"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{"2m12s", "duh"}}, {Fields: []string{"2m24s", "blee"}}, }, }, "years": { rows: model1.Rows{ {Fields: []string{testTime().Add(-365 * 24 * time.Hour).String(), "blee"}}, {Fields: []string{testTime().String(), "duh"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{testTime().String(), "duh"}}, {Fields: []string{testTime().Add(-365 * 24 * time.Hour).String(), "blee"}}, }, }, "durationAsc": { rows: model1.Rows{ {Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}}, {Fields: []string{testTime().String(), "blee"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{testTime().String(), "blee"}}, {Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}}, }, }, "durationDesc": { rows: model1.Rows{ {Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}}, {Fields: []string{testTime().String(), "blee"}}, }, col: 0, e: model1.Rows{ {Fields: []string{testTime().Add(10 * time.Second).String(), "duh"}}, {Fields: []string{testTime().String(), "blee"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.rows.Sort(u.col, u.asc, false, true, false) assert.Equal(t, u.e, u.rows) }) } } func TestRowsSortMetrics(t *testing.T) { uu := map[string]struct { rows model1.Rows col int asc bool e model1.Rows }{ "metricAsc": { rows: model1.Rows{ {Fields: []string{"10m", "duh"}}, {Fields: []string{"1m", "blee"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{"1m", "blee"}}, {Fields: []string{"10m", "duh"}}, }, }, "metricDesc": { rows: model1.Rows{ {Fields: []string{"10000m", "1000Mi"}}, {Fields: []string{"1m", "50Mi"}}, }, col: 1, asc: false, e: model1.Rows{ {Fields: []string{"10000m", "1000Mi"}}, {Fields: []string{"1m", "50Mi"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.rows.Sort(u.col, u.asc, true, false, false) assert.Equal(t, u.e, u.rows) }) } } func TestRowsSortCapacity(t *testing.T) { uu := map[string]struct { rows model1.Rows col int asc bool e model1.Rows }{ "capacityAsc": { rows: model1.Rows{ {Fields: []string{"10Gi", "duh"}}, {Fields: []string{"10G", "blee"}}, }, col: 0, asc: true, e: model1.Rows{ {Fields: []string{"10G", "blee"}}, {Fields: []string{"10Gi", "duh"}}, }, }, "capacityDesc": { rows: model1.Rows{ {Fields: []string{"10000m", "1000Mi"}}, {Fields: []string{"1m", "50Mi"}}, }, col: 1, asc: false, e: model1.Rows{ {Fields: []string{"10000m", "1000Mi"}}, {Fields: []string{"1m", "50Mi"}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.rows.Sort(u.col, u.asc, false, false, true) assert.Equal(t, u.e, u.rows) }) } } func TestLess(t *testing.T) { uu := map[string]struct { isNumber bool isDuration bool isCapacity bool id1, id2 string v1, v2 string e bool }{ "years": { isNumber: false, isDuration: true, isCapacity: false, id1: "id1", id2: "id2", v1: "2y263d", v2: "1y179d", }, "hours": { isNumber: false, isDuration: true, isCapacity: false, id1: "id1", id2: "id2", v1: "2y263d", v2: "19h", }, "capacity1": { isNumber: false, isDuration: false, isCapacity: true, id1: "id1", id2: "id2", v1: "1Gi", v2: "1G", e: false, }, "capacity2": { isNumber: false, isDuration: false, isCapacity: true, id1: "id1", id2: "id2", v1: "1Gi", v2: "1Ti", e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, model1.Less(u.isNumber, u.isDuration, u.isCapacity, u.id1, u.id2, u.v1, u.v2)) }) } } ================================================ FILE: internal/model1/rows.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import "sort" // Rows represents a collection of rows. type Rows []Row // Delete removes an element by id. func (rr Rows) Delete(id string) Rows { idx, ok := rr.Find(id) if !ok { return rr } if idx == 0 { return rr[1:] } if idx+1 == len(rr) { return rr[:len(rr)-1] } return append(rr[:idx], rr[idx+1:]...) } // Upsert adds a new item. func (rr Rows) Upsert(r Row) Rows { idx, ok := rr.Find(r.ID) if !ok { return append(rr, r) } rr[idx] = r return rr } // Find locates a row by id. Returns false is not found. func (rr Rows) Find(id string) (int, bool) { for i, r := range rr { if r.ID == id { return i, true } } return 0, false } // Sort rows based on column index and order. func (rr Rows) Sort(col int, asc, isNum, isDur, isCapacity bool) { t := RowSorter{ Rows: rr, Index: col, IsNumber: isNum, IsDuration: isDur, IsCapacity: isCapacity, Asc: asc, } sort.Sort(t) } ================================================ FILE: internal/model1/table_data.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "context" "errors" "fmt" "log/slog" "regexp" "strings" "sync" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/slogs" "github.com/sahilm/fuzzy" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" ) // SortFn represent a function that can sort columnar data. type SortFn func(rows Rows, sortCol SortColumn) // SortColumn represents a sortable column. type SortColumn struct { Name string ASC bool } // IsSet checks if the sort column is set. func (s SortColumn) IsSet() bool { return s.Name != "" } const spacer = " " type FilterOpts struct { Toast bool Filter string Invert bool } // TableData tracks a K8s resource for tabular display. type TableData struct { header Header rowEvents *RowEvents namespace string gvr *client.GVR mx sync.RWMutex } // NewTableData returns a new table. func NewTableData(gvr *client.GVR) *TableData { return &TableData{ gvr: gvr, rowEvents: NewRowEvents(10), } } func NewTableDataFull(gvr *client.GVR, ns string, h Header, re *RowEvents) *TableData { t := NewTableDataWithRows(gvr, h, re) t.namespace = ns return t } func NewTableDataWithRows(gvr *client.GVR, h Header, re *RowEvents) *TableData { t := NewTableData(gvr) t.header, t.rowEvents = h, re return t } func NewTableDataFromTable(td *TableData) *TableData { t := NewTableData(td.gvr) t.header = td.header t.rowEvents = td.rowEvents t.namespace = td.namespace return t } func (t *TableData) AddRow(re RowEvent) { t.rowEvents.Add(re) } func (t *TableData) SetRow(idx int, re RowEvent) { t.rowEvents.Set(idx, re) } func (t *TableData) FindRow(id string) (RowEvent, bool) { return t.rowEvents.Get(id) } func (t *TableData) RowAt(idx int) (RowEvent, bool) { return t.rowEvents.At(idx) } func (t *TableData) RowsRange(f ReRangeFn) { t.rowEvents.Range(f) } func (t *TableData) Sort(sc SortColumn) { col, idx := t.HeadCol(sc.Name, false) if idx < 0 { return } t.rowEvents.Sort( t.GetNamespace(), idx, col.Time, col.MX, col.Capacity, sc.ASC, ) } func (t *TableData) Header() Header { return t.header } // HeaderCount returns the number of header cols. func (t *TableData) HeaderCount() int { t.mx.RLock() defer t.mx.RUnlock() return len(t.header) } func (t *TableData) HeadCol(n string, w bool) (header HeaderColumn, idx int) { idx, ok := t.header.IndexOf(n, w) if !ok { return HeaderColumn{}, -1 } return t.header[idx], idx } func (t *TableData) Filter(f FilterOpts) *TableData { td := NewTableDataFromTable(t) if f.Toast { td.rowEvents = t.filterToast() } if f.Filter == "" || internal.IsLabelSelector(f.Filter) { return td } if f, ok := internal.IsFuzzySelector(f.Filter); ok { td.rowEvents = t.fuzzyFilter(f) return td } rr, err := t.rxFilter(f.Filter, internal.IsInverseSelector(f.Filter)) if err == nil { td.rowEvents = rr } else { slog.Error("RX filter failed", slogs.Error, err) } return td } func (t *TableData) rxFilter(q string, inverse bool) (*RowEvents, error) { if strings.Contains(q, " ") { return t.rowEvents, nil } if inverse { q = q[1:] } rx, err := regexp.Compile(`(?i)(` + q + `)`) if err != nil { return nil, fmt.Errorf("invalid rx filter %q: %w", q, err) } vidx := t.header.FilterColIndices(t.namespace, true) rr := NewRowEvents(t.RowCount() / 2) t.rowEvents.Range(func(_ int, re RowEvent) bool { ff := make([]string, 0, len(re.Row.Fields)) for idx, r := range re.Row.Fields { if !vidx.Has(idx) { continue } ff = append(ff, r) } match := rx.MatchString(strings.Join(ff, spacer)) if (inverse && !match) || (!inverse && match) { rr.Add(re) } return true }) return rr, nil } func (t *TableData) fuzzyFilter(q string) *RowEvents { q = strings.TrimSpace(q) ss := make([]string, 0, t.RowCount()/2) t.rowEvents.Range(func(_ int, re RowEvent) bool { ss = append(ss, re.Row.ID) return true }) mm := fuzzy.Find(q, ss) rr := NewRowEvents(t.RowCount() / 2) for _, m := range mm { if re, ok := t.rowEvents.At(m.Index); !ok { slog.Error("Unable to find event for index in fuzzfilter", slogs.Index, m.Index) } else { rr.Add(re) } } return rr } func (t *TableData) filterToast() *RowEvents { rr := NewRowEvents(10) idx, ok := t.header.IndexOf("VALID", true) if !ok { return rr } t.rowEvents.Range(func(_ int, re RowEvent) bool { if re.Row.Fields[idx] != "" { rr.Add(re) } return true }) return rr } func (t *TableData) GetNamespace() string { t.mx.RLock() defer t.mx.RUnlock() return t.namespace } func (t *TableData) Reset(ns string) { t.mx.Lock() t.namespace = ns t.mx.Unlock() t.Clear() } func (t *TableData) Render(_ context.Context, r Renderer, oo []runtime.Object) error { var rows Rows if len(oo) > 0 { if r.IsGeneric() { table, ok := oo[0].(*metav1.Table) if !ok { return fmt.Errorf("expecting a meta table but got %T", oo[0]) } rows = make(Rows, len(table.Rows)) if err := GenericHydrate(t.namespace, table, rows, r); err != nil { return err } } else { rows = make(Rows, len(oo)) if err := Hydrate(t.namespace, oo, rows, r); err != nil { return err } } } t.Update(rows) t.SetHeader(t.namespace, r.Header(t.namespace)) if t.HeaderCount() == 0 { return fmt.Errorf("no data found for resource %s", t.gvr) } return nil } // Empty checks if there are no entries. func (t *TableData) Empty() bool { t.mx.RLock() defer t.mx.RUnlock() return t.rowEvents.Empty() } func (t *TableData) SetRowEvents(re *RowEvents) { t.rowEvents = re } func (t *TableData) GetRowEvents() *RowEvents { return t.rowEvents } // RowCount returns the number of rows. func (t *TableData) RowCount() int { t.mx.RLock() defer t.mx.RUnlock() return t.rowEvents.Len() } // IndexOfHeader return the index of the header. func (t *TableData) IndexOfHeader(h string) (int, bool) { return t.header.IndexOf(h, false) } // Labelize prints out specific label columns. func (t *TableData) Labelize(labels []string) *TableData { idx, ok := t.header.IndexOf("LABELS", true) if !ok { return t } cols := []int{0, 1} if client.IsNamespaced(t.namespace) { cols = cols[1:] } data := TableData{ namespace: t.namespace, header: t.header.Labelize(cols, idx, t.rowEvents), } data.rowEvents = t.rowEvents.Labelize(cols, idx, labels) return &data } // ComputeSortCol computes the best matched sort column. func (t *TableData) ComputeSortCol(vs *config.ViewSetting, sc SortColumn, manual bool) SortColumn { if vs.IsBlank() { if sc.Name != "" { return sc } if psc, err := t.sortCol(vs); err == nil { return psc } return sc } if manual && sc.IsSet() { return sc } if s, asc, err := vs.SortCol(); err == nil { return SortColumn{Name: s, ASC: asc} } return sc } func (t *TableData) sortCol(vs *config.ViewSetting) (SortColumn, error) { var psc SortColumn if t.HeaderCount() == 0 { return psc, errors.New("no header found") } name, order, _ := vs.SortCol() if _, ok := t.header.IndexOf(name, false); ok { psc.Name, psc.ASC = name, order return psc, nil } if client.IsAllNamespaces(t.GetNamespace()) { if _, ok := t.header.IndexOf("NAMESPACE", false); ok { psc.Name = "NAMESPACE" } else if _, ok := t.header.IndexOf("NAME", false); ok { psc.Name = "NAME" } } else { if _, ok := t.header.IndexOf("NAME", false); ok { psc.Name = "NAME" } else { psc.Name = t.header[0].Name } } psc.ASC = true return psc, nil } // Clear clears out the entire table. func (t *TableData) Clear() { t.mx.Lock() defer t.mx.Unlock() t.header = t.header.Clear() t.rowEvents.Clear() } // Clone returns a copy of the table. func (t *TableData) Clone() *TableData { t.mx.RLock() defer t.mx.RUnlock() return &TableData{ header: t.header.Clone(), rowEvents: t.rowEvents.Clone(), namespace: t.namespace, gvr: t.gvr, } } func (t *TableData) ColumnNames(w bool) []string { t.mx.RLock() defer t.mx.RUnlock() return t.header.ColumnNames(w) } // GetHeader returns table header. func (t *TableData) GetHeader() Header { t.mx.RLock() defer t.mx.RUnlock() return t.header } // SetHeader sets table header. func (t *TableData) SetHeader(ns string, h Header) { t.mx.Lock() defer t.mx.Unlock() t.namespace, t.header = ns, h } // Update computes row deltas and update the table data. func (t *TableData) Update(rows Rows) { empty := t.Empty() kk := sets.New[string]() var blankDelta DeltaRow t.mx.Lock() for _, row := range rows { kk.Insert(row.ID) if empty { t.rowEvents.Add(NewRowEvent(EventAdd, row)) continue } if index, ok := t.rowEvents.FindIndex(row.ID); ok { ev, ok := t.rowEvents.At(index) if !ok { continue } delta := NewDeltaRow(ev.Row, row, t.header) if delta.IsBlank() { ev.Kind, ev.Deltas, ev.Row = EventUnchanged, blankDelta, row t.rowEvents.Set(index, ev) } else { t.rowEvents.Set(index, NewRowEventWithDeltas(row, delta)) } continue } t.rowEvents.Add(NewRowEvent(EventAdd, row)) } t.mx.Unlock() if !empty { t.Delete(kk) } } // Delete removes items in cache that are no longer valid. func (t *TableData) Delete(newKeys sets.Set[string]) { t.mx.Lock() defer t.mx.Unlock() victims := sets.New[string]() t.rowEvents.Range(func(_ int, e RowEvent) bool { if newKeys.Has(e.Row.ID) { delete(newKeys, e.Row.ID) } else { victims.Insert(e.Row.ID) } return true }) for _, id := range victims.UnsortedList() { if err := t.rowEvents.Delete(id); err != nil { slog.Error("Table delete failed", slogs.Error, err, slogs.Message, id, ) } } } // Diff checks if two tables are equal. func (t *TableData) Diff(t2 *TableData) bool { if t2 == nil || t.namespace != t2.namespace || t.header.Diff(t2.header) { return true } idx, ok := t.header.IndexOf("AGE", true) if !ok { idx = -1 } return t.rowEvents.Diff(t2.rowEvents, idx) } ================================================ FILE: internal/model1/table_data_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "log/slog" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/util/sets" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestTableDataComputeSortCol(t *testing.T) { uu := map[string]struct { t1 *TableData vs config.ViewSetting sc SortColumn wide, manual bool e SortColumn }{ "same": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), vs: config.ViewSetting{Columns: []string{"A", "B", "C"}, SortColumn: "A:asc"}, e: SortColumn{Name: "A", ASC: true}, }, "wide-col": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), vs: config.ViewSetting{Columns: []string{"A", "B", "C"}, SortColumn: "B:desc"}, e: SortColumn{Name: "B"}, }, "wide": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B", Attrs: Attrs{Wide: true}}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), wide: true, vs: config.ViewSetting{Columns: []string{"A", "C"}, SortColumn: ""}, e: SortColumn{Name: ""}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { sc := u.t1.ComputeSortCol(&u.vs, u.sc, u.manual) assert.Equal(t, u.e, sc) }) } } func TestTableDataDiff(t *testing.T) { uu := map[string]struct { t1, t2 *TableData e bool }{ "empty": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), e: true, }, "same": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), t2: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), }, "ns-diff": { t1: NewTableDataFull( client.NewGVR("test"), "ns1", Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), t2: NewTableDataFull( client.NewGVR("test"), "ns-2", Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), e: true, }, "header-diff": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "D"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), t2: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), e: true, }, "row-diff": { t1: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), ), t2: NewTableDataWithRows( client.NewGVR("test"), Header{ HeaderColumn{Name: "A"}, HeaderColumn{Name: "B"}, HeaderColumn{Name: "C"}, }, NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"100", "2", "3"}}}, ), ), e: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.t1.Diff(u.t2)) }) } } func TestTableDataUpdate(t *testing.T) { uu := map[string]struct { re, e *RowEvents rr Rows }{ "no-change": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), rr: Rows{ Row{ID: "A", Fields: Fields{"1", "2", "3"}}, Row{ID: "B", Fields: Fields{"0", "2", "3"}}, Row{ID: "C", Fields: Fields{"10", "2", "3"}}, }, e: NewRowEventsWithEvts( RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), }, "add": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), rr: Rows{ Row{ID: "A", Fields: Fields{"1", "2", "3"}}, Row{ID: "B", Fields: Fields{"0", "2", "3"}}, Row{ID: "C", Fields: Fields{"10", "2", "3"}}, Row{ID: "D", Fields: Fields{"10", "2", "3"}}, }, e: NewRowEventsWithEvts( RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, RowEvent{Kind: EventAdd, Row: Row{ID: "D", Fields: Fields{"10", "2", "3"}}}, ), }, "delete": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), rr: Rows{ Row{ID: "A", Fields: Fields{"1", "2", "3"}}, Row{ID: "C", Fields: Fields{"10", "2", "3"}}, }, e: NewRowEventsWithEvts( RowEvent{Kind: EventUnchanged, Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), }, "update": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), rr: Rows{ Row{ID: "A", Fields: Fields{"10", "2", "3"}}, Row{ID: "B", Fields: Fields{"0", "2", "3"}}, Row{ID: "C", Fields: Fields{"10", "2", "3"}}, }, e: NewRowEventsWithEvts( RowEvent{ Kind: EventUpdate, Row: Row{ID: "A", Fields: Fields{"10", "2", "3"}}, Deltas: DeltaRow{"1", "", ""}, }, RowEvent{Kind: EventUnchanged, Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Kind: EventUnchanged, Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), }, } var table TableData for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { table.SetRowEvents(u.re) table.Update(u.rr) assert.Equal(t, u.e, table.GetRowEvents()) }) } } func TestTableDataDelete(t *testing.T) { uu := map[string]struct { re, e *RowEvents kk sets.Set[string] }{ "ordered": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), kk: sets.New[string]("A", "C"), e: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), }, "unordered": { re: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "B", Fields: Fields{"0", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, RowEvent{Row: Row{ID: "D", Fields: Fields{"10", "2", "3"}}}, ), kk: sets.New[string]("C", "A"), e: NewRowEventsWithEvts( RowEvent{Row: Row{ID: "A", Fields: Fields{"1", "2", "3"}}}, RowEvent{Row: Row{ID: "C", Fields: Fields{"10", "2", "3"}}}, ), }, } var table TableData for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { table.SetRowEvents(u.re) table.Delete(u.kk) assert.Equal(t, u.e, table.GetRowEvents()) }) } } ================================================ FILE: internal/model1/test_helper_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1_test import ( "fmt" "time" ) func testTime() time.Time { t, err := time.Parse(time.RFC3339, "2018-12-14T10:36:43.326972-07:00") if err != nil { fmt.Println("TestTime Failed", err) } return t } ================================================ FILE: internal/model1/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package model1 import ( "context" "github.com/derailed/k9s/internal/config" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( NAValue = "na" // EventUnchanged notifies listener resource has not changed. EventUnchanged ResEvent = 1 << iota // EventAdd notifies listener of a resource was added. EventAdd // EventUpdate notifies listener of a resource updated. EventUpdate // EventDelete notifies listener of a resource was deleted. EventDelete // EventClear the stack was reset. EventClear ) // DecoratorFunc decorates a string. type DecoratorFunc func(string) string // ColorerFunc represents a resource row colorer. type ColorerFunc func(ns string, h Header, re *RowEvent) tcell.Color // Renderer represents a resource renderer. type Renderer interface { // IsGeneric identifies a generic handler. IsGeneric() bool // Render converts raw resources to tabular data. Render(o any, ns string, row *Row) error // Header returns the resource header. Header(ns string) Header // ColorerFunc returns a row colorer function. ColorerFunc() ColorerFunc // SetViewSetting sets custom view settings if any. SetViewSetting(vs *config.ViewSetting) // Healthy checks if the resource is healthy. Healthy(ctx context.Context, o any) error } // Generic represents a generic resource. type Generic interface { // SetTable sets up the resource tabular definition. SetTable(ns string, table *metav1.Table) // Header returns a resource header. Header(ns string) Header // Render renders the resource. Render(o any, ns string, row *Row) error } ================================================ FILE: internal/perf/benchmark.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package perf import ( "bytes" "context" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" "github.com/rakyll/hey/requester" ) const ( // BOZO!! Revisit bench and when we should timeout. benchTimeout = 2 * time.Minute benchFmat = "%s_%s_%d.txt" k9sUA = "k9s/" ) // Benchmark puts a workload under load. type Benchmark struct { canceled bool config *config.BenchConfig worker *requester.Work cancelFn context.CancelFunc mx sync.RWMutex } // NewBenchmark returns a new benchmark. func NewBenchmark(base, version string, cfg *config.BenchConfig) (*Benchmark, error) { b := Benchmark{config: cfg} if err := b.init(base, version); err != nil { return nil, err } return &b, nil } func (b *Benchmark) init(base, version string) error { var ctx context.Context ctx, b.cancelFn = context.WithTimeout(context.Background(), benchTimeout) req, err := http.NewRequestWithContext(ctx, b.config.HTTP.Method, base, http.NoBody) if err != nil { return err } if b.config.Auth.User != "" || b.config.Auth.Password != "" { req.SetBasicAuth(b.config.Auth.User, b.config.Auth.Password) } req.Header = b.config.HTTP.Headers slog.Debug("Benchmarking Request", slogs.URL, req.URL.String()) ua := req.UserAgent() if ua == "" { ua = k9sUA } else { ua += " " + k9sUA } ua += version if req.Header == nil { req.Header = make(http.Header) } req.Header.Set("User-Agent", ua) slog.Debug(fmt.Sprintf("Using bench config N:%d--C:%d", b.config.N, b.config.C)) b.worker = &requester.Work{ Request: req, RequestBody: []byte(b.config.HTTP.Body), N: b.config.N, C: b.config.C, H2: b.config.HTTP.HTTP2, } return nil } // Cancel kills the benchmark in progress. func (b *Benchmark) Cancel() { if b == nil { return } b.mx.Lock() defer b.mx.Unlock() b.canceled = true if b.cancelFn != nil { b.cancelFn() b.cancelFn = nil } } // Canceled checks if the benchmark was canceled. func (b *Benchmark) Canceled() bool { return b.canceled } // Run starts a benchmark. func (b *Benchmark) Run(cluster, ct string, done func()) { slog.Debug("Running benchmark", slogs.Cluster, cluster, slogs.Context, ct, ) buff := new(bytes.Buffer) b.worker.Writer = buff // this call will block until the benchmark is complete or times out. b.worker.Run() b.worker.Stop() if buff.Len() > 0 { if err := b.save(cluster, ct, buff); err != nil { slog.Error("Saving Benchmark", slogs.Error, err) } } done() } func (b *Benchmark) save(cluster, ct string, r io.Reader) error { ns, n := client.Namespaced(b.config.Name) n = strings.ReplaceAll(n, "|", "_") n = strings.ReplaceAll(n, ":", "_") dir, err := config.EnsureBenchmarksDir(cluster, ct) if err != nil { return err } bf := filepath.Join(dir, fmt.Sprintf(benchFmat, ns, n, time.Now().UnixNano())) if e := data.EnsureDirPath(bf, data.DefaultDirMod); e != nil { return e } f, err := os.Create(bf) if err != nil { return err } defer func() { if e := f.Close(); e != nil { slog.Error("Benchmark file close failed", slogs.Error, e, slogs.Path, bf, ) } }() if _, err = io.Copy(f, r); err != nil { return err } return nil } ================================================ FILE: internal/pool.go ================================================ package internal import ( "context" "log/slog" "sync" "github.com/derailed/k9s/internal/slogs" ) const DefaultPoolSize = 10 type jobFn func(ctx context.Context) error type WorkerPool struct { semC chan struct{} errC chan error ctx context.Context cancelFn context.CancelFunc mx sync.RWMutex wg sync.WaitGroup wge sync.WaitGroup errs []error } func NewWorkerPool(ctx context.Context, size int) *WorkerPool { _, cancelFn := context.WithCancel(ctx) p := WorkerPool{ semC: make(chan struct{}, size), errC: make(chan error, 1), cancelFn: cancelFn, ctx: ctx, } p.wge.Add(1) go func(wg *sync.WaitGroup) { defer wg.Done() for err := range p.errC { if err != nil { p.mx.Lock() p.errs = append(p.errs, err) p.mx.Unlock() } } }(&p.wge) return &p } func (p *WorkerPool) Add(job jobFn) { p.semC <- struct{}{} p.wg.Add(1) go func(ctx context.Context, wg *sync.WaitGroup, semC <-chan struct{}, errC chan<- error) { defer func() { <-semC wg.Done() }() if err := job(ctx); err != nil { slog.Error("Worker error", slogs.Error, err) errC <- err } }(p.ctx, &p.wg, p.semC, p.errC) } func (p *WorkerPool) Drain() []error { if p.cancelFn != nil { p.cancelFn() p.cancelFn = nil } p.wg.Wait() close(p.semC) close(p.errC) p.wge.Wait() p.mx.RLock() defer p.mx.RUnlock() return p.errs } ================================================ FILE: internal/pool_test.go ================================================ package internal_test import ( "context" "fmt" "sync/atomic" "testing" "github.com/derailed/k9s/internal" "github.com/stretchr/testify/assert" ) func TestWorkerPoolPlain(t *testing.T) { p := internal.NewWorkerPool(context.Background(), 2) var c atomic.Int32 for range 10 { p.Add(func(ctx context.Context) error { select { case <-ctx.Done(): fmt.Println("Worker canceled") return nil default: c.Add(1) return nil } }) } errs := p.Drain() assert.Equal(t, 10, int(c.Load())) assert.Empty(t, errs) } func TestWorkerPoolWithError(t *testing.T) { ctx := context.Background() p := internal.NewWorkerPool(ctx, 2) var c atomic.Int32 for i := range 10 { p.Add(func(ctx context.Context) error { select { case <-ctx.Done(): fmt.Println("Worker canceled") return nil default: if i%2 == 0 { return fmt.Errorf("BOOM%d", i) } c.Add(1) return nil } }) } errs := p.Drain() assert.Equal(t, 5, int(c.Load())) assert.Len(t, errs, 5) } ================================================ FILE: internal/port/ann.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port import ( "errors" ) type Annotations map[string]string func (a Annotations) PreferredPorts(specs ContainerPortSpecs) (PFAnns, error) { if len(specs) == 0 { return nil, errors.New("no exposed ports") } value, ok := a[K9sPortForwardsKey] if !ok { return PFAnns{specs[0].ToPFAnn()}, nil } return specs.MatchAnnotations(value), nil } ================================================ FILE: internal/port/ann_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port_test import ( "errors" "testing" "github.com/derailed/k9s/internal/port" "github.com/stretchr/testify/assert" ) func TestPreferredPorts(t *testing.T) { uu := map[string]struct { anns port.Annotations specs port.ContainerPortSpecs err error e string }{ "no-ports": { anns: port.Annotations{ port.K9sPortForwardsKey: "c1::4321:p1", }, err: errors.New("no exposed ports"), }, "no-annotations": { specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, }, e: "c1::1234:p1", }, "single-numb": { anns: port.Annotations{ port.K9sPortForwardsKey: "c1::4321:1234", }, specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, }, e: "c1::4321:1234/1234", }, "single-same": { anns: port.Annotations{ port.K9sPortForwardsKey: "c1::1234", }, specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, }, e: "c1::1234:1234/1234", }, "single-mismatch": { anns: port.Annotations{ port.K9sPortForwardsKey: "c2::4321:p1", }, specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, }, }, "multi": { anns: port.Annotations{ port.K9sPortForwardsKey: "c1::4321:1234,c1::5432:2345", }, specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, {Container: "c1", PortName: "p2", PortNum: "2345"}, }, e: "c1::4321:1234/1234,c1::5432:2345/2345", }, "multi-mismatch": { anns: port.Annotations{ port.K9sPortForwardsKey: "c1::4321:1234,c1::5432:2345", }, specs: port.ContainerPortSpecs{ {Container: "c1", PortName: "p1", PortNum: "1234"}, {Container: "c2", PortName: "p3", PortNum: "2345"}, }, e: "c1::4321:1234/1234", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { anns, err := u.anns.PreferredPorts(u.specs) assert.Equal(t, u.err, err) if err != nil { return } pfs, err := port.ParsePFs(u.e) if err != nil { pfs = port.PFAnns{} } assert.Equal(t, pfs, anns) }) } } ================================================ FILE: internal/port/co_portspec.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port import ( "strconv" "strings" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" ) // ContainerPortSpecs represents a container exposed ports. type ContainerPortSpecs []ContainerPortSpec func (c ContainerPortSpecs) Dump() string { ss := make([]string, 0, len(c)) for _, spec := range c { ss = append(ss, spec.String()) } return strings.Join(ss, "\n") } // MatchSpec checks if given port matches a spec. func (c ContainerPortSpecs) MatchSpec(s string) bool { // Skip validation if No port are exposed or no container port spec. if len(c) == 0 || !strings.Contains(s, "::") { return true } for _, spec := range c { if spec.MatchSpec(s) { return true } } return false } // ToTunnels convert port specs to tunnels. func (c ContainerPortSpecs) ToTunnels(address string) PortTunnels { tt := make(PortTunnels, 0, len(c)) for _, spec := range c { tt = append(tt, spec.ToTunnel(address)) } return tt } // Find finds a matching container port. func (c ContainerPortSpecs) Find(pf *PFAnn) (ContainerPortSpec, bool) { for _, spec := range c { if spec.Match(pf) { return spec, true } } return ContainerPortSpec{}, false } // Match checks if container ports match a pf annotation. func (c ContainerPortSpecs) Match(pf *PFAnn) bool { for _, spec := range c { if spec.Match(pf) { return true } } return false } func (c ContainerPortSpecs) MatchAnnotations(s string) PFAnns { pfs, err := ParsePFs(s) if err != nil { return nil } mm := make(PFAnns, 0, len(c)) for _, pf := range pfs { if pf.Match(c) { mm = append(mm, pf) } } return mm } // FromContainerPorts hydrates from a pod container specification. func FromContainerPorts(co string, pp []v1.ContainerPort) ContainerPortSpecs { specs := make(ContainerPortSpecs, 0, len(pp)) for _, p := range pp { if p.Protocol != v1.ProtocolTCP { continue } specs = append(specs, NewPortSpec(co, p.Name, p.ContainerPort)) } return specs } // ContainerPortSpec represents a container port specification. type ContainerPortSpec struct { Container string PortName string PortNum string } // NewPortSpec returns a new instance. func NewPortSpec(co, portName string, port int32) ContainerPortSpec { return ContainerPortSpec{ Container: co, PortName: portName, PortNum: strconv.Itoa(int(port)), } } func (c ContainerPortSpec) MatchSpec(s string) bool { tokens := strings.Split(s, "::") if len(tokens) < 2 { return false } return tokens[0] == c.Container && tokens[1] == c.PortNum } func (c ContainerPortSpec) ToTunnel(address string) PortTunnel { return PortTunnel{ Address: address, LocalPort: c.PortNum, ContainerPort: c.PortNum, } } func (c ContainerPortSpec) Port() intstr.IntOrString { if c.PortName != "" { return intstr.Parse(c.PortName) } return intstr.Parse(c.PortNum) } func (c ContainerPortSpec) ToPFAnn() *PFAnn { return &PFAnn{ Container: c.Container, ContainerPort: c.Port(), LocalPort: c.PortNum, } } // Match checks if the container spec matches an annotation. func (c ContainerPortSpec) Match(ann *PFAnn) bool { if c.Container != ann.Container { return false } switch ann.ContainerPort.Type { case intstr.String: return c.PortName == ann.ContainerPort.String() case intstr.Int: return c.PortNum == ann.ContainerPort.String() default: return false } } // String dumps spec to string. func (c ContainerPortSpec) String() string { s := c.Container + "::" + c.PortNum if c.PortName != "" { s += "(" + c.PortName + ")" } return s } ================================================ FILE: internal/port/co_portspec_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port_test import ( "testing" "github.com/derailed/k9s/internal/port" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestContainerPortSpecMatch(t *testing.T) { uu := map[string]struct { ann string spec port.ContainerPortSpec e bool }{ "full": { ann: "c1::4321:1234", spec: port.ContainerPortSpec{ Container: "c1", PortNum: "1234", }, e: true, }, "no-port-name": { ann: "c1::4321:p1/1234", spec: port.ContainerPortSpec{ Container: "c1", PortName: "p1", PortNum: "1234", }, e: true, }, "port-name-hosed": { ann: "c1::4321:blee/1234", spec: port.ContainerPortSpec{ Container: "c1", PortName: "fred", PortNum: "1234", }, }, "container-name-hosed": { ann: "c2::4321:fred/1234", spec: port.ContainerPortSpec{ Container: "c1", PortName: "blee", PortNum: "1234", }, }, "port-num-hosed": { ann: "c2::4321:1235", spec: port.ContainerPortSpec{ Container: "c1", PortNum: "1234", }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.ann) require.NoError(t, err) assert.Equal(t, u.e, u.spec.Match(pf)) }) } } func TestContainerPortSpecString(t *testing.T) { uu := map[string]struct { spec port.ContainerPortSpec e string }{ "full": { spec: port.NewPortSpec("c1", "p1", 1234), e: "c1::1234(p1)", }, "no-name": { spec: port.NewPortSpec("c1", "", 1234), e: "c1::1234", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.spec.String()) }) } } func TestContainerPortSpecsMatch(t *testing.T) { uu := map[string]struct { ann string specs port.ContainerPortSpecs e bool }{ "full": { ann: "c1::4321:p1", specs: port.ContainerPortSpecs{ port.NewPortSpec("c1", "p1", 1234), port.NewPortSpec("c2", "p2", 1235), }, e: true, }, "no-name": { ann: "c1::4321", specs: port.ContainerPortSpecs{ port.NewPortSpec("c1", "", 4321), port.NewPortSpec("c2", "p2", 1235), }, e: true, }, "name-hosed": { ann: "c1::4321:p4", specs: port.ContainerPortSpecs{ port.NewPortSpec("c1", "p1", 1234), port.NewPortSpec("c2", "p2", 1235), }, }, "numb-hosed": { ann: "c1::4321:1235", specs: port.ContainerPortSpecs{ port.NewPortSpec("c1", "p1", 1234), port.NewPortSpec("c2", "p2", 1236), }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.ann) require.NoError(t, err) assert.Equal(t, u.e, u.specs.Match(pf)) }) } } ================================================ FILE: internal/port/pf.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port import ( "errors" "fmt" "regexp" "strings" "k8s.io/apimachinery/pkg/util/intstr" ) const ( // K9sAutoPortForwardsKey represents an auto portforwards annotation. K9sAutoPortForwardsKey = "k9scli.io/auto-port-forwards" // K9sPortForwardsKey represents a portforwards annotation. K9sPortForwardsKey = "k9scli.io/port-forwards" ) var ( pfRX = regexp.MustCompile(`\A([\w-]+)::(\d*):?(\d*|[\w-]*)/?(\d+)?\z`) pfPlainRX = regexp.MustCompile(`\A(\d*):?(\d*|[\w-]*)\z`) ) // PFAnn represents a portforward annotation value. // Shape: container/portname|portNum:localPort type PFAnn struct { Container string ContainerPort intstr.IntOrString LocalPort string containerPortNum string } func ParsePlainPF(ann string) (*PFAnn, error) { if ann == "" { return nil, fmt.Errorf("invalid annotation %q", ann) } var pf PFAnn mm := pfPlainRX.FindStringSubmatch(strings.TrimSpace(ann)) if len(mm) < 3 { return nil, fmt.Errorf("invalid plain port-forward %s", ann) } if mm[2] == "" { pf.ContainerPort = intstr.Parse(mm[1]) pf.LocalPort = mm[1] return &pf, nil } pf.LocalPort, pf.ContainerPort = mm[1], intstr.Parse(mm[2]) return &pf, nil } // ParsePF hydrate a portforward annotation from string. func ParsePF(ann string) (*PFAnn, error) { if pf, err := ParsePlainPF(ann); err == nil { return pf, nil } var pf PFAnn if mm := pfPlainRX.FindStringSubmatch(strings.TrimSpace(ann)); len(mm) == 3 { pf.containerPortNum = mm[0] } r := pfRX.FindStringSubmatch(strings.TrimSpace(ann)) if len(r) < 4 { return &pf, fmt.Errorf("invalid port-forward specification %s", ann) } pf.Container = r[1] pf.LocalPort, pf.ContainerPort = r[2], intstr.Parse(r[3]) if r[3] == "" { pf.ContainerPort = intstr.Parse(pf.LocalPort) } // Testing only! if len(r) == 5 && r[4] != "" { pf.containerPortNum = r[4] } if pf.LocalPort == "" { pf.LocalPort = pf.containerPortNum } return &pf, nil } // Match checks if annotation matches any of the container ports. func (p *PFAnn) Match(ss ContainerPortSpecs) bool { for _, s := range ss { if s.Match(p) { p.containerPortNum = s.PortNum return true } } return false } func (p *PFAnn) AsSpec() string { s := p.Container + "::" if p.containerPortNum != "" { return s + p.containerPortNum } return s + p.LocalPort } // String dumps the annotation. func (p *PFAnn) String() string { return p.Container + "::" + p.LocalPort + ":" + p.containerPortNum } func (p *PFAnn) PortNum() (string, error) { if p.ContainerPort.Type == intstr.Int { return p.ContainerPort.String(), nil } if p.containerPortNum != "" { return p.containerPortNum, nil } return "", errors.New("no port number assigned") } func (p *PFAnn) ToTunnel(address string) (PortTunnel, error) { var pt PortTunnel port, err := p.PortNum() if err != nil { return pt, err } pt.Address, pt.Container = address, p.Container pt.ContainerPort, pt.LocalPort = port, p.LocalPort return pt, nil } ================================================ FILE: internal/port/pf_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port_test import ( "errors" "testing" "github.com/derailed/k9s/internal/port" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/intstr" ) func TestParsePF(t *testing.T) { uu := map[string]struct { exp string container string containerPort intstr.IntOrString localPort string e error }{ "full-numbs": { exp: "c1::4321:1234", container: "c1", containerPort: intstr.Parse("1234"), localPort: "4321", }, "full-named": { exp: "c1::4321:p1/1234", container: "c1", containerPort: intstr.Parse("p1"), localPort: "4321", }, "just-named": { exp: "c1::p1/1234", container: "c1", containerPort: intstr.Parse("p1"), localPort: "1234", }, "just-num": { exp: "c1::1234", container: "c1", containerPort: intstr.Parse("1234"), localPort: "1234", }, "plain-single": { exp: "1234", container: "", containerPort: intstr.Parse("1234"), localPort: "1234", }, "plain-full": { exp: "4321:1234", container: "", containerPort: intstr.Parse("1234"), localPort: "4321", }, "toast": { exp: "c1:4321:1234", e: errors.New("invalid port-forward specification c1:4321:1234"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.exp) assert.Equal(t, u.e, err) if err != nil { return } assert.Equal(t, u.container, pf.Container) assert.Equal(t, u.containerPort, pf.ContainerPort) assert.Equal(t, u.localPort, pf.LocalPort) }) } } func TestPFMatch(t *testing.T) { uu := map[string]struct { exp string specs port.ContainerPortSpecs err error e bool }{ "match": { exp: "c1::1234", specs: port.ContainerPortSpecs{ {Container: "c1", PortNum: "1234"}, }, e: true, }, "match-portnum": { exp: "c1::4321:1234", specs: port.ContainerPortSpecs{ {Container: "c1", PortNum: "1234"}, }, e: true, }, "no-match": { exp: "c1::1235", specs: port.ContainerPortSpecs{ {Container: "c1", PortNum: "1234"}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.exp) assert.Equal(t, u.err, err) if err != nil { return } assert.Equal(t, u.e, pf.Match(u.specs)) }) } } func TestPFPortNum(t *testing.T) { uu := map[string]struct { exp string err error e string }{ "port-name": { exp: "c1::4321:1234", e: "1234", }, "port-number": { exp: "c1::4321:1234", e: "1234", }, "missing-port-number": { exp: "c1::p1", err: errors.New("no port number assigned"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.exp) require.NoError(t, err) n, err := pf.PortNum() assert.Equal(t, u.err, err) if err != nil { return } assert.Equal(t, u.e, n) }) } } func TestPFToTunnel(t *testing.T) { uu := map[string]struct { exp string err error e port.PortTunnel }{ "port-name": { exp: "c1::p1/1234", e: port.PortTunnel{ Address: "blee", Container: "c1", LocalPort: "1234", ContainerPort: "1234", }, }, "port-numb": { exp: "c1::4321:1234", e: port.PortTunnel{ Address: "blee", Container: "c1", LocalPort: "4321", ContainerPort: "1234", }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.exp) require.NoError(t, err) pt, err := pf.ToTunnel("blee") assert.Equal(t, u.err, err) if err != nil { return } assert.Equal(t, u.e, pt) }) } } func TestPFString(t *testing.T) { uu := map[string]struct { exp string err error e string }{ "port-name": { exp: "c1::p1/1234", e: "c1::1234:1234", }, "port-numb": { exp: "c1::4321:1234/1234", e: "c1::4321:1234", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pf, err := port.ParsePF(u.exp) require.NoError(t, err) assert.Equal(t, u.e, pf.String()) }) } } ================================================ FILE: internal/port/pfs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port import ( "context" "fmt" "strings" ) // PortCheck checks if port is free on host. type PortChecker func(context.Context, PortTunnel) bool // PFAnns represents a collection of port forward annotations. type PFAnns []*PFAnn // ToPortSpec returns a container port and local port definitions. func (aa PFAnns) ToPortSpec(pp ContainerPortSpecs) (ports, localPorts string) { specs, lps := make([]string, 0, len(aa)), make([]string, 0, len(aa)) for _, a := range aa { specs = append(specs, a.AsSpec()) if a.LocalPort == "" { if spec, ok := pp.Find(a); ok { a.LocalPort = spec.PortNum } } if a.LocalPort != "" { lps = append(lps, a.LocalPort) } } return strings.Join(specs, ","), strings.Join(lps, ",") } func (aa PFAnns) ToTunnels(address string, _ ContainerPortSpecs, available PortChecker) (PortTunnels, error) { pts := make(PortTunnels, 0, len(aa)) for _, a := range aa { pt, err := a.ToTunnel(address) if err != nil { return pts, err } if !available(context.Background(), pt) { return pts, fmt.Errorf("port %s is not available on host", pt.LocalPort) } pts = append(pts, pt) } return pts, nil } // ParsePFs hydrates a collection of portforward annotations. func ParsePFs(ann string) (PFAnns, error) { ss := strings.Split(ann, ",") pp := make(PFAnns, 0, len(ss)) for _, s := range ss { f, err := ParsePF(s) if err != nil { return nil, err } pp = append(pp, f) } return pp, nil } func ToTunnels(address, specs, localPorts string) (PortTunnels, error) { pp, lps := strings.Split(specs, ","), strings.Split(localPorts, ",") if len(pp) != len(lps) { return nil, fmt.Errorf("spec to local port count mismatch. Expected %d but got %d", len(pp), len(lps)) } pts := make(PortTunnels, 0, len(pp)) for i, p := range pp { a, err := ParsePF(p) if err != nil { return nil, err } n, err := a.PortNum() if err != nil { return nil, err } pts = append(pts, PortTunnel{ Address: address, Container: a.Container, ContainerPort: n, LocalPort: lps[i], }) } return pts, nil } ================================================ FILE: internal/port/pfs_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port_test import ( "context" "errors" "testing" "github.com/derailed/k9s/internal/port" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/intstr" ) func TestParsePFs(t *testing.T) { uu := map[string]struct { spec string pfs port.PFAnns e error }{ "single": { spec: "c2::4321:1234", pfs: port.PFAnns{ {Container: "c2", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, }, }, "multi": { spec: "c1::4321:1234,c2::6666:6543", pfs: port.PFAnns{ {Container: "c1", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, {Container: "c2", ContainerPort: intstr.Parse("6543"), LocalPort: "6666"}, }, }, "spaces": { spec: " c1::4321:1234 , c2::6666:6543 ", pfs: port.PFAnns{ {Container: "c1", ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, {Container: "c2", ContainerPort: intstr.Parse("6543"), LocalPort: "6666"}, }, }, "plain-multi": { spec: "4321:1234, 6666:6543", pfs: port.PFAnns{ {ContainerPort: intstr.Parse("1234"), LocalPort: "4321"}, {ContainerPort: intstr.Parse("6543"), LocalPort: "6666"}, }, }, "toast": { spec: "c1::p1:1234,c2::4321", e: errors.New("invalid port-forward specification c1::p1:1234"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pfs, err := port.ParsePFs(u.spec) assert.Equal(t, u.e, err) if err != nil { return } assert.Equal(t, u.pfs, pfs) }) } } func TestPFsToTunnel(t *testing.T) { uu := map[string]struct { exp string specs port.ContainerPortSpecs pts port.PortTunnels e error }{ "single": { exp: "c2::4321:1234", specs: port.ContainerPortSpecs{ {Container: "c2", PortName: "p1", PortNum: "1234"}, }, pts: port.PortTunnels{ {Address: "fred", Container: "c2", ContainerPort: "1234", LocalPort: "4321"}, }, }, "hosed": { exp: "c2::p2", specs: port.ContainerPortSpecs{ {Container: "c2", PortName: "p1", PortNum: "1234"}, }, pts: port.PortTunnels{ {Address: "fred", Container: "c2", ContainerPort: "1234", LocalPort: "4321"}, }, e: errors.New("no port number assigned"), }, } f := func(context.Context, port.PortTunnel) bool { return true } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pfs, err := port.ParsePFs(u.exp) require.NoError(t, err) pts, err := pfs.ToTunnels("fred", u.specs, f) assert.Equal(t, u.e, err) if err != nil { return } assert.Equal(t, u.pts, pts) }) } } func TestPFsToPortSpec(t *testing.T) { uu := map[string]struct { exp string spec, port string specs port.ContainerPortSpecs e error }{ "single": { exp: "c2::4321:p2/1234", spec: "c2::1234", port: "4321", specs: port.ContainerPortSpecs{ {Container: "c2", PortNum: "1234"}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pfs, err := port.ParsePFs(u.exp) assert.Equal(t, u.e, err) if err != nil { return } spec, prt := pfs.ToPortSpec(u.specs) assert.Equal(t, u.spec, spec) assert.Equal(t, u.port, prt) }) } } func TestToTunnels(t *testing.T) { uu := map[string]struct { specs, ports string tunnels port.PortTunnels err error }{ "single": { specs: "c2::4321:p2/1234", ports: "4321", tunnels: port.PortTunnels{ { Address: "blee", LocalPort: "4321", Container: "c2", ContainerPort: "1234", }, }, }, "multi": { specs: "c1::5432:2345/2345,c2::4321:p2/1234", ports: "5432,4321", tunnels: port.PortTunnels{ { Address: "blee", LocalPort: "5432", Container: "c1", ContainerPort: "2345", }, { Address: "blee", LocalPort: "4321", Container: "c2", ContainerPort: "1234", }, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { tt, err := port.ToTunnels("blee", u.specs, u.ports) assert.Equal(t, u.err, err) if err != nil { return } assert.Equal(t, u.tunnels, tt) }) } } ================================================ FILE: internal/port/tunnel.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port import ( "context" "fmt" "log/slog" "net" "github.com/derailed/k9s/internal/slogs" ) // PortTunnels represents a collection of tunnels. type PortTunnels []PortTunnel // CheckAvailable checks if all port tunnels are available. func (t PortTunnels) CheckAvailable(ctx context.Context) error { for _, pt := range t { if !IsPortFree(ctx, pt) { return fmt.Errorf("port %s is not available on host", pt.LocalPort) } } return nil } // PortTunnel represents a host tunnel port mapper. type PortTunnel struct { Address, Container, LocalPort, ContainerPort string } // NewPortTunnel returns a new instance. func NewPortTunnel(a, co, lp, cp string) PortTunnel { return PortTunnel{ Address: a, Container: co, LocalPort: lp, ContainerPort: cp, } } // String dumps as string. func (t PortTunnel) String() string { return fmt.Sprintf("%s|%s|%s:%s", t.Address, t.Container, t.LocalPort, t.ContainerPort) } // PortMap returns a port mapping. func (t PortTunnel) PortMap() string { if t.LocalPort == "" { t.LocalPort = t.ContainerPort } return t.LocalPort + ":" + t.ContainerPort } // IsPortFree checks if a address/port pair is available on host. func IsPortFree(ctx context.Context, t PortTunnel) bool { var ncfg net.ListenConfig s, err := ncfg.Listen(ctx, "tcp", fmt.Sprintf("%s:%s", t.Address, t.LocalPort)) if err != nil { slog.Warn("Port is not available", slogs.Port, t.LocalPort, slogs.Address, t.Address) return false } return s.Close() == nil } ================================================ FILE: internal/port/tunnel_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package port_test import ( "testing" "github.com/derailed/k9s/internal/port" "github.com/stretchr/testify/assert" ) func TestPortTunnelMap(t *testing.T) { uu := map[string]struct { pt port.PortTunnel coPort, locPort string e string }{ "plain": { pt: port.PortTunnel{ Address: "localhost", LocalPort: "1234", ContainerPort: "4321", }, e: "1234:4321", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.pt.PortMap()) }) } } ================================================ FILE: internal/render/alias.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "slices" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) var defaultAliasHeader = model1.Header{ model1.HeaderColumn{Name: "RESOURCE"}, model1.HeaderColumn{Name: "GROUP"}, model1.HeaderColumn{Name: "VERSION"}, model1.HeaderColumn{Name: "COMMAND"}, } // Alias renders an aliases to screen. type Alias struct { Base } // Header returns a header row. func (Alias) Header(string) model1.Header { return defaultAliasHeader } // Render renders a K8s resource to screen. // BOZO!! Pass in a row with pre-alloc fields?? func (Alias) Render(o any, _ string, r *model1.Row) error { a, ok := o.(AliasRes) if !ok { return fmt.Errorf("expected AliasRes, but got %T", o) } slices.Sort(a.Aliases) r.ID = a.GVR.String() r.Fields = append(r.Fields, a.GVR.R(), a.GVR.G(), a.GVR.V(), strings.Join(a.Aliases, " "), ) return nil } // ---------------------------------------------------------------------------- // Helpers... // AliasRes represents an alias resource. type AliasRes struct { GVR *client.GVR Aliases []string } // GetObjectKind returns a schema object. func (AliasRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (a AliasRes) DeepCopyObject() runtime.Object { return a } ================================================ FILE: internal/render/alias_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAliasColorer(t *testing.T) { var a render.Alias h := model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, } r := model1.Row{ID: "g/v/r", Fields: model1.Fields{"r", "blee", "g"}} uu := map[string]struct { ns string re model1.RowEvent e tcell.Color }{ "addAll": { ns: client.NamespaceAll, re: model1.RowEvent{Kind: model1.EventAdd, Row: r}, e: tcell.ColorBlue, }, "deleteAll": { ns: client.NamespaceAll, re: model1.RowEvent{Kind: model1.EventDelete, Row: r}, e: tcell.ColorGray, }, "updateAll": { ns: client.NamespaceAll, re: model1.RowEvent{Kind: model1.EventUpdate, Row: r}, e: tcell.ColorDefault, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, a.ColorerFunc()(u.ns, h, &u.re)) }) } } func TestAliasHeader(t *testing.T) { h := model1.Header{ model1.HeaderColumn{Name: "RESOURCE"}, model1.HeaderColumn{Name: "GROUP"}, model1.HeaderColumn{Name: "VERSION"}, model1.HeaderColumn{Name: "COMMAND"}, } var a render.Alias assert.Equal(t, h, a.Header("ns-1")) assert.Equal(t, h, a.Header(client.NamespaceAll)) } func TestAliasRender(t *testing.T) { var a render.Alias o := render.AliasRes{ GVR: client.NewGVR("fred/v1/blee"), Aliases: []string{"a", "b", "c"}, } var r model1.Row require.NoError(t, a.Render(o, "fred/v1/blee", &r)) assert.Equal(t, model1.Row{ ID: "fred/v1/blee", Fields: model1.Fields{"blee", "fred", "v1", "a b c"}, }, r) } func BenchmarkAlias(b *testing.B) { o := render.AliasRes{ GVR: client.NewGVR("fred/v1/blee"), Aliases: []string{"a", "b", "c"}, } var a render.Alias b.ResetTimer() b.ReportAllocs() for range b.N { var r model1.Row _ = a.Render(o, "ns-1", &r) } } ================================================ FILE: internal/render/base.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "log/slog" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" ) // DecoratorFunc decorates a string. type DecoratorFunc func(string) string // AgeDecorator represents a timestamped as human column. var AgeDecorator = toAgeHuman type Base struct { vs *config.ViewSetting specs ColumnSpecs includeObj bool } func (b *Base) SetIncludeObject(f bool) { b.includeObj = f } // IsGeneric identifies a generic handler. func (*Base) IsGeneric() bool { return false } func (b *Base) doHeader(dh model1.Header) model1.Header { if b.specs.isEmpty() { return dh } return b.specs.Header(dh) } // SetViewSetting sets custom view settings if any. func (b *Base) SetViewSetting(vs *config.ViewSetting) { var cols []string b.vs = vs if vs != nil { cols = vs.Columns } specs, err := NewColsSpecs(cols...).parseSpecs() if err != nil { slog.Error("Unable to grok custom columns", slogs.Error, err) return } b.specs = specs } // ColorerFunc colors a resource row. func (*Base) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Happy returns true if resource is happy, false otherwise. func (*Base) Happy(string, *model1.Row) bool { return true } // Healthy checks if the resource is healthy. func (*Base) Healthy(context.Context, any) error { return nil } ================================================ FILE: internal/render/benchmark.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "fmt" "os" "regexp" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/derailed/tview" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) var ( totalRx = regexp.MustCompile(`Total:\s+([0-9.]+)\ssecs`) reqRx = regexp.MustCompile(`Requests/sec:\s+([0-9.]+)`) okRx = regexp.MustCompile(`\[2\d{2}\]\s+(\d+)\s+responses`) errRx = regexp.MustCompile(`\[[45]\d{2}\]\s+(\d+)\s+responses`) toastRx = regexp.MustCompile(`Error distribution`) ) // Benchmark renders a benchmarks to screen. type Benchmark struct { Base } // ColorerFunc colors a resource row. func (Benchmark) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { if !model1.IsValid(ns, h, re.Row) { return model1.ErrColor } return tcell.ColorPaleGreen } } // Header returns a header row. func (Benchmark) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "TIME"}, model1.HeaderColumn{Name: "REQ/S", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "2XX", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "4XX/5XX", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "REPORT"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. func (b Benchmark) Render(o any, ns string, r *model1.Row) error { bench, ok := o.(BenchInfo) if !ok { return fmt.Errorf("no benchmarks available %T", o) } data, err := b.readFile(bench.Path) if err != nil { return fmt.Errorf("unable to load bench file %s", bench.Path) } r.ID = bench.Path r.Fields = make(model1.Fields, len(b.Header(ns))) if err := b.initRow(r.Fields, bench.File); err != nil { return err } b.augmentRow(r.Fields, data) r.Fields[8] = AsStatus(b.diagnose(ns, r.Fields)) return nil } // Happy returns true if resource is happy, false otherwise. func (Benchmark) diagnose(ns string, ff model1.Fields) error { statusCol := 3 if !client.IsAllNamespaces(ns) { statusCol-- } if len(ff) < statusCol { return nil } if ff[statusCol] != "pass" { return errors.New("failed benchmark") } return nil } // ---------------------------------------------------------------------------- // Helpers... func (Benchmark) readFile(file string) (string, error) { data, err := os.ReadFile(file) if err != nil { return "", err } return string(data), nil } func (Benchmark) initRow(row model1.Fields, f os.FileInfo) error { tokens := strings.Split(f.Name(), "_") if len(tokens) < 2 { return fmt.Errorf("invalid file name %s", f.Name()) } row[0] = tokens[0] row[1] = tokens[1] row[7] = f.Name() row[9] = ToAge(metav1.Time{Time: f.ModTime()}) return nil } func (b Benchmark) augmentRow(fields model1.Fields, data string) { if data == "" { return } col := 2 fields[col] = "pass" mf := toastRx.FindAllStringSubmatch(data, 1) if len(mf) > 0 { fields[col] = "fail" } col++ mt := totalRx.FindAllStringSubmatch(data, 1) if len(mt) > 0 { fields[col] = mt[0][1] } col++ mr := reqRx.FindAllStringSubmatch(data, 1) if len(mr) > 0 { fields[col] = mr[0][1] } col++ ms := okRx.FindAllStringSubmatch(data, -1) fields[col] = b.countReq(ms) col++ me := errRx.FindAllStringSubmatch(data, -1) fields[col] = b.countReq(me) } func (Benchmark) countReq(rr [][]string) string { if len(rr) == 0 { return "0" } var sum int for _, m := range rr { if m, err := strconv.Atoi(m[1]); err == nil { sum += m } } return AsThousands(int64(sum)) } // BenchInfo represents benchmark run info. type BenchInfo struct { File os.FileInfo Path string } // GetObjectKind returns a schema object. func (BenchInfo) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (b BenchInfo) DeepCopyObject() runtime.Object { return b } ================================================ FILE: internal/render/benchmark_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "log/slog" "os" "testing" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestAugmentRow(t *testing.T) { uu := map[string]struct { file string e model1.Fields }{ "cool": { "testdata/b1.txt", model1.Fields{"pass", "3.3544", "29.8116", "100", "0"}, }, "2XX": { "testdata/b4.txt", model1.Fields{"pass", "3.3544", "29.8116", "160", "0"}, }, "4XX/5XX": { "testdata/b2.txt", model1.Fields{"pass", "3.3544", "29.8116", "100", "12"}, }, "toast": { "testdata/b3.txt", model1.Fields{"fail", "2.3688", "35.4606", "0", "0"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { data, err := os.ReadFile(u.file) require.NoError(t, err) fields := make(model1.Fields, 8) b := Benchmark{} b.augmentRow(fields, string(data)) assert.Equal(t, u.e, fields[2:7]) }) } } ================================================ FILE: internal/render/cm.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // ConfigMap renders a K8s ConfigMap to screen. type ConfigMap struct { Base } // Header returns a header row. func (m ConfigMap) Header(_ string) model1.Header { return m.doHeader(defaultCMHeader) } var defaultCMHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "DATA"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Render renders a K8s resource to screen. func (m ConfigMap) Render(o any, _ string, row *model1.Row) error { if err := m.defaultRow(o, row); err != nil { return err } if m.specs.isEmpty() { return nil } cols, err := m.specs.realize(o.(*unstructured.Unstructured), defaultCMHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (ConfigMap) defaultRow(o any, r *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected *Unstructured, but got %T", o) } var cm v1.ConfigMap err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cm) if err != nil { return err } r.ID = client.FQN(cm.Namespace, cm.Name) r.Fields = model1.Fields{ cm.Namespace, cm.Name, strconv.Itoa(len(cm.Data)), "", ToAge(cm.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/container.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) const falseStr = "false" // ContainerWithMetrics represents a container and it's metrics. type ContainerWithMetrics interface { // Container returns the container Container() *v1.Container // ContainerStatus returns the current container status. ContainerStatus() *v1.ContainerStatus // Metrics returns the container metrics. Metrics() *mv1beta1.ContainerMetrics // Age returns the pod age. Age() metav1.Time // IsInit indicates a init container. IsInit() bool } // Container renders a K8s Container to screen. type Container struct { Base } // ColorerFunc colors a resource row. func (Container) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("STATE", true) if !ok { return c } switch strings.TrimSpace(re.Row.Fields[idx]) { case Pending: return model1.PendingColor case ContainerCreating, PodInitializing: return model1.AddColor case Terminating, Initialized: return model1.HighlightColor case Completed: return model1.CompletedColor case Running: return c default: return model1.ErrColor } } } // Header returns a header row. func (Container) Header(_ string) model1.Header { return defaultCOHeader } // Header returns a header row. var defaultCOHeader = model1.Header{ model1.HeaderColumn{Name: "IDX"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "PF"}, model1.HeaderColumn{Name: "IMAGE"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "STATE"}, 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: "CPU/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}}, } // Render renders a K8s resource to screen. func (c Container) Render(o any, _ string, row *model1.Row) error { cr, ok := o.(ContainerRes) if !ok { return fmt.Errorf("expected ContainerRes, but got %T", o) } return c.defaultRow(cr, row) } func (c Container) defaultRow(cr ContainerRes, r *model1.Row) error { 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)) } r.ID = cr.Container.Name r.Fields = model1.Fields{ cr.Idx, cr.Container.Name, "●", cr.Container.Image, ready, state, restarts, probe(cr.Container.LivenessProbe) + ":" + probe(cr.Container.ReadinessProbe) + ":" + probe(cr.Container.StartupProbe), toMc(cur.cpu), toMc(res.cpu) + ":" + toMc(res.lcpu), 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), } return nil } // Happy returns true if resource is happy, false otherwise. func (Container) diagnose(state, ready string) error { if state == "Completed" { return nil } if ready == falseStr { return errors.New("container is not ready") } return nil } // ---------------------------------------------------------------------------- // Helpers... func containerRequests(co *v1.Container) v1.ResourceList { req := co.Resources.Requests if len(req) != 0 { return req } lim := co.Resources.Limits if len(lim) != 0 { return lim } return nil } func gatherContainerMX(co *v1.Container, mx *mv1beta1.ContainerMetrics) (c, r metric) { rList, lList := containerRequests(co), co.Resources.Limits if q := rList.Cpu(); q != nil { r.cpu = q.MilliValue() } if q := lList.Cpu(); q != nil { r.lcpu = q.MilliValue() } if q := rList.Memory(); q != nil { r.mem = q.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 q := mx.Usage.Cpu(); q != nil { c.cpu = q.MilliValue() } if q := mx.Usage.Memory(); q != nil { c.mem = q.Value() } } return } // ToContainerPorts returns container ports as a string. func ToContainerPorts(pp []v1.ContainerPort) string { ports := make([]string, len(pp)) for i, p := range pp { if p.Name != "" { ports[i] = p.Name + ":" } ports[i] += strconv.Itoa(int(p.ContainerPort)) if p.Protocol != "TCP" { ports[i] += "╱" + string(p.Protocol) } } return strings.Join(ports, ",") } // ToContainerState returns container state as a string. func ToContainerState(s v1.ContainerState) string { switch { case s.Waiting != nil: if s.Waiting.Reason != "" { return s.Waiting.Reason } return "Waiting" case s.Terminated != nil: if s.Terminated.Reason != "" { return s.Terminated.Reason } return "Terminating" case s.Running != nil: return "Running" default: return MissingValue } } const ( on = "on" off = "off" ) func probe(p *v1.Probe) string { if p == nil { return off } return on } // ContainerRes represents a container and its metrics. type ContainerRes struct { Container *v1.Container Status *v1.ContainerStatus MX *mv1beta1.ContainerMetrics Idx string Age metav1.Time } // GetObjectKind returns a schema object. func (ContainerRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (c ContainerRes) DeepCopyObject() runtime.Object { return c } ================================================ FILE: internal/render/container_int_test.go ================================================ // 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) }) } } ================================================ FILE: internal/render/container_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "fmt" "testing" "time" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" 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 TestContainer(t *testing.T) { var c render.Container cres := render.ContainerRes{ Container: makeContainer(), Status: makeContainerStatus(), MX: makeContainerMetrics(), Age: makeAge(), } var r model1.Row require.NoError(t, c.Render(cres, "blee", &r)) assert.Equal(t, "fred", r.ID) assert.Equal(t, model1.Fields{ "", "fred", "●", "img", "false", "Running", "0", "off:off:off", "10", "20:20", "50", "50", "20", "100:100", "20", "20", "0:0", "", "container is not ready", }, r.Fields[:len(r.Fields)-1], ) } func BenchmarkContainerRender(b *testing.B) { var ( c render.Container r model1.Row cres = render.ContainerRes{ Container: makeContainer(), Status: makeContainerStatus(), MX: makeContainerMetrics(), Age: makeAge(), } ) b.ReportAllocs() b.ResetTimer() for range b.N { _ = c.Render(cres, "blee", &r) } } // ---------------------------------------------------------------------------- // Helpers... func toQty(s string) resource.Quantity { q, _ := resource.ParseQuantity(s) return q } func makeContainerMetrics() *mv1beta1.ContainerMetrics { return &mv1beta1.ContainerMetrics{ Name: "fred", Usage: v1.ResourceList{ v1.ResourceCPU: toQty("10m"), v1.ResourceMemory: toQty("20Mi"), }, } } func makeAge() metav1.Time { return metav1.Time{Time: testTime()} } func makeContainer() *v1.Container { return &v1.Container{ Name: "fred", Image: "img", Resources: v1.ResourceRequirements{ Limits: v1.ResourceList{ v1.ResourceCPU: toQty("20m"), v1.ResourceMemory: toQty("100Mi"), }, }, Env: []v1.EnvVar{ { Name: "fred", Value: "1", ValueFrom: &v1.EnvVarSource{ ConfigMapKeyRef: &v1.ConfigMapKeySelector{Key: "blee"}, }, }, }, } } func makeContainerStatus() *v1.ContainerStatus { return &v1.ContainerStatus{ Name: "fred", State: v1.ContainerState{Running: &v1.ContainerStateRunning{}}, RestartCount: 0, } } func testTime() time.Time { t, err := time.Parse(time.RFC3339, "2018-12-14T10:36:43.326972-07:00") if err != nil { fmt.Println("TestTime Failed", err) } return t } ================================================ FILE: internal/render/context.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "log/slog" "os" "strings" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/clientcmd/api" ) // Context renders a K8s ConfigMap to screen. type Context struct { Base } // ColorerFunc colors a resource row. func (Context) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, r *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, r) if strings.Contains(strings.TrimSpace(r.Row.Fields[0]), "*") { return model1.HighlightColor } return c } } // Header returns a header row. func (Context) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "CLUSTER"}, model1.HeaderColumn{Name: "AUTHINFO"}, model1.HeaderColumn{Name: "NAMESPACE"}, } } // Render renders a K8s resource to screen. func (Context) Render(o any, _ string, r *model1.Row) error { ctx, ok := o.(*NamedContext) if !ok { return fmt.Errorf("expected *NamedContext, but got %T", o) } name := ctx.Name if ctx.IsCurrentContext(ctx.Name) { name += "(*)" } r.ID = ctx.Name r.Fields = model1.Fields{ name, ctx.Context.Cluster, ctx.Context.AuthInfo, ctx.Context.Namespace, } return nil } // Helpers... // NamedContext represents a named cluster context. type NamedContext struct { Name string Context *api.Context Config ContextNamer } // ContextNamer represents a named context. type ContextNamer interface { CurrentContextName() (string, error) } // NewNamedContext returns a new named context. func NewNamedContext(c ContextNamer, n string, ctx *api.Context) *NamedContext { return &NamedContext{Name: n, Context: ctx, Config: c} } // IsCurrentContext return the active context name. func (c *NamedContext) IsCurrentContext(n string) bool { cl, err := c.Config.CurrentContextName() if err != nil { slog.Error("Fail to retrieve current context. Exiting!") os.Exit(1) } return cl == n } // GetObjectKind returns a schema object. func (*NamedContext) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (c *NamedContext) DeepCopyObject() runtime.Object { return c } ================================================ FILE: internal/render/context_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/tools/clientcmd/api" ) func TestContextHeader(t *testing.T) { var c render.Context assert.Len(t, c.Header(""), 4) } func TestContextRender(t *testing.T) { uu := map[string]struct { ctx *render.NamedContext e model1.Row }{ "active": { ctx: &render.NamedContext{ Name: "c1", Context: &api.Context{ LocationOfOrigin: "fred", Cluster: "c1", AuthInfo: "u1", Namespace: "ns1", }, Config: &config{}, }, e: model1.Row{ ID: "c1", Fields: model1.Fields{"c1", "c1", "u1", "ns1"}, }, }, } var r render.Context for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { row := model1.NewRow(4) err := r.Render(uc.ctx, "", &row) require.NoError(t, err) assert.Equal(t, uc.e, row) }) } } // ---------------------------------------------------------------------------- // Helpers... type config struct{} func (config) CurrentContextName() (string, error) { return "fred", nil } ================================================ FILE: internal/render/cr.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // ClusterRole renders a K8s ClusterRole to screen. type ClusterRole struct { Base } // Header returns a header row. func (c ClusterRole) Header(_ string) model1.Header { return c.doHeader(defaultCRHeader) } // Header returns a header rbw. var defaultCRHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Render renders a K8s resource to screen. func (p ClusterRole) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting Unstructured, but got %T", o) } if err := p.defaultRow(raw, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(raw, defaultCRHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (ClusterRole) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var cr rbacv1.ClusterRole err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cr) if err != nil { return err } r.ID = client.FQN("-", cr.Name) r.Fields = model1.Fields{ cr.Name, mapToStr(cr.Labels), ToAge(cr.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/cr_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClusterRoleRender(t *testing.T) { c := render.ClusterRole{} r := model1.NewRow(2) require.NoError(t, c.Render(load(t, "cr"), "-", &r)) assert.Equal(t, "-/blee", r.ID) assert.Equal(t, model1.Fields{"blee"}, r.Fields[:1]) } ================================================ FILE: internal/render/crb.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultCRBHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "CLUSTERROLE"}, model1.HeaderColumn{Name: "SUBJECT-KIND"}, model1.HeaderColumn{Name: "SUBJECTS"}, model1.HeaderColumn{Name: "LABELS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // ClusterRoleBinding renders a K8s ClusterRoleBinding to screen. type ClusterRoleBinding struct { Base } // Header returns a header row. func (c ClusterRoleBinding) Header(_ string) model1.Header { return c.doHeader(defaultCRBHeader) } // Render renders a K8s resource to screen. func (c ClusterRoleBinding) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := c.defaultRow(raw, row); err != nil { return err } if c.specs.isEmpty() { return nil } cols, err := c.specs.realize(raw, defaultCRBHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (ClusterRoleBinding) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var crb rbacv1.ClusterRoleBinding err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &crb) if err != nil { return err } kind, ss := renderSubjects(crb.Subjects) r.ID = client.FQN("-", crb.Name) r.Fields = model1.Fields{ crb.Name, crb.RoleRef.Name, kind, ss, mapToStr(crb.Labels), ToAge(crb.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/crb_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestClusterRoleBindingRender(t *testing.T) { c := render.ClusterRoleBinding{} r := model1.NewRow(5) require.NoError(t, c.Render(load(t, "crb"), "-", &r)) assert.Equal(t, "-/blee", r.ID) assert.Equal(t, model1.Fields{"blee", "blee", "User", "fernand"}, r.Fields[:4]) } ================================================ FILE: internal/render/crd.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultCRDHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "GROUP"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "VERSIONS"}, model1.HeaderColumn{Name: "SCOPE"}, model1.HeaderColumn{Name: "ALIASES", Attrs: model1.Attrs{Wide: 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}}, } // CustomResourceDefinition renders a K8s CustomResourceDefinition to screen. type CustomResourceDefinition struct { Base } // Header returns a header row. func (c CustomResourceDefinition) Header(_ string) model1.Header { return c.doHeader(defaultCRDHeader) } // Render renders a K8s resource to screen. func (c CustomResourceDefinition) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := c.defaultRow(raw, row); err != nil { return err } if c.specs.isEmpty() { return nil } cols, err := c.specs.realize(raw, defaultCRDHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (c CustomResourceDefinition) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var crd v1.CustomResourceDefinition err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &crd) if err != nil { return err } versions := make([]string, 0, len(crd.Spec.Versions)) for _, v := range crd.Spec.Versions { if v.Served { n := v.Name if v.Deprecated { n += "!" } versions = append(versions, n) } } if len(versions) == 0 { slog.Warn("Unable to assert CRD versions", slogs.FQN, crd.Name) } r.ID = client.MetaFQN(&crd.ObjectMeta) r.Fields = model1.Fields{ crd.Spec.Names.Plural, crd.Spec.Group, crd.Spec.Names.Kind, naStrings(versions), string(crd.Spec.Scope), naStrings(crd.Spec.Names.ShortNames), mapToIfc(crd.GetLabels()), AsStatus(c.diagnose(crd.Name, crd.Spec.Versions)), ToAge(crd.GetCreationTimestamp()), } return nil } func (CustomResourceDefinition) diagnose(n string, vv []v1.CustomResourceDefinitionVersion) error { if len(vv) == 0 { return fmt.Errorf("unable to assert CRD servers versions for %s", n) } var ( ee []error served bool ) for _, v := range vv { if v.Served { served = true } if v.Deprecated { if v.DeprecationWarning != nil { ee = append(ee, fmt.Errorf("%s", *v.DeprecationWarning)) } else { ee = append(ee, fmt.Errorf("%s[%s] is deprecated", n, v.Name)) } } } if !served { ee = append(ee, fmt.Errorf("CRD %s is no longer served by the api server", n)) } if len(ee) == 0 { return nil } errs := make([]string, 0, len(ee)) for _, e := range ee { errs = append(errs, e.Error()) } return errors.New(strings.Join(errs, " - ")) } ================================================ FILE: internal/render/crd_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCustomResourceDefinitionRender(t *testing.T) { c := render.CustomResourceDefinition{} r := model1.NewRow(2) require.NoError(t, c.Render(load(t, "crd"), "", &r)) assert.Equal(t, "-/adapters.config.istio.io", r.ID) assert.Equal(t, "adapters", r.Fields[0]) assert.Equal(t, "config.istio.io", r.Fields[1]) } ================================================ FILE: internal/render/cronjob.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultCJHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "SCHEDULE"}, model1.HeaderColumn{Name: "SUSPEND"}, model1.HeaderColumn{Name: "ACTIVE"}, model1.HeaderColumn{Name: "LAST_SCHEDULE", Attrs: model1.Attrs{Time: true}}, model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: 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}}, } // CronJob renders a K8s CronJob to screen. type CronJob struct { Base } // Header returns a header row. func (c CronJob) Header(_ string) model1.Header { return c.doHeader(defaultCJHeader) } // Render renders a K8s resource to screen. func (c CronJob) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := c.defaultRow(raw, row); err != nil { return err } if c.specs.isEmpty() { return nil } cols, err := c.specs.realize(raw, defaultCJHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (CronJob) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var cj batchv1.CronJob err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &cj) if err != nil { return err } lastScheduled := "" if cj.Status.LastScheduleTime != nil { lastScheduled = ToAge(*cj.Status.LastScheduleTime) } r.ID = client.MetaFQN(&cj.ObjectMeta) r.Fields = model1.Fields{ cj.Namespace, cj.Name, computeVulScore(cj.Namespace, cj.Labels, &cj.Spec.JobTemplate.Spec.Template.Spec), cj.Spec.Schedule, boolPtrToStr(cj.Spec.Suspend), strconv.Itoa(len(cj.Status.Active)), lastScheduled, jobSelector(&cj.Spec.JobTemplate.Spec), podContainerNames(&cj.Spec.JobTemplate.Spec.Template.Spec, true), podImageNames(&cj.Spec.JobTemplate.Spec.Template.Spec, true), mapToStr(cj.Labels), "", ToAge(cj.GetCreationTimestamp()), } return nil } // Helpers func jobSelector(spec *batchv1.JobSpec) string { if spec.Selector == nil { return MissingValue } if len(spec.Selector.MatchLabels) > 0 { return mapToStr(spec.Selector.MatchLabels) } if len(spec.Selector.MatchExpressions) == 0 { return "" } ss := make([]string, 0, len(spec.Selector.MatchExpressions)) for _, e := range spec.Selector.MatchExpressions { ss = append(ss, e.String()) } return strings.Join(ss, " ") } func podContainerNames(spec *v1.PodSpec, includeInit bool) string { cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) if includeInit { for i := range spec.InitContainers { cc = append(cc, spec.InitContainers[i].Name) } } for i := range spec.Containers { cc = append(cc, spec.Containers[i].Name) } return strings.Join(cc, ",") } func podImageNames(spec *v1.PodSpec, includeInit bool) string { cc := make([]string, 0, len(spec.Containers)+len(spec.InitContainers)) if includeInit { for i := range spec.InitContainers { cc = append(cc, spec.InitContainers[i].Image) } } for i := range spec.Containers { cc = append(cc, spec.Containers[i].Image) } return strings.Join(cc, ",") } ================================================ FILE: internal/render/cronjob_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCronJobRender(t *testing.T) { c := render.CronJob{} r := model1.NewRow(6) require.NoError(t, c.Render(load(t, "cj"), "", &r)) assert.Equal(t, "default/hello", r.ID) assert.Equal(t, model1.Fields{"default", "hello", "n/a", "*/1 * * * *", "false", "0"}, r.Fields[:6]) } ================================================ FILE: internal/render/cust_col.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "log/slog" "regexp" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tview" "k8s.io/kubectl/pkg/cmd/get" ) var fullRX = regexp.MustCompile(`^([\w\s%/-]+):?([\w\W]*?)\|?([NTWSLRH]{0,3})$`) type colAttr byte const ( number colAttr = 'N' age colAttr = 'T' wide colAttr = 'W' show colAttr = 'S' alignLeft colAttr = 'L' alignRight colAttr = 'R' hide colAttr = 'H' ) type colAttrs struct { align int mx bool mxc bool mxm bool time bool wide bool show bool hide bool capacity bool } func newColFlags(flags string) colAttrs { c := colAttrs{ align: tview.AlignLeft, wide: false, } for _, b := range []byte(flags) { switch colAttr(b) { case hide: c.hide = true case wide: c.wide, c.show = true, false case show: c.show, c.wide = true, false case alignLeft: c.align = tview.AlignLeft case alignRight: c.align = tview.AlignRight case age: c.time = true case number: c.capacity, c.align = true, tview.AlignRight default: slog.Warn("Unknown column attribute", slogs.Attr, b) } } return c } type colDef struct { colAttrs name string idx int spec string } func parse(s string) (colDef, error) { mm := fullRX.FindStringSubmatch(s) if len(mm) == 4 { spec, err := get.RelaxedJSONPathExpression(mm[2]) if err != nil { return colDef{idx: -1}, err } return colDef{ name: mm[1], idx: -1, spec: spec, colAttrs: newColFlags(mm[3]), }, nil } return colDef{idx: -1}, fmt.Errorf("invalid column definition %q", s) } func (c colDef) toHeaderCol() model1.HeaderColumn { return model1.HeaderColumn{ Name: c.name, Attrs: model1.Attrs{ Align: c.align, Wide: c.wide, Show: c.show, Time: c.time, MX: c.mx, MXC: c.mxc, MXM: c.mxm, Hide: c.hide, Capacity: c.capacity, }, } } ================================================ FILE: internal/render/cust_col_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "testing" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestCustCol_parse(t *testing.T) { uu := map[string]struct { s string err error e colDef }{ "empty": { err: errors.New(`invalid column definition ""`), }, "plain": { s: "fred", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, }, }, }, "plain-wide": { s: "fred|W", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, wide: true, }, }, }, "plain-hide": { s: "fred|WH", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, wide: true, hide: true, }, }, }, "plain-show": { s: "fred|S", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, show: true, }, }, }, "age": { s: "AGE|TR", e: colDef{ name: "AGE", idx: -1, colAttrs: colAttrs{ align: tview.AlignRight, time: true, }, }, }, "plain-wide-right": { s: "fred|WR", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignRight, wide: true, }, }, }, "complex": { s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip", e: colDef{ name: "BLEE", idx: -1, spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", colAttrs: colAttrs{ align: tview.AlignLeft, }, }, }, "complex-wide": { s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip|WR", e: colDef{ name: "BLEE", idx: -1, spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", colAttrs: colAttrs{ align: tview.AlignRight, wide: true, }, }, }, "full-complex-wide": { s: "BLEE:.spec.addresses[?(@.type == 'CiliumInternalIP')].ip|WR", e: colDef{ name: "BLEE", idx: -1, spec: "{.spec.addresses[?(@.type == 'CiliumInternalIP')].ip}", colAttrs: colAttrs{ align: tview.AlignRight, wide: true, }, }, }, "full-number-wide": { s: "fred:.metadata.name|NW", e: colDef{ name: "fred", idx: -1, spec: "{.metadata.name}", colAttrs: colAttrs{ align: tview.AlignRight, capacity: true, wide: true, }, }, }, "full-wide": { s: "fred:.metadata.name|RW", e: colDef{ name: "fred", idx: -1, spec: "{.metadata.name}", colAttrs: colAttrs{ align: tview.AlignRight, wide: true, }, }, }, "partial-time-no-wide": { s: "fred:.metadata.name|T", e: colDef{ name: "fred", idx: -1, spec: "{.metadata.name}", colAttrs: colAttrs{ align: tview.AlignLeft, time: true, }, }, }, "partial-no-type-no-wide": { s: "fred:.metadata.name", e: colDef{ name: "fred", idx: -1, spec: "{.metadata.name}", colAttrs: colAttrs{ align: tview.AlignLeft, }, }, }, "partial-no-type-wide": { s: "fred:.metadata.name|W", e: colDef{ name: "fred", idx: -1, spec: "{.metadata.name}", colAttrs: colAttrs{ align: tview.AlignLeft, wide: true, }, }, }, "toast": { s: "fred||.metadata.name|W", e: colDef{ name: "fred", idx: -1, spec: "{.||.metadata.name}", colAttrs: colAttrs{ align: tview.AlignLeft, wide: true, }, }, }, "toast-no-name": { s: ":.metadata.name.fred|TW", err: errors.New(`invalid column definition ":.metadata.name.fred|TW"`), }, "spec-column-typed": { s: `fred:.metadata.name.k8s:fred\.blee|TW`, e: colDef{ name: "fred", spec: `{.metadata.name.k8s:fred\.blee}`, idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, time: true, wide: true, }, }, }, "partial-no-spec-no-wide": { s: "fred|T", e: colDef{ name: "fred", idx: -1, colAttrs: colAttrs{ align: tview.AlignLeft, time: true, }, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { c, err := parse(u.s) if err != nil { assert.Equal(t, u.err, err) } else { assert.Equal(t, u.e, c) } }) } } ================================================ FILE: internal/render/cust_cols.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "fmt" "log/slog" "reflect" "strings" "time" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/itchyny/gojq" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/util/jsonpath" ) // ColsSpecs represents a collection of column specification ie NAME:spec|flags. type ColsSpecs []string // NewColsSpecs returns a new instance. func NewColsSpecs(cols ...string) ColsSpecs { return ColsSpecs(cols) } func (cc ColsSpecs) parseSpecs() (ColumnSpecs, error) { specs := make(ColumnSpecs, 0, len(cc)) for _, c := range cc { def, err := parse(c) if err != nil { return nil, err } specs = append(specs, ColumnSpec{ Header: def.toHeaderCol(), Spec: def.spec, }) } return specs, nil } // RenderedCols tracks a collection of column header and cust column parse expression. type RenderedCols []RenderedCol func (rr RenderedCols) hydrateRow(row *model1.Row) { ff := make(model1.Fields, 0, len(row.Fields)) for _, c := range rr { ff = append(ff, c.Value) } row.Fields = ff } // HasHeader checks if a given header is present in the collection. func (rr RenderedCols) HasHeader(n string) bool { for _, r := range rr { if r.has(n) { return true } } return false } // RenderedCol represents a column header and a column spec. type RenderedCol struct { Header model1.HeaderColumn Value string } // Has checks if the header column match the given name. func (r RenderedCol) has(n string) bool { return r.Header.Name == n } // ColumnSpec tracks a header column and an options cust column spec. type ColumnSpec struct { Header model1.HeaderColumn Spec string } // ColumnSpecs tracks a collection of column specs. type ColumnSpecs []ColumnSpec func (c ColumnSpecs) isEmpty() bool { return len(c) == 0 } // Header builds a new header that is a super set of custom and/or default header. func (cc ColumnSpecs) Header(rh model1.Header) model1.Header { hh := make(model1.Header, 0, len(cc)) for _, h := range cc { hh = append(hh, h.Header) } for _, h := range rh { if idx, ok := hh.IndexOf(h.Name, true); ok { hh[idx].Attrs = hh[idx].Merge(h.Attrs) continue } hh = append(hh, h) } return hh } func (cc ColumnSpecs) realize(o runtime.Object, rh model1.Header, row *model1.Row) (RenderedCols, error) { parsers := make([]*jsonpath.JSONPath, len(cc)) for ix := range cc { if cc[ix].Spec == "" { parsers[ix] = nil continue } parsers[ix] = jsonpath.New( fmt.Sprintf("column%d", ix), ).AllowMissingKeys(true) if err := parsers[ix].Parse(cc[ix].Spec); err != nil && !isJQSpec(cc[ix].Spec) { slog.Warn("Unable to parse custom column", slogs.Name, cc[ix].Header.Name, slogs.Error, err, ) } } vv, err := hydrate(o, cc, parsers, rh, row) if err != nil { return nil, err } for _, hc := range rh { if vv.HasHeader(hc.Name) { continue } if idx, ok := rh.IndexOf(hc.Name, true); ok { rc := RenderedCol{Header: hc, Value: row.Fields[idx]} rc.Header.Wide = true vv = append(vv, rc) } } return vv, nil } func hydrate(o runtime.Object, cc ColumnSpecs, parsers []*jsonpath.JSONPath, rh model1.Header, row *model1.Row) (RenderedCols, error) { cols := make(RenderedCols, len(parsers)) for idx := range parsers { parser := parsers[idx] if parser == nil { ix, ok := rh.IndexOf(cc[idx].Header.Name, true) if !ok { cols[idx] = RenderedCol{ Header: cc[idx].Header, Value: NAValue, } slog.Warn("Unable to find custom column", slogs.Name, cc[idx].Header.Name) continue } var v string if ix >= len(row.Fields) { v = NAValue } else { v = row.Fields[ix] } cols[idx] = RenderedCol{ Header: rh[ix], Value: v, } continue } if o == nil { cols[idx] = RenderedCol{ Header: cc[idx].Header, Value: NAValue, } continue } var ( vals [][]reflect.Value err error ) if unstructured, ok := o.(runtime.Unstructured); ok { if vals, ok := jqParse(cc[idx].Spec, unstructured.UnstructuredContent()); ok { cols[idx] = RenderedCol{ Header: cc[idx].Header, Value: vals, } continue } vals, err = parser.FindResults(unstructured.UnstructuredContent()) } else { rv := reflect.ValueOf(o) if !rv.IsValid() || (rv.Kind() == reflect.Ptr && rv.IsNil()) { cols[idx] = RenderedCol{ Header: cc[idx].Header, Value: NAValue, } continue } vals, err = parser.FindResults(rv.Elem().Interface()) } if err != nil { return nil, err } values := make([]string, 0, len(vals)) if len(vals) == 0 || len(vals[0]) == 0 { values = append(values, MissingValue) } for i := range vals { for j := range vals[i] { var ( strVal string v = vals[i][j].Interface() ) switch { case cc[idx].Header.MXC: switch k := v.(type) { case resource.Quantity: strVal = toMc(k.MilliValue()) case string: if q, err := resource.ParseQuantity(k); err == nil { strVal = toMc(q.MilliValue()) } } case cc[idx].Header.MXM: switch k := v.(type) { case resource.Quantity: strVal = toMi(k.MilliValue()) case string: if q, err := resource.ParseQuantity(k); err == nil { strVal = toMi(q.MilliValue()) } } case cc[idx].Header.Time: switch k := v.(type) { case string: if t, err := time.Parse(time.RFC3339, k); err == nil { strVal = ToAge(metav1.Time{Time: t}) } case metav1.Time: strVal = ToAge(k) } } if strVal == "" { strVal = fmt.Sprintf("%v", v) } values = append(values, strVal) } } cols[idx] = RenderedCol{ Header: cc[idx].Header, Value: strings.Join(values, ","), } } return cols, nil } func isJQSpec(spec string) bool { return len(strings.Split(spec, "|")) > 2 } func jqParse(spec string, o map[string]any) (string, bool) { if !isJQSpec(spec) { return "", false } exp := spec[1 : len(spec)-1] jq, err := gojq.Parse(exp) if err != nil { slog.Warn("Fail to parse JQ expression", slogs.JQExp, exp, slogs.Error, err) return "", false } rr := make([]string, 0, 10) iter := jq.Run(o) for v, ok := iter.Next(); ok; v, ok = iter.Next() { if e, cool := v.(error); cool && e != nil { if errors.Is(e, new(gojq.HaltError)) { break } slog.Error("JQ expression evaluation failed. Check your query", slogs.Error, e) continue } rr = append(rr, fmt.Sprintf("%v", v)) } if len(rr) == 0 { return "", false } return strings.Join(rr, ","), true } ================================================ FILE: internal/render/cust_cols_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "errors" "fmt" "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/util/jsonpath" ) func TestParseSpecs(t *testing.T) { uu := map[string]struct { cols ColsSpecs err error e ColumnSpecs }{ "empty": { e: ColumnSpecs{}, }, "plain": { cols: ColsSpecs{ "a", "b", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", }, }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "with-spec-plain": { cols: ColsSpecs{ "a", "b:.metadata.name", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", }, Spec: "{.metadata.name}", }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "with-spec-fq": { cols: ColsSpecs{ "a", "b:.metadata.name|NW", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", Attrs: model1.Attrs{ Wide: true, Capacity: true, Align: tview.AlignRight, }, }, Spec: "{.metadata.name}", }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "spec-type-no-wide": { cols: ColsSpecs{ "a", "b:.metadata.name|T", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", Attrs: model1.Attrs{ Time: true, }, }, Spec: "{.metadata.name}", }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "plain-wide": { cols: ColsSpecs{ "a", "b|W", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", Attrs: model1.Attrs{Wide: true}, }, }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "no-spec-kind-wide": { cols: ColsSpecs{ "a", "b|NW", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", Attrs: model1.Attrs{ Align: tview.AlignRight, Capacity: true, Wide: true, }, }, }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, "toast-spec": { cols: ColsSpecs{ "a", "b:{{crap.bozo}}|NW", "c", }, err: errors.New(`unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or '{.name1.name2}'`), }, "no-spec": { cols: ColsSpecs{ "a", "b|NW", "c", }, e: ColumnSpecs{ { Header: model1.HeaderColumn{ Name: "a", }, }, { Header: model1.HeaderColumn{ Name: "b", Attrs: model1.Attrs{Align: tview.AlignRight, Capacity: true, Wide: true}, }, }, { Header: model1.HeaderColumn{ Name: "c", }, }, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { cols, err := u.cols.parseSpecs() assert.Equal(t, u.err, err) assert.Equal(t, u.e, cols) }) } } func TestHydrateNilObject(t *testing.T) { cc := ColumnSpecs{ { Header: model1.HeaderColumn{Name: "test"}, Spec: "{.metadata.name}", }, } parser := jsonpath.New(fmt.Sprintf("column%d", 0)).AllowMissingKeys(true) err := parser.Parse("{.metadata.name}") require.NoError(t, err) parsers := []*jsonpath.JSONPath{parser} rh := model1.Header{ {Name: "test"}, } row := &model1.Row{ Fields: model1.Fields{"value1"}, } // Test with nil object - should not panic cols, err := hydrate(nil, cc, parsers, rh, row) require.NoError(t, err) assert.Len(t, cols, 1) assert.Equal(t, NAValue, cols[0].Value) } ================================================ FILE: internal/render/dir.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "fmt" "os" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Dir renders a directory entry to screen. type Dir struct{} // IsGeneric identifies a generic handler. func (Dir) IsGeneric() bool { return false } // Healthy checks if the resource is healthy. func (Dir) Healthy(context.Context, any) error { return nil } // ColorerFunc colors a resource row. func (Dir) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorCadetBlue } } func (Dir) SetViewSetting(*config.ViewSetting) {} // Header returns a header row. func (Dir) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, } } // Render renders a K8s resource to screen. // BOZO!! Pass in a row with pre-alloc fields?? func (Dir) Render(o any, _ string, r *model1.Row) error { d, ok := o.(DirRes) if !ok { return fmt.Errorf("expected DirRes, but got %T", o) } name := "🦄 " if d.Entry.IsDir() { name = "📁 " } name += d.Entry.Name() r.ID, r.Fields = d.Path, append(r.Fields, name) return nil } // ---------------------------------------------------------------------------- // Helpers... // DirRes represents an alias resource. type DirRes struct { Entry os.DirEntry Path string } // GetObjectKind returns a schema object. func (DirRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (d DirRes) DeepCopyObject() runtime.Object { return d } ================================================ FILE: internal/render/dp.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/derailed/tview" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultDPHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "UP-TO-DATE", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, 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}}, } // Deployment renders a K8s Deployment to screen. type Deployment struct { Base } // ColorerFunc colors a resource row. func (Deployment) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("READY", true) if !ok { return c } ready := strings.TrimSpace(re.Row.Fields[idx]) tt := strings.Split(ready, "/") if len(tt) == 2 && tt[1] == "0" { return model1.PendingColor } return c } } // Header returns a header row. func (d Deployment) Header(_ string) model1.Header { return d.doHeader(defaultDPHeader) } // Render renders a K8s resource to screen. func (d Deployment) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := d.defaultRow(raw, row); err != nil { return err } if d.specs.isEmpty() { return nil } cols, err := d.specs.realize(raw, defaultDPHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (d Deployment) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var dp appsv1.Deployment err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &dp) if err != nil { return err } var desired int32 if dp.Spec.Replicas != nil { desired = *dp.Spec.Replicas } r.ID = client.MetaFQN(&dp.ObjectMeta) r.Fields = model1.Fields{ dp.Namespace, dp.Name, computeVulScore(dp.Namespace, dp.Labels, &dp.Spec.Template.Spec), strconv.Itoa(int(dp.Status.AvailableReplicas)) + "/" + strconv.Itoa(int(desired)), strconv.Itoa(int(dp.Status.UpdatedReplicas)), strconv.Itoa(int(dp.Status.AvailableReplicas)), mapToStr(dp.Labels), AsStatus(d.diagnose(dp.Status.Replicas, dp.Status.AvailableReplicas)), ToAge(dp.GetCreationTimestamp()), } return nil } func (Deployment) diagnose(desired, avail int32) error { if desired != avail { return fmt.Errorf("desiring %d replicas got %d available", desired, avail) } return nil } ================================================ FILE: internal/render/dp_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDpRender(t *testing.T) { c := render.Deployment{} r := model1.NewRow(7) require.NoError(t, c.Render(load(t, "dp"), "", &r)) assert.Equal(t, "icx/icx-db", r.ID) assert.Equal(t, model1.Fields{"icx", "icx-db", "n/a", "1/1", "1", "1"}, r.Fields[:6]) } func BenchmarkDpRender(b *testing.B) { var ( c = render.Deployment{} r = model1.NewRow(7) o = load(b, "dp") ) b.ResetTimer() b.ReportAllocs() for range b.N { _ = c.Render(o, "", &r) } } ================================================ FILE: internal/render/ds.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tview" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultDSHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "UP-TO-DATE", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, 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}}, } // DaemonSet renders a K8s DaemonSet to screen. type DaemonSet struct { Base } // Header returns a header row. func (d DaemonSet) Header(_ string) model1.Header { return d.doHeader(defaultDSHeader) } // Render renders a K8s resource to screen. func (d DaemonSet) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := d.defaultRow(raw, row); err != nil { return err } if d.specs.isEmpty() { return nil } cols, err := d.specs.realize(raw, defaultDSHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (d DaemonSet) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var ds appsv1.DaemonSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ds) if err != nil { return err } r.ID = client.MetaFQN(&ds.ObjectMeta) r.Fields = model1.Fields{ ds.Namespace, ds.Name, computeVulScore(ds.Namespace, ds.Labels, &ds.Spec.Template.Spec), strconv.Itoa(int(ds.Status.DesiredNumberScheduled)), strconv.Itoa(int(ds.Status.CurrentNumberScheduled)), strconv.Itoa(int(ds.Status.NumberReady)), strconv.Itoa(int(ds.Status.UpdatedNumberScheduled)), strconv.Itoa(int(ds.Status.NumberAvailable)), mapToStr(ds.Labels), AsStatus(d.diagnose(ds.Status.DesiredNumberScheduled, ds.Status.NumberReady)), ToAge(ds.GetCreationTimestamp()), } return nil } // Happy returns true if resource is happy, false otherwise. func (DaemonSet) diagnose(d, r int32) error { if d != r { return fmt.Errorf("desiring %d replicas but %d ready", d, r) } return nil } ================================================ FILE: internal/render/ds_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDaemonSetRender(t *testing.T) { c := render.DaemonSet{} r := model1.NewRow(9) require.NoError(t, c.Render(load(t, "ds"), "", &r)) assert.Equal(t, "kube-system/fluentd-gcp-v3.2.0", r.ID) assert.Equal(t, model1.Fields{"kube-system", "fluentd-gcp-v3.2.0", "n/a", "2", "2", "2", "2", "2"}, r.Fields[:8]) } ================================================ FILE: internal/render/ep.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultEPHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "ENDPOINTS"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Endpoints renders a K8s Endpoints to screen. type Endpoints struct { Base } // Header returns a header row. func (e Endpoints) Header(_ string) model1.Header { return e.doHeader(defaultEPHeader) } // Render renders a K8s resource to screen. func (e Endpoints) Render(o any, ns string, row *model1.Row) error { if err := e.defaultRow(o, ns, row); err != nil { return err } if e.specs.isEmpty() { return nil } cols, err := e.specs.realize(o.(*unstructured.Unstructured), defaultEPHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (e Endpoints) defaultRow(o any, ns string, r *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var ep v1.Endpoints err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ep) if err != nil { return err } r.ID = client.MetaFQN(&ep.ObjectMeta) r.Fields = make(model1.Fields, 0, len(e.Header(ns))) r.Fields = model1.Fields{ ep.Namespace, ep.Name, missing(toEPs(ep.Subsets)), ToAge(ep.GetCreationTimestamp()), } return nil } // ---------------------------------------------------------------------------- // Helpers... func toEPs(ss []v1.EndpointSubset) string { aa := make([]string, 0, len(ss)) for _, s := range ss { pp := make([]string, len(s.Ports)) portsToStrs(s.Ports, pp) a := make([]string, len(s.Addresses)) processIPs(a, pp, s.Addresses) aa = append(aa, strings.Join(a, ",")) } return strings.Join(aa, ",") } func portsToStrs(pp []v1.EndpointPort, ss []string) { for i := range pp { ss[i] = strconv.Itoa(int(pp[i].Port)) } } func processIPs(aa, pp []string, addrs []v1.EndpointAddress) { const maxIPs = 3 var i int for _, a := range addrs { if a.IP == "" { continue } if len(pp) == 0 { aa[i], i = a.IP, i+1 continue } if len(pp) > maxIPs { aa[i], i = a.IP+":"+strings.Join(pp[:maxIPs], ",")+"...", i+1 } else { aa[i], i = a.IP+":"+strings.Join(pp, ","), i+1 } } } ================================================ FILE: internal/render/ep_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEndpointsRender(t *testing.T) { c := render.Endpoints{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "ep"), "", &r)) assert.Equal(t, "ns-1/blee", r.ID) assert.Equal(t, model1.Fields{"ns-1", "blee", "10.0.0.67:8080"}, r.Fields[:3]) } ================================================ FILE: internal/render/eps.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" discoveryv1 "k8s.io/api/discovery/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultEPsHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "ADDRESSTYPE"}, model1.HeaderColumn{Name: "PORTS"}, model1.HeaderColumn{Name: "ENDPOINTS"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // EndpointSlice renders a K8s EndpointSlice to screen. type EndpointSlice struct { Base } // Header returns a header row. func (e EndpointSlice) Header(_ string) model1.Header { return e.doHeader(defaultEPsHeader) } // Render renders a K8s resource to screen. func (e EndpointSlice) Render(o any, ns string, row *model1.Row) error { if err := e.defaultRow(o, ns, row); err != nil { return err } if e.specs.isEmpty() { return nil } cols, err := e.specs.realize(o.(*unstructured.Unstructured), defaultEPsHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (e EndpointSlice) defaultRow(o any, ns string, r *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var eps discoveryv1.EndpointSlice err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &eps) if err != nil { return err } r.ID = client.MetaFQN(&eps.ObjectMeta) r.Fields = make(model1.Fields, 0, len(e.Header(ns))) r.Fields = model1.Fields{ eps.Namespace, eps.Name, string(eps.AddressType), toPorts(eps.Ports), toEPss(eps.Endpoints), ToAge(eps.GetCreationTimestamp()), } return nil } // ---------------------------------------------------------------------------- // Helpers... func toEPss(ee []discoveryv1.Endpoint) string { if len(ee) == 0 { return UnsetValue } aa := make([]string, 0, len(ee)) for _, e := range ee { aa = append(aa, e.Addresses...) } return strings.Join(aa, ",") } func toPorts(ee []discoveryv1.EndpointPort) string { if len(ee) == 0 { return UnsetValue } aa := make([]string, 0, len(ee)) for _, e := range ee { if e.Port != nil { aa = append(aa, strconv.Itoa(int(*e.Port))) } } return strings.Join(aa, ",") } ================================================ FILE: internal/render/eps_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEndpointSliceRender(t *testing.T) { c := render.EndpointSlice{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "eps"), "", &r)) assert.Equal(t, "blee/fred", r.ID) assert.Equal(t, model1.Fields{"blee", "fred", "IPv4", "4244", "172.20.0.2,172.20.0.3"}, r.Fields[:5]) } ================================================ FILE: internal/render/ev.go ================================================ // Copyright Authors of K9s package render import ( "context" "fmt" "log/slog" "github.com/derailed/k9s/internal/slogs" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Event renders a event resource to screen. type Event struct { Table } // Healthy checks component health. func (*Event) Healthy(_ context.Context, o any) error { r, ok := o.(metav1.TableRow) if !ok { slog.Error("Expected TableRow", slogs.Type, fmt.Sprintf("%T", o)) return nil } idx := 2 if idx < len(r.Cells) && r.Cells[idx] != "Normal" { return fmt.Errorf("event is not normal: %s", r.Cells[idx]) } return nil } ================================================ FILE: internal/render/generic.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) var defaultGENHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Generic renders a K8s generic resource to screen. type Generic struct { Base } // Header returns a header row. func (m Generic) Header(_ string) model1.Header { return m.doHeader(defaultGENHeader) } // Render renders a K8s resource to screen. func (m Generic) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := m.defaultRow(raw, row); err != nil { return err } if m.specs.isEmpty() { return nil } cols, err := m.specs.realize(o.(*unstructured.Unstructured), defaultGENHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (Generic) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { r.ID = client.FQN(raw.GetNamespace(), raw.GetName()) r.Fields = model1.Fields{ raw.GetNamespace(), raw.GetName(), "", ToAge(raw.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/helm/chart.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package helm import ( "context" "fmt" "log/slog" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "helm.sh/helm/v3/pkg/release" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Chart renders a helm chart to screen. type Chart struct{} // IsGeneric identifies a generic handler. func (Chart) IsGeneric() bool { return false } func (Chart) SetViewSetting(*config.ViewSetting) {} // ColorerFunc colors a resource row. func (Chart) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Header returns a header row. func (Chart) Header(_ string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "REVISION"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "CHART"}, model1.HeaderColumn{Name: "APP VERSION"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a chart to screen. func (c Chart) Render(o any, _ string, r *model1.Row) error { h, ok := o.(ReleaseRes) if !ok { return fmt.Errorf("expected ReleaseRes, but got %T", o) } r.ID = client.FQN(h.Release.Namespace, h.Release.Name) r.Fields = model1.Fields{ h.Release.Namespace, h.Release.Name, strconv.Itoa(h.Release.Version), h.Release.Info.Status.String(), h.Release.Chart.Metadata.Name + "-" + h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.AppVersion, render.AsStatus(c.diagnose(h.Release.Info.Status.String())), render.ToAge(metav1.Time{Time: h.Release.Info.LastDeployed.Time}), } return nil } // Healthy checks component health. func (c Chart) Healthy(_ context.Context, o any) error { h, ok := o.(*ReleaseRes) if !ok { slog.Error("Expected *ReleaseRes, but got", slogs.Type, fmt.Sprintf("%T", o)) } return c.diagnose(h.Release.Info.Status.String()) } func (Chart) diagnose(s string) error { if s != "deployed" { return fmt.Errorf("chart is in an invalid state") } return nil } // ---------------------------------------------------------------------------- // Helpers... // ReleaseRes represents a helm chart resource. type ReleaseRes struct { Release *release.Release } // GetObjectKind returns a schema object. func (ReleaseRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (h ReleaseRes) DeepCopyObject() runtime.Object { return h } ================================================ FILE: internal/render/helm/history.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package helm import ( "context" "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" ) // History renders a History chart to screen. type History struct{} func (History) SetViewSetting(*config.ViewSetting) {} // IsGeneric identifies a generic handler. func (History) IsGeneric() bool { return false } // ColorerFunc colors a resource row. func (History) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Header returns a header row. func (History) Header(_ string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "REVISION"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "CHART"}, model1.HeaderColumn{Name: "APP VERSION"}, model1.HeaderColumn{Name: "DESCRIPTION"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, } } // Render renders a chart to screen. func (c History) Render(o any, _ string, r *model1.Row) error { h, ok := o.(ReleaseRes) if !ok { return fmt.Errorf("expected HistoryRes, but got %T", o) } r.ID = client.FQN(h.Release.Namespace, h.Release.Name) r.ID += ":" + strconv.Itoa(h.Release.Version) r.Fields = model1.Fields{ strconv.Itoa(h.Release.Version), h.Release.Info.Status.String(), h.Release.Chart.Metadata.Name + "-" + h.Release.Chart.Metadata.Version, h.Release.Chart.Metadata.AppVersion, h.Release.Info.Description, render.AsStatus(c.diagnose(h.Release.Info.Status.String())), } return nil } // Healthy checks component health. func (History) Healthy(context.Context, any) error { return nil } func (History) diagnose(string) error { return nil } ================================================ FILE: internal/render/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "log/slog" "sort" "strconv" "strings" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/vul" "github.com/derailed/tview" "github.com/mattn/go-runewidth" "golang.org/x/text/language" "golang.org/x/text/message" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/duration" ) // ExtractImages returns a collection of container images. // !!BOZO!! If this has any legs?? enable scans on other container types. func ExtractImages(spec *v1.PodSpec) []string { ii := make([]string, 0, len(spec.Containers)) for i := range spec.Containers { ii = append(ii, spec.Containers[i].Image) } return ii } func computeVulScore(ns string, lbls map[string]string, spec *v1.PodSpec) string { if vul.ImgScanner == nil || !vul.ImgScanner.IsInitialized() || vul.ImgScanner.ShouldExcludes(ns, lbls) { return NAValue } ii := ExtractImages(spec) vul.ImgScanner.Enqueue(context.Background(), ii...) sc := vul.ImgScanner.Score(ii...) return sc } func runesToNum(rr []rune) int64 { var r int64 var m int64 = 1 for i := len(rr) - 1; i >= 0; i-- { v := int64(rr[i] - '0') r += v * m m *= 10 } return r } // AsThousands prints a number with thousand separator. func AsThousands(n int64) string { p := message.NewPrinter(language.English) return p.Sprintf("%d", n) } // AsStatus returns error as string. func AsStatus(err error) string { if err == nil { return "" } return err.Error() } func asSelector(s *metav1.LabelSelector) string { sel, err := metav1.LabelSelectorAsSelector(s) if err != nil { slog.Error("Selector conversion failed", slogs.Error, err) return NAValue } return sel.String() } // ToSelector flattens a map selector to a string selector. func toSelector(m map[string]string) string { s := make([]string, 0, len(m)) for k, v := range m { s = append(s, k+"="+v) } return strings.Join(s, ",") } // Blank checks if a collection is empty or all values are blank. func blank(ss []string) bool { for _, s := range ss { if s != "" { return false } } return true } // Join a slice of strings, skipping blanks. func join(ss []string, sep string) string { switch len(ss) { case 0: return "" case 1: return ss[0] } b := make([]string, 0, len(ss)) for _, s := range ss { if s != "" { b = append(b, s) } } if len(b) == 0 { return "" } n := len(sep) * (len(b) - 1) for i := range b { n += len(ss[i]) } var buff strings.Builder buff.Grow(n) buff.WriteString(b[0]) for _, s := range b[1:] { buff.WriteString(sep) buff.WriteString(s) } return buff.String() } // AsPerc prints a number as percentage with parens. func AsPerc(p string) string { return "(" + p + ")" } // PrintPerc prints a number as percentage. func PrintPerc(p int) string { return strconv.Itoa(p) + "%" } // IntToStr converts an int to a string. func IntToStr(p int) string { return strconv.Itoa(p) } func missing(s string) string { return check(s, MissingValue) } func naStrings(ss []string) string { if len(ss) == 0 { return NAValue } return strings.Join(ss, ",") } func na(s string) string { return check(s, NAValue) } func check(s, sub string) string { if s == "" { return sub } return s } func boolToStr(b bool) string { switch b { case true: return "true" default: return "false" } } // ToAge converts time to human duration. func ToAge(t metav1.Time) string { if t.IsZero() { return UnknownValue } return duration.HumanDuration(time.Since(t.Time)) } func toAgeHuman(s string) string { if s == "" { return UnknownValue } t, err := time.Parse(time.RFC3339, s) if err != nil { return NAValue } return duration.HumanDuration(time.Since(t)) } // 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)) } func mapToStr(m map[string]string) string { if len(m) == 0 { return "" } kk := make([]string, 0, len(m)) for k := range m { kk = append(kk, k) } sort.Strings(kk) bb := make([]byte, 0, 100) for i, k := range kk { bb = append(bb, k+"="+m[k]...) if i < len(kk)-1 { bb = append(bb, ',') } } return string(bb) } func mapToIfc(m any) (s string) { if m == nil { return "" } mm, ok := m.(map[string]any) if !ok { return "" } if len(mm) == 0 { return "" } kk := make([]string, 0, len(mm)) for k := range mm { kk = append(kk, k) } sort.Strings(kk) for i, k := range kk { str, ok := mm[k].(string) if !ok { continue } s += k + "=" + str if i < len(kk)-1 { s += " " } } 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 } return strconv.Itoa(int(v)) } func toMi(v int64) string { if v == 0 { return ZeroValue } return strconv.Itoa(int(client.ToMB(v))) } func boolPtrToStr(b *bool) string { if b == nil { return "false" } return boolToStr(*b) } func strPtrToStr(s *string) string { if s == nil { return "" } return *s } // Pad a string up to the given length or truncates if greater than length. func Pad(s string, width int) string { if len(s) == width { return s } if len(s) > width { return Truncate(s, width) } return s + strings.Repeat(" ", width-len(s)) } ================================================ FILE: internal/render/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "encoding/json" "fmt" "os" "testing" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" "k8s.io/apimachinery/pkg/runtime" ) func TestTableGenericHydrate(t *testing.T) { raw := load(t, "p1") tt := metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ {Name: "c1"}, {Name: "c2"}, }, Rows: []metav1beta1.TableRow{ { Cells: []any{"fred", 10}, Object: runtime.RawExtension{Object: raw}, }, { Cells: []any{"blee", 20}, Object: runtime.RawExtension{Object: raw}, }, }, } rr := make([]model1.Row, 2) var re Table re.SetTable("blee", &tt) require.NoError(t, model1.GenericHydrate("blee", &tt, rr, &re)) assert.Len(t, rr, 2) assert.Len(t, rr[0].Fields, 2) } func TestTableHydrate(t *testing.T) { oo := []runtime.Object{ &PodWithMetrics{Raw: load(t, "p1")}, } rr := make([]model1.Row, 1) re := NewPod() require.NoError(t, model1.Hydrate("blee", oo, rr, re)) assert.Len(t, rr, 1) assert.Len(t, rr[0].Fields, 26) } func TestToAge(t *testing.T) { uu := map[string]struct { t time.Time e string }{ "zero": { t: time.Time{}, e: UnknownValue, }, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, uc.e, ToAge(metav1.Time{Time: uc.t})) }) } } func TestToAgeHuman(t *testing.T) { uu := map[string]struct { t, e string }{ "blank": { t: "", e: UnknownValue, }, "good": { t: time.Now().Add(-10 * time.Second).Format(time.RFC3339Nano), e: "10s", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, toAgeHuman(u.t)) }) } } func TestJoin(t *testing.T) { uu := map[string]struct { i []string e string }{ "zero": {[]string{}, ""}, "std": {[]string{"a", "b", "c"}, "a,b,c"}, "blank": {[]string{"", "", ""}, ""}, "sparse": {[]string{"a", "", "c"}, "a,c"}, "withBlank": {[]string{"", "a", "c"}, "a,c"}, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, uc.e, join(uc.i, ",")) }) } } func TestBoolPtrToStr(t *testing.T) { tv, fv := true, false uu := []struct { p *bool e string }{ {nil, "false"}, {&tv, "true"}, {&fv, "false"}, } for _, u := range uu { assert.Equal(t, u.e, boolPtrToStr(u.p)) } } func TestNamespaced(t *testing.T) { uu := []struct { p, ns, n string }{ {"fred/blee", "fred", "blee"}, } for _, u := range uu { ns, n := client.Namespaced(u.p) assert.Equal(t, u.ns, ns) assert.Equal(t, u.n, n) } } func TestMissing(t *testing.T) { uu := []struct { i, e string }{ {"fred", "fred"}, {"", MissingValue}, } for _, u := range uu { assert.Equal(t, u.e, missing(u.i)) } } func TestBoolToStr(t *testing.T) { uu := []struct { i bool e string }{ {true, "true"}, {false, "false"}, } for _, u := range uu { assert.Equal(t, u.e, boolToStr(u.i)) } } func TestNa(t *testing.T) { uu := []struct { i, e string }{ {"fred", "fred"}, {"", NAValue}, } for _, u := range uu { assert.Equal(t, u.e, na(u.i)) } } 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, Truncate(u.data, u.size)) }) } } func TestToSelector(t *testing.T) { uu := map[string]struct { m map[string]string e []string }{ "cool": { map[string]string{"app": "fred", "env": "test"}, []string{"app=fred,env=test", "env=test,app=fred"}, }, "empty": { map[string]string{}, []string{""}, }, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { s := toSelector(uc.m) var match bool for _, e := range uc.e { if e == s { match = true } } assert.True(t, match) }) } } func TestBlank(t *testing.T) { uu := map[string]struct { a []string e bool }{ "full": { a: []string{"fred", "blee"}, }, "empty": { e: true, }, "blank": { a: []string{"fred", ""}, }, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, uc.e, blank(uc.a)) }) } } func TestMetaFQN(t *testing.T) { uu := map[string]struct { m metav1.ObjectMeta e string }{ "full": {metav1.ObjectMeta{Namespace: "fred", Name: "blee"}, "fred/blee"}, "nons": {metav1.ObjectMeta{Name: "blee"}, "-/blee"}, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, uc.e, client.MetaFQN(&uc.m)) }) } } func TestFQN(t *testing.T) { uu := map[string]struct { ns, n string e string }{ "full": {ns: "fred", n: "blee", e: "fred/blee"}, "nons": {n: "blee", e: "blee"}, } for k := range uu { uc := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, uc.e, client.FQN(uc.ns, uc.n)) }) } } func TestMapToStr(t *testing.T) { uu := []struct { i map[string]string e string }{ {map[string]string{"blee": "duh", "aa": "bb"}, "aa=bb,blee=duh"}, {map[string]string{}, ""}, } for _, u := range uu { assert.Equal(t, u.e, mapToStr(u.i)) } } func BenchmarkMapToStr(b *testing.B) { ll := map[string]string{ "blee": "duh", "aa": "bb", } b.ReportAllocs() b.ResetTimer() for range b.N { mapToStr(ll) } } func TestRunesToNum(t *testing.T) { uu := map[string]struct { rr []rune e int64 }{ "0": { rr: []rune(""), e: 0, }, "100": { rr: []rune("100"), e: 100, }, "64": { rr: []rune("64"), e: 64, }, "52640": { rr: []rune("52640"), e: 52640, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, runesToNum(u.rr)) }) } } func BenchmarkRunesToNum(b *testing.B) { rr := []rune("5465") b.ReportAllocs() b.ResetTimer() for range b.N { runesToNum(rr) } } func TestToMc(t *testing.T) { uu := []struct { v int64 e string }{ {0, "0"}, {2, "2"}, {1_000, "1000"}, } for _, u := range uu { assert.Equal(t, u.e, toMc(u.v)) } } func TestToMi(t *testing.T) { uu := []struct { v int64 e string }{ {0, "0"}, {2 * client.MegaByte, "2"}, {1_000 * client.MegaByte, "1000"}, } for _, u := range uu { assert.Equal(t, u.e, toMi(u.v)) } } func TestIntToStr(t *testing.T) { uu := []struct { v int e string }{ {0, "0"}, {10, "10"}, } for _, u := range uu { assert.Equal(t, u.e, IntToStr(u.v)) } } func BenchmarkIntToStr(b *testing.B) { v := 10 b.ResetTimer() b.ReportAllocs() for range b.N { IntToStr(v) } } // Helpers... func load(t *testing.T, n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) require.NoError(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) require.NoError(t, err) return &o } ================================================ FILE: internal/render/hpa.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "strconv" "strings" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" ) // HorizontalPodAutoscaler renders a K8s HorizontalPodAutoscaler to screen. type HorizontalPodAutoscaler struct { Table } // ColorerFunc colors a resource row. func (*HorizontalPodAutoscaler) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) maxPodsIndex, ok := h.IndexOf("MAXPODS", true) if !ok || maxPodsIndex >= len(re.Row.Fields) { return c } replicasIndex, ok := h.IndexOf("REPLICAS", true) if !ok || replicasIndex >= len(re.Row.Fields) { return c } maxPodsS := strings.TrimSpace(re.Row.Fields[maxPodsIndex]) currentReplicasS := strings.TrimSpace(re.Row.Fields[replicasIndex]) maxPods, err := strconv.Atoi(maxPodsS) if err != nil { return c } currentReplicas, err := strconv.Atoi(currentReplicasS) if err != nil { return c } if currentReplicas >= maxPods { c = model1.ErrColor } return c } } ================================================ FILE: internal/render/hpa_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestHorizontalPodAutoscalerColorer(t *testing.T) { hpaHeader := model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "REFERENCE"}, model1.HeaderColumn{Name: "TARGETS%"}, model1.HeaderColumn{Name: "MINPODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "MAXPODS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "REPLICAS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } uu := map[string]struct { h model1.Header re *model1.RowEvent e tcell.Color }{ "when replicas = maxpods": { h: hpaHeader, re: &model1.RowEvent{ Kind: model1.EventUnchanged, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "fred", "100%", "1", "5", "5", "1d"}, }, }, e: model1.ErrColor, }, "when replicas > maxpods, for some reason": { h: hpaHeader, re: &model1.RowEvent{ Kind: model1.EventUnchanged, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "fred", "100%", "1", "5", "6", "1d"}, }, }, e: model1.ErrColor, }, "when replicas < maxpods": { h: hpaHeader, re: &model1.RowEvent{ Kind: model1.EventUnchanged, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "fred", "100%", "1", "5", "1", "1d"}, }, }, e: model1.StdColor, }, } var r HorizontalPodAutoscaler for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, r.ColorerFunc()("", u.h, u.re)) }) } } ================================================ FILE: internal/render/img_scan.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/vul" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) const ( CVEParseIdx = 5 sevColName = "SEVERITY" ) // ImageScan renders scans report table. type ImageScan struct { Base } // ColorerFunc colors a resource row. func (ImageScan) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf(sevColName, true) if !ok { return c } sev := strings.TrimSpace(re.Row.Fields[idx]) switch sev { case vul.Sev1: c = tcell.ColorRed case vul.Sev2: c = tcell.ColorDarkOrange case vul.Sev3: c = tcell.ColorYellow case vul.Sev4: c = tcell.ColorDeepSkyBlue case vul.Sev5: c = tcell.ColorCadetBlue default: c = tcell.ColorDarkOliveGreen } return c } } // Header returns a header row. func (ImageScan) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "SEVERITY"}, model1.HeaderColumn{Name: "VULNERABILITY"}, model1.HeaderColumn{Name: "IMAGE"}, model1.HeaderColumn{Name: "LIBRARY"}, model1.HeaderColumn{Name: "VERSION"}, model1.HeaderColumn{Name: "FIXED-IN"}, model1.HeaderColumn{Name: "TYPE"}, } } // Render renders a K8s resource to screen. func (ImageScan) Render(o any, _ string, r *model1.Row) error { res, ok := o.(ImageScanRes) if !ok { return fmt.Errorf("expected ImageScanRes, but got %T", o) } r.ID = fmt.Sprintf("%s|%s", res.Image, strings.Join(res.Row, "|")) r.Fields = model1.Fields{ res.Row.Severity(), res.Row.Vulnerability(), res.Image, res.Row.Name(), res.Row.Version(), res.Row.Fix(), res.Row.Type(), } return nil } // ---------------------------------------------------------------------------- // Helpers... // ImageScanRes represents a container and its metrics. type ImageScanRes struct { Image string Row vul.Row } // GetObjectKind returns a schema object. func (ImageScanRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (is ImageScanRes) DeepCopyObject() runtime.Object { return is } ================================================ FILE: internal/render/job.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/duration" ) var defaultJOBHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "COMPLETIONS"}, model1.HeaderColumn{Name: "DURATION"}, model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Job renders a K8s Job to screen. type Job struct { Base } // Header returns a header row. func (j Job) Header(_ string) model1.Header { return j.doHeader(defaultJOBHeader) } // Render renders a K8s resource to screen. func (j Job) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := j.defaultRow(raw, row); err != nil { return err } if j.specs.isEmpty() { return nil } cols, err := j.specs.realize(raw, defaultJOBHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (j Job) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var job batchv1.Job err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &job) if err != nil { return err } ready := toCompletion(&job.Spec, &job.Status) cc, ii := toContainers(&job.Spec.Template.Spec) r.ID = client.MetaFQN(&job.ObjectMeta) r.Fields = model1.Fields{ job.Namespace, job.Name, computeVulScore(job.Namespace, job.Labels, &job.Spec.Template.Spec), ready, toDuration(&job.Status), jobSelector(&job.Spec), cc, ii, AsStatus(j.diagnose(ready, &job.Status)), ToAge(job.GetCreationTimestamp()), } return nil } func (Job) diagnose(ready string, status *batchv1.JobStatus) error { tokens := strings.Split(ready, "/") if tokens[0] != tokens[1] && status.Failed > 0 { return fmt.Errorf("%d pods failed", status.Failed) } return nil } // ---------------------------------------------------------------------------- // Helpers... const maxShow = 2 func toContainers(p *v1.PodSpec) (containers, images string) { cc, ii := parseContainers(p.InitContainers) cn, ci := parseContainers(p.Containers) cc, ii = append(cc, cn...), append(ii, ci...) // Limit to 2 of each... if len(cc) > maxShow { cc = append(cc[:2], "(+"+strconv.Itoa(len(cc)-maxShow)+")...") } if len(ii) > maxShow { ii = append(ii[:2], "(+"+strconv.Itoa(len(ii)-maxShow)+")...") } return strings.Join(cc, ","), strings.Join(ii, ",") } func parseContainers(cos []v1.Container) (nn, ii []string) { nn, ii = make([]string, 0, len(cos)), make([]string, 0, len(cos)) for i := range cos { nn, ii = append(nn, cos[i].Name), append(ii, cos[i].Image) } return nn, ii } func toCompletion(spec *batchv1.JobSpec, status *batchv1.JobStatus) (s string) { if spec.Completions != nil { return strconv.Itoa(int(status.Succeeded)) + "/" + strconv.Itoa(int(*spec.Completions)) } if spec.Parallelism == nil { return strconv.Itoa(int(status.Succeeded)) + "/1" } p := *spec.Parallelism if p > 1 { return strconv.Itoa(int(status.Succeeded)) + "/1 of " + strconv.Itoa(int(p)) } return strconv.Itoa(int(status.Succeeded)) + "/1" } func toDuration(status *batchv1.JobStatus) string { if status.StartTime == nil || status.CompletionTime == nil { return MissingValue } return duration.HumanDuration(status.CompletionTime.Sub(status.StartTime.Time)) } ================================================ FILE: internal/render/job_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestJobRender(t *testing.T) { c := render.Job{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "job"), "", &r)) assert.Equal(t, "default/hello-1567179180", r.ID) assert.Equal(t, model1.Fields{"default", "hello-1567179180", "n/a", "1/1", "8s", "controller-uid=7473e6d0-cb3b-11e9-990f-42010a800218", "c1", "blang/busybox-bash"}, r.Fields[:8]) } ================================================ FILE: internal/render/node.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "errors" "fmt" "log/slog" "sort" "strconv" "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" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) const ( labelNodeRolePrefix = "node-role.kubernetes.io/" labelNodeRoleSuffix = "kubernetes.io/role" ) var ( cordonErr = errors.New("node is cordoned") notReadyErr = errors.New("node is not ready") ) var defaultNOHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "ROLE"}, model1.HeaderColumn{Name: "ARCH", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "TAINTS"}, model1.HeaderColumn{Name: "VERSION"}, model1.HeaderColumn{Name: "OS-IMAGE", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "KERNEL", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "INTERNAL-IP", Attrs: model1.Attrs{Wide: true}}, 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: "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: "%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: "SH-GPU/A", Attrs: model1.Attrs{Align: tview.AlignRight, MX: true}}, model1.HeaderColumn{Name: "SH-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}}, } // Node renders a K8s Node to screen. type Node struct { Base } // ColorerFunc colors a resource row. func (*Node) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("VALID", true) if !ok { return c } if strings.TrimSpace(re.Row.Fields[idx]) == cordonErr.Error() { c = model1.PendingColor } return c } } // Header returns a header row. func (n Node) Header(_ string) model1.Header { return n.doHeader(defaultNOHeader) } // Render renders a K8s resource to screen. func (n Node) Render(o any, _ string, row *model1.Row) error { nwm, ok := o.(*NodeWithMetrics) if !ok { return fmt.Errorf("expected NodeWithMetrics, but got %T", o) } if err := n.defaultRow(nwm, row); err != nil { return err } if n.specs.isEmpty() { return nil } cols, err := n.specs.realize(nwm.Raw, defaultNOHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } // Render renders a K8s resource to screen. func (n Node) defaultRow(nwm *NodeWithMetrics, r *model1.Row) error { var no v1.Node err := runtime.DefaultUnstructuredConverter.FromUnstructured(nwm.Raw.Object, &no) if err != nil { return err } iIP, eIP := getIPs(no.Status.Addresses) 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) roles := make(sort.StringSlice, 10) nodeRoles(&no, roles) sort.Sort(roles) podCount := strconv.Itoa(nwm.PodCount) if pc := nwm.PodCount; pc == -1 { podCount = NAValue } r.ID = client.FQN("", no.Name) r.Fields = model1.Fields{ no.Name, join(statuses, ","), join(roles, ","), no.Status.NodeInfo.Architecture, strconv.Itoa(len(no.Spec.Taints)), no.Status.NodeInfo.KubeletVersion, no.Status.NodeInfo.OSImage, no.Status.NodeInfo.KernelVersion, iIP, eIP, podCount, toMc(c.cpu), toMc(a.cpu), client.ToPercentageStr(c.cpu, a.cpu), toMi(c.mem), toMi(a.mem), client.ToPercentageStr(c.mem, a.mem), toMu(a.gpu), toMu(c.gpu), toMu(a.gpuShared), toMu(c.gpuShared), mapToStr(no.Labels), AsStatus(n.diagnose(statuses)), ToAge(no.GetCreationTimestamp()), } return nil } // Healthy checks component health. func (n Node) Healthy(_ context.Context, o any) error { nwm, ok := o.(*NodeWithMetrics) if !ok { slog.Error("Expected *NodeWithMetrics", slogs.Type, fmt.Sprintf("%T", o)) return nil } var no v1.Node err := runtime.DefaultUnstructuredConverter.FromUnstructured(nwm.Raw.Object, &no) if err != nil { slog.Error("Failed to convert unstructured to Node", slogs.Error, err) return nil } ss := make([]string, 10) status(no.Status.Conditions, no.Spec.Unschedulable, ss) return n.diagnose(ss) } func (Node) diagnose(ss []string) error { if len(ss) == 0 { return nil } var ready bool for _, s := range ss { if s == "" { continue } if s == "SchedulingDisabled" { return cordonErr } if s == "Ready" { ready = true } } if !ready { return notReadyErr } return nil } // ---------------------------------------------------------------------------- // Helpers... // NodeWithMetrics represents a node with its associated metrics. type NodeWithMetrics struct { Raw *unstructured.Unstructured MX *mv1beta1.NodeMetrics PodCount int } // GetObjectKind returns a schema object. func (*NodeWithMetrics) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (n *NodeWithMetrics) DeepCopyObject() runtime.Object { return n } type metric struct { cpu, mem int64 lcpu, lmem int64 gpu, gpuShared int64 lgpu int64 } func gatherNodeMX(no *v1.Node, mx *mv1beta1.NodeMetrics) (c, a metric) { a.cpu = no.Status.Allocatable.Cpu().MilliValue() a.mem = no.Status.Allocatable.Memory().Value() if mx != nil { c.cpu = mx.Usage.Cpu().MilliValue() c.mem = mx.Usage.Memory().Value() } gpu, gpuShared := extractNodeGPU(no.Status.Allocatable) if gpu != nil { a.gpu = gpu.Value() } if gpuShared != nil { a.gpuShared = gpuShared.Value() } gpu, gpuShared = extractNodeGPU(no.Status.Capacity) if gpu != nil { c.gpu = gpu.Value() } if gpuShared != nil { c.gpuShared = gpuShared.Value() } return } func extractNodeGPU(rl v1.ResourceList) (main, shared *resource.Quantity) { mm := make(map[string]*resource.Quantity, len(config.KnownGPUVendors)) for _, v := range config.KnownGPUVendors { if q, ok := rl[v1.ResourceName(v)]; ok { mm[v] = &q } } for k, v := range mm { if strings.HasSuffix(k, "shared") { shared = v } else { main = v } } return } func nodeRoles(node *v1.Node, res []string) { index := 0 for k, v := range node.Labels { switch { case strings.HasPrefix(k, labelNodeRolePrefix): if role := strings.TrimPrefix(k, labelNodeRolePrefix); role != "" { res[index] = role index++ } case strings.HasSuffix(k, labelNodeRoleSuffix) && v != "": res[index] = v index++ } if index >= len(res) { break } } if blank(res) { res[index] = MissingValue } } func getIPs(addrs []v1.NodeAddress) (iIP, eIP string) { for _, a := range addrs { //nolint:exhaustive switch a.Type { case v1.NodeExternalIP: eIP = a.Address case v1.NodeInternalIP: iIP = a.Address } } return } func status(conds []v1.NodeCondition, exempt bool, res []string) { var index int conditions := make(map[v1.NodeConditionType]*v1.NodeCondition, len(conds)) for n := range conds { cond := conds[n] conditions[cond.Type] = &cond } validConditions := []v1.NodeConditionType{v1.NodeReady} for _, validCondition := range validConditions { condition, ok := conditions[validCondition] if !ok { continue } neg := "" if condition.Status != v1.ConditionTrue { neg = "Not" } res[index] = neg + string(condition.Type) index++ } if len(res) == 0 { res[index] = "Unknown" index++ } if exempt { res[index] = "SchedulingDisabled" } } ================================================ FILE: internal/render/node_int_test.go ================================================ package render import ( "testing" "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_extractNodeGPU(t *testing.T) { uu := map[string]struct { rl v1.ResourceList main *resource.Quantity shared *resource.Quantity }{ "empty": {}, "nvidia": { rl: v1.ResourceList{ v1.ResourceCPU: resource.MustParse("3"), v1.ResourceMemory: resource.MustParse("4Gi"), v1.ResourceName("nvidia.com/gpu"): resource.MustParse("2"), }, main: makeQ(t, "2"), }, "nvidia-shared": { rl: v1.ResourceList{ v1.ResourceCPU: resource.MustParse("3"), v1.ResourceMemory: resource.MustParse("4Gi"), v1.ResourceName("nvidia.com/gpu.shared"): resource.MustParse("2"), }, shared: makeQ(t, "2"), }, "nvidia-both": { rl: v1.ResourceList{ v1.ResourceCPU: resource.MustParse("3"), v1.ResourceMemory: resource.MustParse("4Gi"), v1.ResourceName("nvidia.com/gpu.shared"): resource.MustParse("2"), v1.ResourceName("nvidia.com/gpu"): resource.MustParse("5"), }, main: makeQ(t, "5"), shared: makeQ(t, "2"), }, "intel": { rl: v1.ResourceList{ v1.ResourceCPU: resource.MustParse("3"), v1.ResourceMemory: resource.MustParse("4Gi"), v1.ResourceName("gpu.intel.com/i915"): resource.MustParse("5"), }, main: makeQ(t, "5"), }, "unknown-vendor": { rl: v1.ResourceList{ v1.ResourceCPU: resource.MustParse("3"), v1.ResourceMemory: resource.MustParse("4Gi"), v1.ResourceName("bozo/gpu"): resource.MustParse("2"), }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { m, s := extractNodeGPU(u.rl) assert.Equal(t, u.main, m) assert.Equal(t, u.shared, s) }) } } func Test_gatherNodeMX(t *testing.T) { uu := map[string]struct { node v1.Node nMX *mv1beta1.NodeMetrics ec, ea metric }{ "empty": {}, "nvidia": { 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"), }, }, }, 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, }, }, "intel": { 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"), }, }, }, ea: metric{ cpu: 8000, mem: 8589934592, gpu: 4, }, ec: metric{ cpu: 0, mem: 0, gpu: 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"), }, }, }, ea: metric{ cpu: 8000, mem: 8589934592, gpu: 0, }, ec: metric{ gpu: 0, }, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { c, a := gatherNodeMX(&u.node, u.nMX) assert.Equal(t, u.ec, c) assert.Equal(t, u.ea, a) }) } } func makeQ(t *testing.T, v string) *resource.Quantity { q, err := resource.ParseQuantity(v) if err != nil { t.Fatal(err) } return &q } ================================================ FILE: internal/render/node_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) func TestNodeRender(t *testing.T) { pom := render.NodeWithMetrics{ Raw: load(t, "no"), MX: makeNodeMX("n1", "10m", "20Mi"), } var no render.Node r := model1.NewRow(14) err := no.Render(&pom, "", &r) 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", "4000", "0", "20", "7874", "0", "n/a", "n/a"} assert.Equal(t, e, r.Fields[:19]) } func BenchmarkNodeRender(b *testing.B) { var ( no render.Node r = model1.NewRow(14) pom = render.NodeWithMetrics{ Raw: load(b, "no"), MX: makeNodeMX("n1", "10m", "10Mi"), } ) b.ResetTimer() b.ReportAllocs() for range b.N { _ = no.Render(&pom, "", &r) } } // ---------------------------------------------------------------------------- // Helpers... func makeNodeMX(name, cpu, mem string) *mv1beta1.NodeMetrics { return &mv1beta1.NodeMetrics{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, Usage: makeRes(cpu, mem), } } ================================================ FILE: internal/render/np.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultNPHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "POD-SELECTOR"}, model1.HeaderColumn{Name: "ING-SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "ING-PORTS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "ING-BLOCK", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "EGR-SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "EGR-PORTS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "EGR-BLOCK", Attrs: model1.Attrs{Wide: 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}}, } // NetworkPolicy renders a K8s NetworkPolicy to screen. type NetworkPolicy struct { Base } // Header returns a header row. func (p NetworkPolicy) Header(_ string) model1.Header { return p.doHeader(defaultNPHeader) } // Render renders a K8s resource to screen. func (p NetworkPolicy) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := p.defaultRow(raw, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(raw, defaultNPHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (NetworkPolicy) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var np netv1.NetworkPolicy err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &np) if err != nil { return err } ip, is, ib := ingress(np.Spec.Ingress) ep, es, eb := egress(np.Spec.Egress) var podSel string if len(np.Spec.PodSelector.MatchLabels) > 0 { podSel = mapToStr(np.Spec.PodSelector.MatchLabels) } if len(np.Spec.PodSelector.MatchExpressions) > 0 { podSel += "::" + expToStr(np.Spec.PodSelector.MatchExpressions) } r.ID = client.MetaFQN(&np.ObjectMeta) r.Fields = model1.Fields{ np.Namespace, np.Name, podSel, is, ip, ib, es, ep, eb, mapToStr(np.Labels), "", ToAge(np.GetCreationTimestamp()), } return nil } // Helpers... func ingress(ii []netv1.NetworkPolicyIngressRule) (port, selector, block string) { var ports, sels, blocks []string for _, i := range ii { if p := portsToStr(i.Ports); p != "" { ports = append(ports, p) } ll, pp := peersToStr(i.From) if ll != "" { sels = append(sels, ll) } if pp != "" { blocks = append(blocks, pp) } } return strings.Join(ports, ","), strings.Join(sels, ","), strings.Join(blocks, ",") } func egress(ee []netv1.NetworkPolicyEgressRule) (port, selector, block string) { var ports, sels, blocks []string for _, e := range ee { if p := portsToStr(e.Ports); p != "" { ports = append(ports, p) } ll, pp := peersToStr(e.To) if ll != "" { sels = append(sels, ll) } if pp != "" { blocks = append(blocks, pp) } } return strings.Join(ports, ","), strings.Join(sels, ","), strings.Join(blocks, ",") } func portsToStr(pp []netv1.NetworkPolicyPort) string { ports := make([]string, 0, len(pp)) for _, p := range pp { proto, port := NAValue, NAValue if p.Protocol != nil { proto = string(*p.Protocol) } if p.Port != nil { port = p.Port.String() } ports = append(ports, proto+":"+port) } return strings.Join(ports, ",") } func peersToStr(pp []netv1.NetworkPolicyPeer) (selector, ip string) { sels := make([]string, 0, len(pp)) ips := make([]string, 0, len(pp)) for _, p := range pp { if peer := renderPeer(p); peer != "" { sels = append(sels, peer) } if p.IPBlock == nil { continue } if b := renderBlock(p.IPBlock); b != "" { ips = append(ips, b) } } return strings.Join(sels, ","), strings.Join(ips, ",") } func renderBlock(b *netv1.IPBlock) string { s := b.CIDR if len(b.Except) == 0 { return s } e, more := b.Except, false if len(b.Except) > 2 { e, more = e[:2], true } if more { return s + "[" + strings.Join(e, ",") + "...]" } return s + "[" + strings.Join(b.Except, ",") + "]" } func renderPeer(i netv1.NetworkPolicyPeer) string { var s string if i.PodSelector != nil { if m := mapToStr(i.PodSelector.MatchLabels); m != "" { s += "po:" + m } if e := expToStr(i.PodSelector.MatchExpressions); e != "" { s += "--" + e } } if i.NamespaceSelector != nil { if m := mapToStr(i.NamespaceSelector.MatchLabels); m != "" { s += "ns:" + m } if e := expToStr(i.NamespaceSelector.MatchExpressions); e != "" { s += "--" + e } } return s } func expToStr(ee []metav1.LabelSelectorRequirement) string { ss := make([]string, len(ee)) for i, e := range ee { ss[i] = labToStr(e) } return strings.Join(ss, ",") } func labToStr(e metav1.LabelSelectorRequirement) string { return fmt.Sprintf("%s-%s%s", e.Key, e.Operator, strings.Join(e.Values, ",")) } ================================================ FILE: internal/render/np_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNetworkPolicyRender(t *testing.T) { c := render.NetworkPolicy{} r := model1.NewRow(9) require.NoError(t, c.Render(load(t, "np"), "", &r)) assert.Equal(t, "default/fred", r.ID) assert.Equal(t, model1.Fields{"default", "fred", "app=nginx", "ns:app=blee,po:app=fred", "TCP:6379", "172.17.0.0/16[172.17.1.0/24,172.17.3.0/24...]", "", "TCP:5978", "10.0.0.0/24"}, r.Fields[:9]) } ================================================ FILE: internal/render/ns.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "errors" "fmt" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tcell/v2" "golang.org/x/exp/slog" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultNSHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, 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}}, } // Namespace renders a K8s Namespace to screen. type Namespace struct { Base } // ColorerFunc colors a resource row. func (Namespace) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) if c == model1.ErrColor { return c } if re.Kind == model1.EventUpdate { c = model1.StdColor } if strings.Contains(strings.TrimSpace(re.Row.Fields[0]), "*") { c = model1.HighlightColor } return c } } // Header returns a header row. func (n Namespace) Header(_ string) model1.Header { return n.doHeader(defaultNSHeader) } // Render renders a K8s resource to screen. func (n Namespace) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := n.defaultRow(raw, row); err != nil { return err } if n.specs.isEmpty() { return nil } cols, err := n.specs.realize(raw, defaultNSHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (n Namespace) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var ns v1.Namespace err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ns) if err != nil { return err } r.ID = client.MetaFQN(&ns.ObjectMeta) r.Fields = model1.Fields{ ns.Name, string(ns.Status.Phase), mapToStr(ns.Labels), AsStatus(n.diagnose(ns.Status.Phase)), ToAge(ns.GetCreationTimestamp()), } return nil } // Healthy checks component health. func (n Namespace) Healthy(_ context.Context, o any) error { res, ok := o.(*unstructured.Unstructured) if !ok { slog.Error("Expected *Unstructured, but got", slogs.Type, fmt.Sprintf("%T", o)) return nil } var ns v1.Namespace err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.Object, &ns) if err != nil { slog.Error("Failed to convert Unstructured to Namespace", slogs.Type, fmt.Sprintf("%T", o), slog.String("error", err.Error())) return nil } return n.diagnose(ns.Status.Phase) } func (Namespace) diagnose(phase v1.NamespacePhase) error { if phase != v1.NamespaceActive && phase != v1.NamespaceTerminating { return errors.New("namespace not ready") } return nil } ================================================ FILE: internal/render/ns_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNSColorer(t *testing.T) { uu := map[string]struct { re model1.RowEvent e tcell.Color }{ "add": { re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{ "blee", "Active", }, }, }, e: model1.AddColor, }, "update": { re: model1.RowEvent{ Kind: model1.EventUpdate, Row: model1.Row{ Fields: model1.Fields{ "blee", "Active", }, }, }, e: model1.StdColor, }, "decorator": { re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{ "blee*", "Active", }, }, }, e: model1.HighlightColor, }, } h := model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, } var r render.Namespace for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, r.ColorerFunc()("", h, &u.re)) }) } } func TestNamespaceRender(t *testing.T) { c := render.Namespace{} r := model1.NewRow(3) require.NoError(t, c.Render(load(t, "ns"), "-", &r)) assert.Equal(t, "-/kube-system", r.ID) assert.Equal(t, model1.Fields{"kube-system", "Active"}, r.Fields[:2]) } ================================================ FILE: internal/render/pdb.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tview" v1 "k8s.io/api/policy/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" ) var defaultPDBHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "MIN-AVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "MAX-UNAVAILABLE", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "ALLOWED-DISRUPTIONS", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "EXPECTED", Attrs: model1.Attrs{Align: tview.AlignRight}}, 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}}, } // PodDisruptionBudget renders a K8s PodDisruptionBudget to screen. type PodDisruptionBudget struct { Base } // Header returns a header row. func (p PodDisruptionBudget) Header(_ string) model1.Header { return p.doHeader(defaultPDBHeader) } // Render renders a K8s resource to screen. func (p PodDisruptionBudget) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := p.defaultRow(raw, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(raw, defaultPDBHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (p PodDisruptionBudget) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pdb v1.PodDisruptionBudget err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pdb) if err != nil { return err } r.ID = client.MetaFQN(&pdb.ObjectMeta) r.Fields = model1.Fields{ pdb.Namespace, pdb.Name, numbToStr(pdb.Spec.MinAvailable), numbToStr(pdb.Spec.MaxUnavailable), strconv.Itoa(int(pdb.Status.DisruptionsAllowed)), strconv.Itoa(int(pdb.Status.CurrentHealthy)), strconv.Itoa(int(pdb.Status.DesiredHealthy)), strconv.Itoa(int(pdb.Status.ExpectedPods)), mapToStr(pdb.Labels), AsStatus(p.diagnose(pdb.Spec.MinAvailable, pdb.Status.CurrentHealthy)), ToAge(pdb.GetCreationTimestamp()), } return nil } func (PodDisruptionBudget) diagnose(v *intstr.IntOrString, healthy int32) error { if v == nil { return nil } if v.IntVal > healthy { return fmt.Errorf("expected %d but got %d", v.IntVal, healthy) } return nil } // Helpers... func numbToStr(n *intstr.IntOrString) string { if n == nil { return NAValue } if n.Type == intstr.Int { return strconv.Itoa(int(n.IntVal)) } return n.StrVal } ================================================ FILE: internal/render/pdb_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPodDisruptionBudgetRender(t *testing.T) { c := render.PodDisruptionBudget{} r := model1.NewRow(9) require.NoError(t, c.Render(load(t, "pdb"), "", &r)) assert.Equal(t, "default/fred", r.ID) assert.Equal(t, model1.Fields{"default", "fred", "2", render.NAValue, "0", "0", "2", "0"}, r.Fields[:8]) } ================================================ FILE: internal/render/pod.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "context" "fmt" "log/slog" "strconv" "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" "github.com/derailed/tview" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) const ( // NodeUnreachablePodReason is reason and message set on a pod when its state // cannot be confirmed as kubelet is unresponsive on the node it is (was) running. NodeUnreachablePodReason = "NodeLost" // k8s.io/kubernetes/pkg/util/node.NodeUnreachablePodReason vulIdx = 2 ) const ( PhaseTerminating = "Terminating" PhaseInitialized = "Initialized" PhaseRunning = "Running" PhaseNotReady = "NoReady" PhaseCompleted = "Completed" PhaseContainerCreating = "ContainerCreating" PhasePodInitializing = "PodInitializing" PhaseUnknown = "Unknown" PhaseCrashLoop = "CrashLoopBackOff" PhaseError = "Error" PhaseImagePullBackOff = "ImagePullBackOff" PhaseOOMKilled = "OOMKilled" PhasePending = "Pending" PhaseContainerStatusUnknown = "ContainerStatusUnknown" PhaseEvicted = "Evicted" ) var defaultPodHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "PF"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "STATUS"}, 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: "CPU/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}}, model1.HeaderColumn{Name: "NOMINATED NODE", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "READINESS GATES", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "QOS", Attrs: model1.Attrs{Wide: 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}}, } // Pod renders a K8s Pod to screen. type Pod struct { *Base } // NewPod returns a new instance. func NewPod() *Pod { return &Pod{ Base: new(Base), } } // ColorerFunc colors a resource row. func (*Pod) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("STATUS", true) if !ok { return c } status := strings.TrimSpace(re.Row.Fields[idx]) switch status { case Pending, ContainerCreating: c = model1.PendingColor case PodInitializing: c = model1.AddColor case Initialized: c = model1.HighlightColor case Completed: c = model1.CompletedColor case Running: if c != model1.ErrColor { c = model1.StdColor } case Terminating: c = model1.KillColor } return c } } // Header returns a header row. func (p *Pod) Header(string) model1.Header { return p.doHeader(defaultPodHeader) } // Render renders a K8s resource to screen. func (p *Pod) Render(o any, _ string, row *model1.Row) error { pwm, ok := o.(*PodWithMetrics) if !ok { return fmt.Errorf("expected PodWithMetrics, but got %T", o) } if err := p.defaultRow(pwm, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(pwm.Raw.DeepCopy(), defaultPodHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (p *Pod) defaultRow(pwm *PodWithMetrics, row *model1.Row) error { var st v1.PodStatus if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["status"].(map[string]any), &st); err != nil { return err } spec := new(v1.PodSpec) if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["spec"].(map[string]any), spec); err != nil { return err } dt := pwm.Raw.GetDeletionTimestamp() cReady, _, cRestarts, lastRestart := p.ContainerStats(st.ContainerStatuses) iReady, iTerminated, iRestarts := p.initContainerStats(spec.InitContainers, st.InitContainerStatuses) cReady += iReady allCounts := len(spec.Containers) + iTerminated rgr, rgt := p.readinessGateStats(spec, &st) ready := hasPodReadyCondition(st.Conditions) var ccmx []mv1beta1.ContainerMetrics if pwm.MX != nil { ccmx = pwm.MX.Containers } c, r := gatherPodMX(spec, ccmx) phase := p.Phase(dt, spec, &st) ns, n := pwm.Raw.GetNamespace(), pwm.Raw.GetName() row.ID = client.FQN(ns, n) row.Fields = model1.Fields{ ns, n, computeVulScore(ns, pwm.Raw.GetLabels(), spec), "●", strconv.Itoa(cReady) + "/" + strconv.Itoa(allCounts), phase, strconv.Itoa(cRestarts + iRestarts), ToAge(lastRestart), toMc(c.cpu), toMc(r.cpu) + ":" + toMc(r.lcpu), 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), asNominated(st.NominatedNodeName), asReadinessGate(spec, &st), p.mapQOS(st.QOSClass), mapToStr(pwm.Raw.GetLabels()), AsStatus(p.diagnose(phase, cReady, allCounts, ready, rgr, rgt)), ToAge(pwm.Raw.GetCreationTimestamp()), } return nil } // Healthy checks component health. func (p *Pod) Healthy(_ context.Context, o any) error { pwm, ok := o.(*PodWithMetrics) if !ok { slog.Error("Expected *PodWithMetrics", slogs.Type, fmt.Sprintf("%T", o)) return nil } var st v1.PodStatus if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["status"].(map[string]any), &st); err != nil { slog.Error("Failed to convert unstructured to PodState", slogs.Error, err) return nil } spec := new(v1.PodSpec) if err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object["spec"].(map[string]any), spec); err != nil { slog.Error("Failed to convert unstructured to PodSpec", slogs.Error, err) return nil } dt := pwm.Raw.GetDeletionTimestamp() phase := p.Phase(dt, spec, &st) cr, _, _, _ := p.ContainerStats(st.ContainerStatuses) ct := len(st.ContainerStatuses) icr, ict, _ := p.initContainerStats(spec.InitContainers, st.InitContainerStatuses) cr += icr ct += ict ready := hasPodReadyCondition(st.Conditions) rgr, rgt := p.readinessGateStats(spec, &st) return p.diagnose(phase, cr, ct, ready, rgr, rgt) } func (*Pod) diagnose(phase string, cr, ct int, ready bool, rgr, rgt int) error { if phase == Completed { return nil } if cr != ct || ct == 0 { return fmt.Errorf("container ready check failed: %d of %d", cr, ct) } if rgt > 0 && rgr != rgt { return fmt.Errorf("readiness gate check failed: %d of %d", rgr, rgt) } if !ready { return fmt.Errorf("pod condition ready is false") } if phase == Terminating { return fmt.Errorf("pod is terminating") } return nil } // ---------------------------------------------------------------------------- // Helpers... func asNominated(n string) string { if n == "" { return MissingValue } return n } func asReadinessGate(spec *v1.PodSpec, st *v1.PodStatus) string { if len(spec.ReadinessGates) == 0 { return MissingValue } var trueConditions int for _, readinessGate := range spec.ReadinessGates { conditionType := readinessGate.ConditionType for _, condition := range st.Conditions { if condition.Type == conditionType { if condition.Status == "True" { trueConditions++ } break } } } return strconv.Itoa(trueConditions) + "/" + strconv.Itoa(len(spec.ReadinessGates)) } // PodWithMetrics represents a pod and its metrics. type PodWithMetrics struct { Raw *unstructured.Unstructured MX *mv1beta1.PodMetrics } // GetObjectKind returns a schema object. func (*PodWithMetrics) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (p *PodWithMetrics) DeepCopyObject() runtime.Object { return p } 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, rgpu := cosRequests(cc) r.cpu, r.mem, r.gpu = rcpu.MilliValue(), rmem.Value(), rgpu.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() return } 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 q := limits.Cpu(); q != nil { cpuQ.Add(*q) } if q := limits.Memory(); q != nil { memQ.Add(*q) } if q := extractGPU(limits); q != nil { gpuQ.Add(*q) } } return } 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 q := rl.Cpu(); q != nil { cpuQ.Add(*q) } if q := rl.Memory(); q != nil { memQ.Add(*q) } if q := extractGPU(rl); q != nil { gpuQ.Add(*q) } } return } 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 } for _, co := range ccmx { c, m := co.Usage.Cpu(), co.Usage.Memory() cpuQ.Add(*c) memQ.Add(*m) } return } func (*Pod) mapQOS(class v1.PodQOSClass) string { //nolint:exhaustive switch class { case v1.PodQOSGuaranteed: return "GA" case v1.PodQOSBurstable: return "BU" default: return "BE" } } // ContainerStats reports pod container stats. func (*Pod) ContainerStats(cc []v1.ContainerStatus) (readyCnt, terminatedCnt, restartCnt int, latest metav1.Time) { for i := range cc { if cc[i].State.Terminated != nil { terminatedCnt++ } if cc[i].Ready { readyCnt++ } restartCnt += int(cc[i].RestartCount) if t := cc[i].LastTerminationState.Terminated; t != nil { ts := cc[i].LastTerminationState.Terminated.FinishedAt if latest.IsZero() || ts.After(latest.Time) { latest = ts } } } return } func (*Pod) initContainerStats(cc []v1.Container, cos []v1.ContainerStatus) (ready, total, restart int) { for i := range cos { if !isSideCarContainer(cc[i].RestartPolicy) { continue } total++ if cos[i].Ready { ready++ } restart += int(cos[i].RestartCount) } return } func (*Pod) readinessGateStats(spec *v1.PodSpec, st *v1.PodStatus) (ready, total int) { total = len(spec.ReadinessGates) for _, readinessGate := range spec.ReadinessGates { for _, condition := range st.Conditions { if condition.Type == readinessGate.ConditionType { if condition.Status == "True" { ready++ } } } } return } // Phase reports the given pod phase. func (p *Pod) Phase(dt *metav1.Time, spec *v1.PodSpec, st *v1.PodStatus) string { status := string(st.Phase) if st.Reason != "" { if dt != nil && st.Reason == NodeUnreachablePodReason { return "Unknown" } status = st.Reason } status, ok := p.initContainerPhase(spec, st, status) if ok { return status } status, ok = p.containerPhase(st, status) if ok && status == Completed { status = Running } if dt == nil { return status } return Terminating } func (*Pod) containerPhase(st *v1.PodStatus, status string) (string, bool) { var running bool for i := len(st.ContainerStatuses) - 1; i >= 0; i-- { cs := st.ContainerStatuses[i] switch { case cs.State.Waiting != nil && cs.State.Waiting.Reason != "": status = cs.State.Waiting.Reason case cs.State.Terminated != nil && cs.State.Terminated.Reason != "": status = cs.State.Terminated.Reason case cs.State.Terminated != nil: if cs.State.Terminated.Signal != 0 { status = "Signal:" + strconv.Itoa(int(cs.State.Terminated.Signal)) } else { status = "ExitCode:" + strconv.Itoa(int(cs.State.Terminated.ExitCode)) } case cs.Ready && cs.State.Running != nil: running = true } } return status, running } func (*Pod) initContainerPhase(spec *v1.PodSpec, pst *v1.PodStatus, status string) (string, bool) { count := len(spec.InitContainers) sidecars := sets.New[string]() for i := range spec.InitContainers { co := spec.InitContainers[i] if isSideCarContainer(co.RestartPolicy) { sidecars.Insert(co.Name) } } for i := range pst.InitContainerStatuses { if s := checkInitContainerStatus(&pst.InitContainerStatuses[i], i, count, sidecars.Has(pst.InitContainerStatuses[i].Name)); s != "" { return s, true } } return status, false } // ---------------------------------------------------------------------------- // Helpers.. func checkInitContainerStatus(cs *v1.ContainerStatus, count, initCount int, restartable bool) string { switch { case cs.State.Terminated != nil: if cs.State.Terminated.ExitCode == 0 { return "" } if cs.State.Terminated.Reason != "" { return "Init:" + cs.State.Terminated.Reason } if cs.State.Terminated.Signal != 0 { return "Init:Signal:" + strconv.Itoa(int(cs.State.Terminated.Signal)) } return "Init:ExitCode:" + strconv.Itoa(int(cs.State.Terminated.ExitCode)) case restartable && cs.Started != nil && *cs.Started: if cs.Ready { return "" } case cs.State.Waiting != nil && cs.State.Waiting.Reason != "" && cs.State.Waiting.Reason != "PodInitializing": return "Init:" + cs.State.Waiting.Reason } return "Init:" + strconv.Itoa(count) + "/" + strconv.Itoa(initCount) } // PodStatus computes pod status. func PodStatus(pod *v1.Pod) string { reason := string(pod.Status.Phase) if pod.Status.Reason != "" { reason = pod.Status.Reason } for _, condition := range pod.Status.Conditions { if condition.Type == v1.PodScheduled && condition.Reason == v1.PodReasonSchedulingGated { reason = v1.PodReasonSchedulingGated } } var initializing bool for i := range pod.Status.InitContainerStatuses { container := pod.Status.InitContainerStatuses[i] switch { case container.State.Terminated != nil && container.State.Terminated.ExitCode == 0: continue case container.State.Terminated != nil: if container.State.Terminated.Reason == "" { if container.State.Terminated.Signal != 0 { reason = fmt.Sprintf("Init:Signal:%d", container.State.Terminated.Signal) } else { reason = fmt.Sprintf("Init:ExitCode:%d", container.State.Terminated.ExitCode) } } else { reason = "Init:" + container.State.Terminated.Reason } initializing = true case container.State.Waiting != nil && container.State.Waiting.Reason != "" && container.State.Waiting.Reason != "PodInitializing": reason = "Init:" + container.State.Waiting.Reason initializing = true default: reason = fmt.Sprintf("Init:%d/%d", i, len(pod.Spec.InitContainers)) initializing = true } break } if !initializing { var hasRunning bool for i := len(pod.Status.ContainerStatuses) - 1; i >= 0; i-- { container := pod.Status.ContainerStatuses[i] switch { case container.State.Waiting != nil && container.State.Waiting.Reason != "": reason = container.State.Waiting.Reason case container.State.Terminated != nil && container.State.Terminated.Reason != "": reason = container.State.Terminated.Reason case container.State.Terminated != nil && container.State.Terminated.Reason == "": if container.State.Terminated.Signal != 0 { reason = fmt.Sprintf("Signal:%d", container.State.Terminated.Signal) } else { reason = fmt.Sprintf("ExitCode:%d", container.State.Terminated.ExitCode) } case container.Ready && container.State.Running != nil: hasRunning = true } } if reason == PhaseCompleted && hasRunning { if hasPodReadyCondition(pod.Status.Conditions) { reason = PhaseRunning } else { reason = PhaseNotReady } } } if pod.DeletionTimestamp != nil && pod.Status.Reason == NodeUnreachablePodReason { reason = PhaseUnknown } else if pod.DeletionTimestamp != nil { reason = PhaseTerminating } return reason } func hasPodReadyCondition(conditions []v1.PodCondition) bool { for _, condition := range conditions { if condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue { return true } } return false } 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 { if isSideCarContainer(cc[i].RestartPolicy) { rcc = append(rcc, cc[i]) } } return rcc } ================================================ FILE: internal/render/pod_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "testing" "time" "github.com/derailed/k9s/internal/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" res "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) func Test_checkInitContainerStatus(t *testing.T) { trueVal := true uu := map[string]struct { status v1.ContainerStatus e string count, total int restart bool }{ "none": { e: "Init:0/0", }, "restart": { status: v1.ContainerStatus{ Name: "ic1", Started: &trueVal, State: v1.ContainerState{}, }, restart: true, e: "Init:0/0", }, "no-restart": { status: v1.ContainerStatus{ Name: "ic1", Started: &trueVal, State: v1.ContainerState{}, }, e: "Init:0/0", }, "terminated-reason": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 1, Reason: "blah", }, }, }, e: "Init:blah", }, "terminated-signal": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 1, Signal: 9, }, }, }, e: "Init:Signal:9", }, "terminated-code": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 1, }, }, }, e: "Init:ExitCode:1", }, "terminated-restart": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ Reason: "blah", }, }, }, }, "waiting": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "blah", }, }, }, e: "Init:blah", }, "waiting-init": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "PodInitializing", }, }, }, e: "Init:0/0", }, "running": { status: v1.ContainerStatus{ Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, e: "Init:0/0", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, checkInitContainerStatus(&u.status, u.count, u.total, u.restart)) }) } } func Test_containerPhase(t *testing.T) { uu := map[string]struct { status v1.PodStatus e string ok bool }{ "none": {}, "empty": { status: v1.PodStatus{ Phase: PhaseUnknown, }, }, "waiting": { status: v1.PodStatus{ Phase: PhaseUnknown, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "waiting", }, }, }, }, }, e: "waiting", }, "terminated": { status: v1.PodStatus{ Phase: PhaseUnknown, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ Reason: "done", }, }, }, }, }, e: "done", }, "terminated-sig": { status: v1.PodStatus{ Phase: PhaseUnknown, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ Signal: 9, }, }, }, }, }, e: "Signal:9", }, "terminated-code": { status: v1.PodStatus{ Phase: PhaseUnknown, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 2, }, }, }, }, }, e: "ExitCode:2", }, "running": { status: v1.PodStatus{ Phase: PhaseUnknown, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", Ready: true, State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, ok: true, }, } var p Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { s, ok := p.containerPhase(&u.status, "") assert.Equal(t, u.ok, ok) assert.Equal(t, u.e, s) }) } } func Test_isSideCarContainer(t *testing.T) { always, never := v1.ContainerRestartPolicyAlways, v1.ContainerRestartPolicy("never") uu := map[string]struct { p *v1.ContainerRestartPolicy e bool }{ "empty": {}, "sidecar": { p: &always, e: true, }, "no-sidecar": { p: &never, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, isSideCarContainer(u.p)) }) } } func Test_filterSidecarCO(t *testing.T) { always := v1.ContainerRestartPolicyAlways uu := map[string]struct { cc, ecc []v1.Container }{ "empty": { cc: []v1.Container{}, ecc: []v1.Container{}, }, "restartable": { cc: []v1.Container{ { Name: "c1", RestartPolicy: &always, }, }, ecc: []v1.Container{ { Name: "c1", RestartPolicy: &always, }, }, }, "not-restartable": { cc: []v1.Container{ { Name: "c1", }, }, ecc: []v1.Container{}, }, "mixed": { cc: []v1.Container{ { Name: "c1", }, { Name: "c2", RestartPolicy: &always, }, }, ecc: []v1.Container{ { Name: "c2", RestartPolicy: &always, }, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.ecc, filterSidecarCO(u.cc)) }) } } func Test_lastRestart(t *testing.T) { uu := map[string]struct { containerStatuses []v1.ContainerStatus expected metav1.Time }{ "no-restarts": { containerStatuses: []v1.ContainerStatus{ { Name: "c1", LastTerminationState: v1.ContainerState{}, }, }, expected: metav1.Time{}, }, "single-container-restart": { containerStatuses: []v1.ContainerStatus{ { Name: "c1", LastTerminationState: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ FinishedAt: metav1.Time{Time: testTime()}, }, }, }, }, expected: metav1.Time{Time: testTime()}, }, "multiple-container-restarts": { containerStatuses: []v1.ContainerStatus{ { Name: "c1", LastTerminationState: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ FinishedAt: metav1.Time{Time: testTime().Add(-1 * time.Hour)}, }, }, }, { Name: "c2", LastTerminationState: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ FinishedAt: metav1.Time{Time: testTime()}, }, }, }, }, expected: metav1.Time{Time: testTime()}, }, "mixed-termination-states": { containerStatuses: []v1.ContainerStatus{ { Name: "c1", LastTerminationState: v1.ContainerState{}, }, { Name: "c2", LastTerminationState: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ FinishedAt: metav1.Time{Time: testTime()}, }, }, }, }, expected: metav1.Time{Time: testTime()}, }, } var p Pod for name, u := range uu { t.Run(name, func(t *testing.T) { _, _, _, lr := p.ContainerStats(u.containerStatuses) assert.Equal(t, u.expected, lr) }) } } func Test_gatherPodMX(t *testing.T) { uu := map[string]struct { spec *v1.PodSpec mx []mv1beta1.ContainerMetrics c, r metric perc string }{ "single": { spec: &v1.PodSpec{ Containers: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), }, }, mx: []mv1beta1.ContainerMetrics{ makeCoMX("c1", "1m", "22Mi"), }, 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{ makeContainer("c1", false, "11m", "22Mi", "111m", "44Mi"), makeContainer("c2", false, "93m", "1402Mi", "0m", "2804Mi"), makeContainer("c3", false, "11m", "34Mi", "0m", "69Mi"), }, }, 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{ makeCoMX("c1", "1m", "22Mi"), makeCoMX("c2", "51m", "1275Mi"), makeCoMX("c3", "1m", "27Mi"), }, c: metric{ cpu: 1 + 51 + 1, gpu: 1, mem: (22 + 1275 + 27) * client.MegaByte, }, perc: "46", }, "sidecar": { spec: &v1.PodSpec{ Containers: []v1.Container{ makeContainer("c1", false, "11m", "22Mi", "111m", "44Mi"), }, InitContainers: []v1.Container{ makeContainer("c2", true, "93m", "1402Mi", "0m", "2804Mi"), }, }, 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{ makeCoMX("c1", "1m", "22Mi"), makeCoMX("c2", "51m", "1275Mi"), }, c: metric{ cpu: 1 + 51, gpu: 1, mem: (22 + 1275) * client.MegaByte, }, perc: "50", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { 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)) }) } } func Test_podLimits(t *testing.T) { uu := map[string]struct { cc []v1.Container l v1.ResourceList }{ "plain": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), }, l: makeRes("20m", "2Mi"), }, "multi-co": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), makeContainer("c2", false, "10m", "1Mi", "40m", "4Mi"), }, l: makeRes("60m", "6Mi"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { 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))) }) } } func Test_podRequests(t *testing.T) { uu := map[string]struct { cc []v1.Container e v1.ResourceList }{ "plain": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), }, e: makeRes("10m", "1Mi"), }, "multi-co": { cc: []v1.Container{ makeContainer("c1", false, "10m", "1Mi", "20m", "2Mi"), makeContainer("c2", false, "10m", "1Mi", "40m", "4Mi"), }, e: makeRes("20m", "2Mi"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { 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))) }) } } func Test_readinessGateStats(t *testing.T) { const ( gate1 = "k9s.derailed.com/gate1" gate2 = "k9s.derailed.com/gate2" ) uu := map[string]struct { spec *v1.PodSpec st *v1.PodStatus r int t int }{ "empty": { spec: &v1.PodSpec{}, st: &v1.PodStatus{ Conditions: []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionTrue}}, }, r: 0, t: 0, }, "single": { spec: &v1.PodSpec{ ReadinessGates: []v1.PodReadinessGate{{ConditionType: gate1}}, }, st: &v1.PodStatus{ Conditions: []v1.PodCondition{{Type: gate1, Status: v1.ConditionTrue}}, }, r: 1, t: 1, }, "multiple": { spec: &v1.PodSpec{ ReadinessGates: []v1.PodReadinessGate{{ConditionType: gate1}, {ConditionType: gate2}}, }, st: &v1.PodStatus{ Conditions: []v1.PodCondition{{Type: gate1, Status: v1.ConditionTrue}, {Type: gate2, Status: v1.ConditionTrue}, {Type: v1.PodReady, Status: v1.ConditionFalse}}, }, r: 2, t: 2, }, "mixed": { spec: &v1.PodSpec{ ReadinessGates: []v1.PodReadinessGate{{ConditionType: gate1}, {ConditionType: gate2}}, }, st: &v1.PodStatus{ Conditions: []v1.PodCondition{{Type: gate1, Status: v1.ConditionTrue}, {Type: gate2, Status: v1.ConditionFalse}, {Type: v1.PodReady, Status: v1.ConditionTrue}}, }, r: 1, t: 2, }, "missing": { spec: &v1.PodSpec{ ReadinessGates: []v1.PodReadinessGate{{ConditionType: gate1}, {ConditionType: gate2}}, }, st: &v1.PodStatus{ Conditions: []v1.PodCondition{{Type: gate1, Status: v1.ConditionTrue}, {Type: v1.PodReady, Status: v1.ConditionTrue}}, }, r: 1, t: 2, }, } var p Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ready, total := p.readinessGateStats(u.spec, u.st) assert.Equal(t, u.r, ready) assert.Equal(t, u.t, total) }) } } func Test_diagnose(t *testing.T) { uu := map[string]struct { phase string cr, ct int ready bool rgr, rgt int err string }{ "completed": { phase: Completed, cr: 0, ct: 1, ready: true, rgr: 0, rgt: 0, err: "", }, "container-ready-check-failed": { phase: "Running", cr: 1, ct: 2, ready: true, rgr: 1, rgt: 2, err: "container ready check failed: 1 of 2", }, "readiness-gate-check-failed": { phase: "Running", cr: 1, ct: 1, ready: true, rgr: 1, rgt: 2, err: "readiness gate check failed: 1 of 2", }, "pod-condition-ready-false": { phase: "Running", cr: 1, ct: 1, ready: false, rgr: 0, rgt: 0, err: "pod condition ready is false", }, "pod-terminating": { phase: "Terminating", cr: 1, ct: 1, ready: true, rgr: 1, rgt: 1, err: "pod is terminating", }, } var p Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { err := p.diagnose(u.phase, u.cr, u.ct, u.ready, u.rgr, u.rgt) if u.err == "" { assert.NoError(t, err) } else { require.Error(t, err) assert.Contains(t, err.Error(), u.err) } }) } } // Helpers... func makeContainer(n string, restartable bool, rc, rm, lc, lm string) v1.Container { always := v1.ContainerRestartPolicyAlways rq := v1.ResourceRequirements{ Requests: makeRes(rc, rm), Limits: makeRes(lc, lm), } var rp *v1.ContainerRestartPolicy if restartable { rp = &always } return v1.Container{Name: n, Resources: rq, RestartPolicy: rp} } 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.ResourceName("nvidia.com/gpu"): gpu, } } func makeCoMX(n, c, m string) mv1beta1.ContainerMetrics { return mv1beta1.ContainerMetrics{ Name: n, Usage: makeRes(c, m), } } func testTime() time.Time { t, err := time.Parse(time.RFC3339, "2018-12-14T10:36:43.326972-07:00") if err != nil { fmt.Println("TestTime Failed", err) } return t } ================================================ FILE: internal/render/pod_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" res "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" mv1beta1 "k8s.io/metrics/pkg/apis/metrics/v1beta1" ) func init() { model1.AddColor = tcell.ColorBlue model1.HighlightColor = tcell.ColorYellow model1.CompletedColor = tcell.ColorGray model1.StdColor = tcell.ColorWhite model1.ErrColor = tcell.ColorRed model1.KillColor = tcell.ColorGray } func TestPodColorer(t *testing.T) { stdHeader := model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "RESTARTS"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "VALID"}, } uu := map[string]struct { re model1.RowEvent h model1.Header e tcell.Color }{ "valid": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.Running, ""}, }, }, e: model1.StdColor, }, "init": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.PodInitializing, ""}, }, }, e: model1.AddColor, }, "init-err": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.PodInitializing, "blah"}, }, }, e: model1.AddColor, }, "initialized": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.Initialized, "blah"}, }, }, e: model1.HighlightColor, }, "completed": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.Completed, "blah"}, }, }, e: model1.CompletedColor, }, "terminating": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", render.Terminating, "blah"}, }, }, e: model1.KillColor, }, "invalid": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", "Running", "blah"}, }, }, e: model1.ErrColor, }, "unknown-cool": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", "blee", ""}, }, }, e: model1.AddColor, }, "unknown-err": { h: stdHeader, re: model1.RowEvent{ Kind: model1.EventAdd, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", "blee", "doh"}, }, }, e: model1.ErrColor, }, "status": { h: stdHeader[0:3], re: model1.RowEvent{ Kind: model1.EventDelete, Row: model1.Row{ Fields: model1.Fields{"blee", "fred", "1/1", "0", "blee", ""}, }, }, e: model1.KillColor, }, } var r render.Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, r.ColorerFunc()("", u.h, &u.re)) }) } } func TestPodRender(t *testing.T) { pom := render.PodWithMetrics{ Raw: load(t, "po"), MX: makePodMX("nginx", "100m", "50Mi"), } po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) require.NoError(t, err) assert.Equal(t, "default/nginx", r.ID) e := model1.Fields{"default", "nginx", "n/a", "●", "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) { pom := render.PodWithMetrics{ Raw: load(b, "po"), MX: makePodMX("nginx", "10m", "10Mi"), } po := render.NewPod() r := model1.NewRow(12) b.ReportAllocs() b.ResetTimer() for range b.N { _ = po.Render(&pom, "", &r) } } func TestPodInitRender(t *testing.T) { pom := render.PodWithMetrics{ Raw: load(t, "po_init"), MX: makePodMX("nginx", "10m", "10Mi"), } po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) require.NoError(t, err) assert.Equal(t, "default/nginx", r.ID) e := model1.Fields{"default", "nginx", "n/a", "●", "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) { pom := render.PodWithMetrics{ Raw: load(t, "po_sidecar"), MX: makePodMX("sleep", "100m", "40Mi"), } po := render.NewPod() r := model1.NewRow(14) err := po.Render(&pom, "", &r) require.NoError(t, err) assert.Equal(t, "default/sleep", r.ID) e := model1.Fields{"default", "sleep", "n/a", "●", "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) { uu := map[string]struct { pod v1.Pod e string }{ "unknown": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: render.PhaseUnknown, }, }, e: render.PhaseUnknown, }, "running": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{}, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, }, e: render.PhaseRunning, }, "gated": { pod: v1.Pod{ Status: v1.PodStatus{ Conditions: []v1.PodCondition{ {Type: v1.PodScheduled, Reason: v1.PodReasonSchedulingGated}, }, Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{}, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, }, e: v1.PodReasonSchedulingGated, }, "backoff": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: render.PhaseImagePullBackOff, }, "backoff-init": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: "Init:ImagePullBackOff", }, "init-terminated-cool": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{}, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: "Init:0/0", }, "init-terminated-reason": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 1, Reason: "blah", }, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: "Init:blah", }, "init-terminated-sig": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 2, Signal: 9, }, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: "Init:Signal:9", }, "init-terminated-code": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 2, }, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: render.PhaseImagePullBackOff, }, }, }, }, }, }, e: "Init:ExitCode:2", }, "co-reason": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ Reason: "blah", }, }, }, }, }, }, e: "blah", }, "co-reason-ready": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", Ready: true, State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, }, e: "Running", }, "co-reason-completed": { pod: v1.Pod{ Status: v1.PodStatus{ Conditions: []v1.PodCondition{ {Type: v1.PodReady, Status: v1.ConditionTrue}, }, Phase: render.PhaseCompleted, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", Ready: true, State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, }, e: "Running", }, "co-sig": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 2, Signal: 9, }, }, }, }, }, }, e: "Signal:9", }, "co-code": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Terminated: &v1.ContainerStateTerminated{ ExitCode: 2, }, }, }, }, }, }, e: "ExitCode:2", }, "co-ready": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: v1.PodRunning, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, }, }, e: "Running", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, render.PodStatus(&u.pod)) }) } } func TestCheckPhase(t *testing.T) { always := v1.ContainerRestartPolicyAlways uu := map[string]struct { pod v1.Pod e string }{ "unknown": { pod: v1.Pod{ Status: v1.PodStatus{ Phase: render.PhaseUnknown, }, }, e: render.PhaseUnknown, }, "terminating": { pod: v1.Pod{ ObjectMeta: metav1.ObjectMeta{ DeletionTimestamp: &metav1.Time{Time: testTime()}, }, Status: v1.PodStatus{ Phase: render.PhaseUnknown, Reason: "bla", }, }, e: render.PhaseTerminating, }, "terminating-toast-node": { pod: v1.Pod{ ObjectMeta: metav1.ObjectMeta{ DeletionTimestamp: &metav1.Time{Time: testTime()}, }, Status: v1.PodStatus{ Phase: render.PhaseUnknown, Reason: render.NodeUnreachablePodReason, }, }, e: render.PhaseUnknown, }, "restartable": { pod: v1.Pod{ ObjectMeta: metav1.ObjectMeta{ DeletionTimestamp: &metav1.Time{Time: testTime()}, }, Spec: v1.PodSpec{ InitContainers: []v1.Container{ { Name: "ic1", RestartPolicy: &always, }, }, }, Status: v1.PodStatus{ Phase: render.PhaseUnknown, Reason: "bla", InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", }, }, }, }, e: "Init:0/1", }, "waiting": { pod: v1.Pod{ ObjectMeta: metav1.ObjectMeta{ DeletionTimestamp: &metav1.Time{Time: testTime()}, }, Spec: v1.PodSpec{ InitContainers: []v1.Container{ { Name: "ic1", RestartPolicy: &always, }, }, Containers: []v1.Container{ { Name: "c1", }, }, }, Status: v1.PodStatus{ Phase: render.PhaseUnknown, Reason: "bla", InitContainerStatuses: []v1.ContainerStatus{ { Name: "ic1", State: v1.ContainerState{ Running: &v1.ContainerStateRunning{}, }, }, }, ContainerStatuses: []v1.ContainerStatus{ { Name: "c1", State: v1.ContainerState{ Waiting: &v1.ContainerStateWaiting{ Reason: "bla", }, }, }, }, }, }, e: "Init:0/1", }, } var p render.Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, p.Phase(u.pod.DeletionTimestamp, &u.pod.Spec, &u.pod.Status)) }) } } // ---------------------------------------------------------------------------- // Helpers... func makePodMX(name, cpu, mem string) *mv1beta1.PodMetrics { return &mv1beta1.PodMetrics{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: "default", }, Containers: []mv1beta1.ContainerMetrics{ {Usage: makeRes(cpu, mem)}, }, } } func makeRes(c, m string) v1.ResourceList { cpu, _ := res.ParseQuantity(c) mem, _ := res.ParseQuantity(m) return v1.ResourceList{ v1.ResourceCPU: cpu, v1.ResourceMemory: mem, } } ================================================ FILE: internal/render/policy.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) func rbacVerbHeader() model1.Header { return model1.Header{ model1.HeaderColumn{Name: "GET "}, model1.HeaderColumn{Name: "LIST "}, model1.HeaderColumn{Name: "WATCH "}, model1.HeaderColumn{Name: "CREATE"}, model1.HeaderColumn{Name: "PATCH "}, model1.HeaderColumn{Name: "UPDATE"}, model1.HeaderColumn{Name: "DELETE"}, model1.HeaderColumn{Name: "DEL-LIST "}, model1.HeaderColumn{Name: "EXTRAS", Attrs: model1.Attrs{Wide: true}}, } } // Policy renders a rbac policy to screen. type Policy struct { Base } // ColorerFunc colors a resource row. func (Policy) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorMediumSpringGreen } } // Header returns a header row. func (Policy) Header(string) model1.Header { h := model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "API-GROUP"}, model1.HeaderColumn{Name: "BINDING"}, } h = append(h, rbacVerbHeader()...) h = append(h, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}) return h } // Render renders a K8s resource to screen. func (Policy) Render(o any, _ string, r *model1.Row) error { p, ok := o.(*PolicyRes) if !ok { return fmt.Errorf("expecting PolicyRes but got %T", o) } r.ID = client.FQN(p.Namespace, p.Resource) r.Fields = append(r.Fields, p.Namespace, cleanseResource(p.Resource), p.Group, p.Binding, ) r.Fields = append(r.Fields, asVerbs(p.Verbs)...) r.Fields = append(r.Fields, "") return nil } // ---------------------------------------------------------------------------- // Helpers... func cleanseResource(r string) string { if r == "" || r[0] == '/' { return r } tt := strings.Split(r, "/") switch len(tt) { case 2, 3: return strings.TrimPrefix(r, tt[0]+"/") default: return r } } // PolicyRes represents a rbac policy rule. type PolicyRes struct { Namespace, Binding string Resource, Group string ResourceName string NonResourceURL string Verbs []string } // NewPolicyRes returns a new policy. func NewPolicyRes(ns, binding, res, grp string, vv []string) *PolicyRes { return &PolicyRes{ Namespace: ns, Binding: binding, Resource: res, Group: grp, Verbs: vv, } } // GR returns the group/resource path. func (p *PolicyRes) GR() string { return p.Group + "/" + p.Resource } // Merge merges two policies. func (p *PolicyRes) Merge(p1 *PolicyRes) (*PolicyRes, error) { if p.GR() != p1.GR() { return nil, fmt.Errorf("policy mismatch %s vs %s", p.GR(), p1.GR()) } for _, v := range p1.Verbs { if !p.hasVerb(v) { p.Verbs = append(p.Verbs, v) } } return p, nil } func (p *PolicyRes) hasVerb(v1 string) bool { for _, v := range p.Verbs { if v == v1 { return true } } return false } // GetObjectKind returns a schema object. func (*PolicyRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (p *PolicyRes) DeepCopyObject() runtime.Object { return p } // Policies represents a collection of RBAC policies. type Policies []*PolicyRes // Upsert adds a new policy. func (pp Policies) Upsert(p *PolicyRes) Policies { idx, ok := pp.find(p.GR()) if !ok { return append(pp, p) } p, err := pp[idx].Merge(p) if err != nil { slog.Error("Policy upsert failed", slogs.Error, err) return pp } pp[idx] = p return pp } // Find locates a row by id. Returns false is not found. func (pp Policies) find(gr string) (int, bool) { for i, p := range pp { if p.GR() == gr { return i, true } } return 0, false } ================================================ FILE: internal/render/policy_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "testing" "github.com/stretchr/testify/assert" ) func Test_cleanseResource(t *testing.T) { uu := map[string]struct { r, e string }{ "empty": {}, "single": { r: "fred", e: "fred", }, "grp/res": { r: "fred/blee", e: "blee", }, "grp/res/sub": { r: "fred/blee/bob", e: "blee/bob", }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, cleanseResource(u.r)) }) } } ================================================ FILE: internal/render/policy_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "errors" "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPolicyResMerge(t *testing.T) { uu := map[string]struct { p1, p2, e *render.PolicyRes err error }{ "simple": { p1: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get"}), p2: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"patch"}), e: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get", "patch"}), }, "dups": { p1: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get"}), p2: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get", "delete"}), e: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get", "delete"}), }, "mismatch": { p1: render.NewPolicyRes("fred", "blee", "deployments", "apps/v1", []string{"get"}), p2: render.NewPolicyRes("fred", "blee", "statefulsets", "apps/v1", []string{"get", "delete"}), err: errors.New("policy mismatch apps/v1/deployments vs apps/v1/statefulsets"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { e, err := u.p1.Merge(u.p2) assert.Equal(t, u.err, err) assert.Equal(t, u.e, e) }) } } func TestPolicyRender(t *testing.T) { var p render.Policy var r model1.Row o := render.PolicyRes{ Namespace: "blee", Binding: "fred", Resource: "res", Group: "grp", ResourceName: "bob", NonResourceURL: "/blee", Verbs: []string{"get", "list", "watch"}, } require.NoError(t, p.Render(&o, "fred", &r)) assert.Equal(t, "blee/res", r.ID) assert.Equal(t, model1.Fields{ "blee", "res", "grp", "fred", "[green::b] ✓ [::]", "[green::b] ✓ [::]", "[green::b] ✓ [::]", "[orangered::b] × [::]", "[orangered::b] × [::]", "[orangered::b] × [::]", "[orangered::b] × [::]", "[orangered::b] × [::]", "", "", }, r.Fields) } ================================================ FILE: internal/render/port_forward_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "time" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPortForwardRender(t *testing.T) { o := render.ForwardRes{ Forwarder: fwd{}, Config: render.BenchCfg{ C: 1, N: 1, Host: "0.0.0.0", Path: "/", }, } var p render.PortForward var r model1.Row require.NoError(t, p.Render(o, "fred", &r)) assert.Equal(t, "blee/fred", r.ID) assert.Equal(t, model1.Fields{ "blee", "fred", "co", "p1:p2", "http://0.0.0.0:p1/", "1", "1", "", }, r.Fields[:8]) } // Helpers... type fwd struct{} func (fwd) ID() string { return "blee/fred" } func (fwd) Path() string { return "blee/fred" } func (fwd) Container() string { return "co" } func (fwd) Port() string { return "p1:p2" } func (fwd) Active() bool { return true } func (fwd) Age() time.Time { return testTime() } func (fwd) Address() string { return "" } ================================================ FILE: internal/render/portforward.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Forwarder represents a port forwarder. type Forwarder interface { // ID returns the PF FQN. ID() string // Container returns a container name. Container() string // Port returns container exposed port. Port() string // Address returns the host address. Address() string // Active returns forwarder current state. Active() bool // Age returns forwarder age. Age() time.Time } // PortForward renders a portforwards to screen. type PortForward struct { Base } // ColorerFunc colors a resource row. func (PortForward) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorSkyblue } } // Header returns a header row. func (PortForward) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "CONTAINER"}, model1.HeaderColumn{Name: "PORTS"}, model1.HeaderColumn{Name: "URL"}, model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "N"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. func (PortForward) Render(o any, _ string, r *model1.Row) error { pf, ok := o.(ForwardRes) if !ok { return fmt.Errorf("expecting a ForwardRes but got %T", o) } ports := strings.Split(pf.Port(), ":") r.ID = pf.ID() ns, n := client.Namespaced(r.ID) r.Fields = model1.Fields{ ns, trimContainer(n), pf.Container(), pf.Port(), UrlFor(pf.Config.Host, pf.Config.Path, ports[0], pf.Address()), AsThousands(int64(pf.Config.C)), AsThousands(int64(pf.Config.N)), "", ToAge(metav1.Time{Time: pf.Age()}), } return nil } // Helpers... func trimContainer(n string) string { tokens := strings.Split(n, "|") if len(tokens) == 0 { return n } _, name := client.Namespaced(tokens[0]) return name } // UrlFor computes fq url for a given benchmark configuration. func UrlFor(host, path, port, address string) string { if host == "" { host = address } if path == "" { path = "/" } return "http://" + host + ":" + port + path } // BenchCfg represents a benchmark configuration. type BenchCfg struct { C, N int Host, Path string } // ForwardRes represents a benchmark resource. type ForwardRes struct { Forwarder Config BenchCfg } // GetObjectKind returns a schema object. func (ForwardRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (f ForwardRes) DeepCopyObject() runtime.Object { return f } ================================================ FILE: internal/render/pv.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "path" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) const terminatingPhase = "Terminating" // PersistentVolume renders a K8s PersistentVolume to screen. type PersistentVolume struct { Base } // ColorerFunc colors a resource row. func (PersistentVolume) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("STATUS", true) if !ok { return c } switch strings.TrimSpace(re.Row.Fields[idx]) { case string(v1.VolumeBound): return model1.StdColor case string(v1.VolumeAvailable): return tcell.ColorGreen case string(v1.VolumePending): return model1.PendingColor case terminatingPhase: return model1.CompletedColor } return c } } // Header returns a header row. func (p PersistentVolume) Header(_ string) model1.Header { return p.doHeader(defaultPVHeader) } var defaultPVHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "CAPACITY", Attrs: model1.Attrs{Capacity: true}}, model1.HeaderColumn{Name: "ACCESS MODES"}, model1.HeaderColumn{Name: "RECLAIM POLICY"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "CLAIM"}, model1.HeaderColumn{Name: "STORAGECLASS"}, model1.HeaderColumn{Name: "REASON"}, model1.HeaderColumn{Name: "VOLUMEMODE", Attrs: model1.Attrs{Wide: 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}}, } // Render renders a K8s resource to screen. func (p PersistentVolume) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := p.defaultRow(raw, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(raw, defaultPVHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (p PersistentVolume) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pv v1.PersistentVolume err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pv) if err != nil { return err } phase := pv.Status.Phase if pv.DeletionTimestamp != nil { phase = terminatingPhase } var claim string if pv.Spec.ClaimRef != nil { claim = path.Join(pv.Spec.ClaimRef.Namespace, pv.Spec.ClaimRef.Name) } class, found := pv.Annotations[v1.BetaStorageClassAnnotation] if !found { class = pv.Spec.StorageClassName } size := pv.Spec.Capacity[v1.ResourceStorage] r.ID = client.MetaFQN(&pv.ObjectMeta) r.Fields = model1.Fields{ pv.Name, size.String(), accessMode(pv.Spec.AccessModes), string(pv.Spec.PersistentVolumeReclaimPolicy), string(phase), claim, class, pv.Status.Reason, p.volumeMode(pv.Spec.VolumeMode), mapToStr(pv.Labels), AsStatus(p.diagnose(phase)), ToAge(pv.GetCreationTimestamp()), } return nil } func (PersistentVolume) diagnose(phase v1.PersistentVolumePhase) error { if phase == v1.VolumeFailed { return fmt.Errorf("failed to delete or recycle") } return nil } func (PersistentVolume) volumeMode(m *v1.PersistentVolumeMode) string { if m == nil { return MissingValue } return string(*m) } // ---------------------------------------------------------------------------- // Helpers... func accessMode(aa []v1.PersistentVolumeAccessMode) string { dd := accessDedup(aa) s := make([]string, 0, len(dd)) for _, am := range dd { switch am { case v1.ReadWriteOnce: s = append(s, "RWO") case v1.ReadOnlyMany: s = append(s, "ROX") case v1.ReadWriteMany: s = append(s, "RWX") case v1.ReadWriteOncePod: s = append(s, "RWOP") } } return strings.Join(s, ",") } func accessContains(cc []v1.PersistentVolumeAccessMode, a v1.PersistentVolumeAccessMode) bool { for _, c := range cc { if c == a { return true } } return false } func accessDedup(cc []v1.PersistentVolumeAccessMode) []v1.PersistentVolumeAccessMode { set := []v1.PersistentVolumeAccessMode{} for _, c := range cc { if !accessContains(set, c) { set = append(set, c) } } return set } ================================================ FILE: internal/render/pv_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPersistentVolumeRender(t *testing.T) { c := render.PersistentVolume{} r := model1.NewRow(9) require.NoError(t, c.Render(load(t, "pv"), "-", &r)) assert.Equal(t, "-/pvc-07aa4e2c-8726-11e9-a8e8-42010a80015b", r.ID) assert.Equal(t, model1.Fields{"pvc-07aa4e2c-8726-11e9-a8e8-42010a80015b", "1Gi", "RWO", "Delete", "Bound", "default/www-nginx-sts-1", "standard"}, r.Fields[:7]) } func TestTerminatingPersistentVolumeRender(t *testing.T) { c := render.PersistentVolume{} r := model1.NewRow(9) require.NoError(t, c.Render(load(t, "pv_terminating"), "-", &r)) assert.Equal(t, "-/pvc-a4d86f51-916c-476b-83af-b551c91a8ac0", r.ID) assert.Equal(t, model1.Fields{"pvc-a4d86f51-916c-476b-83af-b551c91a8ac0", "1Gi", "RWO", "Delete", "Terminating", "default/www-nginx-sts-2", "standard"}, r.Fields[:7]) } ================================================ FILE: internal/render/pvc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultPVCHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "VOLUME"}, model1.HeaderColumn{Name: "CAPACITY", Attrs: model1.Attrs{Capacity: true}}, model1.HeaderColumn{Name: "ACCESS MODES"}, model1.HeaderColumn{Name: "STORAGECLASS"}, 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}}, } // PersistentVolumeClaim renders a K8s PersistentVolumeClaim to screen. type PersistentVolumeClaim struct { Base } // Header returns a header row. func (p PersistentVolumeClaim) Header(_ string) model1.Header { return p.doHeader(defaultPVCHeader) } // Render renders a K8s resource to screen. func (p PersistentVolumeClaim) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := p.defaultRow(raw, row); err != nil { return err } if p.specs.isEmpty() { return nil } cols, err := p.specs.realize(raw, defaultPVCHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (p PersistentVolumeClaim) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var pvc v1.PersistentVolumeClaim err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pvc) if err != nil { return err } phase := pvc.Status.Phase if pvc.DeletionTimestamp != nil { phase = "Terminating" } storage := pvc.Spec.Resources.Requests[v1.ResourceStorage] var capacity, accessModes string if pvc.Spec.VolumeName != "" { accessModes = accessMode(pvc.Status.AccessModes) storage = pvc.Status.Capacity[v1.ResourceStorage] capacity = storage.String() } class, found := pvc.Annotations[v1.BetaStorageClassAnnotation] if !found { if pvc.Spec.StorageClassName != nil { class = *pvc.Spec.StorageClassName } } r.ID = client.MetaFQN(&pvc.ObjectMeta) r.Fields = model1.Fields{ pvc.Namespace, pvc.Name, string(phase), pvc.Spec.VolumeName, capacity, accessModes, class, mapToStr(pvc.Labels), AsStatus(p.diagnose(string(phase))), ToAge(pvc.GetCreationTimestamp()), } return nil } func (PersistentVolumeClaim) diagnose(r string) error { if r != "Bound" && r != "Available" { return fmt.Errorf("unexpected status %s", r) } return nil } ================================================ FILE: internal/render/pvc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPersistentVolumeClaimRender(t *testing.T) { c := render.PersistentVolumeClaim{} r := model1.NewRow(8) require.NoError(t, c.Render(load(t, "pvc"), "", &r)) assert.Equal(t, "default/www-nginx-sts-0", r.ID) assert.Equal(t, model1.Fields{"default", "www-nginx-sts-0", "Bound", "pvc-fbabd470-8725-11e9-a8e8-42010a80015b", "1Gi", "RWO", "standard"}, r.Fields[:7]) } ================================================ FILE: internal/render/rbac.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "github.com/derailed/k9s/internal/model1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) const allVerbs = "*" var ( k8sVerbs = []string{ "get", "list", "watch", "create", "patch", "update", "delete", "deletecollection", } httpTok8sVerbs = map[string]string{ "post": "create", "put": "update", } ) // Rbac renders a rbac to screen. type Rbac struct { Base } // ColorerFunc colors a resource row. func (Rbac) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Header returns a header row. func (Rbac) Header(string) model1.Header { h := make(model1.Header, 0, 10) h = append(h, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "API-GROUP"}, ) h = append(h, rbacVerbHeader()...) return append(h, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}) } // Render renders a K8s resource to screen. func (r Rbac) Render(o any, ns string, ro *model1.Row) error { p, ok := o.(*PolicyRes) if !ok { return fmt.Errorf("expecting PolicyRes but got %T", o) } ro.ID = p.Resource ro.Fields = make(model1.Fields, 0, len(r.Header(ns))) ro.Fields = append(ro.Fields, cleanseResource(p.Resource), p.Group, ) ro.Fields = append(ro.Fields, asVerbs(p.Verbs)...) ro.Fields = append(ro.Fields, "") return nil } // ---------------------------------------------------------------------------- // Helpers... func asVerbs(verbs []string) []string { const ( verbLen = 4 unknownLen = 30 ) r := make([]string, 0, len(k8sVerbs)+1) for _, v := range k8sVerbs { r = append(r, toVerbIcon(hasVerb(verbs, v))) } var unknowns []string for _, v := range verbs { if hv, ok := httpTok8sVerbs[v]; ok { v = hv } if !hasVerb(k8sVerbs, v) && v != allVerbs { unknowns = append(unknowns, v) } } return append(r, Truncate(strings.Join(unknowns, ","), unknownLen)) } func toVerbIcon(ok bool) string { if ok { return "[green::b] ✓ [::]" } return "[orangered::b] × [::]" } func hasVerb(verbs []string, verb string) bool { if len(verbs) == 1 && verbs[0] == allVerbs { return true } for _, v := range verbs { if hv, ok := httpTok8sVerbs[v]; ok { if hv == verb { return true } } if v == verb { return true } } return false } // RuleRes represents an rbac rule. type RuleRes struct { Resource, Group string ResourceName string NonResourceURL string Verbs []string } // NewRuleRes returns a new rule. func NewRuleRes(res, grp string, vv []string) *RuleRes { return &RuleRes{ Resource: res, Group: grp, Verbs: vv, } } // GetObjectKind returns a schema object. func (*RuleRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (r *RuleRes) DeepCopyObject() runtime.Object { return r } // Rules represents a collection of rules. type Rules []*RuleRes // Upsert adds a new rule. func (rr Rules) Upsert(r *RuleRes) Rules { idx, ok := rr.find(r.Resource) if !ok { return append(rr, r) } rr[idx] = r return rr } // Find locates a row by id. Returns false is not found. func (rr Rules) find(res string) (int, bool) { for i, r := range rr { if r.Resource == res { return i, true } } return 0, false } ================================================ FILE: internal/render/reference.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Reference renders a reference to screen. type Reference struct { Base } // ColorerFunc colors a resource row. func (Reference) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorCadetBlue } } // Header returns a header row. func (Reference) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "GVR"}, } } // Render renders a K8s resource to screen. // BOZO!! Pass in a row with pre-alloc fields?? func (Reference) Render(o any, _ string, r *model1.Row) error { ref, ok := o.(ReferenceRes) if !ok { return fmt.Errorf("expected ReferenceRes, but got %T", o) } r.ID = client.FQN(ref.Namespace, ref.Name) r.Fields = append(r.Fields, ref.Namespace, ref.Name, ref.GVR, ) return nil } // ---------------------------------------------------------------------------- // Helpers... // ReferenceRes represents a reference resource. type ReferenceRes struct { Namespace string Name string GVR string } // GetObjectKind returns a schema object. func (ReferenceRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (a ReferenceRes) DeepCopyObject() runtime.Object { return a } ================================================ FILE: internal/render/reference_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReferenceRender(t *testing.T) { o := render.ReferenceRes{ Namespace: "ns1", Name: "blee", GVR: client.SecGVR.String(), } var ( ref = render.Reference{} r model1.Row ) require.NoError(t, ref.Render(o, "fred", &r)) assert.Equal(t, "ns1/blee", r.ID) assert.Equal(t, model1.Fields{ "ns1", "blee", client.SecGVR.String(), }, r.Fields) } ================================================ FILE: internal/render/render_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "encoding/json" "fmt" "os" "testing" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // Helpers... func load(t testing.TB, n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) require.NoError(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) require.NoError(t, err) return &o } ================================================ FILE: internal/render/ro.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // Role renders a K8s Role to screen. type Role struct { Base } // Header returns a header row. func (r Role) Header(_ string) model1.Header { return r.doHeader(defaultROHeader) } var defaultROHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, 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}}, } // Render renders a K8s resource to screen. func (r Role) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := r.defaultRow(raw, row); err != nil { return err } if r.specs.isEmpty() { return nil } cols, err := r.specs.realize(raw, defaultROHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (Role) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var ro rbacv1.Role err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ro) if err != nil { return err } row.ID = client.MetaFQN(&ro.ObjectMeta) row.Fields = model1.Fields{ ro.Namespace, ro.Name, mapToStr(ro.Labels), "", ToAge(ro.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/ro_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRoleRender(t *testing.T) { c := render.Role{} r := model1.NewRow(3) require.NoError(t, c.Render(load(t, "ro"), "", &r)) assert.Equal(t, "default/blee", r.ID) assert.Equal(t, model1.Fields{"default", "blee"}, r.Fields[:2]) } ================================================ FILE: internal/render/rob.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultROBHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "ROLE"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "SUBJECTS"}, 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}}, } // RoleBinding renders a K8s RoleBinding to screen. type RoleBinding struct { Base } // Header returns a header row. func (r RoleBinding) Header(_ string) model1.Header { return r.doHeader(defaultROBHeader) } // Render renders a K8s resource to screen. func (r RoleBinding) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := r.defaultRow(raw, row); err != nil { return err } if r.specs.isEmpty() { return nil } cols, err := r.specs.realize(raw, defaultROBHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (RoleBinding) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var rb rbacv1.RoleBinding err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &rb) if err != nil { return err } kind, ss := renderSubjects(rb.Subjects) row.ID = client.MetaFQN(&rb.ObjectMeta) row.Fields = model1.Fields{ rb.Namespace, rb.Name, rb.RoleRef.Name, kind, ss, mapToStr(rb.Labels), "", ToAge(rb.GetCreationTimestamp()), } return nil } // ---------------------------------------------------------------------------- // Helpers... func renderSubjects(ss []rbacv1.Subject) (kind, subjects string) { if len(ss) == 0 { return NAValue, "" } tt := make([]string, 0, len(ss)) for _, s := range ss { kind = toSubjectAlias(s.Kind) tt = append(tt, s.Name) } return kind, strings.Join(tt, ",") } func toSubjectAlias(s string) string { if s == "" { return s } switch s { case rbacv1.UserKind: return "User" case rbacv1.GroupKind: return "Group" case rbacv1.ServiceAccountKind: return "SvcAcct" default: return strings.ToUpper(s) } } ================================================ FILE: internal/render/rob_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRoleBindingRender(t *testing.T) { c := render.RoleBinding{} r := model1.NewRow(6) require.NoError(t, c.Render(load(t, "rb"), "", &r)) assert.Equal(t, "default/blee", r.ID) assert.Equal(t, model1.Fields{"default", "blee", "blee", "SvcAcct", "fernand"}, r.Fields[:5]) } ================================================ FILE: internal/render/rs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tview" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // ReplicaSet renders a K8s ReplicaSet to screen. type ReplicaSet struct { Base } // ColorerFunc colors a resource row. func (ReplicaSet) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Header returns a header row. func (r ReplicaSet) Header(_ string) model1.Header { return r.doHeader(defaultRSHeader) } var defaultRSHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "DESIRED", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "CURRENT", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "READY", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Render renders a K8s resource to screen. func (r ReplicaSet) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := r.defaultRow(raw, row); err != nil { return err } if r.specs.isEmpty() { return nil } cols, err := r.specs.realize(raw, defaultRSHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (r ReplicaSet) defaultRow(raw *unstructured.Unstructured, row *model1.Row) error { var rs appsv1.ReplicaSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &rs) if err != nil { return err } var ( cc = rs.Spec.Template.Spec.Containers cos, imgs = make([]string, 0, len(cc)), make([]string, 0, len(cc)) ) for i := range cc { cos, imgs = append(cos, cc[i].Name), append(imgs, cc[i].Image) } row.ID = client.MetaFQN(&rs.ObjectMeta) row.Fields = model1.Fields{ rs.Namespace, rs.Name, computeVulScore(rs.Namespace, rs.Labels, &rs.Spec.Template.Spec), strconv.Itoa(int(*rs.Spec.Replicas)), strconv.Itoa(int(rs.Status.Replicas)), strconv.Itoa(int(rs.Status.ReadyReplicas)), strings.Join(cos, ","), strings.Join(imgs, ","), mapToStr(rs.Labels), AsStatus(r.diagnose(&rs)), ToAge(rs.GetCreationTimestamp()), } return nil } func (ReplicaSet) diagnose(rs *appsv1.ReplicaSet) error { if rs.Status.Replicas != rs.Status.ReadyReplicas { if rs.Status.Replicas == 0 { return fmt.Errorf("did not phase down correctly expecting 0 replicas but got %d", rs.Status.ReadyReplicas) } return fmt.Errorf("mismatch desired(%d) vs ready(%d)", rs.Status.Replicas, rs.Status.ReadyReplicas) } return nil } ================================================ FILE: internal/render/rs_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReplicaSetRender(t *testing.T) { c := render.ReplicaSet{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "rs"), "", &r)) assert.Equal(t, "icx/icx-db-7d4b578979", r.ID) assert.Equal(t, model1.Fields{"icx", "icx-db-7d4b578979", "n/a", "1", "1", "1"}, r.Fields[:6]) } ================================================ FILE: internal/render/sa.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultSAHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "SECRET"}, 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}}, } // ServiceAccount renders a K8s ServiceAccount to screen. type ServiceAccount struct { Base } // Header returns a header row. func (s ServiceAccount) Header(_ string) model1.Header { return s.doHeader(defaultSAHeader) } // Render renders a K8s resource to screen. func (s ServiceAccount) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := s.defaultRow(raw, row); err != nil { return err } if s.specs.isEmpty() { return nil } cols, err := s.specs.realize(raw, defaultSAHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (ServiceAccount) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sa v1.ServiceAccount err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sa) if err != nil { return err } r.ID = client.MetaFQN(&sa.ObjectMeta) r.Fields = model1.Fields{ sa.Namespace, sa.Name, strconv.Itoa(len(sa.Secrets)), mapToStr(sa.Labels), "", ToAge(sa.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/sa_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServiceAccountRender(t *testing.T) { c := render.ServiceAccount{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "sa"), "", &r)) assert.Equal(t, "default/blee", r.ID) assert.Equal(t, model1.Fields{"default", "blee", "2"}, r.Fields[:3]) } ================================================ FILE: internal/render/sc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/kubectl/pkg/util/storage" ) var defaultSCHeader = model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "PROVISIONER"}, model1.HeaderColumn{Name: "RECLAIMPOLICY"}, model1.HeaderColumn{Name: "VOLUMEBINDINGMODE"}, model1.HeaderColumn{Name: "ALLOWVOLUMEEXPANSION"}, 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}}, } // StorageClass renders a K8s StorageClass to screen. type StorageClass struct { Base } // Header returns a header row. func (s StorageClass) Header(_ string) model1.Header { return s.doHeader(defaultSCHeader) } // Render renders a K8s resource to screen. func (s StorageClass) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := s.defaultRow(raw, row); err != nil { return err } if s.specs.isEmpty() { return nil } cols, err := s.specs.realize(raw, defaultSCHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (s StorageClass) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sc storagev1.StorageClass err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sc) if err != nil { return err } r.ID = client.FQN(client.ClusterScope, sc.Name) r.Fields = model1.Fields{ s.nameWithDefault(&sc.ObjectMeta), sc.Provisioner, strPtrToStr((*string)(sc.ReclaimPolicy)), strPtrToStr((*string)(sc.VolumeBindingMode)), boolPtrToStr(sc.AllowVolumeExpansion), mapToStr(sc.Labels), "", ToAge(sc.GetCreationTimestamp()), } return nil } func (StorageClass) nameWithDefault(meta *metav1.ObjectMeta) string { if storage.IsDefaultAnnotationText(*meta) == "Yes" { return meta.Name + " (default)" } return meta.Name } ================================================ FILE: internal/render/sc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStorageClassRender(t *testing.T) { c := render.StorageClass{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "sc"), "", &r)) assert.Equal(t, "-/standard", r.ID) assert.Equal(t, model1.Fields{"standard (default)", "kubernetes.io/gce-pd", "Delete", "Immediate", "true"}, r.Fields[:5]) } ================================================ FILE: internal/render/screen_dump.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "os" "path/filepath" "time" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/duration" ) // ScreenDump renders a screendumps to screen. type ScreenDump struct { Base } // ColorerFunc colors a resource row. func (ScreenDump) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorNavajoWhite } } // Header returns a header row. func (ScreenDump) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "DIR"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } } // Render renders a K8s resource to screen. func (ScreenDump) Render(o any, _ string, r *model1.Row) error { f, ok := o.(FileRes) if !ok { return fmt.Errorf("expecting screendumper, but got %T", o) } r.ID = filepath.Join(f.Dir, f.File.Name()) r.Fields = model1.Fields{ f.File.Name(), f.Dir, "", timeToAge(f.File.ModTime()), } return nil } // ---------------------------------------------------------------------------- // Helpers... func timeToAge(timestamp time.Time) string { return duration.HumanDuration(time.Since(timestamp)) } // FileRes represents a file resource. type FileRes struct { File os.FileInfo Dir string } // GetObjectKind returns a schema object. func (FileRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (c FileRes) DeepCopyObject() runtime.Object { return c } ================================================ FILE: internal/render/screen_dump_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "os" "testing" "time" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestScreenDumpRender(t *testing.T) { var s render.ScreenDump var r model1.Row o := render.FileRes{ File: fileInfo{}, Dir: "fred/blee", } require.NoError(t, s.Render(o, "fred", &r)) assert.Equal(t, "fred/blee/bob", r.ID) assert.Equal(t, model1.Fields{ "bob", "fred/blee", "", }, r.Fields[:len(r.Fields)-1]) } // Helpers... type fileInfo struct{} var _ os.FileInfo = fileInfo{} func (fileInfo) Name() string { return "bob" } func (fileInfo) Size() int64 { return 100 } func (fileInfo) ModTime() time.Time { return testTime() } func (fileInfo) IsDir() bool { return false } func (fileInfo) Sys() any { return nil } func (fileInfo) Mode() os.FileMode { return os.FileMode(0644) } ================================================ FILE: internal/render/secret.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultSECHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "TYPE"}, model1.HeaderColumn{Name: "DATA"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Secret renders a K8s Secret to screen. type Secret struct { Base } // Header returns a header row. func (s Secret) Header(_ string) model1.Header { return s.doHeader(defaultSECHeader) } // Render renders a K8s resource to screen. func (s Secret) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := s.defaultRow(raw, row); err != nil { return err } if s.specs.isEmpty() { return nil } cols, err := s.specs.realize(raw, defaultSECHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (Secret) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sec v1.Secret err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sec) if err != nil { return err } r.ID = client.FQN(sec.Namespace, sec.Name) r.Fields = model1.Fields{ sec.Namespace, sec.Name, string(sec.Type), strconv.Itoa(len(sec.Data)), "", ToAge(raw.GetCreationTimestamp()), } return nil } ================================================ FILE: internal/render/section.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render // Level tracks lint check level. type Level int const ( // OkLevel denotes no linting issues. OkLevel Level = iota // InfoLevel denotes FIY linting issues. InfoLevel // WarnLevel denotes a warning issue. WarnLevel // ErrorLevel denotes a serious issue. ErrorLevel ) type ( // Sections represents a collection of sections. Sections []Section // Section represents a sanitizer pass. Section struct { Title string `json:"sanitizer" yaml:"sanitizer"` GVR string `yaml:"gvr" json:"gvr"` Outcome Outcome `json:"issues,omitempty" yaml:"issues,omitempty"` } // Outcome represents a classification of reports outcome. Outcome map[string]Issues // Issues represents a collection of issues. Issues []Issue // Issue represents a sanitization issue. Issue struct { Group string `yaml:"group" json:"group"` GVR string `yaml:"gvr" json:"gvr"` Level Level `yaml:"level" json:"level"` Message string `yaml:"message" json:"message"` } ) ================================================ FILE: internal/render/sts.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) var defaultSTSHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "VS", Attrs: model1.Attrs{VS: true}}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "SERVICE"}, model1.HeaderColumn{Name: "CONTAINERS", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "IMAGES", Attrs: model1.Attrs{Wide: 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}}, } // StatefulSet renders a K8s StatefulSet to screen. type StatefulSet struct { Base } // Header returns a header row. func (s StatefulSet) Header(_ string) model1.Header { return s.doHeader(defaultSTSHeader) } // Render renders a K8s resource to screen. func (s StatefulSet) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := s.defaultRow(raw, row); err != nil { return err } if s.specs.isEmpty() { return nil } cols, err := s.specs.realize(raw, defaultSTSHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (s StatefulSet) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var sts appsv1.StatefulSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sts) if err != nil { return err } var desired int32 if sts.Spec.Replicas != nil { desired = *sts.Spec.Replicas } r.ID = client.MetaFQN(&sts.ObjectMeta) r.Fields = model1.Fields{ sts.Namespace, sts.Name, computeVulScore(sts.Namespace, sts.Labels, &sts.Spec.Template.Spec), strconv.Itoa(int(sts.Status.ReadyReplicas)) + "/" + strconv.Itoa(int(desired)), asSelector(sts.Spec.Selector), na(sts.Spec.ServiceName), podContainerNames(&sts.Spec.Template.Spec, true), podImageNames(&sts.Spec.Template.Spec, true), mapToStr(sts.Labels), AsStatus(s.diagnose(desired, sts.Status.Replicas, sts.Status.ReadyReplicas)), ToAge(sts.GetCreationTimestamp()), } return nil } func (StatefulSet) diagnose(d, c, r int32) error { if c != r { return fmt.Errorf("desired %d replicas got %d available", c, r) } if d != r { return fmt.Errorf("want %d replicas got %d available", d, r) } return nil } ================================================ FILE: internal/render/sts_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStatefulSetRender(t *testing.T) { c := render.StatefulSet{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "sts"), "", &r)) assert.Equal(t, "default/nginx-sts", r.ID) assert.Equal(t, model1.Fields{"default", "nginx-sts", "n/a", "4/4", "app=nginx-sts", "nginx-sts", "nginx", "k8s.gcr.io/nginx-slim:0.8", "app=nginx-sts", ""}, r.Fields[:len(r.Fields)-1]) } ================================================ FILE: internal/render/subject.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // Subject renders a rbac to screen. type Subject struct { Base } // ColorerFunc colors a resource row. func (Subject) ColorerFunc() model1.ColorerFunc { return func(string, model1.Header, *model1.RowEvent) tcell.Color { return tcell.ColorMediumSpringGreen } } // Header returns a header row. func (Subject) Header(string) model1.Header { return model1.Header{ model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "FIRST LOCATION"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, } } // Render renders a K8s resource to screen. func (s Subject) Render(o any, _ string, r *model1.Row) error { res, ok := o.(SubjectRes) if !ok { return fmt.Errorf("expected SubjectRes, but got %T", s) } r.ID = res.Name r.Fields = model1.Fields{ res.Name, res.Kind, res.FirstLocation, "", } return nil } // ---------------------------------------------------------------------------- // Helpers... // SubjectRes represents a subject rule. type SubjectRes struct { Name, Kind, FirstLocation string } // GetObjectKind returns a schema object. func (SubjectRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (s SubjectRes) DeepCopyObject() runtime.Object { return s } // Subjects represents a collection of RBAC policies. type Subjects []SubjectRes // Upsert adds a new subject. func (ss Subjects) Upsert(s SubjectRes) Subjects { idx, ok := ss.find(s.Name) if !ok { return append(ss, s) } ss[idx] = s return ss } // Find locates a row by id. Returns false is not found. func (ss Subjects) find(res string) (int, bool) { for i, s := range ss { if s.Name == res { return i, true } } return 0, false } ================================================ FILE: internal/render/svc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "sort" "strconv" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // Header returns a header row. var defaultSVCHeader = model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "TYPE"}, model1.HeaderColumn{Name: "CLUSTER-IP"}, model1.HeaderColumn{Name: "EXTERNAL-IP"}, model1.HeaderColumn{Name: "SELECTOR", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "PORTS", Attrs: model1.Attrs{Wide: false}}, 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}}, } // Service renders a K8s Service to screen. type Service struct { Base } // Header returns a header row. func (s Service) Header(_ string) model1.Header { return s.doHeader(defaultSVCHeader) } // Render renders a K8s resource to screen. func (s Service) Render(o any, _ string, row *model1.Row) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } if err := s.defaultRow(raw, row); err != nil { return err } if s.specs.isEmpty() { return nil } cols, err := s.specs.realize(raw, defaultSVCHeader, row) if err != nil { return err } cols.hydrateRow(row) return nil } func (s Service) defaultRow(raw *unstructured.Unstructured, r *model1.Row) error { var svc v1.Service err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &svc) if err != nil { return err } r.ID = client.MetaFQN(&svc.ObjectMeta) r.Fields = model1.Fields{ svc.Namespace, svc.Name, string(svc.Spec.Type), toIP(svc.Spec.ClusterIP), toIPs(svc.Spec.Type, getSvcExtIPS(&svc)), mapToStr(svc.Spec.Selector), ToPorts(svc.Spec.Ports), mapToStr(svc.Labels), AsStatus(s.diagnose()), ToAge(svc.GetCreationTimestamp()), } return nil } func (Service) diagnose() error { return nil } // ---------------------------------------------------------------------------- // Helpers... func toIP(ip string) string { if ip == "" || ip == "None" { return "" } return ip } func getSvcExtIPS(svc *v1.Service) []string { results := []string{} switch svc.Spec.Type { case v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP: return svc.Spec.ExternalIPs case v1.ServiceTypeLoadBalancer: lbIps := lbIngressIP(svc.Status.LoadBalancer) if len(svc.Spec.ExternalIPs) > 0 { if lbIps != "" { results = append(results, lbIps) } return append(results, svc.Spec.ExternalIPs...) } if lbIps != "" { results = append(results, lbIps) } case v1.ServiceTypeExternalName: results = append(results, svc.Spec.ExternalName) } return results } func lbIngressIP(s v1.LoadBalancerStatus) string { ingress := s.Ingress result := []string{} for i := range ingress { if ingress[i].IP != "" { result = append(result, ingress[i].IP) } else if ingress[i].Hostname != "" { result = append(result, ingress[i].Hostname) } } return strings.Join(result, ",") } func toIPs(svcType v1.ServiceType, ips []string) string { if len(ips) == 0 { if svcType == v1.ServiceTypeLoadBalancer { return "" } return "" } sort.Strings(ips) return strings.Join(ips, ",") } // ToPorts returns service ports as a string. func ToPorts(pp []v1.ServicePort) string { ports := make([]string, len(pp)) for i, p := range pp { if p.Name != "" { ports[i] = p.Name + ":" } ports[i] += strconv.Itoa(int(p.Port)) + "►" + strconv.Itoa(int(p.NodePort)) if p.Protocol != "TCP" { ports[i] += "╱" + string(p.Protocol) } } return strings.Join(ports, " ") } ================================================ FILE: internal/render/svc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServiceRender(t *testing.T) { c := render.Service{} r := model1.NewRow(4) require.NoError(t, c.Render(load(t, "svc"), "", &r)) assert.Equal(t, "default/dictionary1", r.ID) assert.Equal(t, model1.Fields{"default", "dictionary1", "ClusterIP", "10.47.248.116", "", "app=dictionary1", "http:4001►0"}, r.Fields[:7]) } func BenchmarkSvcRender(b *testing.B) { var ( svc render.Service r = model1.NewRow(4) s = load(b, "svc") ) b.ResetTimer() b.ReportAllocs() for range b.N { _ = svc.Render(s, "", &r) } } ================================================ FILE: internal/render/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "encoding/json" "fmt" "log/slog" "strings" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/sets" ) const ageTableCol = "Age" var ageCols = sets.New("Last Seen", "First Seen", "Age") // Table renders a tabular resource to screen. type Table struct { Base table *metav1.Table header model1.Header ageIndex int mx sync.RWMutex } func (*Table) IsGeneric() bool { return true } func (t *Table) setAgeIndex(idx int) { t.mx.Lock() defer t.mx.Unlock() t.ageIndex = idx } func (t *Table) getAgeIndex() int { t.mx.RLock() defer t.mx.RUnlock() return t.ageIndex } // SetTable sets the tabular resource. func (t *Table) SetTable(ns string, table *metav1.Table) { t.table = table t.header = t.Header(ns) } // ColorerFunc colors a resource row. func (*Table) ColorerFunc() model1.ColorerFunc { return model1.DefaultColorer } // Header returns a header row. func (t *Table) Header(string) model1.Header { return t.doHeader(t.defaultHeader()) } // Header returns a header row. func (t *Table) defaultHeader() model1.Header { if t.table == nil { return model1.Header{} } h := make(model1.Header, 0, len(t.table.ColumnDefinitions)) for i, c := range t.table.ColumnDefinitions { if c.Name == ageTableCol { t.setAgeIndex(i) continue } timeCol := ageCols.Has(c.Name) h = append(h, model1.HeaderColumn{Name: strings.ToUpper(c.Name), Attrs: model1.Attrs{Time: timeCol}}) } if t.getAgeIndex() > 0 { h = append(h, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}) } return h } // Render renders a K8s resource to screen. func (t *Table) Render(o any, ns string, r *model1.Row) error { row, ok := o.(metav1.TableRow) if !ok { return fmt.Errorf("expected TableRow, but got %T", o) } if err := t.defaultRow(&row, ns, r); err != nil { return err } if t.specs.isEmpty() { return nil } obj := row.Object.Object if obj != nil { obj = obj.DeepCopyObject() } cols, err := t.specs.realize(obj, t.defaultHeader(), r) if err != nil { return err } cols.hydrateRow(r) return nil } func (t *Table) defaultRow(row *metav1.TableRow, ns string, r *model1.Row) error { th := t.header ons, name := ns, UnknownValue switch { case row.Object.Object != nil: if m, _ := meta.Accessor(row.Object.Object); m != nil { ons, name = m.GetNamespace(), m.GetName() } case row.Object.Raw != nil: var pm metav1.PartialObjectMetadata if err := json.Unmarshal(row.Object.Raw, &pm); err != nil { return err } ons, name = pm.Namespace, pm.Name default: if idx, ok := th.IndexOf("NAME", true); ok && idx >= 0 && idx < len(row.Cells) { name = row.Cells[idx].(string) } if idx, ok := th.IndexOf("NAMESPACE", true); ok && idx >= 0 && idx < len(row.Cells) { ons = row.Cells[idx].(string) } } if client.IsClusterWide(ons) { ons = client.ClusterScope } r.ID = client.FQN(ons, name) r.Fields = make(model1.Fields, 0, len(th)) var ( age any ageIdx = t.getAgeIndex() ) for i, c := range row.Cells { if ageIdx > 0 && i == ageIdx { age = c continue } if c == nil { r.Fields = append(r.Fields, Blank) continue } r.Fields = append(r.Fields, fmt.Sprintf("%v", c)) } if d, ok := age.(string); ok { r.Fields = append(r.Fields, d) } else if ageIdx > 0 { slog.Warn("No Duration detected on age field") r.Fields = append(r.Fields, NAValue) } return nil } ================================================ FILE: internal/render/table_int_test.go ================================================ package render import ( "testing" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func Test_defaultHeader(t *testing.T) { uu := map[string]struct { cdefs []metav1.TableColumnDefinition e model1.Header }{ "empty": { e: make(model1.Header, 0), }, "plain": { cdefs: []metav1.TableColumnDefinition{ {Name: "A"}, {Name: "B"}, {Name: "C"}, }, e: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, }, "age": { cdefs: []metav1.TableColumnDefinition{ {Name: "Fred"}, {Name: "Blee"}, {Name: "Age"}, }, e: model1.Header{ model1.HeaderColumn{Name: "FRED"}, model1.HeaderColumn{Name: "BLEE"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, }, "time-cols": { cdefs: []metav1.TableColumnDefinition{ {Name: "Last Seen"}, {Name: "Fred"}, {Name: "Blee"}, {Name: "Age"}, {Name: "First Seen"}, }, e: model1.Header{ model1.HeaderColumn{Name: "LAST SEEN", Attrs: model1.Attrs{Time: true}}, model1.HeaderColumn{Name: "FRED"}, model1.HeaderColumn{Name: "BLEE"}, model1.HeaderColumn{Name: "FIRST SEEN", Attrs: model1.Attrs{Time: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { var ta Table ta.SetTable("ns-1", &metav1.Table{ColumnDefinitions: u.cdefs}) assert.Equal(t, u.e, ta.defaultHeader()) }) } } ================================================ FILE: internal/render/table_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render_test import ( "testing" "github.com/derailed/k9s/internal/client" cfg "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" "k8s.io/apimachinery/pkg/runtime" ) func TestGenericRender(t *testing.T) { uu := map[string]struct { ns string table *metav1beta1.Table eID string eFields model1.Fields eHeader model1.Header }{ "withNS": { ns: "ns1", table: makeNSGeneric(), eID: "ns1/fred", eFields: model1.Fields{"ns1", "c1", "c2", "c3"}, eHeader: model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, }, "all": { ns: client.NamespaceAll, table: makeNSGeneric(), eID: "ns1/fred", eFields: model1.Fields{"ns1", "c1", "c2", "c3"}, eHeader: model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, }, "clusterWide": { ns: client.ClusterScope, table: makeNoNSGeneric(), eID: "-/fred", eFields: model1.Fields{"c1", "c2", "c3"}, eHeader: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, }, "age": { ns: client.ClusterScope, table: makeAgeGeneric(), eID: "-/fred", eFields: model1.Fields{"c1", "c2", "2d"}, eHeader: model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "C"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, }, } for k := range uu { var re render.Table u := uu[k] t.Run(k, func(t *testing.T) { var r model1.Row re.SetTable(u.ns, u.table) assert.Equal(t, u.eHeader, re.Header(u.ns)) require.NoError(t, re.Render(u.table.Rows[0], u.ns, &r)) assert.Equal(t, u.eID, r.ID) assert.Equal(t, u.eFields, r.Fields) }) } } func TestGenericCustRender(t *testing.T) { uu := map[string]struct { ns string table *metav1beta1.Table vs cfg.ViewSetting eID string eFields model1.Fields eHeader model1.Header }{ "spec": { ns: "ns1", table: makeNSGeneric(), vs: cfg.ViewSetting{ Columns: []string{ "NAMESPACE", "BLEE:.metadata.name", "ZORG:.metadata.namespace", }, }, eID: "ns1/fred", eFields: model1.Fields{"ns1", "fred", "ns1", "c1", "c2", "c3"}, eHeader: model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "BLEE"}, model1.HeaderColumn{Name: "ZORG"}, model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, }, } for k, u := range uu { var re render.Table re.SetViewSetting(&u.vs) t.Run(k, func(t *testing.T) { var r model1.Row re.SetTable(u.ns, u.table) assert.Equal(t, u.eHeader, re.Header(u.ns)) require.NoError(t, re.Render(u.table.Rows[0], u.ns, &r)) assert.Equal(t, u.eID, r.ID) assert.Equal(t, u.eFields, r.Fields) }) } } // ---------------------------------------------------------------------------- // Helpers... func makeNSGeneric() *metav1beta1.Table { return &metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ {Name: "NAMESPACE"}, {Name: "a"}, {Name: "b"}, {Name: "c"}, }, Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ Object: &unstructured.Unstructured{ Object: map[string]any{ "kind": "fred", "apiVersion": "v1", "metadata": map[string]any{ "namespace": "ns1", "name": "fred", }, }, }, }, Cells: []any{ "ns1", "c1", "c2", "c3", }, }, }, } } func makeNoNSGeneric() *metav1beta1.Table { return &metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ {Name: "a"}, {Name: "b"}, {Name: "c"}, }, Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ Object: &unstructured.Unstructured{ Object: map[string]any{ "kind": "fred", "apiVersion": "v1", "metadata": map[string]any{ "name": "fred", }, }, }, }, Cells: []any{ "c1", "c2", "c3", }, }, }, } } func makeAgeGeneric() *metav1beta1.Table { return &metav1beta1.Table{ ColumnDefinitions: []metav1beta1.TableColumnDefinition{ {Name: "a"}, {Name: "Age"}, {Name: "c"}, }, Rows: []metav1beta1.TableRow{ { Object: runtime.RawExtension{ Object: &unstructured.Unstructured{ Object: map[string]any{ "kind": "fred", "apiVersion": "v1", "metadata": map[string]any{ "name": "fred", }, }, }, }, Cells: []any{ "c1", "2d", "c2", }, }, }, } } ================================================ FILE: internal/render/testdata/b1.txt ================================================ Summary: Total: 3.3544 secs Slowest: 0.1031 secs Fastest: 0.0310 secs Average: 0.0335 secs Requests/sec: 29.8116 Total data: 61200 bytes Size/request: 612 bytes Response time histogram: 0.031 [1] | 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 0.045 [6] |■■■ 0.053 [0] | 0.060 [0] | 0.067 [0] | 0.074 [0] | 0.081 [0] | 0.089 [0] | 0.096 [0] | 0.103 [1] | Latency distribution: 10% in 0.0314 secs 25% in 0.0317 secs 50% in 0.0320 secs 75% in 0.0327 secs 90% in 0.0369 secs 95% in 0.0394 secs 99% in 0.1031 secs Details (average, fastest, slowest): DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs req write: 0.0000 secs, 0.0000 secs, 0.0001 secs resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs Status code distribution: [200] 100 responses ================================================ FILE: internal/render/testdata/b2.txt ================================================ Summary: Total: 3.3544 secs Slowest: 0.1031 secs Fastest: 0.0310 secs Average: 0.0335 secs Requests/sec: 29.8116 Total data: 61200 bytes Size/request: 612 bytes Response time histogram: 0.031 [1] | 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 0.045 [6] |■■■ 0.053 [0] | 0.060 [0] | 0.067 [0] | 0.074 [0] | 0.081 [0] | 0.089 [0] | 0.096 [0] | 0.103 [1] | Latency distribution: 10% in 0.0314 secs 25% in 0.0317 secs 50% in 0.0320 secs 75% in 0.0327 secs 90% in 0.0369 secs 95% in 0.0394 secs 99% in 0.1031 secs Details (average, fastest, slowest): DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs req write: 0.0000 secs, 0.0000 secs, 0.0001 secs resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs Status code distribution: [200] 100 responses [404] 2 responses [500] 10 responses ================================================ FILE: internal/render/testdata/b3.txt ================================================ Summary: Total: 2.3688 secs Slowest: 0.0000 secs Fastest: 0.0000 secs Average: NaN secs Requests/sec: 35.4606 Response time histogram: Latency distribution: Details (average, fastest, slowest): DNS+dialup: NaN secs, 0.0000 secs, 0.0000 secs DNS-lookup: NaN secs, 0.0000 secs, 0.0000 secs req write: NaN secs, 0.0000 secs, 0.0000 secs resp wait: NaN secs, 0.0000 secs, 0.0000 secs resp read: NaN secs, 0.0000 secs, 0.0000 secs Status code distribution: Error distribution: [84] Get http://localhost:8081: dial tcp [::1]:8081: connect: connection refused ================================================ FILE: internal/render/testdata/b4.txt ================================================ Summary: Total: 3.3544 secs Slowest: 0.1031 secs Fastest: 0.0310 secs Average: 0.0335 secs Requests/sec: 29.8116 Total data: 61200 bytes Size/request: 612 bytes Response time histogram: 0.031 [1] | 0.038 [92] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 0.045 [6] |■■■ 0.053 [0] | 0.060 [0] | 0.067 [0] | 0.074 [0] | 0.081 [0] | 0.089 [0] | 0.096 [0] | 0.103 [1] | Latency distribution: 10% in 0.0314 secs 25% in 0.0317 secs 50% in 0.0320 secs 75% in 0.0327 secs 90% in 0.0369 secs 95% in 0.0394 secs 99% in 0.1031 secs Details (average, fastest, slowest): DNS+dialup: 0.0001 secs, 0.0310 secs, 0.1031 secs DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0049 secs req write: 0.0000 secs, 0.0000 secs, 0.0001 secs resp wait: 0.0330 secs, 0.0305 secs, 0.0973 secs resp read: 0.0005 secs, 0.0000 secs, 0.0039 secs Status code distribution: [200] 100 responses [204] 50 responses [202] 10 responses ================================================ FILE: internal/render/testdata/cj.json ================================================ { "apiVersion": "batch/v1beta1", "kind": "CronJob", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"batch/v1beta1\",\"kind\":\"CronJob\",\"metadata\":{\"annotations\":{},\"name\":\"hello\",\"namespace\":\"default\"},\"spec\":{\"concurrencyPolicy\":\"Forbid\",\"jobTemplate\":{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"args\":[\"/bin/bash\",\"-c\",\"for i in {1..5}; do echo c1 $i; sleep 1; done\"],\"image\":\"blang/busybox-bash\",\"name\":\"c1\"}],\"restartPolicy\":\"OnFailure\"}}}},\"schedule\":\"*/1 * * * *\"}}\n" }, "creationTimestamp": "2019-08-30T15:19:01Z", "name": "hello", "namespace": "default", "resourceVersion": "49753699", "selfLink": "/apis/batch/v1beta1/namespaces/default/cronjobs/hello", "uid": "7f0b856c-cb39-11e9-990f-42010a800218" }, "spec": { "concurrencyPolicy": "Forbid", "failedJobsHistoryLimit": 1, "jobTemplate": { "metadata": { "creationTimestamp": null }, "spec": { "template": { "metadata": { "creationTimestamp": null }, "spec": { "containers": [ { "args": [ "/bin/bash", "-c", "for i in {1..5}; do echo c1 $i; sleep 1; done" ], "image": "blang/busybox-bash", "imagePullPolicy": "Always", "name": "c1", "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "OnFailure", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } } } }, "schedule": "*/1 * * * *", "successfulJobsHistoryLimit": 3, "suspend": false }, "status": { "lastScheduleTime": "2019-08-30T17:01:00Z" } } ================================================ FILE: internal/render/testdata/cm.json ================================================ { "apiVersion": "v1", "data": { "key1": "very", "key2": "charm" }, "kind": "ConfigMap", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"key1\":\"very\",\"key2\":\"charm\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"blee\",\"namespace\":\"default\"}}\n" }, "creationTimestamp": "2019-06-05T21:56:55Z", "name": "blee", "namespace": "default", "resourceVersion": "27009817", "selfLink": "/api/v1/namespaces/default/configmaps/blee", "uid": "d587a666-87dc-11e9-a8e8-42010a80015b" } } ================================================ FILE: internal/render/testdata/cr.json ================================================ { "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRole", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"rbac.authorization.k8s.io/v1\",\"kind\":\"ClusterRole\",\"metadata\":{\"annotations\":{},\"name\":\"blee\"},\"rules\":[{\"apiGroups\":[\"metrics.k8s.io\"],\"resources\":[\"nodes\"],\"verbs\":[\"list\",\"watch\"]},{\"apiGroups\":[\"\"],\"resources\":[\"nodes\",\"configmaps\"],\"verbs\":[\"list\"]},{\"apiGroups\":[\"\"],\"resourceNames\":[\"kube-system\"],\"resources\":[\"namespaces\"],\"verbs\":[\"get\",\"watch\"]},{\"apiGroups\":[\"\"],\"resources\":[\"pods\"],\"verbs\":[\"get\",\"list\",\"watch\",\"delete\"]}]}\n" }, "creationTimestamp": "2019-06-04T16:48:34Z", "name": "blee", "resourceVersion": "26708289", "selfLink": "/apis/rbac.authorization.k8s.io/v1/clusterroles/blee", "uid": "97dbe984-86e8-11e9-a8e8-42010a80015b" }, "rules": [ { "apiGroups": [ "metrics.k8s.io" ], "resources": [ "nodes" ], "verbs": [ "list", "watch" ] }, { "apiGroups": [ "" ], "resources": [ "nodes", "configmaps" ], "verbs": [ "list" ] }, { "apiGroups": [ "" ], "resourceNames": [ "kube-system" ], "resources": [ "namespaces" ], "verbs": [ "get", "watch" ] }, { "apiGroups": [ "" ], "resources": [ "pods" ], "verbs": [ "get", "list", "watch", "delete" ] } ] } ================================================ FILE: internal/render/testdata/crb.json ================================================ { "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "ClusterRoleBinding", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"rbac.authorization.k8s.io/v1\",\"kind\":\"ClusterRoleBinding\",\"metadata\":{\"annotations\":{},\"name\":\"blee\"},\"roleRef\":{\"apiGroup\":\"rbac.authorization.k8s.io\",\"kind\":\"ClusterRole\",\"name\":\"blee\"},\"subjects\":[{\"apiGroup\":\"rbac.authorization.k8s.io\",\"kind\":\"User\",\"name\":\"fernand\"}]}\n" }, "creationTimestamp": "2019-06-04T16:48:35Z", "name": "blee", "resourceVersion": "26689100", "selfLink": "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/blee", "uid": "97e5f84d-86e8-11e9-a8e8-42010a80015b" }, "roleRef": { "apiGroup": "rbac.authorization.k8s.io", "kind": "ClusterRole", "name": "blee" }, "subjects": [ { "apiGroup": "rbac.authorization.k8s.io", "kind": "User", "name": "fernand" } ] } ================================================ FILE: internal/render/testdata/crd.json ================================================ { "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", "metadata": { "annotations": { "helm.sh/hook": "crd-install", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apiextensions.k8s.io/v1\",\"kind\":\"CustomResourceDefinition\",\"metadata\":{\"annotations\":{\"helm.sh/hook\":\"crd-install\"},\"labels\":{\"addonmanager.kubernetes.io/mode\":\"Reconcile\",\"app\":\"mixer\",\"istio\":\"mixer-adapter\",\"k8s-app\":\"istio\",\"package\":\"adapter\"},\"name\":\"adapters.config.istio.io\",\"namespace\":\"\"},\"spec\":{\"group\":\"config.istio.io\",\"names\":{\"categories\":[\"istio-io\",\"policy-istio-io\"],\"kind\":\"adapter\",\"plural\":\"adapters\",\"singular\":\"adapter\"},\"scope\":\"Namespaced\",\"version\":\"v1alpha2\"}}\n" }, "creationTimestamp": "2019-02-05T22:04:29Z", "generation": 1, "labels": { "addonmanager.kubernetes.io/mode": "Reconcile", "app": "mixer", "istio": "mixer-adapter", "k8s-app": "istio", "package": "adapter" }, "name": "adapters.config.istio.io", "resourceVersion": "37115599", "selfLink": "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/adapters.config.istio.io", "uid": "029b8c3e-2992-11e9-81cd-42010a80005b" }, "spec": { "additionalPrinterColumns": [ { "JSONPath": ".metadata.creationTimestamp", "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata", "name": "Age", "type": "date" } ], "group": "config.istio.io", "names": { "categories": [ "istio-io", "policy-istio-io" ], "kind": "adapter", "listKind": "adapterList", "plural": "adapters", "singular": "adapter" }, "scope": "Namespaced", "version": "v1alpha2", "versions": [ { "name": "v1alpha2", "served": true, "storage": true } ] }, "status": { "acceptedNames": { "categories": [ "istio-io", "policy-istio-io" ], "kind": "adapter", "listKind": "adapterList", "plural": "adapters", "singular": "adapter" }, "conditions": [ { "lastTransitionTime": "2019-02-05T22:04:29Z", "message": "no conflicts found", "reason": "NoConflicts", "status": "True", "type": "NamesAccepted" }, { "lastTransitionTime": null, "message": "the initial names have been accepted", "reason": "InitialNamesAccepted", "status": "True", "type": "Established" } ], "storedVersions": [ "v1alpha2" ] } } ================================================ FILE: internal/render/testdata/dp.json ================================================ { "apiVersion": "apps/v1", "kind": "Deployment", "metadata": { "annotations": { "deployment.kubernetes.io/revision": "1", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1beta1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"icx-db\"},\"name\":\"icx-db\",\"namespace\":\"icx\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"icx-db\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"icx-db\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"POSTGRES_USER\",\"valueFrom\":{\"secretKeyRef\":{\"key\":\"pg_user\",\"name\":\"icx-creds\"}}},{\"name\":\"POSTGRES_PASSWORD\",\"valueFrom\":{\"secretKeyRef\":{\"key\":\"pg_pwd\",\"name\":\"icx-creds\"}}}],\"image\":\"postgres:9.2-alpine\",\"imagePullPolicy\":\"IfNotPresent\",\"name\":\"icx-db\",\"ports\":[{\"containerPort\":5432,\"name\":\"client\"}],\"resources\":{\"limits\":{\"cpu\":\"250m\",\"memory\":\"512Mi\"},\"requests\":{\"cpu\":\"250m\",\"memory\":\"256Mi\"}}}]}}}}\n" }, "creationTimestamp": "2019-07-14T04:54:17Z", "generation": 1, "labels": { "app": "icx-db" }, "name": "icx-db", "namespace": "icx", "resourceVersion": "37116271", "selfLink": "/apis/apps/v1/namespaces/icx/deployments/icx-db", "uid": "6f6143bc-a5f3-11e9-990f-42010a800218" }, "spec": { "progressDeadlineSeconds": 600, "replicas": 1, "revisionHistoryLimit": 2, "selector": { "matchLabels": { "app": "icx-db" } }, "strategy": { "rollingUpdate": { "maxSurge": "25%", "maxUnavailable": "25%" }, "type": "RollingUpdate" }, "template": { "metadata": { "creationTimestamp": null, "labels": { "app": "icx-db" } }, "spec": { "containers": [ { "env": [ { "name": "POSTGRES_USER", "valueFrom": { "secretKeyRef": { "key": "pg_user", "name": "icx-creds" } } }, { "name": "POSTGRES_PASSWORD", "valueFrom": { "secretKeyRef": { "key": "pg_pwd", "name": "icx-creds" } } } ], "image": "postgres:9.2-alpine", "imagePullPolicy": "IfNotPresent", "name": "icx-db", "ports": [ { "containerPort": 5432, "name": "client", "protocol": "TCP" } ], "resources": { "limits": { "cpu": "250m", "memory": "512Mi" }, "requests": { "cpu": "250m", "memory": "256Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } } }, "status": { "availableReplicas": 1, "conditions": [ { "lastTransitionTime": "2019-07-14T04:54:20Z", "lastUpdateTime": "2019-07-14T04:54:20Z", "message": "Deployment has minimum availability.", "reason": "MinimumReplicasAvailable", "status": "True", "type": "Available" }, { "lastTransitionTime": "2019-07-14T04:54:17Z", "lastUpdateTime": "2019-07-14T04:54:20Z", "message": "ReplicaSet \"icx-db-7d4b578979\" has successfully progressed.", "reason": "NewReplicaSetAvailable", "status": "True", "type": "Progressing" } ], "observedGeneration": 1, "readyReplicas": 1, "replicas": 1, "updatedReplicas": 1 } } ================================================ FILE: internal/render/testdata/ds.json ================================================ { "apiVersion": "apps/v1", "kind": "DaemonSet", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"DaemonSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"addonmanager.kubernetes.io/mode\":\"Reconcile\",\"k8s-app\":\"fluentd-gcp\",\"kubernetes.io/cluster-service\":\"true\",\"version\":\"v3.2.0\"},\"name\":\"fluentd-gcp-v3.2.0\",\"namespace\":\"kube-system\"},\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"scheduler.alpha.kubernetes.io/critical-pod\":\"\"},\"labels\":{\"k8s-app\":\"fluentd-gcp\",\"kubernetes.io/cluster-service\":\"true\",\"version\":\"v3.2.0\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"NODE_NAME\",\"valueFrom\":{\"fieldRef\":{\"apiVersion\":\"v1\",\"fieldPath\":\"spec.nodeName\"}}},{\"name\":\"STACKDRIVER_METADATA_AGENT_URL\",\"value\":\"http://$(NODE_NAME):8799\"}],\"image\":\"gcr.io/stackdriver-agents/stackdriver-logging-agent:0.6-1.6.0-1\",\"livenessProbe\":{\"exec\":{\"command\":[\"/bin/sh\",\"-c\",\"LIVENESS_THRESHOLD_SECONDS=${LIVENESS_THRESHOLD_SECONDS:-300}; STUCK_THRESHOLD_SECONDS=${LIVENESS_THRESHOLD_SECONDS:-900}; if [ ! -e /var/log/fluentd-buffers ]; then\\n exit 1;\\nfi; touch -d \\\"${STUCK_THRESHOLD_SECONDS} seconds ago\\\" /tmp/marker-stuck; if [[ -z \\\"$(find /var/log/fluentd-buffers -type f -newer /tmp/marker-stuck -print -quit)\\\" ]]; then\\n rm -rf /var/log/fluentd-buffers;\\n exit 1;\\nfi; touch -d \\\"${LIVENESS_THRESHOLD_SECONDS} seconds ago\\\" /tmp/marker-liveness; if [[ -z \\\"$(find /var/log/fluentd-buffers -type f -newer /tmp/marker-liveness -print -quit)\\\" ]]; then\\n exit 1;\\nfi;\\n\"]},\"initialDelaySeconds\":600,\"periodSeconds\":60},\"name\":\"fluentd-gcp\",\"volumeMounts\":[{\"mountPath\":\"/var/log\",\"name\":\"varlog\"},{\"mountPath\":\"/var/lib/docker/containers\",\"name\":\"varlibdockercontainers\",\"readOnly\":true},{\"mountPath\":\"/etc/google-fluentd/config.d\",\"name\":\"config-volume\"}]},{\"command\":[\"/monitor\",\"--stackdriver-prefix=container.googleapis.com/internal/addons\",\"--api-override=https://monitoring.googleapis.com/\",\"--source=fluentd:http://localhost:24231?whitelisted=stackdriver_successful_requests_count,stackdriver_failed_requests_count,stackdriver_ingested_entries_count,stackdriver_dropped_entries_count\",\"--pod-id=$(POD_NAME)\",\"--namespace-id=$(POD_NAMESPACE)\"],\"env\":[{\"name\":\"POD_NAME\",\"valueFrom\":{\"fieldRef\":{\"fieldPath\":\"metadata.name\"}}},{\"name\":\"POD_NAMESPACE\",\"valueFrom\":{\"fieldRef\":{\"fieldPath\":\"metadata.namespace\"}}}],\"image\":\"k8s.gcr.io/prometheus-to-sd:v0.3.1\",\"name\":\"prometheus-to-sd-exporter\"}],\"dnsPolicy\":\"Default\",\"hostNetwork\":true,\"nodeSelector\":{\"beta.kubernetes.io/fluentd-ds-ready\":\"true\"},\"priorityClassName\":\"system-node-critical\",\"serviceAccountName\":\"fluentd-gcp\",\"terminationGracePeriodSeconds\":60,\"tolerations\":[{\"effect\":\"NoExecute\",\"operator\":\"Exists\"},{\"effect\":\"NoSchedule\",\"operator\":\"Exists\"}],\"volumes\":[{\"hostPath\":{\"path\":\"/var/log\"},\"name\":\"varlog\"},{\"hostPath\":{\"path\":\"/var/lib/docker/containers\"},\"name\":\"varlibdockercontainers\"},{\"configMap\":{\"name\":\"fluentd-gcp-config-old-v1.2.5\"},\"name\":\"config-volume\"}]}},\"updateStrategy\":{\"type\":\"RollingUpdate\"}}}\n" }, "creationTimestamp": "2019-04-12T23:35:36Z", "generation": 2, "labels": { "addonmanager.kubernetes.io/mode": "Reconcile", "k8s-app": "fluentd-gcp", "kubernetes.io/cluster-service": "true", "version": "v3.2.0" }, "name": "fluentd-gcp-v3.2.0", "namespace": "kube-system", "resourceVersion": "34805583", "selfLink": "/apis/apps/v1/namespaces/kube-system/daemonsets/fluentd-gcp-v3.2.0", "uid": "ac95611f-5d7b-11e9-af05-42010a800018" }, "spec": { "revisionHistoryLimit": 10, "selector": { "matchLabels": { "k8s-app": "fluentd-gcp", "kubernetes.io/cluster-service": "true", "version": "v3.2.0" } }, "template": { "metadata": { "annotations": { "scheduler.alpha.kubernetes.io/critical-pod": "" }, "creationTimestamp": null, "labels": { "k8s-app": "fluentd-gcp", "kubernetes.io/cluster-service": "true", "version": "v3.2.0" } }, "spec": { "containers": [ { "env": [ { "name": "NODE_NAME", "valueFrom": { "fieldRef": { "apiVersion": "v1", "fieldPath": "spec.nodeName" } } }, { "name": "STACKDRIVER_METADATA_AGENT_URL", "value": "http://$(NODE_NAME):8799" } ], "image": "gcr.io/stackdriver-agents/stackdriver-logging-agent:0.6-1.6.0-1", "imagePullPolicy": "IfNotPresent", "livenessProbe": { "exec": { "command": [ "/bin/sh", "-c", "LIVENESS_THRESHOLD_SECONDS=${LIVENESS_THRESHOLD_SECONDS:-300}; STUCK_THRESHOLD_SECONDS=${LIVENESS_THRESHOLD_SECONDS:-900}; if [ ! -e /var/log/fluentd-buffers ]; then\n exit 1;\nfi; touch -d \"${STUCK_THRESHOLD_SECONDS} seconds ago\" /tmp/marker-stuck; if [[ -z \"$(find /var/log/fluentd-buffers -type f -newer /tmp/marker-stuck -print -quit)\" ]]; then\n rm -rf /var/log/fluentd-buffers;\n exit 1;\nfi; touch -d \"${LIVENESS_THRESHOLD_SECONDS} seconds ago\" /tmp/marker-liveness; if [[ -z \"$(find /var/log/fluentd-buffers -type f -newer /tmp/marker-liveness -print -quit)\" ]]; then\n exit 1;\nfi;\n" ] }, "failureThreshold": 3, "initialDelaySeconds": 600, "periodSeconds": 60, "successThreshold": 1, "timeoutSeconds": 1 }, "name": "fluentd-gcp", "resources": { "limits": { "cpu": "1", "memory": "500Mi" }, "requests": { "cpu": "100m", "memory": "200Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/log", "name": "varlog" }, { "mountPath": "/var/lib/docker/containers", "name": "varlibdockercontainers", "readOnly": true }, { "mountPath": "/etc/google-fluentd/config.d", "name": "config-volume" } ] }, { "command": [ "/monitor", "--stackdriver-prefix=container.googleapis.com/internal/addons", "--api-override=https://monitoring.googleapis.com/", "--source=fluentd:http://localhost:24231?whitelisted=stackdriver_successful_requests_count,stackdriver_failed_requests_count,stackdriver_ingested_entries_count,stackdriver_dropped_entries_count", "--pod-id=$(POD_NAME)", "--namespace-id=$(POD_NAMESPACE)" ], "env": [ { "name": "POD_NAME", "valueFrom": { "fieldRef": { "apiVersion": "v1", "fieldPath": "metadata.name" } } }, { "name": "POD_NAMESPACE", "valueFrom": { "fieldRef": { "apiVersion": "v1", "fieldPath": "metadata.namespace" } } } ], "image": "k8s.gcr.io/prometheus-to-sd:v0.3.1", "imagePullPolicy": "IfNotPresent", "name": "prometheus-to-sd-exporter", "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "Default", "hostNetwork": true, "nodeSelector": { "beta.kubernetes.io/fluentd-ds-ready": "true" }, "priorityClassName": "system-node-critical", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "fluentd-gcp", "serviceAccountName": "fluentd-gcp", "terminationGracePeriodSeconds": 60, "tolerations": [ { "effect": "NoExecute", "operator": "Exists" }, { "effect": "NoSchedule", "operator": "Exists" } ], "volumes": [ { "hostPath": { "path": "/var/log", "type": "" }, "name": "varlog" }, { "hostPath": { "path": "/var/lib/docker/containers", "type": "" }, "name": "varlibdockercontainers" }, { "configMap": { "defaultMode": 420, "name": "fluentd-gcp-config-old-v1.2.5" }, "name": "config-volume" } ] } }, "templateGeneration": 2, "updateStrategy": { "rollingUpdate": { "maxUnavailable": 1 }, "type": "RollingUpdate" } }, "status": { "currentNumberScheduled": 2, "desiredNumberScheduled": 2, "numberAvailable": 2, "numberMisscheduled": 0, "numberReady": 2, "observedGeneration": 2, "updatedNumberScheduled": 2 } } ================================================ FILE: internal/render/testdata/ep.json ================================================ { "apiVersion": "v1", "kind": "Endpoints", "metadata": { "creationTimestamp": "2019-07-10T23:10:43Z", "name": "blee", "namespace": "ns-1" }, "subsets": [ { "addresses": [ { "ip": "10.0.0.67", "nodeName": "n-1", "targetRef": { "kind": "Pod", "name": "blah", "namespace": "blee" } } ], "ports": [ { "name": "http", "port": 8080, "protocol": "TCP" } ] } ] } ================================================ FILE: internal/render/testdata/eps.json ================================================ { "apiVersion": "discovery.k8s.io/v1", "kind": "EndpointSlice", "metadata": { "creationTimestamp": "2025-04-17T22:14:13Z", "name": "fred", "namespace": "blee" }, "addressType": "IPv4", "endpoints": [ { "addresses": [ "172.20.0.2" ], "conditions": { "ready": true, "serving": true, "terminating": false }, "nodeName": "n-1", "targetRef": { "kind": "Pod", "name": "zorg", "namespace": "kube-system" } }, { "addresses": [ "172.20.0.3" ], "conditions": { "ready": true, "serving": true, "terminating": false }, "nodeName": "n-1", "targetRef": { "kind": "Pod", "name": "zorg", "namespace": "kube-system" } } ], "ports": [ { "name": "peer-service", "port": 4244, "protocol": "TCP" } ] } ================================================ FILE: internal/render/testdata/ev.json ================================================ { "apiVersion": "v1", "count": 1, "eventTime": null, "firstTimestamp": "2019-08-30T20:43:05Z", "involvedObject": { "apiVersion": "v1", "fieldPath": "spec.containers{c1}", "kind": "Pod", "name": "hello-1567197780-mn4mv", "namespace": "default", "resourceVersion": "49798867", "uid": "c31fdeb8-cb66-11e9-990f-42010a800218" }, "kind": "Event", "lastTimestamp": "2019-08-30T20:43:05Z", "message": "Successfully pulled image \"blang/busybox-bash\"", "metadata": { "creationTimestamp": "2019-08-30T20:43:05Z", "name": "hello-1567197780-mn4mv.15bfce150bd764dd", "namespace": "default", "resourceVersion": "590733", "selfLink": "/api/v1/namespaces/default/events/hello-1567197780-mn4mv.15bfce150bd764dd", "uid": "c443d4b3-cb66-11e9-990f-42010a800218" }, "reason": "Pulled", "reportingComponent": "", "reportingInstance": "", "source": { "component": "kubelet", "host": "gke-k9s-default-pool-0fa2fb89-qnkc" }, "type": "Normal" } ================================================ FILE: internal/render/testdata/hpa.json ================================================ { "apiVersion": "autoscaling/v1", "kind": "HorizontalPodAutoscaler", "metadata": { "annotations": { "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"False\",\"lastTransitionTime\":\"2019-07-19T20:56:05Z\",\"reason\":\"FailedGetScale\",\"message\":\"the HPA controller was unable to get the target's current scale: deployments/scale.extensions \\\"nginx\\\" not found\"}]", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":10,\"minReplicas\":1,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"nginx\"},\"targetCPUUtilizationPercentage\":10}}\n" }, "creationTimestamp": "2019-07-19T20:55:50Z", "name": "nginx", "namespace": "default", "resourceVersion": "38623948", "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/nginx", "uid": "97104229-aa67-11e9-990f-42010a800218" }, "spec": { "maxReplicas": 10, "minReplicas": 1, "scaleTargetRef": { "apiVersion": "apps/v1", "kind": "Deployment", "name": "nginx" }, "targetCPUUtilizationPercentage": 10 }, "status": { "currentReplicas": 0, "desiredReplicas": 0 } } ================================================ FILE: internal/render/testdata/ing.json ================================================ { "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", "metadata": { "labels": { "role": "ingress" }, "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/rewrite-target\":\"/\"},\"name\":\"test-ingress\",\"namespace\":\"default\"},\"spec\":{\"rules\":[{\"http\":{\"paths\":[{\"backend\":{\"serviceName\":\"test\",\"servicePort\":80},\"path\":\"/testpath\"}]}}]}}\n", "nginx.ingress.kubernetes.io/rewrite-target": "/" }, "creationTimestamp": "2019-08-30T20:53:52Z", "generation": 1, "name": "test-ingress", "namespace": "default", "resourceVersion": "49801063", "selfLink": "/apis/networking.k8s.io/v1/namespaces/default/ingresses/test-ingress", "uid": "45e44c1d-cb68-11e9-990f-42010a800218" }, "spec": { "rules": [ { "http": { "paths": [ { "backend": { "serviceName": "test", "servicePort": 80 }, "path": "/testpath" } ] } } ] }, "status": { "loadBalancer": {} } } ================================================ FILE: internal/render/testdata/job.json ================================================ { "apiVersion": "batch/v1", "kind": "Job", "metadata": { "creationTimestamp": "2019-08-30T15:33:02Z", "labels": { "controller-uid": "7473e6d0-cb3b-11e9-990f-42010a800218", "job-name": "hello-1567179180" }, "name": "hello-1567179180", "namespace": "default", "ownerReferences": [ { "apiVersion": "batch/v1beta1", "blockOwnerDeletion": true, "controller": true, "kind": "CronJob", "name": "hello", "uid": "7f0b856c-cb39-11e9-990f-42010a800218" } ], "resourceVersion": "49735780", "selfLink": "/apis/batch/v1/namespaces/default/jobs/hello-1567179180", "uid": "7473e6d0-cb3b-11e9-990f-42010a800218" }, "spec": { "backoffLimit": 6, "completions": 1, "parallelism": 1, "selector": { "matchLabels": { "controller-uid": "7473e6d0-cb3b-11e9-990f-42010a800218" } }, "template": { "metadata": { "creationTimestamp": null, "labels": { "controller-uid": "7473e6d0-cb3b-11e9-990f-42010a800218", "job-name": "hello-1567179180" } }, "spec": { "containers": [ { "args": [ "/bin/bash", "-c", "for i in {1..5}; do echo c1 $i; sleep 1; done" ], "image": "blang/busybox-bash", "imagePullPolicy": "Always", "name": "c1", "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "OnFailure", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } } }, "status": { "completionTime": "2019-08-30T15:33:10Z", "conditions": [ { "lastProbeTime": "2019-08-30T15:33:10Z", "lastTransitionTime": "2019-08-30T15:33:10Z", "status": "True", "type": "Complete" } ], "startTime": "2019-08-30T15:33:02Z", "succeeded": 1 } } ================================================ FILE: internal/render/testdata/no.json ================================================ { "apiVersion": "v1", "kind": "Node", "metadata": { "annotations": { "kubeadm.alpha.kubernetes.io/cri-socket": "/var/run/dockershim.sock", "node.alpha.kubernetes.io/ttl": "0", "volumes.kubernetes.io/controller-managed-attach-detach": "true" }, "creationTimestamp": "2019-08-26T21:52:09Z", "labels": { "beta.kubernetes.io/arch": "amd64", "beta.kubernetes.io/os": "linux", "kubernetes.io/arch": "amd64", "kubernetes.io/hostname": "minikube", "kubernetes.io/os": "linux", "node-role.kubernetes.io/master": "" }, "name": "minikube", "resourceVersion": "500588", "selfLink": "/api/v1/nodes/minikube", "uid": "3a554aa2-fee7-435b-ae1b-e67bdaac069a" }, "spec": {}, "status": { "addresses": [ { "address": "192.168.64.107", "type": "InternalIP" }, { "address": "minikube", "type": "Hostname" } ], "allocatable": { "cpu": "4", "ephemeral-storage": "15625027559", "hugepages-2Mi": "0", "memory": "8063156Ki", "pods": "110" }, "capacity": { "cpu": "4", "ephemeral-storage": "16954240Ki", "hugepages-2Mi": "0", "memory": "8165556Ki", "pods": "110" }, "conditions": [ { "lastHeartbeatTime": "2019-08-31T04:43:11Z", "lastTransitionTime": "2019-08-26T21:52:06Z", "message": "kubelet has sufficient memory available", "reason": "KubeletHasSufficientMemory", "status": "False", "type": "MemoryPressure" }, { "lastHeartbeatTime": "2019-08-31T04:43:11Z", "lastTransitionTime": "2019-08-26T21:52:06Z", "message": "kubelet has no disk pressure", "reason": "KubeletHasNoDiskPressure", "status": "False", "type": "DiskPressure" }, { "lastHeartbeatTime": "2019-08-31T04:43:11Z", "lastTransitionTime": "2019-08-26T21:52:06Z", "message": "kubelet has sufficient PID available", "reason": "KubeletHasSufficientPID", "status": "False", "type": "PIDPressure" }, { "lastHeartbeatTime": "2019-08-31T04:43:11Z", "lastTransitionTime": "2019-08-26T21:52:06Z", "message": "kubelet is posting ready status", "reason": "KubeletReady", "status": "True", "type": "Ready" } ], "daemonEndpoints": { "kubeletEndpoint": { "Port": 10250 } }, "images": [ { "names": [ "quay.io/kubernetes-ingress-controller/nginx-ingress-controller@sha256:464db4880861bd9d1e74e67a4a9c975a6e74c1e9968776d8d4cc73492a56dfa5", "quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.25.0" ], "sizeBytes": 508299926 }, { "names": [ "k8s.gcr.io/etcd@sha256:17da501f5d2a675be46040422a27b7cc21b8a43895ac998b171db1c346f361f7", "k8s.gcr.io/etcd:3.3.10" ], "sizeBytes": 258116302 }, { "names": [ "k8s.gcr.io/kube-apiserver@sha256:5fae387bacf1def6c3915b4a3035cf8c8a4d06158b2e676721776d3d4afc05a2", "k8s.gcr.io/kube-apiserver:v1.15.2" ], "sizeBytes": 206823358 }, { "names": [ "k8s.gcr.io/kube-controller-manager@sha256:7d3fc48cf83aa0a7b8f129fa4255bb5530908e1a5b194be269ea8329b48e9598", "k8s.gcr.io/kube-controller-manager:v1.15.2" ], "sizeBytes": 158718526 }, { "names": [ "k8s.gcr.io/kubernetes-dashboard-amd64:v1.10.1" ], "sizeBytes": 121711221 }, { "names": [ "k8s.gcr.io/nginx-slim@sha256:8b4501fe0fe221df663c22e16539f399e89594552f400408303c42f3dd8d0e52", "k8s.gcr.io/nginx-slim:0.8" ], "sizeBytes": 110487599 }, { "names": [ "k8s.gcr.io/kube-addon-manager:v9.0" ], "sizeBytes": 83077558 }, { "names": [ "k8s.gcr.io/kube-proxy@sha256:626f983f25f8b7799ca7ab001fd0985a72c2643c0acb877d2888c0aa4fcbdf56", "k8s.gcr.io/kube-proxy:v1.15.2" ], "sizeBytes": 82408284 }, { "names": [ "k8s.gcr.io/kube-scheduler@sha256:8fd3c3251f07234a234469e201900e4274726f1fe0d5dc6fb7da911f1c851a1a", "k8s.gcr.io/kube-scheduler:v1.15.2" ], "sizeBytes": 81107582 }, { "names": [ "gcr.io/k8s-minikube/storage-provisioner:v1.8.1" ], "sizeBytes": 80815640 }, { "names": [ "k8s.gcr.io/k8s-dns-kube-dns-amd64:1.14.13" ], "sizeBytes": 51157394 }, { "names": [ "k8s.gcr.io/k8s-dns-sidecar-amd64:1.14.13" ], "sizeBytes": 42852039 }, { "names": [ "k8s.gcr.io/metrics-server-amd64@sha256:49a9f12f7067d11f42c803dbe61ed2c1299959ad85cb315b25ff7eef8e6b8892", "k8s.gcr.io/metrics-server-amd64:v0.2.1" ], "sizeBytes": 42541759 }, { "names": [ "k8s.gcr.io/k8s-dns-dnsmasq-nanny-amd64:1.14.13" ], "sizeBytes": 41372492 }, { "names": [ "k8s.gcr.io/coredns@sha256:02382353821b12c21b062c59184e227e001079bb13ebd01f9d3270ba0fcbf1e4", "k8s.gcr.io/coredns:1.3.1" ], "sizeBytes": 40303560 }, { "names": [ "blang/busybox-bash@sha256:b4675e303209bfdaeb6cad4c0c90ec3ba2cda85a75b5d965daa91bca86d0d77c", "blang/busybox-bash:latest" ], "sizeBytes": 5912460 }, { "names": [ "k8s.gcr.io/pause@sha256:f78411e19d84a252e53bff71a4407a5686c46983a2c2eeed83929b888179acea", "k8s.gcr.io/pause:3.1" ], "sizeBytes": 742472 } ], "nodeInfo": { "architecture": "amd64", "bootID": "97588c94-edf3-420d-b5ef-226d5a27d348", "containerRuntimeVersion": "docker://18.9.8", "kernelVersion": "4.15.0", "kubeProxyVersion": "v1.15.2", "kubeletVersion": "v1.15.2", "machineID": "fc8b6c7d6c8449bf9066f42449d97619", "operatingSystem": "linux", "osImage": "Buildroot 2018.05.3", "systemUUID": "98F211E9-0000-0000-AC5E-AC87A33863C5" } } } ================================================ FILE: internal/render/testdata/np.json ================================================ { "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1\",\"kind\":\"NetworkPolicy\",\"metadata\":{\"annotations\":{},\"name\":\"fred\",\"namespace\":\"default\"},\"spec\":{\"egress\":[{\"ports\":[{\"port\":5978,\"protocol\":\"TCP\"}],\"to\":[{\"ipBlock\":{\"cidr\":\"10.0.0.0/24\"}}]}],\"ingress\":[{\"from\":[{\"ipBlock\":{\"cidr\":\"172.17.0.0/16\",\"except\":[\"172.17.1.0/24\",\"172.17.3.0/24\",\"172.17.4.0/24\"]}},{\"namespaceSelector\":{\"matchLabels\":{\"app\":\"blee\"}}},{\"podSelector\":{\"matchLabels\":{\"app\":\"fred\"}}}],\"ports\":[{\"port\":6379,\"protocol\":\"TCP\"}]}],\"podSelector\":{\"matchLabels\":{\"app\":\"nginx\"}},\"policyTypes\":[\"Ingress\",\"Egress\"]}}\n" }, "creationTimestamp": "2019-08-27T19:07:20Z", "generation": 2, "name": "fred", "namespace": "default", "resourceVersion": "48999995", "selfLink": "/apis/networking.k8s.io/v1/namespaces/default/networkpolicies/fred", "uid": "e4aada4d-c8fd-11e9-990f-42010a800218" }, "spec": { "egress": [ { "ports": [ { "port": 5978, "protocol": "TCP" } ], "to": [ { "ipBlock": { "cidr": "10.0.0.0/24" } } ] } ], "ingress": [ { "from": [ { "ipBlock": { "cidr": "172.17.0.0/16", "except": [ "172.17.1.0/24", "172.17.3.0/24", "172.17.4.0/24" ] } }, { "namespaceSelector": { "matchLabels": { "app": "blee" } } }, { "podSelector": { "matchLabels": { "app": "fred" } } } ], "ports": [ { "port": 6379, "protocol": "TCP" } ] } ], "podSelector": { "matchLabels": { "app": "nginx" } }, "policyTypes": [ "Ingress", "Egress" ] } } ================================================ FILE: internal/render/testdata/ns.json ================================================ { "apiVersion": "v1", "kind": "Namespace", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Namespace\",\"metadata\":{\"annotations\":{},\"name\":\"kube-system\",\"namespace\":\"\"}}\n" }, "creationTimestamp": "2019-02-05T22:03:54Z", "name": "kube-system", "resourceVersion": "36", "selfLink": "/api/v1/namespaces/kube-system", "uid": "ed757b6f-2991-11e9-81cd-42010a80005b" }, "spec": { "finalizers": [ "kubernetes" ] }, "status": { "phase": "Active" } } ================================================ FILE: internal/render/testdata/p1.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/restartedAt": "2019-12-31T12:26:47-07:00" }, "creationTimestamp": "2019-12-31T19:27:22Z", "generateName": "nginx-7fb78fb6d8-", "labels": { "app": "nginx", "pod-template-hash": "7fb78fb6d8" }, "name": "nginx-7fb78fb6d8-2w75j", "namespace": "default", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "ReplicaSet", "name": "nginx-7fb78fb6d8", "uid": "7ccd0600-2c03-11ea-883f-42010a800044" } ], "resourceVersion": "87290191", "selfLink": "/api/v1/namespaces/default/pods/nginx-7fb78fb6d8-2w75j", "uid": "91bb1cf2-2c03-11ea-883f-42010a800044" }, "spec": { "containers": [ { "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "cpu": "200m", "memory": "20Mi" }, "requests": { "cpu": "200m", "memory": "20Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-dsl46", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "gke-k9s-default-pool-0fa2fb89-lbtf", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "default-token-dsl46", "secret": { "defaultMode": 420, "secretName": "default-token-dsl46" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:23Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:25Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-12-31T19:27:22Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://90e0abf7a779dd76d36038883312baed57a8351428a1d6340df3cff698f51809", "image": "k8s.gcr.io/nginx-slim:0.8", "imageID": "docker-pullable://k8s.gcr.io/nginx-slim@sha256:8b4501fe0fe221df663c22e16539f399e89594552f400408303c42f3dd8d0e52", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-12-31T19:27:24Z" } } } ], "hostIP": "10.128.0.15", "phase": "Running", "podIP": "10.44.0.229", "qosClass": "Guaranteed", "startTime": "2019-12-31T19:27:23Z" } } ================================================ FILE: internal/render/testdata/pdb.json ================================================ { "apiVersion": "policy/v1", "kind": "PodDisruptionBudget", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"policy/v1\",\"kind\":\"PodDisruptionBudget\",\"metadata\":{\"annotations\":{},\"name\":\"fred\",\"namespace\":\"default\"},\"spec\":{\"minAvailable\":2,\"selector\":{\"matchLabels\":{\"app\":\"nginx\"}}}}\n" }, "creationTimestamp": "2019-08-31T03:48:10Z", "generation": 1, "name": "fred", "namespace": "default", "resourceVersion": "49885429", "selfLink": "/apis/policy/v1/namespaces/default/poddisruptionbudgets/fred", "uid": "26b6cf70-cba2-11e9-990f-42010a800218" }, "spec": { "minAvailable": 2, "selector": { "matchLabels": { "app": "nginx" } } }, "status": { "currentHealthy": 0, "desiredHealthy": 2, "disruptionsAllowed": 0, "expectedPods": 0, "observedGeneration": 1 } } ================================================ FILE: internal/render/testdata/po.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"image\":\"nginx:alpine\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"volumeMounts\":[{\"mountPath\":\"/usr/share/nginx/html\",\"name\":\"index\"}]}],\"terminationGracePeriodSeconds\":0,\"volumes\":[{\"name\":\"index\",\"persistentVolumeClaim\":{\"claimName\":\"web\"}}]}}\n" }, "creationTimestamp": "2019-08-09T05:12:19Z", "name": "nginx", "namespace": "default", "resourceVersion": "1482816", "selfLink": "/api/v1/namespaces/default/pods/nginx", "uid": "614908ed-415b-4506-8370-e3e36fa8cc13" }, "spec": { "containers": [ { "image": "nginx:alpine", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "memory": "170Mi" }, "requests": { "cpu": "100m", "memory": "70Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "index" }, { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-9ph8s", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "minikube", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 0, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "index", "persistentVolumeClaim": { "claimName": "web" } }, { "name": "default-token-9ph8s", "secret": { "defaultMode": 420, "secretName": "default-token-9ph8s" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://421bd26d6c682f14b5ea1dcaf06e14a509b2b702fc7793e820520eb1e28e2eaf", "image": "nginx:alpine", "imageID": "docker-pullable://nginx@sha256:482ead44b2203fa32b3390abdaf97cbdc8ad15c07fb03a3e68d7c35a19ad7595", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-08-09T05:12:20Z" } } } ], "hostIP": "192.168.64.104", "phase": "Running", "podIP": "172.17.0.6", "qosClass": "BestEffort", "startTime": "2019-08-09T05:12:19Z" } } ================================================ FILE: internal/render/testdata/po_init.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"image\":\"nginx:alpine\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"volumeMounts\":[{\"mountPath\":\"/usr/share/nginx/html\",\"name\":\"index\"}]}],\"terminationGracePeriodSeconds\":0,\"volumes\":[{\"name\":\"index\",\"persistentVolumeClaim\":{\"claimName\":\"web\"}}]}}\n" }, "creationTimestamp": "2019-08-09T05:12:19Z", "name": "nginx", "namespace": "default", "resourceVersion": "1482816", "selfLink": "/api/v1/namespaces/default/pods/nginx", "uid": "614908ed-415b-4506-8370-e3e36fa8cc13" }, "spec": { "initContainers": [ { "image": "nginx:alpine", "imagePullPolicy": "IfNotPresent", "name": "ic1", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "memory": "170Mi" }, "requests": { "cpu": "100m", "memory": "70Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "index" }, { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-9ph8s", "readOnly": true } ] } ], "containers": [ { "image": "nginx:alpine", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "memory": "170Mi" }, "requests": { "cpu": "100m", "memory": "70Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "index" }, { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-9ph8s", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "minikube", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 0, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "index", "persistentVolumeClaim": { "claimName": "web" } }, { "name": "default-token-9ph8s", "secret": { "defaultMode": 420, "secretName": "default-token-9ph8s" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://421bd26d6c682f14b5ea1dcaf06e14a509b2b702fc7793e820520eb1e28e2eaf", "image": "nginx:alpine", "imageID": "docker-pullable://nginx@sha256:482ead44b2203fa32b3390abdaf97cbdc8ad15c07fb03a3e68d7c35a19ad7595", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-08-09T05:12:20Z" } } } ], "initContainerStatuses": [ { "containerID": "docker://421bd26d6c682f14b5ea1dcaf06e14a509b2b702fc7793e820520eb1e28e2eaf", "image": "nginx:alpine", "imageID": "docker-pullable://nginx@sha256:482ead44b2203fa32b3390abdaf97cbdc8ad15c07fb03a3e68d7c35a19ad7595", "lastState": {}, "name": "ic1", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-08-09T05:12:20Z" } } } ], "hostIP": "192.168.64.104", "phase": "Running", "podIP": "172.17.0.6", "qosClass": "BestEffort", "startTime": "2019-08-09T05:12:19Z" } } ================================================ FILE: internal/render/testdata/po_sidecar.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "creationTimestamp": "2024-08-24T01:54:32Z", "name": "sleep", "namespace": "default", "resourceVersion": "17852", "uid": "35079257-0ffb-4b09-b2c1-3c0d416f2523" }, "spec": { "containers": [ { "command": [ "/bin/sleep", "60" ], "image": "istio/base", "imagePullPolicy": "Always", "name": "sleep", "resources": { "limits": { "cpu": "230m", "memory": "40Mi" }, "requests": { "cpu": "30m", "memory": "10Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "initContainers": [ { "command": [ "/bin/sleep", "1" ], "image": "istio/base", "imagePullPolicy": "Always", "name": "init", "resources": { "limits": { "cpu": "333m", "memory": "333Mi" }, "requests": { "cpu": "333m", "memory": "333Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true } ] }, { "command": [ "/bin/sleep", "60" ], "image": "istio/base", "imagePullPolicy": "Always", "name": "sidecar", "resources": { "limits": { "cpu": "20m", "memory": "40Mi" }, "requests": { "cpu": "20m", "memory": "40Mi" } }, "restartPolicy": "Always", "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true } ] } ], "nodeName": "kind-control-plane", "preemptionPolicy": "PreemptLowerPriority", "priority": 0, "restartPolicy": "Never", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "kube-api-access-mphcq", "projected": { "defaultMode": 420, "sources": [ { "serviceAccountToken": { "expirationSeconds": 3607, "path": "token" } }, { "configMap": { "items": [ { "key": "ca.crt", "path": "ca.crt" } ], "name": "kube-root-ca.crt" } }, { "downwardAPI": { "items": [ { "fieldRef": { "apiVersion": "v1", "fieldPath": "metadata.namespace" }, "path": "namespace" } ] } } ] } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2024-08-24T01:54:36Z", "status": "True", "type": "PodReadyToStartContainers" }, { "lastProbeTime": null, "lastTransitionTime": "2024-08-24T01:54:38Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2024-08-24T01:54:39Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2024-08-24T01:54:39Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2024-08-24T01:54:32Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "containerd://a1848056a183e40afe3189fc4920bd565930180ebdf2f9e2daf778ea8105f93e", "image": "docker.io/istio/base:latest", "imageID": "docker.io/istio/base@sha256:61673929bc39a86dca7d978b27fc632d3e590bc59cd8b2f386408751d007c91e", "lastState": {}, "name": "sleep", "ready": true, "restartCount": 0, "started": true, "state": { "running": { "startedAt": "2024-08-24T01:54:38Z" } }, "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true, "recursiveReadOnly": "Disabled" } ] } ], "hostIP": "172.18.0.2", "hostIPs": [ { "ip": "172.18.0.2" } ], "initContainerStatuses": [ { "containerID": "containerd://75295261e5d751382c9a6ffa4477b84af2934686c360dcba2d8a6b9bc0f8cada", "image": "docker.io/istio/base:latest", "imageID": "docker.io/istio/base@sha256:61673929bc39a86dca7d978b27fc632d3e590bc59cd8b2f386408751d007c91e", "lastState": {}, "name": "init", "ready": true, "restartCount": 0, "started": false, "state": { "terminated": { "containerID": "containerd://75295261e5d751382c9a6ffa4477b84af2934686c360dcba2d8a6b9bc0f8cada", "exitCode": 0, "finishedAt": "2024-08-24T01:54:37Z", "reason": "Completed", "startedAt": "2024-08-24T01:54:35Z" } }, "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true, "recursiveReadOnly": "Disabled" } ] }, { "containerID": "containerd://7a0d216a09630040c5b165c42cc9d2a95d541d95b6ac0a5ca604bf767d1b2cf0", "image": "docker.io/istio/base:latest", "imageID": "docker.io/istio/base@sha256:61673929bc39a86dca7d978b27fc632d3e590bc59cd8b2f386408751d007c91e", "lastState": {}, "name": "sidecar", "ready": true, "restartCount": 0, "started": true, "state": { "running": { "startedAt": "2024-08-24T01:54:38Z" } }, "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "kube-api-access-mphcq", "readOnly": true, "recursiveReadOnly": "Disabled" } ] } ], "phase": "Running", "podIP": "10.244.0.8", "podIPs": [ { "ip": "10.244.0.8" } ], "qosClass": "Burstable", "startTime": "2024-08-24T01:54:32Z" } } ================================================ FILE: internal/render/testdata/pv.json ================================================ { "apiVersion": "v1", "kind": "PersistentVolume", "metadata": { "annotations": { "kubernetes.io/createdby": "gce-pd-dynamic-provisioner", "pv.kubernetes.io/bound-by-controller": "yes", "pv.kubernetes.io/provisioned-by": "kubernetes.io/gce-pd" }, "creationTimestamp": "2019-06-05T00:08:24Z", "finalizers": [ "kubernetes.io/pv-protection" ], "labels": { "failure-domain.beta.kubernetes.io/region": "us-central1", "failure-domain.beta.kubernetes.io/zone": "us-central1-a" }, "name": "pvc-07aa4e2c-8726-11e9-a8e8-42010a80015b", "resourceVersion": "26769902", "selfLink": "/api/v1/persistentvolumes/pvc-07aa4e2c-8726-11e9-a8e8-42010a80015b", "uid": "093234ed-8726-11e9-a8e8-42010a80015b" }, "spec": { "accessModes": [ "ReadWriteOnce" ], "capacity": { "storage": "1Gi" }, "claimRef": { "apiVersion": "v1", "kind": "PersistentVolumeClaim", "name": "www-nginx-sts-1", "namespace": "default", "resourceVersion": "26769889", "uid": "07aa4e2c-8726-11e9-a8e8-42010a80015b" }, "gcePersistentDisk": { "fsType": "ext4", "pdName": "gke-k9s-fd5bf60e-dynam-pvc-07aa4e2c-8726-11e9-a8e8-42010a80015b" }, "nodeAffinity": { "required": { "nodeSelectorTerms": [ { "matchExpressions": [ { "key": "failure-domain.beta.kubernetes.io/zone", "operator": "In", "values": [ "us-central1-a" ] }, { "key": "failure-domain.beta.kubernetes.io/region", "operator": "In", "values": [ "us-central1" ] } ] } ] } }, "persistentVolumeReclaimPolicy": "Delete", "storageClassName": "standard" }, "status": { "phase": "Bound" } } ================================================ FILE: internal/render/testdata/pv_terminating.json ================================================ { "apiVersion": "v1", "kind": "PersistentVolume", "metadata": { "annotations": { "kubernetes.io/createdby": "gce-pd-dynamic-provisioner", "pv.kubernetes.io/bound-by-controller": "yes", "pv.kubernetes.io/provisioned-by": "kubernetes.io/gce-pd" }, "creationTimestamp": "2022-06-22T03:45:57Z", "deletionGracePeriodSeconds": 0, "deletionTimestamp": "2022-07-21T14:11:00Z", "finalizers": [ "kubernetes.io/pv-protection" ], "labels": { "topology.kubernetes.io/region": "asia-southeast1", "topology.kubernetes.io/zone": "asia-southeast1-b" }, "name": "pvc-a4d86f51-916c-476b-83af-b551c91a8ac0", "resourceVersion": "29037811", "uid": "aa195b1a-0e00-43e6-aad9-d4b016904930" }, "spec": { "accessModes": [ "ReadWriteOnce" ], "capacity": { "storage": "1Gi" }, "claimRef": { "apiVersion": "v1", "kind": "PersistentVolumeClaim", "name": "www-nginx-sts-2", "namespace": "default", "resourceVersion": "4028123", "uid": "a4d86f51-916c-476b-83af-b551c91a8ac0" }, "gcePersistentDisk": { "fsType": "ext4", "pdName": "gke-k9s-fd5bf60e-pvc-a4d86f51-916c-476b-83af-b551c91a8ac0" }, "nodeAffinity": { "required": { "nodeSelectorTerms": [ { "matchExpressions": [ { "key": "topology.kubernetes.io/zone", "operator": "In", "values": [ "asia-southeast1-b" ] }, { "key": "topology.kubernetes.io/region", "operator": "In", "values": [ "asia-southeast1" ] } ] } ] } }, "persistentVolumeReclaimPolicy": "Delete", "storageClassName": "standard", "volumeMode": "Filesystem" }, "status": { "phase": "Bound" } } ================================================ FILE: internal/render/testdata/pvc.json ================================================ { "apiVersion": "v1", "kind": "PersistentVolumeClaim", "metadata": { "annotations": { "pv.kubernetes.io/bind-completed": "yes", "pv.kubernetes.io/bound-by-controller": "yes", "volume.beta.kubernetes.io/storage-provisioner": "kubernetes.io/gce-pd" }, "creationTimestamp": "2019-06-05T00:08:01Z", "finalizers": [ "kubernetes.io/pvc-protection" ], "labels": { "app": "nginx-sts" }, "name": "www-nginx-sts-0", "namespace": "default", "resourceVersion": "26769829", "selfLink": "/api/v1/namespaces/default/persistentvolumeclaims/www-nginx-sts-0", "uid": "fbabd470-8725-11e9-a8e8-42010a80015b" }, "spec": { "accessModes": [ "ReadWriteOnce" ], "dataSource": null, "resources": { "requests": { "storage": "1Mi" } }, "storageClassName": "standard", "volumeName": "pvc-fbabd470-8725-11e9-a8e8-42010a80015b" }, "status": { "accessModes": [ "ReadWriteOnce" ], "capacity": { "storage": "1Gi" }, "phase": "Bound" } } ================================================ FILE: internal/render/testdata/rb.json ================================================ { "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "RoleBinding", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"rbac.authorization.k8s.io/v1\",\"kind\":\"RoleBinding\",\"metadata\":{\"annotations\":{},\"name\":\"blee\",\"namespace\":\"default\"},\"roleRef\":{\"apiGroup\":\"rbac.authorization.k8s.io\",\"kind\":\"Role\",\"name\":\"blee\"},\"subjects\":[{\"kind\":\"ServiceAccount\",\"name\":\"fernand\",\"namespace\":\"default\"}]}\n" }, "creationTimestamp": "2019-03-27T22:26:49Z", "name": "blee", "namespace": "default", "resourceVersion": "11177042", "selfLink": "/apis/rbac.authorization.k8s.io/v1/namespaces/default/rolebindings/blee", "uid": "69ed0b23-50df-11e9-83c8-42010a800018" }, "roleRef": { "apiGroup": "rbac.authorization.k8s.io", "kind": "Role", "name": "blee" }, "subjects": [ { "kind": "ServiceAccount", "name": "fernand", "namespace": "default" } ] } ================================================ FILE: internal/render/testdata/ro.json ================================================ { "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"rbac.authorization.k8s.io/v1\",\"kind\":\"Role\",\"metadata\":{\"annotations\":{},\"name\":\"blee\",\"namespace\":\"default\"},\"rules\":[{\"apiGroups\":[\"\"],\"resources\":[\"pods\",\"namespaces\"],\"verbs\":[\"get\",\"list\",\"deletecollection\",\"patch\",\"watch\"]}]}\n" }, "creationTimestamp": "2019-05-24T02:58:58Z", "name": "blee", "namespace": "default", "resourceVersion": "23720646", "selfLink": "/apis/rbac.authorization.k8s.io/v1/namespaces/default/roles/blee", "uid": "e017e058-7dcf-11e9-b9e0-42010a800003" }, "rules": [ { "apiGroups": [ "" ], "resources": [ "pods", "namespaces" ], "verbs": [ "get", "list", "deletecollection", "patch", "watch" ] } ] } ================================================ FILE: internal/render/testdata/rs.json ================================================ { "apiVersion": "networking.k8s.io/v1", "kind": "ReplicaSet", "metadata": { "annotations": { "deployment.kubernetes.io/desired-replicas": "1", "deployment.kubernetes.io/max-replicas": "2", "deployment.kubernetes.io/revision": "1" }, "creationTimestamp": "2019-07-14T04:54:17Z", "generation": 1, "labels": { "app": "icx-db", "pod-template-hash": "7d4b578979" }, "name": "icx-db-7d4b578979", "namespace": "icx", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "Deployment", "name": "icx-db", "uid": "6f6143bc-a5f3-11e9-990f-42010a800218" } ], "resourceVersion": "37116270", "selfLink": "/apis/networking.k8s.io/v1/namespaces/icx/replicasets/icx-db-7d4b578979", "uid": "6f637a60-a5f3-11e9-990f-42010a800218" }, "spec": { "replicas": 1, "selector": { "matchLabels": { "app": "icx-db", "pod-template-hash": "7d4b578979" } }, "template": { "metadata": { "creationTimestamp": null, "labels": { "app": "icx-db", "pod-template-hash": "7d4b578979" } }, "spec": { "containers": [ { "env": [ { "name": "POSTGRES_USER", "valueFrom": { "secretKeyRef": { "key": "pg_user", "name": "icx-creds" } } }, { "name": "POSTGRES_PASSWORD", "valueFrom": { "secretKeyRef": { "key": "pg_pwd", "name": "icx-creds" } } } ], "image": "postgres:9.2-alpine", "imagePullPolicy": "IfNotPresent", "name": "icx-db", "ports": [ { "containerPort": 5432, "name": "client", "protocol": "TCP" } ], "resources": { "limits": { "cpu": "250m", "memory": "512Mi" }, "requests": { "cpu": "250m", "memory": "256Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } } }, "status": { "availableReplicas": 1, "fullyLabeledReplicas": 1, "observedGeneration": 1, "readyReplicas": 1, "replicas": 1 } } ================================================ FILE: internal/render/testdata/sa.json ================================================ { "apiVersion": "v1", "kind": "ServiceAccount", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"ServiceAccount\",\"metadata\":{\"annotations\":{},\"name\":\"blee\",\"namespace\":\"default\"},\"secrets\":[{\"name\":\"blee\",\"namespace\":\"default\"}]}\n" }, "creationTimestamp": "2019-06-05T21:56:55Z", "name": "blee", "namespace": "default", "resourceVersion": "27009820", "selfLink": "/api/v1/namespaces/default/serviceaccounts/blee", "uid": "d5919410-87dc-11e9-a8e8-42010a80015b" }, "secrets": [ { "name": "blee" }, { "name": "blee-token-k42bt" } ] } ================================================ FILE: internal/render/testdata/sc.json ================================================ { "apiVersion": "storage.k8s.io/v1", "kind": "StorageClass", "metadata": { "annotations": { "storageclass.beta.kubernetes.io/is-default-class": "true" }, "creationTimestamp": "2019-02-05T22:04:14Z", "labels": { "addonmanager.kubernetes.io/mode": "EnsureExists", "kubernetes.io/cluster-service": "true" }, "name": "standard", "resourceVersion": "277", "selfLink": "/apis/storage.k8s.io/v1/storageclasses/standard", "uid": "f9d4c94a-2991-11e9-81cd-42010a80005b" }, "parameters": { "type": "pd-standard" }, "provisioner": "kubernetes.io/gce-pd", "reclaimPolicy": "Delete", "volumeBindingMode": "Immediate", "allowVolumeExpansion": true } ================================================ FILE: internal/render/testdata/sec.json ================================================ { "apiVersion": "v1", "data": { "password": "YnVtYmxlYmVldHVuYQo=", "token": "ZmVybmFuZAo=" }, "kind": "Secret", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"password\":\"YnVtYmxlYmVldHVuYQo=\",\"token\":\"ZmVybmFuZAo=\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"fred\"},\"name\":\"s1\",\"namespace\":\"default\"},\"type\":\"Opaque\"}\n" }, "creationTimestamp": "2019-08-30T14:30:50Z", "labels": { "app": "fred" }, "name": "s1", "namespace": "default", "resourceVersion": "49724026", "selfLink": "/api/v1/namespaces/default/secrets/s1", "uid": "c3e3d3f3-cb32-11e9-990f-42010a800218" }, "type": "Opaque" } ================================================ FILE: internal/render/testdata/sts.json ================================================ { "apiVersion": "apps/v1", "kind": "StatefulSet", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"StatefulSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"nginx-sts\"},\"name\":\"nginx-sts\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"nginx-sts\"}},\"serviceName\":\"nginx-sts\",\"template\":{\"metadata\":{\"labels\":{\"app\":\"nginx-sts\"}},\"spec\":{\"containers\":[{\"image\":\"k8s.gcr.io/nginx-slim:0.8\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80,\"name\":\"web\"}],\"volumeMounts\":[{\"mountPath\":\"/usr/share/nginx/html\",\"name\":\"www\"}]}]}},\"volumeClaimTemplates\":[{\"metadata\":{\"name\":\"www\"},\"spec\":{\"accessModes\":[\"ReadWriteOnce\"],\"resources\":{\"requests\":{\"storage\":\"1Mi\"}}}}]}}\n" }, "creationTimestamp": "2019-11-30T15:41:42Z", "generation": 5, "labels": { "app": "nginx-sts" }, "name": "nginx-sts", "namespace": "default", "resourceVersion": "82973198", "selfLink": "/apis/apps/v1/namespaces/default/statefulsets/nginx-sts", "uid": "e87310a8-1387-11ea-aa02-42010a800053" }, "spec": { "podManagementPolicy": "OrderedReady", "replicas": 4, "revisionHistoryLimit": 10, "selector": { "matchLabels": { "app": "nginx-sts" } }, "serviceName": "nginx-sts", "template": { "metadata": { "annotations": { "kubectl.kubernetes.io/restartedAt": "2019-12-01T13:50:44-07:00" }, "creationTimestamp": null, "labels": { "app": "nginx-sts" } }, "spec": { "containers": [ { "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "name": "web", "protocol": "TCP" } ], "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "www" } ] } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } }, "updateStrategy": { "rollingUpdate": { "partition": 0 }, "type": "RollingUpdate" }, "volumeClaimTemplates": [ { "metadata": { "creationTimestamp": null, "name": "www" }, "spec": { "accessModes": [ "ReadWriteOnce" ], "dataSource": null, "resources": { "requests": { "storage": "1Mi" } }, "volumeMode": "Filesystem" }, "status": { "phase": "Pending" } } ] }, "status": { "collisionCount": 0, "currentReplicas": 4, "currentRevision": "nginx-sts-5b89ffb894", "observedGeneration": 5, "readyReplicas": 4, "replicas": 4, "updateRevision": "nginx-sts-5b89ffb894", "updatedReplicas": 4 } } ================================================ FILE: internal/render/testdata/svc.json ================================================ { "apiVersion": "v1", "kind": "Service", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"name\":\"dictionary1\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"name\":\"http\",\"port\":4001,\"targetPort\":\"http\"}],\"selector\":{\"app\":\"dictionary1\"},\"type\":\"ClusterIP\"}}\n" }, "creationTimestamp": "2019-07-10T23:10:43Z", "name": "dictionary1", "namespace": "default", "resourceVersion": "36257616", "selfLink": "/api/v1/namespaces/default/services/dictionary1", "uid": "f1007a5c-a367-11e9-990f-42010a800218" }, "spec": { "clusterIP": "10.47.248.116", "ports": [ { "name": "http", "port": 4001, "protocol": "TCP", "targetPort": "http" } ], "selector": { "app": "dictionary1" }, "sessionAffinity": "None", "type": "ClusterIP" }, "status": { "loadBalancer": {} } } ================================================ FILE: internal/render/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render const ( // NonResource represents a custom resource. NonResource = "*" ) const ( // Terminating represents a pod terminating status. Terminating = "Terminating" // Running represents a pod running status. Running = "Running" // Initialized represents a pod initialized status. Initialized = "Initialized" // Completed represents a pod completed status. Completed = "Completed" // ContainerCreating represents a pod container status. ContainerCreating = "ContainerCreating" // PodInitializing represents a pod initializing status. PodInitializing = "PodInitializing" // Pending represents a pod pending status. Pending = "Pending" // Blank represents no value. Blank = "" ) const ( // MissingValue indicates an unset value. MissingValue = "" // NAValue indicates a value that does not pertain. NAValue = "n/a" // UnknownValue represents an unknown. UnknownValue = "" // UnsetValue represent an unset value. UnsetValue = "" // ZeroValue represents a zero value. ZeroValue = "0" ) ================================================ FILE: internal/render/workload.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package render import ( "fmt" "strings" "github.com/derailed/k9s/internal/model1" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) var defaultWKHeader = model1.Header{ model1.HeaderColumn{Name: "KIND"}, model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME"}, model1.HeaderColumn{Name: "STATUS"}, model1.HeaderColumn{Name: "READY"}, model1.HeaderColumn{Name: "VALID", Attrs: model1.Attrs{Wide: true}}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, } // Workload renders a workload to screen. type Workload struct { Base } // ColorerFunc colors a resource row. func (Workload) ColorerFunc() model1.ColorerFunc { return func(ns string, h model1.Header, re *model1.RowEvent) tcell.Color { c := model1.DefaultColorer(ns, h, re) idx, ok := h.IndexOf("STATUS", true) if !ok { return c } status := strings.TrimSpace(re.Row.Fields[idx]) if status == "DEGRADED" { c = model1.PendingColor } return c } } // Header returns a header rbw. func (Workload) Header(string) model1.Header { return defaultWKHeader } // Render renders a K8s resource to screen. func (Workload) Render(o any, _ string, r *model1.Row) error { res, ok := o.(*WorkloadRes) if !ok { return fmt.Errorf("expected WorkloadRes but got %T", o) } r.ID = fmt.Sprintf("%s|%s|%s", res.Row.Cells[0].(string), res.Row.Cells[1].(string), res.Row.Cells[2].(string)) r.Fields = model1.Fields{ res.Row.Cells[0].(string), res.Row.Cells[1].(string), res.Row.Cells[2].(string), res.Row.Cells[3].(string), res.Row.Cells[4].(string), res.Row.Cells[5].(string), ToAge(res.Row.Cells[6].(metav1.Time)), } return nil } type WorkloadRes struct { Row metav1.TableRow } // GetObjectKind returns a schema object. func (*WorkloadRes) GetObjectKind() schema.ObjectKind { return nil } // DeepCopyObject returns a container copy. func (a *WorkloadRes) DeepCopyObject() runtime.Object { return a } ================================================ FILE: internal/slogs/child.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package slogs import "log/slog" // CLog returns a child logger. func CLog(subsys string) *slog.Logger { return slog.With(Subsys, subsys) } ================================================ FILE: internal/slogs/keys.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package slogs const ( // Error tracks an error logger key. Error = "error" // Stack tracks a stack logger key. Stack = "stack" // Subsys tracks a subsystem logger key. Subsys = "subsys" // SchemaFile tracks a schema file logger key. SchemaFile = "schema-file" // RefType tracks a reference type. RefType = "ref-type" // GVR tracks a group version resource logger key. GVR = "gvr" // AuthorSpec tracks an author spec logger key. AuthSpec = "auth-spec" // AuthStatus tracks an auth status logger key. AuthStatus = "auth-status" // AuthReason tracks an auth reason logger key. AuthReason = "auth-reason" // Ports tracks a ports logger key. Port = "port" // Address tracks an address logger key. Address = "address" // ResName tracks a resource name logger key. ResName = "res-name" // Verb tracks a verb logger key. Verb = "verb" // ResType tracks a resource type logger key. ResType = "res-type" // View tracks a view logger key. View = "view" // GOR tracks a gor logger key. GOR = "gor" // Shortcut tracks a shortcut logger key. Shortcut = "shortcut" // Page tracks a page logger key. Page = "page" // Skin tracks a skin logger key. Skin = "skin" // CmdHist tracks a command history logger key. CmdHist = "cmd-hist" // Image tracks an image logger key. Image = "image" // FQN tracks a fully qualified name logger key. FQN = "fqn" // ConfigName tracks a config name logger key. ConfigName = "config-name" // CompName tracks a component name logger key. CompName = "comp-name" // Command tracks a command logger key. Command = "cmd" // Context tracks a context logger key. Context = "context" // Cluster tracks a cluster logger key. Cluster = "cluster" // Container tracks a container logger key. Container = "container" // Options tracks an options logger key. Options = "options" // Count tracks a count logger key. Count = "count" // MaxRetries tracks a max retries logger key. MaxRetries = "max-retries" // Retry tracks a retry logger key. Retry = "retry" // Message tracks a message logger key. Message = "message" // Index tracks an index logger key. Index = "index" // Path tracks a path logger key. Path = "path" // Dir tracks a directory logger key. Dir = "dir" // FileName tracks a file name logger key. FileName = "file-name" // Key tracks a key logger key. Key = "key" // Plugin tracks a plugin logger key. Plugin = "plugin" // Component tracks a component logger key. Component = "component" // RowID tracks a row id logger key. RowID = "row-id" // Cell tracks a cell logger key. Cell = "cell" // HeaderSize tracks a header size logger key. HeaderSize = "row-size" // Namespace tracks a namespace logger key. Namespace = "ns" // AllNS tracks all namespaces logger key. AllNS = "all-ns" // Max tracks a max logger key. Max = "max" // Elapsed tracks an elapsed logger key. Elapsed = "elapsed" // Log tracks a log logger key. Log = "log" // Annotation tracks an annotation logger key. Annotation = "annotation" // Bool tracks a boolean logger key. Bool = "bool" // Replicas tracks a replicas logger key. Replicas = "replicas" // Revision tracks a revision logger key. Revision = "revision" // ColName tracks a column name logger key. ColName = "col-name" // URL tracks a URL logger key. URL = "url" // Attr tracks an attribute logger key. Attr = "attr" // Name tracks a name logger key. Name = "name" // Matches tracks a matches logger key. Matches = "matches" // Line tracks a line logger key. Line = "line" // Sig tracks a signal logger key. Sig = "signal" // Bin tracks a binary logger key. Bin = "binary" // Args tracks an arguments logger key. Args = "args" // PodPhase tracks a pod phase logger key. PodPhase = "pod-phase" // ShellPodCfg tracks a shell pod config logger key. ShellPodCfg = "shell-pod-cfg" // PFID tracks a port forward id logger key. PFID = "port-fwd-id" // PFTunnel tracks a port forward tunnel logger key. PFTunnel = "port-fwd-tunnel" // Config tracks a config logger key. Config = "config" // ResKind tracks a resource kind logger key. ResKind = "res-kind" // ResGrpVersion tracks a resource group version logger key. ResGrpVersion = "res-grp-version" // ID tracks an id logger key. ID = "id" // ViewSetting tracks a view setting logger key. ViewSetting = "view-setting" // JQExp tracks a jq expression logger key. JQExp = "jq-exp" // Duration tracks a duration logger key. Duration = "duration" // Type tracks a type logger key. Type = "type" // Requested tracks a requested value logger key. Requested = "requested" // Minimum tracks a minimum value logger key. Minimum = "minimum" ) ================================================ FILE: internal/tchart/component.go ================================================ package tchart import ( "image" "sync" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // Component represents a graphic component. type Component struct { *tview.Box bgColor, noColor tcell.Color focusFgColor, focusBgColor string seriesColors []tcell.Color dimmed tcell.Style id, legend string blur func(tcell.Key) mx sync.RWMutex } // NewComponent returns a new component. func NewComponent(id string) *Component { return &Component{ Box: tview.NewBox(), id: id, noColor: tcell.ColorDefault, seriesColors: []tcell.Color{ tcell.ColorGreen, tcell.ColorOrange, tcell.ColorOrangeRed, }, dimmed: tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor).Foreground(tcell.ColorGray).Dim(true), } } // SetFocusColorNames sets the focus color names. func (c *Component) SetFocusColorNames(fg, bg string) { c.focusFgColor, c.focusBgColor = fg, bg } // SetBackgroundColor sets the graph bg color. func (c *Component) SetBackgroundColor(color tcell.Color) { c.mx.Lock() defer c.mx.Unlock() c.Box.SetBackgroundColor(color) c.bgColor = color c.dimmed = c.dimmed.Background(color) } // ID returns the component ID. func (c *Component) ID() string { return c.id } // SetLegend sets the component legend. func (c *Component) SetLegend(l string) { c.mx.Lock() defer c.mx.Unlock() c.legend = l } // InputHandler returns the handler for this primitive. func (c *Component) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { return c.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { switch key := event.Key(); key { case tcell.KeyEnter: case tcell.KeyBacktab, tcell.KeyTab: if c.blur != nil { c.blur(key) } setFocus(c) } }) } // IsDial returns true if chart is a dial func (*Component) IsDial() bool { return false } // SetBlurFunc sets a callback fn when component gets out of focus. func (c *Component) SetBlurFunc(handler func(key tcell.Key)) *Component { c.blur = handler return c } // SetSeriesColors sets the component series colors. func (c *Component) SetSeriesColors(cc ...tcell.Color) { c.mx.Lock() defer c.mx.Unlock() c.seriesColors = cc } // GetSeriesColorNames returns series colors by name. func (c *Component) GetSeriesColorNames() []string { c.mx.RLock() defer c.mx.RUnlock() if len(c.seriesColors) < 3 { return []string{"green", "orange", "red"} } nn := make([]string, 0, len(c.seriesColors)) for _, color := range c.seriesColors { for name, co := range tcell.ColorNames { if co == color { nn = append(nn, name) } } } return nn } func (c *Component) colorForSeries() []tcell.Color { c.mx.RLock() defer c.mx.RUnlock() return c.seriesColors } func (c *Component) asRect() image.Rectangle { x, y, width, height := c.GetInnerRect() return image.Rectangle{ Min: image.Point{X: x, Y: y}, Max: image.Point{X: x + width, Y: y + height}, } } ================================================ FILE: internal/tchart/component_int_test.go ================================================ package tchart import ( "image" "testing" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" ) func TestComponentAsRect(t *testing.T) { c := NewComponent("fred") r := image.Rectangle{Min: image.Point{X: 0, Y: 0}, Max: image.Point{X: 15, Y: 10}} assert.Equal(t, r, c.asRect()) } func TestComponentColorForSeries(t *testing.T) { c := NewComponent("fred") cc := c.colorForSeries() assert.Len(t, cc, 3) assert.Equal(t, tcell.ColorGreen, cc[0]) assert.Equal(t, tcell.ColorOrange, cc[1]) assert.Equal(t, tcell.ColorOrangeRed, cc[2]) assert.Equal(t, []string{"green", "orange", "orangered"}, c.GetSeriesColorNames()) } ================================================ FILE: internal/tchart/component_test.go ================================================ package tchart_test import ( "testing" "github.com/derailed/k9s/internal/tchart" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" ) func TestCoSeriesColorNames(t *testing.T) { c := tchart.NewComponent("fred") c.SetSeriesColors(tcell.ColorGreen, tcell.ColorBlue, tcell.ColorRed) assert.Equal(t, []string{"green", "blue", "red"}, c.GetSeriesColorNames()) } ================================================ FILE: internal/tchart/dot_matrix.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package tchart import ( "github.com/derailed/tview" ) var dots = []rune{' ', '⠂', '▤', '▥'} const ( b = ' ' h = tview.BoxDrawingsHeavyHorizontal v = tview.BoxDrawingsHeavyVertical tl = tview.BoxDrawingsHeavyDownAndRight tr = tview.BoxDrawingsHeavyDownAndLeft bl = tview.BoxDrawingsHeavyUpAndRight br = tview.BoxDrawingsHeavyUpAndLeft teeL = tview.BoxDrawingsHeavyVerticalAndLeft teeR = tview.BoxDrawingsHeavyVerticalAndRight lh = '\u2578' rh = '\u257a' hv = '\u2579' lv = '\u257b' ) // Matrix represents a number dial. type Matrix [][]rune // Orientation tracks char orientations. type Orientation int // DotMatrix tracks a char matrix. type DotMatrix struct { row, col int } // NewDotMatrix returns a new matrix. func NewDotMatrix() DotMatrix { return DotMatrix{ row: 3, col: 3, } } // Print prints the matrix. func (DotMatrix) Print(n int) Matrix { return To3x3Char(n) } // To3x3Char returns 3x3 number matrix. func To3x3Char(numb int) Matrix { switch numb { case 1: return Matrix{ []rune{b, lv, b}, []rune{b, v, b}, []rune{b, hv, b}, } case 2: return Matrix{ []rune{rh, h, tr}, []rune{tl, h, br}, []rune{bl, h, lh}, } case 3: return Matrix{ []rune{h, h, tr}, []rune{rh, h, teeL}, []rune{h, h, br}, } case 4: return Matrix{ []rune{lv, b, lv}, []rune{bl, h, teeL}, []rune{b, b, hv}, } case 5: return Matrix{ []rune{tl, h, lh}, []rune{bl, h, tr}, []rune{rh, h, br}, } case 6: return Matrix{ []rune{tl, h, lh}, []rune{teeR, h, tr}, []rune{bl, h, br}, } case 7: return Matrix{ []rune{h, h, tr}, []rune{b, b, v}, []rune{b, b, hv}, } case 8: return Matrix{ []rune{tl, h, tr}, []rune{teeR, h, teeL}, []rune{bl, h, br}, } case 9: return Matrix{ []rune{tl, h, tr}, []rune{bl, h, teeL}, []rune{rh, h, br}, } default: return Matrix{ []rune{tl, h, tr}, []rune{v, b, v}, []rune{bl, h, br}, } } } ================================================ FILE: internal/tchart/dot_matrix_test.go ================================================ package tchart_test import ( "strconv" "testing" "github.com/derailed/k9s/internal/tchart" "github.com/stretchr/testify/assert" ) func TestDial3x3(t *testing.T) { d := tchart.NewDotMatrix() for n := range 2 { i := n t.Run(strconv.Itoa(n), func(t *testing.T) { assert.Equal(t, tchart.To3x3Char(i), d.Print(i)) }) } } ================================================ FILE: internal/tchart/gauge.go ================================================ package tchart import ( "fmt" "image" "time" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) const ( // DeltaSame represents no difference. DeltaSame delta = iota // DeltaMore represents a higher value. DeltaMore // DeltaLess represents a lower value. DeltaLess ) type State struct { OK, Fault int } type delta int // Gauge represents a gauge component. type Gauge struct { *Component state State resolution int deltaOK, deltaFault delta } // NewGauge returns a new gauge. func NewGauge(id string) *Gauge { return &Gauge{ Component: NewComponent(id), } } // SetResolution overrides the default number of digits to display. func (g *Gauge) SetResolution(n int) { g.resolution = n } // IsDial returns true if chart is a dial func (*Gauge) IsDial() bool { return true } func (*Gauge) SetColorIndex(int) {} func (*Gauge) SetMax(float64) {} func (*Gauge) GetMax() float64 { return 0 } // Add adds a metric. func (*Gauge) AddMetric(time.Time, float64) {} // Add adds a new metric. func (g *Gauge) Add(ok, fault int) { g.mx.Lock() defer g.mx.Unlock() g.deltaOK, g.deltaFault = computeDelta(g.state.OK, ok), computeDelta(g.state.Fault, fault) g.state = State{OK: ok, Fault: fault} } type number struct { ok bool val int str string delta delta } // Draw draws the primitive. func (g *Gauge) Draw(sc tcell.Screen) { g.Component.Draw(sc) g.mx.RLock() defer g.mx.RUnlock() rect := g.asRect() mid := image.Point{X: rect.Min.X + rect.Dx()/2, Y: rect.Min.Y + rect.Dy()/2 - 1} var ( fmat = "%d" ) d1, d2 := fmt.Sprintf(fmat, g.state.OK), fmt.Sprintf(fmat, g.state.Fault) style := tcell.StyleDefault.Background(g.bgColor) total := len(d1)*3 + len(d2)*3 + 1 colors := g.colorForSeries() o := image.Point{X: mid.X, Y: mid.Y - 1} o.X -= total / 2 g.drawNum(sc, o, number{ok: true, val: g.state.OK, delta: g.deltaOK, str: d1}, style.Foreground(colors[0]).Dim(false)) o.X, o.Y = o.X+len(d1)*3, mid.Y sc.SetContent(o.X, o.Y, '⠔', nil, style) o.X, o.Y = o.X+1, mid.Y-1 g.drawNum(sc, o, number{ok: false, val: g.state.Fault, delta: g.deltaFault, str: d2}, style.Foreground(colors[1]).Dim(false)) if rect.Dx() > 0 && rect.Dy() > 0 && g.legend != "" { legend := g.legend if g.HasFocus() { legend = fmt.Sprintf("[%s:%s:]", g.focusFgColor, g.focusBgColor) + g.legend + "[::]" } tview.Print(sc, legend, rect.Min.X, o.Y+3, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) } } func (g *Gauge) drawNum(sc tcell.Screen, o image.Point, n number, style tcell.Style) { colors := g.colorForSeries() if n.ok { style = style.Foreground(colors[0]) printDelta(sc, n.delta, o, style) } dm, significant := NewDotMatrix(), n.val == 0 if significant { style = g.dimmed } for i := range len(n.str) { if n.str[i] == '0' && !significant { g.drawDial(sc, dm.Print(int(n.str[i]-48)), o, g.dimmed) } else { significant = true g.drawDial(sc, dm.Print(int(n.str[i]-48)), o, style) } o.X += 3 } if !n.ok { o.X++ printDelta(sc, n.delta, o, style) } } func (*Gauge) drawDial(sc tcell.Screen, m Matrix, o image.Point, style tcell.Style) { for r := range m { var c int for c < len(m[r]) { dot := m[r][c] if dot != dots[0] { sc.SetContent(o.X+c, o.Y+r, dot, nil, style) } c++ } } } // ---------------------------------------------------------------------------- // Helpers... func computeDelta(d1, d2 int) delta { if d2 == 0 { return DeltaSame } d := d2 - d1 switch { case d > 0: return DeltaMore case d < 0: return DeltaLess default: return DeltaSame } } func printDelta(sc tcell.Screen, d delta, o image.Point, s tcell.Style) { s = s.Dim(false) switch d { case DeltaLess: sc.SetContent(o.X-1, o.Y+1, '↓', nil, s) case DeltaMore: sc.SetContent(o.X-1, o.Y+1, '↑', nil, s) } } ================================================ FILE: internal/tchart/gauge_int_test.go ================================================ package tchart import ( "testing" "github.com/stretchr/testify/assert" ) func TestComputeDeltas(t *testing.T) { uu := map[string]struct { d1, d2 int e delta }{ "same": { e: DeltaSame, }, "more": { d1: 10, d2: 20, e: DeltaMore, }, "less": { d1: 20, d2: 10, e: DeltaLess, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, computeDelta(u.d1, u.d2)) }) } } ================================================ FILE: internal/tchart/gauge_test.go ================================================ package tchart_test // import ( // "testing" // "github.com/imhotepio/tchart" // "github.com/stretchr/testify/assert" // ) // func TestMetricsMaxDigits(t *testing.T) { // uu := map[string]struct { // m tchart.State // e int // }{ // "empty": { // e: 1, // }, // "oks": { // m: tchart.State{OK: 100, Fault: 10}, // e: 3, // }, // "errs": { // m: tchart.State{OK: 10, Fault: 1000}, // e: 4, // }, // } // for k := range uu { // u := uu[k] // t.Run(k, func(t *testing.T) { // assert.Equal(t, u.e, u.m.MaxDigits()) // }) // } // } // func TestMetricsMax(t *testing.T) { // uu := map[string]struct { // m tchart.Metric // e int64 // }{ // "empty": { // e: 0, // }, // "max_ok": { // m: tchart.Metric{S1: 100, S2: 10}, // e: 100, // }, // } // for k := range uu { // u := uu[k] // t.Run(k, func(t *testing.T) { // assert.Equal(t, u.e, u.m.Max()) // }) // } // } ================================================ FILE: internal/tchart/series.go ================================================ package tchart import ( "fmt" "log/slog" "sort" "time" ) type MetricSeries map[time.Time]float64 type Times []time.Time func (tt Times) Len() int { return len(tt) } func (tt Times) Swap(i, j int) { tt[i], tt[j] = tt[j], tt[i] } func (tt Times) Less(i, j int) bool { return tt[i].Sub(tt[j]) <= 0 } func (tt Times) Includes(ti time.Time) bool { for _, t := range tt { if t.Equal(ti) { return true } } return false } func (mm MetricSeries) Empty() bool { return len(mm) == 0 } func (mm MetricSeries) Merge(metrics MetricSeries) { for k, v := range metrics { mm[k] = v } } func (mm MetricSeries) Dump() { slog.Debug("METRICS") for _, k := range mm.Keys() { slog.Debug(fmt.Sprintf("%v: %f", k, mm[k])) } } func (mm MetricSeries) Add(t time.Time, f float64) { if _, ok := mm[t]; !ok { mm[t] = f } } func (mm MetricSeries) Keys() Times { kk := make(Times, 0, len(mm)) for k := range mm { kk = append(kk, k) } sort.Sort(kk) return kk } func (mm MetricSeries) Truncate(size int) { kk := mm.Keys() kk = kk[0 : len(kk)-size] for t := range mm { if kk.Includes(t) { continue } delete(mm, kk[0]) } } ================================================ FILE: internal/tchart/series_test.go ================================================ package tchart_test import ( "testing" "time" "github.com/derailed/k9s/internal/tchart" "github.com/stretchr/testify/assert" ) func TestSeriesAdd(t *testing.T) { type tuple struct { time.Time float64 } uu := map[string]struct { tt []tuple e int }{ "one": { tt: []tuple{ {time.Now(), 1000}, }, e: 6, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ss := makeSeries() for _, tu := range u.tt { ss.Add(tu.Time, tu.float64) } assert.Len(t, ss, u.e) }) } } func TestSeriesTruncate(t *testing.T) { uu := map[string]struct { n, e int }{ "one": { n: 1, e: 4, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { ss := makeSeries() ss.Truncate(u.n) assert.Len(t, ss, u.e) }) } } // Helpers... func makeSeries() tchart.MetricSeries { return tchart.MetricSeries{ time.Now(): -100, time.Now(): 0, time.Now(): 100, time.Now(): 50, time.Now(): 10, } } ================================================ FILE: internal/tchart/sparkline.go ================================================ package tchart import ( "fmt" "image" "math" "time" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) var sparks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} const axisColor = "#ff0066" type block struct { full int partial rune } // SparkLine represents a sparkline component. type SparkLine struct { *Component series MetricSeries max float64 unit string colorIndex int } // NewSparkLine returns a new graph. func NewSparkLine(id, unit string) *SparkLine { return &SparkLine{ Component: NewComponent(id), series: make(MetricSeries), unit: unit, } } // GetSeriesColorNames returns series colors by name. func (s *SparkLine) GetSeriesColorNames() []string { s.mx.RLock() defer s.mx.RUnlock() nn := make([]string, 0, len(s.seriesColors)) for _, color := range s.seriesColors { for name, co := range tcell.ColorNames { if co == color { nn = append(nn, name) } } } if len(nn) < 3 { nn = append(nn, "green", "orange", "orangered") } return nn } func (s *SparkLine) SetColorIndex(n int) { s.colorIndex = n } func (s *SparkLine) SetMax(m float64) { if m > s.max { s.max = m } } func (s *SparkLine) GetMax() float64 { return s.max } func (*SparkLine) Add(int, int) {} // Add adds a metric. func (s *SparkLine) AddMetric(t time.Time, f float64) { s.mx.Lock() defer s.mx.Unlock() s.series.Add(t, f) } func (s *SparkLine) printYAxis(screen tcell.Screen, rect image.Rectangle) { style := tcell.StyleDefault.Foreground(tcell.GetColor(axisColor)).Background(s.bgColor) for y := range rect.Dy() - 3 { screen.SetContent(rect.Min.X, rect.Min.Y+y, tview.BoxDrawingsLightVertical, nil, style) } screen.SetContent(rect.Min.X, rect.Min.Y+rect.Dy()-3, tview.BoxDrawingsLightUpAndRight, nil, style) } func (s *SparkLine) printXAxis(screen tcell.Screen, rect image.Rectangle) time.Time { dx, t := rect.Dx()-1, time.Now() vals := make([]string, 0, dx) for i := dx; i > 0; i -= 10 { label := fmt.Sprintf("%02d:%02d", t.Hour(), t.Minute()) vals = append(vals, label) t = t.Add(-(10 * time.Minute)) } y := rect.Max.Y - 2 for _, v := range vals { if dx <= 2 { break } tview.Print(screen, v, rect.Min.X+dx-5, y, 6, tview.AlignCenter, tcell.ColorOrange) dx -= 10 } style := tcell.StyleDefault.Foreground(tcell.GetColor(axisColor)).Background(s.bgColor) for x := 1; x < rect.Dx()-1; x++ { screen.SetContent(rect.Min.X+x, rect.Max.Y-3, tview.BoxDrawingsLightHorizontal, nil, style) } return t } // Draw draws the graph. func (s *SparkLine) Draw(screen tcell.Screen) { s.Component.Draw(screen) s.mx.RLock() defer s.mx.RUnlock() rect := s.asRect() s.printXAxis(screen, rect) padX := 1 s.cutSet(rect.Dx() - padX) var cX int if len(s.series) < rect.Dx() { cX = rect.Max.X - len(s.series) - 1 } else { cX = rect.Min.X + padX } pad := 2 if s.legend != "" { pad++ } scale := float64(len(sparks)*(rect.Dy()-pad)) / float64(s.max) colors := s.colorForSeries() cY := rect.Max.Y - pad - 1 for _, t := range s.series.Keys() { b := s.makeBlock(s.series[t], scale) s.drawBlock(rect, screen, cX, cY, b, colors[s.colorIndex%len(colors)]) cX++ } s.printYAxis(screen, rect) if rect.Dx() > 0 && rect.Dy() > 0 && s.legend != "" { legend := s.legend if s.HasFocus() { legend = fmt.Sprintf("[%s:%s:]", s.focusFgColor, s.focusBgColor) + s.legend + "[::]" } tview.Print(screen, legend, rect.Min.X, rect.Max.Y-1, rect.Dx(), tview.AlignCenter, tcell.ColorWhite) } } func (s *SparkLine) drawBlock(r image.Rectangle, screen tcell.Screen, x, y int, b block, c tcell.Color) { style := tcell.StyleDefault.Foreground(c).Background(s.bgColor) zeroY, full := r.Min.Y, sparks[len(sparks)-1] for range b.full { screen.SetContent(x, y, full, nil, style) y-- if y < zeroY { break } } if b.partial != 0 { screen.SetContent(x, y, b.partial, nil, style) } } func (s *SparkLine) cutSet(width int) { if width <= 0 || s.series.Empty() { return } if len(s.series) > width { s.series.Truncate(width) } } func (*SparkLine) makeBlock(v, scale float64) block { sc := (v * scale) scaled := math.Round(sc) p, b := int(scaled)%len(sparks), block{full: int(scaled / float64(len(sparks)))} if v < 0 { return b } if p > 0 && p < len(sparks) { b.partial = sparks[p] } return b } ================================================ FILE: internal/ui/action.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "log/slog" "slices" "sync" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tcell/v2" ) type ( // RangeFn represents a range iteration callback. RangeFn func(tcell.Key, KeyAction) // ActionHandler handles a keyboard command. ActionHandler func(*tcell.EventKey) *tcell.EventKey // ActionOpts tracks various action options. ActionOpts struct { Visible bool Shared bool Plugin bool HotKey bool Dangerous bool } // KeyAction represents a keyboard action. KeyAction struct { Description string Action ActionHandler Opts ActionOpts } // KeyMap tracks key to action mappings. KeyMap map[tcell.Key]KeyAction // KeyActions tracks mappings between keystrokes and actions. KeyActions struct { actions KeyMap mx sync.RWMutex } ) // NewKeyAction returns a new keyboard action. func NewKeyAction(d string, a ActionHandler, visible bool) KeyAction { return NewKeyActionWithOpts(d, a, ActionOpts{ Visible: visible, }) } // NewSharedKeyAction returns a new shared keyboard action. func NewSharedKeyAction(d string, a ActionHandler, visible bool) KeyAction { return NewKeyActionWithOpts(d, a, ActionOpts{ Visible: visible, Shared: true, }) } // NewKeyActionWithOpts returns a new keyboard action. func NewKeyActionWithOpts(d string, a ActionHandler, opts ActionOpts) KeyAction { return KeyAction{ Description: d, Action: a, Opts: opts, } } // NewKeyActions returns a new instance. func NewKeyActions() *KeyActions { return &KeyActions{ actions: make(map[tcell.Key]KeyAction), } } // NewKeyActionsFromMap construct actions from key map. func NewKeyActionsFromMap(mm KeyMap) *KeyActions { return &KeyActions{actions: mm} } // Get fetches an action given a key. func (a *KeyActions) Get(key tcell.Key) (KeyAction, bool) { a.mx.RLock() defer a.mx.RUnlock() v, ok := a.actions[key] return v, ok } // Len returns action mapping count. func (a *KeyActions) Len() int { a.mx.RLock() defer a.mx.RUnlock() return len(a.actions) } // Reset clears out actions. func (a *KeyActions) Reset(aa *KeyActions) { a.Clear() a.Merge(aa) } // Range ranges over all actions and triggers a given function. func (a *KeyActions) Range(f RangeFn) { var km KeyMap a.mx.RLock() km = a.actions a.mx.RUnlock() for k, v := range km { f(k, v) } } // Add adds a new key action. func (a *KeyActions) Add(k tcell.Key, ka KeyAction) { a.mx.Lock() defer a.mx.Unlock() a.actions[k] = ka } // Bulk bulk insert key mappings. func (a *KeyActions) Bulk(aa KeyMap) { a.mx.Lock() defer a.mx.Unlock() for k, v := range aa { a.actions[k] = v } } // Merge merges given actions into existing set. func (a *KeyActions) Merge(aa *KeyActions) { a.mx.Lock() defer a.mx.Unlock() for k, v := range aa.actions { a.actions[k] = v } } // Clear remove all actions. func (a *KeyActions) Clear() { a.mx.Lock() defer a.mx.Unlock() for k := range a.actions { delete(a.actions, k) } } // ClearDanger remove all dangerous actions. func (a *KeyActions) ClearDanger() { a.mx.Lock() defer a.mx.Unlock() for k, v := range a.actions { if v.Opts.Dangerous { delete(a.actions, k) } } } // Set replace actions with new ones. func (a *KeyActions) Set(aa *KeyActions) { a.mx.Lock() defer a.mx.Unlock() for k, v := range aa.actions { a.actions[k] = v } } // Delete deletes actions by the given keys. func (a *KeyActions) Delete(kk ...tcell.Key) { a.mx.Lock() defer a.mx.Unlock() for _, k := range kk { delete(a.actions, k) } } // Hints returns a collection of hints. func (a *KeyActions) Hints() model.MenuHints { a.mx.RLock() defer a.mx.RUnlock() kk := make([]tcell.Key, 0, len(a.actions)) for k := range a.actions { if !a.actions[k].Opts.Shared { kk = append(kk, k) } } slices.Sort(kk) hh := make(model.MenuHints, 0, len(kk)) for _, k := range kk { if name, ok := tcell.KeyNames[k]; ok { hh = append(hh, model.MenuHint{ Mnemonic: name, Description: a.actions[k].Description, Visible: a.actions[k].Opts.Visible, }, ) } else { slog.Error("Unable to locate key name", slogs.Key, k) } } return hh } ================================================ FILE: internal/ui/action_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestKeyActionsHints(t *testing.T) { kk := ui.NewKeyActionsFromMap(ui.KeyMap{ ui.KeyF: ui.NewKeyAction("fred", nil, true), ui.KeyB: ui.NewKeyAction("blee", nil, true), ui.KeyZ: ui.NewKeyAction("zorg", nil, false), }) hh := kk.Hints() assert.Len(t, hh, 3) assert.Equal(t, model.MenuHint{Mnemonic: "b", Description: "blee", Visible: true}, hh[0]) } ================================================ FILE: internal/ui/app.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "log/slog" "os" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // App represents an application. type App struct { *tview.Application Configurator Main *Pages flash *model.Flash actions *KeyActions views map[string]tview.Primitive cmdBuff *model.FishBuff running bool mx sync.RWMutex } // NewApp returns a new app. func NewApp(cfg *config.Config, _ string) *App { a := App{ Application: tview.NewApplication(), actions: NewKeyActions(), Configurator: Configurator{Config: cfg, Styles: config.NewStyles()}, Main: NewPages(), flash: model.NewFlash(model.DefaultFlashDelay), cmdBuff: model.NewFishBuff(':', model.CommandBuffer), } a.views = map[string]tview.Primitive{ "menu": NewMenu(a.Styles), "logo": NewLogo(a.Styles), "prompt": NewPrompt(&a, a.Config.K9s.UI.NoIcons, a.Styles), "crumbs": NewCrumbs(a.Styles), } return &a } // Init initializes the application. func (a *App) Init() { a.bindKeys() a.Prompt().SetModel(a.cmdBuff) a.cmdBuff.AddListener(a) a.Styles.AddListener(a) a.SetRoot(a.Main, true).EnableMouse(a.Config.K9s.UI.EnableMouse) } // QueueUpdate queues up a ui action. func (a *App) QueueUpdate(f func()) { if a.Application == nil { return } go func() { a.Application.QueueUpdate(f) }() } // QueueUpdateDraw queues up a ui action and redraw the ui. func (a *App) QueueUpdateDraw(f func()) { if a.Application == nil { return } go func() { a.Application.QueueUpdateDraw(f) }() } // IsRunning checks if app is actually running. func (a *App) IsRunning() bool { a.mx.RLock() defer a.mx.RUnlock() return a.running } // SetRunning sets the app run state. func (a *App) SetRunning(f bool) { a.mx.Lock() defer a.mx.Unlock() a.running = f } // BufferCompleted indicates input was accepted. func (*App) BufferCompleted(_, _ string) {} // BufferChanged indicates the buffer was changed. func (*App) BufferChanged(_, _ string) {} // BufferActive indicates the buff activity changed. func (a *App) BufferActive(state bool, _ model.BufferKind) { flex, ok := a.Main.GetPrimitive("main").(*tview.Flex) if !ok { return } if state && flex.ItemAt(1) != a.Prompt() { flex.AddItemAtIndex(1, a.Prompt(), 3, 1, false) } else if !state && flex.ItemAt(1) == a.Prompt() { flex.RemoveItemAtIndex(1) a.SetFocus(flex) } } // SuggestionChanged notifies of update to command suggestions. func (*App) SuggestionChanged([]string) {} // StylesChanged notifies the skin changed. func (a *App) StylesChanged(s *config.Styles) { a.Main.SetBackgroundColor(s.BgColor()) if f, ok := a.Main.GetPrimitive("main").(*tview.Flex); ok { f.SetBackgroundColor(s.BgColor()) if !a.Config.K9s.IsHeadless() { if h, ok := f.ItemAt(0).(*tview.Flex); ok { h.SetBackgroundColor(s.BgColor()) } else { slog.Warn("Header not found", slogs.Subsys, "styles", slogs.Component, "app") } } } else { slog.Error("Main panel not found", slogs.Subsys, "styles", slogs.Component, "app") } } // Conn returns an api server connection. func (a *App) Conn() client.Connection { return a.Config.GetConnection() } func (a *App) bindKeys() { a.actions = NewKeyActionsFromMap(KeyMap{ KeyColon: NewKeyAction("Cmd", a.activateCmd, false), tcell.KeyCtrlR: NewKeyAction("Redraw", a.redrawCmd, false), tcell.KeyCtrlP: NewKeyAction("Persist", a.saveCmd, false), tcell.KeyCtrlU: NewSharedKeyAction("Clear Filter", a.clearCmd, false), tcell.KeyCtrlQ: NewSharedKeyAction("Clear Filter", a.clearCmd, false), }) } // BailOut exits the application. func (a *App) BailOut(exitCode int) { if err := a.Config.Save(true); err != nil { slog.Error("Config save failed!", slogs.Error, err) } a.Stop() os.Exit(exitCode) } // ResetPrompt reset the prompt model and marks buffer as active. func (a *App) ResetPrompt(m PromptModel) { m.ClearText(false) a.Prompt().SetModel(m) a.SetFocus(a.Prompt()) m.SetActive(true) } // ResetCmd clear out user command. func (a *App) ResetCmd() { a.cmdBuff.Reset() } func (a *App) saveCmd(*tcell.EventKey) *tcell.EventKey { if err := a.Config.Save(true); err != nil { a.Flash().Err(err) } a.Flash().Info("current context config saved") return nil } // ActivateCmd toggle command mode. func (a *App) ActivateCmd(b bool) { a.cmdBuff.SetActive(b) } // GetCmd retrieves user command. func (a *App) GetCmd() string { return a.cmdBuff.GetText() } // CmdBuff returns the app cmd model. func (a *App) CmdBuff() *model.FishBuff { return a.cmdBuff } // HasCmd check if cmd buffer is active and has a command. func (a *App) HasCmd() bool { return a.cmdBuff.IsActive() && !a.cmdBuff.Empty() } // InCmdMode check if command mode is active. func (a *App) InCmdMode() bool { return a.Prompt().InCmdMode() } // HasAction checks if key matches a registered binding. func (a *App) HasAction(key tcell.Key) (KeyAction, bool) { return a.actions.Get(key) } // GetActions returns a collection of actions. func (a *App) GetActions() *KeyActions { return a.actions } // AddActions returns the application actions. func (a *App) AddActions(aa *KeyActions) { a.actions.Merge(aa) } // Views return the application root views. func (a *App) Views() map[string]tview.Primitive { return a.views } func (a *App) clearCmd(evt *tcell.EventKey) *tcell.EventKey { if !a.cmdBuff.IsActive() { return evt } a.cmdBuff.ClearText(true) return nil } func (a *App) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if a.InCmdMode() { return evt } a.ResetPrompt(a.cmdBuff) a.cmdBuff.ClearText(true) return nil } // RedrawCmd forces a redraw. func (a *App) redrawCmd(evt *tcell.EventKey) *tcell.EventKey { a.QueueUpdateDraw(func() {}) return evt } // View Accessors... // Crumbs return app crumbs. func (a *App) Crumbs() *Crumbs { return a.views["crumbs"].(*Crumbs) } // Logo return the app logo. func (a *App) Logo() *Logo { return a.views["logo"].(*Logo) } // Prompt returns command prompt. func (a *App) Prompt() *Prompt { return a.views["prompt"].(*Prompt) } // Menu returns app menu. func (a *App) Menu() *Menu { return a.views["menu"].(*Menu) } // Flash returns a flash model. func (a *App) Flash() *model.Flash { return a.flash } // ---------------------------------------------------------------------------- // Helpers... // AsKey converts rune to keyboard key. func AsKey(evt *tcell.EventKey) tcell.Key { if evt.Key() != tcell.KeyRune { return evt.Key() } key := tcell.Key(evt.Rune()) if evt.Modifiers() == tcell.ModAlt { key = tcell.Key(int16(evt.Rune()) * int16(evt.Modifiers())) } return key } ================================================ FILE: internal/ui/app_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestAppGetCmd(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() a.CmdBuff().SetText("blee", "", true) assert.Equal(t, "blee", a.GetCmd()) } func TestAppInCmdMode(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() a.CmdBuff().SetText("blee", "", true) assert.False(t, a.InCmdMode()) a.CmdBuff().SetActive(false) assert.False(t, a.InCmdMode()) } func TestAppResetCmd(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() a.CmdBuff().SetText("blee", "", true) a.ResetCmd() assert.Empty(t, a.CmdBuff().GetText()) } func TestAppHasCmd(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() a.ActivateCmd(true) assert.False(t, a.HasCmd()) a.CmdBuff().SetText("blee", "", true) assert.True(t, a.InCmdMode()) } func TestAppGetActions(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() a.GetActions().Add(ui.KeyZ, ui.KeyAction{Description: "zorg"}) assert.Equal(t, 6, a.GetActions().Len()) } func TestAppViews(t *testing.T) { a := ui.NewApp(mock.NewMockConfig(t), "") a.Init() vv := []string{"crumbs", "logo", "prompt", "menu"} for i := range vv { v := vv[i] t.Run(v, func(t *testing.T) { assert.NotNil(t, a.Views()[v]) }) } assert.NotNil(t, a.Crumbs()) assert.NotNil(t, a.Logo()) assert.NotNil(t, a.Prompt()) assert.NotNil(t, a.Menu()) } ================================================ FILE: internal/ui/config.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "errors" "io/fs" "log/slog" "os" "path/filepath" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/fsnotify/fsnotify" ) // Synchronizer manages ui event queue. type synchronizer interface { Flash() *model.Flash Logo() *Logo UpdateClusterInfo() QueueUpdateDraw(func()) QueueUpdate(func()) } // Configurator represents an application configuration. type Configurator struct { Config *config.Config Styles *config.Styles customView *config.CustomView BenchFile string skinFile string } func (c *Configurator) CustomView() *config.CustomView { if c.customView == nil { c.customView = config.NewCustomView() } return c.customView } // HasSkin returns true if a skin file was located. func (c *Configurator) HasSkin() bool { return c.skinFile != "" } // CustomViewsWatcher watches for view config file changes. func (c *Configurator) CustomViewsWatcher(ctx context.Context, s synchronizer) error { w, err := fsnotify.NewWatcher() if err != nil { return err } go func() { for { select { case evt := <-w.Events: if evt.Name == config.AppViewsFile && evt.Op != fsnotify.Chmod { s.QueueUpdateDraw(func() { if err := c.RefreshCustomViews(); err != nil { slog.Warn("Custom views refresh failed", slogs.Error, err) } }) } case err := <-w.Errors: slog.Warn("CustomView watcher failed", slogs.Error, err) return case <-ctx.Done(): slog.Debug("CustomViewWatcher canceled", slogs.FileName, config.AppViewsFile) if err := w.Close(); err != nil { slog.Error("Closing CustomView watcher", slogs.Error, err) } return } } }() if err := w.Add(config.AppViewsFile); err != nil { return err } slog.Debug("Loading custom views", slogs.FileName, config.AppViewsFile) return c.RefreshCustomViews() } // RefreshCustomViews load view configuration changes. func (c *Configurator) RefreshCustomViews() error { c.CustomView().Reset() return c.CustomView().Load(config.AppViewsFile) } // SkinsDirWatcher watches for skin directory file changes. func (c *Configurator) SkinsDirWatcher(ctx context.Context, s synchronizer) error { if _, err := os.Stat(config.AppSkinsDir); errors.Is(err, fs.ErrNotExist) { return err } w, err := fsnotify.NewWatcher() if err != nil { return err } go func() { for { select { case evt := <-w.Events: if evt.Op != fsnotify.Chmod && filepath.Base(evt.Name) == filepath.Base(c.skinFile) { slog.Debug("Skin file changed detected", slogs.FileName, c.skinFile) s.QueueUpdateDraw(func() { c.RefreshStyles(s) }) } case err := <-w.Errors: slog.Warn("Skin watcher failed", slogs.Error, err) return case <-ctx.Done(): slog.Debug("SkinWatcher canceled", slogs.FileName, c.skinFile) if err := w.Close(); err != nil { slog.Error("Closing Skin watcher", slogs.Error, err) } return } } }() slog.Debug("SkinWatcher initialized", slogs.Dir, config.AppSkinsDir) return w.Add(config.AppSkinsDir) } // ConfigWatcher watches for config settings changes. func (c *Configurator) ConfigWatcher(ctx context.Context, s synchronizer) error { w, err := fsnotify.NewWatcher() if err != nil { return err } go func() { for { select { case evt := <-w.Events: if evt.Has(fsnotify.Create) || evt.Has(fsnotify.Write) { slog.Debug("ConfigWatcher file changed", slogs.FileName, evt.Name) if evt.Name == config.AppConfigFile { if err := c.Config.Load(evt.Name, false); err != nil { slog.Error("K9s config reload failed", slogs.Error, err) s.Flash().Warn("k9s config reload failed. Check k9s logs!") s.Logo().Warn("K9s config reload failed!") } } else { if err := c.Config.K9s.Reload(); err != nil { slog.Error("K9s context config reload failed", slogs.Error, err) s.Flash().Warn("Context config reload failed. Check k9s logs!") s.Logo().Warn("Context config reload failed!") } } s.QueueUpdateDraw(func() { c.RefreshStyles(s) }) } case err := <-w.Errors: slog.Warn("ConfigWatcher failed", slogs.Error, err) return case <-ctx.Done(): slog.Debug("ConfigWatcher canceled") if err := w.Close(); err != nil { slog.Error("Canceling ConfigWatcher", slogs.Error, err) } return } } }() slog.Debug("ConfigWatcher watching", slogs.FileName, config.AppConfigFile) if err := w.Add(config.AppConfigFile); err != nil { return err } cl, ct, ok := c.activeConfig() if !ok { return nil } ctConfigFile := config.AppContextConfig(cl, ct) slog.Debug("ConfigWatcher watching", slogs.FileName, 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 env_skin := os.Getenv("K9S_SKIN"); env_skin != "" { if _, err := os.Stat(config.SkinFileFromName(env_skin)); err == nil { skin = env_skin slog.Debug("Loading env skin", slogs.Skin, skin) return skin, true } } if ct, err := c.Config.K9s.ActiveContext(); err == nil && ct.Skin != "" { if _, err := os.Stat(config.SkinFileFromName(ct.Skin)); err == nil { skin = ct.Skin slog.Debug("Loading context skin", slogs.Skin, skin, slogs.Context, c.Config.K9s.ActiveContextName(), ) return skin, true } } if sk := c.Config.K9s.UI.Skin; sk != "" { if _, err := os.Stat(config.SkinFileFromName(sk)); err == nil { skin = sk slog.Debug("Loading global skin", slogs.Skin, skin) return skin, true } } return skin, skin != "" } func (c *Configurator) activeConfig() (cluster, contxt string, ok bool) { if c.Config == nil || c.Config.K9s == nil { return } ct, err := c.Config.K9s.ActiveContext() if err != nil { return } cluster, contxt = ct.GetClusterName(), c.Config.K9s.ActiveContextName() if cluster != "" && contxt != "" { ok = true } return } // RefreshStyles load for skin configuration changes. func (c *Configurator) RefreshStyles(s synchronizer) { s.UpdateClusterInfo() if c.Styles == nil { c.Styles = config.NewStyles() } defer c.loadSkinFile(s) cl, ct, ok := c.activeConfig() if !ok { return } // !!BOZO!! Lame move out! if bc, err := config.EnsureBenchmarksCfgFile(cl, ct); err != nil { slog.Warn("No benchmark config file found", slogs.Cluster, cl, slogs.Context, ct, slogs.Error, err, ) } else { c.BenchFile = bc } } func (c *Configurator) loadSkinFile(synchronizer) { invert := c.Config.K9s.IsInvert() skin, ok := c.activeSkin() if !ok { slog.Debug("No custom skin found. Using stock skin") c.updateStyles("", invert) return } skinFile := config.SkinFileFromName(skin) slog.Debug("Loading skin file", slogs.Skin, skinFile) if err := c.Styles.Load(skinFile, invert); err != nil { if errors.Is(err, os.ErrNotExist) { slog.Warn("Skin file not found in skins dir", slogs.Skin, filepath.Base(skinFile), slogs.Dir, config.AppSkinsDir, slogs.Error, err, ) c.updateStyles("", invert) } else { slog.Error("Failed to parse skin file", slogs.Path, filepath.Base(skinFile), slogs.Error, err, ) c.updateStyles(skinFile, invert) } } else { c.updateStyles(skinFile, invert) } } func (c *Configurator) updateStyles(f string, invert bool) { c.skinFile = f if f == "" { c.Styles.Reset(invert) } c.Styles.Update() model1.ModColor = c.Styles.Frame().Status.ModifyColor.Color() model1.AddColor = c.Styles.Frame().Status.AddColor.Color() model1.ErrColor = c.Styles.Frame().Status.ErrorColor.Color() model1.StdColor = c.Styles.Frame().Status.NewColor.Color() model1.PendingColor = c.Styles.Frame().Status.PendingColor.Color() model1.HighlightColor = c.Styles.Frame().Status.HighlightColor.Color() model1.KillColor = c.Styles.Frame().Status.KillColor.Color() model1.CompletedColor = c.Styles.Frame().Status.CompletedColor.Color() } ================================================ FILE: internal/ui/config_int_test.go ================================================ // 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" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" ) func Test_activeConfig(t *testing.T) { require.NoError(t, os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")) require.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) require.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) } }) } } ================================================ FILE: internal/ui/config_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "os" "path/filepath" "testing" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" ) func TestSkinnedContext(t *testing.T) { require.NoError(t, os.Setenv(config.K9sEnvConfigDir, "/tmp/k9s-test")) require.NoError(t, config.InitLocs()) defer require.NoError(t, os.RemoveAll(config.K9sEnvConfigDir)) sf := filepath.Join("..", "config", "testdata", "skins", "black-and-wtf.yaml") raw, err := os.ReadFile(sf) require.NoError(t, err) tf := filepath.Join(config.AppSkinsDir, "black-and-wtf.yaml") require.NoError(t, os.WriteFile(tf, raw, data.DefaultFileMod)) var cfg ui.Configurator cfg.Config = mock.NewMockConfig(t) cl, ct := "cl-1", "ct-1" flags := genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ct, } cfg.Config.K9s = config.NewK9s( mock.NewMockConnection(), mock.NewMockKubeSettings(&flags)) _, err = cfg.Config.K9s.ActivateContext("ct-1-1") require.NoError(t, err) cfg.Config.K9s.UI = config.UI{Skin: "black-and-wtf"} cfg.RefreshStyles(newMockSynchronizer()) assert.True(t, cfg.HasSkin()) assert.Equal(t, tcell.ColorGhostWhite.TrueColor(), model1.StdColor) assert.Equal(t, tcell.ColorWhiteSmoke.TrueColor(), model1.ErrColor) } func TestBenchConfig(t *testing.T) { require.NoError(t, os.Setenv(config.K9sEnvConfigDir, "/tmp/test-config")) require.NoError(t, config.InitLocs()) defer require.NoError(t, os.RemoveAll(config.K9sEnvConfigDir)) bc, err := config.EnsureBenchmarksCfgFile("cl-1", "ct-1") require.NoError(t, err) assert.Equal(t, "/tmp/test-config/clusters/cl-1/ct-1/benchmarks.yaml", bc) } // Helpers... type synchronizer struct{} func newMockSynchronizer() synchronizer { return synchronizer{} } func (synchronizer) Flash() *model.Flash { return model.NewFlash(100 * time.Millisecond) } func (synchronizer) Logo() *ui.Logo { return nil } func (synchronizer) UpdateClusterInfo() {} func (synchronizer) QueueUpdateDraw(func()) {} func (synchronizer) QueueUpdate(func()) {} ================================================ FILE: internal/ui/crumbs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" ) // Crumbs represents user breadcrumbs. type Crumbs struct { *tview.TextView styles *config.Styles stack *model.Stack } // NewCrumbs returns a new breadcrumb view. func NewCrumbs(styles *config.Styles) *Crumbs { c := Crumbs{ stack: model.NewStack(), styles: styles, TextView: tview.NewTextView(), } c.SetBackgroundColor(styles.BgColor()) c.SetTextAlign(tview.AlignLeft) c.SetBorderPadding(0, 0, 1, 1) c.SetDynamicColors(true) styles.AddListener(&c) return &c } // StylesChanged notifies skin changed. func (c *Crumbs) StylesChanged(s *config.Styles) { c.styles = s c.SetBackgroundColor(s.BgColor()) c.refresh(c.stack.Flatten()) } // StackPushed indicates a new item was added. func (c *Crumbs) StackPushed(comp model.Component) { c.stack.Push(comp) c.refresh(c.stack.Flatten()) } // StackPopped indicates an item was deleted. func (c *Crumbs) StackPopped(_, _ model.Component) { c.stack.Pop() c.refresh(c.stack.Flatten()) } // StackTop indicates the top of the stack. func (*Crumbs) StackTop(model.Component) {} // Refresh updates view with new crumbs. func (c *Crumbs) refresh(crumbs []string) { c.Clear() last, bgColor := len(crumbs)-1, c.styles.Frame().Crumb.BgColor for i, crumb := range crumbs { if i == last { bgColor = c.styles.Frame().Crumb.ActiveColor } _, _ = fmt.Fprintf(c, "[%s:%s:b] <%s> [-:%s:-] ", c.styles.Frame().Crumb.FgColor, bgColor, strings.ReplaceAll(strings.ToLower(crumb), " ", ""), c.styles.Body().BgColor) } } ================================================ FILE: internal/ui/crumbs_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "context" "log/slog" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/labels" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestNewCrumbs(t *testing.T) { v := ui.NewCrumbs(config.NewStyles()) v.StackPushed(makeComponent("c1")) v.StackPushed(makeComponent("c2")) v.StackPushed(makeComponent("c3")) assert.Equal(t, "[#000000:#00ffff:b] [-:#000000:-] [#000000:#00ffff:b] [-:#000000:-] [#000000:#ffa500:b] [-:#000000:-] \n", v.GetText(false)) } // Helpers... type c struct { name string } func makeComponent(n string) c { return c{name: n} } func (c) SetCommand(*cmd.Interpreter) {} func (c) InCmdMode() bool { return false } func (c) HasFocus() bool { return true } func (c) Hints() model.MenuHints { return nil } func (c) ExtraHints() map[string]string { return nil } func (c c) Name() string { return c.name } func (c) Draw(tcell.Screen) {} func (c) InputHandler() func(*tcell.EventKey, func(tview.Primitive)) { return nil } func (c) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { return nil } func (c) SetRect(int, int, int, int) {} func (c) GetRect() (a, b, c, d int) { return 0, 0, 0, 0 } func (c c) GetFocusable() tview.Focusable { return c } func (c) Focus(func(tview.Primitive)) {} func (c) Blur() {} func (c) Start() {} func (c) Stop() {} func (c) Init(context.Context) error { return nil } func (c) SetFilter(string, bool) {} func (c) SetLabelSelector(labels.Selector, bool) {} ================================================ FILE: internal/ui/deltas.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "regexp" "strconv" "strings" "time" "github.com/derailed/k9s/internal/render" "k8s.io/apimachinery/pkg/api/resource" ) const ( // DeltaSign signals a diff. DeltaSign = "Δ" // PlusSign signals inc. PlusSign = "[red::b]↑" // MinusSign signal dec. MinusSign = "[green::b]↓" ) var percent = regexp.MustCompile(`\A(\d+)%\z`) func deltaNumb(o, n string) (string, bool) { var delta string i, ok := numerical(o) if !ok { return delta, ok } j, _ := numerical(n) switch { case i < j: delta = PlusSign case i > j: delta = MinusSign } return delta, ok } func deltaPerc(o, n string) (string, bool) { var delta string i, ok := percentage(o) if !ok { return delta, ok } j, _ := percentage(n) switch { case i < j: delta = PlusSign case i > j: delta = MinusSign } return delta, ok } func deltaQty(o, n string) (string, bool) { var delta string q1, err := resource.ParseQuantity(o) if err != nil { return delta, false } q2, _ := resource.ParseQuantity(n) switch q1.Cmp(q2) { case -1: delta = PlusSign case 1: delta = MinusSign } return delta, true } func deltaDur(o, n string) (string, bool) { var delta string d1, err := time.ParseDuration(o) if err != nil { return delta, false } d2, _ := time.ParseDuration(n) switch { case d2-d1 > 0: delta = PlusSign case d2-d1 < 0: delta = MinusSign } return delta, true } // Deltas signals diffs between 2 strings. func Deltas(o, n string) string { o, n = strings.TrimSpace(o), strings.TrimSpace(n) if o == "" || o == render.NAValue { return "" } if d, ok := deltaNumb(o, n); ok { return d } if d, ok := deltaPerc(o, n); ok { return d } if d, ok := deltaQty(o, n); ok { return d } if d, ok := deltaDur(o, n); ok { return d } switch strings.Compare(o, n) { case 1, -1: return DeltaSign default: return "" } } func percentage(s string) (int, bool) { if res := percent.FindStringSubmatch(s); len(res) == 2 { n, _ := strconv.Atoi(res[1]) return n, true } return 0, false } func numerical(s string) (int, bool) { n, err := strconv.Atoi(s) if err != nil { return 0, false } return n, true } ================================================ FILE: internal/ui/deltas_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "testing" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" ) func TestDeltas(t *testing.T) { uu := []struct { s1, s2, e string }{ {"", "", ""}, {render.MissingValue, "", DeltaSign}, {render.NAValue, "", ""}, {"fred", "fred", ""}, {"fred", "blee", DeltaSign}, {"1", "1", ""}, {"1", "2", PlusSign}, {"2", "1", MinusSign}, {"2m33s", "2m33s", ""}, {"2m33s", "1m", MinusSign}, {"33s", "1m", PlusSign}, {"10Gi", "10Gi", ""}, {"10Gi", "20Gi", PlusSign}, {"30Gi", "20Gi", MinusSign}, {"15%", "15%", ""}, {"20%", "40%", PlusSign}, {"5%", "2%", MinusSign}, } for _, u := range uu { assert.Equal(t, u.e, Deltas(u.s1, u.s2)) } } ================================================ FILE: internal/ui/dialog/confirm.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) const dialogKey = "dialog" type confirmFunc func() func ShowConfirmAck(app *ui.App, pages *ui.Pages, acceptStr string, override bool, title, msg string, ack confirmFunc, cancel cancelFunc) { styles := app.Styles.Dialog() f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddButton("Cancel", func() { dismissConfirm(pages) cancel() }) var accept bool if override { changedFn := func(t string) { accept = (t == acceptStr) } f.AddInputField("Confirm:", "", 30, nil, changedFn) } else { accept = true } f.AddButton("OK", func() { if !accept { return } ack() dismissConfirm(pages) cancel() }) for i := range 2 { b := f.GetButton(i) if b == nil { continue } b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(0) modal := tview.NewModalForm("<"+title+">", f) modal.SetText(msg) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismissConfirm(pages) cancel() }) pages.AddPage(confirmKey, modal, false, false) pages.ShowPage(confirmKey) } // ShowConfirm pops a confirmation dialog. func ShowConfirm(styles *config.Dialog, pages *ui.Pages, title, msg string, ack confirmFunc, cancel cancelFunc) { f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()). SetFieldBackgroundColor(styles.BgColor.Color()) f.AddButton("Cancel", func() { dismiss(pages) cancel() }) f.AddButton("OK", func() { ack() dismiss(pages) cancel() }) for i := range 2 { if b := f.GetButton(i); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } f.SetFocus(0) modal := tview.NewModalForm("<"+title+">", f) modal.SetText(msg) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismiss(pages) cancel() }) pages.AddPage(dialogKey, modal, false, false) pages.ShowPage(dialogKey) } func dismiss(pages *ui.Pages) { pages.RemovePage(dialogKey) } ================================================ FILE: internal/ui/dialog/confirm_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestConfirmDialog(t *testing.T) { a := tview.NewApplication() p := ui.NewPages() a.SetRoot(p, false) ShowConfirm(new(config.Dialog), p, "Blee", "Yo", func() {}, func() {}) d := p.GetPrimitive(dialogKey).(*tview.ModalForm) assert.NotNil(t, d) dismiss(p) assert.Nil(t, p.GetPrimitive(dialogKey)) } ================================================ FILE: internal/ui/dialog/delete.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( noDeletePropagation = "None" defaultPropagationIdx = 0 ) type ( okFunc func(propagation *metav1.DeletionPropagation, force bool) cancelFunc func() ) var propagationOptions []string = []string{ string(metav1.DeletePropagationBackground), string(metav1.DeletePropagationForeground), string(metav1.DeletePropagationOrphan), noDeletePropagation, } // ShowDelete pops a resource deletion dialog. func ShowDelete(styles *config.Dialog, pages *ui.Pages, msg string, ok okFunc, cancel cancelFunc) { propagation, force := "", false f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddDropDown("Propagation:", propagationOptions, defaultPropagationIdx, func(_ string, optionIndex int) { propagation = propagationOptions[optionIndex] }) propField := f.GetFormItemByLabel("Propagation:").(*tview.DropDown) propField.SetListStyles( styles.FgColor.Color(), styles.BgColor.Color(), styles.ButtonFocusFgColor.Color(), styles.ButtonFocusBgColor.Color(), ) f.AddCheckbox("Force:", force, func(_ string, checked bool) { force = checked }) f.AddButton("Cancel", func() { dismiss(pages) cancel() }) f.AddButton("OK", func() { switch propagation { case noDeletePropagation: ok(nil, force) default: p := metav1.DeletionPropagation(propagation) ok(&p, force) } dismiss(pages) cancel() }) for i := range 2 { b := f.GetButton(i) if b == nil { continue } b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(2) confirm := tview.NewModalForm("", f) confirm.SetText(msg) confirm.SetDoneFunc(func(int, string) { dismiss(pages) cancel() }) pages.AddPage(dialogKey, confirm, false, false) pages.ShowPage(dialogKey) } ================================================ FILE: internal/ui/dialog/delete_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestDeleteDialog(t *testing.T) { p := ui.NewPages() okFunc := func(p *metav1.DeletionPropagation, f bool) { assert.Equal(t, propagationOptions[defaultPropagationIdx], p) assert.True(t, f) } ShowDelete(new(config.Dialog), p, "Yo", okFunc, func() {}) d := p.GetPrimitive(dialogKey).(*tview.ModalForm) assert.NotNil(t, d) dismiss(p) assert.Nil(t, p.GetPrimitive(dialogKey)) } ================================================ FILE: internal/ui/dialog/error.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "fmt" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // ShowError pops an error dialog. func ShowError(styles *config.Dialog, pages *ui.Pages, msg string) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(tcell.ColorIndianRed) f.AddButton("Dismiss", func() { dismiss(pages) }) if b := f.GetButton(0); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(0) modal := tview.NewModalForm("", f) modal.SetText(cowTalk(msg)) modal.SetTextColor(tcell.ColorOrangeRed) modal.SetDoneFunc(func(int, string) { dismiss(pages) }) pages.AddPage(dialogKey, modal, false, false) pages.ShowPage(dialogKey) } func cowTalk(says string) string { msg := fmt.Sprintf("< Ruroh? %s >", strings.TrimSuffix(says, "\n")) buff := make([]string, 0, len(cow)+3) buff = append(buff, msg) buff = append(buff, cow...) return strings.Join(buff, "\n") } var cow = []string{ `\ ^__^ `, ` \ (oo)\_______ `, ` (__)\ )\/\`, ` ||----w | `, ` || || `, } ================================================ FILE: internal/ui/dialog/error_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestErrorDialog(t *testing.T) { p := ui.NewPages() ShowError(new(config.Dialog), p, "Yo") d := p.GetPrimitive(dialogKey).(*tview.ModalForm) assert.NotNil(t, d) dismiss(p) assert.Nil(t, p.GetPrimitive(dialogKey)) } ================================================ FILE: internal/ui/dialog/plugin_inputs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "fmt" "slices" "strconv" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) const pluginInputsKey = "pluginInputs" // PluginInputValues holds the collected input values from the dialog. type PluginInputValues map[string]string // PluginInputsOkFunc is called when the user confirms the plugin inputs. type PluginInputsOkFunc func(values PluginInputValues) // PluginInputsFlashFunc is called to display flash messages. type PluginInputsFlashFunc func(msg string) // ShowPluginInputs pops a dialog to collect plugin input values. func ShowPluginInputs( styles *config.Dialog, pages *ui.Pages, title string, inputs []config.PluginInput, flash PluginInputsFlashFunc, ok PluginInputsOkFunc, cancel cancelFunc, ) { if len(inputs) == 0 { ok(make(PluginInputValues)) return } values := make(PluginInputValues) f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) // Add input fields based on type for _, input := range inputs { label := input.Name if input.Label != "" { label = input.Label } if input.Required { label += " *" } label += ":" switch input.Type { case config.InputTypeString: values[input.Name] = input.Default inputName := input.Name f.AddInputField(label, input.Default, 40, nil, func(text string) { if strings.Contains(text, " ") { text = fmt.Sprintf("%q", text) } values[inputName] = text }) case config.InputTypeNumber: values[input.Name] = input.Default inputName := input.Name f.AddInputField(label, input.Default, 20, func(text string, _ rune) bool { // Allow empty, negative sign, dot for decimals, or valid numbers if text == "" || text == "-" || text == "." || text == "-." { return true } _, err := strconv.ParseFloat(text, 64) return err == nil }, func(text string) { values[inputName] = text }) case config.InputTypeBool: defaultChecked := input.Default == "true" values[input.Name] = input.Default inputName := input.Name f.AddCheckbox(label, defaultChecked, func(_ string, checked bool) { values[inputName] = fmt.Sprintf("%t", checked) }) case config.InputTypeDropdown: if len(input.Options) > 0 { inputName := input.Name // Prepend empty option so dropdown starts unselected options := append([]string{""}, input.Options...) defaultIndex := max(0, slices.Index(options, input.Default)) values[input.Name] = options[defaultIndex] f.AddDropDown(label, options, defaultIndex, func(_ string, optionIndex int) { if optionIndex >= 0 && optionIndex < len(options) { values[inputName] = options[optionIndex] } }) if dropDown := f.GetFormItemByLabel(label); dropDown != nil { if dd, ok := dropDown.(*tview.DropDown); ok { dd.SetListStyles( styles.FgColor.Color(), styles.BgColor.Color(), styles.ButtonFocusFgColor.Color(), styles.ButtonFocusBgColor.Color(), ) } } } } } // Add Cancel button f.AddButton("Cancel", func() { dismissPluginInputs(pages) cancel() }) // Add OK button with validation f.AddButton("OK", func() { // Validate required fields var missing []string for _, input := range inputs { if input.Required { val := values[input.Name] // Bools always have a value (true/false), so skip validation for them if input.Type != config.InputTypeBool && val == "" { missing = append(missing, input.Name) } } } if len(missing) > 0 { if flash != nil { flash("Required fields are missing") } return } // Remove optional fields with zero values for _, input := range inputs { if !input.Required && input.Type != config.InputTypeBool && values[input.Name] == "" { delete(values, input.Name) } } ok(values) dismissPluginInputs(pages) cancel() }) // Style buttons buttonCount := f.GetButtonCount() for i := range buttonCount { if b := f.GetButton(i); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } f.SetFocus(0) modal := tview.NewModalForm("<"+title+">", f) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismissPluginInputs(pages) cancel() }) pages.AddPage(pluginInputsKey, modal, false, false) pages.ShowPage(pluginInputsKey) } func dismissPluginInputs(pages *ui.Pages) { pages.RemovePage(pluginInputsKey) } ================================================ FILE: internal/ui/dialog/prompt.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "context" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) type promptAction func(ctx context.Context) // ShowPrompt pops a prompt dialog. func ShowPrompt(styles *config.Dialog, pages *ui.Pages, title, msg string, action promptAction, cancel cancelFunc) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) ctx, cancelCtx := context.WithCancel(context.Background()) f.AddButton("Cancel", func() { dismiss(pages) cancelCtx() cancel() }) for i := range f.GetButtonCount() { b := f.GetButton(i) if b == nil { continue } b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(0) modal := tview.NewModalForm("<"+title+">", f) modal.SetText(msg) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismiss(pages) cancelCtx() cancel() }) pages.AddPage(dialogKey, modal, false, false) pages.ShowPage(dialogKey) go func() { action(ctx) dismiss(pages) }() } ================================================ FILE: internal/ui/dialog/prompt_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "context" "testing" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/stretchr/testify/assert" ) func TestShowPrompt(t *testing.T) { t.Run("waiting done", func(t *testing.T) { a := tview.NewApplication() p := ui.NewPages() a.SetRoot(p, false) ShowPrompt(new(config.Dialog), p, "Running", "Pod", func(context.Context) { time.Sleep(time.Millisecond) }, func() { t.Errorf("unexpected cancellations") }) }) t.Run("canceled", func(t *testing.T) { a := tview.NewApplication() p := ui.NewPages() a.SetRoot(p, false) go ShowPrompt(new(config.Dialog), p, "Running", "Pod", func(ctx context.Context) { select { case <-time.After(time.Second): t.Errorf("expected cancellations") case <-ctx.Done(): } }, func() {}) time.Sleep(time.Second / 2) d := p.GetPrimitive(dialogKey).(*tview.ModalForm) if assert.NotNil(t, d) { d.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, '\n', 0), func(tview.Primitive) {}) } }) } ================================================ FILE: internal/ui/dialog/restart.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type RestartFn func(*metav1.PatchOptions) bool type RestartDialogOpts struct { Title, Message string FieldManager string Ack RestartFn Cancel cancelFunc } func ShowRestart(styles *config.Dialog, pages *ui.Pages, opts *RestartDialogOpts) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddButton("Cancel", func() { dismissConfirm(pages) opts.Cancel() }) modal := tview.NewModalForm("<"+opts.Title+">", f) args := metav1.PatchOptions{ FieldManager: opts.FieldManager, } f.AddInputField("FieldManager:", args.FieldManager, 40, nil, func(v string) { args.FieldManager = v }) f.AddButton("OK", func() { if !opts.Ack(&args) { return } dismissConfirm(pages) opts.Cancel() }) for i := range 2 { b := f.GetButton(i) if b == nil { continue } b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(1) message := opts.Message modal.SetText(message) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismissConfirm(pages) opts.Cancel() }) pages.AddPage(confirmKey, modal, false, false) pages.ShowPage(confirmKey) } ================================================ FILE: internal/ui/dialog/selection.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) type SelectAction func(index int) func ShowSelection(styles *config.Dialog, pages *ui.Pages, title string, options []string, action SelectAction) { list := tview.NewList() list.ShowSecondaryText(false) list.SetSelectedTextColor(styles.ButtonFocusFgColor.Color()) list.SetSelectedBackgroundColor(styles.ButtonFocusBgColor.Color()) for _, option := range options { list.AddItem(option, "", 0, nil) } modal := ui.NewModalList("<"+title+">", list) modal.SetDoneFunc(func(i int, _ string) { dismiss(pages) action(i) }) pages.AddPage(dialogKey, modal, false, false) pages.ShowPage(dialogKey) } ================================================ FILE: internal/ui/dialog/transfer.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package dialog import ( "strconv" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) const confirmKey = "confirm" type TransferFn func(TransferArgs) bool type TransferArgs struct { From, To, CO string Download, NoPreserve bool Retries int } type TransferDialogOpts struct { Containers []string Pod string Title, Message string Retries int Ack TransferFn Cancel cancelFunc } func ShowUploads(styles *config.Dialog, pages *ui.Pages, opts *TransferDialogOpts) { f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddButton("Cancel", func() { dismissConfirm(pages) opts.Cancel() }) modal := tview.NewModalForm("<"+opts.Title+">", f) args := TransferArgs{ From: opts.Pod, Retries: opts.Retries, } var fromField, toField *tview.InputField args.Download = true f.AddCheckbox("Download:", args.Download, func(_ string, flag bool) { if flag { modal.SetText(strings.Replace(opts.Message, "Upload", "Download", 1)) } else { modal.SetText(strings.Replace(opts.Message, "Download", "Upload", 1)) } args.Download = flag args.From, args.To = args.To, args.From fromField.SetText(args.From) toField.SetText(args.To) }) f.AddInputField("From:", args.From, 40, nil, func(v string) { args.From = v }) f.AddInputField("To:", args.To, 40, nil, func(v string) { args.To = v }) fromField, _ = f.GetFormItemByLabel("From:").(*tview.InputField) toField, _ = f.GetFormItemByLabel("To:").(*tview.InputField) f.AddCheckbox("NoPreserve:", args.NoPreserve, func(_ string, f bool) { args.NoPreserve = f }) if len(opts.Containers) > 0 { args.CO = opts.Containers[0] } f.AddInputField("Container:", args.CO, 30, nil, func(v string) { args.CO = v }) retries := strconv.Itoa(opts.Retries) f.AddInputField("Retries:", retries, 30, nil, func(v string) { retries = v if retriesInt, err := strconv.Atoi(retries); err == nil { args.Retries = retriesInt } }) f.AddButton("OK", func() { if !opts.Ack(args) { return } dismissConfirm(pages) opts.Cancel() }) for i := range 2 { b := f.GetButton(i) if b == nil { continue } b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } f.SetFocus(0) message := opts.Message if len(opts.Containers) > 1 { message += "\nAvailable Containers:" + strings.Join(opts.Containers, ",") } modal.SetText(message) modal.SetTextColor(styles.FgColor.Color()) modal.SetDoneFunc(func(int, string) { dismissConfirm(pages) opts.Cancel() }) pages.AddPage(confirmKey, modal, false, false) pages.ShowPage(confirmKey) } func dismissConfirm(pages *ui.Pages) { pages.RemovePage(confirmKey) } ================================================ FILE: internal/ui/flash.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "log/slog" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) const ( emoHappy = "😎" emoDoh = "😗" emoRed = "😡" ) // Flash represents a flash message indicator. type Flash struct { *tview.TextView app *App testMode bool } // NewFlash returns a new flash view. func NewFlash(app *App) *Flash { f := Flash{ app: app, TextView: tview.NewTextView(), } f.SetTextColor(tcell.ColorAqua) f.SetDynamicColors(true) f.SetTextAlign(tview.AlignCenter) f.SetBorderPadding(0, 0, 1, 1) f.app.Styles.AddListener(&f) return &f } // SetTestMode for testing ONLY! func (f *Flash) SetTestMode(b bool) { f.testMode = b } // StylesChanged notifies listener the skin changed. func (f *Flash) StylesChanged(s *config.Styles) { f.SetBackgroundColor(s.BgColor()) f.SetTextColor(s.FgColor()) } // Watch watches for flash changes. func (f *Flash) Watch(ctx context.Context, c model.FlashChan) { defer slog.Debug("Flash Watch Canceled!") for { select { case <-ctx.Done(): return case msg := <-c: f.SetMessage(msg) } } } // SetMessage sets flash message and level. func (f *Flash) SetMessage(m model.LevelMessage) { fn := func() { if m.Text == "" { f.Clear() return } f.SetTextColor(flashColor(m.Level)) f.SetText(f.flashEmoji(m.Level) + " " + m.Text) } if f.testMode { fn() } else { f.app.QueueUpdateDraw(fn) } } func (f *Flash) flashEmoji(l model.FlashLevel) string { if f.app.Config.K9s.UI.NoIcons { return "" } //nolint:exhaustive switch l { case model.FlashWarn: return emoDoh case model.FlashErr: return emoRed default: return emoHappy } } // Helpers... func flashColor(l model.FlashLevel) tcell.Color { //nolint:exhaustive switch l { case model.FlashWarn: return tcell.ColorOrange case model.FlashErr: return tcell.ColorOrangeRed default: return tcell.ColorNavajoWhite } } ================================================ FILE: internal/ui/flash_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "context" "testing" "time" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestFlash(t *testing.T) { const delay = 10 * time.Millisecond uu := map[string]struct { l model.FlashLevel i, e string }{ "info": {l: model.FlashInfo, i: "hello", e: "😎 hello\n"}, "warn": {l: model.FlashWarn, i: "hello", e: "😗 hello\n"}, "err": {l: model.FlashErr, i: "hello", e: "😡 hello\n"}, } a := ui.NewApp(mock.NewMockConfig(t), "test") f := ui.NewFlash(a) f.SetTestMode(true) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go f.Watch(ctx, a.Flash().Channel()) for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { a.Flash().SetMessage(u.l, u.i) time.Sleep(delay) assert.Equal(t, u.e, f.GetText(false)) }) } } ================================================ FILE: internal/ui/indicator.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "fmt" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/tview" ) // StatusIndicator represents a status indicator when main header is collapsed. type StatusIndicator struct { *tview.TextView app *App styles *config.Styles permanent string cancel context.CancelFunc } // NewStatusIndicator returns a new status indicator. func NewStatusIndicator(app *App, styles *config.Styles) *StatusIndicator { s := StatusIndicator{ TextView: tview.NewTextView(), app: app, styles: styles, } s.SetTextAlign(tview.AlignCenter) s.SetTextColor(styles.FgColor()) s.SetBackgroundColor(styles.BgColor()) s.SetDynamicColors(true) styles.AddListener(&s) return &s } // StylesChanged notifies the skins changed. func (s *StatusIndicator) StylesChanged(styles *config.Styles) { s.styles = styles s.SetBackgroundColor(styles.BgColor()) s.SetTextColor(styles.FgColor()) } const statusIndicatorFmt = "[%s::b]K9s [%s::]%s [%s::]%s:%s:%s [%s::]%s[%s::]::[%s::]%s" // ClusterInfoUpdated notifies the cluster meta was updated. func (s *StatusIndicator) ClusterInfoUpdated(data *model.ClusterMeta) { s.app.QueueUpdateDraw(func() { s.SetPermanent(fmt.Sprintf( statusIndicatorFmt, s.styles.Body().LogoColor.String(), s.styles.K9s.Info.K9sRevColor.String(), data.K9sVer, s.styles.K9s.Info.FgColor.String(), data.Context, data.Cluster, data.K8sVer, s.styles.K9s.Info.CPUColor.String(), render.PrintPerc(data.Cpu), s.styles.Body().FgColor.String(), s.styles.K9s.Info.MEMColor.String(), render.PrintPerc(data.Mem), )) }) } // ClusterInfoChanged notifies the cluster meta was changed. func (s *StatusIndicator) ClusterInfoChanged(prev, cur *model.ClusterMeta) { if !s.app.IsRunning() { return } s.app.QueueUpdateDraw(func() { s.SetPermanent(fmt.Sprintf( statusIndicatorFmt, s.styles.Body().LogoColor.String(), s.styles.K9s.Info.K9sRevColor.String(), cur.K9sVer, s.styles.K9s.Info.FgColor.String(), cur.Context, cur.Cluster, cur.K8sVer, s.styles.K9s.Info.CPUColor.String(), AsPercDelta(prev.Cpu, cur.Cpu), s.styles.Body().FgColor.String(), s.styles.K9s.Info.MEMColor.String(), AsPercDelta(prev.Cpu, cur.Mem), )) }) } // SetPermanent sets permanent title to be reset to after updates. func (s *StatusIndicator) SetPermanent(info string) { s.permanent = info s.SetText(info) } // Reset clears out the logo view and resets colors. func (s *StatusIndicator) Reset() { s.Clear() s.SetPermanent(s.permanent) } // Err displays a log error state. func (s *StatusIndicator) Err(msg string) { s.update(msg, "orangered") } // Warn displays a log warning state. func (s *StatusIndicator) Warn(msg string) { s.update(msg, "mediumvioletred") } // Info displays a log info state. func (s *StatusIndicator) Info(msg string) { s.update(msg, "lawngreen") } func (s *StatusIndicator) update(msg, c string) { s.setText(fmt.Sprintf("[%s::b] <%s> ", c, msg)) } func (s *StatusIndicator) setText(msg string) { if s.cancel != nil { s.cancel() } s.SetText(msg) var ctx context.Context ctx, s.cancel = context.WithCancel(context.Background()) go func(ctx context.Context) { select { case <-ctx.Done(): return case <-time.After(5 * time.Second): s.app.QueueUpdateDraw(func() { s.Reset() }) } }(ctx) } // Helpers... // AsPercDelta represents a percentage with a delta indicator. func AsPercDelta(ov, nv int) string { prev, cur := render.IntToStr(ov), render.IntToStr(nv) return cur + "%" + Deltas(prev, cur) } ================================================ FILE: internal/ui/indicator_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestIndicatorReset(t *testing.T) { i := ui.NewStatusIndicator(ui.NewApp(mock.NewMockConfig(t), ""), config.NewStyles()) i.SetPermanent("Blee") i.Info("duh") i.Reset() assert.Equal(t, "Blee\n", i.GetText(false)) } func TestIndicatorInfo(t *testing.T) { i := ui.NewStatusIndicator(ui.NewApp(mock.NewMockConfig(t), ""), config.NewStyles()) i.Info("Blee") assert.Equal(t, "[lawngreen::b] \n", i.GetText(false)) } func TestIndicatorWarn(t *testing.T) { i := ui.NewStatusIndicator(ui.NewApp(mock.NewMockConfig(t), ""), config.NewStyles()) i.Warn("Blee") assert.Equal(t, "[mediumvioletred::b] \n", i.GetText(false)) } func TestIndicatorErr(t *testing.T) { i := ui.NewStatusIndicator(ui.NewApp(mock.NewMockConfig(t), ""), config.NewStyles()) i.Err("Blee") assert.Equal(t, "[orangered::b] \n", i.GetText(false)) } ================================================ FILE: internal/ui/key.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import "github.com/derailed/tcell/v2" func init() { initKeys() } func initKeys() { tcell.KeyNames[KeyHelp] = "?" tcell.KeyNames[KeySlash] = "/" tcell.KeyNames[KeySpace] = "space" initNumbKeys() initStdKeys() initShiftKeys() initShiftNumKeys() } // Defines numeric keys for container actions. const ( Key0 tcell.Key = iota + 48 Key1 Key2 Key3 Key4 Key5 Key6 Key7 Key8 Key9 ) // Defines numeric keys for container actions. const ( KeyShift0 tcell.Key = 41 KeyShift1 tcell.Key = 33 KeyShift2 tcell.Key = 64 KeyShift3 tcell.Key = 35 KeyShift4 tcell.Key = 36 KeyShift5 tcell.Key = 37 KeyShift6 tcell.Key = 94 KeyShift7 tcell.Key = 38 KeyShift8 tcell.Key = 42 KeyShift9 tcell.Key = 40 ) // Defines char keystrokes. const ( KeyA tcell.Key = iota + 97 KeyB KeyC KeyD KeyE KeyF KeyG KeyH KeyI KeyJ KeyK KeyL KeyM KeyN KeyO KeyP KeyQ KeyR KeyS KeyT KeyU KeyV KeyW KeyX KeyY KeyZ KeyHelp = 63 KeySlash = 47 KeyColon = 58 KeySpace = 32 KeyDash = 45 KeyLeftBracket = 91 KeyRightBracket = 93 ) // Define Shift Keys. const ( KeyShiftA tcell.Key = iota + 65 KeyShiftB KeyShiftC KeyShiftD KeyShiftE KeyShiftF KeyShiftG KeyShiftH KeyShiftI KeyShiftJ KeyShiftK KeyShiftL KeyShiftM KeyShiftN KeyShiftO KeyShiftP KeyShiftQ KeyShiftR KeyShiftS KeyShiftT KeyShiftU KeyShiftV KeyShiftW KeyShiftX KeyShiftY KeyShiftZ ) // NumKeys tracks number keys. var NumKeys = map[int]tcell.Key{ 0: Key0, 1: Key1, 2: Key2, 3: Key3, 4: Key4, 5: Key5, 6: Key6, 7: Key7, 8: Key8, 9: Key9, } func initNumbKeys() { tcell.KeyNames[Key0] = "0" tcell.KeyNames[Key1] = "1" tcell.KeyNames[Key2] = "2" tcell.KeyNames[Key3] = "3" tcell.KeyNames[Key4] = "4" tcell.KeyNames[Key5] = "5" tcell.KeyNames[Key6] = "6" tcell.KeyNames[Key7] = "7" tcell.KeyNames[Key8] = "8" tcell.KeyNames[Key9] = "9" } func initStdKeys() { tcell.KeyNames[KeyA] = "a" tcell.KeyNames[KeyB] = "b" tcell.KeyNames[KeyC] = "c" tcell.KeyNames[KeyD] = "d" tcell.KeyNames[KeyE] = "e" tcell.KeyNames[KeyF] = "f" tcell.KeyNames[KeyG] = "g" tcell.KeyNames[KeyH] = "h" tcell.KeyNames[KeyI] = "i" tcell.KeyNames[KeyJ] = "j" tcell.KeyNames[KeyK] = "k" tcell.KeyNames[KeyL] = "l" tcell.KeyNames[KeyM] = "m" tcell.KeyNames[KeyN] = "n" tcell.KeyNames[KeyO] = "o" tcell.KeyNames[KeyP] = "p" tcell.KeyNames[KeyQ] = "q" tcell.KeyNames[KeyR] = "r" tcell.KeyNames[KeyS] = "s" tcell.KeyNames[KeyT] = "t" tcell.KeyNames[KeyU] = "u" tcell.KeyNames[KeyV] = "v" tcell.KeyNames[KeyW] = "w" tcell.KeyNames[KeyX] = "x" tcell.KeyNames[KeyY] = "y" tcell.KeyNames[KeyZ] = "z" } func initShiftNumKeys() { tcell.KeyNames[KeyShift0] = "Shift-0" tcell.KeyNames[KeyShift1] = "Shift-1" tcell.KeyNames[KeyShift2] = "Shift-2" tcell.KeyNames[KeyShift3] = "Shift-3" tcell.KeyNames[KeyShift4] = "Shift-4" tcell.KeyNames[KeyShift5] = "Shift-5" tcell.KeyNames[KeyShift6] = "Shift-6" tcell.KeyNames[KeyShift7] = "Shift-7" tcell.KeyNames[KeyShift8] = "Shift-8" tcell.KeyNames[KeyShift9] = "Shift-9" } func initShiftKeys() { tcell.KeyNames[KeyShiftA] = "Shift-A" tcell.KeyNames[KeyShiftB] = "Shift-B" tcell.KeyNames[KeyShiftC] = "Shift-C" tcell.KeyNames[KeyShiftD] = "Shift-D" tcell.KeyNames[KeyShiftE] = "Shift-E" tcell.KeyNames[KeyShiftF] = "Shift-F" tcell.KeyNames[KeyShiftG] = "Shift-G" tcell.KeyNames[KeyShiftH] = "Shift-H" tcell.KeyNames[KeyShiftI] = "Shift-I" tcell.KeyNames[KeyShiftJ] = "Shift-J" tcell.KeyNames[KeyShiftK] = "Shift-K" tcell.KeyNames[KeyShiftL] = "Shift-L" tcell.KeyNames[KeyShiftM] = "Shift-M" tcell.KeyNames[KeyShiftN] = "Shift-N" tcell.KeyNames[KeyShiftO] = "Shift-O" tcell.KeyNames[KeyShiftP] = "Shift-P" tcell.KeyNames[KeyShiftQ] = "Shift-Q" tcell.KeyNames[KeyShiftR] = "Shift-R" tcell.KeyNames[KeyShiftS] = "Shift-S" tcell.KeyNames[KeyShiftT] = "Shift-T" tcell.KeyNames[KeyShiftU] = "Shift-U" tcell.KeyNames[KeyShiftV] = "Shift-V" tcell.KeyNames[KeyShiftW] = "Shift-W" tcell.KeyNames[KeyShiftX] = "Shift-X" tcell.KeyNames[KeyShiftY] = "Shift-Y" tcell.KeyNames[KeyShiftZ] = "Shift-Z" } ================================================ FILE: internal/ui/logo.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "strings" "sync" "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" ) // Logo represents a K9s logo. type Logo struct { *tview.Flex logo, status *tview.TextView styles *config.Styles mx sync.Mutex } // NewLogo returns a new logo. func NewLogo(styles *config.Styles) *Logo { l := Logo{ Flex: tview.NewFlex(), logo: logo(), status: status(), styles: styles, } l.SetDirection(tview.FlexRow) l.AddItem(l.logo, 6, 1, false) l.AddItem(l.status, 1, 1, false) l.refreshLogo(styles.Body().LogoColor) l.SetBackgroundColor(styles.BgColor()) styles.AddListener(&l) return &l } // Logo returns the logo viewer. func (l *Logo) Logo() *tview.TextView { return l.logo } // Status returns the status viewer. func (l *Logo) Status() *tview.TextView { return l.status } // StylesChanged notifies the skin changed. func (l *Logo) StylesChanged(s *config.Styles) { l.styles = s l.SetBackgroundColor(l.styles.BgColor()) l.status.SetBackgroundColor(l.styles.BgColor()) l.logo.SetBackgroundColor(l.styles.BgColor()) l.refreshLogo(l.styles.Body().LogoColor) } // IsBenchmarking checks if benchmarking is active or not. func (l *Logo) IsBenchmarking() bool { txt := l.Status().GetText(true) return strings.Contains(txt, "Bench") } // Reset clears out the logo view and resets colors. func (l *Logo) Reset() { l.status.Clear() l.StylesChanged(l.styles) } // Err displays a log error state. func (l *Logo) Err(msg string) { l.update(msg, l.styles.Body().LogoColorError) } // Warn displays a log warning state. func (l *Logo) Warn(msg string) { l.update(msg, l.styles.Body().LogoColorWarn) } // Info displays a log info state. func (l *Logo) Info(msg string) { l.update(msg, l.styles.Body().LogoColorInfo) } func (l *Logo) update(msg string, c config.Color) { l.refreshStatus(msg, c) l.refreshLogo(c) } func (l *Logo) refreshStatus(msg string, c config.Color) { l.mx.Lock() defer l.mx.Unlock() l.status.SetBackgroundColor(c.Color()) l.status.SetText( fmt.Sprintf("[%s::b]%s", l.styles.Body().LogoColorMsg, msg), ) } func (l *Logo) refreshLogo(c config.Color) { l.mx.Lock() defer l.mx.Unlock() l.logo.Clear() for i, s := range LogoSmall { _, _ = fmt.Fprintf(l.logo, "[%s::b]%s", c, s) if i+1 < len(LogoSmall) { _, _ = fmt.Fprintf(l.logo, "\n") } } } func logo() *tview.TextView { v := tview.NewTextView() v.SetWordWrap(false) v.SetWrap(false) v.SetTextAlign(tview.AlignLeft) v.SetDynamicColors(true) return v } func status() *tview.TextView { v := tview.NewTextView() v.SetWordWrap(false) v.SetWrap(false) v.SetTextAlign(tview.AlignCenter) v.SetDynamicColors(true) return v } ================================================ FILE: internal/ui/logo_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestNewLogoView(t *testing.T) { v := ui.NewLogo(config.NewStyles()) v.Reset() const elogo = "[#ffa500::b] ____ __ ________ \n[#ffa500::b]| |/ / __ \\______\n[#ffa500::b]| /\\____ / ___/\n[#ffa500::b]| \\ \\ / /\\___ \\\n[#ffa500::b]|____|\\__ \\/____//____ /\n[#ffa500::b] \\/ \\/ \n" assert.Equal(t, elogo, v.Logo().GetText(false)) assert.Empty(t, v.Status().GetText(false)) } func TestLogoStatus(t *testing.T) { uu := map[string]struct { logo, msg, e string }{ "info": { "[#008000::b] ____ __ ________ \n[#008000::b]| |/ / __ \\______\n[#008000::b]| /\\____ / ___/\n[#008000::b]| \\ \\ / /\\___ \\\n[#008000::b]|____|\\__ \\/____//____ /\n[#008000::b] \\/ \\/ \n", "blee", "[#ffffff::b]blee\n", }, "warn": { "[#c71585::b] ____ __ ________ \n[#c71585::b]| |/ / __ \\______\n[#c71585::b]| /\\____ / ___/\n[#c71585::b]| \\ \\ / /\\___ \\\n[#c71585::b]|____|\\__ \\/____//____ /\n[#c71585::b] \\/ \\/ \n", "blee", "[#ffffff::b]blee\n", }, "err": { "[#ff0000::b] ____ __ ________ \n[#ff0000::b]| |/ / __ \\______\n[#ff0000::b]| /\\____ / ___/\n[#ff0000::b]| \\ \\ / /\\___ \\\n[#ff0000::b]|____|\\__ \\/____//____ /\n[#ff0000::b] \\/ \\/ \n", "blee", "[#ffffff::b]blee\n", }, } v := ui.NewLogo(config.NewStyles()) for n := range uu { k, u := n, uu[n] t.Run(k, func(t *testing.T) { switch k { case "info": v.Info(u.msg) case "warn": v.Warn(u.msg) case "err": v.Err(u.msg) } assert.Equal(t, u.logo, v.Logo().GetText(false)) assert.Equal(t, u.e, v.Status().GetText(false)) }) } } ================================================ FILE: internal/ui/menu.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "regexp" "runtime" "sort" "strconv" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/tview" runewidth "github.com/mattn/go-runewidth" ) const ( menuIndexFmt = " [key:-:b]<%d> [fg:-:fgstyle]%s " maxRows = 6 ) var menuRX = regexp.MustCompile(`\d`) // Menu presents menu options. type Menu struct { *tview.Table styles *config.Styles } // NewMenu returns a new menu. func NewMenu(styles *config.Styles) *Menu { m := Menu{ Table: tview.NewTable(), styles: styles, } m.SetBackgroundColor(styles.BgColor()) styles.AddListener(&m) return &m } // StylesChanged notifies skin changed. func (m *Menu) StylesChanged(s *config.Styles) { m.styles = s m.SetBackgroundColor(s.BgColor()) for row := range m.GetRowCount() { for col := range m.GetColumnCount() { if c := m.GetCell(row, col); c != nil { c.BackgroundColor = s.BgColor() } } } } // StackPushed notifies a component was added. func (m *Menu) StackPushed(c model.Component) { m.HydrateMenu(c.Hints()) } // StackPopped notifies a component was removed. func (m *Menu) StackPopped(_, top model.Component) { if top != nil { m.HydrateMenu(top.Hints()) } else { m.Clear() } } // StackTop notifies the top component. func (m *Menu) StackTop(t model.Component) { m.HydrateMenu(t.Hints()) } // HydrateMenu populate menu ui from hints. func (m *Menu) HydrateMenu(hh model.MenuHints) { m.Clear() sort.Sort(hh) table := make([]model.MenuHints, maxRows+1) colCount := (len(hh) / maxRows) + 1 if m.hasDigits(hh) { colCount++ } for row := range maxRows { table[row] = make(model.MenuHints, colCount) } t := m.buildMenuTable(hh, table, colCount) for row := range t { for col := range len(t[row]) { c := tview.NewTableCell(t[row][col]) if t[row][col] == "" { c = tview.NewTableCell("") } c.SetBackgroundColor(m.styles.BgColor()) m.SetCell(row, col, c) } } } func (*Menu) hasDigits(hh model.MenuHints) bool { for _, h := range hh { if !h.Visible { continue } if menuRX.MatchString(h.Mnemonic) { return true } } return false } func (m *Menu) buildMenuTable(hh model.MenuHints, table []model.MenuHints, colCount int) [][]string { var row, col int firstCmd := true maxKeys := make([]int, colCount) for _, h := range hh { if !h.Visible { continue } if !menuRX.MatchString(h.Mnemonic) && firstCmd { row, col, firstCmd = 0, col+1, false if table[0][0].IsBlank() { col = 0 } } if maxKeys[col] < len(h.Mnemonic) { maxKeys[col] = len(h.Mnemonic) } table[row][col] = h row++ if row >= maxRows { row, col = 0, col+1 } } out := make([][]string, len(table)) for r := range out { out[r] = make([]string, len(table[r])) } m.layout(table, maxKeys, out) return out } func (m *Menu) layout(table []model.MenuHints, mm []int, out [][]string) { for r := range table { for c := range table[r] { out[r][c] = m.formatMenu(table[r][c], mm[c]) } } } func (m *Menu) formatMenu(h model.MenuHint, size int) string { if h.Mnemonic == "" || h.Description == "" { return "" } styles := m.styles.Frame() i, err := strconv.Atoi(h.Mnemonic) if err == nil { return formatNSMenu(i, h.Description, &styles) } return formatPlainMenu(h, size, &styles) } // ---------------------------------------------------------------------------- // Helpers... func keyConv(s string) string { if s == "" || !strings.Contains(s, "alt") { return s } if runtime.GOOS != "darwin" { return s } return strings.Replace(s, "alt", "opt", 1) } // 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)) } func ToMnemonic(s string) string { if s == "" { return s } return "<" + keyConv(strings.ToLower(s)) + ">" } func formatNSMenu(i int, name string, styles *config.Frame) string { fmat := strings.Replace(menuIndexFmt, "[key", "["+styles.Menu.NumKeyColor.String(), 1) fmat = strings.ReplaceAll(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":") fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) fmat = strings.Replace(fmat, "fgstyle]", styles.Menu.FgStyle.ToShortString()+"]", 1) return fmt.Sprintf(fmat, i, name) } func formatPlainMenu(h model.MenuHint, size int, styles *config.Frame) string { menuFmt := " [key:-:b]%-" + strconv.Itoa(size+2) + "s [fg:-:fgstyle]%s " fmat := strings.Replace(menuFmt, "[key", "["+styles.Menu.KeyColor.String(), 1) fmat = strings.Replace(fmat, "[fg", "["+styles.Menu.FgColor.String(), 1) fmat = strings.ReplaceAll(fmat, ":bg:", ":"+styles.Title.BgColor.String()+":") fmat = strings.Replace(fmat, "fgstyle]", styles.Menu.FgStyle.ToShortString()+"]", 1) return fmt.Sprintf(fmat, ToMnemonic(h.Mnemonic), h.Description) } ================================================ FILE: internal/ui/menu_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestNewMenu(t *testing.T) { v := ui.NewMenu(config.NewStyles()) v.HydrateMenu(model.MenuHints{ {Mnemonic: "a", Description: "bleeA", Visible: true}, {Mnemonic: "b", Description: "bleeB", Visible: true}, {Mnemonic: "0", Description: "zero", Visible: true}, }) assert.Equal(t, " [#ff00ff:-:b]<0> [#ffffff:-:d]zero ", v.GetCell(0, 0).Text) assert.Equal(t, " [#1e90ff:-:b] [#ffffff:-:d]bleeA ", v.GetCell(0, 1).Text) assert.Equal(t, " [#1e90ff:-:b] [#ffffff:-:d]bleeB ", v.GetCell(1, 1).Text) } func TestActionHints(t *testing.T) { uu := map[string]struct { aa *ui.KeyActions e model.MenuHints }{ "a": { aa: ui.NewKeyActionsFromMap(ui.KeyMap{ ui.KeyB: ui.NewKeyAction("bleeB", nil, true), ui.KeyA: ui.NewKeyAction("bleeA", nil, true), ui.Key0: ui.NewKeyAction("zero", nil, true), ui.Key1: ui.NewKeyAction("one", nil, false), }), e: model.MenuHints{ {Mnemonic: "0", Description: "zero", Visible: true}, {Mnemonic: "1", Description: "one", Visible: false}, {Mnemonic: "a", Description: "bleeA", Visible: true}, {Mnemonic: "b", Description: "bleeB", Visible: true}, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.aa.Hints()) }) } } ================================================ FILE: internal/ui/modal_list.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) type ModalList struct { *tview.Box // The list embedded in the modal's frame. list *tview.List // The frame embedded in the modal. frame *tview.Frame // The optional callback for when the user clicked one of the items. It // receives the index of the clicked item and the item's text. done func(int, string) } func NewModalList(title string, list *tview.List) *ModalList { m := &ModalList{Box: tview.NewBox()} m.list = list m.list.SetBackgroundColor(tview.Styles.ContrastBackgroundColor).SetBorderPadding(0, 0, 0, 0) m.list.SetSelectedFunc(func(i int, main string, _ string, _ rune) { if m.done != nil { m.done(i, main) } }) m.list.SetDoneFunc(func() { if m.done != nil { m.done(-1, "") } }) m.frame = tview.NewFrame(m.list).SetBorders(0, 0, 1, 0, 0, 0) m.frame.SetBorder(true). SetBackgroundColor(tview.Styles.ContrastBackgroundColor). SetBorderPadding(1, 1, 1, 1) m.frame.SetTitle(title) m.frame.SetTitleColor(tcell.ColorAqua) return m } // Draw draws this primitive onto the screen. func (m *ModalList) Draw(screen tcell.Screen) { // Calculate the width of this modal. width := 0 for i := range m.list.GetItemCount() { main, secondary := m.list.GetItemText(i) width = max(width, len(main)+len(secondary)+2) } screenWidth, screenHeight := screen.Size() // Set the modal's position and size. height := m.list.GetItemCount() + 4 width += 2 x := (screenWidth - width) / 2 y := (screenHeight - height) / 2 m.SetRect(x, y, width, height) // Draw the frame. m.frame.SetRect(x, y, width, height) m.frame.Draw(screen) } func (m *ModalList) SetDoneFunc(handler func(int, string)) *ModalList { m.done = handler return m } // Focus is called when this primitive receives focus. func (m *ModalList) Focus(delegate func(p tview.Primitive)) { delegate(m.list) } // HasFocus returns whether this primitive has focus. func (m *ModalList) HasFocus() bool { return m.list.HasFocus() } // MouseHandler returns the mouse handler for this primitive. func (m *ModalList) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { return m.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { // Pass mouse events on to the form. consumed, capture = m.list.MouseHandler()(action, event, setFocus) if !consumed && action == tview.MouseLeftClick && m.InRect(event.Position()) { setFocus(m) consumed = true } return }) } // InputHandler returns the handler for this primitive. func (m *ModalList) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { if m.frame.HasFocus() { if handler := m.frame.InputHandler(); handler != nil { handler(event, setFocus) return } } }) } ================================================ FILE: internal/ui/padding.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "strings" "unicode" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" ) // MaxyPad tracks uniform column padding. type MaxyPad []int // ComputeMaxColumns figures out column max size and necessary padding. func ComputeMaxColumns(pads MaxyPad, sortColName string, t *model1.TableData) { const colPadding = 1 for i, n := range t.ColumnNames(true) { pads[i] = len(n) if n == sortColName { pads[i] += 2 } } var row int t.RowsRange(func(_ int, re model1.RowEvent) bool { for index, field := range re.Row.Fields { width := len(field) + colPadding if index < len(pads) && width > pads[index] { pads[index] = width } } row++ return true }) } // IsASCII checks if table cell has all ascii characters. func IsASCII(s string) bool { for i := range s { if s[i] > unicode.MaxASCII { return false } } return true } // Pad a string up to the given length or truncates if greater than length. func Pad(s string, width int) string { if len(s) == width { return s } if len(s) > width { return render.Truncate(s, width) } return s + strings.Repeat(" ", width-len(s)) } ================================================ FILE: internal/ui/padding_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/stretchr/testify/assert" ) func TestMaxColumn(t *testing.T) { uu := map[string]struct { t *model1.TableData s string e MaxyPad }{ "ascii col 0": { model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}}, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"hello", "world"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"yo", "mama"}, }, }, ), ), "A", MaxyPad{6, 6}, }, "ascii col 1": { model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}}, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"hello", "world"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"yo", "mama"}, }, }, ), ), "B", MaxyPad{6, 6}, }, "non_ascii": { model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}}, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"Hello World lord of ipsums 😅", "world"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"o", "mama"}, }, }, ), ), "A", MaxyPad{32, 6}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { pads := make(MaxyPad, u.t.HeaderCount()) ComputeMaxColumns(pads, u.s, u.t) assert.Equal(t, u.e, pads) }) } } func TestIsASCII(t *testing.T) { uu := []struct { s string e bool }{ {"hello", true}, {"Yo! 😄", false}, {"😄", false}, } for _, u := range uu { assert.Equal(t, u.e, IsASCII(u.s)) } } func TestPad(t *testing.T) { uu := []struct { s string l int e string }{ {"fred", 3, "fr…"}, {"01234567890", 10, "012345678…"}, {"fred", 10, "fred "}, {"fred", 6, "fred "}, {"fred", 4, "fred"}, } for _, u := range uu { assert.Equal(t, u.e, Pad(u.s, u.l)) } } func BenchmarkMaxColumn(b *testing.B) { table := model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}}, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"hello", "world"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"yo", "mama"}, }, }, ), ) pads := make(MaxyPad, table.HeaderCount()) b.ReportAllocs() b.ResetTimer() for range b.N { ComputeMaxColumns(pads, "A", table) } } ================================================ FILE: internal/ui/pages.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "log/slog" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tview" ) // Pages represents a stack of view pages. type Pages struct { *tview.Pages *model.Stack } // NewPages return a new view. func NewPages() *Pages { p := Pages{ Pages: tview.NewPages(), Stack: model.NewStack(), } p.AddListener(&p) return &p } // IsTopDialog checks if front page is a dialog. func (p *Pages) IsTopDialog() bool { _, pa := p.GetFrontPage() switch pa.(type) { case *tview.ModalForm, *ModalList: return true default: return false } } // Show displays a given page. func (p *Pages) Show(c model.Component) { p.SwitchToPage(componentID(c)) } // Current returns the current component. func (p *Pages) Current() model.Component { c := p.CurrentPage() if c == nil { return nil } return c.Item.(model.Component) } // AddAndShow adds a new page and bring it to front. func (p *Pages) addAndShow(c model.Component) { p.add(c) p.Show(c) } // Add adds a new page. func (p *Pages) add(c model.Component) { p.AddPage(componentID(c), c, true, true) } // Delete removes a page. func (p *Pages) delete(c model.Component) { p.RemovePage(componentID(c)) } // Dump for debug. func (p *Pages) Dump() { slog.Debug("Dumping Pages", slogs.Page, p) for i, c := range p.Peek() { slog.Debug(fmt.Sprintf("%d -- %s -- %#v", i, componentID(c), p.GetPrimitive(componentID(c)))) } } // Stack Protocol... // StackPushed notifies a new component was pushed. func (p *Pages) StackPushed(c model.Component) { p.addAndShow(c) } // StackPopped notifies a component was removed. func (p *Pages) StackPopped(o, _ model.Component) { p.delete(o) } // StackTop notifies a new component is at the top of the stack. func (p *Pages) StackTop(top model.Component) { if top == nil { return } p.Show(top) } // Helpers... func componentID(c model.Component) string { if c.Name() == "" { slog.Error("Component has no name", slogs.Component, fmt.Sprintf("%T", c)) } return fmt.Sprintf("%s-%p", c.Name(), c) } ================================================ FILE: internal/ui/pages_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestPagesPush(t *testing.T) { c1, c2 := makeComponent("c1"), makeComponent("c2") p := ui.NewPages() p.Push(c1) p.Push(c2) assert.Equal(t, 2, p.GetPageCount()) assert.Equal(t, c2, p.CurrentPage().Item) } func TestPagesPop(t *testing.T) { c1, c2 := makeComponent("c1"), makeComponent("c2") p := ui.NewPages() p.Push(c1) p.Push(c2) p.Pop() assert.Equal(t, 1, p.GetPageCount()) assert.Equal(t, c1, p.CurrentPage().Item) } ================================================ FILE: internal/ui/prompt.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "sync" "unicode" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) const ( defaultPrompt = "%c%c [::b]%s" defaultSpacer = 4 ) var ( _ PromptModel = (*model.FishBuff)(nil) _ Suggester = (*model.FishBuff)(nil) ) // Suggester provides suggestions. type Suggester interface { // CurrentSuggestion returns the current suggestion. CurrentSuggestion() (string, bool) // NextSuggestion returns the next suggestion. NextSuggestion() (string, bool) // PrevSuggestion returns the prev suggestion. PrevSuggestion() (string, bool) // ClearSuggestions clear out all suggestions. ClearSuggestions() } // PromptModel represents a prompt buffer. type PromptModel interface { // SetText sets the model text. SetText(txt, sug string, clear bool) // GetText returns the current text. GetText() string // GetSuggestion returns the current suggestion. GetSuggestion() string // ClearText clears out model text. ClearText(fire bool) // Notify notifies all listener of current suggestions. Notify(bool) // AddListener registers a command listener. AddListener(model.BuffWatcher) // RemoveListener removes a listener. RemoveListener(model.BuffWatcher) // IsActive returns true if prompt is active. IsActive() bool // SetActive sets whether the prompt is active or not. SetActive(bool) // Add adds a new char to the prompt. Add(rune) // Delete deletes the last prompt character. Delete() } // Prompt captures users free from command input. type Prompt struct { *tview.TextView app *App noIcons bool icon rune prefix rune styles *config.Styles model PromptModel spacer int mx sync.RWMutex } // NewPrompt returns a new command view. func NewPrompt(app *App, noIcons bool, styles *config.Styles) *Prompt { p := Prompt{ app: app, styles: styles, noIcons: noIcons, TextView: tview.NewTextView(), spacer: defaultSpacer, } if noIcons { p.spacer-- } p.SetWordWrap(true) p.SetWrap(true) p.SetDynamicColors(true) p.SetBorder(true) p.SetBorderPadding(0, 0, 1, 1) styles.AddListener(&p) p.SetInputCapture(p.keyboard) return &p } // SendKey sends a keyboard event (testing only!). func (p *Prompt) SendKey(evt *tcell.EventKey) { p.keyboard(evt) } // SendStrokes (testing only!) func (p *Prompt) SendStrokes(s string) { for _, r := range s { p.keyboard(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } } // Deactivate sets the prompt as inactive. func (p *Prompt) Deactivate() { if p.model != nil { p.model.ClearText(true) p.model.SetActive(false) } } // SetModel sets the prompt buffer model. func (p *Prompt) SetModel(m PromptModel) { if p.model != nil { p.model.RemoveListener(p) } p.model = m p.model.AddListener(p) } func (p *Prompt) keyboard(evt *tcell.EventKey) *tcell.EventKey { m, ok := p.model.(Suggester) if !ok { return evt } //nolint:exhaustive switch evt.Key() { case tcell.KeyBackspace2, tcell.KeyBackspace, tcell.KeyDelete: p.model.Delete() case tcell.KeyRune: r := evt.Rune() // Filter out control characters and non-printable runes that may come from // terminal escape sequences (e.g., cursor position reports like [7;15R) // Only accept printable characters for user input if isValidInputRune(r) { p.model.Add(r) } case tcell.KeyEscape: p.model.ClearText(true) p.model.SetActive(false) case tcell.KeyEnter, tcell.KeyCtrlE: p.model.SetText(p.model.GetText(), "", true) p.model.SetActive(false) case tcell.KeyCtrlW, tcell.KeyCtrlU: p.model.ClearText(true) case tcell.KeyUp: if s, ok := m.NextSuggestion(); ok { p.model.SetText(p.model.GetText(), s, true) } case tcell.KeyDown: if s, ok := m.PrevSuggestion(); ok { p.model.SetText(p.model.GetText(), s, true) } case tcell.KeyTab, tcell.KeyRight, tcell.KeyCtrlF: if s, ok := m.CurrentSuggestion(); ok { p.model.SetText(p.model.GetText()+s, "", true) m.ClearSuggestions() } } return nil } // StylesChanged notifies skin changed. func (p *Prompt) StylesChanged(s *config.Styles) { p.styles = s p.SetBackgroundColor(s.K9s.Prompt.BgColor.Color()) p.SetTextColor(s.K9s.Prompt.FgColor.Color()) } // InCmdMode returns true if command is active, false otherwise. func (p *Prompt) InCmdMode() bool { if p.model == nil { return false } return p.model.IsActive() } func (p *Prompt) activate() { p.Clear() p.SetCursorIndex(len(p.model.GetText())) p.write(p.model.GetText(), p.model.GetSuggestion()) p.model.Notify(false) } func (p *Prompt) Clear() { p.mx.Lock() defer p.mx.Unlock() p.TextView.Clear() } func (p *Prompt) Draw(sc tcell.Screen) { p.mx.RLock() defer p.mx.RUnlock() p.TextView.Draw(sc) } func (p *Prompt) update(text, suggestion string) { p.Clear() p.write(text, suggestion) } func (p *Prompt) write(text, suggest string) { p.mx.Lock() defer p.mx.Unlock() p.SetCursorIndex(p.spacer + len(text)) if suggest != "" { text += fmt.Sprintf("[%s::-]%s", p.styles.Prompt().SuggestColor, suggest) } p.StylesChanged(p.styles) _, _ = fmt.Fprintf(p, defaultPrompt, p.icon, p.prefix, text) } // ---------------------------------------------------------------------------- // Event Listener protocol... // BufferCompleted indicates input was accepted. func (p *Prompt) BufferCompleted(text, suggestion string) { p.update(text, suggestion) } // BufferChanged indicates the buffer was changed. func (p *Prompt) BufferChanged(text, suggestion string) { p.update(text, suggestion) } // SuggestionChanged notifies the suggestion changed. func (p *Prompt) SuggestionChanged(text, suggestion string) { p.update(text, suggestion) } // BufferActive indicates the buff activity changed. func (p *Prompt) BufferActive(activate bool, kind model.BufferKind) { if activate { p.ShowCursor(true) p.SetBorder(true) p.SetTextColor(p.styles.FgColor()) p.SetBorderColor(p.colorFor(kind)) p.icon, p.prefix = p.prefixesFor(kind) p.activate() return } p.ShowCursor(false) p.SetBorder(false) p.SetBackgroundColor(p.styles.BgColor()) p.Clear() } func (p *Prompt) prefixesFor(k model.BufferKind) (ic, prefix rune) { defer func() { if p.noIcons { ic = ' ' } }() //nolint:exhaustive switch k { case model.CommandBuffer: return '🐶', '>' default: return '🐩', '/' } } // ---------------------------------------------------------------------------- // Helpers... // isValidInputRune checks if a rune is valid for user input. // It filters out control characters and non-printable characters that may // come from terminal escape sequences (e.g., cursor position reports). func isValidInputRune(r rune) bool { // Reject control characters (0x00-0x1F, 0x7F) except for common whitespace if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' { return false } // Only accept printable characters return unicode.IsPrint(r) || unicode.IsSpace(r) } func (p *Prompt) colorFor(k model.BufferKind) tcell.Color { //nolint:exhaustive switch k { case model.CommandBuffer: return p.styles.Prompt().Border.CommandColor.Color() default: return p.styles.Prompt().Border.DefaultColor.Color() } } ================================================ FILE: internal/ui/prompt_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" ) func TestCmdNew(t *testing.T) { uu := map[string]struct { mode rune kind model.BufferKind noIcon bool e string }{ "cmd": { mode: ':', noIcon: true, kind: model.CommandBuffer, e: " > [::b]blee\n", }, "cmd-ic": { mode: ':', kind: model.CommandBuffer, e: "🐶> [::b]blee\n", }, "search": { mode: '/', kind: model.FilterBuffer, noIcon: true, e: " / [::b]blee\n", }, "search-ic": { mode: '/', kind: model.FilterBuffer, e: "🐩/ [::b]blee\n", }, } for k, u := range uu { t.Run(k, func(t *testing.T) { v := ui.NewPrompt(nil, u.noIcon, config.NewStyles()) m := model.NewFishBuff(u.mode, u.kind) v.SetModel(m) m.AddListener(v) for _, r := range "blee" { m.Add(r) } m.SetActive(true) assert.Equal(t, u.e, v.GetText(false)) }) } } func TestCmdUpdate(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) v := ui.NewPrompt(nil, true, config.NewStyles()) v.SetModel(m) m.AddListener(v) m.SetText("blee", "", true) m.Add('!') assert.Equal(t, "\x00\x00 [::b]blee!\n", v.GetText(false)) assert.False(t, v.InCmdMode()) } func TestCmdMode(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) v := ui.NewPrompt(&ui.App{}, true, config.NewStyles()) v.SetModel(m) m.AddListener(v) for _, f := range []bool{false, true} { m.SetActive(f) assert.Equal(t, f, v.InCmdMode()) } } func TestPrompt_Deactivate(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) v := ui.NewPrompt(&ui.App{}, true, config.NewStyles()) v.SetModel(m) m.AddListener(v) m.SetActive(true) if assert.True(t, v.InCmdMode()) { v.Deactivate() assert.False(t, v.InCmdMode()) } } // Tests that, when active, the prompt has the appropriate color func TestPromptColor(t *testing.T) { styles := config.NewStyles() app := ui.App{} // Make sure to have different values to be sure that the prompt color actually changes depending on its type assert.NotEqual(t, styles.Prompt().Border.DefaultColor.Color(), styles.Prompt().Border.CommandColor.Color(), ) testCases := []struct { kind model.BufferKind expectedColor tcell.Color }{ // Command prompt case { kind: model.CommandBuffer, expectedColor: styles.Prompt().Border.CommandColor.Color(), }, // Any other prompt type case { // Simulate a different type of prompt since no particular constant exists kind: model.CommandBuffer + 1, expectedColor: styles.Prompt().Border.DefaultColor.Color(), }, } for _, testCase := range testCases { m := model.NewFishBuff(':', testCase.kind) prompt := ui.NewPrompt(&app, true, styles) prompt.SetModel(m) m.AddListener(prompt) m.SetActive(true) assert.Equal(t, testCase.expectedColor, prompt.GetBorderColor()) } } // Tests that, when a change of style occurs, the prompt will have the appropriate color when active func TestPromptStyleChanged(t *testing.T) { app := ui.App{} styles := config.NewStyles() newStyles := config.NewStyles() newStyles.K9s.Prompt.Border = config.PromptBorder{ DefaultColor: "green", CommandColor: "yellow", } // Check that the prompt won't change the border into the same style assert.NotEqual(t, styles.Prompt().Border.CommandColor.Color(), newStyles.Prompt().Border.CommandColor.Color()) assert.NotEqual(t, styles.Prompt().Border.DefaultColor.Color(), newStyles.Prompt().Border.DefaultColor.Color()) testCases := []struct { kind model.BufferKind expectedColor tcell.Color }{ // Command prompt case { kind: model.CommandBuffer, expectedColor: newStyles.Prompt().Border.CommandColor.Color(), }, // Any other prompt type case { // Simulate a different type of prompt since no particular constant exists kind: model.CommandBuffer + 1, expectedColor: newStyles.Prompt().Border.DefaultColor.Color(), }, } for _, testCase := range testCases { m := model.NewFishBuff(':', testCase.kind) prompt := ui.NewPrompt(&app, true, styles) m.SetActive(true) prompt.SetModel(m) m.AddListener(prompt) prompt.StylesChanged(newStyles) m.SetActive(true) assert.Equal(t, testCase.expectedColor, prompt.GetBorderColor()) } } ================================================ FILE: internal/ui/prompt_validation_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "fmt" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" ) // TestPrompt_FiltersControlCharacters tests that control characters from // terminal escape sequences are filtered out and not added to the buffer. func TestPrompt_FiltersControlCharacters(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) p := ui.NewPrompt(nil, true, config.NewStyles()) p.SetModel(m) m.AddListener(p) m.SetActive(true) // Test control characters that should be filtered controlChars := []rune{ 0x00, // NULL 0x01, // SOH 0x1B, // ESC (escape character) 0x7F, // DEL } for _, c := range controlChars { t.Run(fmt.Sprintf("control_char_0x%02X", c), func(t *testing.T) { evt := tcell.NewEventKey(tcell.KeyRune, c, tcell.ModNone) p.SendKey(evt) // Control characters should not be added to buffer assert.Empty(t, m.GetText(), "Control character 0x%02X should be filtered", c) }) } } // TestPrompt_AcceptsPrintableCharacters tests that valid printable // characters are accepted and added to the buffer. func TestPrompt_AcceptsPrintableCharacters(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) p := ui.NewPrompt(nil, true, config.NewStyles()) p.SetModel(m) m.AddListener(p) m.SetActive(true) // Test valid printable characters validChars := []rune{ 'a', 'Z', '0', '9', '!', '@', '#', '$', ' ', // space '[', ']', ';', 'R', // characters from escape sequences (should be accepted if typed) } for _, c := range validChars { t.Run(fmt.Sprintf("valid_char_%c", c), func(t *testing.T) { evt := tcell.NewEventKey(tcell.KeyRune, c, tcell.ModNone) p.SendKey(evt) // Valid characters should be added assert.Contains(t, m.GetText(), string(c), "Valid character %c should be accepted", c) // Clear for next test m.ClearText(true) }) } // Test tab separately (it's a control char but should be accepted) t.Run("valid_char_tab", func(t *testing.T) { evt := tcell.NewEventKey(tcell.KeyRune, '\t', tcell.ModNone) p.SendKey(evt) // Tab should be accepted (it's a special case in the validation) // Note: Tab might be converted to spaces or handled differently by the buffer text := m.GetText() // Tab is accepted by validation, but may be handled specially by the buffer // Just verify the buffer isn't empty (meaning something was processed) assert.NotNil(t, text, "Tab character should be processed") m.ClearText(true) }) } // TestPrompt_FiltersEscapeSequencePattern tests that escape sequence // patterns are not automatically added when they appear as individual runes. // Note: This test verifies the validation works, but escape sequences // should ideally be handled by tcell before reaching KeyRune. func TestPrompt_FiltersEscapeSequencePattern(t *testing.T) { m := model.NewFishBuff(':', model.CommandBuffer) p := ui.NewPrompt(nil, true, config.NewStyles()) p.SetModel(m) m.AddListener(p) m.SetActive(true) // Simulate the problematic escape sequence pattern [7;15R // Each character individually is printable, but we want to ensure // they don't appear unexpectedly escapeSequence := "[7;15R" // Send each character for _, r := range escapeSequence { evt := tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone) p.SendKey(evt) } // The characters themselves are printable, so they will be added // This test documents the current behavior - the fix prevents // control characters, but printable escape sequence chars would // still be added if tcell doesn't filter them first text := m.GetText() // If all characters are printable, they will be in the buffer // This is expected behavior - the fix prevents control chars, // but can't prevent legitimate printable characters assert.NotEmpty(t, text, "Printable escape sequence chars may still appear") // However, we can verify no control characters made it through for _, r := range text { assert.False(t, isControlChar(r), "No control characters should be in buffer") } } // Helper function to check if a rune is a control character func isControlChar(r rune) bool { return r >= 0x00 && r <= 0x1F || r == 0x7F } ================================================ FILE: internal/ui/select_table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // SelectTable represents a table with selections. type SelectTable struct { *tview.Table model Tabular selectedFn func(string) string marks map[string]struct{} selFgColor tcell.Color selBgColor tcell.Color } // SetModel sets the table model. func (s *SelectTable) SetModel(m Tabular) { s.model = m } // GetModel returns the current model. func (s *SelectTable) GetModel() Tabular { return s.model } // ClearSelection reset selected row. func (s *SelectTable) ClearSelection() { s.Select(0, 0) s.ScrollToBeginning() } // SelectFirstRow select first data row if any. func (s *SelectTable) SelectFirstRow() { if s.GetRowCount() > 0 { s.Select(1, 0) } } // GetSelectedItems return currently marked or selected items names. func (s *SelectTable) GetSelectedItems() []string { if len(s.marks) == 0 { if item := s.GetSelectedItem(); item != "" { return []string{item} } return nil } items := make([]string, 0, len(s.marks)) for item := range s.marks { items = append(items, item) } return items } // GetRowID returns the row id at given location. func (s *SelectTable) GetRowID(index int) (string, bool) { cell := s.GetCell(index, 0) if cell == nil { return "", false } id, ok := cell.GetReference().(string) return id, ok } // GetSelectedItem returns the currently selected item name. func (s *SelectTable) GetSelectedItem() string { if s.GetSelectedRowIndex() == 0 || s.model.Empty() { return "" } sel, ok := s.GetCell(s.GetSelectedRowIndex(), 0).GetReference().(string) if !ok { return "" } if s.selectedFn != nil { return s.selectedFn(sel) } return sel } // GetSelectedCell returns the content of a cell for the currently selected row. func (s *SelectTable) GetSelectedCell(col int) string { r, _ := s.GetSelection() return TrimCell(s, r, col) } // SetSelectedFn defines a function that cleanse the current selection. func (s *SelectTable) SetSelectedFn(f func(string) string) { s.selectedFn = f } // GetSelectedRowIndex fetch the currently selected row index. func (s *SelectTable) GetSelectedRowIndex() int { r, _ := s.GetSelection() return r } // SelectRow select a given row by index. func (s *SelectTable) SelectRow(r, c int, broadcast bool) { if !broadcast { s.SetSelectionChangedFunc(nil) } if count := s.model.RowCount(); count > 0 && r-1 > count { r = count + 1 } defer s.SetSelectionChangedFunc(s.selectionChanged) s.Select(r, c) } // UpdateSelection refresh selected row. func (s *SelectTable) updateSelection(broadcast bool) { r, c := s.GetSelection() s.SelectRow(r, c, broadcast) } func (s *SelectTable) selectionChanged(r, c int) { if r < 0 { return } if cell := s.GetCell(r, c); cell != nil { s.SetSelectedStyle( tcell.StyleDefault.Foreground(s.selFgColor). Background(cell.Color).Attributes(tcell.AttrBold)) } } // ClearMarks delete all marked items. func (s *SelectTable) ClearMarks() { for k := range s.marks { delete(s.marks, k) } } // DeleteMark delete a marked item. func (s *SelectTable) DeleteMark(k string) { delete(s.marks, k) } // ToggleMark toggles marked row. func (s *SelectTable) ToggleMark() { sel := s.GetSelectedItem() if sel == "" { return } if _, ok := s.marks[sel]; ok { delete(s.marks, s.GetSelectedItem()) } else { s.marks[sel] = struct{}{} } if cell := s.GetCell(s.GetSelectedRowIndex(), 0); cell != nil { s.SetSelectedStyle(tcell.StyleDefault.Foreground(cell.BackgroundColor).Background(cell.Color).Attributes(tcell.AttrBold)) } } // SpanMark toggles marked row. func (s *SelectTable) SpanMark() { selIndex, prev := s.GetSelectedRowIndex(), -1 if selIndex <= 0 { return } // Look back to find previous mark for i := selIndex - 1; i > 0; i-- { id, ok := s.GetRowID(i) if !ok { break } if _, ok := s.marks[id]; ok { prev = i break } } if prev != -1 { s.markRange(prev, selIndex) return } // Look forward to see if we have a mark for i := selIndex; i < s.GetRowCount(); i++ { id, ok := s.GetRowID(i) if !ok { break } if _, ok := s.marks[id]; ok { prev = i break } } s.markRange(prev, selIndex) } func (s *SelectTable) markRange(prev, curr int) { if prev < 0 { return } if prev > curr { prev, curr = curr, prev } for i := prev + 1; i <= curr; i++ { id, ok := s.GetRowID(i) if !ok { break } s.marks[id] = struct{}{} cell := s.GetCell(s.GetSelectedRowIndex(), 0) if cell == nil { break } s.SetSelectedStyle(tcell.StyleDefault.Foreground(cell.BackgroundColor).Background(cell.Color).Attributes(tcell.AttrBold)) } } // IsMarked returns true if this item was marked. func (s *SelectTable) IsMarked(item string) bool { _, ok := s.marks[item] return ok } ================================================ FILE: internal/ui/splash.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "fmt" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" ) // LogoSmall K9s small log. var LogoSmall = []string{ ` ____ __ ________ `, `| |/ / __ \______`, `| /\____ / ___/`, `| \ \ / /\___ \`, `|____|\__ \/____//____ /`, ` \/ \/ `, } // LogoBig K9s big logo for splash page. var LogoBig = []string{ ` ____ __ ________ _______ ____ ___ `, `| |/ / __ \______/ ___ \| | | |`, `| /\____ / ___/ \ \/| | | |`, `| \ \ / /\___ \ \___| |___| |`, `|____|\__ \/____//____ /\______ /_______ \___|`, ` \/ \/ \/ \/ `, } // Splash represents a splash screen. type Splash struct { *tview.Flex } // NewSplash instantiates a new splash screen with product and company info. func NewSplash(styles *config.Styles, version string) *Splash { s := Splash{Flex: tview.NewFlex()} s.SetBackgroundColor(styles.BgColor()) logo := tview.NewTextView() logo.SetDynamicColors(true) logo.SetTextAlign(tview.AlignCenter) s.layoutLogo(logo, styles) vers := tview.NewTextView() vers.SetDynamicColors(true) vers.SetTextAlign(tview.AlignCenter) s.layoutRev(vers, version, styles) s.SetDirection(tview.FlexRow) s.AddItem(logo, 10, 1, false) s.AddItem(vers, 1, 1, false) return &s } func (*Splash) layoutLogo(t *tview.TextView, styles *config.Styles) { logo := strings.Join(LogoBig, fmt.Sprintf("\n[%s::b]", styles.Body().LogoColor)) _, _ = fmt.Fprintf(t, "%s[%s::b]%s\n", strings.Repeat("\n", 2), styles.Body().LogoColor, logo) } func (*Splash) layoutRev(t *tview.TextView, rev string, styles *config.Styles) { _, _ = fmt.Fprintf(t, "[%s::b]Revision [red::b]%s", styles.Body().FgColor, rev) } ================================================ FILE: internal/ui/splash_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" ) func TestNewSplash(t *testing.T) { s := ui.NewSplash(config.NewStyles(), "bozo") x, y, w, h := s.GetRect() assert.Equal(t, 0, x) assert.Equal(t, 0, y) assert.Equal(t, 15, w) assert.Equal(t, 10, h) } ================================================ FILE: internal/ui/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "fmt" "log/slog" "sync" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/vul" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) const maxTruncate = 50 type ( // ColorerFunc represents a row colorer. ColorerFunc func(ns string, evt model1.RowEvent) tcell.Color // DecorateFunc represents a row decorator. DecorateFunc func(*model1.TableData) // SelectedRowFunc a table selection callback. SelectedRowFunc func(r int) ) // Table represents tabular data. type Table struct { *SelectTable gvr *client.GVR sortCol model1.SortColumn selectedColIdx int manualSort bool Path string Extras string actions *KeyActions cmdBuff *model.FishBuff styles *config.Styles viewSetting *config.ViewSetting colorerFn model1.ColorerFunc decorateFn DecorateFunc wide bool toast bool hasMetrics bool ctx context.Context mx sync.RWMutex readOnly bool noIcon bool fullGVR bool } // NewTable returns a new table view. func NewTable(gvr *client.GVR) *Table { return &Table{ SelectTable: &SelectTable{ Table: tview.NewTable(), model: model.NewTable(gvr), marks: make(map[string]struct{}), }, ctx: context.Background(), gvr: gvr, actions: NewKeyActions(), cmdBuff: model.NewFishBuff('/', model.FilterBuffer), sortCol: model1.SortColumn{ASC: true}, } } // SetFullGVR toggles full GVR title display. func (t *Table) SetFullGVR(b bool) { t.mx.Lock() defer t.mx.Unlock() t.fullGVR = b } // SetNoIcon toggles no icon mode. func (t *Table) SetNoIcon(b bool) { t.mx.Lock() defer t.mx.Unlock() t.noIcon = b } // SetReadOnly toggles read-only mode. func (t *Table) SetReadOnly(ro bool) { t.mx.Lock() defer t.mx.Unlock() t.readOnly = ro } func (t *Table) setSortCol(sc model1.SortColumn) { t.mx.Lock() defer t.mx.Unlock() t.sortCol = sc } func (t *Table) toggleSortCol() { t.mx.Lock() defer t.mx.Unlock() t.sortCol.ASC = !t.sortCol.ASC } func (t *Table) getSortCol() model1.SortColumn { t.mx.RLock() defer t.mx.RUnlock() return t.sortCol } func (t *Table) setMSort(b bool) { t.mx.Lock() defer t.mx.Unlock() t.manualSort = b } func (t *Table) getMSort() bool { t.mx.RLock() defer t.mx.RUnlock() return t.manualSort } func (t *Table) getSelectedColIdx() int { t.mx.RLock() defer t.mx.RUnlock() return t.selectedColIdx } // initSelectedColumn initializes the selected column index based on current sort column. func (t *Table) initSelectedColumn() { data := t.GetFilteredData() if data == nil || data.HeaderCount() == 0 { return } sc := t.getSortCol() if sc.Name == "" { t.mx.Lock() t.selectedColIdx = 0 t.mx.Unlock() return } // Find the visual column index for the current sort column header := data.Header() visibleCol := 0 for _, h := range header { if t.shouldExcludeColumn(h) { continue } if h.Name == sc.Name { t.mx.Lock() t.selectedColIdx = visibleCol t.mx.Unlock() return } visibleCol++ } // If sort column not found in visible columns, default to 0 t.mx.Lock() t.selectedColIdx = 0 t.mx.Unlock() } // moveSelectedColumn moves the column selection by delta (-1 for left, +1 for right). func (t *Table) moveSelectedColumn(delta int) { data := t.GetFilteredData() if data == nil || data.HeaderCount() == 0 { return } // Count visible columns visibleCount := 0 for _, h := range data.Header() { if !t.shouldExcludeColumn(h) { visibleCount++ } } if visibleCount == 0 { return } t.mx.Lock() t.selectedColIdx += delta // Wrap around if t.selectedColIdx >= visibleCount { t.selectedColIdx = 0 } else if t.selectedColIdx < 0 { t.selectedColIdx = visibleCount - 1 } t.mx.Unlock() t.Refresh() } // SelectNextColumn moves the column selection to the right. func (t *Table) SelectNextColumn() { t.moveSelectedColumn(1) } // SelectPrevColumn moves the column selection to the left. func (t *Table) SelectPrevColumn() { t.moveSelectedColumn(-1) } // SortSelectedColumn sorts by the currently selected column. func (t *Table) SortSelectedColumn() { data := t.GetFilteredData() if data == nil || data.HeaderCount() == 0 { return } idx := t.getSelectedColIdx() if idx < 0 { return } // Map visual column index to actual header column name // (accounting for hidden columns) header := data.Header() visibleCol := 0 var colName string for _, h := range header { if t.shouldExcludeColumn(h) { continue } if visibleCol == idx { colName = h.Name break } visibleCol++ } if colName == "" { return } sc := t.getSortCol() // Toggle direction if same column, otherwise default to ascending asc := true if sc.Name == colName { asc = !sc.ASC } t.SetSortCol(colName, asc) t.setMSort(true) t.Refresh() } // SetViewSetting sets custom view config is present. func (t *Table) SetViewSetting(vs *config.ViewSetting) bool { t.mx.Lock() defer t.mx.Unlock() if !t.viewSetting.Equals(vs) { t.viewSetting = vs slog.Debug("Updating custom view setting", slogs.GVR, t.gvr, slogs.ViewSetting, vs) t.model.SetViewSetting(t.ctx, vs) return true } return false } // GetViewSetting return current view settings if any. func (t *Table) GetViewSetting() *config.ViewSetting { t.mx.RLock() defer t.mx.RUnlock() return t.viewSetting } func (t *Table) GetContext() context.Context { return t.ctx } func (t *Table) SetContext(ctx context.Context) { t.ctx = ctx } // Init initializes the component. func (t *Table) Init(ctx context.Context) { t.SetFixed(1, 0) t.SetBorder(true) t.SetBorderAttributes(tcell.AttrBold) t.SetBorderPadding(0, 0, 1, 1) t.SetSelectable(true, false) t.SetSelectionChangedFunc(t.selectionChanged) t.SetBackgroundColor(tcell.ColorDefault) t.Select(1, 0) t.styles = mustExtractStyles(ctx) t.StylesChanged(t.styles) } // GVR returns a resource descriptor. func (t *Table) GVR() *client.GVR { return t.gvr } // ViewSettingsChanged notifies listener the view configuration changed. func (t *Table) ViewSettingsChanged(vs *config.ViewSetting) { if t.SetViewSetting(vs) { if vs == nil { if !t.getMSort() && !t.sortCol.IsSet() { t.setSortCol(model1.SortColumn{}) } } else { t.setMSort(false) } t.Refresh() } } // StylesChanged notifies the skin changed. func (t *Table) StylesChanged(s *config.Styles) { t.SetBackgroundColor(s.Table().BgColor.Color()) t.SetBorderColor(s.Frame().Border.FgColor.Color()) t.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) t.SetSelectedStyle( tcell.StyleDefault.Foreground(t.styles.Table().CursorFgColor.Color()). Background(t.styles.Table().CursorBgColor.Color()).Attributes(tcell.AttrBold)) t.selFgColor = s.Table().CursorFgColor.Color() t.selBgColor = s.Table().CursorBgColor.Color() t.Refresh() } // ResetToast resets toast flag. func (t *Table) ResetToast() { t.toast = false t.Refresh() } // ToggleToast toggles to show toast resources. func (t *Table) ToggleToast() { t.toast = !t.toast t.Refresh() } // ToggleWide toggles wide col display. func (t *Table) ToggleWide() { t.wide = !t.wide t.Refresh() } // Actions returns active menu bindings. func (t *Table) Actions() *KeyActions { return t.actions } // Styles returns styling configurator. func (t *Table) Styles() *config.Styles { return t.styles } // FilterInput filters user commands. func (t *Table) FilterInput(r rune) bool { if !t.cmdBuff.IsActive() { return false } t.cmdBuff.Add(r) t.ClearSelection() t.doUpdate(t.filtered(t.GetModel().Peek())) t.UpdateTitle() t.SelectFirstRow() return true } // Filter filters out table data. func (t *Table) Filter(string) { t.ClearSelection() t.doUpdate(t.filtered(t.GetModel().Peek())) t.UpdateTitle() t.SelectFirstRow() } // Hints returns the view hints. func (t *Table) Hints() model.MenuHints { return t.actions.Hints() } // ExtraHints returns additional hints. func (*Table) ExtraHints() map[string]string { return nil } // GetFilteredData fetch filtered tabular data. func (t *Table) GetFilteredData() *model1.TableData { return t.filtered(t.GetModel().Peek()) } // SetDecorateFn specifies the default row decorator. func (t *Table) SetDecorateFn(f DecorateFunc) { t.decorateFn = f } // SetColorerFn specifies the default colorer. func (t *Table) SetColorerFn(f model1.ColorerFunc) { t.colorerFn = f } // SetSortCol sets in sort column index and order. func (t *Table) SetSortCol(name string, asc bool) { t.setSortCol(model1.SortColumn{Name: name, ASC: asc}) } // Update table content. func (t *Table) Update(data *model1.TableData, hasMetrics bool) *model1.TableData { if t.decorateFn != nil { t.decorateFn(data) } t.hasMetrics = hasMetrics return t.doUpdate(t.filtered(data)) } func (t *Table) GetNamespace() string { if t.GetModel() != nil { return t.GetModel().GetNamespace() } return client.NamespaceAll } func (t *Table) doUpdate(data *model1.TableData) *model1.TableData { if client.IsAllNamespaces(data.GetNamespace()) { t.actions.Add( KeyShiftP, NewKeyAction("Sort Namespace", t.SortColCmd("NAMESPACE", true), false), ) } else { t.actions.Delete(KeyShiftP) } oldSortCol := t.getSortCol() t.setSortCol(data.ComputeSortCol(t.GetViewSetting(), t.getSortCol(), t.getMSort())) // Initialize selected column index to match the current sort column // This ensures the highlight starts at the sorted column newSortCol := t.getSortCol() if oldSortCol.Name != newSortCol.Name { t.initSelectedColumn() } return data } func (t *Table) shouldExcludeColumn(h model1.HeaderColumn) bool { return (h.Hide || (!t.wide && h.Wide)) || (h.Name == "NAMESPACE" && !t.GetModel().ClusterWide()) || (h.MX && !t.hasMetrics) || (h.VS && vul.ImgScanner == nil) } func (t *Table) UpdateUI(cdata, data *model1.TableData) { t.Clear() fg := t.styles.Table().Header.FgColor.Color() bg := t.styles.Table().Header.BgColor.Color() var col int for _, h := range cdata.Header() { if t.shouldExcludeColumn(h) { continue } t.AddHeaderCell(col, h) c := t.GetCell(0, col) c.SetBackgroundColor(bg) c.SetTextColor(fg) col++ } cdata.Sort(t.getSortCol()) pads := make(MaxyPad, cdata.HeaderCount()) ComputeMaxColumns(pads, t.getSortCol().Name, cdata) cdata.RowsRange(func(row int, re model1.RowEvent) bool { ore, ok := data.FindRow(re.Row.ID) if !ok { slog.Error("Unable to find original row event", slogs.RowID, re.Row.ID) return true } t.buildRow(row+1, re, ore, cdata.Header(), pads) return true }) t.updateSelection(true) t.UpdateTitle() } func (t *Table) buildRow(r int, re, ore model1.RowEvent, h model1.Header, pads MaxyPad) { color := model1.DefaultColorer if t.colorerFn != nil { color = t.colorerFn } marked := t.IsMarked(re.Row.ID) var col int ns := t.GetModel().GetNamespace() for c, field := range re.Row.Fields { if c >= len(h) { slog.Error("Field/header overflow detected. Check your mappings!", slogs.GVR, t.GVR(), slogs.Cell, c, slogs.HeaderSize, len(h), ) continue } if t.shouldExcludeColumn(h[c]) { continue } if !re.Deltas.IsBlank() && !h.IsTimeCol(c) { var old string if c < len(ore.Deltas) { old = ore.Deltas[c] } if c < len(re.Deltas) { old = re.Deltas[c] } field += Deltas(old, field) } if h[c].Decorator != nil { field = h[c].Decorator(field) } if h[c].Align == tview.AlignLeft { field = formatCell(field, pads[c]) } cell := tview.NewTableCell(field) cell.SetExpansion(1) cell.SetAlign(h[c].Align) fgColor := color(ns, h, &re) cell.SetTextColor(fgColor) if marked { cell.SetTextColor(t.styles.Table().MarkColor.Color()) } if col == 0 { cell.SetReference(re.Row.ID) } t.SetCell(r, col, cell) col++ } } // SortColCmd designates a sorted column. func (t *Table) SortColCmd(name string, asc bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { sc := t.getSortCol() sc.ASC = !sc.ASC if sc.Name != name { sc.ASC = asc } sc.Name = name t.setSortCol(sc) t.setMSort(true) // Sync selected column index with the new sort column t.initSelectedColumn() t.Refresh() return nil } } // SortInvertCmd reverses sorting order. func (t *Table) SortInvertCmd(*tcell.EventKey) *tcell.EventKey { t.toggleSortCol() t.Refresh() return nil } // ClearMarks clear out marked items. func (t *Table) ClearMarks() { t.SelectTable.ClearMarks() t.Refresh() } // Refresh update the table data. func (t *Table) Refresh() { data := t.model.Peek() if data.HeaderCount() == 0 { return } cdata := t.Update(data, t.hasMetrics) t.UpdateUI(cdata, data) } // GetSelectedRow returns the entire selected row or nil if nothing selected. func (t *Table) GetSelectedRow(path string) *model1.Row { data := t.model.Peek() re, ok := data.FindRow(path) if !ok { return nil } return &re.Row } // NameColIndex returns the index of the resource name column. func (t *Table) NameColIndex() int { col := 0 if client.IsClusterScoped(t.GetModel().GetNamespace()) { return col } if t.GetModel().ClusterWide() { col++ } return col } // AddHeaderCell configures a table cell header. func (t *Table) AddHeaderCell(col int, h model1.HeaderColumn) { sc := t.getSortCol() sortCol := h.Name == sc.Name selectedCol := col == t.getSelectedColIdx() styles := t.styles.Table() c := tview.NewTableCell(columnIndicator(sortCol, selectedCol, sc.ASC, &styles, h.Name)) c.SetExpansion(1) c.SetSelectable(false) c.SetAlign(h.Align) t.SetCell(0, col, c) } func (t *Table) filtered(data *model1.TableData) *model1.TableData { return data.Filter(model1.FilterOpts{ Toast: t.toast, Filter: t.cmdBuff.GetText(), }) } // CmdBuff returns the associated command buffer. func (t *Table) CmdBuff() *model.FishBuff { return t.cmdBuff } // ShowDeleted marks row as deleted. func (t *Table) ShowDeleted() { r, _ := t.GetSelection() cols := t.GetColumnCount() for x := range cols { t.GetCell(r, x).SetAttributes(tcell.AttrDim) } } // UpdateTitle refreshes the table title. func (t *Table) UpdateTitle() { t.SetTitle(t.styleTitle()) } func (t *Table) styleTitle() string { rc := int64(t.GetRowCount()) if rc > 0 { rc-- } ns := t.GetModel().GetNamespace() if client.IsClusterWide(ns) || ns == client.NotNamespaced { ns = client.NamespaceAll } path := t.Path if path != "" { cns, n := client.Namespaced(path) if cns == client.ClusterScope { ns = n } else { ns = path } } if t.Extras != "" { ns = t.Extras } resource := t.gvr.R() if t.fullGVR { resource = t.gvr.String() } var ( title string styles = t.styles.Frame() ) if ns == client.ClusterScope { title = SkinTitle(fmt.Sprintf(TitleFmt, resource, render.AsThousands(rc)), &styles) } else { title = SkinTitle(fmt.Sprintf(NSTitleFmt, resource, ns, render.AsThousands(rc)), &styles) } buff := t.cmdBuff.GetText() if internal.IsLabelSelector(buff) { if sel, err := ExtractLabelSelector(buff); err == nil { buff = render.Truncate(sel.String(), maxTruncate) } } else if l := t.GetModel().GetLabelSelector(); l != nil && !l.Empty() { buff = render.Truncate(l.String(), maxTruncate) } else if buff != "" { buff = render.Truncate(buff, maxTruncate) } if buff == "" { return title } return title + SkinTitle(fmt.Sprintf(SearchFmt, buff), &styles) } // ROIndicator returns an icon showing whether the session is in readonly mode or not. func ROIndicator(ro, noIC bool) string { switch { case noIC: return "" case ro: return lockedIC default: return unlockedIC } } ================================================ FILE: internal/ui/table_helper.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "fmt" "log/slog" "os" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/labels" ) const ( // DefaultColorName indicator to keep term colors. DefaultColorName = "default" // SearchFmt represents a filter view title. SearchFmt = "<[filter:bg:r]/%s[fg:bg:-]> " // NSTitleFmt represents a namespaced view title. NSTitleFmt = " [fg:bg:b]%s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-][[count:bg:b]%s[fg:bg:-]][fg:bg:-] " // TitleFmt represents a standard view title. TitleFmt = " [fg:bg:b]%s[fg:bg:-][[count:bg:b]%s[fg:bg:-]][fg:bg:-] " descIndicator = "↓" ascIndicator = "↑" // FullFmat specifies a namespaced dump file name. FullFmat = "%s-%s-%d.csv" // NoNSFmat specifies a cluster wide dump file name. NoNSFmat = "%s-%d.csv" ) func mustExtractStyles(ctx context.Context) *config.Styles { styles, ok := ctx.Value(internal.KeyStyles).(*config.Styles) if !ok { slog.Error("Expecting valid styles. Exiting!") os.Exit(1) } return styles } // TrimCell removes superfluous padding. func TrimCell(tv *SelectTable, row, col int) string { c := tv.GetCell(row, col) if c == nil { slog.Error("Trim cell failed", slogs.Error, fmt.Errorf("no cell at [%d:%d]", row, col)) return "" } return strings.TrimSpace(c.Text) } // ExtractLabelSelector extracts label query. func ExtractLabelSelector(s string) (labels.Selector, error) { selStr := s if strings.Index(s, "-l") == 0 { selStr = strings.TrimSpace(s[2:]) } return labels.Parse(selStr) } // SkinTitle decorates a title. func SkinTitle(fmat string, style *config.Frame) string { bgColor := style.Title.BgColor if bgColor == config.DefaultColor { bgColor = config.TransparentColor } fmat = strings.ReplaceAll(fmat, "[fg:bg", "["+style.Title.FgColor.String()+":"+bgColor.String()) fmat = strings.Replace(fmat, "[hilite", "["+style.Title.HighlightColor.String(), 1) fmat = strings.Replace(fmat, "[key", "["+style.Menu.NumKeyColor.String(), 1) fmat = strings.Replace(fmat, "[filter", "["+style.Title.FilterColor.String(), 1) fmat = strings.Replace(fmat, "[count", "["+style.Title.CounterColor.String(), 1) fmat = strings.ReplaceAll(fmat, ":bg:", ":"+bgColor.String()+":") return fmat } func columnIndicator(sort, selected, asc bool, style *config.Table, name string) string { // Build the column name with selection indicator var displayName string if selected { displayName = fmt.Sprintf("[%s::]%s[::]", style.Header.SelectedSortColumnColor, name) } else { displayName = fmt.Sprintf("[%s::]%s[::]", style.Header.FgColor, name) } // Add sort indicator if this column is sorted suffix := "" if sort { order := descIndicator if asc { order = ascIndicator } suffix = fmt.Sprintf("[%s::b]%s[::]", style.Header.SorterColor, order) } return displayName + suffix } func formatCell(field string, padding int) string { if IsASCII(field) { return Pad(field, padding) } return field } ================================================ FILE: internal/ui/table_helper_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "testing" "github.com/derailed/k9s/internal/render" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/labels" ) func TestTruncate(t *testing.T) { uu := map[string]struct { s, e string }{ "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.i…", }, "less": { s: "app=fred,env=blee", e: "app=fred,env=blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, render.Truncate(u.s, 50)) }) } } func TestExtractLabelSelector(t *testing.T) { sel, _ := labels.Parse("app=fred,env=blee") uu := map[string]struct { sel string err error e labels.Selector }{ "cool": { sel: "-l app=fred,env=blee", e: sel, }, "no-space": { sel: "-lapp=fred,env=blee", e: sel, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { sel, err := ExtractLabelSelector(u.sel) assert.Equal(t, u.err, err) assert.Equal(t, u.e, sel) }) } } ================================================ FILE: internal/ui/table_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui_test import ( "context" "testing" "time" "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/model1" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) func TestTableNew(t *testing.T) { v := ui.NewTable(client.NewGVR("fred")) v.Init(makeContext()) assert.Equal(t, "fred", v.GVR().String()) } func TestTableUpdate(t *testing.T) { v := ui.NewTable(client.NewGVR("fred")) v.Init(makeContext()) data := makeTableData() cdata := v.Update(data, false) v.UpdateUI(cdata, data) assert.Equal(t, data.RowCount()+1, v.GetRowCount()) assert.Equal(t, data.HeaderCount(), v.GetColumnCount()) } func TestTableSelection(t *testing.T) { v := ui.NewTable(client.NewGVR("fred")) v.Init(makeContext()) m := new(mockModel) v.SetModel(m) data := m.Peek() cdata := v.Update(data, false) v.UpdateUI(cdata, data) v.SelectRow(1, 0, true) r := v.GetSelectedRow("r1") if r != nil { assert.Equal(t, model1.Row{ID: "r1", Fields: model1.Fields{"blee", "duh", "fred"}}, *r) } assert.Equal(t, "r1", v.GetSelectedItem()) assert.Equal(t, "blee", v.GetSelectedCell(0)) assert.Equal(t, 1, v.GetSelectedRowIndex()) assert.Equal(t, []string{"r1"}, v.GetSelectedItems()) v.ClearSelection() v.SelectFirstRow() assert.Equal(t, 1, v.GetSelectedRowIndex()) } // ---------------------------------------------------------------------------- // Helpers... type mockModel struct{} var _ ui.Tabular = &mockModel{} func (*mockModel) SetViewSetting(context.Context, *config.ViewSetting) {} func (*mockModel) SetInstance(string) {} func (*mockModel) SetLabelSelector(labels.Selector) {} func (*mockModel) GetLabelSelector() labels.Selector { return nil } func (*mockModel) Empty() bool { return false } func (*mockModel) RowCount() int { return 1 } func (*mockModel) HasMetrics() bool { return true } func (*mockModel) Peek() *model1.TableData { return makeTableData() } func (*mockModel) Refresh(context.Context) error { return nil } func (*mockModel) ClusterWide() bool { return false } func (*mockModel) GetNamespace() string { return "blee" } func (*mockModel) SetNamespace(string) {} func (*mockModel) ToggleToast() {} func (*mockModel) AddListener(model.TableListener) {} func (*mockModel) RemoveListener(model.TableListener) {} func (*mockModel) Watch(context.Context) error { return nil } func (*mockModel) Get(context.Context, string) (runtime.Object, error) { return nil, nil } func (*mockModel) InNamespace(string) bool { return true } func (*mockModel) SetRefreshRate(time.Duration) {} func (*mockModel) Delete(context.Context, string, *metav1.DeletionPropagation, dao.Grace) error { return nil } func (*mockModel) Describe(context.Context, string) (string, error) { return "", nil } func (*mockModel) ToYAML(context.Context, string) (string, error) { return "", nil } func makeTableData() *model1.TableData { return model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "A"}, model1.HeaderColumn{Name: "B"}, model1.HeaderColumn{Name: "C"}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ ID: "r1", Fields: model1.Fields{"blee", "duh", "fred"}, }, }, model1.RowEvent{ Row: model1.Row{ ID: "r2", Fields: model1.Fields{"blee", "duh", "zorg"}, }, }, ), ) } func makeContext() context.Context { ctx := context.WithValue(context.Background(), internal.KeyStyles, config.NewStyles()) ctx = context.WithValue(ctx, internal.KeyViewConfig, config.NewCustomView()) return ctx } ================================================ FILE: internal/ui/tree.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "github.com/derailed/k9s/internal/model" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // KeyListenerFunc listens to key presses. type KeyListenerFunc func() // Tree represents a tree view. type Tree struct { *tview.TreeView actions *KeyActions selectedItem string cmdBuff *model.FishBuff expandNodes bool Count int keyListener KeyListenerFunc } // NewTree returns a new view. func NewTree() *Tree { return &Tree{ TreeView: tview.NewTreeView(), expandNodes: true, actions: NewKeyActions(), cmdBuff: model.NewFishBuff('/', model.FilterBuffer), } } // Init initializes the view. func (t *Tree) Init(context.Context) error { t.BindKeys() t.SetBorder(true) t.SetBorderAttributes(tcell.AttrBold) t.SetBorderPadding(0, 0, 1, 1) t.SetGraphics(true) t.SetGraphicsColor(tcell.ColorCadetBlue) t.SetInputCapture(t.keyboard) return nil } // SetSelectedItem sets the currently selected node. func (t *Tree) SetSelectedItem(s string) { t.selectedItem = s } // GetSelectedItem returns the currently selected item or blank if none. func (t *Tree) GetSelectedItem() string { return t.selectedItem } // ExpandNodes returns true if nodes are expanded or false otherwise. func (t *Tree) ExpandNodes() bool { return t.expandNodes } // CmdBuff returns the filter command. func (t *Tree) CmdBuff() *model.FishBuff { return t.cmdBuff } // SetKeyListenerFn sets a key entered listener. func (t *Tree) SetKeyListenerFn(f KeyListenerFunc) { t.keyListener = f } // Actions returns active menu bindings. func (t *Tree) Actions() *KeyActions { return t.actions } // Hints returns the view hints. func (t *Tree) Hints() model.MenuHints { return t.actions.Hints() } // ExtraHints returns additional hints. func (*Tree) ExtraHints() map[string]string { return nil } // BindKeys binds default mnemonics. func (t *Tree) BindKeys() { t.Actions().Merge(NewKeyActionsFromMap(KeyMap{ KeySpace: NewKeyAction("Expand/Collapse", t.noopCmd, true), KeyX: NewKeyAction("Expand/Collapse All", t.toggleCollapseCmd, true), })) } func (t *Tree) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := t.actions.Get(AsKey(evt)); ok { return a.Action(evt) } return evt } func (*Tree) noopCmd(evt *tcell.EventKey) *tcell.EventKey { return evt } func (t *Tree) toggleCollapseCmd(*tcell.EventKey) *tcell.EventKey { t.expandNodes = !t.expandNodes t.GetRoot().Walk(func(node, parent *tview.TreeNode) bool { if parent != nil { node.SetExpanded(t.expandNodes) } return true }) return nil } // ClearSelection clears the currently selected node. func (t *Tree) ClearSelection() { t.selectedItem = "" t.SetCurrentNode(nil) } ================================================ FILE: internal/ui/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package ui import ( "context" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) const ( unlockedIC = "[RW]" lockedIC = "[R]" ) // Namespaceable tracks namespaces. type Namespaceable interface { // ClusterWide returns true if the model represents resource in all namespaces. ClusterWide() bool // GetNamespace returns the model namespace. GetNamespace() string // SetNamespace changes the model namespace. SetNamespace(string) // InNamespace check if current namespace matches models. InNamespace(string) bool } // Lister tracks resource getter. type Lister interface { // Get returns a resource instance. Get(ctx context.Context, path string) (runtime.Object, error) } // Tabular represents a tabular model. type Tabular interface { Namespaceable Lister // SetInstance sets parent resource path. SetInstance(string) // SetLabelSelector sets the label selector. SetLabelSelector(labels.Selector) // GetLabelSelector fetch the label filter. GetLabelSelector() labels.Selector // Empty returns true if model has no data. Empty() bool // RowCount returns the model data count. RowCount() int // Peek returns current model data. Peek() *model1.TableData // Watch watches a given resource for changes. Watch(context.Context) error // Refresh forces a new refresh. Refresh(context.Context) error // SetRefreshRate sets the model watch loop rate. SetRefreshRate(time.Duration) // AddListener registers a model listener. AddListener(model.TableListener) // RemoveListener unregister a model listener. RemoveListener(model.TableListener) // Delete a resource. Delete(context.Context, string, *metav1.DeletionPropagation, dao.Grace) error // SetViewSetting injects custom cols specification. SetViewSetting(context.Context, *config.ViewSetting) } ================================================ FILE: internal/view/actions.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "fmt" "log/slog" "slices" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/util/sets" ) // AllScopes represents actions available for all views. const AllScopes = "all" // Runner represents a runnable action handler. type Runner interface { // App returns the current app. App() *App // GetSelectedItem returns the current selected item. GetSelectedItem() string // Aliases returns all aliases assoxciated with the view GVR. Aliases() sets.Set[string] // EnvFn returns the current environment function. EnvFn() EnvFunc } func hasAll(scopes []string) bool { return slices.Contains(scopes, AllScopes) } func includes(aliases []string, s string) bool { return slices.Contains(aliases, s) } func inScope(scopes []string, aliases sets.Set[string]) bool { if hasAll(scopes) { return true } for _, s := range scopes { if _, ok := aliases[s]; ok { return ok } } return false } func hotKeyActions(r Runner, aa *ui.KeyActions) error { hh := config.NewHotKeys() aa.Range(func(k tcell.Key, a ui.KeyAction) { if a.Opts.HotKey { aa.Delete(k) } }) var errs error if err := hh.Load(r.App().Config.ContextHotkeysPath()); err != nil { errs = errors.Join(errs, err) } for k, hk := range hh.HotKey { key, err := asKey(hk.ShortCut) if err != nil { errs = errors.Join(errs, err) continue } if _, ok := aa.Get(key); ok { if !hk.Override { errs = errors.Join(errs, fmt.Errorf("duplicate hotkey found for %q in %q", hk.ShortCut, k)) continue } slog.Debug("HotKey overrode action shortcut", slogs.Shortcut, hk.ShortCut, slogs.Key, k, ) } command, err := r.EnvFn()().Substitute(hk.Command) if err != nil { slog.Warn("Invalid shortcut command", slogs.Error, err) continue } aa.Add(key, ui.NewKeyActionWithOpts( hk.Description, gotoCmd(r, command, "", !hk.KeepHistory), ui.ActionOpts{ Shared: true, HotKey: true, }, )) } return errs } func gotoCmd(r Runner, cmd, path string, clearStack bool) ui.ActionHandler { return func(*tcell.EventKey) *tcell.EventKey { r.App().gotoResource(cmd, path, clearStack, true) return nil } } func pluginActions(r Runner, aa *ui.KeyActions) error { // Skip plugin loading if no valid connection if r.App().Conn() == nil || !r.App().Conn().ConnectionOK() { return nil } aa.Range(func(k tcell.Key, a ui.KeyAction) { if a.Opts.Plugin { aa.Delete(k) } }) path, err := r.App().Config.ContextPluginsPath() if err != nil { return err } pp := config.NewPlugins() if err := pp.Load(path, true); err != nil { return err } var ( errs error aliases = r.Aliases() ro = r.App().Config.IsReadOnly() ) for k := range pp.Plugins { if !inScope(pp.Plugins[k].Scopes, aliases) || (ro && pp.Plugins[k].Dangerous) { continue } key, err := asKey(pp.Plugins[k].ShortCut) if err != nil { errs = errors.Join(errs, err) continue } if _, ok := aa.Get(key); ok { if !pp.Plugins[k].Override { errs = errors.Join(errs, fmt.Errorf("duplicate plugin key found for %q in %q", pp.Plugins[k].ShortCut, k)) continue } slog.Debug("Plugin overrode action shortcut", slogs.Plugin, k, slogs.Key, pp.Plugins[k].ShortCut, ) } plugin := pp.Plugins[k] aa.Add(key, ui.NewKeyActionWithOpts( pp.Plugins[k].Description, pluginAction(r, &plugin), ui.ActionOpts{ Visible: true, Plugin: true, Dangerous: plugin.Dangerous, }, )) } return errs } func pluginAction(r Runner, p *config.Plugin) ui.ActionHandler { return func(evt *tcell.EventKey) *tcell.EventKey { path := r.GetSelectedItem() if path == "" { return evt } if r.EnvFn() == nil { return nil } // Collect inputs if defined, then execute plugin if len(p.Inputs) > 0 { d := r.App().Styles.Dialog() dialog.ShowPluginInputs(&d, r.App().Content.Pages, "Plugin Inputs", p.Inputs, func(msg string) { r.App().Flash().Warn(msg) }, func(inputValues dialog.PluginInputValues) { executePlugin(r, p, inputValues) }, func() {}, ) return nil } executePlugin(r, p, nil) return nil } } func executePlugin(r Runner, p *config.Plugin, inputValues dialog.PluginInputValues) { // Get base environment and add input values with INPUT_ prefix env := r.EnvFn()() for name, value := range inputValues { env["INPUT_"+strings.ToUpper(name)] = value } args := make([]string, len(p.Args)) for i, a := range p.Args { arg, err := env.Substitute(a) if err != nil { slog.Error("Plugin Args match failed", slogs.Error, err) return } args[i] = arg } cb := func() { opts := shellOpts{ binary: p.Command, background: p.Background, pipes: p.Pipes, args: args, } suspend, errChan, statusChan := run(r.App(), &opts) if !suspend { r.App().Flash().Infof("Plugin command failed: %q", p.Description) return } var errs error for e := range errChan { errs = errors.Join(errs, e) } if errs != nil { if !strings.Contains(errs.Error(), "signal: interrupt") { slog.Error("Plugin command failed", slogs.Error, errs) r.App().cowCmd(errs.Error()) return } } go func() { for st := range statusChan { if !p.OverwriteOutput { r.App().Flash().Infof("Plugin command launched successfully: %q", st) } else if strings.Contains(st, outputPrefix) { infoMsg := strings.TrimPrefix(st, outputPrefix) r.App().Flash().Info(strings.TrimSpace(infoMsg)) return } } }() } if p.ShouldConfirm() { msg := fmt.Sprintf("Run?\n%s %s", p.Command, strings.Join(args, " ")) d := r.App().Styles.Dialog() dialog.ShowConfirm(&d, r.App().Content.Pages, "Confirm "+p.Description, msg, cb, func() {}) return } cb() } ================================================ FILE: internal/view/actions_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "log/slog" "testing" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/util/sets" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestHasAll(t *testing.T) { uu := map[string]struct { scopes []string e bool }{ "empty": {}, "all": { scopes: []string{"blee", "duh", AllScopes}, e: true, }, "none": { scopes: []string{"blee", "duh", "alla"}, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, hasAll(u.scopes)) }) } } func TestIncludes(t *testing.T) { uu := map[string]struct { s string ss []string e bool }{ "empty": {}, "yes": { s: "blee", ss: []string{"yo", "duh", "blee"}, e: true, }, "no": { s: "blue", ss: []string{"yo", "duh", "blee"}, }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, includes(u.ss, u.s)) }) } } func TestInScope(t *testing.T) { uu := map[string]struct { ss []string aa sets.Set[string] e bool }{ "empty": {}, "yes": { e: true, ss: []string{"blee", "duh", "fred"}, aa: sets.New("blee", "fred", "duh"), }, "no": { ss: []string{"blee", "duh", "fred"}, aa: sets.New("blee1", "fred1"), }, "no-scopes": { aa: sets.New("aa", "blee1", "fred1"), }, "no-aliases": { ss: []string{"blee1", "fred1"}, }, "all": { e: true, ss: []string{AllScopes}, aa: sets.New("blee1", "fred1"), }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, inScope(u.ss, u.aa)) }) } } ================================================ FILE: internal/view/alias.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) const aliasTitle = "Aliases" // Alias represents a command alias view. type Alias struct { ResourceViewer } // NewAlias returns a new alias view. func NewAlias(gvr *client.GVR) ResourceViewer { a := Alias{ ResourceViewer: NewBrowser(gvr), } a.GetTable().SetBorderFocusColor(tcell.ColorAliceBlue) a.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorAliceBlue).Attributes(tcell.AttrNone)) a.AddBindKeysFn(a.bindKeys) a.SetContextFn(a.aliasContext) return &a } // Init initializes the view. func (a *Alias) Init(ctx context.Context) error { if err := a.ResourceViewer.Init(ctx); err != nil { return err } a.GetTable().GetModel().SetNamespace(client.NotNamespaced) return nil } func (a *Alias) aliasContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyAliases, a.App().command.alias) } func (a *Alias) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, ui.KeyShiftN, ui.KeyShiftS, tcell.KeyCtrlS, tcell.KeyCtrlSpace, ui.KeySpace) aa.Delete(tcell.KeyCtrlW, tcell.KeyCtrlL) aa.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, true), ui.KeyShiftR: ui.NewKeyAction("Sort Resource", a.GetTable().SortColCmd("RESOURCE", true), false), ui.KeyShiftC: ui.NewKeyAction("Sort Command", a.GetTable().SortColCmd("COMMAND", true), false), ui.KeyShiftA: ui.NewKeyAction("Sort ApiGroup", a.GetTable().SortColCmd("API-GROUP", true), false), }) } func (a *Alias) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.GetTable().CmdBuff().IsActive() { return a.GetTable().activateCmd(evt) } path := a.GetTable().GetSelectedItem() if path == "" { return evt } a.App().gotoResource(client.NewGVR(path).String(), "", true, true) return nil } ================================================ FILE: internal/view/alias_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "context" "testing" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view" "github.com/derailed/tcell/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) func TestAliasNew(t *testing.T) { v := view.NewAlias(client.AliGVR) require.NoError(t, v.Init(makeContext(t))) assert.Equal(t, "Aliases", v.Name()) assert.Len(t, v.Hints(), 7) } func TestAliasSearch(t *testing.T) { v := view.NewAlias(client.AliGVR) require.NoError(t, v.Init(makeContext(t))) v.GetTable().SetModel(new(mockModel)) v.GetTable().Refresh() v.App().Prompt().SetModel(v.GetTable().CmdBuff()) v.App().Prompt().SendStrokes("blee") assert.Equal(t, 3, v.GetTable().GetColumnCount()) assert.Equal(t, 3, v.GetTable().GetRowCount()) } func TestAliasGoto(t *testing.T) { v := view.NewAlias(client.AliGVR) require.NoError(t, v.Init(makeContext(t))) v.GetTable().Select(0, 0) b := buffL{} v.GetTable().CmdBuff().SetActive(true) v.GetTable().CmdBuff().AddListener(&b) v.GetTable().SendKey(tcell.NewEventKey(tcell.KeyEnter, 256, tcell.ModNone)) assert.True(t, v.GetTable().CmdBuff().IsActive()) } // ---------------------------------------------------------------------------- // Helpers... type buffL struct { active int changed int } func (b *buffL) BufferChanged(string, string) { b.changed++ } func (*buffL) BufferCompleted(string, string) {} func (b *buffL) BufferActive(bool, model.BufferKind) { b.active++ } func makeContext(t testing.TB) context.Context { a := view.NewApp(mock.NewMockConfig(t)) ctx := context.WithValue(context.Background(), internal.KeyApp, a) return context.WithValue(ctx, internal.KeyStyles, a.Styles) } type mockModel struct{} var ( _ ui.Tabular = (*mockModel)(nil) _ ui.Suggester = (*mockModel)(nil) ) func (*mockModel) SetViewSetting(context.Context, *config.ViewSetting) {} func (*mockModel) CurrentSuggestion() (string, bool) { return "", false } func (*mockModel) NextSuggestion() (string, bool) { return "", false } func (*mockModel) PrevSuggestion() (string, bool) { return "", false } func (*mockModel) ClearSuggestions() {} func (*mockModel) SetInstance(string) {} func (*mockModel) SetLabelSelector(labels.Selector) {} func (*mockModel) GetLabelSelector() labels.Selector { return nil } func (*mockModel) Empty() bool { return false } func (*mockModel) RowCount() int { return 1 } func (*mockModel) HasMetrics() bool { return true } func (*mockModel) Peek() *model1.TableData { return makeTableData() } func (*mockModel) ClusterWide() bool { return false } func (*mockModel) GetNamespace() string { return "blee" } func (*mockModel) SetNamespace(string) {} func (*mockModel) ToggleToast() {} func (*mockModel) AddListener(model.TableListener) {} func (*mockModel) RemoveListener(model.TableListener) {} func (*mockModel) Watch(context.Context) error { return nil } func (*mockModel) Refresh(context.Context) error { return nil } func (*mockModel) Get(context.Context, string) (runtime.Object, error) { return nil, nil } func (*mockModel) Delete(context.Context, string, *metav1.DeletionPropagation, dao.Grace) error { return nil } func (*mockModel) Describe(context.Context, string) (string, error) { return "", nil } func (*mockModel) ToYAML(context.Context, string) (string, error) { return "", nil } func (*mockModel) InNamespace(string) bool { return true } func (*mockModel) SetRefreshRate(time.Duration) {} func makeTableData() *model1.TableData { return model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "RESOURCE"}, model1.HeaderColumn{Name: "COMMAND"}, model1.HeaderColumn{Name: "APIGROUP"}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ ID: "r1", Fields: model1.Fields{"blee", "duh", "fred"}, }, }, model1.RowEvent{ Row: model1.Row{ ID: "r2", Fields: model1.Fields{"fred", "duh", "zorg"}, }, }, ), ) } ================================================ FILE: internal/view/app.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "maps" "os" "os/signal" "sort" "strings" "sync/atomic" "syscall" "time" "github.com/cenkalti/backoff/v4" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/k9s/internal/vul" "github.com/derailed/k9s/internal/watch" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // ExitStatus indicates UI exit conditions. var ExitStatus = "" const ( splashDelay = 1 * time.Second clusterRefresh = 15 * time.Second clusterInfoWidth = 50 clusterInfoPad = 15 ) // App represents an application view. type App struct { version string *ui.App Content *PageStack command *Command factory *watch.Factory cancelFn context.CancelFunc clusterModel *model.ClusterInfo cmdHistory *model.History filterHistory *model.History conRetry int32 showHeader bool showLogo bool showCrumbs bool } // NewApp returns a K9s app instance. func NewApp(cfg *config.Config) *App { a := App{ App: ui.NewApp(cfg, cfg.K9s.ActiveContextName()), cmdHistory: model.NewHistory(model.MaxHistory), filterHistory: model.NewHistory(model.MaxHistory), Content: NewPageStack(), } a.ReloadStyles() a.Views()["statusIndicator"] = ui.NewStatusIndicator(a.App, a.Styles) a.Views()["clusterInfo"] = NewClusterInfo(&a) return &a } // ReloadStyles reloads skin file. func (a *App) ReloadStyles() { a.RefreshStyles(a) } // UpdateClusterInfo updates clusterInfo panel func (a *App) UpdateClusterInfo() { if a.factory != nil { a.clusterModel.Reset(a.factory) } } // ConOK checks the connection is cool, returns false otherwise. func (a *App) ConOK() bool { return atomic.LoadInt32(&a.conRetry) == 0 } // Init initializes the application. func (a *App) Init(version string, _ int) error { a.version = model.NormalizeVersion(version) ctx := context.WithValue(context.Background(), internal.KeyApp, a) if err := a.Content.Init(ctx); err != nil { return err } a.Content.AddListener(a.Crumbs()) a.Content.AddListener(a.Menu()) a.App.Init() a.SetInputCapture(a.keyboard) a.bindKeys() // Allow initialization even without a valid connection // We'll fall back to context view in defaultCmd if a.Conn() != nil { ns := a.Config.ActiveNamespace() a.factory = watch.NewFactory(a.Conn()) a.initFactory(ns) a.clusterModel = model.NewClusterInfo(a.factory, a.version, a.Config.K9s) a.clusterModel.AddListener(a.clusterInfo()) a.clusterModel.AddListener(a.statusIndicator()) if a.Conn().ConnectionOK() { go func() { a.clusterModel.Refresh() a.QueueUpdateDraw(func() { a.clusterInfo().Init() }) }() } } a.command = NewCommand(a) if err := a.command.Init(a.Config.ContextAliasesPath()); err != nil { return err } a.CmdBuff().SetSuggestionFn(a.suggestCommand()) a.layout(ctx) a.initSignals() if a.Config.K9s.ImageScans.Enable { a.initImgScanner(version) } a.ReloadStyles() return nil } func (*App) stopImgScanner() { if vul.ImgScanner != nil { vul.ImgScanner.Stop() } } func (a *App) clearHistory() { a.cmdHistory.Clear() a.filterHistory.Clear() } func (a *App) initImgScanner(version string) { defer func(t time.Time) { slog.Debug("Scanner init time", slogs.Elapsed, time.Since(t)) }(time.Now()) vul.ImgScanner = vul.NewImageScanner(a.Config.K9s.ImageScans, slog.Default()) go vul.ImgScanner.Init("k9s", version) } func (a *App) layout(ctx context.Context) { flash := ui.NewFlash(a.App) go flash.Watch(ctx, a.Flash().Channel()) main := tview.NewFlex().SetDirection(tview.FlexRow) main.AddItem(a.statusIndicator(), 1, 1, false) main.AddItem(a.Content, 0, 10, true) if !a.Config.K9s.IsCrumbsless() { main.AddItem(a.Crumbs(), 1, 1, false) } main.AddItem(flash, 1, 1, false) a.Main.AddPage("main", main, true, false) a.toggleHeader(!a.Config.K9s.IsHeadless(), !a.Config.K9s.IsLogoless()) if !a.Config.K9s.IsSplashless() { a.Main.AddPage("splash", ui.NewSplash(a.Styles, a.version), true, true) } } func (*App) initSignals() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGHUP) go func(sig chan os.Signal) { <-sig os.Exit(0) }(sig) } func (a *App) suggestCommand() model.SuggestionFunc { contextNames, err := a.contextNames() if err != nil { slog.Error("Failed to list contexts", slogs.Error, err) } return func(s string) (entries sort.StringSlice) { if s == "" { if a.cmdHistory.Empty() { return } return a.cmdHistory.List() } ls := strings.ToLower(s) for alias := range maps.Keys(a.command.alias.Alias) { if suggest, ok := cmd.ShouldAddSuggest(ls, alias); ok { entries = append(entries, suggest) } } namespaceNames, err := a.factory.Client().ValidNamespaceNames() if err != nil { slog.Error("Failed to obtain list of namespaces", slogs.Error, err) } entries = append(entries, cmd.SuggestSubCommand(s, namespaceNames, contextNames)...) if len(entries) == 0 { return nil } entries.Sort() return } } func (a *App) contextNames() ([]string, error) { // Return empty list if no factory if a.factory == nil { return []string{}, nil } contexts, err := a.factory.Client().Config().Contexts() if err != nil { return nil, err } contextNames := make([]string, 0, len(contexts)) for ctxName := range contexts { contextNames = append(contextNames, ctxName) } return contextNames, nil } func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey { if k, ok := a.HasAction(ui.AsKey(evt)); ok && !a.Content.IsTopDialog() { return k.Action(evt) } return evt } func (a *App) bindKeys() { a.AddActions(ui.NewKeyActionsFromMap(ui.KeyMap{ tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false), tcell.KeyCtrlG: ui.NewSharedKeyAction("ToggleCrumbs", a.toggleCrumbsCmd, false), ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false), ui.KeyLeftBracket: ui.NewSharedKeyAction("Go Back", a.previousCommand, false), ui.KeyRightBracket: ui.NewSharedKeyAction("Go Forward", a.nextCommand, false), ui.KeyDash: ui.NewSharedKeyAction("Last View", a.lastCommand, false), tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false), tcell.KeyCtrlC: ui.NewKeyAction("Quit", a.quitCmd, false), })) } // ActiveView returns the currently active view. func (a *App) ActiveView() model.Component { return a.Content.GetPrimitive("main").(model.Component) } func (a *App) toggleHeader(header, logo bool) { a.showHeader, a.showLogo = header, logo flex, ok := a.Main.GetPrimitive("main").(*tview.Flex) if !ok { slog.Error("Expecting flex view main panel. Exiting!") os.Exit(1) } if a.showHeader { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false) } else { flex.RemoveItemAtIndex(0) flex.AddItemAtIndex(0, a.statusIndicator(), 1, 1, false) } } func (a *App) toggleCrumbs(flag bool) { a.showCrumbs = flag flex, ok := a.Main.GetPrimitive("main").(*tview.Flex) if !ok { slog.Error("Expecting valid flex view main panel. Exiting!") os.Exit(1) } if a.showCrumbs { if _, ok := flex.ItemAt(2).(*ui.Crumbs); !ok { flex.AddItemAtIndex(2, a.Crumbs(), 1, 1, false) } } else { flex.RemoveItemAtIndex(2) } } func (a *App) buildHeader() tview.Primitive { header := tview.NewFlex() header.SetBackgroundColor(a.Styles.BgColor()) header.SetDirection(tview.FlexColumn) if !a.showHeader { return header } clWidth := clusterInfoWidth if a.Conn() != nil && a.Conn().ConnectionOK() { n, err := a.Conn().Config().CurrentClusterName() if err == nil { size := len(n) + clusterInfoPad if size > clWidth { clWidth = size } } } header.AddItem(a.clusterInfo(), clWidth, 1, false) header.AddItem(a.Menu(), 0, 1, false) if a.showLogo { header.AddItem(a.Logo(), 26, 1, false) } return header } // Halt stop the application event loop. func (a *App) Halt() { if a.cancelFn != nil { a.cancelFn() a.cancelFn = nil } } // Resume restarts the app event loop. func (a *App) Resume() { var ctx context.Context ctx, a.cancelFn = context.WithCancel(context.Background()) go a.clusterUpdater(ctx) if a.Config.K9s.UI.Reactive { if err := a.ConfigWatcher(ctx, a); err != nil { slog.Warn("ConfigWatcher failed", slogs.Error, err) } if err := a.SkinsDirWatcher(ctx, a); err != nil { slog.Warn("SkinsWatcher failed", slogs.Error, err) } if err := a.CustomViewsWatcher(ctx, a); err != nil { slog.Warn("CustomView watcher failed", slogs.Error, err) } } } func (a *App) clusterUpdater(ctx context.Context) { if a.Conn() == nil || !a.Conn().ConnectionOK() || a.factory == nil || a.clusterModel == nil { slog.Debug("Skipping cluster updater - no valid connection") return } if err := a.refreshCluster(ctx); err != nil { slog.Error("Cluster updater failed!", slogs.Error, err) return } bf := model.NewExpBackOff(ctx, clusterRefresh, 2*time.Minute) delay := clusterRefresh for { select { case <-ctx.Done(): slog.Debug("ClusterInfo updater canceled!") return case <-time.After(delay): if err := a.refreshCluster(ctx); err != nil { slog.Error("Cluster updates failed. Giving up ;(", slogs.Error, err) if delay = bf.NextBackOff(); delay == backoff.Stop { a.BailOut(1) return } } else { bf.Reset() delay = clusterRefresh } } } } func (a *App) refreshCluster(context.Context) error { if a.Conn() == nil || a.factory == nil || a.clusterModel == nil { return nil } c := a.Content.Top() if ok := a.Conn().CheckConnectivity(); ok { if atomic.LoadInt32(&a.conRetry) > 0 { atomic.StoreInt32(&a.conRetry, 0) a.Status(model.FlashInfo, "K8s connectivity OK") if c != nil { c.Start() } } else { a.ClearStatus(true) } a.factory.ValidatePortForwards() } else if c != nil { atomic.AddInt32(&a.conRetry, 1) c.Stop() } count, maxConnRetry := atomic.LoadInt32(&a.conRetry), a.Config.K9s.MaxConnRetry if count >= maxConnRetry { slog.Error("Conn check failed. Bailing out!", slogs.Retry, count, slogs.MaxRetries, maxConnRetry, ) ExitStatus = fmt.Sprintf("Lost K8s connection (%d). Bailing out!", count) a.BailOut(1) } if count > 0 { a.Status(model.FlashWarn, fmt.Sprintf("Dial K8s Toast [%d/%d]", count, maxConnRetry)) return fmt.Errorf("conn check failed (%d/%d)", count, maxConnRetry) } // Reload alias go func() { if err := a.command.Reset(a.Config.ContextAliasesPath(), false); err != nil { slog.Warn("Command reset failed", slogs.Error, err) a.QueueUpdateDraw(func() { a.Logo().Warn("Aliases load failed!") }) } }() // Update cluster info a.clusterModel.Refresh() return nil } func (a *App) switchNS(ns string) error { if a.Config.ActiveNamespace() == ns { return nil } if ns == client.ClusterScope { ns = client.BlankNamespace } if err := a.Config.SetActiveNamespace(ns); err != nil { return err } return a.factory.SetActiveNS(ns) } func (a *App) switchContext(ci *cmd.Interpreter, force bool) error { contextName, ok := ci.HasContext() if (!ok || a.Config.ActiveContextName() == contextName) && !force { return nil } a.Halt() defer a.Resume() { a.Config.Reset() ct, err := a.Config.ActivateContext(contextName) if err != nil { return err } if cns, ok := ci.NSArg(); ok { ct.Namespace.Active = cns } p := cmd.NewInterpreter(a.Config.ActiveView()) p.ResetContextArg() if p.IsContextCmd() { a.Config.SetActiveView(client.PodGVR.String()) } ns := a.Config.ActiveNamespace() if !a.Conn().IsValidNamespace(ns) { slog.Warn("Unable to validate namespace", slogs.Namespace, ns) if err := a.Config.SetActiveNamespace(ns); err != nil { return err } } a.Flash().Infof("Using %q namespace", ns) if err := a.Config.Save(true); err != nil { slog.Error("Fail to save config to disk", slogs.Subsys, "config", slogs.Error, err) } if a.factory == nil && a.Conn() != nil { a.factory = watch.NewFactory(a.Conn()) a.clusterModel = model.NewClusterInfo(a.factory, a.version, a.Config.K9s) a.clusterModel.AddListener(a.clusterInfo()) a.clusterModel.AddListener(a.statusIndicator()) } if a.factory != nil { a.initFactory(ns) } if err := a.command.Reset(a.Config.ContextAliasesPath(), true); err != nil { return err } slog.Debug("Switching Context", slogs.Context, contextName, slogs.Namespace, ns, slogs.View, a.Config.ActiveView(), ) a.Flash().Infof("Switching context to %q::%q", contextName, ns) a.ReloadStyles() a.gotoResource(a.Config.ActiveView(), "", true, true) if a.clusterModel != nil { go a.clusterModel.Reset(a.factory) } } return nil } func (a *App) initFactory(ns string) { a.factory.Terminate() a.factory.Start(ns) } // BailOut exists the application. func (a *App) BailOut(exitCode int) { defer func() { if err := recover(); err != nil { slog.Error("Bailout failed", slogs.Error, err) } }() if err := nukeK9sShell(a); err != nil { slog.Error("Unable to nuke k9s shell pod", slogs.Error, err) } a.stopImgScanner() a.factory.Terminate() a.App.BailOut(exitCode) } // Run starts the application loop. func (a *App) Run() error { a.Resume() go func() { if !a.Config.K9s.IsSplashless() { <-time.After(splashDelay) } a.QueueUpdateDraw(func() { a.Main.SwitchToPage("main") // if command bar is already active, focus it if a.CmdBuff().IsActive() { a.SetFocus(a.Prompt()) } }) }() if err := a.command.defaultCmd(true); err != nil { return err } a.SetRunning(true) if err := a.Application.Run(); err != nil { return err } return nil } // Status reports a new app status for display. func (a *App) Status(l model.FlashLevel, msg string) { a.QueueUpdateDraw(func() { if a.showHeader { a.setLogo(l, msg) } else { a.setIndicator(l, msg) } }) } // IsBenchmarking check if benchmarks are active. func (a *App) IsBenchmarking() bool { return a.Logo().IsBenchmarking() } // ClearStatus reset logo back to normal. func (a *App) ClearStatus(flash bool) { a.QueueUpdate(func() { a.Logo().Reset() if flash { a.Flash().Clear() } }) } func (a *App) setLogo(l model.FlashLevel, msg string) { switch l { case model.FlashErr: a.Logo().Err(msg) case model.FlashWarn: a.Logo().Warn(msg) case model.FlashInfo: a.Logo().Info(msg) default: a.Logo().Reset() } } func (a *App) setIndicator(l model.FlashLevel, msg string) { switch l { case model.FlashErr: a.statusIndicator().Err(msg) case model.FlashWarn: a.statusIndicator().Warn(msg) case model.FlashInfo: a.statusIndicator().Info(msg) default: a.statusIndicator().Reset() } } // PrevCmd pops the command stack. func (a *App) PrevCmd(*tcell.EventKey) *tcell.EventKey { if !a.Content.IsLast() { a.Content.Pop() } return nil } func (a *App) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey { if a.Prompt().InCmdMode() { return evt } a.QueueUpdateDraw(func() { a.showHeader = !a.showHeader a.toggleHeader(a.showHeader, a.showLogo) }) return nil } func (a *App) toggleCrumbsCmd(evt *tcell.EventKey) *tcell.EventKey { if a.Prompt().InCmdMode() { return evt } a.QueueUpdateDraw(func() { a.showCrumbs = !a.showCrumbs a.toggleCrumbs(a.showCrumbs) }) return nil } func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() { a.gotoResource(a.GetCmd(), "", true, true) a.ResetCmd() return nil } return evt } func (a *App) cowCmd(msg string) { d := a.Styles.Dialog() dialog.ShowError(&d, a.Content.Pages, msg) } func (a *App) dirCmd(path string, pushCmd bool) error { slog.Debug("Exec Dir command", slogs.Path, path) _, err := os.Stat(path) if err != nil { return err } if path == "." { dir, err := os.Getwd() if err == nil { path = dir } } if pushCmd { a.cmdHistory.Push("dir " + path) } return a.inject(NewDir(path), true) } func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey { noExit := a.Config.K9s.NoExitOnCtrlC if a.InCmdMode() { if isBailoutEvt(evt) && noExit { return nil } return evt } if !noExit { a.BailOut(0) } return nil } func (a *App) helpCmd(evt *tcell.EventKey) *tcell.EventKey { if evt != nil && evt.Rune() == '?' && a.Prompt().InCmdMode() { return evt } top := a.Content.Top() if top != nil && top.Name() == "help" { a.Content.Pop() return nil } if err := a.inject(NewHelp(a), false); err != nil { a.Flash().Err(err) } a.Prompt().Deactivate() return nil } // previousCommand returns to the command prior to the current one in the history func (a *App) previousCommand(evt *tcell.EventKey) *tcell.EventKey { if evt != nil && evt.Rune() == rune(ui.KeyLeftBracket) && a.Prompt().InCmdMode() { return evt } c, ok := a.cmdHistory.Back() if !ok { a.App.Flash().Warn("Can't go back any further") return evt } a.gotoResource(c, "", true, false) return nil } // nextCommand returns to the command subsequent to the current one in the history func (a *App) nextCommand(evt *tcell.EventKey) *tcell.EventKey { if evt != nil && evt.Rune() == rune(ui.KeyRightBracket) && a.Prompt().InCmdMode() { return evt } c, ok := a.cmdHistory.Forward() if !ok { a.App.Flash().Warn("Can't go forward any further") return evt } // We go to the resource before updating the history so that // gotoResource doesn't add this command to the history a.gotoResource(c, "", true, false) return nil } // lastCommand switches between the last command and the current one a la `cd -` func (a *App) lastCommand(evt *tcell.EventKey) *tcell.EventKey { if evt != nil && evt.Rune() == ui.KeyDash && a.Prompt().InCmdMode() { return evt } c, ok := a.cmdHistory.Top() if !ok { a.App.Flash().Warn("No previous view to switch to") return evt } a.gotoResource(c, "", true, false) return nil } func (a *App) aliasCmd(*tcell.EventKey) *tcell.EventKey { if a.Content.Top() != nil && a.Content.Top().Name() == aliasTitle { a.Content.Pop() return nil } if err := a.inject(NewAlias(client.AliGVR), false); err != nil { a.Flash().Err(err) } return nil } func (a *App) gotoResource(c, path string, clearStack, pushCmd bool) { err := a.command.run(cmd.NewInterpreter(c), path, clearStack, pushCmd) if err != nil { d := a.Styles.Dialog() dialog.ShowError(&d, a.Content.Pages, err.Error()) } } func (a *App) inject(c model.Component, clearStack bool) error { ctx := context.WithValue(context.Background(), internal.KeyApp, a) if err := c.Init(ctx); err != nil { slog.Error("Component init failed", slogs.Error, err, slogs.CompName, c.Name(), ) return err } if clearStack { a.Content.Clear() } a.Content.Push(c) return nil } func (a *App) clusterInfo() *ClusterInfo { return a.Views()["clusterInfo"].(*ClusterInfo) } func (a *App) statusIndicator() *ui.StatusIndicator { return a.Views()["statusIndicator"].(*ui.StatusIndicator) } ================================================ FILE: internal/view/app_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" ) func TestAppNew(t *testing.T) { a := view.NewApp(mock.NewMockConfig(t)) _ = a.Init("blee", 10) assert.Equal(t, 14, a.GetActions().Len()) } ================================================ FILE: internal/view/benchmark.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "log/slog" "os" "path/filepath" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // Benchmark represents a service benchmark results view. type Benchmark struct { ResourceViewer } // NewBenchmark returns a new viewer. func NewBenchmark(gvr *client.GVR) ResourceViewer { b := Benchmark{ ResourceViewer: NewBrowser(gvr), } b.GetTable().SetBorderFocusColor(tcell.ColorSeaGreen) b.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorSeaGreen).Attributes(tcell.AttrNone)) b.GetTable().SetSortCol(ageCol, true) b.SetContextFn(b.benchContext) b.GetTable().SetEnterFn(b.viewBench) return &b } func (b *Benchmark) benchContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyDir, benchDir(b.App().Config)) } func (b *Benchmark) viewBench(app *App, _ ui.Tabular, _ *client.GVR, path string) { mdata, err := readBenchFile(app.Config, b.benchFile()) if err != nil { app.Flash().Errf("Unable to load bench file %s", err) return } details := NewDetails(b.App(), "Results", fileToSubject(path), contentYAML, false).Update(mdata) if err := app.inject(details, false); err != nil { app.Flash().Err(err) } } func (b *Benchmark) benchFile() string { r := b.GetTable().GetSelectedRowIndex() return ui.TrimCell(b.GetTable().SelectTable, r, 7) } // ---------------------------------------------------------------------------- // Helpers... func fileToSubject(path string) string { tokens := strings.Split(path, "/") ee := strings.Split(tokens[len(tokens)-1], "_") return ee[0] + "/" + ee[1] } func benchDir(cfg *config.Config) string { ct, err := cfg.K9s.ActiveContext() if err != nil { slog.Error("No active context located", slogs.Error, err) return render.MissingValue } return filepath.Join( config.AppBenchmarksDir, data.SanitizeFileName(ct.ClusterName), data.SanitizeFileName(cfg.K9s.ActiveContextName()), ) } func readBenchFile(cfg *config.Config, n string) (string, error) { bb, err := os.ReadFile(filepath.Join(benchDir(cfg), n)) if err != nil { return "", err } return string(bb), nil } ================================================ FILE: internal/view/browser.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" ) // Browser represents a generic resource browser. type Browser struct { *Table namespaces map[int]string meta *metav1.APIResource accessor dao.Accessor contextFn ContextFunc cancelFn context.CancelFunc mx sync.RWMutex updating bool firstView atomic.Int32 } // NewBrowser returns a new browser. func NewBrowser(gvr *client.GVR) ResourceViewer { return &Browser{ Table: NewTable(gvr), } } func (b *Browser) setUpdating(f bool) { b.mx.Lock() defer b.mx.Unlock() b.updating = f } func (b *Browser) getUpdating() bool { b.mx.RLock() defer b.mx.RUnlock() return b.updating } // SetCommand sets the current command. func (b *Browser) SetCommand(i *cmd.Interpreter) { b.GetTable().SetCommand(i) } // Init watches all running pods in given namespace. func (b *Browser) Init(ctx context.Context) error { var err error b.meta, err = dao.MetaAccess.MetaFor(b.GVR()) if err != nil { return err } colorerFn := model1.DefaultColorer if r, ok := model.Registry[b.GVR()]; ok && r.Renderer != nil { colorerFn = r.Renderer.ColorerFunc() } b.GetTable().SetColorerFn(colorerFn) if e := b.Table.Init(ctx); e != nil { return e } ns := client.CleanseNamespace(b.app.Config.ActiveNamespace()) if dao.IsK8sMeta(b.meta) && b.app.ConOK() { if _, e := b.app.factory.CanForResource(ns, b.GVR(), client.ListAccess); e != nil { return e } } if b.App().IsRunning() { b.app.CmdBuff().Reset() } b.SetReadOnly(b.app.Config.IsReadOnly()) b.SetNoIcon(b.app.Config.K9s.UI.NoIcons) b.SetFullGVR(b.app.Config.K9s.UI.UseFullGVRTitle) b.bindKeys(b.Actions()) for _, f := range b.bindKeysFn { f(b.Actions()) } b.accessor, err = dao.AccessorFor(b.app.factory, b.GVR()) if err != nil { return err } b.setNamespace(ns) row, _ := b.GetSelection() if row == 0 && b.GetRowCount() > 0 { b.Select(1, 0) } b.GetModel().SetRefreshRate(b.App().Config.K9s.RefreshDuration()) b.CmdBuff().SetSuggestionFn(b.suggestFilter()) return nil } // InCmdMode checks if prompt is active. func (b *Browser) InCmdMode() bool { return b.CmdBuff().InCmdMode() } func (b *Browser) suggestFilter() model.SuggestionFunc { return func(s string) (entries sort.StringSlice) { if s == "" { if b.App().filterHistory.Empty() { return } return b.App().filterHistory.List() } s = strings.ToLower(s) for _, h := range b.App().filterHistory.List() { if s == h { continue } if strings.HasPrefix(h, s) { entries = append(entries, strings.Replace(h, s, "", 1)) } } return } } func (b *Browser) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ tcell.KeyEscape: ui.NewSharedKeyAction("Filter Reset", b.resetCmd, false), ui.KeyQ: ui.NewSharedKeyAction("Filter Reset", b.resetCmd, false), tcell.KeyEnter: ui.NewSharedKeyAction("Filter", b.filterCmd, false), tcell.KeyHelp: ui.NewSharedKeyAction("Help", b.helpCmd, false), }) } // SetInstance sets a single instance view. func (b *Browser) SetInstance(path string) { b.GetModel().SetInstance(path) } // Start initializes browser updates. func (b *Browser) Start() { ns := b.app.Config.ActiveNamespace() if n := b.GetModel().GetNamespace(); !client.IsClusterScoped(n) { ns = n } if err := b.app.switchNS(ns); err != nil { slog.Error("Unable to switch namespace", slogs.Error, err) } b.Stop() b.firstView.Store(0) // Reset first view counter on each start b.GetModel().AddListener(b) b.Table.Start() b.CmdBuff().AddListener(b) if err := b.GetModel().Watch(b.prepareContext()); err != nil { go func() { time.Sleep(500 * time.Millisecond) b.app.QueueUpdateDraw(func() { b.App().Flash().Errf("Watcher failed for %s -- %s", b.GVR(), err) }) }() } } // Stop terminates browser updates. func (b *Browser) Stop() { b.mx.Lock() if b.cancelFn != nil { b.cancelFn() b.cancelFn = nil } b.mx.Unlock() b.GetModel().RemoveListener(b) b.CmdBuff().RemoveListener(b) b.Table.Stop() } func (b *Browser) SetFilter(s string, wipe bool) { b.CmdBuff().SetText(s, "", wipe) } func (b *Browser) SetLabelSelector(sel labels.Selector, wipe bool) { if sel != nil { b.CmdBuff().SetText(sel.String(), "", wipe) } b.GetModel().SetLabelSelector(sel) } // BufferChanged indicates the buffer was changed. func (*Browser) BufferChanged(_, _ string) {} // BufferCompleted indicates input was accepted. func (b *Browser) BufferCompleted(text, _ string) { if internal.IsLabelSelector(text) { if sel, err := ui.ExtractLabelSelector(text); err == nil { b.GetModel().SetLabelSelector(sel) } } else { b.GetModel().SetLabelSelector(labels.Everything()) } } // BufferActive indicates the buff activity changed. func (b *Browser) BufferActive(state bool, _ model.BufferKind) { if state { return } if err := b.GetModel().Refresh(b.GetContext()); err != nil { slog.Error("Model refresh failed", slogs.GVR, b.GVR(), slogs.Error, err, ) } mdata := b.GetModel().Peek() cdata := b.Update(mdata, b.App().Conn().HasMetrics()) b.app.QueueUpdateDraw(func() { if b.getUpdating() { return } b.setUpdating(true) defer b.setUpdating(false) b.UpdateUI(cdata, mdata) if b.GetRowCount() > 1 { b.App().filterHistory.Push(b.CmdBuff().GetText()) } }) } func (b *Browser) prepareContext() context.Context { ctx := b.defaultContext() b.mx.Lock() if b.cancelFn != nil { b.cancelFn() } ctx, b.cancelFn = context.WithCancel(ctx) b.mx.Unlock() if b.contextFn != nil { ctx = b.contextFn(ctx) } if path, ok := ctx.Value(internal.KeyPath).(string); ok && path != "" { b.Path = path } b.mx.Lock() b.SetContext(ctx) b.mx.Unlock() return ctx } func (b *Browser) refresh() { b.Start() } // Name returns the component name. func (b *Browser) Name() string { return b.meta.Kind } // SetContextFn populates a custom context. func (b *Browser) SetContextFn(f ContextFunc) { b.contextFn = f } // GetTable returns the underlying table. func (b *Browser) GetTable() *Table { return b.Table } // Aliases returns all available aliases. func (b *Browser) Aliases() sets.Set[string] { return aliases(b.meta, b.app.command.AliasesFor(client.NewGVRFromMeta(b.meta))) } // ---------------------------------------------------------------------------- // Model Protocol... // TableNoData notifies view no data is available. func (b *Browser) TableNoData(mdata *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 } // Skip warning on first view (likely during initialization) if b.firstView.Load() == 0 || mdata.HeaderCount() == 0 { b.firstView.Add(1) return } cdata := b.Update(mdata, 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 %q namespace", b.GVR(), client.PrintNamespace(b.GetNamespace())) } b.refreshActions() b.UpdateUI(cdata, mdata) }) } // TableDataChanged notifies view new data is available. func (b *Browser) TableDataChanged(mdata *model1.TableData) { var cancel context.CancelFunc b.mx.RLock() cancel = b.cancelFn b.mx.RUnlock() if cancel == nil || !b.app.IsRunning() { return } cdata := b.Update(mdata, b.app.Conn().HasMetrics()) b.app.QueueUpdateDraw(func() { if b.getUpdating() { return } b.setUpdating(true) defer b.setUpdating(false) if b.GetColumnCount() == 0 { 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) }) } // TableLoadFailed notifies view something went south. func (b *Browser) TableLoadFailed(err error) { b.app.QueueUpdateDraw(func() { b.app.Flash().Err(err) b.App().ClearStatus(false) }) } // ---------------------------------------------------------------------------- // Actions... func (b *Browser) nsWarpCmd(*tcell.EventKey) *tcell.EventKey { path := b.GetTable().GetSelectedItem() if path == "" { return nil } o, err := b.app.factory.Get(b.GVR(), path, true, nil) if err != nil { return nil } u, ok := o.(*unstructured.Unstructured) if !ok { return nil } b.App().gotoResource(b.GVR().String()+" "+u.GetNamespace(), "", true, true) return nil } func (b *Browser) viewCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt } v := NewLiveView(b.app, yamlAction, model.NewYAML(b.GVR(), path)) if err := v.app.inject(v, false); err != nil { v.app.Flash().Err(err) } return nil } func (b *Browser) helpCmd(evt *tcell.EventKey) *tcell.EventKey { if b.CmdBuff().InCmdMode() { return nil } return evt } func (b *Browser) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !b.CmdBuff().InCmdMode() { hasFilter := !b.CmdBuff().Empty() b.CmdBuff().ClearText(false) if hasFilter { b.GetModel().SetLabelSelector(labels.Everything()) b.Refresh() } return b.App().PrevCmd(evt) } b.CmdBuff().Reset() if internal.IsLabelSelector(b.CmdBuff().GetText()) { b.Start() } b.Refresh() return nil } func (b *Browser) filterCmd(evt *tcell.EventKey) *tcell.EventKey { if !b.CmdBuff().IsActive() { return evt } b.CmdBuff().SetActive(false) if internal.IsLabelSelector(b.CmdBuff().GetText()) { b.Start() return nil } b.Refresh() return nil } func (b *Browser) enterCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if b.filterCmd(evt) == nil || path == "" { return nil } f := describeResource if b.enterFn != nil { f = b.enterFn } f(b.app, b.GetModel(), b.GVR(), path) return nil } func (b *Browser) refreshCmd(*tcell.EventKey) *tcell.EventKey { b.app.Flash().Info("Refreshing...") b.refresh() return nil } func (b *Browser) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { selections := b.GetSelectedItems() if len(selections) == 0 { return evt } b.Stop() defer b.Start() { msg := fmt.Sprintf("Delete %s %s?", b.GVR().R(), selections[0]) if len(selections) > 1 { msg = fmt.Sprintf("Delete %d marked %s?", len(selections), b.GVR()) } if !dao.IsK8sMeta(b.meta) { b.simpleDelete(selections, msg) return nil } b.resourceDelete(selections, msg) } return nil } func (b *Browser) describeCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt } describeResource(b.app, b.GetModel(), b.GVR(), path) return nil } func (b *Browser) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := b.GetSelectedItem() if path == "" { return evt } b.Stop() defer b.Start() if err := editRes(b.app, b.GVR(), path); err != nil { b.App().Flash().Err(err) } return nil } func editRes(app *App, gvr *client.GVR, path string) error { if path == "" { return fmt.Errorf("nothing selected %q", path) } ns, n := client.Namespaced(path) if n == "" { return fmt.Errorf("missing resource name in path %q", path) } if client.IsClusterScoped(ns) { ns = client.BlankNamespace } if ok, err := app.Conn().CanI(ns, gvr, n, client.PatchAccess); !ok || err != nil { return fmt.Errorf("current user can't edit resource %s", gvr) } args := make([]string, 0, 10) args = append(args, "edit", gvr.FQN(n)) if ns != client.BlankNamespace { args = append(args, "-n", ns) } if err := runK(app, &shellOpts{clear: true, args: args}); err != nil { app.Flash().Errf("Edit command failed: %s", err) } return nil } func (b *Browser) switchNamespaceCmd(evt *tcell.EventKey) *tcell.EventKey { i, err := strconv.Atoi(string(evt.Rune())) if err != nil { slog.Error("Unable to convert keystroke", slogs.Error, err) return nil } ns := b.namespaces[i] auth, err := b.App().factory.Client().CanI(ns, b.GVR(), "", client.ListAccess) if !auth { if err == nil { err = fmt.Errorf("access denied for user on: %s/%s", ns, b.GVR()) } b.App().Flash().Err(err) return nil } if err := b.app.switchNS(ns); err != nil { b.App().Flash().Err(err) return nil } b.setNamespace(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) b.app.CmdBuff().Reset() if err := b.app.Config.SetActiveNamespace(b.GetModel().GetNamespace()); err != nil { slog.Error("Unable to set active namespace during ns switch", slogs.Error, err) } return nil } // ---------------------------------------------------------------------------- // Helpers... func (b *Browser) setNamespace(ns string) { ns = client.CleanseNamespace(ns) if b.GetModel().InNamespace(ns) { return } if !b.meta.Namespaced { ns = client.ClusterScope } b.GetModel().SetNamespace(ns) } func (b *Browser) defaultContext() context.Context { ctx := context.WithValue(context.Background(), internal.KeyFactory, b.app.factory) ctx = context.WithValue(ctx, internal.KeyGVR, b.GVR()) ctx = context.WithValue(ctx, internal.KeyPath, b.Path) if internal.IsLabelSelector(b.CmdBuff().GetText()) { if sel, err := ui.ExtractLabelSelector(b.CmdBuff().GetText()); err == nil { ctx = context.WithValue(ctx, internal.KeyLabels, sel) } } ctx = context.WithValue(ctx, internal.KeyNamespace, client.CleanseNamespace(b.App().Config.ActiveNamespace())) ctx = context.WithValue(ctx, internal.KeyWithMetrics, b.app.factory.Client().HasMetrics()) return ctx } func (b *Browser) refreshActions() { if top := b.App().Content.Top(); top != nil && top.Name() != b.Name() { return } aa := ui.NewKeyActionsFromMap(ui.KeyMap{ ui.KeyC: ui.NewKeyAction("Copy", b.cpCmd, false), tcell.KeyEnter: ui.NewKeyAction("View", b.enterCmd, false), tcell.KeyCtrlR: ui.NewKeyAction("Refresh", b.refreshCmd, false), }) if b.app.ConOK() { b.namespaceActions(aa) if !b.app.Config.IsReadOnly() { if client.Can(b.meta.Verbs, "edit") { aa.Add(ui.KeyE, ui.NewKeyActionWithOpts("Edit", b.editCmd, ui.ActionOpts{ Visible: true, Dangerous: true, })) } if client.Can(b.meta.Verbs, "delete") { aa.Add(tcell.KeyCtrlD, ui.NewKeyActionWithOpts("Delete", b.deleteCmd, ui.ActionOpts{ Visible: true, Dangerous: true, })) } } else { b.Actions().ClearDanger() } } if !dao.IsK9sMeta(b.meta) { aa.Add(ui.KeyY, ui.NewKeyAction(yamlAction, b.viewCmd, true)) aa.Add(ui.KeyD, ui.NewKeyAction("Describe", b.describeCmd, true)) } for _, f := range b.bindKeysFn { f(aa) } b.Actions().Merge(aa) if err := pluginActions(b, b.Actions()); err != nil { slog.Warn("Plugins load failed", slogs.Error, err) b.app.Logo().Warn("Plugins load failed!") } if err := hotKeyActions(b, b.Actions()); err != nil { slog.Warn("Hotkeys load failed", slogs.Error, err) b.app.Logo().Warn("HotKeys load failed!") } b.app.Menu().HydrateMenu(b.Hints()) } func (b *Browser) namespaceActions(aa *ui.KeyActions) { if !b.meta.Namespaced || b.GetTable().Path != "" { return } aa.Add(ui.KeyN, ui.NewKeyAction("Copy Namespace", b.cpNsCmd, false)) if b.meta.Namespaced { aa.Add(ui.KeyW, ui.NewKeyAction("Warp To Namespace", b.nsWarpCmd, true)) } b.namespaces = make(map[int]string, data.MaxFavoritesNS) 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 { continue } if numKey, ok := ui.NumKeys[index]; ok { aa.Add(numKey, ui.NewKeyAction(ns, b.switchNamespaceCmd, true)) b.namespaces[index] = ns index++ } else { slog.Warn("No number key available for favorite namespace. Skipping...", slogs.Namespace, ns, slogs.Index, index, slogs.Max, len(favNamespaces), ) break } } } func (b *Browser) simpleDelete(selections []string, msg string) { d := b.app.Styles.Dialog() dialog.ShowConfirm(&d, b.app.Content.Pages, "Confirm Delete", msg, func() { b.ShowDeleted() if len(selections) > 1 { b.app.Flash().Infof("Delete %d marked %s", len(selections), b.GVR().R()) } else { b.app.Flash().Infof("Delete resource %s %s", b.GVR(), selections[0]) } for _, sel := range selections { nuker, ok := b.accessor.(dao.Nuker) if !ok { b.app.Flash().Errf("Invalid nuker %T", b.accessor) continue } if err := nuker.Delete(context.Background(), sel, nil, dao.DefaultGrace); err != nil { b.app.Flash().Errf("Delete failed with `%s", err) } else { b.app.factory.DeleteForwarder(sel) } b.GetTable().DeleteMark(sel) } b.refresh() }, func() {}) } func (b *Browser) resourceDelete(selections []string, msg string) { okFn := func(propagation *metav1.DeletionPropagation, force bool) { b.ShowDeleted() if len(selections) > 1 { b.app.Flash().Infof("Delete %d marked %s", len(selections), b.GVR()) } else { b.app.Flash().Infof("Delete resource %s %s", b.GVR(), selections[0]) } for _, sel := range selections { grace := dao.DefaultGrace if force { grace = dao.ForceGrace } if err := b.GetModel().Delete(b.defaultContext(), sel, propagation, grace); err != nil { b.app.Flash().Errf("Delete failed with `%s", err) } else { b.app.factory.DeleteForwarder(sel) } b.GetTable().DeleteMark(sel) } b.refresh() } d := b.app.Styles.Dialog() dialog.ShowDelete(&d, b.app.Content.Pages, msg, okFn, func() {}) } ================================================ FILE: internal/view/cluster_info.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) var _ model.ClusterInfoListener = (*ClusterInfo)(nil) // ClusterInfo represents a cluster info view. type ClusterInfo struct { *tview.Table app *App styles *config.Styles } // NewClusterInfo returns a new cluster info view. func NewClusterInfo(app *App) *ClusterInfo { return &ClusterInfo{ Table: tview.NewTable(), app: app, styles: app.Styles, } } // Init initializes the view. func (c *ClusterInfo) Init() { c.SetBorderPadding(0, 0, 1, 0) c.app.Styles.AddListener(c) c.layout() c.StylesChanged(c.app.Styles) } // StylesChanged notifies skin changed. func (c *ClusterInfo) StylesChanged(s *config.Styles) { c.styles = s c.SetBackgroundColor(s.BgColor()) c.updateStyle() } func (c *ClusterInfo) hasMetrics() bool { mx := c.app.Conn().HasMetrics() if mx { auth, err := c.app.Conn().CanI("", client.NmxGVR, "", client.ListAccess) if err != nil { slog.Warn("No nodes metrics access", slogs.Error, err) } mx = auth } return mx } func (c *ClusterInfo) layout() { for row, section := range []string{"Context", "Cluster", "User", "K9s Rev", "K8s Rev", "CPU", "MEM"} { c.SetCell(row, 0, c.sectionCell(section)) c.SetCell(row, 1, c.infoCell(render.NAValue)) } } func (c *ClusterInfo) sectionCell(t string) *tview.TableCell { cell := tview.NewTableCell(t + ":") cell.SetAlign(tview.AlignLeft) cell.SetBackgroundColor(c.app.Styles.BgColor()) return cell } func (c *ClusterInfo) infoCell(t string) *tview.TableCell { cell := tview.NewTableCell(t) cell.SetExpansion(2) cell.SetTextColor(c.styles.K9s.Info.FgColor.Color()) cell.SetBackgroundColor(c.app.Styles.BgColor()) return cell } func (c *ClusterInfo) setCell(row int, s string) int { if s == "" { s = render.NAValue } c.GetCell(row, 1).SetText(s) return row + 1 } // ClusterInfoUpdated notifies the cluster meta was updated. func (c *ClusterInfo) ClusterInfoUpdated(data *model.ClusterMeta) { c.ClusterInfoChanged(data, data) } func (*ClusterInfo) warnCell(s string, w bool) string { if w { return fmt.Sprintf("[orangered::b]%s", s) } return s } // ClusterInfoChanged notifies the cluster meta was changed. func (c *ClusterInfo) ClusterInfoChanged(prev, curr *model.ClusterMeta) { c.app.QueueUpdateDraw(func() { c.Clear() c.layout() context := curr.Context if ic := ui.ROIndicator(c.app.Config.IsReadOnly(), c.app.Config.K9s.UI.NoIcons); ic != "" { context += " " + ic } row := c.setCell(0, context) row = c.setCell(row, curr.Cluster) row = c.setCell(row, curr.User) if curr.K9sLatest != "" { row = c.setCell(row, fmt.Sprintf("%s ⚡️[cadetblue::b]%s", curr.K9sVer, curr.K9sLatest)) } else { row = c.setCell(row, curr.K9sVer) } row = c.setCell(row, curr.K8sVer) if c.hasMetrics() { row = c.setCell(row, ui.AsPercDelta(prev.Cpu, curr.Cpu)) _ = c.setCell(row, ui.AsPercDelta(prev.Mem, curr.Mem)) c.setDefCon(curr.Cpu, curr.Mem) } else { row = c.setCell(row, c.warnCell(render.NAValue, true)) _ = c.setCell(row, c.warnCell(render.NAValue, true)) } c.updateStyle() }) } const defconFmt = "%s %s level!" func (c *ClusterInfo) setDefCon(cpu, mem int) { var set bool l := c.app.Config.K9s.Thresholds.LevelFor(config.CPU, cpu) if l > config.SeverityLow { c.app.Status(flashLevel(l), fmt.Sprintf(defconFmt, flashMessage(l), "CPU")) set = true } l = c.app.Config.K9s.Thresholds.LevelFor(config.MEM, mem) if l > config.SeverityLow { c.app.Status(flashLevel(l), fmt.Sprintf(defconFmt, flashMessage(l), "Memory")) set = true } if !set && !c.app.IsBenchmarking() { c.app.ClearStatus(true) } } func (c *ClusterInfo) updateStyle() { for row := range c.GetRowCount() { c.GetCell(row, 0).SetTextColor(c.styles.K9s.Info.FgColor.Color()) c.GetCell(row, 0).SetBackgroundColor(c.styles.BgColor()) var s tcell.Style s = s.Bold(true) s = s.Foreground(c.styles.K9s.Info.SectionColor.Color()) s = s.Background(c.styles.BgColor()) c.GetCell(row, 1).SetStyle(s) } } // ---------------------------------------------------------------------------- // Helpers... func flashLevel(l config.SeverityLevel) model.FlashLevel { //nolint:exhaustive switch l { case config.SeverityHigh: return model.FlashErr case config.SeverityMedium: return model.FlashWarn default: return model.FlashInfo } } func flashMessage(l config.SeverityLevel) string { //nolint:exhaustive switch l { case config.SeverityHigh: return "Critical" case config.SeverityMedium: return "Warning" default: return "OK" } } ================================================ FILE: internal/view/cm.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // ConfigMap represents a configmap viewer. type ConfigMap struct { ResourceViewer } // NewConfigMap returns a new viewer. func NewConfigMap(gvr *client.GVR) ResourceViewer { s := ConfigMap{ ResourceViewer: NewOwnerExtender( NewBrowser(gvr), ), } s.AddBindKeysFn(s.bindKeys) return &s } func (s *ConfigMap) bindKeys(aa *ui.KeyActions) { aa.Add(ui.KeyU, ui.NewKeyAction("UsedBy", s.refCmd, true)) } func (s *ConfigMap) refCmd(evt *tcell.EventKey) *tcell.EventKey { return scanRefs(evt, s.App(), s.GetTable(), client.CmGVR) } func scanRefs(evt *tcell.EventKey, a *App, t *Table, gvr *client.GVR) *tcell.EventKey { path := t.GetSelectedItem() if path == "" { return evt } ctx := context.Background() refs, err := dao.ScanForRefs(refContext(gvr, path, true)(ctx), a.factory) if err != nil { a.Flash().Err(err) return nil } if len(refs) == 0 { a.Flash().Warnf("No references found at this time for %s::%s. Check again later!", gvr, path) return nil } a.Flash().Infof("Viewing references for %s::%s", gvr, path) view := NewReference(client.RefGVR) view.SetContextFn(refContext(gvr, path, false)) if err := a.inject(view, false); err != nil { a.Flash().Err(err) } return nil } func refContext(gvr *client.GVR, path string, wait bool) ContextFunc { return func(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, path) ctx = context.WithValue(ctx, internal.KeyGVR, gvr) return context.WithValue(ctx, internal.KeyWait, wait) } } ================================================ FILE: internal/view/cm_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfigMapNew(t *testing.T) { s := view.NewConfigMap(client.CmGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "ConfigMaps", s.Name()) assert.Len(t, s.Hints(), 9) } ================================================ FILE: internal/view/cmd/args.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "maps" "slices" "strings" ) const ( nsKey = "ns" topicKey = "topic" filterKey = "filter" fuzzyKey = "fuzzy" labelKey = "labels" contextKey = "context" ) type args map[string]string func newArgs(p *Interpreter, aa []string) args { arguments := make(args, len(aa)) if len(aa) == 0 { return arguments } for i := 0; i < len(aa); i++ { a := strings.TrimSpace(aa[i]) switch { case strings.Index(a, fuzzyFlag) == 0: if a == fuzzyFlag { i++ if i < len(aa) { arguments[fuzzyKey] = strings.ToLower(strings.TrimSpace(aa[i])) } } else { arguments[fuzzyKey] = strings.ToLower(a[2:]) } case strings.Index(a, filterFlag) == 0: if p.IsDirCmd() { if _, ok := arguments[topicKey]; !ok { arguments[topicKey] = a } } else { arguments[filterKey] = strings.ToLower(a[1:]) } case strings.Index(a, contextFlag) == 0: arguments[contextKey] = a[1:] case isLabelArg(a): arguments[labelKey] = strings.ToLower(a) default: switch { case p.IsContextCmd(): arguments[contextKey] = a case p.IsDirCmd(): if _, ok := arguments[topicKey]; !ok { arguments[topicKey] = a } case p.IsXrayCmd(): if _, ok := arguments[topicKey]; ok { arguments[nsKey] = strings.ToLower(a) } else { arguments[topicKey] = strings.ToLower(a) } default: arguments[nsKey] = strings.ToLower(a) } } } return arguments } func (a args) String() string { ss := make([]string, 0, len(a)) kk := maps.Keys(a) for _, k := range slices.Sorted(kk) { v := a[k] switch k { case labelKey: v = "'" + v + "'" case filterKey: v = filterFlag + v case contextKey: v = contextFlag + v } ss = append(ss, v) } return strings.Join(ss, " ") } func (a args) hasFilters() bool { _, fok := a[filterKey] _, zok := a[fuzzyKey] _, lok := a[labelKey] return fok || zok || lok } func isLabelArg(arg string) bool { for _, flag := range labelFlags { if strings.Contains(arg, flag) { return true } } return false } ================================================ FILE: internal/view/cmd/args_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "testing" "github.com/stretchr/testify/assert" ) func TestFlagsNew(t *testing.T) { uu := map[string]struct { i *Interpreter aa []string ll args }{ "empty": { i: NewInterpreter("po"), ll: make(args), }, "ns": { i: NewInterpreter("po"), aa: []string{"ns1"}, ll: args{nsKey: "ns1"}, }, "ns+spaces": { i: NewInterpreter("po"), aa: []string{" ns1 "}, ll: args{nsKey: "ns1"}, }, "filter": { i: NewInterpreter("po"), aa: []string{"/fred"}, ll: args{filterKey: "fred"}, }, "inverse-filter": { i: NewInterpreter("po"), aa: []string{"/!fred"}, ll: args{filterKey: "!fred"}, }, "fuzzy-filter": { i: NewInterpreter("po"), aa: []string{"-f", "fred"}, ll: args{fuzzyKey: "fred"}, }, "fuzzy-filter-nospace": { i: NewInterpreter("po"), aa: []string{"-ffred"}, ll: args{fuzzyKey: "fred"}, }, "filter+ns": { i: NewInterpreter("po"), aa: []string{"/fred", " ns1 "}, ll: args{nsKey: "ns1", filterKey: "fred"}, }, "label": { i: NewInterpreter("po"), aa: []string{"app=fred"}, ll: args{labelKey: "app=fred"}, }, "label-toast": { i: NewInterpreter("po"), aa: []string{"="}, ll: args{labelKey: "="}, }, "multi-labels": { i: NewInterpreter("po"), aa: []string{"app=fred,blee=duh"}, ll: args{labelKey: "app=fred,blee=duh"}, }, "label+ns": { i: NewInterpreter("po"), aa: []string{"a=b,c=d", " ns1 "}, ll: args{labelKey: "a=b,c=d", nsKey: "ns1"}, }, "full-monty": { i: NewInterpreter("po"), aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg"}, 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", fuzzyKey: "blee", labelKey: "app=fred", nsKey: "ns1", contextKey: "ctx1", }, }, "full-monty+ctx-with-space": { i: NewInterpreter("po"), aa: []string{"app=fred", "ns1", "-f", "blee", "/zorg", "@zorg fred"}, ll: args{ filterKey: "zorg", fuzzyKey: "blee", labelKey: "app=fred", nsKey: "ns1", contextKey: "zorg fred", }, }, "full-monty+ctx-first": { i: NewInterpreter("po"), aa: []string{"@ctx1", "app=fred", "ns1", "-f", "blee", "/zorg"}, ll: args{ filterKey: "zorg", fuzzyKey: "blee", labelKey: "app=fred", nsKey: "ns1", contextKey: "ctx1", }, }, "full-monty+ctx-with-space-middle": { i: NewInterpreter("po"), aa: []string{"app=fred", "@ctx1", "ns1", "-f", "blee", "/zorg"}, 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", fuzzyKey: "blee", labelKey: "app=fred", nsKey: "ns1", contextKey: "Dev", }, }, "ctx": { i: NewInterpreter("ctx"), aa: []string{"Dev"}, ll: args{contextKey: "Dev"}, }, "toast": { i: NewInterpreter("apply -f"), ll: args{}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { l := newArgs(u.i, u.aa) assert.Equal(t, u.ll, l) }) } } func TestFlagsHasFilters(t *testing.T) { uu := map[string]struct { i *Interpreter aa []string ok bool }{ "empty": {}, "ns": { i: NewInterpreter("po"), aa: []string{"ns1"}, }, "filter": { i: NewInterpreter("po"), aa: []string{"/fred"}, ok: true, }, "inverse-filter": { i: NewInterpreter("po"), aa: []string{"/!fred"}, ok: true, }, "fuzzy-filter": { i: NewInterpreter("po"), aa: []string{"-f", "fred"}, ok: true, }, "filter+ns": { i: NewInterpreter("po"), aa: []string{"/fred", "ns1"}, ok: true, }, "label": { i: NewInterpreter("po"), aa: []string{"app=fred"}, ok: true, }, "multi-labels": { i: NewInterpreter("po"), aa: []string{"app=fred,blee=duh"}, ok: true, }, "label+ns": { i: NewInterpreter("po"), aa: []string{"app=fred", "ns1"}, ok: true, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { l := newArgs(u.i, u.aa) assert.Equal(t, u.ok, l.hasFilters()) }) } } ================================================ FILE: internal/view/cmd/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "slices" "strings" "github.com/derailed/k9s/internal/client" ) // ToLabels converts a string into a map of labels. func ToLabels(s string) map[string]string { var ( ll = strings.Split(s, ",") lbls = make(map[string]string, len(ll)) ) for _, l := range ll { if k, v, ok := splitKv(l); ok { lbls[k] = v } else { continue } } if len(lbls) == 0 { return nil } return lbls } func splitKv(s string) (k, v string, ok bool) { switch { case strings.Contains(s, labelFlagNotEq): kv := strings.SplitN(s, labelFlagNotEq, 2) if len(kv) == 2 && kv[0] != "" && kv[1] != "" { return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]), true } case strings.Contains(s, labelFlagEqs): kv := strings.SplitN(s, labelFlagEqs, 2) if len(kv) == 2 && kv[0] != "" && kv[1] != "" { return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]), true } case strings.Contains(s, labelFlagEq): kv := strings.SplitN(s, labelFlagEq, 2) if len(kv) == 2 && kv[0] != "" && kv[1] != "" { return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]), true } } return "", "", false } // ShouldAddSuggest checks if a suggestion match the given command. func ShouldAddSuggest(command, suggest string) (string, bool) { if command != suggest && strings.HasPrefix(suggest, command) { return strings.TrimPrefix(suggest, command), true } return "", false } // SuggestSubCommand suggests namespaces or contexts based on current command. func SuggestSubCommand(command string, namespaces client.NamespaceNames, contexts []string) []string { p := NewInterpreter(command) var suggests []string switch { case p.IsCowCmd(), p.IsHelpCmd(), p.IsAliasCmd(), p.IsBailCmd(), p.IsDirCmd(): return nil case p.IsXrayCmd(): _, ns, ok := p.XrayArgs() if !ok || ns == "" { return nil } suggests = completeNS(ns, namespaces) case p.IsContextCmd(): n, ok := p.ContextArg() if !ok { return nil } suggests = completeCtx(command, n, contexts) case p.HasNS(): if n, ok := p.HasContext(); ok { suggests = completeCtx(command, n, contexts) } if len(suggests) > 0 { break } ns, ok := p.NSArg() if !ok { return nil } suggests = completeNS(ns, namespaces) default: if n, ok := p.HasContext(); ok { suggests = completeCtx(command, n, contexts) } } slices.Sort(suggests) return suggests } func completeNS(s string, nn client.NamespaceNames) []string { s = strings.ToLower(s) var suggests []string if suggest, ok := ShouldAddSuggest(s, client.NamespaceAll); ok { suggests = append(suggests, suggest) } for ns := range nn { if suggest, ok := ShouldAddSuggest(s, ns); ok { suggests = append(suggests, suggest) } } return suggests } func completeCtx(command, s string, contexts []string) []string { var suggests []string for _, ctxName := range contexts { if suggest, ok := ShouldAddSuggest(s, ctxName); ok { if s == "" && !strings.HasSuffix(command, " ") { suggests = append(suggests, " "+suggest) continue } suggests = append(suggests, suggest) } } return suggests } ================================================ FILE: internal/view/cmd/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "log/slog" "testing" "github.com/stretchr/testify/assert" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func Test_toLabels(t *testing.T) { uu := map[string]struct { s string ll map[string]string }{ "empty": {}, "toast": { s: "=", }, "toast-1": { s: "=,", }, "toast-2": { s: ",", }, "toast-3": { s: ",=", }, "simple": { s: "a=b", ll: map[string]string{"a": "b"}, }, "multi": { s: "a=b,c=d", ll: map[string]string{"a": "b", "c": "d"}, }, "multi-toast1": { s: "a=,c=d", ll: map[string]string{"c": "d"}, }, "multi-toast2": { s: "a=b,=d", ll: map[string]string{"a": "b"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.ll, ToLabels(u.s)) }) } } func TestSuggestSubCommand(t *testing.T) { namespaceNames := map[string]struct{}{ "kube-system": {}, "kube-public": {}, "default": {}, "nginx-ingress": {}, } contextNames := []string{"develop", "test", "pre", "prod"} tests := []struct { Command string Suggestions []string }{ {Command: "q", Suggestions: nil}, {Command: "xray dp", Suggestions: nil}, {Command: "help k", Suggestions: nil}, {Command: "ctx p", Suggestions: []string{"re", "rod"}}, {Command: "ctx p", Suggestions: []string{"re", "rod"}}, {Command: "ctx pr", Suggestions: []string{"e", "od"}}, {Command: "ctx", Suggestions: []string{" develop", " pre", " prod", " test"}}, {Command: "ctx ", Suggestions: []string{"develop", "pre", "prod", "test"}}, {Command: "context d", Suggestions: []string{"evelop"}}, {Command: "contexts t", Suggestions: []string{"est"}}, {Command: "po ", Suggestions: nil}, {Command: "po x", Suggestions: nil}, {Command: "po k", Suggestions: []string{"ube-public", "ube-system"}}, {Command: "po kube-", Suggestions: []string{"public", "system"}}, } for _, tt := range tests { got := SuggestSubCommand(tt.Command, namespaceNames, contextNames) assert.Equal(t, tt.Suggestions, got) } } ================================================ FILE: internal/view/cmd/interpreter.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "log/slog" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" "k8s.io/apimachinery/pkg/labels" ) // Interpreter tracks user prompt input. type Interpreter struct { line string cmd string aliases []string args args } // NewInterpreter returns a new instance. func NewInterpreter(s string, aliases ...string) *Interpreter { c := Interpreter{ line: s, args: make(args), aliases: aliases, } c.grok() return &c } // ClearNS clears the current namespace if any. func (c *Interpreter) ClearNS() { c.SwitchNS(client.BlankNamespace) } // SwitchNS replaces the current namespace with the provided one. func (c *Interpreter) SwitchNS(ns string) { if ons, ok := c.NSArg(); ok && ons != client.BlankNamespace { c.Reset(strings.TrimSpace(strings.Replace(c.line, " "+ons, " "+ns, 1)), "") return } if ns != client.BlankNamespace { c.Reset(strings.TrimSpace(c.line)+" "+ns, "") } } func (c *Interpreter) Merge(p *Interpreter) { if p == nil { return } c.cmd = p.cmd for k, v := range p.args { c.args[k] = v } c.line = c.cmd + " " + c.args.String() } func (c *Interpreter) grok() { ff := strings.Fields(c.line) if len(ff) == 0 { return } c.cmd = strings.ToLower(ff[0]) var lbls string line := strings.TrimSpace(strings.Replace(c.line, ff[0], "", 1)) if strings.Contains(line, "'") { start, end, ok := quoteIndicies(line) if ok { lbls = line[start+1 : end] line = strings.TrimSpace(strings.Replace(line, "'"+lbls+"'", "", 1)) } else { slog.Error("Unmatched single quote in command line", slogs.Line, c.line) line = "" } } ff = strings.Fields(line) if lbls != "" { ff = append(ff, lbls) } c.args = newArgs(c, ff) } func quoteIndicies(s string) (start, end int, ok bool) { start, end = -1, -1 for i, r := range s { if r == '\'' { if start == -1 { start = i } else if end == -1 { end = i break } } } ok = start != -1 && end != -1 return } // 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 } func (c *Interpreter) Aliases() []string { return c.aliases } func (c *Interpreter) Args() string { return strings.TrimSpace(strings.Replace(c.line, c.cmd, "", 1)) } // 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 { c.args = make(args, len(c1.args)) } for k, v := range c1.args { if v != "" { c.args[k] = v } } } // Reset resets with new command. func (c *Interpreter) Reset(line, alias string) *Interpreter { c.line = line c.grok() if alias != "" && alias != c.cmd { c.addAlias(alias) } return c } func (c *Interpreter) addAlias(a string) { for _, v := range c.aliases { if v == a { return } } c.aliases = append(c.aliases, a) } // GetLine returns 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 { return helpCmd.Has(c.cmd) } // IsBailCmd returns true if quit cmd is detected. func (c *Interpreter) IsBailCmd() bool { return bailCmd.Has(c.cmd) } // IsAliasCmd returns true if alias cmd is detected. func (c *Interpreter) IsAliasCmd() bool { return aliasCmd.Has(c.cmd) } // IsXrayCmd returns true if xray cmd is detected. func (c *Interpreter) IsXrayCmd() bool { return xrayCmd.Has(c.cmd) } // IsContextCmd returns true if context cmd is detected. func (c *Interpreter) IsContextCmd() bool { return contextCmd.Has(c.cmd) } // IsNamespaceCmd returns true if ns cmd is detected. func (c *Interpreter) IsNamespaceCmd() bool { return namespaceCmd.Has(c.cmd) } // IsDirCmd returns true if dir cmd is detected. func (c *Interpreter) IsDirCmd() bool { return dirCmd.Has(c.cmd) } // 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() || strings.Contains(c.line, contextFlag) { return c.args[contextKey], true } return "", false } // 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() { return "", false } d, ok := c.args[topicKey] return d, ok && d != "" } // CowArg returns the cow message. func (c *Interpreter) CowArg() (string, bool) { if !c.IsCowCmd() { return "", false } m, ok := c.args[nsKey] return m, ok && m != "" } // RBACArgs returns the subject and topic is any. func (c *Interpreter) RBACArgs() (subject, verb string, ok bool) { if !c.IsRBACCmd() { return } tt := rbacRX.FindStringSubmatch(c.line) if len(tt) < 3 { return } subject, verb, ok = tt[1], tt[2], true return } // XrayArgs return the gvr and ns if any. func (c *Interpreter) XrayArgs() (cmd, namespace string, ok bool) { if !c.IsXrayCmd() { return } gvr, ok1 := c.args[topicKey] if !ok1 { return } ns, ok2 := c.args[nsKey] switch { case ok1 && ok2: cmd, namespace, ok = gvr, ns, true case ok1 && !ok2: cmd, namespace, ok = gvr, "", true default: return } return } // FilterArg returns the current filter if any. func (c *Interpreter) FilterArg() (string, bool) { f, ok := c.args[filterKey] 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 && ns != client.BlankNamespace } // HasContext returns the current context if any. func (c *Interpreter) HasContext() (string, bool) { ctx, ok := c.args[contextKey] return ctx, ok && ctx != "" } // LabelsSelector returns the label selector if any. func (c *Interpreter) LabelsSelector() (labels.Selector, error) { return labels.Parse(c.args[labelKey]) } ================================================ FILE: internal/view/cmd/interpreter_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd_test import ( "errors" "testing" "github.com/derailed/k9s/internal/view/cmd" "github.com/stretchr/testify/assert" ) func TestRbacCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool args []string }{ "empty": {}, "user": { cmd: "can u:fernand", ok: true, args: []string{"u", "fernand"}, }, "user_spacing": { cmd: "can u: fernand ", ok: true, args: []string{"u", "fernand"}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsRBACCmd()) c, s, ok := p.RBACArgs() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.args[0], c) assert.Equal(t, u.args[1], s) } }) } } func TestNsCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool ns string }{ "empty": {}, "happy": { cmd: "pod fred", ok: true, ns: "fred", }, "ns-arg-spaced": { cmd: "pod fred ", ok: true, ns: "fred", }, "caps-no-ns": { cmd: "Deploy", }, "caps-with-ns": { cmd: "DEPLOY Fred", ok: true, ns: "fred", }, "no-ns": { cmd: "pod", }, "full-ns": { cmd: "pod app=blee fred @zorg", ok: true, ns: "fred", }, "full-repeat-ns": { cmd: "pod app=blee blee @zorg", ok: true, ns: "blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) ns, ok := p.NSArg() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.ns, ns) } }) } } func TestSwitchNS(t *testing.T) { uu := map[string]struct { cmd string ns string e string }{ "empty": {}, "blank": { cmd: "pod fred", e: "pod", }, "no-op": { cmd: "pod fred", ns: "fred", e: "pod fred", }, "no-ns": { cmd: "pod", ns: "blee", e: "pod blee", }, "full-ns": { cmd: "pod app=blee fred @zorg", ns: "blee", e: "pod app=blee blee @zorg", }, "full--repeat-ns": { cmd: "pod app=zorg zorg @zorg", ns: "blee", e: "pod app=zorg blee @zorg", }, "full-no-ns": { cmd: "pod app=blee @zorg", 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": {}, "has-ns": { cmd: "pod fred", e: "pod", }, "no-ns": { cmd: "pod", e: "pod", }, "full-repeat-ns": { cmd: "pod app=blee @zorg zorg", e: "pod app=blee @zorg", }, "full-no-ns": { cmd: "pod app=blee @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 ok bool filter string }{ "empty": {}, "normal": { cmd: "pod /fred", ok: true, filter: "fred", }, "caps": { cmd: "POD /FRED", ok: true, filter: "fred", }, "filter+ns": { cmd: "pod /fred ns1", ok: true, filter: "fred", }, "ns+filter": { cmd: "pod ns1 /fred", ok: true, filter: "fred", }, "ns+filter+labels": { cmd: "pod ns1 /fred app=blee,fred=zorg", ok: true, filter: "fred", }, "filtered": { cmd: "pod /cilium kube-system", ok: true, filter: "cilium", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) f, ok := p.FilterArg() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.filter, f) } }) } } func TestLabelCmd(t *testing.T) { uu := map[string]struct { cmd string err error labels string }{ "empty": {}, "plain": { cmd: "pod fred=blee", labels: "fred=blee", }, "multi": { cmd: "pod fred=blee,zorg=duh", labels: "fred=blee,zorg=duh", }, "complex-lbls": { cmd: "pod 'fred in (blee,zorg),blee notin (zorg)'", labels: "blee notin (zorg),fred in (blee,zorg)", }, "no-lbls": { cmd: "pod ns-1", }, "multi-ns": { cmd: "pod fred=blee,zorg=duh ns1", labels: "fred=blee,zorg=duh", }, "l-arg-spaced": { cmd: "pod fred=blee ", labels: "fred=blee", }, "l-arg-caps": { cmd: "POD FRED=BLEE ", labels: "fred=blee", }, "toast-labels": { cmd: "pod =blee", err: errors.New("found '=', expected: !, identifier, or 'end of string'"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) ll, err := p.LabelsSelector() assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.labels, ll.String()) } }) } } func TestXRayCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool res, ns string }{ "empty": {}, "happy": { cmd: "xray po", ok: true, res: "po", }, "happy+ns": { cmd: "xray po ns1", ok: true, res: "po", ns: "ns1", }, "toast": { cmd: "xrayzor po", }, "toast-1": { cmd: "xray", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) res, ns, ok := p.XrayArgs() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.res, res) assert.Equal(t, u.ns, ns) } }) } } func TestDirCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool dir string }{ "empty": {}, "happy": { cmd: "dir dir1", ok: true, dir: "dir1", }, "extra-ns": { cmd: "dir dir1 ns1", ok: true, dir: "dir1", }, "toast": { cmd: "dirdel dir1", }, "toast-nodir": { cmd: "dir", }, "caps": { cmd: "dir DirName", ok: true, dir: "DirName", }, "abs": { cmd: "dir /tmp", ok: true, dir: "/tmp", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) dir, ok := p.DirArg() assert.Equal(t, u.ok, ok) assert.Equal(t, u.dir, dir) }) } } func TestRBACCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool cat, sub string }{ "empty": {}, "toast": { cmd: "canopy u:bozo", }, "toast-1": { cmd: "can u:", }, "toast-2": { cmd: "can bozo", }, "user": { cmd: "can u:bozo", ok: true, cat: "u", sub: "bozo", }, "group": { cmd: "can g:bozo", ok: true, cat: "g", sub: "bozo", }, "sa": { cmd: "can s:bozo", ok: true, cat: "s", sub: "bozo", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) cat, sub, ok := p.RBACArgs() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.cat, cat) assert.Equal(t, u.sub, sub) } }) } } func TestContextCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool ctx string }{ "empty": {}, "happy-full": { cmd: "context ctx1", ok: true, ctx: "ctx1", }, "happy-alias": { cmd: "ctx ctx1", ok: true, ctx: "ctx1", }, "toast": { cmd: "ctxto ctx1", }, "caps": { cmd: "ctx Dev", ok: true, ctx: "Dev", }, "contains-key": { cmd: "ctx kind-fred", ok: true, ctx: "kind-fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsContextCmd()) if u.ok { ctx, ok := p.ContextArg() assert.Equal(t, u.ok, ok) assert.Equal(t, u.ctx, ctx) } }) } } func TestHelpCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool }{ "empty": {}, "plain": { cmd: "help", ok: true, }, "toast": { cmd: "helpme", }, "toast1": { cmd: "hozer", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsHelpCmd()) }) } } func TestBailCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool }{ "empty": {}, "plain": { cmd: "quit", ok: true, }, "q": { cmd: "q", ok: true, }, "q!": { cmd: "q!", ok: true, }, "toast": { cmd: "zorg", }, "toast1": { cmd: "quitter", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsBailCmd()) }) } } func TestAliasCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool }{ "empty": {}, "plain": { cmd: "alias", ok: true, }, "a": { cmd: "a", ok: true, }, "toast": { cmd: "abba", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsAliasCmd()) }) } } func TestCowCmd(t *testing.T) { uu := map[string]struct { cmd string ok bool }{ "empty": {}, "plain": { cmd: "cow", ok: true, }, "msg": { cmd: "cow bumblebeetuna", ok: true, }, "toast": { cmd: "cowdy", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) assert.Equal(t, u.ok, p.IsCowCmd()) }) } } func TestArgs(t *testing.T) { uu := map[string]struct { cmd string ok bool ctx string }{ "empty": {}, "with-plain-context": { cmd: "po @fred", ok: true, ctx: "fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) ctx, ok := p.ContextArg() assert.Equal(t, u.ok, ok) if u.ok { assert.Equal(t, u.ctx, ctx) } }) } } func Test_grokLabels(t *testing.T) { uu := map[string]struct { cmd string err error lbls string }{ "empty": {}, "no-labels": { cmd: "po @fred", }, "plain-label": { cmd: "po a=b,b=c @fred", lbls: "a=b,b=c", }, "label-quotes": { cmd: "po 'a=b,b=c' @fred", lbls: "a=b,b=c", }, "partial-quotes-label": { cmd: "po 'a=b @fred", lbls: "", }, "complex": { cmd: "po 'a in (b,c),b notin (c,z)' fred'", lbls: "a in (b,c),b notin (c,z)", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) sel, err := p.LabelsSelector() assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.lbls, sel.String()) } }) } } ================================================ FILE: internal/view/cmd/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package cmd import ( "regexp" "k8s.io/apimachinery/pkg/util/sets" ) const ( cowCmd = "cow" canCmd = "can" nsFlag = "-n" filterFlag = "/" labelFlagEq = "=" labelFlagEqs = "==" labelFlagNotEq = "!=" labelFlagIn = " in " labelFlagNotin = " notin " labelFlagQuote = "'" label fuzzyFlag = "-f" contextFlag = "@" ) var ( labelFlags = []string{ labelFlagEq, labelFlagEqs, labelFlagNotEq, labelFlagIn, labelFlagNotin, } rbacRX = regexp.MustCompile(`^can\s+([ugs]):\s*([\w-:]+)\s*$`) contextCmd = sets.New( "ctx", "context", "contexts", ) namespaceCmd = sets.New( "ns", "namespace", "namespaces", ) dirCmd = sets.New( "dir", "dirs", "d", "ls", ) bailCmd = sets.New( "q", "q!", "qa", "Q", "quit", "exit", ) helpCmd = sets.New( "?", "h", "help", ) aliasCmd = sets.New( "a", "alias", "aliases", ) xrayCmd = sets.New( "x", "xr", "xray", ) ) ================================================ FILE: internal/view/command.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "fmt" "log/slog" "regexp" "runtime/debug" "strings" "sync" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/view/cmd" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" ) const ( podCmd = "v1/pods" ctxCmd = "ctx" ) var ( customViewers MetaViewers contextRX = regexp.MustCompile(`\s+@([\w-]+)`) ) // Command represents a user command. type Command struct { app *App alias *dao.Alias mx sync.Mutex } // NewCommand returns a new command. func NewCommand(app *App) *Command { return &Command{ app: app, } } // AliasesFor gather all known aliases for a given resource. func (c *Command) AliasesFor(gvr *client.GVR) sets.Set[string] { if c.alias == nil { return sets.New[string]() } return c.alias.AliasesFor(gvr) } // Init initializes the command. func (c *Command) Init(path string) error { if c.app.factory != nil { c.alias = dao.NewAlias(c.app.factory) if _, err := c.alias.Ensure(path); err != nil { slog.Error("Ensure aliases failed", slogs.Error, err) return err } } customViewers = loadCustomViewers() return nil } // Reset resets Command and reload aliases. func (c *Command) Reset(path string, nuke bool) error { c.mx.Lock() defer c.mx.Unlock() if c.alias == nil { return nil } if nuke { c.alias.Clear() } if _, err := c.alias.Ensure(path); err != nil { return err } return nil } var allowedCmds = sets.New[*client.GVR]( client.PodGVR, client.SvcGVR, client.DpGVR, client.DsGVR, client.StsGVR, client.RsGVR, ) func allowedXRay(gvr *client.GVR) bool { return allowedCmds.Has(gvr) } func (c *Command) contextCmd(p *cmd.Interpreter, pushCmd bool) error { ct, ok := p.ContextArg() if !ok { return fmt.Errorf("invalid command use `context xxx`") } if ct != "" { return useContext(c.app, ct) } gvr, v, comd, err := c.viewMetaFor(p) if err != nil { return err } if comd != nil { p = comd } return c.exec(p, gvr, c.componentFor(gvr, ct, v), true, pushCmd) } func (*Command) namespaceCmd(p *cmd.Interpreter) bool { ns, ok := p.NSArg() if !ok { return false } if ns != "" { _ = p.Reset(client.PodGVR.String(), "") p.SwitchNS(ns) } return false } func (c *Command) aliasCmd(p *cmd.Interpreter, pushCmd bool) error { filter, _ := p.FilterArg() v := NewAlias(client.AliGVR) v.SetFilter(filter, true) return c.exec(p, client.AliGVR, v, false, pushCmd) } func (c *Command) xrayCmd(p *cmd.Interpreter, pushCmd bool) error { arg, cns, ok := p.XrayArgs() if !ok { return errors.New("invalid command. use `xray xxx`") } if c.alias == nil { return fmt.Errorf("no connection available") } gvr, ok := c.alias.Resolve(cmd.NewInterpreter(arg)) if !ok { return fmt.Errorf("invalid resource name: %q", arg) } if !allowedXRay(gvr) { return fmt.Errorf("unsupported resource %q", arg) } ns := c.app.Config.ActiveNamespace() if cns != "" { ns = cns } if err := c.app.Config.SetActiveNamespace(client.CleanseNamespace(ns)); err != nil { return err } if err := c.app.switchNS(ns); err != nil { return err } return c.exec(p, client.XGVR, NewXray(gvr), true, pushCmd) } // Run execs the command by showing associated display. func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack, pushCmd bool) error { if c.specialCmd(p, pushCmd) { return nil } gvr, v, comd, err := c.viewMetaFor(p) if err != nil { return err } if comd != nil { p.Merge(comd) } if context, ok := p.HasContext(); ok { if context != c.app.Config.ActiveContextName() { if err := c.app.Config.Save(true); err != nil { slog.Error("Config save failed during command exec", slogs.Error, err) } else { slog.Debug("Successfully saved config", slogs.Context, context) } } res, err := dao.AccessorFor(c.app.factory, client.CtGVR) if err != nil { return err } switcher, ok := res.(dao.Switchable) if !ok { return errors.New("expecting a switchable resource") } if err := switcher.Switch(context); err != nil { slog.Error("Unable to switch context", slogs.Error, err) return err } if err := c.app.switchContext(p, false); err != nil { return err } } ns := c.app.Config.ActiveNamespace() if cns, ok := p.NSArg(); ok { ns = cns } 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) co.SetFilter("", true) co.SetLabelSelector(labels.Everything(), true) if f, ok := p.FilterArg(); ok { co.SetFilter(f, true) } if f, ok := p.FuzzyArg(); ok { co.SetFilter("-f "+f, true) } if sel, err := p.LabelsSelector(); err == nil { co.SetLabelSelector(sel, false) } else { slog.Error("Unable to grok labels selector", slogs.Error, err) } return c.exec(p, gvr, co, clearStack, pushCmd) } func (c *Command) defaultCmd(isRoot bool) error { if c.app.Conn() == nil || !c.app.Conn().ConnectionOK() { return c.run(cmd.NewInterpreter("context"), "", true, true) } defCmd := podCmd if isRoot { defCmd = ctxCmd } p := cmd.NewInterpreter(c.app.Config.ActiveView()) if p.IsBlank() { return c.run(p.Reset(defCmd, ""), "", true, true) } if err := c.run(p, "", true, true); err != nil { slog.Error("Command exec failed. Using default command", slogs.Command, p.GetLine(), slogs.Error, err, ) p = p.Reset(defCmd, "") return c.run(p, "", true, true) } return nil } func (c *Command) specialCmd(p *cmd.Interpreter, pushCmd bool) bool { switch { case p.IsCowCmd(): if msg, ok := p.CowArg(); !ok { c.app.Flash().Errf("Invalid command. Use `cow xxx`") } else { c.app.cowCmd(msg) } case p.IsBailCmd(): c.app.BailOut(0) case p.IsHelpCmd(): _ = c.app.helpCmd(nil) case p.IsAliasCmd(): if err := c.aliasCmd(p, pushCmd); err != nil { c.app.Flash().Err(err) } case p.IsXrayCmd(): if err := c.xrayCmd(p, pushCmd); err != nil { c.app.Flash().Err(err) } case p.IsRBACCmd(): if cat, sub, ok := p.RBACArgs(); !ok { c.app.Flash().Errf("Invalid command. Use `can [u|g|s]:xxx`") } else if err := c.app.inject(NewPolicy(c.app, cat, sub), true); err != nil { c.app.Flash().Err(err) } case p.IsContextCmd(): if err := c.contextCmd(p, pushCmd); err != nil { c.app.Flash().Err(err) } case p.IsNamespaceCmd(): return c.namespaceCmd(p) case p.IsDirCmd(): if a, ok := p.DirArg(); !ok { c.app.Flash().Errf("Invalid command. Use `dir xxx`") } else if err := c.app.dirCmd(a, pushCmd); err != nil { c.app.Flash().Err(err) } default: return false } return true } func (c *Command) viewMetaFor(p *cmd.Interpreter) (*client.GVR, *MetaViewer, *cmd.Interpreter, error) { if c.alias == nil { return client.NoGVR, nil, nil, fmt.Errorf("no connection available") } gvr, ok := c.alias.Resolve(p) if !ok { return client.NoGVR, nil, nil, fmt.Errorf("`%s` command not found", p.Cmd()) } v := MetaViewer{ viewerFn: func(gvr *client.GVR) ResourceViewer { return NewScaleExtender(NewOwnerExtender(NewBrowser(gvr))) }, } if mv, ok := customViewers[gvr]; ok { v = mv } return gvr, &v, p, nil } func (*Command) componentFor(gvr *client.GVR, fqn string, v *MetaViewer) ResourceViewer { var view ResourceViewer if v.viewerFn != nil { view = v.viewerFn(gvr) } else { view = NewBrowser(gvr) } view.SetInstance(fqn) if v.enterFn != nil { view.GetTable().SetEnterFn(v.enterFn) } return view } func (c *Command) exec(p *cmd.Interpreter, gvr *client.GVR, comp model.Component, clearStack, pushCmd bool) (err error) { defer func() { if e := recover(); e != nil { slog.Error("Failure detected during command exec", slogs.Error, e) c.app.Content.Dump() slog.Debug("Dumping history buffer", slogs.CmdHist, c.app.cmdHistory.List()) slog.Error("Dumping stack", slogs.Stack, string(debug.Stack())) ci := cmd.NewInterpreter(podCmd) currentCommand, ok := c.app.cmdHistory.Top() if ok { ci = ci.Reset(currentCommand, "") } err = c.run(ci, "", true, true) } }() if comp == nil { return fmt.Errorf("no component found for %s", gvr) } comp.SetCommand(p) if clearStack { v := contextRX.ReplaceAllString(p.GetLine(), "") c.app.Config.SetActiveView(v) } if err := c.app.inject(comp, clearStack); err != nil { return err } if pushCmd { c.app.cmdHistory.Push(p.GetLine()) } slog.Debug("History (exec)", slogs.Stack, strings.Join(c.app.cmdHistory.List(), "|")) return } ================================================ FILE: internal/view/command_test.go ================================================ package view import ( "errors" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/view/cmd" "github.com/stretchr/testify/assert" ) func Test_viewMetaFor(t *testing.T) { uu := map[string]struct { cmd string gvr *client.GVR p *cmd.Interpreter err error }{ "empty": { cmd: "", gvr: client.PodGVR, err: errors.New("`` command not found"), }, "toast": { cmd: "v1/pd", gvr: client.PodGVR, err: errors.New("`v1/pd` command not found"), }, "gvr": { cmd: "v1/pods", gvr: client.PodGVR, p: cmd.NewInterpreter("v1/pods"), err: errors.New("blah"), }, "short-name": { cmd: "po", gvr: client.PodGVR, p: cmd.NewInterpreter("v1/pods", "po"), err: errors.New("blee"), }, "custom-alias": { cmd: "pdl", gvr: client.PodGVR, p: cmd.NewInterpreter("v1/pods @fred 'app=blee' default", "pdl"), err: errors.New("blee"), }, "inception": { cmd: "pdal blee", gvr: client.PodGVR, p: cmd.NewInterpreter("v1/pods @fred 'app=blee' blee", "pdal", "pod"), err: errors.New("blee"), }, } c := &Command{ alias: &dao.Alias{ Aliases: config.NewAliases(), }, } c.alias.Define(client.PodGVR, "po", "pod", "pods", client.PodGVR.String()) c.alias.Define(client.NewGVR("pod default"), "pd") c.alias.Define(client.NewGVR("pod @fred 'app=blee' default"), "pdl") c.alias.Define(client.NewGVR("pdl"), "pdal") for k, u := range uu { t.Run(k, func(t *testing.T) { p := cmd.NewInterpreter(u.cmd) gvr, _, acmd, err := c.viewMetaFor(p) if err != nil { assert.Equal(t, u.err.Error(), err.Error()) } else { assert.Equal(t, u.gvr, gvr) assert.Equal(t, u.p, acmd) } }) } } ================================================ FILE: internal/view/container.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" v1 "k8s.io/api/core/v1" ) const containerTitle = "Containers" // Container represents a container view. type Container struct { ResourceViewer } // NewContainer returns a new container view. func NewContainer(gvr *client.GVR) ResourceViewer { c := Container{} c.ResourceViewer = NewLogsExtender(NewBrowser(gvr), c.logOptions) c.SetEnvFn(c.k9sEnv) c.GetTable().SetEnterFn(c.viewLogs) c.GetTable().SetDecorateFn(c.decorateRows) c.GetTable().SetSortCol("IDX", true) c.AddBindKeysFn(c.bindKeys) c.GetTable().SetDecorateFn(c.portForwardIndicator) return &c } func (c *Container) portForwardIndicator(data *model1.TableData) { ff := c.App().factory.Forwarders() col, ok := data.IndexOfHeader("PF") if !ok { return } data.RowsRange(func(_ int, re model1.RowEvent) bool { if ff.IsContainerForwarded(c.GetTable().Path, re.Row.ID) { re.Row.Fields[col] = "[orange::b]Ⓕ" } return true }) } func (c *Container) decorateRows(data *model1.TableData) { decorateCpuMemHeaderRows(c.App(), data) } // Name returns the component name. func (*Container) Name() string { return containerTitle } func (c *Container) bindDangerousKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyS: ui.NewKeyActionWithOpts( "Shell", c.shellCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyA: ui.NewKeyActionWithOpts( "Attach", c.attachCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), }) } func (c *Container) bindKeys(aa *ui.KeyActions) { aa.Delete(tcell.KeyCtrlSpace, ui.KeySpace) if !c.App().Config.IsReadOnly() { c.bindDangerousKeys(aa) } aa.Bulk(ui.KeyMap{ ui.KeyF: ui.NewKeyAction("Show PortForward", c.showPFCmd, true), ui.KeyShiftF: ui.NewKeyAction("PortForward", c.portFwdCmd, true), }) } func (c *Container) k9sEnv() Env { path := c.GetTable().GetSelectedItem() row := c.GetTable().GetSelectedRow(path) env := defaultEnv(c.App().Conn().Config(), path, c.GetTable().GetModel().Peek().Header(), row) env["NAMESPACE"], env["POD"] = client.Namespaced(c.GetTable().Path) return env } func (c *Container) logOptions(prev bool) (*dao.LogOptions, error) { path := c.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("nothing selected") } cfg := c.App().Config.K9s.Logger opts := dao.LogOptions{ Path: c.GetTable().Path, Container: path, Lines: cfg.TailCount, SinceSeconds: cfg.SinceSeconds, SingleContainer: true, ShowTimestamp: cfg.ShowTime, Previous: prev, } return &opts, nil } func (c *Container) viewLogs(*App, ui.Tabular, *client.GVR, string) { c.ResourceViewer.(*LogsExtender).showLogs(c.GetTable().Path, false) } // Handlers... func (c *Container) showPFCmd(evt *tcell.EventKey) *tcell.EventKey { path := c.GetTable().GetSelectedItem() if path == "" { return evt } if !c.App().factory.Forwarders().IsContainerForwarded(c.GetTable().Path, path) { c.App().Flash().Errf("no port-forward defined") return nil } pf := NewPortForward(client.PfGVR) pf.SetContextFn(c.portForwardContext) if err := c.App().inject(pf, false); err != nil { c.App().Flash().Err(err) } return nil } func (c *Container) portForwardContext(ctx context.Context) context.Context { if bc := c.App().BenchFile; bc != "" { ctx = context.WithValue(ctx, internal.KeyBenchCfg, c.App().BenchFile) } return context.WithValue(ctx, internal.KeyPath, c.GetTable().Path) } func (c *Container) shellCmd(evt *tcell.EventKey) *tcell.EventKey { path := c.GetTable().GetSelectedItem() if path == "" { return evt } var err error c.Stop() defer func() { c.Start() if err != nil { c.App().QueueUpdate(func() { if err != nil { c.App().Flash().Errf("Shell exec failed: %s", err) } }) c.App().Flash().Err(err) } }() err = shellIn(c.App(), c.GetTable().Path, path) return nil } func (c *Container) attachCmd(evt *tcell.EventKey) *tcell.EventKey { sel := c.GetTable().GetSelectedItem() if sel == "" { return evt } c.Stop() defer c.Start() attachIn(c.App(), c.GetTable().Path, sel) return nil } func (c *Container) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { path := c.GetTable().GetSelectedItem() if path == "" { return evt } if _, ok := c.App().factory.ForwarderFor(fwFQN(c.GetTable().Path, path)); ok { c.App().Flash().Errf("A port-forward already exists on container %s", c.GetTable().Path) return nil } ports, ann, ok := c.listForwardable(path) if !ok { return nil } ShowPortForwards(c, c.GetTable().Path+"|"+path, ports, ann, startFwdCB) return nil } func checkRunningStatus(co string, ss []v1.ContainerStatus) error { var cs *v1.ContainerStatus for i := range ss { if ss[i].Name == co { cs = &ss[i] break } } if cs == nil { return fmt.Errorf("unable to locate container status for %q", co) } if render.ToContainerState(cs.State) != "Running" { return fmt.Errorf("Container %s is not running?", co) } return nil } func locateContainer(co string, cc []v1.Container) (*v1.Container, error) { for i := range cc { if cc[i].Name == co { return &cc[i], nil } } return nil, fmt.Errorf("unable to locate container named %q", co) } func (c *Container) listForwardable(path string) (port.ContainerPortSpecs, map[string]string, bool) { po, err := fetchPod(c.App().factory, c.GetTable().Path) if err != nil { return nil, nil, false } co, err := locateContainer(path, po.Spec.Containers) if err != nil { c.App().Flash().Err(err) return nil, nil, false } if err := checkRunningStatus(path, po.Status.ContainerStatuses); err != nil { c.App().Flash().Err(err) return nil, nil, false } return port.FromContainerPorts(path, co.Ports), po.Annotations, true } ================================================ FILE: internal/view/container_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestContainerNew(t *testing.T) { c := view.NewContainer(client.CoGVR) require.NoError(t, c.Init(makeCtx(t))) assert.Equal(t, "Containers", c.Name()) assert.Len(t, c.Hints(), 13) } ================================================ FILE: internal/view/context.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "fmt" "log/slog" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) const ( renamePage = "rename" inputField = "New name:" ) // Context presents a context viewer. type Context struct { ResourceViewer } // NewContext returns a new viewer. func NewContext(gvr *client.GVR) ResourceViewer { c := Context{ ResourceViewer: NewBrowser(gvr), } c.GetTable().SetEnterFn(c.useCtx) c.AddBindKeysFn(c.bindKeys) return &c } func (c *Context) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, tcell.KeyCtrlSpace, ui.KeySpace) if !c.App().Config.IsReadOnly() { c.bindDangerousKeys(aa) } } func (c *Context) bindDangerousKeys(aa *ui.KeyActions) { aa.Add(ui.KeyR, ui.NewKeyAction("Rename", c.renameCmd, true)) aa.Add(tcell.KeyCtrlD, ui.NewKeyAction("Delete", c.deleteCmd, true)) } func (c *Context) renameCmd(evt *tcell.EventKey) *tcell.EventKey { contextName := c.GetTable().GetSelectedItem() if contextName == "" { return evt } c.showRenameModal(contextName, c.renameDialogCallback) return nil } func (c *Context) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { contextName := c.GetTable().GetSelectedItem() if contextName == "" { return evt } d := c.App().Styles.Dialog() dialog.ShowConfirm(&d, c.App().Content.Pages, "Delete", fmt.Sprintf("Delete context %q?", contextName), func() { if err := c.App().factory.Client().Config().DelContext(contextName); err != nil { c.App().Flash().Err(err) return } c.Refresh() }, func() {}) return nil } func (c *Context) renameDialogCallback(form *tview.Form, contextName string) error { app := c.App() input := form.GetFormItemByLabel(inputField).(*tview.InputField) if err := app.factory.Client().Config().RenameContext(contextName, input.GetText()); err != nil { c.App().Flash().Err(err) return nil } c.Refresh() return nil } func (c *Context) showRenameModal(name string, ok func(form *tview.Form, contextName string) error) { app := c.App() styles := app.Styles.Dialog() f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddInputField(inputField, name, 0, nil, nil). AddButton("OK", func() { if err := ok(f, name); err != nil { app.Flash().Err(err) return } app.Content.Pages.RemovePage(renamePage) }). AddButton("Cancel", func() { app.Content.RemovePage(renamePage) }) m := tview.NewModalForm("", f) m.SetText(fmt.Sprintf("Rename context %q?", name)) m.SetDoneFunc(func(int, string) { app.Content.RemovePage(renamePage) }) app.Content.AddPage(renamePage, m, false, false) app.Content.ShowPage(renamePage) for i := range f.GetButtonCount() { f.GetButton(i). SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()). SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } func (c *Context) useCtx(app *App, _ ui.Tabular, gvr *client.GVR, path string) { slog.Debug("Using context", slogs.GVR, gvr, slogs.FQN, path, ) if err := useContext(app, path); err != nil { app.Flash().Err(err) return } c.App().clearHistory() c.Refresh() c.GetTable().Select(1, 0) } func useContext(app *App, name string) error { if app.Content.Top() != nil { app.Content.Top().Stop() } res, err := dao.AccessorFor(app.factory, client.CtGVR) if err != nil { return err } switcher, ok := res.(dao.Switchable) if !ok { return errors.New("expecting a switchable resource") } app.Config.K9s.ToggleContextSwitch(true) defer app.Config.K9s.ToggleContextSwitch(false) // Save config prior to context switch... if err := app.Config.Save(true); err != nil { slog.Error("Fail to save config to disk", slogs.Subsys, "config", slogs.Error, err) } if err := switcher.Switch(name); err != nil { slog.Error("Context switch failed during use command", slogs.Error, err) return err } return app.switchContext(cmd.NewInterpreter("ctx "+name), true) } ================================================ FILE: internal/view/context_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestContext(t *testing.T) { ctx := view.NewContext(client.CtGVR) require.NoError(t, ctx.Init(makeCtx(t))) assert.Equal(t, "Contexts", ctx.Name()) assert.Len(t, ctx.Hints(), 8) } ================================================ FILE: internal/view/cow.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // Cow represents a bomb viewer. type Cow struct { *tview.TextView actions *ui.KeyActions app *App says string } // NewCow returns a have a cow viewer. func NewCow(app *App, says string) *Cow { return &Cow{ TextView: tview.NewTextView(), app: app, actions: ui.NewKeyActions(), says: says, } } // Init initializes the viewer. func (c *Cow) Init(_ context.Context) error { c.SetBorder(true) c.SetScrollable(true).SetWrap(true).SetRegions(true) c.SetDynamicColors(true) c.SetHighlightColor(tcell.ColorOrange) c.SetTitleColor(tcell.ColorAqua) c.SetInputCapture(c.keyboard) c.SetBorderPadding(0, 0, 1, 1) c.updateTitle() c.SetTextAlign(tview.AlignCenter) c.app.Styles.AddListener(c) c.StylesChanged(c.app.Styles) c.bindKeys() c.SetInputCapture(c.keyboard) c.talk() return nil } // InCmdMode checks if prompt is active. func (*Cow) InCmdMode() bool { return false } func (c *Cow) talk() { says := c.says if says == "" { says = "Nothing to report here. Please move along..." } x, _, w, _ := c.GetRect() c.SetText(cowTalk(says, (x+w)/2)) } func cowTalk(says string, w int) string { msg := fmt.Sprintf("[red::]< [::b]Ruroh? %s[::-] >", says) buff := make([]string, 0, len(cow)+3) buff = append(buff, "[red::] "+strings.Repeat("─", len(says)+8), strings.TrimSuffix(msg, "\n"), " "+strings.Repeat("─", len(says)+8), ) rCount := w/2 - 8 if rCount < 0 { rCount = w / 2 } spacer := strings.Repeat(" ", rCount) for _, s := range cow { buff = append(buff, "[red::b]"+spacer+s) } return strings.Join(buff, "\n") } func (c *Cow) bindKeys() { c.actions.Add(tcell.KeyEscape, ui.NewKeyAction("Back", c.resetCmd, false)) } func (c *Cow) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := c.actions.Get(ui.AsKey(evt)); ok { return a.Action(evt) } return evt } // StylesChanged notifies the skin changes. func (c *Cow) StylesChanged(s *config.Styles) { c.SetBackgroundColor(s.BgColor()) c.SetTextColor(s.FgColor()) c.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) } func (c *Cow) resetCmd(evt *tcell.EventKey) *tcell.EventKey { return c.app.PrevCmd(evt) } // Actions returns menu actions. func (c *Cow) Actions() *ui.KeyActions { return c.actions } // Name returns the component name. func (*Cow) Name() string { return "cow" } // Start starts the view updater. func (*Cow) Start() {} // Stop terminates the updater. func (c *Cow) Stop() { c.app.Styles.RemoveListener(c) } // Hints returns menu hints. func (c *Cow) Hints() model.MenuHints { return c.actions.Hints() } // ExtraHints returns additional hints. func (*Cow) ExtraHints() map[string]string { return nil } func (c *Cow) updateTitle() { c.SetTitle(" Error ") } var cow = []string{ `\ ^__^ `, ` \ (oo)\_______ `, ` (__)\ )\/\`, ` ||----w | `, ` || || `, } ================================================ FILE: internal/view/crd.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" ) // CRD represents a crd viewer. type CRD struct { ResourceViewer } // NewCRD returns a new viewer. func NewCRD(gvr *client.GVR) ResourceViewer { s := CRD{ ResourceViewer: NewOwnerExtender(NewBrowser(gvr)), } s.GetTable().SetEnterFn(s.showCRD) return &s } func (*CRD) showCRD(app *App, _ ui.Tabular, _ *client.GVR, path string) { _, crd := client.Namespaced(path) app.gotoResource(crd, "", false, true) } ================================================ FILE: internal/view/cronjob.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" "github.com/derailed/tview" batchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) const ( suspendDialogKey = "suspend" lastScheduledCol = "LAST_SCHEDULE" defaultSuspendStatus = "true" ) // CronJob represents a cronjob viewer. type CronJob struct { ResourceViewer } // NewCronJob returns a new viewer. func NewCronJob(gvr *client.GVR) ResourceViewer { c := CronJob{ResourceViewer: NewVulnerabilityExtender( NewOwnerExtender(NewBrowser(gvr)), )} c.AddBindKeysFn(c.bindKeys) c.GetTable().SetEnterFn(c.showJobs) return &c } func (*CronJob) showJobs(app *App, _ ui.Tabular, gvr *client.GVR, fqn string) { slog.Debug("Showing Jobs", slogs.GVR, gvr, slogs.FQN, fqn) o, err := app.factory.Get(gvr, fqn, true, labels.Everything()) if err != nil { app.Flash().Err(err) return } var cj batchv1.CronJob err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &cj) if err != nil { app.Flash().Err(err) return } ns, _ := client.Namespaced(fqn) if err := app.Config.SetActiveNamespace(ns); err != nil { slog.Error("Unable to set active namespace during show pods", slogs.Error, err) } v := NewJob(client.JobGVR) v.SetContextFn(jobCtx(fqn, string(cj.UID))) if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func jobCtx(fqn, uid string) ContextFunc { return func(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, fqn) return context.WithValue(ctx, internal.KeyUID, uid) } } func (c *CronJob) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyT: ui.NewKeyAction("Trigger", c.triggerCmd, true), ui.KeyS: ui.NewKeyAction("Suspend/Resume", c.toggleSuspendCmd, true), }) } func (c *CronJob) triggerCmd(evt *tcell.EventKey) *tcell.EventKey { fqns := c.GetTable().GetSelectedItems() if len(fqns) == 0 { return evt } msg := fmt.Sprintf("Trigger CronJob: %s?", fqns[0]) if len(fqns) > 1 { msg = fmt.Sprintf("Trigger %d CronJobs?", len(fqns)) } d := c.App().Styles.Dialog() dialog.ShowConfirm(&d, c.App().Content.Pages, "Confirm Job Trigger", msg, func() { res, err := dao.AccessorFor(c.App().factory, c.GVR()) if err != nil { c.App().Flash().Err(fmt.Errorf("no accessor for %q", c.GVR())) return } runner, ok := res.(dao.Runnable) if !ok { c.App().Flash().Err(fmt.Errorf("expecting a job runner resource for %q", c.GVR())) return } for _, fqn := range fqns { if err := runner.Run(fqn); err != nil { c.App().Flash().Errf("CronJob trigger failed for %s: %v", fqn, err) } else { c.App().Flash().Infof("Triggered Job %s %s", c.GVR(), fqn) } } }, func() {}) return nil } func (c *CronJob) toggleSuspendCmd(evt *tcell.EventKey) *tcell.EventKey { table := c.GetTable() sel := table.GetSelectedItem() if sel == "" { return evt } cell := table.GetCell(c.GetTable().GetSelectedRowIndex(), c.GetTable().NameColIndex()+2) if cell == nil { c.App().Flash().Errf("Unable to assert current status") return nil } c.Stop() defer c.Start() c.showSuspendDialog(cell, sel) return nil } func (c *CronJob) showSuspendDialog(cell *tview.TableCell, sel string) { title := "Suspend" if strings.TrimSpace(cell.Text) == defaultSuspendStatus { title = "Resume" } d := c.App().Styles.Dialog() dialog.ShowConfirm(&d, c.App().Content.Pages, title, sel, func() { ctx, cancel := context.WithTimeout(context.Background(), c.App().Conn().Config().CallTimeout()) defer cancel() res, err := dao.AccessorFor(c.App().factory, c.GVR()) if err != nil { c.App().Flash().Err(fmt.Errorf("no accessor for %q", c.GVR())) return } cronJob, ok := res.(*dao.CronJob) if !ok { c.App().Flash().Errf("expecting a cron job for %q", c.GVR()) return } if err := cronJob.ToggleSuspend(ctx, sel); err != nil { c.App().Flash().Errf("Cronjob %s failed for %v", strings.ToLower(title), err) return } }, func() {}) } ================================================ FILE: internal/view/details.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "io" "strings" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "k8s.io/apimachinery/pkg/labels" ) const ( detailsTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " contentTXT = "text" contentYAML = "yaml" ) // Details represents a generic text viewer. type Details struct { *tview.Flex text *tview.TextView actions *ui.KeyActions app *App title, subject string cmdBuff *model.FishBuff model *model.Text currentRegion, maxRegions int searchable bool fullScreen bool contentType string } // NewDetails returns a details viewer. func NewDetails(app *App, title, subject, contentType string, searchable bool) *Details { d := Details{ Flex: tview.NewFlex(), text: tview.NewTextView(), app: app, title: title, subject: subject, actions: ui.NewKeyActions(), cmdBuff: model.NewFishBuff('/', model.FilterBuffer), model: model.NewText(), searchable: searchable, contentType: contentType, } d.AddItem(d.text, 0, 1, true) return &d } func (*Details) SetCommand(*cmd.Interpreter) {} func (*Details) SetFilter(string, bool) {} func (*Details) SetLabelSelector(labels.Selector, bool) {} // Init initializes the viewer. func (d *Details) Init(_ context.Context) error { if d.title != "" { d.SetBorder(true) } d.text.SetScrollable(true).SetWrap(true).SetRegions(true) d.text.SetDynamicColors(true) d.text.SetHighlightColor(tcell.ColorOrange) d.SetTitleColor(tcell.ColorAqua) d.SetInputCapture(d.keyboard) d.SetBorderPadding(0, 0, 1, 1) d.updateTitle() d.app.Styles.AddListener(d) d.StylesChanged(d.app.Styles) d.setFullScreen(d.app.Config.K9s.UI.DefaultsToFullScreen) d.app.Prompt().SetModel(d.cmdBuff) d.cmdBuff.AddListener(d) d.bindKeys() d.SetInputCapture(d.keyboard) d.model.AddListener(d) return nil } // InCmdMode checks if prompt is active. func (d *Details) InCmdMode() bool { return d.cmdBuff.InCmdMode() } // TextChanged notifies the model changed. func (d *Details) TextChanged(lines []string) { switch d.contentType { case contentYAML: d.text.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(lines, "\n"))) default: d.text.SetText(strings.Join(lines, "\n")) } d.text.ScrollToBeginning() } // TextFiltered notifies when the filter changed. func (d *Details) TextFiltered(lines []string, matches fuzzy.Matches) { d.currentRegion, d.maxRegions = 0, len(matches) ll := linesWithRegions(lines, matches) d.text.SetText(colorizeYAML(d.app.Styles.Views().Yaml, strings.Join(ll, "\n"))) d.text.Highlight() if len(matches) > 0 { d.text.Highlight("search_0") d.text.ScrollToHighlight() } } // BufferChanged indicates the buffer was changed. func (*Details) BufferChanged(_, _ string) {} // BufferCompleted indicates input was accepted. func (d *Details) BufferCompleted(text, _ string) { d.model.Filter(text) d.updateTitle() } // BufferActive indicates the buff activity changed. func (d *Details) BufferActive(state bool, k model.BufferKind) { d.app.BufferActive(state, k) } func (d *Details) bindKeys() { d.actions.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewSharedKeyAction("Filter", d.filterCmd, false), tcell.KeyEscape: ui.NewKeyAction("Back", d.resetCmd, false), ui.KeyQ: ui.NewKeyAction("Back", d.resetCmd, false), tcell.KeyCtrlS: ui.NewKeyAction("Save", d.saveCmd, false), ui.KeyC: ui.NewKeyAction("Copy", cpCmd(d.app.Flash(), d.text), true), ui.KeyF: ui.NewKeyAction("Toggle FullScreen", d.toggleFullScreenCmd, true), ui.KeyN: ui.NewKeyAction("Next Match", d.nextCmd, true), ui.KeyShiftN: ui.NewKeyAction("Prev Match", d.prevCmd, true), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", d.activateCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", d.eraseCmd, false), }) if !d.searchable { d.actions.Delete(ui.KeyN, ui.KeyShiftN) } } func (d *Details) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := d.actions.Get(ui.AsKey(evt)); ok { return a.Action(evt) } return evt } // StylesChanged notifies the skin changed. func (d *Details) StylesChanged(s *config.Styles) { d.SetBackgroundColor(s.BgColor()) d.text.SetTextColor(s.FgColor()) d.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) d.TextChanged(d.model.Peek()) } // Update updates the view content. func (d *Details) Update(buff string) *Details { d.model.SetText(buff) return d } func (d *Details) GetWriter() io.Writer { return d.text } // SetSubject updates the subject. func (d *Details) SetSubject(s string) { d.subject = s } // Actions returns menu actions. func (d *Details) Actions() *ui.KeyActions { return d.actions } // Name returns the component name. func (d *Details) Name() string { return d.title } // Start starts the view updater. func (*Details) Start() {} // Stop terminates the updater. func (d *Details) Stop() { d.app.Styles.RemoveListener(d) } // Hints returns menu hints. func (d *Details) Hints() model.MenuHints { return d.actions.Hints() } // ExtraHints returns additional hints. func (*Details) ExtraHints() map[string]string { return nil } func (d *Details) nextCmd(evt *tcell.EventKey) *tcell.EventKey { if d.cmdBuff.Empty() { return evt } d.currentRegion++ if d.currentRegion >= d.maxRegions { d.currentRegion = 0 } d.text.Highlight(fmt.Sprintf("search_%d", d.currentRegion)) d.text.ScrollToHighlight() d.updateTitle() return nil } func (d *Details) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { if d.app.InCmdMode() { return evt } d.setFullScreen(!d.fullScreen) return nil } func (d *Details) setFullScreen(isFullScreen bool) { d.fullScreen = isFullScreen d.SetFullScreen(isFullScreen) d.SetBorder(!isFullScreen) if isFullScreen { d.SetBorderPadding(0, 0, 0, 0) } else { d.SetBorderPadding(0, 0, 1, 1) } } func (d *Details) prevCmd(evt *tcell.EventKey) *tcell.EventKey { if d.cmdBuff.Empty() { return evt } d.currentRegion-- if d.currentRegion < 0 { d.currentRegion = d.maxRegions - 1 } d.text.Highlight(fmt.Sprintf("search_%d", d.currentRegion)) d.text.ScrollToHighlight() d.updateTitle() return nil } func (d *Details) filterCmd(*tcell.EventKey) *tcell.EventKey { d.model.Filter(d.cmdBuff.GetText()) d.cmdBuff.SetActive(false) d.updateTitle() return nil } func (d *Details) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if d.app.InCmdMode() { return evt } d.app.ResetPrompt(d.cmdBuff) return nil } func (d *Details) eraseCmd(*tcell.EventKey) *tcell.EventKey { if !d.cmdBuff.IsActive() { return nil } d.cmdBuff.Delete() return nil } func (d *Details) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !d.cmdBuff.InCmdMode() { d.cmdBuff.Reset() return d.app.PrevCmd(evt) } if d.cmdBuff.GetText() != "" { d.model.ClearFilter() } d.cmdBuff.SetActive(false) d.cmdBuff.Reset() d.updateTitle() return nil } func (d *Details) saveCmd(*tcell.EventKey) *tcell.EventKey { if path, err := saveYAML(d.app.Config.K9s.ContextScreenDumpDir(), d.title, d.text.GetText(true)); err != nil { d.app.Flash().Err(err) } else { d.app.Flash().Infof("Log %s saved successfully!", path) } return nil } func (d *Details) updateTitle() { if d.title == "" { return } fmat := fmt.Sprintf(detailsTitleFmt, d.title, d.subject) var ( buff = d.cmdBuff.GetText() styles = d.app.Styles.Frame() ) if buff == "" { d.SetTitle(ui.SkinTitle(fmat, &styles)) return } if d.maxRegions != 0 { buff += fmt.Sprintf("[%d:%d]", d.currentRegion+1, d.maxRegions) } fmat += fmt.Sprintf(ui.SearchFmt, buff) d.SetTitle(ui.SkinTitle(fmat, &styles)) } ================================================ FILE: internal/view/dir.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "os" "path" "slices" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" ) const ( kustomize = "kustomization" kustomizeNoExt = "Kustomization" kustomizeYAML = kustomize + extYAML kustomizeYML = kustomize + extYML extYAML = ".yaml" extYML = ".yml" ) // Dir represents a command directory view. type Dir struct { ResourceViewer path string } // NewDir returns a new instance. func NewDir(s string) ResourceViewer { d := Dir{ ResourceViewer: NewBrowser(client.DirGVR), path: s, } d.GetTable().SetBorderFocusColor(tcell.ColorAliceBlue) d.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorAliceBlue).Attributes(tcell.AttrNone)) d.AddBindKeysFn(d.bindKeys) d.SetContextFn(d.dirContext) return &d } // Init initializes the view. func (d *Dir) Init(ctx context.Context) error { if err := d.ResourceViewer.Init(ctx); err != nil { return err } return nil } func (d *Dir) dirContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyPath, d.path) } func (d *Dir) bindDangerousKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyA: ui.NewKeyActionWithOpts("Apply", d.applyCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyD: ui.NewKeyActionWithOpts("Delete", d.delCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyE: ui.NewKeyActionWithOpts("Edit", d.editCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), }) } func (d *Dir) bindKeys(aa *ui.KeyActions) { // !!BOZO!! Lame! aa.Delete(ui.KeyShiftA, tcell.KeyCtrlS, tcell.KeyCtrlSpace, ui.KeySpace) aa.Delete(tcell.KeyCtrlW, tcell.KeyCtrlL, tcell.KeyCtrlD, tcell.KeyCtrlZ) if !d.App().Config.IsReadOnly() { d.bindDangerousKeys(aa) } aa.Bulk(ui.KeyMap{ ui.KeyY: ui.NewKeyAction(yamlAction, d.viewCmd, true), tcell.KeyEnter: ui.NewKeyAction("Goto", d.gotoCmd, true), }) } func (d *Dir) viewCmd(evt *tcell.EventKey) *tcell.EventKey { sel := d.GetTable().GetSelectedItem() if sel == "" { return evt } if path.Ext(sel) == "" { return nil } yaml, err := os.ReadFile(sel) if err != nil { d.App().Flash().Err(err) return nil } details := NewDetails(d.App(), yamlAction, sel, contentYAML, true).Update(string(yaml)) if err := d.App().inject(details, false); err != nil { d.App().Flash().Err(err) } return nil } func isManifest(s string) bool { ext := path.Ext(s) return ext == ".yml" || ext == ".yaml" } func (d *Dir) editCmd(evt *tcell.EventKey) *tcell.EventKey { sel := d.GetTable().GetSelectedItem() if sel == "" { return evt } if !isManifest(sel) { d.App().Flash().Errf("you must select a manifest") return nil } d.Stop() defer d.Start() if !edit(d.App(), &shellOpts{clear: true, args: []string{sel}}) { d.App().Flash().Errf("Failed to launch editor") } return nil } func (d *Dir) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { if d.GetTable().CmdBuff().IsActive() { return d.GetTable().activateCmd(evt) } sel := d.GetTable().GetSelectedItem() if sel == "" { return evt } if isManifest(sel) { d.App().Flash().Errf("you must select a directory") return nil } v := NewDir(sel) if err := d.App().inject(v, false); err != nil { d.App().Flash().Err(err) } return evt } func isKustomized(sel string) bool { if isManifest(sel) { return false } ff, err := os.ReadDir(sel) if err != nil { return false } kk := []string{kustomizeNoExt, kustomizeYAML, kustomizeYML} for _, f := range ff { if slices.Contains(kk, f.Name()) { return true } } return false } func containsDir(sel string) bool { if isManifest(sel) { return false } ff, err := os.ReadDir(sel) if err != nil { return false } for _, f := range ff { if f.IsDir() { return true } } return false } func (d *Dir) applyCmd(evt *tcell.EventKey) *tcell.EventKey { sel := d.GetTable().GetSelectedItem() if sel == "" { return evt } opts := []string{"-f"} if containsDir(sel) { opts = append(opts, "-R") } if isKustomized(sel) { opts = []string{"-k"} } d.Stop() defer d.Start() { args := make([]string, 0, 10) args = append(args, "apply") args = append(args, opts...) args = append(args, sel) res, err := runKu(context.Background(), d.App(), &shellOpts{clear: false, args: args}) if err != nil { res = "status:\n " + err.Error() + "\nmessage:\n" + fmtResults(res) } else { res = "message:\n" + fmtResults(res) } details := NewDetails(d.App(), "Applied Manifest", sel, contentYAML, true).Update(res) if err := d.App().inject(details, false); err != nil { d.App().Flash().Err(err) } } return nil } func (d *Dir) delCmd(evt *tcell.EventKey) *tcell.EventKey { sel := d.GetTable().GetSelectedItem() if sel == "" { return evt } opts := []string{"-f"} msgResource := "manifest" if containsDir(sel) { opts = append(opts, "-R") } if isKustomized(sel) { opts = []string{"-k"} msgResource = "kustomization" } d.Stop() defer d.Start() msg := fmt.Sprintf("Delete resource(s) in %s %s", msgResource, sel) dlg := d.App().Styles.Dialog() dialog.ShowConfirm(&dlg, d.App().Content.Pages, "Confirm Delete", msg, func() { args := make([]string, 0, 10) args = append(args, "delete") args = append(args, opts...) args = append(args, sel) res, err := runKu(context.Background(), d.App(), &shellOpts{clear: false, args: args}) if err != nil { res = "status:\n " + err.Error() + "\nmessage:\n" + fmtResults(res) } else { res = "message:\n" + fmtResults(res) } details := NewDetails(d.App(), "Deleted Manifest", sel, contentYAML, true).Update(res) if err := d.App().inject(details, false); err != nil { d.App().Flash().Err(err) } }, func() {}) return nil } func fmtResults(res string) string { res = strings.TrimSpace(res) lines := strings.Split(res, "\n") ll := make([]string, 0, len(lines)) for _, l := range lines { ll = append(ll, " "+l) } return strings.Join(ll, "\n") } ================================================ FILE: internal/view/dir_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "testing" "github.com/stretchr/testify/assert" ) func TestIsManifest(t *testing.T) { uu := map[string]struct { file string e bool }{ "yaml": {file: "fred.yaml", e: true}, "yml": {file: "fred.yml", e: true}, "nope": {file: "fred.txt"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, isManifest(u.file)) }) } } func TestIsKustomized(t *testing.T) { uu := map[string]struct { path string e bool }{ "toast": {path: "testdata/fred"}, "yaml": {path: "testdata/kmanifests", e: true}, "yml": {path: "testdata/k1manifests", e: true}, "noExt": {path: "testdata/k2manifests", e: true}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, isKustomized(u.path)) }) } } ================================================ FILE: internal/view/dir_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDir(t *testing.T) { v := view.NewDir("/fred") require.NoError(t, v.Init(makeCtx(t))) assert.Equal(t, "Directory", v.Name()) assert.Len(t, v.Hints(), 9) } ================================================ FILE: internal/view/dp.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const scaleDialogKey = "scale" // Deploy represents a deployment view. type Deploy struct { ResourceViewer } // NewDeploy returns a new deployment view. func NewDeploy(gvr *client.GVR) ResourceViewer { var d Deploy d.ResourceViewer = NewPortForwardExtender( NewVulnerabilityExtender( NewRestartExtender( NewScaleExtender( NewImageExtender( NewOwnerExtender( NewLogsExtender(NewBrowser(gvr), d.logOptions), ), ), ), ), ), ) d.AddBindKeysFn(d.bindKeys) d.GetTable().SetEnterFn(d.showPods) return &d } func (d *Deploy) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyZ: ui.NewKeyAction("ReplicaSets", d.replicaSetsCmd, true), }) } func (d *Deploy) logOptions(prev bool) (*dao.LogOptions, error) { path := d.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("you must provide a selection") } dp, err := d.getInstance(path) if err != nil { return nil, err } return podLogOptions(d.App(), path, prev, &dp.ObjectMeta, &dp.Spec.Template.Spec), nil } func (d *Deploy) replicaSetsCmd(evt *tcell.EventKey) *tcell.EventKey { dName := d.GetTable().GetSelectedItem() if dName == "" { return evt } dp, err := d.getInstance(dName) if err != nil { d.App().Flash().Err(err) return nil } showReplicasetsFromSelector(d.App(), dName, dp.Spec.Selector) return nil } func (d *Deploy) showPods(app *App, _ ui.Tabular, _ *client.GVR, fqn string) { dp, err := d.getInstance(fqn) if err != nil { app.Flash().Err(err) return } showPodsFromSelector(app, fqn, dp.Spec.Selector) } func (d *Deploy) getInstance(fqn string) (*appsv1.Deployment, error) { var dp dao.Deployment dp.Init(d.App().factory, d.GVR()) return dp.GetInstance(fqn) } // ---------------------------------------------------------------------------- // Helpers... func showPodsFromSelector(app *App, path string, sel *metav1.LabelSelector) { l, err := metav1.LabelSelectorAsSelector(sel) if err != nil { app.Flash().Err(err) return } showPods(app, path, l, "") } func showReplicasetsFromSelector(app *App, path string, sel *metav1.LabelSelector) { l, err := metav1.LabelSelectorAsSelector(sel) if err != nil { app.Flash().Err(err) return } showReplicasets(app, path, l, "") } ================================================ FILE: internal/view/dp_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDeploy(t *testing.T) { v := view.NewDeploy(client.DpGVR) require.NoError(t, v.Init(makeCtx(t))) assert.Equal(t, "Deployments", v.Name()) assert.Len(t, v.Hints(), 15) } ================================================ FILE: internal/view/drain_dialog.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "strconv" "time" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) const drainKey = "drain" // DrainFunc represents a drain callback function. type DrainFunc func(v ResourceViewer, sels []string, opts dao.DrainOptions) // ShowDrain pops a node drain dialog. func ShowDrain(view ResourceViewer, sels []string, opts dao.DrainOptions, okFn DrainFunc) { styles := view.App().Styles.Dialog() f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()). SetFieldBackgroundColor(styles.BgColor.Color()) f.AddInputField("GracePeriod:", strconv.Itoa(opts.GracePeriodSeconds), 0, nil, func(v string) { a, err := asIntOpt(v) if err != nil { view.App().Flash().Err(err) return } view.App().Flash().Clear() opts.GracePeriodSeconds = a }) f.AddInputField("Timeout:", opts.Timeout.String(), 0, nil, func(v string) { a, err := asDurOpt(v) if err != nil { view.App().Flash().Err(err) return } view.App().Flash().Clear() opts.Timeout = a }) f.AddCheckbox("Ignore DaemonSets:", opts.IgnoreAllDaemonSets, func(_ string, v bool) { opts.IgnoreAllDaemonSets = v }) f.AddCheckbox("Delete EmptyDir Data:", opts.DeleteEmptyDirData, func(_ string, v bool) { opts.DeleteEmptyDirData = v }) f.AddCheckbox("Force:", opts.Force, func(_ string, v bool) { opts.Force = v }) f.AddCheckbox("Disable Eviction:", opts.DisableEviction, func(_ string, v bool) { opts.DisableEviction = v }) pages := view.App().Content.Pages f.AddButton("Cancel", func() { DismissDrain(view, pages) }) f.AddButton("OK", func() { DismissDrain(view, pages) okFn(view, sels, opts) }) modal := tview.NewModalForm("", f) path := "Drain " if len(sels) == 1 { path += sels[0] } else { path += fmt.Sprintf("(%d) nodes", len(sels)) } path += "?" modal.SetText(path) modal.SetDoneFunc(func(int, string) { DismissDrain(view, pages) }) pages.AddPage(drainKey, modal, false, true) pages.ShowPage(drainKey) view.App().SetFocus(pages.GetPrimitive(drainKey)) } // DismissDrain dismiss the port forward dialog. func DismissDrain(v ResourceViewer, p *ui.Pages) { p.RemovePage(drainKey) v.App().SetFocus(p.CurrentPage().Item) } // ---------------------------------------------------------------------------- // Helpers... func asDurOpt(v string) (time.Duration, error) { d, err := time.ParseDuration(v) if err != nil { return 0, err } return d, nil } func asIntOpt(v string) (int, error) { i, err := strconv.Atoi(v) if err != nil { return 0, err } return i, nil } ================================================ FILE: internal/view/ds.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" appsv1 "k8s.io/api/apps/v1" ) // DaemonSet represents a daemon set custom viewer. type DaemonSet struct { ResourceViewer } // NewDaemonSet returns a new viewer. func NewDaemonSet(gvr *client.GVR) ResourceViewer { var d DaemonSet d.ResourceViewer = NewPortForwardExtender( NewVulnerabilityExtender( NewRestartExtender( NewImageExtender( NewOwnerExtender( NewLogsExtender(NewBrowser(gvr), d.logOptions), ), ), ), ), ) d.GetTable().SetEnterFn(d.showPods) return &d } func (d *DaemonSet) showPods(app *App, _ ui.Tabular, _ *client.GVR, path string) { var res dao.DaemonSet res.Init(app.factory, d.GVR()) ds, err := res.GetInstance(path) if err != nil { d.App().Flash().Err(err) return } showPodsFromSelector(app, path, ds.Spec.Selector) } func (d *DaemonSet) logOptions(prev bool) (*dao.LogOptions, error) { path := d.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("you must provide a selection") } ds, err := d.getInstance(path) if err != nil { return nil, err } return podLogOptions(d.App(), path, prev, &ds.ObjectMeta, &ds.Spec.Template.Spec), nil } func (d *DaemonSet) getInstance(fqn string) (*appsv1.DaemonSet, error) { var ds dao.DaemonSet ds.Init(d.App().factory, client.DsGVR) return ds.GetInstance(fqn) } ================================================ FILE: internal/view/ds_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDaemonSet(t *testing.T) { v := view.NewDaemonSet(client.DsGVR) require.NoError(t, v.Init(makeCtx(t))) assert.Equal(t, "DaemonSets", v.Name()) assert.Len(t, v.Hints(), 14) } ================================================ FILE: internal/view/env.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "log/slog" "regexp" "sort" "strconv" "strings" "github.com/derailed/k9s/internal/slogs" ) // Env represent K9s and K8s available environment variables. type Env map[string]string // EnvRX match $XXX, $!XXX, ${XXX} or ${!XXX} custom arg. // | // | (g2)(group 3) (g5)( group 6 ) // | ( group 1 ) ( group 4 ) // | ( group 0 ) var envRX = regexp.MustCompile(`(\$(!?)([\w\-]+))|(\$\{(!?)([\w\-%/: ]+)})`) // keyFromSubmatch extracts the name and inverse flag of a match. func keyFromSubmatch(m []string) (key string, inverse bool) { // group 1 matches $XXX and $!XXX args. if m[1] != "" { return m[3], m[2] == "!" } // group 4 matches ${XXX} and ${!XXX} args. return m[6], m[5] == "!" } // Substitute replaces env variable keys from in a string with their corresponding values. func (e Env) Substitute(arg string) (string, error) { matches := envRX.FindAllStringSubmatch(arg, -1) if len(matches) == 0 { return arg, nil } // To prevent the substitution starts with the shorter environment variable, // sort with the length of the found environment variables. sort.Slice(matches, func(i, j int) bool { return len(matches[i][0]) > len(matches[j][0]) }) for _, m := range matches { key, inverse := keyFromSubmatch(m) v, ok := e[strings.ToUpper(key)] if !ok { slog.Warn("No k9s environment matching key", slogs.Matches, matches, slogs.Key, key, ) continue } if b, err := strconv.ParseBool(v); err == nil { if inverse { b = !b } v = fmt.Sprintf("%t", b) } arg = strings.ReplaceAll(arg, m[0], v) } return arg, nil } ================================================ FILE: internal/view/env_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "testing" "github.com/stretchr/testify/assert" ) func TestEnvReplace(t *testing.T) { uu := map[string]struct { arg string err error e string }{ "no-args": {arg: "blee blah", e: "blee blah"}, "simple": {arg: "$A", e: "10"}, "substring": {arg: "$A and $AA", e: "10 and 20"}, "with-text": {arg: "Something $A", e: "Something 10"}, "noMatch": {arg: "blah blah and $BLEE", e: "blah blah and $BLEE"}, "lower": {arg: "And then $b happened", e: "And then blee happened"}, "dash": {arg: "$col0", e: "fred"}, "underline": {arg: "$RESOURCE_GROUP", e: "foo"}, "mix": {arg: "$col0 and then $a but $B", e: "fred and then 10 but blee"}, "subs": {arg: `{"spec" : {"suspend" : $COL0 }}`, e: `{"spec" : {"suspend" : fred }}`}, "boolean": {arg: "$COL-BOOL", e: "false"}, "invert": {arg: "$!COL-BOOL", e: "true"}, "simple_braces": {arg: "${A}", e: "10"}, "embed_braces": {arg: "blabla${A}blabla", e: "blabla10blabla"}, "open_braces": {arg: "${A", e: "${A"}, "closed_braces": {arg: "$A}", e: "10}"}, "substring_braces": {arg: "${A} and ${AA}", e: "10 and 20"}, "with-text_braces": {arg: "Something ${A}", e: "Something 10"}, "noMatch_braces": {arg: "blah blah and ${BLEE}", e: "blah blah and ${BLEE}"}, "lower_braces": {arg: "And then ${b} happened", e: "And then blee happened"}, "dash_braces": {arg: "${col0}", e: "fred"}, "underline_braces": {arg: "${RESOURCE_GROUP}", e: "foo"}, "mix_braces": {arg: "${col0} and then ${a} but ${B}", e: "fred and then 10 but blee"}, "subs_braces": {arg: `{"spec" : {"suspend" : ${COL0} }}`, e: `{"spec" : {"suspend" : fred }}`}, "boolean_braces": {arg: "${COL-BOOL}", e: "false"}, "invert_braces": {arg: "${!COL-BOOL}", e: "true"}, "special_braces": {arg: "${COL-%CPU/L}/${COL-MEM/R:L}", e: "10/32:32"}, "space_braces": {arg: "${READINESS GATES}", e: "bar"}, } e := Env{ "A": "10", "AA": "20", "B": "blee", "COL0": "fred", "FRED": "fred", "COL-NAME": "zorg", "COL-BOOL": "false", "COL-%CPU/L": "10", "COL-MEM/R:L": "32:32", "RESOURCE_GROUP": "foo", "READINESS GATES": "bar", } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { a, err := e.Substitute(u.arg) assert.Equal(t, u.err, err) assert.Equal(t, u.e, a) }) } } ================================================ FILE: internal/view/event.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) const faultsFilter = "Warning|Error" // Event represents a command alias view. type Event struct { ResourceViewer } // NewEvent returns a new alias view. func NewEvent(gvr *client.GVR) ResourceViewer { e := Event{ ResourceViewer: NewBrowser(gvr), } e.AddBindKeysFn(e.bindKeys) e.GetTable().SetSortCol("LAST SEEN", false) return &e } func (e *Event) bindKeys(aa *ui.KeyActions) { aa.Delete(tcell.KeyCtrlD, ui.KeyE, ui.KeyA) aa.Bulk(ui.KeyMap{ tcell.KeyCtrlZ: ui.NewKeyAction("Toggle Faults", e.toggleFaults, false), }) } func (e *Event) toggleFaults(*tcell.EventKey) *tcell.EventKey { b, ok := e.ResourceViewer.(*Browser) if !ok { return nil } filter := b.CmdBuff().GetText() if filter == faultsFilter { e.SetFilter("", true) e.App().Flash().Info("Showing all events") } else { e.SetFilter(faultsFilter, true) e.App().Flash().Info("Showing Warning and Error events only") } return nil } ================================================ FILE: internal/view/exec.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "os" "os/exec" "os/signal" "strings" "syscall" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui/dialog" "github.com/fatih/color" v1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) const ( shellCheck = `command -v bash >/dev/null && exec bash || exec sh` bannerFmt = "<> Pod: %s | Container: %s \n" outputPrefix = "[output]" ) var editorEnvVars = []string{"K9S_EDITOR", "KUBE_EDITOR", "EDITOR"} type shellOpts struct { clear, background bool pipes []string binary string banner string args []string } func (s shellOpts) String() string { return fmt.Sprintf("%s %s", s.binary, strings.Join(s.args, " ")) } func runK(a *App, opts *shellOpts) error { bin, err := exec.LookPath("kubectl") if errors.Is(err, exec.ErrDot) { return fmt.Errorf("kubectl command must not be in the current working directory: %w", err) } if err != nil { return fmt.Errorf("kubectl command is not in your path: %w", err) } args := []string{opts.args[0]} if u, err := a.Conn().Config().ImpersonateUser(); err == nil { args = append(args, "--as", u) } if g, err := a.Conn().Config().ImpersonateGroups(); err == nil { args = append(args, "--as-group", g) } if isInsecure := a.Conn().Config().Flags().Insecure; isInsecure != nil && *isInsecure { args = append(args, "--insecure-skip-tls-verify") } args = append(args, "--context", a.Config.K9s.ActiveContextName()) if cfg := a.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { args = append(args, "--kubeconfig", *cfg) } if len(args) > 0 { opts.args = append(args, opts.args[1:]...) } opts.binary = bin suspended, errChan, stChan := run(a, opts) if !suspended { return fmt.Errorf("unable to run command") } for v := range stChan { slog.Debug("stdout", slogs.Line, v) } var errs error for e := range errChan { errs = errors.Join(errs, e) } return errs } func run(a *App, opts *shellOpts) (ok bool, errC chan error, outC chan string) { errChan := make(chan error, 1) statusChan := make(chan string, 1) if opts.background { if err := execute(opts, statusChan); err != nil { errChan <- err a.Flash().Errf("Exec failed %q: %s", opts, err) } close(errChan) return true, errChan, statusChan } a.Halt() defer a.Resume() return a.Suspend(func() { if err := execute(opts, statusChan); err != nil { errChan <- err a.Flash().Errf("Exec failed %q: %s", opts, err) } close(errChan) }), errChan, statusChan } func edit(a *App, opts *shellOpts) bool { var ( bin string err error ) for _, e := range editorEnvVars { env := os.Getenv(e) if env == "" { continue } // There may be situations where the user sets the editor as the binary // followed by some arguments (e.g. "code -w" to make it work with vscode) // // In such cases, the actual binary is only the first token envTokens := strings.Split(env, " ") if bin, err = exec.LookPath(envTokens[0]); err == nil { // Make sure the path is at the end (this allows running editors // with custom options) if len(envTokens) > 1 { originalArgs := opts.args opts.args = envTokens[1:] opts.args = append(opts.args, originalArgs...) } break } } if bin == "" { a.Flash().Errf("You must set at least one of those env vars: %s", strings.Join(editorEnvVars, "|")) return false } opts.binary, opts.background = bin, false suspended, errChan, _ := run(a, opts) if !suspended { a.Flash().Errf("edit command failed") } status := true for e := range errChan { a.Flash().Err(e) status = false } return status } func execute(opts *shellOpts, statusChan chan<- string) error { if opts.clear { clearScreen() } ctx, cancel := context.WithCancel(context.Background()) defer func() { if !opts.background { cancel() clearScreen() } }() var interrupted bool sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func(cancel context.CancelFunc) { defer slog.Debug("Got signal canceled") select { case sig := <-sigChan: slog.Debug("Command canceled with signal", slogs.Sig, sig) cancel() case <-ctx.Done(): slog.Debug("Signal context canceled!") } interrupted = true }(cancel) cmds := make([]*exec.Cmd, 0, 1) cmd := exec.CommandContext(ctx, opts.binary, opts.args...) slog.Debug("Exec command", slogs.Command, opts) if env := os.Getenv("K9S_EDITOR"); env != "" { // There may be situations where the user sets the editor as the binary // followed by some arguments (e.g. "code -w" to make it work with vscode) // // In such cases, the actual binary is only the first token binTokens := strings.Split(env, " ") if bin, err := exec.LookPath(binTokens[0]); err == nil { binTokens[0] = bin cmd.Env = append(os.Environ(), fmt.Sprintf("KUBE_EDITOR=%s", strings.Join(binTokens, " "))) } } cmds = append(cmds, cmd) for _, p := range opts.pipes { tokens := strings.Split(p, " ") if len(tokens) < 2 { continue } cmd := exec.CommandContext(ctx, tokens[0], tokens[1:]...) slog.Debug("Exec command", slogs.Command, cmd) cmds = append(cmds, cmd) } var o, e bytes.Buffer err := pipe(ctx, opts, statusChan, &o, &e, cmds...) if err != nil && !interrupted { slog.Error("Pipe Exec failed", slogs.Error, err, slogs.Command, cmds, ) return errors.Join(err, fmt.Errorf("%s", e.String())) } return nil } func runKu(ctx context.Context, a *App, opts *shellOpts) (string, error) { bin, err := exec.LookPath("kubectl") if errors.Is(err, exec.ErrDot) { slog.Error("Kubectl exec can not reside in current working directory", slogs.Error, err) return "", err } if err != nil { slog.Error("Kubectl exec not found", slogs.Error, err) return "", err } var args []string if u, err := a.Conn().Config().ImpersonateUser(); err == nil { args = append(args, "--as", u) } if g, err := a.Conn().Config().ImpersonateGroups(); err == nil { args = append(args, "--as-group", g) } args = append(args, "--context", a.Config.K9s.ActiveContextName()) if cfg := a.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { args = append(args, "--kubeconfig", *cfg) } if len(args) > 0 { opts.args = append(args, opts.args...) } opts.binary, opts.background = bin, false return oneShoot(ctx, opts) } func oneShoot(ctx context.Context, opts *shellOpts) (string, error) { if opts.clear { clearScreen() } slog.Debug("Executing command", slogs.Bin, opts.binary, slogs.Args, strings.Join(opts.args, " "), ) cmd := exec.CommandContext(ctx, opts.binary, opts.args...) var err error buff := bytes.NewBufferString("") cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, buff, buff _, _ = cmd.Stdout.Write([]byte(opts.banner)) err = cmd.Run() return strings.Trim(buff.String(), "\n"), err } func clearScreen() { fmt.Print("\033[H\033[2J") } const ( k9sShell = "k9s-shell" k9sShellRetryCount = 50 k9sShellRetryDelay = 2 * time.Second ) func launchNodeShell(v model.Igniter, a *App, node string) { if err := nukeK9sShell(a); err != nil { a.Flash().Errf("Cleaning node shell failed: %s", err) return } msg := fmt.Sprintf("Launching node shell on %s...", node) d := a.Styles.Dialog() dialog.ShowPrompt(&d, a.Content.Pages, "Launching", msg, func(ctx context.Context) { err := launchShellPod(ctx, a, node) if err != nil { if !errors.Is(err, context.Canceled) { a.Flash().Errf("Launching node shell failed: %s", err) } return } go launchPodShell(v, a) }, func() { if err := nukeK9sShell(a); err != nil { a.Flash().Errf("Cleaning node shell failed: %s", err) return } }) } func launchPodShell(v model.Igniter, a *App) { if a.Config.K9s.ShellPod == nil { slog.Error("Shell pod not configured!") return } defer func() { if err := nukeK9sShell(a); err != nil { a.Flash().Errf("Launching node shell failed: %s", err) return } }() v.Stop() defer v.Start() ns := a.Config.K9s.ShellPod.Namespace if err := sshIn(a, client.FQN(ns, k9sShellPodName()), k9sShell); err != nil { a.Flash().Errf("Launching node shell failed: %s", err) } } func sshIn(a *App, fqn, co string) error { cfg := a.Config.K9s.ShellPod platform, err := getPodOS(a.factory, fqn) if err != nil { slog.Warn("os detect failed", slogs.Error, err) } args := buildShellArgs("exec", fqn, co, a.Conn().Config().Flags()) args = append(args, "--") if len(cfg.Command) > 0 { args = append(args, cfg.Command...) args = append(args, cfg.Args...) } else { if platform == windowsOS { args = append(args, "--", powerShell) } args = append(args, "sh", "-c", shellCheck) } slog.Debug("Running command with args", slogs.Args, args) c := color.New(color.BgGreen).Add(color.FgBlack).Add(color.Bold) err = runK(a, &shellOpts{ clear: true, banner: c.Sprintf(bannerFmt, fqn, co), args: args}, ) if err != nil { return fmt.Errorf("shell exec failed: %w", err) } return nil } func nukeK9sShell(a *App) error { ct, err := a.Config.K9s.ActiveContext() if err != nil { return err } if !ct.FeatureGates.NodeShell || a.Config.K9s.ShellPod == nil { return nil } ns := a.Config.K9s.ShellPod.Namespace ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() dial, err := a.Conn().Dial() if err != nil { return err } err = dial.CoreV1().Pods(ns).Delete(ctx, k9sShellPodName(), metav1.DeleteOptions{}) if kerrors.IsNotFound(err) { return nil } return err } func launchShellPod(ctx context.Context, a *App, node string) error { var ( spo = a.Config.K9s.ShellPod spec = k9sShellPod(node, spo) ) dial, err := a.Conn().Dial() if err != nil { return err } conn := dial.CoreV1().Pods(spo.Namespace) if _, err = conn.Create(ctx, spec, metav1.CreateOptions{}); err != nil { return err } for i := range k9sShellRetryCount { o, err := a.factory.Get(client.PodGVR, client.FQN(spo.Namespace, k9sShellPodName()), true, labels.Everything()) if err != nil { select { case <-ctx.Done(): return ctx.Err() case <-time.After(k9sShellRetryDelay): continue } } var pod v1.Pod if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod); err != nil { return err } slog.Debug("Checking k9s shell pod retries", slogs.Retry, i, slogs.PodPhase, pod.Status.Phase, ) if pod.Status.Phase == v1.PodRunning { return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(k9sShellRetryDelay): } } return fmt.Errorf("unable to launch shell pod on node %s", node) } func k9sShellPodName() string { return fmt.Sprintf("%s-%d", k9sShell, os.Getpid()) } func k9sShellPod(node string, cfg *config.ShellPod) *v1.Pod { var grace int64 var priv = true slog.Debug("Shell pod config", slogs.ShellPodCfg, cfg) c := v1.Container{ Name: k9sShell, Image: cfg.Image, ImagePullPolicy: cfg.ImagePullPolicy, VolumeMounts: []v1.VolumeMount{ { Name: "root-vol", MountPath: "/host", ReadOnly: true, }, }, Resources: asResource(cfg.Limits), Stdin: true, TTY: cfg.TTY, SecurityContext: &v1.SecurityContext{ Privileged: &priv, }, } v := []v1.Volume{ { Name: "root-vol", VolumeSource: v1.VolumeSource{ HostPath: &v1.HostPathVolumeSource{ Path: "/", }, }, }, } if len(cfg.Command) != 0 { c.Command = cfg.Command } if len(cfg.Args) > 0 { c.Args = cfg.Args } if len(cfg.HostPathVolume) > 0 { for _, h := range cfg.HostPathVolume { c.VolumeMounts = append(c.VolumeMounts, v1.VolumeMount{ Name: h.Name, MountPath: h.MountPath, ReadOnly: h.ReadOnly, }) v = append(v, v1.Volume{ Name: h.Name, VolumeSource: v1.VolumeSource{ HostPath: &v1.HostPathVolumeSource{ Path: h.HostPath, }, }, }) } } return &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: k9sShellPodName(), Namespace: cfg.Namespace, Labels: cfg.Labels, }, Spec: v1.PodSpec{ NodeName: node, RestartPolicy: v1.RestartPolicyNever, HostPID: true, HostNetwork: true, ImagePullSecrets: cfg.ImagePullSecrets, TerminationGracePeriodSeconds: &grace, Volumes: v, Containers: []v1.Container{c}, Tolerations: []v1.Toleration{ { Operator: v1.TolerationOperator("Exists"), }, }, }, } } func asResource(r config.Limits) v1.ResourceRequirements { return v1.ResourceRequirements{ Limits: v1.ResourceList{ v1.ResourceCPU: resource.MustParse(r[v1.ResourceCPU]), v1.ResourceMemory: resource.MustParse(r[v1.ResourceMemory]), }, } } func pipe(_ context.Context, opts *shellOpts, statusChan chan<- string, w, e *bytes.Buffer, cmds ...*exec.Cmd) error { if len(cmds) == 0 { return nil } if len(cmds) == 1 { cmd := cmds[0] if opts.background { go func() { cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, w, e if err := cmd.Run(); err != nil { slog.Error("Command exec failed", slogs.Error, err) } else { for _, l := range strings.Split(w.String(), "\n") { if l != "" { statusChan <- fmt.Sprintf("%s %s", outputPrefix, l) } } statusChan <- fmt.Sprintf("Command completed successfully: %q", render.Truncate(cmd.String(), 20)) slog.Info("Command ran successfully", slogs.Command, cmd.String()) } close(statusChan) }() return nil } cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr _, _ = cmd.Stdout.Write([]byte(opts.banner)) slog.Debug("Exec started") err := cmd.Run() var ex *exec.ExitError // Check if exec failed from a signal if errors.As(err, &ex) && !ex.Exited() { return nil } slog.Debug("Command exec done", slogs.Error, err) if err == nil { statusChan <- fmt.Sprintf("Command completed successfully: %q", cmd.String()) } close(statusChan) if err != nil { err = fmt.Errorf("command failed. Check k9s logs: %w", err) } return err } last := len(cmds) - 1 for i := range cmds { cmds[i].Stderr = os.Stderr if i+1 < len(cmds) { r, w := io.Pipe() cmds[i].Stdout, cmds[i+1].Stdin = w, r } } cmds[last].Stdout = os.Stdout for _, cmd := range cmds { slog.Debug("Starting command", slogs.Command, cmd) if err := cmd.Start(); err != nil { return err } } return cmds[len(cmds)-1].Wait() } ================================================ FILE: internal/view/group.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // Group presents a RBAC group viewer. type Group struct { ResourceViewer } // NewGroup returns a new subject viewer. func NewGroup(gvr *client.GVR) ResourceViewer { g := Group{ResourceViewer: NewBrowser(gvr)} g.AddBindKeysFn(g.bindKeys) g.SetContextFn(g.subjectCtx) return &g } func (g *Group) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, ui.KeyShiftP, tcell.KeyCtrlSpace, ui.KeySpace) aa.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("Rules", g.policyCmd, true), }) } func (*Group) subjectCtx(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeySubjectKind, "Group") } func (g *Group) policyCmd(evt *tcell.EventKey) *tcell.EventKey { path := g.GetTable().GetSelectedItem() if path == "" { return evt } if err := g.App().inject(NewPolicy(g.App(), "Group", path), false); err != nil { g.App().Flash().Err(err) } return nil } ================================================ FILE: internal/view/helm_chart.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // HelmChart represents a helm chart view. type HelmChart struct { ResourceViewer } // NewHelmChart returns a new helm-chart view. func NewHelmChart(gvr *client.GVR) ResourceViewer { c := HelmChart{ ResourceViewer: NewValueExtender(NewBrowser(gvr)), } c.GetTable().SetBorderFocusColor(tcell.ColorMediumSpringGreen) c.GetTable().SetSelectedStyle(tcell.StyleDefault. Foreground(tcell.ColorWhite). Background(tcell.ColorMediumSpringGreen).Attributes(tcell.AttrNone)) c.AddBindKeysFn(c.bindKeys) c.GetTable().SetEnterFn(c.viewReleases) c.SetContextFn(c.chartContext) return &c } func (*HelmChart) chartContext(ctx context.Context) context.Context { return ctx } func (c *HelmChart) bindKeys(aa *ui.KeyActions) { aa.Delete(tcell.KeyCtrlS) aa.Bulk(ui.KeyMap{ ui.KeyR: ui.NewKeyAction("Releases", c.historyCmd, true), }) } func (c *HelmChart) viewReleases(app *App, _ ui.Tabular, _ *client.GVR, _ string) { v := NewHistory(client.HmhGVR) v.SetContextFn(c.helmContext) if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func (c *HelmChart) historyCmd(evt *tcell.EventKey) *tcell.EventKey { path := c.GetTable().GetSelectedItem() if path == "" { return evt } c.viewReleases(c.App(), c.GetTable().GetModel(), c.GVR(), path) return nil } func (c *HelmChart) helmContext(ctx context.Context) context.Context { path := c.GetTable().GetSelectedItem() if path == "" { return ctx } ctx = context.WithValue(ctx, internal.KeyFQN, path) return context.WithValue(ctx, internal.KeyPath, path) } ================================================ FILE: internal/view/helm_history.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render/helm" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" ) // History represents a helm History view. type History struct { ResourceViewer Values *model.RevValues } // NewHistory returns a new helm-history view. func NewHistory(gvr *client.GVR) ResourceViewer { h := History{ ResourceViewer: NewValueExtender(NewBrowser(gvr)), } h.GetTable().SetColorerFn(helm.History{}.ColorerFunc()) h.GetTable().SetBorderFocusColor(tcell.ColorMediumSpringGreen) h.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorMediumSpringGreen).Attributes(tcell.AttrNone)) h.AddBindKeysFn(h.bindKeys) h.SetContextFn(h.HistoryContext) h.GetTable().SetEnterFn(h.getValsCmd) return &h } // Init initializes the view func (h *History) Init(ctx context.Context) error { if err := h.ResourceViewer.Init(ctx); err != nil { return err } h.GetTable().SetSortCol("REVISION", false) return nil } func (*History) HistoryContext(ctx context.Context) context.Context { return ctx } func (h *History) bindKeys(aa *ui.KeyActions) { if !h.App().Config.IsReadOnly() { h.bindDangerousKeys(aa) } aa.Delete(ui.KeyShiftA, ui.KeyShiftN, tcell.KeyCtrlS, tcell.KeyCtrlSpace, ui.KeySpace, tcell.KeyCtrlD) aa.Bulk(ui.KeyMap{ ui.KeyShiftN: ui.NewKeyAction("Sort Revision", h.GetTable().SortColCmd("REVISION", true), false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", h.GetTable().SortColCmd("AGE", true), false), }) } func (h *History) getValsCmd(app *App, _ ui.Tabular, _ *client.GVR, path string) { ns, n := client.Namespaced(path) tt := strings.Split(n, ":") if len(tt) < 2 { app.Flash().Err(fmt.Errorf("unable to parse version in %q", path)) return } name, rev := tt[0], tt[1] h.Values = model.NewRevValues(h.GVR(), client.FQN(ns, name), rev) v := NewLiveView(h.App(), "Values", h.Values) if err := v.app.inject(v, false); err != nil { v.app.Flash().Err(err) } } func (h *History) bindDangerousKeys(aa *ui.KeyActions) { aa.Add(ui.KeyR, ui.NewKeyActionWithOpts("RollBackTo...", h.rollbackCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }, )) } func (h *History) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { path := h.GetTable().GetSelectedItem() if path == "" { return evt } ns, nrev := client.Namespaced(path) tt := strings.Split(nrev, ":") n, rev := nrev, "" if len(tt) == 2 { n, rev = tt[0], tt[1] } h.Stop() defer h.Start() msg := fmt.Sprintf("RollingBack chart [yellow::b]%s[-::-] to release <[orangered::b]%s[-::-]>?", n, rev) dialog.ShowConfirmAck(h.App().App, h.App().Content.Pages, n, false, "Confirm Rollback", msg, func() { ctx, cancel := context.WithTimeout(context.Background(), h.App().Conn().Config().CallTimeout()) defer cancel() if err := h.rollback(ctx, client.FQN(ns, n), rev); err != nil { h.App().Flash().Err(err) } else { h.App().Flash().Infof("Rollout restart in progress for char `%s...", n) } }, func() {}) return nil } func (h *History) rollback(ctx context.Context, path, rev string) error { var hm dao.HelmHistory hm.Init(h.App().factory, h.GVR()) if err := hm.Rollback(ctx, path, rev); err != nil { return err } h.Refresh() return nil } ================================================ FILE: internal/view/help.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "sort" "strconv" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "k8s.io/apimachinery/pkg/labels" ) const ( helpTitle = "Help" helpTitleFmt = " [aqua::b]%s " ) // HelpFunc processes menu hints. type HelpFunc func() model.MenuHints // Help presents a help viewer. type Help struct { *Table styles *config.Styles hints HelpFunc maxKey, maxDesc, maxRows int } // NewHelp returns a new help viewer. func NewHelp(app *App) *Help { return &Help{ Table: NewTable(client.HlpGVR), hints: app.Content.Top().Hints, } } func (*Help) SetCommand(*cmd.Interpreter) {} func (*Help) SetFilter(string, bool) {} func (*Help) SetLabelSelector(labels.Selector, bool) {} // Init initializes the component. func (h *Help) Init(ctx context.Context) error { if err := h.Table.Init(ctx); err != nil { return err } h.SetSelectable(false, false) h.resetTitle() h.SetBorder(true) h.SetBorderPadding(0, 0, 1, 1) h.bindKeys() h.build() h.app.Styles.AddListener(h) h.StylesChanged(h.app.Styles) return nil } // InCmdMode checks if prompt is active. func (*Help) InCmdMode() bool { return false } // StylesChanged notifies skin changed. func (h *Help) StylesChanged(s *config.Styles) { h.styles = s h.SetBackgroundColor(s.BgColor()) h.updateStyle() } func (h *Help) bindKeys() { h.Actions().Delete(ui.KeySpace, tcell.KeyCtrlSpace, tcell.KeyCtrlS, ui.KeySlash) h.Actions().Bulk(ui.KeyMap{ tcell.KeyEscape: ui.NewKeyAction("Back", h.app.PrevCmd, true), ui.KeyQ: ui.NewKeyAction("Back", h.app.PrevCmd, false), ui.KeyHelp: ui.NewKeyAction("Back", h.app.PrevCmd, false), tcell.KeyEnter: ui.NewKeyAction("Back", h.app.PrevCmd, false), }) } func (h *Help) computeMaxes(hh model.MenuHints) { h.maxKey, h.maxDesc = 0, 0 for _, hint := range hh { if len(hint.Mnemonic) > h.maxKey { h.maxKey = len(hint.Mnemonic) } if len(hint.Description) > h.maxDesc { h.maxDesc = len(hint.Description) } } h.maxKey += 2 } func (h *Help) computeExtraMaxes(ee map[string]string) { for k, v := range ee { if len(k) > h.maxDesc { h.maxDesc = len(k) } if len(v) > h.maxKey { h.maxKey = len(v) } } } func (h *Help) build() { h.Clear() sections := []string{"RESOURCE", "GENERAL", "NAVIGATION"} h.maxRows = len(h.showGeneral()) ff := []HelpFunc{ h.hints, h.showGeneral, h.showNav, } var col int extras := h.app.Content.Top().ExtraHints() for i, section := range sections { hh := ff[i]() sort.Sort(hh) h.computeMaxes(hh) if extras != nil { h.computeExtraMaxes(extras) } h.addSection(col, section, hh) if i == 0 && extras != nil { h.addExtras(extras, col, len(hh)) } col += 2 } if hh, err := h.showHotKeys(); err == nil { h.computeMaxes(hh) h.addSection(col, "HOTKEYS", hh) } } func (h *Help) addExtras(extras map[string]string, col, size int) { kk := make([]string, 0, len(extras)) for k := range extras { kk = append(kk, k) } sort.StringSlice(kk).Sort() row := size + 1 for _, k := range kk { h.SetCell(row, col, padCell(extras[k], h.maxKey)) h.SetCell(row, col+1, padCell(k, h.maxDesc)) row++ } } func (*Help) showNav() model.MenuHints { return model.MenuHints{ { Mnemonic: "g", Description: "Goto Top", }, { Mnemonic: "Shift-g", Description: "Goto Bottom", }, { Mnemonic: "Ctrl-b", Description: "Page Up", }, { Mnemonic: "Ctrl-f", Description: "Page Down", }, { Mnemonic: "h", Description: "Left", }, { Mnemonic: "l", Description: "Right", }, { Mnemonic: "k", Description: "Up", }, { Mnemonic: "j", Description: "Down", }, { Mnemonic: "[", Description: "History Back", }, { Mnemonic: "]", Description: "History Forward", }, { Mnemonic: "-", Description: "Last Used Command", }, } } func (h *Help) showHotKeys() (model.MenuHints, error) { hh := config.NewHotKeys() if err := hh.Load(h.App().Config.ContextHotkeysPath()); err != nil { return nil, fmt.Errorf("no hotkey configuration found") } kk := make(sort.StringSlice, 0, len(hh.HotKey)) for k := range hh.HotKey { kk = append(kk, k) } kk.Sort() mm := make(model.MenuHints, 0, len(hh.HotKey)) for _, k := range kk { mm = append(mm, model.MenuHint{ Mnemonic: hh.HotKey[k].ShortCut, Description: hh.HotKey[k].Description, }) } return mm, nil } func (*Help) showGeneral() model.MenuHints { return model.MenuHints{ { Mnemonic: "?", Description: "Help", }, { Mnemonic: "Ctrl-a", Description: "Aliases", }, { Mnemonic: ":cmd", Description: "Command mode", }, { Mnemonic: "/term", Description: "Filter mode", }, { Mnemonic: "esc", Description: "Back/Clear", }, { Mnemonic: "q", Description: "Back", }, { Mnemonic: "tab", Description: "Field Next", }, { Mnemonic: "backtab", Description: "Field Previous", }, { Mnemonic: "Ctrl-r", Description: "Reload", }, { Mnemonic: "Ctrl-u", Description: "Command Clear", }, { Mnemonic: "Ctrl-e", Description: "Toggle Header", }, { Mnemonic: "Ctrl-g", Description: "Toggle Crumbs", }, { Mnemonic: ":q", Description: "Quit", }, { Mnemonic: "space", Description: "Mark", }, { Mnemonic: "Ctrl-space", Description: "Mark Range", }, { Mnemonic: "Ctrl-\\", Description: "Mark Clear", }, { Mnemonic: "Ctrl-s", Description: "Save", }, { Mnemonic: "shift-left", Description: "Select Previous Column", }, { Mnemonic: "shift-right", Description: "Select Next Column", }, } } func (h *Help) resetTitle() { h.SetTitle(fmt.Sprintf(helpTitleFmt, helpTitle)) } func (h *Help) addSpacer(c int) { cell := tview.NewTableCell(render.Pad("", h.maxKey)) cell.SetExpansion(1) h.SetCell(0, c, cell) } func (h *Help) addSection(c int, title string, hh model.MenuHints) { if len(hh) > h.maxRows { h.maxRows = len(hh) } row := 0 h.SetCell(row, c, h.titleCell(title)) h.addSpacer(c + 1) row++ for _, hint := range hh { col := c h.SetCell(row, col, padCellWithRef(ui.ToMnemonic(hint.Mnemonic), h.maxKey, hint.Mnemonic)) col++ h.SetCell(row, col, padCell(hint.Description, h.maxDesc)) row++ } if len(hh) >= h.maxRows { return } for i := h.maxRows - len(hh); i > 0; i-- { col := c h.SetCell(row, col, padCell("", h.maxKey)) col++ h.SetCell(row, col, padCell("", h.maxDesc)) row++ } } func (h *Help) updateStyle() { var ( style = tcell.StyleDefault.Background(h.styles.K9s.Help.BgColor.Color()) key = style.Foreground(h.styles.K9s.Help.KeyColor.Color()).Bold(true) numKey = style.Foreground(h.app.Styles.K9s.Help.NumKeyColor.Color()).Bold(true) info = style.Foreground(h.app.Styles.K9s.Help.FgColor.Color()) heading = style.Foreground(h.app.Styles.K9s.Help.SectionColor.Color()) ) for col := range h.GetColumnCount() { for row := range h.GetRowCount() { c := h.GetCell(row, col) if c == nil { continue } switch { case row == 0: c.SetStyle(heading) case col%2 != 0: c.SetStyle(info) default: if _, err := strconv.Atoi(extractRef(c)); err == nil { c.SetStyle(numKey) continue } c.SetStyle(key) } } } } // ---------------------------------------------------------------------------- // Helpers... func extractRef(c *tview.TableCell) string { if ref, ok := c.GetReference().(string); ok { return ref } return c.Text } func (h *Help) titleCell(title string) *tview.TableCell { c := tview.NewTableCell(title) c.SetTextColor(h.Styles().K9s.Help.SectionColor.Color()) c.SetAttributes(tcell.AttrBold) c.SetExpansion(1) c.SetAlign(tview.AlignLeft) return c } func padCellWithRef(s string, width int, ref any) *tview.TableCell { return padCell(s, width).SetReference(ref) } func padCell(s string, width int) *tview.TableCell { return tview.NewTableCell(render.Pad(s, width)) } ================================================ FILE: internal/view/help_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "strings" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHelp(t *testing.T) { ctx := makeCtx(t) app := ctx.Value(internal.KeyApp).(*view.App) po := view.NewPod(client.PodGVR) require.NoError(t, po.Init(ctx)) app.Content.Push(po) v := view.NewHelp(app) require.NoError(t, v.Init(ctx)) assert.Equal(t, 20, v.GetRowCount()) assert.Equal(t, 8, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) } ================================================ FILE: internal/view/helpers.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "fmt" "log/slog" "os" "regexp" "strconv" "strings" "github.com/atotto/clipboard" "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/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" ) func isBailoutEvt(evt *tcell.EventKey) bool { return evt.Name() == "Ctrl+C" } func aliases(m *v1.APIResource, aa sets.Set[string]) sets.Set[string] { ss := sets.New(aa.UnsortedList()...) ss.Insert(m.Name) ss.Insert(m.ShortNames...) if m.SingularName != "" { ss.Insert(m.SingularName) } return ss } func clipboardWrite(text string) error { if text != "" { return clipboard.WriteAll(text) } return nil } var bracketRX = regexp.MustCompile(`\[(.+)\[\]`) func sanitizeEsc(s string) string { return bracketRX.ReplaceAllString(s, `[$1]`) } func cpCmd(flash *model.Flash, v *tview.TextView) func(*tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { if err := clipboardWrite(sanitizeEsc(v.GetText(true))); err != nil { flash.Err(err) return evt } flash.Info("Content copied to clipboard...") return nil } } func parsePFAnn(s string) (port, lport string, ok bool) { tokens := strings.Split(s, ":") if len(tokens) != 2 { return } return tokens[0], tokens[1], true } func k8sEnv(c *client.Config) Env { ctx, err := c.CurrentContextName() if err != nil { ctx = render.NAValue } cluster, err := c.CurrentClusterName() if err != nil { cluster = render.NAValue } user, err := c.CurrentUserName() if err != nil { user = render.NAValue } groups, err := c.CurrentGroupNames() if err != nil { groups = []string{render.NAValue} } var cfg string kcfg := c.Flags().KubeConfig if kcfg != nil && *kcfg != "" { cfg = *kcfg } else { cfg = os.Getenv("KUBECONFIG") } return Env{ "CONTEXT": ctx, "CLUSTER": cluster, "USER": user, "GROUPS": strings.Join(groups, ","), "KUBECONFIG": cfg, } } func defaultEnv(c *client.Config, path string, header model1.Header, row *model1.Row) Env { env := k8sEnv(c) env["NAMESPACE"], env["NAME"] = client.Namespaced(path) if row == nil { return env } for _, col := range header.ColumnNames(true) { idx, ok := header.IndexOf(col, true) if ok && idx < len(row.Fields) { env["COL-"+col] = row.Fields[idx] } } return env } func describeResource(app *App, _ ui.Tabular, gvr *client.GVR, path string) { v := NewLiveView(app, "Describe", model.NewDescribe(gvr, path)) if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func showReplicasets(app *App, path string, labelSel labels.Selector, fieldSel string) { v := NewReplicaSet(client.RsGVR) v.SetContextFn(func(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, path) return context.WithValue(ctx, internal.KeyFields, fieldSel) }) v.SetLabelSelector(labelSel, true) ns, _ := client.Namespaced(path) if err := app.Config.SetActiveNamespace(ns); err != nil { slog.Error("Unable to set active namespace during show replicasets", slogs.Error, err) } if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func showPods(app *App, path string, labelSel labels.Selector, fieldSel string) { v := NewPod(client.PodGVR) v.SetContextFn(podCtx(app, path, fieldSel)) v.SetLabelSelector(labelSel, true) ns, _ := client.Namespaced(path) if err := app.Config.SetActiveNamespace(ns); err != nil { slog.Error("Unable to set active namespace during show pods", slogs.Error, err) } if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func podCtx(_ *App, path, fieldSel string) ContextFunc { return func(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, path) return context.WithValue(ctx, internal.KeyFields, fieldSel) } } func extractApp(ctx context.Context) (*App, error) { app, ok := ctx.Value(internal.KeyApp).(*App) if !ok { return nil, errors.New("no application found in context") } return app, nil } // AsKey maps a string representation of a key to a tcell key. func asKey(key string) (tcell.Key, error) { for k, v := range tcell.KeyNames { if key == v { return k, nil } } return 0, fmt.Errorf("invalid key specified: %q", key) } // FwFQN returns a fully qualified ns/name:container id. func fwFQN(po, co string) string { return po + "|" + co } func isTCPPort(p string) bool { return !strings.Contains(p, "UDP") } // ContainerID computes container ID based on ns/po/co. func containerID(path, co string) string { ns, n := client.Namespaced(path) po := strings.Split(n, "-")[0] return ns + "/" + po + ":" + co } // UrlFor computes fq url for a given benchmark configuration. func urlFor(cfg *config.BenchConfig, port string) string { host := "localhost" if cfg.HTTP.Host != "" { host = cfg.HTTP.Host } path := "/" if cfg.HTTP.Path != "" { path = cfg.HTTP.Path } return "http://" + host + ":" + port + path } func fqn(ns, n string) string { if ns == "" { return n } return ns + "/" + n } func decorateCpuMemHeaderRows(app *App, data *model1.TableData) { for colIndex, header := range data.Header() { var check string if header.Name == "%CPU/L" { check = config.CPU } if header.Name == "%MEM/L" { check = config.MEM } if check == "" { continue } data.RowsRange(func(_ int, re model1.RowEvent) bool { if re.Row.Fields[colIndex] == render.NAValue { return true } n, err := strconv.Atoi(re.Row.Fields[colIndex]) if err != nil { return true } if n > 100 { n = 100 } severity := app.Config.K9s.Thresholds.LevelFor(check, n) if severity == config.SeverityLow { return true } color := app.Config.K9s.Thresholds.SeverityColor(check, n) if color != "" { re.Row.Fields[colIndex] = "[" + color + "::b]" + re.Row.Fields[colIndex] } return true }) } } func matchTag(i int, s string) string { return `<<<"search_` + strconv.Itoa(i) + `">>>` + s + `<<<"">>>` } func linesWithRegions(lines []string, matches fuzzy.Matches) []string { ll := make([]string, len(lines)) copy(ll, lines) offsetForLine := make(map[int]int) for i, m := range matches { for _, loc := range dao.ContinuousRanges(m.MatchedIndexes) { start, end := loc[0]+offsetForLine[m.Index], loc[1]+offsetForLine[m.Index] line := ll[m.Index] if end > len(line) { end = len(line) } regionStr := matchTag(i, line[start:end]) ll[m.Index] = line[:start] + regionStr + line[end:] offsetForLine[m.Index] += len(regionStr) - (end - start) } } return ll } ================================================ FILE: internal/view/helpers_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "log/slog" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/tcell/v2" "github.com/sahilm/fuzzy" "github.com/stretchr/testify/assert" "k8s.io/cli-runtime/pkg/genericclioptions" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestParsePFAnn(t *testing.T) { uu := map[string]struct { ann, co, port string ok bool }{ "named-port": { ann: "c1:blee", co: "c1", port: "blee", ok: true, }, "port-num": { ann: "c1:1234", co: "c1", port: "1234", ok: true, }, "toast": { ann: "zorg", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { co, port, ok := parsePFAnn(u.ann) if u.ok { assert.Equal(t, u.co, co) assert.Equal(t, u.port, port) assert.Equal(t, u.ok, ok) } }) } } func TestExtractApp(t *testing.T) { app := NewApp(mock.NewMockConfig(t)) uu := map[string]struct { app *App err error }{ "cool": {app: app}, "not-cool": {err: errors.New("no application found in context")}, } for k := range uu { u := uu[k] ctx := context.Background() if u.app != nil { ctx = context.WithValue(ctx, internal.KeyApp, u.app) } t.Run(k, func(t *testing.T) { app, err := extractApp(ctx) assert.Equal(t, u.err, err) if err == nil { assert.Equal(t, u.app, app) } }) } } func TestFwFQN(t *testing.T) { uu := map[string]struct { po, co, e string }{ "cool": {po: "p1", co: "c1", e: "p1|c1"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, fwFQN(u.po, u.co)) }) } } func TestAsKey(t *testing.T) { uu := map[string]struct { k string err error e tcell.Key }{ "cool": {k: "Ctrl-A", e: tcell.KeyCtrlA}, "miss": {k: "fred", e: 0, err: errors.New(`invalid key specified: "fred"`)}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { key, err := asKey(u.k) assert.Equal(t, u.err, err) assert.Equal(t, u.e, key) }) } } func TestK8sEnv(t *testing.T) { cl, ctx, cfg, u := "cluster1", "context1", "cfg1", "user1" flags := genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ctx, AuthInfoName: &u, KubeConfig: &cfg, } c := client.NewConfig(&flags) env := k8sEnv(c) assert.Len(t, env, 5) assert.Equal(t, cl, env["CLUSTER"]) assert.Equal(t, ctx, env["CONTEXT"]) assert.Equal(t, u, env["USER"]) assert.Equal(t, render.NAValue, env["GROUPS"]) assert.Equal(t, cfg, env["KUBECONFIG"]) } func TestK9sEnv(t *testing.T) { cl, ctx, cfg, u := "cluster1", "context1", "cfg1", "user1" flags := genericclioptions.ConfigFlags{ ClusterName: &cl, Context: &ctx, AuthInfoName: &u, KubeConfig: &cfg, } c := client.NewConfig(&flags) h := model1.Header{ {Name: "A"}, {Name: "B"}, {Name: "C"}, } r := model1.Row{ Fields: []string{"a1", "b1", "c1"}, } env := defaultEnv(c, "fred/blee", h, &r) assert.Len(t, env, 10) assert.Equal(t, cl, env["CLUSTER"]) assert.Equal(t, ctx, env["CONTEXT"]) assert.Equal(t, u, env["USER"]) assert.Equal(t, render.NAValue, env["GROUPS"]) assert.Equal(t, cfg, env["KUBECONFIG"]) assert.Equal(t, "fred", env["NAMESPACE"]) assert.Equal(t, "blee", env["NAME"]) assert.Equal(t, "a1", env["COL-A"]) assert.Equal(t, "b1", env["COL-B"]) assert.Equal(t, "c1", env["COL-C"]) } func TestIsTCPPort(t *testing.T) { uu := map[string]struct { p string e bool }{ "tcp": {"80╱TCP", true}, "udp": {"80╱UDP", false}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, isTCPPort(u.p)) }) } } func TestFQN(t *testing.T) { uu := map[string]struct { ns, n, e string }{ "fullFQN": {"blee", "fred", "blee/fred"}, "allNS": {"", "fred", "fred"}, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, fqn(u.ns, u.n)) }) } } func TestUrlFor(t *testing.T) { uu := map[string]struct { cfg config.BenchConfig co, port string e string }{ "empty": { config.BenchConfig{}, "c1", "9000", "http://localhost:9000/", }, "path": { config.BenchConfig{ HTTP: config.HTTP{ Path: "/fred/blee", }, }, "c1", "9000", "http://localhost:9000/fred/blee", }, "host/path": { config.BenchConfig{ HTTP: config.HTTP{ Host: "zorg", Path: "/fred/blee", }, }, "c1", "9000", "http://zorg:9000/fred/blee", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, urlFor(&u.cfg, u.port)) }) } } func TestContainerID(t *testing.T) { uu := map[string]struct { path, co string e string }{ "plain": { "fred/blee", "c1", "fred/blee:c1", }, "podID": { "fred/blee-78f8b5d78c-f8588", "c1", "fred/blee:c1", }, "stsID": { "fred/blee-1", "c1", "fred/blee:c1", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, containerID(u.path, u.co)) }) } } func Test_linesWithRegions(t *testing.T) { uu := map[string]struct { lines []string matches fuzzy.Matches e []string }{ "empty-lines": { e: []string{}, }, "no-match": { lines: []string{"bar"}, e: []string{"bar"}, }, "single-match": { lines: []string{"foo", "bar", "baz"}, matches: fuzzy.Matches{ {Index: 1, MatchedIndexes: []int{0, 1, 2}}, }, e: []string{"foo", matchTag(0, "bar"), "baz"}, }, "single-character": { lines: []string{"foo", "bar", "baz"}, matches: fuzzy.Matches{ {Index: 1, MatchedIndexes: []int{1}}, }, e: []string{"foo", "b" + matchTag(0, "a") + "r", "baz"}, }, "multiple-matches": { lines: []string{"foo", "bar", "baz"}, matches: fuzzy.Matches{ {Index: 1, MatchedIndexes: []int{0, 1, 2}}, {Index: 2, MatchedIndexes: []int{0, 1, 2}}, }, e: []string{"foo", matchTag(0, "bar"), matchTag(1, "baz")}, }, "multiple-matches-same-line": { lines: []string{"foosfoo baz", "dfbarfoos bar"}, matches: fuzzy.Matches{ {Index: 0, MatchedIndexes: []int{0, 1, 2}}, {Index: 0, MatchedIndexes: []int{4, 5, 6}}, {Index: 1, MatchedIndexes: []int{5, 6, 7}}, }, e: []string{ matchTag(0, "foo") + "s" + matchTag(1, "foo") + " baz", "dfbar" + matchTag(2, "foo") + "s bar", }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { t.Parallel() assert.Equal(t, u.e, linesWithRegions(u.lines, u.matches)) }) } } func Test_sanitizeEsc(t *testing.T) { uu := map[string]struct { s string e string }{ "empty": {}, "empty-brackets": { s: "[]", e: "[]", }, "tag": { s: "[fred[]", e: "[fred]", }, } for k, u := range uu { t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, sanitizeEsc(u.s)) }) } } ================================================ FILE: internal/view/image_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" corev1 "k8s.io/api/core/v1" ) const imageKey = "setImage" type imageFormSpec struct { name, dockerImage, newDockerImage string init bool } func (m *imageFormSpec) modified() bool { newDockerImage := strings.TrimSpace(m.newDockerImage) return newDockerImage != "" && m.dockerImage != newDockerImage } func (m *imageFormSpec) imageSpec() dao.ImageSpec { ret := dao.ImageSpec{ Name: m.name, Init: m.init, } if m.modified() { ret.DockerImage = strings.TrimSpace(m.newDockerImage) } else { ret.DockerImage = m.dockerImage } return ret } // ImageExtender provides for overriding container images. type ImageExtender struct { ResourceViewer } // NewImageExtender returns a new extender. func NewImageExtender(r ResourceViewer) ResourceViewer { s := ImageExtender{ResourceViewer: r} s.AddBindKeysFn(s.bindKeys) return &s } func (s *ImageExtender) bindKeys(aa *ui.KeyActions) { if s.App().Config.IsReadOnly() { return } aa.Add(ui.KeyI, ui.NewKeyAction("Set Image", s.setImageCmd, false)) } func (s *ImageExtender) setImageCmd(*tcell.EventKey) *tcell.EventKey { path := s.GetTable().GetSelectedItem() if path == "" { return nil } s.Stop() defer s.Start() if err := s.showImageDialog(path); err != nil { s.App().Flash().Err(err) } return nil } func (s *ImageExtender) showImageDialog(path string) error { form, err := s.makeSetImageForm(path) if err != nil { return err } confirm := tview.NewModalForm("", form) confirm.SetText(fmt.Sprintf("Set image %s %s", s.GVR(), path)) confirm.SetDoneFunc(func(int, string) { s.dismissDialog() }) s.App().Content.AddPage(imageKey, confirm, false, false) s.App().Content.ShowPage(imageKey) return nil } func (s *ImageExtender) makeSetImageForm(fqn string) (*tview.Form, error) { podSpec, err := s.getPodSpec(fqn) if err != nil { return nil, err } formContainerLines := make([]*imageFormSpec, 0, len(podSpec.InitContainers)+len(podSpec.Containers)) for i := range podSpec.InitContainers { spec := podSpec.InitContainers[i] formContainerLines = append(formContainerLines, &imageFormSpec{init: true, name: spec.Name, dockerImage: spec.Image}) } for i := range podSpec.Containers { spec := podSpec.Containers[i] formContainerLines = append(formContainerLines, &imageFormSpec{name: spec.Name, dockerImage: spec.Image}) } styles := s.App().Styles.Dialog() f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()). AddButton("OK", func() { defer s.dismissDialog() var imageSpecsModified dao.ImageSpecs for _, v := range formContainerLines { if v.modified() { imageSpecsModified = append(imageSpecsModified, v.imageSpec()) } } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() if err := s.setImages(ctx, fqn, imageSpecsModified); err != nil { slog.Error("Unable to set image name", slogs.FQN, fqn, slogs.Error, err, ) s.App().Flash().Err(err) return } s.App().Flash().Infof("Resource %s:%s image updated successfully", s.GVR(), fqn) }). AddButton("Cancel", func() { s.dismissDialog() }) for i := range formContainerLines { ctn := formContainerLines[i] f.AddInputField(ctn.name, ctn.dockerImage, 0, nil, func(changed string) { ctn.newDockerImage = changed }) } for i := range f.GetButtonCount() { f.GetButton(i). SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()). SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } return f, nil } func (s *ImageExtender) dismissDialog() { s.App().Content.RemovePage(imageKey) } func (s *ImageExtender) getPodSpec(path string) (*corev1.PodSpec, error) { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return nil, err } resourceWPodSpec, ok := res.(dao.ContainsPodSpec) if !ok { return nil, fmt.Errorf("expecting a ContainsPodSpec for %q but got %T", s.GVR(), res) } return resourceWPodSpec.GetPodSpec(path) } func (s *ImageExtender) setImages(ctx context.Context, path string, imageSpecs dao.ImageSpecs) error { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return err } resourceWPodSpec, ok := res.(dao.ContainsPodSpec) if !ok { return fmt.Errorf("expecting a scalable resource for %q", s.GVR()) } return resourceWPodSpec.SetImages(ctx, path, imageSpecs) } ================================================ FILE: internal/view/img_scan.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "runtime" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) const ( imgScanTitle = "Scans" browseOSX = "open" browseLinux = "sensible-browser" cveGovURL = "https://nvd.nist.gov/vuln/detail/" ghsaURL = "https://github.com/advisories/" ) // ImageScan represents an image vulnerability scan view. type ImageScan struct { ResourceViewer } // NewImageScan returns a new scans view. func NewImageScan(gvr *client.GVR) ResourceViewer { v := ImageScan{} v.ResourceViewer = NewBrowser(gvr) v.AddBindKeysFn(v.bindKeys) v.GetTable().SetEnterFn(v.viewCVE) v.GetTable().SetSortCol("SEVERITY", true) return &v } // Name returns the component name. func (*ImageScan) Name() string { return imgScanTitle } func (i *ImageScan) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, ui.KeyShiftN, ui.KeyShiftS, tcell.KeyCtrlZ, tcell.KeyCtrlW) aa.Bulk(ui.KeyMap{ ui.KeyShiftL: ui.NewKeyAction("Sort Lib", i.GetTable().SortColCmd("LIBRARY", false), true), ui.KeyShiftS: ui.NewKeyAction("Sort Severity", i.GetTable().SortColCmd("SEVERITY", false), true), ui.KeyShiftF: ui.NewKeyAction("Sort Fixed-in", i.GetTable().SortColCmd("FIXED-IN", false), true), ui.KeyShiftV: ui.NewKeyAction("Sort Vulnerability", i.GetTable().SortColCmd("VULNERABILITY", false), true), }) } func (*ImageScan) viewCVE(app *App, _ ui.Tabular, _ *client.GVR, path string) { bin := browseLinux if runtime.GOOS == "darwin" { bin = browseOSX } tt := strings.Split(path, "|") if len(tt) < 7 { app.Flash().Errf("parse path failed: %s", path) } cve := tt[render.CVEParseIdx] site := cveGovURL if strings.Index(cve, "GHSA") == 0 { site = ghsaURL } site += cve ok, errChan, _ := run(app, &shellOpts{ background: true, binary: bin, args: []string{site}, }) if !ok { app.Flash().Errf("unable to run browser command") return } var errs error for e := range errChan { errs = errors.Join(e) } if errs != nil { app.Flash().Err(errs) } } ================================================ FILE: internal/view/job.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" batchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) // Job represents a job viewer. type Job struct { ResourceViewer } // NewJob returns a new viewer. func NewJob(gvr *client.GVR) ResourceViewer { var j Job j.ResourceViewer = NewVulnerabilityExtender( NewOwnerExtender( NewLogsExtender(NewBrowser(gvr), j.logOptions), ), ) j.GetTable().SetEnterFn(j.showPods) j.GetTable().SetSortCol("AGE", true) return &j } func (*Job) showPods(app *App, _ ui.Tabular, gvr *client.GVR, path string) { o, err := app.factory.Get(gvr, path, true, labels.Everything()) if err != nil { app.Flash().Err(err) return } var job batchv1.Job err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &job) if err != nil { app.Flash().Err(err) return } showPodsFromSelector(app, path, job.Spec.Selector) } func (j *Job) logOptions(prev bool) (*dao.LogOptions, error) { path := j.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("you must provide a selection") } job, err := j.getInstance(path) if err != nil { return nil, err } return podLogOptions(j.App(), path, prev, &job.ObjectMeta, &job.Spec.Template.Spec), nil } func (j *Job) getInstance(fqn string) (*batchv1.Job, error) { var job dao.Job job.Init(j.App().factory, client.JobGVR) return job.GetInstance(fqn) } ================================================ FILE: internal/view/live_view.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strconv" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "k8s.io/apimachinery/pkg/labels" ) const ( liveViewTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " yamlAction = "YAML" ) // LiveView represents a live text viewer. type LiveView struct { *tview.Flex title string model model.ResourceViewer text *tview.TextView actions *ui.KeyActions app *App cmdBuff *model.FishBuff currentRegion, maxRegions int cancel context.CancelFunc fullScreen bool managedField bool autoRefresh bool } // NewLiveView returns a live viewer. func NewLiveView(app *App, title string, m model.ResourceViewer) *LiveView { v := LiveView{ Flex: tview.NewFlex(), text: tview.NewTextView(), app: app, title: title, actions: ui.NewKeyActions(), currentRegion: 0, maxRegions: 0, cmdBuff: model.NewFishBuff('/', model.FilterBuffer), model: m, autoRefresh: app.Config.K9s.LiveViewAutoRefresh, } v.AddItem(v.text, 0, 1, true) return &v } func (*LiveView) SetCommand(*cmd.Interpreter) {} func (*LiveView) SetFilter(string, bool) {} func (*LiveView) SetLabelSelector(labels.Selector, bool) {} // Init initializes the viewer. func (v *LiveView) Init(_ context.Context) error { if v.title != "" { v.SetBorder(true) } v.text.SetScrollable(true).SetWrap(true).SetRegions(true) v.text.SetDynamicColors(true) v.text.SetHighlightColor(tcell.ColorOrange) v.SetTitleColor(tcell.ColorAqua) v.SetInputCapture(v.keyboard) v.SetBorderPadding(0, 0, 1, 1) v.updateTitle() v.app.Styles.AddListener(v) v.StylesChanged(v.app.Styles) v.setFullScreen(v.app.Config.K9s.UI.DefaultsToFullScreen) v.app.Prompt().SetModel(v.cmdBuff) v.cmdBuff.AddListener(v) v.bindKeys() v.SetInputCapture(v.keyboard) if v.model != nil { v.model.AddListener(v) } return nil } // InCmdMode checks if prompt is active. func (v *LiveView) InCmdMode() bool { return v.cmdBuff.InCmdMode() } // ResourceFailed notifies when there is an issue. func (v *LiveView) ResourceFailed(err error) { v.text.SetTextAlign(tview.AlignCenter) x, _, w, _ := v.GetRect() v.text.SetText(cowTalk(err.Error(), x+w)) } // ResourceChanged notifies when the filter changes. func (v *LiveView) ResourceChanged(lines []string, matches fuzzy.Matches) { v.app.QueueUpdateDraw(func() { v.text.SetTextAlign(tview.AlignLeft) v.currentRegion, v.maxRegions = 0, len(matches) if v.text.GetText(true) == "" { v.text.ScrollToBeginning() } lines = linesWithRegions(lines, matches) v.text.SetText(colorizeYAML(v.app.Styles.Views().Yaml, strings.Join(lines, "\n"))) v.text.Highlight() if v.currentRegion < v.maxRegions { v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() } v.updateTitle() }) } // BufferChanged indicates the buffer was changed. func (*LiveView) BufferChanged(_, _ string) {} // BufferCompleted indicates input was accepted. func (v *LiveView) BufferCompleted(text, _ string) { v.model.Filter(text) } // BufferActive indicates the buff activity changed. func (v *LiveView) BufferActive(state bool, k model.BufferKind) { v.app.BufferActive(state, k) } func (v *LiveView) bindKeys() { v.actions.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewSharedKeyAction("Filter", v.filterCmd, false), tcell.KeyEscape: ui.NewKeyAction("Back", v.resetCmd, false), ui.KeyQ: ui.NewKeyAction("Back", v.resetCmd, false), tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, false), ui.KeyC: ui.NewKeyAction("Copy", cpCmd(v.app.Flash(), v.text), true), ui.KeyF: ui.NewKeyAction("Toggle FullScreen", v.toggleFullScreenCmd, true), ui.KeyR: ui.NewKeyAction("Toggle Auto-Refresh", v.toggleRefreshCmd, true), ui.KeyN: ui.NewKeyAction("Next Match", v.nextCmd, true), ui.KeyShiftN: ui.NewKeyAction("Prev Match", v.prevCmd, true), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", v.activateCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", v.eraseCmd, false), }) if !v.app.Config.IsReadOnly() { v.actions.Add(ui.KeyE, ui.NewKeyAction("Edit", v.editCmd, true)) } if v.title == yamlAction { v.actions.Add(ui.KeyM, ui.NewKeyAction("Toggle ManagedFields", v.toggleManagedCmd, true)) } if _, ok := v.model.(model.EncDecResourceViewer); ok { v.actions.Add(ui.KeyX, ui.NewKeyAction("Toggle Decode", v.toggleEncodedDecodedCmd, true)) } } func (v *LiveView) toggleEncodedDecodedCmd(evt *tcell.EventKey) *tcell.EventKey { m, ok := v.model.(model.EncDecResourceViewer) if !ok { return evt } m.Toggle() v.Start() return nil } func (v *LiveView) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := v.model.GetPath() if path == "" { return evt } v.Stop() defer v.Start() if err := editRes(v.app, v.model.GVR(), path); err != nil { v.app.Flash().Err(err) } return nil } // ToggleRefreshCmd is used for pausing the refreshing of data on config map and secrets. func (v *LiveView) toggleRefreshCmd(*tcell.EventKey) *tcell.EventKey { v.autoRefresh = !v.autoRefresh if v.autoRefresh { v.Start() v.app.Flash().Info("Auto-refresh is enabled") return nil } v.Stop() v.app.Flash().Info("Auto-refresh is disabled") return nil } func (v *LiveView) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := v.actions.Get(ui.AsKey(evt)); ok { return a.Action(evt) } return evt } // StylesChanged notifies the skin changed. func (v *LiveView) StylesChanged(s *config.Styles) { v.SetBackgroundColor(s.BgColor()) v.text.SetTextColor(s.FgColor()) v.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) } // Actions returns menu actions. func (v *LiveView) Actions() *ui.KeyActions { return v.actions } // Name returns the component name. func (v *LiveView) Name() string { return v.title } // Start starts the view updater. func (v *LiveView) Start() { if v.autoRefresh { var ctx context.Context ctx, v.cancel = context.WithCancel(v.defaultCtx()) if err := v.model.Watch(ctx); err != nil { slog.Error("LiveView watcher failed", slogs.Error, err) } return } if err := v.model.Refresh(v.defaultCtx()); err != nil { slog.Error("LiveView refresh failed", slogs.Error, err) } } func (v *LiveView) defaultCtx() context.Context { return context.WithValue(context.Background(), internal.KeyFactory, v.app.factory) } // Stop terminates the updater. func (v *LiveView) Stop() { if v.cancel != nil { v.cancel() v.cancel = nil } v.app.Styles.RemoveListener(v) } // Hints returns menu hints. func (v *LiveView) Hints() model.MenuHints { return v.actions.Hints() } // ExtraHints returns additional hints. func (*LiveView) ExtraHints() map[string]string { return nil } func (v *LiveView) toggleManagedCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.managedField = !v.managedField v.model.SetOptions(v.defaultCtx(), map[string]bool{model.ManagedFieldsOpts: v.managedField}) v.app.Flash().Info("toggled managed fields") return nil } func (v *LiveView) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.setFullScreen(!v.fullScreen) return nil } func (v *LiveView) setFullScreen(isFullScreen bool) { v.fullScreen = isFullScreen v.SetFullScreen(isFullScreen) v.SetBorder(!isFullScreen) if isFullScreen { v.SetBorderPadding(0, 0, 0, 0) } else { v.SetBorderPadding(0, 0, 1, 1) } } func (v *LiveView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.Empty() { return evt } v.currentRegion++ if v.currentRegion >= v.maxRegions { v.currentRegion = 0 } v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() v.updateTitle() return nil } func (v *LiveView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.Empty() { return evt } v.currentRegion-- if v.currentRegion < 0 { v.currentRegion = v.maxRegions - 1 } v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() v.updateTitle() return nil } func (v *LiveView) filterCmd(*tcell.EventKey) *tcell.EventKey { v.model.Filter(v.cmdBuff.GetText()) v.cmdBuff.SetActive(false) v.updateTitle() return nil } func (v *LiveView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.app.ResetPrompt(v.cmdBuff) return nil } func (v *LiveView) eraseCmd(*tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.IsActive() { return nil } v.cmdBuff.Delete() return nil } func (v *LiveView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.InCmdMode() { v.cmdBuff.Reset() return v.app.PrevCmd(evt) } if v.cmdBuff.GetText() != "" { v.model.ClearFilter() } v.cmdBuff.SetActive(false) v.cmdBuff.Reset() v.updateTitle() return nil } func (v *LiveView) saveCmd(*tcell.EventKey) *tcell.EventKey { name := fmt.Sprintf("%s--%s", strings.Replace(v.model.GetPath(), "/", "-", 1), strings.ToLower(v.title)) if _, err := saveYAML(v.app.Config.K9s.ContextScreenDumpDir(), name, sanitizeEsc(v.text.GetText(true))); err != nil { v.app.Flash().Err(err) } else { v.app.Flash().Infof("File %q saved successfully!", name) } return nil } func (v *LiveView) updateTitle() { if v.title == "" { return } var fmat string if v.model != nil { fmat = fmt.Sprintf(liveViewTitleFmt, v.title, v.model.GetPath()) } var ( buff = v.cmdBuff.GetText() styles = v.app.Styles.Frame() ) if buff == "" { v.SetTitle(ui.SkinTitle(fmat, &styles)) return } if v.maxRegions > 0 { buff += fmt.Sprintf("[%d:%d]", v.currentRegion+1, v.maxRegions) } fmat += fmt.Sprintf(ui.SearchFmt, buff) v.SetTitle(ui.SkinTitle(fmat, &styles)) } ================================================ FILE: internal/view/live_view_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestLiveViewSetText(t *testing.T) { s := ` apiVersion: v1 data: the secret name you want to quote to use tls.","title":"secretName","type":"string"}},"required":["http","class","classInSpec"],"type":"object"} ` v := NewLiveView(NewApp(mock.NewMockConfig(t)), "fred", nil) require.NoError(t, v.Init(context.Background())) v.text.SetText(colorizeYAML(config.Yaml{}, s)) assert.Equal(t, s, sanitizeEsc(v.text.GetText(true))) } ================================================ FILE: internal/view/log.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "sync" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/color" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "k8s.io/apimachinery/pkg/labels" ) const ( logTitle = "logs" logMessage = "Waiting for logs...\n" logFmt = "([hilite:bg:]%s[-:bg:-])[[green:bg:b]%s[-:bg:-]] " logCoFmt = "([hilite:bg:]%s:[hilite:bg:b]%s[-:bg:-])[[green:bg:b]%s[-:bg:-]] " defaultFlushTimeout = 50 * time.Millisecond ) // Log represents a generic log viewer. type Log struct { *tview.Flex app *App logs *Logger indicator *LogIndicator ansiWriter io.Writer model *model.Log cancelFn context.CancelFunc cancelUpdates bool mx sync.Mutex follow bool columnLock bool requestOneRefresh bool } var _ model.Component = (*Log)(nil) // NewLog returns a new viewer. func NewLog(gvr *client.GVR, opts *dao.LogOptions) *Log { return &Log{ Flex: tview.NewFlex(), model: model.NewLog(gvr, opts, defaultFlushTimeout), } } func (*Log) SetCommand(*cmd.Interpreter) {} func (*Log) SetFilter(string, bool) {} func (*Log) SetLabelSelector(labels.Selector, bool) {} // Init initializes the viewer. func (l *Log) Init(ctx context.Context) (err error) { if l.app, err = extractApp(ctx); err != nil { return err } l.model.Configure(l.app.Config.K9s.Logger) l.SetBorder(true) l.SetDirection(tview.FlexRow) l.indicator = NewLogIndicator(l.app.Config, l.app.Styles, l.isContainerLogView()) l.AddItem(l.indicator, 1, 1, false) if !l.model.HasDefaultContainer() { l.indicator.ToggleAllContainers() } l.indicator.Refresh() l.logs = NewLogger(l.app) if e := l.logs.Init(ctx); e != nil { return e } l.logs.SetBorderPadding(0, 0, 1, 1) l.logs.SetText("[orange::d]" + logMessage) l.logs.SetWrap(l.app.Config.K9s.Logger.TextWrap) l.logs.SetMaxLines(l.app.Config.K9s.Logger.BufferSize) l.ansiWriter = tview.ANSIWriter(l.logs, l.app.Styles.Views().Log.FgColor.String(), l.app.Styles.Views().Log.BgColor.String()) l.AddItem(l.logs, 0, 1, true) l.bindKeys() l.StylesChanged(l.app.Styles) l.toggleFullScreen() l.model.Init(l.app.factory) l.updateTitle() l.follow = !l.app.Config.K9s.Logger.DisableAutoscroll l.columnLock = l.app.Config.K9s.Logger.ColumnLock l.model.ToggleShowTimestamp(l.app.Config.K9s.Logger.ShowTime) return nil } // InCmdMode checks if prompt is active. func (l *Log) InCmdMode() bool { return l.logs.cmdBuff.InCmdMode() } // LogCanceled indicates no more logs are coming. func (l *Log) LogCanceled() { slog.Debug("Logs watcher canceled!") l.Flush([][]byte{[]byte("\n🏁 [red::b]Stream exited! No more logs...")}) } // LogStop disables log flushes. func (l *Log) LogStop() { slog.Debug("Logs watcher stopped!") l.mx.Lock() defer l.mx.Unlock() l.cancelUpdates = true } // LogResume resume log flushes. func (l *Log) LogResume() { l.mx.Lock() defer l.mx.Unlock() l.cancelUpdates = false } // LogCleared clears the logs. func (l *Log) LogCleared() { l.app.QueueUpdateDraw(func() { l.logs.Clear() }) } // LogFailed notifies an error occurred. func (l *Log) LogFailed(err error) { l.app.QueueUpdateDraw(func() { l.app.Flash().Err(err) if l.logs.GetText(true) == logMessage { l.logs.Clear() } if _, err = l.ansiWriter.Write([]byte(tview.Escape(color.Colorize(err.Error(), color.Red)))); err != nil { slog.Error("Log line write failed", slogs.Error, err) } }) } // LogChanged updates the logs. func (l *Log) LogChanged(lines [][]byte) { l.app.QueueUpdateDraw(func() { if l.logs.GetText(true) == logMessage { l.logs.Clear() } l.Flush(lines) }) } // BufferCompleted indicates input was accepted. func (l *Log) BufferCompleted(text, _ string) { l.model.Filter(text) l.updateTitle() } // BufferChanged indicates the buffer was changed. func (*Log) BufferChanged(_, _ string) {} // BufferActive indicates the buff activity changed. func (l *Log) BufferActive(state bool, k model.BufferKind) { l.app.BufferActive(state, k) } // StylesChanged reports skin changes. func (l *Log) StylesChanged(s *config.Styles) { l.SetBackgroundColor(s.Views().Log.BgColor.Color()) l.logs.SetTextColor(s.Views().Log.FgColor.Color()) l.logs.SetBackgroundColor(s.Views().Log.BgColor.Color()) } // GetModel returns the log model. func (l *Log) GetModel() *model.Log { return l.model } // Hints returns a collection of menu hints. func (l *Log) Hints() model.MenuHints { return l.logs.Actions().Hints() } // ExtraHints returns additional hints. func (*Log) ExtraHints() map[string]string { return nil } func (l *Log) cancel() { l.mx.Lock() defer l.mx.Unlock() if l.cancelFn != nil { l.cancelFn() l.cancelFn = nil } } func (l *Log) getContext() context.Context { l.cancel() ctx := context.Background() ctx, l.cancelFn = context.WithCancel(ctx) return ctx } // Start runs the component. func (l *Log) Start() { l.model.Start(l.getContext()) l.model.AddListener(l) l.app.Styles.AddListener(l) l.logs.cmdBuff.AddListener(l) l.logs.cmdBuff.AddListener(l.app.Prompt()) l.updateTitle() } // Stop terminates the component. func (l *Log) Stop() { l.model.RemoveListener(l) l.model.Stop() l.cancel() l.app.Styles.RemoveListener(l) l.logs.cmdBuff.RemoveListener(l) l.logs.cmdBuff.RemoveListener(l.app.Prompt()) } // Name returns the component name. func (*Log) Name() string { return logTitle } func (l *Log) bindKeys() { l.logs.Actions().Bulk(ui.KeyMap{ ui.Key0: ui.NewKeyAction("tail", l.sinceCmd(-1), true), ui.Key1: ui.NewKeyAction("head", l.sinceCmd(0), true), ui.Key2: ui.NewKeyAction("1m", l.sinceCmd(60), true), ui.Key3: ui.NewKeyAction("5m", l.sinceCmd(5*60), true), ui.Key4: ui.NewKeyAction("15m", l.sinceCmd(15*60), true), ui.Key5: ui.NewKeyAction("30m", l.sinceCmd(30*60), true), ui.Key6: ui.NewKeyAction("1h", l.sinceCmd(60*60), true), tcell.KeyEnter: ui.NewSharedKeyAction("Filter", l.filterCmd, false), tcell.KeyEscape: ui.NewKeyAction("Back", l.resetCmd, false), ui.KeyQ: ui.NewKeyAction("Back", l.resetCmd, false), ui.KeyShiftC: ui.NewKeyAction("Clear", l.clearCmd, true), ui.KeyM: ui.NewKeyAction("Mark", l.markCmd, true), ui.KeyS: ui.NewKeyAction("Toggle AutoScroll", l.toggleAutoScrollCmd, true), ui.KeyShiftL: ui.NewKeyAction("Toggle ColumnLock", l.toggleColumnLockCmd, true), ui.KeyF: ui.NewKeyAction("Toggle FullScreen", l.toggleFullScreenCmd, true), ui.KeyT: ui.NewKeyAction("Toggle Timestamp", l.toggleTimestampCmd, true), ui.KeyW: ui.NewKeyAction("Toggle Wrap", l.toggleTextWrapCmd, true), tcell.KeyCtrlS: ui.NewKeyAction("Save", l.SaveCmd, true), ui.KeyC: ui.NewKeyAction("Copy", cpCmd(l.app.Flash(), l.logs.TextView), true), }) if l.model.HasDefaultContainer() { l.logs.Actions().Add(ui.KeyA, ui.NewKeyAction("Toggle AllContainers", l.toggleAllContainers, true)) } } func (l *Log) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !l.logs.cmdBuff.IsActive() { if l.logs.cmdBuff.GetText() == "" { return l.app.PrevCmd(evt) } } l.logs.cmdBuff.Reset() l.logs.cmdBuff.SetActive(false) l.model.Filter(l.logs.cmdBuff.GetText()) l.updateTitle() return nil } // SendStrokes (testing only!) func (l *Log) SendStrokes(s string) { l.app.Prompt().SendStrokes(s) } // SendKeys (testing only!) func (l *Log) SendKeys(kk ...tcell.Key) { for _, k := range kk { l.logs.keyboard(tcell.NewEventKey(k, ' ', tcell.ModNone)) } } // Indicator returns the scroll mode viewer. func (l *Log) Indicator() *LogIndicator { return l.indicator } func (l *Log) updateTitle() { sinceSeconds, since := l.model.SinceSeconds(), "tail" if sinceSeconds > 0 && sinceSeconds < 60*60 { since = fmt.Sprintf("%dm", sinceSeconds/60) } if sinceSeconds >= 60*60 { since = fmt.Sprintf("%dh", sinceSeconds/(60*60)) } if l.model.IsHead() { since = "head" } title := " Logs" if l.model.LogOptions().Previous { title = " Previous Logs" } var ( path, co = l.model.GetPath(), l.model.GetContainer() styles = l.app.Styles.Frame() ) if co == "" { title += ui.SkinTitle(fmt.Sprintf(logFmt, path, since), &styles) } else { title += ui.SkinTitle(fmt.Sprintf(logCoFmt, path, co, since), &styles) } buff := l.logs.cmdBuff.GetText() if buff != "" { title += ui.SkinTitle(fmt.Sprintf(ui.SearchFmt, buff), &styles) } l.SetTitle(title) } // Logs returns the log viewer. func (l *Log) Logs() *Logger { return l.logs } // EOL tracks end of lines. var EOL = []byte{'\n'} // Flush write logs to viewer. func (l *Log) Flush(lines [][]byte) { defer func() { if l.cancelUpdates { l.cancelUpdates = false } }() if len(lines) == 0 || (!l.requestOneRefresh && !l.indicator.AutoScroll()) || l.cancelUpdates { return } if l.requestOneRefresh { l.requestOneRefresh = false } for i := range lines { if l.cancelUpdates { break } _, _ = l.ansiWriter.Write(lines[i]) } if l.follow { if l.columnLock { // Enables end tracking without resetting column l.logs.SetScrollable(false).SetScrollable(true) } else { l.logs.ScrollToEnd() } } } // ---------------------------------------------------------------------------- // Actions... func (l *Log) sinceCmd(n int) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { l.logs.Clear() ctx := l.getContext() if n == 0 { l.model.Head(ctx) } else { l.model.SetSinceSeconds(ctx, int64(n)) } l.requestOneRefresh = true l.updateTitle() return nil } } func (l *Log) toggleAllContainers(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleAllContainers() l.model.ToggleAllContainers(l.getContext()) l.updateTitle() return nil } func (l *Log) filterCmd(evt *tcell.EventKey) *tcell.EventKey { if !l.logs.cmdBuff.IsActive() { _, _ = fmt.Fprintln(l.ansiWriter) return evt } l.logs.cmdBuff.SetActive(false) l.model.Filter(l.logs.cmdBuff.GetText()) l.updateTitle() return nil } // SaveCmd dumps the logs to file. func (l *Log) SaveCmd(*tcell.EventKey) *tcell.EventKey { path, err := saveData(l.app.Config.K9s.ContextScreenDumpDir(), l.model.GetPath(), l.logs.GetText(true)) if err != nil { l.app.Flash().Err(err) return nil } l.app.Flash().Infof("Log %s saved successfully!", path) return nil } func ensureDir(dir string) error { return os.MkdirAll(dir, 0744) } func saveData(dir, fqn, logs string) (string, error) { if err := ensureDir(dir); err != nil { return "", err } f := fmt.Sprintf("%s-%d.log", fqn, time.Now().UnixNano()) path := filepath.Join(dir, data.SanitizeFileName(f)) mod := os.O_CREATE | os.O_WRONLY file, err := os.OpenFile(path, mod, 0600) if err != nil { slog.Error("Unable to save log file", slogs.Path, path, slogs.Error, err, ) return "", nil } defer func() { if err := file.Close(); err != nil { slog.Error("Closing Log file failed", slogs.Path, path, slogs.Error, err, ) } }() if _, err := file.WriteString(logs); err != nil { return "", err } return path, nil } func (l *Log) clearCmd(*tcell.EventKey) *tcell.EventKey { l.model.Clear() return nil } func (l *Log) markCmd(*tcell.EventKey) *tcell.EventKey { _, _, w, _ := l.GetRect() _, _ = fmt.Fprintf(l.ansiWriter, "[%s:-:b]%s[-:-:-]\n", l.app.Styles.Views().Log.FgColor.String(), strings.Repeat("-", w-4)) l.follow = true return nil } func (l *Log) toggleTimestampCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleTimestamp() l.model.ToggleShowTimestamp(l.indicator.showTime) l.indicator.Refresh() return nil } func (l *Log) toggleTextWrapCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleTextWrap() l.logs.SetWrap(l.indicator.textWrap) l.indicator.Refresh() return nil } // ToggleAutoScrollCmd toggles autoscroll status. func (l *Log) toggleAutoScrollCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleAutoScroll() l.follow = l.indicator.AutoScroll() l.indicator.Refresh() return nil } func (l *Log) toggleColumnLockCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleColumnLock() l.columnLock = l.indicator.ColumnLock() l.indicator.Refresh() return nil } func (l *Log) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.indicator.ToggleFullScreen() l.toggleFullScreen() l.indicator.Refresh() return nil } func (l *Log) toggleFullScreen() { l.SetFullScreen(l.indicator.FullScreen()) l.SetBorder(!l.indicator.FullScreen()) if l.indicator.FullScreen() { l.logs.SetBorderPadding(0, 0, 0, 0) } else { l.logs.SetBorderPadding(0, 0, 1, 1) } } func (l *Log) isContainerLogView() bool { return l.model.HasDefaultContainer() } ================================================ FILE: internal/view/log_indicator.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "sync/atomic" "github.com/derailed/k9s/internal/config" "github.com/derailed/tview" ) const spacer = " " // LogIndicator represents a log view indicator. type LogIndicator struct { *tview.TextView styles *config.Styles scrollStatus int32 indicator []byte fullScreen bool textWrap bool showTime bool allContainers bool shouldDisplayAllContainers bool columnLock bool } // NewLogIndicator returns a new indicator. func NewLogIndicator(cfg *config.Config, styles *config.Styles, allContainers bool) *LogIndicator { l := LogIndicator{ styles: styles, TextView: tview.NewTextView(), indicator: make([]byte, 0, 100), scrollStatus: 1, fullScreen: cfg.K9s.UI.DefaultsToFullScreen, textWrap: cfg.K9s.Logger.TextWrap, showTime: cfg.K9s.Logger.ShowTime, shouldDisplayAllContainers: allContainers, columnLock: cfg.K9s.Logger.ColumnLock, } if cfg.K9s.Logger.DisableAutoscroll { l.scrollStatus = 0 } l.StylesChanged(styles) styles.AddListener(&l) l.SetTextAlign(tview.AlignCenter) l.SetDynamicColors(true) return &l } // StylesChanged notifies listener the skin changed. func (l *LogIndicator) StylesChanged(styles *config.Styles) { l.SetBackgroundColor(styles.K9s.Views.Log.Indicator.BgColor.Color()) l.SetTextColor(styles.K9s.Views.Log.Indicator.FgColor.Color()) l.Refresh() } // AutoScroll reports the current scrolling status. func (l *LogIndicator) AutoScroll() bool { return atomic.LoadInt32(&l.scrollStatus) == 1 } // ColumnLock reports the current column lock mode. func (l *LogIndicator) ColumnLock() bool { return l.columnLock } // Timestamp reports the current timestamp mode. func (l *LogIndicator) Timestamp() bool { return l.showTime } // TextWrap reports the current wrap mode. func (l *LogIndicator) TextWrap() bool { return l.textWrap } // FullScreen reports the current screen mode. func (l *LogIndicator) FullScreen() bool { return l.fullScreen } // ToggleColumnLock toggles the current column lock mode. func (l *LogIndicator) ToggleColumnLock() { l.columnLock = !l.columnLock } // ToggleTimestamp toggles the current timestamp mode. func (l *LogIndicator) ToggleTimestamp() { l.showTime = !l.showTime } // ToggleFullScreen toggles the screen mode. func (l *LogIndicator) ToggleFullScreen() { l.fullScreen = !l.fullScreen l.Refresh() } // ToggleTextWrap toggles the wrap mode. func (l *LogIndicator) ToggleTextWrap() { l.textWrap = !l.textWrap l.Refresh() } // ToggleAutoScroll toggles the scroll mode. func (l *LogIndicator) ToggleAutoScroll() { var val int32 = 1 if l.AutoScroll() { val = 0 } atomic.StoreInt32(&l.scrollStatus, val) l.Refresh() } // ToggleAllContainers toggles the all-containers mode. func (l *LogIndicator) ToggleAllContainers() { l.allContainers = !l.allContainers l.Refresh() } func (l *LogIndicator) reset() { l.Clear() l.indicator = l.indicator[:0] } // Refresh updates the view. func (l *LogIndicator) Refresh() { l.reset() var ( toggleFmt = "[::b]%s:[" toggleOnFmt = toggleFmt + string(l.styles.K9s.Views.Log.Indicator.ToggleOnColor) + "::b]On[-::] %s" toggleOffFmt = toggleFmt + string(l.styles.K9s.Views.Log.Indicator.ToggleOffColor) + "::d]Off[-::]%s" ) if l.shouldDisplayAllContainers { if l.allContainers { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "AllContainers", spacer)...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "AllContainers", spacer)...) } } if l.AutoScroll() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "Autoscroll", spacer)...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "Autoscroll", spacer)...) } if l.ColumnLock() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "ColumnLock", spacer)...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "ColumnLock", spacer)...) } if l.FullScreen() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "FullScreen", spacer)...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "FullScreen", spacer)...) } if l.Timestamp() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "Timestamps", spacer)...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "Timestamps", spacer)...) } if l.TextWrap() { l.indicator = append(l.indicator, fmt.Sprintf(toggleOnFmt, "Wrap", "")...) } else { l.indicator = append(l.indicator, fmt.Sprintf(toggleOffFmt, "Wrap", "")...) } _, _ = l.Write(l.indicator) } ================================================ FILE: internal/view/log_indicator_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" ) func TestLogIndicatorRefresh(t *testing.T) { defaults := config.NewStyles() uu := map[string]struct { li *view.LogIndicator e string }{ "all-containers": { view.NewLogIndicator(config.NewConfig(nil), defaults, true), "[::b]AllContainers:[gray::d]Off[-::] [::b]Autoscroll:[limegreen::b]On[-::] [::b]ColumnLock:[gray::d]Off[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, "plain": { view.NewLogIndicator(config.NewConfig(nil), defaults, false), "[::b]Autoscroll:[limegreen::b]On[-::] [::b]ColumnLock:[gray::d]Off[-::] [::b]FullScreen:[gray::d]Off[-::] [::b]Timestamps:[gray::d]Off[-::] [::b]Wrap:[gray::d]Off[-::]\n", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.li.Refresh() assert.Equal(t, u.e, u.li.GetText(false)) }) } } func BenchmarkLogIndicatorRefresh(b *testing.B) { defaults := config.NewStyles() v := view.NewLogIndicator(config.NewConfig(nil), defaults, true) b.ReportAllocs() b.ResetTimer() for range b.N { v.Refresh() } } ================================================ FILE: internal/view/log_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestLogAutoScroll(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", SingleContainer: true, } v := NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) ii := dao.NewLogItems() ii.Add(dao.NewLogItemFromString("blee"), dao.NewLogItemFromString("bozo")) v.GetModel().Set(ii) v.GetModel().Notify() assert.Len(t, v.Hints(), 18) v.toggleAutoScrollCmd(nil) assert.Equal(t, "Autoscroll:Off ColumnLock:Off FullScreen:Off Timestamps:Off Wrap:Off", v.Indicator().GetText(true)) } func TestLogColumnLock(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) buff := dao.NewLogItems() for i := range 100 { buff.Add(dao.NewLogItemFromString(fmt.Sprintf("line-%d\n", i))) } v.GetModel().Set(buff) v.toggleColumnLockCmd(nil) const column = 2 v.Logs().ScrollTo(-1, column) v.toggleAutoScrollCmd(nil) r, c := v.Logs().GetScrollOffset() assert.Equal(t, -1, r) assert.Equal(t, column, c) } func TestLogViewNav(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) buff := dao.NewLogItems() for i := range 100 { buff.Add(dao.NewLogItemFromString(fmt.Sprintf("line-%d\n", i))) } v.GetModel().Set(buff) v.toggleAutoScrollCmd(nil) r, _ := v.Logs().GetScrollOffset() assert.Equal(t, -1, r) } func TestLogViewClear(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) v.toggleAutoScrollCmd(nil) v.Logs().SetText("blee\nblah") v.Logs().Clear() assert.Empty(t, v.Logs().GetText(true)) } func TestLogTimestamp(t *testing.T) { opts := dao.LogOptions{ Path: "fred/blee", Container: "c1", } l := NewLog(client.NewGVR("test"), &opts) require.NoError(t, l.Init(makeContext(t))) ii := dao.NewLogItems() ii.Add( &dao.LogItem{ Pod: "fred/blee", Container: "c1", Bytes: []byte("ttt Testing 1, 2, 3\n"), }, ) var list logList l.GetModel().AddListener(&list) l.GetModel().Set(ii) l.SendKeys(ui.KeyT) l.Logs().Clear() ll := make([][]byte, ii.Len()) ii.Lines(0, true, ll) l.Flush(ll) assert.Equal(t, fmt.Sprintf("%-30s %s", "ttt", "fred/blee c1 Testing 1, 2, 3\n"), l.Logs().GetText(true)) assert.Equal(t, 2, list.change) assert.Equal(t, 2, list.clear) assert.Equal(t, 0, list.fail) } func TestLogFilter(t *testing.T) { opts := dao.LogOptions{ Path: "fred/blee", Container: "c1", } l := NewLog(client.NewGVR("test"), &opts) require.NoError(t, l.Init(makeContext(t))) buff := dao.NewLogItems() buff.Add( dao.NewLogItemFromString("duh"), dao.NewLogItemFromString("zorg"), ) var list logList l.GetModel().AddListener(&list) l.GetModel().Set(buff) l.SendKeys(ui.KeySlash) l.SendStrokes("zorg") assert.Equal(t, "duhzorg", list.lines) assert.Equal(t, 1, list.change) assert.Equal(t, 1, list.clear) assert.Equal(t, 0, list.fail) } // ---------------------------------------------------------------------------- // Helpers... type logList struct { change, clear, fail int lines string } func (l *logList) LogChanged(ll [][]byte) { l.change++ l.lines = "" for _, line := range ll { l.lines += string(line) } } func (*logList) LogCanceled() {} func (*logList) LogStop() {} func (*logList) LogResume() {} func (l *logList) LogCleared() { l.clear++ } func (l *logList) LogFailed(error) { l.fail++ } ================================================ FILE: internal/view/log_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "bytes" "errors" "fmt" "io/fs" "os" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestLog(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := view.NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) ii := dao.NewLogItems() ii.Add(dao.NewLogItemFromString("blee\n"), dao.NewLogItemFromString("bozo\n")) ll := make([][]byte, ii.Len()) ii.Lines(0, false, ll) v.Flush(ll) assert.Equal(t, "Waiting for logs...\nblee\nbozo\n", v.Logs().GetText(true)) } func TestLogFlush(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := view.NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) items := dao.NewLogItems() items.Add( dao.NewLogItemFromString("\033[0;30mblee\n"), dao.NewLogItemFromString("\033[0;32mBozo\n"), ) ll := make([][]byte, items.Len()) items.Lines(0, false, ll) v.Flush(ll) assert.Equal(t, "[orange::d]Waiting for logs...\n[black::]blee\n[green::]Bozo\n\n", v.Logs().GetText(false)) } func BenchmarkLogFlush(b *testing.B) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := view.NewLog(client.PodGVR, &opts) _ = v.Init(makeContext(b)) items := dao.NewLogItems() items.Add( dao.NewLogItemFromString("\033[0;30mblee\n"), dao.NewLogItemFromString("\033[0;101mBozo\n"), dao.NewLogItemFromString("\033[0;101mBozo\n"), ) ll := make([][]byte, items.Len()) items.Lines(0, false, ll) b.ReportAllocs() b.ResetTimer() for range b.N { v.Flush(ll) } } func TestLogAnsi(t *testing.T) { buff := bytes.NewBufferString("") w := tview.ANSIWriter(buff, "white", "black") _, _ = fmt.Fprintf(w, "[YELLOW] ok") assert.Equal(t, "[YELLOW] ok", buff.String()) v := tview.NewTextView() v.SetDynamicColors(true) aw := tview.ANSIWriter(v, "white", "black") s := "[2019-03-27T15:05:15,246][INFO ][o.e.c.r.a.AllocationService] [es-0] Cluster health status changed from [YELLOW] to [GREEN] (reason: [shards started [[.monitoring-es-6-2019.03.27][0]]" _, _ = fmt.Fprintf(aw, "%s", s) assert.Equal(t, s+"\n", v.GetText(false)) } func TestLogViewSave(t *testing.T) { opts := dao.LogOptions{ Path: "fred/p1", Container: "blee", } v := view.NewLog(client.PodGVR, &opts) require.NoError(t, v.Init(makeContext(t))) app := makeApp(t) ii := dao.NewLogItems() ii.Add(dao.NewLogItemFromString("blee"), dao.NewLogItemFromString("bozo")) ll := make([][]byte, ii.Len()) ii.Lines(0, false, ll) v.Flush(ll) dd := "/tmp/test-dumps/na" require.NoError(t, ensureDumpDir(dd)) app.Config.K9s.ScreenDumpDir = "/tmp/test-dumps" dir := app.Config.K9s.ContextScreenDumpDir() c1, err := os.ReadDir(dir) require.NoError(t, err, "Dir: %q", dir) v.SaveCmd(nil) c2, err := os.ReadDir(dir) require.NoError(t, err, "Dir: %q", dir) assert.Len(t, c2, len(c1)+1) } func TestAllContainerKeyBinding(t *testing.T) { uu := map[string]struct { opts *dao.LogOptions e bool }{ "action-present": { opts: &dao.LogOptions{Path: "", DefaultContainer: "container"}, e: true, }, "action-missing": { opts: &dao.LogOptions{}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { v := view.NewLog(client.PodGVR, u.opts) require.NoError(t, v.Init(makeContext(t))) _, got := v.Logs().Actions().Get(ui.KeyA) assert.Equal(t, u.e, got) }) } } // ---------------------------------------------------------------------------- // Helpers... func makeApp(t *testing.T) *view.App { return view.NewApp(mock.NewMockConfig(t)) } func ensureDumpDir(n string) error { config.AppDumpsDir = n if _, err := os.Stat(n); errors.Is(err, fs.ErrNotExist) { return os.MkdirAll(n, 0700) } if err := os.RemoveAll(n); err != nil { return err } return os.MkdirAll(n, 0700) } ================================================ FILE: internal/view/logger.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // Logger represents a generic log viewer. type Logger struct { *tview.TextView actions *ui.KeyActions app *App title, subject string cmdBuff *model.FishBuff } // NewLogger returns a logger viewer. func NewLogger(app *App) *Logger { return &Logger{ TextView: tview.NewTextView(), app: app, actions: ui.NewKeyActions(), cmdBuff: model.NewFishBuff('/', model.FilterBuffer), } } // Init initializes the viewer. func (l *Logger) Init(_ context.Context) error { if l.title != "" { l.SetBorder(true) } l.SetScrollable(true).SetWrap(true) l.SetDynamicColors(true) l.SetHighlightColor(tcell.ColorOrange) l.SetTitleColor(tcell.ColorAqua) l.SetInputCapture(l.keyboard) l.SetBorderPadding(0, 0, 1, 1) l.app.Styles.AddListener(l) l.StylesChanged(l.app.Styles) l.app.Prompt().SetModel(l.cmdBuff) l.cmdBuff.AddListener(l) l.bindKeys() l.SetInputCapture(l.keyboard) return nil } // BufferChanged indicates the buffer was changed. func (*Logger) BufferChanged(_, _ string) {} // BufferCompleted indicates input was accepted. func (*Logger) BufferCompleted(_, _ string) {} // BufferActive indicates the buff activity changed. func (l *Logger) BufferActive(state bool, k model.BufferKind) { l.app.BufferActive(state, k) } func (l *Logger) bindKeys() { l.actions.Bulk(ui.KeyMap{ tcell.KeyEscape: ui.NewKeyAction("Back", l.resetCmd, false), ui.KeyQ: ui.NewKeyAction("Back", l.resetCmd, false), tcell.KeyCtrlS: ui.NewKeyAction("Save", l.saveCmd, false), ui.KeyC: ui.NewKeyAction("Copy", cpCmd(l.app.Flash(), l.TextView), true), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", l.activateCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", l.eraseCmd, false), }) } func (l *Logger) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := l.actions.Get(ui.AsKey(evt)); ok { return a.Action(evt) } return evt } // StylesChanged notifies the skin changed. func (l *Logger) StylesChanged(*config.Styles) { l.SetBackgroundColor(l.app.Styles.BgColor()) l.SetTextColor(l.app.Styles.FgColor()) l.SetBorderFocusColor(l.app.Styles.Frame().Border.FocusColor.Color()) } // SetSubject updates the subject. func (l *Logger) SetSubject(s string) { l.subject = s } // Actions returns menu actions. func (l *Logger) Actions() *ui.KeyActions { return l.actions } // Name returns the component name. func (l *Logger) Name() string { return l.title } // Start starts the view updater. func (*Logger) Start() {} // Stop terminates the updater. func (l *Logger) Stop() { l.app.Styles.RemoveListener(l) } // Hints returns menu hints. func (l *Logger) Hints() model.MenuHints { return l.actions.Hints() } // ExtraHints returns additional hints. func (*Logger) ExtraHints() map[string]string { return nil } func (l *Logger) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if l.app.InCmdMode() { return evt } l.app.ResetPrompt(l.cmdBuff) return nil } func (l *Logger) eraseCmd(*tcell.EventKey) *tcell.EventKey { if !l.cmdBuff.IsActive() { return nil } l.cmdBuff.Delete() return nil } func (l *Logger) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !l.cmdBuff.InCmdMode() { l.cmdBuff.Reset() return l.app.PrevCmd(evt) } l.cmdBuff.SetActive(false) l.cmdBuff.Reset() return nil } func (l *Logger) saveCmd(*tcell.EventKey) *tcell.EventKey { if path, err := saveYAML(l.app.Config.K9s.ContextScreenDumpDir(), l.title, l.GetText(true)); err != nil { l.app.Flash().Err(err) } else { l.app.Flash().Infof("Log %s saved successfully!", path) } return nil } ================================================ FILE: internal/view/logs_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // LogsExtender adds log actions to a given viewer. type LogsExtender struct { ResourceViewer optionsFn LogOptionsFunc } // NewLogsExtender returns a new extender. func NewLogsExtender(v ResourceViewer, f LogOptionsFunc) ResourceViewer { l := LogsExtender{ ResourceViewer: v, optionsFn: f, } l.AddBindKeysFn(l.bindKeys) return &l } // BindKeys injects new menu actions. func (l *LogsExtender) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyL: ui.NewKeyAction("Logs", l.logsCmd(false), true), ui.KeyP: ui.NewKeyAction("Logs Previous", l.logsCmd(true), true), }) } func (l *LogsExtender) logsCmd(prev bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { path := l.GetTable().GetSelectedItem() if path == "" { return nil } if !isResourcePath(path) { path = l.GetTable().Path } l.showLogs(path, prev) return nil } } func isResourcePath(p string) bool { ns, n := client.Namespaced(p) return ns != "" && n != "" } func (l *LogsExtender) showLogs(path string, prev bool) { ns, _ := client.Namespaced(path) _, err := l.App().factory.CanForResource(ns, client.PodGVR, client.ListAccess) if err != nil { l.App().Flash().Err(err) return } opts := l.buildLogOpts(path, "", prev) if l.optionsFn != nil { if opts, err = l.optionsFn(prev); err != nil { l.App().Flash().Err(err) return } } if err := l.App().inject(NewLog(l.GVR(), opts), false); err != nil { l.App().Flash().Err(err) } } // buildLogOpts(path, co, prev, false, config.DefaultLoggerTailCount),. func (l *LogsExtender) buildLogOpts(path, co string, prevLogs bool) *dao.LogOptions { cfg := l.App().Config.K9s.Logger opts := dao.LogOptions{ Path: path, Container: co, Lines: cfg.TailCount, Previous: prevLogs, ShowTimestamp: cfg.ShowTime, } if opts.Container == "" { opts.AllContainers = true } return &opts } func podLogOptions(app *App, fqn string, prev bool, m *metav1.ObjectMeta, spec *v1.PodSpec) *dao.LogOptions { var ( cc = fetchContainers(m, spec, true) cfg = app.Config.K9s.Logger opts = dao.LogOptions{ Path: fqn, Lines: cfg.TailCount, SinceSeconds: cfg.SinceSeconds, SingleContainer: len(cc) == 1, ShowTimestamp: cfg.ShowTime, Previous: prev, } ) if c, ok := dao.GetDefaultContainer(m, spec); ok { opts.Container, opts.DefaultContainer = c, c } else if len(cc) == 1 { opts.Container = cc[0] } else { opts.AllContainers = true } return &opts } ================================================ FILE: internal/view/node.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Node represents a node view. type Node struct { ResourceViewer } // NewNode returns a new node view. func NewNode(gvr *client.GVR) ResourceViewer { n := Node{ ResourceViewer: NewBrowser(gvr), } n.AddBindKeysFn(n.bindKeys) n.GetTable().SetEnterFn(n.showPods) n.SetContextFn(n.nodeContext) return &n } func (n *Node) nodeContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyPodCounting, !n.App().Config.K9s.DisablePodCounting) } func (n *Node) bindDangerousKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyC: ui.NewKeyActionWithOpts( "Cordon", n.toggleCordonCmd(true), ui.ActionOpts{ Visible: true, Dangerous: true, }, ), ui.KeyU: ui.NewKeyActionWithOpts( "Uncordon", n.toggleCordonCmd(false), ui.ActionOpts{ Visible: true, Dangerous: true, }, ), ui.KeyR: ui.NewKeyActionWithOpts( "Drain", n.drainCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }, ), }) ct, err := n.App().Config.K9s.ActiveContext() if err != nil { slog.Error("No active context located", slogs.Error, err) return } if ct.FeatureGates.NodeShell && n.App().Config.K9s.ShellPod != nil { aa.Add(ui.KeyS, ui.NewKeyAction("Shell", n.sshCmd, true)) } } func (n *Node) bindKeys(aa *ui.KeyActions) { if !n.App().Config.IsReadOnly() { n.bindDangerousKeys(aa) } aa.Bulk(ui.KeyMap{ ui.KeyY: ui.NewKeyAction(yamlAction, n.yamlCmd, true), }) } func (n *Node) showPods(a *App, _ ui.Tabular, _ *client.GVR, path string) { showPods(a, n.GetTable().GetSelectedItem(), nil, "spec.nodeName="+path) } func (n *Node) drainCmd(evt *tcell.EventKey) *tcell.EventKey { sels := n.GetTable().GetSelectedItems() if len(sels) == 0 { return evt } opts := dao.DrainOptions{ GracePeriodSeconds: -1, Timeout: 5 * time.Second, } ShowDrain(n, sels, opts, drainNode) return nil } func drainNode(v ResourceViewer, sels []string, opts dao.DrainOptions) { res, err := dao.AccessorFor(v.App().factory, v.GVR()) if err != nil { v.App().Flash().Err(err) return } m, ok := res.(dao.NodeMaintainer) if !ok { v.App().Flash().Err(fmt.Errorf("expecting a maintainer for %q", v.GVR())) return } v.Stop() defer v.Start() { d := NewDetails(v.App(), "Drain Progress", "nodes", contentYAML, true) if err := v.App().inject(d, false); err != nil { v.App().Flash().Err(err) } for _, sel := range sels { if err := m.Drain(sel, opts, d.GetWriter()); err != nil { v.App().Flash().Err(err) } } v.Refresh() } } func (n *Node) toggleCordonCmd(cordon bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(evt *tcell.EventKey) *tcell.EventKey { sels := n.GetTable().GetSelectedItems() if len(sels) == 0 { return evt } title, msg := "Confirm ", "" if cordon { title, msg = title+"Cordon", "Cordon " } else { title, msg = title+"Uncordon", "Uncordon " } if len(sels) == 1 { msg += sels[0] + "?" } else { msg += fmt.Sprintf("(%d) marked %s?", len(sels), n.GVR().R()) } d := n.App().Styles.Dialog() dialog.ShowConfirm(&d, n.App().Content.Pages, title, msg, func() { res, err := dao.AccessorFor(n.App().factory, n.GVR()) if err != nil { n.App().Flash().Err(err) return } m, ok := res.(dao.NodeMaintainer) if !ok { n.App().Flash().Err(fmt.Errorf("expecting a maintainer for %q", n.GVR())) return } for _, s := range sels { if err := m.ToggleCordon(s, cordon); err != nil { n.App().Flash().Err(err) } } n.Refresh() }, func() {}) return nil } } func (n *Node) sshCmd(evt *tcell.EventKey) *tcell.EventKey { path := n.GetTable().GetSelectedItem() if path == "" { return evt } n.Stop() defer n.Start() _, node := client.Namespaced(path) launchNodeShell(n, n.App(), node) return nil } func (n *Node) yamlCmd(evt *tcell.EventKey) *tcell.EventKey { path := n.GetTable().GetSelectedItem() if path == "" { return evt } n.Stop() defer n.Start() ctx, cancel := context.WithTimeout(context.Background(), n.App().Conn().Config().CallTimeout()) defer cancel() sel := n.GetTable().GetSelectedItem() gvr := n.GVR().GVR() dial, err := n.App().factory.Client().DynDial() if err != nil { n.App().Flash().Err(err) return nil } o, err := dial.Resource(gvr).Get(ctx, sel, metav1.GetOptions{}) if err != nil { n.App().Flash().Errf("Unable to get resource %q -- %s", n.GVR(), err) return nil } raw, err := dao.ToYAML(o, false) if err != nil { n.App().Flash().Errf("Unable to marshal resource %s", err) return nil } details := NewDetails(n.App(), yamlAction, sel, contentYAML, true).Update(raw) if err := n.App().inject(details, false); err != nil { n.App().Flash().Err(err) } return nil } ================================================ FILE: internal/view/ns.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/util/sets" ) const ( favNSIndicator = "+" defaultNSIndicator = "(*)" ) // Namespace represents a namespace viewer. type Namespace struct { ResourceViewer } // NewNamespace returns a new viewer. func NewNamespace(gvr *client.GVR) ResourceViewer { n := Namespace{ ResourceViewer: NewBrowser(gvr), } n.GetTable().SetDecorateFn(n.decorate) n.GetTable().SetEnterFn(n.switchNs) n.AddBindKeysFn(n.bindKeys) return &n } func (n *Namespace) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyU: ui.NewKeyAction("Use", n.useNsCmd, true), }) } func (n *Namespace) switchNs(app *App, _ ui.Tabular, _ *client.GVR, path string) { n.useNamespace(path) _, ns := client.Namespaced(path) app.gotoResource(client.PodGVR.String()+" "+ns, "", false, true) } func (n *Namespace) useNsCmd(*tcell.EventKey) *tcell.EventKey { path := n.GetTable().GetSelectedItem() if path == "" { return nil } n.useNamespace(path) return nil } func (n *Namespace) useNamespace(fqn string) { _, ns := client.Namespaced(fqn) if client.CleanseNamespace(n.App().Config.ActiveNamespace()) == ns { return } if err := n.App().switchNS(ns); err != nil { n.App().Flash().Err(err) return } if err := n.App().Config.SetActiveNamespace(ns); err != nil { n.App().Flash().Err(err) return } } func (n *Namespace) decorate(td *model1.TableData) { if n.App().Conn() == nil || td.RowCount() == 0 { return } // checks if all ns is in the list if not add it. if _, ok := td.FindRow(client.NamespaceAll); !ok { td.AddRow(model1.RowEvent{ Kind: model1.EventUnchanged, Row: model1.Row{ ID: client.NamespaceAll, Fields: model1.Fields{client.NamespaceAll, "Active", "", "", ""}, }, }, ) } var ( favs = sets.New(n.App().Config.FavNamespaces()...) activeNS = n.App().Config.ActiveNamespace() ) td.RowsRange(func(i int, re model1.RowEvent) bool { _, n := client.Namespaced(re.Row.ID) if favs.Has(n) { re.Row.Fields[0] += favNSIndicator } if n == activeNS { re.Row.Fields[0] += defaultNSIndicator } re.Kind = model1.EventUnchanged td.SetRow(i, re) return true }) } ================================================ FILE: internal/view/ns_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNSCleanser(t *testing.T) { ns := view.NewNamespace(client.NsGVR) require.NoError(t, ns.Init(makeCtx(t))) assert.Equal(t, "Namespaces", ns.Name()) assert.Len(t, ns.Hints(), 8) } ================================================ FILE: internal/view/owner_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" "github.com/go-errors/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" ) // OwnerExtender adds owner actions to a given viewer. type OwnerExtender struct { ResourceViewer } // NewOwnerExtender returns a new extender. func NewOwnerExtender(r ResourceViewer) ResourceViewer { v := &OwnerExtender{ResourceViewer: r} v.AddBindKeysFn(v.bindKeys) return v } func (v *OwnerExtender) bindKeys(aa *ui.KeyActions) { aa.Add(ui.KeyShiftJ, ui.NewKeyAction("Jump Owner", v.ownerCmd, true)) } func (v *OwnerExtender) ownerCmd(evt *tcell.EventKey) *tcell.EventKey { path := v.GetTable().GetSelectedItem() if path == "" { return evt } if err := v.findOwnerFor(path); err != nil { slog.Warn("Unable to jump to owner of resource", slogs.FQN, path, slogs.Error, err, ) v.App().Flash().Warnf("Unable to jump owner: %s", err) } return nil } func (v *OwnerExtender) findOwnerFor(path string) error { res, err := dao.AccessorFor(v.App().factory, v.GVR()) if err != nil { return err } o, err := res.Get(v.defaultCtx(), path) if err != nil { return err } u, ok := v.asUnstructuredObject(o) if !ok { return errors.Errorf("unsupported object type: %t", o) } ns, _ := client.Namespaced(path) ownerReferences := u.GetOwnerReferences() if len(ownerReferences) == 1 { return v.jumpOwner(ns, &ownerReferences[0]) } else if len(ownerReferences) > 1 { owners := make([]string, 0, len(ownerReferences)) for idx, ownerRef := range ownerReferences { owners = append(owners, fmt.Sprintf("%d: %s", idx, ownerRef.Kind)) } d := v.App().Styles.Dialog() dialog.ShowSelection(&d, v.App().Content.Pages, "Jump To", owners, func(index int) { if index >= 0 { err = v.jumpOwner(ns, &ownerReferences[index]) } }) return err } return errors.Errorf("no owner found") } func (v *OwnerExtender) jumpOwner(ns string, owner *metav1.OwnerReference) error { gv, err := schema.ParseGroupVersion(owner.APIVersion) if err != nil { return err } gvr, namespaced, found := dao.MetaAccess.GVK2GVR(gv, owner.Kind) if !found { return errors.Errorf("unsupported GVK: %s/%s", owner.APIVersion, owner.Kind) } var ownerFQN string if namespaced { ownerFQN = client.FQN(ns, owner.Name) } else { ownerFQN = owner.Name } v.App().gotoResource(gvr.String(), ownerFQN, false, true) return nil } func (v *OwnerExtender) defaultCtx() context.Context { return context.WithValue(context.Background(), internal.KeyFactory, v.App().factory) } func (*OwnerExtender) asUnstructuredObject(o runtime.Object) (*unstructured.Unstructured, bool) { switch v := o.(type) { case *unstructured.Unstructured: return v, true case *render.PodWithMetrics: return v.Raw, true default: return nil, false } } ================================================ FILE: internal/view/page_stack.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" ) // PageStack represents a stack of pages. type PageStack struct { *ui.Pages app *App } // NewPageStack returns a new page stack. func NewPageStack() *PageStack { return &PageStack{ Pages: ui.NewPages(), } } // Init initializes the view. func (p *PageStack) Init(ctx context.Context) (err error) { if p.app, err = extractApp(ctx); err != nil { return err } p.AddListener(p) return nil } // StackPushed notifies a new page was added. func (p *PageStack) StackPushed(c model.Component) { c.Start() p.app.SetFocus(c) } // StackPopped notifies a page was removed. func (p *PageStack) StackPopped(o, top model.Component) { o.Stop() p.StackTop(top) } // StackTop notifies for the top component. func (p *PageStack) StackTop(top model.Component) { if top == nil { return } top.Start() p.app.SetFocus(top) } ================================================ FILE: internal/view/pf.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "regexp" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/perf" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" ) // PortForward presents active portforward viewer. type PortForward struct { ResourceViewer bench *perf.Benchmark } // NewPortForward returns a new viewer. func NewPortForward(gvr *client.GVR) ResourceViewer { p := PortForward{ ResourceViewer: NewBrowser(gvr), } p.GetTable().SetBorderFocusColor(tcell.ColorDodgerBlue) p.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDodgerBlue).Attributes(tcell.AttrNone)) p.GetTable().SetSortCol(ageCol, true) p.SetContextFn(p.portForwardContext) p.AddBindKeysFn(p.bindKeys) return &p } func (p *PortForward) portForwardContext(ctx context.Context) context.Context { if bc := p.App().BenchFile; bc != "" { return context.WithValue(ctx, internal.KeyBenchCfg, p.App().BenchFile) } return ctx } func (p *PortForward) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftS) aa.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("View Benchmarks", p.showBenchCmd, true), ui.KeyB: ui.NewKeyAction("Benchmark Run/Stop", p.toggleBenchCmd, true), tcell.KeyCtrlD: ui.NewKeyAction("Delete", p.deleteCmd, true), ui.KeyShiftP: ui.NewKeyAction("Sort Ports", p.GetTable().SortColCmd("PORTS", true), false), ui.KeyShiftU: ui.NewKeyAction("Sort URL", p.GetTable().SortColCmd("URL", true), false), }) } func (p *PortForward) showBenchCmd(*tcell.EventKey) *tcell.EventKey { b := NewBenchmark(client.BeGVR) b.SetContextFn(p.getContext) if err := p.App().inject(b, false); err != nil { p.App().Flash().Err(err) } return nil } func (p *PortForward) getContext(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyDir, benchDir(p.App().Config)) path := p.GetTable().GetSelectedItem() if path == "" { return ctx } return context.WithValue(ctx, internal.KeyPath, path) } func (p *PortForward) toggleBenchCmd(*tcell.EventKey) *tcell.EventKey { if p.bench != nil { p.App().Status(model.FlashErr, "Benchmark Canceled!") p.bench.Cancel() p.App().ClearStatus(true) return nil } path := p.GetTable().GetSelectedItem() if path == "" { return nil } cfg := dao.BenchConfigFor(p.App().BenchFile, path) cfg.Name = path r, _ := p.GetTable().GetSelection() slog.Debug("Port forward namespace", slogs.Namespace, p.GetTable().GetModel().GetNamespace()) col := 3 if client.IsAllNamespaces(p.GetTable().GetModel().GetNamespace()) { col = 4 } base := ui.TrimCell(p.GetTable().SelectTable, r, col) var err error p.bench, err = perf.NewBenchmark(base, p.App().version, &cfg) if err != nil { p.App().Flash().Errf("Bench failed %v", err) p.App().ClearStatus(false) return nil } p.App().Status(model.FlashWarn, "Benchmark in progress...") go func() { if err := p.runBenchmark(); err != nil { slog.Error("Benchmark run failed", slogs.Error, err) } }() return nil } func (p *PortForward) runBenchmark() error { slog.Debug("Bench starting...") ct, err := p.App().Config.K9s.ActiveContext() if err != nil { return err } name := p.App().Config.K9s.ActiveContextName() p.bench.Run(ct.ClusterName, name, func() { slog.Debug("Benchmark Completed!", slogs.Name, name) p.App().QueueUpdate(func() { if p.bench.Canceled() { p.App().Status(model.FlashInfo, "Benchmark canceled") } else { p.App().Status(model.FlashInfo, "Benchmark Completed!") p.bench.Cancel() } p.bench = nil go func() { <-time.After(2 * time.Second) p.App().QueueUpdate(func() { p.App().ClearStatus(true) }) }() }) }) return nil } func (p *PortForward) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { if !p.GetTable().CmdBuff().Empty() { p.GetTable().CmdBuff().Reset() return nil } selections := p.GetTable().GetSelectedItems() if len(selections) == 0 { return evt } p.Stop() defer p.Start() var msg string if len(selections) > 1 { msg = fmt.Sprintf("Delete %d marked %s?", len(selections), p.GVR()) } else if h, err := pfToHuman(selections[0]); err == nil { msg = fmt.Sprintf("Delete %s %s?", p.GVR().R(), h) } else { p.App().Flash().Err(err) return nil } d := p.App().Styles.Dialog() dialog.ShowConfirm(&d, p.App().Content.Pages, "Delete", msg, func() { for _, s := range selections { var pf dao.PortForward pf.Init(p.App().factory, client.PfGVR) if err := pf.Delete(context.Background(), s, nil, dao.DefaultGrace); err != nil { p.App().Flash().Err(err) return } } p.App().Flash().Infof("Successfully deleted %d PortForward!", len(selections)) p.GetTable().Refresh() }, func() {}) return nil } // ---------------------------------------------------------------------------- // Helpers... var selRx = regexp.MustCompile(`\A([\w-]+)/([\w-]+)\|([\w-]+)?\|(\d+):(\d+)`) func pfToHuman(s string) (string, error) { mm := selRx.FindStringSubmatch(s) if len(mm) < 6 { return "", fmt.Errorf("unable to parse selection %s", s) } return fmt.Sprintf("%s::%s %s->%s", mm[2], mm[3], mm[4], mm[5]), nil } ================================================ FILE: internal/view/pf_dialog.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "log/slog" "math" "strconv" "strings" "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" ) const portForwardKey = "portforward" // PortForwardCB represents a port-forward callback function. type PortForwardCB func(ResourceViewer, string, port.PortTunnels) error // ShowPortForwards pops a port forwarding configuration dialog. func ShowPortForwards(v ResourceViewer, path string, ports port.ContainerPortSpecs, aa port.Annotations, okFn PortForwardCB) { styles := v.App().Styles.Dialog() f := tview.NewForm() f.SetItemPadding(0) f.SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()). SetFieldBackgroundColor(styles.BgColor.Color()) pf, err := aa.PreferredPorts(ports) if err != nil { slog.Warn("Unable to resolve preferred ports", slogs.FQN, path, slogs.Error, err, ) } p1, p2 := pf.ToPortSpec(ports) fieldLen := int(math.Max(30, float64(len(p1)))) f.AddInputField("Container Port:", p1, fieldLen, nil, nil) f.AddInputField("Local Port:", p2, fieldLen, nil, nil) coField := f.GetFormItemByLabel("Container Port:").(*tview.InputField) loField := f.GetFormItemByLabel("Local Port:").(*tview.InputField) if coField.GetText() == "" { coField.SetPlaceholder("Enter a container name::port") } coField.SetChangedFunc(func(s string) { p := extractPort(s) loField.SetText(p) p2 = p }) if loField.GetText() == "" { loField.SetPlaceholder("Enter a local port") } address := v.App().Config.K9s.PortForwardAddress f.AddInputField("Address:", address, fieldLen, nil, func(h string) { address = h }) for i := range 3 { if field, ok := f.GetFormItem(i).(*tview.InputField); ok { field.SetLabelColor(styles.LabelFgColor.Color()) field.SetFieldTextColor(styles.FieldFgColor.Color()) } } f.AddButton("OK", func() { if coField.GetText() == "" || loField.GetText() == "" { v.App().Flash().Err(fmt.Errorf("container to local port mismatch")) return } tt, err := port.ToTunnels(address, coField.GetText(), loField.GetText()) if err != nil { v.App().Flash().Err(err) return } if err := okFn(v, path, tt); err != nil { v.App().Flash().Err(err) } }) pages := v.App().Content.Pages f.AddButton("Cancel", func() { DismissPortForwards(v, pages) }) for i := range 2 { if b := f.GetButton(i); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } modal := tview.NewModalForm("", f) msg := path if len(ports) > 1 { msg += "\n\nExposed Ports:\n" + ports.Dump() } modal.SetText(msg) modal.SetTextColor(styles.FgColor.Color()) modal.SetBackgroundColor(styles.BgColor.Color()) modal.SetDoneFunc(func(int, string) { DismissPortForwards(v, pages) }) pages.AddPage(portForwardKey, modal, false, true) pages.ShowPage(portForwardKey) v.App().SetFocus(pages.GetPrimitive(portForwardKey)) } // DismissPortForwards dismiss the port forward dialog. func DismissPortForwards(v ResourceViewer, p *ui.Pages) { p.RemovePage(portForwardKey) v.App().SetFocus(p.CurrentPage().Item) } // ---------------------------------------------------------------------------- // Helpers... func extractPort(p string) string { tokens := strings.Split(p, "::") if len(tokens) < 2 { ports := strings.Split(p, ",") for _, t := range ports { if _, err := strconv.Atoi(strings.TrimSpace(t)); err != nil { return "" } } return p } return tokens[1] } ================================================ FILE: internal/view/pf_dialog_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "testing" "github.com/stretchr/testify/assert" ) func TestExtractPort(t *testing.T) { uu := map[string]struct { portSpec, e string }{ "full": { portSpec: "co::8000", e: "8000", }, "toast": { portSpec: "co:8000", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, extractPort(u.portSpec)) }) } } ================================================ FILE: internal/view/pf_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/watch" "github.com/derailed/tcell/v2" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/portforward" ) // PortForwardExtender adds port-forward extensions. type PortForwardExtender struct { ResourceViewer } // NewPortForwardExtender returns a new extender. func NewPortForwardExtender(r ResourceViewer) ResourceViewer { p := PortForwardExtender{ResourceViewer: r} p.AddBindKeysFn(p.bindKeys) return &p } func (p *PortForwardExtender) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyF: ui.NewKeyAction("Show PortForward", p.showPFCmd, true), ui.KeyShiftF: ui.NewKeyAction("Port-Forward", p.portFwdCmd, true), }) } func (p *PortForwardExtender) portFwdCmd(evt *tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return evt } podName, err := p.fetchPodName(path) if err != nil { p.App().Flash().Err(err) return nil } if err := ensurePodPortFwdAllowed(p.App().factory, podName); err != nil { p.App().Flash().Err(err) return nil } if err := showFwdDialog(p, podName, startFwdCB); err != nil { p.App().Flash().Err(err) } return nil } func (p *PortForwardExtender) showPFCmd(evt *tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return evt } podName, err := p.fetchPodName(path) if err != nil { p.App().Flash().Err(err) return nil } if !p.App().factory.Forwarders().IsPodForwarded(podName) { p.App().Flash().Errf("no port-forward defined") return nil } pf := NewPortForward(client.PfGVR) pf.SetContextFn(p.portForwardContext) if err := p.App().inject(pf, false); err != nil { p.App().Flash().Err(err) } return nil } func (p *PortForwardExtender) fetchPodName(path string) (string, error) { res, err := dao.AccessorFor(p.App().factory, p.GVR()) if err != nil { return "", err } ctrl, ok := res.(dao.Controller) if !ok { return "", fmt.Errorf("expecting a controller resource for %q", p.GVR()) } return ctrl.Pod(path) } func (p *PortForwardExtender) portForwardContext(ctx context.Context) context.Context { if bc := p.App().BenchFile; bc != "" { ctx = context.WithValue(ctx, internal.KeyBenchCfg, p.App().BenchFile) } return context.WithValue(ctx, internal.KeyPath, p.GetTable().GetSelectedItem()) } // ---------------------------------------------------------------------------- // Helpers... func ensurePodPortFwdAllowed(factory dao.Factory, podName string) error { pod, err := fetchPod(factory, podName) if err != nil { return err } if pod.Status.Phase != v1.PodRunning { return fmt.Errorf("pod must be running. Current status=%v", pod.Status.Phase) } return nil } func runForward(v ResourceViewer, pf watch.Forwarder, f *portforward.PortForwarder) { v.App().factory.AddForwarder(pf) v.App().QueueUpdateDraw(func() { DismissPortForwards(v, v.App().Content.Pages) }) pf.SetActive(true) if err := f.ForwardPorts(); err != nil { v.App().Flash().Warnf("PortForward failed for %s: %s. Deleting!", pf.ID(), err) } v.App().QueueUpdateDraw(func() { v.App().factory.DeleteForwarder(pf.ID()) pf.SetActive(false) }) } func startFwdCB(v ResourceViewer, path string, pts port.PortTunnels) error { if err := pts.CheckAvailable(context.Background()); err != nil { return err } tt := make([]string, 0, len(pts)) for _, pt := range pts { if _, ok := v.App().factory.ForwarderFor(dao.PortForwardID(path, pt.Container, pt.PortMap())); ok { return fmt.Errorf("port-forward is already active on pod %s", path) } pf := dao.NewPortForwarder(v.App().factory) fwd, err := pf.Start(path, pt) if err != nil { return err } slog.Debug(">>> Starting port forward", slogs.PFID, pf.ID(), slogs.PFTunnel, pt, ) go runForward(v, pf, fwd) tt = append(tt, pt.LocalPort) } if len(tt) == 1 { v.App().Flash().Infof("PortForward activated %s", tt[0]) return nil } v.App().Flash().Infof("PortForwards activated %s", strings.Join(tt, ",")) return nil } func showFwdDialog(v ResourceViewer, path string, cb PortForwardCB) error { mm, anns, err := fetchPodPorts(v.App().factory, path) if err != nil { return err } ports := make(port.ContainerPortSpecs, 0, len(mm)) for co, pp := range mm { for _, p := range pp { if p.Protocol != v1.ProtocolTCP { continue } ports = append(ports, port.NewPortSpec(co, p.Name, p.ContainerPort)) } } if spec, ok := anns[port.K9sAutoPortForwardsKey]; ok { pfs, err := port.ParsePFs(spec) if err != nil { return err } pts, err := pfs.ToTunnels(v.App().Config.K9s.PortForwardAddress, ports, port.IsPortFree) if err != nil { return err } return startFwdCB(v, path, pts) } ShowPortForwards(v, path, ports, anns, cb) return nil } func fetchPodPorts(f *watch.Factory, path string) (ports map[string][]v1.ContainerPort, anns map[string]string, err error) { slog.Debug("Fetching ports on pod", slogs.FQN, path) o, err := f.Get(client.PodGVR, path, true, labels.Everything()) if err != nil { return nil, nil, err } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, nil, err } pp := make(map[string][]v1.ContainerPort, len(pod.Spec.Containers)) for i := range pod.Spec.Containers { pp[pod.Spec.Containers[i].Name] = pod.Spec.Containers[i].Ports } return pp, pod.Annotations, nil } ================================================ FILE: internal/view/pf_extender_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) func TestEnsurePodPortFwdAllowed(t *testing.T) { uu := map[string]struct { podExists bool podPhase corev1.PodPhase expectError bool }{ "pod-not-exist": { expectError: true, }, "pod-pending": { podExists: true, podPhase: corev1.PodPending, expectError: true, }, "pod-running": { podExists: true, podPhase: corev1.PodRunning, expectError: false, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { f := testFactory{} if u.podExists { f.expectedGet = &unstructured.Unstructured{ Object: map[string]any{ "status": map[string]any{ "phase": u.podPhase, }, }, } } err := ensurePodPortFwdAllowed(f, "ns/name") if u.expectError { require.Error(t, err) return } require.NoError(t, err) }) } } type testFactory struct { expectedGet runtime.Object } var _ dao.Factory = testFactory{} func (testFactory) Client() client.Connection { return nil } func (t testFactory) Get(*client.GVR, string, bool, labels.Selector) (runtime.Object, error) { if t.expectedGet != nil { return t.expectedGet, nil } return nil, errors.New("not found") } func (testFactory) List(*client.GVR, string, bool, labels.Selector) ([]runtime.Object, error) { return nil, nil } func (testFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (testFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (testFactory) Forwarders() watch.Forwarders { return nil } func (testFactory) WaitForCacheSync() {} func (testFactory) DeleteForwarder(string) {} ================================================ FILE: internal/view/pf_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPortForwardNew(t *testing.T) { pf := view.NewPortForward(client.PfGVR) require.NoError(t, pf.Init(makeCtx(t))) assert.Equal(t, "PortForwards", pf.Name()) assert.Len(t, pf.Hints(), 11) } ================================================ FILE: internal/view/picker.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "k8s.io/apimachinery/pkg/labels" ) // Picker represents a container picker. type Picker struct { *tview.List actions ui.KeyActions } // NewPicker returns a new picker. func NewPicker() *Picker { return &Picker{ List: tview.NewList(), actions: *ui.NewKeyActions(), } } func (*Picker) SetCommand(*cmd.Interpreter) {} func (*Picker) SetFilter(string, bool) {} func (*Picker) SetLabelSelector(labels.Selector, bool) {} // Init initializes the view. func (p *Picker) Init(ctx context.Context) error { app, err := extractApp(ctx) if err != nil { return err } pickerView := app.Styles.Views().Picker p.actions.Add(tcell.KeyEscape, ui.NewKeyAction("Back", app.PrevCmd, true)) p.SetBorder(true) p.SetMainTextColor(pickerView.MainColor.Color()) p.ShowSecondaryText(false) p.SetShortcutColor(pickerView.ShortcutColor.Color()) p.SetSelectedBackgroundColor(pickerView.FocusColor.Color()) p.SetTitle(fmt.Sprintf(" [%s::b]Containers Picker ", app.Styles.Frame().Title.FgColor.String())) p.SetInputCapture(func(evt *tcell.EventKey) *tcell.EventKey { if a, ok := p.actions.Get(evt.Key()); ok { a.Action(evt) evt = nil } return evt }) return nil } // InCmdMode checks if prompt is active. func (*Picker) InCmdMode() bool { return false } // Start starts the view. func (*Picker) Start() {} // Stop stops the view. func (*Picker) Stop() {} // Name returns the component name. func (*Picker) Name() string { return "picker" } // Hints returns the view hints. func (p *Picker) Hints() model.MenuHints { return p.actions.Hints() } // ExtraHints returns additional hints. func (*Picker) ExtraHints() map[string]string { return nil } func (p *Picker) populate(ss []string) { p.Clear() for i, s := range ss { p.AddItem(s, "Select a container", rune('a'+i), nil) } } ================================================ FILE: internal/view/pod.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "fmt" "io/fs" "log/slog" "os" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" "github.com/fatih/color" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/genericclioptions" ) const ( windowsOS = "windows" powerShell = "powershell" osSelector = "kubernetes.io/os" osBetaSelector = "beta." + osSelector trUpload = "Upload" trDownload = "Download" pfIndicator = "[orange::b]Ⓕ" defaultTxRetries = 999 magicPrompt = "Yes Please!" ) // Pod represents a pod viewer. type Pod struct { ResourceViewer } // NewPod returns a new viewer. func NewPod(gvr *client.GVR) ResourceViewer { var p Pod p.ResourceViewer = NewPortForwardExtender( NewOwnerExtender( NewVulnerabilityExtender( NewImageExtender( NewLogsExtender(NewBrowser(gvr), p.logOptions), ), ), ), ) p.AddBindKeysFn(p.bindKeys) p.GetTable().SetEnterFn(p.showContainers) p.GetTable().SetDecorateFn(p.portForwardIndicator) return &p } func (p *Pod) portForwardIndicator(data *model1.TableData) { ff := p.App().factory.Forwarders() defer decorateCpuMemHeaderRows(p.App(), data) idx, ok := data.IndexOfHeader("PF") if !ok { return } data.RowsRange(func(_ int, re model1.RowEvent) bool { if ff.IsPodForwarded(re.Row.ID) { re.Row.Fields[idx] = pfIndicator } return true }) } func (p *Pod) bindDangerousKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ tcell.KeyCtrlK: ui.NewKeyActionWithOpts( "Kill", p.killCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyS: ui.NewKeyActionWithOpts( "Shell", p.shellCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyA: ui.NewKeyActionWithOpts( "Attach", p.attachCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyT: ui.NewKeyActionWithOpts( "Transfer", p.transferCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), ui.KeyZ: ui.NewKeyActionWithOpts( "Sanitize", p.sanitizeCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), }) } func (p *Pod) bindKeys(aa *ui.KeyActions) { if !p.App().Config.IsReadOnly() { p.bindDangerousKeys(aa) } aa.Bulk(ui.KeyMap{ ui.KeyO: ui.NewKeyAction("Show Node", p.showNode, true), }) } func (p *Pod) logOptions(prev bool) (*dao.LogOptions, error) { path := p.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("you must provide a selection") } pod, err := fetchPod(p.App().factory, path) if err != nil { return nil, err } return podLogOptions(p.App(), path, prev, &pod.ObjectMeta, &pod.Spec), nil } func (p *Pod) showContainers(app *App, _ ui.Tabular, _ *client.GVR, _ string) { co := NewContainer(client.CoGVR) co.SetContextFn(p.coContext) if err := app.inject(co, false); err != nil { app.Flash().Err(err) } } func (p *Pod) coContext(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeyPath, p.GetTable().GetSelectedItem()) } // Handlers... func (p *Pod) showNode(evt *tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return evt } pod, err := fetchPod(p.App().factory, path) if err != nil { p.App().Flash().Err(err) return nil } if pod.Spec.NodeName == "" { p.App().Flash().Err(errors.New("no node assigned")) return nil } no := NewNode(client.NodeGVR) no.SetInstance(pod.Spec.NodeName) if err := p.App().inject(no, false); err != nil { p.App().Flash().Err(err) } return nil } func (p *Pod) killCmd(evt *tcell.EventKey) *tcell.EventKey { selections := p.GetTable().GetSelectedItems() if len(selections) == 0 { return evt } res, err := dao.AccessorFor(p.App().factory, p.GVR()) if err != nil { p.App().Flash().Err(err) return nil } nuker, ok := res.(dao.Nuker) if !ok { p.App().Flash().Err(fmt.Errorf("expecting a nuker for %q", p.GVR())) return nil } if len(selections) > 1 { p.App().Flash().Infof("Delete %d marked %s", len(selections), p.GVR()) } else { p.App().Flash().Infof("Delete resource %s %s", p.GVR(), selections[0]) } p.GetTable().ShowDeleted() for _, path := range selections { if err := nuker.Delete(context.Background(), path, nil, dao.NowGrace); err != nil { p.App().Flash().Errf("Delete failed with %s", err) } else { p.App().factory.DeleteForwarder(path) } p.GetTable().DeleteMark(path) } p.Refresh() return nil } func (p *Pod) shellCmd(evt *tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return evt } if !podIsRunning(p.App().factory, path) { p.App().Flash().Errf("%s is not in a running state", path) return nil } if err := containerShellIn(p.App(), p, path, ""); err != nil { p.App().Flash().Err(err) } return nil } func (p *Pod) attachCmd(evt *tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return evt } if !podIsRunning(p.App().factory, path) { p.App().Flash().Errf("%s is not in a happy state", path) return nil } if err := containerAttachIn(p.App(), p, path, ""); err != nil { p.App().Flash().Err(err) } return nil } func (p *Pod) sanitizeCmd(*tcell.EventKey) *tcell.EventKey { res, err := dao.AccessorFor(p.App().factory, p.GVR()) if err != nil { p.App().Flash().Err(err) return nil } s, ok := res.(dao.Sanitizer) if !ok { p.App().Flash().Err(fmt.Errorf("expecting a sanitizer for %q", p.GVR())) return nil } msg := fmt.Sprintf("Sanitize deletes all pods in completed/error state\nPlease enter [orange::b]%s[-::-] to proceed.", magicPrompt) dialog.ShowConfirmAck(p.App().App, p.App().Content.Pages, magicPrompt, true, "Sanitize", msg, func() { ctx, cancel := context.WithTimeout(context.Background(), 5*p.App().Conn().Config().CallTimeout()) defer cancel() total, err := s.Sanitize(ctx, p.GetTable().GetModel().GetNamespace()) if err != nil { p.App().Flash().Err(err) return } p.App().Flash().Infof("Sanitized %d %s", total, p.GVR()) p.Refresh() }, func() {}) return nil } func (p *Pod) transferCmd(*tcell.EventKey) *tcell.EventKey { path := p.GetTable().GetSelectedItem() if path == "" { return nil } ns, n := client.Namespaced(path) ack := func(args dialog.TransferArgs) bool { local := args.To if !args.Download { local = args.From } if _, err := os.Stat(local); !args.Download && errors.Is(err, fs.ErrNotExist) { p.App().Flash().Err(err) return false } opts := make([]string, 0, 10) opts = append(opts, "cp", strings.TrimSpace(args.From), strings.TrimSpace(args.To), fmt.Sprintf("--no-preserve=%t", args.NoPreserve), fmt.Sprintf("--retries=%d", args.Retries), ) if args.CO != "" { opts = append(opts, "-c="+args.CO) } opts = append(opts, fmt.Sprintf("--retries=%d", args.Retries)) cliOpts := shellOpts{ background: true, args: opts, } op := trUpload if args.Download { op = trDownload } fqn := path + ":" + args.CO if err := runK(p.App(), &cliOpts); err != nil { p.App().cowCmd(err.Error()) } else { p.App().Flash().Infof("%s successful on %s!", op, fqn) } return true } pod, err := fetchPod(p.App().factory, path) if err != nil { p.App().Flash().Err(err) return nil } opts := dialog.TransferDialogOpts{ Title: "Transfer", Containers: fetchContainers(&pod.ObjectMeta, &pod.Spec, false), Message: "Download Files", Pod: fmt.Sprintf("%s/%s:", ns, n), Ack: ack, Retries: defaultTxRetries, Cancel: func() {}, } d := p.App().Styles.Dialog() dialog.ShowUploads(&d, p.App().Content.Pages, &opts) return nil } // ---------------------------------------------------------------------------- // Helpers... func containerShellIn(a *App, comp model.Component, path, co string) error { if co != "" { resumeShellIn(a, comp, path, co) return nil } pod, err := fetchPod(a.factory, path) if err != nil { return err } if dco, ok := dao.GetDefaultContainer(&pod.ObjectMeta, &pod.Spec); ok { resumeShellIn(a, comp, path, dco) return nil } cc := fetchContainers(&pod.ObjectMeta, &pod.Spec, false) if len(cc) == 1 { resumeShellIn(a, comp, path, cc[0]) return nil } picker := NewPicker() picker.populate(cc) picker.SetSelectedFunc(func(_ int, co, _ string, _ rune) { resumeShellIn(a, comp, path, co) }) return a.inject(picker, false) } func resumeShellIn(a *App, c model.Component, path, co string) { var err error c.Stop() defer func() { c.Start() a.QueueUpdate(func() { if err != nil { a.Flash().Errf("Shell exec failed: %s", err) } }) }() err = shellIn(a, path, co) } func shellIn(a *App, fqn, co string) error { platform, err := getPodOS(a.factory, fqn) if err != nil { slog.Warn("OS detection failed (assuming linux)", slogs.Error, err) platform = "linux" } args := computeShellArgs(fqn, co, a.Conn().Config().Flags(), platform) c := color.New(color.BgGreen).Add(color.FgBlack).Add(color.Bold) return runK(a, &shellOpts{ clear: true, banner: c.Sprintf(bannerFmt, fqn, co), args: args}, ) } func containerAttachIn(a *App, comp model.Component, path, co string) error { if co != "" { resumeAttachIn(a, comp, path, co) return nil } pod, err := fetchPod(a.factory, path) if err != nil { return err } cc := fetchContainers(&pod.ObjectMeta, &pod.Spec, false) if len(cc) == 1 { resumeAttachIn(a, comp, path, cc[0]) return nil } picker := NewPicker() picker.populate(cc) picker.SetSelectedFunc(func(_ int, co, _ string, _ rune) { resumeAttachIn(a, comp, path, co) }) if err := a.inject(picker, false); err != nil { return err } return nil } func resumeAttachIn(a *App, c model.Component, path, co string) { c.Stop() defer c.Start() attachIn(a, path, co) } func attachIn(a *App, path, co string) { args := buildShellArgs("attach", path, co, a.Conn().Config().Flags()) c := color.New(color.BgGreen).Add(color.FgBlack).Add(color.Bold) if err := runK(a, &shellOpts{clear: true, banner: c.Sprintf(bannerFmt, path, co), args: args}); err != nil { a.Flash().Errf("Attach exec failed: %s", err) } } func computeShellArgs(path, co string, flags *genericclioptions.ConfigFlags, platform string) []string { args := buildShellArgs("exec", path, co, flags) if platform == windowsOS { return append(args, "--", powerShell) } return append(args, "--", "sh", "-c", shellCheck) } func isFlagSet(flag *string) (string, bool) { if flag == nil || *flag == "" { return "", false } return *flag, true } func buildShellArgs(cmd, path, co string, flags *genericclioptions.ConfigFlags) []string { args := make([]string, 0, 15) args = append(args, cmd, "-it") ns, po := client.Namespaced(path) if ns != client.BlankNamespace { args = append(args, "-n", ns) } args = append(args, po) if flags != nil { if v, ok := isFlagSet(flags.KubeConfig); ok { args = append(args, "--kubeconfig", v) } if v, ok := isFlagSet(flags.Context); ok { args = append(args, "--context", v) } if v, ok := isFlagSet(flags.BearerToken); ok { args = append(args, "--token", v) } } if co != "" { args = append(args, "-c", co) } return args } func fetchContainers(meta *metav1.ObjectMeta, spec *v1.PodSpec, allContainers bool) []string { nn := make([]string, 0, len(spec.Containers)+len(spec.EphemeralContainers)+len(spec.InitContainers)) // put the default container as the first entry defaultContainer, ok := dao.GetDefaultContainer(meta, spec) if ok { nn = append(nn, defaultContainer) } for i := range spec.Containers { if spec.Containers[i].Name != defaultContainer { nn = append(nn, spec.Containers[i].Name) } } for i := range spec.InitContainers { isSidecar := spec.InitContainers[i].RestartPolicy != nil && *spec.InitContainers[i].RestartPolicy == v1.ContainerRestartPolicyAlways if allContainers || isSidecar { nn = append(nn, spec.InitContainers[i].Name) } } for i := range spec.EphemeralContainers { nn = append(nn, spec.EphemeralContainers[i].Name) } return nn } func fetchPod(f dao.Factory, path string) (*v1.Pod, error) { o, err := f.Get(client.PodGVR, path, true, labels.Everything()) if err != nil { return nil, err } var pod v1.Pod err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod) if err != nil { return nil, err } return &pod, nil } func podIsRunning(f dao.Factory, fqn string) bool { po, err := fetchPod(f, fqn) if err != nil { slog.Error("Unable to fetch pod", slogs.FQN, fqn, slogs.Error, err, ) return false } var re render.Pod return re.Phase(po.DeletionTimestamp, &po.Spec, &po.Status) == render.Running } func getPodOS(f dao.Factory, fqn string) (string, error) { po, err := fetchPod(f, fqn) if err != nil { return "", err } if podOS, ok := osFromSelector(po.Spec.NodeSelector); ok { return podOS, nil } node, err := dao.FetchNode(context.Background(), f, po.Spec.NodeName) if err == nil { if nodeOS, ok := osFromSelector(node.Labels); ok { return nodeOS, nil } } return "", errors.New("no os information available") } func osFromSelector(s map[string]string) (string, bool) { if platform, ok := s[osBetaSelector]; ok { return platform, ok } platform, ok := s[osSelector] return platform, ok } ================================================ FILE: internal/view/pod_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "strings" "testing" "github.com/stretchr/testify/assert" "k8s.io/cli-runtime/pkg/genericclioptions" ) func newStr(s string) *string { return &s } func TestComputeShellArgs(t *testing.T) { uu := map[string]struct { fqn, co, os string cfg *genericclioptions.ConfigFlags e string }{ "config": { fqn: "fred/blee", co: "c1", os: "darwin", cfg: &genericclioptions.ConfigFlags{ KubeConfig: newStr("coolConfig"), }, e: "exec -it -n fred blee --kubeconfig coolConfig -c c1 -- sh -c " + shellCheck, }, "no-config": { fqn: "fred/blee", co: "c1", os: "linux", e: "exec -it -n fred blee -c c1 -- sh -c " + shellCheck, }, "empty-config": { fqn: "fred/blee", cfg: new(genericclioptions.ConfigFlags), e: "exec -it -n fred blee -- sh -c " + shellCheck, }, "single-container": { fqn: "fred/blee", os: "linux", cfg: new(genericclioptions.ConfigFlags), e: "exec -it -n fred blee -- sh -c " + shellCheck, }, "windows": { fqn: "fred/blee", co: "c1", os: windowsOS, cfg: new(genericclioptions.ConfigFlags), e: "exec -it -n fred blee -c c1 -- powershell", }, "full": { fqn: "fred/blee", co: "c1", os: windowsOS, cfg: &genericclioptions.ConfigFlags{ KubeConfig: newStr("coolConfig"), Context: newStr("coolContext"), BearerToken: newStr("coolToken"), }, e: "exec -it -n fred blee --kubeconfig coolConfig --context coolContext --token coolToken -c c1 -- powershell", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { args := computeShellArgs(u.fqn, u.co, u.cfg, u.os) assert.Equal(t, u.e, strings.Join(args, " ")) }) } } ================================================ FILE: internal/view/pod_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPodNew(t *testing.T) { po := view.NewPod(client.PodGVR) require.NoError(t, po.Init(makeCtx(t))) assert.Equal(t, "Pods", po.Name()) assert.Len(t, po.Hints(), 19) } // Helpers... func makeCtx(t testing.TB) context.Context { cfg := mock.NewMockConfig(t) return context.WithValue(context.Background(), internal.KeyApp, view.NewApp(cfg)) } ================================================ FILE: internal/view/policy.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) const ( group = "Group" user = "User" sa = "ServiceAccount" ) // Policy presents a RBAC rules viewer based on what a given user/group or sa can do. type Policy struct { ResourceViewer subjectKind, subjectName string } // NewPolicy returns a new viewer. func NewPolicy(_ *App, subject, name string) *Policy { p := Policy{ ResourceViewer: NewBrowser(client.PolGVR), subjectKind: subject, subjectName: name, } p.AddBindKeysFn(p.bindKeys) p.GetTable().SetSortCol("API-GROUP", false) p.SetContextFn(p.subjectCtx) p.GetTable().SetEnterFn(blankEnterFn) return &p } func (p *Policy) subjectCtx(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeySubjectKind, mapSubject(p.subjectKind)) ctx = context.WithValue(ctx, internal.KeyPath, mapSubject(p.subjectKind)+":"+p.subjectName) return context.WithValue(ctx, internal.KeySubjectName, p.subjectName) } func (*Policy) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, tcell.KeyCtrlSpace, ui.KeySpace) } func mapSubject(subject string) string { switch subject { case "g": return group case "s": return sa case "u": return user default: return subject } } ================================================ FILE: internal/view/priorityclass.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // PriorityClass presents a priority class viewer. type PriorityClass struct { ResourceViewer } // NewPriorityClass returns a new viewer. func NewPriorityClass(gvr *client.GVR) ResourceViewer { s := PriorityClass{ ResourceViewer: NewBrowser(gvr), } s.AddBindKeysFn(s.bindKeys) return &s } func (s *PriorityClass) bindKeys(aa *ui.KeyActions) { aa.Add(ui.KeyU, ui.NewKeyAction("UsedBy", s.refCmd, true)) } func (s *PriorityClass) refCmd(evt *tcell.EventKey) *tcell.EventKey { return scanRefs(evt, s.App(), s.GetTable(), client.PcGVR) } ================================================ FILE: internal/view/priorityclass_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPriorityClassNew(t *testing.T) { s := view.NewPriorityClass(client.PcGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "PriorityClass", s.Name()) assert.Len(t, s.Hints(), 8) } ================================================ FILE: internal/view/pulse.go ================================================ package view import ( "context" "fmt" "image" "log/slog" "time" "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/slogs" "github.com/derailed/k9s/internal/tchart" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "golang.org/x/text/cases" "golang.org/x/text/language" "k8s.io/apimachinery/pkg/labels" ) const ( cpuFmt = " %s [%s::b]%s[white::-]([%s::]%sm[white::]/[%s::]%sm[-::])" memFmt = " %s [%s::b]%s[white::-]([%s::]%sMi[white::]/[%s::]%sMi[-::])" pulseTitle = "Pulses" NSTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " dirLeft = 1 dirRight = -dirLeft dirDown = 4 dirUp = -dirDown grayC = "gray" ) var corpusGVRs = append(model.PulseGVRs, client.CpuGVR, client.MemGVR) type Charts map[*client.GVR]Graphable // Graphable represents a graphic component. type Graphable interface { tview.Primitive // ID returns the graph id. ID() string // Add adds a metric Add(ok, fault int) AddMetric(time.Time, float64) // SetLegend sets the graph legend SetLegend(string) SetColorIndex(int) SetMax(float64) GetMax() float64 // SetSeriesColors sets charts series colors. SetSeriesColors(...tcell.Color) // GetSeriesColorNames returns the series color names. GetSeriesColorNames() []string // SetFocusColorNames sets the focus color names. SetFocusColorNames(fg, bg string) // SetBackgroundColor sets chart bg color. SetBackgroundColor(tcell.Color) SetBorderColor(tcell.Color) *tview.Box // IsDial returns true if chart is a dial IsDial() bool } // Pulse represents a command health view. type Pulse struct { *tview.Grid app *App gvr *client.GVR model *model.Pulse cancelFn context.CancelFunc actions *ui.KeyActions charts Charts prevFocusIndex int chartGVRs client.GVRs } // NewPulse returns a new alias view. func NewPulse(gvr *client.GVR) ResourceViewer { return &Pulse{ Grid: tview.NewGrid(), model: model.NewPulse(gvr), actions: ui.NewKeyActions(), prevFocusIndex: -1, } } // Init initializes the view. func (p *Pulse) Init(ctx context.Context) error { p.SetBorder(true) p.SetGap(0, 0) p.SetBorderPadding(0, 0, 1, 1) var err error if p.app, err = extractApp(ctx); err != nil { return err } ns := p.app.Config.ActiveNamespace() frame := p.app.Styles.Frame() p.SetTitle(ui.SkinTitle(fmt.Sprintf(NSTitleFmt, pulseTitle, ns), &frame)) index, chartRow := 4, 6 if client.IsAllNamespace(ns) { index, chartRow = 0, 8 } p.chartGVRs = corpusGVRs[index:] p.charts = make(Charts, len(p.chartGVRs)) var x, y, col int for _, gvr := range p.chartGVRs[:len(p.chartGVRs)-2] { p.charts[gvr] = p.makeGA(image.Point{X: x, Y: y}, image.Point{X: 2, Y: 2}, gvr) col, y = col+1, y+2 if y > 6 { y = 0 } if col >= 4 { col, x = 0, x+2 } } if p.app.Conn().HasMetrics() { p.charts[client.CpuGVR] = p.makeSP(image.Point{X: chartRow, Y: 0}, image.Point{X: 2, Y: 4}, client.CpuGVR, "c") p.charts[client.MemGVR] = p.makeSP(image.Point{X: chartRow, Y: 4}, image.Point{X: 2, Y: 4}, client.MemGVR, "Gi") } p.GetItem(0).Focus = true p.app.SetFocus(p.charts[p.chartGVRs[0]]) p.bindKeys() p.app.Styles.AddListener(p) p.StylesChanged(p.app.Styles) p.model.SetNamespace(ns) return nil } // InCmdMode checks if prompt is active. func (*Pulse) InCmdMode() bool { return false } func (*Pulse) SetCommand(*cmd.Interpreter) {} func (*Pulse) SetFilter(string, bool) {} func (*Pulse) SetLabelSelector(labels.Selector, bool) {} // StylesChanged notifies the skin changed. func (p *Pulse) StylesChanged(s *config.Styles) { p.SetBackgroundColor(s.Charts().BgColor.Color()) for _, c := range p.charts { c.SetFocusColorNames(s.Charts().FocusFgColor.String(), s.Charts().FocusBgColor.String()) if c.IsDial() { c.SetBackgroundColor(s.Charts().DialBgColor.Color()) c.SetSeriesColors(s.Charts().DefaultDialColors.Colors()...) } else { c.SetBackgroundColor(s.Charts().ChartBgColor.Color()) c.SetSeriesColors(s.Charts().DefaultChartColors.Colors()...) } if ss, ok := s.Charts().ResourceColors[c.ID()]; ok { c.SetSeriesColors(ss.Colors()...) } } } // SeriesChanged update cluster time series. func (p *Pulse) SeriesChanged(tt dao.TimeSeries) { if len(tt) == 0 { return } cpu, ok := p.charts[client.CpuGVR] if !ok { return } mem := p.charts[client.MemGVR] if !ok { return } for i := range tt { t := tt[i] cpu.SetMax(float64(t.Value.AllocatableCPU)) mem.SetMax(float64(t.Value.AllocatableMEM)) cpu.AddMetric(t.Time, float64(t.Value.CurrentCPU)) mem.AddMetric(t.Time, float64(t.Value.CurrentMEM)) } last := tt[len(tt)-1] perc := client.ToPercentage(last.Value.CurrentCPU, int64(cpu.GetMax())) index := int(p.app.Config.K9s.Thresholds.LevelFor("cpu", perc)) cpu.SetColorIndex(int(p.app.Config.K9s.Thresholds.LevelFor("cpu", perc))) nn := cpu.GetSeriesColorNames() if last.Value.CurrentCPU == 0 { nn[0] = grayC } if last.Value.AllocatableCPU == 0 { nn[1] = grayC } cpu.SetLegend(fmt.Sprintf(cpuFmt, cases.Title(language.English).String(client.CpuGVR.R()), p.app.Config.K9s.Thresholds.SeverityColor("cpu", perc), render.PrintPerc(perc), nn[index], render.AsThousands(last.Value.CurrentCPU), "white", render.AsThousands(int64(cpu.GetMax())), )) nn = mem.GetSeriesColorNames() if last.Value.CurrentMEM == 0 { nn[0] = grayC } if last.Value.AllocatableMEM == 0 { nn[1] = grayC } perc = client.ToPercentage(last.Value.CurrentMEM, int64(mem.GetMax())) index = int(p.app.Config.K9s.Thresholds.LevelFor("memory", perc)) mem.SetColorIndex(index) mem.SetLegend(fmt.Sprintf(memFmt, cases.Title(language.English).String(client.MemGVR.R()), p.app.Config.K9s.Thresholds.SeverityColor("memory", perc), render.PrintPerc(perc), nn[index], render.AsThousands(last.Value.CurrentMEM), "white", render.AsThousands(int64(mem.GetMax())), )) } // PulseChanged notifies the model data changed. func (p *Pulse) PulseChanged(pt model.HealthPoint) { v, ok := p.charts[pt.GVR] if !ok { return } nn := v.GetSeriesColorNames() if pt.Total == 0 { nn[0] = grayC } if pt.Faults == 0 { nn[1] = grayC } v.SetLegend(cases.Title(language.English).String(pt.GVR.R())) if pt.Faults > 0 { v.SetBorderColor(tcell.ColorDarkRed) } else { v.SetBorderColor(tcell.ColorDarkOliveGreen) } v.Add(pt.Total, pt.Faults) } // PulseFailed notifies the load failed. func (p *Pulse) PulseFailed(err error) { p.app.Flash().Err(err) } func (p *Pulse) bindKeys() { p.actions.Merge(ui.NewKeyActionsFromMap(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("Goto", p.enterCmd, true), tcell.KeyTab: ui.NewKeyAction("Next", p.nextFocusCmd(dirLeft), true), tcell.KeyBacktab: ui.NewKeyAction("Prev", p.nextFocusCmd(dirRight), true), tcell.KeyDown: ui.NewKeyAction("Down", p.nextFocusCmd(dirDown), false), tcell.KeyUp: ui.NewKeyAction("Up", p.nextFocusCmd(dirUp), false), tcell.KeyRight: ui.NewKeyAction("Next", p.nextFocusCmd(dirLeft), false), tcell.KeyLeft: ui.NewKeyAction("Prev", p.nextFocusCmd(dirRight), false), ui.KeyH: ui.NewKeyAction("Prev", p.nextFocusCmd(dirRight), false), ui.KeyJ: ui.NewKeyAction("Down", p.nextFocusCmd(dirDown), false), ui.KeyK: ui.NewKeyAction("Up", p.nextFocusCmd(dirUp), false), ui.KeyL: ui.NewKeyAction("Next", p.nextFocusCmd(dirLeft), false), })) } func (p *Pulse) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() if key == tcell.KeyRune { key = tcell.Key(evt.Rune()) } if a, ok := p.actions.Get(key); ok { return a.Action(evt) } return evt } func (p *Pulse) defaultContext() context.Context { return context.WithValue(context.Background(), internal.KeyFactory, p.app.factory) } func (*Pulse) Restart() {} // Start initializes resource watch loop. func (p *Pulse) Start() { p.Stop() ctx := p.defaultContext() ctx, p.cancelFn = context.WithCancel(ctx) gaugeChan, metricsChan, err := p.model.Watch(ctx) if err != nil { slog.Error("Pulse watch failed", slogs.Error, err) return } go func() { for { select { case check, ok := <-gaugeChan: if !ok { return } p.app.QueueUpdateDraw(func() { p.PulseChanged(check) }) case mx, ok := <-metricsChan: if !ok { return } p.app.QueueUpdateDraw(func() { p.SeriesChanged(mx) }) } } }() } // Stop terminates watch loop. func (p *Pulse) Stop() { if p.cancelFn == nil { return } p.cancelFn() p.cancelFn = nil } // Refresh updates the view func (*Pulse) Refresh() {} // GVR returns a resource descriptor. func (p *Pulse) GVR() *client.GVR { return p.gvr } // Name returns the component name. func (*Pulse) Name() string { return pulseTitle } // App returns the current app handle. func (p *Pulse) App() *App { return p.app } // SetInstance sets specific resource instance. func (*Pulse) SetInstance(string) {} // SetEnvFn sets the custom environment function. func (*Pulse) SetEnvFn(EnvFunc) {} // AddBindKeysFn sets up extra key bindings. func (*Pulse) AddBindKeysFn(BindKeysFunc) {} // SetContextFn sets custom context. func (*Pulse) SetContextFn(ContextFunc) {} func (*Pulse) GetContextFn() ContextFunc { return nil } // GetTable return the view table if any. func (*Pulse) GetTable() *Table { return nil } // Actions returns active menu bindings. func (p *Pulse) Actions() *ui.KeyActions { return p.actions } // Hints returns the view hints. func (p *Pulse) Hints() model.MenuHints { return p.actions.Hints() } // ExtraHints returns additional hints. func (*Pulse) ExtraHints() map[string]string { return nil } func (p *Pulse) enterCmd(*tcell.EventKey) *tcell.EventKey { v := p.App().GetFocus() s, ok := v.(Graphable) if !ok { return nil } g, ok := v.(Graphable) if !ok { return nil } p.prevFocusIndex = p.findIndex(g) for i := range len(p.charts) { gi := p.GetItem(i) if i == p.prevFocusIndex { gi.Focus = true } else { gi.Focus = false } } p.Stop() res := client.NewGVR(s.ID()).R() if res == "cpu" || res == "memory" { res = client.PodGVR.String() } p.App().SetFocus(p.App().Main) p.App().gotoResource(res+" "+p.model.GetNamespace(), "", false, true) return nil } func (p *Pulse) nextFocusCmd(direction int) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { v := p.app.GetFocus() g, ok := v.(Graphable) if !ok { return nil } currentIndex := p.findIndex(g) nextIndex, total := currentIndex+direction, len(p.charts) if nextIndex < 0 { return nil } switch direction { case dirLeft: if nextIndex >= total { return nil } p.prevFocusIndex = -1 case dirRight: p.prevFocusIndex = -1 case dirUp: if p.app.Conn().HasMetrics() { if currentIndex >= total-2 { if p.prevFocusIndex >= 0 && p.prevFocusIndex != currentIndex { nextIndex = p.prevFocusIndex } else if currentIndex == p.chartGVRs.Len()-1 { nextIndex += 1 } } else { p.prevFocusIndex = currentIndex } } case dirDown: if p.app.Conn().HasMetrics() { if currentIndex >= total-6 && currentIndex < total-2 { switch { case (currentIndex % 4) <= 1: p.prevFocusIndex, nextIndex = currentIndex, total-2 case (currentIndex % 4) <= 3: p.prevFocusIndex, nextIndex = currentIndex, total-1 } } else if currentIndex >= total-2 { return nil } } } if nextIndex < 0 { nextIndex = 0 } else if nextIndex > total-1 { nextIndex = currentIndex } p.GetItem(nextIndex).Focus = false p.GetItem(nextIndex).Item.Blur() i, v := p.nextFocus(nextIndex) p.GetItem(i).Focus = true p.app.SetFocus(v) return nil } } func (p *Pulse) makeSP(loc, span image.Point, gvr *client.GVR, unit string) *tchart.SparkLine { s := tchart.NewSparkLine(gvr.String(), unit) s.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) if cc, ok := p.app.Styles.Charts().ResourceColors[gvr.String()]; ok { s.SetSeriesColors(cc.Colors()...) } else { s.SetSeriesColors(p.app.Styles.Charts().DefaultChartColors.Colors()...) } s.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.English).String(gvr.R()))) s.SetInputCapture(p.keyboard) p.AddItem(s, loc.X, loc.Y, span.X, span.Y, 0, 0, false) return s } func (p *Pulse) makeGA(loc, span image.Point, gvr *client.GVR) *tchart.Gauge { g := tchart.NewGauge(gvr.String()) g.SetBorder(true) g.SetBackgroundColor(p.app.Styles.Charts().BgColor.Color()) if cc, ok := p.app.Styles.Charts().ResourceColors[gvr.String()]; ok { g.SetSeriesColors(cc.Colors()...) } else { g.SetSeriesColors(p.app.Styles.Charts().DefaultDialColors.Colors()...) } g.SetLegend(fmt.Sprintf(" %s ", cases.Title(language.English).String(gvr.R()))) g.SetInputCapture(p.keyboard) p.AddItem(g, loc.X, loc.Y, span.X, span.Y, 0, 0, false) return g } // ---------------------------------------------------------------------------- // Helpers func (p *Pulse) nextFocus(index int) (int, tview.Primitive) { if index >= len(p.chartGVRs) { return 0, p.charts[p.chartGVRs[0]] } if index < 0 { return len(p.chartGVRs) - 1, p.charts[p.chartGVRs[len(p.chartGVRs)-1]] } return index, p.charts[p.chartGVRs[index]] } func (p *Pulse) findIndex(g Graphable) int { for i, gvr := range p.chartGVRs { if gvr.String() == g.ID() { return i } } return 0 } ================================================ FILE: internal/view/pvc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // PersistentVolumeClaim represents a PVC custom viewer. type PersistentVolumeClaim struct { ResourceViewer } // NewPersistentVolumeClaim returns a new viewer. func NewPersistentVolumeClaim(gvr *client.GVR) ResourceViewer { v := PersistentVolumeClaim{ ResourceViewer: NewOwnerExtender(NewBrowser(gvr)), } v.AddBindKeysFn(v.bindKeys) return &v } func (p *PersistentVolumeClaim) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyU: ui.NewKeyAction("UsedBy", p.refCmd, true), }) } func (p *PersistentVolumeClaim) refCmd(evt *tcell.EventKey) *tcell.EventKey { return scanRefs(evt, p.App(), p.GetTable(), client.PvcGVR) } ================================================ FILE: internal/view/pvc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPVCNew(t *testing.T) { v := view.NewPersistentVolumeClaim(client.PvcGVR) require.NoError(t, v.Init(makeCtx(t))) assert.Equal(t, "PersistentVolumeClaims", v.Name()) assert.Len(t, v.Hints(), 9) } ================================================ FILE: internal/view/rbac.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // Rbac presents an RBAC policy viewer. type Rbac struct { ResourceViewer } // NewRbac returns a new viewer. func NewRbac(gvr *client.GVR) ResourceViewer { r := Rbac{ ResourceViewer: NewBrowser(gvr), } r.AddBindKeysFn(r.bindKeys) r.GetTable().SetSortCol("API-GROUP", true) r.GetTable().SetEnterFn(blankEnterFn) return &r } func (*Rbac) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, tcell.KeyCtrlSpace, ui.KeySpace) } func showRules(app *App, _ ui.Tabular, gvr *client.GVR, path string) { v := NewRbac(client.RbacGVR) v.SetContextFn(rbacCtx(gvr, path)) if err := app.inject(v, false); err != nil { app.Flash().Err(err) } } func rbacCtx(gvr *client.GVR, path string) ContextFunc { return func(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, path) return context.WithValue(ctx, internal.KeyGVR, gvr) } } func blankEnterFn(_ *App, _ ui.Tabular, _ *client.GVR, _ string) {} ================================================ FILE: internal/view/rbac_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRbacNew(t *testing.T) { v := view.NewRbac(client.RbacGVR) require.NoError(t, v.Init(makeCtx(t))) assert.Equal(t, "Rbac", v.Name()) assert.Len(t, v.Hints(), 6) } ================================================ FILE: internal/view/reference.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // Reference represents resource references. type Reference struct { ResourceViewer } // NewReference returns a new alias view. func NewReference(gvr *client.GVR) ResourceViewer { r := Reference{ ResourceViewer: NewBrowser(gvr), } r.GetTable().SetBorderFocusColor(tcell.ColorMediumSpringGreen) r.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorMediumSpringGreen).Attributes(tcell.AttrNone)) r.AddBindKeysFn(r.bindKeys) return &r } // Init initializes the view. func (r *Reference) Init(ctx context.Context) error { if err := r.ResourceViewer.Init(ctx); err != nil { return err } r.GetTable().GetModel().SetNamespace(client.BlankNamespace) return nil } func (r *Reference) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, tcell.KeyCtrlS, tcell.KeyCtrlSpace, ui.KeySpace) aa.Delete(tcell.KeyCtrlW, tcell.KeyCtrlL, tcell.KeyCtrlZ) aa.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("Goto", r.gotoCmd, true), ui.KeyShiftV: ui.NewKeyAction("Sort GVR", r.GetTable().SortColCmd("GVR", true), false), }) } func (r *Reference) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { row, _ := r.GetTable().GetSelection() if row == 0 { return evt } path := r.GetTable().GetSelectedItem() ns, _ := client.Namespaced(path) gvr := ui.TrimCell(r.GetTable().SelectTable, row, 2) r.App().gotoResource(client.NewGVR(gvr).String()+" "+ns, path, false, true) return evt } ================================================ FILE: internal/view/reference_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReferenceNew(t *testing.T) { s := view.NewReference(client.RefGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "References", s.Name()) assert.Len(t, s.Hints(), 6) } ================================================ FILE: internal/view/registrar.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" ) func loadCustomViewers() MetaViewers { m := make(MetaViewers, 30) coreViewers(m) miscViewers(m) appsViewers(m) rbacViewers(m) batchViewers(m) crdViewers(m) helmViewers(m) return m } func helmViewers(vv MetaViewers) { vv[client.HmGVR] = MetaViewer{ viewerFn: NewHelmChart, } } func coreViewers(vv MetaViewers) { vv[client.NsGVR] = MetaViewer{ viewerFn: NewNamespace, } vv[client.EvGVR] = MetaViewer{ viewerFn: NewEvent, } vv[client.PodGVR] = MetaViewer{ viewerFn: NewPod, } vv[client.SvcGVR] = MetaViewer{ viewerFn: NewService, } vv[client.NodeGVR] = MetaViewer{ viewerFn: NewNode, } vv[client.SecGVR] = MetaViewer{ viewerFn: NewSecret, } vv[client.PcGVR] = MetaViewer{ viewerFn: NewPriorityClass, } vv[client.CmGVR] = MetaViewer{ viewerFn: NewConfigMap, } vv[client.SaGVR] = MetaViewer{ viewerFn: NewServiceAccount, } vv[client.PvcGVR] = MetaViewer{ viewerFn: NewPersistentVolumeClaim, } } func miscViewers(vv MetaViewers) { vv[client.WkGVR] = MetaViewer{ viewerFn: NewWorkload, } vv[client.CtGVR] = MetaViewer{ viewerFn: NewContext, } vv[client.CoGVR] = MetaViewer{ viewerFn: NewContainer, } vv[client.ScnGVR] = MetaViewer{ viewerFn: NewImageScan, } vv[client.PfGVR] = MetaViewer{ viewerFn: NewPortForward, } vv[client.SdGVR] = MetaViewer{ viewerFn: NewScreenDump, } vv[client.BeGVR] = MetaViewer{ viewerFn: NewBenchmark, } vv[client.AliGVR] = MetaViewer{ viewerFn: NewAlias, } vv[client.RefGVR] = MetaViewer{ viewerFn: NewReference, } vv[client.PuGVR] = MetaViewer{ viewerFn: NewPulse, } } func appsViewers(vv MetaViewers) { vv[client.DpGVR] = MetaViewer{ viewerFn: NewDeploy, } vv[client.RsGVR] = MetaViewer{ viewerFn: NewReplicaSet, } vv[client.StsGVR] = MetaViewer{ viewerFn: NewStatefulSet, } vv[client.DsGVR] = MetaViewer{ viewerFn: NewDaemonSet, } } func rbacViewers(vv MetaViewers) { vv[client.RbacGVR] = MetaViewer{ enterFn: showRules, } vv[client.UsrGVR] = MetaViewer{ viewerFn: NewUser, } vv[client.GrpGVR] = MetaViewer{ viewerFn: NewGroup, } vv[client.CrGVR] = MetaViewer{ enterFn: showRules, } vv[client.CrbGVR] = MetaViewer{ enterFn: showRules, } vv[client.RoGVR] = MetaViewer{ enterFn: showRules, } vv[client.RobGVR] = MetaViewer{ enterFn: showRules, } } func batchViewers(vv MetaViewers) { vv[client.CjGVR] = MetaViewer{ viewerFn: NewCronJob, } vv[client.JobGVR] = MetaViewer{ viewerFn: NewJob, } } func crdViewers(vv MetaViewers) { vv[client.CrdGVR] = MetaViewer{ viewerFn: NewCRD, } } ================================================ FILE: internal/view/restart_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "fmt" "strings" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // RestartExtender represents a restartable resource. type RestartExtender struct { ResourceViewer } // NewRestartExtender returns a new extender. func NewRestartExtender(v ResourceViewer) ResourceViewer { r := RestartExtender{ResourceViewer: v} v.AddBindKeysFn(r.bindKeys) return &r } // BindKeys creates additional menu actions. func (r *RestartExtender) bindKeys(aa *ui.KeyActions) { if r.App().Config.IsReadOnly() { return } aa.Add(ui.KeyR, ui.NewKeyActionWithOpts("Restart", r.restartCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }, )) } func (r *RestartExtender) restartCmd(*tcell.EventKey) *tcell.EventKey { paths := r.GetTable().GetSelectedItems() if len(paths) == 0 || paths[0] == "" { return nil } r.Stop() defer r.Start() msg := fmt.Sprintf("Restart %s %s?", singularize(r.GVR().R()), paths[0]) if len(paths) > 1 { msg = fmt.Sprintf("Restart %d %s?", len(paths), r.GVR().R()) } d := r.App().Styles.Dialog() opts := dialog.RestartDialogOpts{ Title: "Confirm Restart", Message: msg, FieldManager: "kubectl-rollout", Ack: func(opts *metav1.PatchOptions) bool { ctx, cancel := context.WithTimeout(context.Background(), r.App().Conn().Config().CallTimeout()) defer cancel() for _, path := range paths { if err := r.restartRollout(ctx, path, opts); err != nil { r.App().Flash().Err(err) } else { r.App().Flash().Infof("Restart in progress for `%s...", path) } } return true }, Cancel: func() {}, } dialog.ShowRestart(&d, r.App().Content.Pages, &opts) return nil } func (r *RestartExtender) restartRollout(ctx context.Context, path string, opts *metav1.PatchOptions) error { res, err := dao.AccessorFor(r.App().factory, r.GVR()) if err != nil { return err } s, ok := res.(dao.Restartable) if !ok { return errors.New("resource is not restartable") } return s.Restart(ctx, path, opts) } // Helpers... func singularize(s string) string { if strings.LastIndex(s, "s") == len(s)-1 { return s[:len(s)-1] } return s } ================================================ FILE: internal/view/rs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" ) // ReplicaSet presents a replicaset viewer. type ReplicaSet struct { ResourceViewer } // NewReplicaSet returns a new viewer. func NewReplicaSet(gvr *client.GVR) ResourceViewer { r := ReplicaSet{ ResourceViewer: NewOwnerExtender( NewVulnerabilityExtender( NewBrowser(gvr), ), ), } r.AddBindKeysFn(r.bindKeys) r.GetTable().SetEnterFn(r.showPods) return &r } func (r *ReplicaSet) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ tcell.KeyCtrlL: ui.NewKeyAction("Rollback", r.rollbackCmd, true), }) } func (*ReplicaSet) showPods(app *App, _ ui.Tabular, _ *client.GVR, path string) { var drs dao.ReplicaSet rs, err := drs.Load(app.factory, path) if err != nil { app.Flash().Err(err) return } showPodsFromSelector(app, path, rs.Spec.Selector) } func (r *ReplicaSet) rollbackCmd(evt *tcell.EventKey) *tcell.EventKey { path := r.GetTable().GetSelectedItem() if path == "" { return evt } msg := fmt.Sprintf("Rollback %s %s?", r.GVR(), path) d := r.App().Styles.Dialog() dialog.ShowConfirm(&d, r.App().Content.Pages, "Rollback", msg, func() { r.App().Flash().Infof("Rolling back %s %s", r.GVR(), path) var drs dao.ReplicaSet drs.Init(r.App().factory, r.GVR()) if err := drs.Rollback(path); err != nil { r.App().Flash().Err(err) } else { r.App().Flash().Infof("%s successfully rolled back", path) } r.Refresh() }, func() {}) return nil } ================================================ FILE: internal/view/sa.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // ServiceAccount represents a serviceaccount viewer. type ServiceAccount struct { ResourceViewer } // NewServiceAccount returns a new viewer. func NewServiceAccount(gvr *client.GVR) ResourceViewer { s := ServiceAccount{ ResourceViewer: NewOwnerExtender(NewBrowser(gvr)), } s.AddBindKeysFn(s.bindKeys) s.SetContextFn(s.subjectCtx) return &s } func (s *ServiceAccount) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyU: ui.NewKeyAction("UsedBy", s.refCmd, true), tcell.KeyEnter: ui.NewKeyAction("Rules", s.policyCmd, true), }) } func (*ServiceAccount) subjectCtx(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeySubjectKind, sa) } func (s *ServiceAccount) refCmd(evt *tcell.EventKey) *tcell.EventKey { return scanSARefs(evt, s.App(), s.GetTable(), client.SaGVR) } func (s *ServiceAccount) policyCmd(evt *tcell.EventKey) *tcell.EventKey { path := s.GetTable().GetSelectedItem() if path == "" { return evt } if err := s.App().inject(NewPolicy(s.App(), sa, path), false); err != nil { s.App().Flash().Err(err) } return nil } func scanSARefs(evt *tcell.EventKey, a *App, t *Table, gvr *client.GVR) *tcell.EventKey { path := t.GetSelectedItem() if path == "" { return evt } ctx := context.Background() refs, err := dao.ScanForSARefs(refContext(gvr, path, true)(ctx), a.factory) if err != nil { a.Flash().Err(err) return nil } if len(refs) == 0 { a.Flash().Warnf("No references found at this time for %s::%s. Check again later!", gvr, path) return nil } a.Flash().Infof("Viewing references for %s::%s", gvr, path) view := NewReference(client.RefGVR) view.SetContextFn(refContext(gvr, path, false)) if err := a.inject(view, false); err != nil { a.Flash().Err(err) } return nil } ================================================ FILE: internal/view/scale_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strconv" "strings" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // ScaleExtender adds scaling extensions. type ScaleExtender struct { ResourceViewer } // NewScaleExtender returns a new extender. func NewScaleExtender(r ResourceViewer) ResourceViewer { s := ScaleExtender{ResourceViewer: r} s.AddBindKeysFn(s.bindKeys) return &s } func (s *ScaleExtender) bindKeys(aa *ui.KeyActions) { if s.App().Config.IsReadOnly() { return } meta, err := dao.MetaAccess.MetaFor(s.GVR()) if err != nil { slog.Error("No meta information found", slogs.GVR, s.GVR(), slogs.Error, err, ) return } if dao.IsScalable(meta) { aa.Add(ui.KeyS, ui.NewKeyActionWithOpts("Scale", s.scaleCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }, )) } } func (s *ScaleExtender) scaleCmd(*tcell.EventKey) *tcell.EventKey { paths := s.GetTable().GetSelectedItems() if len(paths) == 0 { return nil } s.Stop() defer s.Start() s.showScaleDialog(paths) return nil } func (s *ScaleExtender) showScaleDialog(paths []string) { form, err := s.makeScaleForm(paths) if err != nil { s.App().Flash().Err(err) return } confirm := tview.NewModalForm("", form) msg := fmt.Sprintf("Scale %s %s?", singularize(s.GVR().R()), paths[0]) if len(paths) > 1 { msg = fmt.Sprintf("Scale [%d] %s?", len(paths), s.GVR().R()) } confirm.SetText(msg) confirm.SetDoneFunc(func(int, string) { s.dismissDialog() }) s.App().Content.AddPage(scaleDialogKey, confirm, false, false) s.App().Content.ShowPage(scaleDialogKey) } func (s *ScaleExtender) valueOf(col string) (string, error) { colIdx, ok := s.GetTable().HeaderIndex(col) if !ok { return "", fmt.Errorf("no column index for %s", col) } return s.GetTable().GetSelectedCell(colIdx), nil } func (s *ScaleExtender) replicasFromReady(_ string) (string, error) { replicas, err := s.valueOf("READY") if err != nil { return "", err } tokens := strings.Split(replicas, "/") if len(tokens) < 2 { return "", fmt.Errorf("unable to locate replicas from %s", replicas) } return strings.TrimRight(tokens[1], ui.DeltaSign), nil } func (s *ScaleExtender) replicasFromScaleSubresource(sel string) (string, error) { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return "", err } replicasGetter, ok := res.(dao.ReplicasGetter) if !ok { return "", fmt.Errorf("expecting a replicasGetter resource for %q", s.GVR()) } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() replicas, err := replicasGetter.Replicas(ctx, sel) if err != nil { return "", err } return strconv.Itoa(int(replicas)), nil } func (s *ScaleExtender) makeScaleForm(fqns []string) (*tview.Form, error) { factor := "0" if len(fqns) == 1 { // If the CRD resource supports scaling, then first try to // read the replicas directly from the CRD. if meta, _ := dao.MetaAccess.MetaFor(s.GVR()); dao.IsScalable(meta) { replicas, err := s.replicasFromScaleSubresource(fqns[0]) if err == nil && replicas != "" { factor = replicas } } // For built-in resources or cases where we can't get the replicas from the CRD, we can // only try to get the number of copies from the READY field. if factor == "0" { replicas, err := s.replicasFromReady(fqns[0]) if err != nil { return nil, err } factor = replicas } } styles := s.App().Styles.Dialog() f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddInputField("Replicas:", factor, 4, func(textToCheck string, _ rune) bool { _, err := strconv.Atoi(textToCheck) return err == nil }, func(changed string) { factor = changed }) f.AddButton("OK", func() { defer s.dismissDialog() count, err := strconv.Atoi(factor) if err != nil { s.App().Flash().Err(err) return } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() for _, fqn := range fqns { if err := s.scale(ctx, fqn, int32(count)); err != nil { slog.Error("Unable to scale resource", slogs.FQN, fqn) s.App().Flash().Err(err) return } } if len(fqns) != 1 { s.App().Flash().Infof("[%d] %s scaled successfully", len(fqns), singularize(s.GVR().R())) } else { s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), fqns[0]) } }) f.AddButton("Cancel", func() { s.dismissDialog() }) for i := range 2 { if b := f.GetButton(i); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } for i := range f.GetButtonCount() { f.GetButton(i). SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()). SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } return f, nil } func (s *ScaleExtender) dismissDialog() { s.App().Content.RemovePage(scaleDialogKey) } func (s *ScaleExtender) scale(ctx context.Context, path string, replicas int32) error { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return err } scaler, ok := res.(dao.Scalable) if !ok { return fmt.Errorf("expecting a scalable resource for %q", s.GVR()) } return scaler.Scale(ctx, path, replicas) } ================================================ FILE: internal/view/screen_dump.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // ScreenDump presents a directory listing viewer. type ScreenDump struct { ResourceViewer } // NewScreenDump returns a new viewer. func NewScreenDump(gvr *client.GVR) ResourceViewer { s := ScreenDump{ ResourceViewer: NewBrowser(gvr), } s.GetTable().SetBorderFocusColor(tcell.ColorSteelBlue) s.GetTable().SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorRoyalBlue).Attributes(tcell.AttrNone)) s.GetTable().SetSortCol(ageCol, true) s.GetTable().SelectRow(1, 0, true) s.GetTable().SetEnterFn(s.edit) s.SetContextFn(s.dirContext) return &s } func (s *ScreenDump) dirContext(ctx context.Context) context.Context { dir := s.App().Config.K9s.ContextScreenDumpDir() if err := data.EnsureFullPath(dir, data.DefaultDirMod); err != nil { s.App().Flash().Err(err) return ctx } return context.WithValue(ctx, internal.KeyDir, dir) } func (s *ScreenDump) edit(app *App, _ ui.Tabular, _ *client.GVR, path string) { slog.Debug("ScreenDump selection", slogs.FQN, path) s.Stop() defer s.Start() if !edit(app, &shellOpts{clear: true, args: []string{path}}) { app.Flash().Errf("Failed to launch editor") } } ================================================ FILE: internal/view/screen_dump_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestScreenDumpNew(t *testing.T) { po := view.NewScreenDump(client.SdGVR) require.NoError(t, po.Init(makeCtx(t))) assert.Equal(t, "ScreenDumps", po.Name()) assert.Len(t, po.Hints(), 7) } ================================================ FILE: internal/view/secret.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "k8s.io/apimachinery/pkg/labels" ) // Secret presents a secret viewer. type Secret struct { ResourceViewer } // NewSecret returns a new viewer. func NewSecret(gvr *client.GVR) ResourceViewer { s := Secret{ ResourceViewer: NewOwnerExtender(NewBrowser(gvr)), } s.AddBindKeysFn(s.bindKeys) return &s } func (s *Secret) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyX: ui.NewKeyAction("Decode", s.decodeCmd, true), ui.KeyU: ui.NewKeyAction("UsedBy", s.refCmd, true), }) } func (s *Secret) refCmd(evt *tcell.EventKey) *tcell.EventKey { return scanRefs(evt, s.App(), s.GetTable(), client.SecGVR) } func (s *Secret) decodeCmd(evt *tcell.EventKey) *tcell.EventKey { path := s.GetTable().GetSelectedItem() if path == "" { return evt } o, err := s.App().factory.Get(s.GVR(), path, true, labels.Everything()) if err != nil { s.App().Flash().Err(err) return nil } mm, err := dao.ExtractSecrets(o) if err != nil { s.App().Flash().Err(err) return nil } raw, err := data.WriteYAML(mm) if err != nil { s.App().Flash().Errf("Error decoding secret %s", err) return nil } details := NewDetails(s.App(), "Secret Decoder", path, contentYAML, true).Update(string(raw)) if err := s.App().inject(details, false); err != nil { s.App().Flash().Err(err) } return nil } ================================================ FILE: internal/view/secret_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSecretNew(t *testing.T) { s := view.NewSecret(client.SecGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "Secrets", s.Name()) assert.Len(t, s.Hints(), 10) } ================================================ FILE: internal/view/sts.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/ui" appsv1 "k8s.io/api/apps/v1" ) // StatefulSet represents a statefulset viewer. type StatefulSet struct { ResourceViewer } // NewStatefulSet returns a new viewer. func NewStatefulSet(gvr *client.GVR) ResourceViewer { var s StatefulSet s.ResourceViewer = NewPortForwardExtender( NewVulnerabilityExtender( NewRestartExtender( NewScaleExtender( NewImageExtender( NewOwnerExtender( NewLogsExtender(NewBrowser(gvr), s.logOptions), ), ), ), ), ), ) s.GetTable().SetEnterFn(s.showPods) return &s } func (s *StatefulSet) logOptions(prev bool) (*dao.LogOptions, error) { path := s.GetTable().GetSelectedItem() if path == "" { return nil, errors.New("you must provide a selection") } sts, err := s.getInstance(path) if err != nil { return nil, err } return podLogOptions(s.App(), path, prev, &sts.ObjectMeta, &sts.Spec.Template.Spec), nil } func (s *StatefulSet) showPods(app *App, _ ui.Tabular, _ *client.GVR, path string) { i, err := s.getInstance(path) if err != nil { app.Flash().Err(err) return } showPodsFromSelector(app, path, i.Spec.Selector) } func (s *StatefulSet) getInstance(path string) (*appsv1.StatefulSet, error) { var sts dao.StatefulSet return sts.GetInstance(s.App().factory, path) } ================================================ FILE: internal/view/sts_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStatefulSetNew(t *testing.T) { s := view.NewStatefulSet(client.StsGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "StatefulSets", s.Name()) assert.Len(t, s.Hints(), 14) } ================================================ FILE: internal/view/svc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "errors" "fmt" "log/slog" "strings" "time" "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/perf" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) // Service represents a service viewer. type Service struct { ResourceViewer bench *perf.Benchmark } // NewService returns a new viewer. func NewService(gvr *client.GVR) ResourceViewer { s := Service{ ResourceViewer: NewPortForwardExtender( NewOwnerExtender( NewLogsExtender(NewBrowser(gvr), nil), ), ), } s.AddBindKeysFn(s.bindKeys) s.GetTable().SetEnterFn(s.showPods) return &s } // Protocol... func (s *Service) bindKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyB: ui.NewKeyAction("Bench Run/Stop", s.toggleBenchCmd, true), }) } func (s *Service) showPods(a *App, _ ui.Tabular, _ *client.GVR, path string) { var res dao.Service res.Init(a.factory, s.GVR()) svc, err := res.GetInstance(path) if err != nil { a.Flash().Err(err) return } if svc.Spec.Type == v1.ServiceTypeExternalName { a.Flash().Warnf("No matching pods. Service %s is an external service.", path) return } if svc.Spec.Selector == nil { a.Flash().Warnf("No matching pods. Service %s does not provide any selectors", path) return } showPods(a, path, labels.SelectorFromSet(svc.Spec.Selector), "") } func (*Service) checkSvc(svc *v1.Service) error { if svc.Spec.Type != "NodePort" && svc.Spec.Type != "LoadBalancer" { return errors.New("you must select a reachable service") } return nil } func (*Service) getExternalPort(svc *v1.Service) (string, error) { if svc.Spec.Type == "LoadBalancer" { return "", nil } ports := render.ToPorts(svc.Spec.Ports) pp := strings.Split(ports, " ") // Grab the first port pair for now... tokens := strings.Split(pp[0], "►") if len(tokens) < 2 { return "", errors.New("no ports pair found") } return tokens[1], nil } func (s *Service) toggleBenchCmd(evt *tcell.EventKey) *tcell.EventKey { if s.bench != nil { slog.Debug(">>> Benchmark canceled!!") s.App().Status(model.FlashErr, "Benchmark Canceled!") s.bench.Cancel() s.App().ClearStatus(true) return nil } path := s.GetTable().GetSelectedItem() if path == "" || s.bench != nil { return evt } cust, err := config.NewBench(s.App().BenchFile) if err != nil { slog.Debug("No bench config file found", slogs.FileName, s.App().BenchFile) } cfg, ok := cust.Benchmarks.Services[path] if !ok { s.App().Flash().Errf("No bench config found for service %s in %s", path, s.App().BenchFile) return nil } cfg.Name = path slog.Debug("Benchmark config", slogs.Config, cfg) svc, err := fetchService(s.App().factory, path) if err != nil { s.App().Flash().Err(err) return nil } if e := s.checkSvc(svc); e != nil { s.App().Flash().Err(e) return nil } port, err := s.getExternalPort(svc) if err != nil { s.App().Flash().Err(err) return nil } if err := s.runBenchmark(port, &cfg); err != nil { s.App().Flash().Errf("Benchmark failed %v", err) s.App().ClearStatus(false) s.bench = nil } return nil } // BOZO!! Refactor used by forwards. func (s *Service) runBenchmark(port string, cfg *config.BenchConfig) error { if cfg.HTTP.Host == "" { return fmt.Errorf("invalid benchmark host %q", cfg.HTTP.Host) } var err error base := cfg.HTTP.Host if !strings.Contains(base, ":") { base += ":" + port + cfg.HTTP.Path } else { base += cfg.HTTP.Path } if strings.Index(base, "http") != 0 { base = "http://" + base } if s.bench, err = perf.NewBenchmark(base, s.App().version, cfg); err != nil { return err } s.App().Status(model.FlashWarn, "Benchmark in progress...") slog.Debug("Benchmark starting...") ct, err := s.App().Config.K9s.ActiveContext() if err != nil { return err } name := s.App().Config.K9s.ActiveContextName() go s.bench.Run(ct.ClusterName, name, s.benchDone) return nil } func (s *Service) benchDone() { slog.Debug("Bench Completed!") s.App().QueueUpdate(func() { if s.bench.Canceled() { s.App().Status(model.FlashInfo, "Benchmark canceled") } else { s.App().Status(model.FlashInfo, "Benchmark Completed!") s.bench.Cancel() } s.bench = nil go clearStatus(s.App()) }) } // ---------------------------------------------------------------------------- // Helpers... func clearStatus(app *App) { <-time.After(2 * time.Second) app.QueueUpdate(func() { app.ClearStatus(true) }) } func fetchService(f dao.Factory, path string) (*v1.Service, error) { o, err := f.Get(client.SvcGVR, path, true, labels.Everything()) if err != nil { return nil, err } var svc v1.Service err = runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &svc) if err != nil { return nil, err } return &svc, nil } ================================================ FILE: internal/view/svc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view_test import ( "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/view" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func init() { dao.MetaAccess.RegisterMeta(client.DirGVR.String(), &metav1.APIResource{ Name: "dirs", SingularName: "dir", Kind: "Directory", Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.PodGVR.String(), &metav1.APIResource{ Name: "pods", SingularName: "pod", Namespaced: true, Kind: "Pods", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.NsGVR.String(), &metav1.APIResource{ Name: "namespaces", SingularName: "namespace", Namespaced: true, Kind: "Namespaces", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.SvcGVR.String(), &metav1.APIResource{ Name: "services", SingularName: "service", Namespaced: true, Kind: "Services", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.SecGVR.String(), &metav1.APIResource{ Name: "secrets", SingularName: "secret", Namespaced: true, Kind: "Secrets", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.PcGVR.String(), &metav1.APIResource{ Name: "priorityclasses", SingularName: "priorityclass", Namespaced: false, Kind: "PriorityClass", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.CmGVR.String(), &metav1.APIResource{ Name: "configmaps", SingularName: "configmap", Namespaced: true, Kind: "ConfigMaps", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.RefGVR.String(), &metav1.APIResource{ Name: "references", SingularName: "reference", Namespaced: true, Kind: "References", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.AliGVR.String(), &metav1.APIResource{ Name: "aliases", SingularName: "alias", Namespaced: true, Kind: "Aliases", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.CoGVR.String(), &metav1.APIResource{ Name: "containers", SingularName: "container", Namespaced: true, Kind: "Containers", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.CtGVR.String(), &metav1.APIResource{ Name: "contexts", SingularName: "context", Namespaced: true, Kind: "Contexts", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta("subjects", &metav1.APIResource{ Name: "subjects", SingularName: "subject", Namespaced: true, Kind: "Subjects", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.RbacGVR.String(), &metav1.APIResource{ Name: "rbacs", SingularName: "rbac", Namespaced: true, Kind: "Rbac", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.PfGVR.String(), &metav1.APIResource{ Name: "portforwards", SingularName: "portforward", Namespaced: true, Kind: "PortForwards", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.SdGVR.String(), &metav1.APIResource{ Name: "screendumps", SingularName: "screendump", Namespaced: true, Kind: "ScreenDumps", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.StsGVR.String(), &metav1.APIResource{ Name: "statefulsets", SingularName: "statefulset", Namespaced: true, Kind: "StatefulSets", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.DsGVR.String(), &metav1.APIResource{ Name: "daemonsets", SingularName: "daemonset", Namespaced: true, Kind: "DaemonSets", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.DpGVR.String(), &metav1.APIResource{ Name: "deployments", SingularName: "deployment", Namespaced: true, Kind: "Deployments", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) dao.MetaAccess.RegisterMeta(client.PvcGVR.String(), &metav1.APIResource{ Name: "persistentvolumeclaims", SingularName: "persistentvolumeclaim", Namespaced: true, Kind: "PersistentVolumeClaims", Verbs: []string{"get", "list", "watch", "delete"}, Categories: []string{"k9s"}, }) } func TestServiceNew(t *testing.T) { s := view.NewService(client.SvcGVR) require.NoError(t, s.Init(makeCtx(t))) assert.Equal(t, "Services", s.Name()) assert.Len(t, s.Hints(), 13) } ================================================ FILE: internal/view/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "log/slog" "path/filepath" "strings" "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/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" ) // Table represents a table viewer. type Table struct { *ui.Table app *App enterFn EnterFunc envFn EnvFunc bindKeysFn []BindKeysFunc command *cmd.Interpreter } // NewTable returns a new viewer. func NewTable(gvr *client.GVR) *Table { t := Table{ Table: ui.NewTable(gvr), } t.envFn = t.defaultEnv return &t } // Init initializes the component. func (t *Table) Init(ctx context.Context) (err error) { if t.app, err = extractApp(ctx); err != nil { return err } if t.app.Conn() != nil { ctx = context.WithValue(ctx, internal.KeyHasMetrics, t.app.Conn().HasMetrics()) } ctx = context.WithValue(ctx, internal.KeyStyles, t.app.Styles) ctx = context.WithValue(ctx, internal.KeyViewConfig, t.app.CustomView()) t.Table.Init(ctx) if !t.app.Config.K9s.UI.Reactive { if err := t.app.RefreshCustomViews(); err != nil { slog.Warn("CustomViews load failed", slogs.Error, err) t.app.Logo().Warn("Views load failed!") } } t.SetInputCapture(t.keyboard) t.bindKeys() t.GetModel().SetRefreshRate(t.app.Config.K9s.RefreshDuration()) t.CmdBuff().AddListener(t) return nil } // SetCommand sets the current command. func (t *Table) SetCommand(i *cmd.Interpreter) { t.command = i } // HeaderIndex returns index of a given column or false if not found. func (t *Table) HeaderIndex(colName string) (int, bool) { for i := range t.GetColumnCount() { h := t.GetCell(0, i) if h == nil { continue } s := h.Text if idx := strings.Index(s, "["); idx > 0 { s = s[:idx] } if s == colName { return i, true } } return 0, false } // SendKey sends a keyboard event (testing only!). func (t *Table) SendKey(evt *tcell.EventKey) { t.app.Prompt().SendKey(evt) } func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { key := evt.Key() // Handle Shift+Left/Right for column selection if evt.Modifiers()&tcell.ModShift != 0 { if key == tcell.KeyLeft { t.Table.SelectPrevColumn() return nil } if key == tcell.KeyRight { t.Table.SelectNextColumn() return nil } } if key == tcell.KeyUp || key == tcell.KeyDown { return evt } if a, ok := t.Actions().Get(ui.AsKey(evt)); ok && !t.app.Content.IsTopDialog() { return a.Action(evt) } return evt } // Name returns the table name. func (t *Table) Name() string { return t.GVR().R() } // AddBindKeysFn adds additional key bindings. func (t *Table) AddBindKeysFn(f BindKeysFunc) { t.bindKeysFn = append(t.bindKeysFn, f) } // SetEnvFn sets a function to pull viewer env vars for plugins. func (t *Table) SetEnvFn(f EnvFunc) { t.envFn = f } // EnvFn returns an plugin env function if available. func (t *Table) EnvFn() EnvFunc { return t.envFn } func (t *Table) defaultEnv() Env { path := t.GetSelectedItem() row := t.GetSelectedRow(path) env := defaultEnv(t.app.Conn().Config(), path, t.GetModel().Peek().Header(), row) env["FILTER"] = t.CmdBuff().GetText() if env["FILTER"] == "" { env["NAMESPACE"], env["FILTER"] = client.Namespaced(path) } env["RESOURCE_GROUP"] = t.GVR().G() env["RESOURCE_VERSION"] = t.GVR().V() env["RESOURCE_NAME"] = t.GVR().R() return env } // App returns the current app handle. func (t *Table) App() *App { return t.app } // Start runs the component. func (t *Table) Start() { t.Stop() t.CmdBuff().AddListener(t) t.Styles().AddListener(t.Table) cmds := []string{t.Table.GVR().String()} if t.command != nil { if t.command.GetLine() != t.Table.GVR().String() { cmds = append(cmds, t.command.GetLine()) } for _, a := range t.command.Aliases() { cmds = append(cmds, a) } } t.App().CustomView().AddListeners(t.Table, cmds...) } // Stop terminates the component. func (t *Table) Stop() { t.CmdBuff().RemoveListener(t) t.Styles().RemoveListener(t.Table) t.App().CustomView().RemoveListener(t.Table) } // SetEnterFn specifies the default enter behavior. func (t *Table) SetEnterFn(f EnterFunc) { t.enterFn = f } // SetExtraActionsFn specifies custom keyboard behavior. func (*Table) SetExtraActionsFn(BoostActionsFunc) {} // BufferCompleted indicates input was accepted. func (t *Table) BufferCompleted(text, _ string) { t.app.QueueUpdateDraw(func() { t.Filter(text) }) } // BufferChanged indicates the buffer was changed. func (*Table) BufferChanged(_, _ string) {} // BufferActive indicates the buff activity changed. func (t *Table) BufferActive(state bool, k model.BufferKind) { t.app.BufferActive(state, k) if !state { t.app.SetFocus(t) } } func (t *Table) saveCmd(*tcell.EventKey) *tcell.EventKey { if path, err := saveTable(t.app.Config.K9s.ContextScreenDumpDir(), t.GVR().R(), t.Path, t.GetFilteredData()); err != nil { t.app.Flash().Err(err) } else { t.app.Flash().Infof("File saved successfully: %q", render.Truncate(filepath.Base(path), 50)) } return nil } func (t *Table) bindKeys() { t.Actions().Bulk(ui.KeyMap{ ui.KeyHelp: ui.NewKeyAction("Help", t.App().helpCmd, true), ui.KeySpace: ui.NewSharedKeyAction("Mark", t.markCmd, false), tcell.KeyCtrlSpace: ui.NewSharedKeyAction("Mark Range", t.markSpanCmd, false), tcell.KeyCtrlBackslash: ui.NewSharedKeyAction("Marks Clear", t.clearMarksCmd, false), tcell.KeyCtrlS: ui.NewSharedKeyAction("Save", t.saveCmd, false), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", t.activateCmd, false), tcell.KeyCtrlZ: ui.NewKeyAction("Toggle Faults", t.toggleFaultCmd, false), tcell.KeyCtrlW: ui.NewKeyAction("Toggle Wide", t.toggleWideCmd, false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(nameCol, true), false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(ageCol, true), false), ui.KeyShiftS: ui.NewKeyAction("Sort Status", t.SortColCmd(statusCol, true), false), ui.KeyShiftO: ui.NewKeyAction("Sort Selected Column", t.sortSelectedColumnCmd, false), }) } func (t *Table) toggleFaultCmd(*tcell.EventKey) *tcell.EventKey { t.ToggleToast() return nil } func (t *Table) toggleWideCmd(*tcell.EventKey) *tcell.EventKey { t.ToggleWide() return nil } func (t *Table) sortSelectedColumnCmd(*tcell.EventKey) *tcell.EventKey { t.Table.SortSelectedColumn() return nil } func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey { paths := t.GetSelectedItems() if len(paths) == 0 { return evt } names := make([]string, 0, len(paths)) for _, path := range paths { _, n := client.Namespaced(path) names = append(names, n) } text := strings.Join(names, "\n") if err := clipboardWrite(text); err != nil { t.app.Flash().Err(err) return nil } if len(names) > 1 { t.app.Flash().Infof("%d resource names copied to clipboard...", len(names)) } else { t.app.Flash().Info("Resource name copied to clipboard...") } return nil } func (t *Table) cpNsCmd(evt *tcell.EventKey) *tcell.EventKey { paths := t.GetSelectedItems() if len(paths) == 0 { return evt } namespaces := make([]string, 0, len(paths)) for _, path := range paths { ns, _ := client.Namespaced(path) namespaces = append(namespaces, ns) } text := strings.Join(namespaces, "\n") if err := clipboardWrite(text); err != nil { t.app.Flash().Err(err) return nil } if len(namespaces) > 1 { t.app.Flash().Infof("%d resource namespaces copied to clipboard...", len(namespaces)) } else { t.app.Flash().Info("Resource namespace copied to clipboard...") } return nil } func (t *Table) markCmd(*tcell.EventKey) *tcell.EventKey { t.ToggleMark() t.Refresh() return nil } func (t *Table) markSpanCmd(*tcell.EventKey) *tcell.EventKey { t.SpanMark() t.Refresh() return nil } func (t *Table) clearMarksCmd(*tcell.EventKey) *tcell.EventKey { t.ClearMarks() t.Refresh() return nil } func (t *Table) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if t.app.InCmdMode() { return evt } t.App().ResetPrompt(t.CmdBuff()) return evt } ================================================ FILE: internal/view/table_helper.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "encoding/csv" "fmt" "log/slog" "os" "path/filepath" "strings" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" ) func computeFilename(dumpPath, ns, title, path string) (string, error) { now := time.Now().UnixNano() dir := dumpPath if err := ensureDir(dir); err != nil { return "", err } name := title + "-" + data.SanitizeFileName(path) if path == "" { name = title } var fName string if ns == client.ClusterScope { fName = fmt.Sprintf(ui.NoNSFmat, name, now) } else { fName = fmt.Sprintf(ui.FullFmat, name, ns, now) } return strings.ToLower(filepath.Join(dir, fName)), nil } func saveTable(dir, title, path string, mdata *model1.TableData) (string, error) { ns := mdata.GetNamespace() if client.IsClusterWide(ns) { ns = client.NamespaceAll } fPath, err := computeFilename(dir, ns, title, path) if err != nil { return "", err } slog.Debug("Saving table to disk", slogs.FileName, fPath) mod := os.O_CREATE | os.O_WRONLY out, err := os.OpenFile(fPath, mod, 0600) if err != nil { return "", err } defer func() { if err := out.Close(); err != nil { slog.Error("Closing file failed", slogs.Path, fPath, slogs.Error, err, ) } }() w := csv.NewWriter(out) _ = w.Write(mdata.ColumnNames(true)) mdata.RowsRange(func(_ int, re model1.RowEvent) bool { _ = w.Write(re.Row.Fields) return true }) w.Flush() if err := w.Error(); err != nil { return "", err } return fPath, nil } ================================================ FILE: internal/view/table_int_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "errors" "io/fs" "os" "testing" "time" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/mock" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/model1" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tview" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) func TestTableSave(t *testing.T) { v := NewTable(client.NewGVR("test")) require.NoError(t, v.Init(makeContext(t))) v.SetTitle("k9s-test") require.NoError(t, ensureDumpDir("/tmp/test-dumps")) dir := v.app.Config.K9s.ContextScreenDumpDir() c1, _ := os.ReadDir(dir) v.saveCmd(nil) c2, _ := os.ReadDir(dir) assert.Len(t, c2, len(c1)+1) } func TestTableNew(t *testing.T) { v := NewTable(client.NewGVR("test")) require.NoError(t, v.Init(makeContext(t))) data := model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "FRED"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true, Decorator: render.AgeDecorator}}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "a", "10", "3m"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "b", "15", "1m"}, }, }, ), ) cdata := v.Update(data, false) v.UpdateUI(cdata, data) assert.Equal(t, 3, v.GetRowCount()) } func TestTableViewFilter(t *testing.T) { v := NewTable(client.NewGVR("test")) require.NoError(t, v.Init(makeContext(t))) v.SetModel(&mockTableModel{}) v.Refresh() v.CmdBuff().SetActive(true) v.CmdBuff().SetText("blee", "", true) assert.Equal(t, 5, v.GetRowCount()) } func TestTableViewSort(t *testing.T) { v := NewTable(client.NewGVR("test")) require.NoError(t, v.Init(makeContext(t))) v.SetModel(new(mockTableModel)) uu := map[string]struct { sortCol string sorted []string reversed []string }{ "by_name": { sortCol: "NAME", sorted: []string{"r0", "r1", "r2", "r3"}, reversed: []string{"r3", "r2", "r1", "r0"}, }, "by_age": { sortCol: "AGE", sorted: []string{"r0", "r1", "r2", "r3"}, reversed: []string{"r3", "r2", "r1", "r0"}, }, "by_fred": { sortCol: "FRED", sorted: []string{"r3", "r2", "r0", "r1"}, reversed: []string{"r1", "r0", "r2", "r3"}, }, } for k := range uu { u := uu[k] v.SortColCmd(u.sortCol, true)(nil) assert.Len(t, u.sorted, v.GetRowCount()-1) for i, s := range u.sorted { assert.Equal(t, s, v.GetCell(i+1, 0).Text) } v.SortInvertCmd(nil) assert.Len(t, u.reversed, v.GetRowCount()-1) for i, s := range u.reversed { assert.Equal(t, s, v.GetCell(i+1, 0).Text) } } } // ---------------------------------------------------------------------------- // Helpers... type mockTableModel struct{} var _ ui.Tabular = (*mockTableModel)(nil) func (*mockTableModel) SetViewSetting(context.Context, *config.ViewSetting) {} func (*mockTableModel) SetInstance(string) {} func (*mockTableModel) SetLabelSelector(labels.Selector) {} func (*mockTableModel) GetLabelSelector() labels.Selector { return nil } func (*mockTableModel) Empty() bool { return false } func (*mockTableModel) RowCount() int { return 1 } func (*mockTableModel) HasMetrics() bool { return true } func (*mockTableModel) Peek() *model1.TableData { return makeTableData() } func (*mockTableModel) Refresh(context.Context) error { return nil } func (*mockTableModel) ClusterWide() bool { return false } func (*mockTableModel) GetNamespace() string { return "blee" } func (*mockTableModel) SetNamespace(string) {} func (*mockTableModel) ToggleToast() {} func (*mockTableModel) AddListener(model.TableListener) {} func (*mockTableModel) RemoveListener(model.TableListener) {} func (*mockTableModel) Watch(context.Context) error { return nil } func (*mockTableModel) Get(context.Context, string) (runtime.Object, error) { return nil, nil } func (*mockTableModel) Delete(context.Context, string, *metav1.DeletionPropagation, dao.Grace) error { return nil } func (*mockTableModel) Describe(context.Context, string) (string, error) { return "", nil } func (*mockTableModel) ToYAML(context.Context, string) (string, error) { return "", nil } func (*mockTableModel) InNamespace(string) bool { return true } func (*mockTableModel) SetRefreshRate(time.Duration) {} func makeTableData() *model1.TableData { return model1.NewTableDataWithRows( client.NewGVR("test"), model1.Header{ model1.HeaderColumn{Name: "NAMESPACE"}, model1.HeaderColumn{Name: "NAME", Attrs: model1.Attrs{Align: tview.AlignRight}}, model1.HeaderColumn{Name: "FRED"}, model1.HeaderColumn{Name: "AGE", Attrs: model1.Attrs{Time: true}}, }, model1.NewRowEventsWithEvts( model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "r3", "10", "3y125d"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "r2", "15", "2y12d"}, }, Deltas: model1.DeltaRow{"", "", "20", ""}, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "r1", "20", "19h"}, }, }, model1.RowEvent{ Row: model1.Row{ Fields: model1.Fields{"ns1", "r0", "15", "10s"}, }, }, ), ) } func makeContext(t *testing.T) context.Context { a := NewApp(mock.NewMockConfig(t)) ctx := context.WithValue(context.Background(), internal.KeyApp, a) return context.WithValue(ctx, internal.KeyStyles, a.Styles) } func ensureDumpDir(n string) error { config.AppDumpsDir = n if _, err := os.Stat(n); errors.Is(err, fs.ErrNotExist) { return os.Mkdir(n, 0700) } if err := os.RemoveAll(n); err != nil { return err } return os.Mkdir(n, 0700) } ================================================ FILE: internal/view/testdata/fred/kmanifests/cm.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: the-map data: altGreeting: "Good Morning!" enableRisky: "false" ================================================ FILE: internal/view/testdata/fred/kmanifests/kustomization.yaml ================================================ commonLabels: app: fred resources: - cm.yaml ================================================ FILE: internal/view/testdata/k1manifests/cm.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: the-map data: altGreeting: "Good Morning!" enableRisky: "false" ================================================ FILE: internal/view/testdata/k1manifests/kustomization.yml ================================================ commonLabels: app: fred resources: - cm.yaml ================================================ FILE: internal/view/testdata/k2manifests/Kustomization ================================================ commonLabels: app: fred resources: - cm.yaml ================================================ FILE: internal/view/testdata/k2manifests/cm.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: the-map data: altGreeting: "Good Morning!" enableRisky: "false" ================================================ FILE: internal/view/testdata/kmanifests/cm.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: the-map data: altGreeting: "Good Morning!" enableRisky: "false" ================================================ FILE: internal/view/testdata/kmanifests/kustomization.yaml ================================================ commonLabels: app: fred resources: - cm.yaml ================================================ FILE: internal/view/testdata/manifests/cm.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: the-map data: altGreeting: "Good Morning!" enableRisky: "false" ================================================ FILE: internal/view/testdata/manifests/kustomization.yaml ================================================ commonLabels: app: fred resources: - cm.yaml ================================================ FILE: internal/view/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" ) const ( ageCol = "AGE" nameCol = "NAME" statusCol = "STATUS" cpuCol = "CPU" memCol = "MEM" uptodateCol = "UP-TO-DATE" readyCol = "READY" availCol = "AVAILABLE" ) type ( // EnvFunc represent the current view exposed environment. EnvFunc func() Env // BoostActionsFunc extends viewer keyboard actions. BoostActionsFunc func(ui.KeyActions) // EnterFunc represents an enter key action. EnterFunc func(app *App, model ui.Tabular, gvr *client.GVR, path string) // LogOptionsFunc returns the active log options. LogOptionsFunc func(bool) (*dao.LogOptions, error) // ContextFunc enhances a given context. ContextFunc func(context.Context) context.Context // BindKeysFunc adds new menu actions. BindKeysFunc func(*ui.KeyActions) ) // ActionExtender enhances a given viewer by adding new menu actions. type ActionExtender interface { // BindKeys injects new menu actions. BindKeys(ResourceViewer) } // Hinter represents a view that can produce menu hints. type Hinter interface { // Hints returns a collection of hints. Hints() model.MenuHints } // Viewer represents a component viewer. type Viewer interface { model.Component // Actions returns active menu bindings. Actions() *ui.KeyActions // App returns an app handle. App() *App // Refresh updates the viewer Refresh() } // TableViewer represents a tabular viewer. type TableViewer interface { Viewer // GetTable returns a table component. GetTable() *Table } // ResourceViewer represents a generic resource viewer. type ResourceViewer interface { TableViewer // SetEnvFn sets a function to pull viewer env vars for plugins. SetEnvFn(EnvFunc) // GVR returns a resource descriptor. GVR() *client.GVR // SetContextFn provision a custom context. SetContextFn(ContextFunc) // AddBindKeysFn provision additional key bindings. AddBindKeysFn(BindKeysFunc) // SetInstance sets a parent FQN SetInstance(string) // SetCommand sets the current command. SetCommand(*cmd.Interpreter) } // LogViewer represents a log viewer. type LogViewer interface { ResourceViewer ShowLogs(prev bool) } // RestartableViewer represents a viewer with restartable resources. type RestartableViewer interface { LogViewer } // ScalableViewer represents a viewer with scalable resources. type ScalableViewer interface { LogViewer } // SubjectViewer represents a policy viewer. type SubjectViewer interface { ResourceViewer // SetSubject sets the active subject. SetSubject(s string) } // ViewerFunc returns a viewer matching a given gvr. type ViewerFunc func(*client.GVR) ResourceViewer // MetaViewer represents a registered meta viewer. type MetaViewer struct { viewerFn ViewerFunc enterFn EnterFunc } // MetaViewers represents a collection of meta viewers. type MetaViewers map[*client.GVR]MetaViewer ================================================ FILE: internal/view/user.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // User presents a user viewer. type User struct { ResourceViewer } // NewUser returns a new subject viewer. func NewUser(gvr *client.GVR) ResourceViewer { u := User{ResourceViewer: NewBrowser(gvr)} u.AddBindKeysFn(u.bindKeys) u.SetContextFn(u.subjectCtx) return &u } func (u *User) bindKeys(aa *ui.KeyActions) { aa.Delete(ui.KeyShiftA, ui.KeyShiftP, tcell.KeyCtrlSpace, ui.KeySpace, tcell.KeyCtrlD, ui.KeyE) aa.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewKeyAction("Rules", u.policyCmd, true), }) } func (*User) subjectCtx(ctx context.Context) context.Context { return context.WithValue(ctx, internal.KeySubjectKind, "User") } func (u *User) policyCmd(evt *tcell.EventKey) *tcell.EventKey { path := u.GetTable().GetSelectedItem() if path == "" { return evt } if err := u.App().inject(NewPolicy(u.App(), "User", path), false); err != nil { u.App().Flash().Err(err) } return nil } ================================================ FILE: internal/view/value_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // ValueExtender adds values actions to a given viewer. type ValueExtender struct { ResourceViewer } // NewValueExtender returns a new extender. func NewValueExtender(r ResourceViewer) ResourceViewer { p := ValueExtender{ResourceViewer: r} p.AddBindKeysFn(p.bindKeys) p.GetTable().SetEnterFn(func(*App, ui.Tabular, *client.GVR, string) { p.valuesCmd(nil) }) return &p } func (v *ValueExtender) bindKeys(aa *ui.KeyActions) { aa.Add(ui.KeyV, ui.NewKeyAction("Values", v.valuesCmd, true)) } func (v *ValueExtender) valuesCmd(evt *tcell.EventKey) *tcell.EventKey { path := v.GetTable().GetSelectedItem() if path == "" { return evt } showValues(v.defaultCtx(), v.App(), path, v.GVR()) return nil } func (v *ValueExtender) defaultCtx() context.Context { return context.WithValue(context.Background(), internal.KeyFactory, v.App().factory) } func showValues(ctx context.Context, app *App, path string, gvr *client.GVR) { vm := model.NewValues(gvr, path) if err := vm.Init(app.factory); err != nil { app.Flash().Errf("Initializing the values model failed: %s", err) } toggleValuesCmd := func(*tcell.EventKey) *tcell.EventKey { if err := vm.ToggleValues(); err != nil { app.Flash().Errf("Values toggle failed: %s", err) return nil } if err := vm.Refresh(ctx); err != nil { slog.Error("Values viewer refresh failed", slogs.Error, err) return nil } app.Flash().Infof("Values toggled") return nil } v := NewLiveView(app, "Values", vm) v.actions.Add(ui.KeyV, ui.NewKeyAction("Toggle All Values", toggleValuesCmd, true)) if err := v.app.inject(v, false); err != nil { v.app.Flash().Err(err) } } ================================================ FILE: internal/view/vul_extender.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" ) // VulnerabilityExtender adds vul image scan extensions. type VulnerabilityExtender struct { ResourceViewer } // NewVulnerabilityExtender returns a new extender. func NewVulnerabilityExtender(r ResourceViewer) ResourceViewer { v := VulnerabilityExtender{ResourceViewer: r} v.AddBindKeysFn(v.bindKeys) return &v } func (v *VulnerabilityExtender) bindKeys(aa *ui.KeyActions) { if v.App().Config.K9s.ImageScans.Enable { aa.Bulk(ui.KeyMap{ ui.KeyV: ui.NewKeyAction("Show Vulnerabilities", v.showVulCmd, true), ui.KeyShiftV: ui.NewKeyAction("Sort Vulnerabilities", v.GetTable().SortColCmd("VS", true), false), }) } } func (v *VulnerabilityExtender) showVulCmd(*tcell.EventKey) *tcell.EventKey { isv := NewImageScan(client.ScnGVR) isv.SetContextFn(v.selContext) if err := v.App().inject(isv, false); err != nil { v.App().Flash().Err(err) } return nil } func (v *VulnerabilityExtender) selContext(ctx context.Context) context.Context { ctx = context.WithValue(ctx, internal.KeyPath, v.GetTable().GetSelectedItem()) return context.WithValue(ctx, internal.KeyGVR, v.GVR()) } ================================================ FILE: internal/view/workload.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/tcell/v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Workload presents a workload viewer. type Workload struct { ResourceViewer } // NewWorkload returns a new viewer. func NewWorkload(gvr *client.GVR) ResourceViewer { w := Workload{ ResourceViewer: NewBrowser(gvr), } w.GetTable().SetEnterFn(w.showRes) w.AddBindKeysFn(w.bindKeys) w.GetTable().SetSortCol("KIND", true) return &w } func (w *Workload) bindDangerousKeys(aa *ui.KeyActions) { aa.Bulk(ui.KeyMap{ ui.KeyE: ui.NewKeyActionWithOpts("Edit", w.editCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), tcell.KeyCtrlD: ui.NewKeyActionWithOpts("Delete", w.deleteCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }), }) } func (w *Workload) bindKeys(aa *ui.KeyActions) { if !w.App().Config.IsReadOnly() { w.bindDangerousKeys(aa) } aa.Bulk(ui.KeyMap{ ui.KeyShiftK: ui.NewKeyAction("Sort Kind", w.GetTable().SortColCmd("KIND", true), false), ui.KeyShiftR: ui.NewKeyAction("Sort Ready", w.GetTable().SortColCmd("READY", true), false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", w.GetTable().SortColCmd(ageCol, true), false), ui.KeyY: ui.NewKeyAction(yamlAction, w.yamlCmd, true), ui.KeyD: ui.NewKeyAction("Describe", w.describeCmd, true), }) } func parsePath(path string) (*client.GVR, string, bool) { tt := strings.Split(path, "|") if len(tt) != 3 { slog.Error("Unable to parse workload path", slogs.Path, path) return client.NoGVR, client.FQN("", ""), false } return client.NewGVR(tt[0]), client.FQN(tt[1], tt[2]), true } func (*Workload) showRes(app *App, _ ui.Tabular, _ *client.GVR, path string) { gvr, fqn, ok := parsePath(path) if !ok { app.Flash().Err(fmt.Errorf("unable to parse path: %q", path)) return } app.gotoResource(gvr.String(), fqn, false, true) } func (w *Workload) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { selections := w.GetTable().GetSelectedItems() if len(selections) == 0 { return evt } w.Stop() defer w.Start() { msg := fmt.Sprintf("Delete %s %s?", w.GVR().R(), selections[0]) if len(selections) > 1 { msg = fmt.Sprintf("Delete %d marked %s?", len(selections), w.GVR()) } w.resourceDelete(selections, msg) } return nil } func (w *Workload) defaultContext(gvr *client.GVR, fqn string) context.Context { ctx := context.WithValue(context.Background(), internal.KeyFactory, w.App().factory) ctx = context.WithValue(ctx, internal.KeyGVR, gvr) if fqn != "" { ctx = context.WithValue(ctx, internal.KeyPath, fqn) } if internal.IsLabelSelector(w.GetTable().CmdBuff().GetText()) { if sel, err := ui.ExtractLabelSelector(w.GetTable().CmdBuff().GetText()); err == nil { ctx = context.WithValue(ctx, internal.KeyLabels, sel) } } ctx = context.WithValue(ctx, internal.KeyNamespace, client.CleanseNamespace(w.App().Config.ActiveNamespace())) ctx = context.WithValue(ctx, internal.KeyWithMetrics, w.App().factory.Client().HasMetrics()) return ctx } func (w *Workload) resourceDelete(selections []string, msg string) { okFn := func(propagation *metav1.DeletionPropagation, force bool) { w.GetTable().ShowDeleted() if len(selections) > 1 { w.App().Flash().Infof("Delete %d marked %s", len(selections), w.GVR()) } else { w.App().Flash().Infof("Delete resource %s %s", w.GVR(), selections[0]) } for _, sel := range selections { gvr, fqn, ok := parsePath(sel) if !ok { w.App().Flash().Err(fmt.Errorf("unable to parse path: %q", sel)) return } grace := dao.DefaultGrace if force { grace = dao.ForceGrace } if err := w.GetTable().GetModel().Delete(w.defaultContext(gvr, fqn), fqn, propagation, grace); err != nil { w.App().Flash().Errf("Delete failed with `%s", err) } else { w.App().factory.DeleteForwarder(sel) } w.GetTable().DeleteMark(sel) } w.GetTable().Start() } d := w.App().Styles.Dialog() dialog.ShowDelete(&d, w.App().Content.Pages, msg, okFn, func() {}) } func (w *Workload) describeCmd(evt *tcell.EventKey) *tcell.EventKey { path := w.GetTable().GetSelectedItem() if path == "" { return evt } gvr, fqn, ok := parsePath(path) if !ok { w.App().Flash().Err(fmt.Errorf("unable to parse path: %q", path)) return evt } describeResource(w.App(), nil, gvr, fqn) return nil } func (w *Workload) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := w.GetTable().GetSelectedItem() if path == "" { return evt } gvr, fqn, ok := parsePath(path) if !ok { w.App().Flash().Err(fmt.Errorf("unable to parse path: %q", path)) return evt } w.Stop() defer w.Start() if err := editRes(w.App(), gvr, fqn); err != nil { w.App().Flash().Err(err) } return nil } func (w *Workload) yamlCmd(evt *tcell.EventKey) *tcell.EventKey { path := w.GetTable().GetSelectedItem() if path == "" { return evt } gvr, fqn, ok := parsePath(path) if !ok { w.App().Flash().Err(fmt.Errorf("unable to parse path: %q", path)) return evt } v := NewLiveView(w.App(), yamlAction, model.NewYAML(gvr, fqn)) if err := v.app.inject(v, false); err != nil { v.app.Flash().Err(err) } return nil } ================================================ FILE: internal/view/xray.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "regexp" "strings" "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/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/ui/dialog" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/k9s/internal/xray" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "golang.org/x/text/cases" "golang.org/x/text/language" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" ) const xrayTitle = "Xray" var _ ResourceViewer = (*Xray)(nil) // Xray represents an xray tree view. type Xray struct { *ui.Tree app *App gvr *client.GVR meta *metav1.APIResource model *model.Tree cancelFn context.CancelFunc envFn EnvFunc } // NewXray returns a new view. func NewXray(gvr *client.GVR) ResourceViewer { return &Xray{ gvr: gvr, Tree: ui.NewTree(), model: model.NewTree(gvr), } } func (*Xray) SetCommand(*cmd.Interpreter) {} func (*Xray) SetFilter(string, bool) {} func (*Xray) SetLabelSelector(labels.Selector, bool) {} // Init initializes the view. func (x *Xray) Init(ctx context.Context) error { x.envFn = x.k9sEnv if err := x.Tree.Init(ctx); err != nil { return err } x.SetKeyListenerFn(x.keyEntered) var err error x.meta, err = dao.MetaAccess.MetaFor(x.gvr) if err != nil { return err } if x.app, err = extractApp(ctx); err != nil { return err } x.bindKeys() x.SetBackgroundColor(x.app.Styles.Xray().BgColor.Color()) x.SetBorderColor(x.app.Styles.Xray().FgColor.Color()) x.SetBorderFocusColor(x.app.Styles.Frame().Border.FocusColor.Color()) x.SetGraphicsColor(x.app.Styles.Xray().GraphicColor.Color()) x.SetTitle(fmt.Sprintf(" %s-%s ", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R()))) x.model.SetRefreshRate(x.app.Config.K9s.RefreshDuration()) x.model.SetNamespace(client.CleanseNamespace(x.app.Config.ActiveNamespace())) x.model.AddListener(x) x.SetChangedFunc(func(n *tview.TreeNode) { spec, ok := n.GetReference().(xray.NodeSpec) if !ok { slog.Error("No ref found on node", slogs.FQN, n.GetText()) return } x.SetSelectedItem(spec.AsPath()) x.refreshActions() }) x.refreshActions() return nil } // InCmdMode checks if prompt is active. func (*Xray) InCmdMode() bool { return false } // ExtraHints returns additional hints. func (x *Xray) ExtraHints() map[string]string { if x.app.Config.K9s.UI.NoIcons { return nil } return xray.EmojiInfo() } // SetInstance sets specific resource instance. func (*Xray) SetInstance(string) {} func (x *Xray) bindKeys() { x.Actions().Bulk(ui.KeyMap{ ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", x.activateCmd, false), tcell.KeyEscape: ui.NewSharedKeyAction("Filter Reset", x.resetCmd, false), tcell.KeyEnter: ui.NewKeyAction("Goto", x.gotoCmd, true), }) } func (x *Xray) keyEntered() { x.ClearSelection() x.update(x.filter(x.model.Peek())) } func (x *Xray) refreshActions() { aa := ui.NewKeyActions() defer func() { if err := pluginActions(x, aa); err != nil { slog.Warn("Plugins load failed", slogs.Error, err) } if err := hotKeyActions(x, aa); err != nil { slog.Warn("HotKeys load failed", slogs.Error, err) } x.Actions().Merge(aa) x.app.Menu().HydrateMenu(x.Hints()) }() x.Actions().Clear() x.bindKeys() x.BindKeys() spec := x.selectedSpec() if spec == nil { return } gvr := spec.GVR() var err error x.meta, err = dao.MetaAccess.MetaFor(gvr) if err != nil { slog.Warn("No meta found!", slogs.GVR, gvr, slogs.Error, err, ) return } if client.Can(x.meta.Verbs, "edit") { aa.Add(ui.KeyE, ui.NewKeyAction("Edit", x.editCmd, true)) } if client.Can(x.meta.Verbs, "delete") { aa.Add(tcell.KeyCtrlD, ui.NewKeyAction("Delete", x.deleteCmd, true)) } if !dao.IsK9sMeta(x.meta) { aa.Bulk(ui.KeyMap{ ui.KeyY: ui.NewKeyAction(yamlAction, x.viewCmd, true), ui.KeyD: ui.NewKeyAction("Describe", x.describeCmd, true), }) } switch gvr { case client.NsGVR: x.Actions().Delete(tcell.KeyEnter) case client.CoGVR: x.Actions().Delete(tcell.KeyEnter) aa.Bulk(ui.KeyMap{ ui.KeyS: ui.NewKeyAction("Shell", x.shellCmd, true), ui.KeyL: ui.NewKeyAction("Logs", x.logsCmd(false), true), ui.KeyP: ui.NewKeyAction("Logs Previous", x.logsCmd(true), true), }) case client.PodGVR: aa.Bulk(ui.KeyMap{ ui.KeyS: ui.NewKeyAction("Shell", x.shellCmd, true), ui.KeyA: ui.NewKeyAction("Attach", x.attachCmd, true), ui.KeyL: ui.NewKeyAction("Logs", x.logsCmd(false), true), ui.KeyP: ui.NewKeyAction("Logs Previous", x.logsCmd(true), true), }) } x.Actions().Merge(aa) } // GetSelectedPath returns the current selection as string. func (x *Xray) GetSelectedPath() string { spec := x.selectedSpec() if spec == nil { return "" } return spec.Path() } func (x *Xray) selectedSpec() *xray.NodeSpec { node := x.GetCurrentNode() if node == nil { return nil } ref, ok := node.GetReference().(xray.NodeSpec) if !ok { slog.Error("Expecting a NodeSpec", slogs.Path, node.GetText(), slogs.RefType, fmt.Sprintf("%T", node.GetReference()), ) return nil } return &ref } // EnvFn returns an plugin env function if available. func (x *Xray) EnvFn() EnvFunc { return x.envFn } func (x *Xray) k9sEnv() Env { env := k8sEnv(x.app.Conn().Config()) spec := x.selectedSpec() if spec == nil { return env } env["FILTER"] = x.CmdBuff().GetText() if env["FILTER"] == "" { ns, n := client.Namespaced(spec.Path()) env["NAMESPACE"], env["FILTER"] = ns, n } switch spec.GVR() { case client.CoGVR: _, co := client.Namespaced(spec.Path()) env["CONTAINER"] = co ns, n := client.Namespaced(*spec.ParentPath()) env["NAMESPACE"], env["POD"], env["NAME"] = ns, n, co default: ns, n := client.Namespaced(spec.Path()) env["NAMESPACE"], env["NAME"] = ns, n } return env } // Aliases returns all available aliases. func (x *Xray) Aliases() sets.Set[string] { return aliases(x.meta, x.app.command.AliasesFor(client.NewGVRFromMeta(x.meta))) } func (x *Xray) logsCmd(prev bool) func(evt *tcell.EventKey) *tcell.EventKey { return func(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } x.showLogs(spec, prev) return nil } } func (x *Xray) showLogs(spec *xray.NodeSpec, prev bool) { // Need to load and wait for pods path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { _, coName := client.Namespaced(spec.Path()) path, co = *spec.ParentPath(), coName } ns, _ := client.Namespaced(path) _, err := x.app.factory.CanForResource(ns, client.PodGVR, client.ListAccess) if err != nil { x.app.Flash().Err(err) return } opts := dao.LogOptions{ Path: path, Container: co, Previous: prev, } if err := x.app.inject(NewLog(client.PodGVR, &opts), false); err != nil { x.app.Flash().Err(err) } } func (x *Xray) shellCmd(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } if spec.Status() != "ok" { x.app.Flash().Errf("%s is not in a running state", spec.Path()) return nil } path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { _, co = client.Namespaced(spec.Path()) path = *spec.ParentPath() } if err := containerShellIn(x.app, x, path, co); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) attachCmd(*tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return nil } if spec.Status() != "ok" { x.app.Flash().Errf("%s is not in a running state", spec.Path()) return nil } path, co := spec.Path(), "" if spec.GVR() == client.CoGVR { path = *spec.ParentPath() } if err := containerAttachIn(x.app, x, path, co); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) viewCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } ctx := x.defaultContext() raw, err := x.model.ToYAML(ctx, spec.GVR(), spec.Path()) if err != nil { x.App().Flash().Errf("unable to get resource %q -- %s", spec.GVR(), err) return nil } details := NewDetails(x.app, yamlAction, spec.Path(), contentYAML, true).Update(raw) if err := x.app.inject(details, false); err != nil { x.app.Flash().Err(err) } return nil } func (x *Xray) deleteCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.Stop() defer x.Start() { meta, err := dao.MetaAccess.MetaFor(spec.GVR()) if err != nil { slog.Warn("No meta found!", slogs.GVR, spec.GVR(), slogs.Error, err, ) return nil } x.resourceDelete(spec.GVR(), spec, fmt.Sprintf("Delete %s %s?", meta.SingularName, spec.Path())) } return nil } func (x *Xray) describeCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.describe(spec.GVR(), spec.Path()) return nil } func (x *Xray) describe(gvr *client.GVR, path string) { ctx := context.Background() ctx = context.WithValue(ctx, internal.KeyFactory, x.app.factory) yaml, err := x.model.Describe(ctx, gvr, path) if err != nil { x.app.Flash().Errf("Describe command failed: %s", err) return } details := NewDetails(x.app, "Describe", path, contentYAML, true).Update(yaml) if err := x.app.inject(details, false); err != nil { x.app.Flash().Err(err) } } func (x *Xray) editCmd(evt *tcell.EventKey) *tcell.EventKey { spec := x.selectedSpec() if spec == nil { return evt } x.Stop() defer x.Start() { ns, n := client.Namespaced(spec.Path()) args := make([]string, 0, 10) args = append(args, "edit", spec.GVR().R(), "-n", ns, "--context", x.app.Config.K9s.ActiveContextName(), ) if cfg := x.app.Conn().Config().Flags().KubeConfig; cfg != nil && *cfg != "" { args = append(args, "--kubeconfig", *cfg) } if err := runK(x.app, &shellOpts{args: append(args, n)}); err != nil { x.app.Flash().Errf("Edit exec failed: %s", err) } } return evt } func (x *Xray) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if x.app.InCmdMode() { return evt } x.app.ResetPrompt(x.CmdBuff()) return nil } func (x *Xray) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !x.CmdBuff().InCmdMode() { x.CmdBuff().Reset() return x.app.PrevCmd(evt) } x.CmdBuff().Reset() x.model.ClearFilter() x.Start() return nil } func (x *Xray) gotoCmd(*tcell.EventKey) *tcell.EventKey { if x.CmdBuff().IsActive() { if internal.IsLabelSelector(x.CmdBuff().GetText()) { x.Start() } x.CmdBuff().SetActive(false) x.GetRoot().ExpandAll() return nil } spec := x.selectedSpec() if spec == nil { return nil } if len(strings.Split(spec.Path(), "/")) == 1 { return nil } x.app.gotoResource(spec.GVR().String(), spec.Path(), false, true) return nil } func (x *Xray) filter(root *xray.TreeNode) *xray.TreeNode { q := x.CmdBuff().GetText() if x.CmdBuff().Empty() || internal.IsLabelSelector(q) { return root } x.UpdateTitle() if f, ok := internal.IsFuzzySelector(q); ok { return root.Filter(f, fuzzyFilter) } if internal.IsInverseSelector(q) { return root.Filter(q, rxInverseFilter) } return root.Filter(q, rxFilter) } // TreeNodeSelected callback for node selection. func (x *Xray) TreeNodeSelected() { x.app.QueueUpdateDraw(func() { n := x.GetCurrentNode() if n != nil { n.SetColor(x.app.Styles.Xray().CursorColor.Color()) } }) } // TreeLoadFailed notifies the load failed. func (x *Xray) TreeLoadFailed(err error) { x.app.Flash().Err(err) } func (x *Xray) update(node *xray.TreeNode) { root := makeTreeNode(node, x.ExpandNodes(), x.app.Config.K9s.UI.NoIcons, x.app.Styles) if node == nil { x.app.QueueUpdateDraw(func() { x.SetRoot(root) }) return } for _, c := range node.Children { x.hydrate(root, c) } if x.GetSelectedItem() == "" { x.SetSelectedItem(node.Spec().Path()) } x.app.QueueUpdateDraw(func() { x.SetRoot(root) root.Walk(func(node, parent *tview.TreeNode) bool { spec, ok := node.GetReference().(xray.NodeSpec) if !ok { slog.Error("Expecting a NodeSpec", slogs.FQN, node.GetText(), slogs.RefType, fmt.Sprintf("%T", node.GetReference()), ) return false } // BOZO!! Figure this out expand/collapse but the root if parent != nil { node.SetExpanded(x.ExpandNodes()) } else { node.SetExpanded(true) } if spec.AsPath() == x.GetSelectedItem() { node.SetExpanded(true).SetSelectable(true) x.SetCurrentNode(node) } return true }) }) } // TreeChanged notifies the model data changed. func (x *Xray) TreeChanged(node *xray.TreeNode) { x.Count = node.Count(x.gvr) x.update(x.filter(node)) x.UpdateTitle() } func (x *Xray) hydrate(parent *tview.TreeNode, n *xray.TreeNode) { node := makeTreeNode(n, x.ExpandNodes(), x.app.Config.K9s.UI.NoIcons, x.app.Styles) for _, c := range n.Children { x.hydrate(node, c) } parent.AddChild(node) } // SetEnvFn sets the custom environment function. func (*Xray) SetEnvFn(EnvFunc) {} // Refresh updates the view. func (*Xray) Refresh() {} // BufferCompleted indicates the buffer was changed. func (x *Xray) BufferCompleted(_, _ string) { x.update(x.filter(x.model.Peek())) } // BufferChanged indicates the buffer was changed. func (*Xray) BufferChanged(_, _ string) {} // BufferActive indicates the buff activity changed. func (x *Xray) BufferActive(state bool, k model.BufferKind) { x.app.BufferActive(state, k) } func (x *Xray) defaultContext() context.Context { ctx := context.WithValue(context.Background(), internal.KeyFactory, x.app.factory) ctx = context.WithValue(ctx, internal.KeyFields, "") if x.CmdBuff().Empty() { ctx = context.WithValue(ctx, internal.KeyLabels, labels.Everything()) } else { if sel, err := ui.ExtractLabelSelector(x.CmdBuff().GetText()); err == nil { ctx = context.WithValue(ctx, internal.KeyLabels, sel) } } return ctx } // Start initializes resource watch loop. func (x *Xray) Start() { x.Stop() x.CmdBuff().AddListener(x) ctx := x.defaultContext() ctx, x.cancelFn = context.WithCancel(ctx) x.model.Watch(ctx) x.UpdateTitle() } // Stop terminates watch loop. func (x *Xray) Stop() { if x.cancelFn == nil { return } x.cancelFn() x.cancelFn = nil x.CmdBuff().RemoveListener(x) } // AddBindKeysFn sets up extra key bindings. func (*Xray) AddBindKeysFn(BindKeysFunc) {} // SetContextFn sets custom context. func (*Xray) SetContextFn(ContextFunc) {} // Name returns the component name. func (*Xray) Name() string { return "XRay" } // GetTable returns the underlying table. func (*Xray) GetTable() *Table { return nil } // GVR returns a resource descriptor. func (x *Xray) GVR() *client.GVR { return x.gvr } // App returns the current app handle. func (x *Xray) App() *App { return x.app } // UpdateTitle updates the view title. func (x *Xray) UpdateTitle() { t := x.styleTitle() x.app.QueueUpdateDraw(func() { x.SetTitle(t) }) } func (x *Xray) styleTitle() string { base := fmt.Sprintf("%s-%s", xrayTitle, cases.Title(language.Und, cases.NoLower).String(x.gvr.R())) ns := x.model.GetNamespace() if client.IsAllNamespaces(ns) { ns = client.NamespaceAll } var ( title string styles = x.app.Styles.Frame() ) if ns == client.ClusterScope { title = ui.SkinTitle(fmt.Sprintf(ui.TitleFmt, base, render.AsThousands(int64(x.Count))), &styles) } else { title = ui.SkinTitle(fmt.Sprintf(ui.NSTitleFmt, base, ns, render.AsThousands(int64(x.Count))), &styles) } buff := x.CmdBuff().GetText() if buff == "" { return title } if internal.IsLabelSelector(buff) { if sel, err := ui.ExtractLabelSelector(buff); err == nil { buff = sel.String() } } return title + ui.SkinTitle(fmt.Sprintf(ui.SearchFmt, buff), &styles) } func (x *Xray) resourceDelete(gvr *client.GVR, spec *xray.NodeSpec, msg string) { d := x.app.Styles.Dialog() dialog.ShowDelete(&d, x.app.Content.Pages, msg, func(_ *metav1.DeletionPropagation, force bool) { x.app.Flash().Infof("Delete resource %s %s", spec.GVR(), spec.Path()) accessor, err := dao.AccessorFor(x.app.factory, gvr) if err != nil { slog.Error("No accessor found", slogs.GVR, gvr, slogs.Error, err, ) return } nuker, ok := accessor.(dao.Nuker) if !ok { x.app.Flash().Errf("Invalid nuker %T", accessor) return } grace := dao.DefaultGrace if force { grace = dao.ForceGrace } if err := nuker.Delete(context.Background(), spec.Path(), nil, grace); err != nil { x.app.Flash().Errf("Delete failed with `%s", err) } else { x.app.Flash().Infof("%s `%s deleted successfully", x.GVR(), spec.Path()) x.app.factory.DeleteForwarder(spec.Path()) } x.Refresh() }, func() {}) } // ---------------------------------------------------------------------------- // Helpers... func fuzzyFilter(q, path string) bool { q = strings.TrimSpace(q[2:]) mm := fuzzy.Find(q, []string{path}) return len(mm) > 0 } func rxFilter(q, path string) bool { rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, xray.PathSeparator) for _, t := range tokens { if rx.MatchString(t) { return true } } return false } func rxInverseFilter(q, path string) bool { q = strings.TrimSpace(q[1:]) rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, xray.PathSeparator) for _, t := range tokens { if rx.MatchString(t) { return false } } return true } func makeTreeNode(node *xray.TreeNode, expanded, showIcons bool, styles *config.Styles) *tview.TreeNode { n := tview.NewTreeNode("No data...") if node != nil { n.SetText(node.Title(showIcons)) n.SetReference(node.Spec()) } n.SetSelectable(true) n.SetExpanded(expanded) n.SetColor(styles.Xray().CursorColor.Color()) n.SetSelectedFunc(func() { n.SetExpanded(!n.IsExpanded()) }) return n } ================================================ FILE: internal/view/yaml.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "time" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/config/data" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/tview" ) var ( keyValRX = regexp.MustCompile(`\A(\s*)([\w\-./\s]+):\s(.+)\z`) keyRX = regexp.MustCompile(`\A(\s*)([\w\-./\s]+):\s*\z`) searchRX = regexp.MustCompile(`<<<("search_\d+")>>>(.+)<<<"">>>`) ) const ( yamlFullFmt = "%s[key::b]%s[colon::-]: [val::]%s" yamlKeyFmt = "%s[key::b]%s[colon::-]:" yamlValueFmt = "[val::]%s" ) func colorizeYAML(style config.Yaml, raw string) string { lines := strings.Split(tview.Escape(raw), "\n") fullFmt := strings.Replace(yamlFullFmt, "[key", "["+style.KeyColor.String(), 1) fullFmt = strings.Replace(fullFmt, "[colon", "["+style.ColonColor.String(), 1) fullFmt = strings.Replace(fullFmt, "[val", "["+style.ValueColor.String(), 1) keyFmt := strings.Replace(yamlKeyFmt, "[key", "["+style.KeyColor.String(), 1) keyFmt = strings.Replace(keyFmt, "[colon", "["+style.ColonColor.String(), 1) valFmt := strings.Replace(yamlValueFmt, "[val", "["+style.ValueColor.String(), 1) buff := make([]string, 0, len(lines)) for _, l := range lines { res := keyValRX.FindStringSubmatch(l) if len(res) == 4 { buff = append(buff, enableRegion(fmt.Sprintf(fullFmt, res[1], res[2], res[3]))) continue } res = keyRX.FindStringSubmatch(l) if len(res) == 3 { buff = append(buff, enableRegion(fmt.Sprintf(keyFmt, res[1], res[2]))) continue } buff = append(buff, enableRegion(fmt.Sprintf(valFmt, l))) } return strings.Join(buff, "\n") } func enableRegion(s string) string { if searchRX.MatchString(s) { return strings.ReplaceAll(strings.ReplaceAll(s, "<<<", "["), ">>>", "]") } return s } func saveYAML(dir, name, raw string) (string, error) { if err := ensureDir(dir); err != nil { return "", err } fName := fmt.Sprintf("%s--%d.yaml", data.SanitizeFileName(name), time.Now().Unix()) fpath := filepath.Join(dir, fName) mod := os.O_CREATE | os.O_WRONLY file, err := os.OpenFile(fpath, mod, 0600) if err != nil { slog.Error("Unable to open YAML file", slogs.Path, fpath, slogs.Error, err, ) return "", nil } defer func() { if err := file.Close(); err != nil { slog.Error("Closing yaml file failed", slogs.Path, fpath, slogs.Error, err, ) } }() if _, err := file.WriteString(raw); err != nil { return "", err } return fpath, nil } ================================================ FILE: internal/view/yaml_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "testing" "github.com/derailed/k9s/internal/config" "github.com/stretchr/testify/assert" ) func TestYaml(t *testing.T) { uu := []struct { s, e string }{ { `api: fred version: v1`, `[#4682b4::b]api[#ffffff::-]: [#ffefd5::]fred [#4682b4::b]version[#ffffff::-]: [#ffefd5::]v1`, }, { `api: <<<"search_0">>>fred<<<"">>> version: v1`, `[#4682b4::b]api[#ffffff::-]: [#ffefd5::]["search_0"]fred[""] [#4682b4::b]version[#ffffff::-]: [#ffefd5::]v1`, }, { `api: version: v1`, `[#4682b4::b]api[#ffffff::-]: [#4682b4::b]version[#ffffff::-]: [#ffefd5::]v1`, }, { " fred:blee", "[#ffefd5::] fred:blee", }, { "fred blee: blee", "[#4682b4::b]fred blee[#ffffff::-]: [#ffefd5::]blee", }, { "Node-Selectors: ", "[#4682b4::b]Node-Selectors[#ffffff::-]: [#ffefd5::] ", }, { "fred.blee: ", "[#4682b4::b]fred.blee[#ffffff::-]: [#ffefd5::] ", }, { "certmanager.k8s.io/cluster-issuer: nameOfClusterIssuer", "[#4682b4::b]certmanager.k8s.io/cluster-issuer[#ffffff::-]: [#ffefd5::]nameOfClusterIssuer", }, { "Message: Pod The node was low on resource: [DiskPressure].", "[#4682b4::b]Message[#ffffff::-]: [#ffefd5::]Pod The node was low on resource: [DiskPressure[].", }, { `data: "<<<"`, `[#4682b4::b]data[#ffffff::-]: [#ffefd5::]"<<<"`, }, } s := config.NewStyles() for _, u := range uu { assert.Equal(t, u.e, colorizeYAML(s.Views().Yaml, u.s)) } } ================================================ FILE: internal/vul/scan.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "fmt" "io" "strings" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/vulnerability" ) const ( wontFix = "(won't fix)" naValue = "" ) // Scans tracks scans per image. type Scans map[string]*Scan // Dump dumps reports to writer. func (s Scans) Dump(w io.Writer) error { for k, v := range s { _, _ = fmt.Fprintf(w, "Image: %s -- ", k) v.Tally.Dump(w) _, _ = fmt.Fprintln(w) if err := v.Dump(w); err != nil { return err } } return nil } // Scan tracks image vulnerability scan. type Scan struct { ID string Table *table Tally tally } func newScan(img string) *Scan { return &Scan{ID: img, Table: newTable()} } // Dump dump report to stdout. func (s *Scan) Dump(w io.Writer) error { return s.Table.dump(w) } func (s *Scan) run(mm *match.Matches, store vulnerability.MetadataProvider) error { for m := range mm.Enumerate() { meta, err := store.VulnerabilityMetadata(vulnerability.Reference{ ID: m.Vulnerability.ID, Namespace: m.Vulnerability.Namespace, }) if err != nil { return err } var severity string if meta != nil { severity = meta.Severity } fixVersion := strings.Join(m.Vulnerability.Fix.Versions, ", ") switch m.Vulnerability.Fix.State { case vulnerability.FixStateWontFix: fixVersion = wontFix case vulnerability.FixStateUnknown: fixVersion = naValue } s.Table.addRow(newRow(m.Package.Name, m.Package.Version, fixVersion, string(m.Package.Type), m.Vulnerability.ID, severity)) } s.Table.dedup() s.Tally = newTally(s.Table) return nil } func colorize(rr []string) []string { crr := make([]string, len(rr)) copy(crr, rr) crr[len(crr)-1] = sevColor(crr[len(crr)-1]) return crr } ================================================ FILE: internal/vul/scanner.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "context" "errors" "fmt" "log/slog" "sync" "time" "github.com/anchore/clio" "github.com/anchore/grype/cmd/grype/cli/options" "github.com/anchore/grype/grype" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/matcher" "github.com/anchore/grype/grype/matcher/dotnet" "github.com/anchore/grype/grype/matcher/golang" "github.com/anchore/grype/grype/matcher/java" "github.com/anchore/grype/grype/matcher/javascript" "github.com/anchore/grype/grype/matcher/python" "github.com/anchore/grype/grype/matcher/ruby" "github.com/anchore/grype/grype/matcher/stock" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/vex" "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/syft/syft" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/slogs" ) var ImgScanner *imageScanner const ( imgChanSize = 3 imgScanTimeout = 2 * time.Second scanConcurrency = 2 ) type imageScanner struct { provider vulnerability.Provider status *vulnerability.ProviderStatus opts *options.Grype scans Scans mx sync.RWMutex initialized bool config config.ImageScans log *slog.Logger } // NewImageScanner returns a new instance. func NewImageScanner(cfg config.ImageScans, l *slog.Logger) *imageScanner { return &imageScanner{ scans: make(Scans), config: cfg, log: l.With(slogs.Subsys, "vul"), } } func (s *imageScanner) ShouldExcludes(ns string, lbls map[string]string) bool { return s.config.ShouldExclude(ns, lbls) } // GetScan fetch scan for a given image. Returns ok=false when not found. func (s *imageScanner) GetScan(img string) (*Scan, bool) { s.mx.RLock() defer s.mx.RUnlock() scan, ok := s.scans[img] return scan, ok } func (s *imageScanner) setScan(img string, sc *Scan) { s.mx.Lock() defer s.mx.Unlock() s.scans[img] = sc } // Init initializes image vulnerability database. func (s *imageScanner) Init(name, version string) { defer func(t time.Time) { slog.Debug("VulDb initialization complete", slogs.Elapsed, time.Since(t), ) }(time.Now()) opts := options.DefaultGrype(clio.Identification{Name: name, Version: version}) opts.GenerateMissingCPEs = true provider, status, err := grype.LoadVulnerabilityDB( opts.ToClientConfig(), opts.ToCuratorConfig(), opts.DB.AutoUpdate, ) if err != nil { s.log.Error("VulDb load failed", slogs.Error, err) return } s.mx.Lock() s.opts, s.provider, s.status = opts, provider, status s.mx.Unlock() if e := validateDBLoad(err, status); e != nil { s.log.Error("VulDb validate failed", slogs.Error, e) return } s.mx.Lock() s.initialized = true s.mx.Unlock() slog.Debug("VulDB initialized") } // Stop closes scan database. func (s *imageScanner) Stop() { s.mx.RLock() defer s.mx.RUnlock() if s.provider != nil { _ = s.provider.Close() s.provider = nil } } func (s *imageScanner) Score(ii ...string) string { var sc scorer for _, i := range ii { if scan, ok := s.GetScan(i); ok { sc = sc.Add(newScorer(scan.Tally)) } } return sc.String() } func (s *imageScanner) IsInitialized() bool { s.mx.RLock() defer s.mx.RUnlock() return s.initialized } func (s *imageScanner) Enqueue(ctx context.Context, images ...string) { ctx, cancel := context.WithTimeout(ctx, imgScanTimeout) defer cancel() for _, img := range images { if _, ok := s.GetScan(img); ok { continue } go s.scanWorker(ctx, img) } } func (s *imageScanner) scanWorker(ctx context.Context, img string) { defer s.log.Debug("ScanWorker bailing out!") s.log.Debug("ScanWorker processing image", slogs.Image, img) sc := newScan(img) s.setScan(img, sc) if err := s.scan(ctx, img, sc); err != nil { s.log.Warn("Scan failed for image", slogs.Image, img, slogs.Error, err, ) } } func (s *imageScanner) scan(_ context.Context, img string, sc *Scan) error { defer func(t time.Time) { s.log.Debug("[Vulscan] perf", slogs.Image, img, slogs.Elapsed, time.Since(t), ) }(time.Now()) packages, pkgContext, _, err := pkg.Provide(img, getProviderConfig(s.opts)) if err != nil { return fmt.Errorf("failed to analyze image packages: %w", err) } processor, err := vex.NewProcessor(vex.ProcessorOptions{ Documents: s.opts.VexDocuments, IgnoreRules: s.opts.Ignore, }) if err != nil { return fmt.Errorf("failed to create VEX processor: %w", err) } v := grype.VulnerabilityMatcher{ VulnerabilityProvider: s.provider, IgnoreRules: s.opts.Ignore, NormalizeByCVE: s.opts.ByCVE, FailSeverity: s.opts.FailOnSeverity(), Matchers: getMatchers(s.opts), VexProcessor: processor, } var errs error mm, _, err := v.FindMatches(packages, pkgContext) if err != nil { errs = errors.Join(errs, err) } if err := sc.run(mm, s.provider); err != nil { errs = errors.Join(errs, err) } return errs } func getProviderConfig(opts *options.Grype) pkg.ProviderConfig { return pkg.ProviderConfig{ SyftProviderConfig: pkg.SyftProviderConfig{ SBOMOptions: syft.DefaultCreateSBOMConfig(), RegistryOptions: opts.Registry.ToOptions(), Exclusions: opts.Exclusions, Platform: opts.Platform, Name: opts.Name, DefaultImagePullSource: opts.DefaultImagePullSource, }, SynthesisConfig: pkg.SynthesisConfig{ GenerateMissingCPEs: opts.GenerateMissingCPEs, }, } } func getMatchers(opts *options.Grype) []match.Matcher { return matcher.NewDefaultMatchers( matcher.Config{ Java: java.MatcherConfig{ ExternalSearchConfig: opts.ExternalSources.ToJavaMatcherConfig(), UseCPEs: opts.Match.Java.UseCPEs, }, Ruby: ruby.MatcherConfig(opts.Match.Ruby), Python: python.MatcherConfig(opts.Match.Python), Dotnet: dotnet.MatcherConfig(opts.Match.Dotnet), Javascript: javascript.MatcherConfig(opts.Match.Javascript), Golang: golang.MatcherConfig{ UseCPEs: opts.Match.Golang.UseCPEs, AlwaysUseCPEForStdlib: opts.Match.Golang.AlwaysUseCPEForStdlib, AllowMainModulePseudoVersionComparison: opts.Match.Golang.AllowMainModulePseudoVersionComparison, }, Stock: stock.MatcherConfig(opts.Match.Stock), }, ) } func validateDBLoad(loadErr error, status *vulnerability.ProviderStatus) error { if loadErr != nil { return fmt.Errorf("failed to load vulnerability db: %w", loadErr) } if status == nil { return fmt.Errorf("unable to determine the status of the vulnerability db") } if status.Error != nil { return fmt.Errorf("db could not be loaded: %w", status.Error) } return nil } ================================================ FILE: internal/vul/scorer.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import "fmt" type scorer uint8 func (b scorer) String() string { return fmt.Sprintf("%08b", b)[:6] } func newScorer(t tally) scorer { return fromTally(t) } func (b scorer) Add(b1 scorer) scorer { return b | b1 } func fromTally(t tally) scorer { var b scorer for i, v := range t { if v == 0 { continue } switch i { case sevCritical: b |= 0x80 case sevHigh: b |= 0x40 case sevMedium: b |= 0x20 case sevLow: b |= 0x10 case sevNegligible: b |= 0x08 case sevUnknown: b |= 0x04 } } return b } ================================================ FILE: internal/vul/scorer_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "testing" "github.com/stretchr/testify/assert" ) func Test_scorerAdd(t *testing.T) { uu := map[string]struct { b, b1, e scorer }{ "zero": {}, "same": { b: scorer(0x80), b1: scorer(0x80), e: scorer(0x80), }, "c+h": { b: scorer(0x80), b1: scorer(0x40), e: scorer(0xC0), }, "ch+hm": { b: scorer(0xc0), b1: scorer(0xa0), e: scorer(0xe0), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.b.Add(u.b1)) }) } } func Test_scorerFromTally(t *testing.T) { uu := map[string]struct { tt tally b scorer }{ "zero": {}, "critical": { tt: tally{29, 0, 0, 0, 0, 0, 0}, b: scorer(0x80), }, "high": { tt: tally{0, 17, 0, 0, 0, 0, 0}, b: scorer(0x40), }, "medium": { tt: tally{0, 0, 5, 0, 0, 0, 0}, b: scorer(0x20), }, "low": { tt: tally{0, 0, 0, 10, 0, 0, 0}, b: scorer(0x10), }, "negligible": { tt: tally{0, 0, 0, 0, 10, 0, 0}, b: scorer(0x08), }, "unknown": { tt: tally{0, 0, 0, 0, 0, 10, 0}, b: scorer(0x04), }, "c/h": { tt: tally{10, 20, 0, 0, 0, 0, 0}, b: scorer(0xC0), }, "c/m": { tt: tally{10, 0, 20, 0, 0, 0, 0}, b: scorer(0xA0), }, "c/h/l": { tt: tally{10, 1, 20, 0, 0, 0, 0}, b: scorer(0xE0), }, "n/u": { tt: tally{0, 0, 0, 0, 10, 20, 0}, b: scorer(0x0C), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.b, newScorer(u.tt)) }) } } ================================================ FILE: internal/vul/table.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "fmt" "io" "sort" "strings" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" "github.com/olekukonko/tablewriter/tw" ) const ( nameIdx = iota verIdx fixIdx typeIdx vulIdx sevIdx ) type Row []string func newRow(ss ...string) Row { r := make(Row, 0, len(ss)) for i, s := range ss { if i == sevIdx { s = toSev(s) } r = append(r, s) } return r } func toSev(s string) string { switch s { case "Critical": return Sev1 case "High": return Sev2 case "Medium": return Sev3 case "Low": return Sev4 case "Negligible": return Sev5 default: return SevU } } func (r Row) Name() string { return r[nameIdx] } func (r Row) Version() string { return r[verIdx] } func (r Row) Fix() string { return r[fixIdx] } func (r Row) Type() string { return r[typeIdx] } func (r Row) Vulnerability() string { return r[vulIdx] } func (r Row) Severity() string { return r[sevIdx] } func sevColor(s string) string { switch strings.ToLower(s) { case "critical": return fmt.Sprintf("[red::b]%s[-::-]", s) case "high": return fmt.Sprintf("[orange::b]%s[-::-]", s) case "medium": return fmt.Sprintf("[yellow::b]%s[-::-]", s) case "low": return fmt.Sprintf("[blue::b]%s[-::-]", s) default: return fmt.Sprintf("[gray::b]%s[-::-]", s) } } type table struct { Rows []Row } func newTable() *table { return &table{} } func (t *table) dedup() { var ( seen = make(map[string]struct{}, len(t.Rows)) rr = make([]Row, 0, len(t.Rows)) ) for _, v := range t.Rows { key := strings.Join(v, "|") if _, ok := seen[key]; ok { continue } rr, seen[key] = append(rr, v), struct{}{} } t.Rows = rr } func (t *table) addRow(r Row) { t.Rows = append(t.Rows, r) } func (t *table) dump(w io.Writer) error { columns := []string{"Name", "Installed", "Fixed-In", "Type", "Vulnerability", "Severity"} ascii := tw.NewSymbols(tw.StyleASCII) cfg := tablewriter.Config{ Behavior: tw.Behavior{TrimSpace: tw.On}, Row: tw.CellConfig{ Padding: tw.CellPadding{ Global: tw.Padding{Left: " ", Right: " "}, // 2‑space pad }, Alignment: tw.CellAlignment{Global: tw.AlignLeft}, }, } table := tablewriter.NewTable( w, tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{ Borders: tw.BorderNone, Settings: tw.Settings{ Separators: tw.SeparatorsNone, Lines: tw.LinesNone, }, Symbols: ascii, })), tablewriter.WithConfig(cfg), ) table.Header(columns) for _, row := range t.Rows { err := table.Append(colorize(row)) if err != nil { return err } } return table.Render() } func (t *table) sort() { t.dedup() sort.SliceStable(t.Rows, func(i, j int) bool { if t.Rows[i][nameIdx] != t.Rows[j][nameIdx] { return t.Rows[i][nameIdx] < t.Rows[j][nameIdx] } if t.Rows[i][verIdx] != t.Rows[j][verIdx] { return t.Rows[i][verIdx] < t.Rows[j][verIdx] } if t.Rows[i][typeIdx] != t.Rows[j][typeIdx] { return t.Rows[i][typeIdx] < t.Rows[j][typeIdx] } if t.Rows[i][sevIdx] == t.Rows[j][sevIdx] { return t.Rows[i][vulIdx] < t.Rows[j][vulIdx] } return sevToScore(t.Rows[i][sevIdx]) < sevToScore(t.Rows[j][sevIdx]) }) } func (t *table) sortSev() { t.dedup() sort.SliceStable(t.Rows, func(i, j int) bool { if s1, s2 := sevToScore(t.Rows[i][sevIdx]), sevToScore(t.Rows[j][sevIdx]); s1 != s2 { return s1 < s2 } if t.Rows[i][nameIdx] != t.Rows[j][nameIdx] { return t.Rows[i][nameIdx] < t.Rows[j][nameIdx] } if t.Rows[i][verIdx] != t.Rows[j][verIdx] { return t.Rows[i][verIdx] < t.Rows[j][verIdx] } if t.Rows[i][typeIdx] != t.Rows[j][typeIdx] { return t.Rows[i][typeIdx] < t.Rows[j][typeIdx] } return t.Rows[i][vulIdx] < t.Rows[j][vulIdx] }) } func sevToScore(s string) int { switch s { case Sev1: return 1 case Sev2: return 2 case Sev3: return 3 case Sev4: return 4 case Sev5: return 5 default: return 6 } } ================================================ FILE: internal/vul/table_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "bufio" "os" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_sort(t *testing.T) { uu := map[string]struct { t1, t2 *table }{ "simple": { t1: makeTable(t, "testdata/sort/no_dups/sc1.text"), t2: makeTable(t, "testdata/sort/no_dups/sc2.text"), }, "dups": { t1: makeTable(t, "testdata/sort/dups/sc1.text"), t2: makeTable(t, "testdata/sort/dups/sc2.text"), }, "full": { t1: makeTable(t, "testdata/sort/full/sc1.text"), t2: makeTable(t, "testdata/sort/full/sc2.text"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.t1.sort() assert.Equal(t, u.t2, u.t1) }) } } func Test_sortSev(t *testing.T) { uu := map[string]struct { t1, t2 *table }{ "simple": { t1: makeTable(t, "testdata/sort_sev/no_dups/sc1.text"), t2: makeTable(t, "testdata/sort_sev/no_dups/sc2.text"), }, "dups": { t1: makeTable(t, "testdata/sort_sev/dups/sc1.text"), t2: makeTable(t, "testdata/sort_sev/dups/sc2.text"), }, "full": { t1: makeTable(t, "testdata/sort_sev/full/sc1.text"), t2: makeTable(t, "testdata/sort_sev/full/sc2.text"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { u.t1.sortSev() assert.Equal(t, u.t2, u.t1) }) } } // Helpers... func makeTable(t *testing.T, path string) *table { f, err := os.Open(path) defer func() { _ = f.Close() }() require.NoError(t, err) sc := bufio.NewScanner(f) var tt table for sc.Scan() { ff := strings.Fields(sc.Text()) tt.addRow(newRow(ff...)) } require.NoError(t, sc.Err()) return &tt } ================================================ FILE: internal/vul/tally.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "fmt" "io" ) const ( sevCritical = iota sevHigh sevMedium sevLow sevNegligible sevUnknown sevFixed ) var vulWeights = []int{10_000, 100, 100, 10, 0, 0, 0, 0} type tally [7]int func newTally(t *table) tally { var tt tally for _, r := range t.Rows { if r.Fix() != "" { tt[sevFixed]++ } switch r.Severity() { case Sev1: tt[sevCritical]++ case Sev2: tt[sevHigh]++ case Sev3: tt[sevMedium]++ case Sev4: tt[sevLow]++ case Sev5: tt[sevNegligible]++ case SevU: tt[sevUnknown]++ } } return tt } // Dump dumps tally as text. func (t tally) Dump(w io.Writer) { _, _ = fmt.Fprintf(w, "%d critical, %d high, %d medium, %d low, %d negligible", t[sevCritical], t[sevHigh], t[sevMedium], t[sevLow], t[sevNegligible], ) if t[sevUnknown] > 0 { _, _ = fmt.Fprintf(w, " (%d unknown)", t[sevUnknown]) } if t[sevFixed] > 0 { _, _ = fmt.Fprintf(w, " -- [Fixed: %d]", t[sevFixed]) } } func (t *tally) score() int { var s int for i, v := range t[:5] { s += v * vulWeights[i] } return s } ================================================ FILE: internal/vul/tally_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul import ( "testing" "github.com/stretchr/testify/assert" ) func Test_newTally(t *testing.T) { uu := map[string]struct { t *table tt tally }{ "full": { t: makeTable(t, "testdata/sort/full/sc2.text"), tt: tally{7, 14, 8, 0, 0, 0, 29}, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.tt, newTally(u.t)) }) } } func Test_score(t *testing.T) { uu := map[string]struct { tt tally sc int }{ "zero": {}, "critical": { tt: tally{29, 7, 14, 8, 0, 0, 0}, sc: 292180, }, "high": { tt: tally{0, 17, 14, 8, 0, 0, 0}, sc: 3180, }, "medium": { tt: tally{0, 0, 14, 0, 0, 0, 0}, sc: 1400, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.sc, u.tt.score()) }) } } ================================================ FILE: internal/vul/testdata/sort/dups/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium ================================================ FILE: internal/vul/testdata/sort/dups/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium ================================================ FILE: internal/vul/testdata/sort/full/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium ================================================ FILE: internal/vul/testdata/sort/full/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium ================================================ FILE: internal/vul/testdata/sort/no_dups/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High ================================================ FILE: internal/vul/testdata/sort/no_dups/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High ================================================ FILE: internal/vul/testdata/sort_sev/dups/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium ================================================ FILE: internal/vul/testdata/sort_sev/dups/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium ================================================ FILE: internal/vul/testdata/sort_sev/full/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium ================================================ FILE: internal/vul/testdata/sort_sev/full/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical stdlib go1.19.4 n/a go-module CVE-2023-24538 Critical stdlib go1.19.4 n/a go-module CVE-2023-24540 Critical stdlib go1.19.4 n/a go-module CVE-2023-29402 Critical stdlib go1.19.4 n/a go-module CVE-2023-29404 Critical stdlib go1.19.4 n/a go-module CVE-2023-29405 Critical stdlib go1.19.4 n/a go-module CVE-2023-39323 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High golang.org/x/net v0.4.0 0.17.0 go-module GHSA-4374-p667-p6c8 High golang.org/x/net v0.4.0 0.7.0 go-module GHSA-vvpx-j8f3-3w6h High stdlib go1.19.4 n/a go-module CVE-2022-41722 High stdlib go1.19.4 n/a go-module CVE-2022-41723 High stdlib go1.19.4 n/a go-module CVE-2022-41724 High stdlib go1.19.4 n/a go-module CVE-2022-41725 High stdlib go1.19.4 n/a go-module CVE-2023-24534 High stdlib go1.19.4 n/a go-module CVE-2023-24536 High stdlib go1.19.4 n/a go-module CVE-2023-24537 High stdlib go1.19.4 n/a go-module CVE-2023-24539 High stdlib go1.19.4 n/a go-module CVE-2023-29400 High stdlib go1.19.4 n/a go-module CVE-2023-29403 High stdlib go1.19.4 n/a go-module CVE-2023-44487 High github.com/prometheus/alertmanager v0.25.0 0.25.1 go-module GHSA-v86x-5fm3-5p7j Medium golang.org/x/net v0.4.0 0.13.0 go-module GHSA-2wrh-6pvc-2jm9 Medium golang.org/x/net v0.4.0 0.17.0 go-module GHSA-qppj-fm5r-hxr3 Medium stdlib go1.19.4 n/a go-module CVE-2023-24532 Medium stdlib go1.19.4 n/a go-module CVE-2023-29406 Medium stdlib go1.19.4 n/a go-module CVE-2023-29409 Medium stdlib go1.19.4 n/a go-module CVE-2023-39318 Medium stdlib go1.19.4 n/a go-module CVE-2023-39319 Medium ================================================ FILE: internal/vul/testdata/sort_sev/no_dups/sc1.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High ================================================ FILE: internal/vul/testdata/sort_sev/no_dups/sc2.text ================================================ busybox 1.34.1 n/a binary CVE-2022-48174 Critical busybox 1.34.1 n/a binary CVE-2022-28391 High ================================================ FILE: internal/vul/types.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package vul const ( // Sev1 tracks Critical sev. Sev1 = "SEV-1" // Sev2 tracks High sev. Sev2 = "SEV-2" // Sev3 tracks Medium sev. Sev3 = "SEV-3" // Sev4 tracks Low sev. Sev4 = "SEV-4" // Sev5 tracks Negligible sev. Sev5 = "SEV-5" // SevU tracks Unknown sev. SevU = "SEV-U" ) ================================================ FILE: internal/watch/factory.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package watch import ( "fmt" "log/slog" "strings" "sync" "time" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" di "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/informers" ) const ( defaultResync = 10 * time.Minute defaultWaitTime = 500 * time.Millisecond ) // Factory tracks various resource informers. type Factory struct { factories map[string]di.DynamicSharedInformerFactory client client.Connection stopChan chan struct{} forwarders Forwarders mx sync.RWMutex } // NewFactory returns a new informers factory. func NewFactory(clt client.Connection) *Factory { return &Factory{ client: clt, factories: make(map[string]di.DynamicSharedInformerFactory), forwarders: NewForwarders(), } } // Start initializes the informers until caller cancels the context. func (f *Factory) Start(ns string) { f.mx.Lock() defer f.mx.Unlock() slog.Debug("Factory started", slogs.Namespace, ns) f.stopChan = make(chan struct{}) for ns, fac := range f.factories { slog.Debug("Starting factory for ns", slogs.Namespace, ns) fac.Start(f.stopChan) } } // Terminate terminates all watchers and forwards. func (f *Factory) Terminate() { f.mx.Lock() defer f.mx.Unlock() if f.stopChan != nil { close(f.stopChan) f.stopChan = nil } for k := range f.factories { delete(f.factories, k) } f.forwarders.DeleteAll() } // List returns a resource collection. func (f *Factory) List(gvr *client.GVR, ns string, wait bool, lbls labels.Selector) ([]runtime.Object, error) { if client.IsAllNamespace(ns) { ns = client.BlankNamespace } inf, err := f.CanForResource(ns, gvr, client.ListAccess) if err != nil { return nil, err } var oo []runtime.Object if client.IsClusterScoped(ns) { oo, err = inf.Lister().List(lbls) } else { oo, err = inf.Lister().ByNamespace(ns).List(lbls) } if !wait || (wait && inf.Informer().HasSynced()) { return oo, err } f.waitForCacheSync(ns) if client.IsClusterScoped(ns) { return inf.Lister().List(lbls) } return inf.Lister().ByNamespace(ns).List(lbls) } // HasSynced checks if given informer is up to date. func (f *Factory) HasSynced(gvr *client.GVR, ns string) (bool, error) { inf, err := f.CanForResource(ns, gvr, client.ListAccess) if err != nil { return false, err } return inf.Informer().HasSynced(), nil } // Get retrieves a given resource. func (f *Factory) Get(gvr *client.GVR, fqn string, wait bool, _ labels.Selector) (runtime.Object, error) { ns, n := namespaced(fqn) if client.IsAllNamespace(ns) { ns = client.BlankNamespace } inf, err := f.CanForInstance(fqn, gvr, []string{client.GetVerb}) if err != nil { return nil, err } var o runtime.Object if client.IsClusterScoped(ns) { o, err = inf.Lister().Get(n) } else { o, err = inf.Lister().ByNamespace(ns).Get(n) } if !wait || (wait && inf.Informer().HasSynced()) { return o, err } f.waitForCacheSync(ns) if client.IsClusterScoped(ns) { return inf.Lister().Get(n) } return inf.Lister().ByNamespace(ns).Get(n) } func (f *Factory) waitForCacheSync(ns string) { if client.IsClusterWide(ns) { ns = client.BlankNamespace } f.mx.RLock() defer f.mx.RUnlock() fac, ok := f.factories[ns] if !ok { return } // Hang for a sec for the cache to refresh if still not done bail out! c := make(chan struct{}) go func(c chan struct{}) { <-time.After(defaultWaitTime) close(c) }(c) _ = fac.WaitForCacheSync(c) } // WaitForCacheSync waits for all factories to update their cache. func (f *Factory) WaitForCacheSync() { for ns, fac := range f.factories { m := fac.WaitForCacheSync(f.stopChan) for k, v := range m { slog.Debug("CACHE `%q Loaded %t:%s", slogs.Namespace, ns, slogs.ResGrpVersion, v, slogs.ResKind, k, ) } } } // Client return the factory connection. func (f *Factory) Client() client.Connection { return f.client } // FactoryFor returns a factory for a given namespace. func (f *Factory) FactoryFor(ns string) di.DynamicSharedInformerFactory { return f.factories[ns] } // SetActiveNS sets the active namespace. func (f *Factory) SetActiveNS(ns string) error { if f.isClusterWide() { return nil } _, err := f.ensureFactory(ns) return err } func (f *Factory) isClusterWide() bool { f.mx.RLock() defer f.mx.RUnlock() _, ok := f.factories[client.BlankNamespace] return ok } // CanForResource return an informer is user has access. func (f *Factory) CanForResource(ns string, gvr *client.GVR, verbs []string) (informers.GenericInformer, error) { var resName string if gvr == client.NsGVR { resName = ns } auth, err := f.Client().CanI(ns, gvr, resName, verbs) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("%v access denied on resource %q:%q", verbs, ns, gvr) } return f.ForResource(ns, gvr) } // CanForInstance return an informer is user has access. func (f *Factory) CanForInstance(fqn string, gvr *client.GVR, verbs []string) (informers.GenericInformer, error) { ns, n := namespaced(fqn) if client.IsAllNamespace(ns) { ns = client.BlankNamespace } // For namespace resources, set namespace to the resource name to allow // RoleBindings within that namespace to grant permissions if gvr == client.NsGVR { ns = n } auth, err := f.Client().CanI(ns, gvr, n, verbs) if err != nil { return nil, err } if !auth { return nil, fmt.Errorf("%v access denied on resource %q:%q", verbs, ns, gvr) } return f.ForResource(ns, gvr) } // ForResource returns an informer for a given resource. func (f *Factory) ForResource(ns string, gvr *client.GVR) (informers.GenericInformer, error) { fact, err := f.ensureFactory(ns) if err != nil { return nil, err } inf := fact.ForResource(gvr.GVR()) if inf == nil { slog.Error("No informer found", slogs.GVR, gvr, slogs.Namespace, ns, ) return inf, nil } f.mx.RLock() defer f.mx.RUnlock() fact.Start(f.stopChan) return inf, nil } func (f *Factory) ensureFactory(ns string) (di.DynamicSharedInformerFactory, error) { if client.IsClusterWide(ns) { ns = client.BlankNamespace } f.mx.Lock() defer f.mx.Unlock() if fac, ok := f.factories[ns]; ok { return fac, nil } dial, err := f.client.DynDial() if err != nil { return nil, err } f.factories[ns] = di.NewFilteredDynamicSharedInformerFactory( dial, defaultResync, ns, nil, ) return f.factories[ns], nil } // AddForwarder registers a new portforward for a given container. func (f *Factory) AddForwarder(pf Forwarder) { f.mx.Lock() defer f.mx.Unlock() f.forwarders[pf.ID()] = pf } // DeleteForwarder deletes portforward for a given container. func (f *Factory) DeleteForwarder(path string) { count := f.forwarders.Kill(path) slog.Warn("Deleted portforward", slogs.Count, count, slogs.GVR, path, ) } // Forwarders returns all portforwards. func (f *Factory) Forwarders() Forwarders { f.mx.RLock() defer f.mx.RUnlock() return f.forwarders } // ForwarderFor returns a portforward for a given container or nil if none exists. func (f *Factory) ForwarderFor(path string) (Forwarder, bool) { f.mx.RLock() defer f.mx.RUnlock() fwd, ok := f.forwarders[path] return fwd, ok } // ValidatePortForwards check if pods are still around for portforwards. // BOZO!! Review!!! func (f *Factory) ValidatePortForwards() { for k, fwd := range f.forwarders { tokens := strings.Split(k, ":") if len(tokens) != 2 { slog.Error("Invalid port-forward key", slogs.Key, k) return } paths := strings.Split(tokens[0], "|") if len(paths) < 1 { slog.Error("Invalid port-forward path", slogs.Path, tokens[0]) } o, err := f.Get(client.PodGVR, paths[0], false, labels.Everything()) if err != nil { fwd.Stop() delete(f.forwarders, k) continue } var pod v1.Pod if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.(*unstructured.Unstructured).Object, &pod); err != nil { continue } if pod.GetCreationTimestamp().Unix() > fwd.Age().Unix() { fwd.Stop() delete(f.forwarders, k) } } } ================================================ FILE: internal/watch/forwarders.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package watch import ( "fmt" "log/slog" "strings" "time" "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/slogs" "k8s.io/client-go/tools/portforward" ) // Forwarder represents a port forwarder. type Forwarder interface { // Start starts a port-forward. Start(path string, tunnel port.PortTunnel) (*portforward.PortForwarder, error) // Stop terminates a port forward. Stop() // ID returns the pf id. ID() string // Container returns a container name. Container() string // Port returns the port mapping. Port() string // Address returns the host address. Address() string // FQN returns the full port-forward name. FQN() string // Active returns forwarder current state. Active() bool // SetActive sets port-forward state. SetActive(bool) // Age returns forwarder age. Age() time.Time // HasPortMapping returns true if port mapping exists. HasPortMapping(string) bool } // Forwarders tracks active port forwards. type Forwarders map[string]Forwarder // NewForwarders returns new forwarders. func NewForwarders() Forwarders { return make(map[string]Forwarder) } // IsPodForwarded checks if pod has a forward. func (ff Forwarders) IsPodForwarded(fqn string) bool { fqn += "|" for k := range ff { if strings.HasPrefix(k, fqn) { return true } } return false } // IsContainerForwarded checks if pod has a forward. func (ff Forwarders) IsContainerForwarded(fqn, co string) bool { fqn += "|" + co for k := range ff { if strings.HasPrefix(k, fqn) { return true } } return false } // DeleteAll stops and delete all port-forwards. func (ff Forwarders) DeleteAll() { for k, f := range ff { slog.Debug("Deleting forwarder", slogs.ID, f.ID()) f.Stop() delete(ff, k) } } // Kill stops and delete a port-forwards associated with pod. func (ff Forwarders) Kill(path string) int { var stats int // The way port forwards are stored is `pod_fqn|container|local_port:container_port` // The '|' is added to make sure we do not delete port forwards from other pods that have the same prefix // Without the `|` port forwards for pods, default/web-0 and default/web-0-bla would be both deleted // even if we want only port forwards for default/web-0 to be deleted prefix := path + "|" for k, f := range ff { if k == path || strings.HasPrefix(k, prefix) { stats++ slog.Debug("Stop and delete port-forward", slogs.Name, k) f.Stop() delete(ff, k) } } return stats } // Dump for debug! func (ff Forwarders) Dump() { slog.Debug("----------- PORT-FORWARDS --------------") for k, f := range ff { slog.Debug(fmt.Sprintf(" %s -- %s", k, f)) } } ================================================ FILE: internal/watch/forwarders_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package watch_test import ( "log/slog" "testing" "time" "github.com/derailed/k9s/internal/port" "github.com/derailed/k9s/internal/watch" "github.com/stretchr/testify/assert" "k8s.io/client-go/tools/portforward" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestIsPodForwarded(t *testing.T) { uu := map[string]struct { ff watch.Forwarders fqn string e bool }{ "happy": { ff: watch.Forwarders{ "ns1/p1||8080:8080": newNoOpForwarder(), }, fqn: "ns1/p1", e: true, }, "dud": { ff: watch.Forwarders{ "ns1/p1||8080:8080": newNoOpForwarder(), }, fqn: "ns1/p2", }, "sub": { ff: watch.Forwarders{ "ns1/freddy||8080:8080": newNoOpForwarder(), }, fqn: "ns1/fred", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.ff.IsPodForwarded(u.fqn)) }) } } func TestIsContainerForwarded(t *testing.T) { uu := map[string]struct { ff watch.Forwarders fqn, co string e bool }{ "happy": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), }, fqn: "ns1/p1", co: "c1", e: true, }, "dud": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), }, fqn: "ns1/p1", co: "c2", }, "sub": { ff: watch.Forwarders{ "ns1/freddy|c1|8080:8080": newNoOpForwarder(), }, fqn: "ns1/fred", co: "c1", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.ff.IsContainerForwarded(u.fqn, u.co)) }) } } func TestKill(t *testing.T) { uu := map[string]struct { ff watch.Forwarders path string kills int }{ "partial_match": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1", kills: 1, }, "partial_no_match": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p", }, "path_sub": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1", kills: 1, }, "partial_multi": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1|c2|8081:8081": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1", kills: 2, }, "full_match": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1|c1|8080:8080", kills: 1, }, "full_no_match_co": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1|c2|8080:8080", }, "full_no_match_ports": { ff: watch.Forwarders{ "ns1/p1|c1|8080:8080": newNoOpForwarder(), "ns1/p1_1|c1|8080:8080": newNoOpForwarder(), "ns1/p2|c1|8080:8080": newNoOpForwarder(), }, path: "ns1/p1|c1|8081:8080", }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.kills, u.ff.Kill(u.path)) }) } } type noOpForwarder struct{} func newNoOpForwarder() noOpForwarder { return noOpForwarder{} } func (noOpForwarder) Start(string, port.PortTunnel) (*portforward.PortForwarder, error) { return nil, nil } func (noOpForwarder) Stop() {} func (noOpForwarder) ID() string { return "" } func (noOpForwarder) Container() string { return "" } func (noOpForwarder) Port() string { return "" } func (noOpForwarder) FQN() string { return "" } func (noOpForwarder) Active() bool { return false } func (noOpForwarder) SetActive(bool) {} func (noOpForwarder) Age() time.Time { return time.Now() } func (noOpForwarder) HasPortMapping(string) bool { return false } func (noOpForwarder) Address() string { return "" } ================================================ FILE: internal/watch/helper.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package watch import ( "fmt" "log/slog" "path" "strings" "k8s.io/apimachinery/pkg/runtime/schema" ) func toGVR(gvr string) schema.GroupVersionResource { tokens := strings.Split(gvr, "/") if len(tokens) < 3 { tokens = append([]string{""}, tokens...) } return schema.GroupVersionResource{ Group: tokens[0], Version: tokens[1], Resource: tokens[2], } } func namespaced(n string) (ns, res string) { ns, res = path.Split(n) return strings.Trim(ns, "/"), res } // DumpFactory for debug. func DumpFactory(f *Factory) { slog.Debug("----------- FACTORIES -------------") for ns := range f.factories { slog.Debug(fmt.Sprintf(" Factory for NS %q", ns)) } slog.Debug("-----------------------------------") } // DebugFactory for debug. func DebugFactory(f *Factory, ns, gvr string) { slog.Debug(fmt.Sprintf("----------- DEBUG FACTORY (%s) -------------", gvr)) fac, ok := f.factories[ns] if !ok { return } inf := fac.ForResource(toGVR(gvr)) for i, k := range inf.Informer().GetStore().ListKeys() { slog.Debug(fmt.Sprintf("%d -- %s", i, k)) } } ================================================ FILE: internal/xray/container.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "log/slog" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/slogs" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" ) // Container represents an xray renderer. type Container struct{} // Render renders an xray node. func (c *Container) Render(ctx context.Context, ns string, o any) error { co, ok := o.(render.ContainerRes) if !ok { return fmt.Errorf("expected ContainerRes, but got %T", o) } f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return fmt.Errorf("no factory found in context") } root := NewTreeNode(client.CoGVR, client.FQN(ns, co.Container.Name)) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } pns, _ := client.Namespaced(parent.ID) c.envRefs(f, root, pns, co.Container) parent.Add(root) return nil } func (c *Container) envRefs(f dao.Factory, parent *TreeNode, ns string, co *v1.Container) { for _, e := range co.Env { if e.ValueFrom == nil { continue } c.secretRefs(f, parent, ns, e.ValueFrom.SecretKeyRef) c.configMapRefs(f, parent, ns, e.ValueFrom.ConfigMapKeyRef) } for _, e := range co.EnvFrom { if e.ConfigMapRef != nil { gvr, id := client.CmGVR, client.FQN(ns, e.ConfigMapRef.Name) addRef(f, parent, gvr, id, e.ConfigMapRef.Optional) } if e.SecretRef != nil { gvr, id := client.SecGVR, client.FQN(ns, e.SecretRef.Name) addRef(f, parent, gvr, id, e.SecretRef.Optional) } } } func (c *Container) secretRefs(f dao.Factory, parent *TreeNode, ns string, ref *v1.SecretKeySelector) { if ref == nil { return } gvr, id := client.SecGVR, client.FQN(ns, ref.Name) addRef(f, parent, gvr, id, ref.Optional) } func (c *Container) configMapRefs(f dao.Factory, parent *TreeNode, ns string, ref *v1.ConfigMapKeySelector) { if ref == nil { return } gvr, id := client.CmGVR, client.FQN(ns, ref.Name) addRef(f, parent, gvr, id, ref.Optional) } // ---------------------------------------------------------------------------- // Helpers... func addRef(f dao.Factory, parent *TreeNode, gvr *client.GVR, id string, optional *bool) { if parent.Find(gvr, id) == nil { n := NewTreeNode(gvr, id) validate(f, n, optional) parent.Add(n) } } func validate(f dao.Factory, n *TreeNode, optional *bool) { res, err := f.Get(n.GVR, n.ID, true, labels.Everything()) if err != nil || res == nil { if optional == nil || !*optional { slog.Warn("Missing ref", slogs.GVR, n.GVR, slogs.ID, n.ID, ) n.Extras[StatusKey] = MissingRefStatus } return } n.Extras[StatusKey] = OkStatus } ================================================ FILE: internal/xray/container_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "encoding/json" "fmt" "log/slog" "os" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/watch" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/informers" ) func init() { slog.SetDefault(slog.New(slog.DiscardHandler)) } func TestCOConfigMapRefs(t *testing.T) { var re xray.Container root := xray.NewTreeNode(client.NewGVR("root"), "root") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", render.ContainerRes{Container: makeCMContainer("c1", false)})) assert.Equal(t, xray.MissingRefStatus, root.Children[0].Children[0].Extras[xray.StatusKey]) } func TestCORefs(t *testing.T) { uu := map[string]struct { co render.ContainerRes level1, level2 int e string }{ "cm_required": { co: render.ContainerRes{Container: makeCMContainer("c1", false)}, level1: 1, level2: 1, e: xray.MissingRefStatus, }, "cm_optional": { co: render.ContainerRes{Container: makeCMContainer("c1", true)}, level1: 1, level2: 1, e: xray.OkStatus, }, "cm_doubleRef": { co: render.ContainerRes{Container: makeDoubleCMKeysContainer("c1", false)}, level1: 1, level2: 1, e: xray.MissingRefStatus, }, "sec_required": { co: render.ContainerRes{Container: makeSecContainer("c1", false)}, level1: 1, level2: 1, e: xray.MissingRefStatus, }, "sec_optional": { co: render.ContainerRes{Container: makeSecContainer("c1", true)}, level1: 1, level2: 1, e: xray.OkStatus, }, "envFrom_optional": { co: render.ContainerRes{Container: makeCMEnvFromContainer("c1", false)}, level1: 1, level2: 2, e: xray.MissingRefStatus, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { var re xray.Container root := xray.NewTreeNode(client.NewGVR("root"), "root") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", u.co)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) assert.Equal(t, u.e, root.Children[0].Children[0].Extras[xray.StatusKey]) }) } } // ---------------------------------------------------------------------------- // Helpers... func makeFactory() testFactory { return testFactory{} } type testFactory struct { rows map[*client.GVR][]runtime.Object } var _ dao.Factory = testFactory{} func (f testFactory) Client() client.Connection { return nil } func (f testFactory) Get(gvr *client.GVR, _ string, _ bool, _ labels.Selector) (runtime.Object, error) { oo, ok := f.rows[gvr] if ok && len(oo) > 0 { return oo[0], nil } return nil, nil } func (f testFactory) List(gvr *client.GVR, _ string, _ bool, _ labels.Selector) ([]runtime.Object, error) { oo, ok := f.rows[gvr] if ok { return oo, nil } return nil, nil } func (f testFactory) ForResource(string, *client.GVR) (informers.GenericInformer, error) { return nil, nil } func (f testFactory) CanForResource(string, *client.GVR, []string) (informers.GenericInformer, error) { return nil, nil } func (f testFactory) WaitForCacheSync() {} func (f testFactory) Forwarders() watch.Forwarders { return nil } func (f testFactory) DeleteForwarder(string) {} func makeCMEnvFromContainer(n string, optional bool) *v1.Container { return &v1.Container{ Name: n, EnvFrom: []v1.EnvFromSource{ { ConfigMapRef: &v1.ConfigMapEnvSource{ LocalObjectReference: v1.LocalObjectReference{ Name: "cm1", }, Optional: &optional, }, SecretRef: &v1.SecretEnvSource{ LocalObjectReference: v1.LocalObjectReference{ Name: "sec1", }, Optional: &optional, }, }, }, } } func makeCMContainer(n string, optional bool) *v1.Container { return &v1.Container{ Name: n, Env: []v1.EnvVar{ { Name: "e1", ValueFrom: &v1.EnvVarSource{ ConfigMapKeyRef: &v1.ConfigMapKeySelector{ LocalObjectReference: v1.LocalObjectReference{ Name: "cm1", }, Key: "k1", Optional: &optional, }, }, }, }, } } func makeSecContainer(n string, optional bool) *v1.Container { return &v1.Container{ Name: n, Env: []v1.EnvVar{ { Name: "e1", ValueFrom: &v1.EnvVarSource{ SecretKeyRef: &v1.SecretKeySelector{ LocalObjectReference: v1.LocalObjectReference{ Name: "sec1", }, Key: "k1", Optional: &optional, }, }, }, }, } } func makeDoubleCMKeysContainer(n string, optional bool) *v1.Container { return &v1.Container{ Name: n, Env: []v1.EnvVar{ { Name: "e1", ValueFrom: &v1.EnvVarSource{ ConfigMapKeyRef: &v1.ConfigMapKeySelector{ LocalObjectReference: v1.LocalObjectReference{ Name: "cm1", }, Key: "k2", Optional: &optional, }, }, }, { Name: "e2", ValueFrom: &v1.EnvVarSource{ ConfigMapKeyRef: &v1.ConfigMapKeySelector{ LocalObjectReference: v1.LocalObjectReference{ Name: "cm1", }, Key: "k1", Optional: &optional, }, }, }, }, } } func load(t *testing.T, n string) *unstructured.Unstructured { raw, err := os.ReadFile(fmt.Sprintf("testdata/%s.json", n)) require.NoError(t, err) var o unstructured.Unstructured err = json.Unmarshal(raw, &o) require.NoError(t, err) return &o } ================================================ FILE: internal/xray/dp.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" appsv1 "k8s.io/api/apps/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) // Deployment represents an xray renderer. type Deployment struct{} // Render renders an xray node. func (d *Deployment) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var dp appsv1.Deployment err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &dp) if err != nil { return err } parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } root := NewTreeNode(client.DpGVR, client.FQN(dp.Namespace, dp.Name)) oo, err := locatePods(ctx, dp.Namespace, dp.Spec.Selector) if err != nil { return err } ctx = context.WithValue(ctx, KeyParent, root) var re Pod for _, o := range oo { p, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting *Unstructured but got %T", o) } if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { return err } } if root.IsLeaf() { return nil } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, dp.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(root) return d.validate(root, dp) } func (*Deployment) validate(root *TreeNode, dp appsv1.Deployment) error { root.Extras[StatusKey] = OkStatus var r int32 if dp.Spec.Replicas != nil { r = int32(*dp.Spec.Replicas) } a := dp.Status.AvailableReplicas if a != r || dp.Status.UnavailableReplicas != 0 { root.Extras[StatusKey] = ToastStatus } root.Extras[InfoKey] = fmt.Sprintf("%d/%d/%d", a, r, dp.Status.UnavailableReplicas) return nil } // ---------------------------------------------------------------------------- // Helpers... func locatePods(ctx context.Context, ns string, sel *metav1.LabelSelector) ([]runtime.Object, error) { l, err := metav1.LabelSelectorAsSelector(sel) if err != nil { return nil, err } fsel, err := labels.ConvertSelectorToLabelsMap(l.String()) if err != nil { return nil, err } f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return nil, fmt.Errorf("expecting a factory but got %T", ctx.Value(internal.KeyFactory)) } return f.List(client.PodGVR, ns, false, fsel.AsSelector()) } ================================================ FILE: internal/xray/dp_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" ) func TestDeployRender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "dp", level1: 1, level2: 1, status: xray.OkStatus, }, } var re xray.Deployment for k := range uu { f := makeFactory() f.rows = map[*client.GVR][]runtime.Object{ client.PodGVR: {load(t, "po")}, client.SaGVR: {load(t, "sa")}, } u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.DpGVR, "deployments") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, f) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/ds.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // DaemonSet represents an xray renderer. type DaemonSet struct{} // Render renders an xray node. func (d *DaemonSet) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var ds appsv1.DaemonSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &ds) if err != nil { return err } parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } root := NewTreeNode(client.DsGVR, client.FQN(ds.Namespace, ds.Name)) oo, err := locatePods(ctx, ds.Namespace, ds.Spec.Selector) if err != nil { return err } ctx = context.WithValue(ctx, KeyParent, root) var re Pod for _, o := range oo { p, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting *Unstructured but got %T", o) } if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { return err } } if root.IsLeaf() { return nil } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, ds.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(root) return d.validate(root, ds) } func (*DaemonSet) validate(root *TreeNode, ds appsv1.DaemonSet) error { root.Extras[StatusKey] = OkStatus d := ds.Status.DesiredNumberScheduled a := ds.Status.NumberAvailable if d != a { root.Extras[StatusKey] = ToastStatus } root.Extras[InfoKey] = fmt.Sprintf("%d/%d", a, d) return nil } ================================================ FILE: internal/xray/ds_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" ) func TestDaemonSetRender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "ds", level1: 1, level2: 1, status: xray.OkStatus, }, } var re xray.DaemonSet for k := range uu { f := makeFactory() f.rows = map[*client.GVR][]runtime.Object{client.PodGVR: {load(t, "po")}} u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.DsGVR, "daemonsets") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, f) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/generic.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal/client" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Generic renders a generic resource to screen. type Generic struct { table *metav1.Table } // SetTable sets the tabular resource. func (g *Generic) SetTable(_ string, t *metav1.Table) { g.table = t } // Render renders a K8s resource to screen. func (g *Generic) Render(ctx context.Context, ns string, o any) error { row, ok := o.(metav1.TableRow) if !ok { return fmt.Errorf("expecting a TableRow but got %T", o) } n, ok := row.Cells[0].(string) if !ok { return fmt.Errorf("expecting row 0 to be a string but got %T", row.Cells[0]) } root := NewTreeNode(client.NewGVR("generic"), client.FQN(ns, n)) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting TreeNode but got %T", ctx.Value(KeyParent)) } parent.Add(root) return nil } ================================================ FILE: internal/xray/generic_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" ) func TestGenericRender(t *testing.T) { uu := map[string]struct { level1 int status string }{ "plain": { level1: 1, status: xray.OkStatus, }, } var re xray.Generic for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { root := xray.NewTreeNode(client.NewGVR("generic"), "generics") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", makeTable())) assert.Equal(t, u.level1, root.CountChildren()) }) } } // Helpers... func makeTable() metav1beta1.TableRow { return metav1beta1.TableRow{ Cells: []any{"fred", "blee"}, } } ================================================ FILE: internal/xray/ns.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal/client" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // Namespace represents an xray renderer. type Namespace struct{} // Render renders an xray node. func (n *Namespace) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected NamespaceWithMetrics, but got %T", o) } var nss v1.Namespace err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &nss) if err != nil { return err } root := NewTreeNode(client.NsGVR, client.FQN(client.ClusterScope, nss.Name)) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } parent.Add(root) return n.validate(root, nss) } func (*Namespace) validate(root *TreeNode, ns v1.Namespace) error { root.Extras[StatusKey] = OkStatus if ns.Status.Phase == v1.NamespaceTerminating { root.Extras[StatusKey] = ToastStatus } return nil } ================================================ FILE: internal/xray/ns_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNamespaceRender(t *testing.T) { uu := map[string]struct { file string level1 int status string }{ "plain": { file: "ns", level1: 1, status: xray.OkStatus, }, } var re xray.Namespace for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.NsGVR, "namespaces") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) }) } } ================================================ FILE: internal/xray/pod.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "strconv" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) // Pod represents an xray renderer. type Pod struct{} // Render renders an xray node. func (p *Pod) Render(ctx context.Context, ns string, o any) error { pwm, ok := o.(*render.PodWithMetrics) if !ok { return fmt.Errorf("expected PodWithMetrics, but got %T", o) } var po v1.Pod err := runtime.DefaultUnstructuredConverter.FromUnstructured(pwm.Raw.Object, &po) if err != nil { return err } f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return fmt.Errorf("no factory found in context") } node := NewTreeNode(client.PodGVR, client.FQN(po.Namespace, po.Name)) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } if err := p.containerRefs(ctx, node, po.Namespace, &po.Spec); err != nil { return err } p.podVolumeRefs(f, node, po.Namespace, po.Spec.Volumes) if err := p.serviceAccountRef(ctx, f, node, po.Namespace, &po.Spec); err != nil { return err } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, po.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(node) return p.validate(node, po) } func (p *Pod) validate(node *TreeNode, po v1.Pod) error { var re render.Pod phase := re.Phase(po.DeletionTimestamp, &po.Spec, &po.Status) ss := po.Status.ContainerStatuses readyCnt, _, _, _ := re.ContainerStats(ss) status := OkStatus if readyCnt != len(ss) { status = ToastStatus } if phase == "Completed" { status = CompletedStatus } node.Extras[StatusKey] = status node.Extras[InfoKey] = strconv.Itoa(readyCnt) + "/" + strconv.Itoa(len(ss)) return nil } func (*Pod) containerRefs(ctx context.Context, parent *TreeNode, ns string, spec *v1.PodSpec) error { ctx = context.WithValue(ctx, KeyParent, parent) var cre Container for i := range len(spec.InitContainers) { if err := cre.Render(ctx, ns, render.ContainerRes{Container: &spec.InitContainers[i]}); err != nil { return err } } for i := range len(spec.Containers) { if err := cre.Render(ctx, ns, render.ContainerRes{Container: &spec.Containers[i]}); err != nil { return err } } for i := range len(spec.EphemeralContainers) { if err := cre.Render(ctx, ns, render.ContainerRes{Container: &spec.Containers[i]}); err != nil { return err } } return nil } func (*Pod) serviceAccountRef(ctx context.Context, f dao.Factory, parent *TreeNode, ns string, spec *v1.PodSpec) error { if spec.ServiceAccountName == "" { return nil } fqn := client.FQN(ns, spec.ServiceAccountName) o, err := f.Get(client.SaGVR, fqn, true, labels.Everything()) if err != nil { return err } if o == nil { addRef(f, parent, client.SaGVR, fqn, nil) return nil } var saRE ServiceAccount ctx = context.WithValue(ctx, KeyParent, parent) ctx = context.WithValue(ctx, KeySAAutomount, spec.AutomountServiceAccountToken) return saRE.Render(ctx, ns, o) } func (*Pod) podVolumeRefs(f dao.Factory, parent *TreeNode, ns string, vv []v1.Volume) { for i := range vv { sec := vv[i].Secret if sec != nil { addRef(f, parent, client.SecGVR, client.FQN(ns, sec.SecretName), sec.Optional) continue } cm := vv[i].ConfigMap if cm != nil { addRef(f, parent, client.CmGVR, client.FQN(ns, cm.Name), cm.Optional) continue } pvc := vv[i].PersistentVolumeClaim if pvc != nil { addRef(f, parent, client.PvcGVR, client.FQN(ns, pvc.ClaimName), nil) } } } ================================================ FILE: internal/xray/pod_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPodRender(t *testing.T) { uu := map[string]struct { file string count, children int status string }{ "plain": { file: "po", children: 1, count: 7, status: xray.OkStatus, }, "withInit": { file: "init", children: 1, count: 7, status: xray.OkStatus, }, "cilium": { file: "cilium", children: 1, count: 8, status: xray.OkStatus, }, } var re xray.Pod for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.PodGVR, "pods") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", &render.PodWithMetrics{Raw: o})) assert.Equal(t, u.children, root.CountChildren()) assert.Equal(t, u.count, root.Count(client.NoGVR)) }) } } ================================================ FILE: internal/xray/rs.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // ReplicaSet represents an xray renderer. type ReplicaSet struct{} // Render renders an xray node. func (r *ReplicaSet) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var rs appsv1.ReplicaSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &rs) if err != nil { return err } parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } root := NewTreeNode(client.RsGVR, client.FQN(rs.Namespace, rs.Name)) oo, err := locatePods(ctx, rs.Namespace, rs.Spec.Selector) if err != nil { return err } ctx = context.WithValue(ctx, KeyParent, root) var re Pod for _, o := range oo { p, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting *Unstructured but got %T", o) } if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { return err } } if root.IsLeaf() { return nil } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, rs.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(root) return r.validate(root, rs) } func (*ReplicaSet) validate(root *TreeNode, rs appsv1.ReplicaSet) error { root.Extras[StatusKey] = OkStatus var r int32 if rs.Spec.Replicas != nil { r = int32(*rs.Spec.Replicas) } a := rs.Status.Replicas if a != r { root.Extras[StatusKey] = ToastStatus } root.Extras[InfoKey] = fmt.Sprintf("%d/%d", a, r) return nil } ================================================ FILE: internal/xray/rs_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" ) func TestReplicaSetRender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "rs", level1: 1, level2: 1, status: xray.OkStatus, }, } var re xray.ReplicaSet for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { f := makeFactory() f.rows = map[*client.GVR][]runtime.Object{ client.PodGVR: {load(t, "po")}, } o := load(t, u.file) root := xray.NewTreeNode(client.RsGVR, "replicasets") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, f) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/sa.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // ServiceAccount represents an xray renderer. type ServiceAccount struct{} // Render renders an xray node. func (s *ServiceAccount) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("ServiceAccount render expecting *Unstructured, but got %T", o) } var sa v1.ServiceAccount err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sa) if err != nil { return err } f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return fmt.Errorf("no factory found in context") } node := NewTreeNode(client.SaGVR, client.FQN(sa.Namespace, sa.Name)) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } parent.Add(node) for _, sec := range sa.Secrets { addRef(f, node, client.SecGVR, client.FQN(sa.Namespace, sec.Name), nil) } for _, sec := range sa.ImagePullSecrets { addRef(f, node, client.SecGVR, client.FQN(sa.Namespace, sec.Name), nil) } auto, _ := ctx.Value(KeySAAutomount).(*bool) return s.validate(node, sa, auto) } func (*ServiceAccount) validate(node *TreeNode, sa v1.ServiceAccount, auto *bool) error { node.Extras[StatusKey] = OkStatus if sa.AutomountServiceAccountToken != nil { node.Extras[InfoKey] = fmt.Sprintf("automount=%t", *sa.AutomountServiceAccountToken) } if auto != nil { node.Extras[InfoKey] = fmt.Sprintf("automount=%t", *auto) } return nil } ================================================ FILE: internal/xray/sa_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSARender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "sa", level1: 1, level2: 2, status: xray.OkStatus, }, } var re xray.ServiceAccount for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.SaGVR, "serviceaccounts") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, makeFactory()) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/section.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" ) // Section represents an xray renderer. type Section struct { render.Base } // Render renders an xray node. func (s *Section) Render(ctx context.Context, ns string, o any) error { section, ok := o.(render.Section) if !ok { return fmt.Errorf("expected Section, but got %T", o) } root := NewTreeNode(client.NewGVR(section.GVR), section.Title) parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } s.outcomeRefs(root, section) parent.Add(root) return nil } func (*Section) outcomeRefs(parent *TreeNode, section render.Section) { for k, issues := range section.Outcome { p := NewTreeNode(client.NewGVR(section.GVR), cleanse(k)) parent.Add(p) for _, issue := range issues { msg := colorize(cleanse(issue.Message), issue.Level) c := NewTreeNode(client.NewGVR(fmt.Sprintf("issue_%d", issue.Level)), msg) if issue.Group == "__root__" { p.Add(c) continue } if pa := p.Find(client.NewGVR(issue.GVR), issue.Group); pa != nil { pa.Add(c) continue } pa := NewTreeNode(client.NewGVR(issue.GVR), issue.Group) pa.Add(c) p.Add(pa) } } } // ---------------------------------------------------------------------------- // Helpers... func colorize(s string, l render.Level) string { c := "green" // nolint:exhaustive switch l { case render.ErrorLevel: c = "red" case render.WarnLevel: c = "orange" case render.InfoLevel: c = "blue" } return fmt.Sprintf("[%s::]%s", c, s) } func cleanse(s string) string { s = strings.ReplaceAll(s, "[", "(") s = strings.ReplaceAll(s, "]", ")") s = strings.ReplaceAll(s, "/", "::") return s } ================================================ FILE: internal/xray/sts.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/render" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" ) // StatefulSet represents an xray renderer. type StatefulSet struct{} // Render renders an xray node. func (s *StatefulSet) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var sts appsv1.StatefulSet err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &sts) if err != nil { return err } parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } root := NewTreeNode(client.StsGVR, client.FQN(sts.Namespace, sts.Name)) oo, err := locatePods(ctx, sts.Namespace, sts.Spec.Selector) if err != nil { return err } ctx = context.WithValue(ctx, KeyParent, root) var re Pod for _, o := range oo { p, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting *Unstructured but got %T", o) } if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { return err } } if root.IsLeaf() { return nil } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, sts.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(root) return s.validate(root, sts) } func (*StatefulSet) validate(root *TreeNode, sts appsv1.StatefulSet) error { root.Extras[StatusKey] = OkStatus var r int32 if sts.Spec.Replicas != nil { r = int32(*sts.Spec.Replicas) } a := sts.Status.CurrentReplicas if a != r { root.Extras[StatusKey] = ToastStatus } root.Extras[InfoKey] = fmt.Sprintf("%d/%d", a, r) return nil } ================================================ FILE: internal/xray/sts_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" ) func TestStatefulSetRender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "sts", level1: 1, level2: 1, status: xray.OkStatus, }, } var re xray.StatefulSet for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { f := makeFactory() f.rows = map[*client.GVR][]runtime.Object{client.PodGVR: {load(t, "po")}} o := load(t, u.file) root := xray.NewTreeNode(client.StsGVR, "statefulsets") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, f) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/svc.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "context" "fmt" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/render" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) // Service represents an xray renderer. type Service struct{} // Render renders an xray node. func (s *Service) Render(ctx context.Context, ns string, o any) error { raw, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expected Unstructured, but got %T", o) } var svc v1.Service err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &svc) if err != nil { return err } parent, ok := ctx.Value(KeyParent).(*TreeNode) if !ok { return fmt.Errorf("expecting a TreeNode but got %T", ctx.Value(KeyParent)) } root := NewTreeNode(client.SvcGVR, client.FQN(svc.Namespace, svc.Name)) oo, err := s.locatePods(ctx, svc.Namespace, svc.Spec.Selector) if err != nil { return err } ctx = context.WithValue(ctx, KeyParent, root) var re Pod for _, o := range oo { p, ok := o.(*unstructured.Unstructured) if !ok { return fmt.Errorf("expecting *Unstructured but got %T", o) } if err := re.Render(ctx, ns, &render.PodWithMetrics{Raw: p}); err != nil { return err } } root.Extras[StatusKey] = OkStatus if root.IsLeaf() { return nil } gvr, nsID := client.NsGVR, client.FQN(client.ClusterScope, svc.Namespace) nsn := parent.Find(gvr, nsID) if nsn == nil { nsn = NewTreeNode(gvr, nsID) parent.Add(nsn) } nsn.Add(root) return nil } func (s *Service) locatePods(ctx context.Context, ns string, sel map[string]string) ([]runtime.Object, error) { f, ok := ctx.Value(internal.KeyFactory).(dao.Factory) if !ok { return nil, fmt.Errorf("expecting a factory but got %T", ctx.Value(internal.KeyFactory)) } ll := make([]string, 0, len(sel)) for k, v := range sel { ll = append(ll, fmt.Sprintf("%s=%s", k, v)) } fsel, err := labels.ConvertSelectorToLabelsMap(strings.Join(ll, ",")) if err != nil { return nil, err } return f.List(client.PodGVR, ns, false, fsel.AsSelector()) } ================================================ FILE: internal/xray/svc_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "context" "testing" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" ) func TestServiceRender(t *testing.T) { uu := map[string]struct { file string level1, level2 int status string }{ "plain": { file: "svc", level1: 1, level2: 1, status: xray.OkStatus, }, } var re xray.Service for k := range uu { f := makeFactory() f.rows = map[*client.GVR][]runtime.Object{client.PodGVR: {load(t, "po")}} u := uu[k] t.Run(k, func(t *testing.T) { o := load(t, u.file) root := xray.NewTreeNode(client.SvcGVR, "services") ctx := context.WithValue(context.Background(), xray.KeyParent, root) ctx = context.WithValue(ctx, internal.KeyFactory, f) require.NoError(t, re.Render(ctx, "", o)) assert.Equal(t, u.level1, root.CountChildren()) assert.Equal(t, u.level2, root.Children[0].CountChildren()) }) } } ================================================ FILE: internal/xray/testdata/cilium.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "creationTimestamp": "2020-01-21T00:06:31Z", "generateName": "cilium-operator-55658fb5c4-", "labels": { "io.cilium/app": "operator", "name": "cilium-operator", "pod-template-hash": "55658fb5c4" }, "name": "cilium-operator-55658fb5c4-rxtnl", "namespace": "kube-system", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "ReplicaSet", "name": "cilium-operator-55658fb5c4", "uid": "aa49a24b-e5b7-4349-88ce-c275ee36097c" } ], "resourceVersion": "1255", "selfLink": "/api/v1/namespaces/kube-system/pods/cilium-operator-55658fb5c4-rxtnl", "uid": "db060299-45c3-40c6-9a87-d8643f0d51e2" }, "spec": { "containers": [ { "args": [ "--debug=$(CILIUM_DEBUG)", "--identity-allocation-mode=$(CILIUM_IDENTITY_ALLOCATION_MODE)" ], "command": [ "cilium-operator" ], "env": [ { "name": "CILIUM_K8S_NAMESPACE", "valueFrom": { "fieldRef": { "apiVersion": "v1", "fieldPath": "metadata.namespace" } } }, { "name": "K8S_NODE_NAME", "valueFrom": { "fieldRef": { "apiVersion": "v1", "fieldPath": "spec.nodeName" } } }, { "name": "CILIUM_DEBUG", "valueFrom": { "configMapKeyRef": { "key": "debug", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_CLUSTER_NAME", "valueFrom": { "configMapKeyRef": { "key": "cluster-name", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_CLUSTER_ID", "valueFrom": { "configMapKeyRef": { "key": "cluster-id", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_IPAM", "valueFrom": { "configMapKeyRef": { "key": "ipam", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_DISABLE_ENDPOINT_CRD", "valueFrom": { "configMapKeyRef": { "key": "disable-endpoint-crd", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_KVSTORE", "valueFrom": { "configMapKeyRef": { "key": "kvstore", "name": "cilium-config", "optional": true } } }, { "name": "CILIUM_KVSTORE_OPT", "valueFrom": { "configMapKeyRef": { "key": "kvstore-opt", "name": "cilium-config", "optional": true } } }, { "name": "AWS_ACCESS_KEY_ID", "valueFrom": { "secretKeyRef": { "key": "AWS_ACCESS_KEY_ID", "name": "cilium-aws", "optional": true } } }, { "name": "AWS_SECRET_ACCESS_KEY", "valueFrom": { "secretKeyRef": { "key": "AWS_SECRET_ACCESS_KEY", "name": "cilium-aws", "optional": true } } }, { "name": "AWS_DEFAULT_REGION", "valueFrom": { "secretKeyRef": { "key": "AWS_DEFAULT_REGION", "name": "cilium-aws", "optional": true } } }, { "name": "CILIUM_IDENTITY_ALLOCATION_MODE", "valueFrom": { "configMapKeyRef": { "key": "identity-allocation-mode", "name": "cilium-config", "optional": true } } } ], "image": "docker.io/cilium/operator:v1.6.5", "imagePullPolicy": "IfNotPresent", "livenessProbe": { "failureThreshold": 3, "httpGet": { "path": "/healthz", "port": 9234, "scheme": "HTTP" }, "initialDelaySeconds": 60, "periodSeconds": 10, "successThreshold": 1, "timeoutSeconds": 3 }, "name": "cilium-operator", "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "cilium-operator-token-r9t5t", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "hostNetwork": true, "nodeName": "minikube", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "cilium-operator", "serviceAccountName": "cilium-operator", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "cilium-operator-token-r9t5t", "secret": { "defaultMode": 420, "secretName": "cilium-operator-token-r9t5t" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2020-01-21T00:07:39Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-21T00:07:47Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-21T00:07:47Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-21T00:07:39Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://9bc42a0d9395adafd9f8d922350c9029f8fa234060df9b03dd5e256804613f68", "image": "cilium/operator:v1.6.5", "imageID": "docker-pullable://cilium/operator@sha256:bcf273e7af15e7a0c9eb8df2f87fc81fe56323217ec8b2b35cd9cd5115920055", "lastState": {}, "name": "cilium-operator", "ready": true, "restartCount": 0, "started": true, "state": { "running": { "startedAt": "2020-01-21T00:07:46Z" } } } ], "hostIP": "192.168.64.7", "phase": "Running", "podIP": "192.168.64.7", "podIPs": [ { "ip": "192.168.64.7" } ], "qosClass": "BestEffort", "startTime": "2020-01-21T00:07:39Z" } } ================================================ FILE: internal/xray/testdata/dp.json ================================================ { "apiVersion": "apps/v1", "kind": "Deployment", "metadata": { "annotations": { "deployment.kubernetes.io/revision": "3", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"nginx\"},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"nginx\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"nginx\"}},\"spec\":{\"containers\":[{\"env\":[{\"name\":\"FRED\",\"valueFrom\":{\"configMapKeyRef\":{\"key\":\"fred\",\"name\":\"busy\"}}},{\"name\":\"PROPS\",\"valueFrom\":{\"configMapKeyRef\":{\"key\":\"props\",\"name\":\"busy\"}}}],\"image\":\"k8s.gcr.io/nginx-slim:0.8\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"200Mi\"}}}]}}}}\n" }, "creationTimestamp": "2020-01-16T04:18:04Z", "generation": 4, "labels": { "app": "nginx" }, "name": "nginx", "namespace": "default", "resourceVersion": "3338230", "selfLink": "/apis/apps/v1/namespaces/default/deployments/nginx", "uid": "a2baf77e-5301-4efd-ac40-ff3da9716c80" }, "spec": { "progressDeadlineSeconds": 600, "replicas": 1, "revisionHistoryLimit": 10, "selector": { "matchLabels": { "app": "nginx" } }, "strategy": { "rollingUpdate": { "maxSurge": "25%", "maxUnavailable": "25%" }, "type": "RollingUpdate" }, "template": { "metadata": { "creationTimestamp": null, "labels": { "app": "nginx" } }, "spec": { "containers": [ { "env": [ { "name": "FRED", "valueFrom": { "configMapKeyRef": { "key": "fred", "name": "busy" } } }, { "name": "PROPS", "valueFrom": { "configMapKeyRef": { "key": "props", "name": "busy" } } } ], "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "cpu": "100m", "memory": "200Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } } }, "status": { "availableReplicas": 1, "conditions": [ { "lastTransitionTime": "2020-01-16T14:52:45Z", "lastUpdateTime": "2020-01-16T14:52:45Z", "message": "Deployment has minimum availability.", "reason": "MinimumReplicasAvailable", "status": "True", "type": "Available" }, { "lastTransitionTime": "2020-01-18T01:20:50Z", "lastUpdateTime": "2020-01-18T01:20:50Z", "message": "ReplicaSet \"nginx-5bbc876d89\" has successfully progressed.", "reason": "NewReplicaSetAvailable", "status": "True", "type": "Progressing" } ], "observedGeneration": 4, "readyReplicas": 1, "replicas": 1, "updatedReplicas": 1 } } ================================================ FILE: internal/xray/testdata/ds.json ================================================ { "apiVersion": "apps/v1", "kind": "DaemonSet", "metadata": { "annotations": { "deprecated.daemonset.template.generation": "1", "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"DaemonSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"k8s-app\":\"fluentd-logging\"},\"name\":\"fluentd-elasticsearch\",\"namespace\":\"default\"},\"spec\":{\"selector\":{\"matchLabels\":{\"name\":\"fluentd-elasticsearch\"}},\"template\":{\"metadata\":{\"labels\":{\"name\":\"fluentd-elasticsearch\"}},\"spec\":{\"containers\":[{\"image\":\"fluentd\",\"name\":\"fluentd-elasticsearch\",\"resources\":{\"limits\":{\"memory\":\"200Mi\"},\"requests\":{\"cpu\":\"100m\",\"memory\":\"200Mi\"}},\"volumeMounts\":[{\"mountPath\":\"/var/log\",\"name\":\"varlog\"},{\"mountPath\":\"/var/lib/docker/containers\",\"name\":\"varlibdockercontainers\",\"readOnly\":true}]}],\"terminationGracePeriodSeconds\":1,\"tolerations\":[{\"effect\":\"NoSchedule\",\"key\":\"node-role.kubernetes.io/master\"}],\"volumes\":[{\"hostPath\":{\"path\":\"/var/log\"},\"name\":\"varlog\"},{\"hostPath\":{\"path\":\"/var/lib/docker/containers\"},\"name\":\"varlibdockercontainers\"}]}}}}\n" }, "creationTimestamp": "2020-01-18T14:43:04Z", "generation": 1, "labels": { "k8s-app": "fluentd-logging" }, "name": "fluentd-elasticsearch", "namespace": "default", "resourceVersion": "3450170", "selfLink": "/apis/apps/v1/namespaces/default/daemonsets/fluentd-elasticsearch", "uid": "8c03864a-a428-4769-b89c-11d66e01614d" }, "spec": { "revisionHistoryLimit": 10, "selector": { "matchLabels": { "name": "fluentd-elasticsearch" } }, "template": { "metadata": { "creationTimestamp": null, "labels": { "name": "fluentd-elasticsearch" } }, "spec": { "containers": [ { "image": "fluentd", "imagePullPolicy": "Always", "name": "fluentd-elasticsearch", "resources": { "limits": { "memory": "200Mi" }, "requests": { "cpu": "100m", "memory": "200Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/log", "name": "varlog" }, { "mountPath": "/var/lib/docker/containers", "name": "varlibdockercontainers", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 1, "tolerations": [ { "effect": "NoSchedule", "key": "node-role.kubernetes.io/master" } ], "volumes": [ { "hostPath": { "path": "/var/log", "type": "" }, "name": "varlog" }, { "hostPath": { "path": "/var/lib/docker/containers", "type": "" }, "name": "varlibdockercontainers" } ] } }, "updateStrategy": { "rollingUpdate": { "maxUnavailable": 1 }, "type": "RollingUpdate" } }, "status": { "currentNumberScheduled": 1, "desiredNumberScheduled": 1, "numberAvailable": 1, "numberMisscheduled": 0, "numberReady": 1, "observedGeneration": 1, "updatedNumberScheduled": 1 } } ================================================ FILE: internal/xray/testdata/init.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"hurry-up-and-wait\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"command\":[\"sh\",\"-c\",\"echo The app is running! \\u0026\\u0026 sleep 3600\"],\"image\":\"busybox\",\"name\":\"busy\",\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"100Mi\"}}}],\"initContainers\":[{\"command\":[\"sh\",\"-c\",\"echo \\\"sleeping...\\\"; sleep 10\"],\"image\":\"busybox\",\"name\":\"init-sleep\",\"resources\":{\"limits\":{\"cpu\":\"100m\",\"memory\":\"100Mi\"}}}]}}\n" }, "creationTimestamp": "2020-01-18T06:31:29Z", "name": "hurry-up-and-wait", "namespace": "default", "resourceVersion": "3381576", "selfLink": "/api/v1/namespaces/default/pods/hurry-up-and-wait", "uid": "6b29055a-433b-4398-bfde-0fd371759bbf" }, "spec": { "containers": [ { "command": [ "sh", "-c", "echo The app is running! \u0026\u0026 sleep 3600" ], "image": "busybox", "imagePullPolicy": "Always", "name": "busy", "resources": { "limits": { "cpu": "100m", "memory": "100Mi" }, "requests": { "cpu": "100m", "memory": "100Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-rr22g", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "initContainers": [ { "command": [ "sh", "-c", "echo \"sleeping...\"; sleep 10" ], "image": "busybox", "imagePullPolicy": "Always", "name": "init-sleep", "resources": { "limits": { "cpu": "100m", "memory": "100Mi" }, "requests": { "cpu": "100m", "memory": "100Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-rr22g", "readOnly": true } ] } ], "nodeName": "minikube", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 30, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "default-token-rr22g", "secret": { "defaultMode": 420, "secretName": "default-token-rr22g" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2020-01-18T06:31:42Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-18T06:31:44Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-18T06:31:44Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2020-01-18T06:31:29Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://3c4de1de5d3c8f78bcce5f65218d5cbe4ed7b7b86261dd74dcc0f96e832e7db3", "image": "busybox:latest", "imageID": "docker-pullable://busybox@sha256:6915be4043561d64e0ab0f8f098dc2ac48e077fe23f488ac24b665166898115a", "lastState": {}, "name": "busy", "ready": true, "restartCount": 0, "started": true, "state": { "running": { "startedAt": "2020-01-18T06:31:43Z" } } } ], "hostIP": "192.168.64.6", "initContainerStatuses": [ { "containerID": "docker://87f5d5f73827b402263ef77ca72b715c4ad858e7da71abc5655cc049e4c2ae20", "image": "busybox:latest", "imageID": "docker-pullable://busybox@sha256:6915be4043561d64e0ab0f8f098dc2ac48e077fe23f488ac24b665166898115a", "lastState": {}, "name": "init-sleep", "ready": true, "restartCount": 0, "state": { "terminated": { "containerID": "docker://87f5d5f73827b402263ef77ca72b715c4ad858e7da71abc5655cc049e4c2ae20", "exitCode": 0, "finishedAt": "2020-01-18T06:31:42Z", "reason": "Completed", "startedAt": "2020-01-18T06:31:32Z" } } } ], "phase": "Running", "podIP": "172.17.0.11", "podIPs": [ { "ip": "172.17.0.11" } ], "qosClass": "Guaranteed", "startTime": "2020-01-18T06:31:29Z" } } ================================================ FILE: internal/xray/testdata/ns.json ================================================ { "apiVersion": "v1", "kind": "Namespace", "metadata": { "creationTimestamp": "2019-12-31T20:49:23Z", "name": "default", "resourceVersion": "146", "selfLink": "/api/v1/namespaces/default", "uid": "3da8811c-7632-4a42-b4f5-608c21165ff7" }, "spec": { "finalizers": [ "kubernetes" ] }, "status": { "phase": "Active" } } ================================================ FILE: internal/xray/testdata/po.json ================================================ { "apiVersion": "v1", "kind": "Pod", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"image\":\"nginx:alpine\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}],\"volumeMounts\":[{\"mountPath\":\"/usr/share/nginx/html\",\"name\":\"index\"}]}],\"terminationGracePeriodSeconds\":0,\"volumes\":[{\"name\":\"index\",\"persistentVolumeClaim\":{\"claimName\":\"web\"}}]}}\n" }, "creationTimestamp": "2019-08-09T05:12:19Z", "name": "nginx", "namespace": "default", "resourceVersion": "1482816", "selfLink": "/api/v1/namespaces/default/pods/nginx", "uid": "614908ed-415b-4506-8370-e3e36fa8cc13" }, "spec": { "containers": [ { "image": "nginx:alpine", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "memory": "170Mi" }, "requests": { "cpu": "100m", "memory": "70Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "index" }, { "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", "name": "default-token-9ph8s", "readOnly": true } ] } ], "dnsPolicy": "ClusterFirst", "enableServiceLinks": true, "nodeName": "minikube", "priority": 0, "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "default", "serviceAccountName": "default", "terminationGracePeriodSeconds": 0, "tolerations": [ { "effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300 }, { "effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300 } ], "volumes": [ { "name": "index", "persistentVolumeClaim": { "claimName": "web" } }, { "name": "default-token-9ph8s", "secret": { "defaultMode": 420, "secretName": "default-token-9ph8s" } } ] }, "status": { "conditions": [ { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "Initialized" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "Ready" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:21Z", "status": "True", "type": "ContainersReady" }, { "lastProbeTime": null, "lastTransitionTime": "2019-08-09T05:12:19Z", "status": "True", "type": "PodScheduled" } ], "containerStatuses": [ { "containerID": "docker://421bd26d6c682f14b5ea1dcaf06e14a509b2b702fc7793e820520eb1e28e2eaf", "image": "nginx:alpine", "imageID": "docker-pullable://nginx@sha256:482ead44b2203fa32b3390abdaf97cbdc8ad15c07fb03a3e68d7c35a19ad7595", "lastState": {}, "name": "nginx", "ready": true, "restartCount": 0, "state": { "running": { "startedAt": "2019-08-09T05:12:20Z" } } } ], "hostIP": "192.168.64.104", "phase": "Running", "podIP": "172.17.0.6", "qosClass": "BestEffort", "startTime": "2019-08-09T05:12:19Z" } } ================================================ FILE: internal/xray/testdata/rs.json ================================================ { "apiVersion": "apps/v1", "kind": "ReplicaSet", "metadata": { "annotations": { "deployment.kubernetes.io/desired-replicas": "1", "deployment.kubernetes.io/max-replicas": "2", "deployment.kubernetes.io/revision": "2" }, "creationTimestamp": "2020-01-20T01:34:11Z", "generation": 1, "labels": { "app": "nginx-pv", "pod-template-hash": "6476d7d5c8" }, "name": "nginx-pv-6476d7d5c8", "namespace": "default", "ownerReferences": [ { "apiVersion": "apps/v1", "blockOwnerDeletion": true, "controller": true, "kind": "Deployment", "name": "nginx-pv", "uid": "68aa70ff-ff7c-4a67-8d4f-fc31ef27ec35" } ], "resourceVersion": "3743997", "selfLink": "/apis/apps/v1/namespaces/default/replicasets/nginx-pv-6476d7d5c8", "uid": "547a036d-94d9-4818-bd9e-ec2939019471" }, "spec": { "replicas": 1, "selector": { "matchLabels": { "app": "nginx-pv", "pod-template-hash": "6476d7d5c8" } }, "template": { "metadata": { "creationTimestamp": null, "labels": { "app": "nginx-pv", "pod-template-hash": "6476d7d5c8" } }, "spec": { "automountServiceAccountToken": true, "containers": [ { "env": [ { "name": "FRED", "valueFrom": { "configMapKeyRef": { "key": "fred", "name": "busy" } } }, { "name": "PROPS", "valueFrom": { "configMapKeyRef": { "key": "props", "name": "busy" } } } ], "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "protocol": "TCP" } ], "resources": { "limits": { "cpu": "100m", "memory": "200Mi" } }, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "volumeMounts": [ { "mountPath": "/usr/share/nginx/html", "name": "index" } ] } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "serviceAccount": "zorg", "serviceAccountName": "zorg", "terminationGracePeriodSeconds": 30, "volumes": [ { "name": "index", "persistentVolumeClaim": { "claimName": "web" } } ] } } }, "status": { "availableReplicas": 1, "fullyLabeledReplicas": 1, "observedGeneration": 1, "readyReplicas": 1, "replicas": 1 } } ================================================ FILE: internal/xray/testdata/sa.json ================================================ { "apiVersion": "v1", "kind": "ServiceAccount", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"ServiceAccount\",\"metadata\":{\"annotations\":{},\"name\":\"zorg\",\"namespace\":\"default\"},\"secrets\":[{\"name\":\"zorg\"}]}\n" }, "creationTimestamp": "2020-01-19T16:31:41Z", "name": "zorg", "namespace": "default", "resourceVersion": "3667084", "selfLink": "/api/v1/namespaces/default/serviceaccounts/zorg", "uid": "be8959a7-e324-4cfd-88c1-5fd45c028be6" }, "secrets": [ { "name": "zorg" }, { "name": "zorg-token-rhhzn" } ] } ================================================ FILE: internal/xray/testdata/sts.json ================================================ { "apiVersion": "apps/v1", "kind": "StatefulSet", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"StatefulSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"nginx-sts\"},\"name\":\"nginx-sts\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"nginx-sts\"}},\"serviceName\":\"nginx-sts\",\"template\":{\"metadata\":{\"labels\":{\"app\":\"nginx-sts\"}},\"spec\":{\"containers\":[{\"image\":\"k8s.gcr.io/nginx-slim:0.8\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80,\"name\":\"web\"}]}]}}}}\n" }, "creationTimestamp": "2020-01-15T06:48:21Z", "generation": 1, "labels": { "app": "nginx-sts" }, "name": "nginx-sts", "namespace": "default", "resourceVersion": "2946929", "selfLink": "/apis/apps/v1/namespaces/default/statefulsets/nginx-sts", "uid": "59c516cb-9fe4-4d7f-b7f4-479928506423" }, "spec": { "podManagementPolicy": "OrderedReady", "replicas": 2, "revisionHistoryLimit": 10, "selector": { "matchLabels": { "app": "nginx-sts" } }, "serviceName": "nginx-sts", "template": { "metadata": { "creationTimestamp": null, "labels": { "app": "nginx-sts" } }, "spec": { "containers": [ { "image": "k8s.gcr.io/nginx-slim:0.8", "imagePullPolicy": "IfNotPresent", "name": "nginx", "ports": [ { "containerPort": 80, "name": "web", "protocol": "TCP" } ], "resources": {}, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File" } ], "dnsPolicy": "ClusterFirst", "restartPolicy": "Always", "schedulerName": "default-scheduler", "securityContext": {}, "terminationGracePeriodSeconds": 30 } }, "updateStrategy": { "rollingUpdate": { "partition": 0 }, "type": "RollingUpdate" } }, "status": { "collisionCount": 0, "currentReplicas": 2, "currentRevision": "nginx-sts-688d57df8f", "observedGeneration": 1, "readyReplicas": 2, "replicas": 2, "updateRevision": "nginx-sts-688d57df8f", "updatedReplicas": 2 } } ================================================ FILE: internal/xray/testdata/svc.json ================================================ { "apiVersion": "v1", "kind": "Service", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"nodePort\":30805,\"port\":8080,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"nginx\"},\"type\":\"NodePort\"}}\n" }, "creationTimestamp": "2020-01-16T04:18:04Z", "name": "nginx", "namespace": "default", "resourceVersion": "3066081", "selfLink": "/api/v1/namespaces/default/services/nginx", "uid": "3dc94561-06ce-4e56-8002-7c4679203d5b" }, "spec": { "clusterIP": "10.96.10.89", "externalTrafficPolicy": "Cluster", "ports": [ { "nodePort": 30805, "port": 8080, "protocol": "TCP", "targetPort": 80 } ], "selector": { "app": "nginx" }, "sessionAffinity": "None", "type": "NodePort" }, "status": { "loadBalancer": {} } } ================================================ FILE: internal/xray/tree_node.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray import ( "fmt" "log/slog" "reflect" "sort" "strings" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/dao" "github.com/fvbommel/sortorder" ) const ( // KeyParent indicates a parent node context key. KeyParent TreeRef = "parent" // KeySAAutomount indicates whether an automount sa token is active or not. KeySAAutomount TreeRef = "automount" // PathSeparator represents a node path separator. PathSeparator = "::" // StatusKey status map key. StatusKey = "status" // InfoKey state map key. InfoKey = "info" // OkStatus stands for all is cool. OkStatus = "ok" // ToastStatus stands for a resource is not up to snuff // aka not running or incomplete. ToastStatus = "toast" // CompletedStatus stands for a completed resource. CompletedStatus = "completed" // MissingRefStatus stands for a non existing resource reference. MissingRefStatus = "noref" ) // ---------------------------------------------------------------------------- // TreeRef namespaces tree context values. type TreeRef string // ---------------------------------------------------------------------------- // NodeSpec represents a node resource specification. type NodeSpec struct { GVRs client.GVRs Paths, Statuses []string } // ParentGVR returns the parent GVR. func (s NodeSpec) ParentGVR() *client.GVR { if len(s.GVRs) > 1 { return s.GVRs[1] } return nil } // ParentPath returns the parent path. func (s NodeSpec) ParentPath() *string { if len(s.Paths) > 1 { return &s.Paths[1] } return nil } // GVR returns the current GVR. func (s NodeSpec) GVR() *client.GVR { return s.GVRs[0] } // Path returns the current path. func (s NodeSpec) Path() string { return s.Paths[0] } // Status returns the current status. func (s NodeSpec) Status() string { return s.Statuses[0] } // AsPath returns path hierarchy as string. func (s NodeSpec) AsPath() string { return strings.Join(s.Paths, PathSeparator) } // AsGVR returns a gvr hierarchy as string. func (s NodeSpec) AsGVR() string { ss := make([]string, 0, len(s.GVRs)) for _, gvr := range s.GVRs { ss = append(ss, gvr.R()) } return strings.Join(ss, PathSeparator) } // AsStatus returns a status hierarchy as string. func (s NodeSpec) AsStatus() string { return strings.Join(s.Statuses, PathSeparator) } // ---------------------------------------------------------------------------- // ChildNodes represents a collection of children nodes. type ChildNodes []*TreeNode // Len returns the list size. func (c ChildNodes) Len() int { return len(c) } // Swap swaps list values. func (c ChildNodes) Swap(i, j int) { c[i], c[j] = c[j], c[i] } // Less returns true if i < j. func (c ChildNodes) Less(i, j int) bool { id1, id2 := c[i].ID, c[j].ID return sortorder.NaturalLess(id1, id2) } // ---------------------------------------------------------------------------- // TreeNode represents a resource tree node. type TreeNode struct { GVR *client.GVR ID string Children ChildNodes Parent *TreeNode Extras map[string]string } // NewTreeNode returns a new instance. func NewTreeNode(gvr *client.GVR, id string) *TreeNode { return &TreeNode{ GVR: gvr, ID: id, Extras: map[string]string{StatusKey: OkStatus}, } } // CountChildren returns the children count. func (t *TreeNode) CountChildren() int { return len(t.Children) } // Count all the nodes from this node. func (t *TreeNode) Count(gvr *client.GVR) int { counter := 0 if t.GVR == gvr || gvr == client.NoGVR { counter++ } for _, c := range t.Children { counter += c.Count(gvr) } return counter } // Diff computes a tree diff. func (t *TreeNode) Diff(d *TreeNode) bool { if t == nil { return d != nil } if t.CountChildren() != d.CountChildren() { return true } if t.ID != d.ID || t.GVR != d.GVR || !reflect.DeepEqual(t.Extras, d.Extras) { return true } for i := 0; i < len(t.Children); i++ { if t.Children[i].Diff(d.Children[i]) { return true } } return false } // Sort sorts the tree nodes. func (t *TreeNode) Sort() { sort.Sort(t.Children) for _, c := range t.Children { c.Sort() } } // Spec returns this node specification. func (t *TreeNode) Spec() NodeSpec { var gvrs client.GVRs var paths, statuses []string for parent := t; parent != nil; parent = parent.Parent { gvrs = append(gvrs, parent.GVR) paths = append(paths, parent.ID) statuses = append(statuses, parent.Extras[StatusKey]) } return NodeSpec{ GVRs: gvrs, Paths: paths, Statuses: statuses, } } // Flatten returns a collection of node specs. func (t *TreeNode) Flatten() []NodeSpec { refs := make([]NodeSpec, 0, len(t.Children)) for _, c := range t.Children { if c.IsLeaf() { refs = append(refs, c.Spec()) continue } refs = append(refs, c.Flatten()...) } return refs } // Blank returns true if this node is unset. func (t *TreeNode) Blank() bool { return t.GVR == client.NoGVR && t.ID == "" } // Hydrate hydrates a full tree bases on a collection of specifications. func Hydrate(specs []NodeSpec) *TreeNode { root := NewTreeNode(client.NoGVR, "") nav := root for _, spec := range specs { for i := len(spec.Paths) - 1; i >= 0; i-- { if nav.Blank() { nav.GVR, nav.ID, nav.Extras[StatusKey] = spec.GVRs[i], spec.Paths[i], spec.Statuses[i] continue } c := NewTreeNode(spec.GVRs[i], spec.Paths[i]) c.Extras[StatusKey] = spec.Statuses[i] if n := nav.Find(spec.GVRs[i], spec.Paths[i]); n == nil { nav.Add(c) nav = c } else { nav = n } } nav = root } return root } // Level computes the current node level. func (t *TreeNode) Level() int { var level int p := t for p != nil { p = p.Parent level++ } return level - 1 } // MaxDepth computes the max tree depth. func (t *TreeNode) MaxDepth(depth int) int { max := depth for _, c := range t.Children { m := c.MaxDepth(depth + 1) if m > max { max = m } } return max } // Root returns the current tree root node. func (t *TreeNode) Root() *TreeNode { for p := t; p != nil; p = p.Parent { if p.Parent == nil { return p } } return nil } // IsLeaf returns true if node has no children. func (t *TreeNode) IsLeaf() bool { return t.CountChildren() == 0 } // IsRoot returns true if node is top node. func (t *TreeNode) IsRoot() bool { return t.Parent == nil } // ShallowClone performs a shallow node clone. func (t *TreeNode) ShallowClone() *TreeNode { return &TreeNode{GVR: t.GVR, ID: t.ID, Extras: t.Extras} } // Filter filters the node based on query. func (t *TreeNode) Filter(q string, filter func(q, path string) bool) *TreeNode { specs := t.Flatten() matches := make([]NodeSpec, 0, len(specs)) for _, s := range specs { if filter(q, s.AsPath()+s.AsStatus()) { matches = append(matches, s) } } if len(matches) == 0 { return nil } return Hydrate(matches) } // Add adds a new child node. func (t *TreeNode) Add(c *TreeNode) { c.Parent = t t.Children = append(t.Children, c) } // Clear delete all descendant nodes. func (t *TreeNode) Clear() { t.Children = []*TreeNode{} } // Find locates a node given a gvr/id spec. func (t *TreeNode) Find(gvr *client.GVR, id string) *TreeNode { if t.GVR == gvr && t.ID == id { return t } for _, c := range t.Children { if v := c.Find(gvr, id); v != nil { return v } } return nil } // Title computes the node title. func (t *TreeNode) Title(noIcons bool) string { return t.computeTitle(noIcons) } // ---------------------------------------------------------------------------- // Helpers... // Dump for debug... func (t *TreeNode) Dump() { dump(t, 0) } func dump(n *TreeNode, level int) { if n == nil { slog.Debug("NO DATA!!") return } slog.Debug(fmt.Sprintf("%s%s::%s\n", strings.Repeat(" ", level), n.GVR, n.ID)) for _, c := range n.Children { dump(c, level+1) } } // DumpStdOut to stdout for debug. func (t *TreeNode) DumpStdOut() { dumpStdOut(t, 0) } func dumpStdOut(n *TreeNode, level int) { if n == nil { fmt.Println("NO DATA!!") return } fmt.Printf("%s%s::%s\n", strings.Repeat(" ", level), n.GVR, n.ID) for _, c := range n.Children { dumpStdOut(c, level+1) } } func category(gvr *client.GVR) string { meta, err := dao.MetaAccess.MetaFor(gvr) if err != nil { return "" } return meta.SingularName } func (t TreeNode) computeTitle(noIcons bool) string { if !noIcons { return t.toEmojiTitle() } return t.toTitle() } const ( titleFmt = " [gray::-]%s/[white::b][%s::b]%s[::]" topTitleFmt = " [white::b][%s::b]%s[::]" toast = "TOAST" ) func (t TreeNode) toTitle() (title string) { _, n := client.Namespaced(t.ID) color, status := "white", "OK" if v, ok := t.Extras[StatusKey]; ok { switch v { case ToastStatus: color, status = "orangered", toast case MissingRefStatus: color, status = "orange", toast+"_REF" } } defer func() { if status != "OK" { title += fmt.Sprintf(" [gray::-][yellow:%s:b]%s[gray::-]", color, status) } }() categ := category(t.GVR) if categ == "" { title = fmt.Sprintf(topTitleFmt, color, n) } else { title = fmt.Sprintf(titleFmt, categ, color, n) } if !t.IsLeaf() { title += fmt.Sprintf("[white::d](%d[-::d])[-::-]", t.CountChildren()) } info, ok := t.Extras[InfoKey] if !ok { return } title += fmt.Sprintf(" [antiquewhite::][%s][::]", info) return } const colorFmt = "%s [%s::b]%s[::]" func (t TreeNode) toEmojiTitle() (title string) { _, n := client.Namespaced(t.ID) color, status := "white", "OK" if v, ok := t.Extras[StatusKey]; ok { switch v { case ToastStatus: color, status = "orangered", toast case MissingRefStatus: color, status = "orange", toast+"_REF" } } defer func() { if status != "OK" { title += fmt.Sprintf(" [gray::-][yellow:%s:b]%s[gray::-]", color, status) } }() title = fmt.Sprintf(colorFmt, toEmoji(t.GVR), color, n) if !t.IsLeaf() { title += fmt.Sprintf("[white::d](%d[-::d])[-::-]", t.CountChildren()) } info, ok := t.Extras[InfoKey] if !ok { return } title += fmt.Sprintf(" [antiquewhite::][%s][::]", info) return } func toEmoji(gvr *client.GVR) string { if e := v1Emoji(gvr); e != "" { return e } if e := appsEmoji(gvr); e != "" { return e } if e := issueEmoji(gvr.String()); e != "" { return e } switch gvr { case client.HpaGVR: return "♎️" case client.CrGVR, client.CrbGVR: return "👩‍" case client.RoGVR, client.RobGVR: return "👨🏻‍" case client.NpGVR: return "📕" case client.PdbGVR: return "🏷 " case client.PspGVR: return "👮‍♂️" case client.CoGVR: return "🐳" case client.NewGVR("report"): return "🧼" default: return "📎" } } func issueEmoji(gvr string) string { switch gvr { case "issue_0": return "👍" case "issue_1": return "🔊" case "issue_2": return "☣️ " case "issue_3": return "🧨" default: return "" } } func v1Emoji(gvr *client.GVR) string { switch gvr { case client.NsGVR: return "🗂 " case client.NodeGVR: return "🖥 " case client.PodGVR: return "🚛" case client.SvcGVR: return "💁‍♀️" case client.SaGVR: return "💳" case client.PvGVR: return "📚" case client.PvcGVR: return "🎟 " case client.SecGVR: return "🔒" case client.CmGVR: return "🗺 " default: return "" } } func appsEmoji(gvr *client.GVR) string { switch gvr { case client.DpGVR: return "🪂" case client.StsGVR: return "🎎" case client.DsGVR: return "😈" case client.RsGVR: return "👯‍♂️" default: return "" } } // EmojiInfo returns emoji help. func EmojiInfo() map[string]string { gvrs := []*client.GVR{ client.CoGVR, client.NsGVR, client.PodGVR, client.SvcGVR, client.SaGVR, client.PvGVR, client.PvcGVR, client.SecGVR, client.CmGVR, client.DpGVR, client.StsGVR, client.DsGVR, } m := make(map[string]string, len(gvrs)) for _, gvr := range gvrs { m[gvr.R()] = toEmoji(gvr) } return m } ================================================ FILE: internal/xray/tree_node_test.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package xray_test import ( "regexp" "strings" "testing" "github.com/derailed/k9s/internal/client" "github.com/derailed/k9s/internal/xray" "github.com/stretchr/testify/assert" ) func TestTreeNodeCount(t *testing.T) { uu := map[string]struct { root *xray.TreeNode e int }{ "simple": { root: root1(), e: 3, }, "complex": { root: root3(), e: 26, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.root.Count(client.NoGVR)) }) } } func TestTreeNodeFilter(t *testing.T) { uu := map[string]struct { q string root, e *xray.TreeNode }{ "filter_simple": { root: root1(), e: diff1(), q: "c1", }, "filter_complex": { root: root2(), e: diff2(), q: "c2", }, "filter_no_match": { root: root2(), e: nil, q: "bozo", }, "filter_all_match": { root: root2(), e: root2(), q: "", }, "filter_complex1": { root: root3(), e: diff3(), q: "coredns", }, } rx := func(q, path string) bool { rx := regexp.MustCompile(`(?i)` + q) tokens := strings.Split(path, "::") for _, t := range tokens { if rx.MatchString(t) { return true } } return false } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { filtered := u.root.Filter(u.q, rx) assert.Equal(t, u.e, filtered) }) } } func TestTreeNodeHydrate(t *testing.T) { threeOK := []string{"ok", "ok", "ok"} fiveOK := append(threeOK, "ok", "ok") uu := map[string]struct { spec []xray.NodeSpec e *xray.TreeNode }{ "flat_simple": { spec: []xray.NodeSpec{ { GVRs: []*client.GVR{client.CoGVR, client.PodGVR}, Paths: []string{"c1", "default/p1"}, Statuses: threeOK, }, { GVRs: []*client.GVR{client.CoGVR, client.PodGVR}, Paths: []string{"c2", "default/p1"}, Statuses: threeOK, }, }, e: root1(), }, "flat_complex": { spec: []xray.NodeSpec{ { GVRs: []*client.GVR{client.SecGVR, client.CoGVR, client.PodGVR}, Paths: []string{"s1", "c1", "default/p1"}, Statuses: threeOK, }, { GVRs: []*client.GVR{client.SecGVR, client.CoGVR, client.PodGVR}, Paths: []string{"s2", "c2", "default/p1"}, Statuses: threeOK, }, }, e: root2(), }, "complex1": { spec: []xray.NodeSpec{ { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"default/default-token-rr22g", "default/nginx-6b866d578b-c6tcn", "default/nginx", "-/default", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.CmGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/coredns", "kube-system/coredns-6955765f44-89q2p", "kube-system/coredns", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/coredns-token-5cq9j", "kube-system/coredns-6955765f44-89q2p", "kube-system/coredns", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.CmGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/coredns", "kube-system/coredns-6955765f44-r9j9t", "kube-system/coredns", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/coredns-token-5cq9j", "kube-system/coredns-6955765f44-r9j9t", "kube-system/coredns", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/default-token-thzt8", "kube-system/metrics-server-6754dbc9df-88bk4", "kube-system/metrics-server", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kube-system/nginx-ingress-token-kff5q", "kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55", "kube-system/nginx-ingress-controller", "-/kube-system", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kubernetes-dashboard/kubernetes-dashboard-token-d6rt4", "kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56", "kubernetes-dashboard/dashboard-metrics-scraper", "-/kubernetes-dashboard", "deployments"}, Statuses: fiveOK, }, { GVRs: []*client.GVR{client.SecGVR, client.PodGVR, client.DpGVR, client.NsGVR, client.DpGVR}, Paths: []string{"kubernetes-dashboard/kubernetes-dashboard-token-d6rt4", "kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d", "kubernetes-dashboard/kubernetes-dashboard", "-/kubernetes-dashboard", "deployments"}, Statuses: fiveOK, }, }, e: root3(), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { root := xray.Hydrate(u.spec) assert.Equal(t, u.e.Flatten(), root.Flatten()) }) } } func TestTreeNodeFlatten(t *testing.T) { uu := map[string]struct { root *xray.TreeNode e []xray.NodeSpec }{ "flat_simple": { root: root1(), e: []xray.NodeSpec{ { GVRs: []*client.GVR{client.CoGVR, client.PodGVR}, Paths: []string{"c1", "default/p1"}, Statuses: []string{"ok", "ok"}, }, { GVRs: []*client.GVR{client.CoGVR, client.PodGVR}, Paths: []string{"c2", "default/p1"}, Statuses: []string{"ok", "ok"}, }, }, }, "flat_complex": { root: root2(), e: []xray.NodeSpec{ { GVRs: []*client.GVR{client.SecGVR, client.CoGVR, client.PodGVR}, Paths: []string{"s1", "c1", "default/p1"}, Statuses: []string{"ok", "ok", "ok"}, }, { GVRs: []*client.GVR{client.SecGVR, client.CoGVR, client.PodGVR}, Paths: []string{"s2", "c2", "default/p1"}, Statuses: []string{"ok", "ok", "ok"}, }, }, }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { flat := u.root.Flatten() assert.Equal(t, u.e, flat) }) } } func TestTreeNodeDiff(t *testing.T) { uu := map[string]struct { n1, n2 *xray.TreeNode e bool }{ "blank": { n1: &xray.TreeNode{}, n2: &xray.TreeNode{}, }, "same": { n1: xray.NewTreeNode(client.PodGVR, "default/p1"), n2: xray.NewTreeNode(client.PodGVR, "default/p1"), }, } for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { assert.Equal(t, u.e, u.n1.Diff(u.n2)) }) } } func TestTreeNodeClone(t *testing.T) { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") n.Add(c1) c := n.ShallowClone() assert.Equal(t, n.GVR, c.GVR) } func TestTreeNodeRoot(t *testing.T) { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") c2 := xray.NewTreeNode(client.CoGVR, "c2") n.Add(c1) n.Add(c2) assert.Equal(t, 2, n.CountChildren()) assert.Equal(t, n, n.Root()) assert.True(t, n.IsRoot()) assert.False(t, n.IsLeaf()) assert.Equal(t, n, c1.Root()) assert.False(t, c1.IsRoot()) assert.Equal(t, n, c2.Root()) assert.True(t, c1.IsLeaf()) } func TestTreeNodeLevel(t *testing.T) { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") c2 := xray.NewTreeNode(client.CoGVR, "c2") n.Add(c1) n.Add(c2) assert.Equal(t, 0, n.Level()) assert.Equal(t, 1, c1.Level()) assert.Equal(t, 1, c2.Level()) } func TestTreeNodeMaxDepth(t *testing.T) { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") c2 := xray.NewTreeNode(client.CoGVR, "c2") n.Add(c1) n.Add(c2) assert.Equal(t, 1, n.MaxDepth(0)) } // ---------------------------------------------------------------------------- // Helpers... func root1() *xray.TreeNode { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") c2 := xray.NewTreeNode(client.CoGVR, "c2") n.Add(c1) n.Add(c2) return n } func diff1() *xray.TreeNode { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c1") n.Add(c1) return n } func root2() *xray.TreeNode { c1 := xray.NewTreeNode(client.CoGVR, "c1") s1 := xray.NewTreeNode(client.SecGVR, "s1") c1.Add(s1) c2 := xray.NewTreeNode(client.CoGVR, "c2") s2 := xray.NewTreeNode(client.SecGVR, "s2") c2.Add(s2) n := xray.NewTreeNode(client.PodGVR, "default/p1") n.Add(c1) n.Add(c2) return n } func diff2() *xray.TreeNode { n := xray.NewTreeNode(client.PodGVR, "default/p1") c1 := xray.NewTreeNode(client.CoGVR, "c2") n.Add(c1) s1 := xray.NewTreeNode(client.SecGVR, "s2") c1.Add(s1) return n } func root3() *xray.TreeNode { n := xray.NewTreeNode(client.DpGVR, "deployments") ns1 := xray.NewTreeNode(client.NsGVR, "-/default") n.Add(ns1) { d1 := xray.NewTreeNode(client.DpGVR, "default/nginx") ns1.Add(d1) { p1 := xray.NewTreeNode(client.PodGVR, "default/nginx-6b866d578b-c6tcn") d1.Add(p1) { s1 := xray.NewTreeNode(client.SecGVR, "default/default-token-rr22g") p1.Add(s1) } } } ns2 := xray.NewTreeNode(client.NsGVR, "-/kube-system") n.Add(ns2) { d2 := xray.NewTreeNode(client.DpGVR, "kube-system/coredns") ns2.Add(d2) { p2 := xray.NewTreeNode(client.PodGVR, "kube-system/coredns-6955765f44-89q2p") d2.Add(p2) { c1 := xray.NewTreeNode(client.CmGVR, "kube-system/coredns") p2.Add(c1) s2 := xray.NewTreeNode(client.SecGVR, "kube-system/coredns-token-5cq9j") p2.Add(s2) } p3 := xray.NewTreeNode(client.PodGVR, "kube-system/coredns-6955765f44-r9j9t") d2.Add(p3) { c2 := xray.NewTreeNode(client.CmGVR, "kube-system/coredns") p3.Add(c2) s3 := xray.NewTreeNode(client.SecGVR, "kube-system/coredns-token-5cq9j") p3.Add(s3) } } d3 := xray.NewTreeNode(client.DpGVR, "kube-system/metrics-server") ns2.Add(d3) { p3 := xray.NewTreeNode(client.PodGVR, "kube-system/metrics-server-6754dbc9df-88bk4") d3.Add(p3) { s4 := xray.NewTreeNode(client.SecGVR, "kube-system/default-token-thzt8") p3.Add(s4) } } d4 := xray.NewTreeNode(client.DpGVR, "kube-system/nginx-ingress-controller") ns2.Add(d4) { p4 := xray.NewTreeNode(client.PodGVR, "kube-system/nginx-ingress-controller-6fc5bcc8c9-cwp55") d4.Add(p4) { s5 := xray.NewTreeNode(client.SecGVR, "kube-system/nginx-ingress-token-kff5q") p4.Add(s5) } } } ns3 := xray.NewTreeNode(client.NsGVR, "-/kubernetes-dashboard") n.Add(ns3) { d5 := xray.NewTreeNode(client.DpGVR, "kubernetes-dashboard/dashboard-metrics-scraper") ns3.Add(d5) { p5 := xray.NewTreeNode(client.PodGVR, "kubernetes-dashboard/dashboard-metrics-scraper-7b64584c5c-c7b56") d5.Add(p5) { s6 := xray.NewTreeNode(client.SecGVR, "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4") p5.Add(s6) } } d6 := xray.NewTreeNode(client.DpGVR, "kubernetes-dashboard/kubernetes-dashboard") ns3.Add(d6) { p6 := xray.NewTreeNode(client.PodGVR, "kubernetes-dashboard/kubernetes-dashboard-79d9cd965-b4c7d") d6.Add(p6) { s6 := xray.NewTreeNode(client.SecGVR, "kubernetes-dashboard/kubernetes-dashboard-token-d6rt4") p6.Add(s6) } } } return n } func diff3() *xray.TreeNode { n := xray.NewTreeNode(client.DpGVR, "deployments") ns2 := xray.NewTreeNode(client.NsGVR, "-/kube-system") n.Add(ns2) { d2 := xray.NewTreeNode(client.DpGVR, "kube-system/coredns") ns2.Add(d2) { p2 := xray.NewTreeNode(client.PodGVR, "kube-system/coredns-6955765f44-89q2p") d2.Add(p2) { c1 := xray.NewTreeNode(client.CmGVR, "kube-system/coredns") p2.Add(c1) s2 := xray.NewTreeNode(client.SecGVR, "kube-system/coredns-token-5cq9j") p2.Add(s2) } p3 := xray.NewTreeNode(client.PodGVR, "kube-system/coredns-6955765f44-r9j9t") d2.Add(p3) { c2 := xray.NewTreeNode(client.CmGVR, "kube-system/coredns") p3.Add(c2) s3 := xray.NewTreeNode(client.SecGVR, "kube-system/coredns-token-5cq9j") p3.Add(s3) } } } return n } ================================================ FILE: main.go ================================================ // SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package main import ( "flag" "os" "github.com/derailed/k9s/cmd" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/klog/v2" ) func init() { klog.InitFlags(nil) var logFile string for i, a := range os.Args { if a == "--logFile" && i+1 < len(os.Args) { logFile = os.Args[i+1] break } } if logFile != "" { if err := flag.Set("log_file", logFile); err != nil { panic(err) } } if err := flag.Set("logtostderr", "false"); err != nil { panic(err) } if err := flag.Set("alsologtostderr", "false"); err != nil { panic(err) } if err := flag.Set("stderrthreshold", "fatal"); err != nil { panic(err) } if err := flag.Set("v", "-10"); err != nil { panic(err) } } func main() { cmd.Execute() } ================================================ FILE: plugins/README.md ================================================ # K9s community plugins K9s plugins extend the tool to provide additional functionality via actions to further help you observe or administer your Kubernetes clusters. Following is an example of some plugin files in this directory. Other files are not listed in this table. | Plugin-Name | Description | Available on Views | Shortcut | Kubectl plugin, external dependencies | |--------------------------------|-------------------------------------------------------------------------------------------|-------------------------------------|-------------|---------------------------------------------------------------------------------------| | ai-incident-investigation.yaml | Run AI investigation on application issues to find the root cause in seconds | all | Shift-h/o | [HolmesGPT](https://github.com/robusta-dev/holmesgpt) | | argocd.yaml | Perform argocd operation quickly | applications | Shift-r | [ArgoCD](https://argo-cd.readthedocs.io/en/stable/getting_started/) | | argo-workflows.yaml | View, watch, terminate and trigger argo workflows | workflows/workflowtemplates/cronworkflows | v/Shift-w/t/s | [Argo Workflows](https://argo-workflows.readthedocs.io/en/latest/) | | crd-wizard.yaml | Clear and intuitive interface for visualizing and exploring CR(D)s | applications | Shift-w | [crd-wizard](https://github.com/pehlicd/crd-wizard) | | debug-container.yaml | Add [ephemeral debug container](1)
([nicolaka/netshoot](2)) | containers | Shift-d | | | dive.yaml | Dive image layers | containers | d | [Dive](https://github.com/wagoodman/dive) | | dup.yaml | Duplicate, edit and Debug resources | all | Shift-d/e/v | [dup](https://github.com/vash/dup) | | external-secrets.yaml | Refresh external/push-secrets | externalsecrets/pushsecrets | Shift-R | [External Secrets](https://external-secrets.io) | | get-all-namespace-resources.yaml | List all namespace resources (using standard kubectl) | all | m | [kubectl](https://kubernetes.io/docs/tasks/tools/) | | get-all.yaml | get all resources in a namespace | all | g | [Krew](https://krew.sigs.k8s.io/), [ketall](https://github.com/corneliusweig/ketall/) | | helm-diff.yaml | Diff with previous revision / current revision | helm/history | Shift-D/Q | [helm-diff](https://github.com/databus23/helm-diff) | | job-suspend.yaml | Suspends a running cronjob | cronjobs | Ctrl-s | | | k3d-root-shell.yaml | Root shell to k3d container | containers | Shift-s | [jq](https://stedolan.github.io/jq/) | | keda-toggle.yaml | Enable/disable [keda](3) ScaledObject autoscaler | scaledobjects | Ctrl-N | | | kube-metrics.yaml | Visualize live pod/node metric graphs (Memory/CPU) | pods/nodes | m | [kube-metics](https://github.com/bakito/kube-metrics) | | log-stern.yaml | View resource logs using stern | pods | Ctrl-l | | | log-jq.yaml | View resource logs using jq | pods | Ctrl-j | kubectl-plugins/kubectl-jq | | log-bunyan.yaml | View pods, service, deployment logs using bunyan | pods, service, deployment | Ctrl-l | [Bunyan](https://www.npmjs.com/package/bunyan) | | log-full.yaml | get full logs from pod/container | pods/containers | Ctrl-l | | | pvc-debug-container.yaml | Add ephemeral debug container with pvc mounted | pods | s | kubectl | | resource-recommendations.yaml | View recommendations for CPU/Memory requests based on historical data | deployments/daemonsets/statefulsets | Shift-k | [Robusta KRR](https://github.com/robusta-dev/krr) | | szero.yaml | Temporarily scale down/up all deployments, statefulsets, and daemonsets | namespaces | Shift-d/u | [szero](https://github.com/jadolg/szero) | | trace-dns.yaml | Trace DNS resolution using Inspektor Gadget (4) | containers/pods/nodes | Shift-d | | | vector-dev-top.yaml | Run `vector top` in vector.dev container | pods/container | h | [vector top](https://vector.dev/highlights/2020-12-23-vector-top/) | | start-alpine.yaml | Starts a deployment for the `alpine:latest` docker image in the current namespace/context | deployments/pods | Ctrl-T | | [1]: https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/#ephemeral-container [2]: https://github.com/nicolaka/netshoot [3]: https://keda.sh/ [4]: https://inspektor-gadget.io/ ================================================ FILE: plugins/ai-incident-investigation.yaml ================================================ plugins: # Author: Pavan Gudiwada # Investigate incidents in your cluster to quickly find the root cause using HolmesGPT # Requires HolmesGPT to be installed and configured (https://github.com/robusta-dev/holmesgpt) on your system # Open any K9s view, then: # Shift+H to run an investigation with default ask command # Shift+O to customize the question before running an investigation. holmesgpt: shortCut: Shift-H description: Ask HolmesGPT scopes: - all command: bash background: false confirm: false args: - -c - | holmes ask "why is $NAME of $RESOURCE_NAME in -n $NAMESPACE not working as expected" echo "Press 'q' to exit" while : ; do read -n 1 k <&1 if [[ $k = q ]] ; then break fi done custom-holmesgpt: shortCut: Shift-Q description: Custom HolmesGPT Ask scopes: - all command: bash background: false confirm: false args: - -c - | INSTRUCTIONS="# Edit the line below. Lines starting with '#' will be ignored." DEFAULT_ASK_COMMAND="why is $NAME of $RESOURCE_NAME in -n $NAMESPACE not working as expected" QUESTION_FILE=$(mktemp) echo "$INSTRUCTIONS" > "$QUESTION_FILE" echo "$DEFAULT_ASK_COMMAND" >> "$QUESTION_FILE" # Open the line in the default text editor ${EDITOR:-nano} "$QUESTION_FILE" # Read the modified line, ignoring lines starting with '#' user_input=$(grep -v '^#' "$QUESTION_FILE") echo running: holmes ask "\"$user_input\"" holmes ask "$user_input" echo "Press 'q' to exit" while : ; do read -n 1 k <&1 if [[ $k = q ]] ; then break fi done ================================================ FILE: plugins/argo-rollouts-powershell.yaml ================================================ # Manage argo-rollouts from PowerShell # See https://argoproj.github.io/argo-rollouts/ # Get rollout details # Watch rollout progress #

(with confirmation) Promote rollout # (with confirmation) Restart rollout plugins: argo-rollouts-get: shortCut: g confirm: false description: Get details scopes: - rollouts command: powershell background: false args: - kubectl - argo - rollouts - get - rollout - $NAME - --context - $CONTEXT - -n - $NAMESPACE; - $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') argo-rollouts-watch: shortCut: w confirm: false description: Watch progress scopes: - rollouts command: powershell background: false args: - kubectl - argo - rollouts - get - rollout - $NAME - --context - $CONTEXT - -n - $NAMESPACE - -w; - $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') argo-rollouts-promote: shortCut: p confirm: true description: Promote scopes: - rollouts command: powershell background: false args: - kubectl - argo - rollouts - promote - $NAME - --context - $CONTEXT - -n - $NAMESPACE; - $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') argo-rollouts-restart: shortCut: r confirm: true description: Restart scopes: - rollouts command: powershell background: false args: - kubectl - argo - rollouts - restart - $NAME - --context - $CONTEXT - -n - $NAMESPACE; - $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') ================================================ FILE: plugins/argo-rollouts.yaml ================================================ # Manage argo-rollouts # See https://argoproj.github.io/argo-rollouts/ # Get rollout details # Watch rollout progress #

(with confirmation) Promote rollout # (with confirmation) Restart rollout plugins: argo-rollouts-get: shortCut: g confirm: false description: Get details scopes: - rollouts command: bash background: false args: - -c - kubectl argo rollouts get rollout $NAME --context $CONTEXT -n $NAMESPACE |& less argo-rollouts-watch: shortCut: w confirm: false description: Watch progress scopes: - rollouts command: bash background: false args: - -c - kubectl argo rollouts get rollout $NAME --context $CONTEXT -n $NAMESPACE -w argo-rollouts-promote: shortCut: p confirm: true description: Promote scopes: - rollouts command: bash background: false args: - -c - kubectl argo rollouts promote $NAME --context $CONTEXT -n $NAMESPACE |& less argo-rollouts-restart: shortCut: r confirm: true description: Restart scopes: - rollouts command: bash background: false args: - -c - kubectl argo rollouts restart $NAME --context $CONTEXT -n $NAMESPACE |& less ================================================ FILE: plugins/argo-workflows.yaml ================================================ # Plugin to interact with argo workflows directly from k9s. # As the plugin acts as a wrapper around the argo (workflows) CLI, it must be installed for # the plugin to work. To install it, see https://github.com/argoproj/argo-workflows/releases/ plugins: view-workflow: shortCut: v confirm: false description: view scopes: - workflows command: sh background: false args: - -c - | argo -n $NAMESPACE get $NAME echo -n "\nPress enter to return to k9s..." && read _ watch-workflow: shortCut: Shift-W confirm: false description: watch scopes: - workflows command: sh background: false args: - -c - "watch -n 3 -q 5 argo -n $NAMESPACE get $NAME" terminate-workflow: shortCut: t confirm: true description: terminate scopes: - workflows command: argo background: false args: - -n - $NAMESPACE - terminate - $NAME submit-from: shortCut: s confirm: true description: submit-as-workflow scopes: - workflowtemplates - cronworkflows command: argo background: false args: - -n - $NAMESPACE - submit - --from - $RESOURCE_NAME/$NAME ================================================ FILE: plugins/argocd.yaml ================================================ plugins: argocd: shortCut: "s" description: Sync ArgoCD Application scopes: - application command: argocd args: - app - sync - $NAME - --app-namespace - $NAMESPACE background: true confirm: true refresh-apps: shortCut: Shift-R confirm: false scopes: - apps description: Refresh a argocd app hard command: bash background: false args: - -c - "kubectl annotate applications -n argocd $NAME argocd.argoproj.io/refresh=hard" disable-auto-sync: shortCut: Shift-J confirm: false scopes: - apps description: Disable argocd sync command: kubectl background: false args: - patch - applications - -n - argocd - $NAME - "--type=json" - '-p=[{"op":"replace", "path": "/spec/syncPolicy", "value": {}}]' enable-auto-sync: shortCut: Shift-B confirm: false scopes: - apps description: Enable argocd sync command: kubectl background: false args: - patch - applications - -n - argocd - $NAME - --type=merge - '-p={"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true},"syncOptions":["ApplyOutOfSyncOnly=true","CreateNamespace=true","PruneLast=true","PrunePropagationPolicy=foreground"]}}}' ================================================ FILE: plugins/blame.yaml ================================================ plugins: # kubectl-blame by knight42 # Annotate each line in the given resource's YAML with information from the managedFields to show who last modified the field. # Source: https://github.com/knight42/kubectl-blame # Install via: # krew: `kubectl krew install blame` # go: `go install github.com/knight42/kubectl-blame@latest` blame: shortCut: b confirm: false description: "Blame" scopes: - all command: sh background: false args: - -c - "kubectl-blame $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT | less" ================================================ FILE: plugins/carvel.yaml ================================================ # $HOME/.k9s/plugin.yml plugins: kapp-inspect: shortCut: Shift-Z confirm: false description: Kapp inspect scopes: - app command: bash background: false args: - -c - "export FORCE_COLOR=1;kapp inspect -a $NAME.app --namespace $NAMESPACE --kubeconfig-context $CONTEXT --color --tty | less -RK" kctrl-app-status: shortCut: Shift-Q confirm: false description: kctrl app status scopes: - app command: bash background: false args: - -c - "export FORCE_COLOR=1;kctrl app status -a $NAME --namespace $NAMESPACE --kubeconfig-context $CONTEXT --color --tty | less -RK" kctrl-app-pause: shortCut: Shift-T confirm: false description: kctrl app pause scopes: - app command: bash background: false args: - -c - "export FORCE_COLOR=1;kctrl app pause -a $NAME --namespace $NAMESPACE --kubeconfig-context $CONTEXT --yes --color --tty | less -RK" kctrl-app-kick: shortCut: Shift-K confirm: false description: kctrl app kick scopes: - app command: bash background: false args: - -c - "export FORCE_COLOR=1;kctrl app kick -a $NAME --namespace $NAMESPACE --kubeconfig-context $CONTEXT --yes --color --tty | less -RK" ================================================ FILE: plugins/cert-manager.yaml ================================================ # Manage cert-manager Certificate resources via cmctl. # See: https://github.com/cert-manager/cmctl plugins: cert-status: shortCut: Shift-S confirm: false description: Certificate status scopes: - certificates command: bash background: false args: - -c - "cmctl status certificate --context $CONTEXT -n $NAMESPACE $NAME |& less" cert-renew: shortCut: Shift-R confirm: false description: Certificate renew scopes: - certificates command: bash background: false args: - -c - "cmctl renew --context $CONTEXT -n $NAMESPACE $NAME |& less" secret-inspect: shortCut: Shift-I confirm: false description: Inspect secret scopes: - secrets command: bash background: false args: - -c - "cmctl inspect secret --context $CONTEXT -n $NAMESPACE $NAME |& less" ================================================ FILE: plugins/crd-wizard.yaml ================================================ # See: https://github.com/pehlicd/crd-wizard plugins: crd-wizard: shortCut: Shift-W description: CRD Wizard dangerous: false scopes: - crds command: bash background: false confirm: false args: - -c - "crd-wizard tui --context $CONTEXT --kind $COL-KIND" ================================================ FILE: plugins/crossplane.yaml ================================================ plugins: # crossplane-trace list all the relationships with a resource (Claim, Composite, or Managed Resource) # Requires 'crossplane' cli binary installed crossplane-trace: shortCut: t confirm: false description: "Crossplane Trace" scopes: - all command: sh background: false args: - -c - | if [ -n "$NAMESPACE" ]; then crossplane beta trace --context $CONTEXT -n $NAMESPACE $RESOURCE_NAME.$RESOURCE_GROUP $NAME -owide | less -K else crossplane beta trace --context $CONTEXT $RESOURCE_NAME.$RESOURCE_GROUP $NAME -owide | less -K fi # crossplane-watch requires 'crossplane' cli and 'viddy' binaries installed # 'viddy' is a modern implementation of 'watch' command written in rust. Read more on https://github.com/sachaos/viddy. crossplane-watch: shortCut: w confirm: false description: "Crossplane Watch" scopes: - all command: sh background: false args: - -c - | if [ -n "$NAMESPACE" ]; then viddy -pw 'crossplane beta trace --context $CONTEXT -n $NAMESPACE $RESOURCE_NAME.$RESOURCE_GROUP $NAME -owide' else viddy -pw 'crossplane beta trace --context $CONTEXT $RESOURCE_NAME.$RESOURCE_GROUP $NAME -owide' fi ================================================ FILE: plugins/current-ctx-terminal.yaml ================================================ plugins: open-terminal: shortCut: Ctrl-T confirm: false description: Open a terminal in the current context scopes: - all command: /usr/bin/sh background: false args: - -c - bash -c "kubectl config use-context $CONTEXT && echo -e \"\e[1;42mk9s bash terminal.\nCtrl + d or 'exit' to go back to k9s\e[0m\" && bash" # New window for terminal can be opened with any emulator #- x-terminal-emulator -e bash -c "kubectl config use-context $CONTEXT && echo -e \"\e[1;42mk9s bash terminal.\nCtrl + d or 'exit' to go back to k9s\e[0m\" && bash" # example with tilix: #- tilix -e bash -c "kubectl config use-context $CONTEXT && echo -e \"\e[1;42mk9s bash terminal.\nCtrl + d or 'exit' to go back to k9s\e[0m\" && bash" ================================================ FILE: plugins/debug-container.yaml ================================================ plugins: #--- Create debug container for selected pod in current namespace # See https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/#ephemeral-container debug: shortCut: Shift-D description: Add debug container dangerous: true scopes: - containers command: bash background: false confirm: true inputs: - name: image label: Debug image type: dropdown required: true default: nicolaka/netshoot:v0.15 options: - nicolaka/netshoot:v0.15 - busybox:1.37 - alpine:3.23 - ubuntu:26.04 - name: profile label: Debug profile type: dropdown required: true default: sysadmin options: - general - baseline - restricted - netadmin - sysadmin - legacy - name: share_processes label: Share processes type: bool required: true default: true args: - -c - >- kubectl debug -it --context $CONTEXT -n=$NAMESPACE $POD --target=$NAME --image=$INPUT_IMAGE --profile=$INPUT_PROFILE $([ "$INPUT_SHARE_PROCESSES" = "true" ] && echo "--share-processes") -- sh ================================================ FILE: plugins/dive.yaml ================================================ plugins: dive: shortCut: d confirm: false description: "Dive image" scopes: - containers command: dive background: false args: - $COL-IMAGE ================================================ FILE: plugins/dup.yaml ================================================ plugins: dup: shortCut: Shift-D description: Duplicate scopes: - all command: bash background: false confirm: true args: - -c - "kubectl dup -k $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_edit: # Prompted to edit a new duplicate resource shortCut: Shift-E description: Duplicate + Edit scopes: - all command: bash background: false confirm: true args: - -c - "kubectl dup $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_debug_pods: # Spawn a resource with no readiness probes and infinite running command. shortCut: Shift-V description: Debug scopes: - pods command: bash background: true confirm: true args: - -c - "kubectl dup -kdl $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_debug_deploy: # Spawn a resource with no readiness probes and infinite running command. shortCut: Shift-V description: Debug scopes: - deployments command: bash background: true confirm: true args: - -c - "kubectl dup -kdl $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_debug_sts: # Spawn a resource with no readiness probes and infinite running command. shortCut: Shift-V description: Debug scopes: - statefulsets command: bash background: true confirm: true args: - -c - "kubectl dup -kdl $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_debug_cronjob: # Spawn a resource with no readiness probes and infinite running command. shortCut: Shift-V description: Debug scopes: - cronjobs command: bash background: true confirm: true args: - -c - "kubectl dup -kdl $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" dup_debug_jobs: # Spawn a resource with no readiness probes and infinite running command. shortCut: Shift-V description: Debug scopes: - jobs command: bash background: true confirm: true args: - -c - "kubectl dup -kdl $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT" ================================================ FILE: plugins/duplik8s.yaml ================================================ # Duplicate Pods, Deployments and StatefulSet for easy debugging # and troubleshooting. # # See https://github.com/Telemaco019/duplik8s plugins: duplicate: shortCut: Ctrl-B description: Duplicate resource scopes: - po - deploy - statefulset command: kubectl background: true args: - duplicate - $RESOURCE_NAME - $NAME - -n - $NAMESPACE - --context - $CONTEXT ================================================ FILE: plugins/eks-node-viewer.yaml ================================================ # plugin to easily open eks-node-viewer on viewed context # requires eks-node-viewer installed on system # https://github.com/awslabs/eks-node-viewer/ plugins: eks-node-viewer: shortCut: Shift-X description: "eks-node-viewer" scopes: - node background: false command: bash args: - -c - | env $(kubectl config view --context $CONTEXT --minify -o json | jq -r ".users[0].user.exec.env[] | select(.name == \"AWS_PROFILE\") | \"AWS_PROFILE=\" + .value" && kubectl config view --context $CONTEXT --minify -o json | jq -r ".users[0].user.exec.args | \"AWS_REGION=\" + .[1]") eks-node-viewer --context $CONTEXT --resources cpu,memory --extra-labels karpenter.sh/nodepool,eks-node-viewer/node-age --node-sort=creation=dsc ================================================ FILE: plugins/external-secrets.yaml ================================================ plugins: refresh-external-secrets: shortCut: Shift-R confirm: false scopes: - externalsecrets description: Refresh the externalsecret command: bash background: true args: - -c - "kubectl annotate externalsecrets.external-secrets.io --context $CONTEXT -n $NAMESPACE $NAME force-sync=$(date +%s) --overwrite" refresh-push-secrets: shortCut: Shift-R confirm: false scopes: - pushsecrets description: Refresh the pushsecret command: bash background: true args: - -c - "kubectl annotate pushsecrets.external-secrets.io --context $CONTEXT -n $NAMESPACE $NAME force-sync=$(date +%s) --overwrite" ================================================ FILE: plugins/flux.yaml ================================================ # $HOME/.k9s/plugin.yml # move selected line to chosen resource in K9s, then: # Shift-T (with confirmation) to toggle helm releases or kustomizations suspend and resume # Shift-R (no confirmation) to reconcile a git source or a helm release or a kustomization plugins: toggle-helmrelease: shortCut: Shift-T confirm: true scopes: - helmreleases description: Toggle to suspend or resume a HelmRelease command: bash background: false args: - -c - >- suspended=$(kubectl --context $CONTEXT get helmreleases -n $NAMESPACE $NAME -o=custom-columns=TYPE:.spec.suspend | tail -1); verb=$([ $suspended = "true" ] && echo "resume" || echo "suspend"); flux $verb helmrelease --context $CONTEXT -n $NAMESPACE $NAME | less -K toggle-kustomization: shortCut: Shift-T confirm: true scopes: - kustomizations description: Toggle to suspend or resume a Kustomization command: bash background: false args: - -c - >- suspended=$(kubectl --context $CONTEXT get kustomizations -n $NAMESPACE $NAME -o=custom-columns=TYPE:.spec.suspend | tail -1); verb=$([ $suspended = "true" ] && echo "resume" || echo "suspend"); flux $verb kustomization --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-git: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - gitrepositories command: bash background: false args: - -c - >- flux reconcile source git --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-hr: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - helmreleases command: bash background: false args: - -c - >- flux reconcile helmrelease --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-helm-repo: shortCut: Shift-Z description: Flux reconcile scopes: - helmrepositories command: bash background: false confirm: false args: - -c - >- flux reconcile source helm --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-oci-repo: shortCut: Shift-Z description: Flux reconcile scopes: - ocirepositories command: bash background: false confirm: false args: - -c - >- flux reconcile source oci --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-ks: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - kustomizations command: bash background: false args: - -c - >- flux reconcile kustomization --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-ir: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - imagerepositories command: sh background: false args: - -c - >- flux reconcile image repository --context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-iua: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - imageupdateautomations command: sh background: false args: - -c - >- flux reconcile image update --context $CONTEXT -n $NAMESPACE $NAME | less -K toggle-rset: shortCut: Shift-T confirm: false scopes: - resourcesets description: Toggle to suspend or resume a ResourceSet command: bash background: false args: - -c - >- reconcile=$(kubectl --context $CONTEXT get resourceset -n $NAMESPACE $NAME -o=custom-columns='TYPE:.metadata.annotations.fluxcd\.controlplane\.io/reconcile' | tail -1); verb=$([ $reconcile = "disabled" ] && echo "resume" || echo "suspend"); flux-operator $verb rset --kube-context $CONTEXT -n $NAMESPACE $NAME | less -K toggle-inputprovider: shortCut: Shift-T confirm: false scopes: - resourcesetinputprovider description: Toggle to suspend or resume an InputProvider command: bash background: false args: - -c - >- reconcile=$(kubectl --context $CONTEXT get resourcesetinputprovider -n $NAMESPACE $NAME -o=custom-columns='TYPE:.metadata.annotations.fluxcd\.controlplane\.io/reconcile' | tail -1); verb=$([ $reconcile = "disabled" ] && echo "resume" || echo "suspend"); flux-operator $verb inputprovider --kube-context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-rset: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - resourcesets command: bash background: false args: - -c - >- flux-operator reconcile rset --kube-context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-inputprovider: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - resources command: bash background: false args: - -c - >- flux-operator reconcile inputprovider --kube-context $CONTEXT -n $NAMESPACE $NAME | less -K reconcile-fluxinstance: shortCut: Shift-R confirm: false description: Flux reconcile scopes: - fluxinstances command: bash background: false args: - -c - >- flux-operator reconcile instance --kube-context $CONTEXT -n $NAMESPACE $NAME | less -K trace: shortCut: Shift-Q confirm: false description: Flux trace scopes: - all command: bash background: false args: - -c - >- if [ -n "$RESOURCE_GROUP" ]; then api_endpoint="/apis/$RESOURCE_GROUP/$RESOURCE_VERSION"; else api_endpoint="/api/$RESOURCE_VERSION"; fi; api_resource=$(kubectl get --raw "${api_endpoint}" | jq -r ".resources[] | select(.name==\"$RESOURCE_NAME\")"); kind=$(echo ${api_resource} | jq -r '.kind'); namespace_arg=$(echo ${api_resource} | jq -r "if .namespaced == true then \"--namespace $NAMESPACE\" else \"\" end"); [ -n "$RESOURCE_GROUP" ] && api_version=$RESOURCE_GROUP/; api_version=${api_version}$RESOURCE_VERSION; flux trace --context $CONTEXT --kind ${kind} --api-version ${api_version} ${namespace_arg} $NAME |& less -K # credits: https://github.com/fluxcd/flux2/discussions/2494 get-suspended-helmreleases: shortCut: Shift-S confirm: false description: Suspended Helm Releases scopes: - helmrelease command: sh background: false args: - -c - >- kubectl get --context $CONTEXT --all-namespaces helmreleases.helm.toolkit.fluxcd.io -o json | jq -r '.items[] | select(.spec.suspend==true) | [.metadata.namespace,.metadata.name,.spec.suspend] | @tsv' | less -K get-suspended-kustomizations: shortCut: Shift-S confirm: false description: Suspended Kustomizations scopes: - kustomizations command: sh background: false args: - -c - >- kubectl get --context $CONTEXT --all-namespaces kustomizations.kustomize.toolkit.fluxcd.io -o json | jq -r '.items[] | select(.spec.suspend==true) | [.metadata.name,.spec.suspend] | @tsv' | less -K ================================================ FILE: plugins/get-all-namespace-resources.yaml ================================================ plugins: get-all-resources-by-selected-namespace: shortCut: m confirm: false description: List all resources of the selected namespace scopes: - namespaces command: sh background: false args: - -c - 'for r in $(kubectl api-resources --verbs=list --namespaced -o name); do out=$(kubectl get --ignore-not-found --show-kind -n $NAME $r 2>/dev/null); if [ -n "$out" ]; then echo "$out"; echo ""; fi; done | less' get-all-resources-in-current-namespace: shortCut: m confirm: false description: List all resources of the current namespace scopes: - configmaps - controllerrevisions - daemonsets - deployments - endpoints - endpointslices - events - horizontalpodautoscalers - ingresses - jobs - leases - limitranges - networkpolicies - persistentvolumeclaims - poddisruptionbudgets - pods - replicasets - replicationcontrollers - resourcequotas - rolebindings - roles - secrets - serviceaccounts - services - statefulsets command: sh background: false args: - -c - 'for r in $(kubectl api-resources --verbs=list --namespaced -o name); do out=$(kubectl get --ignore-not-found --show-kind -n $NAMESPACE $r 2>/dev/null); if [ -n "$out" ]; then echo "$out"; echo ""; fi; done | less' ================================================ FILE: plugins/get-all.yaml ================================================ plugins: #get all resources in a namespace using the krew get-all plugin get-all-namespace: shortCut: g confirm: false description: get-all scopes: - namespaces command: sh background: false args: - -c - "kubectl get-all --context $CONTEXT -n $NAME | less -K" get-all-other: shortCut: g confirm: false description: get-all scopes: - all command: sh background: false args: - -c - "kubectl get-all --context $CONTEXT -n $NAMESPACE | less -K" ================================================ FILE: plugins/helm-default-values.yaml ================================================ plugins: helm-default-values: shortCut: Shift-V confirm: false description: Chart Default Values scopes: - helm command: sh background: false args: - -c - >- revision=$(helm history -n $NAMESPACE --kube-context $CONTEXT $COL-NAME | grep deployed | cut -d$'\t' -f1 | tr -d ' \t'); kubectl get secrets --context $CONTEXT -n $NAMESPACE sh.helm.release.v1.$COL-NAME.v$revision -o yaml | yq e '.data.release' - | base64 -d | base64 -d | gunzip | jq -r '.chart.values' | yq -P | less -K ================================================ FILE: plugins/helm-diff.yaml ================================================ # Requires helm-diff plugin installed: https://github.com/databus23/helm-diff # In helm view: Diff with Previous Revision # In helm-history view: Diff with Current Revision plugins: helm-diff-previous: shortCut: Shift-D confirm: false description: Diff with Previous Revision scopes: - helm command: bash background: false args: - -c - >- LAST_REVISION=$(($COL-REVISION-1)); helm diff revision $COL-NAME $COL-REVISION $LAST_REVISION --kube-context $CONTEXT --namespace $NAMESPACE --color | less -RK helm-diff-current: shortCut: Shift-Q confirm: false description: Diff with Current Revision scopes: - history command: bash background: false args: - -c - >- RELEASE_NAME=$(echo $NAME | cut -d':' -f1); LATEST_REVISION=$(helm history -n $NAMESPACE --kube-context $CONTEXT $RELEASE_NAME | grep deployed | cut -d$'\t' -f1 | tr -d ' \t'); helm diff revision $RELEASE_NAME $LATEST_REVISION $COL-REVISION --kube-context $CONTEXT --namespace $NAMESPACE --color | less -RK ================================================ FILE: plugins/helm-purge.yaml ================================================ # $HOME/.k9s/plugin.yml plugins: # Issues a helm delete --purge for the resource associated with the selected pod # Requires https://github.com/derailed/k9s/blob/master/plugins/kubectl/kubectl-purge helm-purge: shortCut: Ctrl-P description: Helm Purge dangerous: true scopes: - po command: kubectl background: true args: - purge - $NAMESPACE - $NAME ================================================ FILE: plugins/helm-values.yaml ================================================ # View user-supplied values when the helm chart was created plugins: helm-values: shortCut: v confirm: false description: Values scopes: - helm command: sh background: false args: - -c - "helm get values $COL-NAME -n $NAMESPACE --kube-context $CONTEXT | less -K" ================================================ FILE: plugins/job-suspend.yaml ================================================ plugins: # Suspends/Resumes a cronjob toggleCronjob: shortCut: Ctrl-S confirm: true dangerous: true scopes: - cj description: Toggle to suspend or resume a running cronjob command: kubectl background: true args: - patch - cronjobs - $NAME - -n - $NAMESPACE - --context - $CONTEXT - -p - '{"spec" : {"suspend" : $!COL-SUSPEND }}' ================================================ FILE: plugins/k3d-root-shell.yaml ================================================ plugins: # Opens a shell to k3d container as root k3d-root-shell: shortCut: Shift-S confirm: false dangerous: true description: "Root Shell" scopes: - containers command: bash background: false args: - -c - | # Check dependencies command -v jq >/dev/null || { echo -e "jq is not installed (https://stedolan.github.io/jq/)\nPress 'q' to close" | less; exit 1; } # Extract node name and container ID POD_DATA="$(kubectl get pod/$POD -o json --namespace $NAMESPACE --context $CONTEXT)" # ${...} is used to prevent variable substitution by k9s (e.g. $POD_DATA) NODE_NAME=$(echo "${POD_DATA}" | jq -r '.spec.nodeName') CONTAINER_ID=$(echo "${POD_DATA}" | jq -r '.status.containerStatuses[] | select(.name == "$COL-NAME") | .containerID ' | grep -oP '(?<=containerd://).*') echo "<> Pod: $NAMESPACE/$POD | Container: $COL-NAME (${NODE_NAME}/${CONTAINER_ID})" # Credits for this approach to https://gist.github.com/mamiu/4944e10305bc1c3af84946b33237b0e9 docker exec -it $NODE_NAME sh -c "runc --root /run/containerd/runc/k8s.io/ exec -t -u 0 ${CONTAINER_ID} sh" ================================================ FILE: plugins/keda-toggle.yaml ================================================ plugins: toggle-keda: shortCut: Ctrl-N override: false overwriteOutput: true confirm: false dangerous: true description: Toggle autoscaling on keda scaledobject scopes: - scaledobjects command: bash background: true args: - -c - | ANNOTATION="autoscaling.keda.sh/paused-replicas" if kubectl get scaledobject $NAME -n $NAMESPACE --context $CONTEXT -o yaml | grep -q "$ANNOTATION: \"0\""; then # If annotation found, remove it kubectl annotate scaledobject $NAME "$ANNOTATION"- -n $NAMESPACE --context $CONTEXT >/dev/null && echo "Keda autoscaling for $NAME enabled" else # If annotation not found, add it kubectl annotate scaledobject $NAME "$ANNOTATION"=0 -n $NAMESPACE --context $CONTEXT >/dev/null && echo "Keda autoscaling for $NAME disabled" fi ================================================ FILE: plugins/kube-metrics.yaml ================================================ # requires 'kube-metrics' cli binary installed to be installed (https://github.com/bakito/kube-metrics) plugins: # allows visualizing pod and node metrics kube-metrics-pod: shortCut: m confirm: false description: "Metrics" scopes: - pods - nodes command: sh background: false args: - -c - | if [ -n "$NAMESPACE" ]; then kube-metrics pod --namespace=$NAMESPACE $NAME else kube-metrics node $NAME fi ================================================ FILE: plugins/kubectl/kubectl-purge ================================================ #!/bin/bash usage="kubectl $(basename "$0") [-h] NAMESPACE NAME kubectl plugin, requires TILLER_NS set before call. Will run helm delete --purge on release associated with pod. Release is acquired from the pod's 'describe' information in the 'tags' section. Examples: kubectl purge my-namespace my-namespace-pod1-123: Purge the release associated with 'my-namespace-pod1-123' pod" while getopts ':h' option; do case "$option" in h) echo "$usage" exit ;; esac done shift $((OPTIND -1)) namespace=$1 name=$2 if [ -z "$TILLER_NS" ]; then echo "Set TILLER_NS environment variable before calling this function" exit 1; elif [ -z "$namespace" ]; then echo "No Namespace provided" exit 1; elif [ -z "$name" ]; then echo "No Name provided" exit 1; fi kubectl describe pods -n $namespace $name | grep release | cut -f 2 -d'=' | xargs -J rel helm --tiller-namespace $TILLER_NS delete --purge rel ================================================ FILE: plugins/kubectl-get-in-shell.yaml ================================================ plugins: # provides a way to continue working on the currently selected object in a new shell without doing lengthy copy/paste of current context. # It simply formats the `kubectl get` command, taking care to omit -n when the namespace is not defined (typically for cluster-wide resources) kubectl-get-cmd: shortCut: Shift-B confirm: false description: get into shell scopes: - all command: bash background: false args: - -c - (printf "copy/paste in a shell:\n\n"; if [ "$NAMESPACE" != "" -a "$NAMESPACE" != "-" ]; then printf "kubectl get --context $CONTEXT -n $NAMESPACE $RESOURCE_NAME $NAME \n" ; else printf "kubectl get --context $CONTEXT $RESOURCE_NAME $NAME \n"; fi ) |& less ================================================ FILE: plugins/kubectl-plugins/kubectl-jq ================================================ #!/bin/bash kubectl logs -f $1 -n $2 --context $3 | jq -rR '. as $raw | try (fromjson | .message) catch ("\u001b[31m" + $raw + "\u001b[0m")' ================================================ FILE: plugins/liveMigration.yaml ================================================ # $XDG_CONFIG_HOME/k9s/plugins.yaml plugins: # liveMigration plugin config by rabin-io # # Trigger virtual machine live migration, for VM's running on k8s cluster using kubevirt # or Openshift with CNV (OpenShift Virtualization) installed. # # Require `virtctl` cli in your PATH, # can be downloaded from Openshift `Command Line Tools` page # or from kubevirt site https://kubevirt.io/user-guide/operations/virtctl_client_tool/ # # liveMigration: # Can be triggered from the VMI (VirtualMachineInstance) view, with shortcut `m` shortCut: m # Description to show in K9s menu description: Live Migrate moves VM to another compute node # Enable confirmation dialog confirm: true dangerous: true # Collections of views that support this shortcut. (You can use `all`) scopes: - virtualmachineinstance # Whether or not to run the command in background mode background: false # The command to run upon invocation. command: virtctl # Defines the command arguments args: - migrate - $NAME - -n - $NAMESPACE - --context - $CONTEXT ================================================ FILE: plugins/log-bunyan.yaml ================================================ # Forwards logs to bunyan cli for formatting # Install Bunyan: https://www.npmjs.com/package/bunyan plugins: bunyanlogsp: shortCut: Ctrl-L confirm: false description: "Logs (bunyan)" scopes: - pod command: bash background: false args: - -ic - | kubectl logs -f $NAME -n $NAMESPACE --context $CONTEXT | bunyan -o short exit 0 bunyanlogsd: shortCut: Ctrl-L confirm: false description: "Logs (bunyan)" scopes: - deployment command: bash background: false args: - -ic - | kubectl logs -f deployment/$NAME -n $NAMESPACE --context $CONTEXT | bunyan -o short exit 0 bunyanlogss: shortCut: Ctrl-L confirm: false description: "Logs (bunyan)" scopes: - service command: bash background: false args: - -ic - | kubectl logs -f service/$NAME -n $NAMESPACE --context $CONTEXT | bunyan -o short exit 0 ================================================ FILE: plugins/log-full.yaml ================================================ plugins: # See https://k9scli.io/topics/plugins/ raw-logs-follow: shortCut: Ctrl-G description: logs -f scopes: - po command: kubectl background: false args: - logs - -f - $NAME - -n - $NAMESPACE - --context - $CONTEXT - --kubeconfig - $KUBECONFIG log-less: shortCut: Shift-K description: "logs|less" scopes: - po command: bash background: false args: - -c - '"$@" | less' - dummy-arg - kubectl - logs - $NAME - -n - $NAMESPACE - --context - $CONTEXT - --kubeconfig - $KUBECONFIG log-less-container: shortCut: Shift-L description: "logs|less" scopes: - containers command: bash background: false args: - -c - '"$@" | less' - dummy-arg - kubectl - logs - -c - $NAME - $POD - -n - $NAMESPACE - --context - $CONTEXT - --kubeconfig - $KUBECONFIG ================================================ FILE: plugins/log-jq.yaml ================================================ plugins: # Sends logs over to jq for processing. This leverages kubectl plugin kubectl-jq. jqlogs: shortCut: Ctrl-J confirm: false description: "Logs (jq)" scopes: - po command: kubectl background: false args: - jq - $NAME - $NAMESPACE - $CONTEXT ================================================ FILE: plugins/log-loki.yaml ================================================ plugins: # https://grafana.com/docs/loki/latest/query/logcli/ # you must set the LOKI_ADDR environment variable ("export LOKI_ADDR=https://loki.internal" in bash) before starting k9s to use logcli loki-container: shortCut: Shift-L description: "loki fmt" scopes: - containers command: logcli background: false args: - query - "{ namespace = \"$NAMESPACE\", pod = \"$POD\", container = \"$NAME\" }" - -f loki-container-raw: shortCut: Ctrl-E description: "loki raw" scopes: - containers command: logcli background: false args: - query - "{ namespace = \"$NAMESPACE\", pod = \"$POD\", container = \"$NAME\" }" - -f - -oraw loki-pods: shortCut: Shift-L description: "loki fmt" scopes: - po command: logcli background: false args: - query - "{ namespace = \"$NAMESPACE\", pod = \"$NAME\" }" - -f loki-pods-raw: shortCut: Ctrl-L description: "loki raw" scopes: - po command: logcli background: false args: - query - "{ namespace = \"$NAMESPACE\", pod = \"$NAME\" }" - -f - -oraw loki-node: shortCut: Shift-L description: "loki fmt" scopes: - node command: logcli background: false args: - query - "{ node_name = \"$NAME\" }" - -f loki-node-raw: shortCut: Ctrl-L description: "loki raw" scopes: - node command: logcli background: false args: - query - "{ node_name = \"$NAME\" }" - -f - -oraw loki-ns: shortCut: Shift-L description: "loki fmt" scopes: - namespace command: logcli background: false args: - query - "{ namespace = \"$NAME\" }" - -f loki-ns-raw: shortCut: Ctrl-L description: "loki raw" scopes: - namespace command: logcli background: false args: - query - "{ namespace = \"$NAME\" }" - -f - -oraw ================================================ FILE: plugins/log-stern.yaml ================================================ plugins: # Leverage stern (https://github.com/stern/stern) to output logs. stern: shortCut: Ctrl-Y confirm: false description: "Logs " scopes: - pods command: stern background: false args: - --tail - 50 - $FILTER - -n - $NAMESPACE - --context - $CONTEXT ================================================ FILE: plugins/node-root-shell.yaml ================================================ plugins: node-root-shell: shortCut: a description: Run root shell on node dangerous: true scopes: - nodes command: bash background: false confirm: true args: - -c - | host="$1" json=' { "apiVersion": "v1", "spec": { "hostIPC": true, "hostNetwork": true, "hostPID": true ' if ! [[ -z "$host" ]]; then json+=", \"nodeSelector\" : { \"kubernetes.io/hostname\" : \"$host\" } "; fi json+=' } } ' kubectl run -ti --image alpine:3.8 --rm --privileged --restart=Never --overrides="$json" root --command -- nsenter -t 1 -m -u -n -i -- bash -l ================================================ FILE: plugins/openssl.yaml ================================================ # Inspect certificate chains with openssl. # See: https://github.com/openssl/openssl. plugins: secret-openssl-ca: shortCut: Ctrl-O confirm: false description: Openssl ca.crt scopes: - secrets command: bash background: false args: - -c - kubectl get secret --context $CONTEXT -n $NAMESPACE $NAME -o jsonpath='{.data.ca\.crt}' | base64 -d | openssl storeutl -noout -text -certs /dev/stdin |& less secret-openssl-tls: shortCut: Shift-O confirm: false description: Openssl tls.crt scopes: - secrets command: bash background: false args: - -c - kubectl get secret --context $CONTEXT -n $NAMESPACE $NAME -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl storeutl -noout -text -certs /dev/stdin |& less ================================================ FILE: plugins/pvc-debug-container.yaml ================================================ plugins: pvc-shell: shortCut: s description: "Shell on PVC" scopes: - pvc command: sh background: false confirm: false inputs: - name: podname label: POD name type: string required: true default: "pvc-shell" - name: image label: Image type: dropdown required: true default: nicolaka/netshoot:v0.15 options: - nicolaka/netshoot:v0.15 - ubuntu:26.04 - name: mountpath label: Mount path type: string required: true default: "/mnt/data" args: - -c - | NODE=$(kubectl --context $CONTEXT -n $NAMESPACE get pods \ -o jsonpath='{range .items[?(@.spec.volumes[*].persistentVolumeClaim.claimName=="'"$NAME"'")]}{.spec.nodeName}{"\n"}{end}' | head -n1) if [ -n "$NODE" ]; then NODE_LINE="nodeName: $NODE" else NODE_LINE="" fi echo "Starting a shell pod with PVC - $NAME mounted at $INPUT_MOUNTPATH" { cat </dev/null 2>&1 echo "Waiting for pod to be ready." if ! kubectl --context $CONTEXT -n $NAMESPACE wait --for=condition=Ready pod/$INPUT_PODNAME --timeout=60s; then echo "Pod did not become Ready. Likely a ReadWriteOnce conflict." echo "Press Enter to return to k9s." read dummy kubectl --context $CONTEXT -n $NAMESPACE delete pod $INPUT_PODNAME --ignore-not-found --grace-period=0 --force >/dev/null 2>&1 exit 0 fi kubectl --context $CONTEXT -n $NAMESPACE exec -it $INPUT_PODNAME -- bash || echo "Could not exec into pod." echo "Cleaning up pod." kubectl --context $CONTEXT -n $NAMESPACE delete pod $INPUT_PODNAME --ignore-not-found --grace-period=0 --force >/dev/null 2>&1 ================================================ FILE: plugins/remove-finalizers.yaml ================================================ # Removes all finalizers from the selected resource. Finalizers are namespaced keys that tell Kubernetes to wait # until specific conditions are met before it fully deletes resources marked for deletion. # Before deleting an object you need to ensure that all finalizers has been removed. Usually this would be done # by the specific controller but under some circumstances it is possible to encounter a set of objects blocked # for deletion. # This plugin makes this task easier by providing a shortcut to directly removing them all. # Be careful when using this plugin as it may leave dangling resources or instantly deleting resources that were # blocked by the finalizers. # Author: github.com/jalvarezit plugins: remove_finalizers: shortCut: Ctrl-F confirm: true dangerous: true scopes: - all description: | Removes all finalizers from selected resource. Be careful when using it, it may leave dangling resources or delete them command: kubectl background: true args: - patch - --context - $CONTEXT - --namespace - $NAMESPACE - $RESOURCE_NAME.$RESOURCE_GROUP - $NAME - -p - '{"metadata":{"finalizers":null}}' - --type - merge ================================================ FILE: plugins/resource-recommendations.yaml ================================================ plugins: # Author: Daniel Rubin # Get recommendations for CPU/Memory requests and limits using Robusta KRR # Requires Prometheus in the Cluster and Robusta KRR (https://github.com/robusta-dev/krr) on your system # Open K9s in deployments/daemonsets/statefulsets view, then: # Shift-K to get recommendations krr: shortCut: Shift-K description: Get krr scopes: - deployments - daemonsets - statefulsets - cronjobs command: bash background: false confirm: false args: - -c - | LABELS=$(kubectl get $RESOURCE_NAME $NAME -n $NAMESPACE --context $CONTEXT --show-labels | awk '{print $NF}' | awk '{if(NR>1)print}') krr simple --cluster $CONTEXT --selector $LABELS echo "Press 'q' to exit" while : ; do read -n 1 k <&1 if [[ $k = q ]] ; then break fi done krr-ns: shortCut: Shift-K description: Get krr scopes: - namespaces command: bash background: false confirm: false args: - -c - | krr simple --cluster $CONTEXT -n $RESOURCE_NAME echo "Press 'q' to exit" while : ; do read -n 1 k <&1 if [[ $k = q ]] ; then break fi done ================================================ FILE: plugins/rm-ns.yaml ================================================ plugins: # remove finalizers from a stuck namespace rm-ns: shortCut: n confirm: true dangerous: true description: Remove NS Finalizers scopes: - namespace command: sh background: false args: - -c - "kubectl get namespace $NAME -o json | jq '.spec.finalizers=[]' | kubectl replace --raw /api/v1/namespaces/$NAME/finalize -f - > /dev/null" ================================================ FILE: plugins/spark-operator.yaml ================================================ # See https://github.com/kubeflow/spark-operator plugins: toggleScheduledSparkApp: shortCut: s confirm: true dangerous: true scopes: - scheduledsparkapp description: Toggle suspend command: kubectl background: true args: - patch - scheduledsparkapp - $NAME - -n - $NAMESPACE - --context - $CONTEXT - -p - '{"spec": {"suspend": $!COL-SUSPEND}}' - --type - merge ================================================ FILE: plugins/start-alpine.yaml ================================================ plugins: start-alpine-pod: shortCut: Ctrl-T confirm: true description: "Start an alpine:latest pod in current context/namespace" scopes: - pods - deployments command: bash background: true args: - -c - | echo '{"apiVersion": "apps/v1", "kind": "Deployment", "metadata": {"name": "alpine", "labels": {"app": "alpine", "debug": "1"}}, "spec": {"selector": {"matchLabels": {"app": "alpine"}}, "replicas": 1, "template": {"metadata": {"labels": {"app": "alpine", "debug": "1"}, "annotations": {"kubectl.kubernetes.io/default-container": "alpine"}}, "spec": {"containers": [{"name": "alpine", "image": "alpine:latest", "imagePullPolicy": "Always", "securityContext": {"runAsUser": 0, "runAsGroup": 0}, "stdin": true, "tty": true, "stdinOnce": true, "terminationMessagePath": "/dev/termination-log", "terminationMessagePolicy": "File", "resources": {"requests": {"cpu": "100m", "memory": "100Mi"}, "limits": {"cpu": "100m", "memory": "100Mi"}}}], "restartPolicy": "Always"}}}}' | kubectl apply -f - --context $CONTEXT --namespace $NAMESPACE ================================================ FILE: plugins/szero.yaml ================================================ # Temporarily scale down/up all deployments, statefulsets, and daemonsets in a namespace using szero # Uses https://github.com/jadolg/szero plugins: szero-down: shortCut: Shift-D confirm: true dangerous: true description: Scale all down scopes: - namespace command: sh background: false args: - -c - "szero down --context $CONTEXT --namespace $NAME" szero-up: shortCut: Shift-U confirm: true dangerous: true description: Scale all up scopes: - namespace command: sh background: false args: - -c - "szero up --context $CONTEXT --namespace $NAME" ================================================ FILE: plugins/trace-dns.yaml ================================================ # Author: Qasim Sarfraz # Trace DNS requests for containers, pods, and nodes # Requires kubectl version 1.30 or later # https://github.com/inspektor-gadget/inspektor-gadget # https://www.inspektor-gadget.io/docs/latest/gadgets/trace_dns plugins: trace-dns: shortCut: Shift-D description: Trace DNS requests scopes: - containers - pods - nodes command: bash confirm: false background: false args: - -c - | IG_VERSION=v0.34.0 IG_IMAGE=ghcr.io/inspektor-gadget/ig:$IG_VERSION IG_FIELD=k8s.podName,src,dst,qr,qtype,name,rcode,latency_ns GREEN='\033[0;32m' RED='\033[0;31m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Ensure kubectl version is 1.30 or later KUBECTL_VERSION=$(kubectl version --client | awk '/Client Version:/{print $3}') if [[ "$(echo "$KUBECTL_VERSION" | cut -d. -f2)" -lt 30 ]]; then echo -e "${RED}kubectl version 1.30 or later is required${NC}" sleep 3 exit fi clear # Handle containers if [[ -n "$POD" ]]; then echo -e "${GREEN}Tracing DNS requests for container ${BLUE}${NAME}${GREEN} in pod ${BLUE}${POD}${GREEN} in namespace ${BLUE}${NAMESPACE}${NC}" IG_NODE=$(kubectl get pod "$POD" -n "$NAMESPACE" -o jsonpath='{.spec.nodeName}') kubectl debug --kubeconfig=$KUBECONFIG --context=$CONTEXT -q \ --profile=sysadmin "node/$IG_NODE" -it --image="$IG_IMAGE" -- \ ig run trace_dns:$IG_VERSION -F "k8s.podName==$POD" -F "k8s.containerName=$NAME" \ --fields "$IG_FIELD" exit fi # Handle pods if [[ -n "$NAMESPACE" ]]; then echo -e "${GREEN}Tracing DNS requests for pod ${BLUE}${NAME}${GREEN} in namespace ${BLUE}${NAMESPACE}${NC}" IG_NODE=$(kubectl get pod "$NAME" -n "$NAMESPACE" -o jsonpath='{.spec.nodeName}') kubectl debug --kubeconfig=$KUBECONFIG --context=$CONTEXT -q \ --profile=sysadmin -it --image="$IG_IMAGE" "node/$IG_NODE" -- \ ig run trace_dns:$IG_VERSION -F "k8s.podName==$NAME" \ --fields "$IG_FIELD" exit fi # Handle nodes echo -e "${GREEN}Tracing DNS requests for node ${BLUE}${NAME}${NC}" kubectl debug --kubeconfig=$KUBECONFIG --context=$CONTEXT -q \ --profile=sysadmin -it --image="$IG_IMAGE" "node/$NAME" -- \ ig run trace_dns:$IG_VERSION --fields "$IG_FIELD" ================================================ FILE: plugins/vector-dev-top.yaml ================================================ # SPDX-FileCopyrightText: 2025 Robin Schneider # # SPDX-License-Identifier: CC0-1.0 plugins: vector-top: # t hotkey is already used for "transfer" by k9s. # Using h because of health. shortCut: h confirm: false description: "Execute `vector top`" scopes: - pods command: sh background: false args: # Both works. I have not read through https://github.com/derailed/k9s/issues/1852 yet. # - -ic - -c - "kubectl exec --context=$CONTEXT --namespace=$NAMESPACE --stdin --tty $NAME -- vector top" vector-top-container: # t hotkey is already used for "transfer" by k9s. shortCut: h confirm: false description: "Execute `vector top`" scopes: - containers command: sh background: false args: - -c - "kubectl exec --context=$CONTEXT --namespace=$NAMESPACE --stdin --tty $POD --container=$NAME -- vector top" ================================================ FILE: plugins/watch-events.yaml ================================================ # watch events on selected resources # requires linux "watch" command # change '-n' to adjust refresh time in seconds plugins: watch-events: shortCut: Shift-E confirm: false description: Get Events scopes: - all command: sh background: false args: - -c - "kubectl events --context $CONTEXT --namespace $NAMESPACE --for $RESOURCE_NAME.${RESOURCE_GROUP:+.RESOURCE_GROUP}/$NAME --watch" ================================================ FILE: skins/axual.yaml ================================================ # ----------------------------------------------------------------------------- # Axual Skin contributed by [@JayKus](jimmy@axual.com) # ----------------------------------------------------------------------------- # Styles... blue: &blue "#113851" red: &red "#D7595F" yellow: &yellow "#F2BF40" cyan: &cyan "#47B0AB" grey: &grey "#A99688" light: &light "#F1EFE4" # Skin... k9s: # General K9s styles body: fgColor: *yellow bgColor: *blue logoColor: orange # Command prompt styles prompt: fgColor: *yellow bgColor: *blue suggestColor: orange # ClusterInfoView styles info: fgColor: *yellow sectionColor: *light dialog: fgColor: *yellow bgColor: *blue buttonFgColor: *yellow buttonBgColor: *blue buttonFocusFgColor: *grey buttonFocusBgColor: *light labelFgColor: *grey fieldFgColor: *light # Frame styles frame: # Borders styles border: fgColor: *yellow focusColor: *light # MenuView attributes and styles menu: fgColor: *yellow keyColor: *yellow # Used for favorite namespaces numKeyColor: *light # CrumbView attributes for history navigation. crumbs: fgColor: *light bgColor: *cyan activeColor: *red # Resource status and update styles status: # Text display, highlight is same colors inverted newColor: *yellow modifyColor: *blue addColor: *blue errorColor: *red highlightColor: *yellow killColor: *red completedColor: *grey # Border title styles. title: fgColor: *yellow bgColor: *blue highlightColor: *cyan counterColor: *light filterColor: "slategray" # Specific views styles views: # TableView attributes. table: fgColor: *blue bgColor: *blue cursorColor: *blue # Header row styles. header: fgColor: *light bgColor: *blue sorterColor: "orange" # Xray style xray: fgColor: *light bgColor: *blue cursorColor: *red graphicColor: *yellow showIcons: false # YAML info styles. yaml: keyColor: *yellow colonColor: *yellow valueColor: *cyan # Logs styles. logs: fgColor: *yellow bgColor: *blue indicator: fgColor: *red bgColor: *blue toggleOnColor: *yellow toggleOffColor: *grey # Chart drawing charts: bgColor: *blue defaultDialColors: - *light - *red defaultChartColors: - *light - *red ================================================ FILE: skins/black-and-wtf.yaml ================================================ # ----------------------------------------------------------------------------- # BlackAndWtf skin # ----------------------------------------------------------------------------- # Styles... fg: &fg "white" bg: &bg "black" mark: &mark "darkgoldenrod" active: &active "dimgray" text: &text "navajowhite" white: &white "whitesmoke" ghost: &ghost "ghostwhite" dslate: &dslate "darkslategray" err: &err "pink" slate: &slate "slategray" gray: &gray "gray" # Skin... k9s: body: fgColor: *fg bgColor: *bg logoColor: *fg prompt: fgColor: *fg bgColor: *bg suggestColor: *gray info: fgColor: *text sectionColor: *fg dialog: fgColor: *fg bgColor: *bg buttonFgColor: *fg buttonBgColor: *bg buttonFocusFgColor: *slate buttonFocusBgColor: *white labelFgColor: *ghost fieldFgColor: *white frame: border: fgColor: *fg focusColor: *fg menu: fgColor: *fg keyColor: *fg numKeyColor: *text crumbs: fgColor: *fg bgColor: *bg activeColor: *active status: newColor: *white modifyColor: *text addColor: *ghost errorColor: *err highlightColor: *dslate killColor: *slate completedColor: *gray title: fgColor: *fg highlightColor: *active counterColor: *text filterColor: *slate views: table: fgColor: *fg bgColor: *bg cursorBgColor: *fg cursorFgColor: *bg markColor: *mark header: fgColor: *dslate bgColor: *bg sorterColor: *fg xray: fgColor: *fg bgColor: *bg cursorColor: *ghost graphicColor: *gray showIcons: false yaml: keyColor: *ghost colorColor: *slate valueColor: *text logs: fgColor: *ghost bgColor: *bg indicator: fgColor: *ghost bgColor: *bg toggleOnColor: *mark toggleOffColor: *gray charts: bgColor: default defaultDialColors: - *white - *err defaultChartColors: - *white - *err ================================================ FILE: skins/dracula.yaml ================================================ # ----------------------------------------------------------------------------- # Dracula skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#f8f8f2" background: &background "#282a36" current_line: ¤t_line "#44475a" selection: &selection "#44475a" comment: &comment "#6272a4" cyan: &cyan "#8be9fd" green: &green "#50fa7b" orange: &orange "#ffb86c" pink: &pink "#ff79c6" purple: &purple "#bd93f9" red: &red "#ff5555" yellow: &yellow "#f1fa8c" # Skin... k9s: # General K9s styles body: fgColor: *foreground bgColor: *background logoColor: *purple # Command prompt styles prompt: fgColor: *foreground bgColor: *background suggestColor: *purple # ClusterInfoView styles. info: fgColor: *pink sectionColor: *foreground # Dialog styles. dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *purple buttonFocusFgColor: *yellow buttonFocusBgColor: *pink labelFgColor: *orange fieldFgColor: *foreground frame: # Borders styles. border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *pink # Used for favorite namespaces numKeyColor: *pink # CrumbView attributes for history navigation. crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line # Resource status and update styles status: newColor: *cyan modifyColor: *purple addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment # Border title styles. title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *purple filterColor: *pink views: # Charts skins... charts: bgColor: default defaultDialColors: - *purple - *red defaultChartColors: - *purple - *red # TableView attributes. table: fgColor: *foreground bgColor: *background # Header row styles. header: fgColor: *foreground bgColor: *background sorterColor: *cyan # Xray view attributes. xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *purple showIcons: false # YAML info styles. yaml: keyColor: *pink colonColor: *purple valueColor: *foreground # Logs styles. logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *purple toggleOnColor: *green toggleOffColor: *cyan ================================================ FILE: skins/everforest-dark.yaml ================================================ # ----------------------------------------------------------------------------- # Everforest Dark # https://github.com/sainnhe/everforest/blob/master/palette.md#dark # ----------------------------------------------------------------------------- # text: &text "#d3c6aa" base: &base "#1e2326" overlay: &overlay "#2e383c" muted: &muted "#495156" red: &red "#e67e80" blue: &blue "#7fbbb3" yellow: &yellow "#dbbc7f" green: &green "#83c092" pink: &pink "#d699b6" orange: &orange "#e69875" # Skin... k9s: # General K9s styles body: fgColor: *text bgColor: *base logoColor: *green # Command prompt styles prompt: fgColor: *text bgColor: *base suggestColor: *green border: command: *orange default: *blue # ClusterInfoView styles. info: fgColor: *green sectionColor: *text # Dialog styles. dialog: fgColor: *text bgColor: *base buttonFgColor: *text buttonBgColor: *green buttonFocusFgColor: *yellow buttonFocusBgColor: *green labelFgColor: *yellow fieldFgColor: *text frame: # Borders styles. border: fgColor: *overlay focusColor: *overlay menu: fgColor: *text keyColor: *green # Used for favorite namespaces numKeyColor: *green # CrumbView attributes for history navigation. crumbs: fgColor: *text bgColor: *overlay activeColor: *overlay # Resource status and update styles status: newColor: *green modifyColor: *red addColor: *blue errorColor: *pink highlightcolor: *yellow killColor: *muted completedColor: *muted # Border title styles. title: fgColor: *text bgColor: *overlay highlightColor: *yellow counterColor: *green filterColor: *green views: # Charts skins... charts: bgColor: default defaultDialColors: - *green - *pink defaultChartColors: - *green - *pink # TableView attributes. table: fgColor: *text bgColor: *base # Header row styles. header: fgColor: *text bgColor: *base sorterColor: *red # Xray view attributes. xray: fgColor: *text bgColor: *base cursorColor: *overlay graphicColor: *green showIcons: false # YAML info styles. yaml: keyColor: *green colonColor: *green valueColor: *text # Logs styles. logs: fgColor: *text bgColor: *base indicator: fgColor: *text bgColor: *base ================================================ FILE: skins/everforest-light.yaml ================================================ # ----------------------------------------------------------------------------- # Everforest Light # https://github.com/sainnhe/everforest/blob/master/palette.md#dark # ----------------------------------------------------------------------------- # text: &text "#5c6a72" base: &base "#f2efdf" overlay: &overlay "#fffbef" muted: &muted "#edeada" red: &red "#f85552" blue: &blue "#3a94c5" yellow: &yellow "#dfa000" green: &green "#35a77c" pink: &pink "#df69ba" orange: &orange "#f57d26" # Skin... k9s: # General K9s styles body: fgColor: *text bgColor: *base logoColor: *green # Command prompt styles prompt: fgColor: *text bgColor: *base suggestColor: *green border: command: *orange default: *blue # ClusterInfoView styles. info: fgColor: *green sectionColor: *text # Dialog styles. dialog: fgColor: *text bgColor: *base buttonFgColor: *text buttonBgColor: *green buttonFocusFgColor: *yellow buttonFocusBgColor: *green labelFgColor: *yellow fieldFgColor: *text frame: # Borders styles. border: fgColor: *overlay focusColor: *overlay menu: fgColor: *text keyColor: *green # Used for favorite namespaces numKeyColor: *green # CrumbView attributes for history navigation. crumbs: fgColor: *text bgColor: *overlay activeColor: *overlay # Resource status and update styles status: newColor: *green modifyColor: *red addColor: *blue errorColor: *pink highlightcolor: *yellow killColor: *muted completedColor: *muted # Border title styles. title: fgColor: *text bgColor: *overlay highlightColor: *yellow counterColor: *green filterColor: *green views: # Charts skins... charts: bgColor: default defaultDialColors: - *green - *pink defaultChartColors: - *green - *pink # TableView attributes. table: fgColor: *text bgColor: *base # Header row styles. header: fgColor: *text bgColor: *base sorterColor: *red # Xray view attributes. xray: fgColor: *text bgColor: *base cursorColor: *overlay graphicColor: *green showIcons: false # YAML info styles. yaml: keyColor: *green colonColor: *green valueColor: *text # Logs styles. logs: fgColor: *text bgColor: *base indicator: fgColor: *text bgColor: *base ================================================ FILE: skins/gruvbox-dark-hard.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Gruvbox Dark Skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#ebdbb2" background: &background "#1d2021" current_line: ¤t_line "#ebdbb2" selection: &selection "#3c3735" comment: &comment "#bdad93" cyan: &cyan "#689d69" green: &green "#989719" orange: &orange "#d79920" magenta: &magenta "#b16185" blue: &blue "#448488" red: &red "#cc231c" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: "#fff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-dark.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Gruvbox Dark Skin # Author: [@indiebrain](https://github.com/indiebrain) # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#ebdbb2" background: &background "#272727" current_line: ¤t_line "#ebdbb2" selection: &selection "#3c3735" comment: &comment "#bdad93" cyan: &cyan "#689d69" green: &green "#989719" orange: &orange "#d79920" magenta: &magenta "#b16185" blue: &blue "#448488" red: &red "#cc231c" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: "#fff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-light-hard.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Gruvbox Light Skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#3c3735" background: &background "#f9f5d7" current_line: ¤t_line "#ebdbb2" selection: &selection "#3c3735" comment: &comment "#bdad93" cyan: &cyan "#689d69" green: &green "#989719" orange: &orange "#d79920" magenta: &magenta "#b16185" blue: &blue "#448488" red: &red "#cc231c" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *foreground cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: skins/gruvbox-light.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Gruvbox Light Skin # Author: [@indiebrain](https://github.com/indiebrain) # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#3c3735" background: &background "#fbf1c7" current_line: ¤t_line "#ebdbb2" selection: &selection "#3c3735" comment: &comment "#bdad93" cyan: &cyan "#689d69" green: &green "#989719" orange: &orange "#d79920" magenta: &magenta "#b16185" blue: &blue "#448488" red: &red "#cc231c" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *foreground cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: skins/gruvbox-material-dark-hard.yaml ================================================ # ---------------------------------------- # Gruvbox Material Dark Hard Theme for k9s # ---------------------------------------- foreground: &foreground "#d4be98" background: &background "#1d2021" current_line: ¤t_line "#d4be98" selection: &selection "#3c3836" comment: &comment "#928374" cyan: &cyan "#7daea3" green: &green "#a9b665" orange: &orange "#e78a4e" magenta: &magenta "#d3869b" blue: &blue "#7daea3" red: &red "#ea6962" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-material-dark-medium.yaml ================================================ # ------------------------------------------ # Gruvbox Material Dark Medium Theme for k9s # ------------------------------------------ foreground: &foreground "#d4be98" background: &background "#282828" current_line: ¤t_line "#d4be98" selection: &selection "#3c3836" comment: &comment "#928374" cyan: &cyan "#89b482" green: &green "#a9b665" orange: &orange "#e78a4e" magenta: &magenta "#d3869b" blue: &blue "#7daea3" red: &red "#ea6962" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-material-dark-soft.yaml ================================================ # ---------------------------------------- # Gruvbox Material Dark Soft Theme for k9s # ---------------------------------------- foreground: &foreground "#ddc7a1" background: &background "#32302f" current_line: ¤t_line "#ddc7a1" selection: &selection "#3c3836" comment: &comment "#928374" cyan: &cyan "#89b482" green: &green "#a9b665" orange: &orange "#e78a4e" magenta: &magenta "#d3869b" blue: &blue "#7daea3" red: &red "#ea6962" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-material-light-hard.yaml ================================================ # ----------------------------------------- # Gruvbox Material Light Hard Theme for k9s # ----------------------------------------- foreground: &foreground "#654735" background: &background "#f9f5d7" current_line: ¤t_line "#654735" selection: &selection "#d5c4a1" comment: &comment "#9d0006" cyan: &cyan "#689d6a" green: &green "#98971a" orange: &orange "#d79921" magenta: &magenta "#b16286" blue: &blue "#458588" red: &red "#cc241d" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: [*blue, *red] defaultChartColors: [*blue, *red] table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-material-light-medium.yaml ================================================ # ------------------------------------------- # Gruvbox Material Light Medium Theme for k9s # ------------------------------------------- foreground: &foreground "#654735" background: &background "#fbf1c7" current_line: ¤t_line "#654735" selection: &selection "#d5c4a1" comment: &comment "#9d0006" cyan: &cyan "#689d6a" green: &green "#98971a" orange: &orange "#d79921" magenta: &magenta "#b16286" blue: &blue "#458588" red: &red "#cc241d" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: [*blue, *red] defaultChartColors: [*blue, *red] table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/gruvbox-material-light-soft.yaml ================================================ # ---------------------------------------- # Grvbox Material Light Soft Theme for k9s # ---------------------------------------- foreground: &foreground "#654735" background: &background "#f2e5bc" current_line: ¤t_line "#654735" selection: &selection "#d5c4a1" comment: &comment "#9d0006" cyan: &cyan "#689d6a" green: &green "#98971a" orange: &orange "#d79921" magenta: &magenta "#b16286" blue: &blue "#458588" red: &red "#cc241d" k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *magenta numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *comment activeColor: *blue status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: *background defaultDialColors: [*blue, *red] defaultChartColors: [*blue, *red] table: fgColor: *foreground bgColor: *background cursorFgColor: "#ffffff" cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *selection xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *background ================================================ FILE: skins/in-the-navy.yaml ================================================ # ----------------------------------------------------------------------------- # In the Navy # ----------------------------------------------------------------------------- # Styles... fg: &fg "dodgerblue" bg: &bg "white" blue: &blue "blue" sky: &sky "lightskyblue" steel: &steel "steelblue" dark: &dark "darkblue" alice: &alice "aliceblue" corn: &corn "cornflowerblue" err: &err "indianred" royal: &royal "royalblue" slate: &slate "slategray" gray: &gray "gray" cadet: &cadet "cadetblue" powder: &powder "powderblue" aqua: &aqua "aqua" mslate: &mslate "mediumslateblue" # Skin... k9s: body: fgColor: *fg bgColor: *bg logoColor: *blue prompt: fgColor: *fg bgColor: *bg suggestColor: *cadet info: fgColor: *sky sectionColor: *steel dialog: fgColor: *fg bgColor: *bg buttonFgColor: *fg buttonBgColor: *powder buttonFocusFgColor: white buttonFocusBgColor: *aqua labelFgColor: *mslate fieldFgColor: *fg frame: border: fgColor: *fg bgColor: *dark focusColor: *alice menu: fgColor: *dark keyColor: *corn numKeyColor: *cadet crumbs: fgColor: *bg bgColor: *steel activeColor: *sky status: newColor: *blue modifyColor: *powder addColor: *sky errorColor: *err highlightColor: *royal killColor: *slate completedColor: *gray title: fgColor: *cadet bgColor: *bg highlightColor: *sky counterColor: *slate filterColor: *slate views: table: fgColor: *fg bgColor: *bg cursorFgColor: *fg cursorBgColor: *aqua markColor: *mslate header: fgColor: *fg bgColor: *bg sorterColor: *cadet xray: fgColor: *blue bgColor: *dark cursorColor: *aqua graphicColor: *mslate showIcons: false charts: bgColor: *bg defaultDialColors: - *aqua - *err defaultChartColors: - *aqua - *err yaml: keyColor: *steel colorColor: *blue valueColor: *royal logs: fgColor: *dark bgColor: *bg indicator: fgColor: *dark bgColor: *bg toggleOnColor: *steel toggleOffColor: *blue ================================================ FILE: skins/kanagawa.yaml ================================================ # ----------------------------------------------------------------------------- # Kanagawa Skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#dcd7ba" background: &background "#1f1f28" black: &black "#090618" blue: &blue "#7e9cd8" green: &green "#76946a" grey: &grey "#727169" orange: &orange "#ffa066" purple: &purple "#957fb8" red: &red "#c34043" yellow: &yellow "#c0a36e" yellow_bright: &yellow_bright "#e6c384" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *green prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *grey sectionColor: *green help: fgColor: *foreground bgColor: *background keyColor: *yellow numKeyColor: *blue sectionColor: *purple dialog: fgColor: *black bgColor: *background buttonFgColor: *foreground buttonBgColor: *green buttonFocusFgColor: *black buttonFocusBgColor: *blue labelFgColor: *orange fieldFgColor: *blue frame: border: fgColor: *green focusColor: *green menu: fgColor: *grey keyColor: *yellow numKeyColor: *yellow crumbs: fgColor: *black bgColor: *green activeColor: *yellow status: newColor: *blue modifyColor: *green addColor: *grey pendingColor: *orange errorColor: *red highlightColor: *yellow killColor: *purple completedColor: *grey title: fgColor: *blue bgColor: *background highlightColor: *purple counterColor: *foreground filterColor: *blue views: charts: bgColor: *background defaultDialColors: - *green - *red defaultChartColors: - *green - *red table: fgColor: *yellow bgColor: *background cursorFgColor: *black cursorBgColor: *blue markColor: *yellow_bright header: fgColor: *grey bgColor: *background sorterColor: *orange xray: fgColor: *blue bgColor: *background cursorColor: *foreground graphicColor: *yellow_bright showIcons: false yaml: keyColor: *red colonColor: *grey valueColor: *grey logs: fgColor: *grey bgColor: *background indicator: fgColor: *blue bgColor: *background toggleOnColor: *red toggleOffColor: *grey help: fgColor: *grey bgColor: *background indicator: fgColor: *blue ================================================ FILE: skins/kiss.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Kiss Skin # Author: [@beejeebus](justin.p.randell@gmail.com) # ----------------------------------------------------------------------------- # Skin... k9s: body: fgColor: default bgColor: default logoColor: default prompt: fgColor: default bgColor: default suggestColor: default info: fgColor: default sectionColor: default dialog: fgColor: default bgColor: default buttonFgColor: default buttonBgColor: default buttonFocusFgColor: default buttonFocusBgColor: default labelFgColor: default fieldFgColor: default frame: border: fgColor: default focusColor: default menu: fgColor: default keyColor: default numKeyColor: default crumbs: fgColor: default bgColor: default activeColor: default status: newColor: default modifyColor: default addColor: default errorColor: default highlightColor: default killColor: default completedColor: default title: fgColor: default bgColor: default highlightColor: default counterColor: default filterColor: default views: table: fgColor: default bgColor: default cursorFgColor: default cursorBfColor: default header: fgColor: default bgColor: default sorterColor: default yaml: keyColor: default colonColor: default valueColor: default logs: fgColor: default bgColor: default indicator: fgColor: default bgColor: default toggleOnColor: default toggleOffColor: default ================================================ FILE: skins/monokai.yaml ================================================ # ----------------------------------------------------------------------------- # Monokai skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#ffffff" background: &background "default" backgroundOpaque: &backgroundOpaque "#333333" magenta: &magenta "#f72972" orange: &orange "#e47c20" lightBlue: &lightBlue "#c3eff7" blue: &blue "#69d9ed" darkBlue: &darkBlue "#3174a2" green: &green "#a7e24c" purple: &purple "#856cc4" yellow: &yellow "#e1df8f" darkGray: &darkGray "#666666" # Skin... k9s: # General K9s styles body: fgColor: *foreground bgColor: *background logoColor: *purple logoColorMsg: *foreground logoColorInfo: *lightBlue logoColorWarn: *orange logoColorError: *magenta # Command prompt styles prompt: fgColor: *foreground bgColor: *background suggestColor: *darkGray # ClusterInfoView styles. info: fgColor: *magenta sectionColor: *yellow # Help Menu styles help: fgColor: *foreground bgColor: *background keyColor: *green numKeyColor: *green sectionColor: *blue # Dialog styles. dialog: fgColor: *yellow bgColor: *background buttonFgColor: *foreground buttonBgColor: *background buttonFocusFgColor: *foreground buttonFocusBgColor: *purple labelFgColor: *magenta fieldFgColor: *darkBlue frame: # Borders styles. border: fgColor: *darkGray focusColor: *darkGray menu: fgColor: *foreground keyColor: *magenta # Used for favorite namespaces numKeyColor: *green # CrumbView attributes for history navigation. crumbs: fgColor: *yellow bgColor: *backgroundOpaque activeColor: *purple # Resource status and update styles status: newColor: *blue modifyColor: *purple addColor: *green pendingColor: *orange errorColor: *magenta highlightColor: *blue killColor: *magenta completedColor: *darkBlue # Border title styles. title: fgColor: *purple bgColor: *background highlightColor: *yellow counterColor: *green filterColor: *orange # Specific views styles views: # Charts skins... charts: bgColor: *background dialBgColor: *background chartBgColor: *backgroundOpaque defaultDialColors: - *blue - *magenta defaultChartColors: - *blue - *magenta resourceColors: batch/v1/jobs: - *blue - *magenta v1/persistentvolumes: - *blue - *magenta cpu: - *blue - *magenta mem: - *blue - *magenta v1/events: - *blue - *magenta v1/pods: - *blue - *magenta # TableView attributes. table: fgColor: *foreground bgColor: *background cursorFgColor: *foreground cursorBgColor: *backgroundOpaque markColor: *magenta # Header row styles. header: fgColor: *foreground bgColor: *backgroundOpaque sorterColor: *magenta # Xray view attributes. xray: fgColor: *foreground bgColor: *background cursorColor: *blue cursorTextColor: *foreground graphicColor: *blue # YAML info styles. yaml: keyColor: *green colonColor: *magenta valueColor: *foreground # Logs styles. logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *backgroundOpaque toggleOnColor: *green toggleOffColor: *magenta ================================================ FILE: skins/narsingh.yaml ================================================ # ----------------------------------------------------------------------------- # Narsingh skin # ----------------------------------------------------------------------------- # Skin... k9s: # General K9s styles body: fgColor: "#97979b" bgColor: "#282a36" logoColor: "#5af78e" prompt: fgColor: "#97979b" bgColor: "#282a36" suggestColor: "#5af78e" info: fgColor: white sectionColor: "#5af78e" dialog: fgColor: "#97979b" bgColor: "#282a36" buttonFgColor: "#97979b" buttonBgColor: "#282a36" buttonFocusFgColor: "#97979b" buttonFocusBgColor: "#5af78e" labelFgColor: "#97979b" fieldFgColor: "#5af78e" frame: border: fgColor: "#5af78e" focusColor: "#5af78e" menu: fgColor: white keyColor: "#57c7ff" numKeyColor: "#ff6ac1" crumbs: fgColor: "#282a36" bgColor: white activeColor: "#f3f99d" status: newColor: "#eff0eb" modifyColor: "#5af78e" addColor: "#57c7ff" errorColor: "#ff5c57" highlightColor: "#f3f99d" killColor: mediumpurple completedColor: gray title: fgColor: "#5af78e" bgColor: "#282a36" highlightColor: white counterColor: white filterColor: "#57c7ff" views: # Charts skins... charts: bgColor: default defaultDialColors: - "#57c7ff" - "#ff5c57" defaultChartColors: - "#57c7ff" - "#ff5c57" table: fgColor: "#57c7ff" bgColor: "#282a36" markColor: darkgoldenrod header: fgColor: white bgColor: "#282a36" sorterColor: orange xray: fgColor: "#57c7ff" bgColor: "#282a36" cursorColor: "#5af78e" graphicColor: darkgoldenrod showIcons: false yaml: keyColor: "#ff5c57" colonColor: white valueColor: "#f3f99d" logs: fgColor: white bgColor: "#282a36" indicator: fgColor: white bgColor: "#282a36" toggleOnColor: "#ff5c57" toggleOffColor: "#f3f99d" ================================================ FILE: skins/nightfox.yaml ================================================ # ----------------------------------------------------------------------------- # K9s Nightfox Theme # Based on the Nightfox.nvim color scheme: # https://github.com/EdenEast/nightfox.nvim # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#cdcecf" background: &background "#192330" current_line: ¤t_line "#2b3b51" selection: &selection "#2b3b51" comment: &comment "#738091" cyan: &cyan "#63cdcf" green: &green "#81b29a" orange: &orange "#f4a261" magenta: &magenta "#9d79d6" blue: &blue "#719cd6" red: &red "#c94f6d" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *selection cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *cyan xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: skins/nord.yaml ================================================ # ----------------------------------------------------------------------------- # Nord skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#DADEE8" background: &background "#30343F" current_line: ¤t_line "#383D4A" selection: &selection "#D9DEE8" comment: &comment "#8891A7" cyan: &cyan "#88C0D0" green: &green "#A3BE8C" orange: &orange "#D08770" blue: &blue "#81A1C1" magenta: &magenta "#B48EAD" red: &red "#BF616A" yellow: &yellow "#EBCB8B" # Skin... k9s: # General K9s styles body: fgColor: *foreground bgColor: default logoColor: *magenta # Command prompt styles prompt: fgColor: *foreground bgColor: *background suggestColor: *orange # ClusterInfoView styles. info: fgColor: *blue sectionColor: *foreground # Dialog styles. dialog: fgColor: *foreground bgColor: default buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: *yellow buttonFocusBgColor: *blue labelFgColor: *orange fieldFgColor: *foreground frame: # Borders styles. border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *blue # Used for favorite namespaces numKeyColor: *blue # CrumbView attributes for history navigation. crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line # Resource status and update styles status: newColor: *cyan modifyColor: *magenta addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment # Border title styles. title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *magenta filterColor: *blue views: # Charts skins... charts: bgColor: default defaultDialColors: - *magenta - *red defaultChartColors: - *magenta - *red # TableView attributes. table: fgColor: *foreground bgColor: default # Header row styles. header: fgColor: *foreground bgColor: default sorterColor: *cyan # Xray view attributes. xray: fgColor: *foreground bgColor: default cursorColor: *current_line graphicColor: *magenta showIcons: false # YAML info styles. yaml: keyColor: *blue colonColor: *magenta valueColor: *foreground # Logs styles. logs: fgColor: *foreground bgColor: default indicator: fgColor: *foreground bgColor: *magenta toggleOnColor: *magenta toggleOffColor: *blue help: fgColor: *foreground bgColor: *background indicator: fgColor: *red ================================================ FILE: skins/one-dark.yaml ================================================ # ----------------------------------------------------------------------------- # OneDark Skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#abb2bf" background: &background "#282c34" black: &black "#080808" blue: &blue "#61afef" green: &green "#98c379" grey: &grey "#abb2bf" orange: &orange "#ffb86c" purple: &purple "#c678dd" red: &red "#e06370" yellow: &yellow "#e5c07b" yellow_bright: &yellow_bright "#d19a66" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *green prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *grey sectionColor: *green help: fgColor: *foreground bgColor: *background keyColor: *yellow numKeyColor: *blue sectionColor: *purple dialog: fgColor: *black bgColor: *background buttonFgColor: *foreground buttonBgColor: *green buttonFocusFgColor: *black buttonFocusBgColor: *blue labelFgColor: *orange fieldFgColor: *blue frame: border: fgColor: *green focusColor: *green menu: fgColor: *grey keyColor: *yellow numKeyColor: *yellow crumbs: fgColor: *black bgColor: *green activeColor: *yellow status: newColor: *blue modifyColor: *green addColor: *grey pendingColor: *orange errorColor: *red highlightColor: *yellow killColor: *purple completedColor: *grey title: fgColor: *blue bgColor: *background highlightColor: *purple counterColor: *foreground filterColor: *blue views: charts: bgColor: *background defaultDialColors: - *green - *red defaultChartColors: - *green - *red table: fgColor: *yellow bgColor: *background cursorFgColor: *black cursorBgColor: *blue markColor: *yellow_bright header: fgColor: *grey bgColor: *background sorterColor: *orange xray: fgColor: *blue bgColor: *background cursorColor: *foreground graphicColor: *yellow_bright showIcons: false yaml: keyColor: *red colonColor: *grey valueColor: *grey logs: fgColor: *grey bgColor: *background indicator: fgColor: *blue bgColor: *background toggleOnColor: *red toggleOffColor: *grey help: fgColor: *grey bgColor: *background indicator: fgColor: *blue ================================================ FILE: skins/red.yaml ================================================ # ----------------------------------------------------------------------------- # Red skin # ----------------------------------------------------------------------------- # Skin... k9s: body: fgColor: red bgColor: black logoColor: red prompt: fgColor: red bgColor: black suggestColor: red info: fgColor: red sectionColor: red dialog: fgColor: red bgColor: black buttonFgColor: black buttonBgColor: red buttonFocusFgColor: white buttonFocusBgColor: red labelFgColor: red fieldFgColor: red frame: border: fgColor: red focusColor: red menu: fgColor: white keyColor: red numKeyColor: red crumbs: fgColor: black bgColor: red activeColor: red status: newColor: red modifyColor: greenyellow addColor: white errorColor: red pendingColor: darkred highlightColor: red killColor: red completedColor: gray title: fgColor: red highlightColor: red counterColor: red filterColor: red views: charts: bgColor: black defaultDialColors: - linegreen - redred defaultChartColors: - linegreen - redred table: fgColor: red bgColor: black cursorFgColor: black cursorBgColor: red markColor: darkgoldenrod header: fgColor: red bgColor: black sorterColor: red xray: fgColor: red bgColor: black cursorColor: red graphicColor: darkgoldenrod showIcons: false yaml: keyColor: red colonColor: white valueColor: red logs: fgColor: white bgColor: black indicator: fgColor: red bgColor: black toggleOnColor: red toggleOffColor: white ================================================ FILE: skins/rose-pine-dawn.yaml ================================================ # ----------------------------------------------------------------------------- # Rose Pine Dawn # https://rosepinetheme.com/palette/ingredients/ # ----------------------------------------------------------------------------- # text: &text "#575279" base: &base "#faf4ed" overlay: &overlay "#f2e9e1" muted: &muted "#9893a5" rose: &rose "#d7827e" pine: &pine "#286983" gold: &gold "#ea9d34" iris: &iris "#907aa9" love: &love "#b4637a" # Skin... k9s: # General K9s styles body: fgColor: *text bgColor: *base logoColor: *iris # Command prompt styles prompt: fgColor: *text bgColor: *base suggestColor: *iris # ClusterInfoView styles. info: fgColor: *iris sectionColor: *text # Dialog styles. dialog: fgColor: *text bgColor: *base buttonFgColor: *text buttonBgColor: *iris buttonFocusFgColor: *gold buttonFocusBgColor: *iris labelFgColor: *gold fieldFgColor: *text frame: # Borders styles. border: fgColor: *overlay focusColor: *overlay menu: fgColor: *text keyColor: *iris # Used for favorite namespaces numKeyColor: *iris # CrumbView attributes for history navigation. crumbs: fgColor: *text bgColor: *overlay activeColor: *overlay # Resource status and update styles status: newColor: *rose modifyColor: *iris addColor: *pine errorColor: *love highlightcolor: *gold killColor: *muted completedColor: *muted # Border title styles. title: fgColor: *text bgColor: *overlay highlightColor: *gold counterColor: *iris filterColor: *iris views: # Charts skins... charts: bgColor: default defaultDialColors: - *iris - *love defaultChartColors: - *iris - *love # TableView attributes. table: fgColor: *text bgColor: *base # Header row styles. header: fgColor: *text bgColor: *base sorterColor: *rose # Xray view attributes. xray: fgColor: *text bgColor: *base cursorColor: *overlay graphicColor: *iris showIcons: false # YAML info styles. yaml: keyColor: *iris colonColor: *iris valueColor: *text # Logs styles. logs: fgColor: *text bgColor: *base indicator: fgColor: *text bgColor: *iris ================================================ FILE: skins/rose-pine-moon.yaml ================================================ # ----------------------------------------------------------------------------- # Rose Pine Main # https://rosepinetheme.com/palette/ingredients/ # ----------------------------------------------------------------------------- # text: &text "#e0def4" base: &base "#232136" overlay: &overlay "#393552" muted: &muted "#6e6a86" rose: &rose "#ea9a97" pine: &pine "#3e8fb0" gold: &gold "#f6c177" iris: &iris "#c4a7e7" love: &love "#eb6f92" # Skin... k9s: # General K9s styles body: fgColor: *text bgColor: *base logoColor: *iris # Command prompt styles prompt: fgColor: *text bgColor: *base suggestColor: *iris # ClusterInfoView styles. info: fgColor: *iris sectionColor: *text # Dialog styles. dialog: fgColor: *text bgColor: *base buttonFgColor: *text buttonBgColor: *iris buttonFocusFgColor: *gold buttonFocusBgColor: *iris labelFgColor: *gold fieldFgColor: *text frame: # Borders styles. border: fgColor: *overlay focusColor: *overlay menu: fgColor: *text keyColor: *iris # Used for favorite namespaces numKeyColor: *iris # CrumbView attributes for history navigation. crumbs: fgColor: *text bgColor: *overlay activeColor: *overlay # Resource status and update styles status: newColor: *rose modifyColor: *iris addColor: *pine errorColor: *love highlightcolor: *gold killColor: *muted completedColor: *muted # Border title styles. title: fgColor: *text bgColor: *overlay highlightColor: *gold counterColor: *iris filterColor: *iris views: # Charts skins... charts: bgColor: default defaultDialColors: - *iris - *love defaultChartColors: - *iris - *love # TableView attributes. table: fgColor: *text bgColor: *base # Header row styles. header: fgColor: *text bgColor: *base sorterColor: *rose # Xray view attributes. xray: fgColor: *text bgColor: *base cursorColor: *overlay graphicColor: *iris showIcons: false # YAML info styles. yaml: keyColor: *iris colonColor: *iris valueColor: *text # Logs styles. logs: fgColor: *text bgColor: *base indicator: fgColor: *text bgColor: *iris ================================================ FILE: skins/rose-pine.yaml ================================================ # ----------------------------------------------------------------------------- # Rose Pine skin # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#e0def4" background: &background "#191724" current_line: ¤t_line "#26233a" selection: &selection "#26233a" comment: &comment "#6272a4" cyan: &cyan "#ebbcba" green: &green "#31748f" orange: &orange "#f6c177" pink: &pink "#c4a7e7" purple: &purple "#c4a7e7" red: &red "#eb6f92" yellow: &yellow "#f6c177" # Skin... k9s: # General K9s styles body: fgColor: *foreground bgColor: *background logoColor: *purple # Command prompt styles prompt: fgColor: *foreground bgColor: *background suggestColor: *purple # ClusterInfoView styles. info: fgColor: *pink sectionColor: *foreground # Dialog styles. dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *purple buttonFocusFgColor: *yellow buttonFocusBgColor: *pink labelFgColor: *orange fieldFgColor: *foreground frame: # Borders styles. border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *pink # Used for favorite namespaces numKeyColor: *pink # CrumbView attributes for history navigation. crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line # Resource status and update styles status: newColor: *cyan modifyColor: *purple addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment # Border title styles. title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *purple filterColor: *pink views: # Charts skins... charts: bgColor: default defaultDialColors: - *purple - *red defaultChartColors: - *purple - *red # TableView attributes. table: fgColor: *foreground bgColor: *background # Header row styles. header: fgColor: *foreground bgColor: *background sorterColor: *cyan # Xray view attributes. xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *purple showIcons: false # YAML info styles. yaml: keyColor: *pink colonColor: *purple valueColor: *foreground # Logs styles. logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *purple toggleOnColor: *green toggleOffColor: *selection ================================================ FILE: skins/snazzy.yaml ================================================ # ----------------------------------------------------------------------------- # Snazzy skin # ----------------------------------------------------------------------------- # Skin... k9s: body: fgColor: "#97979b" bgColor: "#282a36" logoColor: "#5af78e" prompt: fgColor: "#97979b" bgColor: "#282a36" suggestColor: "#5af78e" info: fgColor: white sectionColor: "#5af78e" dialog: fgColor: "#97979b" bgColor: "#282a36" buttonFgColor: "#97979b" buttonBgColor: "#282a36" buttonFocusFgColor: "#97979b" buttonFocusBgColor: "#5af78e" labelFgColor: "#97979b" fieldFgColor: "#5af78e" frame: border: fgColor: "#5af78e" focusColor: "#5af78e" menu: fgColor: white keyColor: "#57c7ff" numKeyColor: "#ff6ac1" crumbs: fgColor: "#282a36" bgColor: white activeColor: "#f3f99d" status: newColor: "#eff0eb" modifyColor: "#5af78e" addColor: "#57c7ff" errorColor: "#ff5c57" highlightColor: "#f3f99d" killColor: mediumpurple completedColor: gray title: fgColor: "#5af78e" bgColor: "#282a36" highlightColor: white counterColor: white filterColor: "#57c7ff" views: # Charts skins... charts: bgColor: default defaultDialColors: - "#57c7ff" - "#ff5c57" defaultChartColors: - "#57c7ff" - "#ff5c57" table: fgColor: "#57c7ff" bgColor: "#282a36" markColor: darkgoldenrod header: fgColor: white bgColor: "#282a36" sorterColor: orange xray: fgColor: "#57c7ff" bgColor: "#282a36" cursorColor: "#5af78e" graphicColor: darkgoldenrod showIcons: false yaml: keyColor: "#ff5c57" colonColor: white valueColor: "#f3f99d" logs: fgColor: white bgColor: "#282a36" indicator: fgColor: white bgColor: "#282a36" toggleOnColor: "#ff5c57" toggleOffColor: white ================================================ FILE: skins/solarized-16.yaml ================================================ # K9s Solarized Skin Contributed by [@graelo](graelo@grael.cc) # # The table below is extracted from # and joined with both the ascii standard names from # and the solarized color names from # # "black": ColorBlack, black base02 # "maroon": ColorMaroon, red red # "green": ColorGreen, green green # "olive": ColorOlive, yellow yellow # "navy": ColorNavy, blue blue # "purple": ColorPurple, magenta magenta # "teal": ColorTeal, cyan cyan # "silver": ColorSilver, white base2 # "gray": ColorGray, brightblack base03 # "red": ColorRed, brightred orange # "lime": ColorLime, brightgreen base01 # "yellow": ColorYellow, brightyellow base00 # "blue": ColorBlue, brightblue base0 # "fuchsia": ColorFuchsia, brightmagenta violet # "aqua": ColorAqua, brightcyan base1 # "white": ColorWhite, brightwhite base3 base03: &base03 gray # base03 brightblack base02: &base02 black # base02 black base01: &base01 lime # base01 brightgreen base00: &base00 yellow # base00 brightyellow base0: &base0 blue # base0 brightblue base1: &base1 aqua # base1 brightcyan base2: &base2 silver # base2 white base3: &base3 white # base3 brightwhite yellow: &yellow olive # accent yellow #b58900 orange: &orange red # accent orange #cb4b16 red: &red maroon # accent red #dc322f magenta: &magenta purple # accent magenta #d33682 violet: &violet fuchsia # accent violet #6c71c4 blue: &blue navy # accent blue #268bd2 cyan: &cyan teal # accent cyan #2aa198 green: &green green # accent green #859900 background: &background default # transparent foreground: &foreground yellow # base00 current_line: ¤t_line white # base2 selection: &selection silver # base2 comment: &comment aqua # base1 k9s: body: fgColor: *foreground bgColor: *background logoColor: *magenta prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *blue sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: *base2 buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *foreground menu: fgColor: *foreground keyColor: *blue numKeyColor: *green crumbs: fgColor: *base2 bgColor: *base0 activeColor: *blue status: newColor: *base00 modifyColor: *blue addColor: *yellow errorColor: *red highlightColor: *orange killColor: *violet completedColor: *green title: fgColor: *foreground bgColor: *background highlightColor: *blue counterColor: *magenta filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *base2 cursorBgColor: *background markColor: *magenta header: fgColor: *foreground bgColor: *background sorterColor: *magenta xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *green colonColor: *base02 valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection ================================================ FILE: skins/solarized-dark.yaml ================================================ # ----------------------------------------------------------------------------- # Solarized Dark Skin # Based on: K9s Solarized Dark Skin # Author: [@danmikita](danmikita@gmail.com) # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#839495" background: &background "#002833" current_line: ¤t_line "#003440" selection: &selection "#003440" comment: &comment "#6272a4" cyan: &cyan "#2aa197" green: &green "#859901" orange: &orange "#cb4a16" magenta: &magenta "#d33582" blue: &blue "#2aa198" red: &red "#dc312e" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: *foreground bgColor: *current_line activeColor: *current_line status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *current_line highlightColor: *orange counterColor: *blue filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: *selection cursorBgColor: *current_line header: fgColor: *foreground bgColor: *background sorterColor: *cyan xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: skins/solarized-light.yaml ================================================ # ----------------------------------------------------------------------------- # Solarized Light Skin # Author: [@leg100](louisgarman@gmail.com) # ----------------------------------------------------------------------------- # Styles... foreground: &foreground "#657b83" background: &background "#fdf6e3" current_line: ¤t_line "#eee8d5" selection: &selection "#eee8d5" comment: &comment "#93a1a1" cyan: &cyan "#2aa198" green: &green "#859900" yellow: &yellow "#b58900" orange: &orange "#cb4b16" magenta: &magenta "#d33682" blue: &blue "#268bd2" red: &red "#dc322f" # Skin... k9s: body: fgColor: *foreground bgColor: *background logoColor: *blue prompt: fgColor: *foreground bgColor: *background suggestColor: *orange info: fgColor: *magenta sectionColor: *foreground dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *magenta buttonFocusFgColor: white buttonFocusBgColor: *cyan labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *foreground menu: fgColor: *foreground keyColor: *magenta numKeyColor: *magenta crumbs: fgColor: white bgColor: *cyan activeColor: *yellow status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange killColor: *comment completedColor: *comment title: fgColor: *foreground bgColor: *background highlightColor: *blue counterColor: *magenta filterColor: *magenta views: charts: bgColor: default defaultDialColors: - *blue - *red defaultChartColors: - *blue - *red table: fgColor: *foreground bgColor: *background cursorFgColor: white cursorBgColor: *background markColor: darkgoldenrod header: fgColor: *foreground bgColor: *background sorterColor: *cyan xray: fgColor: *foreground bgColor: *background cursorColor: *current_line graphicColor: *blue showIcons: false yaml: keyColor: *magenta colonColor: *blue valueColor: *foreground logs: fgColor: *foreground bgColor: *background indicator: fgColor: *foreground bgColor: *selection toggleOnColor: *magenta toggleOffColor: *blue ================================================ FILE: skins/stock.yaml ================================================ # ----------------------------------------------------------------------------- # Stock skin # ----------------------------------------------------------------------------- # Skin... k9s: body: fgColor: dodgerblue bgColor: black logoColor: orange prompt: fgColor: cadetblue bgColor: black suggestColor: dodgerblue info: fgColor: orange sectionColor: white dialog: fgColor: dodgerblue bgColor: black buttonFgColor: black buttonBgColor: dodgerblue buttonFocusFgColor: white buttonFocusBgColor: fuchsia labelFgColor: fuchsia fieldFgColor: dodgerblue frame: border: fgColor: dodgerblue focusColor: aqua menu: fgColor: white fgStyle: dim keyColor: dodgerblue numKeyColor: fuchsia crumbs: fgColor: black bgColor: steelblue activeColor: orange status: newColor: lightskyblue modifyColor: greenyellow addColor: white errorColor: orangered pendingColor: darkorange highlightColor: aqua killColor: mediumpurple completedColor: gray title: fgColor: aqua highlightColor: fuchsia counterColor: papayawhip filterColor: steelblue views: # Charts skins... charts: bgColor: black defaultDialColors: - linegreen - orangered defaultChartColors: - linegreen - orangered table: fgColor: blue bgColor: black cursorFgColor: black cursorBgColor: aqua markColor: darkgoldenrod header: fgColor: white bgColor: black sorterColor: orange xray: fgColor: blue bgColor: black cursorColor: aqua graphicColor: darkgoldenrod showIcons: false yaml: keyColor: steelblue colonColor: white valueColor: papayawhip logs: fgColor: white bgColor: black indicator: fgColor: dodgerblue bgColor: black toggleOnColor: papayawhip toggleOffColor: steelblue ================================================ FILE: skins/transparent.yaml ================================================ # ----------------------------------------------------------------------------- # Transparent skin # Preserve your terminal session background color # ----------------------------------------------------------------------------- # Skin... k9s: body: bgColor: default prompt: bgColor: default info: sectionColor: default dialog: bgColor: default labelFgColor: default fieldFgColor: default frame: crumbs: bgColor: default title: bgColor: default counterColor: default menu: fgColor: default views: charts: bgColor: default table: bgColor: default header: fgColor: default bgColor: default xray: bgColor: default logs: bgColor: default indicator: bgColor: default toggleOnColor: default toggleOffColor: default yaml: colonColor: default valueColor: default ================================================ FILE: skins/vercel.yaml ================================================ foreground: &foreground "#ffffff" background: &background "#000000" current_line: ¤t_line "#1a1a1a" selection: &selection "#e63946" comment: &comment "#555555" cyan: &cyan "#00bcd4" green: &green "#2ecc71" orange: &orange "#f4a261" magenta: &magenta "#9d0191" blue: &blue "#0070f3" red: &red "#e63946" k9s: body: fgColor: *foreground bgColor: *background logoColor: *red prompt: fgColor: *foreground bgColor: *background suggestColor: *red info: fgColor: *red sectionColor: *foreground help: fgColor: *foreground bgColor: *background keyColor: *red numKeyColor: *blue sectionColor: *green dialog: fgColor: *foreground bgColor: *background buttonFgColor: *foreground buttonBgColor: *red buttonFocusFgColor: *background buttonFocusBgColor: *red labelFgColor: *orange fieldFgColor: *foreground frame: border: fgColor: *selection focusColor: *current_line menu: fgColor: *foreground keyColor: *red numKeyColor: *red crumbs: fgColor: *foreground bgColor: *comment activeColor: *red status: newColor: *cyan modifyColor: *blue addColor: *green errorColor: *red highlightColor: *orange ================================================ FILE: snap/snapcraft.yaml ================================================ name: k9s base: core22 version: 'v0.50.18' 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. grade: stable confinement: classic architectures: - amd64 - arm64 - armhf - i386 apps: k9s: command: bin/k9s parts: build: plugin: go source: https://github.com/derailed/k9s source-type: git source-tag: $SNAPCRAFT_PROJECT_VERSION override-build: | make test make build install $SNAPCRAFT_PART_BUILD/execs/k9s -D $SNAPCRAFT_PART_INSTALL/bin/k9s build-packages: - build-essential build-snaps: - go ================================================ FILE: testdata/aliases/aliases.yaml ================================================ aliases: dp: deployments sec: v1/secrets jo: jobs cr: clusterroles crb: clusterrolebindings ro: roles rb: rolebindings np: networkpolicies