147 lines
5.4 KiB
Python
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"
|