diff --git a/.github/scripts/preview_slug.py b/.github/scripts/preview_slug.py index 87f906a0..e51c2778 100644 --- a/.github/scripts/preview_slug.py +++ b/.github/scripts/preview_slug.py @@ -13,7 +13,7 @@ DOMAIN = "tfminecraft.net" MAX_LENGTH = 40 # Hostnames with their own DNS records or tunnel rules; a preview there could never be reached. -RESERVED = {"www", "dev", "api", "mail", "map", "admin", "staff", "play", "mc", "status", "preview", "t3"} +RESERVED = {"www", "dev", "api", "mail", "map", "admin", "staff", "play", "mc", "status", "preview", "t3", "portainer"} VALID = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?") diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 51a17801..8adb59e8 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -2,6 +2,8 @@ name: tfmc-staging services: backend: + # Reaps the renderer's Chromium processes. + init: true build: context: . dockerfile: backend/Dockerfile @@ -37,6 +39,7 @@ services: restart: unless-stopped frontend: + init: true build: context: . dockerfile: frontend/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 9436ac10..e09e1f04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ services: backend: + # Reaps the renderer's Chromium processes. + init: true build: context: . dockerfile: backend/Dockerfile @@ -17,6 +19,7 @@ services: restart: always frontend: + init: true build: context: . dockerfile: frontend/Dockerfile diff --git a/scripts/ps-compose.sh b/scripts/ps-compose.sh new file mode 100755 index 00000000..97b3683d --- /dev/null +++ b/scripts/ps-compose.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Limited docker compose for the ProvinceSystem main and dev sites. Installed as root's +# /usr/local/sbin/ps-compose and run as tfmc (docker group) through sudo, so people +# without docker access can manage the two sites but not mount the host. +set -euo pipefail + +usage() { + echo "usage: ps-compose [args...]" >&2 + exit 1 +} + +[[ $# -ge 2 ]] || usage +target="$1" +shift +cmd="$1" +shift + +case "$target" in + main) + dir=/home/tfmc/ProvinceSystem + files=(-f "$dir/docker-compose.yml" -f "$dir/docker-compose.override.yml") + ;; + dev) + dir=/home/tfmc/tfmc-staging + files=(-f "$dir/docker-compose.staging.yml" -f "$dir/docker-compose.override.yml") + ;; + *) + usage + ;; +esac + +case "$cmd" in + ps|logs|start|stop|restart|up|build|pull|exec|top) ;; + *) + echo "subcommand not allowed: $cmd" >&2 + usage + ;; +esac + +for arg in "$@"; do + # After `logs`, -f is --follow; elsewhere it could select a compose file. + [[ $cmd == logs && $arg == -f ]] && continue + case "$arg" in + -f|--file|--file=*|-H|--host|--host=*|--context|--context=*|--project-directory|--project-directory=*|--project-name|--project-name=*|--env-file|--env-file=*|--privileged|--privileged=*|-v|--volume|--volume=*|--mount|--mount=*) + echo "argument not allowed: $arg" >&2 + exit 1 + ;; + esac +done + +cd "$dir" +exec docker compose --project-directory "$dir" "${files[@]}" "$cmd" "$@" diff --git a/scripts/ps-image-prune.service b/scripts/ps-image-prune.service new file mode 100644 index 00000000..96bdc065 --- /dev/null +++ b/scripts/ps-image-prune.service @@ -0,0 +1,10 @@ +[Unit] +Description=Remove untagged ProvinceSystem website builds and week-old build cache +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +Nice=10 +IOSchedulingClass=idle +ExecStart=/usr/local/sbin/ps-image-prune diff --git a/scripts/ps-image-prune.sh b/scripts/ps-image-prune.sh new file mode 100755 index 00000000..06042327 --- /dev/null +++ b/scripts/ps-image-prune.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Installed as root's /usr/local/sbin/ps-image-prune; run weekly by ps-image-prune.timer. +# Untagged local builds of the main and dev sites, plus build cache unused for a week. +# Tagged images used by the running containers are left alone. Preview images are handled by ps-preview. +set -euo pipefail +docker image prune -f --filter "label=com.docker.compose.project=provincesystem" +docker image prune -f --filter "label=com.docker.compose.project=tfmc-staging" +docker builder prune -f --filter "until=168h" diff --git a/scripts/ps-image-prune.timer b/scripts/ps-image-prune.timer new file mode 100644 index 00000000..347031f2 --- /dev/null +++ b/scripts/ps-image-prune.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly clean-up of old ProvinceSystem website builds + +[Timer] +OnCalendar=Mon *-*-* 05:00:00 +AccuracySec=1min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/ps-preview-reap.service b/scripts/ps-preview-reap.service new file mode 100644 index 00000000..4a149073 --- /dev/null +++ b/scripts/ps-preview-reap.service @@ -0,0 +1,13 @@ +[Unit] +Description=Stop expired ProvinceSystem website previews +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +User=tfmc +Group=tfmc +SupplementaryGroups=docker +Nice=10 +IOSchedulingClass=idle +ExecStart=/usr/local/sbin/ps-preview reap diff --git a/scripts/ps-preview-reap.timer b/scripts/ps-preview-reap.timer new file mode 100644 index 00000000..dfcc2f26 --- /dev/null +++ b/scripts/ps-preview-reap.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Stop expired ProvinceSystem website previews every minute + +[Timer] +OnCalendar=*-*-* *:*:00 +AccuracySec=5s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/ps-preview-router.Caddyfile b/scripts/ps-preview-router.Caddyfile new file mode 100644 index 00000000..2ee5acea --- /dev/null +++ b/scripts/ps-preview-router.Caddyfile @@ -0,0 +1,26 @@ +{ + admin off +} + +:80 { + @preview header_regexp host Host ^([a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)\.tfminecraft\.net$ + handle @preview { + header X-Robots-Tag "noindex, nofollow" + handle_path /api/* { + reverse_proxy pv-{re.host.1}-backend:8000 { + header_up X-Forwarded-Proto https + } + } + handle { + reverse_proxy pv-{re.host.1}-frontend:3000 { + header_up X-Forwarded-Proto https + } + } + } + handle { + respond "Not found" 404 + } + handle_errors 502 503 504 { + respond "This preview isn't running. It has probably expired; redeploy it from GitHub Actions." 503 + } +} diff --git a/scripts/ps-preview-ssh.sh b/scripts/ps-preview-ssh.sh new file mode 100755 index 00000000..0c94cea5 --- /dev/null +++ b/scripts/ps-preview-ssh.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Forced command for the Preview workflow's key on psdeploy. Installed as root's +# /usr/local/sbin/ps-preview-ssh; sudoers lets psdeploy run ps-preview as tfmc: +# restrict,command="/usr/local/sbin/ps-preview-ssh" ssh-ed25519 ... github-preview-deploy +set -euo pipefail +read -r -a args <<< "${SSH_ORIGINAL_COMMAND:-}" +case "${args[0]:-}" in + up|down|list) ;; + *) echo "usage: up | down | list" >&2; exit 2 ;; +esac +exec sudo -n -u tfmc /usr/local/sbin/ps-preview "${args[@]}" diff --git a/scripts/ps-preview.sh b/scripts/ps-preview.sh new file mode 100755 index 00000000..2d01c998 --- /dev/null +++ b/scripts/ps-preview.sh @@ -0,0 +1,282 @@ +#!/bin/bash +# Per-branch ProvinceSystem website previews at https://.tfminecraft.net. +# Installed as root's /usr/local/sbin/ps-preview and run as tfmc (docker group), by the +# preview key on psdeploy (through ps-preview-ssh), by admins with sudo -u tfmc, and by +# ps-preview-reap.timer. Preview backends run as tfmc too, so tfmc owns every file. +set -euo pipefail +umask 077 + +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export HOME=/home/tfmc + +ROOT=/home/tfmc/previews +LOCK="$ROOT/.lock" +NETWORK=ps-previews +DOMAIN=tfminecraft.net +MAX_PREVIEWS=4 +TTL=5400 +IMAGE_PREFIX=ghcr.io/tf-minecraft/provincesystem-preview +RESERVED=(www dev api mail map admin staff play mc status preview t3 portainer) +MOUNTS=(data input defines output) + +die() { echo "ps-preview: $1" >&2; exit "${2:-1}"; } + +usage() { + cat >&2 <<'EOF' +usage: ps-preview up + ps-preview down + ps-preview reap + ps-preview list + ps-preview logs [backend|frontend] +EOF + exit 2 +} + +[[ "$(id -un)" == tfmc ]] || die "must run as tfmc (sudo -u tfmc $0 ...)" +cd / +mkdir -p "$ROOT" + +validate_slug() { + local slug="$1" r + [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ ]] || die "invalid slug: '$slug'" 2 + for r in "${RESERVED[@]}"; do + [[ "$slug" != "$r" ]] || die "slug '$slug' is reserved" 2 + done +} + +take_lock() { + exec 9>"$LOCK" + if [[ "${1:-}" == nowait ]]; then + flock -n 9 || exit 0 + else + flock -w 900 9 || die "timed out waiting for another ps-preview run" + fi +} + +low_prio() { nice -n 10 ionice -c3 "$@"; } + +previews() { + local d + for d in "$ROOT"/*/; do + [[ -d "$d" ]] || continue + basename "$d" + done +} + +remove_dir() { + rm -rf "${ROOT:?}/$1" +} + +staging_backend() { + local id + id=$(docker ps -a -q \ + --filter label=com.docker.compose.project=tfmc-staging \ + --filter label=com.docker.compose.service=backend | head -n1) + [[ -n "$id" ]] || die "cannot find the tfmc-staging backend container" + echo "$id" +} + +create_data() { + local slug="$1" tmp="$ROOT/.new-$slug" src m container + container=$(staging_backend) + remove_dir ".new-$slug" + mkdir -p "$tmp" + for m in "${MOUNTS[@]}"; do + src=$(docker inspect "$container" \ + --format "{{range .Mounts}}{{if eq .Destination \"/app/src/$m\"}}{{.Source}}{{end}}{{end}}") + [[ -n "$src" && -d "$src" ]] || die "dev backend has no host mount for /app/src/$m" + case "$m" in + # Copied separately below via sqlite's online backup; the other .db files are old backups. + data) low_prio rsync -a --exclude='/*.db' --exclude='/*.db-*' --exclude='/maintenance-backups/' "$src/" "$tmp/$m/" ;; + # Regenerable tile/webp cache. + output) low_prio rsync -a --exclude='/_derived/' "$src/" "$tmp/$m/" ;; + *) low_prio rsync -a "$src/" "$tmp/$m/" ;; + esac + if [[ "$m" == data ]]; then + [[ -f "$src/province.db" ]] || die "dev data has no province.db" + low_prio sqlite3 "$src/province.db" ".timeout 30000" ".backup '$tmp/data/province.db'" + fi + done + sqlite3 "$tmp/data/province.db" <<'SQL' +.timeout 30000 +DELETE FROM sessions; +DELETE FROM user_sessions; +DELETE FROM discord_oauth_states; +DELETE FROM discord_link_codes; +VACUUM; +SQL + { + echo "STAFF_KEY=$(openssl rand -hex 32)" + echo "PLUGIN_KEY=$(openssl rand -hex 32)" + } > "$tmp/keys" + mv "$tmp" "$ROOT/$slug" +} + +write_compose() { + local slug="$1" dir="$ROOT/$slug" staff plugin + staff=$(sed -n 's/^STAFF_KEY=//p' "$dir/keys") + plugin=$(sed -n 's/^PLUGIN_KEY=//p' "$dir/keys") + [[ -n "$staff" && -n "$plugin" ]] || die "missing keys in $dir/keys" + cat > "$dir/compose.yml" </dev/null 2>&1 || true + remove_dir "$slug" +} + +cmd_up() { + [[ $# -eq 2 ]] || usage + local slug="$1" sha="$2" dir count=0 p + validate_slug "$slug" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || die "invalid sha: '$sha'" 2 + dir="$ROOT/$slug" + take_lock + + if [[ ! -d "$dir" ]]; then + while read -r p; do + [[ -n "$p" ]] && count=$((count + 1)) + done < <(previews) + if (( count >= MAX_PREVIEWS )); then + echo "ps-preview: $MAX_PREVIEWS previews are already running; stop one first:" >&2 + previews | sed 's/^/ /' >&2 + exit 3 + fi + fi + + low_prio docker pull -q "$IMAGE_PREFIX-backend:$slug" >/dev/null + low_prio docker pull -q "$IMAGE_PREFIX-frontend:$slug" >/dev/null + docker image prune -f \ + --filter "label=org.opencontainers.image.source=https://github.com/TF-Minecraft/ProvinceSystem" >/dev/null + + [[ -d "$dir" ]] || create_data "$slug" + write_compose "$slug" + docker compose -p "pv-$slug" -f "$dir/compose.yml" up -d --remove-orphans --pull never --quiet-pull >&2 + + echo "$sha" > "$dir/sha" + echo $(( $(date +%s) + TTL )) > "$dir/expires" + echo "https://$slug.$DOMAIN" +} + +cmd_down() { + [[ $# -eq 1 ]] || usage + local slug="$1" + [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ ]] || die "invalid slug: '$slug'" 2 + take_lock + do_down "$slug" + echo "stopped $slug" +} + +cmd_reap() { + [[ $# -eq 0 ]] || usage + local now slug expires project + take_lock nowait + now=$(date +%s) + while read -r slug; do + [[ -n "$slug" ]] || continue + expires=$(cat "$ROOT/$slug/expires" 2>/dev/null || true) + if [[ ! "$expires" =~ ^[0-9]+$ ]] || (( now >= expires )); then + echo "reaping $slug" + do_down "$slug" || echo "failed to reap $slug" >&2 + fi + done < <(previews) + # Compose projects whose preview dir is gone. + while read -r project; do + slug="${project#pv-}" + [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ ]] || continue + [[ -d "$ROOT/$slug" ]] && continue + echo "reaping orphaned project $project" + docker compose -p "$project" down -v --remove-orphans || echo "failed to reap $project" >&2 + done < <(docker ps -a --filter label=com.docker.compose.project \ + --format '{{.Label "com.docker.compose.project"}}' | grep '^pv-' | sort -u) + docker image prune -f \ + --filter "label=org.opencontainers.image.source=https://github.com/TF-Minecraft/ProvinceSystem" >/dev/null +} + +cmd_list() { + [[ $# -eq 0 ]] || usage + local slug sha expires + while read -r slug; do + [[ -n "$slug" ]] || continue + sha=$(cat "$ROOT/$slug/sha" 2>/dev/null || echo -) + expires=$(cat "$ROOT/$slug/expires" 2>/dev/null || true) + if [[ "$expires" =~ ^[0-9]+$ ]]; then + expires=$(date -u -d "@$expires" +%Y-%m-%dT%H:%M:%SZ) + else + expires=- + fi + printf '%s %s %s\n' "$slug" "$sha" "$expires" + done < <(previews) +} + +cmd_logs() { + [[ $# -ge 1 && $# -le 2 ]] || usage + local slug="$1" service="${2:-}" + validate_slug "$slug" + case "$service" in + ""|backend|frontend) ;; + *) usage ;; + esac + [[ -d "$ROOT/$slug" ]] || die "no such preview: $slug" + docker compose -p "pv-$slug" -f "$ROOT/$slug/compose.yml" logs --no-color --tail 200 ${service:+"$service"} +} + +[[ $# -ge 1 ]] || usage +sub="$1" +shift +case "$sub" in + up) cmd_up "$@" ;; + down) cmd_down "$@" ;; + reap) cmd_reap "$@" ;; + list) cmd_list "$@" ;; + logs) cmd_logs "$@" ;; + *) usage ;; +esac