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.
Files changed (40) hide show
  1. {k8stools-2.0.2 → k8stools-2.0.4}/CLAUDE.md +66 -1
  2. {k8stools-2.0.2 → k8stools-2.0.4}/PKG-INFO +22 -2
  3. {k8stools-2.0.2 → k8stools-2.0.4}/README.md +21 -1
  4. {k8stools-2.0.2 → k8stools-2.0.4}/pyproject.toml +1 -1
  5. k8stools-2.0.4/redaction-bug.md +25 -0
  6. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/__init__.py +1 -1
  7. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/k8s_tools.py +124 -26
  8. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mock_state.py +10 -1
  9. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/redaction.py +106 -11
  10. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_capture.py +5 -1
  11. k8stools-2.0.4/tests/test_duration_format.py +182 -0
  12. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_k8s_tools.py +4 -2
  13. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_k8s_tools_realk8s.py +13 -1
  14. k8stools-2.0.4/tests/test_log_decoding.py +147 -0
  15. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_new_tools.py +4 -1
  16. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_redaction.py +122 -0
  17. k8stools-2.0.4/time-bug-report.md +159 -0
  18. {k8stools-2.0.2 → k8stools-2.0.4}/uv.lock +1 -1
  19. {k8stools-2.0.2 → k8stools-2.0.4}/.claude/settings.local.json +0 -0
  20. {k8stools-2.0.2 → k8stools-2.0.4}/.github/workflows/unit-tests.yml +0 -0
  21. {k8stools-2.0.2 → k8stools-2.0.4}/.gitignore +0 -0
  22. {k8stools-2.0.2 → k8stools-2.0.4}/.python-version +0 -0
  23. {k8stools-2.0.2 → k8stools-2.0.4}/.vscode/mcp.json +0 -0
  24. {k8stools-2.0.2 → k8stools-2.0.4}/.vscode/settings.json +0 -0
  25. {k8stools-2.0.2 → k8stools-2.0.4}/LICENSE +0 -0
  26. {k8stools-2.0.2 → k8stools-2.0.4}/chat-with-cluster.png +0 -0
  27. {k8stools-2.0.2 → k8stools-2.0.4}/designs/mock-state-capture.md +0 -0
  28. {k8stools-2.0.2 → k8stools-2.0.4}/docs/ROADMAP.md +0 -0
  29. {k8stools-2.0.2 → k8stools-2.0.4}/envrc.template +0 -0
  30. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/capture.py +0 -0
  31. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/fixtures/otel-demo.json +0 -0
  32. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mcp_client.py +0 -0
  33. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mcp_server.py +0 -0
  34. {k8stools-2.0.2 → k8stools-2.0.4}/src/k8stools/mock_tools.py +0 -0
  35. {k8stools-2.0.2 → k8stools-2.0.4}/tests/conftest.py +0 -0
  36. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mcp_client.py +0 -0
  37. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mock_state.py +0 -0
  38. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_mock_tools.py +0 -0
  39. {k8stools-2.0.2 → k8stools-2.0.4}/tests/test_replicaset_tool.py +0 -0
  40. {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 `datetime.timedelta` for age/duration fields. Use snake_case field names. Include `pod_name`/`namespace` in container-level models for context.
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.2
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
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "k8stools"
4
- version = "2.0.2"
4
+ version = "2.0.4"
5
5
  description = "Tools and an MCP server for building Kubernetes agents"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -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).
@@ -78,4 +78,4 @@ the built-in OTel Demo snapshot by default, or your own via load_mock_state() or
78
78
  the server's --state-file option.
79
79
  """
80
80
 
81
- __version__ = "2.0.2"
81
+ __version__ = "2.0.4"
@@ -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: datetime.timedelta
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: datetime.timedelta
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[datetime.timedelta]
351
- age: datetime.timedelta
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[datetime.timedelta] # Time since event occurred
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, optional: Log content if any found for this pod/container, or None otherwise
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=True, # Important: This loads all content into memory
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
- # The response is a single string containing all logs
927
- if resp:
928
- return resp
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: datetime.timedelta
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: datetime.timedelta
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: datetime.timedelta
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: datetime.timedelta
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: datetime.timedelta
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[datetime.timedelta] = None
1683
- last_successful_time: Optional[datetime.timedelta] = None
1684
- age: datetime.timedelta
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[datetime.timedelta] = None
1795
- completion_time: Optional[datetime.timedelta] = None
1884
+ start_time: Optional[Duration] = None
1885
+ completion_time: Optional[Duration] = None
1796
1886
  conditions: list[str] = Field(default_factory=list)
1797
- age: datetime.timedelta
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: datetime.timedelta
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`` (unwrapping Optional/Union)."""
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))