fix(init): gate userdel/groupdel on --purge to avoid nuking the operator

Every plain `decnet deinit` ran userdel + groupdel unconditionally. In
dev the operator may pass `--user $USER --group $USER` to avoid file
ownership churn against a source checkout — at which point deinit
would cheerfully delete their own login account.

Move user/group removal behind --purge, matching the existing
behaviour for /var/lib/decnet + /var/log/decnet. Help text updated:
--purge now clearly advertises that it also wipes the service
user/group, with an explicit warning to only run it when `decnet init`
created the account in the first place.

Test updated: plain --deinit must NOT invoke userdel/groupdel;
--deinit --purge must.
This commit is contained in:
2026-04-24 00:38:51 -04:00
parent 38832d87d5
commit edc8297af3
2 changed files with 36 additions and 16 deletions

View File

@@ -501,14 +501,18 @@ def register(app: typer.Typer) -> None:
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.",
"unit files, polkit rule, tmpfiles.d entry, /etc/decnet. "
"Preserves /var/lib/decnet, /var/log/decnet, and the "
"service user/group — 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.",
help="With --deinit, also wipe /var/lib/decnet, "
"/var/log/decnet, AND the service user/group. "
"Destructive — operator data is gone, and if --user "
"points at your own login account, that account goes "
"with it. Only use when the user/group was created by "
"`decnet init` in the first place.",
),
user: str = typer.Option(
"decnet", "--user",
@@ -664,6 +668,11 @@ def register(app: typer.Typer) -> None:
f"{pfx / 'var/log/decnet'} (operator data); "
"re-run with --purge to remove.[/]"
)
# User / group removal is also gated on --purge. In dev the
# operator may have passed their own login user via
# `--user $USER` to avoid ownership churn; an unconditional
# `userdel anti` during deinit would nuke their account.
if purge:
_step(
f"remove user {user!r}",
lambda: _remove_user(user, dry_run=dry_run),
@@ -672,6 +681,12 @@ def register(app: typer.Typer) -> None:
f"remove group {group!r}",
lambda: _remove_group(group, dry_run=dry_run),
)
else:
console.print(
f"[dim]preserved user {user!r} and group {group!r}; "
"re-run with --purge to remove (only do this if "
"they were created by `decnet init`).[/]"
)
console.print("[bold green]DECNET deinit complete.[/]")
return

View File

@@ -387,11 +387,13 @@ def test_deinit_removes_units_polkit_tmpfiles_and_preserves_data(
assert (prefix / "var/lib/decnet").exists()
assert (prefix / "var/log/decnet/events.jsonl").read_text() == "{}\n"
# systemctl disable + daemon-reload + userdel + groupdel were invoked.
# systemctl disable + daemon-reload 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
# User / group are PRESERVED without --purge — an operator who
# passed --user $USER during dev must not lose their login account.
assert ["userdel", "decnet"] not in subprocess_calls
assert ["groupdel", "decnet"] not in subprocess_calls
def test_deinit_purge_wipes_data_dirs(
@@ -406,6 +408,9 @@ def test_deinit_purge_wipes_data_dirs(
assert result.exit_code == 0, result.output
assert not (prefix / "var/lib/decnet").exists()
assert not (prefix / "var/log/decnet").exists()
# --purge also removes the service user/group.
assert ["userdel", "decnet"] in subprocess_calls
assert ["groupdel", "decnet"] in subprocess_calls
def test_deinit_is_idempotent_on_clean_host(