chore(agent): add phase 10 — dev dials the fleet directly
Six tasks that make dev the prod topology: the documented `ops`-installable agent sudoers grant, installing the agents as systemd services, the upsd ACL for the host and laptop ranges, `.env.dev` pointing at the real hosts (token in the URL userinfo, `NUT_PORT=3051`), retiring `scripts/dev-tunnels.sh`, and a live + browser verification pass. No dashboard Python changes — deployment, config, scripts and docs only. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
# Phase 10 — Dev dials the fleet directly (no more SSH tunnels)
|
||||||
|
|
||||||
|
**Source:** user request (2026-10) — "I don't want this separation of architectures. My laptop can
|
||||||
|
talk to these hosts directly via local network or vpn already. Just have dev work like prod."
|
||||||
|
**Story:** n/a
|
||||||
|
**Context:** `AGENTS.md` (dev workflow: `scripts/dev-tunnels.sh`, `DEV_TUNNELS`/`DEV_FORWARDS`),
|
||||||
|
`deploy/install-agent.sh`, `deploy/nut-sudoers`, `scripts/dev-server.sh`,
|
||||||
|
`scripts/capture-fixtures.sh`, `tests/test_live_fleet.py`, `.env.dev.example`, `.env.example`,
|
||||||
|
`README.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Dev becomes the prod topology: each host's agent runs as a **systemd service** (installed by
|
||||||
|
`ops` through a documented minimal sudoers grant), bound to the address the dashboard dials,
|
||||||
|
token in the URL userinfo; driveripper's upsd ACLs the host and laptop IP ranges on port 3051;
|
||||||
|
`.env.dev` points at the real hosts with no `DEV_TUNNELS`/`DEV_FORWARDS`; `scripts/dev-tunnels.sh`
|
||||||
|
is retired. The dashboard's Python is untouched — this phase changes deployment, config, scripts
|
||||||
|
and docs, and proves parity with the live suite plus browser screenshots.
|
||||||
|
|
||||||
|
## Decisions (user, 2026-10)
|
||||||
|
|
||||||
|
- The agent is a **systemd service, installed by the `ops` user** (not root). The repo documents
|
||||||
|
the **simplest possible working sudoers addition** in `deploy/agent-sudoers`, and the user adds
|
||||||
|
it to each host.
|
||||||
|
- The upsd ACL allows the **host's IP range and the laptop's IP range** — both real ranges,
|
||||||
|
derived at install time and commented in `.env.dev` so they can be re-derived when the laptop's
|
||||||
|
IP changes.
|
||||||
|
|
||||||
|
## Precondition (user action, before task 02)
|
||||||
|
|
||||||
|
The sudoers line needs root, and the command-scoped sudo on driveripper does not include
|
||||||
|
`visudo`. The user adds the one line from `deploy/agent-sudoers` on each host. If it is missing
|
||||||
|
when task 02 runs, that task must **stop cleanly and print the exact instructions** — never loop,
|
||||||
|
never wait, never attempt to edit sudoers itself.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `05_ups_hero_battery_glyph` (complete) — the last UI phase; its pages are what this phase
|
||||||
|
re-verifies against live data after the transport change.
|
||||||
|
- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`,
|
||||||
|
`09_disks_smartctl` (todo) — independent: this phase changes transport and ops, not payloads or
|
||||||
|
page behaviour, and they do not depend on the transport.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. `01_sudoers_grant_and_installer_hint.md` — `deploy/agent-sudoers` (the minimal working grant
|
||||||
|
+ install instructions, in the `nut-sudoers` house style); `install-agent.sh` run without
|
||||||
|
root prints the exact line to add.
|
||||||
|
2. `02_install_agents_on_fleet.md` — on all three hosts: kill the ad-hoc agent, stage the
|
||||||
|
installer, install the systemd service bound to the dialed address, capture the printed
|
||||||
|
tokens, verify from the laptop.
|
||||||
|
3. `03_upsd_acl_for_laptop.md` — driveripper: append ACL lines for the host range and the laptop
|
||||||
|
range to `upsd.conf` (read-first, `cmp`-after, never blind-write), restart `nut-server`, verify
|
||||||
|
the NUT wire from the laptop.
|
||||||
|
4. `04_env_dev_direct_dial.md` — stop the tunnels; rewrite the local `.env.dev` to direct targets
|
||||||
|
(userinfo tokens, `NUT_HOST`/`NUT_PORT=3051`); restart the dev server; sources ok.
|
||||||
|
5. `05_retire_tunnels_update_scripts.md` — delete `scripts/dev-tunnels.sh`; rewrite
|
||||||
|
`.env.dev.example` direct-shaped; fix `capture-fixtures.sh`, the live suite's docstring and
|
||||||
|
skip messages, `ups_contract.py`, the `alerts.py` docstring, `test_config.py`, `.env.example`'s
|
||||||
|
NUT note, and the dev-workflow sections of `README.md`/`AGENTS.md`.
|
||||||
|
6. `06_verify_direct_dial_live.md` — full live verification: `/api/health` ok over direct dial,
|
||||||
|
`uv run pytest -m live --live` green, browser screenshots of `/` + `/system` + `/ups` + `/llm`
|
||||||
|
on live data, no tunnel port needed on the laptop.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- The Python changes in this phase are test-file strings and one docstring — no new logic. The
|
||||||
|
full suite must stay 100% green and overall coverage must not drop (gate: `--cov-fail-under=94`).
|
||||||
|
- `bash -n` on `deploy/install-agent.sh` and `scripts/capture-fixtures.sh` after their edits.
|
||||||
|
- The real proof of this phase is the live path: `uv run pytest -m live --live` against the
|
||||||
|
directly-dialed fleet (the same production code paths over the prod-shaped transport) plus a
|
||||||
|
browser screenshot per `AGENTS.md` rule 10.
|
||||||
|
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in
|
||||||
|
the project, not just the phase's — pre-existing failures included) and the coverage gate in
|
||||||
|
`.agents/validate.sh` holds.
|
||||||
|
|
||||||
|
## Documentation & Consistency
|
||||||
|
|
||||||
|
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands
|
||||||
|
after this phase — fixing every inconsistency found, **including pre-existing ones not caused
|
||||||
|
by the phase** (including any remaining mention of `dev-tunnels.sh`, `DEV_TUNNELS`,
|
||||||
|
`DEV_FORWARDS`, `9121`/`9122`/`9123` or `3494`).
|
||||||
|
- Task 05 updates `README.md`/`AGENTS.md` immediately for the user-visible dev-workflow change.
|
||||||
|
- `PLAN.md` is locked and must not be edited: its Data-Model note ("forwarded to
|
||||||
|
`127.0.0.1:3494` in dev") goes stale after this phase — flag it in the final pass's report to
|
||||||
|
the user, and leave the file untouched.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `systemctl status dashboards-agent` is active on all three hosts, each bound to the address
|
||||||
|
the dashboard dials, answering `/health` (unauthenticated) and `/snapshot` (bearer) from the
|
||||||
|
laptop
|
||||||
|
- [ ] driveripper's upsd answers `LIST UPS` from the laptop on 3051 — not via the host's loopback
|
||||||
|
- [ ] `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494"` matches
|
||||||
|
nothing outside `.agents/phases/complete/`
|
||||||
|
- [ ] `./scripts/dev-server.sh status` shows every source `ok` over direct dial
|
||||||
|
- [ ] `uv run pytest -m live --live` (with `.env.dev` sourced) is green
|
||||||
|
- [ ] `data/logs/` holds `phase10-*.png` screenshots of `/`, `/system`, `/ups`, `/llm` on live
|
||||||
|
data over direct dial
|
||||||
|
- [ ] full test suite **100% passing**, coverage gate (≥94%) holds, `ruff` + `pyright` clean
|
||||||
|
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow and mention no tunnels
|
||||||
|
(verified in the final pass)
|
||||||
|
- [ ] no behavior change in completed phases: the four pages render the same payloads as before
|
||||||
|
the transport change
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Task 01 — The agent sudoers grant and the installer's hint
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
A user with the command-scoped `ops` sudo (no root) can install `deploy/install-agent.sh`
|
||||||
|
because the repo documents the single sudoers line that makes it work, and running the
|
||||||
|
installer without root tells you that exact line instead of the generic "run as root".
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `deploy/agent-sudoers` (new) — house style: follow `deploy/nut-sudoers` (header with the
|
||||||
|
`visudo -cf` check-first install steps, a "WHY THESE ARE NEEDED" block, scope notes, the
|
||||||
|
grant, a "deliberately NOT granted" block). Content:
|
||||||
|
- **Why:** the agent is read-only by construction and every non-`/health` request needs the
|
||||||
|
bearer token, so the only privileged operation is installing its files and unit. The grant
|
||||||
|
is the whole script path, run as root, with no argument restriction — the flags
|
||||||
|
(`--bind`, `--port`, `--status`, `--uninstall`, `--rotate-token`) are non-sensitive, and
|
||||||
|
the documented flow never puts a token on argv (the installer generates one and prints it
|
||||||
|
once).
|
||||||
|
- **Canonical staging path:** the host has no repo checkout, so the host gets
|
||||||
|
`deploy/` + `agent/` staged to `~/agent-install/` (task 02 does the staging). The grant
|
||||||
|
line is therefore stable:
|
||||||
|
```
|
||||||
|
ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh
|
||||||
|
```
|
||||||
|
- **Install steps** (per host, the same two-stage check as `nut-sudoers`):
|
||||||
|
`sudo visudo -cf deploy/agent-sudoers` (adapt the path first), then install to
|
||||||
|
`/etc/sudoers.d/dashboards-agent` with `install -m 0440 -o root -g root`, and
|
||||||
|
`sudo visudo -cf` on the installed copy.
|
||||||
|
- **Deliberately NOT granted:** `visudo`/`vim`/`sh` (no self-amendment of the grant),
|
||||||
|
`systemctl *` other than what the script itself already needs as root, `cat` of
|
||||||
|
`/etc/dashboards-agent.token` (the token stays root-readable; the installer prints it
|
||||||
|
once), and the NUT instant-command clients.
|
||||||
|
2. `deploy/install-agent.sh` — replace the `need_root` failure message for the non-root case:
|
||||||
|
when `id -u != 0`, print (a) the exact NOPASSWD line for the script's **resolved absolute
|
||||||
|
path** (so a checkout at a different path still works), (b) the two install steps from
|
||||||
|
`deploy/agent-sudoers`, and (c) a pointer to `deploy/agent-sudoers`. Keep the exit code
|
||||||
|
(1) and the behaviour when already root unchanged. `bash -n` must stay clean.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `bash -n deploy/install-agent.sh` clean.
|
||||||
|
- Run the script **as the current (non-root) user** locally: `bash deploy/install-agent.sh
|
||||||
|
--bind 127.0.0.1` must exit 1 and print the hint with this checkout's absolute path; the
|
||||||
|
printed line must match the file's canonical line apart from the path (diff them in
|
||||||
|
`data/logs/NOTES-sudoers.md`).
|
||||||
|
- No Python changes this task; the full suite must stay green.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `deploy/agent-sudoers` exists, passes `visudo -cf` syntax locally, and names the canonical
|
||||||
|
staging path and the exact grant line
|
||||||
|
- [ ] non-root `bash deploy/install-agent.sh --bind 127.0.0.1` prints the exact line to add and
|
||||||
|
exits 1
|
||||||
|
- [ ] root behaviour unchanged (the script still refuses nothing new; `need_root` logic intact)
|
||||||
|
- [ ] `bash -n` clean on the script; `uv run pytest` 100% green; `ruff` + `pyright` clean
|
||||||
|
- [ ] README.md/AGENTS.md unchanged yet (task 05 is where the docs change)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Task 02 — Install the agents as systemd services on the fleet
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
**Precondition (user action):** the one line from `deploy/agent-sudoers` is installed on each
|
||||||
|
host (root-only; the scoped sudo does not include `visudo`). If it is missing on a host, this
|
||||||
|
task must stop cleanly for that host and leave the exact instructions in
|
||||||
|
`data/logs/NOTES-agent-install.md` — never loop, never wait, never edit sudoers.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Each of the three hosts (`driveripper`, `deskwork`, `gateway`) runs `dashboards-agent` as an
|
||||||
|
active systemd service — the prod shape — bound to the address the dashboard will dial, with
|
||||||
|
its bearer token captured for `.env.dev`. The old ad-hoc tunnel agent is gone.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. Once, on the laptop: `./scripts/dev-tunnels.sh stop` (kills the local ssh legs; the remote
|
||||||
|
ad-hoc agents are orphans and are killed in step 2).
|
||||||
|
2. Per host (`ssh driveripper-ops | deskwork-ops | gateway-ops`):
|
||||||
|
- `ssh <host> "pkill -f 'host_agent.py --port 9120' || true"` — the orphaned ad-hoc agent
|
||||||
|
from the old dev flow (it runs as `ops`, so `ops` can kill it). Leave the
|
||||||
|
`~/lab-agent/` files on disk; they are simply no longer used.
|
||||||
|
- Stage the installer: `ssh <host> "mkdir -p ~/agent-install"` then
|
||||||
|
`rsync -a --delete deploy/ agent/ <host>:~/agent-install/`.
|
||||||
|
- Check the grant: `ssh <host> "sudo -n ~/agent-install/deploy/install-agent.sh --status"`.
|
||||||
|
On a permission failure: append to `data/logs/NOTES-agent-install.md` the host, the exact
|
||||||
|
line from `deploy/agent-sudoers` (with this host's staging path), and the two `visudo`
|
||||||
|
install steps; mark the host **BLOCKED**, continue with the next host, and end the task
|
||||||
|
with the blocker visible in the summary. Do not retry or wait.
|
||||||
|
- Install: `ssh <host> "sudo ~/agent-install/deploy/install-agent.sh --bind <addr> --port 9120"`.
|
||||||
|
- `<addr>` is the address the dashboard will dial. Derive it: the host's name from
|
||||||
|
`.env.dev` (the `*.reeselink.com` names used by the engines, or the LAN name the user
|
||||||
|
uses), resolved on the laptop (`getent hosts <name>`), then confirmed present on the
|
||||||
|
host (`ssh <host> "ip -o addr"`). If resolution is ambiguous, use `0.0.0.0` — the
|
||||||
|
installer requires a token off-loopback, which the flow always provides.
|
||||||
|
- The installer prints the new token exactly once on first install. Record it for task 04
|
||||||
|
(`data/logs/NOTES-agent-install.md` may hold `host:port + unit state` only — **no
|
||||||
|
tokens** in NOTES; the tokens go into `.env.dev` in task 04, which is `0600` and
|
||||||
|
gitignored, the same place `NUT_PASSWORD` already lives).
|
||||||
|
- Verify **from the laptop** (a host-side check could pass via loopback):
|
||||||
|
`curl -sf http://<host>:9120/health` → `{"ok": true}`, and
|
||||||
|
`curl -sf -H "Authorization: Bearer <token>" http://<host>:9120/snapshot | head -c 200`
|
||||||
|
shows the `cpu` object.
|
||||||
|
- `ssh <host> "systemctl status dashboards-agent --no-pager" | head -5` → `active (running)`.
|
||||||
|
3. If a firewall on any host blocks 9120 from the laptop (the `curl` in step 2 fails while
|
||||||
|
`systemctl status` is active), record it in `data/logs/NOTES-agent-install.md` with the host
|
||||||
|
and the port, and mark the host BLOCKED-with-details — the user's scoped sudo set may not
|
||||||
|
cover firewall edits on every host, and this task does not guess.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- No Python changes; `uv run pytest` must stay 100% green (this is an ops task — its "tests"
|
||||||
|
are the verifications above).
|
||||||
|
- `data/logs/NOTES-agent-install.md` records per host: bind address, unit state, verification
|
||||||
|
outcome, and — for any BLOCKED host — the exact user instructions. No secrets in the file.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] all three hosts: `dashboards-agent` active, verified from the laptop (or a host is
|
||||||
|
BLOCKED with the exact user instructions in `data/logs/NOTES-agent-install.md`)
|
||||||
|
- [ ] the old ad-hoc agent is dead on each host (the pkill in step 2 ran; no `9120` loopback
|
||||||
|
listener remains)
|
||||||
|
- [ ] tokens captured for task 04 (in the NOTES file only as host:port — the values go straight
|
||||||
|
into `.env.dev` there)
|
||||||
|
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Task 03 — upsd ACL for the host and laptop IP ranges
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
driveripper's upsd (port 3051) accepts the NUT wire from the laptop over the network, using
|
||||||
|
the user-decided ranges — the host's IP range and the laptop's IP range — so the UPS source
|
||||||
|
can dial `NUT_HOST=<driveripper-addr> NUT_PORT=3051` directly. No `upsd.users` account is
|
||||||
|
touched, so `upsmon`'s orderly power-down is preserved.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. Read first (the `NUT_READ` sudo grant on driveripper covers `cat /etc/ups/*`):
|
||||||
|
`ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" > data/logs/upsd.conf.before`.
|
||||||
|
Confirm the file's current ACL lines (NUT 2.8 refuses non-local connections by default, so
|
||||||
|
there are likely none).
|
||||||
|
2. Derive the two ranges:
|
||||||
|
- **Laptop address as seen from driveripper:** `ssh driveripper-ops "ss -tn state
|
||||||
|
established '( sport = :22 )'"` — the source IP of this ssh session.
|
||||||
|
- **Host range:** driveripper's LAN subnet (`ssh driveripper-ops "ip -o addr"` — the
|
||||||
|
interface prefix, e.g. a `/24`).
|
||||||
|
3. Build the new file as `upsd.conf.before` **plus** exactly two appended NUT 2.8 ACL lines
|
||||||
|
(`ACL <ip-or-net> <netmask>`; a single host is mask `255.255.255.255`), write it back with
|
||||||
|
the granted `tee`, then prove the diff is exactly those two lines — the same read-first /
|
||||||
|
`cmp`-after care `AGENTS.md` mandates for `upsd.users` (never a blind write):
|
||||||
|
```
|
||||||
|
cat data/logs/upsd.conf.new | ssh driveripper-ops "sudo tee /etc/ups/upsd.conf"
|
||||||
|
ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" | diff - data/logs/upsd.conf.before
|
||||||
|
```
|
||||||
|
4. `ssh driveripper-ops "sudo systemctl restart nut-server"` (the `NUT_WRITE` grant). `upsmon`
|
||||||
|
re-authenticates from `upsd.users`, which was not modified.
|
||||||
|
5. Firewall: `ssh driveripper-ops "sudo firewall-cmd --list-ports"` — if a firewall is active
|
||||||
|
and 3051 is not reachable from the laptop, add a rule limited to the laptop address (the
|
||||||
|
scoped sudo includes `firewall-cmd *` "only if upsd must serve the LAN"); record what was
|
||||||
|
added in `data/logs/NOTES-upsd-acl.md`. If no firewall is active, record that.
|
||||||
|
6. Verify **from the laptop** (a host-side check passes via loopback and proves nothing):
|
||||||
|
`printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` → `BEGIN LIST UPS`. Task 04's
|
||||||
|
`/api/health` is the app-level proof.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- No Python changes; `uv run pytest` must stay 100% green.
|
||||||
|
- `data/logs/NOTES-upsd-acl.md` records the two ACL lines, the diff output (must show exactly
|
||||||
|
the two added lines), the firewall state, and the laptop-side verification result.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `diff` between `upsd.conf.before` and the live file shows exactly the two ACL lines
|
||||||
|
- [ ] `nut-server` restarted cleanly (`systemctl status nut-server` active)
|
||||||
|
- [ ] from the laptop, `printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` answers
|
||||||
|
`BEGIN LIST UPS` (with both UPS names following, or at least the BEGIN marker)
|
||||||
|
- [ ] no `upsd.users` modification (the diff is limited to `upsd.conf`)
|
||||||
|
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Task 04 — `.env.dev` goes direct
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
The dev configuration dials the fleet the prod way: real host addresses with bearer tokens in
|
||||||
|
URL userinfo, upsd directly on 3051, and no `DEV_TUNNELS`/`DEV_FORWARDS` — with the dev server
|
||||||
|
showing every source healthy over the new transport.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `./scripts/dev-tunnels.sh stop` (idempotent; task 02 may have done it already).
|
||||||
|
2. Edit the local `.env.dev` (keep its `0600` mode; it is gitignored):
|
||||||
|
- delete the `DEV_TUNNELS` and `DEV_FORWARDS` lines
|
||||||
|
- `HOST_TARGETS="driveripper=http://<token>@<addr>:9120 deskwork=http://<token>@<addr>:9120
|
||||||
|
gateway=http://<token>@<addr>:9120"` — the addresses exactly as task 02 verified them,
|
||||||
|
the tokens task 02 captured.
|
||||||
|
- `NUT_HOST=<driveripper-addr>`, `NUT_PORT=3051`; keep `NUT_USER` / `NUT_PASSWORD` /
|
||||||
|
`NUT_ALLOW_ACTIONS` unchanged.
|
||||||
|
- `GUFO_BASE_URL` / `VLLM_BASE_URL` stay (already direct); `DASH_PORT` stays.
|
||||||
|
- add a short comment: where the upsd ACL lines live (`/etc/ups/upsd.conf` on
|
||||||
|
driveripper) and that a changed laptop IP means re-adding one ACL line (task 03's
|
||||||
|
method).
|
||||||
|
3. `./scripts/dev-server.sh restart`, then `./scripts/dev-server.sh status` — every source
|
||||||
|
`ok`. A host that was BLOCKED in task 02 may still be down; that is expected and must be
|
||||||
|
recorded, not "fixed" with a tunnel.
|
||||||
|
4. Watch the transition: a host that was tunnel-served should log an unreachable/ok (or
|
||||||
|
stale/ok) transition in `/api/events` — the poller's normal bookkeeping. Confirm the
|
||||||
|
transitions are the only new events and no source is stuck `DOWN`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `uv run pytest` 100% green (no Python changes this task).
|
||||||
|
- `.env.dev` keeps mode `0600` and contains no token in any file git tracks.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `.env.dev` has no `DEV_TUNNELS`/`DEV_FORWARDS` and every target is a real address with
|
||||||
|
userinfo token (hosts) or direct `host:3051` (NUT)
|
||||||
|
- [ ] `./scripts/dev-server.sh status` shows every reachable source `ok` over direct dial
|
||||||
|
- [ ] `curl -s localhost:8090/api/system/snapshot` shows live data for every installed host
|
||||||
|
(a BLOCKED host is absent or stale — recorded, not hidden)
|
||||||
|
- [ ] the dev server shows no `DOWN` source that was healthy before the switchover
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Task 05 — Retire the tunnel machinery and update scripts and docs
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Everything that exists to serve the old architecture goes away or gets rewritten: the tunnel
|
||||||
|
script is deleted, the example env file is direct-shaped, and no tracked file mentions the
|
||||||
|
tunnel ports or variables any more.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `git rm scripts/dev-tunnels.sh` — the user decided against the separation; git history
|
||||||
|
keeps the script if it is ever needed.
|
||||||
|
2. `scripts/capture-fixtures.sh` — drop the "needs the tunnels up" line; the hard-coded
|
||||||
|
`HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)` becomes a parse of `HOST_TARGETS`
|
||||||
|
from `.env.dev` (source it the way `dev-server.sh` does: `set -a; . .env.dev; set +a`,
|
||||||
|
then split `name=url` pairs and use the URL as-is, token userinfo and all — the app's own
|
||||||
|
`config.parse_targets` shows the shape). The NUT line already uses `$NUT_HOST`.
|
||||||
|
`bash -n` must stay clean.
|
||||||
|
3. `tests/test_live_fleet.py` — module docstring: replace the `./scripts/dev-tunnels.sh
|
||||||
|
start` line of the dev loop with `set -a; . .env.dev; set +a` (direct dial); both skip
|
||||||
|
messages become `pytest.skip("HOST_TARGETS is unset")`.
|
||||||
|
4. `tests/ups_contract.py` — the message that mentions `DEV_FORWARDS` (around L89): the UPS
|
||||||
|
hero row is contracted against the real drivers over direct dial; reword, drop the
|
||||||
|
forward reference.
|
||||||
|
5. `src/dashboards/alerts.py` — the docstring example `http://h:9121/snapshot` (around L138):
|
||||||
|
update to the direct-dial shape (`http://<host>:9120/snapshot`). Docstring only.
|
||||||
|
6. `tests/test_config.py` — the `HOST_TARGETS` monkeypatch `deskwork=http://127.0.0.1:9122`
|
||||||
|
(around L100): cosmetic, update to the direct shape (e.g.
|
||||||
|
`deskwork=http://tok@deskwork.example.net:9120`); the test stays hermetic.
|
||||||
|
7. `.env.example` — the NUT section's "reach it with an ssh forward or a tailnet address, or
|
||||||
|
bind a LAN address only if a firewall rule limits it": add the now-default third option —
|
||||||
|
ACL the dashboard's source range in `upsd.conf` (NUT refuses non-local connections by
|
||||||
|
default).
|
||||||
|
8. `.env.dev.example` — full rewrite, direct-shaped: the agent installed per host via
|
||||||
|
`deploy/agent-sudoers` + `deploy/install-agent.sh` (bound to the dialed address, token in
|
||||||
|
the URL userinfo), `NUT_HOST`/`NUT_PORT` pointing at the real upsd (keep the `ss -ltn` /
|
||||||
|
`nc 'LIST UPS'` port-discovery note — still the right first move), the upsd-ACL note, no
|
||||||
|
`DEV_TUNNELS`/`DEV_FORWARDS`, RFC 2606 placeholder hostnames (the file's existing
|
||||||
|
convention), and the "never commit this file" warning.
|
||||||
|
9. `README.md` — the dev section (around L40–49 and L275): replace the tunnel lines with the
|
||||||
|
direct-dial flow — the agent install (the `deploy/agent-sudoers` grant, the staged
|
||||||
|
installer, the token into `HOST_TARGETS` userinfo), the upsd ACL, `.env.dev`,
|
||||||
|
`./scripts/dev-server.sh restart`. State plainly that dev is the prod topology.
|
||||||
|
10. `AGENTS.md` — the fleet + Commands sections: the agents are systemd services installed by
|
||||||
|
`ops` via the `deploy/agent-sudoers` grant (bound to the dialed address, token in
|
||||||
|
userinfo); upsd serves the LAN for the ACLed ranges; the `dev-tunnels.sh` lines out of the
|
||||||
|
Commands block; "Nothing listens on the hosts in dev" is the opposite of the truth now and
|
||||||
|
must go. Keep the port-3051 discovery note (it is still why `3493` looks dead to a
|
||||||
|
default client).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `uv run pytest` 100% green (the edits are strings/docstrings; the suite must catch any
|
||||||
|
regression), `ruff` + `pyright` clean, `bash -n scripts/capture-fixtures.sh` clean.
|
||||||
|
- `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494" --` must
|
||||||
|
match nothing outside `.agents/phases/complete/` (and this phase's own todo files, which
|
||||||
|
describe the change).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `scripts/dev-tunnels.sh` is deleted from the working tree
|
||||||
|
- [ ] `git grep` for the tunnel names/ports is clean (per Testing & Quality)
|
||||||
|
- [ ] `.env.dev.example` is direct-shaped and passes the same "copy and edit" workflow as
|
||||||
|
before
|
||||||
|
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow with no tunnel
|
||||||
|
references (the phase's final pass re-checks both project-wide)
|
||||||
|
- [ ] full test suite 100% passing; `ruff` + `pyright` clean
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Task 06 — Verify the direct-dial fleet end to end
|
||||||
|
|
||||||
|
**Phase:** `10_dev_direct_dial` · **Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Prove the claim this phase exists for — dev works like prod: every source healthy over the
|
||||||
|
direct transport, the live suite green against the real fleet, and the four pages rendering
|
||||||
|
live data in a browser, with no tunnel port existing on the laptop.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `./scripts/dev-server.sh restart`, then `curl -s localhost:8090/api/health` — every source
|
||||||
|
`ok` (a host BLOCKED in task 02 is the only acceptable `DOWN`, and it must be the one
|
||||||
|
recorded in `data/logs/NOTES-agent-install.md`).
|
||||||
|
2. Prove the laptop needs no tunnel port:
|
||||||
|
`ss -ltn | grep -E ':(9121|9122|9123|3494)\s'` → empty (the old tunnel ports are gone),
|
||||||
|
while the host sources in `/api/health` are still `ok`.
|
||||||
|
3. `set -a; . .env.dev; set +a; uv run pytest -m live --live` — green (or the normal
|
||||||
|
per-source skips; a source that is configured and reachable must not skip).
|
||||||
|
4. Browser verification (`AGENTS.md` rule 10) — screenshots to `data/logs/` as
|
||||||
|
`phase10-*.png`, all against live data over direct dial:
|
||||||
|
- `/` — the fleet table shows all three hosts with live numbers (any BLOCKED host dimmed
|
||||||
|
`n/a`, as designed), alerts consistent with the source states
|
||||||
|
- `/system` — three live host cards (CPU/MEM/TEMP/DISK/GPU/NET heroes populated where the
|
||||||
|
hardware reports it; gateway's absent GPU stays an uncoloured `n/a`)
|
||||||
|
- `/ups` — both real UPS cards, live charge/load/runtime, **no `SIMULATED` badge**
|
||||||
|
- `/llm` — the live engine(s) with throughput and latency cells
|
||||||
|
5. `data/logs/NOTES-verify.md` — what was verified, which hosts are BLOCKED (if any) with a
|
||||||
|
pointer to the task-02 notes, and the `PLAN.md` staleness flag: its Data-Model note
|
||||||
|
("forwarded to `127.0.0.1:3494` in dev", around L94) no longer describes reality —
|
||||||
|
**report it to the user; do not edit `PLAN.md`** (locked).
|
||||||
|
6. The full gate: `bash .agents/validate.sh`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- The live suite is read-only by policy — nothing in this task sends an instant command,
|
||||||
|
restarts a service, or writes to an upstream.
|
||||||
|
- `uv run pytest` (hermetic) 100% green; coverage gate (≥94%) holds; `ruff` + `pyright`
|
||||||
|
clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `/api/health`: every reachable source `ok` over direct dial; no `DOWN` source that was
|
||||||
|
healthy before the phase
|
||||||
|
- [ ] no tunnel port (9121/9122/9123/3494) listening on the laptop
|
||||||
|
- [ ] `uv run pytest -m live --live` green against the directly-dialed fleet
|
||||||
|
- [ ] `data/logs/phase10-*.png` exists for `/`, `/system`, `/ups`, `/llm`, showing live data
|
||||||
|
- [ ] `data/logs/NOTES-verify.md` written, including the `PLAN.md` staleness flag for the user
|
||||||
|
- [ ] `bash .agents/validate.sh` passes
|
||||||
|
- [ ] no behavior change in completed phases (same payloads, same pages — only the transport
|
||||||
|
underneath changed)
|
||||||
Reference in New Issue
Block a user