k8stools 2.0.2__tar.gz → 2.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {k8stools-2.0.2 → k8stools-2.0.3}/CLAUDE.md +41 -1
- {k8stools-2.0.2 → k8stools-2.0.3}/PKG-INFO +1 -1
- {k8stools-2.0.2 → k8stools-2.0.3}/pyproject.toml +1 -1
- k8stools-2.0.3/redaction-bug.md +25 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/__init__.py +1 -1
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/k8s_tools.py +54 -19
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mock_state.py +10 -1
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/redaction.py +92 -11
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_capture.py +5 -1
- k8stools-2.0.3/tests/test_duration_format.py +182 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_redaction.py +122 -0
- k8stools-2.0.3/time-bug-report.md +159 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/uv.lock +1 -1
- {k8stools-2.0.2 → k8stools-2.0.3}/.claude/settings.local.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/.github/workflows/unit-tests.yml +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/.gitignore +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/.python-version +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/.vscode/mcp.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/.vscode/settings.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/LICENSE +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/README.md +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/chat-with-cluster.png +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/designs/mock-state-capture.md +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/docs/ROADMAP.md +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/envrc.template +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/capture.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/fixtures/otel-demo.json +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mcp_client.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mcp_server.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mock_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/conftest.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_k8s_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_k8s_tools_realk8s.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mcp_client.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mock_state.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mock_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_new_tools.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_replicaset_tool.py +0 -0
- {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_version.py +0 -0
|
@@ -60,7 +60,22 @@ Tools in `k8s_tools.py` follow three patterns:
|
|
|
60
60
|
2. **API-typed** — Pydantic models that match K8s client types with lesser-used fields omitted (e.g. `get_pod_container_statuses`)
|
|
61
61
|
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
62
|
|
|
63
|
-
Use `
|
|
63
|
+
Use the `Duration` type (`k8s_tools.Duration`) for age/duration fields, not a bare
|
|
64
|
+
`datetime.timedelta`. It is a `timedelta` that normalizes to whole, non-negative
|
|
65
|
+
seconds on validation. Pydantic describes a `timedelta` as
|
|
66
|
+
`{"type": "string", "format": "duration"}` but serializes a microsecond-bearing one
|
|
67
|
+
as `"P2DT5M27.978616S"`; JSON Schema's `duration` format points at RFC 3339
|
|
68
|
+
Appendix A, whose grammar allows neither a decimal point nor a sign. An MCP client
|
|
69
|
+
that validates structured output against the declared schema (ajv does; Python's
|
|
70
|
+
`jsonschema` skips `duration` unless the optional `isoduration` extra is installed)
|
|
71
|
+
therefore rejected the whole call, on every row of every list tool. Since every age
|
|
72
|
+
is `now() - <k8s timestamp>` and Kubernetes timestamps are whole-second, the
|
|
73
|
+
fractional part was never information — just the microseconds of that one `now()`.
|
|
74
|
+
This is the same rule `MockState`'s replay clock already applies, for the same
|
|
75
|
+
reason. `tests/test_duration_format.py` checks each model's serialized output
|
|
76
|
+
against the format its own schema declares.
|
|
77
|
+
|
|
78
|
+
Use snake_case field names. Include `pod_name`/`namespace` in container-level models for context.
|
|
64
79
|
|
|
65
80
|
All tools are collected in the `TOOLS` list in `k8s_tools.py` for agent/MCP registration.
|
|
66
81
|
|
|
@@ -163,6 +178,31 @@ keys) and values under keys/env-var names matching
|
|
|
163
178
|
separators and camelCase, so `apiKey` matches and `VALKEY_ADDR` does not),
|
|
164
179
|
replacing them with a visible `[REDACTED]` marker.
|
|
165
180
|
|
|
181
|
+
Redaction is **span-granular, with two deliberate exceptions.** A matched token
|
|
182
|
+
(AWS key, JWT) is replaced where it sits, leaving the surrounding text readable.
|
|
183
|
+
The exceptions:
|
|
184
|
+
|
|
185
|
+
- **PEM blocks go whole.** The pattern matches only the `-----BEGIN ... PRIVATE
|
|
186
|
+
KEY-----` header, and the secret is the base64 body *after* it — substituting
|
|
187
|
+
just the matched span would black out the header and publish the key. Hence the
|
|
188
|
+
`_TOKEN_SHAPE_RES` / `_CONTAINMENT_SHAPE_RES` split: never move a pattern into
|
|
189
|
+
the former unless its match is the entire secret.
|
|
190
|
+
- **A string that parses as a JSON object/array is recursed into** and
|
|
191
|
+
re-serialized, rather than treated as one opaque value. Config-as-JSON under a
|
|
192
|
+
single ConfigMap key is common, and opacity failed in both directions: one
|
|
193
|
+
incidental token match blacked out an entire non-secret config file, while a real
|
|
194
|
+
`{"password": ...}` inside a blob was invisible to the name rule and passed
|
|
195
|
+
through verbatim. Nothing is re-serialized when nothing matched, so clean values
|
|
196
|
+
keep their exact formatting.
|
|
197
|
+
|
|
198
|
+
The `key` exemption below applies *inside* these blobs too. Lifting it there
|
|
199
|
+
looked obviously right — those names are the author's, not Kubernetes schema —
|
|
200
|
+
and re-measuring said otherwise: on the 38-pod cluster it added 12 redactions,
|
|
201
|
+
every one `tags[].key: "instance"` in a Grafana dashboard ConfigMap, and no
|
|
202
|
+
secrets. Structural `key` fields dominate inside config documents just as they do
|
|
203
|
+
in the API. With the exemption kept, this whole change is redaction-neutral on
|
|
204
|
+
that cluster: same 6 redactions, same paths, same values as before it.
|
|
205
|
+
|
|
166
206
|
A field named *exactly* `key` is exempt — in the Kubernetes API that is always
|
|
167
207
|
structural (taint, map entry, label selector, projected-volume item), never a
|
|
168
208
|
credential. Before that exemption a real 38-pod capture produced 139 redactions of
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# get_configmap / ConfigMap.data redaction: a single value-shape match anywhere in a JSON-serialized ConfigMap value blacks out the entire value, not just the match
|
|
2
|
+
## Repro:
|
|
3
|
+
any ConfigMap whose data value is a JSON-encoded blob (common pattern: config-as-JSON-string under one key) where some substring incidentally matches the JWT/AKIA/PEM regexes.
|
|
4
|
+
|
|
5
|
+
## Impact:
|
|
6
|
+
legitimate, non-secret operational config (in our case: warm-runtime pool target count) becomes fully unreadable via the MCP tool, forcing a workaround (grep-ing cronjob logs instead).
|
|
7
|
+
|
|
8
|
+
## Suggested fixes (in rough order of effort):
|
|
9
|
+
* When a string value looks like JSON (json.loads succeeds), recurse redaction into the parsed structure and re-serialize, instead of treating it as one opaque string.
|
|
10
|
+
* At minimum, redact only the matched span within the string (rx.sub(REDACTED, s)) rather than replacing the whole value when a partial match is found — this alone would have kept the rest of warm-runtimes.json visible.
|
|
11
|
+
* Note in output when a value is a "detected JSON string with an internal match" vs. a flat credential-shaped value, so callers can tell which failure mode they hit.
|
|
12
|
+
|
|
13
|
+
## What we saw
|
|
14
|
+
Reading the warm-runtimes-config ConfigMap in openhands-cloud-c3:
|
|
15
|
+
```
|
|
16
|
+
{
|
|
17
|
+
"name": "warm-runtimes-config",
|
|
18
|
+
"namespace": "openhands-cloud-c3",
|
|
19
|
+
"data": {
|
|
20
|
+
"warm-runtimes.json": "[REDACTED]"
|
|
21
|
+
},
|
|
22
|
+
"binary_data_keys": []
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
The whole value came back redacted, even though this ConfigMap is just warm-pool sizing config (count, runtime spec names) — nothing secret. We had to fall back to the reconciler's own log line (Missing N runtimes for v1, creating N warm runtimes...) to get the real number (5).
|
|
@@ -16,9 +16,9 @@ import sys
|
|
|
16
16
|
import os
|
|
17
17
|
import logging
|
|
18
18
|
import datetime
|
|
19
|
-
from typing import Optional, Union, Literal, Any
|
|
19
|
+
from typing import Optional, Union, Literal, Any, Annotated
|
|
20
20
|
|
|
21
|
-
from pydantic import BaseModel, Field
|
|
21
|
+
from pydantic import BaseModel, Field, AfterValidator
|
|
22
22
|
import yaml
|
|
23
23
|
|
|
24
24
|
from kubernetes import client, config
|
|
@@ -85,12 +85,47 @@ def _get_batch_v1_api_client() -> client.BatchV1Api:
|
|
|
85
85
|
raise K8sConfigError(f"Unexpected error: {e}") from e
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _to_whole_seconds(td: datetime.timedelta) -> datetime.timedelta:
|
|
89
|
+
"""Truncate a duration to whole seconds, flooring at zero.
|
|
90
|
+
|
|
91
|
+
Both adjustments keep the value inside the grammar our own JSON Schema
|
|
92
|
+
declares for it (see :data:`Duration`).
|
|
93
|
+
|
|
94
|
+
Truncation: every age here is ``now() - <k8s timestamp>``, and Kubernetes
|
|
95
|
+
timestamps are whole-second, so the sub-second part of an age is nothing but
|
|
96
|
+
the microseconds of the ``now()`` that happened to compute it - noise, and
|
|
97
|
+
identical across every row of a single response. This matches the rule
|
|
98
|
+
``MockState``'s replay clock already follows for the same reason.
|
|
99
|
+
|
|
100
|
+
Flooring: a cluster whose clock is ahead of ours yields a small negative age,
|
|
101
|
+
which pydantic renders as ``-PT5S``. A duration has no sign in this grammar,
|
|
102
|
+
so clamp it; at this granularity "just created" is the honest reading anyway.
|
|
103
|
+
"""
|
|
104
|
+
return datetime.timedelta(seconds=max(int(td.total_seconds()), 0))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: A ``timedelta`` field on a response model.
|
|
108
|
+
#:
|
|
109
|
+
#: Pydantic's default JSON Schema for ``timedelta`` is
|
|
110
|
+
#: ``{"type": "string", "format": "duration"}``, but its default *serialization*
|
|
111
|
+
#: emits fractional seconds (``"P2DT5M27.978616S"``) whenever the value carries
|
|
112
|
+
#: microseconds. JSON Schema's ``duration`` format references RFC 3339 Appendix A,
|
|
113
|
+
#: whose grammar admits only integer components - so pydantic's own schema and its
|
|
114
|
+
#: own serializer disagree, and an MCP client that validates structured output
|
|
115
|
+
#: against the declared schema (ajv does; Python's ``jsonschema`` skips ``duration``
|
|
116
|
+
#: unless the optional ``isoduration`` extra is installed) rejects the tool call
|
|
117
|
+
#: outright, on every row, before the caller sees any data.
|
|
118
|
+
#:
|
|
119
|
+
#: Normalizing the value rather than the schema keeps ``format: "duration"``
|
|
120
|
+
#: accurate and keeps direct Python callers' fields real ``timedelta`` objects.
|
|
121
|
+
Duration = Annotated[datetime.timedelta, AfterValidator(_to_whole_seconds)]
|
|
122
|
+
|
|
88
123
|
|
|
89
124
|
class NamespaceSummary(BaseModel):
|
|
90
125
|
"""Summary information about a namespace, like returned by `kubectl get namespace`"""
|
|
91
126
|
name: str
|
|
92
127
|
status: str
|
|
93
|
-
age:
|
|
128
|
+
age: Duration
|
|
94
129
|
|
|
95
130
|
|
|
96
131
|
def get_namespaces() -> list[NamespaceSummary]:
|
|
@@ -142,7 +177,7 @@ class NodeSummary(BaseModel):
|
|
|
142
177
|
name: str
|
|
143
178
|
status: str
|
|
144
179
|
roles: list[str]
|
|
145
|
-
age:
|
|
180
|
+
age: Duration
|
|
146
181
|
version: str
|
|
147
182
|
internal_ip: Optional[str] = None
|
|
148
183
|
external_ip: Optional[str] = None
|
|
@@ -347,8 +382,8 @@ class PodSummary(BaseModel):
|
|
|
347
382
|
total_containers: int
|
|
348
383
|
ready_containers: int
|
|
349
384
|
restarts: int
|
|
350
|
-
last_restart: Optional[
|
|
351
|
-
age:
|
|
385
|
+
last_restart: Optional[Duration]
|
|
386
|
+
age: Duration
|
|
352
387
|
ip: Optional[str] = None
|
|
353
388
|
node: Optional[str] = None
|
|
354
389
|
|
|
@@ -501,7 +536,7 @@ def _format_timedelta(td: Optional[datetime.timedelta]) -> str:
|
|
|
501
536
|
|
|
502
537
|
class EventSummary(BaseModel):
|
|
503
538
|
"""This is the representation of a Kubernetes Event"""
|
|
504
|
-
last_seen: Optional[
|
|
539
|
+
last_seen: Optional[Duration] # Time since event occurred
|
|
505
540
|
type: str
|
|
506
541
|
reason: str
|
|
507
542
|
object: str
|
|
@@ -942,7 +977,7 @@ class DeploymentSummary(BaseModel):
|
|
|
942
977
|
ready_replicas: int
|
|
943
978
|
up_to_date_relicas: int
|
|
944
979
|
available_replicas: int
|
|
945
|
-
age:
|
|
980
|
+
age: Duration
|
|
946
981
|
|
|
947
982
|
def get_deployment_summaries(namespace: Optional[str] = None) -> list[DeploymentSummary]:
|
|
948
983
|
"""
|
|
@@ -1047,7 +1082,7 @@ class ReplicaSetSummary(BaseModel):
|
|
|
1047
1082
|
current_replicas: int
|
|
1048
1083
|
ready_replicas: int
|
|
1049
1084
|
images: list[str]
|
|
1050
|
-
age:
|
|
1085
|
+
age: Duration
|
|
1051
1086
|
|
|
1052
1087
|
|
|
1053
1088
|
def get_replicaset_summaries(namespace: Optional[str] = None,
|
|
@@ -1227,7 +1262,7 @@ class ServiceSummary(BaseModel):
|
|
|
1227
1262
|
cluster_ip: Optional[str] = None
|
|
1228
1263
|
external_ip: Optional[str] = None
|
|
1229
1264
|
ports: list[PortInfo]
|
|
1230
|
-
age:
|
|
1265
|
+
age: Duration
|
|
1231
1266
|
selector: dict[str, str] = Field(default_factory=dict)
|
|
1232
1267
|
labels: dict[str, str] = Field(default_factory=dict)
|
|
1233
1268
|
annotations: dict[str, str] = Field(default_factory=dict)
|
|
@@ -1378,7 +1413,7 @@ class ConfigMapSummary(BaseModel):
|
|
|
1378
1413
|
namespace: str
|
|
1379
1414
|
key_count: int
|
|
1380
1415
|
data_size: int # total bytes across all values (data + binary_data)
|
|
1381
|
-
age:
|
|
1416
|
+
age: Duration
|
|
1382
1417
|
|
|
1383
1418
|
|
|
1384
1419
|
def _configmap_key_count_and_size(config_map) -> tuple[int, int]:
|
|
@@ -1548,7 +1583,7 @@ class StatefulSetSummary(BaseModel):
|
|
|
1548
1583
|
current_replicas: int
|
|
1549
1584
|
update_strategy: str
|
|
1550
1585
|
service_name: Optional[str] = None
|
|
1551
|
-
age:
|
|
1586
|
+
age: Duration
|
|
1552
1587
|
|
|
1553
1588
|
|
|
1554
1589
|
def get_statefulset_summaries(namespace: Optional[str] = None) -> list[StatefulSetSummary]:
|
|
@@ -1679,9 +1714,9 @@ class CronJobSummary(BaseModel):
|
|
|
1679
1714
|
schedule: str
|
|
1680
1715
|
suspend: bool
|
|
1681
1716
|
active: int
|
|
1682
|
-
last_schedule_time: Optional[
|
|
1683
|
-
last_successful_time: Optional[
|
|
1684
|
-
age:
|
|
1717
|
+
last_schedule_time: Optional[Duration] = None
|
|
1718
|
+
last_successful_time: Optional[Duration] = None
|
|
1719
|
+
age: Duration
|
|
1685
1720
|
containers: list[ContainerTemplateSummary] = Field(default_factory=list)
|
|
1686
1721
|
|
|
1687
1722
|
|
|
@@ -1791,10 +1826,10 @@ class JobSummary(BaseModel):
|
|
|
1791
1826
|
active: int
|
|
1792
1827
|
succeeded: int
|
|
1793
1828
|
failed: int
|
|
1794
|
-
start_time: Optional[
|
|
1795
|
-
completion_time: Optional[
|
|
1829
|
+
start_time: Optional[Duration] = None
|
|
1830
|
+
completion_time: Optional[Duration] = None
|
|
1796
1831
|
conditions: list[str] = Field(default_factory=list)
|
|
1797
|
-
age:
|
|
1832
|
+
age: Duration
|
|
1798
1833
|
containers: list[ContainerTemplateSummary] = Field(default_factory=list)
|
|
1799
1834
|
|
|
1800
1835
|
|
|
@@ -2041,7 +2076,7 @@ class PVCSummary(BaseModel):
|
|
|
2041
2076
|
access_modes: list[str] = Field(default_factory=list)
|
|
2042
2077
|
storage_class: Optional[str] = None
|
|
2043
2078
|
mounted_by: list[str] = Field(default_factory=list)
|
|
2044
|
-
age:
|
|
2079
|
+
age: Duration
|
|
2045
2080
|
|
|
2046
2081
|
|
|
2047
2082
|
def get_pvc_summaries(namespace: Optional[str] = None) -> list[PVCSummary]:
|
|
@@ -76,9 +76,18 @@ class CaptureFormatError(Exception):
|
|
|
76
76
|
# ---------------------------------------------------------------------------
|
|
77
77
|
|
|
78
78
|
def _annotation_contains(annotation: Any, target: type) -> bool:
|
|
79
|
-
"""True if ``target`` appears anywhere in ``annotation
|
|
79
|
+
"""True if ``target`` appears anywhere in ``annotation``.
|
|
80
|
+
|
|
81
|
+
Unwraps ``Optional``/``Union`` and ``Annotated``. The latter matters because
|
|
82
|
+
``k8s_tools.Duration`` is an annotated ``timedelta``: pydantic strips the
|
|
83
|
+
metadata from a bare field's ``annotation`` but keeps it inside an
|
|
84
|
+
``Optional[...]``, so without this an optional duration would silently encode
|
|
85
|
+
under its plain name instead of ``<field>_seconds`` and fail to round-trip.
|
|
86
|
+
"""
|
|
80
87
|
if annotation is target:
|
|
81
88
|
return True
|
|
89
|
+
if hasattr(annotation, "__metadata__"): # Annotated[X, ...] -> X
|
|
90
|
+
return _annotation_contains(typing.get_args(annotation)[0], target)
|
|
82
91
|
origin = typing.get_origin(annotation)
|
|
83
92
|
if origin is Union or origin is types.UnionType:
|
|
84
93
|
return any(_annotation_contains(arg, target) for arg in typing.get_args(annotation))
|
|
@@ -29,7 +29,9 @@ Two independent signals trigger a redaction:
|
|
|
29
29
|
|
|
30
30
|
1. **Value shape** — the string looks like a credential regardless of where it
|
|
31
31
|
sits: AWS access keys (``AKIA...``), bearer/JWT tokens (``eyJ...``), or PEM
|
|
32
|
-
private-key blocks.
|
|
32
|
+
private-key blocks. A self-contained token is replaced where it sits, leaving
|
|
33
|
+
the rest of the surrounding text readable; a PEM header stands for a secret
|
|
34
|
+
that continues past the match, so that value goes in full.
|
|
33
35
|
2. **Key / field name** — the enclosing map key, model field, or env-var name
|
|
34
36
|
contains one of ``key``, ``secret``, ``token``, ``password``, ``passwd``,
|
|
35
37
|
``credential``, ``cred`` **as a whole word**. Names are split on separators and
|
|
@@ -48,6 +50,14 @@ Two independent signals trigger a redaction:
|
|
|
48
50
|
nothing. The exemption does not apply to env-var names, where a variable a user
|
|
49
51
|
named ``KEY`` plausibly does hold one.
|
|
50
52
|
|
|
53
|
+
A string value that is itself a JSON object or array — config-as-JSON under a
|
|
54
|
+
single ConfigMap key is a common pattern — is parsed and redacted entry by entry
|
|
55
|
+
rather than treated as one opaque blob, then re-serialized (which normalizes its
|
|
56
|
+
whitespace). Both rules apply inside, so an incidental token match no longer
|
|
57
|
+
blacks out a whole config file and a ``{"password": ...}`` buried in one no longer
|
|
58
|
+
escapes the name rule. Inside such a blob the ``key`` exemption is lifted: those
|
|
59
|
+
names are the author's, not Kubernetes schema.
|
|
60
|
+
|
|
51
61
|
Expect occasional false positives on high-entropy-but-non-secret values. The
|
|
52
62
|
visible marker and the opt-out make that tolerable. This is a defense-in-depth
|
|
53
63
|
aid, not a guarantee — callers that need strong guarantees should still scrub on
|
|
@@ -60,10 +70,11 @@ redaction layer) get raw, un-redacted return values and can call
|
|
|
60
70
|
|
|
61
71
|
import copy
|
|
62
72
|
import functools
|
|
73
|
+
import json
|
|
63
74
|
import logging
|
|
64
75
|
import os
|
|
65
76
|
import re
|
|
66
|
-
from typing import Any
|
|
77
|
+
from typing import Any, Optional
|
|
67
78
|
|
|
68
79
|
from pydantic import BaseModel
|
|
69
80
|
|
|
@@ -127,17 +138,91 @@ def _field_name_is_sensitive(name: str) -> bool:
|
|
|
127
138
|
return False
|
|
128
139
|
return _name_is_sensitive(name)
|
|
129
140
|
|
|
130
|
-
#: Value-shape patterns
|
|
131
|
-
#:
|
|
132
|
-
|
|
141
|
+
#: Value-shape patterns whose match *is* the whole secret. Because the match is
|
|
142
|
+
#: self-contained, only the matched span needs to go — the surrounding text is
|
|
143
|
+
#: ordinary config and stays readable.
|
|
144
|
+
_TOKEN_SHAPE_RES = (
|
|
133
145
|
re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
|
|
134
146
|
re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), # JWT / bearer token
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
#: Value-shape patterns that merely *mark* a secret whose sensitive part lies
|
|
150
|
+
#: outside the match. A PEM header is the whole of the match but none of the
|
|
151
|
+
#: secret - the key material is the base64 body that follows - so a value matching
|
|
152
|
+
#: one of these is replaced in full. Substituting only the matched span here would
|
|
153
|
+
#: black out the `-----BEGIN RSA PRIVATE KEY-----` line and publish the key.
|
|
154
|
+
_CONTAINMENT_SHAPE_RES = (
|
|
135
155
|
re.compile(r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----"), # PEM private key block
|
|
136
156
|
)
|
|
137
157
|
|
|
138
158
|
|
|
139
159
|
def _value_is_secret_shaped(s: str) -> bool:
|
|
140
|
-
|
|
160
|
+
"""Whether ``s`` matches any value-shape pattern, anywhere in the string."""
|
|
161
|
+
return any(
|
|
162
|
+
rx.search(s) for rx in (*_TOKEN_SHAPE_RES, *_CONTAINMENT_SHAPE_RES)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _redact_json_string(s: str) -> Optional[tuple[str, int]]:
|
|
167
|
+
"""Redact inside a string that is itself a JSON document.
|
|
168
|
+
|
|
169
|
+
Storing config-as-JSON under a single ConfigMap key is a common pattern, and
|
|
170
|
+
it defeats a redactor that treats every string as one opaque value in *both*
|
|
171
|
+
directions: one incidental token match blacks out an entire config file, while
|
|
172
|
+
a real ``{"password": "..."}`` inside the blob is invisible to the key-name
|
|
173
|
+
rule and passes through verbatim. Parsing, recursing, and re-serializing fixes
|
|
174
|
+
both — a match redacts its own entry and nothing else.
|
|
175
|
+
|
|
176
|
+
Returns ``None`` when ``s`` is not a JSON object/array, or when recursion found
|
|
177
|
+
nothing, so the caller falls through and the original text is left byte-for-byte
|
|
178
|
+
intact. Re-serialization otherwise normalizes whitespace, which is why it only
|
|
179
|
+
happens when there is actually something to hide.
|
|
180
|
+
"""
|
|
181
|
+
stripped = s.strip()
|
|
182
|
+
# Cheap gate: only objects and arrays are worth parsing. It also keeps bare
|
|
183
|
+
# scalars ("5", "true") from round-tripping through json.dumps.
|
|
184
|
+
if not stripped or stripped[0] not in "{[":
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
parsed = json.loads(stripped)
|
|
188
|
+
except (ValueError, RecursionError):
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(parsed, (dict, list)):
|
|
191
|
+
return None
|
|
192
|
+
# The `key` exemption applies in here too. Lifting it looked right - these are
|
|
193
|
+
# the author's field names, not Kubernetes schema - but measured against a real
|
|
194
|
+
# cluster it added 12 redactions, all of them `tags[].key: "instance"` in a
|
|
195
|
+
# Grafana dashboard ConfigMap, and no secrets. Structural `key` fields dominate
|
|
196
|
+
# inside config documents exactly as they do in the API. A credential-shaped
|
|
197
|
+
# value under a `key` field is still caught by the value-shape rules.
|
|
198
|
+
redacted, count = _redact(parsed)
|
|
199
|
+
if not count:
|
|
200
|
+
return None
|
|
201
|
+
return json.dumps(redacted, indent=2 if "\n" in s else None), count
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _redact_string(value: str, key_is_sensitive: bool) -> tuple[str, int]:
|
|
205
|
+
"""Redact a single string value. See :func:`_redact` for the parameters."""
|
|
206
|
+
if value == REDACTED:
|
|
207
|
+
return value, 0
|
|
208
|
+
if key_is_sensitive:
|
|
209
|
+
return REDACTED, 1
|
|
210
|
+
|
|
211
|
+
# Before treating the string as opaque text, see whether it is structured. A
|
|
212
|
+
# PEM block nested inside a JSON blob is handled by this recursion too: it
|
|
213
|
+
# arrives back here as a leaf and hits the containment rule below.
|
|
214
|
+
nested = _redact_json_string(value)
|
|
215
|
+
if nested is not None:
|
|
216
|
+
return nested
|
|
217
|
+
|
|
218
|
+
if any(rx.search(value) for rx in _CONTAINMENT_SHAPE_RES):
|
|
219
|
+
return REDACTED, 1
|
|
220
|
+
|
|
221
|
+
count = 0
|
|
222
|
+
for rx in _TOKEN_SHAPE_RES:
|
|
223
|
+
value, n = rx.subn(REDACTED, value)
|
|
224
|
+
count += n
|
|
225
|
+
return value, count
|
|
141
226
|
|
|
142
227
|
|
|
143
228
|
def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
|
|
@@ -147,11 +232,7 @@ def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
|
|
|
147
232
|
strings are immutable so the parent assigns the replacement.
|
|
148
233
|
"""
|
|
149
234
|
if isinstance(value, str):
|
|
150
|
-
|
|
151
|
-
return value, 0
|
|
152
|
-
if key_is_sensitive or _value_is_secret_shaped(value):
|
|
153
|
-
return REDACTED, 1
|
|
154
|
-
return value, 0
|
|
235
|
+
return _redact_string(value, key_is_sensitive)
|
|
155
236
|
|
|
156
237
|
if isinstance(value, BaseModel):
|
|
157
238
|
count = 0
|
|
@@ -288,10 +288,14 @@ def test_a_containers_name_cannot_redact_its_own_logs(monkeypatch):
|
|
|
288
288
|
assert pod["previous_logs"]["valkey-cart"] == log
|
|
289
289
|
|
|
290
290
|
# A secret-shaped value inside a log is still caught, as on the live server.
|
|
291
|
+
# Only the token's own span goes: a log line is mostly diagnostics, and the
|
|
292
|
+
# surrounding text is the part an agent is reading the log for.
|
|
291
293
|
monkeypatch.setattr(k8s_tools, "get_logs_for_pod_and_container",
|
|
292
294
|
lambda *a, **kw: f"token={_FakeCluster.JWT}")
|
|
293
295
|
state = capture_state(redact=True)
|
|
294
|
-
|
|
296
|
+
captured_log = state["pods"][0]["logs"]["valkey-cart"]
|
|
297
|
+
assert _FakeCluster.JWT not in captured_log
|
|
298
|
+
assert captured_log == "token=[REDACTED]"
|
|
295
299
|
|
|
296
300
|
|
|
297
301
|
def test_pod_labels_are_not_subject_to_the_key_name_heuristic(monkeypatch):
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Tests that `timedelta` fields serialize to values their own schema accepts.
|
|
2
|
+
|
|
3
|
+
Pydantic describes a `timedelta` field as `{"type": "string", "format": "duration"}`
|
|
4
|
+
but serializes one carrying microseconds as `"P2DT5M27.978616S"`. JSON Schema's
|
|
5
|
+
`duration` format references RFC 3339 Appendix A, whose grammar admits only whole
|
|
6
|
+
numbers - so the default schema and the default serializer contradict each other,
|
|
7
|
+
and an MCP client that validates structured output against the declared schema
|
|
8
|
+
(ajv does) rejects the call outright, on every row, before the caller sees data.
|
|
9
|
+
|
|
10
|
+
Every age here is `now() - <k8s timestamp>`; Kubernetes timestamps are whole-second,
|
|
11
|
+
so the fractional part is always just the microseconds of that `now()` - which is
|
|
12
|
+
why this reproduced on essentially every row of every list tool against a live
|
|
13
|
+
cluster, while the largely whole-second mock fixture mostly hid it.
|
|
14
|
+
|
|
15
|
+
`k8s_tools.Duration` normalizes the value so the declared format stays truthful.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import inspect
|
|
20
|
+
import re
|
|
21
|
+
import typing
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
|
|
26
|
+
from k8stools import k8s_tools, mock_tools
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: RFC 3339 Appendix A `duration`, the grammar JSON Schema's `format: "duration"`
|
|
30
|
+
#: points at. Transcribed from ajv-formats, which is what the reporting client used.
|
|
31
|
+
#: Note there is no decimal point anywhere in it, and no sign.
|
|
32
|
+
RFC3339_DURATION_RE = re.compile(
|
|
33
|
+
r"^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: A duration with a sub-second component, as every live-cluster age has.
|
|
37
|
+
FRACTIONAL = datetime.timedelta(days=2, minutes=5, seconds=27, microseconds=978616)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _duration_fields(model: type[BaseModel]) -> list[str]:
|
|
41
|
+
"""Field names whose *serialization* schema declares `format: "duration"`.
|
|
42
|
+
|
|
43
|
+
Read off the generated schema rather than the annotation, so the test asserts
|
|
44
|
+
against exactly what an MCP client is handed and told to validate against.
|
|
45
|
+
"""
|
|
46
|
+
schema = model.model_json_schema(mode="serialization")
|
|
47
|
+
names = []
|
|
48
|
+
for name, prop in schema.get("properties", {}).items():
|
|
49
|
+
branches = [prop, *prop.get("anyOf", [])]
|
|
50
|
+
if any(b.get("format") == "duration" for b in branches):
|
|
51
|
+
names.append(name)
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _models_with_durations() -> list[type[BaseModel]]:
|
|
56
|
+
return [
|
|
57
|
+
obj for obj in vars(k8s_tools).values()
|
|
58
|
+
if inspect.isclass(obj) and issubclass(obj, BaseModel)
|
|
59
|
+
and obj is not BaseModel and _duration_fields(obj)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _accepts_none(annotation) -> bool:
|
|
64
|
+
return type(None) in typing.get_args(annotation)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _placeholder(annotation):
|
|
68
|
+
"""A minimal value satisfying `annotation`, for the fields we aren't testing."""
|
|
69
|
+
if _accepts_none(annotation):
|
|
70
|
+
return None
|
|
71
|
+
origin = typing.get_origin(annotation) or annotation
|
|
72
|
+
return {str: "x", int: 0, bool: False, float: 0.0, list: [], dict: {}}.get(origin)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build(model: type[BaseModel], value, overrides=None) -> BaseModel:
|
|
76
|
+
"""Instantiate `model`, putting `value` in every duration field.
|
|
77
|
+
|
|
78
|
+
`overrides` replaces individual fields afterwards, for the cases where one
|
|
79
|
+
duration field needs a different value than the rest.
|
|
80
|
+
"""
|
|
81
|
+
durations = _duration_fields(model)
|
|
82
|
+
kwargs = {}
|
|
83
|
+
for name, field in model.model_fields.items():
|
|
84
|
+
if name in durations:
|
|
85
|
+
kwargs[name] = value
|
|
86
|
+
elif field.is_required():
|
|
87
|
+
kwargs[name] = _placeholder(field.annotation)
|
|
88
|
+
kwargs.update(overrides or {})
|
|
89
|
+
return model(**kwargs)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --- the normalization itself -----------------------------------------------
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("given,expected_seconds", [
|
|
95
|
+
(datetime.timedelta(seconds=27, microseconds=978616), 27),
|
|
96
|
+
(datetime.timedelta(microseconds=1), 0),
|
|
97
|
+
(datetime.timedelta(0), 0),
|
|
98
|
+
(datetime.timedelta(days=8), 8 * 86400),
|
|
99
|
+
# A cluster clock ahead of ours yields a negative age, which pydantic renders
|
|
100
|
+
# as "-PT5S" - no more valid under this grammar than a fractional one.
|
|
101
|
+
(datetime.timedelta(seconds=-5), 0),
|
|
102
|
+
(datetime.timedelta(days=-2), 0),
|
|
103
|
+
])
|
|
104
|
+
def test_durations_are_truncated_to_whole_nonnegative_seconds(given, expected_seconds):
|
|
105
|
+
assert k8s_tools._to_whole_seconds(given) == datetime.timedelta(seconds=expected_seconds)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_field_keeps_a_real_timedelta_for_direct_python_callers():
|
|
109
|
+
"""Normalizing the value, not the type: `.age` is still a timedelta."""
|
|
110
|
+
ns = k8s_tools.NamespaceSummary(name="d", status="Active", age=FRACTIONAL)
|
|
111
|
+
assert isinstance(ns.age, datetime.timedelta)
|
|
112
|
+
assert ns.age == datetime.timedelta(days=2, minutes=5, seconds=27)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_the_declared_schema_still_says_duration():
|
|
116
|
+
"""The fix must not paper over the problem by dropping the format annotation."""
|
|
117
|
+
prop = k8s_tools.NamespaceSummary.model_json_schema()["properties"]["age"]
|
|
118
|
+
assert prop == {"title": "Age", "type": "string", "format": "duration"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- every model, against its own declared schema ---------------------------
|
|
122
|
+
|
|
123
|
+
def test_there_are_duration_fields_to_check():
|
|
124
|
+
"""Guard against the sweeps below silently degrading to no-ops."""
|
|
125
|
+
models = _models_with_durations()
|
|
126
|
+
assert len(models) >= 10
|
|
127
|
+
assert k8s_tools.PodSummary in models
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("model", _models_with_durations(), ids=lambda m: m.__name__)
|
|
131
|
+
def test_fractional_input_serializes_within_the_declared_format(model):
|
|
132
|
+
dumped = _build(model, FRACTIONAL).model_dump(mode="json")
|
|
133
|
+
for name in _duration_fields(model):
|
|
134
|
+
assert RFC3339_DURATION_RE.match(dumped[name]), \
|
|
135
|
+
f"{model.__name__}.{name} = {dumped[name]!r} violates format: duration"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.parametrize("model", _models_with_durations(), ids=lambda m: m.__name__)
|
|
139
|
+
def test_optional_duration_fields_still_accept_none(model):
|
|
140
|
+
"""Normalization must not turn an absent duration into PT0S."""
|
|
141
|
+
optional = [n for n in _duration_fields(model)
|
|
142
|
+
if _accepts_none(model.model_fields[n].annotation)]
|
|
143
|
+
if not optional:
|
|
144
|
+
pytest.skip(f"{model.__name__} has no optional duration field")
|
|
145
|
+
dumped = _build(model, FRACTIONAL, {n: None for n in optional}).model_dump(mode="json")
|
|
146
|
+
for name in optional:
|
|
147
|
+
assert dumped[name] is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- end to end, over the actual tool surface -------------------------------
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize("tool", mock_tools.TOOLS, ids=lambda f: f.__name__)
|
|
153
|
+
def test_every_tool_emits_conformant_durations(tool):
|
|
154
|
+
"""The end-to-end shape of the bug report: call the tool, validate every
|
|
155
|
+
duration in its structured output against the format its schema declares."""
|
|
156
|
+
mock_tools.load_mock_state()
|
|
157
|
+
params = inspect.signature(tool).parameters
|
|
158
|
+
if any(p.default is inspect.Parameter.empty for p in params.values()):
|
|
159
|
+
pytest.skip(f"{tool.__name__} needs arguments")
|
|
160
|
+
result = tool()
|
|
161
|
+
|
|
162
|
+
items = result if isinstance(result, list) else [result]
|
|
163
|
+
for item in items:
|
|
164
|
+
if not isinstance(item, BaseModel):
|
|
165
|
+
continue
|
|
166
|
+
dumped = item.model_dump(mode="json")
|
|
167
|
+
for name in _duration_fields(type(item)):
|
|
168
|
+
if dumped[name] is None:
|
|
169
|
+
continue
|
|
170
|
+
assert RFC3339_DURATION_RE.match(dumped[name]), \
|
|
171
|
+
f"{tool.__name__} -> {type(item).__name__}.{name} = " \
|
|
172
|
+
f"{dumped[name]!r} violates format: duration"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_mock_namespaces_were_the_reproducing_case():
|
|
176
|
+
"""`get_namespaces` is the no-argument reproducer from the report, and the one
|
|
177
|
+
place the mock fixture did carry fractional seconds (`P1Y55DT1H37M32.49238S`)."""
|
|
178
|
+
mock_tools.load_mock_state()
|
|
179
|
+
ages = [ns.model_dump(mode="json")["age"] for ns in mock_tools.get_namespaces()]
|
|
180
|
+
assert ages, "fixture should have namespaces"
|
|
181
|
+
for age in ages:
|
|
182
|
+
assert RFC3339_DURATION_RE.match(age), f"{age!r} violates format: duration"
|
|
@@ -246,3 +246,125 @@ def test_value_shape_still_wins_regardless_of_name():
|
|
|
246
246
|
assert out["key"] == REDACTED
|
|
247
247
|
assert out["hockey"] == REDACTED
|
|
248
248
|
assert count == 2
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --- granularity: a match should not black out unrelated text ----------------
|
|
252
|
+
#
|
|
253
|
+
# Reported against 2.0.2: a ConfigMap holding config-as-JSON under one key came
|
|
254
|
+
# back as `{"warm-runtimes.json": "[REDACTED]"}` because one substring inside the
|
|
255
|
+
# blob matched a value-shape pattern. The whole file - warm-pool sizing, nothing
|
|
256
|
+
# secret - became unreadable. The same opacity cut the other way too: secrets
|
|
257
|
+
# named inside such a blob were invisible to the key-name rule entirely.
|
|
258
|
+
|
|
259
|
+
_JWT = "eyJhbGciOiJIUzI1Ni.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w"
|
|
260
|
+
_PEM = ("-----BEGIN RSA PRIVATE KEY-----\n"
|
|
261
|
+
"MIIEowIBAAKCAQEAsecretkeymaterial123456\n"
|
|
262
|
+
"-----END RSA PRIVATE KEY-----")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_a_token_inside_a_plain_string_redacts_only_its_own_span():
|
|
266
|
+
out, count = redact_object({"note": f"use {_JWT} to authenticate"})
|
|
267
|
+
assert out["note"] == f"use {REDACTED} to authenticate"
|
|
268
|
+
assert count == 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_a_pem_block_is_still_redacted_in_full():
|
|
272
|
+
"""The PEM pattern matches only the header; the key material is what follows.
|
|
273
|
+
Substituting just the matched span would black out the BEGIN line and publish
|
|
274
|
+
the key, so a value containing a PEM header goes in its entirety."""
|
|
275
|
+
out, count = redact_object({"blob": f"leading text\n{_PEM}\ntrailing text"})
|
|
276
|
+
assert out["blob"] == REDACTED
|
|
277
|
+
assert "MIIEowIBAAKCAQEAsecretkeymaterial123456" not in str(out)
|
|
278
|
+
assert count == 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_json_configmap_value_keeps_everything_but_the_match():
|
|
282
|
+
"""The reported case: one incidental token match must not hide the config."""
|
|
283
|
+
import json
|
|
284
|
+
blob = json.dumps({"target_count": 5, "runtimes": ["v1", "v2"],
|
|
285
|
+
"bootstrap": _JWT}, indent=2)
|
|
286
|
+
out, count = redact_object({
|
|
287
|
+
"name": "warm-runtimes-config", "namespace": "openhands-cloud-c3",
|
|
288
|
+
"data": {"warm-runtimes.json": blob}, "binary_data_keys": []})
|
|
289
|
+
redacted = json.loads(out["data"]["warm-runtimes.json"])
|
|
290
|
+
assert redacted["target_count"] == 5 # the number they needed
|
|
291
|
+
assert redacted["runtimes"] == ["v1", "v2"]
|
|
292
|
+
assert redacted["bootstrap"] == REDACTED
|
|
293
|
+
assert count == 1
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_secrets_named_inside_a_json_blob_are_found():
|
|
297
|
+
"""The under-redaction half: before this, the key-name rule could not see
|
|
298
|
+
into a JSON string, so these passed through verbatim."""
|
|
299
|
+
import json
|
|
300
|
+
blob = json.dumps({"db": {"password": "hunter2", "api_token": "abc"},
|
|
301
|
+
"host": "db.internal"})
|
|
302
|
+
out, count = redact_object({"data": {"app.json": blob}})
|
|
303
|
+
redacted = json.loads(out["data"]["app.json"])
|
|
304
|
+
assert redacted["db"]["password"] == REDACTED
|
|
305
|
+
assert redacted["db"]["api_token"] == REDACTED
|
|
306
|
+
assert redacted["host"] == "db.internal"
|
|
307
|
+
assert count == 2
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_a_pem_nested_in_a_json_blob_goes_whole_but_takes_nothing_else():
|
|
311
|
+
import json
|
|
312
|
+
blob = json.dumps({"cert": "public-cert-data", "priv": _PEM, "port": 443})
|
|
313
|
+
out, count = redact_object({"data": {"tls.json": blob}})
|
|
314
|
+
redacted = json.loads(out["data"]["tls.json"])
|
|
315
|
+
assert redacted["priv"] == REDACTED
|
|
316
|
+
assert redacted["cert"] == "public-cert-data"
|
|
317
|
+
assert redacted["port"] == 443
|
|
318
|
+
assert count == 1
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_the_key_exemption_holds_inside_a_json_blob_too():
|
|
322
|
+
"""Lifting the exemption in here looked right - these are the author's field
|
|
323
|
+
names, not Kubernetes schema - but measured against a real 38-pod cluster it
|
|
324
|
+
added 12 redactions, every one of them `tags[].key: "instance"` in a Grafana
|
|
325
|
+
dashboard ConfigMap, and not one secret. Structural `key` fields dominate
|
|
326
|
+
inside config documents just as they do in the API."""
|
|
327
|
+
import json
|
|
328
|
+
blob = json.dumps({"targets": [{"tags": [{"key": "instance", "value": "db-1"}]}]})
|
|
329
|
+
out, count = redact_object({"data": {"dash.json": blob}})
|
|
330
|
+
assert out["data"]["dash.json"] == blob # untouched, not even re-serialized
|
|
331
|
+
assert count == 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_a_credential_shaped_value_under_key_is_still_caught_inside_a_blob():
|
|
335
|
+
"""What keeps the exemption affordable: the value-shape rules do not care
|
|
336
|
+
what the field is called."""
|
|
337
|
+
import json
|
|
338
|
+
blob = json.dumps({"key": "AKIAIOSFODNN7EXAMPLE"})
|
|
339
|
+
out, count = redact_object({"data": {"cfg.json": blob}})
|
|
340
|
+
assert json.loads(out["data"]["cfg.json"])["key"] == REDACTED
|
|
341
|
+
assert count == 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_a_clean_json_value_is_left_byte_for_byte_alone():
|
|
345
|
+
"""No match means no re-serialization, so formatting is never churned."""
|
|
346
|
+
original = '{\n "count": 5,\n "name": "pool"\n}'
|
|
347
|
+
out, count = redact_object({"data": {"cfg.json": original}})
|
|
348
|
+
assert out["data"]["cfg.json"] == original
|
|
349
|
+
assert count == 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@pytest.mark.parametrize("value", [
|
|
353
|
+
"not json at all", "{broken json", "", " ",
|
|
354
|
+
"5", "true", "null", '"just a string"',
|
|
355
|
+
])
|
|
356
|
+
def test_non_container_json_values_are_untouched(value):
|
|
357
|
+
"""Bare scalars must not round-trip through the JSON path."""
|
|
358
|
+
out, count = redact_object({"data": {"k": value}})
|
|
359
|
+
assert out["data"]["k"] == value
|
|
360
|
+
assert count == 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_a_json_array_blob_is_also_recursed():
|
|
364
|
+
import json
|
|
365
|
+
blob = json.dumps([{"name": "a", "token": "s3kr1t"}, {"name": "b", "port": 80}])
|
|
366
|
+
out, count = redact_object({"data": {"list.json": blob}})
|
|
367
|
+
redacted = json.loads(out["data"]["list.json"])
|
|
368
|
+
assert redacted[0]["token"] == REDACTED
|
|
369
|
+
assert redacted[1]["port"] == 80
|
|
370
|
+
assert count == 1
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Bug report: `datetime.timedelta` fields produce values that fail strict `format: "duration"` JSON-Schema validation
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
Every k8stools MCP tool whose Pydantic response model includes a `datetime.timedelta`
|
|
6
|
+
field (`age`, `last_seen`) emits a value shape that is rejected by MCP clients that do
|
|
7
|
+
strict JSON-Schema `format: "duration"` validation of structured tool output. When this
|
|
8
|
+
happens, the tool call fails outright with an MCP protocol error — the client never sees
|
|
9
|
+
any data, even though the underlying tool executed successfully and the payload is
|
|
10
|
+
"reasonable" ISO 8601.
|
|
11
|
+
|
|
12
|
+
This is not tied to a particular cluster or tool argument — it reproduces on `get_namespaces()`
|
|
13
|
+
(no arguments) and on every list-summary tool tested.
|
|
14
|
+
|
|
15
|
+
## Environment
|
|
16
|
+
|
|
17
|
+
- `k8stools` version: **2.0.2** (installed via `uv`/PyPI, unmodified)
|
|
18
|
+
- Invocation: `k8s-mcp-server --transport=streamable-http` (no `--mock`, no `--state-file` —
|
|
19
|
+
live cluster)
|
|
20
|
+
- Python: 3.12.11
|
|
21
|
+
- `mcp` SDK: whatever version `k8stools==2.0.2` pulls in via `uv.lock` (server uses
|
|
22
|
+
`mcp.server.mcpserver.MCPServer` / `Tool.from_function(fn, structured_output=True)`)
|
|
23
|
+
- Client: an MCP client that validates tool call results against the tool's declared
|
|
24
|
+
`outputSchema` (structured content validation), rejecting non-conformant responses before
|
|
25
|
+
the caller ever sees the data.
|
|
26
|
+
|
|
27
|
+
## Steps to reproduce
|
|
28
|
+
|
|
29
|
+
1. Start the server: `k8s-mcp-server --transport=streamable-http`
|
|
30
|
+
2. Call the `get_namespaces` tool (or `get_pod_summaries`, `get_events`, etc.) via an MCP
|
|
31
|
+
client that validates structured output against the tool's JSON output schema.
|
|
32
|
+
3. Observe the call fail with a schema-validation error instead of returning data.
|
|
33
|
+
|
|
34
|
+
## Actual error (verbatim, trimmed)
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
MCP error -32602: Structured content does not match the tool's output schema:
|
|
38
|
+
data/result/0/age must match format "duration",
|
|
39
|
+
data/result/1/age must match format "duration",
|
|
40
|
+
...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
and, for `Optional[timedelta]` fields:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
data/result/0/last_seen must match format "duration",
|
|
47
|
+
data/result/0/last_seen must be null,
|
|
48
|
+
data/result/0/last_seen must match a schema in anyOf,
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Every element in the result array fails identically — this is not a per-record data quality
|
|
53
|
+
issue, it's a structural mismatch between every timedelta value and the schema that describes it.
|
|
54
|
+
|
|
55
|
+
## Root cause
|
|
56
|
+
|
|
57
|
+
`k8s_tools.py` declares timing fields as plain `datetime.timedelta` on `BaseModel` subclasses,
|
|
58
|
+
e.g.:
|
|
59
|
+
|
|
60
|
+
```464:467:k8stools/k8s_tools.py (site-packages copy)
|
|
61
|
+
class PodSummary(BaseModel):
|
|
62
|
+
"""A summary of a pod's status like returned by `kubectl get pods -o wide`"""
|
|
63
|
+
...
|
|
64
|
+
last_restart: Optional[datetime.timedelta]
|
|
65
|
+
age: datetime.timedelta
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```89:93:k8stools/k8s_tools.py (site-packages copy)
|
|
69
|
+
class NamespaceSummary(BaseModel):
|
|
70
|
+
"""Summary information about a namespace, like returned by `kubectl get namespace`"""
|
|
71
|
+
name: str
|
|
72
|
+
status: str
|
|
73
|
+
age: datetime.timedelta
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```504:509:k8stools/k8s_tools.py (site-packages copy)
|
|
77
|
+
class EventSummary(BaseModel):
|
|
78
|
+
"""This is the representation of a Kubernetes Event"""
|
|
79
|
+
last_seen: Optional[datetime.timedelta] # Time since event occurred
|
|
80
|
+
type: str
|
|
81
|
+
reason: str
|
|
82
|
+
object: str
|
|
83
|
+
message: str
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pydantic v2's default JSON Schema generation for `datetime.timedelta` emits
|
|
87
|
+
`{"type": "string", "format": "duration"}`, and its default *value* serialization for a
|
|
88
|
+
`timedelta` produces an ISO 8601 duration string that **includes fractional (sub-second)
|
|
89
|
+
seconds when the timedelta has microsecond precision**, e.g. the values actually observed
|
|
90
|
+
over the wire:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
"P2DT5M27.978616S"
|
|
94
|
+
"PT10H56M59.978616S"
|
|
95
|
+
"PT50M41.978616S"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
These are computed as `now() - <k8s object's creationTimestamp / lastTimestamp>`. Since
|
|
99
|
+
Kubernetes' own timestamps are whole-second (no sub-second component), the fractional part
|
|
100
|
+
of every one of these values is just `now()`'s microseconds — which is why it's identical
|
|
101
|
+
across every row in a single response (all computed from one `now()` call). That's expected
|
|
102
|
+
and not itself a bug; the bug is that this fractional value is legal ISO 8601 but is **not**
|
|
103
|
+
legal per the stricter grammar that JSON Schema's `format: "duration"` keyword references
|
|
104
|
+
(RFC 3339 Appendix A), which most conformant/strict format validators implement. That grammar
|
|
105
|
+
requires each duration component to be a plain (integer) digit sequence — it does not permit a
|
|
106
|
+
decimal point anywhere, including in the seconds component. A validator that enforces this
|
|
107
|
+
strictly will reject `"P2DT5M27.978616S"` even though it is a well-formed, human-readable ISO
|
|
108
|
+
8601 duration.
|
|
109
|
+
|
|
110
|
+
In short: **pydantic's own default schema (`format: "duration"`) and pydantic's own default
|
|
111
|
+
serialization (fractional-second ISO 8601 strings) are mutually inconsistent** under any
|
|
112
|
+
validator that takes `format: "duration"` seriously. Any MCP client that validates structured
|
|
113
|
+
output strictly against the declared schema will intermittently or consistently break on
|
|
114
|
+
every tool that surfaces an `age` or `last_seen` field — i.e., essentially all of the
|
|
115
|
+
list/summary tools (`get_namespaces`, `get_node_summaries`, `get_pod_summaries`, `get_events`,
|
|
116
|
+
`get_pod_events`, `get_deployment_summaries`, `get_replicaset_summaries`,
|
|
117
|
+
`get_statefulset_summaries`, `get_cronjob_summaries`, `get_job_summaries`, `get_pvc_summaries`,
|
|
118
|
+
`get_service_summaries`, `get_configmap_summaries`).
|
|
119
|
+
|
|
120
|
+
## Why this seemed to "come and go" in our testing
|
|
121
|
+
|
|
122
|
+
We saw calls to these same tools **succeed** with these exact fractional-second value shapes
|
|
123
|
+
earlier in the same day, then start failing after simply restarting the MCP server process
|
|
124
|
+
(no code/version change on the server side — same `k8stools==2.0.2`, same venv, same
|
|
125
|
+
`k8s-mcp-server` binary, confirmed via `pip`/`uv.lock`/site-packages timestamps). That strongly
|
|
126
|
+
suggests the validation strictness is a property of the **client's** connection/session
|
|
127
|
+
(e.g., whether it negotiates or enables structured-output validation on a given connection),
|
|
128
|
+
not of the server. That's a plausible explanation for the inconsistent symptom, but it doesn't
|
|
129
|
+
change the underlying defect: the server is emitting values that don't conform to its own
|
|
130
|
+
declared schema, and **any** strictly-validating client — now or in the future — will hit this
|
|
131
|
+
100% of the time on 100% of rows.
|
|
132
|
+
|
|
133
|
+
## Suggested fixes (any one of these resolves it)
|
|
134
|
+
|
|
135
|
+
1. **Truncate to whole seconds before serialization.** Drop microseconds from every
|
|
136
|
+
`timedelta` before it's placed on a response model (e.g. `age.total_seconds()` rounded, or
|
|
137
|
+
reconstruct a `timedelta(seconds=int(age.total_seconds()))`). Minute-granularity age/last-seen
|
|
138
|
+
data doesn't need microsecond precision anyway, and this keeps the `format: "duration"`
|
|
139
|
+
schema annotation truthful.
|
|
140
|
+
2. **Stop declaring `format: "duration"`** on these fields (e.g. represent `age`/`last_seen` as
|
|
141
|
+
a plain `str` computed via the existing `_format_timedelta()` helper already used for CLI
|
|
142
|
+
output, rather than a native `timedelta`). This sidesteps the whole format-conformance
|
|
143
|
+
question and gives every consumer a human-readable string like `"53m46s"` instead.
|
|
144
|
+
3. **Provide a custom serializer** (`@field_serializer` in Pydantic v2) for these fields that
|
|
145
|
+
emits a strictly RFC 3339 Appendix A–conformant duration string (no fractional component —
|
|
146
|
+
round to the nearest second), while leaving the field type as `timedelta` for typing
|
|
147
|
+
purposes.
|
|
148
|
+
|
|
149
|
+
Option 1 or 3 preserve the current "real" `timedelta`/duration semantics; option 2 is the
|
|
150
|
+
simplest and most robust against any future client strictness, at the cost of losing a
|
|
151
|
+
machine-parseable duration type in the schema.
|
|
152
|
+
|
|
153
|
+
## Impact
|
|
154
|
+
|
|
155
|
+
Total loss of function for every affected tool when called through a strictly-validating MCP
|
|
156
|
+
client — the call fails outright with no data, rather than degrading gracefully. This blocked
|
|
157
|
+
a live production-incident investigation (watching pod/event state during a load test) because
|
|
158
|
+
every relevant tool (`get_pod_summaries`, `get_events`) was unusable after a routine server
|
|
159
|
+
restart, with no code change on either side.
|
|
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
|