eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env sh
2# πŸ€– ops canary β€” per-session test instances (CLAUDE.md Β§ Per-session canaries),
3# with the rules that used to live only in prose encoded as behaviour:
4#
5# ./deploy/ops canary create <slug> # test-<slug>.11ch.at, flagged --canary
6# ./deploy/ops canary deploy <slug> # build THIS branch -> stage -> deploy --pin
7# ./deploy/ops canary reset <slug> # wipe back to first-run (iterate onboarding)
8# ./deploy/ops canary destroy <slug> # reset (archive DB) + remove --stop
9# ./deploy/ops canary list # every canary + pinned row on the box
10#
11# The rules, and where each is enforced:
12# - Canaries are test-<slug> on the wildcard domain β€” no DNS step, no cert
13# wait, visually distinct from real member spaces (this script only ever
14# forms test-<slug>.<domain> hostnames).
15# - Creation passes --canary: the registry flag is a DELIBERATE signal, never
16# inferred from the hostname β€” it is what lets `lchat reset` wipe the DB at
17# destroy time while refusing to touch any real instance.
18# - Deploys always --pin. An unpinned canary is moved onto the channel build
19# by the hourly auto-updater β€” out from under the session, silently.
20# - Destroy wipes the data. `lchat remove` deliberately leaves /data behind
21# (right for real instances, wrong for a canary), and the hardened sudoers
22# allowlist has no `rm` β€” so the encoded path is `lchat reset` (archives
23# the DB to /data/trash, canary-gated) then `remove --stop` (which also
24# disables any bot/hooks sidecar units).
25# - Admin tokens for canaries are stored NOWHERE (not 1Password) on purpose:
26# everything about a canary dies with its session.
27# - Fleet-wide operations skip AUTO=pinned rows β€” each is someone's live
28# canary. `list` shows them so a sweep can check first.
29set -eu
30
31here="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
32root="$(CDPATH= cd -- "$here/.." && pwd)"
33# The box canaries live on β€” an ssh alias; user/hostname come from
34# ~/.ssh/config (box specifics in CLAUDE.local.md). Canaries live on the DEV
35# MESH by default (Paul, 2026-07-22): throwaway test instances don't belong on
36# the box real members live on, and they'd eat its instance cap. Helsinki is
37# the dev default because build-linux.sh emits amd64 (Dublin is arm64). Alias
38# to add once per machine:
39# Host elevendev-fi
40# HostName 157.180.123.183
41# User root
42# IdentityFile ~/.ssh/eleven-dev-box
43# Prod (LCHAT_CANARY_BOX=elevenbox LCHAT_CANARY_DOMAIN=11ch.at) is the
44# deliberate exception, not the default.
45# NB dev has no wildcard cert BY DESIGN (the DNSimple token never goes on a
46# dev box), so each dev canary host burns one per-host HTTP-01 cert from the
47# shared 50/week 11ch.at LE budget β€” fine at canary cadence, not for bursts.
48BOX="${LCHAT_CANARY_BOX:-elevendev-fi}"
49DOMAIN="${LCHAT_CANARY_DOMAIN:-fi.dev.11ch.at}"
50# The home server a canary's instance points at (LCHAT_HOME_ORIGIN β€” the
51# sign-in/enrol origin baked into its pages). Dev canaries must ride the DEV
52# home: without this, add-instance leaves the env empty and the binary falls
53# back to the compiled PRODUCTION home β€” a dev canary whose sign-in round
54# trips hit prod (old code, real accounts). Keyed off the domain so the
55# deliberate prod exception (LCHAT_CANARY_DOMAIN=11ch.at) keeps the compiled
56# default; LCHAT_CANARY_HOME_ORIGIN overrides either way.
57case "$DOMAIN" in
58 *.dev.*) HOME_ORIGIN="${LCHAT_CANARY_HOME_ORIGIN:-https://home.dev.elevenmessenger.com}" ;;
59 *) HOME_ORIGIN="${LCHAT_CANARY_HOME_ORIGIN:-}" ;;
60esac
61LCHAT=/opt/lchat/lchat
62
63usage() {
64 echo "usage: ops canary create|deploy|reset|destroy <slug> | ops canary list" >&2
65 echo " slug: lowercase letters/digits/hyphens β€” your branch or feature name" >&2
66 exit 2
67}
68
69# Canary links (the admin link's token, the setup/onboarding link's one-time
70# secret) must never land in a session transcript β€” transcripts are public. So
71# every verb that mints one writes the full output to ~/<host>.link ON THE BOX
72# and this filter redacts the URL fragments/tokens from what echoes locally.
73# Fetch the real links from your own terminal: ssh <box> cat <host>.link
74redact_secrets() { sed -E 's/#[^[:space:]]+/#<redacted>/g'; }
75
76cmd="${1:-}"; slug="${2:-}"
77
78host_for() {
79 # Everything below derives the hostname from here, so this script is
80 # structurally incapable of touching a non-test- host.
81 case "$1" in
82 *[!a-z0-9-]*|-*|"") echo "ops canary: bad slug '$1' (lowercase letters/digits/hyphens)" >&2; exit 2 ;;
83 esac
84 echo "test-$1.$DOMAIN"
85}
86
87case "$cmd" in
88
89create)
90 host="$(host_for "$slug")"
91 echo "ops canary: creating $host on $BOX (flagged --canary)"
92 # add-instance prints the admin link + the one-time setup link β€” both carry
93 # secrets, so the full output is staged box-side and echoed here redacted.
94 homeflag=""
95 [ -n "$HOME_ORIGIN" ] && homeflag="--home-origin '$HOME_ORIGIN'"
96 out="$(ssh "$BOX" "sudo -n $LCHAT add-instance --canary $homeflag '$host' > \$HOME/$host.link 2>&1; st=\$?; cat \$HOME/$host.link; exit \$st")" || {
97 printf '%s\n' "$out" | redact_secrets >&2
98 exit 1
99 }
100 printf '%s\n' "$out" | redact_secrets
101 cat <<EOF
102
103Canary up. Its links live in $host.link on the box, nowhere else β€” canary
104tokens deliberately die with the session (don't vault them).
105 fetch links: ssh $BOX cat $host.link # run in YOUR terminal
106 next: ./deploy/ops canary deploy $slug # put THIS branch's build on it
107 reset: ./deploy/ops canary reset $slug # back to first-run, same token
108 tear down: ./deploy/ops canary destroy $slug # when the work merges
109EOF
110 ;;
111
112deploy)
113 host="$(host_for "$slug")"
114 ver="$(git -C "$root" rev-parse --short HEAD)"
115 if ! git -C "$root" diff --quiet || ! git -C "$root" diff --cached --quiet; then
116 ver="${ver}-dirty"
117 echo "ops canary: working tree has uncommitted changes β€” building anyway ($ver)" >&2
118 fi
119
120 echo "ops canary: building $ver for linux/amd64"
121 out="$(mktemp -t lchat-canary.XXXXXX)"
122 trap 'rm -f "$out"' EXIT
123 cd "$root" # build-linux.sh compiles the package at the current directory
124 OUT="$out" "$here/build-linux.sh" "$ver"
125
126 # A per-slug upload name so parallel sessions' uploads never trample each
127 # other in the shared incoming dir (a fixed name is a race).
128 up="/opt/lchat/incoming/lchat-upload-$slug"
129 ssh "$BOX" 'mkdir -p /opt/lchat/incoming'
130 scp -q "$out" "$BOX:$up"
131 staged="$(ssh "$BOX" "sudo -n $LCHAT stage -file '$up' && rm -f '$up'")"
132 echo " $staged"
133 staged_ver="$(echo "$staged" | awk '{print $2}')"
134 [ "$staged_ver" = "$ver" ] || {
135 echo "ops canary: staged '$staged_ver' != built '$ver' β€” aborting" >&2; exit 1; }
136
137 # --pin is not optional: without it the hourly auto-updater moves the canary
138 # back onto its channel build, out from under the session. deploy itself
139 # health-checks /api/version and auto-rolls back a bad build.
140 ssh "$BOX" "sudo -n $LCHAT deploy '$host' --version '$ver' --pin"
141 cat <<EOF
142
143$host is running $ver (pinned β€” the auto-updater will leave it alone).
144 iterate: commit, then ./deploy/ops canary deploy $slug
145 step back: ssh $BOX sudo -n $LCHAT rollback $host
146EOF
147 ;;
148
149reset)
150 host="$(host_for "$slug")"
151 echo "ops canary: resetting $host in place (DB archived to /data/trash; env/token kept)"
152 # `lchat reset` is canary-gated (a real instance refuses), restarts the
153 # instance on an empty DB, and mints a fresh one-time onboarding link β€” the
154 # loop for iterating setup/onboarding scripts without a new host (each new
155 # dev host would burn another cert from the shared 50/week LE budget).
156 out="$(ssh "$BOX" "sudo -n $LCHAT reset '$host' --yes > \$HOME/$host.link 2>&1; st=\$?; cat \$HOME/$host.link; exit \$st")" || {
157 printf '%s\n' "$out" | redact_secrets >&2
158 echo "ops canary: reset refused β€” if $host is genuinely a throwaway, flag it:" >&2
159 echo " ssh $BOX sudo -n $LCHAT canary $host --on # then re-run reset" >&2
160 exit 1
161 }
162 printf '%s\n' "$out" | redact_secrets
163 cat <<EOF
164
165$host is back at first-run (same admin token, same pinned build). A bot
166sidecar, if the canary had one, must be re-provisioned β€” its identities died
167with the DB (add-bot again after the new admin claims the space).
168 fetch the fresh onboarding link: ssh $BOX cat $host.link # YOUR terminal
169EOF
170 ;;
171
172destroy)
173 host="$(host_for "$slug")"
174 echo "ops canary: destroying $host on $BOX"
175 # reset first (it needs the registry row remove deletes): archives the DB +
176 # blobs to /data/trash β€” canary-gated, so a host that was never flagged
177 # --canary refuses here rather than wiping. That refusal is the safety
178 # model working; flag it by hand only if it is genuinely a throwaway.
179 out="$(ssh "$BOX" "sudo -n $LCHAT reset '$host' --yes")" || {
180 printf '%s\n' "$out" | redact_secrets >&2
181 echo "ops canary: reset refused β€” if $host is genuinely a throwaway, flag it:" >&2
182 echo " ssh $BOX sudo -n $LCHAT canary $host --on # then re-run destroy" >&2
183 exit 1
184 }
185 printf '%s\n' "$out" | redact_secrets
186 # remove --stop: drops the route (and in.<host>), stops + disables the
187 # instance unit and any bot/hooks sidecar units. The staged link file goes
188 # with it β€” its secrets are dead, and a stale file would mislead the next
189 # session to reuse this slug.
190 ssh "$BOX" "sudo -n $LCHAT remove '$host' --stop; rm -f \$HOME/$host.link"
191 cat <<EOF
192
193$host destroyed. Its DB is archived in /data/trash (the reaper's trash purge
194deletes it for good); the leftover env file is harmless and is overwritten if
195the slug is ever recreated.
196Remember the session's why-entry if you haven't yet: ./deploy/ops ledger "..."
197EOF
198 ;;
199
200list)
201 echo "canaries + pinned rows on $BOX (a pinned row is someone's live canary β€”"
202 echo "fleet-wide operations must leave it alone):"
203 echo
204 ssh "$BOX" "sudo -n $LCHAT instances" | awk 'NR==1 || /test-|canary/'
205 echo
206 ssh "$BOX" "sudo -n $LCHAT update status" | awk 'NR==1 || /pinned/'
207 ;;
208
209*) usage ;;
210esac
211