Files
MangaCircus/deploy/circus.sh

143 lines
7.1 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
# 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