feat(ctl): add umwelt-ctl update #6

Merged
troed merged 5 commits from feat/ctl-update into main 2026-10-14 18:05:05 +02:00
Owner

Adds umwelt-ctl update — one command that refreshes a host after a code change: the local install, every provisioned remote server, and (opt-in) the running sandboxes.

Today a rollout means re-running remote-install by hand for each server and knowing which of them were provisioned with --egress. This is that, once.

The command

umwelt-ctl update [name ...] [--all] [--restart] [--dry-run] [--force] [--registry P]
Invocation Local install Remotes Sandboxes
update yes — reported
update --all yes every registry entry reported
update headless no that one only reported
update --restart yes — restarted
update --all --restart yes every registry entry restarted

Names and --all together are an error. A named run never touches the local planes. Every restart is the graceful shutdown, never --hard. --force overrides the up-to-date skip without regenerating a server's base config.

Per-server state

[[server]] gains an optional remote_dir / config / egress record, written by remote-install on every install; absent keys mean today's defaults, so existing registries keep working. An entry with no record is probed once over admin ssh — the [egress] verdict from the server's own config, the install directory recovered from its managed authorized_keys relay path — and the answer is written, so the next update is cheap. remote-status uses the recorded directory too. Up-to-date servers are skipped by their tree digest, and one server's failure never stops the others.

Remote restarts stop a server's running agents over admin ssh through the tree already pushed there (ctl.py stop <sid>, graceful), using the server's own session prefix. The next call relaunches them on the new tree; the local cached transport self-heals.

Notes

  • ctl.py had to join the reduced-push manifest (nothing else in a pushed tree can send that shutdown), which changes the tree digest — so the first update re-pushes every provisioned server. Intended for a code change, but it is a full re-push.
  • Pre-existing and out of scope: _verify_server_floor (used by the install verify and remote-status) is still bound to the default install root, so a non-default --remote-dir server has its egress artifacts under-checked.
  • A registry-write-only failure still exits non-zero, matching the existing contract, even though the refresh itself succeeded.

Testing

1123 passed, 3 skipped (baseline 1073). 32 new tests in umwelt/tests/test_ctl_update.py plus additions to test_remotes.py, test_provision.py, test_ctl_remote.py. Every new test is hermetic — no ssh, no podman, no network — through the injected run; the generated ssh snippets are additionally executed for real under sh -c against temp directories. Docs updated in CONFIGURATION.md and AGENTS.md.

Adds `umwelt-ctl update` — one command that refreshes a host after a code change: the local install, every provisioned remote server, and (opt-in) the running sandboxes. Today a rollout means re-running `remote-install` by hand for each server and knowing which of them were provisioned with `--egress`. This is that, once. ## The command ``` umwelt-ctl update [name ...] [--all] [--restart] [--dry-run] [--force] [--registry P] ``` | Invocation | Local install | Remotes | Sandboxes | |---|---|---|---| | `update` | yes | — | reported | | `update --all` | yes | every registry entry | reported | | `update headless` | no | that one only | reported | | `update --restart` | yes | — | restarted | | `update --all --restart` | yes | every registry entry | restarted | Names and `--all` together are an error. A named run never touches the local planes. Every restart is the graceful `shutdown`, never `--hard`. `--force` overrides the up-to-date skip without regenerating a server's base config. ## Per-server state `[[server]]` gains an optional `remote_dir` / `config` / `egress` record, written by `remote-install` on every install; absent keys mean today's defaults, so existing registries keep working. An entry with no record is probed once over admin ssh — the `[egress]` verdict from the server's own config, the install directory recovered from its managed `authorized_keys` relay path — and the answer is written, so the next update is cheap. `remote-status` uses the recorded directory too. Up-to-date servers are skipped by their tree digest, and one server's failure never stops the others. Remote restarts stop a server's running agents over admin ssh through the tree already pushed there (`ctl.py stop <sid>`, graceful), using the server's own session prefix. The next call relaunches them on the new tree; the local cached transport self-heals. ## Notes - `ctl.py` had to join the reduced-push manifest (nothing else in a pushed tree can send that shutdown), which changes the tree digest — so the first `update` re-pushes every provisioned server. Intended for a code change, but it is a full re-push. - Pre-existing and out of scope: `_verify_server_floor` (used by the install verify and `remote-status`) is still bound to the default install root, so a non-default `--remote-dir` server has its egress artifacts under-checked. - A registry-write-only failure still exits non-zero, matching the existing contract, even though the refresh itself succeeded. ## Testing `1123 passed, 3 skipped` (baseline 1073). 32 new tests in `umwelt/tests/test_ctl_update.py` plus additions to `test_remotes.py`, `test_provision.py`, `test_ctl_remote.py`. Every new test is hermetic — no ssh, no podman, no network — through the injected `run`; the generated ssh snippets are additionally executed for real under `sh -c` against temp directories. Docs updated in `CONFIGURATION.md` and `AGENTS.md`.
A refresh has to replay how a server was installed - its tree directory and
whether it carries the opt-in egress prerequisites - instead of asking the
operator to remember. The registry's `[[server]]` gains three optional keys
(remote_dir, config, egress); absent means today's default, so an existing
registry keeps working unchanged and a wrong type is a load-time error.

remote-install now writes the record on every install, and remote-status
resolves the relay path from the recorded remote_dir when its own argument
was not passed explicitly, so a provisioned server is verified where it was
actually installed rather than where the invocation guessed.
Rolling out a code change meant re-running remote-install by hand for every
server and remembering which of them needed --egress. `umwelt-ctl update`
is that, once: it refreshes the local install (`install.sh status`, and
`install.sh install` only when that is not ok - the one step that may ask
for sudo), then the selected servers - replaying each one's recorded install
parameters, probing a recordless entry once over admin ssh and writing the
answer back, and skipping a server whose tree digest already matches - and
reports the running sandboxes, restarting what the run covers gracefully
with --restart (the local control planes iff the local install ran, plus the
selected servers' agents, stopped through the tree already pushed there).

Every mutating step rides the same injected `run` the other orchestrators
take, so each test is hermetic: no ssh, no rsync, no podman, no network.
The reduced MANIFEST now carries `ctl.py`, which is what a server's own
graceful stop needs. The tree-digest verdict is now one shared comparison
(`_tree_verdict`) used by both `remote-status` and `update`, so a status and
a refresh can never disagree about a tree.
`_remote_agents_command` globbed `$XDG_RUNTIME_DIR/{prefix}-*.sock` with the
*local* effective prefix (`load_prefix(config)`), so a host whose own
`session_prefix` differs from the server's looked for a glob that server does
not have: it reported "no running agents" and `--restart` silently left the
remote agents alone - exactly the per-remote knowledge this command exists to
remove.

The snippet now reads the server's prefix out of the server's own config (the
same file the stop command names, defaulting to the install dir's umwelt.toml
that remote-install.sh generated) with `umwelt` as the fallback, and reports
it, so "no running agents" names the glob that server actually has.
`_remote_agents_command` loses its `prefix` argument, `_restart_remote_agents`
no longer takes the local prefix, and the local enumeration keeps
`load_prefix(config)`.
The recordless-server probe took `egress_optin` from the preflight, but
PREFLIGHT_SCRIPT resolves its base config from the *default* install root
(`$UMWELT_REMOTE_HOME`, else `$HOME/.local/share/umwelt/remote`) while the
same probe discovers the server's real `remote_dir` from its managed
`authorized_keys` line one ssh later. A server installed with
`--remote-dir /opt/x --egress` therefore probed egress=false, was
re-installed without its egress prerequisites, and the false was persisted
(the renderer omits `egress = false`), so every later refresh skipped it too
- the wrong-install-parameter failure this feature exists to prevent.

The verdict now comes from a dedicated read of the *discovered* directory's
config, after the directory is known; a check that cannot be read is
reported and read as "no opt-in" rather than silently.

Folded in with it:

- `install.sh status`'s captured output is printed when the status is not
  ok, so "not ok" says which check failed.
- The remote stop uses the config path the enumeration snippet resolved
  (reported as `cfg=`), so the two halves of one command cannot disagree
  about a relative `--config`; a shared `_remote_dir_setup` expands a
  relative `remote_dir`/config the way `install_command` does.
- The probe-time record write keeps a recorded `config` (upsert symmetry).
- `--restart` skips a server whose refresh just failed (its code did not
  change, so stopping its agents would only destroy sandboxes), and says so.
- Tests for the two reachable failure paths that had none: the registry
  write and the local graceful shutdown.
- CONFIGURATION.md's three `update` usage lines agree on `[--registry P]`.
Three tests that could not fail for the reason they were written, plus the
one production change two of them pinned:

- `test_the_probe_keeps_a_recorded_config` drove a stale digest, so the
  re-install rewrote the record anyway and the test passed even with the
  probe-write fix reverted. It now drives a *current* digest, so the probe's
  write is the only write, and asserts no install ran. Confirmed to fail
  with the fix reverted.
- `_update_remote` now returns a fourth element, `refresh_failed`, set only
  when the re-install failed. A record write only the probe could not make
  is a warning (the install rewrites the whole record), so `--restart` no
  longer skips that server with a message claiming its refresh failed. New
  test: a registry-write-only failure still restarts the agents, with the
  install's own upsert landing the record.
- The `[egress]` read gets a hermetic `sh -c` execution test (present,
  absent, no config, relative dir against `$HOME`), like the round-1 prefix
  snippet, so a future edit to that grep fragment cannot slip through.
  Confirmed to fail with the fragment broken.
- `_remote_egress_optin`'s docstring now says what the code does: an absent
  config is "no opt-in" and is not reported; only a failed read is named.
troed merged commit 763d1dea24 into main 2026-10-14 18:05:05 +02:00
troed deleted branch feat/ctl-update 2026-10-14 18:05:05 +02:00
troed referenced this pull request from a commit 2026-10-14 18:05:06 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
troed/umwelt!6
No description provided.