Files
FabledSteward/tests/plugins/docker/test_ingest_events.py
T
bvandeusen a8de3570fe
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 41s
CI / integration (push) Successful in 2m25s
CI / publish (push) Successful in 8s
feat(docker): persist pushed container logs into docker_logs [M79 step 3]
Ingest side of container logs. The host_agent ingest route extracts
sample["docker_logs"] as time-series batches (append every line, each carrying
its own Docker ts with recorded_at as fallback) and hands them to the docker
capability alongside the existing container/swarm/disk data — still under the
begin_nested SAVEPOINT so a logs failure can't sink host metrics.

- ingest.py: _persist_logs + pure _log_rows(batches, host_id) that shapes/filters
  records (drops the _steward truncation marker, malformed + lineless records,
  normalises unknown stream → stdout); persist_host_docker gains a logs= param
- routes.py: accumulate docker_log_batches, add to the guard + capability call
- unit test for _log_rows shaping/filtering; integration push→store→query test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
2026-07-19 18:46:31 -04:00

100 lines
4.4 KiB
Python

"""Unit tests for the docker plugin's lifecycle-event derivation.
_derive_events is a pure function (no DB), and ingest.py imports its models
lazily inside functions, so importing it here doesn't register ORM tables —
safe for the no-DB unit lane.
"""
from plugins.docker.ingest import _derive_events
def _c(name, status, **kw):
return {"name": name, "status": status, **kw}
def test_start_on_running_after_stopped():
old = {"web": {"status": "exited", "health": None, "oom_killed": False, "exit_code": 0}}
events = _derive_events(old, [_c("web", "running", image="nginx")])
assert events == [("web", "start", "nginx")]
def test_start_for_new_container_when_state_exists():
# old_state is non-empty (host already seen), a fresh container appears running.
old = {"other": {"status": "running", "health": None, "oom_killed": False, "exit_code": None}}
events = _derive_events(old, [
_c("other", "running"),
_c("new", "running", image="redis"),
])
assert ("new", "start", "redis") in events
# the unchanged 'other' produces nothing
assert all(e[0] != "other" for e in events)
def test_stop_on_clean_exit():
old = {"job": {"status": "running", "health": None, "oom_killed": False, "exit_code": None}}
events = _derive_events(old, [_c("job", "exited", exit_code=0)])
assert events == [("job", "stop", None)]
def test_die_on_nonzero_exit():
old = {"job": {"status": "running", "health": None, "oom_killed": False, "exit_code": None}}
events = _derive_events(old, [_c("job", "exited", exit_code=137)])
assert events == [("job", "die", "exit 137")]
def test_oom_on_flip_to_true():
old = {"hog": {"status": "running", "health": None, "oom_killed": False, "exit_code": None}}
# OOM-killed → also no longer running with a non-zero exit, so die + oom both fire.
events = _derive_events(old, [_c("hog", "exited", exit_code=137, oom_killed=True)])
assert ("hog", "oom", None) in events
assert ("hog", "die", "exit 137") in events
def test_health_change_emitted():
old = {"svc": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
events = _derive_events(old, [_c("svc", "running", health="unhealthy")])
assert events == [("svc", "health_change", "healthy→unhealthy")]
def test_removed_running_container_stops():
old = {"gone": {"status": "running", "health": None, "oom_killed": False, "exit_code": None}}
events = _derive_events(old, []) # vanished from the listing entirely
assert events == [("gone", "stop", "removed")]
def test_no_event_when_unchanged():
old = {"web": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
events = _derive_events(old, [_c("web", "running", health="healthy")])
assert events == []
# ── container-log row shaping (m79, pure) ─────────────────────────────────────
def test_log_rows_shapes_and_filters():
from datetime import datetime, timezone
from plugins.docker.ingest import _log_rows
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
batches = [(rec_at, [
{"container": "web", "stream": "stdout",
"ts": "2026-01-01T00:00:01+00:00", "line": "hello"},
{"container": "web", "stream": "stderr", "ts": None, "line": "no-ts"},
{"container": "_steward", "stream": "stderr",
"ts": None, "line": "truncated"}, # advisory marker → dropped
{"container": "web", "stream": "weird", "ts": None, "line": "bad-stream"},
{"container": "", "line": "x"}, # no name → dropped
{"container": "web", "ts": None, "line": None}, # no line → dropped
"not-a-dict", # malformed → dropped
])]
rows = list(_log_rows(batches, "host-1"))
assert [r["line"] for r in rows] == ["hello", "no-ts", "bad-stream"]
assert rows[0]["host_id"] == "host-1" and rows[0]["container_name"] == "web"
assert rows[1]["ts"] == rec_at # None ts → recorded_at fallback
assert rows[2]["stream"] == "stdout" # unknown stream normalised
def test_log_rows_skips_non_list_records():
from datetime import datetime, timezone
from plugins.docker.ingest import _log_rows
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
assert list(_log_rows([(rec_at, None)], "h")) == []