#!/bin/bash # Operate a MangaCircus Docker Compose deployment. Run it from anywhere; it works in the repository # folder that contains it. Needs Docker with Compose, nothing else on the host (no Node). # Extra Compose files (e.g. compose.lan.yml) come from COMPOSE_FILE in .env, like plain `docker compose`. # # status services, reader health, scraper queue # up reader first (waits for health), then the scraper; resumes the queue if `down` paused it # down pause the queue, wait for the running chapter (<= 15 min), stop all services # restart [service] down + up, or restart one service # logs [service] [lines] recent logs (default: all services, 100 lines) # pause | resume pause or resume the scraper queue # update git pull --ff-only, rebuild the image, recreate safely (queue paused meanwhile) # rescan make the reader re-read every chapter.json (full reconciliation) without an admin login # restore-test restore the newest built-in backup into a throwaway reader and check it starts # # Safety: never `docker compose down -v`. Only the scraper writes the library; only the reader writes users. set -euo pipefail cd "$(dirname "$(readlink -f "$0")")/.." log() { echo "[circus] $*"; } [ -f .env ] || { log "no .env here; see docs/deployment.md" >&2; exit 1; } env_value() { sed -n "s/^$1=//p" .env | tail -n 1 | sed -e 's/^["'\'']//' -e 's/["'\'']$//'; } running() { [ -n "$(docker compose ps -q --status running "$1" 2>/dev/null)" ]; } # Scraper control through the reader container: the token never leaves it and nothing secret is printed. control() { docker compose exec -T -e ACTION="$1" reader node -e ' const {secret}=require("./runtime/config"); (async()=>{ const a=process.env.ACTION, state=a==="state"; const r=await fetch(process.env.SCRAPER_URL+(state?"/api/state":"/api/control"),{method:state?"GET":"POST", signal:AbortSignal.timeout(20000),headers:{authorization:"Bearer "+secret("SCRAPER_TOKEN"),"content-type":"application/json"}, ...(state?{}:{body:JSON.stringify({action:a})})}); if(!r.ok)throw new Error("scraper HTTP "+r.status); const s=await r.json(); console.log(JSON.stringify({status:s.status,paused:s.paused,sourcePaused:s.sourcePaused,active:(s.active||[]).length, queue:(s.queue||[]).length,nextDiscoveryAt:s.nextDiscoveryAt,library:s.libraryStatus&&{state:s.libraryStatus.state,error:s.libraryStatus.error}})); })().catch(e=>{console.error(e.message);process.exitCode=1;});' } field() { sed -n "s/.*\"$1\":\([^,}]*\).*/\1/p"; } health() { docker compose exec -T reader node -e 'fetch("http://127.0.0.1:8080/health").then(r=>r.text()).then(t=>console.log(t))' 2>/dev/null; } wait_reader() { for _ in $(seq 1 60); do health | grep -q '"ok":true' && return 0; sleep 5; done log "reader not healthy after 5 minutes" >&2; return 1 } # Pause the queue and wait for the running chapter; prints whether the queue was paused before. pause_wait() { local was=true if running scraper && running reader; then was=$(control state | field paused) [ "$was" = true ] || control pause >/dev/null for _ in $(seq 1 90); do [ "$(control state | field active)" = 0 ] && break; sleep 10; done fi echo "$was" } resume_if() { [ "$1" = false ] || { log "queue stays paused"; return 0; } for _ in $(seq 1 30); do control resume >/dev/null 2>&1 && { log "queue resumed"; return 0; }; sleep 5; done log "could not resume the queue" >&2; return 1 } start_all() { docker compose up -d reader wait_reader docker compose up -d --remove-orphans sleep 15 } cmd=${1:-status}; shift || true case "$cmd" in status) docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}' running reader && { health || echo "reader: health check failed"; } || echo "reader: not running" running scraper && running reader && control state || echo "scraper: not running" ;; up) start_all # Only a queue that `down` paused is resumed; without that marker the queue is left as it is. if [ -f .queue-was-paused ]; then was=$(cat .queue-was-paused); rm -f .queue-was-paused; resume_if "$was"; fi docker compose ps --format 'table {{.Service}}\t{{.Status}}' ;; down) was=$(pause_wait); echo "$was" > .queue-was-paused docker compose stop log "stopped (queue was paused before: $was)" ;; restart) if [ -n "${1:-}" ]; then docker compose restart "$1"; [ "$1" != reader ] || wait_reader else "$0" down && "$0" up; fi ;; logs) docker compose logs --no-color --tail "${2:-100}" ${1:+"$1"} ;; pause|resume) control "$cmd" ;; update) git pull --ff-only VCS_REF=$(git rev-parse --short HEAD) docker compose build was=$(pause_wait) start_all # recreates the containers whose image or configuration changed resume_if "$was" log "now running $(git log -1 --format='%h %s')" ;; rescan) # The reader re-reads in-place chapter.json edits only on a full reconciliation. Marking the saved # index as overdue makes it run one within a minute while it keeps serving the current index. data=$(env_value DATA_PATH); data=${data:-./data} image=$(docker compose images -q reader | head -n 1) [ -f "$data/library-index.json" ] || { log "no saved index yet; the reader scans on its own" >&2; exit 1; } docker compose stop reader docker run --rm --user 1000:1000 -v "$(readlink -f "$data")":/data "$image" node -e ' const fs=require("fs"),f="/data/library-index.json",j=JSON.parse(fs.readFileSync(f,"utf8")); j.fullAt=1; fs.writeFileSync(f+".tmp",JSON.stringify(j)); fs.renameSync(f+".tmp",f);' docker compose up -d reader wait_reader log "full reconciliation scheduled; it logs \"[scan] full: …\" when done (docker compose logs reader)" ;; restore-test) backup=$(env_value BACKUP_PATH); library=$(env_value LIBRARY_PATH); library=${library:-./library} [ -n "$backup" ] || { log "BACKUP_PATH is not set in .env" >&2; exit 1; } archive=$(ls -1t "$backup"/circus-*.tar.gz 2>/dev/null | head -n 1) [ -n "$archive" ] || { log "no circus-*.tar.gz in $backup" >&2; exit 1; } image=$(docker compose images -q reader | head -n 1) tmp=$(mktemp -d); mkdir "$tmp/data" trap 'docker rm -f circus-restore-test >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT tar -C "$tmp/data" -xzf "$archive" chmod -R a+rwX "$tmp/data" log "archive: $archive" docker run -d --name circus-restore-test --network none --user 1000:1000 --read-only --tmpfs /tmp \ -v "$(readlink -f "$library")":/library:ro -v "$tmp/data":/data -e DATA=/data -e LIBRARY=/library \ -e SIGNUP=closed -e SCAN_INTERVAL=0 "$image" node server.js >/dev/null out="" for _ in $(seq 1 30); do out=$(docker exec circus-restore-test node -e 'fetch("http://127.0.0.1:8080/api/auth").then(r=>r.text()).then(console.log)' 2>/dev/null || true) [ -n "$out" ] && break; sleep 2 done echo "auth: $out" echo "$out" | grep -q '"firstRun":false' && echo "RESTORE TEST OK" || { echo "RESTORE TEST FAILED" >&2; exit 1; } ;; *) sed -n '2,17p' "$0"; exit 2 ;; esac