Add the `watchdog/` package (thin Python-3.12 stdlib-only daemon) and the `orchestrator-watchdog` compose service — the brain half of the domain-0 observability pair. F1a (ORCH-099) exposes GET /metrics raw signal; F1b reads it, augments with host / container / dependency probes, runs each signal through a generalised pure decision function (decide(signal_active, prev, now, cooldown), a strict superset of disk_watchdog.decide_action) with per-signal in-memory dedup/throttle/recovery, and alerts over its OWN independent Telegram channel. Key properties (ADR-001): - Observer separated from observed: separate container; /metrics not answering is itself the master `orch_down` alarm (debounced K ticks — no flap on a hiccup). - Strictly read-only: docker.sock GET-only + mounted :ro (double guard), host paths :ro, no DB/disk writes, no process control — self-hosting-safe. - never-raise on three levels (per-source/per-tick/per-send) + WATCHDOG_ENABLED kill-switch (disabled -> inert idle-loop, not exit). - Disk anti-duplicate (D6): disk_watchdog (ORCH-063) stays sole owner of the 85% alert; sidecar carries orch_down + an opt-in 97% ceiling (default off). - NO import from src/** (C-1); src/**, STAGE_TRANSITIONS, QG_CHECKS, check_*, DB schema — untouched. env_file optional so a missing .env.watchdog never breaks `docker compose up` for the prod orchestrator. Tests: tests/watchdog/ (TC-01…TC-13) + full tests/ regression green (TC-14). Docs: CHANGELOG, .env.example canon (WATCHDOG_*); architecture README + adr-0033 authored at the architecture stage. Refs: ORCH-100 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
2.6 KiB
Python
85 lines
2.6 KiB
Python
"""TC-10: independent Telegram transport.
|
|
|
|
The sidecar sends through its OWN bot_token/chat_id from env and must NOT import
|
|
``src.notifications`` or the orchestrator's code (C-1 / BR-8).
|
|
"""
|
|
import pathlib
|
|
|
|
from watchdog import notify as notify_mod
|
|
from watchdog.notify import Notifier, send_telegram
|
|
|
|
|
|
def test_notify_uses_own_token_and_chat(monkeypatch):
|
|
captured = {}
|
|
|
|
def _fake_opener(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
captured["data"] = req.data
|
|
|
|
class _R:
|
|
status = 200
|
|
|
|
def getcode(self):
|
|
return 200
|
|
|
|
def __enter__(self_inner):
|
|
return self_inner
|
|
|
|
def __exit__(self_inner, *a):
|
|
return False
|
|
|
|
return _R()
|
|
|
|
ok = send_telegram(
|
|
"MYTOKEN", "MYCHAT", "hello", opener=_fake_opener, api_base="https://tg.test"
|
|
)
|
|
assert ok is True
|
|
assert "botMYTOKEN" in captured["url"]
|
|
assert b"MYCHAT" in captured["data"]
|
|
|
|
|
|
def test_missing_credentials_is_failsafe_no_send():
|
|
# Absent token/chat -> logs and returns False, never raises (fail-safe).
|
|
assert send_telegram("", "chat", "x") is False
|
|
assert send_telegram("tok", "", "x") is False
|
|
|
|
|
|
def test_send_failure_is_swallowed():
|
|
def _boom(req, timeout=None):
|
|
raise OSError("network down")
|
|
|
|
assert send_telegram("t", "c", "x", opener=_boom) is False
|
|
|
|
|
|
def test_notifier_wraps_credentials(monkeypatch):
|
|
sent = {}
|
|
monkeypatch.setattr(
|
|
notify_mod, "send_telegram",
|
|
lambda tok, chat, text, timeout: sent.update(tok=tok, chat=chat, text=text) or True,
|
|
)
|
|
Notifier("TOK", "CHAT").send("body")
|
|
assert sent == {"tok": "TOK", "chat": "CHAT", "text": "body"}
|
|
|
|
|
|
def test_watchdog_package_does_not_import_src():
|
|
# No watchdog/*.py file may reference the orchestrator's src package (C-1).
|
|
# (Source scan, not sys.modules: the global test conftest imports src.* for
|
|
# every test, so a runtime check would be polluted.)
|
|
pkg_root = pathlib.Path(notify_mod.__file__).resolve().parent
|
|
offenders = []
|
|
for py in pkg_root.rglob("*.py"):
|
|
text = py.read_text(encoding="utf-8")
|
|
for needle in ("import src", "from src", "src.notifications"):
|
|
if needle in text:
|
|
offenders.append(f"{py.name}: {needle}")
|
|
assert offenders == [], f"watchdog references the orchestrator src: {offenders}"
|
|
|
|
|
|
def test_notify_source_has_no_src_notifications_import():
|
|
import inspect
|
|
|
|
src = inspect.getsource(notify_mod)
|
|
assert "src.notifications" not in src
|
|
assert "from src" not in src
|
|
assert "import src" not in src
|