Adds background watcher that tails active log sources and ingests entries in near-real-time, keeping the DB fresh without manual ingest runs. - app/watch/watcher.py: Watcher + WatchSource using subprocess + select loop; flushes every 10s or 100 lines; syncs FTS index every 3 flushes - patterns/watch.yaml: declarative source config (journald/docker/podman) - app/rest.py: lifespan context manager starts/stops watcher on app startup/shutdown; GET /api/watch/status + POST /api/watch/reload - web/src/views/DashboardView.vue: live/manual indicator chip + stale banner copy adapts to whether live watching is active - tests/test_watch_watcher.py: 16 tests covering config load, command building, docker timestamp stripping, orchestrator lifecycle Closes #4
159 lines
4.9 KiB
Python
159 lines
4.9 KiB
Python
"""Tests for app/watch/watcher.py — config loading, command building, output parsing."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.watch.watcher import (
|
|
WatchConfig,
|
|
WatchSource,
|
|
Watcher,
|
|
_strip_docker_ts,
|
|
load_watch_config,
|
|
)
|
|
|
|
|
|
# ── Config loading ──────────────────────────────────────────────────────────
|
|
|
|
def test_load_watch_config_missing_file(tmp_path: Path):
|
|
result = load_watch_config(tmp_path / "nonexistent.yaml")
|
|
assert result == []
|
|
|
|
|
|
def test_load_watch_config_empty_sources(tmp_path: Path):
|
|
cfg = tmp_path / "watch.yaml"
|
|
cfg.write_text("sources: []\n")
|
|
assert load_watch_config(cfg) == []
|
|
|
|
|
|
def test_load_watch_config_parses_journald(tmp_path: Path):
|
|
cfg = tmp_path / "watch.yaml"
|
|
cfg.write_text("""
|
|
sources:
|
|
- type: journald
|
|
id: system-journal
|
|
args: ["-u", "sshd"]
|
|
""")
|
|
configs = load_watch_config(cfg)
|
|
assert len(configs) == 1
|
|
assert configs[0].source_type == "journald"
|
|
assert configs[0].source_id == "system-journal"
|
|
assert configs[0].args == ["-u", "sshd"]
|
|
|
|
|
|
def test_load_watch_config_parses_podman(tmp_path: Path):
|
|
cfg = tmp_path / "watch.yaml"
|
|
cfg.write_text("""
|
|
sources:
|
|
- type: podman
|
|
id: podman:turnstone
|
|
args: ["turnstone"]
|
|
""")
|
|
configs = load_watch_config(cfg)
|
|
assert configs[0].source_type == "podman"
|
|
assert configs[0].args == ["turnstone"]
|
|
|
|
|
|
def test_load_watch_config_no_args_defaults_to_empty(tmp_path: Path):
|
|
cfg = tmp_path / "watch.yaml"
|
|
cfg.write_text("""
|
|
sources:
|
|
- type: journald
|
|
id: system
|
|
""")
|
|
configs = load_watch_config(cfg)
|
|
assert configs[0].args == []
|
|
|
|
|
|
# ── Command building ─────────────────────────────────────────────────────────
|
|
|
|
def _make_source(source_type: str, source_id: str, args: list = None, db=None, pattern=None):
|
|
if db is None:
|
|
db = Path("/tmp/fake.db")
|
|
if pattern is None:
|
|
pattern = Path("/tmp/fake.yaml")
|
|
cfg = WatchConfig(source_type=source_type, source_id=source_id, args=args or [])
|
|
return WatchSource(cfg, db, pattern)
|
|
|
|
|
|
def test_build_command_journald():
|
|
src = _make_source("journald", "sys")
|
|
cmd = src._build_command()
|
|
assert cmd[:3] == ["journalctl", "-f", "--output=json"]
|
|
|
|
|
|
def test_build_command_journald_with_unit_filter():
|
|
src = _make_source("journald", "sshd", args=["-u", "sshd"])
|
|
cmd = src._build_command()
|
|
assert "-u" in cmd
|
|
assert "sshd" in cmd
|
|
|
|
|
|
def test_build_command_podman_with_container():
|
|
src = _make_source("podman", "podman:ts", args=["turnstone"])
|
|
cmd = src._build_command()
|
|
assert cmd[0] == "podman"
|
|
assert "logs" in cmd
|
|
assert "-f" in cmd
|
|
assert "turnstone" in cmd
|
|
|
|
|
|
def test_build_command_docker_no_args_returns_none():
|
|
src = _make_source("docker", "docker:nginx")
|
|
cmd = src._build_command()
|
|
assert cmd is None
|
|
|
|
|
|
def test_build_command_unknown_type_returns_none():
|
|
src = _make_source("kafka", "topic:logs")
|
|
cmd = src._build_command()
|
|
assert cmd is None
|
|
|
|
|
|
# ── Docker timestamp stripping ───────────────────────────────────────────────
|
|
|
|
def test_strip_docker_ts_removes_rfc3339_prefix():
|
|
line = "2024-01-15T12:34:56.789012345Z some log line"
|
|
assert _strip_docker_ts(line) == "some log line"
|
|
|
|
|
|
def test_strip_docker_ts_passes_plain_lines():
|
|
line = "plain log line without timestamp"
|
|
assert _strip_docker_ts(line) == line
|
|
|
|
|
|
def test_strip_docker_ts_handles_offset_timezone():
|
|
# Docker --timestamps always uses Z (UTC), but be safe
|
|
line = "2024-01-15T12:34:56Z message"
|
|
assert _strip_docker_ts(line) == "message"
|
|
|
|
|
|
# ── Watcher orchestrator ─────────────────────────────────────────────────────
|
|
|
|
def test_watcher_status_empty_when_no_sources(tmp_path: Path):
|
|
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
|
|
assert w.status == []
|
|
assert not w.is_active()
|
|
|
|
|
|
def test_watcher_configure_creates_sources(tmp_path: Path):
|
|
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
|
|
configs = [
|
|
WatchConfig("journald", "sys"),
|
|
WatchConfig("podman", "ts", ["turnstone"]),
|
|
]
|
|
w.configure(configs)
|
|
assert len(w.status) == 2
|
|
assert w.status[0]["source_id"] == "sys"
|
|
assert w.status[1]["source_id"] == "ts"
|
|
|
|
|
|
def test_watcher_status_shows_not_running_before_start(tmp_path: Path):
|
|
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
|
|
w.configure([WatchConfig("journald", "sys")])
|
|
assert not w.is_active()
|
|
assert w.status[0]["running"] is False
|