kx-cli 0.0.5__tar.gz → 0.0.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kx_cli-0.0.5/src/kx_cli.egg-info → kx_cli-0.0.6}/PKG-INFO +35 -6
- {kx_cli-0.0.5 → kx_cli-0.0.6}/README.md +33 -5
- {kx_cli-0.0.5 → kx_cli-0.0.6}/pyproject.toml +2 -1
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/describe.py +2 -1
- kx_cli-0.0.6/src/kx/commands/diagnostic.py +195 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/edit.py +2 -1
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/exec.py +2 -1
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/get.py +2 -1
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/logs.py +2 -1
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/port_forward.py +4 -1
- kx_cli-0.0.6/src/kx/commands/theme.py +23 -0
- kx_cli-0.0.6/src/kx/config.py +82 -0
- kx_cli-0.0.6/src/kx/console.py +654 -0
- kx_cli-0.0.6/src/kx/diagnostics.py +363 -0
- kx_cli-0.0.6/src/kx/errors.py +27 -0
- kx_cli-0.0.6/src/kx/graph.py +186 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/index.py +8 -14
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/main.py +178 -102
- kx_cli-0.0.6/src/kx/themes.py +149 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6/src/kx_cli.egg-info}/PKG-INFO +35 -6
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/SOURCES.txt +15 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/requires.txt +1 -0
- kx_cli-0.0.6/tests/test_cli_help.py +48 -0
- kx_cli-0.0.6/tests/test_config.py +183 -0
- kx_cli-0.0.6/tests/test_console.py +622 -0
- kx_cli-0.0.6/tests/test_delete.py +34 -0
- kx_cli-0.0.6/tests/test_diagnostic.py +241 -0
- kx_cli-0.0.6/tests/test_diagnostics.py +415 -0
- kx_cli-0.0.6/tests/test_error_handling.py +94 -0
- kx_cli-0.0.6/tests/test_events.py +69 -0
- kx_cli-0.0.6/tests/test_graph.py +233 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_index.py +3 -4
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_state.py +2 -3
- kx_cli-0.0.6/tests/test_theme.py +40 -0
- kx_cli-0.0.6/tests/test_themes.py +36 -0
- kx_cli-0.0.6/tests/test_tree.py +59 -0
- kx_cli-0.0.5/src/kx/config.py +0 -38
- kx_cli-0.0.5/src/kx/console.py +0 -368
- kx_cli-0.0.5/src/kx/graph.py +0 -231
- kx_cli-0.0.5/tests/test_config.py +0 -87
- kx_cli-0.0.5/tests/test_console.py +0 -202
- {kx_cli-0.0.5 → kx_cli-0.0.6}/LICENSE +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/setup.cfg +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/__init__.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/__init__.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/back.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/delete.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/drop.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/events.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/forward.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/labels.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/namespace.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/rollout.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/scale.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/state.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/tree.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/yaml.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/events.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/k8s.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/kinds.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/kubectl.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/state.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/types.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/dependency_links.txt +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/entry_points.txt +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/top_level.txt +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_back.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_cli_get.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_describe.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_drop.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_edit.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_exec.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_forward.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_get.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_kinds.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_labels.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_logs.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_namespace.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_port_forward.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_rollout.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_scale.py +0 -0
- {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_yaml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kx-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.6
|
|
4
4
|
Summary: kubectl wrapper with index-based resource selection
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -16,6 +16,7 @@ Provides-Extra: dev
|
|
|
16
16
|
Requires-Dist: ruff; extra == "dev"
|
|
17
17
|
Requires-Dist: pre-commit; extra == "dev"
|
|
18
18
|
Requires-Dist: pytest; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
20
|
Dynamic: license-file
|
|
20
21
|
|
|
21
22
|
<div align="center">
|
|
@@ -63,14 +64,17 @@ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespac
|
|
|
63
64
|
|
|
64
65
|
```
|
|
65
66
|
$ kx get pods
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
Pods · default · 3 items
|
|
68
|
+
X NAME READY STATUS RESTARTS AGE
|
|
69
|
+
1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
|
|
70
|
+
2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
|
|
71
|
+
3 postgres-0 1/1 Running 0 12d
|
|
70
72
|
```
|
|
71
73
|
|
|
72
74
|
All subsequent commands reference resources by their `X` index from the last `kx get`.
|
|
73
75
|
|
|
76
|
+
Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
|
|
77
|
+
|
|
74
78
|
### Commands
|
|
75
79
|
|
|
76
80
|
<!-- commands-table-start -->
|
|
@@ -89,7 +93,9 @@ All subsequent commands reference resources by their `X` index from the last `kx
|
|
|
89
93
|
| `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
|
|
90
94
|
| `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
|
|
91
95
|
| `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
|
|
96
|
+
| `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
|
|
92
97
|
| `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
|
|
98
|
+
| `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
|
|
93
99
|
| `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
|
|
94
100
|
| `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
|
|
95
101
|
| `kx back` | Navigate to the previous kx get result. |
|
|
@@ -128,7 +134,7 @@ kx delete 3
|
|
|
128
134
|
|
|
129
135
|
## State
|
|
130
136
|
|
|
131
|
-
`kx` maintains a history of up to 10 `kx get` results in `~/.
|
|
137
|
+
`kx` maintains a history of up to 10 `kx get` results in `~/.kx/state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
|
|
132
138
|
|
|
133
139
|
```
|
|
134
140
|
$ kx get pods # saves a new entry, cursor advances
|
|
@@ -139,6 +145,29 @@ $ kx state --all # lists all history entries and the current position
|
|
|
139
145
|
|
|
140
146
|
Use `kx state <position>` to jump directly to any history entry, and `kx drop <position>` to remove one.
|
|
141
147
|
|
|
148
|
+
## Configuration
|
|
149
|
+
|
|
150
|
+
`kx` reads `~/.kx/config.toml`; environment variables override file settings.
|
|
151
|
+
|
|
152
|
+
| Key | Env var | Default | Description |
|
|
153
|
+
| --- | --- | --- | --- |
|
|
154
|
+
| `max_history` | `KX_MAX_HISTORY` | `10` | Number of `kx get` results kept in history. |
|
|
155
|
+
| `shells` | `KX_SHELLS` (comma-separated) | `["bash", "sh"]` | Shell candidates for `kx exec`. |
|
|
156
|
+
| `no_color` | `KX_NO_COLOR` | `false` | Disable styled output (same as `--no-color`). |
|
|
157
|
+
| `theme` | `KX_THEME` | `"github-dark"` | Color theme for all output. |
|
|
158
|
+
|
|
159
|
+
Styled output is emitted only when stdout is a terminal — piped or redirected output is plain text, so `kx get pods | grep worker` stays clean. The [`NO_COLOR`](https://no-color.org/) convention is honored as well.
|
|
160
|
+
|
|
161
|
+
### Themes
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
kx theme # list available themes (indexed) with a preview of each
|
|
165
|
+
kx theme nord # persist a theme to ~/.kx/config.toml
|
|
166
|
+
kx theme 3 # same, selecting by index from the kx theme listing
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Prefab themes: `github-dark` (default), `dracula`, `nord`, `gruvbox`, `solarized-dark`, `catppuccin-mocha`, `tokyo-night`, `rose-pine`, `mono`, `light` (for light terminal backgrounds), and `plain` (no styling at all).
|
|
170
|
+
|
|
142
171
|
## Development
|
|
143
172
|
|
|
144
173
|
```bash
|
|
@@ -43,14 +43,17 @@ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespac
|
|
|
43
43
|
|
|
44
44
|
```
|
|
45
45
|
$ kx get pods
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
Pods · default · 3 items
|
|
47
|
+
X NAME READY STATUS RESTARTS AGE
|
|
48
|
+
1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
|
|
49
|
+
2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
|
|
50
|
+
3 postgres-0 1/1 Running 0 12d
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
All subsequent commands reference resources by their `X` index from the last `kx get`.
|
|
53
54
|
|
|
55
|
+
Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
|
|
56
|
+
|
|
54
57
|
### Commands
|
|
55
58
|
|
|
56
59
|
<!-- commands-table-start -->
|
|
@@ -69,7 +72,9 @@ All subsequent commands reference resources by their `X` index from the last `kx
|
|
|
69
72
|
| `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
|
|
70
73
|
| `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
|
|
71
74
|
| `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
|
|
75
|
+
| `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
|
|
72
76
|
| `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
|
|
77
|
+
| `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
|
|
73
78
|
| `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
|
|
74
79
|
| `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
|
|
75
80
|
| `kx back` | Navigate to the previous kx get result. |
|
|
@@ -108,7 +113,7 @@ kx delete 3
|
|
|
108
113
|
|
|
109
114
|
## State
|
|
110
115
|
|
|
111
|
-
`kx` maintains a history of up to 10 `kx get` results in `~/.
|
|
116
|
+
`kx` maintains a history of up to 10 `kx get` results in `~/.kx/state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
|
|
112
117
|
|
|
113
118
|
```
|
|
114
119
|
$ kx get pods # saves a new entry, cursor advances
|
|
@@ -119,6 +124,29 @@ $ kx state --all # lists all history entries and the current position
|
|
|
119
124
|
|
|
120
125
|
Use `kx state <position>` to jump directly to any history entry, and `kx drop <position>` to remove one.
|
|
121
126
|
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
`kx` reads `~/.kx/config.toml`; environment variables override file settings.
|
|
130
|
+
|
|
131
|
+
| Key | Env var | Default | Description |
|
|
132
|
+
| --- | --- | --- | --- |
|
|
133
|
+
| `max_history` | `KX_MAX_HISTORY` | `10` | Number of `kx get` results kept in history. |
|
|
134
|
+
| `shells` | `KX_SHELLS` (comma-separated) | `["bash", "sh"]` | Shell candidates for `kx exec`. |
|
|
135
|
+
| `no_color` | `KX_NO_COLOR` | `false` | Disable styled output (same as `--no-color`). |
|
|
136
|
+
| `theme` | `KX_THEME` | `"github-dark"` | Color theme for all output. |
|
|
137
|
+
|
|
138
|
+
Styled output is emitted only when stdout is a terminal — piped or redirected output is plain text, so `kx get pods | grep worker` stays clean. The [`NO_COLOR`](https://no-color.org/) convention is honored as well.
|
|
139
|
+
|
|
140
|
+
### Themes
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
kx theme # list available themes (indexed) with a preview of each
|
|
144
|
+
kx theme nord # persist a theme to ~/.kx/config.toml
|
|
145
|
+
kx theme 3 # same, selecting by index from the kx theme listing
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Prefab themes: `github-dark` (default), `dracula`, `nord`, `gruvbox`, `solarized-dark`, `catppuccin-mocha`, `tokyo-night`, `rose-pine`, `mono`, `light` (for light terminal backgrounds), and `plain` (no styling at all).
|
|
149
|
+
|
|
122
150
|
## Development
|
|
123
151
|
|
|
124
152
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kx-cli"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.6"
|
|
4
4
|
description = "kubectl wrapper with index-based resource selection"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -21,6 +21,7 @@ dev = [
|
|
|
21
21
|
"ruff",
|
|
22
22
|
"pre-commit",
|
|
23
23
|
"pytest",
|
|
24
|
+
"pytest-cov",
|
|
24
25
|
]
|
|
25
26
|
|
|
26
27
|
[project.scripts]
|
|
@@ -11,7 +11,8 @@ class DescribeCommand:
|
|
|
11
11
|
self.state = state
|
|
12
12
|
self.kubectl = kubectl
|
|
13
13
|
|
|
14
|
-
def execute(self, index: int, extra_args: list[str] =
|
|
14
|
+
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
15
|
+
extra_args = extra_args or []
|
|
15
16
|
name, namespace, kind = self.state.fields(index)
|
|
16
17
|
self.kubectl.run_interactive(
|
|
17
18
|
["describe", kind, name, "-n", namespace, *extra_args]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from kx.diagnostics import (
|
|
2
|
+
ContainerDiagnostic,
|
|
3
|
+
DiagnosticData,
|
|
4
|
+
DiagnosticReport,
|
|
5
|
+
DiagnosticsServiceProtocol,
|
|
6
|
+
Finding,
|
|
7
|
+
PodDiagnostic,
|
|
8
|
+
ReplicaHealth,
|
|
9
|
+
Severity,
|
|
10
|
+
)
|
|
11
|
+
from kx.kinds import Kind
|
|
12
|
+
from kx.state import StateServiceProtocol
|
|
13
|
+
|
|
14
|
+
_SUPPORTED_KINDS = {Kind.Deployment, Kind.StatefulSet, Kind.DaemonSet, Kind.Pod}
|
|
15
|
+
_RESTART_WARN_THRESHOLD = 5
|
|
16
|
+
|
|
17
|
+
_IMAGE_PULL_REASONS = {"ImagePullBackOff", "ErrImagePull", "InvalidImageName"}
|
|
18
|
+
_CONFIG_ERROR_REASONS = {"CreateContainerConfigError", "CreateContainerError"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiagnosticCommand:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
state: StateServiceProtocol,
|
|
25
|
+
diagnostics: DiagnosticsServiceProtocol,
|
|
26
|
+
):
|
|
27
|
+
self.state = state
|
|
28
|
+
self.diagnostics = diagnostics
|
|
29
|
+
|
|
30
|
+
def execute(self, index: int) -> DiagnosticReport:
|
|
31
|
+
name, namespace, kind = self.state.fields(index)
|
|
32
|
+
if kind not in _SUPPORTED_KINDS:
|
|
33
|
+
raise ValueError(f"diagnostic is not supported for '{kind}'.")
|
|
34
|
+
return build_report(self.diagnostics.gather(kind, name, namespace))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_report(data: DiagnosticData) -> DiagnosticReport:
|
|
38
|
+
findings: list[Finding] = []
|
|
39
|
+
|
|
40
|
+
if data.replicas is not None:
|
|
41
|
+
findings.extend(_replica_findings(data.replicas))
|
|
42
|
+
for pod in data.pods:
|
|
43
|
+
findings.extend(_pod_findings(pod))
|
|
44
|
+
findings.extend(_event_findings(data.warning_events))
|
|
45
|
+
|
|
46
|
+
# Prioritize: highest severity first, stable within a severity.
|
|
47
|
+
findings.sort(key=lambda f: f.severity, reverse=True)
|
|
48
|
+
verdict = max((f.severity for f in findings), default=Severity.OK)
|
|
49
|
+
|
|
50
|
+
return DiagnosticReport(
|
|
51
|
+
kind=data.kind,
|
|
52
|
+
name=data.name,
|
|
53
|
+
namespace=data.namespace,
|
|
54
|
+
verdict=verdict,
|
|
55
|
+
findings=findings,
|
|
56
|
+
pods=data.pods,
|
|
57
|
+
warning_events=data.warning_events,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _replica_findings(replicas: ReplicaHealth) -> list[Finding]:
|
|
62
|
+
findings: list[Finding] = []
|
|
63
|
+
gen = replicas.generation
|
|
64
|
+
observed = replicas.observed_generation
|
|
65
|
+
if gen is not None and observed is not None and observed < gen:
|
|
66
|
+
findings.append(
|
|
67
|
+
Finding(
|
|
68
|
+
Severity.CRITICAL,
|
|
69
|
+
f"Rollout stalled: observed generation {observed} behind spec generation {gen}",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if replicas.ready < replicas.desired:
|
|
73
|
+
severity = (
|
|
74
|
+
Severity.CRITICAL
|
|
75
|
+
if replicas.ready == 0 and replicas.desired > 0
|
|
76
|
+
else Severity.WARNING
|
|
77
|
+
)
|
|
78
|
+
findings.append(
|
|
79
|
+
Finding(
|
|
80
|
+
severity,
|
|
81
|
+
f"Only {replicas.ready}/{replicas.desired} replicas ready",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if replicas.available < replicas.desired:
|
|
85
|
+
findings.append(
|
|
86
|
+
Finding(
|
|
87
|
+
Severity.WARNING,
|
|
88
|
+
f"{replicas.available}/{replicas.desired} replicas available",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if replicas.updated < replicas.desired:
|
|
92
|
+
findings.append(
|
|
93
|
+
Finding(
|
|
94
|
+
Severity.WARNING,
|
|
95
|
+
f"Rollout in progress: {replicas.updated}/{replicas.desired} replicas updated",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return findings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pod_findings(pod: PodDiagnostic) -> list[Finding]:
|
|
102
|
+
findings: list[Finding] = []
|
|
103
|
+
|
|
104
|
+
for container in pod.containers:
|
|
105
|
+
findings.extend(_container_findings(pod.name, container))
|
|
106
|
+
|
|
107
|
+
if pod.phase == "Pending" and not pod.scheduling.schedulable:
|
|
108
|
+
detail = pod.scheduling.message or pod.scheduling.reason or "unschedulable"
|
|
109
|
+
findings.append(
|
|
110
|
+
Finding(Severity.CRITICAL, f"Pod {pod.name} unschedulable: {detail}")
|
|
111
|
+
)
|
|
112
|
+
elif pod.phase == "Pending":
|
|
113
|
+
findings.append(Finding(Severity.WARNING, f"Pod {pod.name} pending"))
|
|
114
|
+
elif pod.phase == "Failed":
|
|
115
|
+
findings.append(Finding(Severity.CRITICAL, f"Pod {pod.name} failed"))
|
|
116
|
+
elif (
|
|
117
|
+
pod.phase == "Running"
|
|
118
|
+
and pod.ready_containers < pod.total_containers
|
|
119
|
+
and not any(c.waiting_reason for c in pod.containers)
|
|
120
|
+
):
|
|
121
|
+
findings.append(
|
|
122
|
+
Finding(
|
|
123
|
+
Severity.WARNING,
|
|
124
|
+
f"Pod {pod.name}: {pod.ready_containers}/{pod.total_containers} containers ready",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return findings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _container_findings(pod_name: str, container: ContainerDiagnostic) -> list[Finding]:
|
|
131
|
+
findings: list[Finding] = []
|
|
132
|
+
reason = container.waiting_reason
|
|
133
|
+
|
|
134
|
+
if reason == "CrashLoopBackOff":
|
|
135
|
+
findings.append(
|
|
136
|
+
Finding(
|
|
137
|
+
Severity.CRITICAL,
|
|
138
|
+
f"CrashLoopBackOff in pod {pod_name} ({container.restart_count} restarts)",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
elif reason in _IMAGE_PULL_REASONS:
|
|
142
|
+
findings.append(
|
|
143
|
+
Finding(
|
|
144
|
+
Severity.CRITICAL,
|
|
145
|
+
f"Image pull failure ({reason}) in pod {pod_name}",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
elif reason in _CONFIG_ERROR_REASONS:
|
|
149
|
+
detail = container.waiting_message or reason
|
|
150
|
+
findings.append(
|
|
151
|
+
Finding(
|
|
152
|
+
Severity.CRITICAL,
|
|
153
|
+
f"Container config error in pod {pod_name}: {detail}",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
elif reason is not None:
|
|
157
|
+
findings.append(
|
|
158
|
+
Finding(
|
|
159
|
+
Severity.WARNING,
|
|
160
|
+
f"Container {container.name} in pod {pod_name} waiting: {reason}",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if "OOMKilled" in (container.terminated_reason, container.last_terminated_reason):
|
|
165
|
+
findings.append(Finding(Severity.CRITICAL, f"OOMKilled in pod {pod_name}"))
|
|
166
|
+
elif container.terminated_reason not in (None, "Completed") and container.exit_code:
|
|
167
|
+
findings.append(
|
|
168
|
+
Finding(
|
|
169
|
+
Severity.CRITICAL,
|
|
170
|
+
f"Container {container.name} in pod {pod_name} terminated: "
|
|
171
|
+
f"{container.terminated_reason} (exit {container.exit_code})",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if reason is None and container.restart_count >= _RESTART_WARN_THRESHOLD:
|
|
176
|
+
findings.append(
|
|
177
|
+
Finding(
|
|
178
|
+
Severity.WARNING,
|
|
179
|
+
f"Container {container.name} in pod {pod_name} "
|
|
180
|
+
f"restarted {container.restart_count} times",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return findings
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _event_findings(events) -> list[Finding]:
|
|
187
|
+
# The message is deliberately omitted: the WARNING EVENTS section renders it
|
|
188
|
+
# in full, so repeating it here only bloats the summary.
|
|
189
|
+
return [
|
|
190
|
+
Finding(
|
|
191
|
+
Severity.WARNING,
|
|
192
|
+
f"{event.reason} ×{event.count} on {event.kind}/{event.name}",
|
|
193
|
+
)
|
|
194
|
+
for event in events
|
|
195
|
+
]
|
|
@@ -7,6 +7,7 @@ class EditCommand:
|
|
|
7
7
|
self.state = state
|
|
8
8
|
self.kubectl = kubectl
|
|
9
9
|
|
|
10
|
-
def execute(self, index: int, extra_args: list[str] =
|
|
10
|
+
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
11
|
+
extra_args = extra_args or []
|
|
11
12
|
name, namespace, kind = self.state.fields(index)
|
|
12
13
|
self.kubectl.run_interactive(["edit", kind, name, "-n", namespace, *extra_args])
|
|
@@ -17,8 +17,9 @@ class ExecCommand:
|
|
|
17
17
|
self.shells = shells
|
|
18
18
|
|
|
19
19
|
def execute(
|
|
20
|
-
self, index: int, cmd: list[str] | None, extra_args: list[str] =
|
|
20
|
+
self, index: int, cmd: list[str] | None, extra_args: list[str] | None = None
|
|
21
21
|
) -> None:
|
|
22
|
+
extra_args = extra_args or []
|
|
22
23
|
name, namespace, kind = self.state.fields(index)
|
|
23
24
|
if kind != Kind.Pod:
|
|
24
25
|
raise ValueError("exec is only supported for pods.")
|
|
@@ -28,8 +28,9 @@ class GetCommand:
|
|
|
28
28
|
self,
|
|
29
29
|
resource: str,
|
|
30
30
|
filter_term: str | None = None,
|
|
31
|
-
extra_args: list[str] =
|
|
31
|
+
extra_args: list[str] | None = None,
|
|
32
32
|
) -> str:
|
|
33
|
+
extra_args = extra_args or []
|
|
33
34
|
output = self.kubectl.run(["get", resource, *extra_args])
|
|
34
35
|
if filter_term:
|
|
35
36
|
output = self.index.filter(output, filter_term)
|
|
@@ -12,7 +12,8 @@ class LogsCommand:
|
|
|
12
12
|
self.state = state
|
|
13
13
|
self.kubectl = kubectl
|
|
14
14
|
|
|
15
|
-
def execute(self, index: int, extra_args: list[str] =
|
|
15
|
+
def execute(self, index: int, extra_args: list[str] | None = None) -> None:
|
|
16
|
+
extra_args = extra_args or []
|
|
16
17
|
name, namespace, kind = self.state.fields(index)
|
|
17
18
|
if kind == Kind.Pod:
|
|
18
19
|
self.kubectl.run_interactive(["logs", name, "-n", namespace, *extra_args])
|
|
@@ -17,7 +17,10 @@ class PortForwardCommand:
|
|
|
17
17
|
self.kubectl = kubectl
|
|
18
18
|
self.state = state
|
|
19
19
|
|
|
20
|
-
def execute(
|
|
20
|
+
def execute(
|
|
21
|
+
self, index: int, port: str, extra_args: list[str] | None = None
|
|
22
|
+
) -> None:
|
|
23
|
+
extra_args = extra_args or []
|
|
21
24
|
name, namespace, kind = self.state.fields(index)
|
|
22
25
|
if kind not in _SUPPORTED_KINDS:
|
|
23
26
|
raise ValueError(f"port-forward is not supported for '{kind}'.")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
from kx.themes import THEMES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ThemeCommand:
|
|
7
|
+
def __init__(self, save: Callable[[str], None]):
|
|
8
|
+
self.save = save
|
|
9
|
+
|
|
10
|
+
def execute(self, name: str) -> str:
|
|
11
|
+
if name.isdigit():
|
|
12
|
+
names = list(THEMES)
|
|
13
|
+
position = int(name)
|
|
14
|
+
if not 1 <= position <= len(names):
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"Theme index {position} is out of range — {len(names)} themes "
|
|
17
|
+
f"(run 'kx theme' to list)."
|
|
18
|
+
)
|
|
19
|
+
name = names[position - 1]
|
|
20
|
+
if name not in THEMES:
|
|
21
|
+
raise ValueError(f"Unknown theme '{name}'. Run 'kx theme' to list themes.")
|
|
22
|
+
self.save(name)
|
|
23
|
+
return f"Theme set to '{name}'"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from kx.themes import DEFAULT_THEME, THEMES
|
|
8
|
+
|
|
9
|
+
_CONFIG_FILE = Path.home() / ".kx" / "config.toml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Config:
|
|
14
|
+
max_history: int = 10
|
|
15
|
+
shells: tuple[str, ...] = ("bash", "sh")
|
|
16
|
+
no_color: bool = False
|
|
17
|
+
theme: str = DEFAULT_THEME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> Config:
|
|
21
|
+
kwargs: dict = {}
|
|
22
|
+
|
|
23
|
+
if _CONFIG_FILE.exists():
|
|
24
|
+
try:
|
|
25
|
+
with open(_CONFIG_FILE, "rb") as f:
|
|
26
|
+
data = tomllib.load(f)
|
|
27
|
+
except tomllib.TOMLDecodeError as e:
|
|
28
|
+
raise SystemExit(f"kx: error reading {_CONFIG_FILE}: {e}")
|
|
29
|
+
|
|
30
|
+
if "max_history" in data:
|
|
31
|
+
kwargs["max_history"] = data["max_history"]
|
|
32
|
+
if "shells" in data:
|
|
33
|
+
kwargs["shells"] = tuple(data["shells"])
|
|
34
|
+
if "no_color" in data:
|
|
35
|
+
kwargs["no_color"] = data["no_color"]
|
|
36
|
+
if "theme" in data:
|
|
37
|
+
kwargs["theme"] = data["theme"]
|
|
38
|
+
|
|
39
|
+
if "KX_MAX_HISTORY" in os.environ:
|
|
40
|
+
try:
|
|
41
|
+
kwargs["max_history"] = int(os.environ["KX_MAX_HISTORY"])
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise SystemExit("kx: KX_MAX_HISTORY must be an integer")
|
|
44
|
+
|
|
45
|
+
if "KX_SHELLS" in os.environ:
|
|
46
|
+
kwargs["shells"] = tuple(os.environ["KX_SHELLS"].split(","))
|
|
47
|
+
|
|
48
|
+
if "KX_NO_COLOR" in os.environ:
|
|
49
|
+
value = os.environ["KX_NO_COLOR"].lower()
|
|
50
|
+
kwargs["no_color"] = value in {"1", "true", "yes", "on"}
|
|
51
|
+
|
|
52
|
+
if "KX_THEME" in os.environ:
|
|
53
|
+
kwargs["theme"] = os.environ["KX_THEME"]
|
|
54
|
+
|
|
55
|
+
if "theme" in kwargs and kwargs["theme"] not in THEMES:
|
|
56
|
+
raise SystemExit(
|
|
57
|
+
f"kx: unknown theme '{kwargs['theme']}' (run kx theme to list themes)"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return Config(**kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_theme(name: str) -> None:
|
|
64
|
+
"""Persist the theme choice to the config file.
|
|
65
|
+
|
|
66
|
+
Rewrites only the `theme = ...` line (or appends one) instead of
|
|
67
|
+
round-tripping the TOML, so user comments and formatting survive. Safe
|
|
68
|
+
because the config schema is flat: there are no tables a `theme` key
|
|
69
|
+
could belong to.
|
|
70
|
+
"""
|
|
71
|
+
line = f'theme = "{name}"'
|
|
72
|
+
if _CONFIG_FILE.exists():
|
|
73
|
+
text = _CONFIG_FILE.read_text()
|
|
74
|
+
new, count = re.subn(r"(?m)^theme\s*=.*$", line, text, count=1)
|
|
75
|
+
if count == 0:
|
|
76
|
+
if text and not text.endswith("\n"):
|
|
77
|
+
text += "\n"
|
|
78
|
+
new = text + line + "\n"
|
|
79
|
+
_CONFIG_FILE.write_text(new)
|
|
80
|
+
else:
|
|
81
|
+
_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
_CONFIG_FILE.write_text(line + "\n")
|