DEBT-035 step 2. Today the artifacts subtree is auto-created by Docker as root when a decoy container's bind-mount fires for the first time. The resulting permissions are root:root 0o755 — the API process (running as the decnet user) hits PermissionError trying to read transcripts written by the container, and the soft-fail 404 path gets exercised on every fresh deploy. Add `/var/lib/decnet/artifacts` to init's dirs list with mode 0o2775: * 0o2000 — setgid bit. New files inherit the directory's group (decnet), regardless of which uid created them. This is the load- bearing bit for cross-container reads. * 0o0775 — owner+group rwx, world rx. Group-write lets the API process and the local TTP worker read each other's outputs without a manual chown. `_ensure_dir` already respects the full mode word via `os.chmod`, no helper change needed. Test asserts the resulting directory carries exactly 0o2775 after a fresh `decnet init --prefix`. Defence-in-depth: this works even if the per-decoy compose `user:` directive (next commit) misses a template — files still land in the decnet group.
864 lines
30 KiB
Python
864 lines
30 KiB
Python
"""
|
|
`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, Optional
|
|
|
|
import typer
|
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
|
|
import decnet as _decnet_pkg
|
|
from .gating import _require_master_mode
|
|
from .utils import console, log
|
|
|
|
|
|
_CONFIG_PLACEHOLDER = """\
|
|
# /etc/decnet/decnet.ini — DECNET host config.
|
|
#
|
|
# Every key is OPTIONAL. Absent keys fall through to env-var defaults
|
|
# defined in decnet/env.py. Real env vars always win over this file
|
|
# (precedence: env > INI > default), so systemd EnvironmentFile= and
|
|
# one-off `DECNET_FOO=bar decnet ...` invocations always take effect.
|
|
#
|
|
# Secrets (JWT, admin password, DB password) intentionally DO NOT
|
|
# live here. Put them in /opt/decnet/.env.local or the systemd
|
|
# EnvironmentFile= — never in a group-readable INI.
|
|
|
|
[decnet]
|
|
# DECNET-service user/group as configured at `decnet init` time.
|
|
# Resolved to a uid/gid on each host at deploy time via pwd.getpwnam,
|
|
# so the same user name can have different numeric uids on master vs
|
|
# agents without breaking artifact ownership.
|
|
api-user = {api_user}
|
|
api-group = {api_group}
|
|
# mode = master # or "agent"
|
|
|
|
# [api]
|
|
# host = 127.0.0.1
|
|
# port = 8000
|
|
|
|
# [web]
|
|
# host = 127.0.0.1
|
|
# port = 8080
|
|
# admin-user = admin
|
|
# cors-origins = http://localhost:8080 # comma-separated
|
|
|
|
# [database]
|
|
# type = sqlite # or "mysql"
|
|
# url = mysql+asyncmy://user@host:3306/decnet # if set, wins over host/port/name/user
|
|
# host = localhost
|
|
# port = 3306
|
|
# name = decnet
|
|
# user = decnet
|
|
|
|
# [bus]
|
|
# enabled = true
|
|
# type = unix # or "fake"
|
|
# socket = /run/decnet/bus.sock
|
|
# group = decnet
|
|
|
|
# [swarm]
|
|
# master-host = 10.0.0.1
|
|
# syslog-port = 6514
|
|
# swarmctl-port = 8770
|
|
# swarmctl-host = 127.0.0.1
|
|
|
|
# [logging]
|
|
# system-log = /var/log/decnet/decnet.system.log
|
|
# ingest-log = /var/log/decnet/decnet.log
|
|
# agent-log = /var/log/decnet/agent.log
|
|
|
|
# [ingester]
|
|
# batch-size = 100
|
|
# batch-max-wait-ms = 250
|
|
|
|
# [tracing]
|
|
# enabled = false
|
|
# otel-endpoint = http://localhost:4317
|
|
|
|
# [agent]
|
|
# Managed by the enroll bundle — do NOT edit by hand on an agent host.
|
|
"""
|
|
|
|
|
|
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 ] <label>``,
|
|
``"skip: <reason>"`` → ``[SKIP] <label> (<reason>)``.
|
|
Any exception becomes ``[FAIL] <label>: <err>`` and re-raises.
|
|
"""
|
|
try:
|
|
result = action()
|
|
except Exception as exc: # noqa: BLE001
|
|
console.print(f"[red][FAIL][/] {label}: {exc}")
|
|
raise
|
|
if result.startswith("skip:"):
|
|
reason = result[len("skip:") :].strip()
|
|
console.print(f"[yellow][SKIP][/] {label} ({reason})")
|
|
else:
|
|
console.print(f"[green][ OK ][/] {label}")
|
|
return True
|
|
|
|
|
|
def _ensure_group(group: str, *, dry_run: bool) -> str:
|
|
try:
|
|
grp.getgrnam(group)
|
|
return f"skip: group {group} already exists"
|
|
except KeyError:
|
|
_run(["groupadd", "--system", group], dry_run=dry_run)
|
|
return "ok"
|
|
|
|
|
|
def _ensure_user(user: str, group: str, install_dir: str, *, dry_run: bool) -> str:
|
|
try:
|
|
pwd.getpwnam(user)
|
|
return f"skip: user {user} already exists"
|
|
except KeyError:
|
|
_run(
|
|
[
|
|
"useradd", "--system",
|
|
"--gid", group,
|
|
"--home-dir", install_dir,
|
|
"--shell", "/usr/sbin/nologin",
|
|
"--comment", "DECNET honeypot",
|
|
user,
|
|
],
|
|
dry_run=dry_run,
|
|
)
|
|
return "ok"
|
|
|
|
|
|
def _ensure_dir(
|
|
path: Path, *, mode: int, owner: str, group: str, dry_run: bool
|
|
) -> str:
|
|
existed = path.exists()
|
|
if dry_run:
|
|
console.print(
|
|
f" [dim]would ensure dir:[/] {path} (mode={oct(mode)}, "
|
|
f"owner={owner}:{group})"
|
|
)
|
|
return "skip: dry-run" if existed else "ok"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
os.chmod(path, mode)
|
|
uid = pwd.getpwnam(owner).pw_uid
|
|
gid = grp.getgrnam(group).gr_gid
|
|
os.chown(path, uid, gid)
|
|
except (KeyError, PermissionError):
|
|
# owner/group not yet created, or we're not root (--prefix tests).
|
|
# mkdir is the load-bearing part; perm bits come back on the real
|
|
# root run.
|
|
pass
|
|
return f"skip: {path} already present" if existed else "ok"
|
|
|
|
|
|
def _ensure_config(
|
|
path: Path, group: str, *, user: str, dry_run: bool,
|
|
) -> str:
|
|
if path.exists():
|
|
return f"skip: {path} already present"
|
|
if dry_run:
|
|
console.print(f" [dim]would write:[/] {path}")
|
|
return "ok"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
rendered = _CONFIG_PLACEHOLDER.format(api_user=user, api_group=group)
|
|
path.write_text(rendered)
|
|
try:
|
|
os.chmod(path, 0o640)
|
|
gid = grp.getgrnam(group).gr_gid
|
|
os.chown(path, 0, gid)
|
|
except (KeyError, PermissionError):
|
|
pass
|
|
return "ok"
|
|
|
|
|
|
def _copy_if_changed(
|
|
src: Path, dst: Path, *, mode: int, force: bool, dry_run: bool
|
|
) -> str:
|
|
if dst.exists() and not force and _sha256(src) == _sha256(dst):
|
|
return f"skip: {dst} up to date"
|
|
if dry_run:
|
|
console.print(f" [dim]would install:[/] {src} -> {dst} (mode={oct(mode)})")
|
|
return "ok"
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
try:
|
|
os.chmod(dst, mode)
|
|
os.chown(dst, 0, 0)
|
|
except PermissionError:
|
|
pass
|
|
return "ok"
|
|
|
|
|
|
def _render_template(src: Path, context: dict[str, str]) -> str:
|
|
"""Render a Jinja2 .j2 template with the given context.
|
|
|
|
StrictUndefined: a missing context variable is an error, not a
|
|
silent empty-string substitution — that way a typo in the template
|
|
fails loudly instead of shipping a broken systemd unit.
|
|
"""
|
|
env = Environment(
|
|
loader=FileSystemLoader(str(src.parent)),
|
|
undefined=StrictUndefined,
|
|
keep_trailing_newline=True,
|
|
autoescape=False, # nosec B701 — rendering systemd INI, not HTML
|
|
)
|
|
template = env.get_template(src.name)
|
|
return template.render(**context)
|
|
|
|
|
|
def _write_rendered_if_changed(
|
|
src: Path, dst: Path, rendered: str, *, mode: int, force: bool, dry_run: bool
|
|
) -> str:
|
|
"""Write *rendered* content to *dst* only if it differs from what's there.
|
|
|
|
SHA compares rendered-output ↔ on-disk bytes (NOT source-template ↔
|
|
on-disk) so operators who customise their install_dir get idempotent
|
|
re-runs instead of every ``decnet init`` rewriting files.
|
|
"""
|
|
rendered_bytes = rendered.encode("utf-8")
|
|
if dst.exists() and not force:
|
|
if hashlib.sha256(dst.read_bytes()).hexdigest() == hashlib.sha256(rendered_bytes).hexdigest():
|
|
return f"skip: {dst} up to date"
|
|
if dry_run:
|
|
console.print(f" [dim]would render:[/] {src} -> {dst} (mode={oct(mode)})")
|
|
return "ok"
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_bytes(rendered_bytes)
|
|
try:
|
|
os.chmod(dst, mode)
|
|
os.chown(dst, 0, 0)
|
|
except PermissionError:
|
|
pass
|
|
return "ok"
|
|
|
|
|
|
def _resolve_venv_dir(install_dir: str, explicit: str | None) -> str:
|
|
"""Pick the virtualenv systemd units should ExecStart out of.
|
|
|
|
Priority:
|
|
1. ``--venv-dir`` flag (explicit; absolute path required).
|
|
2. ``VIRTUAL_ENV`` env var, but only when it lives under
|
|
``install_dir`` (refuse to bake /home/user/.venv into a system
|
|
service — that directory is user-owned and may vanish).
|
|
3. ``{install_dir}/venv`` — what ``enroll_bootstrap.sh`` creates
|
|
on fresh agents; the production default.
|
|
4. First hit from a short list of dev-box conventions under
|
|
``install_dir``: ``.venv``, ``.311``, ``.312``, ``.313``.
|
|
|
|
Raises RuntimeError with an operator-friendly message if none of
|
|
those resolve to a directory containing ``bin/decnet``. Failing loud
|
|
at init time beats systemd spamming journalctl with
|
|
'Failed at step EXEC spawning .../venv/bin/decnet: No such file or
|
|
directory' on every auto-restart.
|
|
"""
|
|
install_path = Path(install_dir)
|
|
|
|
candidates: list[Path] = []
|
|
if explicit:
|
|
if not explicit.startswith("/"):
|
|
raise RuntimeError(
|
|
f"--venv-dir must be an absolute path, got {explicit!r}"
|
|
)
|
|
candidates.append(Path(explicit))
|
|
else:
|
|
virtual_env = os.environ.get("VIRTUAL_ENV")
|
|
if virtual_env:
|
|
ve_path = Path(virtual_env)
|
|
try:
|
|
ve_path.relative_to(install_path)
|
|
candidates.append(ve_path)
|
|
except ValueError:
|
|
# VIRTUAL_ENV lives outside install_dir — don't bake a
|
|
# user-home venv into a root-owned systemd unit.
|
|
pass
|
|
candidates.append(install_path / "venv")
|
|
for name in (".venv", ".311", ".312", ".313"):
|
|
candidates.append(install_path / name)
|
|
|
|
for cand in candidates:
|
|
if (cand / "bin" / "decnet").is_file():
|
|
return str(cand)
|
|
|
|
searched = ", ".join(str(c) for c in candidates)
|
|
raise RuntimeError(
|
|
"Could not find a DECNET venv. Create one first (e.g. "
|
|
f"`python -m venv {install_path}/venv && "
|
|
f"{install_path}/venv/bin/pip install -e {install_path}[dev]`) "
|
|
"or pass --venv-dir. Searched: " + searched
|
|
)
|
|
|
|
|
|
def _install_units(
|
|
deploy: Path,
|
|
systemd_dir: Path,
|
|
*,
|
|
install_dir: str,
|
|
venv_dir: str,
|
|
user: str,
|
|
group: str,
|
|
force: bool,
|
|
dry_run: bool,
|
|
) -> str:
|
|
"""Render decnet-*.service.j2 → systemd_dir/decnet-*.service, and copy
|
|
the static decnet.target (no templating needed — it has no install
|
|
path references)."""
|
|
context = {
|
|
"install_dir": install_dir,
|
|
"venv_dir": venv_dir,
|
|
"user": user,
|
|
"group": group,
|
|
}
|
|
templates = sorted(deploy.glob("decnet-*.service.j2"))
|
|
static = [deploy / "decnet.target"]
|
|
|
|
touched = 0
|
|
for src in templates:
|
|
rendered = _render_template(src, context)
|
|
# decnet-api.service.j2 → decnet-api.service
|
|
dst_name = src.name[: -len(".j2")]
|
|
result = _write_rendered_if_changed(
|
|
src, systemd_dir / dst_name, rendered,
|
|
mode=0o644, force=force, dry_run=dry_run,
|
|
)
|
|
if not result.startswith("skip:"):
|
|
touched += 1
|
|
for src in static:
|
|
result = _copy_if_changed(
|
|
src, systemd_dir / src.name,
|
|
mode=0o644, force=force, dry_run=dry_run,
|
|
)
|
|
if not result.startswith("skip:"):
|
|
touched += 1
|
|
total = len(templates) + len(static)
|
|
if touched == 0:
|
|
return f"skip: {total} unit files up to date"
|
|
return f"ok ({touched}/{total} installed)"
|
|
|
|
|
|
def _install_polkit(
|
|
deploy: Path, rules_dir: Path, *, group: str, force: bool, dry_run: bool
|
|
) -> str:
|
|
"""Render the group-scoped polkit rule to /etc/polkit-1/rules.d/.
|
|
|
|
The rule has to reference the same POSIX group passed via --group —
|
|
otherwise the API (running as that user) can't
|
|
systemctl start/stop decnet-*.service without an interactive auth
|
|
prompt that never gets answered in a daemon context.
|
|
"""
|
|
src = deploy / "polkit" / "50-decnet-workers.rules.j2"
|
|
if not src.is_file():
|
|
raise RuntimeError(f"missing polkit rule template at {src}")
|
|
rendered = _render_template(src, {"group": group})
|
|
# 50-decnet-workers.rules.j2 → 50-decnet-workers.rules
|
|
dst_name = src.name[: -len(".j2")]
|
|
return _write_rendered_if_changed(
|
|
src, rules_dir / dst_name, rendered,
|
|
mode=0o644, force=force, dry_run=dry_run,
|
|
)
|
|
|
|
|
|
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:
|
|
src = deploy / "tmpfiles.d" / "decnet.conf"
|
|
if not src.is_file():
|
|
raise RuntimeError(f"missing tmpfiles.d entry at {src}")
|
|
result = _copy_if_changed(
|
|
src, tmpfiles_dir / src.name,
|
|
mode=0o644, force=force, dry_run=dry_run,
|
|
)
|
|
# Apply immediately so /run/decnet exists before daemon-reload.
|
|
_run(["systemd-tmpfiles", "--create", str(tmpfiles_dir / src.name)], dry_run=dry_run)
|
|
return result
|
|
|
|
|
|
def _install_logrotate(
|
|
deploy: Path, logrotate_dir: Path, *, force: bool, dry_run: bool
|
|
) -> str:
|
|
"""Drop the logrotate config into ``/etc/logrotate.d/decnet``.
|
|
|
|
The ingester / forwarder hold the log files open via Python, so the
|
|
config uses ``copytruncate`` rather than rename+create. Without this
|
|
rule, /var/log/decnet/ grows without bound and a single noisy day of
|
|
attacker traffic fills the disk on a small VPS. Best-effort: a host
|
|
without logrotate installed (rare on systemd distros) still boots
|
|
fine — the operator just needs to wire their own rotation.
|
|
"""
|
|
src = deploy / "logrotate.d" / "decnet"
|
|
if not src.is_file():
|
|
raise RuntimeError(f"missing logrotate config at {src}")
|
|
return _copy_if_changed(
|
|
src, logrotate_dir / src.name,
|
|
mode=0o644, force=force, dry_run=dry_run,
|
|
)
|
|
|
|
|
|
def register(app: typer.Typer) -> None:
|
|
@app.command(name="init")
|
|
def init_cmd(
|
|
dry_run: bool = typer.Option(
|
|
False, "--dry-run",
|
|
help="Print every action; make no changes.",
|
|
),
|
|
no_start: bool = typer.Option(
|
|
False, "--no-start",
|
|
help="Install everything but don't `systemctl enable --now decnet.target`.",
|
|
),
|
|
force: bool = typer.Option(
|
|
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. "
|
|
"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, "
|
|
"/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",
|
|
help="System user to own DECNET processes.",
|
|
),
|
|
group: str = typer.Option(
|
|
"decnet", "--group",
|
|
help="Primary group of the DECNET user.",
|
|
),
|
|
install_dir: str = typer.Option(
|
|
"/opt/decnet", "--install-dir",
|
|
help="Absolute path where DECNET is installed. Default "
|
|
"/opt/decnet; distros that reserve /opt can point this "
|
|
"at /srv/decnet, /usr/local/decnet, etc. Gets rendered "
|
|
"into every systemd unit via Jinja2 and used as the "
|
|
"decnet user's home directory.",
|
|
),
|
|
venv_dir: Optional[str] = typer.Option(
|
|
None, "--venv-dir",
|
|
help="Absolute path to the Python venv systemd should "
|
|
"ExecStart from. If omitted, auto-detected in order: "
|
|
"$VIRTUAL_ENV (if under --install-dir), "
|
|
"{install-dir}/venv, then {install-dir}/{.venv,.311,"
|
|
".312,.313}. Init aborts if none exists.",
|
|
),
|
|
prefix: str = typer.Option(
|
|
"", "--prefix", hidden=True,
|
|
help="Filesystem prefix for tests (e.g. tmp_path). Empty = real root.",
|
|
),
|
|
) -> None:
|
|
"""One-shot bootstrap of a DECNET master host.
|
|
|
|
Creates the `decnet` user/group, installs systemd units,
|
|
polkit rules, tmpfiles.d entries, seeds directories and
|
|
drops a placeholder config, then starts decnet.target.
|
|
"""
|
|
_require_master_mode("init")
|
|
|
|
if purge and not deinit:
|
|
console.print("[red]--purge only applies with --deinit[/]")
|
|
raise typer.Exit(1)
|
|
|
|
# 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)
|
|
|
|
if not install_dir.startswith("/"):
|
|
console.print(
|
|
f"[red]decnet init: --install-dir must be absolute, got {install_dir!r}[/]"
|
|
)
|
|
raise typer.Exit(1)
|
|
# Strip leading slash so pfx-joining works under --prefix test mode
|
|
# (Path("/"). / "/opt/decnet" == Path("/opt/decnet"), dropping pfx).
|
|
_install_rel = install_dir.lstrip("/")
|
|
|
|
required_tools: tuple[str, ...] = ("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:
|
|
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"
|
|
logrotate_dir = pfx / "etc/logrotate.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(
|
|
"remove logrotate config",
|
|
lambda: _remove_file(
|
|
logrotate_dir / "decnet",
|
|
dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"systemctl daemon-reload",
|
|
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], # type: ignore[func-returns-value]
|
|
)
|
|
_step(
|
|
f"remove {etc_decnet / 'decnet.ini'}",
|
|
lambda: _remove_file(etc_decnet / "decnet.ini", dry_run=dry_run),
|
|
)
|
|
# Legacy name from pre-domain-sections placeholder era.
|
|
# Harmless if absent (the _remove_file step logs skip).
|
|
_step(
|
|
f"remove legacy {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 / _install_rel}",
|
|
lambda: _remove_dir_if_present(
|
|
pfx / _install_rel, 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.[/]"
|
|
)
|
|
# 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),
|
|
)
|
|
_step(
|
|
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
|
|
|
|
try:
|
|
deploy = _deploy_root()
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]decnet init: {exc}[/]")
|
|
raise typer.Exit(1) from exc
|
|
|
|
# Resolve venv BEFORE any file writes — fails loud if the
|
|
# operator hasn't created one yet, instead of shipping broken
|
|
# systemd units that journalctl spams forever. Skipped under
|
|
# --prefix (test mode) because the test harness doesn't build a
|
|
# real venv and the rendered string is asserted on directly.
|
|
if prefix:
|
|
resolved_venv = venv_dir or f"{install_dir}/venv"
|
|
else:
|
|
try:
|
|
resolved_venv = _resolve_venv_dir(install_dir, venv_dir)
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]decnet init: {exc}[/]")
|
|
raise typer.Exit(1) from exc
|
|
console.print(f"[dim]using venv: {resolved_venv}[/]")
|
|
|
|
dirs = [
|
|
(pfx / _install_rel, 0o755, user, group),
|
|
(pfx / "var/lib/decnet", 0o750, user, group),
|
|
(pfx / "var/lib/decnet/geoip", 0o755, user, group),
|
|
# DEBT-035 / DEBT-047: artifact root carries setgid (the
|
|
# 0o2... bit) so every file written under it inherits the
|
|
# decnet group regardless of which container's uid created
|
|
# it. Group-write (0o2775) lets the API process and the
|
|
# local TTP worker read each other's outputs without a
|
|
# manual chown after every fresh deploy.
|
|
(pfx / "var/lib/decnet/artifacts", 0o2775, user, group),
|
|
(pfx / "var/log/decnet", 0o750, user, group),
|
|
(etc_decnet, 0o755, "root", group),
|
|
(pfx / "run/decnet", 0o755, "root", group),
|
|
]
|
|
|
|
console.print(
|
|
f"[bold cyan]DECNET init[/] "
|
|
f"(dry_run={dry_run}, no_start={no_start}, force={force})"
|
|
)
|
|
|
|
_step(
|
|
f"ensure group {group!r}",
|
|
lambda: _ensure_group(group, dry_run=dry_run),
|
|
)
|
|
_step(
|
|
f"ensure user {user!r}",
|
|
lambda: _ensure_user(user, group, install_dir, dry_run=dry_run),
|
|
)
|
|
for path, mode, d_owner, d_group in dirs:
|
|
_step(
|
|
f"ensure dir {path}",
|
|
lambda p=path, m=mode, o=d_owner, g=d_group: # type: ignore[misc]
|
|
_ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run),
|
|
)
|
|
_step(
|
|
f"write {etc_decnet / 'decnet.ini'}",
|
|
lambda: _ensure_config(
|
|
etc_decnet / "decnet.ini", group,
|
|
user=user, dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"install systemd units",
|
|
lambda: _install_units(
|
|
deploy, systemd_dir,
|
|
install_dir=install_dir, venv_dir=resolved_venv,
|
|
user=user, group=group,
|
|
force=force, dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"install polkit rule",
|
|
lambda: _install_polkit(
|
|
deploy, polkit_dir, group=group,
|
|
force=force, dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"install tmpfiles.d entry",
|
|
lambda: _install_tmpfiles(
|
|
deploy, tmpfiles_dir, force=force, dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"install logrotate config",
|
|
lambda: _install_logrotate(
|
|
deploy, logrotate_dir, force=force, dry_run=dry_run,
|
|
),
|
|
)
|
|
_step(
|
|
"systemctl daemon-reload",
|
|
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], # type: ignore[func-returns-value]
|
|
)
|
|
|
|
if no_start:
|
|
console.print("[yellow]--no-start: skipping decnet.target start[/]")
|
|
return
|
|
|
|
try:
|
|
_step(
|
|
"systemctl enable --now decnet.target",
|
|
lambda: (
|
|
_run( # type: ignore[func-returns-value]
|
|
["systemctl", "enable", "--now", "decnet.target"],
|
|
dry_run=dry_run,
|
|
),
|
|
"ok",
|
|
)[1],
|
|
)
|
|
except subprocess.CalledProcessError as exc:
|
|
console.print(
|
|
f"[red]decnet.target failed to start (rc={exc.returncode}); "
|
|
"inspect `systemctl status decnet.target` and individual "
|
|
"`decnet-*.service` units.[/]"
|
|
)
|
|
raise typer.Exit(1) from exc
|
|
|
|
console.print("[bold green]DECNET init complete.[/] "
|
|
"Check `decnet status` or the Workers panel.")
|
|
sys.stdout.flush()
|