dumpd gains POST /deploy-bot on the bridge-only auth port: HMAC-SHA256 over the body with BOT_DEPLOY_SECRET, a 300 MB cap, one install at a time, and the installer's output returned in the response so the CI job shows it. BOT_DEPLOY_AUTO=0 makes the server refuse. configure.py generates the secret, writes both settings into /etc/vanguard/deploy.env, installs deployBotBundle.sh as /usr/local/bin/vanguard-deploy-bot and creates /srv/bot/app; --show prints what to paste into Gitea. Chose this over mounting the Docker socket into job containers, which would give every CI job root on the host. Also moved threading/subprocess to dumpd's module imports, which the new module-level lock needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
6.5 KiB
Bash
Executable File
124 lines
6.5 KiB
Bash
Executable File
#!/bin/bash
|
|
# Install a Vanguard Bot release bundle into the place the discord-bot container expects it.
|
|
#
|
|
# The bundle comes from the bot repo's CI (workflow "ci", job "bundle", artifact "vanguard-bot-bundle"):
|
|
# one tar.gz holding main.py, app/, website/, version.json, BUILD.json and vendor/ — the bot's own
|
|
# dependencies. Nothing is installed here and the server needs no package index.
|
|
#
|
|
# ./deployBotBundle.sh vanguard-bot-bundle.tar.gz install that file
|
|
# ./deployBotBundle.sh --latest download the newest main build from Gitea first
|
|
# ./deployBotBundle.sh --rollback put the previous bundle back
|
|
# ./deployBotBundle.sh --status what is installed right now
|
|
#
|
|
# --latest needs a Gitea token with read access to the repo, in .env as BOT_REPO_TOKEN (or $BOT_REPO_TOKEN).
|
|
# Layout: $BOT_DIR/app = live (mounted at /app) $BOT_DIR/previous = the one before $BOT_DIR/.incoming = staging
|
|
set -euo pipefail
|
|
# Runs from the bundle root or as /usr/local/bin/vanguard-deploy-bot (dumpd calls it that way): find the stack.
|
|
STACK="${VANGUARD_DIR:-}"
|
|
if [[ -z $STACK && -f /etc/vanguard/deploy.env ]]; then
|
|
STACK=$(grep -E '^DEPLOY_REPO_DIR=' /etc/vanguard/deploy.env | head -1 | cut -d= -f2- || true)
|
|
fi
|
|
if [[ -z $STACK || ! -f $STACK/docker-compose.yml ]]; then STACK="$(cd "$(dirname "$0")" && pwd)"; fi
|
|
[[ -f $STACK/docker-compose.yml ]] || { echo "error: cannot find the stack (set VANGUARD_DIR)" >&2; exit 1; }
|
|
cd "$STACK"
|
|
BOT_DIR="${BOT_DIR:-/srv/bot}"
|
|
REPO="${BOT_REPO:-VanguardOrg/VanguardBot}"
|
|
BRANCH="${BOT_BRANCH:-main}"
|
|
fromenv() { [[ -f .env ]] && grep -E "^$1=" .env | head -1 | cut -d= -f2- || true; }
|
|
GIT_HOST="$(fromenv GIT_HOST)"
|
|
BOT_HOST="$(fromenv BOT_HOST)"
|
|
BOT_REPO_TOKEN="${BOT_REPO_TOKEN:-$(fromenv BOT_REPO_TOKEN)}"
|
|
|
|
say() { printf '\n== %s\n' "$*"; }
|
|
die() { echo "error: $*" >&2; exit 1; }
|
|
|
|
unwrap() { # unwrap <downloaded-file> <destination.tar.gz> — Gitea hands out artifacts as a zip holding the tar.gz
|
|
if python3 -c "import zipfile,sys; sys.exit(0 if zipfile.is_zipfile(sys.argv[1]) else 1)" "$1"; then
|
|
python3 - "$1" "$2" <<'PYZIP'
|
|
import sys,zipfile
|
|
with zipfile.ZipFile(sys.argv[1]) as archive:
|
|
names=[n for n in archive.namelist() if n.endswith('.tar.gz')] or archive.namelist()
|
|
if not names: sys.exit('the zip holds no files')
|
|
open(sys.argv[2],'wb').write(archive.read(names[0]))
|
|
PYZIP
|
|
echo " (unpacked $(basename "$1"): a zip from Gitea)"
|
|
elif [[ $1 != "$2" ]]; then cp -f "$1" "$2"; fi
|
|
}
|
|
|
|
summary() { # summary <bundle-root>
|
|
python3 - "$1" <<'PY'
|
|
import json,sys,pathlib
|
|
root=pathlib.Path(sys.argv[1]); build=root/'BUILD.json'
|
|
if not build.is_file(): print(' (no BUILD.json — not a CI bundle)'); sys.exit(0)
|
|
d=json.loads(build.read_text())
|
|
print(f" version {d.get('version')} commit {(d.get('commit') or '?')[:8]} built {d.get('built_at')} {d.get('python')} ({d.get('abi')})")
|
|
print(f" {len(d.get('files',{}))} files, {len(d.get('packages',[]))} packages: {', '.join(d.get('packages',[])[:6])}…")
|
|
PY
|
|
}
|
|
|
|
case "${1:-}" in
|
|
--status)
|
|
say "installed at $BOT_DIR/app"; summary "$BOT_DIR/app"
|
|
if [[ -d $BOT_DIR/previous ]]; then say "previous"; summary "$BOT_DIR/previous"; fi
|
|
say "container"; docker ps --filter name=discord-bot --format ' {{.Names}} {{.Status}}'
|
|
exit 0 ;;
|
|
--rollback)
|
|
[[ -d $BOT_DIR/previous ]] || die "no previous bundle in $BOT_DIR/previous"
|
|
say "rolling back"; summary "$BOT_DIR/previous"
|
|
docker stop discord-bot >/dev/null
|
|
rm -rf "$BOT_DIR/.rollback" && mv "$BOT_DIR/app" "$BOT_DIR/.rollback" && mv "$BOT_DIR/previous" "$BOT_DIR/app" && mv "$BOT_DIR/.rollback" "$BOT_DIR/previous"
|
|
docker start discord-bot >/dev/null; sleep 8; docker logs --tail 12 discord-bot; exit 0 ;;
|
|
--latest)
|
|
[[ -n ${BOT_REPO_TOKEN:-} ]] || die "set BOT_REPO_TOKEN in .env (Gitea token with read access to $REPO)"
|
|
[[ -n $GIT_HOST ]] || die "GIT_HOST not found in .env"
|
|
TARBALL=$(mktemp /tmp/vanguard-bot-bundle.XXXXXX.tar.gz)
|
|
say "newest '$BRANCH' bundle from https://$GIT_HOST/$REPO"
|
|
URL=$(curl -fsS -H "Authorization: token $BOT_REPO_TOKEN" \
|
|
"https://$GIT_HOST/api/v1/repos/$REPO/actions/artifacts?name=vanguard-bot-bundle" |
|
|
python3 -c "
|
|
import json,sys
|
|
items=json.load(sys.stdin).get('artifacts') or []
|
|
ok=[a for a in items if not a.get('expired')]
|
|
if not ok: sys.exit('no unexpired vanguard-bot-bundle artifact — check the ci workflow ran')
|
|
print(sorted(ok,key=lambda a:a.get('created_at',''))[-1]['archive_download_url'])")
|
|
curl -fsSL -H "Authorization: token $BOT_REPO_TOKEN" -o "$TARBALL.zip" "$URL"
|
|
unwrap "$TARBALL.zip" "$TARBALL"; rm -f "$TARBALL.zip" ;;
|
|
"" | -h | --help) sed -n '2,20p' "$0"; exit 2 ;;
|
|
*) [[ -f $1 ]] || die "no such file: $1"
|
|
TARBALL=$(mktemp /tmp/vanguard-bot-bundle.XXXXXX.tar.gz); unwrap "$1" "$TARBALL" ;;
|
|
esac
|
|
|
|
say "staging $TARBALL"
|
|
rm -rf "$BOT_DIR/.incoming"; mkdir -p "$BOT_DIR/.incoming"
|
|
tar -xzf "$TARBALL" -C "$BOT_DIR/.incoming"
|
|
for required in main.py app/main.py app/config.py website/static/home.html vendor; do
|
|
[[ -e "$BOT_DIR/.incoming/$required" ]] || die "bundle is missing $required — is this the CI artifact?"
|
|
done
|
|
summary "$BOT_DIR/.incoming"
|
|
python3 - "$BOT_DIR/.incoming" <<'PY'
|
|
import hashlib,json,pathlib,sys
|
|
root=pathlib.Path(sys.argv[1]); d=json.loads((root/'BUILD.json').read_text())
|
|
bad=[name for name,digest in d.get('files',{}).items()
|
|
if name!='BUILD.json' and hashlib.sha256((root/name).read_bytes()).hexdigest()!=digest]
|
|
sys.exit(f" {len(bad)} file(s) do not match BUILD.json: {bad[:3]}" if bad else 0)
|
|
PY
|
|
echo " checksums match BUILD.json"
|
|
|
|
say "swapping in and restarting discord-bot"
|
|
docker stop discord-bot >/dev/null 2>&1 || true
|
|
rm -rf "$BOT_DIR/previous"
|
|
if [[ -d $BOT_DIR/app ]]; then mv "$BOT_DIR/app" "$BOT_DIR/previous"; fi
|
|
mv "$BOT_DIR/.incoming" "$BOT_DIR/app"
|
|
chown -R 1000:1000 "$BOT_DIR/app" 2>/dev/null || sudo chown -R 1000:1000 "$BOT_DIR/app"
|
|
docker compose up -d discord-bot >/dev/null
|
|
sleep 10
|
|
|
|
say "result"
|
|
docker ps --filter name=discord-bot --format ' {{.Names}} {{.Status}}'
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'X-Authentik-Username: deploy-check' -H 'X-Authentik-Groups: admins' http://127.0.0.1:8000/health || true)
|
|
echo " /health -> ${code:-no answer} (200 = serving; 401/403 = running but the identity headers were refused)"
|
|
docker logs --tail 15 discord-bot 2>&1 | sed 's/^/ /'
|
|
echo
|
|
echo " web UI: https://${BOT_HOST:-BOT_HOST-not-set}/ (Caddy + Authentik; needs the bot-users group)"
|
|
echo " rollback: ./deployBotBundle.sh --rollback"
|