From ff39567acf766b9106baa4f37899dcc1217b733d Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Fri, 2 Oct 2026 11:43:00 -0700 Subject: [PATCH] Add a hispec CLI so adding a daemon is one command --- docs/architecture/daemons.md | 2 + docs/operations/systemd.md | 251 +++++++++++++-------- src/hispec/__init__.py | 14 +- src/hispec/cli/__init__.py | 110 ++++++++++ src/hispec/cli/__main__.py | 6 + src/hispec/cli/commands.py | 315 +++++++++++++++++++++++++++ src/hispec/cli/doctor.py | 258 ++++++++++++++++++++++ src/hispec/cli/instances.py | 130 +++++++++++ src/hispec/cli/output.py | 10 + src/hispec/cli/units.py | 90 ++++++++ systemd/README.md | 27 ++- systemd/bin/hispec | 17 ++ systemd/bin/hispec-doctor | 165 -------------- systemd/bin/hispec-enable | 5 + systemd/bin/hispec-fei-start | 69 ------ systemd/bin/hispec-fei-stop | 74 ------- systemd/install.sh | 34 +-- systemd/instances/hspower_bspec1.env | 2 +- systemd/instances/hspower_cal1.env | 2 +- systemd/instances/hspower_cal2.env | 2 +- systemd/instances/hspower_cal3.env | 2 +- systemd/instances/hspower_cal4.env | 2 +- systemd/instances/hspower_fei1.env | 2 +- systemd/instances/hspower_fei2.env | 2 +- systemd/instances/hspower_fib1.env | 2 +- systemd/instances/hspower_rspec1.env | 2 +- 26 files changed, 1163 insertions(+), 432 deletions(-) create mode 100644 src/hispec/cli/__init__.py create mode 100644 src/hispec/cli/__main__.py create mode 100644 src/hispec/cli/commands.py create mode 100644 src/hispec/cli/doctor.py create mode 100644 src/hispec/cli/instances.py create mode 100644 src/hispec/cli/output.py create mode 100644 src/hispec/cli/units.py create mode 100755 systemd/bin/hispec delete mode 100755 systemd/bin/hispec-doctor delete mode 100755 systemd/bin/hispec-fei-start delete mode 100755 systemd/bin/hispec-fei-stop diff --git a/docs/architecture/daemons.md b/docs/architecture/daemons.md index 664ee16..860f268 100644 --- a/docs/architecture/daemons.md +++ b/docs/architecture/daemons.md @@ -219,3 +219,5 @@ The mechanics, in the order they are done: config path. 6. Add the row to the instance table in {doc}`../operations/systemd` and to the daemon inventory above. +7. Commit, then on the host `git pull` and `hispec deploy `, which + copies both files into `/etc/hispec`, enables and starts it. diff --git a/docs/operations/systemd.md b/docs/operations/systemd.md index e78592b..b890c19 100644 --- a/docs/operations/systemd.md +++ b/docs/operations/systemd.md @@ -5,8 +5,14 @@ This page is what you need to start, stop, watch and debug them. It assumes no prior systemd knowledge. If you only want the commands, jump to [Everyday commands](#everyday-commands). +To add a daemon to a host, see [Deploying an instance](#deploying-an-instance). If something is broken, jump to [Troubleshooting](#troubleshooting). +Everything an operator does goes through one command, `hispec`. `hispec --help` +lists the subcommands and each takes `--help`. Setting a host up in the first +place is a separate, one-time job for an admin; see +[Host setup](#host-setup-admins). + ## The idea in one minute systemd is the thing on a Linux host that starts programs, keeps them running, @@ -47,7 +53,8 @@ most common source of confusion here. | `enable` | adds it to the set that comes up at boot | at the next boot; `enable` on its own starts nothing | `enable --now` does both. You want `start` for day-to-day work. You want -`enable` once, when an instance is first deployed and should survive reboots. +`enable` once, when an instance is first deployed and should survive reboots; +`hispec deploy` does that for you. ## Everyday commands @@ -56,45 +63,53 @@ are being asked for a password, see [systemctl keeps asking for a password](#systemctl-keeps-asking-for-a-password). ```bash -systemctl status hispec@hsfei_adc # is it up? what was the last log line? -systemctl start hispec@hsfei_adc # start it -systemctl stop hispec@hsfei_adc # stop it -systemctl restart hispec@hsfei_adc # stop then start, e.g. after a config edit - -journalctl -u hispec@hsfei_adc -f # follow the log live (Ctrl-C to quit) -journalctl -u hispec@hsfei_adc -n 100 # the last 100 lines -journalctl -u hispec@hsfei_adc --since "1 hour ago" +hispec status # every deployed daemon: running? at boot? since when? +hispec start hsfei_adc # start it +hispec stop hsfei_adc # stop it +hispec restart hsfei_adc # stop then start, e.g. after a config edit + +hispec logs hsfei_adc -f # follow the log live (Ctrl-C to quit) +hispec logs hsfei_adc -n 500 # the last 500 lines (default 100) +hispec logs hsfei_adc --since "1 hour ago" ``` -To see everything at once: +`hispec status` looks like this: -```bash -systemctl list-units 'hispec@*' # all currently loaded instances -systemctl list-units 'hispec@*' --failed # only the broken ones -systemctl list-unit-files 'hispec@*' # which are set to start at boot ``` +INSTANCE STATE BOOT SINCE DAEMON +hsfei_adc active enabled 2026-09-21 09:14:02 hsfei/adc +hsfei_atcfw failed enabled 2026-09-21 09:20:41 generic/filterwheel +hsfei_ms inactive disabled hsfei/pi-daemon + +20 more in the repo, not deployed here (hispec status --all; hispec deploy --new) +``` + +These are wrappers around `systemctl` and `journalctl`, and the plain commands +still work if you prefer them: `systemctl start hispec@hsfei_adc`, +`journalctl -u hispec@hsfei_adc -f`, `systemctl list-units 'hispec@*'`. -### Starting and stopping the FEI as a group +### Starting and stopping a whole subsystem -The FEI has a dozen daemons and you rarely want just one of them. Two scripts -wrap the whole subsystem: +Wherever `hispec` takes an instance name it also takes a subsystem, written +either in full or without the `hs`, or `all`: ```bash -hispec-fei-start # start every deployed hsfei_* daemon -hispec-fei-stop # stop them all, in reverse order -hispec-fei-start hsfei_adc hsfei_ms # or name the ones you want -hispec-fei-start --dry-run # print what it would do, do nothing +hispec start fei # every deployed hsfei_* daemon +hispec stop fei # all of them, in reverse order +hispec restart cal # every hscal_* daemon +hispec start power fei # PDUs first, then the FEI +hispec stop power fei # FEI first, then the PDUs +hispec start hsfei_adc hsfei_ms # or name the ones you want +hispec start --dry-run fei # print what it would do, do nothing +hispec status fei # just the FEI ``` -They work off whatever is deployed in `/etc/hispec/instances/`, so they stay -correct as instances are added without anyone editing the scripts. Instances -that are already in the desired state are skipped rather than restarted, and -both scripts exit non-zero (after trying every instance) if any of them -failed, so they are safe to use from another script. - -There is no equivalent for `hscal` yet; the same scripts will do it with -`HISPEC_PREFIX=hscal_ hispec-fei-start`, which is a stopgap rather than a -feature. +Names are matched against what is deployed in `/etc/hispec/instances/`, so +these stay correct as instances are added without anyone editing anything. +`start` leaves already-running daemons alone and `stop` skips stopped ones. If +any instance fails, both carry on with the rest and then exit non-zero, so they +are safe to use from another script. `start` goes in the order you name things; +`stop` goes in the reverse order, so a stop undoes a start. ### Reading `systemctl status` @@ -124,62 +139,95 @@ daemon; whether they survive a reboot depends on the host's journal configuration. ```bash -journalctl -u hispec@hsfei_adc -f # live -journalctl -u hispec@hsfei_adc -p err # errors only -journalctl -u 'hispec@*' --since today # every HISPEC daemon at once +hispec logs hsfei_adc -f # live +hispec logs hsfei_adc -p err # errors only +hispec logs fei -f # the whole FEI, interleaved +hispec logs all --since today # every HISPEC daemon at once ``` If a daemon's YAML config sets `logging.file`, that daemon writes to a file under `/var/log/hispec/` instead, and the journal will be nearly empty for it. Reading other users' journal entries requires being in the `systemd-journal` -group. If `journalctl -u hispec@...` prints nothing at all for a daemon you can -see running, that is the reason. Run `hispec-doctor`. +group. If `hispec logs` prints nothing at all for a daemon you can see running, +that is the reason. Run `hispec doctor`. ## Deploying an instance -Two files, then one enable. The first two steps need no `sudo`: +One command, no `sudo`, no `install.sh` and no `systemctl daemon-reload`: ```bash -# 1. the config, with real hardware values filled in -cp /opt/hispec/app/config/hsfei/hsfei_atcpress.yaml /etc/hispec/hsfei_atcpress.yaml +hispec deploy hsfei_atcpress +``` + +``` +hsfei_atcpress + copied /etc/hispec/instances/hsfei_atcpress.env + copied /etc/hispec/hsfei_atcpress.yaml +enabled at boot: hsfei_atcpress -# 2. the instance file that points the template at that config -cp /opt/hispec/app/systemd/instances/hsfei_atcpress.env \ - /etc/hispec/instances/hsfei_atcpress.env +hsfei_atcpress started +``` + +That copies the instance's two files from the repo into `/etc/hispec`, adds it +to the boot set, and starts it. Deploy several at once by naming them, or +deploy everything the repo defines that this host does not have yet: -# 3. start it now and at every boot -hispec-enable --now hsfei_atcpress +```bash +hispec deploy hsfei_adc hsfei_ms +hispec deploy --new # e.g. after a git pull added instances +hispec deploy --new --dry-run # see what that would do first ``` -`hispec-enable` is a small helper that exists because `systemctl enable` cannot -be granted per-unit (see [the note on -enable/disable](#why-enable-is-a-helper-and-not-just-systemctl)). It refuses -any name that does not already have an instance file deployed. +A deployed config is never overwritten by default. Once it is on a host it +holds that host's real ports and addresses, so if it differs from the repo +copy, `deploy` keeps it and says so; `--force` replaces it. The `.env` instance +file is different. It only says which script to run and where the config is, +so it always follows the repo. If a daemon was already running when its files +changed, `deploy` prints the `hispec restart` that picks them up. + +Two more options: `--no-start` copies and enables but does not start, for +example to fill in a config first. `--no-enable` starts the daemon without +adding it to the boot set. + +Why no `daemon-reload`? `hispec@.service` is a template. systemd reads +`/etc/hispec/instances/.env` when the instance starts, not when units are +loaded, so a new instance is just a new file. `daemon-reload` is only needed +when the template itself changes, and `install.sh` does it then. + +### Boot set ```bash -hispec-enable hsfei_adc hsfei_ms # add to the boot set, don't start now -hispec-enable --now hsfei_adc # add to the boot set and start -hispec-enable --disable hsfei_adc # remove from the boot set, leave it running +hispec enable hsfei_adc hsfei_ms # add to the boot set, don't start now +hispec enable --now hsfei_adc # add to the boot set and start +hispec disable hsfei_adc # remove from the boot set, leave it running +hispec disable --now hsfei_adc # remove from the boot set and stop ``` -Stopping and disabling are independent: `systemctl stop` takes a daemon down -until you start it again or the host reboots; `hispec-enable --disable` keeps -it from coming back at boot. +Stopping and disabling are independent: `hispec stop` takes a daemon down +until you start it again or the host reboots; `hispec disable` keeps it from +coming back at boot. `enable` and `disable` go through a small root helper (see +[the note on enable/disable](#why-enable-is-a-helper-and-not-just-systemctl)), +which is why plain `systemctl enable` asks for a password and `hispec enable` +does not. ### Adding a daemon that has no instance file yet -Add the config under `config//` in the repo, write the matching -`systemd/instances/.env`: +In the repo, add the config under `config//.yaml` and write the +matching `systemd/instances/.env`: ```sh HISPEC_DAEMON= HISPEC_CONFIG=/etc/hispec/.yaml ``` -commit both, then deploy them as above. Also add the row to the table in -[Deployed instances](#deployed-instances) and to the inventory in -{doc}`../architecture/daemons`. +Also add the row to the table in [Deployed instances](#deployed-instances) and +to the inventory in {doc}`../architecture/daemons`. Commit, then on the host: + +```bash +git -C /opt/hispec/app pull +hispec deploy +``` ### Secrets @@ -228,13 +276,17 @@ Without it a daemon is refused with `ACCESS_REFUSED ... mechanism PLAIN`. Start here: ```bash -hispec-doctor +hispec doctor ``` -It checks your group membership, the installed unit and polkit rule, the -directories, and every deployed instance, and prints the exact command to fix -whatever it finds. Run it as yourself, not under `sudo`, which would hide the -permission problems it is looking for. +It checks your group membership, the installed unit and polkit rule, whether +you can enable without a password, the directories, and every deployed +instance, and prints the exact command to fix whatever it finds. Run it as +yourself, not under `sudo`, which would hide the permission problems it is +looking for. + +If `hispec` itself says the venv is missing, an admin needs to re-run +`install.sh`. ### systemctl keeps asking for a password @@ -254,15 +306,15 @@ of likelihood: hispec-ops`. An admin adds you with `sudo /opt/hispec/app/systemd/install.sh `; you then have to log out and back in for it to take effect. -2. **You ran `enable`, not `start`.** `systemctl enable` and `disable` need a - different permission that polkit cannot restrict to one unit, so they always - prompt. Use `hispec-enable` instead, which does not. +2. **You ran `systemctl enable`, not `start`.** `systemctl enable` and + `disable` need a different permission that polkit cannot restrict to one + unit, so they always prompt. Use `hispec enable` instead, which does not. 3. **The polkit rule is not installed**, or is the pre-rename copy that still - matches `hispec-daemon@`. `hispec-doctor` reports both; the fix is for an + matches `hispec-daemon@`. `hispec doctor` reports both; the fix is for an admin to re-run `install.sh`. 4. **The host's polkit is older than 0.106.** JavaScript `.rules` files are ignored silently by older versions: no error, just a prompt every time. - `hispec-doctor` checks the version. + `hispec doctor` checks the version. You should never need `sudo` to start, stop, restart or look at a HISPEC daemon. If you do, something in the list above is wrong; please fix it rather @@ -273,7 +325,7 @@ root-owned log files that then break the next non-root start. ```bash systemctl status hispec@ -journalctl -u hispec@ -n 50 +hispec logs ``` Common causes, in the order they bite: @@ -283,8 +335,8 @@ Common causes, in the order they bite: file name exactly. - **`No such file or directory`** on the config: `HISPEC_CONFIG` points somewhere that does not exist. The config has to be deployed to `/etc/hispec/` - separately from the `.env`; copying one and forgetting the other is the usual - mistake. + as well as the `.env`. `hispec deploy ` copies both; copying one by + hand and forgetting the other is the usual mistake. - **A serial port or USB error**: the device is unplugged, powered off, or claimed by another process. Two instances pointing at the same port will do this to each other, and so will a daemon left running from a manual test. @@ -301,7 +353,7 @@ Common causes, in the order they bite: its own. The journal has the traceback: ```bash -journalctl -u hispec@ -n 200 --no-pager +hispec logs -n 200 ``` After 5 failures in 60 seconds systemd stops retrying and leaves the unit @@ -309,13 +361,13 @@ After 5 failures in 60 seconds systemd stops retrying and leaves the unit ```bash systemctl reset-failed hispec@ -systemctl start hispec@ +hispec start ``` ### A config change has not taken effect The config is read at startup. Edit `/etc/hispec/.yaml`, then -`systemctl restart hispec@`. Editing the copy in the repo under +`hispec restart `. Editing the copy in the repo under `config/` changes nothing on a running host; the deployed copy under `/etc/hispec/` is what the daemon reads. @@ -323,12 +375,14 @@ The config is read at startup. Edit `/etc/hispec/.yaml`, then ```bash git -C /opt/hispec/app pull -systemctl restart hispec@ # or hispec-fei-start after a stop +hispec restart # or: hispec restart fei +hispec deploy --new # if the pull added instances ``` -The venv install is editable, so a `pull` is enough unless dependencies -changed, in which case an admin re-runs `install.sh`. Pulling needs write -access to `/opt/hispec/app`, so either run as `hispec` or ask an admin. +The venv install is editable, so a `pull` is enough, and it updates the +`hispec` command too. An admin only needs to re-run `install.sh` if +dependencies or `hispec@.service` changed. Pulling needs write access to +`/opt/hispec/app`, so either run as `hispec` or ask an admin. ## Deployed instances @@ -404,14 +458,24 @@ libby modify hispec.keygrabber.reload=1 # re-read the config file ## Host setup (admins) +This section is for whoever administers the host (IT / systems). It needs root. +Operators never need it to add, start or deploy daemons. + ```bash sudo git clone /opt/hispec/app cd /opt/hispec/app && sudo git submodule update --init --recursive sudo ./systemd/install.sh alice bob # operator usernames ``` -`install.sh` is idempotent. Re-run it after a repo update, to enrol more -operators, or to repair a host. It: +`install.sh` is idempotent. Re-run it: + +- to enrol more operators (`sudo ./systemd/install.sh carol`); +- after a pull that changed dependencies in `pyproject.toml`, or + `hispec@.service`, the polkit rule or `hispec-enable` under `systemd/`; +- to repair a host `hispec doctor` says is broken. + +You do **not** need it to add a daemon or to pick up code changes; +[`hispec deploy`](#deploying-an-instance) and a `git pull` cover those. It: - creates the `hispec-ops` group and the unprivileged `hispec` system user the daemons run as; @@ -421,16 +485,22 @@ operators, or to repair a host. It: rest; - creates a root-only `/etc/hispec/secrets.env`; - creates the venv at `/opt/hispec/venv` with the repo `pip install -e`'d into - it; -- installs `hispec@.service`, the polkit rule, the sudoers drop-in, and the - `hispec-fei-start` / `hispec-fei-stop` / `hispec-doctor` / `hispec-enable` - commands; + it, which also provides the `hispec` CLI; +- installs `hispec@.service` (then runs `daemon-reload`), the polkit rule, the + sudoers drop-in, `/usr/local/bin/hispec` and `/usr/local/sbin/hispec-enable`; +- removes the retired `hispec-fei-start`, `hispec-fei-stop` and `hispec-doctor` + scripts (now `hispec start fei`, `hispec stop fei` and `hispec doctor`); - migrates any instance still running under the old `hispec-daemon@` name, preserving whether it was enabled and whether it was up; - lists instances that are deployed but not set to start at boot. `HISPEC_REPO_DIR` and `HISPEC_VENV_DIR` override the paths. +`/usr/local/bin/hispec` is a tiny wrapper that runs the CLI from the venv. The +CLI lives in `src/hispec/cli/`, uses only the standard library so that +`hispec doctor` works on a half-broken venv, and, being an editable install, +follows `git pull`. + ### What the unit does `hispec@.service` runs the daemon as the unprivileged `hispec` user with @@ -452,10 +522,15 @@ unit name with it. A polkit rule for it would grant `hispec-ops` enable/disable on every unit on the host, which is too much. So enable/disable goes through `/usr/local/sbin/hispec-enable` instead, a root -helper with a `NOPASSWD` sudoers entry scoped to that one path. The helper -validates the instance name against `^[a-z][a-z0-9_]*$` and requires a deployed -instance file before it will touch anything, which is the scoping polkit could -not express. +helper with a `NOPASSWD` sudoers entry scoped to that one path; `hispec enable` +and `hispec deploy` call it with `sudo -n`. The helper validates the instance +name against `^[a-z][a-z0-9_]*$` and requires a deployed instance file before +it will touch anything, which is the scoping polkit could not express. + +The helper is a separate root-owned script rather than part of the `hispec` +CLI on purpose. The CLI runs from the venv, which the `hispec` user can write +to; if root ran it, anyone who could change the venv or the checkout would +have root. The polkit `.rules` format needs polkit 0.106 or newer (Ubuntu 22.04 and later). On an older host it is ignored silently, with no error, and operators diff --git a/src/hispec/__init__.py b/src/hispec/__init__.py index 9551da5..70db4f2 100644 --- a/src/hispec/__init__.py +++ b/src/hispec/__init__.py @@ -1,5 +1,15 @@ -from .daemon import HispecDaemon +"""HISPEC instrument control software.""" __all__ = [ - "HispecDaemon", + "HispecDaemon", # pylint: disable=undefined-all-variable ] + + +def __getattr__(name): + # Imported on first use rather than at package import, so that + # `import hispec.cli` does not pull in libby. The CLI has to keep working + # (`hispec doctor` especially) on a host whose venv is half broken. + if name == "HispecDaemon": + from .daemon import HispecDaemon # pylint: disable=import-outside-toplevel + return HispecDaemon + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/hispec/cli/__init__.py b/src/hispec/cli/__init__.py new file mode 100644 index 0000000..a61de35 --- /dev/null +++ b/src/hispec/cli/__init__.py @@ -0,0 +1,110 @@ +"""``hispec``: deploy, run and inspect the HISPEC daemons on this host. + +One command for operators and developers, replacing the separate +hispec-fei-start / hispec-fei-stop / hispec-doctor scripts. Host setup +(users, groups, unit file, polkit, venv) is still systemd/install.sh, run by +an admin; nothing here needs root. +""" +from __future__ import annotations + +import argparse +from typing import List, Optional + +from . import commands +from .doctor import doctor +from .instances import Paths + +TARGETS_HELP = ("instance names (hsfei_adc), subsystems (hsfei, or just fei), " + "or 'all'") + +EPILOG = """\ +examples: + hispec status what is deployed here, and is it running? + hispec deploy hsfei_newthing copy its files from the repo, enable, start + hispec deploy --new the same for every instance not deployed yet + hispec start fei start every deployed hsfei_* daemon + hispec stop fei stop them, in reverse order + hispec restart hsfei_adc e.g. after editing /etc/hispec/hsfei_adc.yaml + hispec logs hsfei_adc -f follow its log + hispec doctor why isn't it working? + +Every subcommand takes --help. Full guide: + https://caltechopticalobservatories.github.io/hispec/operations/systemd.html +""" + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="hispec", + description="Deploy, run and inspect the HISPEC daemons on this host.", + epilog=EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", metavar="") + + def add(name, func, help_text, description=None): + p = sub.add_parser(name, help=help_text, description=description or help_text, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.set_defaults(func=func) + return p + + p = add("deploy", commands.deploy, + "copy instances from the repo to this host, enable and start them", + "Copy each instance's .env and config from the repo into /etc/hispec, add\n" + "it to the boot set, and start it. No daemon-reload or install.sh needed.\n\n" + "The instance file always follows the repo. A deployed config that differs\n" + "from the repo is kept, since it holds this host's real hardware values;\n" + "pass --force to replace it.") + p.add_argument("names", nargs="*", metavar="name", help="instances to deploy") + p.add_argument("--new", action="store_true", + help="deploy every repo instance not yet deployed here") + p.add_argument("--force", action="store_true", + help="replace deployed configs that differ from the repo") + p.add_argument("--no-start", action="store_true", help="do not start them now") + p.add_argument("--no-enable", action="store_true", help="do not start them at boot") + p.add_argument("-n", "--dry-run", action="store_true", help="print what would happen") + + for verb, text in (("start", "start daemons (already-running ones are left alone)"), + ("stop", "stop daemons, in reverse order (they still start at boot)"), + ("restart", "restart daemons, e.g. after a config edit or git pull")): + p = add(verb, commands.act, text) + p.set_defaults(verb=verb) + p.add_argument("targets", nargs="+", metavar="target", help=TARGETS_HELP) + p.add_argument("-n", "--dry-run", action="store_true", help="print what would happen") + + for verb, text in (("enable", "start daemons at every boot (--now: and start now)"), + ("disable", "stop starting daemons at boot (--now: and stop now)")): + p = add(verb, commands.enable, text) + p.set_defaults(verb=verb) + p.add_argument("targets", nargs="+", metavar="target", help=TARGETS_HELP) + now = "start" if verb == "enable" else "stop" + p.add_argument("--now", action="store_true", help=f"also {now} them now") + p.add_argument("-n", "--dry-run", action="store_true", help="print what would happen") + + p = add("status", commands.status, "show each daemon: running? enabled at boot? since when?") + p.add_argument("targets", nargs="*", metavar="target", help=TARGETS_HELP) + p.add_argument("-a", "--all", action="store_true", + help="include instances defined in the repo but not deployed here") + + p = add("logs", commands.logs, "show daemon logs (journalctl)") + p.add_argument("targets", nargs="+", metavar="target", help=TARGETS_HELP) + p.add_argument("-f", "--follow", action="store_true", help="keep printing new lines") + p.add_argument("-n", "--lines", type=int, help="how many lines (default 100)") + p.add_argument("--since", help='e.g. "1 hour ago", today, "2026-09-21 09:00"') + p.add_argument("-p", "--priority", help="e.g. err, warning") + + add("doctor", doctor, "check this host and your account, and say what to fix") + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + """Entry point for the ``hispec`` console script.""" + parser = _parser() + args = parser.parse_args(argv) + if not getattr(args, "func", None): + parser.print_help() + return 2 + try: + return args.func(Paths.from_env(), args) + except KeyboardInterrupt: + return 130 diff --git a/src/hispec/cli/__main__.py b/src/hispec/cli/__main__.py new file mode 100644 index 0000000..cc1083c --- /dev/null +++ b/src/hispec/cli/__main__.py @@ -0,0 +1,6 @@ +"""python -m hispec.cli""" +import sys + +from . import main + +sys.exit(main()) diff --git a/src/hispec/cli/commands.py b/src/hispec/cli/commands.py new file mode 100644 index 0000000..5c22ad8 --- /dev/null +++ b/src/hispec/cli/commands.py @@ -0,0 +1,315 @@ +"""The operator subcommands: deploy, start/stop/restart, enable/disable, +status and logs. ``doctor`` is in its own module.""" +from __future__ import annotations + +import argparse +import os +import shutil +import sys +from pathlib import Path +from typing import List, NamedTuple, Optional, Tuple + +from . import instances as inst +from . import units +from .instances import Paths, TargetError +from .output import paint + + +def _err(message: str) -> None: + print(f"hispec: {message}", file=sys.stderr) + + +def _resolve(paths: Paths, targets: List[str], names: List[str]) -> Optional[List[str]]: + """Expand targets, explaining a miss. None means an error was printed.""" + try: + return inst.resolve(targets, names) + except TargetError as exc: + target = str(exc) + if target in inst.in_repo(paths): + _err(f"{target} is not deployed on this host; deploy it with: hispec deploy {target}") + else: + _err(f"no deployed instance or subsystem matches '{target}' (see: hispec status --all)") + return None + + +def _install_file(src: Path, dst: Path, overwrite: bool, dry_run: bool) -> str: + """Copy src to dst, replacing a different dst only if ``overwrite``. + + Returns what happened, for printing. + """ + if dst.exists(): + if dst.read_bytes() == src.read_bytes(): + return "unchanged" + if not overwrite: + return "kept" + outcome = "updated" + else: + outcome = "copied" + if dry_run: + return {"copied": "would copy", "updated": "would update"}[outcome] + # Write beside the target and rename, so a daemon starting meanwhile never + # reads half a file. Group-writable so the rest of hispec-ops can edit it; + # the directory is setgid hispec-ops, which takes care of the group. + tmp = dst.with_name(f".{dst.name}.{os.getpid()}.tmp") + try: + shutil.copyfile(src, tmp) + os.chmod(tmp, 0o664) + os.replace(tmp, dst) + finally: + if tmp.exists(): + tmp.unlink() + return outcome + + +class _Deployment(NamedTuple): + name: str + env_src: Path + env_dst: Path + config_src: Path + config_dst: Path + + +def _deploy_plan(paths: Paths, names: List[str]) -> Optional[List[_Deployment]]: + """Work out every copy up front, so a bad name copies nothing at all.""" + plan = [] + problems = 0 + for name in names: + env_src = paths.repo_instances / f"{name}.env" + if not inst.NAME_RE.match(name) or not env_src.is_file(): + _err(f"no {env_src}; is '{name}' a hispec instance? (see: hispec status --all)") + problems += 1 + continue + config = inst.read_env(env_src).get("HISPEC_CONFIG", "") + if not config: + _err(f"{env_src} sets no HISPEC_CONFIG") + problems += 1 + continue + found = inst.repo_configs(paths, name) + if len(found) != 1: + where = ", ".join(str(p) for p in found) or "none" + _err(f"{name}: expected one {paths.repo}/config/*/{name}.yaml, found {where}") + problems += 1 + continue + plan.append(_Deployment(name, env_src, paths.instances / f"{name}.env", + found[0], Path(config))) + return None if problems else plan + + +def _copy(paths: Paths, plan: List[_Deployment], force: bool, dry_run: bool) -> Optional[List[str]]: + """Copy every file in the plan. Returns the instances whose files changed. + + The instance file only points at things, so it always follows the repo. + The config is another matter: once deployed it holds this host's real + ports and addresses, and a silent overwrite would lose them. + """ + updated = [] + for item in plan: + print(item.name) + for src, dst, overwrite in ((item.env_src, item.env_dst, True), + (item.config_src, item.config_dst, force)): + try: + outcome = _install_file(src, dst, overwrite, dry_run) + except OSError as exc: + print() + _err(f"cannot write {dst}: {exc.strerror}") + if isinstance(exc, PermissionError): + _err("deploying needs hispec-ops membership; run: hispec doctor") + return None + note = "" + if outcome == "kept": + where = src.relative_to(paths.repo) + note = paint(f" (differs from {where}; --force replaces it)", "33") + elif outcome == "updated" and item.name not in updated: + updated.append(item.name) + print(f" {outcome:<12} {dst}{note}") + return updated + + +def deploy(paths: Paths, args: argparse.Namespace) -> int: + """Copy instance files and configs from the repo, then enable and start.""" + if args.new: + have = set(inst.deployed(paths)) + names = [n for n in inst.in_repo(paths) if n not in have] + list(args.names) + if not names: + print(f"Nothing to deploy: every instance in {paths.repo_instances} " + "is already deployed.") + return 0 + elif args.names: + names = list(args.names) + else: + _err("name the instances to deploy, or pass --new") + return 2 + + plan = _deploy_plan(paths, list(dict.fromkeys(names))) + if plan is None: + return 1 + updated = _copy(paths, plan, args.force, args.dry_run) + if updated is None: + return 1 + + names = [item.name for item in plan] + rc = 0 + if not args.no_enable: + rc |= _enable(names, [], args.dry_run) + if not args.no_start: + print() + rc |= _act(names, "start", args.dry_run) + + running = units.states(updated) if updated and not args.dry_run else {} + restart = [n for n, s in running.items() if s.running] + if restart: + print() + print("Already running with the old files; to pick up the new ones:") + print(f" hispec restart {' '.join(restart)}") + return rc + + +def _enable(names: List[str], options: List[str], dry_run: bool) -> int: + verb = "disable" if "--disable" in options else "enable" + if dry_run: + print(f"would {verb} at boot: {' '.join(names)}") + return 0 + proc = units.enable_helper([*options, *names]) + if proc.returncode == 0: + print(f"{verb}d at boot: {' '.join(names)}") + return 0 + detail = (proc.stderr or proc.stdout).strip() + if "password is required" in detail or proc.returncode == 127: + _err(f"cannot run {units.ENABLE_HELPER} without a password: {detail}") + _err("an admin needs to add you to hispec-ops, or re-run install.sh; " + "run: hispec doctor") + else: + _err(detail or f"{units.ENABLE_HELPER} failed") + return 1 + + +def enable(paths: Paths, args: argparse.Namespace) -> int: + """Add instances to (or remove them from) the set started at boot.""" + names = _resolve(paths, args.targets, inst.deployed(paths)) + if names is None: + return 1 + options = (["--disable"] if args.verb == "disable" else []) + (["--now"] if args.now else []) + return _enable(names, options, args.dry_run) + + +_DONE = {"start": "started", "stop": "stopped", "restart": "restarted"} + + +def _act(names: List[str], verb: str, dry_run: bool) -> int: + """Apply a verb to every instance, carrying on past failures.""" + state = units.states(names) + failed = [] + for name in names: + if verb == "start" and state[name].active == "active": + print(f"{name:<24} already running") + continue + if verb == "stop" and not state[name].running: + print(f"{name:<24} not running") + continue + if dry_run: + print(f"{name:<24} would {verb}") + continue + if units.systemctl(verb, name): + print(f"{name:<24} {_DONE[verb]}") + else: + print(f"{name:<24} " + paint("FAILED", "31") + f" (hispec logs {name})") + failed.append(name) + if failed: + print() + _err(f"{len(failed)} of {len(names)} failed to {verb}: {' '.join(failed)}") + return 1 + return 0 + + +def act(paths: Paths, args: argparse.Namespace) -> int: + """start/stop/restart instances or whole subsystems.""" + names = _resolve(paths, args.targets, inst.deployed(paths)) + if names is None: + return 1 + if args.verb == "stop": + # Undo a start in the opposite order: `hispec stop power fei` would + # otherwise cut power before the mechanisms had parked. + names.reverse() + return _act(names, args.verb, args.dry_run) + + +_STATE_COLOR = {"active": "32", "failed": "31", "activating": "33", "deactivating": "33"} + + +def status(paths: Paths, args: argparse.Namespace) -> int: + """One line per instance: running? enabled at boot? since when?""" + here = inst.deployed(paths) + repo = inst.in_repo(paths) + universe = sorted(set(here) | set(repo)) if args.all else here + if args.targets: + names = _resolve(paths, args.targets, universe) + if names is None: + return 1 + else: + names = universe + if not names: + print(f"No instances deployed in {paths.instances}.") + print("See what the repo defines with: hispec status --all") + return 0 + + rows = _status_rows(paths, names, here) + width = max(len("INSTANCE"), *(len(r[0]) for r in rows)) + print(f"{'INSTANCE':<{width}} {'STATE':<12} {'BOOT':<8} {'SINCE':<19} DAEMON") + for name, active, enabled, since, daemon in rows: + colored = paint(f"{active:<12}", _STATE_COLOR.get(active, "")) + print(f"{name:<{width}} {colored} {enabled:<8} {since:<19} {daemon}") + + if not args.all and not args.targets: + missing = [n for n in repo if n not in here] + if missing: + print() + print(f"{len(missing)} more in the repo, not deployed here " + "(hispec status --all; hispec deploy --new)") + return 0 + + +def _status_rows(paths: Paths, names: List[str], here: List[str]) -> List[Tuple[str, ...]]: + state = units.states([n for n in names if n in here]) + rows = [] + for name in names: + if name not in here: + daemon = _daemon(paths.repo_instances / f"{name}.env") + rows.append((name, "not deployed", "", "", daemon)) + continue + s = state[name] + since = s.since if s.active != "inactive" else "" + rows.append((name, s.active, s.enabled, since, _daemon(paths.instances / f"{name}.env"))) + return rows + + +def _daemon(env: Path) -> str: + try: + return inst.read_env(env).get("HISPEC_DAEMON", "?") + except OSError: + return "?" + + +def logs(paths: Paths, args: argparse.Namespace) -> int: + """Hand over to journalctl for the selected instances.""" + known = sorted(set(inst.deployed(paths)) | set(inst.in_repo(paths))) + names = _resolve(paths, args.targets, known) + if names is None: + return 1 + cmd = ["journalctl"] + for name in names: + cmd += ["-u", inst.unit(name)] + if args.follow: + cmd.append("-f") + if args.lines is not None: + cmd += ["-n", str(args.lines)] + elif not args.since and not args.follow: + cmd += ["-n", "100"] + if args.since: + cmd += ["--since", args.since] + if args.priority: + cmd += ["-p", args.priority] + try: + os.execvp(cmd[0], cmd) + except FileNotFoundError: + _err("journalctl not found; this host does not look like it runs systemd") + return 1 diff --git a/src/hispec/cli/doctor.py b/src/hispec/cli/doctor.py new file mode 100644 index 0000000..a40402e --- /dev/null +++ b/src/hispec/cli/doctor.py @@ -0,0 +1,258 @@ +"""``hispec doctor``: check this host's setup and say what is wrong. +""" +from __future__ import annotations + +import argparse +import filecmp +import grp +import os +import pwd +import socket +import stat +from pathlib import Path + +from . import instances as inst +from . import units +from .instances import Paths +from .output import paint + +UNIT_FILE = Path("/etc/systemd/system/hispec@.service") +OLD_UNIT_FILE = Path("/etc/systemd/system/hispec-daemon@.service") +POLKIT_RULE = Path("/etc/polkit-1/rules.d/49-hispec.rules") +OLD_POLKIT_RULE = Path("/etc/polkit-1/rules.d/49-hispec-daemons.rules") +SUDOERS = Path("/etc/sudoers.d/hispec-ops") + + +class Report: + """Collects ok/FAIL/warn lines; FAILs decide the exit status.""" + + def __init__(self) -> None: + self.problems = 0 + + @staticmethod + def section(title: str) -> None: + """Start a group of checks.""" + print() + print(title) + + @staticmethod + def ok(text: str) -> None: + """A check that passed.""" + print(f" {paint('ok', '32')} {text}") + + def bad(self, text: str) -> None: + """A check that failed: something an operator needs is missing.""" + print(f" {paint('FAIL', '31')} {text}") + self.problems += 1 + + @staticmethod + def warn(text: str) -> None: + """Worth knowing, but not broken.""" + print(f" {paint('warn', '33')} {text}") + + @staticmethod + def hint(text: str) -> None: + """The command that fixes the line above.""" + print(f" → {text}") + + +def _my_groups() -> set: + names = set() + for gid in os.getgroups(): + try: + names.add(grp.getgrgid(gid).gr_name) + except KeyError: + pass + return names + + +def _group_exists(name: str) -> bool: + try: + grp.getgrnam(name) + return True + except KeyError: + return False + + +def _owner(path: Path) -> str: + st = path.stat() + try: + user = pwd.getpwuid(st.st_uid).pw_name + except KeyError: + user = str(st.st_uid) + try: + group = grp.getgrgid(st.st_gid).gr_name + except KeyError: + group = str(st.st_gid) + return f"{user}:{group} {stat.S_IMODE(st.st_mode):o}" + + +def _version_tuple(text: str) -> tuple: + try: + return tuple(int(p) for p in text.split(".")) + except ValueError: + return () + + +def _account(r: Report) -> None: + r.section("Your account") + me = pwd.getpwuid(os.geteuid()).pw_name + groups = _my_groups() + if os.geteuid() == 0: + r.warn("running as root, so the permission checks below say nothing about operators") + r.hint("re-run as your own user: hispec doctor") + if not _group_exists("hispec-ops"): + r.bad("group hispec-ops does not exist") + r.hint("an admin needs to run systemd/install.sh on this host") + elif "hispec-ops" in groups: + r.ok("you are in hispec-ops") + else: + r.bad("you are NOT in hispec-ops — this is why systemctl asks for a password") + r.hint(f"admin: sudo /opt/hispec/app/systemd/install.sh {me}") + r.hint("then log out and back in (or run: newgrp hispec-ops)") + if "systemd-journal" in groups: + r.ok("you are in systemd-journal (can read other units' logs)") + else: + r.warn("you are not in systemd-journal; hispec logs will be empty") + r.hint(f"admin: sudo usermod -aG systemd-journal {me}") + + +def _install_hint(paths: Paths) -> str: + return f"admin: sudo {paths.repo}/systemd/install.sh" + + +def _unit_and_polkit(r: Report, paths: Paths) -> None: + install = _install_hint(paths) + r.section("Unit and polkit") + repo_unit = paths.repo / "systemd" / "hispec@.service" + 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}") + r.hint(install) + else: + r.bad(f"{UNIT_FILE} is missing") + r.hint(install) + if OLD_UNIT_FILE.is_file(): + r.warn("the old hispec-daemon@.service is still installed") + r.hint(f"{install} # migrates instances to hispec@") + if POLKIT_RULE.is_file(): + r.ok(f"{POLKIT_RULE} installed") + if 'indexOf("hispec@")' not in POLKIT_RULE.read_text(encoding="utf-8"): + r.bad("the installed polkit rule does not match hispec@ units") + r.hint(f"it is probably the pre-rename copy; {install}") + else: + r.bad(f"{POLKIT_RULE} is missing — every start/stop will prompt") + r.hint(install) + if OLD_POLKIT_RULE.is_file(): + r.warn(f"the old {OLD_POLKIT_RULE.name} is still installed (harmless, matches nothing now)") + r.hint(f"admin: sudo rm {OLD_POLKIT_RULE}") + _polkit_daemon(r) + + +def _polkit_daemon(r: Report) -> None: + proc = units.run(["pkaction", "--version"], capture=True) + if proc.returncode == 127: + r.warn("pkaction not found; cannot check the polkit version") + else: + version = (proc.stdout.split() or [""])[-1] + # JavaScript .rules files need polkit 0.106+; older polkit ignores + # them silently and every operator gets an auth prompt instead. + if _version_tuple(version) >= (0, 106): + r.ok(f"polkit {version} supports JavaScript .rules") + else: + r.bad(f"polkit {version or 'unknown'} is too old for .rules files (need 0.106+)") + r.hint("the rule is ignored silently; operators will keep getting auth prompts") + if any(units.run(["systemctl", "is-active", "--quiet", svc]).returncode == 0 + for svc in ("polkit.service", "polkitd.service")): + r.ok("polkit is running") + else: + r.warn("polkit does not look like it is running") + r.hint("admin: sudo systemctl start polkit") + + +def _sudo(r: Report, paths: Paths) -> None: + install = _install_hint(paths) + if not SUDOERS.is_file(): + r.warn(f"no {SUDOERS}; enabling an instance at boot needs an admin") + r.hint(install) + elif os.geteuid() != 0 and "hispec-ops" in _my_groups(): + if units.run(["sudo", "-n", "-l", units.ENABLE_HELPER], capture=True).returncode == 0: + r.ok("you can run hispec enable without a password") + else: + r.bad("sudo will not run hispec-enable for you without a password") + r.hint(f"{SUDOERS} or {units.ENABLE_HELPER} is out of date; {install}") + else: + r.ok(f"{SUDOERS} installed (passwordless hispec enable)") + + +def _paths(r: Report, paths: Paths) -> None: + install = _install_hint(paths) + r.section("Paths") + for directory in (paths.etc, paths.instances, Path("/var/log/hispec")): + if directory.is_dir(): + r.ok(f"{directory} exists ({_owner(directory)})") + else: + r.bad(f"{directory} is missing") + r.hint(install) + if paths.repo.is_dir(): + r.ok(f"{paths.repo} checked out") + else: + r.bad(f"{paths.repo} is missing") + if (paths.venv / "bin" / "python3").exists(): + r.ok(f"{paths.venv} venv present") + else: + r.bad(f"{paths.venv}/bin/python3 is missing") + r.hint(install) + + +def _deployed(r: Report, paths: Paths) -> None: + r.section("Deployed instances") + names = inst.deployed(paths) + if not names: + r.warn(f"no instance files in {paths.instances} — nothing is deployed on this host") + r.hint("hispec deploy # or: hispec deploy --new") + return + state = units.states(names) + for name in names: + env_file = paths.instances / f"{name}.env" + env = inst.read_env(env_file) + script = env.get("HISPEC_DAEMON", "") + config = env.get("HISPEC_CONFIG", "") + repo_env = paths.repo_instances / f"{name}.env" + if not script: + r.bad(f"{name}: {env_file} sets no HISPEC_DAEMON") + elif not (paths.repo / "daemons" / script).is_file(): + r.bad(f"{name}: daemon script {paths.repo}/daemons/{script} does not exist") + elif not config or not Path(config).is_file(): + r.bad(f"{name}: config {config or ''} does not exist") + r.hint(f"hispec deploy {name}") + elif state[name].active == "active": + r.ok(f"{name}: running") + else: + r.warn(f"{name}: {state[name].active}") + r.hint(f"hispec start {name}") + r.hint(f"hispec logs {name}") + if repo_env.is_file() and not filecmp.cmp(env_file, repo_env, shallow=False): + r.warn(f"{name}: deployed instance file differs from {repo_env}") + r.hint(f"hispec deploy {name} # updates the instance file, keeps the config") + elif not repo_env.is_file(): + r.warn(f"{name}: deployed here but not defined in {paths.repo_instances}") + + +def doctor(paths: Paths, _args: argparse.Namespace) -> int: + """Run every check and exit 1 if any FAILed.""" + me = pwd.getpwuid(os.geteuid()).pw_name + print(f"hispec doctor: {socket.gethostname()}, running as {me}") + r = Report() + _account(r) + _unit_and_polkit(r, paths) + _sudo(r, paths) + _paths(r, paths) + _deployed(r, paths) + print() + if r.problems == 0: + print("No problems found.") + return 0 + print(f"{r.problems} problem(s) found — see the FAIL lines above.") + return 1 diff --git a/src/hispec/cli/instances.py b/src/hispec/cli/instances.py new file mode 100644 index 0000000..3820fb1 --- /dev/null +++ b/src/hispec/cli/instances.py @@ -0,0 +1,130 @@ +""" +An instance is a name, e.g. ``hsfei_adc``, with three files behind it: + +- ``/systemd/instances/.env``, which says which daemon script to + run and where its config is deployed; +- ``/config//.yaml``, the config as committed; +- their deployed copies, ``/etc/hispec/instances/.env`` and whatever + path the ``.env`` gives as ``HISPEC_CONFIG``. + +Deployed means the ``.env`` is in ``/etc/hispec/instances/``, since that is +what ``hispec@.service`` reads. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Mapping + +NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + + +def _default_repo_dir() -> Path: + # install.sh does an editable install, so this file is inside the checkout + # the daemons run from. Anything else (a wheel) gets the standard path. + here = Path(__file__).resolve().parents[3] + if (here / "systemd" / "instances").is_dir(): + return here + return Path("/opt/hispec/app") + + +@dataclass(frozen=True) +class Paths: + """The filesystem layout, overridable for testing and odd hosts.""" + + repo: Path + venv: Path + etc: Path + + @classmethod + def from_env(cls, environ: Mapping[str, str] = os.environ) -> "Paths": + """Build from HISPEC_REPO_DIR / HISPEC_VENV_DIR / HISPEC_ETC_DIR.""" + repo = environ.get("HISPEC_REPO_DIR") + return cls( + repo=Path(repo) if repo else _default_repo_dir(), + venv=Path(environ.get("HISPEC_VENV_DIR", "/opt/hispec/venv")), + etc=Path(environ.get("HISPEC_ETC_DIR", "/etc/hispec")), + ) + + @property + def instances(self) -> Path: + """Deployed instance files, read by hispec@.service.""" + return self.etc / "instances" + + @property + def repo_instances(self) -> Path: + """Instance files as committed.""" + return self.repo / "systemd" / "instances" + + +class TargetError(Exception): + """A name on the command line matched nothing.""" + + +def unit(name: str) -> str: + """The systemd unit for an instance.""" + return f"hispec@{name}.service" + + +def _names_in(directory: Path) -> List[str]: + if not directory.is_dir(): + return [] + return sorted(p.stem for p in directory.glob("*.env") if p.is_file()) + + +def deployed(paths: Paths) -> List[str]: + """Instances deployed on this host, sorted.""" + return _names_in(paths.instances) + + +def in_repo(paths: Paths) -> List[str]: + """Instances defined in the repo, sorted.""" + return _names_in(paths.repo_instances) + + +def repo_configs(paths: Paths, name: str) -> List[Path]: + """Committed configs for an instance. Exactly one is the healthy case.""" + return sorted((paths.repo / "config").glob(f"*/{name}.yaml")) + + +def read_env(path: Path) -> Dict[str, str]: + """Parse a systemd EnvironmentFile: KEY=VALUE lines, # comments.""" + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] + values[key.strip()] = value + return values + + +def resolve(targets: Iterable[str], names: List[str]) -> List[str]: + """Expand command-line targets against the instances in ``names``. + + A target is an instance name (``hsfei_adc``), a subsystem prefix + (``hsfei``, or just ``fei``), or ``all``. Targets keep their command-line + order, so ``hispec start power fei`` powers up before it starts the + mechanisms. Duplicates are dropped, and a target matching nothing is an + error. + """ + selected: List[str] = [] + for target in targets: + if target == "all": + hits = list(names) + elif target in names: + hits = [target] + else: + prefix = target.rstrip("_") + "_" + hits = [n for n in names if n.startswith(prefix)] + if not hits and not target.startswith("hs"): + hits = [n for n in names if n.startswith("hs" + prefix)] + if not hits: + raise TargetError(target) + selected.extend(n for n in hits if n not in selected) + return selected diff --git a/src/hispec/cli/output.py b/src/hispec/cli/output.py new file mode 100644 index 0000000..f1b0f16 --- /dev/null +++ b/src/hispec/cli/output.py @@ -0,0 +1,10 @@ +"""Terminal colour, only when stdout is a terminal.""" +import os +import sys + + +def paint(text: str, code: str) -> str: + """Wrap text in an ANSI colour code, or return it as is when piped.""" + if not code or not sys.stdout.isatty() or os.environ.get("NO_COLOR"): + return text + return f"\033[{code}m{text}\033[0m" diff --git a/src/hispec/cli/units.py b/src/hispec/cli/units.py new file mode 100644 index 0000000..2a56c07 --- /dev/null +++ b/src/hispec/cli/units.py @@ -0,0 +1,90 @@ +"""Thin wrappers around systemctl and the root enable helper. + +Everything that touches systemd goes through ``run``, so tests can replace it. +""" +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from typing import Dict, List, Sequence + +from .instances import unit + +# Root-owned and installed by install.sh, with a NOPASSWD sudoers entry for +# hispec-ops. Deliberately not part of this package: the venv is writable by +# the hispec user, so root must never run code from it. +ENABLE_HELPER = "/usr/local/sbin/hispec-enable" + + +def run(cmd: Sequence[str], capture: bool = False) -> subprocess.CompletedProcess: + """Run a command, inheriting the terminal unless ``capture``.""" + try: + return subprocess.run(list(cmd), check=False, text=True, capture_output=capture) + except FileNotFoundError: + message = f"{cmd[0]}: not found" + if not capture: + print(f"hispec: {message}", file=sys.stderr) + return subprocess.CompletedProcess(list(cmd), 127, "", message + "\n") + + +@dataclass(frozen=True) +class UnitState: + """What systemd says about one instance.""" + + active: str # active, inactive, failed, activating, deactivating + enabled: str # enabled, disabled, or empty if systemd does not know it + since: str # when ``active`` last changed, as systemd prints it + + @property + def running(self) -> bool: + """Up, or on its way up.""" + return self.active in ("active", "activating", "reloading") + + +UNKNOWN = UnitState(active="unknown", enabled="", since="") + + +def states(names: List[str]) -> Dict[str, UnitState]: + """Query every instance in one systemctl call.""" + if not names: + return {} + proc = run( + ["systemctl", "show", "--no-pager", + "--property=Id,ActiveState,UnitFileState,StateChangeTimestamp", + *[unit(n) for n in names]], + capture=True, + ) + found = {} + for block in proc.stdout.split("\n\n"): + props = dict(line.split("=", 1) for line in block.splitlines() if "=" in line) + unit_id = props.get("Id", "") + if not unit_id.startswith("hispec@"): + continue + name = unit_id[len("hispec@"):-len(".service")] + found[name] = UnitState( + active=props.get("ActiveState", "unknown"), + enabled=props.get("UnitFileState", ""), + since=_short_time(props.get("StateChangeTimestamp", "")), + ) + return {n: found.get(n, UNKNOWN) for n in names} + + +def _short_time(stamp: str) -> str: + # "Mon 2026-09-21 09:14:02 HST" -> "2026-09-21 09:14:02" + parts = stamp.split() + return " ".join(parts[1:3]) if len(parts) >= 3 else stamp + + +def systemctl(verb: str, name: str) -> bool: + """start/stop/restart one instance. Output goes to the terminal.""" + return run(["systemctl", verb, unit(name)]).returncode == 0 + + +def enable_helper(args: List[str]) -> subprocess.CompletedProcess: + """Run the root enable/disable helper without a password prompt. + + ``-n`` makes sudo fail instead of prompting, so a missing sudoers entry + is reported as such rather than as a mystery password request. + """ + return run(["sudo", "-n", ENABLE_HELPER, *args], capture=True) diff --git a/systemd/README.md b/systemd/README.md index 9a8f2b1..58ea573 100644 --- a/systemd/README.md +++ b/systemd/README.md @@ -14,24 +14,25 @@ directory. | `hispec@.service` | The template unit. One file runs every daemon; `hispec@` is an instance of it. Installed to `/etc/systemd/system/`. | | `instances/.env` | Per-instance settings: `HISPEC_DAEMON` (script, relative to `daemons/`) and `HISPEC_CONFIG` (deployed config path). Deployed to `/etc/hispec/instances/`. | | `polkit/49-hispec.rules` | Lets `hispec-ops` members start/stop/restart `hispec@*` without sudo. Installed to `/etc/polkit-1/rules.d/`. | -| `bin/hispec-fei-start` | Start every deployed `hsfei_*` daemon. Installed to `/usr/local/bin/`. | -| `bin/hispec-fei-stop` | Stop them, in reverse order. Installed to `/usr/local/bin/`. | -| `bin/hispec-doctor` | Diagnose a host or an account. Run this first when something does not work. Installed to `/usr/local/bin/`. | -| `bin/hispec-enable` | Enable/disable instances at boot without a password prompt, which `systemctl enable` cannot be granted per-unit. Installed to `/usr/local/sbin/`, invoked via `sudo` by a NOPASSWD drop-in. | -| `install.sh` | Idempotent host setup: users, groups, directories, venv, and all of the above. Run as root. | +| `bin/hispec` | Wrapper that runs the `hispec` CLI (`src/hispec/cli/`) from the venv. Installed to `/usr/local/bin/`. | +| `bin/hispec-enable` | Root helper behind `hispec enable` / `hispec deploy`: enables/disables instances at boot, which `systemctl enable` cannot be granted per-unit. Installed to `/usr/local/sbin/`, invoked via `sudo -n` by a NOPASSWD drop-in. | +| `install.sh` | Host setup for admins / IT: users, groups, directories, venv, and all of the above. Idempotent, run as root. Not needed to add a daemon. | ## Quick reference ```bash -sudo ./systemd/install.sh alice bob # host setup, enrolling two operators - -systemctl start hispec@hsfei_adc # no sudo, once you are in hispec-ops -journalctl -u hispec@hsfei_adc -f -hispec-fei-start # the whole FEI subsystem -hispec-doctor # why isn't it working? +sudo ./systemd/install.sh alice bob # admin, once per host: setup + two operators + +# operators, no sudo, once in hispec-ops: +hispec deploy hsfei_adc # copy files from the repo, enable, start +hispec deploy --new # everything the repo has that this host doesn't +hispec status +hispec start fei # the whole FEI subsystem +hispec logs hsfei_adc -f +hispec doctor # why isn't it working? ``` -Each of `bin/*` also responds to `--help`. +`hispec --help` lists every subcommand, and each takes `--help`. ## Adding an instance @@ -40,6 +41,8 @@ Each of `bin/*` also responds to `--help`. path. 3. Add the row to the instance table in [the operations page][ops] and to the inventory in `docs/architecture/daemons.md`. +4. Commit; on the host, `git pull` then `hispec deploy `. No `install.sh` + and no `daemon-reload`. Running two of the same hardware model (two Lakeshores, five filter wheels) is two `.env` files pointing at the same script with different configs, not new diff --git a/systemd/bin/hispec b/systemd/bin/hispec new file mode 100755 index 0000000..924c053 --- /dev/null +++ b/systemd/bin/hispec @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Front door for the hispec CLI (src/hispec/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 `hispec doctor` is exactly what people run when things break. + +VENV_DIR=${HISPEC_VENV_DIR:-/opt/hispec/venv} +REPO_DIR=${HISPEC_REPO_DIR:-/opt/hispec/app} + +if [[ ! -x $VENV_DIR/bin/hispec ]]; then + echo "hispec: $VENV_DIR/bin/hispec is missing; the venv needs (re)installing." >&2 + echo " admin: sudo $REPO_DIR/systemd/install.sh" >&2 + exit 1 +fi +exec "$VENV_DIR/bin/hispec" "$@" diff --git a/systemd/bin/hispec-doctor b/systemd/bin/hispec-doctor deleted file mode 100755 index d87c172..0000000 --- a/systemd/bin/hispec-doctor +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -# Check this host's hispec systemd setup and say what is wrong. -# -# hispec-doctor -# -# Exits 0 if everything an operator needs is in place, 1 otherwise. - -set -uo pipefail - -REPO_DIR=${HISPEC_REPO_DIR:-/opt/hispec/app} -VENV_DIR=${HISPEC_VENV_DIR:-/opt/hispec/venv} -INSTANCE_DIR=${HISPEC_INSTANCE_DIR:-/etc/hispec/instances} - -if [[ ${1:-} == -h || ${1:-} == --help ]]; then - awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0" - exit 0 -fi - -problems=0 - -if [[ -t 1 ]]; then - C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_WARN=$'\033[33m'; C_OFF=$'\033[0m' -else - C_OK=; C_BAD=; C_WARN=; C_OFF= -fi - -ok() { printf ' %sok%s %s\n' "$C_OK" "$C_OFF" "$1"; } -bad() { printf ' %sFAIL%s %s\n' "$C_BAD" "$C_OFF" "$1"; problems=$((problems + 1)); } -warn() { printf ' %swarn%s %s\n' "$C_WARN" "$C_OFF" "$1"; } -hint() { printf ' → %s\n' "$1"; } - -echo "hispec-doctor: $(hostname), running as $(id -un)" - -echo -echo "Your account" -if [[ $EUID -eq 0 ]]; then - warn "running as root, so the permission checks below say nothing about operators" - hint "re-run as your own user: hispec-doctor" -fi -if ! getent group hispec-ops >/dev/null 2>&1; then - bad "group hispec-ops does not exist" - hint "an admin needs to run systemd/install.sh on this host" -elif id -nG | tr ' ' '\n' | grep -qx hispec-ops; then - ok "you are in hispec-ops" -else - bad "you are NOT in hispec-ops — this is why systemctl asks for a password" - hint "admin: sudo usermod -aG hispec-ops,systemd-journal $(id -un)" - hint "then log out and back in (or run: newgrp hispec-ops)" -fi -if id -nG | tr ' ' '\n' | grep -qx systemd-journal; then - ok "you are in systemd-journal (can read other units' logs)" -else - warn "you are not in systemd-journal; journalctl -u hispec@... will be empty" - hint "admin: sudo usermod -aG systemd-journal $(id -un)" -fi - -echo -echo "Unit and polkit" -if [[ -f /etc/systemd/system/hispec@.service ]]; then - ok "/etc/systemd/system/hispec@.service installed" - if [[ -f $REPO_DIR/systemd/hispec@.service ]] && - ! cmp -s /etc/systemd/system/hispec@.service "$REPO_DIR/systemd/hispec@.service"; then - warn "installed unit differs from $REPO_DIR/systemd/hispec@.service" - hint "admin: sudo $REPO_DIR/systemd/install.sh" - fi -else - bad "/etc/systemd/system/hispec@.service is missing" - hint "admin: sudo $REPO_DIR/systemd/install.sh" -fi -if [[ -f /etc/systemd/system/hispec-daemon@.service ]]; then - warn "the old hispec-daemon@.service is still installed" - hint "admin: sudo $REPO_DIR/systemd/install.sh migrates instances to hispec@" -fi -if [[ -f /etc/polkit-1/rules.d/49-hispec.rules ]]; then - ok "/etc/polkit-1/rules.d/49-hispec.rules installed" - if ! grep -q 'indexOf("hispec@")' /etc/polkit-1/rules.d/49-hispec.rules; then - bad "the installed polkit rule does not match hispec@ units" - hint "it is probably the pre-rename copy; admin: sudo $REPO_DIR/systemd/install.sh" - fi -else - bad "/etc/polkit-1/rules.d/49-hispec.rules is missing — every start/stop will prompt" - hint "admin: sudo $REPO_DIR/systemd/install.sh" -fi -if [[ -f /etc/polkit-1/rules.d/49-hispec-daemons.rules ]]; then - warn "the old 49-hispec-daemons.rules is still installed (harmless, matches nothing now)" - hint "admin: sudo rm /etc/polkit-1/rules.d/49-hispec-daemons.rules" -fi -if command -v pkaction >/dev/null; then - polkit_version=$(pkaction --version 2>/dev/null | awk '{print $NF}') - # JavaScript .rules files need polkit 0.106+; older polkit ignores them - # silently and every operator gets an auth prompt instead. - if [[ -n $polkit_version ]] && - [[ $(printf '%s\n0.106\n' "$polkit_version" | sort -V | head -1) == "0.106" ]]; then - ok "polkit $polkit_version supports JavaScript .rules" - else - bad "polkit ${polkit_version:-unknown} is too old for .rules files (need 0.106+)" - hint "the rule is ignored silently; operators will keep getting auth prompts" - fi -else - warn "pkaction not found; cannot check the polkit version" -fi -if systemctl is-active --quiet polkit.service 2>/dev/null || - systemctl is-active --quiet polkitd.service 2>/dev/null; then - ok "polkit is running" -else - warn "polkit does not look like it is running" - hint "admin: sudo systemctl start polkit" -fi -if [[ -f /etc/sudoers.d/hispec-ops ]]; then - ok "/etc/sudoers.d/hispec-ops installed (passwordless hispec-enable)" -else - warn "no /etc/sudoers.d/hispec-ops; enabling an instance at boot needs an admin" - hint "admin: sudo $REPO_DIR/systemd/install.sh" -fi - -echo -echo "Paths" -for dir in /etc/hispec "$INSTANCE_DIR" /var/log/hispec; do - if [[ -d $dir ]]; then - ok "$dir exists ($(stat -c '%U:%G %a' "$dir" 2>/dev/null || echo '?'))" - else - bad "$dir is missing" - hint "admin: sudo $REPO_DIR/systemd/install.sh" - fi -done -[[ -d $REPO_DIR ]] && ok "$REPO_DIR checked out" || bad "$REPO_DIR is missing" -[[ -x $VENV_DIR/bin/python3 ]] && ok "$VENV_DIR venv present" || bad "$VENV_DIR/bin/python3 is missing" - -echo -echo "Deployed instances" -shopt -s nullglob -env_files=("$INSTANCE_DIR"/*.env) -shopt -u nullglob -if [[ ${#env_files[@]} -eq 0 ]]; then - warn "no instance files in $INSTANCE_DIR — nothing is deployed on this host" -else - for env_file in "${env_files[@]}"; do - name=${env_file##*/}; name=${name%.env} - config=$(sed -n 's/^HISPEC_CONFIG=//p' "$env_file" | tail -1) - script=$(sed -n 's/^HISPEC_DAEMON=//p' "$env_file" | tail -1) - state=$(systemctl is-active "hispec@$name.service" 2>/dev/null) - if [[ -z $script ]]; then - bad "$name: $env_file sets no HISPEC_DAEMON" - elif [[ ! -f $REPO_DIR/daemons/$script ]]; then - bad "$name: daemon script $REPO_DIR/daemons/$script does not exist" - elif [[ -z $config || ! -f $config ]]; then - bad "$name: config ${config:-} does not exist" - hint "cp $REPO_DIR/config//$name.yaml ${config:-/etc/hispec/$name.yaml}" - elif [[ $state == active ]]; then - ok "$name: running" - else - warn "$name: $state" - hint "hispec-fei-start $name # or: systemctl start hispec@$name" - hint "journalctl -u hispec@$name -n 50" - fi - done -fi - -echo -if [[ $problems -eq 0 ]]; then - echo "No problems found." -else - echo "$problems problem(s) found — see the FAIL lines above." - exit 1 -fi diff --git a/systemd/bin/hispec-enable b/systemd/bin/hispec-enable index 8a226f3..fc08789 100755 --- a/systemd/bin/hispec-enable +++ b/systemd/bin/hispec-enable @@ -8,6 +8,11 @@ # is NOPASSWD for hispec-ops (see install.sh), and it only ever touches # hispec@ for a name that already has an instance file deployed. # +# Operators normally reach it through `hispec enable` / `hispec deploy`, which +# call it as `sudo -n hispec-enable ...`. It stays a standalone root-owned +# script rather than part of the CLI because the CLI runs from the venv, which +# the hispec user can write to, and root must not run code from there. +# # hispec-enable hsfei_adc hsfei_ms # enable at boot (does not start) # hispec-enable --now hsfei_adc # enable and start # hispec-enable --disable hsfei_adc # disable at boot (does not stop) diff --git a/systemd/bin/hispec-fei-start b/systemd/bin/hispec-fei-start deleted file mode 100755 index 6d56e1a..0000000 --- a/systemd/bin/hispec-fei-start +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Start the FEI daemons. -# -# With no arguments it starts every hsfei_* instance deployed on this host, -# i.e. every /etc/hispec/instances/hsfei_*.env. Name instances explicitly to -# start only those. No sudo needed if you are in hispec-ops. -# -# hispec-fei-start # all deployed FEI daemons -# hispec-fei-start hsfei_adc hsfei_ms # just these two -# hispec-fei-start --dry-run # print what would be started -# -# Already-running instances are left alone, not restarted. Exits non-zero if -# any instance failed to come up, after trying all of them. - -set -uo pipefail - -INSTANCE_DIR=${HISPEC_INSTANCE_DIR:-/etc/hispec/instances} -PREFIX=${HISPEC_PREFIX:-hsfei_} -dry_run=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - -n|--dry-run) dry_run=1; shift ;; - -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; exit 0 ;; - -*) echo "hispec-fei-start: unknown option $1" >&2; exit 1 ;; - *) break ;; - esac -done - -if [[ $# -gt 0 ]]; then - instances=("$@") -else - instances=() - for env_file in "$INSTANCE_DIR/$PREFIX"*.env; do - [[ -f $env_file ]] || continue - name=${env_file##*/} - instances+=("${name%.env}") - done -fi - -if [[ ${#instances[@]} -eq 0 ]]; then - echo "hispec-fei-start: no $PREFIX* instances deployed in $INSTANCE_DIR" >&2 - exit 1 -fi - -failed=() -for name in "${instances[@]}"; do - unit="hispec@$name.service" - if [[ $dry_run -eq 1 ]]; then - echo "would start $unit" - continue - fi - if systemctl is-active --quiet "$unit"; then - printf '%-24s already running\n' "$name" - continue - fi - if systemctl start "$unit"; then - printf '%-24s started\n' "$name" - else - printf '%-24s FAILED (journalctl -u %s -n 50)\n' "$name" "$unit" - failed+=("$name") - fi -done - -if [[ ${#failed[@]} -gt 0 ]]; then - echo - echo "${#failed[@]} of ${#instances[@]} failed to start: ${failed[*]}" >&2 - exit 1 -fi diff --git a/systemd/bin/hispec-fei-stop b/systemd/bin/hispec-fei-stop deleted file mode 100755 index 68dd25d..0000000 --- a/systemd/bin/hispec-fei-stop +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# Stop the FEI daemons. -# -# With no arguments it stops every hsfei_* instance deployed on this host, -# i.e. every /etc/hispec/instances/hsfei_*.env, in reverse of start order. -# Name instances explicitly to stop only those. No sudo needed if you are in -# hispec-ops. -# -# hispec-fei-stop # all deployed FEI daemons -# hispec-fei-stop hsfei_adc hsfei_ms # just these two -# hispec-fei-stop --dry-run # print what would be stopped -# -# This stops the units for now; they still start at boot. Use -# `hispec-enable --disable ` to take one out of the boot set. - -set -uo pipefail - -INSTANCE_DIR=${HISPEC_INSTANCE_DIR:-/etc/hispec/instances} -PREFIX=${HISPEC_PREFIX:-hsfei_} -dry_run=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - -n|--dry-run) dry_run=1; shift ;; - -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; exit 0 ;; - -*) echo "hispec-fei-stop: unknown option $1" >&2; exit 1 ;; - *) break ;; - esac -done - -if [[ $# -gt 0 ]]; then - instances=("$@") -else - instances=() - for env_file in "$INSTANCE_DIR/$PREFIX"*.env; do - [[ -f $env_file ]] || continue - name=${env_file##*/} - instances+=("${name%.env}") - done - # Reverse, so a stop undoes a start in the opposite order. - for ((i = 0, j = ${#instances[@]} - 1; i < j; i++, j--)); do - tmp=${instances[i]}; instances[i]=${instances[j]}; instances[j]=$tmp - done -fi - -if [[ ${#instances[@]} -eq 0 ]]; then - echo "hispec-fei-stop: no $PREFIX* instances deployed in $INSTANCE_DIR" >&2 - exit 1 -fi - -failed=() -for name in "${instances[@]}"; do - unit="hispec@$name.service" - if [[ $dry_run -eq 1 ]]; then - echo "would stop $unit" - continue - fi - if ! systemctl is-active --quiet "$unit"; then - printf '%-24s not running\n' "$name" - continue - fi - if systemctl stop "$unit"; then - printf '%-24s stopped\n' "$name" - else - printf '%-24s FAILED to stop (journalctl -u %s -n 50)\n' "$name" "$unit" - failed+=("$name") - fi -done - -if [[ ${#failed[@]} -gt 0 ]]; then - echo - echo "${#failed[@]} of ${#instances[@]} failed to stop: ${failed[*]}" >&2 - exit 1 -fi diff --git a/systemd/install.sh b/systemd/install.sh index 8ca16c4..b9de376 100755 --- a/systemd/install.sh +++ b/systemd/install.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash -# One-time host setup for running the hispec daemons under systemd. +# Host setup for running the hispec daemons under systemd. For admins / IT. # Run as root. Safe to re-run: it converges the host onto the current repo. # # sudo ./systemd/install.sh # set the host up # sudo ./systemd/install.sh alice bob # ... and enrol two operators # sudo HISPEC_OPS_USERS="alice bob" ./systemd/install.sh # same thing # +# Re-run it to enrol operators, after a dependency or unit-file change, or to +# repair a host. It is NOT part of adding a daemon: once a host is set up, +# operators do that themselves with `hispec deploy `, no root needed. +# # Operators named here are added to hispec-ops and systemd-journal, which is # what lets them start/stop/restart daemons and read logs without a password. # Skipping that step is the single most common reason systemctl keeps @@ -99,12 +103,15 @@ install -m 0644 "$REPO_DIR/systemd/hispec@.service" /etc/systemd/system/hispec@. [[ -d /etc/polkit-1/rules.d ]] || install -d -m 0750 /etc/polkit-1/rules.d install -m 0644 "$REPO_DIR/systemd/polkit/49-hispec.rules" /etc/polkit-1/rules.d/49-hispec.rules -# Operator commands. +# Operator commands. `hispec` is a thin wrapper around the CLI in the venv, +# so the CLI follows `git pull` without a reinstall. hispec-enable is a copy, +# not a link into the repo: it runs as root, so it must not be something the +# hispec user or a `git pull` can change. install -d -m 0755 /usr/local/bin /usr/local/sbin -install -m 0755 "$REPO_DIR/systemd/bin/hispec-fei-start" /usr/local/bin/hispec-fei-start -install -m 0755 "$REPO_DIR/systemd/bin/hispec-fei-stop" /usr/local/bin/hispec-fei-stop -install -m 0755 "$REPO_DIR/systemd/bin/hispec-doctor" /usr/local/bin/hispec-doctor -install -m 0755 "$REPO_DIR/systemd/bin/hispec-enable" /usr/local/sbin/hispec-enable +install -m 0755 "$REPO_DIR/systemd/bin/hispec" /usr/local/bin/hispec +install -m 0755 "$REPO_DIR/systemd/bin/hispec-enable" /usr/local/sbin/hispec-enable +# Retired in favour of `hispec start|stop|doctor`. +rm -f /usr/local/bin/hispec-fei-start /usr/local/bin/hispec-fei-stop /usr/local/bin/hispec-doctor # Passwordless enable/disable, scoped to that one helper. Polkit cannot scope # manage-unit-files to a unit name, so `systemctl enable hispec@x` would @@ -177,20 +184,21 @@ done if [[ -n $pending ]]; then echo echo "Deployed but not set to start at boot:$pending" - echo " hispec-enable --now$pending" + echo " hispec enable --now$pending" fi cat <, and hispec-fei-start/stop - - hispec-enable [--now|--disable] - - journalctl -u hispec@ +Operators in hispec-ops can now, with no password and no further install.sh: + - hispec deploy add a daemon (or: hispec deploy --new) + - hispec start|stop|restart + - hispec enable|disable + - hispec status, hispec logs + - edit /etc/hispec/*.yaml To enrol more operators later: sudo $REPO_DIR/systemd/install.sh ... -If systemctl still asks for a password, run 'hispec-doctor' as that user. +If anything still asks for a password, run 'hispec doctor' as that user. Full instructions: docs/operations/systemd.md EOF diff --git a/systemd/instances/hspower_bspec1.env b/systemd/instances/hspower_bspec1.env index e958998..1096b80 100644 --- a/systemd/instances/hspower_bspec1.env +++ b/systemd/instances/hspower_bspec1.env @@ -1,6 +1,6 @@ # HISPEC BSPEC PDU (blueeaton1) — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_bspec1 +# hispec deploy hspower_bspec1 # # Deploy the config alongside this file: # config/hspower/hspower_bspec1.yaml -> /etc/hispec/hspower_bspec1.yaml diff --git a/systemd/instances/hspower_cal1.env b/systemd/instances/hspower_cal1.env index a4bd3b5..9ed89e0 100644 --- a/systemd/instances/hspower_cal1.env +++ b/systemd/instances/hspower_cal1.env @@ -1,6 +1,6 @@ # HISPEC CAL PDU 1 of 4 — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_cal1 +# hispec deploy hspower_cal1 # # Deploy the config alongside this file: # config/hspower/hspower_cal1.yaml -> /etc/hispec/hspower_cal1.yaml diff --git a/systemd/instances/hspower_cal2.env b/systemd/instances/hspower_cal2.env index bc135f6..565b009 100644 --- a/systemd/instances/hspower_cal2.env +++ b/systemd/instances/hspower_cal2.env @@ -1,6 +1,6 @@ # HISPEC CAL PDU 2 of 4 — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_cal2 +# hispec deploy hspower_cal2 # # Deploy the config alongside this file: # config/hspower/hspower_cal2.yaml -> /etc/hispec/hspower_cal2.yaml diff --git a/systemd/instances/hspower_cal3.env b/systemd/instances/hspower_cal3.env index f9971f0..5e1e75c 100644 --- a/systemd/instances/hspower_cal3.env +++ b/systemd/instances/hspower_cal3.env @@ -1,6 +1,6 @@ # HISPEC CAL PDU 3 of 4 — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_cal3 +# hispec deploy hspower_cal3 # # Deploy the config alongside this file: # config/hspower/hspower_cal3.yaml -> /etc/hispec/hspower_cal3.yaml diff --git a/systemd/instances/hspower_cal4.env b/systemd/instances/hspower_cal4.env index ea82023..1bac271 100644 --- a/systemd/instances/hspower_cal4.env +++ b/systemd/instances/hspower_cal4.env @@ -1,6 +1,6 @@ # HISPEC CAL PDU 4 of 4 — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_cal4 +# hispec deploy hspower_cal4 # # Deploy the config alongside this file: # config/hspower/hspower_cal4.yaml -> /etc/hispec/hspower_cal4.yaml diff --git a/systemd/instances/hspower_fei1.env b/systemd/instances/hspower_fei1.env index ff012ca..74b3526 100644 --- a/systemd/instances/hspower_fei1.env +++ b/systemd/instances/hspower_fei1.env @@ -1,6 +1,6 @@ # HISPEC FEI PDU 1 of 2 (feieaton1) — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_fei1 +# hispec deploy hspower_fei1 # # Deploy the config alongside this file: # config/hspower/hspower_fei1.yaml -> /etc/hispec/hspower_fei1.yaml diff --git a/systemd/instances/hspower_fei2.env b/systemd/instances/hspower_fei2.env index bef7609..3db7d23 100644 --- a/systemd/instances/hspower_fei2.env +++ b/systemd/instances/hspower_fei2.env @@ -1,6 +1,6 @@ # HISPEC FEI PDU 2 of 2 (feieaton2) — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_fei2 +# hispec deploy hspower_fei2 # # Deploy the config alongside this file: # config/hspower/hspower_fei2.yaml -> /etc/hispec/hspower_fei2.yaml diff --git a/systemd/instances/hspower_fib1.env b/systemd/instances/hspower_fib1.env index 7ff6de0..596fe8c 100644 --- a/systemd/instances/hspower_fib1.env +++ b/systemd/instances/hspower_fib1.env @@ -1,6 +1,6 @@ # HISPEC FIB PDU — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_fib1 +# hispec deploy hspower_fib1 # # Deploy the config alongside this file: # config/hspower/hspower_fib1.yaml -> /etc/hispec/hspower_fib1.yaml diff --git a/systemd/instances/hspower_rspec1.env b/systemd/instances/hspower_rspec1.env index 84631fd..9d3d92a 100644 --- a/systemd/instances/hspower_rspec1.env +++ b/systemd/instances/hspower_rspec1.env @@ -1,6 +1,6 @@ # HISPEC RSPEC PDU — Eaton EMAT-08/10 networked PDU (hspower) # -# hispec-enable --now hspower_rspec1 +# hispec deploy hspower_rspec1 # # Deploy the config alongside this file: # config/hspower/hspower_rspec1.yaml -> /etc/hispec/hspower_rspec1.yaml