Files
MangaCircus/deploy/circus.sh
T
MaashkinoozheandClaude Opus 5.5 f53b92a7dc Prepare the repository for a public release
- docs/deployment.md: step-by-step Docker deployment for anyone (setup,
  configuration, LAN/HTTPS/VPN access, adding manga, operation, prebuilt
  images, backups, peering, troubleshooting, rules for agents).
- deploy/circus.sh: host helper without Node (status, up/down with queue
  pause, logs, pause/resume, update, rescan, restore-test); deploy/compose.https.yml
  + Caddyfile for automatic HTTPS.
- README rewritten for public readers (Docker first, sources/copyright note,
  docs index); AGENTS.md + CLAUDE.md for contributors and coding agents.
- Removed notes about one particular installation (Windows host, handoffs,
  audits of a private library, benchmarks with local paths, wishlist, reading
  map) and the unused ops/proxmox templates; generalized remaining mentions of
  hosts, VPN and Windows-only commands.
- Defaults: TZ and reading time zone UTC; .gitattributes keeps shell/YAML LF.
- Personal agent coordination files are git-ignored (CLAUDE.local.md,
  .claude/skills/manga-org/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:09:00 +02:00

143 lines
7.0 KiB
Bash

#!/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
was=$(cat .queue-was-paused 2>/dev/null || echo true); rm -f .queue-was-paused
resume_if "$was"
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