From d2cf1e8b3af5a3c98f883b41a07c26fe99489ef1 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 19:07:24 -0400 Subject: [PATCH] feat(updater): sync systemd unit files and daemon-reload on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap installer copies etc/systemd/system/*.service into /etc/systemd/system at enrollment time, but the updater was skipping that step — a code push could not ship a new unit (e.g. the four per-host microservices added this session) or change ExecStart on an existing one. systemctl alone doesn't re-read unit files; daemon-reload is required. run_update / run_update_self now call _sync_systemd_units after rotation: diff each .service file against the live copy, atomically replace changed ones, then issue a single `systemctl daemon-reload`. No-op on legacy tarballs that don't ship etc/systemd/system/. --- decnet/updater/executor.py | 50 ++++++++++++++++++ tests/updater/test_updater_executor.py | 71 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/decnet/updater/executor.py b/decnet/updater/executor.py index 1ac7ea0..3bcacc4 100644 --- a/decnet/updater/executor.py +++ b/decnet/updater/executor.py @@ -278,6 +278,54 @@ def _spawn_agent(install_dir: pathlib.Path) -> int: return _spawn_agent_via_popen(install_dir) +SYSTEMD_UNIT_DIR = pathlib.Path("/etc/systemd/system") + + +def _sync_systemd_units( + install_dir: pathlib.Path, + dst_root: pathlib.Path = SYSTEMD_UNIT_DIR, +) -> bool: + """Copy any `etc/systemd/system/*.service` files from the active release + into ``dst_root`` (default ``/etc/systemd/system/``) and run + `daemon-reload` if anything changed. + + Returns True if daemon-reload was invoked. The bootstrap installer writes + these files on first enrollment; the updater mirrors that on every code + push so unit edits (ExecStart flips, new units, cap changes) ship too. + Best-effort: a read-only /etc or a missing ``active/etc`` subtree is just + logged and skipped. + """ + src_root = _active_dir(install_dir) / "etc" / "systemd" / "system" + if not src_root.is_dir(): + return False + changed = False + for src in sorted(src_root.glob("*.service")): + dst = dst_root / src.name + try: + new = src.read_bytes() + old = dst.read_bytes() if dst.is_file() else None + if old == new: + continue + tmp = dst.with_suffix(".service.tmp") + tmp.write_bytes(new) + os.chmod(tmp, 0o644) + os.replace(tmp, dst) + log.info("installed/updated systemd unit %s", dst) + changed = True + except OSError as exc: + log.warning("could not install unit %s: %s", dst, exc) + if changed and _systemd_available(): + try: + subprocess.run( # nosec B603 B607 + ["systemctl", "daemon-reload"], + check=True, capture_output=True, text=True, + ) + log.info("systemctl daemon-reload succeeded") + except subprocess.CalledProcessError as exc: + log.warning("systemctl daemon-reload failed: %s", exc.stderr.strip()) + return changed + + def _spawn_agent_via_systemd(install_dir: pathlib.Path) -> int: # Restart agent + forwarder together: both processes run out of the same # /opt/decnet tree, so a code push that replaces the tree must cycle both @@ -509,6 +557,7 @@ def run_update( _rotate(install_dir) _point_current_at(install_dir, _active_dir(install_dir)) _heal_path_symlink(install_dir) + _sync_systemd_units(install_dir) log.info("restarting agent (and forwarder if present)") _stop_agent(install_dir) @@ -606,6 +655,7 @@ def run_update_self( _rotate(updater_install_dir) _point_current_at(updater_install_dir, _active_dir(updater_install_dir)) _heal_path_symlink(updater_install_dir) + _sync_systemd_units(updater_install_dir) # Reconstruct the updater's original launch command from env vars set by # `decnet.updater.server.run`. We can't reuse sys.argv: inside the app diff --git a/tests/updater/test_updater_executor.py b/tests/updater/test_updater_executor.py index 9e001e3..7be0f85 100644 --- a/tests/updater/test_updater_executor.py +++ b/tests/updater/test_updater_executor.py @@ -454,3 +454,74 @@ def test_spawn_agent_via_systemd_tolerates_missing_forwarder_unit( 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