deploy: 9-account/2-host packaging + static release build (Redmine 494)

Everything needed for one ~10-minute Charles window on ultix-streaming
and ultix-offstage, with zero root/systemd on the target accounts:

- deploy/accounts.tsv: the fleet authority (account, host, vertical,
  Redmine project, quota group, ports). Port scheme events=4100+index,
  serve=8090+index with a global 0-8 index so per-account daemons on
  one host never collide; loop state is per-account under ~/.mopac.
- deploy/install-account.sh <account>: idempotent installer run AS the
  target user; renders harness.toml from deploy/harness.toml.in, writes
  a 0600 env-secrets template, refuses to overwrite existing config or
  secrets (re-run = the upgrade path), generates mopac-start/stop.
- deploy/runbook.md: exact Charles sequence (build, stage, install,
  secrets bootstrap, verify, start via nohup or cron @reboot, rollback)
  with the account-port table, per-host time estimates and assumptions.
- Makefile: release target (digest-pinned docker builder, CGO off,
  linux/amd64 static, stripped) plus check/deploy-test entrypoints.
- deploy/tests.sh: 13 packaging tests (TSV scheme, template substitution
  for all 9 accounts, idempotence, refuse-to-overwrite, binary lookup,
  rendered config loads via a dry-run); README deploy section.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-29 05:50:41 -05:00
parent fc518c475e
commit b7799ea0de
7 changed files with 889 additions and 1 deletions
+203
View File
@@ -0,0 +1,203 @@
#!/bin/sh
# install-account.sh — idempotent per-account MOPAC harness installer
# (Redmine 494, SPEC-20260829-charles-brief.md).
#
# Runs AS the target user on the target host (no root, no sudo): creates
# ~/.mopac/{bin,state,reports,work}, installs the static linux/amd64
# binary (built once via `make release` in the Docker builder), renders
# harness.toml from deploy/harness.toml.in + the deploy/accounts.tsv row,
# and writes the 0600 env-secrets template plus start/stop helpers.
#
# Idempotence contract (deploy/tests.sh asserts all of this):
# - re-running is always safe: existing harness.toml and ~/.mopac/env
# are NEVER overwritten (delete them to re-render);
# - the binary and the generated start/stop helpers ARE refreshed
# (that is the upgrade path: make release, re-stage, re-run);
# - exit 0 on success (including "already installed, kept"), non-zero
# only on genuine errors (unknown account, missing binary/template).
#
# Usage: deploy/install-account.sh <account>
# Env: MOPAC_HARNESS_BIN explicit binary path (overrides lookup;
# used by deploy/tests.sh with a stub binary)
#
# Lookup order for the binary:
# 1. $MOPAC_HARNESS_BIN
# 2. <deploy dir>/../bin/harness-linux-amd64 (repo layout, staged tgz)
# 3. <deploy dir>/bin/harness-linux-amd64
# 4. <deploy dir>/harness-linux-amd64
set -eu
die() { echo "install-account.sh: $*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: install-account.sh <account> (accounts: see accounts.tsv)"
ACCOUNT=$1
DEPLOY_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
TSV="$DEPLOY_DIR/accounts.tsv"
TEMPLATE="$DEPLOY_DIR/harness.toml.in"
[ -r "$TSV" ] || die "accounts.tsv not found next to this script ($DEPLOY_DIR)"
[ -r "$TEMPLATE" ] || die "harness.toml.in not found next to this script"
# --- account row from the TSV (tab-separated, exact case-sensitive match)
ROW=$(awk -F'\t' -v a="$ACCOUNT" '$1 !~ /^#/ && $1 == a {print}' "$TSV")
[ -n "$ROW" ] || die "account '$ACCOUNT' not in $TSV"
[ "$(printf '%s\n' "$ROW" | wc -l)" -eq 1 ] || die "account '$ACCOUNT' has duplicate rows in accounts.tsv"
HOST=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $2}')
INDEX=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $3}')
VERTICAL=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $4}')
REDMINE_PROJECT=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $5}')
QUOTA_ACCOUNT=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $6}')
EVENTS_PORT=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $7}')
SERVE_PORT=$(printf '%s\n' "$ROW" | awk -F'\t' '{print $8}')
[ -n "$HOST" ] && [ -n "$INDEX" ] && [ -n "$VERTICAL" ] && [ -n "$REDMINE_PROJECT" ] \
&& [ -n "$QUOTA_ACCOUNT" ] && [ -n "$EVENTS_PORT" ] && [ -n "$SERVE_PORT" ] \
|| die "malformed row for '$ACCOUNT' in accounts.tsv (need 8 columns)"
# --- concurrency guard: ports must follow the fleet scheme (unique per
# account, so multiple daemons on one host never collide)
[ "$EVENTS_PORT" -eq $((4100 + INDEX)) ] || die "$ACCOUNT: events_port $EVENTS_PORT != 4100+$INDEX (accounts.tsv scheme broken)"
[ "$SERVE_PORT" -eq $((8090 + INDEX)) ] || die "$ACCOUNT: serve_port $SERVE_PORT != 8090+$INDEX (accounts.tsv scheme broken)"
# --- locate the static binary
BIN=""
for cand in "${MOPAC_HARNESS_BIN:-}" "$DEPLOY_DIR/../bin/harness-linux-amd64" \
"$DEPLOY_DIR/bin/harness-linux-amd64" "$DEPLOY_DIR/harness-linux-amd64"; do
[ -n "$cand" ] && [ -f "$cand" ] && { BIN=$cand; break; }
done
[ -n "$BIN" ] || die "static binary not found (run 'make release' and stage bin/harness-linux-amd64 next to deploy/, or set MOPAC_HARNESS_BIN)"
HOME_DIR=$(CDPATH= cd && pwd)
MOPAC="$HOME_DIR/.mopac"
# --- advisory: wrong host? (hostname check never blocks an install)
THIS_HOST=$(hostname -s 2>/dev/null || echo unknown)
if [ "$THIS_HOST" != "$HOST" ]; then
echo "install-account.sh: NOTE: account '$ACCOUNT' belongs on '$HOST' but this host is '$THIS_HOST' (continuing)"
fi
# --- 1. directory skeleton
mkdir -p "$MOPAC/bin" "$MOPAC/state/loop" "$MOPAC/state/events" "$MOPAC/reports" "$MOPAC/work"
chmod 700 "$MOPAC" "$MOPAC/state"
# --- 2. binary (always refreshed: the upgrade path)
cp "$BIN" "$MOPAC/bin/harness.new"
chmod 0755 "$MOPAC/bin/harness.new"
mv -f "$MOPAC/bin/harness.new" "$MOPAC/bin/harness"
echo "installed binary: $MOPAC/bin/harness ($(wc -c <"$MOPAC/bin/harness" | tr -d ' ') bytes)"
# --- 3. harness.toml (rendered once; NEVER overwritten)
if [ -e "$MOPAC/harness.toml" ]; then
echo "kept existing: $MOPAC/harness.toml (delete it to re-render)"
else
sed -e "s|@ACCOUNT@|$ACCOUNT|g" \
-e "s|@HOST@|$HOST|g" \
-e "s|@INDEX@|$INDEX|g" \
-e "s|@VERTICAL@|$VERTICAL|g" \
-e "s|@REDMINE_PROJECT@|$REDMINE_PROJECT|g" \
-e "s|@QUOTA_ACCOUNT@|$QUOTA_ACCOUNT|g" \
-e "s|@EVENTS_PORT@|$EVENTS_PORT|g" \
-e "s|@SERVE_PORT@|$SERVE_PORT|g" \
-e "s|@HOME@|$HOME_DIR|g" \
"$TEMPLATE" > "$MOPAC/harness.toml.new"
if grep -q '@[A-Z_][A-Z0-9_]*@' "$MOPAC/harness.toml.new"; then
rm -f "$MOPAC/harness.toml.new"
die "template substitution left placeholders for '$ACCOUNT' (template/template mismatch)"
fi
mv "$MOPAC/harness.toml.new" "$MOPAC/harness.toml"
echo "rendered: $MOPAC/harness.toml (vertical=$VERTICAL project=$REDMINE_PROJECT events=:$EVENTS_PORT serve=:$SERVE_PORT)"
fi
# --- 4. secrets template (0600; NEVER overwritten)
if [ -e "$MOPAC/env" ]; then
echo "kept existing: $MOPAC/env (fill it if not yet filled)"
else
ENV_TMP="$MOPAC/env.new"
cat >"$ENV_TMP" <<EOF
# MOPAC secrets for $ACCOUNT ($HOST). Mode 0600. NEVER committed, never
# logged; sourced by ~/.mopac/bin/mopac-start and referenced from
# harness.toml as env: refs. Fill each value from Bitwarden/Vault.
export HARNESS_REDMINE_KEY=""
export HARNESS_LITELLM_KEY=""
export HARNESS_REDMINE_WEBHOOK_SECRET=""
export HARNESS_DISCOURSE_WEBHOOK_SECRET=""
export HARNESS_GITEA_WEBHOOK_SECRET=""
export HARNESS_SERVE_VKEY=""
# Optional (uncomment the matching harness.toml section when used):
# export HARNESS_GITEA_KEY=""
# export HARNESS_KEYPROXY_TOKEN=""
# export HARNESS_ZAI_KEY=""
EOF
chmod 0600 "$ENV_TMP"
mv "$ENV_TMP" "$MOPAC/env"
echo "created: $MOPAC/env (0600) — FILL IT IN (runbook step: secrets bootstrap)"
fi
# --- 5. start/stop helpers (generated; always refreshed)
mkscript() { # dst mode
DST=$1; MODE=$2
chmod "$MODE" "$DST.new" && mv -f "$DST.new" "$DST"
}
START="$MOPAC/bin/mopac-start"
sed -e "s|@ACCOUNT@|$ACCOUNT|g" -e "s|@EVENTS_PORT@|$EVENTS_PORT|g" -e "s|@SERVE_PORT@|$SERVE_PORT|g" >"$START.new" <<'EOF'
#!/bin/sh
# Generated by install-account.sh for @ACCOUNT@ — safe to regenerate.
# Starts the three daemons under this account (no root, no systemd):
# loop (Redmine poll -> bounded turns)
# events (webhook receiver, :@EVENTS_PORT@)
# serve (OWUI front door, :@SERVE_PORT@)
# Idempotent: daemons with a live pid are skipped. Logs + pids land in
# ~/.mopac/state/. Survives logout (nohup); for reboot persistence add
# the cron @reboot line from deploy/runbook.md.
set -eu
H="${HOME%/}/.mopac"
if [ -r "$H/env" ]; then . "$H/env"; fi
cd "$H"
start() { # name args...
name=$1; shift
if [ -s "$H/state/$name.pid" ] && kill -0 "$(cat "$H/state/$name.pid")" 2>/dev/null; then
echo "$name already running (pid $(cat "$H/state/$name.pid"))"
return 0
fi
nohup "$H/bin/harness" "$@" >>"$H/state/$name.log" 2>&1 &
echo $! >"$H/state/$name.pid"
echo "started $name (pid $!)"
}
start loop -config "$H/harness.toml"
start events -config "$H/harness.toml" -listen ":@EVENTS_PORT@"
start serve -config "$H/harness.toml" -listen ":@SERVE_PORT@"
EOF
mkscript "$START" 0755
STOP="$MOPAC/bin/mopac-stop"
sed -e "s|@ACCOUNT@|$ACCOUNT|g" >"$STOP.new" <<'EOF'
#!/bin/sh
# Generated by install-account.sh for @ACCOUNT@ — safe to regenerate.
# Stops the daemons started by mopac-start (SIGTERM; the harness exits
# cleanly on SIGINT/SIGTERM). Stale pids are cleaned up.
H="${HOME%/}/.mopac"
for name in loop events serve; do
f="$H/state/$name.pid"
if [ -s "$f" ]; then
pid=$(cat "$f")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" && echo "stopped $name (pid $pid)"
else
echo "$name not running (stale pid $pid)"
fi
rm -f "$f"
else
echo "$name not running (no pid file)"
fi
done
EOF
mkscript "$STOP" 0755
echo "generated: $MOPAC/bin/mopac-start, $MOPAC/bin/mopac-stop"
echo
echo "account '$ACCOUNT' on $HOST ready. Next (runbook):"
echo " 1. fill in ~/.mopac/env (Bitwarden/Vault values)"
echo " 2. smoke: ~/.mopac/bin/harness once --dry-run --demo -config ~/.mopac/harness.toml"
echo " 3. start: ~/.mopac/bin/mopac-start"