143 lines
7.1 KiB
Bash
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
|