carlos / platform Public

infra: rehearsal + promote scripts; state and operator queues updated

Flagship and Tito both live: edges active with real certs, smoke
static site serving, kass aedcae3 + titogo ff61d3b shipped. Promotes
and the Kass data-staging rehearsal queued for the operator (release
signing key and production data movement stay human-gated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3018079228fcc67aa19e7dc6d975d6bb51970c09 parent d8ee557
7 files changed, +172 −28
  • infra/flagship/README.md +21 −17
  • infra/flagship/plan.out binary
  • infra/scripts/promote-pending.sh +17 −0
  • infra/scripts/rehearse-kass.sh +69 −0
  • infra/scripts/ship-app.sh +13 −0
  • infra/scripts/smoke-flagship.sh +35 −0
  • infra/tito/README.md +17 −11
diff --git a/infra/flagship/README.md b/infra/flagship/README.md
index 155f06845..129e3d494 100644
--- a/infra/flagship/README.md
+++ b/infra/flagship/README.md
@@ -5,35 +5,39 @@
AWS account **271376211898** (adopted from Keymail, spec §12), profile
`keymail`, eu-west-1. State: `s3://carlos-tfstate-271376211898/flagship/`.
-## Operator queue (actions the agent could not take unattended)
+## State (2026-08-01)
-1. **Apply the base stack** (15 resources, plan reviewed — buckets, IE
- box with SSM-only access, IAM):
+- ✅ Base stack applied (15 resources); IE box `i-0da792cabf8951fda`,
+ EIP **99.81.104.219**, `carlos-edge` ACTIVE (build 2024e50).
+- ✅ Mail stack applied (13 resources): `kass.training` SES identity +
+ DKIM CNAMEs in DNSimple, `kass-signin` config set, `carlos-kass-smtp`
+ user. DKIM propagation/verification pending — check
+ `aws --profile keymail sesv2 get-email-identity --email-identity kass.training`.
+- ✅ Secrets copied and byte-verified: `/carlos/kass/{prod,devbox}/pepper`,
+ `/carlos/kass/prod/smtp-{user,pass}`.
+- ✅ Release keys provisioned (`/carlos/release-key`); smoke static site
+ serves at `https://smoke.99.81.104.219.sslip.io/` with a real LE cert.
+- ✅ Kass `aedcae3` shipped to the deployment bucket (not yet promoted).
- ```
- cd infra/flagship && tofu apply plan.out
- ```
-
- (If `plan.out` is stale, `tofu plan -out=plan.out` first and re-review:
- everything should be `carlos-*` named, nothing keymail-touching.)
+## Operator queue
-2. **Apply the mail stack** (after 1; needs `DNSIMPLE_TOKEN` in env for
- the DKIM CNAMEs):
+1. **Promote pending releases** (needs the SSM signing keys):
```
- cd infra/flagship && DNSIMPLE_TOKEN=... tofu apply
+ CARLOS_BIN=./carlos bash infra/scripts/promote-pending.sh
```
-3. **Copy Kass's secrets** (blocked for the agent — credential material):
+2. **Kass rehearsal** (after 1; runs a restored copy on a rehearsal
+ hostname, no production impact):
```
- bash infra/flagship/scripts/copy-kass-secrets.sh
+ bash infra/scripts/rehearse-kass.sh
```
- Copies both peppers + prod SMTP creds to `/carlos/kass/...` in
- 271376211898, byte-verifying each. Values are never printed.
+ Needs the restored data at /tmp/claude-1001/kass-data (this session
+ restored + integrity-checked it: registry ok, 3 people, 3 instances).
-4. **Root email re-home** (whenever): move this account's root email to a
+3. **Root email re-home** (whenever): move this account's root email to a
carlosframework.com / carlosplatform.com address (spec §12).
## Kass cutover go/no-go checklist (Plan B task B6)
diff --git a/infra/flagship/plan.out b/infra/flagship/plan.out
index 57050db9a..c9e4c57b7 100644
Binary files a/infra/flagship/plan.out and b/infra/flagship/plan.out differ
diff --git a/infra/scripts/promote-pending.sh b/infra/scripts/promote-pending.sh
new file mode 100755
index 000000000..f47d22242
--- /dev/null
+++ b/infra/scripts/promote-pending.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+# 🤖 Promote the two shipped-but-unpromoted releases (signing keys come
+# from each account's SSM; never printed). Run when ready:
+# kass aedcae3 -> stable (flagship)
+# titogo ff61d3b -> canary/platform-adoption (tito)
+set -euo pipefail
+carlos=${CARLOS_BIN:-./carlos}
+
+AWS_PROFILE=keymail AWS_REGION=eu-west-1 \
+CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898 \
+CARLOS_RELEASE_KEY=$(aws --profile keymail ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \
+ "$carlos" promote -app kass aedcae3 stable
+
+AWS_PROFILE=titogo AWS_REGION=eu-west-1 \
+CARLOS_DEPLOYMENT_BUCKET=carlos-tito-platform-665106592142 \
+CARLOS_RELEASE_KEY=$(aws --profile titogo ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \
+ "$carlos" promote -app titogo ff61d3b canary/platform-adoption
diff --git a/infra/scripts/rehearse-kass.sh b/infra/scripts/rehearse-kass.sh
new file mode 100755
index 000000000..966375de6
--- /dev/null
+++ b/infra/scripts/rehearse-kass.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+# 🤖 B5: Kass migration rehearsal on the flagship box — no production
+# impact (old prod keeps serving; this runs a restored COPY on a
+# rehearsal hostname). Prereqs: promote-pending.sh ran (kass on stable);
+# restored data at /tmp/claude-1001/kass-data (or re-run the restore).
+# Usage: rehearse-kass.sh
+set -euo pipefail
+EIP=99.81.104.219
+INSTANCE=i-0da792cabf8951fda
+HOST="kass.${EIP}.sslip.io"
+D=${KASS_DATA_DIR:-/tmp/claude-1001/kass-data}
+
+# 1. Stage the restored data where the box's role can read it.
+AWS_PROFILE=keymail aws s3 sync "$D/" \
+ s3://carlos-data-ie-271376211898/kass-restore/ --no-progress
+
+# 2. On the box: data, env (pepper from SSM), unit, adopt, route, start.
+d=$(mktemp -d)
+cat > "$d/rehearse.sh" <<EOF
+set -e
+useradd --system --home /data/kass --shell /usr/sbin/nologin kass || true
+mkdir -p /data/kass
+aws s3 sync s3://carlos-data-ie-271376211898/kass-restore/ /data/kass/ --no-progress
+pepper=\$(aws ssm get-parameter --name /carlos/kass/prod/pepper --with-decryption --query Parameter.Value --output text --region eu-west-1)
+cat > /data/kass.env <<ENV
+KASS_ADDR=127.0.0.1:8080
+KASS_DATA=/data/kass
+KASS_ORIGIN=https://$HOST
+KASS_RPID=$HOST
+KASS_PEPPER=\$pepper
+ENV
+# No SMTP in the rehearsal: sign-in links log to the journal instead of
+# sending — validates the flow without emailing real people from a copy.
+chmod 600 /data/kass.env; chown -R kass:kass /data/kass /data/kass.env
+cat > /etc/systemd/system/kass.service <<'UNIT'
+[Unit]
+Description=Kass (CARLOS flagship tenant)
+After=network-online.target
+[Service]
+User=kass
+EnvironmentFile=/data/kass.env
+ExecStart=/opt/carlos/live/kass serve
+Restart=always
+NoNewPrivileges=yes
+ProtectSystem=strict
+ReadWritePaths=/data
+PrivateTmp=yes
+[Install]
+WantedBy=multi-user.target
+UNIT
+export \$(grep -v '^#' /etc/carlos/host.env | xargs)
+/opt/carlos/carlos adopt
+/opt/carlos/carlos add -app kass -kind instance -addr 127.0.0.1:8080 -channel stable $HOST
+systemctl daemon-reload && systemctl restart kass
+sleep 4
+systemctl is-active kass
+curl -s -o /dev/null -w 'api/library: %{http_code}\n' --max-time 20 https://$HOST/api/library
+EOF
+jq -n --rawfile s "$d/rehearse.sh" '{commands: ($s | split("\n"))}' > "$d/params.json"
+cmdid=$(aws --profile keymail ssm send-command --instance-ids "$INSTANCE" \
+ --document-name AWS-RunShellScript --parameters "file://$d/params.json" \
+ --query Command.CommandId --output text)
+for i in $(seq 1 36); do sleep 5
+ s=$(aws --profile keymail ssm get-command-invocation --command-id "$cmdid" --instance-id "$INSTANCE" --query Status --output text 2>/dev/null || echo Pending)
+ [ "$s" = Success ] || [ "$s" = Failed ] && break; done
+echo "status: $s"
+aws --profile keymail ssm get-command-invocation --command-id "$cmdid" --instance-id "$INSTANCE" \
+ --query '[StandardOutputContent,StandardErrorContent]' --output text | tail -10
+[ "$s" = Success ]
diff --git a/infra/scripts/ship-app.sh b/infra/scripts/ship-app.sh
new file mode 100755
index 000000000..55781e2c2
--- /dev/null
+++ b/infra/scripts/ship-app.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# 🤖 Ship and promote a release to a deployment. The signing key is read
+# from SSM inside this script and exported only to the promote call —
+# it never appears in a terminal or transcript.
+# Usage: ship-app.sh <aws-profile> <deployment-bucket> <app> <version> <channel> <kind> <file-or-dir>
+set -euo pipefail
+profile=$1; bucket=$2; app=$3; ver=$4; channel=$5; kind=$6; artifact=$7
+carlos=${CARLOS_BIN:-./carlos}
+export AWS_PROFILE=$profile AWS_REGION=eu-west-1 CARLOS_DEPLOYMENT_BUCKET=$bucket
+"$carlos" ship -app "$app" -kind "$kind" -version "$ver" "$artifact"
+CARLOS_RELEASE_KEY=$(aws ssm get-parameter --name /carlos/release-key \
+ --with-decryption --query Parameter.Value --output text) \
+ "$carlos" promote -app "$app" "$ver" "$channel"
diff --git a/infra/scripts/smoke-flagship.sh b/infra/scripts/smoke-flagship.sh
new file mode 100755
index 000000000..a50c3c9ce
--- /dev/null
+++ b/infra/scripts/smoke-flagship.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# 🤖 B2 smoke: on the flagship box, add a static route for the shipped
+# smokesite and verify the edge serves it end to end (bucket -> channel
+# pointer -> verified tar -> edge). Uses sslip.io so the hostname resolves
+# to the box for real ACME issuance.
+# Usage: smoke-flagship.sh <aws-profile> <instance-id> <eip>
+set -euo pipefail
+profile=$1; instance=$2; eip=$3
+host="smoke.${eip}.sslip.io"
+d=$(mktemp -d)
+
+cat > "$d/smoke.sh" <<EOF
+set -e
+export \$(grep -v '^#' /etc/carlos/host.env | xargs)
+/opt/carlos/carlos add -app smokesite -kind static -channel canary/smoke $host
+sleep 4
+curl -s --max-time 20 https://$host/ | head -2
+curl -s -o /dev/null -w 'docs: %{http_code}\n' https://$host/docs/
+curl -s -o /dev/null -w 'missing: %{http_code}\n' https://$host/nope
+EOF
+
+jq -n --rawfile s "$d/smoke.sh" '{commands: ($s | split("\n"))}' > "$d/params.json"
+cmdid=$(aws --profile "$profile" ssm send-command --instance-ids "$instance" \
+ --document-name AWS-RunShellScript --parameters "file://$d/params.json" \
+ --query Command.CommandId --output text)
+for i in $(seq 1 24); do
+ sleep 5
+ status=$(aws --profile "$profile" ssm get-command-invocation --command-id "$cmdid" \
+ --instance-id "$instance" --query Status --output text 2>/dev/null || echo Pending)
+ [ "$status" = "Success" ] || [ "$status" = "Failed" ] && break
+done
+echo "status: $status"
+aws --profile "$profile" ssm get-command-invocation --command-id "$cmdid" \
+ --instance-id "$instance" --query '[StandardOutputContent,StandardErrorContent]' --output text | tail -12
+[ "$status" = "Success" ]
diff --git a/infra/tito/README.md b/infra/tito/README.md
index 467d290a6..71a582455 100644
--- a/infra/tito/README.md
+++ b/infra/tito/README.md
@@ -5,20 +5,26 @@
AWS account **665106592142** (Tito's own; self-hosted deployment), profile
`titogo`, eu-west-1 (IE) first. State: `s3://carlos-tfstate-665106592142/tito/`.
-## Operator queue
-
-1. **Apply the stack** (21 resources, plan reviewed — buckets, IE box,
- svc/pdf Gotenberg, `ie.go.tito.io` alias; does NOT touch `go.tito.io`
- or the wildcard):
+## State (2026-08-01)
- ```
- cd infra/tito && tofu apply plan.out
- ```
+- ✅ Stack applied (21 resources): IE box `i-025a0b70f3d93304d`, EIP
+ **54.228.234.85**, `carlos-edge` ACTIVE (build 2024e50);
+ `ie.go.tito.io` → the EIP; svc/pdf Gotenberg box up
+ (`i-0bb7069abfa36b2aa`, private 172.31.11.159). Hetzner DNS untouched.
+- ✅ Release key provisioned (`/carlos/release-key` in 665106592142).
+- ✅ titogo `ff61d3b` shipped to the deployment bucket (not yet promoted).
- (Re-plan first if stale. Everything is `carlos-tito-*` named; the
- litestream bucket and Hetzner DNS are untouched.)
+## Operator queue
-2. **Set svc/pdf basic-auth** via SSM session: write a
+1. **Promote pending releases**: `bash infra/scripts/promote-pending.sh`
+ (also covers the flagship's Kass promote).
+2. **titogo canary** (after 1): stage a canary instance at
+ `platform.ie.go.tito.io` per Plan C task C2 — route via
+ `carlos add -app titogo -kind instance -addr 127.0.0.1:8443
+ -channel canary/platform-adoption platform.ie.go.tito.io` on the box,
+ plus a Route53 A record for `platform.ie.go.tito.io` (or reuse the
+ `ie.go.tito.io` alias with a CNAME).
+3. **Set svc/pdf basic-auth** via SSM session: write a
`user bcrypt-hash` line to `/etc/caddy/pdf-basicauth` on the svc-pdf
box and `systemctl restart caddy`.