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.
- {k8stools-2.0.3 → k8stools-2.0.4}/CLAUDE.md +25 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/PKG-INFO +22 -2
- {k8stools-2.0.3 → k8stools-2.0.4}/README.md +21 -1
- {k8stools-2.0.3 → k8stools-2.0.4}/pyproject.toml +1 -1
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/__init__.py +1 -1
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/k8s_tools.py +70 -7
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/redaction.py +14 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_k8s_tools.py +4 -2
- {k8stools-2.0.3 → 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.3 → k8stools-2.0.4}/tests/test_new_tools.py +4 -1
- {k8stools-2.0.3 → k8stools-2.0.4}/.claude/settings.local.json +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/.github/workflows/unit-tests.yml +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/.gitignore +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/.python-version +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/.vscode/mcp.json +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/.vscode/settings.json +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/LICENSE +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/chat-with-cluster.png +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/designs/mock-state-capture.md +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/docs/ROADMAP.md +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/envrc.template +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/redaction-bug.md +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/capture.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/fixtures/otel-demo.json +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mcp_client.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mcp_server.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mock_state.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/src/k8stools/mock_tools.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/conftest.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_capture.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_duration_format.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mcp_client.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mock_state.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_mock_tools.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_redaction.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_replicaset_tool.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/tests/test_version.py +0 -0
- {k8stools-2.0.3 → k8stools-2.0.4}/time-bug-report.md +0 -0
- {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
|
+
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
|
|
@@ -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
|
|
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=
|
|
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
|
-
#
|
|
962
|
-
|
|
963
|
-
|
|
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=
|
|
139
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|