"""Integration: the timeline against real Postgres. Each source writes its entry as the thing happens, the entry outlives its record, Settings re-sorts and hides entries when they are read, the first fill skips what is covered, and get_timeline reads it back. The database is shared with other tests, so each test looks for its own uniquely named host. Requires STEWARD_DATABASE_URL. """ from __future__ import annotations import asyncio import os import uuid from datetime import datetime, timedelta, timezone import pytest pytestmark = pytest.mark.integration @pytest.fixture def app(): if not os.environ.get("STEWARD_DATABASE_URL"): pytest.skip("needs Postgres") from steward.app import create_app return create_app(testing=False) async def _entries(app, host: str): from sqlalchemy import select from steward.models.timeline import TimelineEntry async with app.db_sessionmaker() as s: return (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host])) .order_by(TimelineEntry.at))).scalars().all() def test_each_source_writes_an_entry_that_outlives_its_record(app): from sqlalchemy import delete from steward.alerts import service as alert_svc from steward.core.alerts import record_metric, record_metrics from steward.core.audit import log_audit from steward.models.alerts import AlertEvent from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.hosts import Host from steward.models.metrics import PluginMetric from steward.models.users import User, UserRole from steward.timeline import config as tl_config, service as tl from steward.timeline.record import run_finished async def _go(): tag = uuid.uuid4().hex[:8] name, renamed, run_id = f"tl-{tag}", f"tl-{tag}-new", str(uuid.uuid4()) async with app.db_sessionmaker() as s: async with s.begin(): user = User(username=f"tl-{tag}", email=f"tl-{tag}@test", password_hash="x", role=UserRole.admin) host = Host(name=name, address="10.1.2.3") s.add_all([user, host]) await record_metrics(s, [PluginMetric(source_module="host_agent", resource_name=name, metric_name="cpu_pct", value=1.0)]) await s.flush() fields = await alert_svc.rule_fields( s, name=f"hot {tag}", source_module="host_agent", resource_name=name, metric_name="cpu_pct", operator=">", threshold=90, consecutive_failures_required=1, consecutive_ok_required=1) rule = await alert_svc.add_rule(s, fields, created_by=user.id) user_id, host_id, rule_id = user.id, host.id, rule.id # An alert firing. async with app.db_sessionmaker() as s: async with s.begin(): await record_metric(s, "host_agent", name, "cpu_pct", 97.0) # A change to Steward's settings about the host, and one the timeline leaves to the run. await log_audit(app, user_id, f"tl-{tag}", "host.created", "host", host_id, {"name": name}) await log_audit(app, user_id, f"tl-{tag}", "ansible.run.started", "ansible_run", run_id, {}) # A playbook run that changed the host. async with app.db_sessionmaker() as s: async with s.begin(): s.add(AnsibleRun(id=run_id, playbook_path="update.yml", source_name="local", inventory_scope=f"steward:target:{uuid.uuid4()}", status=AnsibleRunStatus.success, triggered_by=user_id, finished_at=datetime.now(timezone.utc), results={"hosts": {name: {"ok": 3, "changed": 1}}, "failures": []})) async with s.begin(): await run_finished(s, run_id) got = {e.type: e for e in await _entries(app, name)} assert set(got) == {"alert.fired", "steward.config", "ansible.changed"} fired = got["alert.fired"] assert fired.summary == f"hot {tag} fired: CPU on {name} is 97% (> 90%)" assert fired.area == "host_os" and fired.ref_type == "alert_event" assert got["steward.config"].summary == f"Added host {name}" assert got["steward.config"].actor == f"tl-{tag}" assert got["ansible.changed"].summary.endswith(": changed 1 host") assert got["ansible.changed"].actor == f"tl-{tag}" # The records age out; the entries stay, saying their details are gone. async with app.db_sessionmaker() as s: async with s.begin(): await s.execute(delete(AlertEvent).where(AlertEvent.rule_id == rule_id)) await s.execute(delete(AnsibleRun).where(AnsibleRun.id == run_id)) async with app.db_sessionmaker() as s: cfg = await tl_config.load(s) card = {v["entry"].type: v for v in await tl.recent_for_host(s, cfg, name)} assert set(card) == {"alert.fired", "steward.config", "ansible.changed"} assert card["alert.fired"]["gone"] and card["alert.fired"]["url"] is None assert card["ansible.changed"]["gone"] assert not card["steward.config"]["gone"] # Renaming the host carries its history to the new name. async with app.db_sessionmaker() as s: async with s.begin(): assert await tl.carry_host_rename(s, name, renamed) == 3 assert await _entries(app, name) == [] assert len(await _entries(app, renamed)) == 3 asyncio.run(_go()) def test_settings_re_sort_and_hide_entries_already_written(app): from sqlalchemy import select from steward.models.timeline import TimelineEntry from steward.timeline import catalog from steward.timeline.config import Config from steward.timeline.record import record from steward.timeline.service import query async def _go(): host = f"tl-{uuid.uuid4().hex[:8]}" now = datetime.now(timezone.utc) async with app.db_sessionmaker() as s: async with s.begin(): record(s, type="ansible.changed", at=now, summary="ran", hosts=[host]) record(s, type="ansible.check", at=now, summary="dry run", hosts=[host]) record(s, type="alert.fired", at=now, summary="hot", hosts=[host], area="hardware") async def types(cfg, **kw): async with app.db_sessionmaker() as s: rows = (await s.execute(query(cfg, host=host, **kw))).scalars().all() return sorted(e.type for e in rows) areas = [list(a) for a in catalog.DEFAULT_AREAS] cfg = Config(areas=areas) assert await types(cfg) == ["alert.fired", "ansible.changed"] # the dry run is hidden assert await types(cfg, include_hidden=True) == ["alert.fired", "ansible.changed", "ansible.check"] assert await types(Config(areas=areas, types={"ansible.check": {"shown": True}})) == \ ["alert.fired", "ansible.changed", "ansible.check"] assert await types(cfg, area="host_os") == ["ansible.changed"] assert await types(cfg, area="hardware") == ["alert.fired"] assert await types(cfg, kind="automation", include_hidden=True) == ["ansible.changed", "ansible.check"] # A type given an area moves every entry of it, past ones included. moved = Config(areas=areas, types={"ansible.changed": {"area": "service"}}) assert await types(moved, area="service") == ["ansible.changed"] assert await types(moved, area="host_os") == [] # A removed area's entries sort under the one they were moved to. merged = Config(areas=[a for a in areas if a[0] != "hardware"], moved={"hardware": "storage"}) assert await types(merged, area="storage") == ["alert.fired"] # An area nobody lists any more sorts under Other. lost = Config(areas=[a for a in areas if a[0] != "hardware"]) assert await types(lost, area="other") == ["alert.fired"] async with app.db_sessionmaker() as s: async with s.begin(): for e in (await s.execute(select(TimelineEntry).where( TimelineEntry.hosts.contains([host])))).scalars(): await s.delete(e) asyncio.run(_go()) def test_the_first_fill_writes_what_predates_the_timeline_once(app): from steward.core.settings import get_setting, set_setting from steward.models.alerts import AlertEvent, AlertOperator, AlertRule from steward.models.users import User, UserRole from steward.timeline.record import record from steward.timeline.service import backfill, fill_sources async def _go(): tag = uuid.uuid4().hex[:8] name = f"tl-{tag}" async with app.db_sessionmaker() as s: async with s.begin(): before = await get_setting(s, "timeline.filled") user = User(username=f"tl-{tag}", email=f"tl-{tag}@test", password_hash="x", role=UserRole.admin) s.add(user) await s.flush() rule = AlertRule(name=f"disk {tag}", source_module="host_agent", resource_name=f"{name}:/", metric_name="disk_used_pct", operator=AlertOperator.gt, threshold=90, created_by=user.id) s.add(rule) await s.flush() # Written straight to the table, as before the timeline existed. s.add(AlertEvent(rule_id=rule.id, from_state="pending", to_state="firing", metric_value=95.0, transitioned_at=datetime.now(timezone.utc) - timedelta(days=3))) await set_setting(s, "timeline.filled", {}) # A plugin's source: its entries are added beside core's, once. async def plugin_source(session, since): return [record(session, type="host.rebooted", at=datetime.now(timezone.utc) - timedelta(days=1), summary=f"{name}: rebooted for 2m", hosts=[name], ref=("test_fill", name))] sources = {"steward": fill_sources()["steward"], "test_plugin": plugin_source} try: assert await backfill(app, sources=sources) >= 2 got = {e.type: e for e in await _entries(app, name)} assert set(got) == {"alert.fired", "host.rebooted"} assert got["alert.fired"].area == "storage" and got["alert.fired"].subject == f"{name}:/" async with app.db_sessionmaker() as s: assert set(await get_setting(s, "timeline.filled")) == {"steward", "test_plugin"} assert await backfill(app, sources=sources) == 0 # once per source async with app.db_sessionmaker() as s: async with s.begin(): await set_setting(s, "timeline.filled", {"test_plugin": "done"}) assert await backfill(app, sources=sources) == 0 # again: what is covered is skipped assert len(await _entries(app, name)) == 2 finally: async with app.db_sessionmaker() as s: async with s.begin(): await set_setting(s, "timeline.filled", before or {"steward": "done"}) def test_retention_prunes_only_once_it_is_set(app): from steward.core.settings import set_setting from steward.timeline.record import record from steward.timeline.service import prune async def _go(): old = datetime.now(timezone.utc) - timedelta(days=400) async with app.db_sessionmaker() as s: record(s, type="steward.config", at=old, summary="old", hosts=[f"tl-{uuid.uuid4().hex[:8]}"]) await s.flush() assert await prune(s, datetime.now(timezone.utc)) == (0, 0) # 0 days keeps everything await set_setting(s, "timeline.retention_days", 365) removed, days = await prune(s, datetime.now(timezone.utc)) assert days == 365 and removed >= 1 await s.rollback() # leave the shared database as it was asyncio.run(_go()) def test_get_timeline_reads_a_host_s_history(app): from steward.core.api_tokens import Caller from steward.mcp import _context from steward.mcp.tools.timeline import get_timeline from steward.models.users import UserRole from steward.timeline.record import record async def _go(): host = f"tl-{uuid.uuid4().hex[:8]}" now = datetime.now(timezone.utc) async with app.db_sessionmaker() as s: async with s.begin(): record(s, type="ansible.changed", at=now - timedelta(hours=2), summary="update.yml: changed 1 host", hosts=[host], detail={"url": "/ansible/runs/x"}, ref=("ansible_run", str(uuid.uuid4()))) record(s, type="ansible.check", at=now - timedelta(hours=1), summary="dry run", hosts=[host]) _context._app_ctx.set(app) _context._caller_ctx.set(Caller(user_id="u", username="viewer", role=UserRole.viewer, token_id=0)) out = await get_timeline(host=host) assert [e["summary"] for e in out["entries"]] == ["update.yml: changed 1 host"] [e] = out["entries"] assert e["kind"] == "automation" and e["area"] == "host_os" and e["hosts"] == [host] assert e["details_kept"] is False and e["url"] is None # the run is not in the table assert out["areas"]["host_os"] == "Host OS" and "alert.fired" in out["types"] both = await get_timeline(host=host, include_hidden=True) assert [x["type"] for x in both["entries"]] == ["ansible.check", "ansible.changed"] assert (await get_timeline(host=host, kind="alert"))["entries"] == [] with pytest.raises(ValueError, match="kind"): await get_timeline(kind="nope") with pytest.raises(ValueError, match="area"): await get_timeline(area="nope") asyncio.run(_go())