feat(infra): add pb-init sidecar to create PocketBase collections on startup
Add scripts/pb-init.sh — an idempotent alpine sh script that authenticates with the PocketBase admin API and POSTs all required collection schemas (books, chapters_idx, ranking, progress, audio_cache, app_users) before any application service starts. 400/422 responses are treated as success so it is safe to run on every docker compose up. Wire pb-init into docker-compose.yml: - pb-init service: alpine:3.19, depends on pocketbase healthy, mounts script - scraper and ui both depend on pb-init via service_completed_successfully, guaranteeing collections exist before the first request hits app_users
This commit is contained in:
@@ -59,6 +59,22 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ────────────────────────────────────────
|
||||
# One-shot init container: creates all required collections via the admin API
|
||||
# and exits. Idempotent — safe to run on every `docker compose up`.
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
volumes:
|
||||
- ./scripts/pb-init.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
|
||||
# ─── Browserless ────────────────────────────────────────────────────────────
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
@@ -94,6 +110,8 @@ services:
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
@@ -142,6 +160,8 @@ services:
|
||||
container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
scraper:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
|
||||
136
scripts/pb-init.sh
Executable file
136
scripts/pb-init.sh
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/bin/sh
|
||||
# pb-init.sh — idempotent PocketBase collection bootstrap
|
||||
#
|
||||
# Creates all collections required by libnovel. Safe to re-run: POST returns
|
||||
# 400/422 when a collection already exists; both are treated as success.
|
||||
#
|
||||
# Required env vars (with defaults):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { echo "[pb-init] $*"; }
|
||||
|
||||
# ─── 1. Wait for PocketBase to be ready ──────────────────────────────────────
|
||||
log "waiting for PocketBase at $PB_URL ..."
|
||||
until wget -qO- "$PB_URL/api/health" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
log "PocketBase is up"
|
||||
|
||||
# ─── 2. Authenticate and obtain a superuser token ────────────────────────────
|
||||
log "authenticating as $PB_EMAIL ..."
|
||||
AUTH_RESPONSE=$(wget -qO- \
|
||||
--header="Content-Type: application/json" \
|
||||
--post-data="{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \
|
||||
"$PB_URL/api/collections/_superusers/auth-with-password")
|
||||
|
||||
TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then
|
||||
log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log "auth token obtained"
|
||||
|
||||
# ─── 3. Helper: create a collection (ignores 400/422 already-exists) ─────────
|
||||
create_collection() {
|
||||
NAME="$1"
|
||||
BODY="$2"
|
||||
STATUS=$(wget -qSO- \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="Authorization: $TOKEN" \
|
||||
--post-data="$BODY" \
|
||||
"$PB_URL/api/collections" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}')
|
||||
case "$STATUS" in
|
||||
200|201) log "created collection: $NAME" ;;
|
||||
400|422) log "collection already exists (skipped): $NAME" ;;
|
||||
*) log "WARNING: unexpected status $STATUS for collection: $NAME" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── 4. Create collections ────────────────────────────────────────────────────
|
||||
|
||||
create_collection "books" '{
|
||||
"name": "books",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text", "required": true},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "summary", "type": "text"},
|
||||
{"name": "total_chapters", "type": "number"},
|
||||
{"name": "source_url", "type": "text"},
|
||||
{"name": "ranking", "type": "number"},
|
||||
{"name": "meta_updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "chapters_idx" '{
|
||||
"name": "chapters_idx",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "number", "type": "number", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "date_label", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "ranking" '{
|
||||
"name": "ranking",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "rank", "type": "number", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "source_url", "type": "text"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "progress" '{
|
||||
"name": "progress",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "audio_cache" '{
|
||||
"name": "audio_cache",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "filename", "type": "text"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "app_users" '{
|
||||
"name": "app_users",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "username", "type": "text", "required": true},
|
||||
{"name": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
log "all collections ready"
|
||||
Reference in New Issue
Block a user