Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Everything except `name` is optional.
| key | default | what it does |
| --- | --- | --- |
| `name` | required | the command, the unit (`<name>@.service`), and `/opt/<name>/app`, `/opt/<name>/venv`, `/etc/<name>` |
| `display_name` | `name` | the instrument's proper name, as it reads in `systemctl status` |
| `user` | `name` | the account the daemons run as |
| `ops_group` | `<name>-ops` | the group that may start, stop and deploy without a password |
| `env_prefix` | `NAME` | instance files set `<PREFIX>_DAEMON`, `_CONFIG` and `_HOST`; operators override paths with `<PREFIX>_REPO_DIR`, `_VENV_DIR`, `_ETC_DIR` |
Expand All @@ -70,5 +71,24 @@ An instance is a name with three files behind it: `<repo>/systemd/instances/
<name>.yaml`, and their deployed copies under `/etc/<name>`. Deployed means the
`.env` is in `/etc/<name>/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

`<name> install`, as root, converges a host: it creates the ops group and the
daemon account, enrols operators, makes `/etc/<name>`, `/etc/<name>/instances`
and `/var/log/<name>` 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/<name>" 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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ dependencies = [
where = ["src"]
include = ["instrumentctl"]

[tool.setuptools.package-data]
"instrumentctl" = ["templates/*"]

[tool.pylint.format]
max-line-length = 100

Expand Down
11 changes: 11 additions & 0 deletions src/instrumentctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from . import commands
from .doctor import doctor
from .installer import install
from .instances import Paths
from .product import ConfigSource, Product

Expand Down Expand Up @@ -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 <etc>/host (default: short hostname)")
return parser


Expand Down
14 changes: 8 additions & 6 deletions src/instrumentctl/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from . import instances as inst
from . import units
from .installer import _render
from .instances import Paths
from .output import paint

Expand Down Expand Up @@ -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")
Expand All @@ -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 "
Expand Down
228 changes: 228 additions & 0 deletions src/instrumentctl/installer.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions src/instrumentctl/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down
Loading
Loading