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.
Files changed (41) hide show
  1. {k8stools-2.1.0 → k8stools-2.2.0}/CHANGELOG.md +33 -0
  2. {k8stools-2.1.0 → k8stools-2.2.0}/CLAUDE.md +7 -0
  3. {k8stools-2.1.0 → k8stools-2.2.0}/PKG-INFO +10 -1
  4. {k8stools-2.1.0 → k8stools-2.2.0}/README.md +9 -0
  5. {k8stools-2.1.0 → k8stools-2.2.0}/designs/mock-state-capture.md +4 -1
  6. {k8stools-2.1.0 → k8stools-2.2.0}/pyproject.toml +1 -1
  7. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/__init__.py +1 -1
  8. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/capture.py +2 -0
  9. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/k8s_tools.py +75 -25
  10. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mock_state.py +65 -2
  11. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_cluster_binding.py +27 -1
  12. k8stools-2.2.0/tests/test_events.py +164 -0
  13. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_k8s_tools_realk8s.py +12 -0
  14. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mock_state.py +56 -1
  15. {k8stools-2.1.0 → k8stools-2.2.0}/uv.lock +1 -1
  16. {k8stools-2.1.0 → k8stools-2.2.0}/.claude/settings.local.json +0 -0
  17. {k8stools-2.1.0 → k8stools-2.2.0}/.github/workflows/unit-tests.yml +0 -0
  18. {k8stools-2.1.0 → k8stools-2.2.0}/.gitignore +0 -0
  19. {k8stools-2.1.0 → k8stools-2.2.0}/.python-version +0 -0
  20. {k8stools-2.1.0 → k8stools-2.2.0}/.vscode/mcp.json +0 -0
  21. {k8stools-2.1.0 → k8stools-2.2.0}/.vscode/settings.json +0 -0
  22. {k8stools-2.1.0 → k8stools-2.2.0}/LICENSE +0 -0
  23. {k8stools-2.1.0 → k8stools-2.2.0}/chat-with-cluster.png +0 -0
  24. {k8stools-2.1.0 → k8stools-2.2.0}/docs/ROADMAP.md +0 -0
  25. {k8stools-2.1.0 → k8stools-2.2.0}/envrc.template +0 -0
  26. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/fixtures/otel-demo.json +0 -0
  27. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mcp_client.py +0 -0
  28. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mcp_server.py +0 -0
  29. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/mock_tools.py +0 -0
  30. {k8stools-2.1.0 → k8stools-2.2.0}/src/k8stools/redaction.py +0 -0
  31. {k8stools-2.1.0 → k8stools-2.2.0}/tests/conftest.py +0 -0
  32. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_capture.py +0 -0
  33. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_duration_format.py +0 -0
  34. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_k8s_tools.py +0 -0
  35. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_log_decoding.py +0 -0
  36. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mcp_client.py +0 -0
  37. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_mock_tools.py +0 -0
  38. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_new_tools.py +0 -0
  39. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_redaction.py +0 -0
  40. {k8stools-2.1.0 → k8stools-2.2.0}/tests/test_replicaset_tool.py +0 -0
  41. {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.1.0
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, "type": "Warning", "reason": "Evicted",
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),
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "k8stools"
4
- version = "2.1.0"
4
+ version = "2.2.0"
5
5
  description = "Tools and an MCP server for building Kubernetes agents"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -78,4 +78,4 @@ the built-in OTel Demo snapshot by default, or your own via load_mock_state() or
78
78
  the server's --state-file option.
79
79
  """
80
80
 
81
- __version__ = "2.1.0"
81
+ __version__ = "2.2.0"
@@ -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.datetime]
694
- Timestamp of the last occurrence of the event (if available).
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
- EventSummary(
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 the Kubernetes API only retains roughly the last
2431
- hour of events.
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(EventSummary(
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.captured_at,
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], tail=tail, since_seconds=since_seconds,
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
- assert out.splitlines() == [lines[-1]]
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():
@@ -544,7 +544,7 @@ wheels = [
544
544
 
545
545
  [[package]]
546
546
  name = "k8stools"
547
- version = "2.1.0"
547
+ version = "2.2.0"
548
548
  source = { editable = "." }
549
549
  dependencies = [
550
550
  { name = "kubernetes" },
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