k8stools 4.0.2__tar.gz → 4.2.0__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.
- {k8stools-4.0.2 → k8stools-4.2.0}/CHANGELOG.md +79 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/CLAUDE.md +21 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/PKG-INFO +21 -3
- {k8stools-4.0.2 → k8stools-4.2.0}/README.md +20 -2
- {k8stools-4.0.2 → k8stools-4.2.0}/docs/TOOL_REFERENCE.md +13 -4
- {k8stools-4.0.2 → k8stools-4.2.0}/pyproject.toml +1 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/__init__.py +1 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/capture.py +4 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/composites.py +116 -22
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/fixtures/otel-demo.json +518 -3
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/k8s_tools.py +99 -17
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mock_state.py +11 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/conftest.py +4 -2
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_builtin_fixture.py +12 -7
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_capture.py +20 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_composites.py +12 -2
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_endpoints.py +1 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_events.py +28 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_k8s_tools_realk8s.py +10 -3
- k8stools-4.2.0/tests/test_live_writes.py +171 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_metrics.py +1 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mock_state.py +2 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_new_tools.py +69 -4
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_replicaset_tool.py +2 -1
- k8stools-4.2.0/tests/test_stuck_rollouts.py +297 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/uv.lock +1 -1
- {k8stools-4.0.2 → k8stools-4.2.0}/.claude/settings.local.json +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/.github/workflows/unit-tests.yml +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/.gitignore +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/.python-version +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/.vscode/mcp.json +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/.vscode/settings.json +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/LICENSE +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/chat-with-cluster.png +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/designs/mock-state-capture.md +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/docs/ROADMAP.md +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/envrc.template +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mcp_client.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mcp_server.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mock_tools.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/redaction.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_cluster_binding.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_custom_resources.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_duration_format.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_hpa.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_ingress.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_init_containers.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_k8s_tools.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_log_decoding.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mcp_client.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mcp_errors.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mock_tools.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_node_conditions.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_notes.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_redaction.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_toolsets.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_version.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_workload_history.py +0 -0
- {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_workloads.py +0 -0
|
@@ -3,6 +3,85 @@
|
|
|
3
3
|
All notable changes to k8stools. Versions follow [semantic versioning](https://semver.org/);
|
|
4
4
|
dates are release-tag dates.
|
|
5
5
|
|
|
6
|
+
## 4.2.0 — 2026-10-24
|
|
7
|
+
|
|
8
|
+
### Issues fixed
|
|
9
|
+
- [#28](https://github.com/BenedatLLC/k8stools/issues/28): get_pvc_summaries:
|
|
10
|
+
an unbound claim's events are not in its summary.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `PVCSummary.events`: for a claim that is not Bound, its events, newest
|
|
14
|
+
first, at most five (issue #28). Why a claim waits (`ProvisioningFailed:
|
|
15
|
+
storageclass ... not found`, `WaitForFirstConsumer`) was in `get_events`,
|
|
16
|
+
and the pods that mount it in `get_pvc_summaries`, so no one result tied the
|
|
17
|
+
reason to the pod. The tool makes one extra events call when any claim is
|
|
18
|
+
unbound, and none when all are bound. Replays fill it from the capture's
|
|
19
|
+
events, so older captures replay it too; captures don't store it.
|
|
20
|
+
- The `--mock` fixture has an unbound claim, `quote-cache`, whose storage
|
|
21
|
+
class doesn't exist, and a new `quote` Deployment whose only pod waits on
|
|
22
|
+
it. The scheduler's, PV controller's and Deployment controller's messages
|
|
23
|
+
for it were recorded from a real cluster with the same objects.
|
|
24
|
+
- `WorkloadHealth.claims` and `WorkloadReport.claims`: the unbound claims an
|
|
25
|
+
unscheduled pod mounts, each with its storage class and newest event, as
|
|
26
|
+
'PersistentVolumeClaim/cache: Pending, storage class fast-ssd;
|
|
27
|
+
ProvisioningFailed: storageclass.storage.k8s.io "fast-ssd" not found'. The
|
|
28
|
+
scheduler's reason, "pod has unbound immediate PersistentVolumeClaims", said
|
|
29
|
+
the pod waited on a claim but not why; the claim's events do. The composites
|
|
30
|
+
read claims only when some pod is unscheduled.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- `get_namespace_health` on the `--mock` fixture lists three unhealthy
|
|
34
|
+
workloads, not two: `Deployment/quote` (0/1) is new. Tests that pin the
|
|
35
|
+
mock's output will see it.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- The live test of replica set ordering failed on a cluster where a
|
|
39
|
+
Deployment had been rolled back: a rollback reuses an older replica set
|
|
40
|
+
under the newest revision number. It now accepts that when
|
|
41
|
+
`get_workload_history` marks the revision `reused`.
|
|
42
|
+
|
|
43
|
+
### Tests
|
|
44
|
+
- `tests/test_live_writes.py` creates the failures #27 and #28 describe (a
|
|
45
|
+
rollout whose new pod can't be scheduled, a claim on a missing storage
|
|
46
|
+
class) in a namespace of its own on a real cluster, and checks the tools
|
|
47
|
+
against the scheduler's and controllers' own messages. It writes to the
|
|
48
|
+
cluster, so it runs only with `K8STOOLS_LIVE_WRITE=1`. A third test has a
|
|
49
|
+
Deployment's pod mount a claim on a missing storage class, and checks that
|
|
50
|
+
the composites name the claim and the PV controller's event.
|
|
51
|
+
|
|
52
|
+
## 4.1.0 — 2026-10-21
|
|
53
|
+
|
|
54
|
+
### Issues fixed
|
|
55
|
+
- [#27](https://github.com/BenedatLLC/k8stools/issues/27): get_namespace_health
|
|
56
|
+
counts a workload with a stuck rollout as healthy.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- **A rollout that can't progress is not healthy** (issue #27). A Deployment
|
|
60
|
+
whose new pod couldn't be scheduled (an invalid nodeSelector on the ITBench
|
|
61
|
+
otel-demo) was listed healthy: its old pod was still ready, and a pod with no
|
|
62
|
+
node has no containers to be waiting. `get_namespace_health` now counts a
|
|
63
|
+
workload with a pod that has no node as unhealthy, and so is a Deployment
|
|
64
|
+
whose `Progressing` condition is `False` (e.g. `ProgressDeadlineExceeded`)
|
|
65
|
+
or whose `ReplicaFailure` is `True` (e.g. a ResourceQuota rejected its pods,
|
|
66
|
+
so there's no pod to find). `ready` still counts the pods serving.
|
|
67
|
+
Workloads not scheduled for the same reason are grouped in `common_failures`.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- `PodSummary.unscheduled`: why a pod has no node, from its `PodScheduled`
|
|
71
|
+
condition.
|
|
72
|
+
- `DeploymentSummary.conditions`: `Available`, `Progressing` and
|
|
73
|
+
`ReplicaFailure`, with reason, message and how long ago each changed.
|
|
74
|
+
- `WorkloadHealth` and `WorkloadReport` gain `unscheduled` (one line per pod:
|
|
75
|
+
how long, and the scheduler's reason) and `conditions` (a Deployment's
|
|
76
|
+
failing conditions, in its controller's words). `get_workload_report`
|
|
77
|
+
previously showed an unscheduled pod nowhere, since it has no instances.
|
|
78
|
+
- The `--mock` fixture has a `checkout` Deployment whose new pod can't be
|
|
79
|
+
scheduled, past its progress deadline, and every Deployment has conditions.
|
|
80
|
+
|
|
81
|
+
Captures made before 4.1.0 have neither field: an unscheduled pod is still
|
|
82
|
+
found (no node and no container statuses), with its reason "not recorded", and
|
|
83
|
+
`DeploymentSummary.conditions` is None.
|
|
84
|
+
|
|
6
85
|
## 4.0.2 — 2026-10-17
|
|
7
86
|
|
|
8
87
|
### Issues fixed
|
|
@@ -24,6 +24,7 @@ tests/
|
|
|
24
24
|
test_mock_state.py - Unit tests for MockState (filters, replicaset ordering, logs, replay clock)
|
|
25
25
|
test_capture.py - Capture serialization round-trip and capture-time redaction
|
|
26
26
|
test_k8s_tools_realk8s.py - Integration tests (real cluster, auto-skipped if unreachable)
|
|
27
|
+
test_live_writes.py - Integration tests that create the failure they check, in their own namespace (opt-in: K8STOOLS_LIVE_WRITE=1)
|
|
27
28
|
test_mock_tools.py - Tests for mock_tools module (incl. parity with k8s_tools.TOOLS)
|
|
28
29
|
test_events.py - Event aggregation (count/first_seen), incl. the events.k8s.io fallback
|
|
29
30
|
test_cluster_binding.py - Cluster selection: configure(), the shared binding, get_cluster_info, server flags
|
|
@@ -35,6 +36,7 @@ tests/
|
|
|
35
36
|
test_notes.py - Description budgets, notes on results and logs, notes in replay
|
|
36
37
|
test_composites.py - get_namespace_health / get_workload_report: semantics, grouping, bounds
|
|
37
38
|
test_init_containers.py - Init containers and native sidecars: the pod model, composites, capture, older captures
|
|
39
|
+
test_stuck_rollouts.py - Unscheduled pods and failing Deployment conditions make a workload unhealthy (#27)
|
|
38
40
|
test_hpa.py - get_hpa_summaries: metrics, conditions, at-max note, replay, composites
|
|
39
41
|
test_endpoints.py - get_endpoint_summaries and ServiceSummary endpoint counts
|
|
40
42
|
test_metrics.py - get_container_metrics / get_node_metrics: units, notes, the three replay cases
|
|
@@ -79,6 +81,10 @@ uv run pytest tests/test_k8s_tools.py tests/test_mock_tools.py -v
|
|
|
79
81
|
# Integration tests against a real cluster (skipped automatically if unreachable)
|
|
80
82
|
KUBECONFIG=~/.kube/your-config.yaml uv run pytest tests/test_k8s_tools_realk8s.py -v
|
|
81
83
|
|
|
84
|
+
# Integration tests that create objects (an unbound claim, a stuck rollout) in a
|
|
85
|
+
# k8stools-test-* namespace and delete it afterwards; skipped unless asked
|
|
86
|
+
K8STOOLS_LIVE_WRITE=1 uv run pytest tests/test_live_writes.py -v
|
|
87
|
+
|
|
82
88
|
# All tests
|
|
83
89
|
uv run pytest -v
|
|
84
90
|
```
|
|
@@ -183,6 +189,17 @@ otherwise assembles from five or six. Rules that keep them trustworthy:
|
|
|
183
189
|
running again after a termination) within `_RECOVERING_WINDOW` (24h) goes in
|
|
184
190
|
`recovering`, in full, most recent first, capped at 10 (`recovering_more` lines
|
|
185
191
|
after). A fixed window, stated in `NOTE_RECOVERING`; no "abnormal" threshold.
|
|
192
|
+
- **Not scheduled is not healthy** (#27): a pod with no node has no container
|
|
193
|
+
statuses, so nothing in it waits, and mid-rollout the old pods keep `ready >=
|
|
194
|
+
desired`. A pod with no node makes its workload unhealthy, with the
|
|
195
|
+
scheduler's reason (`PodSummary.unscheduled`), and so does a Deployment's
|
|
196
|
+
`Progressing=False` or `ReplicaFailure=True` (the latter with no pod at all).
|
|
197
|
+
`ready` stays the count of pods serving. Captures before 4.1.0 have no reason:
|
|
198
|
+
there a pod with no node and no container status counts, with "reason not
|
|
199
|
+
recorded" (not node alone: test captures leave running pods' node unset).
|
|
200
|
+
The unbound claims an unscheduled pod mounts go in `claims`, with the claim's
|
|
201
|
+
newest event: the scheduler's "unbound immediate PersistentVolumeClaims" says
|
|
202
|
+
nothing of why. Claims are read only when some pod is unscheduled.
|
|
186
203
|
- **Init containers apart** (#24): a completed init container (exit 0) is never
|
|
187
204
|
a termination; one that hasn't completed makes the workload unhealthy and is
|
|
188
205
|
named. `WorkloadReport.init_containers` is always serialized (`_keep_empty`), so
|
|
@@ -368,6 +385,10 @@ Invariants worth preserving when touching this code:
|
|
|
368
385
|
- **A pod not in the capture does not exist** for any tool. `mock_tools` no longer
|
|
369
386
|
synthesizes answers for unknown pod names, and `get_pod_container_statuses`
|
|
370
387
|
raises for one, as live.
|
|
388
|
+
- **An unbound claim's events are derived, not stored** (#28). Replay fills
|
|
389
|
+
`PVCSummary.events` from the capture's event records, so captures from before
|
|
390
|
+
the field replay it, and the capture drops it rather than store a copy that
|
|
391
|
+
could disagree with the event list.
|
|
371
392
|
- **Init container statuses: absent means unknown, `[]` means none** (#24). A pod
|
|
372
393
|
record's `init_container_statuses` key is written only when they were read;
|
|
373
394
|
replay turns a missing key into `init_containers=None` with
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: k8stools
|
|
3
|
-
Version: 4.0
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: Tools and an MCP server for building Kubernetes agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/BenedatLLC/k8stools
|
|
6
6
|
Project-URL: Changelog, https://github.com/BenedatLLC/k8stools/blob/main/CHANGELOG.md
|
|
@@ -367,7 +367,7 @@ it is in `investigate`. Every tool is in `all`.
|
|
|
367
367
|
|
|
368
368
|
| Tool | What it answers | T | I |
|
|
369
369
|
|---|---|:-:|:-:|
|
|
370
|
-
| `get_namespace_health` | What is wrong in a namespace, and where. A full entry for each unhealthy workload (last termination with the exit code's meaning, how long instances live, the restart back-off, memory limit vs request, when the template last changed), the same for each *recovering* one (ready, but restarted within the last 24 hours), and one line for each healthy one. Workloads failing the same way are grouped. | ✓ | ✓ |
|
|
370
|
+
| `get_namespace_health` | What is wrong in a namespace, and where. A full entry for each unhealthy workload (last termination with the exit code's meaning, how long instances live, the restart back-off, memory limit vs request, when the template last changed), pods that can't be scheduled and a Deployment's failing rollout conditions, the same for each *recovering* one (ready, but restarted within the last 24 hours), and one line for each healthy one. Workloads failing the same way are grouped. | ✓ | ✓ |
|
|
371
371
|
| `get_workload_report` | Everything about one workload in one call: images, resources and probes; each instance's state and last termination; deduplicated events; current and previous log tails; the last template change; the ConfigMaps and Secrets it uses; its Services, Ingress rules, HPA and resource usage. | ✓ | ✓ |
|
|
372
372
|
| `get_cluster_info` | Which cluster the tools are answering from: kubeconfig context, API server URL and version, or the capture being replayed. | ✓ | ✓ |
|
|
373
373
|
|
|
@@ -424,7 +424,7 @@ out-of-memory kill".
|
|
|
424
424
|
|---|---|:-:|:-:|
|
|
425
425
|
| `get_configmap_summaries` | ConfigMaps, like `kubectl get configmaps` | | ✓ |
|
|
426
426
|
| `get_configmap` | The full contents of one ConfigMap (redacted by the server) | | ✓ |
|
|
427
|
-
| `get_pvc_summaries` | PersistentVolumeClaims, like `kubectl get pvc`, with the pods that mount each one | | ✓ |
|
|
427
|
+
| `get_pvc_summaries` | PersistentVolumeClaims, like `kubectl get pvc`, with the pods that mount each one, and an unbound claim's events | | ✓ |
|
|
428
428
|
|
|
429
429
|
### Custom resources
|
|
430
430
|
|
|
@@ -766,7 +766,11 @@ application, with problems for an agent to find:
|
|
|
766
766
|
* a crash-looping `ad` service, mid-upgrade, whose Service has no ready backends;
|
|
767
767
|
* an `accounting` service that is ready but was OOM-killed 40 minutes ago, after
|
|
768
768
|
578 restarts, and whose previous log file is gone;
|
|
769
|
+
* a `checkout` rollout that can't finish: its new pod can't be scheduled (a node
|
|
770
|
+
selector no node matches), while the old pod still serves;
|
|
769
771
|
* a Deployment whose HPA is at its maximum;
|
|
772
|
+
* a new `quote` Deployment whose pod can't be scheduled, because the
|
|
773
|
+
PersistentVolumeClaim it mounts can't bind: its storage class doesn't exist;
|
|
770
774
|
* an Ingress with one rule to a missing Service;
|
|
771
775
|
* a cert-manager `Certificate` that isn't Ready;
|
|
772
776
|
* a DaemonSet, a CronJob and its Job, a StatefulSet with PVCs and a completed
|
|
@@ -1136,7 +1140,21 @@ uv run pytest -v
|
|
|
1136
1140
|
|
|
1137
1141
|
# Integration tests against a real cluster (skipped automatically if unreachable)
|
|
1138
1142
|
KUBECONFIG=~/.kube/your-config.yaml uv run pytest tests/test_k8s_tools_realk8s.py -v
|
|
1143
|
+
|
|
1144
|
+
# Integration tests that write to the cluster (skipped unless asked)
|
|
1145
|
+
K8STOOLS_LIVE_WRITE=1 KUBECONFIG=~/.kube/your-config.yaml uv run pytest tests/test_live_writes.py -v
|
|
1139
1146
|
```
|
|
1140
1147
|
|
|
1148
|
+
The integration tests above only read what the cluster has. The ones in
|
|
1149
|
+
`tests/test_live_writes.py` create the failures they check, such as a claim on a
|
|
1150
|
+
storage class that doesn't exist or a rollout whose new pod can't be scheduled,
|
|
1151
|
+
and compare the tools' output with the cluster's own messages. Each test creates
|
|
1152
|
+
a `k8stools-test-<random>` namespace and deletes it afterwards, so it needs
|
|
1153
|
+
permission to create and delete namespaces, Deployments and
|
|
1154
|
+
PersistentVolumeClaims. The tests pull `registry.k8s.io/pause`. Use a test
|
|
1155
|
+
cluster such as Minikube or kind. They take about 30 seconds. If a run is
|
|
1156
|
+
interrupted, remove what it left with
|
|
1157
|
+
`kubectl get ns -o name | grep k8stools-test- | xargs -r kubectl delete`.
|
|
1158
|
+
|
|
1141
1159
|
Planned and deferred work is in
|
|
1142
1160
|
[docs/ROADMAP.md](https://github.com/BenedatLLC/k8stools/blob/main/docs/ROADMAP.md).
|
|
@@ -146,7 +146,7 @@ it is in `investigate`. Every tool is in `all`.
|
|
|
146
146
|
|
|
147
147
|
| Tool | What it answers | T | I |
|
|
148
148
|
|---|---|:-:|:-:|
|
|
149
|
-
| `get_namespace_health` | What is wrong in a namespace, and where. A full entry for each unhealthy workload (last termination with the exit code's meaning, how long instances live, the restart back-off, memory limit vs request, when the template last changed), the same for each *recovering* one (ready, but restarted within the last 24 hours), and one line for each healthy one. Workloads failing the same way are grouped. | ✓ | ✓ |
|
|
149
|
+
| `get_namespace_health` | What is wrong in a namespace, and where. A full entry for each unhealthy workload (last termination with the exit code's meaning, how long instances live, the restart back-off, memory limit vs request, when the template last changed), pods that can't be scheduled and a Deployment's failing rollout conditions, the same for each *recovering* one (ready, but restarted within the last 24 hours), and one line for each healthy one. Workloads failing the same way are grouped. | ✓ | ✓ |
|
|
150
150
|
| `get_workload_report` | Everything about one workload in one call: images, resources and probes; each instance's state and last termination; deduplicated events; current and previous log tails; the last template change; the ConfigMaps and Secrets it uses; its Services, Ingress rules, HPA and resource usage. | ✓ | ✓ |
|
|
151
151
|
| `get_cluster_info` | Which cluster the tools are answering from: kubeconfig context, API server URL and version, or the capture being replayed. | ✓ | ✓ |
|
|
152
152
|
|
|
@@ -203,7 +203,7 @@ out-of-memory kill".
|
|
|
203
203
|
|---|---|:-:|:-:|
|
|
204
204
|
| `get_configmap_summaries` | ConfigMaps, like `kubectl get configmaps` | | ✓ |
|
|
205
205
|
| `get_configmap` | The full contents of one ConfigMap (redacted by the server) | | ✓ |
|
|
206
|
-
| `get_pvc_summaries` | PersistentVolumeClaims, like `kubectl get pvc`, with the pods that mount each one | | ✓ |
|
|
206
|
+
| `get_pvc_summaries` | PersistentVolumeClaims, like `kubectl get pvc`, with the pods that mount each one, and an unbound claim's events | | ✓ |
|
|
207
207
|
|
|
208
208
|
### Custom resources
|
|
209
209
|
|
|
@@ -545,7 +545,11 @@ application, with problems for an agent to find:
|
|
|
545
545
|
* a crash-looping `ad` service, mid-upgrade, whose Service has no ready backends;
|
|
546
546
|
* an `accounting` service that is ready but was OOM-killed 40 minutes ago, after
|
|
547
547
|
578 restarts, and whose previous log file is gone;
|
|
548
|
+
* a `checkout` rollout that can't finish: its new pod can't be scheduled (a node
|
|
549
|
+
selector no node matches), while the old pod still serves;
|
|
548
550
|
* a Deployment whose HPA is at its maximum;
|
|
551
|
+
* a new `quote` Deployment whose pod can't be scheduled, because the
|
|
552
|
+
PersistentVolumeClaim it mounts can't bind: its storage class doesn't exist;
|
|
549
553
|
* an Ingress with one rule to a missing Service;
|
|
550
554
|
* a cert-manager `Certificate` that isn't Ready;
|
|
551
555
|
* a DaemonSet, a CronJob and its Job, a StatefulSet with PVCs and a completed
|
|
@@ -915,7 +919,21 @@ uv run pytest -v
|
|
|
915
919
|
|
|
916
920
|
# Integration tests against a real cluster (skipped automatically if unreachable)
|
|
917
921
|
KUBECONFIG=~/.kube/your-config.yaml uv run pytest tests/test_k8s_tools_realk8s.py -v
|
|
922
|
+
|
|
923
|
+
# Integration tests that write to the cluster (skipped unless asked)
|
|
924
|
+
K8STOOLS_LIVE_WRITE=1 KUBECONFIG=~/.kube/your-config.yaml uv run pytest tests/test_live_writes.py -v
|
|
918
925
|
```
|
|
919
926
|
|
|
927
|
+
The integration tests above only read what the cluster has. The ones in
|
|
928
|
+
`tests/test_live_writes.py` create the failures they check, such as a claim on a
|
|
929
|
+
storage class that doesn't exist or a rollout whose new pod can't be scheduled,
|
|
930
|
+
and compare the tools' output with the cluster's own messages. Each test creates
|
|
931
|
+
a `k8stools-test-<random>` namespace and deletes it afterwards, so it needs
|
|
932
|
+
permission to create and delete namespaces, Deployments and
|
|
933
|
+
PersistentVolumeClaims. The tests pull `registry.k8s.io/pause`. Use a test
|
|
934
|
+
cluster such as Minikube or kind. They take about 30 seconds. If a run is
|
|
935
|
+
interrupted, remove what it left with
|
|
936
|
+
`kubectl get ns -o name | grep k8stools-test- | xargs -r kubectl delete`.
|
|
937
|
+
|
|
920
938
|
Planned and deferred work is in
|
|
921
939
|
[docs/ROADMAP.md](https://github.com/BenedatLLC/k8stools/blob/main/docs/ROADMAP.md).
|
|
@@ -134,9 +134,12 @@ over the pod's age is not a rate, and pointing to metrics
|
|
|
134
134
|
#### Returns
|
|
135
135
|
|
|
136
136
|
- **`namespace`** (`str`)
|
|
137
|
-
- **`workloads`** (`list[WorkloadHealth]`): the unhealthy workloads, by name. A workload is healthy when all desired pods are ready, no container is waiting, every init container has completed, and (for a Job) no exit failed. A completed init container's exit 0 is not a termination; one that hasn't completed is, and the entry's `notes` name it (e.g. "Init container 'migrate': Waiting (CrashLoopBackOff)"). Native sidecars count as app containers. Each has:
|
|
137
|
+
- **`workloads`** (`list[WorkloadHealth]`): the unhealthy workloads, by name. A workload is healthy when all desired pods are ready, no container is waiting, every init container has completed, every pod has a node, (for a Deployment) no condition is failing, and (for a Job) no exit failed. A completed init container's exit 0 is not a termination; one that hasn't completed is, and the entry's `notes` name it (e.g. "Init container 'migrate': Waiting (CrashLoopBackOff)"). Native sidecars count as app containers. Each has:
|
|
138
138
|
- **`workload`** (`str`): "Kind/name", e.g. "Deployment/ad", or "Pod/name" for a pod nothing owns.
|
|
139
|
-
- **`ready`**, **`desired`** (`int`): ready and desired pods; for a Job, 1/1 once it succeeded.
|
|
139
|
+
- **`ready`**, **`desired`** (`int`): ready and desired pods; for a Job, 1/1 once it succeeded. Mid-rollout this counts the pods serving, which may be the old revision's.
|
|
140
|
+
- **`unscheduled`** (`list[str]`): its pods that have no node, with how long and the scheduler's reason: "ad-7d9f-x2k: not scheduled for 3h12m: Unschedulable: 0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector." Such a pod has no containers, so it is in no other field. From a capture made before 4.1.0 the reason reads "reason not recorded"; a pod there counts as unscheduled when it has neither a node nor any container status.
|
|
141
|
+
- **`claims`** (`list[str]`): the claims those pods mount that aren't Bound, each with its status, storage class and newest event: 'PersistentVolumeClaim/cache: Pending, storage class fast-ssd; ProvisioningFailed: storageclass.storage.k8s.io "fast-ssd" not found'. The scheduler says only "pod has unbound immediate PersistentVolumeClaims"; why a claim is unbound is in the claim's events. A claim with none reads "no events recorded" (events expire, by default after an hour). Read from `get_pvc_summaries`, only when some pod is unscheduled; empty when claims can't be read or the capture has none.
|
|
142
|
+
- **`conditions`** (`list[str]`): a Deployment's failing conditions in its controller's words: `Progressing=False` (e.g. `ProgressDeadlineExceeded`: the rollout made no progress within `progressDeadlineSeconds`, 10 minutes by default) or `ReplicaFailure=True` (e.g. `FailedCreate`: a ResourceQuota or an admission webhook rejected its pods, so there may be no pod to show).
|
|
140
143
|
- **`restarts`** (`int`): container restarts across the workload's pods; absent when none of its pods could be found.
|
|
141
144
|
- **`last_termination`** (`Termination`): the most recent termination across its pods and containers:
|
|
142
145
|
- **`pod`**, **`container`**
|
|
@@ -156,7 +159,7 @@ over the pod's age is not a rate, and pointing to metrics
|
|
|
156
159
|
- **`ingress_problems`** (`list[str]`): Ingress rules that can't reach a pod: a backend that doesn't resolve, or one with no ready endpoints, e.g. "Ingress/shop: shop.example.com/old -> legacy:80: Service 'legacy' not found".
|
|
157
160
|
- **`recovering`** (`list[WorkloadHealth]`): workloads that pass the healthy rule (all ready, nothing waiting) but have a container that restarted within the last 24 hours: its last termination finished then, and it is running again. Same fields as `workloads`, with `last_termination` the most recent restart. Most recent first, at most 10. A Job's finished container and a completed init container are not restarts. "Ready" is not "healthy": a workload that restarts and recovers would otherwise be listed as healthy, and answers quote that word.
|
|
158
161
|
- **`recovering_more`** (`list[str]`): recovering workloads past the first 10, one line each, e.g. "Deployment/w10 1/1, 5 restarts, last terminated 11h0m ago: exit 137 (OOMKilled)".
|
|
159
|
-
- **`healthy`** (`list[str]`): one line per healthy workload (
|
|
162
|
+
- **`healthy`** (`list[str]`): one line per healthy workload (as defined under `workloads`, and no restart within 24 hours), e.g. "Deployment/cart 3/3", with what's worth knowing about it added: restarts, "HPA at max 3", or "memory at 99.8% of limit" when a container is at 90% or more of its memory limit (with the working-set note in `notes`).
|
|
160
163
|
- **`common_failures`** (`list[FailureGroup]`): two or more unhealthy workloads whose last terminations share an exit code, reason and memory shape. The signature also gives their instance lifetimes ("lifetimes 2s-20s"), which are shown rather than required to match: one workload's instances can live 7s, 20s and 2m on successive restarts. A shared signature is a fact worth checking for a common cause, not a conclusion.
|
|
161
164
|
- **`notes`** (`list[str]`)
|
|
162
165
|
|
|
@@ -182,6 +185,7 @@ those tools, so it answers the same way from a replayed capture.
|
|
|
182
185
|
#### Returns
|
|
183
186
|
|
|
184
187
|
- **`workload`**, **`ready`**, **`desired`**
|
|
188
|
+
- **`unscheduled`**, **`claims`**, **`conditions`** (`list[str]`): as in `get_namespace_health`.
|
|
185
189
|
- **`containers`** (`list[ContainerEssentials]`): per container of the focus pod: `image`, `requests`, `limits`, and `probes` (e.g. "liveness: httpGet :8080/healthz every 10s, fails after 3", or "none configured"). Native sidecars come after the app containers, with `sidecar: true`.
|
|
186
190
|
- **`init_containers`** (`list[ContainerEssentials]`): the focus pod's init containers, from its spec. Always present: `[]` means it has none.
|
|
187
191
|
- **`instances`** (`list[Instance]`): every pod's containers now: `pod_age` (time since the pod was created; when its restarts happened isn't recorded), `state`, `started` (time since the current instance started), `ready`, `restarts`, and `last_termination` as in `get_namespace_health`.
|
|
@@ -276,6 +280,7 @@ A list of PodSummary objects, each providing a summary of a pod's status with th
|
|
|
276
280
|
- **`age`** (`datetime.timedelta`): Age of the pod (current time minus creation timestamp).
|
|
277
281
|
- **`ip`** (`Optional[str]`): Pod IP address (None if not assigned).
|
|
278
282
|
- **`node`** (`Optional[str]`): Name of the node where the pod is running (None if not scheduled).
|
|
283
|
+
- **`unscheduled`** (`Optional[str]`): For a pod with no node, why: its `PodScheduled` condition's reason and message, e.g. "Unschedulable: 0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector.", or "no scheduling decision recorded yet" (`NOT_YET_SCHEDULED`). None once it has a node, and in captures made before 4.1.0.
|
|
279
284
|
- **`owner`** (`Optional[str]`): The pod's controlling owner, as "Kind/name" - the object that created and manages it, e.g. "DaemonSet/otel-collector-agent", "StatefulSet/valkey-cart", "Job/backup-29318400". None for a bare pod nobody manages. This is the direct owner only: a Deployment's pods are owned by one of its replica sets ("ReplicaSet/ad-7d9f8c6b5"), whose `owner_deployment` (`get_replicaset_summaries`) names the Deployment; likewise a Job's `owner` names its CronJob. Static (mirror) pods such as the control plane's report "Node/<node name>". Group pods into workloads by this field rather than by stripping name suffixes.
|
|
280
285
|
- **`notes`** (`list[str]`): Warnings that apply to this item; empty when none do. See [Notes in results](#notes-in-results).
|
|
281
286
|
|
|
@@ -439,6 +444,7 @@ A list of DeploymentSummary objects, each providing a summary of a deployment's
|
|
|
439
444
|
- **`up_to_date_replicas`** (`int`): Number of replicas that are up to date.
|
|
440
445
|
- **`available_replicas`** (`int`): Number of replicas that are available.
|
|
441
446
|
- **`age`** (`datetime.timedelta`): Age of the deployment (current time minus creation timestamp).
|
|
447
|
+
- **`conditions`** (`Optional[list[ResourceCondition]]`): Its status conditions, each with `type`, `status`, `reason`, `message` and `since` (time since its status last changed): `Available`; `Progressing` (`True` while a rollout progresses or once it completed, `False` with reason `ProgressDeadlineExceeded` when it stopped progressing); `ReplicaFailure` (`True` when pods couldn't be created, e.g. `FailedCreate` from a ResourceQuota). Mid-rollout `ready_replicas` counts the old revision's pods too, so these say whether the rollout itself is moving. None when a replayed capture didn't record them (made before 4.1.0).
|
|
442
448
|
|
|
443
449
|
### `get_replicaset_summaries`
|
|
444
450
|
|
|
@@ -1028,7 +1034,9 @@ namespace or all namespaces, similar to `kubectl get pvc`.
|
|
|
1028
1034
|
|
|
1029
1035
|
For each PVC, this also resolves which pods currently mount it (by scanning pod
|
|
1030
1036
|
volumes in the same scope), which is useful for spotting orphaned PVCs — claims
|
|
1031
|
-
with an empty `mounted_by` and no owning pod.
|
|
1037
|
+
with an empty `mounted_by` and no owning pod. A claim that is not Bound also
|
|
1038
|
+
carries its recent events, which say why it waits: they and the pods that
|
|
1039
|
+
mount it are in one result.
|
|
1032
1040
|
|
|
1033
1041
|
#### Parameters
|
|
1034
1042
|
|
|
@@ -1048,6 +1056,7 @@ A list of PVCSummary objects, each with the following fields:
|
|
|
1048
1056
|
- **`storage_class`** (`Optional[str]`): StorageClass backing the claim.
|
|
1049
1057
|
- **`mounted_by`** (`list[str]`): Names of pods (in the same scope) currently mounting this PVC. Empty for orphaned/unmounted claims.
|
|
1050
1058
|
- **`age`** (`datetime.timedelta`): Age of the PVC (current time minus creation timestamp).
|
|
1059
|
+
- **`events`** (`list[EventSummary]`): For a claim that is not Bound, its events, newest first, at most five (e.g. `ProvisioningFailed: storageclass.storage.k8s.io "fast-ssd" not found`, or `WaitForFirstConsumer`). Empty for a bound claim. In a replay, they come from the capture's event records.
|
|
1051
1060
|
|
|
1052
1061
|
### `get_events`
|
|
1053
1062
|
|
|
@@ -217,7 +217,10 @@ def capture_state(namespaces: Optional[list[str]] = None,
|
|
|
217
217
|
redactor(k8s_tools.get_workload_history(w.name, ns, kind)), captured_at))
|
|
218
218
|
state["cronjobs"] += _encode_all(redactor(k8s_tools.get_cronjob_summaries(ns)), captured_at)
|
|
219
219
|
state["jobs"] += _encode_all(redactor(k8s_tools.get_job_summaries(ns)), captured_at)
|
|
220
|
-
|
|
220
|
+
# Without their events: replay derives them from the capture's event
|
|
221
|
+
# records, so a stored copy would only duplicate those, unread.
|
|
222
|
+
state["pvcs"] += [{k: v for k, v in record.items() if k != "events"} for record in
|
|
223
|
+
_encode_all(redactor(k8s_tools.get_pvc_summaries(ns)), captured_at)]
|
|
221
224
|
state["configmaps"] += _capture_configmaps(ns, captured_at, redactor)
|
|
222
225
|
state["events"] += _capture_events(ns, captured_at, redactor)
|
|
223
226
|
state["pods"] += _capture_pods(ns, captured_at, include_logs, max_log_lines,
|
|
@@ -26,6 +26,7 @@ over the record, not the current rhythm.
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
import datetime
|
|
29
|
+
import functools
|
|
29
30
|
import re
|
|
30
31
|
import signal
|
|
31
32
|
from typing import Any, Optional
|
|
@@ -36,7 +37,7 @@ from .k8s_tools import (ContainerEssentials, ContainerUsage, EndpointSummary, Hp
|
|
|
36
37
|
ContainerStateWaiting, EventLine, FailureGroup, Instance, K8sApiError,
|
|
37
38
|
K8sLogReclaimed, LastChange, LOG_NOTE_PREFIX, LOG_RECLAIMED_REASON,
|
|
38
39
|
LogTail, NamespaceHealth, NOTE_INIT_NOT_RECORDED, NOTE_INIT_PENDING,
|
|
39
|
-
NOTE_RECOVERING, NOTE_RESTART_HISTORY,
|
|
40
|
+
NOTE_RECOVERING, NOTE_RESTART_HISTORY, PVCSummary,
|
|
40
41
|
Termination, WorkloadHealth, WorkloadReport, _init_completed,
|
|
41
42
|
_to_whole_seconds)
|
|
42
43
|
|
|
@@ -88,21 +89,89 @@ def _now() -> datetime.datetime:
|
|
|
88
89
|
return datetime.datetime.now(datetime.timezone.utc)
|
|
89
90
|
|
|
90
91
|
|
|
91
|
-
def _workloads(api, namespace: str) -> list[tuple[str, str, int, int]]:
|
|
92
|
-
"""(kind, name, ready, desired) for each workload in the
|
|
92
|
+
def _workloads(api, namespace: str) -> list[tuple[str, str, int, int, list[str]]]:
|
|
93
|
+
"""(kind, name, ready, desired, failing conditions) for each workload in the
|
|
94
|
+
namespace."""
|
|
93
95
|
out = []
|
|
94
96
|
for d in api.get_deployment_summaries(namespace):
|
|
95
|
-
out.append(("Deployment", d.name, d.ready_replicas, d.total_replicas
|
|
97
|
+
out.append(("Deployment", d.name, d.ready_replicas, d.total_replicas,
|
|
98
|
+
_failing_conditions(d.conditions or [])))
|
|
96
99
|
for s in api.get_statefulset_summaries(namespace):
|
|
97
|
-
out.append(("StatefulSet", s.name, s.ready_replicas, s.total_replicas))
|
|
100
|
+
out.append(("StatefulSet", s.name, s.ready_replicas, s.total_replicas, []))
|
|
98
101
|
for d in api.get_daemonset_summaries(namespace):
|
|
99
|
-
out.append(("DaemonSet", d.name, d.number_ready, d.desired_number_scheduled))
|
|
102
|
+
out.append(("DaemonSet", d.name, d.number_ready, d.desired_number_scheduled, []))
|
|
100
103
|
for j in api.get_job_summaries(namespace):
|
|
101
104
|
# A Job is "ready" when it has succeeded; desired is 1 run.
|
|
102
|
-
out.append(("Job", j.name, 1 if j.succeeded else 0, 1))
|
|
105
|
+
out.append(("Job", j.name, 1 if j.succeeded else 0, 1, []))
|
|
103
106
|
return out
|
|
104
107
|
|
|
105
108
|
|
|
109
|
+
#: Deployment conditions in the state that means a rollout can't make progress:
|
|
110
|
+
#: past its progress deadline, or unable to create pods (quota, admission).
|
|
111
|
+
_FAILING_CONDITIONS = {("Progressing", "False"), ("ReplicaFailure", "True")}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _failing_conditions(conditions) -> list[str]:
|
|
115
|
+
"""A Deployment's failing conditions in its controller's words, e.g.
|
|
116
|
+
'Progressing=False (ProgressDeadlineExceeded): ReplicaSet "x" has timed out
|
|
117
|
+
progressing.'"""
|
|
118
|
+
return [f"{c.type}={c.status}" + (f" ({c.reason})" if c.reason else "")
|
|
119
|
+
+ (f": {c.message[:200]}" if c.message else "")
|
|
120
|
+
for c in conditions if (c.type, c.status) in _FAILING_CONDITIONS]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#: WorkloadHealth.unscheduled when a capture recorded no reason (before 4.1.0).
|
|
124
|
+
_REASON_NOT_RECORDED = "reason not recorded (the capture predates k8stools 4.1.0)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _unscheduled(pod, statuses) -> Optional[str]:
|
|
128
|
+
"""Why a pod isn't scheduled, or None if it is.
|
|
129
|
+
|
|
130
|
+
A pod is unscheduled when it has no node. Captures before 4.1.0 recorded no
|
|
131
|
+
reason; there, a pod with no node and no container statuses at all hasn't
|
|
132
|
+
been scheduled either (a scheduled pod's kubelet reports its containers).
|
|
133
|
+
"""
|
|
134
|
+
if pod.node is not None or (pod.unscheduled is None
|
|
135
|
+
and (statuses.containers or statuses.init_containers)):
|
|
136
|
+
return None
|
|
137
|
+
return pod.unscheduled or _REASON_NOT_RECORDED
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _unscheduled_line(pod, reason: str) -> str:
|
|
141
|
+
return f"{pod.name}: not scheduled for {_human(pod.age)}: {reason}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _claim_line(c: PVCSummary) -> str:
|
|
145
|
+
"""An unbound claim and why it waits, in its newest event's words, e.g.
|
|
146
|
+
'PersistentVolumeClaim/cache: Pending, storage class fast-ssd; ProvisioningFailed:
|
|
147
|
+
storageclass.storage.k8s.io "fast-ssd" not found'."""
|
|
148
|
+
line = f"PersistentVolumeClaim/{c.name}: {c.status}" + (
|
|
149
|
+
f", storage class {c.storage_class}" if c.storage_class else "")
|
|
150
|
+
if not c.events:
|
|
151
|
+
return line + "; no events recorded (events expire, by default after an hour)"
|
|
152
|
+
e = c.events[0]
|
|
153
|
+
return line + f"; {e.reason}: {e.message[:200]}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _unbound_claims(api, namespace: str) -> dict[str, list[str]]:
|
|
157
|
+
"""The unbound claims each pod mounts, as _claim_line()s, by pod name.
|
|
158
|
+
|
|
159
|
+
The scheduler says only that a pod has unbound claims; why a claim is
|
|
160
|
+
unbound is in the claim's events. Best-effort: the claims are context for
|
|
161
|
+
a pod already reported unscheduled.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
claims = api.get_pvc_summaries(namespace)
|
|
165
|
+
except K8sApiError:
|
|
166
|
+
return {}
|
|
167
|
+
by_pod: dict[str, list[str]] = {}
|
|
168
|
+
for c in claims:
|
|
169
|
+
if c.status != "Bound":
|
|
170
|
+
for pod in c.mounted_by:
|
|
171
|
+
by_pod.setdefault(pod, []).append(_claim_line(c))
|
|
172
|
+
return by_pod
|
|
173
|
+
|
|
174
|
+
|
|
106
175
|
#: Generated pod-name suffixes: a ReplicaSet's, Job's or DaemonSet's pods are
|
|
107
176
|
#: "<name>-<5 chars>"; a StatefulSet's are "<name>-<ordinal>".
|
|
108
177
|
_RANDOM_SUFFIX = r"-[a-z0-9]{5}"
|
|
@@ -115,20 +184,20 @@ NOTE_MATCHED_BY_NAME = (
|
|
|
115
184
|
|
|
116
185
|
|
|
117
186
|
def _match_by_name(pod_name: str, rs_owner: dict[str, str],
|
|
118
|
-
workloads: list[tuple
|
|
187
|
+
workloads: list[tuple]) -> Optional[tuple[str, str]]:
|
|
119
188
|
"""The workload a pod's generated name points to, for a pod with no owner."""
|
|
120
189
|
for rs, deployment in rs_owner.items():
|
|
121
190
|
if deployment and re.fullmatch(re.escape(rs) + _RANDOM_SUFFIX, pod_name):
|
|
122
191
|
return ("Deployment", deployment)
|
|
123
192
|
for kind, suffix in (("StatefulSet", _ORDINAL_SUFFIX), ("Job", _RANDOM_SUFFIX),
|
|
124
193
|
("DaemonSet", _RANDOM_SUFFIX)):
|
|
125
|
-
for k, name, _
|
|
194
|
+
for k, name, *_ in workloads:
|
|
126
195
|
if k == kind and re.fullmatch(re.escape(name) + suffix, pod_name):
|
|
127
196
|
return (kind, name)
|
|
128
197
|
return None
|
|
129
198
|
|
|
130
199
|
|
|
131
|
-
def _pods_by_workload(api, namespace: str, workloads: list[tuple
|
|
200
|
+
def _pods_by_workload(api, namespace: str, workloads: list[tuple]
|
|
132
201
|
) -> tuple[dict[tuple[str, str], list], list, bool]:
|
|
133
202
|
"""Pods grouped by their workload; pods no workload owns; and whether any
|
|
134
203
|
pod was matched by name.
|
|
@@ -309,11 +378,20 @@ def namespace_health(api, namespace: str = "default") -> NamespaceHealth:
|
|
|
309
378
|
entries: list[WorkloadHealth] = []
|
|
310
379
|
recovering: list[WorkloadHealth] = []
|
|
311
380
|
healthy_lines: list[str] = []
|
|
381
|
+
unschedulable: dict[str, list[str]] = {} # workloads by why their pods aren't scheduled
|
|
382
|
+
# Read only when some pod is unscheduled, and then once.
|
|
383
|
+
unbound_claims = functools.cache(lambda: _unbound_claims(api, namespace))
|
|
312
384
|
|
|
313
|
-
def assess(label, kind, name, ready, desired, pods):
|
|
314
|
-
terminations, first_pod, waiting, init_pending = [], [], False, []
|
|
385
|
+
def assess(label, kind, name, ready, desired, pods, conditions):
|
|
386
|
+
terminations, first_pod, waiting, init_pending, unscheduled = [], [], False, [], {}
|
|
387
|
+
claims: dict[str, None] = {}
|
|
315
388
|
for i, pod in enumerate(pods):
|
|
316
389
|
statuses = api.get_pod_container_statuses(pod.name, namespace)
|
|
390
|
+
# A pod with no node has no containers to wait on: without this, the
|
|
391
|
+
# old pods of a rollout that can't schedule its new one read healthy.
|
|
392
|
+
if reason := _unscheduled(pod, statuses):
|
|
393
|
+
unscheduled[_unscheduled_line(pod, reason)] = reason
|
|
394
|
+
claims.update(dict.fromkeys(unbound_claims().get(pod.name, [])))
|
|
317
395
|
for s in statuses.containers:
|
|
318
396
|
if i == 0:
|
|
319
397
|
first_pod.append(s)
|
|
@@ -337,7 +415,7 @@ def namespace_health(api, namespace: str = "default") -> NamespaceHealth:
|
|
|
337
415
|
hpa = autoscalers.get(label)
|
|
338
416
|
pod_names = {p.name for p in pods}
|
|
339
417
|
readings = [u for u in usage or [] if u.pod in pod_names]
|
|
340
|
-
if ready >= desired and not waiting
|
|
418
|
+
if ready >= desired and not (waiting or failed_job or unscheduled or conditions):
|
|
341
419
|
# A restart: a container running again after a termination (not a
|
|
342
420
|
# Job's container that finished, nor a completed init container).
|
|
343
421
|
restart = _latest([t for t in terminations if t.restart_gap is not None])
|
|
@@ -354,8 +432,12 @@ def namespace_health(api, namespace: str = "default") -> NamespaceHealth:
|
|
|
354
432
|
+ (f", memory at {peak}% of limit"
|
|
355
433
|
if peak is not None and peak >= _NEAR_LIMIT_PERCENT else ""))
|
|
356
434
|
return
|
|
357
|
-
|
|
358
|
-
|
|
435
|
+
w = entry(label, kind, name, ready, desired, pods, restarts, last, first_pod, hpa,
|
|
436
|
+
readings, init_pending)
|
|
437
|
+
w.unscheduled, w.claims, w.conditions = list(unscheduled), list(claims), conditions
|
|
438
|
+
for reason in set(unscheduled.values()):
|
|
439
|
+
unschedulable.setdefault(reason, []).append(label)
|
|
440
|
+
entries.append(w)
|
|
359
441
|
|
|
360
442
|
def entry(label, kind, name, ready, desired, pods, restarts, last, first_pod, hpa,
|
|
361
443
|
readings, init_pending) -> WorkloadHealth:
|
|
@@ -380,11 +462,12 @@ def namespace_health(api, namespace: str = "default") -> NamespaceHealth:
|
|
|
380
462
|
last.notes = []
|
|
381
463
|
return w
|
|
382
464
|
|
|
383
|
-
for kind, name, ready, desired in workloads:
|
|
384
|
-
assess(f"{kind}/{name}", kind, name, ready, desired, grouped.get((kind, name), [])
|
|
465
|
+
for kind, name, ready, desired, conditions in workloads:
|
|
466
|
+
assess(f"{kind}/{name}", kind, name, ready, desired, grouped.get((kind, name), []),
|
|
467
|
+
conditions)
|
|
385
468
|
for pod in standalone:
|
|
386
469
|
assess(f"Pod/{pod.name}", None, pod.name, pod.ready_containers,
|
|
387
|
-
pod.total_containers, [pod])
|
|
470
|
+
pod.total_containers, [pod], [])
|
|
388
471
|
|
|
389
472
|
entries.sort(key=lambda w: w.workload)
|
|
390
473
|
recovering.sort(key=lambda w: w.last_termination.finished)
|
|
@@ -408,6 +491,11 @@ def namespace_health(api, namespace: str = "default") -> NamespaceHealth:
|
|
|
408
491
|
f"{memory or 'unknown'}, "
|
|
409
492
|
f"{_lifetimes([m.last_termination for m in members])}"),
|
|
410
493
|
workloads=[m.workload for m in members]))
|
|
494
|
+
# Pods that can't be scheduled for the same reason (a node selector no node
|
|
495
|
+
# matches, set on several workloads at once) are one failure too.
|
|
496
|
+
failures += [FailureGroup(signature=f"not scheduled: {reason}", workloads=sorted(members))
|
|
497
|
+
for reason, members in unschedulable.items()
|
|
498
|
+
if len(members) > 1 and reason != _REASON_NOT_RECORDED]
|
|
411
499
|
result = NamespaceHealth(
|
|
412
500
|
namespace=namespace, workloads=entries, recovering=recovering[:_MAX_RECOVERING],
|
|
413
501
|
recovering_more=recovering_more, healthy=healthy_lines,
|
|
@@ -446,7 +534,7 @@ _MAX_EVENTS = 20
|
|
|
446
534
|
|
|
447
535
|
|
|
448
536
|
def _find_kind(api, name: str, namespace: str) -> str:
|
|
449
|
-
found = [kind for kind, n, _
|
|
537
|
+
found = [kind for kind, n, *_ in _workloads(api, namespace) if n == name]
|
|
450
538
|
if not found:
|
|
451
539
|
raise K8sApiError(f"No Deployment, StatefulSet, DaemonSet or Job named '{name}' "
|
|
452
540
|
f"in namespace '{namespace}'.")
|
|
@@ -589,13 +677,14 @@ def workload_report(api, name: str, namespace: str = "default", kind: Optional[s
|
|
|
589
677
|
raise K8sApiError(f"Unsupported kind '{kind}': expected one of {', '.join(_KINDS)}.")
|
|
590
678
|
kind = kind or _find_kind(api, name, namespace)
|
|
591
679
|
workloads = _workloads(api, namespace)
|
|
592
|
-
matches = [(r, d) for k, n, r, d in workloads if (k, n) == (kind, name)]
|
|
680
|
+
matches = [(r, d, c) for k, n, r, d, c in workloads if (k, n) == (kind, name)]
|
|
593
681
|
if not matches:
|
|
594
682
|
raise K8sApiError(f"{kind} '{name}' not found in namespace '{namespace}'.")
|
|
595
|
-
ready, desired = matches[0]
|
|
683
|
+
ready, desired, conditions = matches[0]
|
|
596
684
|
grouped, _, by_name = _pods_by_workload(api, namespace, workloads)
|
|
597
685
|
pods = grouped.get((kind, name), [])
|
|
598
|
-
report = WorkloadReport(workload=f"{kind}/{name}", ready=ready, desired=desired
|
|
686
|
+
report = WorkloadReport(workload=f"{kind}/{name}", ready=ready, desired=desired,
|
|
687
|
+
conditions=conditions)
|
|
599
688
|
if by_name and pods and all(p.owner is None for p in pods):
|
|
600
689
|
report.notes.append(NOTE_MATCHED_BY_NAME)
|
|
601
690
|
|
|
@@ -615,7 +704,12 @@ def workload_report(api, name: str, namespace: str = "default", kind: Optional[s
|
|
|
615
704
|
return [s for s in statuses_by_pod[pod.name].init_containers or []
|
|
616
705
|
if not _init_completed(s)]
|
|
617
706
|
|
|
707
|
+
unbound_claims = functools.cache(lambda: _unbound_claims(api, namespace))
|
|
618
708
|
for pod in pods:
|
|
709
|
+
if reason := _unscheduled(pod, statuses_by_pod[pod.name]):
|
|
710
|
+
report.unscheduled.append(_unscheduled_line(pod, reason))
|
|
711
|
+
report.claims.extend(c for c in unbound_claims().get(pod.name, [])
|
|
712
|
+
if c not in report.claims)
|
|
619
713
|
report.instances.extend(instance(pod, s) for s in statuses_by_pod[pod.name].containers)
|
|
620
714
|
report.init_instances.extend(instance(pod, s) for s in pending_init(pod))
|
|
621
715
|
if any(st.init_containers is None for st in statuses_by_pod.values()):
|