docs: record the live UPS command state after real-hardware verification
AGENTS.md gap 1 said the buttons were read-only in practice because no upsd.users account had `instcmds`. That is no longer true: the login verbs, the command catalog, and the `[dashboards]` account have each been fixed and verified against both real UPSes. The rewrite keeps the parts that stay load-bearing: `instcmds = all` means the account may send every advertised command including the power-cutting ones, and `available_commands()` filters `LIST CMD` (which upsd serves unfiltered) — NUT has no call for a user's privileges, so the risk tiers and the typed `DANGER` are the control, not decoration. Also records what was proven reversibly (`beeper.enable` -> OK, `driver.killpower` refused without `DANGER`), why a battery test was not run, and the rules for editing upsd.users without dropping the account `upsmon` authenticates as. README: `instcmds` is one command per line per `man 5 upsd.users`, the fleet runs `instcmds = all`, and authentication uses `USERNAME`/`PASSWORD`. LLM-Generated: true
This commit is contained in:
@@ -130,7 +130,10 @@ Responsibilities, one line each:
|
|||||||
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
||||||
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||||
a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
|
a mystery score), `COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||||
|
(drivers answer two names for the same action — `beeper.enable`/`beeper.on`,
|
||||||
|
`beeper.disable`/`beeper.off` — so one entry is offered per action and any of its names hits
|
||||||
|
that entry's verdict, never a gate the rendered name missed), `available_commands()` filtering,
|
||||||
kelvin-vs-celsius normalisation, and `charger_status` normalised to the `CHARGER_STATUS`
|
kelvin-vs-celsius normalisation, and `charger_status` normalised to the `CHARGER_STATUS`
|
||||||
keys (the same lowercase token `states()` and `on_battery` already compare), and `states()`
|
keys (the same lowercase token `states()` and `on_battery` already compare), and `states()`
|
||||||
publishes one chip per fact — a driver exporting both `battery.charger.status` and the
|
publishes one chip per fact — a driver exporting both `battery.charger.status` and the
|
||||||
@@ -463,20 +466,43 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
|||||||
|
|
||||||
## Known gaps / next work
|
## Known gaps / next work
|
||||||
|
|
||||||
1. **UPS instant commands are read-only in practice.** Reads work against real upsd (see
|
1. **UPS commands work, and the account can now cut power — the app's gates are the control.**
|
||||||
above), but every button is gated off with "NUT_USER / NUT_PASSWORD must name a user with
|
Three defects were cleared on 2026-10-17, each verified against real hardware. (a) `NutClient.login()` sent `USER`/`PASS`, which the deployed
|
||||||
`actions` in upsd.users" because no such user exists yet. Creating one safely needs a
|
upsd 2.8.5 does not implement — each answered `ERR UNKNOWN-COMMAND`, so `_read_line()`
|
||||||
*read* of `/etc/ups/upsd.users` first — `sudo tee` can write the file but overwriting it
|
raised and `POST /api/ups/command` could only 502 before `instcmd()`, credentials or not.
|
||||||
blind would delete the users upsmon already authenticates as, which can take the box down
|
It now sends `USERNAME`/`PASSWORD`, the pair the server's own `HELP` advertises
|
||||||
at the wrong moment. `deploy/nut-sudoers` is the installable ruleset that makes that
|
(`HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS`), pinned
|
||||||
read (and only a scoped write + reload) possible — it carries the
|
by `test_login_uses_the_verbs_upsd_2_8_5_answers_to` — the fake upsd has no `USER`/`PASS`
|
||||||
`ops ALL=(root) NOPASSWD: NUT_READ, NUT_WRITE` grant and is installed with
|
branch, so a regression answers `ERR UNKNOWN-COMMAND` exactly like the real thing.
|
||||||
`sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`,
|
(b) 12 of the 23 commands the two real UPSes advertise had **no `COMMANDS` entry**, and
|
||||||
but it is **repo-side only so far**: the live UPS cards still answer "NUT_USER /
|
`available_commands()` drops anything the catalog misses — the page silently offered less
|
||||||
NUT_PASSWORD must name a user with `actions` in upsd.users" (verified through
|
than the hardware does. Every advertised command is catalogued now, each with a risk tier:
|
||||||
`/api/ups/snapshot`). Once it is installed: read `upsd.users`, add `[dashboards]` with
|
`test_every_command_the_hardware_advertises_is_catalogued` fails if a capture gains a
|
||||||
`instcmds` limited to the battery tests, put the credentials in `.env.dev`, and the
|
command the catalog does not know, and
|
||||||
buttons come alive. Never grant `actions = -`, and never write the file blind.
|
`test_every_command_that_can_cut_power_is_in_the_dangerous_tier` keeps `load.off`,
|
||||||
|
`driver.killpower` and the `shutdown.*` sequence in the tier that types `DANGER` and is
|
||||||
|
refused on battery. `shutdown.stop` is the exception the name hides — it *cancels* a
|
||||||
|
shutdown, so it is `safe`, and the test says so in words.
|
||||||
|
(c) `[dashboards]` in `/etc/ups/upsd.users` is `instcmds = all` at the operator's request,
|
||||||
|
so this app may send every advertised command on both UPSes, power-cutting ones included.
|
||||||
|
`.env.dev` (mode `0600`, gitignored) carries the credentials.
|
||||||
|
What remains to know:
|
||||||
|
- **Proven live, and reversibly.** `beeper.enable` returned `OK` and `ups.beeper.status`
|
||||||
|
read `enabled` on the APC afterwards; `driver.killpower` without the word `DANGER` still
|
||||||
|
answers 422 and never reaches the wire. A battery test has deliberately not been run —
|
||||||
|
it puts real load on a battery things are plugged into, which stays the operator's call.
|
||||||
|
- **`enabled: true` cannot mean "this account may send it".** `available_commands()`
|
||||||
|
filters on `LIST CMD`, which upsd serves **unfiltered**, and NUT has no call for a
|
||||||
|
user's privileges — so the wire cannot tell the page what the account is granted. With
|
||||||
|
`instcmds = all` the two agree by configuration rather than by check, which is precisely
|
||||||
|
why the risk tiers and the typed `DANGER` are load-bearing and not decoration.
|
||||||
|
- **Pre-existing upsd log noise:** every upsd start logs `Unknown upsmon type =` (20
|
||||||
|
occurrences over 12 starts, oldest before this work). Harmless — `upsmon` logs into both
|
||||||
|
UPSes right after it — but unexplained.
|
||||||
|
Editing `upsd.users` at all: never grant `actions = -`, and never write it blind — the
|
||||||
|
running `upsmon` authenticates as its own account from that same file for both UPSes, so
|
||||||
|
dropping it means no orderly power-down. Copy the untouched sections from the live bytes
|
||||||
|
and `cmp` them before writing; `tee` truncates in place, so owner and mode survive.
|
||||||
2. **GPU coverage is AMD only.** `host_agent.gpus()` reads amdgpu DRM sysfs
|
2. **GPU coverage is AMD only.** `host_agent.gpus()` reads amdgpu DRM sysfs
|
||||||
(`gpu_busy_percent`, `mem_info_vram_*`, hwmon temps, clocks). That covers the current
|
(`gpu_busy_percent`, `mem_info_vram_*`, hwmon temps, clocks). That covers the current
|
||||||
fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at
|
fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at
|
||||||
|
|||||||
@@ -249,9 +249,23 @@ not `actions = -`:
|
|||||||
# /etc/ups/upsd.users
|
# /etc/ups/upsd.users
|
||||||
[dashboards]
|
[dashboards]
|
||||||
password = "..."
|
password = "..."
|
||||||
instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
|
instcmds = test.battery.start.quick
|
||||||
|
instcmds = test.battery.start.deep
|
||||||
|
instcmds = test.battery.stop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
One command per `instcmds` line — that is what `man 5 upsd.users` specifies ("to specify
|
||||||
|
multiple commands, use multiple instances of the instcmds field"), and a comma-separated list
|
||||||
|
on one line is not that. `instcmds = all` is the alternative, and the fleet runs that: every
|
||||||
|
command the drivers advertise, including the ones that cut output power. Nothing about the
|
||||||
|
gates changes — the risk tier, the typed `DANGER`, `NUT_ALLOW_ACTIONS` and the on-battery
|
||||||
|
refusal are what prevent a click from becoming an outage, because upsd gives the dashboard no
|
||||||
|
way to ask what an account is allowed to send.
|
||||||
|
|
||||||
|
upsd authenticates with `USERNAME` + `PASSWORD` (see `HELP` on your own server for the verbs
|
||||||
|
it accepts — `USER`/`PASS` are not among them in 2.8.5); a user needs `instcmds`, never
|
||||||
|
`actions = -`.
|
||||||
|
|
||||||
`deploy/nut-sudoers` is the ruleset that lets the `ops` account read (and scoped-write) those
|
`deploy/nut-sudoers` is the ruleset that lets the `ops` account read (and scoped-write) those
|
||||||
files: `sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`.
|
files: `sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`.
|
||||||
Read `upsd.users` before you write it — `upsmon` authenticates from the same file, so
|
Read `upsd.users` before you write it — `upsmon` authenticates from the same file, so
|
||||||
|
|||||||
Reference in New Issue
Block a user