#!/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 # 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. /../bin/harness-linux-amd64 (repo layout, staged tgz) # 3. /bin/harness-linux-amd64 # 4. /harness-linux-amd64 set -eu die() { echo "install-account.sh: $*" >&2; exit 1; } [ $# -eq 1 ] || die "usage: install-account.sh (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" <"$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"