from __future__ import annotations import importlib.util import json import plistlib import subprocess import sys from pathlib import Path import pytest SCRIPTS = Path(__file__).parents[1] / "scripts" sys.path.insert(0, str(SCRIPTS)) try: spec = importlib.util.spec_from_file_location( "telemetry_startup", SCRIPTS / "manage_telemetry_startup.py" ) manager = importlib.util.module_from_spec(spec) spec.loader.exec_module(manager) finally: sys.path.pop(0) def setup(tmp_path, monkeypatch): stack = tmp_path / "state" repo = tmp_path / "release" agents = tmp_path / "agents" for p in (stack, repo, agents): p.mkdir() for name in ( "compose.yaml", "runtime/agents.json", "runtime/mosquitto/acl", "runtime/mosquitto/passwords", ): p = stack / name p.parent.mkdir(parents=True, exist_ok=True) p.write_text("fixture") (stack / ".env").write_text("MISSIONCORE_MQTT_BIND_ADDRESS=192.168.1.5\nSECRET=preserve-me\n") (repo / ".venv/bin").mkdir(parents=True) (repo / ".venv/bin/python").write_text("fixture") docker = tmp_path / "docker" docker.write_text("fixture") monkeypatch.setattr(manager, "DOCKER", str(docker)) (agents / (manager.CORE + ".plist")).write_bytes( plistlib.dumps( { "Label": manager.CORE, "WorkingDirectory": str(repo), "EnvironmentVariables": {"MISSIONCORE_DATA_DIR": "/existing-data"}, } ) ) monkeypatch.setattr( manager, "command", lambda *args: subprocess.CompletedProcess( [], 0, b"hostname worker.example.ts.net\nstricthostkeychecking true\n", b"" ), ) return stack, repo, agents def test_plan_preserves_credentials_and_pins_loopback_transport(tmp_path, monkeypatch): stack, repo, agents = setup(tmp_path, monkeypatch) before = (stack / ".env").read_bytes() doc, files = manager.plan(stack, "worker-test", repo, agents) assert (stack / ".env").read_bytes() == before assert files[stack / ".env"] == b"MISSIONCORE_MQTT_BIND_ADDRESS=127.0.0.1\nSECRET=preserve-me\n" assert "preserve-me" not in json.dumps(doc) startup = plistlib.loads(files[agents / (manager.STARTUP + ".plist")]) assert startup["RunAtLoad"] and startup["StartInterval"] == 30 tunnel = plistlib.loads(files[agents / (manager.TUNNEL + ".plist")]) assert "127.0.0.1:1883:127.0.0.1:1883" in tunnel["ProgramArguments"] assert "StrictHostKeyChecking=yes" in tunnel["ProgramArguments"] assert tunnel["KeepAlive"] and tunnel["ThrottleInterval"] >= 5 core = plistlib.loads(files[agents / (manager.CORE + ".plist")]) assert core["EnvironmentVariables"]["MISSIONCORE_DATA_DIR"] == "/existing-data" assert core["EnvironmentVariables"]["MISSIONCORE_TELEMETRY_PLANE_ROOT"] == str(stack) def test_plan_refuses_non_tailscale_and_changed_input(tmp_path, monkeypatch): stack, repo, agents = setup(tmp_path, monkeypatch) before, _ = manager.plan(stack, "worker-test", repo, agents) with (stack / ".env").open("a") as f: f.write("NEW=setting\n") after, _ = manager.plan(stack, "worker-test", repo, agents) assert before["sha256"] != after["sha256"] monkeypatch.setattr( manager, "command", lambda *args: subprocess.CompletedProcess( [], 0, b"hostname worker.local\nstricthostkeychecking true\n", b"" ), ) with pytest.raises(ValueError, match="Tailscale"): manager.plan(stack, "worker-test", repo, agents) def test_delayed_docker_retries_without_compose_or_workload_launch(monkeypatch, tmp_path): calls = [] monkeypatch.setattr(manager, "receiver_ready", lambda: False) def run(args, timeout): calls.append(args) return subprocess.CompletedProcess(args, 1 if "info" in args else 0) monkeypatch.setattr(manager, "command", run) assert manager.reconcile(tmp_path) == "waiting-for-docker" assert len(calls) == 2 and calls[1][0] == "/usr/bin/open" assert not any("compose" in c for c in calls) def test_ready_receiver_is_not_restarted(monkeypatch, tmp_path): monkeypatch.setattr(manager, "receiver_ready", lambda: True) monkeypatch.setattr(manager, "broker_loopback", lambda: True) monkeypatch.setattr( manager, "command", lambda *args: pytest.fail("Must not restart healthy services") ) assert manager.reconcile(tmp_path) == "ready" def test_old_lan_binding_is_reconciled_even_when_receiver_healthy(monkeypatch, tmp_path): calls = [] bindings = iter((False, True)) monkeypatch.setattr(manager, "receiver_ready", lambda: True) monkeypatch.setattr(manager, "broker_loopback", lambda: next(bindings)) def run(args, timeout): calls.append(args) return subprocess.CompletedProcess(args, 0) monkeypatch.setattr(manager, "command", run) assert manager.reconcile(tmp_path) == "ready" assert calls[-1][-3:] == ["broker", "timescale", "normalizer"] assert "--no-build" in calls[-1] and "--pull" in calls[-1] and "never" in calls[-1] def test_failed_compose_reports_waiting_without_ready(monkeypatch, tmp_path): monkeypatch.setattr(manager, "receiver_ready", lambda: False) monkeypatch.setattr( manager, "command", lambda args, timeout: subprocess.CompletedProcess(args, int("up" in args)), ) assert manager.reconcile(tmp_path) == "waiting-for-receiver"