magicciv/scripts/run/forge.sh
Natalie 6332d47011 fix(infra): make the DO fleet actually work on real hardware + render host
Real-DO testing surfaced bugs the mocked tests couldn't:
- ssh key: reference shared 'mc-fleet' key via data source, not a duplicate (DO 422s on dup pubkeys).
- cmd_dist_up: fail loudly on failed apply; dist:up waits for cloud-init readiness.
- snapshot cloud-init skips runcmd -> bake authorized_keys (FLEET_PUBKEY) + 'cloud-init clean' before snapshot.
- build user passwordless sudo; apt dpkg-lock race fixed (cloud-init --wait + Lock::Timeout).
- size s-8vcpu-16gb-amd (tier max); creds via PKR_VAR env not argv.
- render host: weston+Mesa baked; ./run dist:render proven (Godot->PNG on DO, no GPU). forge:dns shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:45:29 -04:00

124 lines
6 KiB
Bash
Executable file

#!/usr/bin/env bash
# Forgejo origin lifecycle on DigitalOcean. Sourced by ./run (defines cmd_forge_*).
# ./run forge:down stop service, snapshot, destroy droplet (~$6/mo -> ~$0.30/mo idle)
# ./run forge:up recreate from newest snapshot, refresh ~/.vault/mc_forge_creds
#
# DO bills powered-off droplets; only destroy stops billing, so "down" =
# snapshot + destroy and "up" = create-from-snapshot. The droplet gets a NEW ip
# each time, so forge:up refreshes the vault creds file (the single source of
# truth for the forge URL).
_FORGE_TAG="forgejo"
_FORGE_SIZE="s-1vcpu-1gb"
_FORGE_REGION="nyc3"
_FORGE_KEY_ID="57416789"
_FORGE_SNAP_PREFIX="mc-forge-snap"
_VAULT_PAT="$HOME/.vault/do_pat_mc"
_VAULT_CREDS="$HOME/.vault/mc_forge_creds"
_forge_pat() { cat "$_VAULT_PAT" 2>/dev/null; }
_forge_curl() {
# _forge_curl METHOD PATH [JSON-body]
local method="$1" path="$2" data="${3:-}" pat
pat="$(_forge_pat)"
if [ -n "$data" ]; then
curl -s -X "$method" -H "Authorization: Bearer $pat" -H "Content-Type: application/json" -d "$data" "https://api.digitalocean.com/v2${path}"
else
curl -s -X "$method" -H "Authorization: Bearer $pat" "https://api.digitalocean.com/v2${path}"
fi
}
_forge_droplet_id() {
_forge_curl GET "/droplets?tag_name=${_FORGE_TAG}" \
| python3 -c "import sys,json;d=json.load(sys.stdin).get('droplets',[]);print(d[0]['id'] if d else '')"
}
_forge_project_id() {
_forge_curl GET "/projects" \
| python3 -c "import sys,json;print(next((p['id'] for p in json.load(sys.stdin)['projects'] if p['name']=='mc:dev'),''))"
}
_forge_wait_action() {
local aid="$1" st
for _ in $(seq 1 120); do
st=$(_forge_curl GET "/actions/$aid" | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['status'])" 2>/dev/null)
[ "$st" = completed ] && return 0
[ "$st" = errored ] && { echo "action $aid errored" >&2; return 1; }
sleep 5
done
echo "action $aid timed out" >&2
return 1
}
cmd_forge() {
cat <<'EOF'
Forgejo origin lifecycle (DigitalOcean). Needs ~/.vault/do_pat_mc.
./run forge:down stop + snapshot + destroy (~$6/mo -> ~$0.30/mo idle)
./run forge:up restore from newest snapshot, refresh vault creds
./run forge:dns point the 'mcforge' hostname at the current forge IP (sudo; macOS /etc/hosts)
EOF
}
cmd_forge_dns() {
# Map a friendly hostname to the current forge IP in /etc/hosts (macOS).
# Re-run after forge:up (the IP changes). Browse the forge at http://mcforge:3000.
local name="${1:-mcforge}" ip
ip="$(grep -E '^FORGE_IP=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2)"
[ -n "$ip" ] || { echo "no FORGE_IP in $_VAULT_CREDS" >&2; return 1; }
sudo sh -c "sed -i '' '/[[:space:]]${name}\$/d' /etc/hosts 2>/dev/null; printf '%s\t%s\n' '$ip' '$name' >> /etc/hosts"
echo "/etc/hosts: $name -> $ip → http://$name:3000"
}
cmd_forge_down() {
local id ip aid snap
id="$(_forge_droplet_id)"
[ -n "$id" ] || { echo "no live mc-forge droplet (already down?)" >&2; return 1; }
ip=$(grep -E '^FORGE_IP=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2)
echo "[1/4] stopping forgejo service on ${ip:-?}"
[ -n "$ip" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_mc_fleet root@"$ip" 'systemctl stop forgejo' 2>/dev/null || true
echo "[2/4] powering off droplet $id"
aid=$(_forge_curl POST "/droplets/$id/actions" '{"type":"power_off"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['id'])")
_forge_wait_action "$aid" || return 1
snap="${_FORGE_SNAP_PREFIX}-$(date +%Y%m%d%H%M%S)"
echo "[3/4] snapshotting -> $snap"
aid=$(_forge_curl POST "/droplets/$id/actions" "{\"type\":\"snapshot\",\"name\":\"$snap\"}" | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['id'])")
_forge_wait_action "$aid" || return 1
echo "[4/4] destroying droplet $id"
_forge_curl DELETE "/droplets/$id" >/dev/null
echo "forge down — snapshot $snap kept (~\$0.30/mo). './run forge:up' to restore."
}
cmd_forge_up() {
local snapid did ip code projid admin_user admin_pass
snapid=$(_forge_curl GET "/snapshots?resource_type=droplet" \
| python3 -c "import sys,json;s=[x for x in json.load(sys.stdin)['snapshots'] if x['name'].startswith('${_FORGE_SNAP_PREFIX}')];s.sort(key=lambda x:x['created_at']);print(s[-1]['id'] if s else '')")
[ -n "$snapid" ] || { echo "no ${_FORGE_SNAP_PREFIX}-* snapshot found" >&2; return 1; }
echo "[1/4] creating droplet from snapshot $snapid"
did=$(_forge_curl POST "/droplets" "{\"name\":\"mc-forge\",\"region\":\"${_FORGE_REGION}\",\"size\":\"${_FORGE_SIZE}\",\"image\":${snapid},\"ssh_keys\":[${_FORGE_KEY_ID}],\"tags\":[\"magic-civilization\",\"${_FORGE_TAG}\"]}" \
| python3 -c "import sys,json;d=json.load(sys.stdin).get('droplet');print(d['id'] if d else '')")
[ -n "$did" ] || { echo "create failed" >&2; return 1; }
echo "[2/4] waiting for active + ip (droplet $did)"
for _ in $(seq 1 40); do
ip=$(_forge_curl GET "/droplets/$did" | python3 -c "import sys,json;d=json.load(sys.stdin)['droplet'];ips=[n['ip_address'] for n in d['networks']['v4'] if n['type']=='public'];print(ips[0] if ips and d['status']=='active' else '')")
[ -n "$ip" ] && break
sleep 8
done
[ -n "$ip" ] || { echo "droplet never reported an ip" >&2; return 1; }
echo "[3/4] waiting for forgejo http at $ip:3000"
for _ in $(seq 1 30); do code=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "http://$ip:3000/" 2>/dev/null); [ "$code" = 200 ] && break; sleep 4; done
projid="$(_forge_project_id)"
[ -n "$projid" ] && _forge_curl POST "/projects/$projid/resources" "{\"resources\":[\"do:droplet:$did\"]}" >/dev/null
echo "[4/4] refreshing $_VAULT_CREDS with new ip"
admin_user=$(grep -E '^ADMIN_USER=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2); admin_user=${admin_user:-mcadmin}
admin_pass=$(grep -E '^ADMIN_PASS=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2)
umask 177
cat > "$_VAULT_CREDS" <<EOF
FORGE_IP=$ip
FORGE_URL=http://$ip:3000
ADMIN_USER=$admin_user
ADMIN_PASS=$admin_pass
SSH_KEY=~/.ssh/id_mc_fleet
EOF
echo "forge up at http://$ip:3000 (http $code). vault creds refreshed."
}