#!/usr/bin/env bash # Clone the prod Sublarr Postgres DB into the RC Postgres. DESTRUCTIVE on the # target. Guarded so it can only ever hit sublarr-rc-postgres, and only with # FORCE=1. See docs/superpowers/specs/2026-07-09-sublarr-3tier-design.md. set -euo pipefail # NB: rely on ssh's default key discovery (~/.ssh/id_ed25519) rather than an # explicit -i path — a HOME with spaces (Windows git-bash "Dennis Wittke") # word-splits inside the unquoted $SSH expansion and breaks the -i argument. SSH="ssh -o ConnectTimeout=10 root@192.168.178.36" SOURCE_PG="sublarr-postgres" TARGET_PG="${TARGET_PG:-sublarr-rc-postgres}" DB="sublarr" DBUSER="sublarr" RC_COMPOSE_DIR="/mnt/user/appdata/sublarr-rc" RC_PROJECT="sublarr-rc" RC_APP="sublarr-rc" RC_PORT="5766" # RC's port on the Cardinal host (container itself listens on 5765) # Config-at-rest encryption key lives in {config_dir}/.encryption_key, deliberately # OUTSIDE the DB — so a DB clone alone can't decrypt prod's encrypted config values # and the RC app crashes on boot ("Failed to decrypt"). Copy prod's key too. PROD_KEY="/mnt/user/appdata/sublarr/config/.encryption_key" RC_KEY="/mnt/user/appdata/sublarr-rc/config/.encryption_key" # --- Guards (the critical safety logic; run BEFORE any docker/ssh) --- if [[ "${FORCE:-}" != "1" ]]; then echo "REFUSE: set FORCE=1 to run the destructive prod->RC clone" >&2 exit 2 fi case "$TARGET_PG" in sublarr-postgres | sublarr-beta-postgres) echo "REFUSE: target '$TARGET_PG' is prod/beta — never clone onto it" >&2 exit 3 ;; sublarr-rc-postgres) : ;; # the only allowed target *) echo "REFUSE: unknown target '$TARGET_PG' (allowed: sublarr-rc-postgres)" >&2 exit 4 ;; esac if [[ "$TARGET_PG" == "$SOURCE_PG" ]]; then echo "REFUSE: target equals source" >&2 exit 5 fi # --- Clone: stop RC app (release connections) -> drop+recreate -> dump|restore -> start --- echo "Cloning $SOURCE_PG -> $TARGET_PG ..." $SSH "cd $RC_COMPOSE_DIR && docker compose -p $RC_PROJECT stop $RC_APP" $SSH "docker exec $TARGET_PG psql -U $DBUSER -d postgres -c 'DROP DATABASE IF EXISTS $DB;' -c 'CREATE DATABASE $DB;'" # ON_ERROR_STOP is the whole point of this line. Without it psql walks past a # failed statement and still exits 0 -- and because a pg_dump puts the data # first and the ALTER TABLE ... ADD CONSTRAINT, CREATE INDEX and setval calls # LAST, "mostly worked" means a clone with rows but no primary keys and # sequences left at 1. That is what RC had been running on, undetected, until # 2026-09-09: 73 tables without a primary key against prod's 8, the mappings # sequence at 42 while rows reached 1171, and duplicate ids as a result. # --single-transaction makes a failure leave nothing behind to puzzle over. if ! $SSH "set -o pipefail; docker exec $SOURCE_PG pg_dump -U $DBUSER -d $DB --no-owner --no-privileges | docker exec -i $TARGET_PG psql -U $DBUSER -d $DB -v ON_ERROR_STOP=1 --single-transaction -q"; then echo "REFUSE: the restore failed — RC is NOT a copy of prod, leaving it stopped" >&2 exit 6 fi # --- Prove the copy carries the schema, not just the rows ------------------ # A clone is only a mirror if what constrains the data came with it. Compare # the two databases on the things a silent restore drops first. pkless() { $SSH "docker exec $1 psql -U $DBUSER -d $DB -Atc \"SELECT COUNT(*) FROM information_schema.tables t WHERE t.table_schema='public' AND NOT EXISTS (SELECT 1 FROM information_schema.table_constraints c WHERE c.table_name=t.table_name AND c.constraint_type='PRIMARY KEY');\"" | tr -d ' '; } SRC_PKLESS="$(pkless "$SOURCE_PG")" DST_PKLESS="$(pkless "$TARGET_PG")" echo "tables without a primary key: source=$SRC_PKLESS target=$DST_PKLESS" if [[ "$SRC_PKLESS" != "$DST_PKLESS" ]]; then echo "REFUSE: the target lost constraints in the restore ($DST_PKLESS vs $SRC_PKLESS)" >&2 echo " Testing against this clone proves nothing — fix the restore first." >&2 exit 7 fi # --- Do not let the clone inherit prod's identity in the telemetry --------- # config_entries carries usage_stats_install_id, so a straight dump gives RC # prod's id. Both instances then ping stats.sublarr.de under it and overwrite # each other — measured 2026-09-28: prod pinged 17:03:37, RC 17:05:38, and the # single "postgres" install in the public aggregate was therefore describing # the idle mirror rather than the live instance. Blanking it makes RC mint its # own id on first ping. Consent stays as it is; the id is the only thing here # that identifies a machine rather than a decision. $SSH "docker exec $TARGET_PG psql -U $DBUSER -d $DB -q -c \"UPDATE config_entries SET value='' WHERE key='usage_stats_install_id';\"" \ || echo "WARN: could not blank the cloned install id — RC will report as prod" >&2 # --- Keep the first boot quiet ------------------------------------------- # The config PUT below only lands once RC is up, but the boot itself reads # prod's wanted_scan_on_startup / wanted_search_on_startup and the live # intervals. Observed 2026-10-26: RC started a wanted search over prod's # backlog before the jobs were paused. Writing the quiet values into the # clone before the start closes that window; the PUT below stays as the # belt-and-braces path through the app. if [[ "${TRANSLATE:-}" == "1" ]]; then BOOT_AUTOMATION=True; else BOOT_AUTOMATION=False; fi BOOT_SQL="INSERT INTO config_entries (key, value, updated_at) VALUES ('wanted_scan_on_startup','False',now()), ('wanted_search_on_startup','False',now()), ('wanted_search_interval_hours','0',now()), ('upgrade_scan_interval_hours','0',now()), ('wanted_scan_interval_hours','0',now()), ('wanted_auto_translate','False',now()), ('subtitle_automation_enabled','$BOOT_AUTOMATION',now()) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at;" $SSH "docker exec $TARGET_PG psql -U $DBUSER -d $DB -q -v ON_ERROR_STOP=1 -c \"$BOOT_SQL\"" || { echo "REFUSE: could not write the quiet boot settings — leaving RC stopped" >&2 exit 8 } $SSH "cp -a '$PROD_KEY' '$RC_KEY' && chmod 600 '$RC_KEY'" $SSH "cd $RC_COMPOSE_DIR && docker compose -p $RC_PROJECT up -d $RC_APP" # --- Silence the automation the clone just armed ------------------------- # The dump carries config_entries, and the encryption key copied above makes # them decryptable — so RC comes up holding prod's *real* DeepL/OpenSubtitles # credentials, pointed at prod's full backlog (thousands of wanted items). # subtitle_automation then re-translates that backlog on RC, billing the # owner's live DeepL quota for work prod already paid for. Observed # 2026-08-01: 1468 DeepL calls in six hours from a single staging round. # # Pause every job that can reach a paid API. Everything else (cleanup, # stats, health sweeps) is local and stays running so the RC still behaves # like a real instance. Unpause individually in Settings → System → # Scheduler when a round genuinely needs to exercise translation. # NB: 5766 is RC's port *on the host* — the container listens on 5765, but # these curls run on Cardinal, not inside it. echo "Waiting for RC to accept requests ..." $SSH "for i in \$(seq 1 60); do curl -sf --max-time 3 http://localhost:${RC_PORT}/api/v1/health >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1" || { echo "REFUSE: RC did not become healthy — pause the paid-API jobs by hand" >&2 exit 6 } # A pause alone does NOT hold. _apply_intervals_to_apscheduler runs on every # settings-save and on every boot, and re-arms any job whose configured # interval is greater than zero — so the next config write, or the next # restart, quietly puts the paused job back on the clock with prod's live # credentials. Setting the interval to 0 is what sticks: the boot path pauses # on interval <= 0. # # wanted_scan_interval_hours and wanted_auto_translate joined this list on # 2026-08-29, when prod switched both on. The clone carries them, and # wanted_scanner was in nobody's pause list. # TRANSLATE=1 leaves the translation pipeline running. It is off by default # because a clone re-translating prod's backlog is duplicated work, but the # original reason -- billing the owner's live DeepL quota -- no longer # applies: since 2026-08-04 both prod and RC run translation_default_backend # = ollama with an empty fallback chain, so no paid backend is reachable at # all. What it still costs is the Mac mini, which prod shares. # # It exists because an RC that translates nothing cannot fail at translating, # and the promote gate reads "three days without ERNST". On 2026-09-11 RC had # been quiet for 29 h with ev_ok_24h=0 and ev_err_24h=0 -- silence that said # nothing about the build. echo "Disabling the load- and quota-generating settings on RC ..." if [[ "${TRANSLATE:-}" == "1" ]]; then echo " TRANSLATE=1 -- leaving subtitle_automation ON (ollama, no paid backend)" AUTOMATION_JSON='"subtitle_automation_enabled":true' PAUSE_JOBS="wanted_search wanted_scanner mt_reseek upgrade_scan" else AUTOMATION_JSON='"subtitle_automation_enabled":false' PAUSE_JOBS="subtitle_automation wanted_search wanted_scanner mt_reseek upgrade_scan" fi CFG_JSON="{\"wanted_search_interval_hours\":0,\"upgrade_scan_interval_hours\":0,\"wanted_scan_interval_hours\":0,\"wanted_auto_translate\":false,${AUTOMATION_JSON}}" $SSH "docker exec $RC_APP sh -lc 'curl -sS -m 30 -o /dev/null -w \" config: %{http_code}\n\" -X PUT -H \"X-Api-Key: \$SUBLARR_API_KEY\" -H \"Content-Type: application/json\" -d '\''$CFG_JSON'\'' http://localhost:5765/api/v1/config'" || \ echo " config write failed — set the intervals to 0 by hand" >&2 # Belt and braces: the pause bites immediately, the interval keeps it off # across restarts. mt_reseek has no interval setting, so there the pause is # all there is. echo "Pausing paid-API scheduler jobs on RC ..." for _job in $PAUSE_JOBS; do $SSH "curl -sS -X POST 'http://localhost:${RC_PORT}/api/v1/scheduler/jobs/${_job}/pause' -o /dev/null -w ' ${_job}: %{http_code}\n'" || \ echo " ${_job}: pause failed (non-fatal — pause it by hand)" >&2 done if [[ "${TRANSLATE:-}" == "1" ]]; then echo "Done. RC mirrors prod DB; provider jobs off, translation ON." else echo "Done. RC mirrors prod DB (config_entries included); paid-API jobs off." fi