eleven / ocho Public

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>
James Adam c220ba5673165e3a3855f4d2a98f355cd34be710 parent ff3f511
4 files changed, +69 −2
  • deploy/README.md +1 −0
  • deploy/ops +3 −0
  • deploy/ops-pages.sh +62 −0
  • docs/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