k8stools 2.0.2__tar.gz → 2.0.4__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-2.0.2 → k8stools-2.0.4}/CLAUDE.md +66 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/PKG-INFO +22 -2
- {k8stools-2.0.2 → k8stools-2.0.4}/README.md +21 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/pyproject.toml +1 -1
- k8stools-2.0.4/redaction-bug.md +25 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/__init__.py +1 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/k8s_tools.py +124 -26
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mock_state.py +10 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/redaction.py +106 -11
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_capture.py +5 -1
- k8stools-2.0.4/tests/test_duration_format.py +182 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_k8s_tools.py +4 -2
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_k8s_tools_realk8s.py +13 -1
- k8stools-2.0.4/tests/test_log_decoding.py +147 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_new_tools.py +4 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_redaction.py +122 -0
- k8stools-2.0.4/time-bug-report.md +159 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/uv.lock +1 -1
- {k8stools-2.0.2 → k8stools-2.0.4}/.claude/settings.local.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/.github/workflows/unit-tests.yml +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/.gitignore +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/.python-version +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/.vscode/mcp.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/.vscode/settings.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/LICENSE +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/chat-with-cluster.png +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/designs/mock-state-capture.md +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/docs/ROADMAP.md +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/envrc.template +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/capture.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/fixtures/otel-demo.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mcp_client.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mcp_server.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mock_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/conftest.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mcp_client.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mock_state.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mock_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_replicaset_tool.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_version.py +0 -0
|
@@ -24,6 +24,7 @@ tests/
|
|
|
24
24
|
test_capture.py - Capture serialization round-trip and capture-time redaction
|
|
25
25
|
test_k8s_tools_realk8s.py - Integration tests (real cluster, auto-skipped if unreachable)
|
|
26
26
|
test_mock_tools.py - Tests for mock_tools module (incl. parity with k8s_tools.TOOLS)
|
|
27
|
+
test_log_decoding.py - Log decoding at the API boundary (bytes vs str), `previous` plumbing, log redaction
|
|
27
28
|
test_mcp_client.py - MCP client tests
|
|
28
29
|
test_version.py - Asserts pyproject and package __version__ agree
|
|
29
30
|
```
|
|
@@ -60,10 +61,45 @@ Tools in `k8s_tools.py` follow three patterns:
|
|
|
60
61
|
2. **API-typed** — Pydantic models that match K8s client types with lesser-used fields omitted (e.g. `get_pod_container_statuses`)
|
|
61
62
|
3. **Raw dict** — `to_dict()` on the K8s client object; return type is `dict[str, Any]` with fields documented in the docstring (e.g. `get_pod_spec`)
|
|
62
63
|
|
|
63
|
-
Use `
|
|
64
|
+
Use the `Duration` type (`k8s_tools.Duration`) for age/duration fields, not a bare
|
|
65
|
+
`datetime.timedelta`. It is a `timedelta` that normalizes to whole, non-negative
|
|
66
|
+
seconds on validation. Pydantic describes a `timedelta` as
|
|
67
|
+
`{"type": "string", "format": "duration"}` but serializes a microsecond-bearing one
|
|
68
|
+
as `"P2DT5M27.978616S"`; JSON Schema's `duration` format points at RFC 3339
|
|
69
|
+
Appendix A, whose grammar allows neither a decimal point nor a sign. An MCP client
|
|
70
|
+
that validates structured output against the declared schema (ajv does; Python's
|
|
71
|
+
`jsonschema` skips `duration` unless the optional `isoduration` extra is installed)
|
|
72
|
+
therefore rejected the whole call, on every row of every list tool. Since every age
|
|
73
|
+
is `now() - <k8s timestamp>` and Kubernetes timestamps are whole-second, the
|
|
74
|
+
fractional part was never information — just the microseconds of that one `now()`.
|
|
75
|
+
This is the same rule `MockState`'s replay clock already applies, for the same
|
|
76
|
+
reason. `tests/test_duration_format.py` checks each model's serialized output
|
|
77
|
+
against the format its own schema declares.
|
|
78
|
+
|
|
79
|
+
Use snake_case field names. Include `pod_name`/`namespace` in container-level models for context.
|
|
64
80
|
|
|
65
81
|
All tools are collected in the `TOOLS` list in `k8s_tools.py` for agent/MCP registration.
|
|
66
82
|
|
|
83
|
+
The log readers decode the response themselves and pass `_preload_content=False`.
|
|
84
|
+
The log endpoint is the only call whose body is a bare scalar rather than a model,
|
|
85
|
+
and the generated client declares its response type as `str`, then produces it with
|
|
86
|
+
`str(data)`. Since kubernetes 36 the body reaching that call is `bytes` (older
|
|
87
|
+
clients decoded it in `rest.py`), so it returned `repr(bytes)` — one `b'...'` line
|
|
88
|
+
with newlines as the two characters `\n`, useless as logs and silently breaking any
|
|
89
|
+
caller that greps. Asking for the raw response and decoding in
|
|
90
|
+
`_decode_log_response` bypasses that deserialization; non-2xx responses still raise
|
|
91
|
+
`ApiException` either way, so error handling is unchanged. Decoding is
|
|
92
|
+
`errors="replace"`, not strict: container logs are arbitrary bytes and `limit_bytes`
|
|
93
|
+
truncates at a byte offset that can split a character. Don't set
|
|
94
|
+
`_preload_content=True` here again — `tests/test_log_decoding.py` pins it off, and a
|
|
95
|
+
test fake that returns `str` hides the whole failure, which is how it shipped.
|
|
96
|
+
|
|
97
|
+
`previous=True` has three Kubernetes behaviors that read as bugs (identical output
|
|
98
|
+
during CrashLoopBackOff, a restart shifting the window between two calls, and a
|
|
99
|
+
reclaimed log file answering 200 with `unable to retrieve container logs`). They are
|
|
100
|
+
documented in the tool's docstring — which is the MCP tool description — and in
|
|
101
|
+
README's "Previous-instance log semantics". Don't "fix" them in code.
|
|
102
|
+
|
|
67
103
|
`print_*` companion functions exist for each `get_*` summary/spec function — for human-readable debugging output only. The log readers (`get_logs_for_pod_and_container`, `get_logs_for_job`, `get_logs_for_cronjob`) have no `print_*` companion since they already return a printable string.
|
|
68
104
|
|
|
69
105
|
## Error handling
|
|
@@ -163,6 +199,35 @@ keys) and values under keys/env-var names matching
|
|
|
163
199
|
separators and camelCase, so `apiKey` matches and `VALKEY_ADDR` does not),
|
|
164
200
|
replacing them with a visible `[REDACTED]` marker.
|
|
165
201
|
|
|
202
|
+
Redaction is **span-granular, with two deliberate exceptions.** A matched token
|
|
203
|
+
(AWS key, JWT) is replaced where it sits, leaving the surrounding text readable.
|
|
204
|
+
The exceptions:
|
|
205
|
+
|
|
206
|
+
- **PEM blocks go whole.** The pattern matches only the `-----BEGIN ... PRIVATE
|
|
207
|
+
KEY-----` header, and the secret is the base64 body *after* it — substituting
|
|
208
|
+
just the matched span would black out the header and publish the key. Hence the
|
|
209
|
+
`_TOKEN_SHAPE_RES` / `_CONTAINMENT_SHAPE_RES` split: never move a pattern into
|
|
210
|
+
the former unless its match is the entire secret.
|
|
211
|
+
- **A string that parses as a JSON object/array is recursed into** and
|
|
212
|
+
re-serialized, rather than treated as one opaque value. Config-as-JSON under a
|
|
213
|
+
single ConfigMap key is common, and opacity failed in both directions: one
|
|
214
|
+
incidental token match blacked out an entire non-secret config file, while a real
|
|
215
|
+
`{"password": ...}` inside a blob was invisible to the name rule and passed
|
|
216
|
+
through verbatim. Nothing is re-serialized when nothing matched, so clean values
|
|
217
|
+
keep their exact formatting.
|
|
218
|
+
|
|
219
|
+
The `key` exemption below applies *inside* these blobs too. Lifting it there
|
|
220
|
+
looked obviously right — those names are the author's, not Kubernetes schema —
|
|
221
|
+
and re-measuring said otherwise: on the 38-pod cluster it added 12 redactions,
|
|
222
|
+
every one `tags[].key: "instance"` in a Grafana dashboard ConfigMap, and no
|
|
223
|
+
secrets. Structural `key` fields dominate inside config documents just as they do
|
|
224
|
+
in the API. With the exemption kept, this whole change is redaction-neutral on
|
|
225
|
+
that cluster: same 6 redactions, same paths, same values as before it.
|
|
226
|
+
|
|
227
|
+
`bytes` values are decoded, redacted, and re-encoded rather than falling through
|
|
228
|
+
to the scalar branch — no tool returns bytes now that the log readers decode at the
|
|
229
|
+
API boundary, but that branch is what let container logs nearly escape this pass.
|
|
230
|
+
|
|
166
231
|
A field named *exactly* `key` is exempt — in the Kubernetes API that is always
|
|
167
232
|
structural (taint, map entry, label selector, projected-volume item), never a
|
|
168
233
|
credential. Before that exemption a real 38-pod capture produced 139 redactions of
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: k8stools
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.4
|
|
4
4
|
Summary: Tools and an MCP server for building Kubernetes agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/BenedatLLC/k8stools
|
|
6
6
|
Author: Jeff Fischer
|
|
@@ -283,7 +283,7 @@ These are the tools we define:
|
|
|
283
283
|
* `get_pod_container_statuses` - return the status for each of the container in a pod
|
|
284
284
|
* `get_pod_events` - return the events for a pod
|
|
285
285
|
* `get_pod_spec` - retrieves the spec for a given pod
|
|
286
|
-
* `get_logs_for_pod_and_container` - retrieves logs from a pod and container (supports `tail`, `since_seconds`, and `previous`)
|
|
286
|
+
* `get_logs_for_pod_and_container` - retrieves logs from a pod and container (supports `tail`, `since_seconds`, and `previous`). See [Previous-instance log semantics](#previous-instance-log-semantics) for when `previous=True` returns the same text as `previous=False` — it is Kubernetes behavior, not a bug.
|
|
287
287
|
* `get_deployment_summaries` - get a list of deployments, like `kubectl get deployments`
|
|
288
288
|
* `get_replicaset_summaries` - get a deployment's replica sets with their revision numbers and images. A deployment's replica sets are its change history: use this to see when a workload last changed and what the change was.
|
|
289
289
|
* `get_service_summaries` - get a list of services, like `kubectl get services` (includes `selector`/labels/annotations)
|
|
@@ -567,6 +567,26 @@ summary reports how many could not be read (the previous instance may have been
|
|
|
567
567
|
garbage-collected), because a crash-loop capture that lost them looks identical to
|
|
568
568
|
one that never had any.
|
|
569
569
|
|
|
570
|
+
### Previous-instance log semantics
|
|
571
|
+
|
|
572
|
+
`previous=True` asks the kubelet for the container's last *terminated* instance.
|
|
573
|
+
Three of its behaviors read as bugs and are not, and all three show up in captures
|
|
574
|
+
as well as in live queries:
|
|
575
|
+
|
|
576
|
+
- **In CrashLoopBackOff, both calls return the same text.** There is no running
|
|
577
|
+
instance during the backoff, so the kubelet serves the most recently terminated
|
|
578
|
+
one for `previous=False` too. Check `get_pod_container_statuses` before
|
|
579
|
+
concluding the flag was ignored.
|
|
580
|
+
- **A restart between two calls shifts the window.** The instance that was current
|
|
581
|
+
becomes the previous one, so a container crash-looping every few seconds can
|
|
582
|
+
return byte-identical output for both. Compare the log timestamps, not the flag.
|
|
583
|
+
- **A reclaimed log file returns 200, not an error**, with the body
|
|
584
|
+
`unable to retrieve container logs for <container-id>`. It is a successful call
|
|
585
|
+
whose content is an error message.
|
|
586
|
+
|
|
587
|
+
Only the single most recent terminated instance is retained; there is no way to
|
|
588
|
+
reach further back than one.
|
|
589
|
+
|
|
570
590
|
### Replay clock
|
|
571
591
|
|
|
572
592
|
Every age in a capture is stored relative to the moment of capture, so the
|
|
@@ -64,7 +64,7 @@ These are the tools we define:
|
|
|
64
64
|
* `get_pod_container_statuses` - return the status for each of the container in a pod
|
|
65
65
|
* `get_pod_events` - return the events for a pod
|
|
66
66
|
* `get_pod_spec` - retrieves the spec for a given pod
|
|
67
|
-
* `get_logs_for_pod_and_container` - retrieves logs from a pod and container (supports `tail`, `since_seconds`, and `previous`)
|
|
67
|
+
* `get_logs_for_pod_and_container` - retrieves logs from a pod and container (supports `tail`, `since_seconds`, and `previous`). See [Previous-instance log semantics](#previous-instance-log-semantics) for when `previous=True` returns the same text as `previous=False` — it is Kubernetes behavior, not a bug.
|
|
68
68
|
* `get_deployment_summaries` - get a list of deployments, like `kubectl get deployments`
|
|
69
69
|
* `get_replicaset_summaries` - get a deployment's replica sets with their revision numbers and images. A deployment's replica sets are its change history: use this to see when a workload last changed and what the change was.
|
|
70
70
|
* `get_service_summaries` - get a list of services, like `kubectl get services` (includes `selector`/labels/annotations)
|
|
@@ -348,6 +348,26 @@ summary reports how many could not be read (the previous instance may have been
|
|
|
348
348
|
garbage-collected), because a crash-loop capture that lost them looks identical to
|
|
349
349
|
one that never had any.
|
|
350
350
|
|
|
351
|
+
### Previous-instance log semantics
|
|
352
|
+
|
|
353
|
+
`previous=True` asks the kubelet for the container's last *terminated* instance.
|
|
354
|
+
Three of its behaviors read as bugs and are not, and all three show up in captures
|
|
355
|
+
as well as in live queries:
|
|
356
|
+
|
|
357
|
+
- **In CrashLoopBackOff, both calls return the same text.** There is no running
|
|
358
|
+
instance during the backoff, so the kubelet serves the most recently terminated
|
|
359
|
+
one for `previous=False` too. Check `get_pod_container_statuses` before
|
|
360
|
+
concluding the flag was ignored.
|
|
361
|
+
- **A restart between two calls shifts the window.** The instance that was current
|
|
362
|
+
becomes the previous one, so a container crash-looping every few seconds can
|
|
363
|
+
return byte-identical output for both. Compare the log timestamps, not the flag.
|
|
364
|
+
- **A reclaimed log file returns 200, not an error**, with the body
|
|
365
|
+
`unable to retrieve container logs for <container-id>`. It is a successful call
|
|
366
|
+
whose content is an error message.
|
|
367
|
+
|
|
368
|
+
Only the single most recent terminated instance is retained; there is no way to
|
|
369
|
+
reach further back than one.
|
|
370
|
+
|
|
351
371
|
### Replay clock
|
|
352
372
|
|
|
353
373
|
Every age in a capture is stored relative to the moment of capture, so the
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# get_configmap / ConfigMap.data redaction: a single value-shape match anywhere in a JSON-serialized ConfigMap value blacks out the entire value, not just the match
|
|
2
|
+
## Repro:
|
|
3
|
+
any ConfigMap whose data value is a JSON-encoded blob (common pattern: config-as-JSON-string under one key) where some substring incidentally matches the JWT/AKIA/PEM regexes.
|
|
4
|
+
|
|
5
|
+
## Impact:
|
|
6
|
+
legitimate, non-secret operational config (in our case: warm-runtime pool target count) becomes fully unreadable via the MCP tool, forcing a workaround (grep-ing cronjob logs instead).
|
|
7
|
+
|
|
8
|
+
## Suggested fixes (in rough order of effort):
|
|
9
|
+
* When a string value looks like JSON (json.loads succeeds), recurse redaction into the parsed structure and re-serialize, instead of treating it as one opaque string.
|
|
10
|
+
* At minimum, redact only the matched span within the string (rx.sub(REDACTED, s)) rather than replacing the whole value when a partial match is found — this alone would have kept the rest of warm-runtimes.json visible.
|
|
11
|
+
* Note in output when a value is a "detected JSON string with an internal match" vs. a flat credential-shaped value, so callers can tell which failure mode they hit.
|
|
12
|
+
|
|
13
|
+
## What we saw
|
|
14
|
+
Reading the warm-runtimes-config ConfigMap in openhands-cloud-c3:
|
|
15
|
+
```
|
|
16
|
+
{
|
|
17
|
+
"name": "warm-runtimes-config",
|
|
18
|
+
"namespace": "openhands-cloud-c3",
|
|
19
|
+
"data": {
|
|
20
|
+
"warm-runtimes.json": "[REDACTED]"
|
|
21
|
+
},
|
|
22
|
+
"binary_data_keys": []
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
The whole value came back redacted, even though this ConfigMap is just warm-pool sizing config (count, runtime spec names) — nothing secret. We had to fall back to the reconciler's own log line (Missing N runtimes for v1, creating N warm runtimes...) to get the real number (5).
|
|
@@ -16,9 +16,9 @@ import sys
|
|
|
16
16
|
import os
|
|
17
17
|
import logging
|
|
18
18
|
import datetime
|
|
19
|
-
from typing import Optional, Union, Literal, Any
|
|
19
|
+
from typing import Optional, Union, Literal, Any, Annotated
|
|
20
20
|
|
|
21
|
-
from pydantic import BaseModel, Field
|
|
21
|
+
from pydantic import BaseModel, Field, AfterValidator
|
|
22
22
|
import yaml
|
|
23
23
|
|
|
24
24
|
from kubernetes import client, config
|
|
@@ -85,12 +85,47 @@ def _get_batch_v1_api_client() -> client.BatchV1Api:
|
|
|
85
85
|
raise K8sConfigError(f"Unexpected error: {e}") from e
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _to_whole_seconds(td: datetime.timedelta) -> datetime.timedelta:
|
|
89
|
+
"""Truncate a duration to whole seconds, flooring at zero.
|
|
90
|
+
|
|
91
|
+
Both adjustments keep the value inside the grammar our own JSON Schema
|
|
92
|
+
declares for it (see :data:`Duration`).
|
|
93
|
+
|
|
94
|
+
Truncation: every age here is ``now() - <k8s timestamp>``, and Kubernetes
|
|
95
|
+
timestamps are whole-second, so the sub-second part of an age is nothing but
|
|
96
|
+
the microseconds of the ``now()`` that happened to compute it - noise, and
|
|
97
|
+
identical across every row of a single response. This matches the rule
|
|
98
|
+
``MockState``'s replay clock already follows for the same reason.
|
|
99
|
+
|
|
100
|
+
Flooring: a cluster whose clock is ahead of ours yields a small negative age,
|
|
101
|
+
which pydantic renders as ``-PT5S``. A duration has no sign in this grammar,
|
|
102
|
+
so clamp it; at this granularity "just created" is the honest reading anyway.
|
|
103
|
+
"""
|
|
104
|
+
return datetime.timedelta(seconds=max(int(td.total_seconds()), 0))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: A ``timedelta`` field on a response model.
|
|
108
|
+
#:
|
|
109
|
+
#: Pydantic's default JSON Schema for ``timedelta`` is
|
|
110
|
+
#: ``{"type": "string", "format": "duration"}``, but its default *serialization*
|
|
111
|
+
#: emits fractional seconds (``"P2DT5M27.978616S"``) whenever the value carries
|
|
112
|
+
#: microseconds. JSON Schema's ``duration`` format references RFC 3339 Appendix A,
|
|
113
|
+
#: whose grammar admits only integer components - so pydantic's own schema and its
|
|
114
|
+
#: own serializer disagree, and an MCP client that validates structured output
|
|
115
|
+
#: against the declared schema (ajv does; Python's ``jsonschema`` skips ``duration``
|
|
116
|
+
#: unless the optional ``isoduration`` extra is installed) rejects the tool call
|
|
117
|
+
#: outright, on every row, before the caller sees any data.
|
|
118
|
+
#:
|
|
119
|
+
#: Normalizing the value rather than the schema keeps ``format: "duration"``
|
|
120
|
+
#: accurate and keeps direct Python callers' fields real ``timedelta`` objects.
|
|
121
|
+
Duration = Annotated[datetime.timedelta, AfterValidator(_to_whole_seconds)]
|
|
122
|
+
|
|
88
123
|
|
|
89
124
|
class NamespaceSummary(BaseModel):
|
|
90
125
|
"""Summary information about a namespace, like returned by `kubectl get namespace`"""
|
|
91
126
|
name: str
|
|
92
127
|
status: str
|
|
93
|
-
age:
|
|
128
|
+
age: Duration
|
|
94
129
|
|
|
95
130
|
|
|
96
131
|
def get_namespaces() -> list[NamespaceSummary]:
|
|
@@ -142,7 +177,7 @@ class NodeSummary(BaseModel):
|
|
|
142
177
|
name: str
|
|
143
178
|
status: str
|
|
144
179
|
roles: list[str]
|
|
145
|
-
age:
|
|
180
|
+
age: Duration
|
|
146
181
|
version: str
|
|
147
182
|
internal_ip: Optional[str] = None
|
|
148
183
|
external_ip: Optional[str] = None
|
|
@@ -347,8 +382,8 @@ class PodSummary(BaseModel):
|
|
|
347
382
|
total_containers: int
|
|
348
383
|
ready_containers: int
|
|
349
384
|
restarts: int
|
|
350
|
-
last_restart: Optional[
|
|
351
|
-
age:
|
|
385
|
+
last_restart: Optional[Duration]
|
|
386
|
+
age: Duration
|
|
352
387
|
ip: Optional[str] = None
|
|
353
388
|
node: Optional[str] = None
|
|
354
389
|
|
|
@@ -501,7 +536,7 @@ def _format_timedelta(td: Optional[datetime.timedelta]) -> str:
|
|
|
501
536
|
|
|
502
537
|
class EventSummary(BaseModel):
|
|
503
538
|
"""This is the representation of a Kubernetes Event"""
|
|
504
|
-
last_seen: Optional[
|
|
539
|
+
last_seen: Optional[Duration] # Time since event occurred
|
|
505
540
|
type: str
|
|
506
541
|
reason: str
|
|
507
542
|
object: str
|
|
@@ -864,6 +899,35 @@ def print_pod_spec(pod_name: str, namespace: str = "default") -> None:
|
|
|
864
899
|
print(f"Error printing pod spec: {e}")
|
|
865
900
|
|
|
866
901
|
|
|
902
|
+
def _decode_log_response(resp: Any) -> str:
|
|
903
|
+
"""Decode what ``read_namespaced_pod_log`` hands back into usable text.
|
|
904
|
+
|
|
905
|
+
The log endpoint serves ``text/plain``, but the generated client declares the
|
|
906
|
+
response type as ``str`` and runs the body through
|
|
907
|
+
``ApiClient.__deserialize_primitive``, which is ``str(data)``. Since
|
|
908
|
+
kubernetes 36 the body arriving there is ``bytes`` (older clients decoded it
|
|
909
|
+
in ``rest.py``), so that call yields ``repr(bytes)`` - one ``b'...'`` line
|
|
910
|
+
with every newline as the two characters ``\\n``. That is useless as logs and
|
|
911
|
+
silently breaks anything a caller greps for. Requesting the raw response and
|
|
912
|
+
decoding here bypasses that deserialization entirely; status handling is
|
|
913
|
+
unaffected, since the REST client raises ``ApiException`` for non-2xx
|
|
914
|
+
responses whether or not the content was preloaded.
|
|
915
|
+
|
|
916
|
+
``errors="replace"`` rather than strict: container logs are arbitrary bytes,
|
|
917
|
+
and ``limit_bytes`` truncates at a byte offset that can land inside a
|
|
918
|
+
multi-byte character, so a ``UnicodeDecodeError`` here would fail a whole
|
|
919
|
+
investigation over one bad byte.
|
|
920
|
+
"""
|
|
921
|
+
# The raw urllib3 response carries the body on `.data`; a preloaded response
|
|
922
|
+
# is already a str, and is passed through rather than decoded twice.
|
|
923
|
+
data = None if resp is None else getattr(resp, "data", resp)
|
|
924
|
+
if data is None:
|
|
925
|
+
return ''
|
|
926
|
+
if isinstance(data, bytes):
|
|
927
|
+
return data.decode("utf-8", errors="replace")
|
|
928
|
+
return data if isinstance(data, str) else str(data)
|
|
929
|
+
|
|
930
|
+
|
|
867
931
|
def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
|
|
868
932
|
container_name:Optional[str]=None,
|
|
869
933
|
tail:Optional[int]=None,
|
|
@@ -890,8 +954,36 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
|
|
|
890
954
|
where the current instance's logs are empty or
|
|
891
955
|
post-restart. Fails if there is no previous instance.
|
|
892
956
|
|
|
957
|
+
Three Kubernetes behaviors make `previous=True`
|
|
958
|
+
look as though it were ignored or broken; none is a
|
|
959
|
+
fault in this tool, and all are worth knowing before
|
|
960
|
+
drawing a conclusion from the output:
|
|
961
|
+
|
|
962
|
+
* While a container sits in CrashLoopBackOff there
|
|
963
|
+
is no running instance, so the kubelet serves the
|
|
964
|
+
most recently *terminated* one for `previous=False`
|
|
965
|
+
as well - both calls then return the same text.
|
|
966
|
+
Check the container's state with
|
|
967
|
+
`get_pod_container_statuses` before concluding the
|
|
968
|
+
flag had no effect.
|
|
969
|
+
* A restart between two calls shifts the window: the
|
|
970
|
+
instance that was current becomes the previous one,
|
|
971
|
+
so a container crash-looping every few seconds can
|
|
972
|
+
legitimately return identical bytes for both.
|
|
973
|
+
Compare the log timestamps, not just the flag.
|
|
974
|
+
* Once the previous instance's log file has been
|
|
975
|
+
reclaimed, the API answers 200 with the text
|
|
976
|
+
`unable to retrieve container logs for <id>` -
|
|
977
|
+
a successful call whose body is an error message,
|
|
978
|
+
not a raised error.
|
|
979
|
+
|
|
980
|
+
Only the single most recent terminated instance is
|
|
981
|
+
retained by the kubelet; there is no way to reach
|
|
982
|
+
further back than one.
|
|
983
|
+
|
|
893
984
|
Returns:
|
|
894
|
-
str
|
|
985
|
+
str: The log content as text, with real newlines. An empty string if the
|
|
986
|
+
container has produced no log output (never None).
|
|
895
987
|
|
|
896
988
|
Raises
|
|
897
989
|
------
|
|
@@ -912,7 +1004,7 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
|
|
|
912
1004
|
namespace=namespace,
|
|
913
1005
|
container=container_name, # Pass container_name if specified
|
|
914
1006
|
follow=False, # Set to False to get all current logs
|
|
915
|
-
_preload_content=
|
|
1007
|
+
_preload_content=False, # Raw response; see _decode_log_response()
|
|
916
1008
|
timestamps=True, # Optional: Include timestamps
|
|
917
1009
|
tail_lines=tail if tail is not None else 1000, # Default: last 1000 lines
|
|
918
1010
|
limit_bytes=1024*1024, # Limit to 1MB to avoid memory issues
|
|
@@ -923,11 +1015,9 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
|
|
|
923
1015
|
log_kwargs["previous"] = True
|
|
924
1016
|
resp = K8S.read_namespaced_pod_log(**log_kwargs)
|
|
925
1017
|
|
|
926
|
-
#
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
else:
|
|
930
|
-
return ''
|
|
1018
|
+
# A single string containing all logs, with real newlines. An empty log
|
|
1019
|
+
# is '', never None - callers treat the two the same.
|
|
1020
|
+
return _decode_log_response(resp)
|
|
931
1021
|
except client.ApiException as e:
|
|
932
1022
|
raise K8sApiError(f"Error fetching logs: {e}") from e
|
|
933
1023
|
except Exception as e:
|
|
@@ -942,7 +1032,7 @@ class DeploymentSummary(BaseModel):
|
|
|
942
1032
|
ready_replicas: int
|
|
943
1033
|
up_to_date_relicas: int
|
|
944
1034
|
available_replicas: int
|
|
945
|
-
age:
|
|
1035
|
+
age: Duration
|
|
946
1036
|
|
|
947
1037
|
def get_deployment_summaries(namespace: Optional[str] = None) -> list[DeploymentSummary]:
|
|
948
1038
|
"""
|
|
@@ -1047,7 +1137,7 @@ class ReplicaSetSummary(BaseModel):
|
|
|
1047
1137
|
current_replicas: int
|
|
1048
1138
|
ready_replicas: int
|
|
1049
1139
|
images: list[str]
|
|
1050
|
-
age:
|
|
1140
|
+
age: Duration
|
|
1051
1141
|
|
|
1052
1142
|
|
|
1053
1143
|
def get_replicaset_summaries(namespace: Optional[str] = None,
|
|
@@ -1227,7 +1317,7 @@ class ServiceSummary(BaseModel):
|
|
|
1227
1317
|
cluster_ip: Optional[str] = None
|
|
1228
1318
|
external_ip: Optional[str] = None
|
|
1229
1319
|
ports: list[PortInfo]
|
|
1230
|
-
age:
|
|
1320
|
+
age: Duration
|
|
1231
1321
|
selector: dict[str, str] = Field(default_factory=dict)
|
|
1232
1322
|
labels: dict[str, str] = Field(default_factory=dict)
|
|
1233
1323
|
annotations: dict[str, str] = Field(default_factory=dict)
|
|
@@ -1378,7 +1468,7 @@ class ConfigMapSummary(BaseModel):
|
|
|
1378
1468
|
namespace: str
|
|
1379
1469
|
key_count: int
|
|
1380
1470
|
data_size: int # total bytes across all values (data + binary_data)
|
|
1381
|
-
age:
|
|
1471
|
+
age: Duration
|
|
1382
1472
|
|
|
1383
1473
|
|
|
1384
1474
|
def _configmap_key_count_and_size(config_map) -> tuple[int, int]:
|
|
@@ -1548,7 +1638,7 @@ class StatefulSetSummary(BaseModel):
|
|
|
1548
1638
|
current_replicas: int
|
|
1549
1639
|
update_strategy: str
|
|
1550
1640
|
service_name: Optional[str] = None
|
|
1551
|
-
age:
|
|
1641
|
+
age: Duration
|
|
1552
1642
|
|
|
1553
1643
|
|
|
1554
1644
|
def get_statefulset_summaries(namespace: Optional[str] = None) -> list[StatefulSetSummary]:
|
|
@@ -1679,9 +1769,9 @@ class CronJobSummary(BaseModel):
|
|
|
1679
1769
|
schedule: str
|
|
1680
1770
|
suspend: bool
|
|
1681
1771
|
active: int
|
|
1682
|
-
last_schedule_time: Optional[
|
|
1683
|
-
last_successful_time: Optional[
|
|
1684
|
-
age:
|
|
1772
|
+
last_schedule_time: Optional[Duration] = None
|
|
1773
|
+
last_successful_time: Optional[Duration] = None
|
|
1774
|
+
age: Duration
|
|
1685
1775
|
containers: list[ContainerTemplateSummary] = Field(default_factory=list)
|
|
1686
1776
|
|
|
1687
1777
|
|
|
@@ -1791,10 +1881,10 @@ class JobSummary(BaseModel):
|
|
|
1791
1881
|
active: int
|
|
1792
1882
|
succeeded: int
|
|
1793
1883
|
failed: int
|
|
1794
|
-
start_time: Optional[
|
|
1795
|
-
completion_time: Optional[
|
|
1884
|
+
start_time: Optional[Duration] = None
|
|
1885
|
+
completion_time: Optional[Duration] = None
|
|
1796
1886
|
conditions: list[str] = Field(default_factory=list)
|
|
1797
|
-
age:
|
|
1887
|
+
age: Duration
|
|
1798
1888
|
containers: list[ContainerTemplateSummary] = Field(default_factory=list)
|
|
1799
1889
|
|
|
1800
1890
|
|
|
@@ -1954,6 +2044,10 @@ def get_logs_for_job(job_name: str, namespace: str = "default",
|
|
|
1954
2044
|
If set, only return logs newer than this many seconds.
|
|
1955
2045
|
previous : bool, default False
|
|
1956
2046
|
If True, return logs from the previous terminated container instance.
|
|
2047
|
+
See `get_logs_for_pod_and_container` for the cases - CrashLoopBackOff, a
|
|
2048
|
+
restart racing the call, a reclaimed log file - where that legitimately
|
|
2049
|
+
returns the same text as `previous=False`, or a 200 whose body is an
|
|
2050
|
+
error message.
|
|
1957
2051
|
|
|
1958
2052
|
Returns
|
|
1959
2053
|
-------
|
|
@@ -2001,6 +2095,10 @@ def get_logs_for_cronjob(cronjob_name: str, namespace: str = "default",
|
|
|
2001
2095
|
If set, only return logs newer than this many seconds.
|
|
2002
2096
|
previous : bool, default False
|
|
2003
2097
|
If True, return logs from the previous terminated container instance.
|
|
2098
|
+
See `get_logs_for_pod_and_container` for the cases - CrashLoopBackOff, a
|
|
2099
|
+
restart racing the call, a reclaimed log file - where that legitimately
|
|
2100
|
+
returns the same text as `previous=False`, or a 200 whose body is an
|
|
2101
|
+
error message.
|
|
2004
2102
|
|
|
2005
2103
|
Returns
|
|
2006
2104
|
-------
|
|
@@ -2041,7 +2139,7 @@ class PVCSummary(BaseModel):
|
|
|
2041
2139
|
access_modes: list[str] = Field(default_factory=list)
|
|
2042
2140
|
storage_class: Optional[str] = None
|
|
2043
2141
|
mounted_by: list[str] = Field(default_factory=list)
|
|
2044
|
-
age:
|
|
2142
|
+
age: Duration
|
|
2045
2143
|
|
|
2046
2144
|
|
|
2047
2145
|
def get_pvc_summaries(namespace: Optional[str] = None) -> list[PVCSummary]:
|
|
@@ -76,9 +76,18 @@ class CaptureFormatError(Exception):
|
|
|
76
76
|
# ---------------------------------------------------------------------------
|
|
77
77
|
|
|
78
78
|
def _annotation_contains(annotation: Any, target: type) -> bool:
|
|
79
|
-
"""True if ``target`` appears anywhere in ``annotation
|
|
79
|
+
"""True if ``target`` appears anywhere in ``annotation``.
|
|
80
|
+
|
|
81
|
+
Unwraps ``Optional``/``Union`` and ``Annotated``. The latter matters because
|
|
82
|
+
``k8s_tools.Duration`` is an annotated ``timedelta``: pydantic strips the
|
|
83
|
+
metadata from a bare field's ``annotation`` but keeps it inside an
|
|
84
|
+
``Optional[...]``, so without this an optional duration would silently encode
|
|
85
|
+
under its plain name instead of ``<field>_seconds`` and fail to round-trip.
|
|
86
|
+
"""
|
|
80
87
|
if annotation is target:
|
|
81
88
|
return True
|
|
89
|
+
if hasattr(annotation, "__metadata__"): # Annotated[X, ...] -> X
|
|
90
|
+
return _annotation_contains(typing.get_args(annotation)[0], target)
|
|
82
91
|
origin = typing.get_origin(annotation)
|
|
83
92
|
if origin is Union or origin is types.UnionType:
|
|
84
93
|
return any(_annotation_contains(arg, target) for arg in typing.get_args(annotation))
|