- type: file uses tail -F (handles rotation) with auto-format detection - _parse_lines dispatches to journald/servarr/qbit/caddy/syslog/plaintext based on first-line format detection — same logic as batch ingest - watch.yaml updated with file type docs and example-node-specific example - scripts/journal-bridge.sh + .service written directly to example-node Contributor2's watch.yaml covers: system-journal-live (via bridge file), sonarr, radarr, lidarr, prowlarr, bazarr, qbittorrent, nzbget, tautulli
174 lines
5.4 KiB
Python
174 lines
5.4 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
|
|
|
|
|
|
def test_build_command_file_with_path():
|
|
src = _make_source("file", "sonarr", args=["/opt/sonarr/config/logs/sonarr.0.txt"])
|
|
cmd = src._build_command()
|
|
assert cmd[0] == "tail"
|
|
assert "-F" in cmd
|
|
assert "-n" in cmd and "0" in cmd
|
|
assert "/opt/sonarr/config/logs/sonarr.0.txt" in cmd
|
|
|
|
|
|
def test_build_command_file_no_args_returns_none():
|
|
src = _make_source("file", "sonarr")
|
|
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
|