From dc9ee0d7a7a10f1d9c862300e4cd4bebac1acc02 Mon Sep 17 00:00:00 2001 From: Mike Langmayr Date: Mon, 12 Oct 2026 10:59:36 -0700 Subject: [PATCH] Render host setup from the instrument config --- README.md | 24 +- pyproject.toml | 3 + src/instrumentctl/cli.py | 11 + src/instrumentctl/doctor.py | 14 +- src/instrumentctl/installer.py | 228 +++++++++++++++++++ src/instrumentctl/product.py | 4 + src/instrumentctl/templates/enable-helper.sh | 60 +++++ src/instrumentctl/templates/front-door.sh | 17 ++ src/instrumentctl/templates/polkit.rules | 19 ++ src/instrumentctl/templates/sudoers | 5 + src/instrumentctl/templates/unit.service | 53 +++++ 11 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 src/instrumentctl/installer.py create mode 100644 src/instrumentctl/templates/enable-helper.sh create mode 100644 src/instrumentctl/templates/front-door.sh create mode 100644 src/instrumentctl/templates/polkit.rules create mode 100644 src/instrumentctl/templates/sudoers create mode 100644 src/instrumentctl/templates/unit.service diff --git a/README.md b/README.md index 2f1fc2f..632a76d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Everything except `name` is optional. | key | default | what it does | | --- | --- | --- | | `name` | required | the command, the unit (`@.service`), and `/opt//app`, `/opt//venv`, `/etc/` | +| `display_name` | `name` | the instrument's proper name, as it reads in `systemctl status` | | `user` | `name` | the account the daemons run as | | `ops_group` | `-ops` | the group that may start, stop and deploy without a password | | `env_prefix` | `NAME` | instance files set `_DAEMON`, `_CONFIG` and `_HOST`; operators override paths with `_REPO_DIR`, `_VENV_DIR`, `_ETC_DIR` | @@ -70,5 +71,24 @@ An instance is a name with three files behind it: `/systemd/instances/ .yaml`, and their deployed copies under `/etc/`. Deployed means the `.env` is in `/etc//instances/`, which is what the template unit reads. -Host setup, meaning the user, the group, the unit file, the polkit rule and the -venv, is still an `install.sh` in each instrument. Nothing here needs root. +## Host setup + +` install`, as root, converges a host: it creates the ops group and the +daemon account, enrols operators, makes `/etc/`, `/etc//instances` +and `/var/log/` setgid, seeds `secrets.env` and the host role file, and +renders the template unit, the polkit rule, the sudoers drop-in and the two +helper scripts from the same config the rest of the CLI reads. It is safe to +re-run and reports only what it changes. + +Building the virtualenv is not its job, since this code lives inside it. Each +instrument keeps a short `install.sh` that bootstraps uv, runs `uv sync`, and +hands over: + +```bash +"$UV_BIN" sync --all-packages +exec "$VENV_DIR/bin/" install "$@" +``` + +Everything else needs no root. `doctor` compares the installed unit and polkit +rule against what `install` would write now, so a host whose unit predates a +template change is reported rather than silently stale. diff --git a/pyproject.toml b/pyproject.toml index 78ac32d..fb28055 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ where = ["src"] include = ["instrumentctl"] +[tool.setuptools.package-data] +"instrumentctl" = ["templates/*"] + [tool.pylint.format] max-line-length = 100 diff --git a/src/instrumentctl/cli.py b/src/instrumentctl/cli.py index bafd63d..aebe0c7 100644 --- a/src/instrumentctl/cli.py +++ b/src/instrumentctl/cli.py @@ -11,6 +11,7 @@ from . import commands from .doctor import doctor +from .installer import install from .instances import Paths from .product import ConfigSource, Product @@ -112,6 +113,16 @@ def add(name, func, help_text, description=None): p.add_argument("-p", "--priority", help="e.g. err, warning") add("doctor", doctor, "check this host and your account, and say what to fix") + + p = add("install", install, "set this host up (root)", + "Create the ops group and the daemon account, enrol operators, make the\n" + "directories, and render the unit, polkit rule, sudoers drop-in and helper\n" + "scripts. Safe to re-run. The virtualenv must already exist, since this\n" + "code lives in it.") + p.add_argument("operators", nargs="*", metavar="user", + help=f"accounts to enrol in {product.ops_group}") + p.add_argument("--host-role", metavar="ROLE", + help="what to record in /host (default: short hostname)") return parser diff --git a/src/instrumentctl/doctor.py b/src/instrumentctl/doctor.py index 1a9ceef..85bfeb7 100644 --- a/src/instrumentctl/doctor.py +++ b/src/instrumentctl/doctor.py @@ -14,6 +14,7 @@ from . import instances as inst from . import units +from .installer import _render from .instances import Paths from .output import paint @@ -136,11 +137,12 @@ def _unit_and_polkit(r: Report, paths: Paths) -> None: unit_file = product.unit_template polkit_rule = product.polkit_rule r.section("Unit and polkit") - repo_unit = paths.repo / "systemd" / unit_file.name if unit_file.is_file(): r.ok(f"{unit_file} installed") - if repo_unit.is_file() and not filecmp.cmp(unit_file, repo_unit, shallow=False): - r.warn(f"installed unit differs from {repo_unit}") + # Against what install would write now, not a committed copy: that is + # what catches a host whose unit predates a template change + if unit_file.read_text(encoding="utf-8") != _render("unit.service", product, paths): + r.warn("installed unit differs from what install would write") r.hint(install) else: r.bad(f"{unit_file} is missing") @@ -160,9 +162,9 @@ def _unit_and_polkit(r: Report, paths: Paths) -> None: r.hint(f"sudo {product.name} doctor") else: r.ok(f"{polkit_rule} installed") - if f'indexOf("{product.name}@")' not in rule: - r.bad(f"the installed polkit rule does not match {product.name}@ units") - r.hint(f"it is probably the pre-rename copy; {install}") + if rule != _render("polkit.rules", product, paths): + r.bad("the installed polkit rule differs from what install would write") + r.hint(f"it may be a pre-rename copy; {install}") legacy_rule = product.legacy_polkit_path if _is_file(legacy_rule): r.warn(f"the old {legacy_rule.name} is still installed " diff --git a/src/instrumentctl/installer.py b/src/instrumentctl/installer.py new file mode 100644 index 0000000..a4ea8d1 --- /dev/null +++ b/src/instrumentctl/installer.py @@ -0,0 +1,228 @@ +"""``install``: converge a host onto what this instrument needs. + +Run as root, and safe to re-run. It creates the ops group and the daemon +account, enrols operators, makes the directories, and renders the unit, the +polkit rule, the sudoers drop-in and the two helper scripts from the same +``Product`` the rest of the CLI reads, so what ``doctor`` checks is what this +wrote. + +Creating the virtualenv is not here. It has to happen before this runs, since +this code lives inside it. +""" +from __future__ import annotations + +import argparse +import grp +import os +import pwd +import shutil +import subprocess +import tempfile +from importlib import resources +from pathlib import Path +from typing import List, Optional, Sequence + +from . import instances as inst +from . import units +from .instances import Paths +from .output import paint +from .product import Product + +BIN_DIR = Path("/usr/local/bin") +JOURNAL_GROUP = "systemd-journal" + +# Setgid, so a file created by any ops-group member keeps the group rather than +# the creating user's own +CONFIG_MODE = 0o2775 +LOG_MODE = 0o2750 +SECRETS_MODE = 0o660 + + +def _say(action: str, what: str) -> None: + print(f" {paint(action, '32'):<22} {what}") + + +def _render(template: str, product: Product, paths: Paths) -> str: + """Fill a template's ``@placeholder@`` values from the product.""" + values = { + "name": product.name, + "display_name": product.display_name, + "user": product.user, + "ops_group": product.ops_group, + "env_prefix": product.env_prefix, + "docs_url": product.docs_url, + "etc": str(paths.etc), + "venv": str(paths.venv), + "repo": str(paths.repo), + "logs": str(log_dir(product)), + } + text = resources.files(__package__).joinpath("templates", template).read_text( + encoding="utf-8") + for key, value in values.items(): + text = text.replace(f"@{key}@", value) + return text + + +def log_dir(product: Product) -> Path: + """Where the daemons' own log files go, writable by the daemon account.""" + return Path(f"/var/log/{product.name}") + + +def _write(path: Path, content: str, mode: int) -> None: + """Install a rendered file, reporting only when it changes.""" + if path.exists() and path.read_text(encoding="utf-8") == content: + _say("unchanged", str(path)) + return + path.parent.mkdir(parents=True, exist_ok=True) + # Write beside the target and rename, so a reader never sees half a file + handle, temporary = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(content) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + _say("installed", str(path)) + + +def _group_and_user(product: Product) -> None: + """Create the ops group and the account the daemons run as.""" + try: + grp.getgrnam(product.ops_group) + except KeyError: + subprocess.run(["groupadd", "--system", product.ops_group], check=True) + _say("created group", product.ops_group) + try: + pwd.getpwnam(product.user) + # The daemon account joins the ops group so it can read what operators write + subprocess.run(["usermod", "-aG", product.ops_group, product.user], check=True) + except KeyError: + subprocess.run(["useradd", "--system", "--no-create-home", + "--shell", "/usr/sbin/nologin", + "-G", product.ops_group, product.user], check=True) + _say("created user", product.user) + + +def _operators(product: Product, operators: Sequence[str]) -> None: + """Enrol operators, which is what spares them a password on every call.""" + for operator in operators: + try: + pwd.getpwnam(operator) + except KeyError: + print(f" {paint('skipped', '33'):<22} no such user '{operator}'") + continue + groups = [product.ops_group] + try: + grp.getgrnam(JOURNAL_GROUP) + groups.append(JOURNAL_GROUP) + except KeyError: + pass + subprocess.run(["usermod", "-aG", ",".join(groups), operator], check=True) + _say("enrolled", f"{operator} in {', '.join(groups)} " + "(they must log out and back in)") + + +def _directories(product: Product, paths: Paths) -> None: + for directory, mode in ((paths.etc, CONFIG_MODE), + (paths.instances, CONFIG_MODE), + (log_dir(product), LOG_MODE)): + directory.mkdir(parents=True, exist_ok=True) + shutil.chown(directory, product.user, product.ops_group) + directory.chmod(mode) + _say("ready", f"{paths.etc}, {paths.instances}, {log_dir(product)}") + + +def _secrets_and_role(product: Product, paths: Paths, role: Optional[str]) -> None: + """Seed secrets.env and the host role, neither overwritten once present.""" + if not paths.secrets.exists(): + paths.secrets.write_text( + f"# Environment for every {product.name}@ instance. Keep credentials\n" + f"# here rather than in a config file, which is committed.\n" + f"# `{product.name} doctor` lists the ones this host needs.\n", + encoding="utf-8") + _say("created", str(paths.secrets)) + # root-owned and group-readable: requiring sudo for one file pushes people + # into putting credentials in the instance files, which operators can read + shutil.chown(paths.secrets, "root", product.ops_group) + paths.secrets.chmod(SECRETS_MODE) + + host_file = paths.etc / "host" + if not host_file.exists(): + host_file.write_text(f"{role or os.uname().nodename.split('.')[0]}\n", + encoding="utf-8") + _say("recorded host", f"{host_file.read_text().strip()} in {host_file}") + shutil.chown(host_file, "root", product.ops_group) + host_file.chmod(0o644) + + +def _artefacts(product: Product, paths: Paths) -> None: + """Render the unit, the polkit rule, the sudoers drop-in and the helpers.""" + _write(product.unit_template, _render("unit.service", product, paths), 0o644) + product.polkit_rule.parent.mkdir(parents=True, exist_ok=True) + product.polkit_rule.parent.chmod(0o750) + _write(product.polkit_rule, _render("polkit.rules", product, paths), 0o644) + _write(BIN_DIR / product.name, _render("front-door.sh", product, paths), 0o755) + # Root runs this, so it must not live in the venv, which the daemon account + # can write to + _write(product.enable_helper, _render("enable-helper.sh", product, paths), 0o755) + _sudoers(product, paths) + + +def _sudoers(product: Product, paths: Paths) -> None: + """Install the drop-in only if visudo accepts it.""" + content = _render("sudoers", product, paths) + handle, temporary = tempfile.mkstemp() + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(content) + if subprocess.run(["visudo", "-cqf", temporary], check=False).returncode != 0: + print(f" {paint('refused', '31'):<22} " + f"generated sudoers failed validation, not installing") + return + finally: + Path(temporary).unlink(missing_ok=True) + _write(product.sudoers, content, 0o440) + shutil.chown(product.sudoers, "root", "root") + + +def install(paths: Paths, args: argparse.Namespace) -> int: + """Converge this host onto what the instrument needs. Root only.""" + product = paths.product + if os.geteuid() != 0: + print(f"{product.name}: install must run as root (sudo)") + return 1 + if not paths.repo_instances.is_dir(): + print(f"{product.name}: no {paths.repo_instances}; " + f"is {paths.repo} the checkout?") + return 1 + + print(f"{product.name} install: {os.uname().nodename}") + _group_and_user(product) + _operators(product, args.operators) + _directories(product, paths) + _secrets_and_role(product, paths, args.host_role) + _artefacts(product, paths) + shutil.chown(paths.venv, product.user, product.user) + for child in paths.venv.rglob("*"): + shutil.chown(child, product.user, product.user) + _say("owned", f"{paths.venv} by {product.user}") + subprocess.run(["systemctl", "daemon-reload"], check=False) + + print(f"\nHost setup complete. Operators in {product.ops_group} can now " + f"deploy, start and stop\nwithout a password. If anything still asks, " + f"run: {product.name} doctor") + # Whether an instance should come up at boot is an operational decision, + # so report rather than enable + pending = _pending_at_boot(paths) + if pending: + print(f"\nDeployed but not set to start at boot: {' '.join(pending)}") + print(f" {product.name} enable --now {' '.join(pending)}") + return 0 + + +def _pending_at_boot(paths: Paths) -> List[str]: + """Deployed instances that will not come back after a reboot.""" + deployed = inst.deployed(paths) + state = units.states(paths.product, deployed) + return [name for name in deployed if state[name].enabled != "enabled"] diff --git a/src/instrumentctl/product.py b/src/instrumentctl/product.py index 06cdbc1..e0d8bc7 100644 --- a/src/instrumentctl/product.py +++ b/src/instrumentctl/product.py @@ -30,6 +30,8 @@ class Product: # pylint: disable=too-many-instance-attributes """One instrument's names, and the paths and units derived from them.""" name: str + # The instrument's proper name, as it reads in systemctl status + display_name: str user: str ops_group: str env_prefix: str @@ -57,6 +59,8 @@ def load(cls, config: "ConfigSource") -> "Product": help_text = data.get("help", {}) return cls( name=instrument["name"], + display_name=instrument.get("display_name", + instrument["name"]), user=instrument.get("user", instrument["name"]), ops_group=instrument.get("ops_group", f"{instrument['name']}-ops"), env_prefix=instrument.get("env_prefix", instrument["name"].upper()), diff --git a/src/instrumentctl/templates/enable-helper.sh b/src/instrumentctl/templates/enable-helper.sh new file mode 100644 index 0000000..169cfee --- /dev/null +++ b/src/instrumentctl/templates/enable-helper.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Enable (or disable) @name@@ units at boot. +# +# `systemctl enable` needs the org.freedesktop.systemd1.manage-unit-files +# polkit action, which polkit cannot scope to a single unit by name. Granting +# it to @ops_group@ would grant enable/disable on every unit on the host. This +# helper is the narrow alternative: it runs as root via a sudoers drop-in that +# is NOPASSWD for @ops_group@ (see install.sh), and it only ever touches +# @name@@ for a name that already has an instance file deployed. +# +# Operators normally reach it through `@name@ enable` / `@name@ deploy`, which +# call it as `sudo -n @name@-enable ...`. It stays a standalone root-owned +# script rather than part of the CLI because the CLI runs from the venv, which +# the @name@ user can write to, and root must not run code from there. +# +# @name@-enable hsfei_adc hsfei_ms # enable at boot (does not start) +# @name@-enable --now hsfei_adc # enable and start +# @name@-enable --disable hsfei_adc # disable at boot (does not stop) + +set -euo pipefail +export PATH=/usr/sbin:/usr/bin:/sbin:/bin + +INSTANCE_DIR=@etc@/instances + +verb=enable +now=() + +usage() { + awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0" + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --disable) verb=disable; shift ;; + --now) now=(--now); shift ;; + -h|--help) usage 0 ;; + --*) echo "@name@-enable: unknown option $1" >&2; usage 1 ;; + *) break ;; + esac +done + +[[ $# -gt 0 ]] || usage 1 + +# Validate before doing anything, so a typo in the third name does not leave +# the first two half-applied. +for name in "$@"; do + if [[ ! $name =~ ^[a-z][a-z0-9_]*$ ]]; then + echo "@name@-enable: refusing '$name': not a valid instance name" >&2 + exit 1 + fi + if [[ $verb == enable && ! -f "$INSTANCE_DIR/$name.env" ]]; then + echo "@name@-enable: no $INSTANCE_DIR/$name.env; deploy the instance file first" >&2 + exit 1 + fi +done + +for name in "$@"; do + systemctl "$verb" ${now[@]+"${now[@]}"} "@name@@$name.service" +done diff --git a/src/instrumentctl/templates/front-door.sh b/src/instrumentctl/templates/front-door.sh new file mode 100644 index 0000000..c2d696d --- /dev/null +++ b/src/instrumentctl/templates/front-door.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Front door for the @name@ CLI, installed to /usr/local/bin. +# +# The CLI itself lives in the venv, installed editable from the repo, so a +# `git pull` updates it with no reinstall. This wrapper only exists so that a +# missing or broken venv produces an instruction rather than "command not +# found", since `@name@ doctor` is exactly what people run when things break. + +VENV_DIR=${@env_prefix@_VENV_DIR:-@venv@} +REPO_DIR=${@env_prefix@_REPO_DIR:-@repo@} + +if [[ ! -x $VENV_DIR/bin/@name@ ]]; then + echo "@name@: $VENV_DIR/bin/@name@ is missing; the venv needs (re)installing." >&2 + echo " admin: sudo $REPO_DIR/systemd/install.sh" >&2 + exit 1 +fi +exec "$VENV_DIR/bin/@name@" "$@" diff --git a/src/instrumentctl/templates/polkit.rules b/src/instrumentctl/templates/polkit.rules new file mode 100644 index 0000000..9321f4d --- /dev/null +++ b/src/instrumentctl/templates/polkit.rules @@ -0,0 +1,19 @@ +// Let members of the "@ops_group@" group start/stop/restart @name@@* units +// without sudo, while leaving every other systemd unit on the host alone. +// +// Install as /etc/polkit-1/rules.d/49-@name@.rules (picked up automatically +// by polkit, no restart needed). Requires root to install; see install.sh. +// +// Note: this only covers org.freedesktop.systemd1.manage-units (start, +// stop, restart, kill, reset-failed). Polkit cannot scope +// manage-unit-files (enable/disable) to a single unit by name, so those go +// through the @name@-enable helper and its sudoers drop-in instead. +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + subject.isInGroup("@ops_group@")) { + var unit = action.lookup("unit"); + if (unit && unit.indexOf("@name@@") == 0) { + return polkit.Result.YES; + } + } +}); diff --git a/src/instrumentctl/templates/sudoers b/src/instrumentctl/templates/sudoers new file mode 100644 index 0000000..e74727c --- /dev/null +++ b/src/instrumentctl/templates/sudoers @@ -0,0 +1,5 @@ +# Rendered by `@name@ install`. Lets @ops_group@ members enable and disable +# @name@@ units at boot without a password. The helper validates the +# instance name and refuses anything that is not a deployed instance, so this +# does not grant control of other units. +%@ops_group@ ALL=(root) NOPASSWD: /usr/local/sbin/@name@-enable diff --git a/src/instrumentctl/templates/unit.service b/src/instrumentctl/templates/unit.service new file mode 100644 index 0000000..9cff7c6 --- /dev/null +++ b/src/instrumentctl/templates/unit.service @@ -0,0 +1,53 @@ +# Rendered by `@name@ install`. Edits here are lost on the next run; change the +# template in instrumentctl instead. +# +# One template unit runs every daemon. "%i" is the instance name, so +# `systemctl start @name@@` reads @etc@/instances/.env for +# the daemon script (@env_prefix@_DAEMON, relative to daemons/) and the config +# to pass it (@env_prefix@_CONFIG). + +[Unit] +Description=@display_name@ hardware daemon (%i) +Documentation=@docs_url@ +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +User=@user@ +Group=@user@ + +EnvironmentFile=@etc@/instances/%i.env +# Optional, root-only, shared by every instance: for secrets that must not sit +# in the instance files above, since @etc@/instances is group-readable by +# @ops_group@. systemd reads this as root before dropping to User= below. +EnvironmentFile=-@etc@/secrets.env +# Drivers write their own .log next to the working directory, so this has to +# be writable by User= above; the repo checkout is not +WorkingDirectory=@logs@ +ExecStart=@venv@/bin/python3 @repo@/daemons/${@env_prefix@_DAEMON} -c ${@env_prefix@_CONFIG} + +Restart=on-failure +RestartSec=5 +TimeoutStopSec=10 +KillSignal=SIGTERM + +# The daemon logs to stderr/stdout unless its YAML config sets logging.file, +# so by default everything lands in the journal: +# journalctl -u @name@@%i -f +StandardOutput=journal +StandardError=journal +SyslogIdentifier=@name@-%i + +# Basic sandboxing. Loosen ProtectSystem/ProtectHome if a driver needs write +# access outside @etc@, @logs@ (e.g. a serial device node +# under /dev is unaffected by these two). +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target