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.
Files changed (59) hide show
  1. {k8stools-4.0.2 → k8stools-4.2.0}/CHANGELOG.md +79 -0
  2. {k8stools-4.0.2 → k8stools-4.2.0}/CLAUDE.md +21 -0
  3. {k8stools-4.0.2 → k8stools-4.2.0}/PKG-INFO +21 -3
  4. {k8stools-4.0.2 → k8stools-4.2.0}/README.md +20 -2
  5. {k8stools-4.0.2 → k8stools-4.2.0}/docs/TOOL_REFERENCE.md +13 -4
  6. {k8stools-4.0.2 → k8stools-4.2.0}/pyproject.toml +1 -1
  7. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/__init__.py +1 -1
  8. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/capture.py +4 -1
  9. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/composites.py +116 -22
  10. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/fixtures/otel-demo.json +518 -3
  11. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/k8s_tools.py +99 -17
  12. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mock_state.py +11 -1
  13. {k8stools-4.0.2 → k8stools-4.2.0}/tests/conftest.py +4 -2
  14. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_builtin_fixture.py +12 -7
  15. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_capture.py +20 -0
  16. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_composites.py +12 -2
  17. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_endpoints.py +1 -1
  18. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_events.py +28 -0
  19. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_k8s_tools_realk8s.py +10 -3
  20. k8stools-4.2.0/tests/test_live_writes.py +171 -0
  21. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_metrics.py +1 -1
  22. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mock_state.py +2 -1
  23. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_new_tools.py +69 -4
  24. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_replicaset_tool.py +2 -1
  25. k8stools-4.2.0/tests/test_stuck_rollouts.py +297 -0
  26. {k8stools-4.0.2 → k8stools-4.2.0}/uv.lock +1 -1
  27. {k8stools-4.0.2 → k8stools-4.2.0}/.claude/settings.local.json +0 -0
  28. {k8stools-4.0.2 → k8stools-4.2.0}/.github/workflows/unit-tests.yml +0 -0
  29. {k8stools-4.0.2 → k8stools-4.2.0}/.gitignore +0 -0
  30. {k8stools-4.0.2 → k8stools-4.2.0}/.python-version +0 -0
  31. {k8stools-4.0.2 → k8stools-4.2.0}/.vscode/mcp.json +0 -0
  32. {k8stools-4.0.2 → k8stools-4.2.0}/.vscode/settings.json +0 -0
  33. {k8stools-4.0.2 → k8stools-4.2.0}/LICENSE +0 -0
  34. {k8stools-4.0.2 → k8stools-4.2.0}/chat-with-cluster.png +0 -0
  35. {k8stools-4.0.2 → k8stools-4.2.0}/designs/mock-state-capture.md +0 -0
  36. {k8stools-4.0.2 → k8stools-4.2.0}/docs/ROADMAP.md +0 -0
  37. {k8stools-4.0.2 → k8stools-4.2.0}/envrc.template +0 -0
  38. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mcp_client.py +0 -0
  39. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mcp_server.py +0 -0
  40. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/mock_tools.py +0 -0
  41. {k8stools-4.0.2 → k8stools-4.2.0}/src/k8stools/redaction.py +0 -0
  42. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_cluster_binding.py +0 -0
  43. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_custom_resources.py +0 -0
  44. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_duration_format.py +0 -0
  45. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_hpa.py +0 -0
  46. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_ingress.py +0 -0
  47. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_init_containers.py +0 -0
  48. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_k8s_tools.py +0 -0
  49. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_log_decoding.py +0 -0
  50. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mcp_client.py +0 -0
  51. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mcp_errors.py +0 -0
  52. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_mock_tools.py +0 -0
  53. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_node_conditions.py +0 -0
  54. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_notes.py +0 -0
  55. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_redaction.py +0 -0
  56. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_toolsets.py +0 -0
  57. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_version.py +0 -0
  58. {k8stools-4.0.2 → k8stools-4.2.0}/tests/test_workload_history.py +0 -0
  59. {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.2
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 (ready, 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`).
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
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "k8stools"
4
- version = "4.0.2"
4
+ version = "4.2.0"
5
5
  description = "Tools and an MCP server for building Kubernetes agents"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -98,4 +98,4 @@ the built-in OTel Demo snapshot by default, or your own via load_mock_state() or
98
98
  the server's --state-file option.
99
99
  """
100
100
 
101
- __version__ = "4.0.2"
101
+ __version__ = "4.2.0"
@@ -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
- state["pvcs"] += _encode_all(redactor(k8s_tools.get_pvc_summaries(ns)), captured_at)
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 namespace."""
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[str, str, int, int]]) -> Optional[tuple[str, str]]:
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, _, _ in workloads:
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[str, str, int, int]]
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 and not failed_job:
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
- entries.append(entry(label, kind, name, ready, desired, pods, restarts, last,
358
- first_pod, hpa, readings, init_pending))
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, _, _ in _workloads(api, namespace) if n == name]
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()):