Adds a separate `decnet updater` daemon on each worker that owns the agent's release directory and installs tarball pushes from the master over mTLS. A normal `/update` never touches the updater itself, so the updater is always a known-good rescuer if a bad agent push breaks /health — the rotation is reversed and the agent restarted against the previous release. `POST /update-self` handles updater upgrades explicitly (no auto-rollback). - decnet/updater/: executor, FastAPI app, uvicorn launcher - decnet/swarm/updater_client.py, tar_tree.py: master-side push - cli: `decnet updater`, `decnet swarm update [--host|--all] [--include-self] [--dry-run]`, `--updater` on `swarm enroll` - enrollment API issues a second cert (CN=updater@<host>) signed by the same CA; SwarmHost records updater_cert_fingerprint - tests: executor, app, CLI, tar tree, enroll-with-updater (37 new) - wiki: Remote-Updates page + sidebar + SWARM-Mode cross-link
139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
"""HTTP contract for the updater app.
|
|
|
|
Executor functions are monkeypatched — we're testing wire format, not
|
|
the rotation logic (that has test_updater_executor.py).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import pathlib
|
|
import tarfile
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from decnet.updater import app as app_mod
|
|
from decnet.updater import executor as ex
|
|
|
|
|
|
def _tarball(files: dict[str, str] | None = None) -> bytes:
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
for name, content in (files or {"a": "b"}).items():
|
|
data = content.encode()
|
|
info = tarfile.TarInfo(name=name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
return buf.getvalue()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: pathlib.Path) -> TestClient:
|
|
app_mod.configure(
|
|
install_dir=tmp_path / "install",
|
|
updater_install_dir=tmp_path / "install" / "updater",
|
|
agent_dir=tmp_path / "agent",
|
|
)
|
|
(tmp_path / "install" / "releases").mkdir(parents=True)
|
|
return TestClient(app_mod.app)
|
|
|
|
|
|
def test_health_returns_role_and_releases(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(ex, "list_releases", lambda d: [])
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["status"] == "ok"
|
|
assert body["role"] == "updater"
|
|
assert body["releases"] == []
|
|
|
|
|
|
def test_update_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
ex, "run_update",
|
|
lambda data, sha, install_dir, agent_dir: {"status": "updated", "release": {"slot": "active", "sha": sha}, "probe": "ok"},
|
|
)
|
|
r = client.post(
|
|
"/update",
|
|
files={"tarball": ("tree.tgz", _tarball(), "application/gzip")},
|
|
data={"sha": "ABC123"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["release"]["sha"] == "ABC123"
|
|
|
|
|
|
def test_update_rollback_returns_409(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _boom(*a, **kw):
|
|
raise ex.UpdateError("probe failed; rolled back", stderr="connection refused", rolled_back=True)
|
|
monkeypatch.setattr(ex, "run_update", _boom)
|
|
|
|
r = client.post(
|
|
"/update",
|
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
|
data={"sha": ""},
|
|
)
|
|
assert r.status_code == 409, r.text
|
|
detail = r.json()["detail"]
|
|
assert detail["rolled_back"] is True
|
|
assert "connection refused" in detail["stderr"]
|
|
|
|
|
|
def test_update_hard_failure_returns_500(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _boom(*a, **kw):
|
|
raise ex.UpdateError("pip install failed", stderr="resolver error")
|
|
monkeypatch.setattr(ex, "run_update", _boom)
|
|
|
|
r = client.post("/update", files={"tarball": ("t.tgz", _tarball(), "application/gzip")})
|
|
assert r.status_code == 500
|
|
assert r.json()["detail"]["rolled_back"] is False
|
|
|
|
|
|
def test_update_self_requires_confirm(client: TestClient) -> None:
|
|
r = client.post("/update-self", files={"tarball": ("t.tgz", _tarball(), "application/gzip")})
|
|
assert r.status_code == 400
|
|
assert "confirm_self" in r.json()["detail"]
|
|
|
|
|
|
def test_update_self_happy_path(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
ex, "run_update_self",
|
|
lambda data, sha, updater_install_dir: {"status": "self_update_queued", "argv": ["python", "-m", "decnet", "updater"]},
|
|
)
|
|
r = client.post(
|
|
"/update-self",
|
|
files={"tarball": ("t.tgz", _tarball(), "application/gzip")},
|
|
data={"sha": "S", "confirm_self": "true"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "self_update_queued"
|
|
|
|
|
|
def test_rollback_happy(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
ex, "run_rollback",
|
|
lambda install_dir, agent_dir: {"status": "rolled_back", "release": {"slot": "active", "sha": "O"}, "probe": "ok"},
|
|
)
|
|
r = client.post("/rollback")
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "rolled_back"
|
|
|
|
|
|
def test_rollback_missing_prev_returns_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _boom(**_):
|
|
raise ex.UpdateError("no previous release to roll back to")
|
|
monkeypatch.setattr(ex, "run_rollback", _boom)
|
|
r = client.post("/rollback")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_releases_lists_slots(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
ex, "list_releases",
|
|
lambda d: [ex.Release(slot="active", sha="A", installed_at=None),
|
|
ex.Release(slot="prev", sha="B", installed_at=None)],
|
|
)
|
|
r = client.get("/releases")
|
|
assert r.status_code == 200
|
|
slots = [rel["slot"] for rel in r.json()["releases"]]
|
|
assert slots == ["active", "prev"]
|