k8stools 2.1.0__tar.gz → 2.2.0__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.1.0 → k8stools-2.2.0}/CHANGELOG.md +33 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/CLAUDE.md +7 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/PKG-INFO +10 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/README.md +9 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/designs/mock-state-capture.md +4 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/pyproject.toml +1 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/__init__.py +1 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/capture.py +2 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/k8s_tools.py +75 -25
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mock_state.py +65 -2
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_cluster_binding.py +27 -1
- k8stools-2.2.0/tests/test_events.py +164 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_k8s_tools_realk8s.py +12 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mock_state.py +56 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/uv.lock +1 -1
- {k8stools-2.1.0 → k8stools-2.2.0}/.claude/settings.local.json +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/.github/workflows/unit-tests.yml +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/.gitignore +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/.python-version +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/.vscode/mcp.json +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/.vscode/settings.json +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/LICENSE +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/chat-with-cluster.png +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/docs/ROADMAP.md +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/envrc.template +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/fixtures/otel-demo.json +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mcp_client.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mcp_server.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mock_tools.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/redaction.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/conftest.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_capture.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_duration_format.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_k8s_tools.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_log_decoding.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mcp_client.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mock_tools.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_new_tools.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_redaction.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_replicaset_tool.py +0 -0
- {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_version.py +0 -0
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
All notable changes to k8stools. Versions follow [semantic versioning](https://semver.org/);
|
|
4
4
|
dates are release-tag dates.
|
|
5
5
|
|
|
6
|
+
## 2.2.0 — 2026-09-28
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`count` and `first_seen` on events** (issue #8). `get_pod_events` and
|
|
10
|
+
`get_events` now report how many occurrences Kubernetes combined into each
|
|
11
|
+
event record, and how long ago the first one was, next to `last_seen`. For a
|
|
12
|
+
crash-looping container this gives the restart rate directly: on one cluster,
|
|
13
|
+
a `Created` count of 579 over 2 days 8 hours is a restart every ~5.8 minutes,
|
|
14
|
+
a number agents kept getting wrong from the other durations. The tool
|
|
15
|
+
descriptions explain the window the count covers and warn that `BackOff`
|
|
16
|
+
counts are several times the number of restarts. `print_pod_events` and
|
|
17
|
+
`print_events` gain a COUNT column.
|
|
18
|
+
- Captures store both fields; captures from before 2.2.0 replay them as `None`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Replayed `captured_at` was on a different clock** (issue #7).
|
|
22
|
+
`get_cluster_info` returned the date in the capture file while every other
|
|
23
|
+
replayed timestamp was re-anchored to the server's start, so a pod killed
|
|
24
|
+
"5 minutes ago" appeared beside a capture taken a month ago. `captured_at` is
|
|
25
|
+
now the replay's anchor (the server's start time); the recorded date is in the
|
|
26
|
+
startup log and on `MockState.captured_at`.
|
|
27
|
+
- **Replayed logs kept their original timestamps.** Log line timestamps were not
|
|
28
|
+
re-anchored, so they contradicted every other replayed time, and
|
|
29
|
+
`since_seconds` (compared against the replay's "now") returned an empty log for
|
|
30
|
+
any capture older than the window, as if the container had logged nothing.
|
|
31
|
+
The kubelet's timestamp prefix on each line is now moved like every other
|
|
32
|
+
datetime, keeping its original precision and format. Timestamps an application
|
|
33
|
+
writes inside its messages are left as recorded.
|
|
34
|
+
- **Events recorded through `events.k8s.io/v1` had no time.** Their core/v1 view
|
|
35
|
+
leaves `lastTimestamp` empty and records the time in `eventTime` (and
|
|
36
|
+
repetition in `series`), so `last_seen` came back `None`. Both are now read
|
|
37
|
+
when the older fields are empty.
|
|
38
|
+
|
|
6
39
|
## 2.1.0 — 2026-09-28
|
|
7
40
|
|
|
8
41
|
### Added
|
|
@@ -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_events.py - Event aggregation (count/first_seen), incl. the events.k8s.io fallback
|
|
27
28
|
test_cluster_binding.py - Cluster selection: configure(), the shared binding, get_cluster_info, server flags
|
|
28
29
|
test_log_decoding.py - Log decoding at the API boundary (bytes vs str), `previous` plumbing, log redaction
|
|
29
30
|
test_mcp_client.py - MCP client tests
|
|
@@ -213,6 +214,12 @@ Invariants worth preserving when touching this code:
|
|
|
213
214
|
replay — "deployment 8d old, current replica set 7h34m old, so it was upgraded
|
|
214
215
|
7h34m ago". The replay clock quantizes to whole seconds and samples once per
|
|
215
216
|
query to keep that exact; don't reintroduce per-field `datetime.now()`.
|
|
217
|
+
One clock for everything replay returns: datetimes are re-anchored so
|
|
218
|
+
capture time becomes server start time, and that includes
|
|
219
|
+
`get_cluster_info().captured_at` (the anchor itself) and the kubelet's
|
|
220
|
+
timestamp prefix on each log line (`_reanchor_log`). Anything that reports a
|
|
221
|
+
time from the file verbatim contradicts the rest. Tests that capture and
|
|
222
|
+
replay at the same instant cannot see this; use a capture date in the past.
|
|
216
223
|
Only ages advance: an `Interval` field is stored under the same `_seconds` key
|
|
217
224
|
but read back unchanged. Replay tests that freeze the clock can't tell the two
|
|
218
225
|
apart (frozen elapsed time is zero), so test a new duration field on an
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: k8stools
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Tools and an MCP server for building Kubernetes agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/BenedatLLC/k8stools
|
|
6
6
|
Project-URL: Changelog, https://github.com/BenedatLLC/k8stools/blob/main/CHANGELOG.md
|
|
@@ -684,6 +684,15 @@ long session cannot see ages drift between its first tool call and its last.
|
|
|
684
684
|
a suite that believed it had frozen the clock and had not would be flaky with no
|
|
685
685
|
visible cause.)
|
|
686
686
|
|
|
687
|
+
Absolute times are replayed the same way: every timestamp a tool returns is moved
|
|
688
|
+
so the moment of capture becomes the moment the server started. A container killed
|
|
689
|
+
5 minutes before the capture reads as killed 5 minutes before server start, and
|
|
690
|
+
`get_cluster_info`'s `captured_at` is that start time, not the date in the file,
|
|
691
|
+
so the agent sees one consistent clock. Log line timestamps (the kubelet's
|
|
692
|
+
prefix) are moved too, so `since_seconds` works on a capture of any age;
|
|
693
|
+
timestamps an application writes inside its own log messages are left as
|
|
694
|
+
recorded. The file's own capture date is in the server's startup log.
|
|
695
|
+
|
|
687
696
|
### Replaying from Python
|
|
688
697
|
|
|
689
698
|
For a test suite, skip the server and load the capture directly. `MockState` has
|
|
@@ -464,6 +464,15 @@ long session cannot see ages drift between its first tool call and its last.
|
|
|
464
464
|
a suite that believed it had frozen the clock and had not would be flaky with no
|
|
465
465
|
visible cause.)
|
|
466
466
|
|
|
467
|
+
Absolute times are replayed the same way: every timestamp a tool returns is moved
|
|
468
|
+
so the moment of capture becomes the moment the server started. A container killed
|
|
469
|
+
5 minutes before the capture reads as killed 5 minutes before server start, and
|
|
470
|
+
`get_cluster_info`'s `captured_at` is that start time, not the date in the file,
|
|
471
|
+
so the agent sees one consistent clock. Log line timestamps (the kubelet's
|
|
472
|
+
prefix) are moved too, so `since_seconds` works on a capture of any age;
|
|
473
|
+
timestamps an application writes inside its own log messages are left as
|
|
474
|
+
recorded. The file's own capture date is in the server's startup log.
|
|
475
|
+
|
|
467
476
|
### Replaying from Python
|
|
468
477
|
|
|
469
478
|
For a test suite, skip the server and load the capture directly. `MockState` has
|
|
@@ -341,12 +341,15 @@ become `*_offset_seconds` before `captured_at`.
|
|
|
341
341
|
(a filtered view where `involved_kind == "Pod"` and `involved_name == pod_name`):
|
|
342
342
|
```json
|
|
343
343
|
{
|
|
344
|
-
"last_seen_seconds": 120.0, "
|
|
344
|
+
"last_seen_seconds": 120.0, "first_seen_seconds": 120.0, "count": 1,
|
|
345
|
+
"type": "Warning", "reason": "Evicted",
|
|
345
346
|
"namespace": "default", "involved_kind": "Pod", "involved_name": "session-abc",
|
|
346
347
|
"object": "Pod/session-abc",
|
|
347
348
|
"message": "The node was low on resource: ephemeral-storage."
|
|
348
349
|
}
|
|
349
350
|
```
|
|
351
|
+
`first_seen_seconds` and `count` were added in 2.2.0; captures without them
|
|
352
|
+
replay those fields as `null`.
|
|
350
353
|
This is the one structural change from the original design: pods no longer embed
|
|
351
354
|
their own `events`; `MockState.get_pod_events` filters the flat list instead. This
|
|
352
355
|
also lets captures include events with no surviving pod (Evicted, FailedScheduling),
|
|
@@ -253,6 +253,8 @@ def _capture_events(namespace: str, captured_at: datetime.datetime,
|
|
|
253
253
|
kind, name = None, event.object
|
|
254
254
|
records.append({
|
|
255
255
|
"last_seen_seconds": event.last_seen.total_seconds() if event.last_seen else None,
|
|
256
|
+
"first_seen_seconds": event.first_seen.total_seconds() if event.first_seen else None,
|
|
257
|
+
"count": event.count,
|
|
256
258
|
"type": event.type,
|
|
257
259
|
"reason": event.reason,
|
|
258
260
|
"namespace": namespace,
|
|
@@ -667,10 +667,43 @@ def _format_timedelta(td: Optional[datetime.timedelta]) -> str:
|
|
|
667
667
|
class EventSummary(BaseModel):
|
|
668
668
|
"""This is the representation of a Kubernetes Event"""
|
|
669
669
|
last_seen: Optional[Duration] # Time since event occurred
|
|
670
|
+
#: Time since the first occurrence combined into this record.
|
|
671
|
+
first_seen: Optional[Duration] = None
|
|
672
|
+
#: How many occurrences Kubernetes combined into this record.
|
|
673
|
+
count: Optional[int] = None
|
|
670
674
|
type: str
|
|
671
675
|
reason: str
|
|
672
676
|
object: str
|
|
673
677
|
message: str
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _event_summary(event: Any, now: datetime.datetime, obj: str) -> EventSummary:
|
|
681
|
+
"""Convert a core/v1 Event, including one recorded through events.k8s.io/v1.
|
|
682
|
+
|
|
683
|
+
The events.k8s.io API reaches the core/v1 view with the deprecated
|
|
684
|
+
``count``/``firstTimestamp``/``lastTimestamp`` unset. Its time is
|
|
685
|
+
``eventTime`` (the first observation) and any repetition is in ``series``, so
|
|
686
|
+
those fill in when the deprecated fields are empty. A record with
|
|
687
|
+
``eventTime`` and no ``series`` was observed once.
|
|
688
|
+
"""
|
|
689
|
+
series = getattr(event, "series", None)
|
|
690
|
+
event_time = getattr(event, "event_time", None)
|
|
691
|
+
last = (getattr(event, "last_timestamp", None)
|
|
692
|
+
or (series.last_observed_time if series else None)
|
|
693
|
+
or event_time)
|
|
694
|
+
first = getattr(event, "first_timestamp", None) or event_time
|
|
695
|
+
count = (getattr(event, "count", None)
|
|
696
|
+
or (series.count if series else None)
|
|
697
|
+
or (1 if event_time else None))
|
|
698
|
+
return EventSummary(
|
|
699
|
+
last_seen=(now - last) if last else None,
|
|
700
|
+
first_seen=(now - first) if first else None,
|
|
701
|
+
count=count,
|
|
702
|
+
type=event.type or "",
|
|
703
|
+
reason=event.reason or "",
|
|
704
|
+
object=obj,
|
|
705
|
+
message=event.message or "",
|
|
706
|
+
)
|
|
674
707
|
|
|
675
708
|
|
|
676
709
|
def get_pod_events(pod_name: str, namespace: str = "default") -> list[EventSummary]:
|
|
@@ -690,8 +723,22 @@ def get_pod_events(pod_name: str, namespace: str = "default") -> list[EventSumma
|
|
|
690
723
|
list of EventSummary
|
|
691
724
|
List of events associated with the specified pod. Each EventSummary has the following fields:
|
|
692
725
|
|
|
693
|
-
last_seen : Optional[datetime.
|
|
694
|
-
|
|
726
|
+
last_seen : Optional[datetime.timedelta]
|
|
727
|
+
Time since the most recent occurrence of the event (if available).
|
|
728
|
+
first_seen : Optional[datetime.timedelta]
|
|
729
|
+
Time since the first occurrence combined into this record (if
|
|
730
|
+
available).
|
|
731
|
+
count : Optional[int]
|
|
732
|
+
How many occurrences Kubernetes combined into this record: repeats
|
|
733
|
+
of the same event on the same object are counted in one record
|
|
734
|
+
rather than listed separately. The count covers first_seen to
|
|
735
|
+
last_seen, not the object's lifetime - a record that stops repeating
|
|
736
|
+
expires (after 1h by default), and a later repeat starts a new one.
|
|
737
|
+
For a crash-looping container, count the "Created" or "Started"
|
|
738
|
+
events to get restarts. "BackOff" is emitted repeatedly while the
|
|
739
|
+
kubelet waits to restart, so its count is several times the number
|
|
740
|
+
of restarts. count divided by (first_seen - last_seen) is the
|
|
741
|
+
average rate over that window, not the current back-off.
|
|
695
742
|
type : str
|
|
696
743
|
Type of the event.
|
|
697
744
|
reason : str
|
|
@@ -714,16 +761,8 @@ def get_pod_events(pod_name: str, namespace: str = "default") -> list[EventSumma
|
|
|
714
761
|
field_selector = f"involvedObject.name={pod_name}"
|
|
715
762
|
events = K8S.list_namespaced_event(namespace, field_selector=field_selector)
|
|
716
763
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
717
|
-
return [
|
|
718
|
-
|
|
719
|
-
last_seen=(now - event.last_timestamp) if event.last_timestamp else None,
|
|
720
|
-
type=event.type,
|
|
721
|
-
reason=event.reason,
|
|
722
|
-
object=getattr(event.involved_object, 'name', pod_name),
|
|
723
|
-
message=event.message,
|
|
724
|
-
)
|
|
725
|
-
for event in events.items
|
|
726
|
-
]
|
|
764
|
+
return [_event_summary(event, now, getattr(event.involved_object, 'name', pod_name))
|
|
765
|
+
for event in events.items]
|
|
727
766
|
|
|
728
767
|
|
|
729
768
|
def print_pod_events(pod_name: str, namespace: str = "default") -> None:
|
|
@@ -731,11 +770,12 @@ def print_pod_events(pod_name: str, namespace: str = "default") -> None:
|
|
|
731
770
|
Print the events for the specified pod, in a similar format to `kubectl get events`.
|
|
732
771
|
"""
|
|
733
772
|
events = get_pod_events(pod_name, namespace)
|
|
734
|
-
print(f"{'LAST SEEN':<12} {'TYPE':<10} {'REASON':<20} {'OBJECT':<32} {'MESSAGE':<40}")
|
|
773
|
+
print(f"{'LAST SEEN':<12} {'COUNT':<7} {'TYPE':<10} {'REASON':<20} {'OBJECT':<32} {'MESSAGE':<40}")
|
|
735
774
|
for event in events:
|
|
736
775
|
last_seen = _format_timedelta(event.last_seen) if event.last_seen else "-"
|
|
776
|
+
count = str(event.count) if event.count is not None else "-"
|
|
737
777
|
message = (event.message[:37] + '...') if event.message and len(event.message) > 40 else event.message
|
|
738
|
-
print(f"{last_seen:<12} {event.type:<10} {event.reason:<20} {event.object:<32} {message:<40}")
|
|
778
|
+
print(f"{last_seen:<12} {count:<7} {event.type:<10} {event.reason:<20} {event.object:<32} {message:<40}")
|
|
739
779
|
|
|
740
780
|
# see kubernetes.client.models.v1_container_state_running.V1ContainerStateRunning
|
|
741
781
|
class ContainerStateRunning(BaseModel):
|
|
@@ -2427,8 +2467,9 @@ def get_events(namespace: Optional[str] = None,
|
|
|
2427
2467
|
Unlike `get_pod_events` (which is scoped to a single named pod), this supports
|
|
2428
2468
|
the sweep queries common in capacity/storage runbooks — e.g. all "Evicted"
|
|
2429
2469
|
events, or all "FailedScheduling" events — where the affected pods often have no
|
|
2430
|
-
stable name to look up. Note
|
|
2431
|
-
|
|
2470
|
+
stable name to look up. Note Kubernetes expires an event record about an hour
|
|
2471
|
+
(by default) after its last occurrence, so events that stopped repeating
|
|
2472
|
+
earlier than that are gone.
|
|
2432
2473
|
|
|
2433
2474
|
Parameters
|
|
2434
2475
|
----------
|
|
@@ -2452,6 +2493,20 @@ def get_events(namespace: Optional[str] = None,
|
|
|
2452
2493
|
|
|
2453
2494
|
last_seen : Optional[datetime.timedelta]
|
|
2454
2495
|
Time since the event was last seen (if available).
|
|
2496
|
+
first_seen : Optional[datetime.timedelta]
|
|
2497
|
+
Time since the first occurrence combined into this record (if
|
|
2498
|
+
available).
|
|
2499
|
+
count : Optional[int]
|
|
2500
|
+
How many occurrences Kubernetes combined into this record: repeats
|
|
2501
|
+
of the same event on the same object are counted in one record
|
|
2502
|
+
rather than listed separately. The count covers first_seen to
|
|
2503
|
+
last_seen, not the object's lifetime - a record that stops repeating
|
|
2504
|
+
expires (after 1h by default), and a later repeat starts a new one.
|
|
2505
|
+
For a crash-looping container, count the "Created" or "Started"
|
|
2506
|
+
events to get restarts. "BackOff" is emitted repeatedly while the
|
|
2507
|
+
kubelet waits to restart, so its count is several times the number
|
|
2508
|
+
of restarts. count divided by (first_seen - last_seen) is the
|
|
2509
|
+
average rate over that window, not the current back-off.
|
|
2455
2510
|
type : str
|
|
2456
2511
|
Type of the event ("Normal" or "Warning").
|
|
2457
2512
|
reason : str
|
|
@@ -2501,13 +2556,7 @@ def get_events(namespace: Optional[str] = None,
|
|
|
2501
2556
|
obj_name = getattr(involved, "name", None) or ""
|
|
2502
2557
|
obj_kind = getattr(involved, "kind", None)
|
|
2503
2558
|
obj = f"{obj_kind}/{obj_name}" if obj_kind else obj_name
|
|
2504
|
-
results.append(
|
|
2505
|
-
last_seen=(now - event.last_timestamp) if getattr(event, "last_timestamp", None) else None,
|
|
2506
|
-
type=event.type or "",
|
|
2507
|
-
reason=event.reason or "",
|
|
2508
|
-
object=obj,
|
|
2509
|
-
message=event.message or "",
|
|
2510
|
-
))
|
|
2559
|
+
results.append(_event_summary(event, now, obj))
|
|
2511
2560
|
return results
|
|
2512
2561
|
|
|
2513
2562
|
|
|
@@ -2519,11 +2568,12 @@ def print_events(namespace: Optional[str] = None,
|
|
|
2519
2568
|
"""Calls get_events and prints the output to stdout, similar to
|
|
2520
2569
|
`kubectl get events`."""
|
|
2521
2570
|
events = get_events(namespace, reason, involved_kind, involved_name, event_type)
|
|
2522
|
-
print(f"{'LAST SEEN':<12} {'TYPE':<10} {'REASON':<20} {'OBJECT':<40} {'MESSAGE':<40}")
|
|
2571
|
+
print(f"{'LAST SEEN':<12} {'COUNT':<7} {'TYPE':<10} {'REASON':<20} {'OBJECT':<40} {'MESSAGE':<40}")
|
|
2523
2572
|
for event in events:
|
|
2524
2573
|
last_seen = _format_timedelta(event.last_seen) if event.last_seen else "-"
|
|
2574
|
+
count = str(event.count) if event.count is not None else "-"
|
|
2525
2575
|
message = (event.message[:37] + '...') if event.message and len(event.message) > 40 else event.message
|
|
2526
|
-
print(f"{last_seen:<12} {event.type:<10} {event.reason:<20} {event.object:<40} {message:<40}")
|
|
2576
|
+
print(f"{last_seen:<12} {count:<7} {event.type:<10} {event.reason:<20} {event.object:<40} {message:<40}")
|
|
2527
2577
|
|
|
2528
2578
|
|
|
2529
2579
|
|
|
@@ -48,6 +48,7 @@ import datetime
|
|
|
48
48
|
import functools
|
|
49
49
|
import gzip
|
|
50
50
|
import json
|
|
51
|
+
import re
|
|
51
52
|
import types
|
|
52
53
|
import typing
|
|
53
54
|
import zlib
|
|
@@ -319,8 +320,21 @@ class MockState:
|
|
|
319
320
|
server_start_time or datetime.datetime.now(datetime.timezone.utc),
|
|
320
321
|
frozen=frozen)
|
|
321
322
|
|
|
323
|
+
#: When the capture was recorded, as the file says. Tools never report
|
|
324
|
+
#: this: on replay every datetime is re-anchored to the server's start
|
|
325
|
+
#: time (see get_cluster_info), and this is for logs and humans.
|
|
322
326
|
self.captured_at: Optional[str] = data.get("captured_at")
|
|
323
327
|
self.redacted: bool = bool(data.get("redacted", False))
|
|
328
|
+
# How far replay moves every datetime: capture instant -> server start.
|
|
329
|
+
# Stored datetimes are offsets and need no shift, but log lines carry
|
|
330
|
+
# their original timestamps in the text.
|
|
331
|
+
self._log_shift: Optional[datetime.timedelta] = None
|
|
332
|
+
if self.captured_at:
|
|
333
|
+
try:
|
|
334
|
+
recorded = datetime.datetime.fromisoformat(self.captured_at.replace("Z", "+00:00"))
|
|
335
|
+
self._log_shift = self._clock.server_start_time - recorded
|
|
336
|
+
except ValueError:
|
|
337
|
+
pass # an unparseable captured_at leaves log timestamps as recorded
|
|
324
338
|
|
|
325
339
|
# Index the pod records for O(1) lookup; every other list is small enough
|
|
326
340
|
# to filter linearly, which keeps the filtering identical to the real tools'.
|
|
@@ -383,6 +397,12 @@ class MockState:
|
|
|
383
397
|
``source`` is always "capture", so an agent can tell replayed evidence
|
|
384
398
|
from a live cluster. Captures taken before the cluster was recorded
|
|
385
399
|
(including the built-in one) report only when they were taken.
|
|
400
|
+
|
|
401
|
+
``captured_at`` is the replay's anchor, not the date in the file. Every
|
|
402
|
+
other datetime a tool returns is re-anchored to the server's start
|
|
403
|
+
time, and the recorded date beside them contradicted them all - a pod
|
|
404
|
+
killed "5 minutes ago" in a capture taken a month ago. The recorded
|
|
405
|
+
date is on :attr:`captured_at` and in the server's startup log.
|
|
386
406
|
"""
|
|
387
407
|
cluster = self._data.get("cluster") or {}
|
|
388
408
|
return k8s_tools.ClusterInfo(
|
|
@@ -390,7 +410,7 @@ class MockState:
|
|
|
390
410
|
context=cluster.get("context"),
|
|
391
411
|
server=cluster.get("server"),
|
|
392
412
|
server_version=cluster.get("server_version"),
|
|
393
|
-
captured_at=self.
|
|
413
|
+
captured_at=self._clock.server_start_time,
|
|
394
414
|
)
|
|
395
415
|
|
|
396
416
|
@_pinned_query
|
|
@@ -468,12 +488,16 @@ class MockState:
|
|
|
468
488
|
def _event_summary(self, record: dict[str, Any],
|
|
469
489
|
object_name: Optional[str] = None) -> k8s_tools.EventSummary:
|
|
470
490
|
last_seen = record.get("last_seen_seconds")
|
|
491
|
+
# Both absent from captures taken before 2.2.0; they replay as None.
|
|
492
|
+
first_seen = record.get("first_seen_seconds")
|
|
471
493
|
if object_name is None:
|
|
472
494
|
kind = record.get("involved_kind")
|
|
473
495
|
name = record.get("involved_name") or ""
|
|
474
496
|
object_name = record.get("object") or (f"{kind}/{name}" if kind else name)
|
|
475
497
|
return k8s_tools.EventSummary(
|
|
476
498
|
last_seen=self._clock.age(last_seen) if last_seen is not None else None,
|
|
499
|
+
first_seen=self._clock.age(first_seen) if first_seen is not None else None,
|
|
500
|
+
count=record.get("count"),
|
|
477
501
|
type=record.get("type", ""),
|
|
478
502
|
reason=record.get("reason", ""),
|
|
479
503
|
object=object_name,
|
|
@@ -506,7 +530,8 @@ class MockState:
|
|
|
506
530
|
f"Error fetching logs: previous terminated instance of container "
|
|
507
531
|
f"'{container_name}' in pod '{pod_name}' not found.")
|
|
508
532
|
return ''
|
|
509
|
-
return _slice_log(logs[container_name],
|
|
533
|
+
return _slice_log(_reanchor_log(logs[container_name], self._log_shift),
|
|
534
|
+
tail=tail, since_seconds=since_seconds,
|
|
510
535
|
now=self._clock.server_start_time + datetime.timedelta(
|
|
511
536
|
seconds=self._clock.elapsed()))
|
|
512
537
|
|
|
@@ -689,6 +714,44 @@ def _slice_log(text: str, tail: Optional[int], since_seconds: Optional[int],
|
|
|
689
714
|
return "\n".join(lines)
|
|
690
715
|
|
|
691
716
|
|
|
717
|
+
#: The RFC 3339 timestamp the kubelet prefixes to each log line when asked for
|
|
718
|
+
#: timestamps, split so the fractional digits (nanoseconds, in practice) and the
|
|
719
|
+
#: zone spelling can be written back exactly as they came.
|
|
720
|
+
_LOG_TS_RE = re.compile(
|
|
721
|
+
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})(?=\s|$)")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _reanchor_log(text: str, shift: Optional[datetime.timedelta]) -> str:
|
|
725
|
+
"""Move each line's leading timestamp by ``shift``, as replay moves every datetime.
|
|
726
|
+
|
|
727
|
+
Without this, a replayed log kept its original dates beside re-anchored pod
|
|
728
|
+
and event times, and ``since_seconds`` - compared against the replay's "now"
|
|
729
|
+
- dropped every line of any capture older than the window, returning an
|
|
730
|
+
empty log as if the container had logged nothing.
|
|
731
|
+
|
|
732
|
+
Only the kubelet's prefix is moved. Timestamps an application writes inside
|
|
733
|
+
its own message are part of the message, and stay as recorded.
|
|
734
|
+
"""
|
|
735
|
+
if not shift:
|
|
736
|
+
return text
|
|
737
|
+
return "\n".join(_shift_line(line, shift) for line in text.split("\n"))
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _shift_line(line: str, shift: datetime.timedelta) -> str:
|
|
741
|
+
m = _LOG_TS_RE.match(line)
|
|
742
|
+
if m is None:
|
|
743
|
+
return line
|
|
744
|
+
base, frac, zone = m.group(1), m.group(2) or "", m.group(3)
|
|
745
|
+
micros = int((frac + "000000")[:6])
|
|
746
|
+
stamp = datetime.datetime.fromisoformat(base + ("+00:00" if zone == "Z" else zone))
|
|
747
|
+
shifted = stamp + datetime.timedelta(microseconds=micros) + shift
|
|
748
|
+
# Same number of fractional digits as the original: microseconds from the
|
|
749
|
+
# shifted time, any digits past them (nanoseconds) kept as they were.
|
|
750
|
+
new_frac = (f"{shifted.microsecond:06d}" + frac[6:])[:len(frac)]
|
|
751
|
+
head = shifted.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%S")
|
|
752
|
+
return head + (f".{new_frac}" if frac else "") + zone + line[m.end():]
|
|
753
|
+
|
|
754
|
+
|
|
692
755
|
def _leading_timestamp(line: str) -> Optional[datetime.datetime]:
|
|
693
756
|
"""Parse the RFC3339 timestamp kubectl prefixes to each log line, if present."""
|
|
694
757
|
token = line.split(" ", 1)[0]
|
|
@@ -217,10 +217,36 @@ def test_replayed_cluster_info_says_it_is_a_capture():
|
|
|
217
217
|
assert info.source == "capture"
|
|
218
218
|
assert (info.context, info.server, info.server_version) == \
|
|
219
219
|
("prod", "https://prod.example:6443", "v1.31.2")
|
|
220
|
-
assert info.captured_at.isoformat() == "2026-09-27T21:50:53+00:00"
|
|
221
220
|
assert info.kubeconfig is None
|
|
222
221
|
|
|
223
222
|
|
|
223
|
+
@pytest.mark.parametrize("frozen", [True, False])
|
|
224
|
+
def test_replayed_captured_at_is_on_the_same_clock_as_everything_else(frozen):
|
|
225
|
+
"""Issue #7: captured_at was the date in the file while every other datetime
|
|
226
|
+
was re-anchored to the server's start, so a pod killed "5 minutes ago"
|
|
227
|
+
appeared beside a capture taken a month ago."""
|
|
228
|
+
import datetime, json
|
|
229
|
+
recorded = "2026-08-01T12:00:00+00:00"
|
|
230
|
+
start = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
|
|
231
|
+
status = {"pod_name": "p", "namespace": "default", "container_name": "c",
|
|
232
|
+
"image": "img", "ready": False, "restart_count": 1, "started": False,
|
|
233
|
+
"stop_signal": None, "state": None, "volume_mounts": [],
|
|
234
|
+
"resource_requests": {}, "resource_limits": {}, "allocated_resources": {},
|
|
235
|
+
"last_state": {"state_name": "Terminated", "exit_code": 137,
|
|
236
|
+
"started_at_offset_seconds": 360.0,
|
|
237
|
+
"finished_at_offset_seconds": 297.0}}
|
|
238
|
+
state = MockState(json.loads(json.dumps({
|
|
239
|
+
"version": "1", "captured_at": recorded, "redacted": False,
|
|
240
|
+
"pods": [{"summary": {"name": "p", "namespace": "default"},
|
|
241
|
+
"container_statuses": [status]}]})),
|
|
242
|
+
frozen=frozen, server_start_time=start)
|
|
243
|
+
captured_at = state.get_cluster_info().captured_at
|
|
244
|
+
finished_at = state.get_pod_container_statuses("p", "default")[0].last_state.finished_at
|
|
245
|
+
assert captured_at == start
|
|
246
|
+
assert captured_at - finished_at == datetime.timedelta(seconds=297)
|
|
247
|
+
assert state.captured_at == recorded # the file's own date stays available
|
|
248
|
+
|
|
249
|
+
|
|
224
250
|
def test_a_capture_without_a_cluster_record_still_answers():
|
|
225
251
|
info = MockState.from_builtin(frozen=True).get_cluster_info()
|
|
226
252
|
assert info.source == "capture"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for event aggregation: EventSummary.count and first_seen (issue #8).
|
|
2
|
+
|
|
3
|
+
Kubernetes combines repeats of an event into one record. core/v1 events carry
|
|
4
|
+
that in count / firstTimestamp / lastTimestamp; events recorded through
|
|
5
|
+
events.k8s.io/v1 reach the core/v1 view with those unset, and carry it in
|
|
6
|
+
eventTime and series instead. No cluster needed: these are fake API objects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from k8stools import k8s_tools
|
|
16
|
+
from k8stools.capture import _capture_events, _Redactor, CaptureStats
|
|
17
|
+
from k8stools.mock_state import CAPTURE_VERSION, MockState
|
|
18
|
+
|
|
19
|
+
UTC = datetime.timezone.utc
|
|
20
|
+
NOW = datetime.datetime.now(UTC).replace(microsecond=0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _event(reason="Created", *, count=None, first=None, last=None, event_time=None,
|
|
24
|
+
series=None, name="ad-1", kind="Pod"):
|
|
25
|
+
return SimpleNamespace(
|
|
26
|
+
type="Normal", reason=reason, message=f"{reason} message",
|
|
27
|
+
involved_object=SimpleNamespace(name=name, kind=kind),
|
|
28
|
+
count=count, first_timestamp=first, last_timestamp=last,
|
|
29
|
+
event_time=event_time, series=series)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ago(**kw):
|
|
33
|
+
return NOW - datetime.timedelta(**kw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _FakeCore:
|
|
37
|
+
def __init__(self, events):
|
|
38
|
+
self.events = events
|
|
39
|
+
|
|
40
|
+
def list_namespaced_event(self, namespace, field_selector=None):
|
|
41
|
+
return SimpleNamespace(items=self.events)
|
|
42
|
+
|
|
43
|
+
def list_event_for_all_namespaces(self, field_selector=None):
|
|
44
|
+
return SimpleNamespace(items=self.events)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def serve(monkeypatch):
|
|
49
|
+
"""Serve fake events to both event tools; returns what each tool made of them."""
|
|
50
|
+
def install(*events):
|
|
51
|
+
monkeypatch.setattr(k8s_tools, "K8S", _FakeCore(list(events)))
|
|
52
|
+
return (k8s_tools.get_pod_events("ad-1", "default"),
|
|
53
|
+
k8s_tools.get_events(namespace="default"))
|
|
54
|
+
return install
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _only(results):
|
|
58
|
+
"""Both tools must agree; return the one event."""
|
|
59
|
+
pod_events, events = results
|
|
60
|
+
assert len(pod_events) == len(events) == 1
|
|
61
|
+
a, b = pod_events[0], events[0]
|
|
62
|
+
assert (a.count, a.first_seen, a.last_seen) == (b.count, b.first_seen, b.last_seen)
|
|
63
|
+
return b
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _close(td, **kw):
|
|
67
|
+
"""Within a few seconds: NOW is sampled here, the tool samples its own."""
|
|
68
|
+
return abs(td - datetime.timedelta(**kw)) < datetime.timedelta(seconds=5)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- core/v1 aggregation -------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def test_aggregated_event_keeps_count_and_window(serve):
|
|
74
|
+
e = _only(serve(_event(count=579, first=_ago(days=2, hours=8), last=_ago(minutes=2))))
|
|
75
|
+
assert e.count == 579
|
|
76
|
+
assert _close(e.first_seen, days=2, hours=8)
|
|
77
|
+
assert _close(e.last_seen, minutes=2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_restart_rate_is_readable_from_one_record(serve):
|
|
81
|
+
"""The motivating case from the issue, with benben's numbers: ad restarted
|
|
82
|
+
579 times in 2d8h15m, i.e. every ~5.8 minutes - not "every minute"."""
|
|
83
|
+
e = _only(serve(_event(count=579, first=_ago(days=2, hours=8, minutes=17),
|
|
84
|
+
last=_ago(minutes=2))))
|
|
85
|
+
per_restart = (e.first_seen - e.last_seen) / e.count
|
|
86
|
+
assert datetime.timedelta(minutes=5, seconds=30) < per_restart < datetime.timedelta(minutes=6)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- events.k8s.io/v1 in the core/v1 view --------------------------------------
|
|
90
|
+
|
|
91
|
+
def test_series_fills_in_when_the_deprecated_fields_are_empty(serve):
|
|
92
|
+
series = SimpleNamespace(count=12, last_observed_time=_ago(minutes=1))
|
|
93
|
+
e = _only(serve(_event(event_time=_ago(hours=1), series=series)))
|
|
94
|
+
assert e.count == 12
|
|
95
|
+
assert _close(e.first_seen, hours=1)
|
|
96
|
+
assert _close(e.last_seen, minutes=1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_single_events_k8s_io_event_counts_once_and_has_a_time(serve):
|
|
100
|
+
"""No series means observed once. last_seen used to be None here, although
|
|
101
|
+
the event's time was right there in eventTime."""
|
|
102
|
+
e = _only(serve(_event(reason="Scheduled", event_time=_ago(minutes=10))))
|
|
103
|
+
assert e.count == 1
|
|
104
|
+
assert _close(e.first_seen, minutes=10) and _close(e.last_seen, minutes=10)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_the_deprecated_fields_win_when_both_are_set(serve):
|
|
108
|
+
series = SimpleNamespace(count=3, last_observed_time=_ago(hours=5))
|
|
109
|
+
e = _only(serve(_event(count=40, first=_ago(hours=2), last=_ago(minutes=1),
|
|
110
|
+
event_time=_ago(hours=9), series=series)))
|
|
111
|
+
assert e.count == 40
|
|
112
|
+
assert _close(e.first_seen, hours=2) and _close(e.last_seen, minutes=1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_an_event_with_no_times_or_count_stays_unknown(serve):
|
|
116
|
+
e = _only(serve(_event()))
|
|
117
|
+
assert (e.count, e.first_seen, e.last_seen) == (None, None, None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_docstrings_warn_that_backoff_counts_are_not_restarts():
|
|
121
|
+
"""BackOff is emitted repeatedly while waiting (2561 BackOff vs 579 Created
|
|
122
|
+
for one pod on benben), so reading it as restarts overstates the rate ~4x.
|
|
123
|
+
The docstring is the MCP tool description, so the warning has to be there."""
|
|
124
|
+
for tool in (k8s_tools.get_pod_events, k8s_tools.get_events):
|
|
125
|
+
assert '"BackOff"' in tool.__doc__ and '"Created"' in tool.__doc__
|
|
126
|
+
assert "first_seen" in tool.__doc__ and "count" in tool.__doc__
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- capture and replay --------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _capture(events):
|
|
132
|
+
return {"version": CAPTURE_VERSION, "captured_at": NOW.isoformat(),
|
|
133
|
+
"redacted": False, "events": events}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_count_and_first_seen_round_trip_and_first_seen_ages(monkeypatch):
|
|
137
|
+
monkeypatch.setattr(k8s_tools, "get_events", lambda namespace=None: [
|
|
138
|
+
k8s_tools.EventSummary(last_seen=datetime.timedelta(minutes=2),
|
|
139
|
+
first_seen=datetime.timedelta(days=2),
|
|
140
|
+
count=579, type="Normal", reason="Created",
|
|
141
|
+
object="Pod/ad-1", message="Container created")])
|
|
142
|
+
records = _capture_events("default", NOW, _Redactor(False, CaptureStats()))
|
|
143
|
+
assert records[0]["count"] == 579
|
|
144
|
+
assert records[0]["first_seen_seconds"] == 2 * 86400
|
|
145
|
+
|
|
146
|
+
later = datetime.datetime.now(UTC) - datetime.timedelta(hours=1)
|
|
147
|
+
state = MockState(json.loads(json.dumps(_capture(records))), frozen=False,
|
|
148
|
+
server_start_time=later)
|
|
149
|
+
for e in (state.get_events()[0], state.get_pod_events("ad-1", "default")[0]):
|
|
150
|
+
assert e.count == 579
|
|
151
|
+
# An age, advanced like last_seen - the window between them is unchanged.
|
|
152
|
+
assert e.first_seen == datetime.timedelta(days=2, hours=1)
|
|
153
|
+
assert e.first_seen - e.last_seen == datetime.timedelta(days=2) - datetime.timedelta(minutes=2)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_a_capture_from_before_2_2_0_replays_the_new_fields_as_none():
|
|
157
|
+
state = MockState(_capture([{
|
|
158
|
+
"last_seen_seconds": 120.0, "type": "Warning", "reason": "BackOff",
|
|
159
|
+
"namespace": "default", "involved_kind": "Pod", "involved_name": "ad-1",
|
|
160
|
+
"object": "Pod/ad-1", "message": "Back-off restarting failed container"}]),
|
|
161
|
+
frozen=True)
|
|
162
|
+
e = state.get_events()[0]
|
|
163
|
+
assert (e.count, e.first_seen) == (None, None)
|
|
164
|
+
assert e.last_seen == datetime.timedelta(seconds=120)
|
|
@@ -758,3 +758,15 @@ def test_get_cluster_info():
|
|
|
758
758
|
if info.source == "kubeconfig":
|
|
759
759
|
_, current = config.list_kube_config_contexts()
|
|
760
760
|
assert info.context == current["name"]
|
|
761
|
+
|
|
762
|
+
def test_events_carry_count_and_first_seen():
|
|
763
|
+
"""On a real cluster every event record reports how many occurrences it
|
|
764
|
+
combines, and its window runs forwards: first_seen is no newer than last_seen."""
|
|
765
|
+
events = k8s_tools.get_events()
|
|
766
|
+
if not events:
|
|
767
|
+
pytest.skip("No events found in the cluster.")
|
|
768
|
+
for event in events:
|
|
769
|
+
assert event.count is None or event.count >= 1
|
|
770
|
+
if event.first_seen is not None and event.last_seen is not None:
|
|
771
|
+
assert event.first_seen >= event.last_seen
|
|
772
|
+
assert any(e.count is not None for e in events)
|
|
@@ -224,7 +224,62 @@ def test_since_seconds_filters_on_parseable_timestamps():
|
|
|
224
224
|
state = MockState(_capture(pods=[_pod_with_logs({"c": "\n".join(lines)})]),
|
|
225
225
|
frozen=True, server_start_time=now)
|
|
226
226
|
out = state.get_logs_for_pod_and_container("p", "default", "c", since_seconds=120)
|
|
227
|
-
|
|
227
|
+
# Compare messages: replay re-anchors the prefix by the (here, microseconds)
|
|
228
|
+
# between capture and server start. See TestReplayedLogTimestamps.
|
|
229
|
+
assert [l.split(" ", 1)[1] for l in out.splitlines()] == ["at -30s"]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class TestReplayedLogTimestamps:
|
|
233
|
+
"""Log lines carry their timestamps in the text, so replay has to move them
|
|
234
|
+
the way it moves every other datetime. Before 2.2.0 it did not: a replayed
|
|
235
|
+
log kept its original dates, and since_seconds - compared against the
|
|
236
|
+
replay's "now" - dropped every line of any capture older than the window.
|
|
237
|
+
The since_seconds test above could not see it: it captures and replays at
|
|
238
|
+
the same instant."""
|
|
239
|
+
|
|
240
|
+
CAPTURED = datetime.datetime(2026, 8, 1, 12, 0, 0, tzinfo=UTC)
|
|
241
|
+
|
|
242
|
+
def _state(self, logs, previous_logs=None, frozen=True, start=None):
|
|
243
|
+
capture = _capture(pods=[_pod_with_logs(logs, previous_logs)])
|
|
244
|
+
capture["captured_at"] = self.CAPTURED.isoformat()
|
|
245
|
+
return MockState(capture, frozen=frozen,
|
|
246
|
+
server_start_time=start or datetime.datetime.now(UTC))
|
|
247
|
+
|
|
248
|
+
def _line(self, seconds_before_capture, text, fmt="%Y-%m-%dT%H:%M:%S.%f"):
|
|
249
|
+
ts = self.CAPTURED - datetime.timedelta(seconds=seconds_before_capture)
|
|
250
|
+
return f"{ts.strftime(fmt)}123Z {text}" # nanosecond precision, as the kubelet writes
|
|
251
|
+
|
|
252
|
+
@pytest.mark.parametrize("frozen", [True, False])
|
|
253
|
+
def test_since_seconds_works_on_an_old_capture(self, frozen):
|
|
254
|
+
lines = [self._line(s, f"at -{s}s") for s in (600, 300, 30)]
|
|
255
|
+
state = self._state({"c": "\n".join(lines)}, frozen=frozen)
|
|
256
|
+
out = state.get_logs_for_pod_and_container("p", "default", "c", since_seconds=120)
|
|
257
|
+
assert [l.split(" ", 1)[1] for l in out.splitlines()] == ["at -30s"]
|
|
258
|
+
|
|
259
|
+
def test_prefixes_move_with_the_other_replayed_datetimes(self):
|
|
260
|
+
start = datetime.datetime.now(UTC)
|
|
261
|
+
state = self._state({"c": self._line(30, "killed")}, start=start)
|
|
262
|
+
line = state.get_logs_for_pod_and_container("p", "default", "c")
|
|
263
|
+
stamp = datetime.datetime.fromisoformat(line.split(" ", 1)[0][:26] + "+00:00")
|
|
264
|
+
# 30s before capture reads as 30s before the replay's anchor, like
|
|
265
|
+
# every started_at/finished_at the tools return.
|
|
266
|
+
assert abs(stamp - (start - datetime.timedelta(seconds=30))) < datetime.timedelta(microseconds=2)
|
|
267
|
+
|
|
268
|
+
def test_format_and_message_are_preserved(self):
|
|
269
|
+
app_stamp = "2026-08-01T11:59:00Z"
|
|
270
|
+
text = "\n".join([self._line(30, f"app says {app_stamp}"), "a continuation line"])
|
|
271
|
+
out = self._state({"c": text}).get_logs_for_pod_and_container("p", "default", "c")
|
|
272
|
+
first, second = out.splitlines()
|
|
273
|
+
prefix, message = first.split(" ", 1)
|
|
274
|
+
assert len(prefix) == len("2026-08-01T11:59:30.000000123Z")
|
|
275
|
+
assert prefix.endswith("123Z") # nanosecond digits kept
|
|
276
|
+
assert message == f"app says {app_stamp}" # the app's own timestamp is not ours to move
|
|
277
|
+
assert second == "a continuation line"
|
|
278
|
+
|
|
279
|
+
def test_previous_logs_are_re_anchored_too(self):
|
|
280
|
+
state = self._state({"c": "current"}, {"c": self._line(30, "the OOM")})
|
|
281
|
+
out = state.get_logs_for_pod_and_container("p", "default", "c", previous=True)
|
|
282
|
+
assert not out.startswith("2026-08-01")
|
|
228
283
|
|
|
229
284
|
|
|
230
285
|
def test_since_seconds_is_ignored_when_lines_carry_no_timestamp():
|
|
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
|