From 91111ea7ee31b2f48c2bb0d605f0c7fd9e0f75aa Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 14:31:56 -0400 Subject: [PATCH] feat(cli): add `decnet init --deinit` to undo a previous bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse of init, step-by-step: systemctl disable --now decnet.target, remove every decnet-*.service + decnet.target unit file, drop the polkit rule, drop the tmpfiles.d entry, daemon-reload, remove /etc/decnet + /etc/decnet/config.ini, /run/decnet, /opt/decnet, and userdel/groupdel the decnet identity. Preserves /var/lib/decnet and /var/log/decnet by default — those hold operator data. Pass `--deinit --purge` to rm -rf them too. Idempotent on a clean host (every step prints [SKIP]). Honours --dry-run. 5 new tests cover the full-undo path, --purge, idempotent clean-host deinit, dry-run side-effect-free behaviour, and the --purge without --deinit guard. --- decnet/cli/init.py | 208 +++++++++++++++++++++++++++++++++++++++-- tests/cli/test_init.py | 107 +++++++++++++++++++++ 2 files changed, 305 insertions(+), 10 deletions(-) diff --git a/decnet/cli/init.py b/decnet/cli/init.py index e97975b8..66fc7795 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -207,6 +207,84 @@ def _install_polkit( ) +def _run_allow_fail(argv: List[str], *, dry_run: bool) -> str: + """Like ``_run`` but tolerates non-zero exits (stop/disable on an + already-absent unit is fine during deinit).""" + if dry_run: + console.print(f" [dim]would run (allow fail):[/] {' '.join(argv)}") + return "ok" + log.info("init: exec (allow fail) %s", argv) + result = subprocess.run(argv, check=False) # nosec B603 + if result.returncode != 0: + return f"skip: rc={result.returncode} (already absent)" + return "ok" + + +def _remove_file(path: Path, *, dry_run: bool) -> str: + if not path.exists() and not path.is_symlink(): + return f"skip: {path} already absent" + if dry_run: + console.print(f" [dim]would remove:[/] {path}") + return "ok" + path.unlink() + return "ok" + + +def _uninstall_units(systemd_dir: Path, *, dry_run: bool) -> str: + removed = 0 + present = sorted(systemd_dir.glob("decnet-*.service")) + target = systemd_dir / "decnet.target" + if target.exists(): + present.append(target) + for path in present: + if dry_run: + console.print(f" [dim]would remove:[/] {path}") + removed += 1 + continue + path.unlink() + removed += 1 + if removed == 0: + return "skip: no decnet unit files present" + return f"ok ({removed} removed)" + + +def _remove_user(user: str, *, dry_run: bool) -> str: + try: + pwd.getpwnam(user) + except KeyError: + return f"skip: user {user} already absent" + # userdel returns non-zero if the user still owns running + # processes; that's the operator's problem to sort out, not ours. + return _run_allow_fail(["userdel", user], dry_run=dry_run) + + +def _remove_group(group: str, *, dry_run: bool) -> str: + try: + grp.getgrnam(group) + except KeyError: + return f"skip: group {group} already absent" + return _run_allow_fail(["groupdel", group], dry_run=dry_run) + + +def _remove_dir_if_present( + path: Path, *, dry_run: bool, recursive: bool = False +) -> str: + if not path.exists(): + return f"skip: {path} already absent" + if dry_run: + verb = "would rm -rf" if recursive else "would rmdir" + console.print(f" [dim]{verb}:[/] {path}") + return "ok" + if recursive: + shutil.rmtree(path, ignore_errors=True) + else: + try: + path.rmdir() + except OSError as exc: + return f"skip: {path} not empty ({exc.strerror})" + return "ok" + + def _install_tmpfiles( deploy: Path, tmpfiles_dir: Path, *, force: bool, dry_run: bool ) -> str: @@ -237,6 +315,18 @@ def register(app: typer.Typer) -> None: False, "--force", help="Overwrite unit / polkit / tmpfiles entries even if identical.", ), + deinit: bool = typer.Option( + False, "--deinit", + help="Undo a previous init: stop + disable decnet.target, remove " + "unit files, polkit rule, tmpfiles.d entry, /etc/decnet, and " + "the decnet user/group. Preserves /var/lib/decnet and " + "/var/log/decnet — pass --purge to remove those too.", + ), + purge: bool = typer.Option( + False, "--purge", + help="With --deinit, also wipe /var/lib/decnet and " + "/var/log/decnet. Destructive — operator data is gone.", + ), user: str = typer.Option( "decnet", "--user", help="System user to own DECNET processes.", @@ -258,27 +348,125 @@ def register(app: typer.Typer) -> None: """ _require_master_mode("init") - # Root check — skip when --prefix is set (tests don't run as root). - if not prefix and os.geteuid() != 0: - console.print("[red]decnet init: must run as root (use sudo)[/]") + if purge and not deinit: + console.print("[red]--purge only applies with --deinit[/]") raise typer.Exit(1) - for tool in ("systemctl", "useradd", "groupadd", "systemd-tmpfiles"): + # Root check — skip when --prefix is set (tests don't run as root). + if not prefix and os.geteuid() != 0: + verb = "deinit" if deinit else "init" + console.print(f"[red]decnet {verb}: must run as root (use sudo)[/]") + raise typer.Exit(1) + + required_tools = ("systemctl",) if deinit else ( + "systemctl", "useradd", "groupadd", "systemd-tmpfiles", + ) + if deinit: + required_tools = required_tools + ("userdel", "groupdel") + for tool in required_tools: if shutil.which(tool) is None and not dry_run: - console.print(f"[red]decnet init: {tool!r} is required on PATH[/]") + verb = "deinit" if deinit else "init" + console.print(f"[red]decnet {verb}: {tool!r} is required on PATH[/]") raise typer.Exit(1) + pfx = Path(prefix) if prefix else Path("/") + systemd_dir = pfx / "etc/systemd/system" + polkit_dir = pfx / "etc/polkit-1/rules.d" + tmpfiles_dir = pfx / "etc/tmpfiles.d" + etc_decnet = pfx / "etc/decnet" + + if deinit: + console.print( + f"[bold cyan]DECNET deinit[/] " + f"(dry_run={dry_run}, purge={purge})" + ) + _step( + "systemctl stop + disable decnet.target", + lambda: _run_allow_fail( + ["systemctl", "disable", "--now", "decnet.target"], + dry_run=dry_run, + ), + ) + _step( + "remove systemd unit files", + lambda: _uninstall_units(systemd_dir, dry_run=dry_run), + ) + _step( + "remove polkit rule", + lambda: _remove_file( + polkit_dir / "50-decnet-workers.rules", + dry_run=dry_run, + ), + ) + _step( + "remove tmpfiles.d entry", + lambda: _remove_file( + tmpfiles_dir / "decnet.conf", + dry_run=dry_run, + ), + ) + _step( + "systemctl daemon-reload", + lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], + ) + _step( + f"remove {etc_decnet / 'config.ini'}", + lambda: _remove_file(etc_decnet / "config.ini", dry_run=dry_run), + ) + _step( + f"remove {etc_decnet}", + lambda: _remove_dir_if_present(etc_decnet, dry_run=dry_run), + ) + _step( + f"remove {pfx / 'run/decnet'}", + lambda: _remove_dir_if_present( + pfx / "run/decnet", dry_run=dry_run, + ), + ) + _step( + f"remove {pfx / 'opt/decnet'}", + lambda: _remove_dir_if_present( + pfx / "opt/decnet", dry_run=dry_run, + ), + ) + if purge: + _step( + f"purge {pfx / 'var/lib/decnet'}", + lambda: _remove_dir_if_present( + pfx / "var/lib/decnet", + dry_run=dry_run, recursive=True, + ), + ) + _step( + f"purge {pfx / 'var/log/decnet'}", + lambda: _remove_dir_if_present( + pfx / "var/log/decnet", + dry_run=dry_run, recursive=True, + ), + ) + else: + console.print( + f"[dim]preserved {pfx / 'var/lib/decnet'} and " + f"{pfx / 'var/log/decnet'} (operator data); " + "re-run with --purge to remove.[/]" + ) + _step( + f"remove user {user!r}", + lambda: _remove_user(user, dry_run=dry_run), + ) + _step( + f"remove group {group!r}", + lambda: _remove_group(group, dry_run=dry_run), + ) + console.print("[bold green]DECNET deinit complete.[/]") + return + try: deploy = _deploy_root() except RuntimeError as exc: console.print(f"[red]decnet init: {exc}[/]") raise typer.Exit(1) from exc - pfx = Path(prefix) if prefix else Path("/") - systemd_dir = pfx / "etc/systemd/system" - polkit_dir = pfx / "etc/polkit-1/rules.d" - tmpfiles_dir = pfx / "etc/tmpfiles.d" - etc_decnet = pfx / "etc/decnet" dirs = [ (pfx / "opt/decnet", 0o755, user, group), (pfx / "var/lib/decnet", 0o750, user, group), diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index f28d885f..3f2fdb98 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -204,6 +204,113 @@ def test_default_invokes_target_start( assert ["systemctl", "daemon-reload"] in subprocess_calls +def _seed_installed_state(prefix: Path) -> None: + """Create the files a prior `decnet init` would have installed.""" + systemd = prefix / "etc/systemd/system" + systemd.mkdir(parents=True) + (systemd / "decnet-bus.service").write_text("# bus\n") + (systemd / "decnet-api.service").write_text("# api\n") + (systemd / "decnet.target").write_text("# target\n") + polkit = prefix / "etc/polkit-1/rules.d" + polkit.mkdir(parents=True) + (polkit / "50-decnet-workers.rules").write_text("// rule\n") + tmpfiles = prefix / "etc/tmpfiles.d" + tmpfiles.mkdir(parents=True) + (tmpfiles / "decnet.conf").write_text("d /run/decnet\n") + etc_decnet = prefix / "etc/decnet" + etc_decnet.mkdir(parents=True) + (etc_decnet / "config.ini").write_text("[decnet]\n") + (prefix / "opt/decnet").mkdir(parents=True) + (prefix / "run/decnet").mkdir(parents=True) + (prefix / "var/lib/decnet").mkdir(parents=True) + (prefix / "var/log/decnet").mkdir(parents=True) + (prefix / "var/log/decnet/events.jsonl").write_text("{}\n") + + +def test_deinit_removes_units_polkit_tmpfiles_and_preserves_data( + tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, present_user_and_group: None, +) -> None: + prefix = tmp_path / "root" + _seed_installed_state(prefix) + result = runner.invoke( + app, ["init", "--deinit", "--prefix", str(prefix)], + ) + assert result.exit_code == 0, result.output + + # Units + polkit + tmpfiles.d gone. + assert not (prefix / "etc/systemd/system/decnet-bus.service").exists() + assert not (prefix / "etc/systemd/system/decnet.target").exists() + assert not (prefix / "etc/polkit-1/rules.d/50-decnet-workers.rules").exists() + assert not (prefix / "etc/tmpfiles.d/decnet.conf").exists() + assert not (prefix / "etc/decnet").exists() + assert not (prefix / "opt/decnet").exists() + + # Data dirs preserved. + assert (prefix / "var/lib/decnet").exists() + assert (prefix / "var/log/decnet/events.jsonl").read_text() == "{}\n" + + # systemctl disable + daemon-reload + userdel + groupdel were invoked. + assert ["systemctl", "disable", "--now", "decnet.target"] in subprocess_calls + assert ["systemctl", "daemon-reload"] in subprocess_calls + assert ["userdel", "decnet"] in subprocess_calls + assert ["groupdel", "decnet"] in subprocess_calls + + +def test_deinit_purge_wipes_data_dirs( + tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, present_user_and_group: None, +) -> None: + prefix = tmp_path / "root" + _seed_installed_state(prefix) + result = runner.invoke( + app, ["init", "--deinit", "--purge", "--prefix", str(prefix)], + ) + assert result.exit_code == 0, result.output + assert not (prefix / "var/lib/decnet").exists() + assert not (prefix / "var/log/decnet").exists() + + +def test_deinit_is_idempotent_on_clean_host( + tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, missing_user_and_group: None, +) -> None: + prefix = tmp_path / "root" + # Nothing seeded — everything should SKIP. + result = runner.invoke( + app, ["init", "--deinit", "--prefix", str(prefix)], + ) + assert result.exit_code == 0, result.output + assert result.output.count("[SKIP]") >= 5 + # userdel / groupdel never invoked because user/group are absent. + assert ["userdel", "decnet"] not in subprocess_calls + assert ["groupdel", "decnet"] not in subprocess_calls + + +def test_deinit_dry_run_touches_nothing( + tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, present_user_and_group: None, +) -> None: + prefix = tmp_path / "root" + _seed_installed_state(prefix) + result = runner.invoke( + app, + ["init", "--deinit", "--purge", "--dry-run", "--prefix", str(prefix)], + ) + assert result.exit_code == 0, result.output + assert subprocess_calls == [] + assert (prefix / "etc/systemd/system/decnet.target").exists() + assert (prefix / "var/lib/decnet").exists() + + +def test_purge_without_deinit_errors(tmp_path: Path) -> None: + result = runner.invoke( + app, ["init", "--purge", "--prefix", str(tmp_path / "root")], + ) + assert result.exit_code == 1 + assert "--purge only applies with --deinit" in result.output + + def test_missing_deploy_dir_errors_clearly(monkeypatch: Any, tmp_path: Path) -> None: def _boom() -> Path: raise RuntimeError("cannot locate deploy/ directory (looked at /nope)")