From 3dae44c65275405959ed330b34c14c32aab05989 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 14:28:11 -0400 Subject: [PATCH] feat(cli): add `decnet init` one-shot master-host bootstrap Creates the decnet system user/group, installs every unit file from deploy/ into /etc/systemd/system, drops the polkit rule, seeds /opt/decnet + /var/{lib,log}/decnet + /etc/decnet + /run/decnet, writes a placeholder /etc/decnet/config.ini, applies the new tmpfiles.d entry so /run/decnet survives reboots, daemon-reloads, and `systemctl enable --now decnet.target`. Idempotent (re-runs print [SKIP] on already-configured items), --dry-run previews the plan without touching anything, --no-start defers the target start, --force overwrites even matching unit files. Master-only (added to MASTER_ONLY_COMMANDS). 9 orchestration tests cover the non-root gate, dry-run, useradd/ groupadd argv, SKIP on present user/group, unit-file idempotency, --force overwrite, --no-start suppression, happy path, and the "deploy/ not found" error message. --- decnet/cli/__init__.py | 3 +- decnet/cli/gating.py | 2 +- decnet/cli/init.py | 361 ++++++++++++++++++++++++++++++++++ deploy/tmpfiles.d/decnet.conf | 4 + tests/cli/__init__.py | 0 tests/cli/test_init.py | 217 ++++++++++++++++++++ 6 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 decnet/cli/init.py create mode 100644 deploy/tmpfiles.d/decnet.conf create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_init.py diff --git a/decnet/cli/__init__.py b/decnet/cli/__init__.py index 493db82e..3576daa4 100644 --- a/decnet/cli/__init__.py +++ b/decnet/cli/__init__.py @@ -25,6 +25,7 @@ from . import ( db, deploy, forwarder, + init, inventory, lifecycle, listener, @@ -52,7 +53,7 @@ for _mod in ( swarm, deploy, lifecycle, workers, inventory, web, profiler, sniffer, db, - topology, bus, + topology, bus, init, ): _mod.register(app) diff --git a/decnet/cli/gating.py b/decnet/cli/gating.py index af724b22..3da191a9 100644 --- a/decnet/cli/gating.py +++ b/decnet/cli/gating.py @@ -29,7 +29,7 @@ MASTER_ONLY_COMMANDS: frozenset[str] = frozenset({ "api", "swarmctl", "deploy", "redeploy", "teardown", "mutate", "listener", "profiler", "services", "distros", "correlate", "archetypes", "web", - "db-reset", + "db-reset", "init", }) MASTER_ONLY_GROUPS: frozenset[str] = frozenset({"swarm", "topology"}) diff --git a/decnet/cli/init.py b/decnet/cli/init.py new file mode 100644 index 00000000..e97975b8 --- /dev/null +++ b/decnet/cli/init.py @@ -0,0 +1,361 @@ +""" +`decnet init` — one-shot master-host bootstrap. + +Idempotent: running it twice is a no-op on already-configured items. +Takes a freshly ``pip install``'d DECNET and turns it into a ready-to- +run master host: creates the ``decnet`` system user/group, installs +the systemd units + polkit rule + tmpfiles.d entry, seeds the +directory layout, drops a placeholder config, and starts the +``decnet.target`` grouping unit. + +Requires root. Uses ``subprocess.run`` (never ``shell=True``) for every +privileged call so the full argv surface is auditable. +""" +from __future__ import annotations + +import grp +import hashlib +import os +import pwd +import shutil +import subprocess # nosec B404 +import sys +from pathlib import Path +from typing import Callable, List + +import typer + +import decnet as _decnet_pkg +from .gating import _require_master_mode +from .utils import console, log + + +_CONFIG_PLACEHOLDER = """\ +# /etc/decnet/config.ini — DECNET master-host config. +# Placeholder; reserved for future structured settings. +# Today, most knobs live in /opt/decnet/.env.local as env vars. +[decnet] +""" + + +def _deploy_root() -> Path: + """Resolve the on-disk ``deploy/`` directory of the installed package. + + Editable install (``pip install -e .``): sibling of the ``decnet`` + package at repo root. Wheel installs aren't supported yet — the + error message tells the operator to use an editable install. + """ + root = Path(_decnet_pkg.__file__).resolve().parent.parent / "deploy" + if not (root / "decnet.target").is_file(): + raise RuntimeError( + f"cannot locate deploy/ directory (looked at {root}); " + "are you on a wheel install that didn't bundle deploy/? " + "use `pip install -e .` from a git checkout" + ) + return root + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def _run(argv: List[str], *, dry_run: bool) -> None: + if dry_run: + console.print(f" [dim]would run:[/] {' '.join(argv)}") + return + log.info("init: exec %s", argv) + subprocess.run(argv, check=True) # nosec B603 + + +def _step(label: str, action: Callable[[], str]) -> bool: + """Run ``action``, print a checklist line. + + The callable returns the human-readable outcome verb: + ``"ok"`` → ``[ OK ]