deploy: ops pages push — the encoded status-pages copy step (#1396)
The carbon-tally runbook's last step (copy the committed carbon.json to the box's /opt/lchat/status-pages/ for the 5-minute publisher) had no ops verb, so it could only be done as a hand-run scp — the path CLAUDE.md closes. 'ops pages push [file...]' encodes it: basenames under web/status/ only, refuses a file with uncommitted changes (the box must never carry a state main doesn't), and lands each file as a temp name + mv — the pages dir is group-writable by lchat-operators but the files may be another operator's (644), so a direct scp onto one fails, and the rename is atomic under the publisher.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+69
−2
deploy/README.md+1 −0deploy/ops+3 −0deploy/ops-pages.sh+62 −0docs/status.md+3 −2
diff --git a/deploy/README.md b/deploy/README.md| index c00eb15..6723cc3 100644 |
| --- a/deploy/README.md |
| +++ b/deploy/README.md |
| @@ -24,6 +24,7 @@ Bare `./deploy/ops` lists the verbs: |
| ./deploy/ops dev provision # dev mesh: refresh systemd units + route short links |
| ./deploy/ops dev rebuild # destroy + recreate the Dublin box (new keys) |
| ./deploy/ops ledger "why..." # draft the public SSH-access why-entry |
| +./deploy/ops pages push # committed web/status data -> the status box |
| ``` |
| Verbs that need credentials fetch them themselves (the `release-server.sh` |
diff --git a/deploy/ops b/deploy/ops| index af9848b..3263fb8 100755 |
| --- a/deploy/ops |
| +++ b/deploy/ops |
| @@ -32,6 +32,8 @@ ops — the operator command surface |
| ./deploy/ops ledger "why..." draft the public SSH-access why-entry |
| ./deploy/ops mesh seed [--prune] <peer> [box] |
| replicate routes (+env) to a mesh peer |
| + ./deploy/ops pages push [file...] push committed web/status data files to the |
| + status box (default: carbon.json) |
| Start with `ops doctor` — it checks every credential and access path the other |
| verbs need, and prints the fix under each failure. `ops validate` is its |
| @@ -63,6 +65,7 @@ case "$verb" in |
| dev) exec "$here/ops-dev.sh" "$@" ;; |
| ledger) exec "$here/ops-ledger.sh" "$@" ;; |
| mesh) exec "$here/ops-mesh.sh" "$@" ;; |
| + pages) exec "$here/ops-pages.sh" "$@" ;; |
| ""|-h|--help|help) usage ;; |
| *) |
| printf 'ops: unknown verb %s\n\n' "$verb" >&2 |
diff --git a/deploy/ops-pages.sh b/deploy/ops-pages.sh| new file mode 100755 |
| index 0000000..e600b10 |
| --- /dev/null |
| +++ b/deploy/ops-pages.sh |
| @@ -0,0 +1,62 @@ |
| +#!/usr/bin/env sh |
| +# 🤖 ops pages — push committed status-page files from web/status/ to the |
| +# status box's pages dir, where the 5-minute publisher |
| +# (deploy/status-publish.sh) uploads them to status.elevenmessenger.com. |
| +# |
| +# ./deploy/ops pages push [file...] # default: carbon.json |
| +# |
| +# This is the encoded form of the accrual runbook's last step (docs/status.md |
| +# § Keeping the carbon tally accruing): accrue → commit (tiny PR, merged |
| +# promptly) → push the file here. Files are named by basename and always read |
| +# from web/status/ — they are COMMITTED data, so a file with uncommitted local |
| +# changes is refused (commit and merge first; the box must never carry a state |
| +# main doesn't). |
| +# |
| +# Write path: the pages dir is setgid, group-writable by lchat-operators, but |
| +# the files themselves may be owned by another operator (mode 644) — a direct |
| +# scp onto one fails with permission denied. So each file lands as a temp name |
| +# and an mv replaces it: a rename needs only the directory write bit, and it's |
| +# atomic, so the publisher can never read a half-copied file. |
| +set -eu |
| + |
| +BOX=${LCHAT_STATUS_BOX:-elevenbox} |
| +PAGES_DIR=${LCHAT_PAGES_DIR:-/opt/lchat/status-pages} |
| +here="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" |
| +repo="$(CDPATH= cd -- "$here/.." && pwd)" |
| + |
| +usage() { |
| + echo "usage: ops pages push [file...] # basenames under web/status/; default: carbon.json" >&2 |
| + echo " (box: \$LCHAT_STATUS_BOX, default elevenbox; dir: \$LCHAT_PAGES_DIR)" >&2 |
| + exit 2 |
| +} |
| + |
| +cmd="${1:-}" |
| +[ $# -gt 0 ] && shift |
| +case "$cmd" in |
| + |
| +push) |
| + files="${*:-carbon.json}" |
| + for f in $files; do |
| + case "$f" in */*) echo "ops pages: '$f' — basenames only (files live in web/status/)" >&2; exit 1 ;; esac |
| + src="$repo/web/status/$f" |
| + [ -f "$src" ] || { echo "ops pages: no such file web/status/$f" >&2; exit 1; } |
| + if ! git -C "$repo" diff --quiet HEAD -- "web/status/$f"; then |
| + echo "ops pages: web/status/$f has uncommitted changes — commit (and merge) first" >&2 |
| + exit 1 |
| + fi |
| + done |
| + for f in $files; do |
| + tmp="$PAGES_DIR/.$f.tmp.$$" |
| + echo "ops pages: $f -> $BOX:$PAGES_DIR/" |
| + scp -o ConnectTimeout=10 -q "$repo/web/status/$f" "$BOX:$tmp" |
| + ssh -o ConnectTimeout=10 "$BOX" "mv '$tmp' '$PAGES_DIR/$f'" |
| + done |
| + echo "ops pages: done — the publisher uploads within ~5 minutes" |
| + echo |
| + echo "note: this opened box SSH sessions — if this session hasn't written its" |
| + echo " access-ledger why-entry yet: ./deploy/ops ledger \"...\"" |
| + ;; |
| + |
| +-h|--help|help|"") usage ;; |
| +*) echo "ops pages: unknown subcommand '$cmd'" >&2; usage ;; |
| +esac |
diff --git a/docs/status.md b/docs/status.md| index 7551a54..10408cf 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -444,8 +444,9 @@ the session is told to accrue. The accrual itself: |
| merged promptly: two long-lived branches accruing the same window is the |
| one race the cutoffs can't see, and the contract test fails main on the |
| overlap (fix: drop the younger duplicate entry, re-run `--write`). |
| -3. Copy `web/status/carbon.json` to the box's `/opt/lchat/status-pages/` (with |
| - `carbon.html` if the page changed); the next 5-minute publish uploads it. |
| +3. `./deploy/ops pages push` — copies the committed `carbon.json` to the box's |
| + `/opt/lchat/status-pages/` (add `carbon.html` as an argument if the page |
| + changed); the next 5-minute publish uploads it. |
| The conversion factors live in the JSON so a recalibration (better public |
| data) is one committed change; if the factors change, recompute the whole |