k8stools 2.0.3__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.3 → k8stools-2.0.4}/CLAUDE.md +25 -0
  2. {k8stools-2.0.3 → k8stools-2.0.4}/PKG-INFO +22 -2
  3. {k8stools-2.0.3 → k8stools-2.0.4}/README.md +21 -1
  4. {k8stools-2.0.3 → k8stools-2.0.4}/pyproject.toml +1 -1
  5. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/__init__.py +1 -1
  6. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/k8s_tools.py +70 -7
  7. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/redaction.py +14 -0
  8. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_k8s_tools.py +4 -2
  9. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_k8s_tools_realk8s.py +13 -1
  10. k8stools-2.0.4/tests/test_log_decoding.py +147 -0
  11. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_new_tools.py +4 -1
  12. {k8stools-2.0.3 → k8stools-2.0.4}/.claude/settings.local.json +0 -0
  13. {k8stools-2.0.3 → k8stools-2.0.4}/.github/workflows/unit-tests.yml +0 -0
  14. {k8stools-2.0.3 → k8stools-2.0.4}/.gitignore +0 -0
  15. {k8stools-2.0.3 → k8stools-2.0.4}/.python-version +0 -0
  16. {k8stools-2.0.3 → k8stools-2.0.4}/.vscode/mcp.json +0 -0
  17. {k8stools-2.0.3 → k8stools-2.0.4}/.vscode/settings.json +0 -0
  18. {k8stools-2.0.3 → k8stools-2.0.4}/LICENSE +0 -0
  19. {k8stools-2.0.3 → k8stools-2.0.4}/chat-with-cluster.png +0 -0
  20. {k8stools-2.0.3 → k8stools-2.0.4}/designs/mock-state-capture.md +0 -0
  21. {k8stools-2.0.3 → k8stools-2.0.4}/docs/ROADMAP.md +0 -0
  22. {k8stools-2.0.3 → k8stools-2.0.4}/envrc.template +0 -0
  23. {k8stools-2.0.3 → k8stools-2.0.4}/redaction-bug.md +0 -0
  24. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/capture.py +0 -0
  25. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/fixtures/otel-demo.json +0 -0
  26. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mcp_client.py +0 -0
  27. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mcp_server.py +0 -0
  28. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mock_state.py +0 -0
  29. {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mock_tools.py +0 -0
  30. {k8stools-2.0.3 → k8stools-2.0.4}/tests/conftest.py +0 -0
  31. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_capture.py +0 -0
  32. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_duration_format.py +0 -0
  33. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mcp_client.py +0 -0
  34. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mock_state.py +0 -0
  35. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mock_tools.py +0 -0
  36. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_redaction.py +0 -0
  37. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_replicaset_tool.py +0 -0
  38. {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_version.py +0 -0
  39. {k8stools-2.0.3 → k8stools-2.0.4}/time-bug-report.md +0 -0
  40. {k8stools-2.0.3 → k8stools-2.0.4}/uv.lock +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
  ```
@@ -79,6 +80,26 @@ Use snake_case field names. Include `pod_name`/`namespace` in container-level mo
79
80
 
80
81
  All tools are collected in the `TOOLS` list in `k8s_tools.py` for agent/MCP registration.
81
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
+
82
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.
83
104
 
84
105
  ## Error handling
@@ -203,6 +224,10 @@ The exceptions:
203
224
  in the API. With the exemption kept, this whole change is redaction-neutral on
204
225
  that cluster: same 6 redactions, same paths, same values as before it.
205
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
+
206
231
  A field named *exactly* `key` is exempt — in the Kubernetes API that is always
207
232
  structural (taint, map entry, label selector, projected-volume item), never a
208
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
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.3"
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"
@@ -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.3"
81
+ __version__ = "2.0.4"
@@ -899,6 +899,35 @@ def print_pod_spec(pod_name: str, namespace: str = "default") -> None:
899
899
  print(f"Error printing pod spec: {e}")
900
900
 
901
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
+
902
931
  def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
903
932
  container_name:Optional[str]=None,
904
933
  tail:Optional[int]=None,
@@ -925,8 +954,36 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
925
954
  where the current instance's logs are empty or
926
955
  post-restart. Fails if there is no previous instance.
927
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
+
928
984
  Returns:
929
- 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).
930
987
 
931
988
  Raises
932
989
  ------
@@ -947,7 +1004,7 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
947
1004
  namespace=namespace,
948
1005
  container=container_name, # Pass container_name if specified
949
1006
  follow=False, # Set to False to get all current logs
950
- _preload_content=True, # Important: This loads all content into memory
1007
+ _preload_content=False, # Raw response; see _decode_log_response()
951
1008
  timestamps=True, # Optional: Include timestamps
952
1009
  tail_lines=tail if tail is not None else 1000, # Default: last 1000 lines
953
1010
  limit_bytes=1024*1024, # Limit to 1MB to avoid memory issues
@@ -958,11 +1015,9 @@ def get_logs_for_pod_and_container(pod_name:str, namespace:str = "default",
958
1015
  log_kwargs["previous"] = True
959
1016
  resp = K8S.read_namespaced_pod_log(**log_kwargs)
960
1017
 
961
- # The response is a single string containing all logs
962
- if resp:
963
- return resp
964
- else:
965
- 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)
966
1021
  except client.ApiException as e:
967
1022
  raise K8sApiError(f"Error fetching logs: {e}") from e
968
1023
  except Exception as e:
@@ -1989,6 +2044,10 @@ def get_logs_for_job(job_name: str, namespace: str = "default",
1989
2044
  If set, only return logs newer than this many seconds.
1990
2045
  previous : bool, default False
1991
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.
1992
2051
 
1993
2052
  Returns
1994
2053
  -------
@@ -2036,6 +2095,10 @@ def get_logs_for_cronjob(cronjob_name: str, namespace: str = "default",
2036
2095
  If set, only return logs newer than this many seconds.
2037
2096
  previous : bool, default False
2038
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.
2039
2102
 
2040
2103
  Returns
2041
2104
  -------
@@ -234,6 +234,19 @@ def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
234
234
  if isinstance(value, str):
235
235
  return _redact_string(value, key_is_sensitive)
236
236
 
237
+ if isinstance(value, bytes):
238
+ # No tool returns bytes today - `get_logs_for_pod_and_container` decodes at
239
+ # the API boundary - but letting them fall through to the scalar branch at
240
+ # the end would publish the single largest payload we handle unredacted,
241
+ # which is how container logs nearly escaped this pass (issue #6). Redact
242
+ # the decoded text and hand back bytes, so the caller's type is unchanged
243
+ # and a future bytes-returning tool cannot silently repeat it.
244
+ text, count = _redact_string(value.decode("utf-8", errors="replace"),
245
+ key_is_sensitive)
246
+ # Nothing is re-encoded when nothing matched, for the same reason nothing is
247
+ # re-serialized in `_redact_json_string`: clean values keep their exact bytes.
248
+ return (text.encode("utf-8"), count) if count else (value, 0)
249
+
237
250
  if isinstance(value, BaseModel):
238
251
  count = 0
239
252
  for name in type(value).model_fields:
@@ -275,6 +288,7 @@ def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
275
288
  return value, count
276
289
 
277
290
  # int / float / bool / None / datetime / timedelta / etc. — never secret-shaped.
291
+ # Anything that can carry text is handled above, not here.
278
292
  return value, 0
279
293
 
280
294
 
@@ -135,8 +135,10 @@ class MockK8S:
135
135
  )
136
136
  return SimpleNamespace(items=[event1, event2])
137
137
 
138
- def read_namespaced_pod_log(self, name, namespace, container=None, follow=False, _preload_content=True, timestamps=True, tail_lines=None, limit_bytes=None):
139
- # Return a sample log string for testing
138
+ def read_namespaced_pod_log(self, name, namespace, container=None, follow=False, _preload_content=False, timestamps=True, tail_lines=None, limit_bytes=None):
139
+ # Returns an already-decoded str, unlike the bytes-bearing response the real
140
+ # client hands back; this is the deliberate counterpart to MockCoreV1 in
141
+ # test_new_tools.py, and covers the pass-through (no double-decode) branch.
140
142
  if name == "pod-1" and namespace == "default" and container == "container-1":
141
143
  return "2025-07-12T00:00:00Z container-1 log line 1\n2025-07-12T00:01:00Z container-1 log line 2"
142
144
  return ""
@@ -155,8 +155,20 @@ def test_retrieve_logs_for_pod_and_container():
155
155
 
156
156
  pod_name = running_pod.metadata.name
157
157
  try:
158
- logs = k8s_tools.get_logs_for_pod_and_container(pod_name, "default")
158
+ logs = k8s_tools.get_logs_for_pod_and_container(pod_name, "default", tail=5)
159
159
  assert isinstance(logs, str)
160
+ if logs:
161
+ # Real text, not `repr(bytes)` - the failure mode of issue #6, which only
162
+ # a real client reproduces (a fake returning str hides it entirely).
163
+ assert not logs.startswith("b'")
164
+ assert "\\n" not in logs
165
+ assert logs.count("\n") <= 5
166
+ # Every line is timestamped, so a decoded log always has a real newline.
167
+ assert logs.endswith("\n")
168
+ # `previous` is not asserted to differ from the above: while a container is in
169
+ # CrashLoopBackOff the kubelet serves the same terminated instance for both,
170
+ # and a reclaimed log file comes back 200 with "unable to retrieve container
171
+ # logs". Both are correct, so the only stable assertion is on the format.
160
172
  except k8s_tools.K8sApiError as e:
161
173
  # If we still get an error (e.g., no logs available yet), that's acceptable for this test
162
174
  # We just want to make sure the function doesn't crash unexpectedly
@@ -0,0 +1,147 @@
1
+ """Tests for log decoding at the Kubernetes API boundary (issue #6).
2
+
3
+ `read_namespaced_pod_log` is the only tool call whose response is a bare scalar
4
+ rather than a model, and the generated client stringifies its body with
5
+ `str(data)`. Since kubernetes 36 that body is `bytes`, so the client returns
6
+ `repr(bytes)`: one `b'...'` line with newlines as the two characters `\\n`.
7
+ `get_logs_for_pod_and_container` therefore asks for the raw response and decodes
8
+ it itself; these tests pin that down from both directions, plus the redaction and
9
+ `previous`-plumbing behavior that depends on it.
10
+ """
11
+
12
+ from types import SimpleNamespace
13
+
14
+ import pytest
15
+
16
+ from k8stools import k8s_tools
17
+ from k8stools.redaction import REDACTED, redact_object, wrap_with_redaction
18
+
19
+
20
+ class FakeLogApi:
21
+ """Stands in for CoreV1Api, returning whatever body the test hands it."""
22
+
23
+ def __init__(self, body):
24
+ self.body = body
25
+ self.last_log_kwargs = None
26
+
27
+ def read_namespaced_pod_log(self, **kwargs):
28
+ self.last_log_kwargs = kwargs
29
+ if isinstance(self.body, (bytes, str)) or self.body is None:
30
+ # The raw urllib3 response carries the body on `.data`.
31
+ return SimpleNamespace(data=self.body)
32
+ return self.body
33
+
34
+
35
+ @pytest.fixture
36
+ def fake_api(monkeypatch):
37
+ def install(body):
38
+ api = FakeLogApi(body)
39
+ monkeypatch.setattr(k8s_tools, "K8S", api)
40
+ return api
41
+ return install
42
+
43
+
44
+ # --- decoding ---------------------------------------------------------------
45
+
46
+ LOG_BYTES = (b"2026-09-19T00:00:00Z line one\n"
47
+ b"2026-09-19T00:00:01Z line two\n"
48
+ b"2026-09-19T00:00:02Z line three\n")
49
+
50
+
51
+ def test_bytes_body_decoded_to_text(fake_api):
52
+ fake_api(LOG_BYTES)
53
+ logs = k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c")
54
+ assert isinstance(logs, str)
55
+ assert not logs.startswith("b'")
56
+ assert logs.count("\n") == 3 # real newlines...
57
+ assert "\\n" not in logs # ...not the two characters of an escape
58
+ assert logs.splitlines()[0].endswith("line one")
59
+
60
+
61
+ def test_str_body_passed_through_not_double_decoded(fake_api):
62
+ """A client that decodes for us (or a preloaded response) must not be re-decoded."""
63
+ fake_api("2026-09-19T00:00:00Z already text\n")
64
+ logs = k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c")
65
+ assert logs == "2026-09-19T00:00:00Z already text\n"
66
+
67
+
68
+ def test_bare_str_response_without_data_attribute(monkeypatch):
69
+ """A preloaded response is a bare str with no `.data` at all; still fine."""
70
+ class PreloadedApi:
71
+ def read_namespaced_pod_log(self, **kwargs):
72
+ return "plain string response"
73
+
74
+ monkeypatch.setattr(k8s_tools, "K8S", PreloadedApi())
75
+ assert k8s_tools.get_logs_for_pod_and_container("pod-1") == "plain string response"
76
+
77
+
78
+ def test_invalid_utf8_does_not_raise(fake_api):
79
+ """`limit_bytes` truncates at a byte offset, which can split a character."""
80
+ fake_api(b"2026-09-19T00:00:00Z caf\xe9 \xf0\x9f\x92 truncated\n")
81
+ logs = k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c")
82
+ assert "�" in logs
83
+ assert "truncated" in logs
84
+
85
+
86
+ @pytest.mark.parametrize("body", [b"", None])
87
+ def test_empty_log_is_empty_string(fake_api, body):
88
+ fake_api(body)
89
+ assert k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c") == ''
90
+
91
+
92
+ def test_raw_response_requested(fake_api):
93
+ """Flipping `_preload_content` back on reintroduces `repr(bytes)`; pin it off."""
94
+ api = fake_api(LOG_BYTES)
95
+ k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c")
96
+ assert api.last_log_kwargs["_preload_content"] is False
97
+
98
+
99
+ # --- redaction --------------------------------------------------------------
100
+
101
+ SECRET_LOG = (b"2026-09-19T00:00:00Z starting up\n"
102
+ b"2026-09-19T00:00:01Z using key AKIAIOSFODNN7EXAMPLE\n")
103
+
104
+
105
+ @pytest.mark.parametrize("body", [SECRET_LOG, SECRET_LOG.decode()])
106
+ def test_secret_in_logs_is_redacted(fake_api, body):
107
+ """Logs are the largest and most secret-prone payload the server returns;
108
+ they must not escape the redaction boundary in either response shape."""
109
+ fake_api(body)
110
+ tool = wrap_with_redaction(k8s_tools.get_logs_for_pod_and_container)
111
+ logs = tool("pod-1", "default", "c")
112
+ assert "AKIAIOSFODNN7EXAMPLE" not in logs
113
+ assert REDACTED in logs
114
+ assert "starting up" in logs # span-granular: the rest stays readable
115
+
116
+
117
+ def test_bytes_are_redacted_not_passed_through():
118
+ """Defence in depth: bytes reaching `_redact` directly are handled, not
119
+ dropped into the never-secret-shaped scalar branch."""
120
+ out, count = redact_object({"blob": b"token AKIAIOSFODNN7EXAMPLE here"})
121
+ assert count == 1
122
+ assert isinstance(out["blob"], bytes)
123
+ assert b"AKIAIOSFODNN7EXAMPLE" not in out["blob"]
124
+ assert REDACTED.encode() in out["blob"]
125
+
126
+
127
+ def test_clean_bytes_keep_their_exact_value():
128
+ original = b"nothing secret here\n\xff\xfe"
129
+ out, count = redact_object({"blob": original})
130
+ assert count == 0
131
+ assert out["blob"] == original # not re-encoded, so no lossy round-trip
132
+
133
+
134
+ # --- `previous` plumbing ----------------------------------------------------
135
+
136
+ def test_previous_forwarded_once(fake_api):
137
+ api = fake_api(LOG_BYTES)
138
+ k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c", previous=True)
139
+ assert api.last_log_kwargs["previous"] is True
140
+
141
+
142
+ def test_previous_false_omits_the_key(fake_api):
143
+ """The API rejects `previous` for a container with no terminated instance, so
144
+ the key is left out entirely rather than sent as False."""
145
+ api = fake_api(LOG_BYTES)
146
+ k8s_tools.get_logs_for_pod_and_container("pod-1", "default", "c")
147
+ assert "previous" not in api.last_log_kwargs
@@ -113,7 +113,10 @@ class MockCoreV1:
113
113
 
114
114
  def read_namespaced_pod_log(self, name, namespace, container=None, **kwargs):
115
115
  self.last_log_kwargs = dict(name=name, namespace=namespace, container=container, **kwargs)
116
- return f"logs for {name}"
116
+ # Faithful to the real client under `_preload_content=False`: the raw
117
+ # response object, with the body as bytes on `.data`. A fake returning a
118
+ # plain str is what let the repr-of-bytes bug (issue #6) reach a release.
119
+ return SimpleNamespace(data=f"logs for {name}".encode())
117
120
 
118
121
  # --- events ---
119
122
  def list_event_for_all_namespaces(self, field_selector=None):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes