fix(telemetry): recover worker agent and Tailscale transport automatically
This commit is contained in:
@@ -144,3 +144,17 @@ def test_contour_router_returns_404_for_unknown_contour(tmp_path: Path) -> None:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
install("missing")
|
||||
assert error.value.status_code == 404
|
||||
|
||||
|
||||
def test_broker_apply_uses_explicit_installation_root(tmp_path, monkeypatch):
|
||||
from k1link.web import compute_contour_api as api
|
||||
root = tmp_path / 'prepared-stack'
|
||||
monkeypatch.setenv('MISSIONCORE_TELEMETRY_PLANE_ROOT', str(root))
|
||||
seen = []
|
||||
def apply(target, installation):
|
||||
seen.append(installation)
|
||||
return {'ready': True}
|
||||
monkeypatch.setattr(api, 'apply_broker_network', apply)
|
||||
router = api.build_compute_contour_router(root_provider=lambda: tmp_path / 'system')
|
||||
_endpoint(router, '/api/v1/system/contours/{contour_id}/network/broker', 'POST')('worker-006')
|
||||
assert seen == [root]
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user