feat(cli): add decnet init --deinit to undo a previous bootstrap
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.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user