merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

View File

@@ -0,0 +1,138 @@
"""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, expected_sha256=None: {"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, expected_sha256=None: {"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"]

View File

@@ -0,0 +1,637 @@
"""Updater executor: directory rotation, probe-driven rollback, safety checks.
All three real seams (`_run_pip`, `_spawn_agent`, `_stop_agent`,
`_probe_agent`) are monkeypatched so these tests never shell out or
touch a real Python venv. The rotation/symlink/extract logic is exercised
against a ``tmp_path`` install dir.
"""
from __future__ import annotations
import io
import pathlib
import subprocess
import tarfile
from typing import Any
import pytest
from decnet.updater import executor as ex
# ------------------------------------------------------------------ helpers
def _make_tarball(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name, content in files.items():
data = content.encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
class _PipOK:
returncode = 0
stdout = ""
stderr = ""
class _PipFail:
returncode = 1
stdout = ""
stderr = "resolver error: Could not find a version that satisfies ..."
@pytest.fixture
def install_dir(tmp_path: pathlib.Path) -> pathlib.Path:
d = tmp_path / "decnet"
d.mkdir()
(d / "releases").mkdir()
return d
@pytest.fixture
def agent_dir(tmp_path: pathlib.Path) -> pathlib.Path:
d = tmp_path / "agent"
d.mkdir()
# executor._probe_agent checks these exist before constructing SSL ctx,
# but the probe seam is monkeypatched in every test so content doesn't
# matter — still create them so the non-stubbed path is representative.
(d / "ca.crt").write_bytes(b"-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n")
(d / "worker.crt").write_bytes(b"-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n")
(d / "worker.key").write_bytes(b"-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----\n")
return d
@pytest.fixture
def seed_existing_release(install_dir: pathlib.Path) -> None:
"""Pretend an install is already live: create releases/active with a marker."""
active = install_dir / "releases" / "active"
active.mkdir()
(active / "marker.txt").write_text("old")
ex._write_manifest(active, sha="OLDSHA")
# current -> active
ex._point_current_at(install_dir, active)
# --------------------------------------------------------- extract + safety
def test_extract_rejects_path_traversal(tmp_path: pathlib.Path) -> None:
evil = _make_tarball({"../escape.txt": "pwned"})
with pytest.raises(ex.UpdateError, match="unsafe path"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_rejects_absolute_paths(tmp_path: pathlib.Path) -> None:
evil = _make_tarball({"/etc/passwd": "root:x:0:0"})
with pytest.raises(ex.UpdateError, match="unsafe path"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_happy_path(tmp_path: pathlib.Path) -> None:
tb = _make_tarball({"a/b.txt": "hello"})
out = tmp_path / "out"
ex.extract_tarball(tb, out)
assert (out / "a" / "b.txt").read_text() == "hello"
def _tarball_with_link(linkname: str, target: str, *, hard: bool = False) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name=linkname)
info.type = tarfile.LNKTYPE if hard else tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
return buf.getvalue()
def test_extract_rejects_symlinks(tmp_path: pathlib.Path) -> None:
evil = _tarball_with_link("link.txt", "/etc/passwd")
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_rejects_hardlinks(tmp_path: pathlib.Path) -> None:
evil = _tarball_with_link("link.txt", "real.txt", hard=True)
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(evil, tmp_path / "out")
def test_extract_rejects_device_nodes(tmp_path: pathlib.Path) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="dev_null")
info.type = tarfile.CHRTYPE
info.devmajor = 1
info.devminor = 3
tar.addfile(info)
with pytest.raises(ex.UpdateError, match="only regular files"):
ex.extract_tarball(buf.getvalue(), tmp_path / "out")
def test_extract_rejects_oversized_tarball(
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
# Lower the cap rather than building a 256 MiB tarball in memory.
monkeypatch.setattr(ex, "MAX_TARBALL_UNCOMPRESSED_BYTES", 32)
big = _make_tarball({"big.txt": "x" * 64})
with pytest.raises(ex.UpdateError, match="exceeds size cap"):
ex.extract_tarball(big, tmp_path / "out")
def test_extract_strips_setuid_bit(tmp_path: pathlib.Path) -> None:
buf = io.BytesIO()
payload = b"hello"
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="suid.bin")
info.size = len(payload)
info.mode = 0o4755 # setuid + rwxr-xr-x
tar.addfile(info, io.BytesIO(payload))
out = tmp_path / "out"
ex.extract_tarball(buf.getvalue(), out)
mode = (out / "suid.bin").stat().st_mode & 0o7777
assert mode & 0o4000 == 0, f"setuid bit should be stripped, got {oct(mode)}"
# ----------------------------------------------------------- sha256 verify
def test_run_update_rejects_sha256_mismatch(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
) -> None:
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
bad = "0" * 64
with pytest.raises(ex.UpdateError, match="sha256 mismatch"):
ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256=bad,
)
# Mismatch must abort before staging is left around.
assert not (install_dir / "releases" / "active.new").exists()
def test_run_update_accepts_correct_sha256(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
) -> None:
import hashlib as _hl
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
digest = _hl.sha256(tb).hexdigest()
result = ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256=digest,
)
assert result["status"] == "updated"
def test_run_update_rejects_malformed_sha256(
install_dir: pathlib.Path, agent_dir: pathlib.Path,
) -> None:
tb = _make_tarball({"x.txt": "y"})
with pytest.raises(ex.UpdateError, match="not a 64-char hex"):
ex.run_update(
tb, sha="S", install_dir=install_dir, agent_dir=agent_dir,
expected_sha256="not-a-hex-digest",
)
def test_clean_stale_staging(install_dir: pathlib.Path) -> None:
staging = install_dir / "releases" / "active.new"
staging.mkdir()
(staging / "junk").write_text("left from a crash")
ex.clean_stale_staging(install_dir)
assert not staging.exists()
# ---------------------------------------------------------------- happy path
def test_update_rotates_and_probes(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 42)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
result = ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
assert result["status"] == "updated"
assert result["release"]["sha"] == "NEWSHA"
assert (install_dir / "releases" / "active" / "marker.txt").read_text() == "new"
# Old release demoted, not deleted.
assert (install_dir / "releases" / "prev" / "marker.txt").read_text() == "old"
# Current symlink points at the new active.
assert (install_dir / "current").resolve() == (install_dir / "releases" / "active").resolve()
def test_update_first_install_without_previous(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
) -> None:
"""No existing active/ dir — first real install via the updater."""
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "first"})
result = ex.run_update(tb, sha="S1", install_dir=install_dir, agent_dir=agent_dir)
assert result["status"] == "updated"
assert not (install_dir / "releases" / "prev").exists()
# ------------------------------------------------------------ pip failure
def test_update_pip_failure_aborts_before_rotation(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipFail())
stop_called: list[bool] = []
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: stop_called.append(True))
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
tb = _make_tarball({"marker.txt": "new"})
with pytest.raises(ex.UpdateError, match="pip install failed") as ei:
ex.run_update(tb, sha="S", install_dir=install_dir, agent_dir=agent_dir)
assert "resolver error" in ei.value.stderr
# Nothing rotated — old active still live, no prev created.
assert (install_dir / "releases" / "active" / "marker.txt").read_text() == "old"
assert not (install_dir / "releases" / "prev").exists()
# Agent never touched.
assert stop_called == []
# Staging cleaned up.
assert not (install_dir / "releases" / "active.new").exists()
# ------------------------------------------------------------ probe failure
def test_update_probe_failure_rolls_back(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
calls: list[int] = [0]
def _probe(**_: Any) -> tuple[bool, str]:
calls[0] += 1
if calls[0] == 1:
return False, "connection refused"
return True, "ok" # rollback probe succeeds
monkeypatch.setattr(ex, "_probe_agent", _probe)
tb = _make_tarball({"marker.txt": "new"})
with pytest.raises(ex.UpdateError, match="health probe") as ei:
ex.run_update(tb, sha="NEWSHA", install_dir=install_dir, agent_dir=agent_dir)
assert ei.value.rolled_back is True
assert "connection refused" in ei.value.stderr
# Rolled back: active has the old marker again.
assert (install_dir / "releases" / "active" / "marker.txt").read_text() == "old"
# Prev now holds what would have been the new release.
assert (install_dir / "releases" / "prev" / "marker.txt").read_text() == "new"
# Current symlink points back at active.
assert (install_dir / "current").resolve() == (install_dir / "releases" / "active").resolve()
# ------------------------------------------------------------ manual rollback
def test_manual_rollback_swaps(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
agent_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
# Seed a prev/ so rollback has somewhere to go.
prev = install_dir / "releases" / "prev"
prev.mkdir()
(prev / "marker.txt").write_text("older")
ex._write_manifest(prev, sha="OLDERSHA")
monkeypatch.setattr(ex, "_stop_agent", lambda *a, **k: None)
monkeypatch.setattr(ex, "_spawn_agent", lambda *a, **k: 1)
monkeypatch.setattr(ex, "_probe_agent", lambda **_: (True, "ok"))
result = ex.run_rollback(install_dir=install_dir, agent_dir=agent_dir)
assert result["status"] == "rolled_back"
assert (install_dir / "releases" / "active" / "marker.txt").read_text() == "older"
assert (install_dir / "releases" / "prev" / "marker.txt").read_text() == "old"
def test_manual_rollback_refuses_without_prev(
install_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
with pytest.raises(ex.UpdateError, match="no previous release"):
ex.run_rollback(install_dir=install_dir)
# ---------------------------------------------------------------- releases
def test_list_releases_includes_only_existing_slots(
install_dir: pathlib.Path,
seed_existing_release: None,
) -> None:
rs = ex.list_releases(install_dir)
assert [r.slot for r in rs] == ["active"]
assert rs[0].sha == "OLDSHA"
# ---------------------------------------------------------------- self-update
def test_update_self_rotates_and_calls_exec_cb(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
# Seed a stand-in "active" for the updater itself.
active = install_dir / "releases" / "active"
active.mkdir()
(active / "marker").write_text("old-updater")
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
seen_argv: list[list[str]] = []
tb = _make_tarball({"marker": "new-updater"})
result = ex.run_update_self(
tb, sha="USHA", updater_install_dir=install_dir,
exec_cb=lambda argv: seen_argv.append(argv),
)
assert result["status"] == "self_update_queued"
assert (install_dir / "releases" / "active" / "marker").read_text() == "new-updater"
assert (install_dir / "releases" / "prev" / "marker").read_text() == "old-updater"
assert len(seen_argv) == 1
assert "updater" in seen_argv[0]
def test_update_self_under_systemd_defers_to_systemctl(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
"""Under systemd, update-self must NOT os.execv — it hands the restart
to systemd so the new process inherits the unit context. A detached
``systemctl restart decnet-updater.service`` is scheduled after a short
sleep so the HTTP response can flush before the unit cycles."""
active = install_dir / "releases" / "active"
active.mkdir()
(active / "marker").write_text("old-updater")
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipOK())
monkeypatch.setattr(ex, "_systemd_available", lambda: True)
popen_calls: list[list[str]] = []
class _FakePopen:
def __init__(self, cmd, **kwargs):
popen_calls.append(cmd)
monkeypatch.setattr(ex.subprocess, "Popen", _FakePopen)
monkeypatch.setattr(ex.os, "execv", lambda *a, **k: pytest.fail("execv taken under systemd"))
tb = _make_tarball({"marker": "new-updater"})
result = ex.run_update_self(tb, sha="USHA", updater_install_dir=install_dir)
assert result == {"status": "self_update_queued", "via": "systemd"}
assert len(popen_calls) == 1
sh_cmd = popen_calls[0]
assert sh_cmd[:2] == ["sh", "-c"]
assert "systemctl restart decnet-updater.service" in sh_cmd[2]
def test_update_self_pip_failure_leaves_active_intact(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
active = install_dir / "releases" / "active"
active.mkdir()
(active / "marker").write_text("old-updater")
monkeypatch.setattr(ex, "_run_pip", lambda release: _PipFail())
tb = _make_tarball({"marker": "new-updater"})
with pytest.raises(ex.UpdateError, match="pip install failed"):
ex.run_update_self(tb, sha="U", updater_install_dir=install_dir, exec_cb=lambda a: None)
assert (install_dir / "releases" / "active" / "marker").read_text() == "old-updater"
assert not (install_dir / "releases" / "active.new").exists()
def test_stop_agent_falls_back_to_proc_scan_when_no_pidfile(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
"""No agent.pid → _stop_agent still terminates agents found via /proc."""
killed: list[tuple[int, int]] = []
def fake_kill(pid: int, sig: int) -> None:
killed.append((pid, sig))
raise ProcessLookupError # pretend it already died after SIGTERM
monkeypatch.setattr(ex, "_systemd_available", lambda: False)
monkeypatch.setattr(ex, "_discover_agent_pids", lambda: [4242, 4243])
monkeypatch.setattr(ex.os, "kill", fake_kill)
assert not (install_dir / "agent.pid").exists()
ex._stop_agent(install_dir, grace=0.0)
import signal as _signal
assert (4242, _signal.SIGTERM) in killed
assert (4243, _signal.SIGTERM) in killed
def test_systemd_available_requires_invocation_id_and_systemctl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Both INVOCATION_ID and a resolvable systemctl are needed."""
monkeypatch.delenv("INVOCATION_ID", raising=False)
assert ex._systemd_available() is False
monkeypatch.setenv("INVOCATION_ID", "abc")
monkeypatch.setattr("shutil.which", lambda _: None)
assert ex._systemd_available() is False
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/systemctl")
assert ex._systemd_available() is True
def test_spawn_agent_dispatches_to_systemd_when_available(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
monkeypatch.setattr(ex, "_systemd_available", lambda: True)
called: list[pathlib.Path] = []
monkeypatch.setattr(ex, "_spawn_agent_via_systemd", lambda d: called.append(d) or 999)
monkeypatch.setattr(ex, "_spawn_agent_via_popen", lambda d: pytest.fail("popen path taken"))
assert ex._spawn_agent(install_dir) == 999
assert called == [install_dir]
def test_spawn_agent_dispatches_to_popen_when_not_systemd(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
monkeypatch.setattr(ex, "_systemd_available", lambda: False)
monkeypatch.setattr(ex, "_spawn_agent_via_systemd", lambda d: pytest.fail("systemd path taken"))
monkeypatch.setattr(ex, "_spawn_agent_via_popen", lambda d: 777)
assert ex._spawn_agent(install_dir) == 777
def test_stop_agent_is_noop_under_systemd(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
"""Under systemd, stop is skipped — systemctl restart handles it atomically."""
monkeypatch.setattr(ex, "_systemd_available", lambda: True)
monkeypatch.setattr(ex, "_discover_agent_pids", lambda: pytest.fail("scanned /proc"))
monkeypatch.setattr(ex.os, "kill", lambda *a, **k: pytest.fail("sent signal"))
(install_dir / "agent.pid").write_text("12345")
ex._stop_agent(install_dir, grace=0.0) # must not raise
def test_spawn_agent_via_systemd_records_main_pid(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
calls: list[list[str]] = []
class _Out:
def __init__(self, stdout: str = "", returncode: int = 0, stderr: str = "") -> None:
self.stdout = stdout
self.returncode = returncode
self.stderr = stderr
def fake_run(cmd, **kwargs): # type: ignore[no-untyped-def]
calls.append(cmd)
if "show" in cmd:
return _Out("4711\n")
return _Out("")
monkeypatch.setattr(ex.subprocess, "run", fake_run)
pid = ex._spawn_agent_via_systemd(install_dir)
assert pid == 4711
assert (install_dir / "agent.pid").read_text() == "4711"
# Agent restart, forwarder restart, each aux microservice, then the
# MainPID lookup on the agent.
assert calls[0] == ["systemctl", "restart", ex.AGENT_SYSTEMD_UNIT]
assert calls[1] == ["systemctl", "restart", ex.FORWARDER_SYSTEMD_UNIT]
aux_calls = calls[2 : 2 + len(ex.AUXILIARY_SYSTEMD_UNITS)]
assert aux_calls == [
["systemctl", "restart", unit] for unit in ex.AUXILIARY_SYSTEMD_UNITS
]
show_call = calls[2 + len(ex.AUXILIARY_SYSTEMD_UNITS)]
assert show_call[:2] == ["systemctl", "show"]
assert ex.AGENT_SYSTEMD_UNIT in show_call
def test_spawn_agent_via_systemd_tolerates_missing_forwarder_unit(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
) -> None:
"""Legacy enrollments lack decnet-forwarder.service — restart fails and
must not abort the update."""
class _Out:
def __init__(self, stdout: str = "", returncode: int = 0, stderr: str = "") -> None:
self.stdout = stdout
self.returncode = returncode
self.stderr = stderr
def fake_run(cmd, **kwargs): # type: ignore[no-untyped-def]
if "restart" in cmd and ex.FORWARDER_SYSTEMD_UNIT in cmd:
return _Out(returncode=5, stderr="Unit not found.")
if "show" in cmd:
return _Out("4711\n")
return _Out("")
monkeypatch.setattr(ex.subprocess, "run", fake_run)
pid = ex._spawn_agent_via_systemd(install_dir)
assert pid == 4711
# ---------------------------------------------------------- _sync_systemd_units
def _make_release_with_units(install_dir: pathlib.Path, units: dict[str, str]) -> None:
src = install_dir / "releases" / "active" / "etc" / "systemd" / "system"
src.mkdir(parents=True)
for name, body in units.items():
(src / name).write_text(body)
def test_sync_systemd_units_copies_new_files_and_reloads(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
tmp_path: pathlib.Path,
) -> None:
"""Shipping a new unit or changing an existing one triggers a single
daemon-reload after the file writes."""
_make_release_with_units(install_dir, {
"decnet-collector.service": "unit-body-v1\n",
"decnet-agent.service": "unit-body-agent\n",
})
dst_root = tmp_path / "etc-systemd"
dst_root.mkdir()
(dst_root / "decnet-agent.service").write_text("unit-body-agent-OLD\n")
calls: list[list[str]] = []
def fake_run(cmd, **kwargs): # type: ignore[no-untyped-def]
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setenv("INVOCATION_ID", "x")
monkeypatch.setattr(ex.subprocess, "run", fake_run)
changed = ex._sync_systemd_units(install_dir, dst_root=dst_root)
assert changed is True
assert (dst_root / "decnet-collector.service").read_text() == "unit-body-v1\n"
assert (dst_root / "decnet-agent.service").read_text() == "unit-body-agent\n"
assert calls == [["systemctl", "daemon-reload"]]
def test_sync_systemd_units_noop_when_unchanged(
monkeypatch: pytest.MonkeyPatch,
install_dir: pathlib.Path,
tmp_path: pathlib.Path,
) -> None:
_make_release_with_units(install_dir, {"decnet-agent.service": "same\n"})
dst_root = tmp_path / "etc-systemd"
dst_root.mkdir()
(dst_root / "decnet-agent.service").write_text("same\n")
calls: list[list[str]] = []
monkeypatch.setenv("INVOCATION_ID", "x")
monkeypatch.setattr(
ex.subprocess, "run",
lambda cmd, **_: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0, "", ""),
)
changed = ex._sync_systemd_units(install_dir, dst_root=dst_root)
assert changed is False
assert calls == [] # no daemon-reload when nothing changed
def test_sync_systemd_units_missing_src_is_noop(
install_dir: pathlib.Path,
tmp_path: pathlib.Path,
) -> None:
"""Legacy bundles without etc/systemd/system in the release: no-op."""
(install_dir / "releases" / "active").mkdir(parents=True)
assert ex._sync_systemd_units(install_dir, dst_root=tmp_path) is False