Files
NODEDC_MISSION_CORE/tests/test_telemetry_startup.py
T

147 lines
5.4 KiB
Python

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"