| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | service=${1:?usage: tools/import-arr.sh sonarr|radarr [preview-id]} |
| 5 | [[ $service == sonarr || $service == radarr ]] || { echo 'Expected sonarr or radarr' >&2; exit 1; } |
| 6 | instance=${2:-$service} |
| 7 | [[ $instance == "$service" || $instance =~ ^${service}-preview-[0-9a-f]{8}$ ]] || { echo 'Expected the service ID or its preview ID' >&2; exit 1; } |
| 8 | |
| 9 | source_host=${STUDIO_MIGRATION_SOURCE:-zenith} |
| 10 | target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1} |
| 11 | target_port=${STUDIO_DEPLOY_PORT:-2222} |
| 12 | remote=(ssh -p "$target_port" "$target_host") |
| 13 | source_copy_host=$source_host |
| 14 | source_rsh=ssh |
| 15 | offline=false |
| 16 | if [[ -n ${STUDIO_LEGACY_HANDOFF:-} ]]; then |
| 17 | [[ $instance == "$service" && -n ${STUDIO_DEPLOY_HOST:-} ]] || { |
| 18 | echo 'Offline handoff requires a production ARR service and target' >&2; exit 1; |
| 19 | } |
| 20 | sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port" |
| 21 | source_copy_host=$target_host |
| 22 | source_rsh="ssh -p $target_port" |
| 23 | offline=true |
| 24 | fi |
| 25 | source_ssh() { |
| 26 | if [[ $offline == true ]]; then |
| 27 | "${remote[@]}" "$@" |
| 28 | else |
| 29 | ssh "$source_host" "$@" |
| 30 | fi |
| 31 | } |
| 32 | if [[ $instance == "$service" ]]; then |
| 33 | root="/srv/prod/$service" |
| 34 | if [[ $offline == false ]]; then |
| 35 | source_running=$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' $service") |
| 36 | [[ $source_running == false ]] || { echo "Stop Zenith $service before importing production data" >&2; exit 1; } |
| 37 | fi |
| 38 | response=$("${remote[@]}" "curl -s -w '\n%{http_code}' -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$service") |
| 39 | status=${response##*$'\n'} |
| 40 | if [[ $status == 200 ]]; then |
| 41 | stopped=$(python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())' <<<"${response%$'\n'*}") |
| 42 | [[ $stopped == true ]] || { echo "Stop production $service before importing" >&2; exit 1; } |
| 43 | elif [[ $status != 404 ]]; then |
| 44 | echo "Could not verify production $service job state: $status" >&2 |
| 45 | exit 1 |
| 46 | fi |
| 47 | else |
| 48 | root="/srv/staging/$instance" |
| 49 | source_id=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'") |
| 50 | [[ $source_id == "$service" ]] || { echo 'Preview belongs to another service' >&2; exit 1; } |
| 51 | fi |
| 52 | dataset=$("${remote[@]}" "findmnt -n -o SOURCE --mountpoint $root") |
| 53 | [[ $dataset == */prod/"$service" || $dataset == */staging/"$instance" ]] || { echo "Expected a mounted service dataset at $root" >&2; exit 1; } |
| 54 | |
| 55 | scratch=$(mktemp -d) |
| 56 | source_backup= |
| 57 | cleanup() { |
| 58 | rm -rf "$scratch" |
| 59 | if [[ -n $source_backup ]]; then |
| 60 | source_ssh "rm -f '$source_backup'" |
| 61 | fi |
| 62 | } |
| 63 | trap cleanup EXIT |
| 64 | |
| 65 | source_backup=$(source_ssh "python3 - '/mnt/storage1/apps/$service/$service.db'" <<'PY' |
| 66 | import os |
| 67 | import sqlite3 |
| 68 | import sys |
| 69 | import tempfile |
| 70 | |
| 71 | source = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True) |
| 72 | fd, name = tempfile.mkstemp(prefix="studio-arr-", suffix=".db") |
| 73 | os.close(fd) |
| 74 | try: |
| 75 | target = sqlite3.connect(name) |
| 76 | source.backup(target) |
| 77 | if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok": |
| 78 | raise ValueError("ARR SQLite backup is invalid") |
| 79 | target.close() |
| 80 | source.close() |
| 81 | except BaseException: |
| 82 | os.unlink(name) |
| 83 | raise |
| 84 | print(name) |
| 85 | PY |
| 86 | ) |
| 87 | rsync -a --exclude="/$service.db*" --exclude='/logs.db*' --exclude='/*.pid' --exclude='/logs/' \ |
| 88 | -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/$service/" "$scratch/config/" |
| 89 | if [[ $offline == true ]]; then |
| 90 | scp -q -P "$target_port" "$source_copy_host:$source_backup" "$scratch/config/$service.db" |
| 91 | else |
| 92 | scp -q "$source_copy_host:$source_backup" "$scratch/config/$service.db" |
| 93 | fi |
| 94 | |
| 95 | if [[ $instance != "$service" ]]; then |
| 96 | "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance" |
| 97 | fi |
| 98 | for _ in {1..30}; do |
| 99 | running=$("${remote[@]}" "curl -s -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations" | |
| 100 | python3 -c 'import json,sys; print(sum(x["ClientStatus"] == "running" for x in json.load(sys.stdin)))') |
| 101 | [[ $running == 0 ]] && break |
| 102 | sleep 2 |
| 103 | done |
| 104 | [[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; } |
| 105 | |
| 106 | snapshot="$dataset@before-arr-import-$(date +%s)-$$" |
| 107 | "${remote[@]}" "zfs snapshot $snapshot" |
| 108 | rsync -a --delete -e "ssh -p $target_port" "$scratch/config/" "$target_host:$root/config/" |
| 109 | uid=$("${remote[@]}" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"$service\"])'") |
| 110 | "${remote[@]}" "chown -R $uid:$uid $root/config; chmod 750 $root/config; python3 -c 'import sqlite3; c=sqlite3.connect(\"$root/config/$service.db\"); assert c.execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\"'" |
| 111 | local_hash=$(shasum -a 256 "$scratch/config/$service.db" | cut -d ' ' -f 1) |
| 112 | remote_hash=$("${remote[@]}" "sha256sum $root/config/$service.db" | cut -d ' ' -f 1) |
| 113 | [[ $local_hash == "$remote_hash" ]] || { echo 'Copied database hash differs from source backup' >&2; exit 1; } |
| 114 | |
| 115 | if [[ $instance == "$service" ]]; then |
| 116 | if [[ $offline == false ]]; then |
| 117 | [[ $(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' $service") == false ]] || { |
| 118 | echo "Zenith $service restarted during import; leave the home server stopped" >&2 |
| 119 | exit 1 |
| 120 | } |
| 121 | fi |
| 122 | echo "Imported production $service from Zenith. Previous dataset state: $snapshot" |
| 123 | else |
| 124 | python3 "$(dirname "$0")/deploy.py" stage "$service" |
| 125 | "${remote[@]}" "zfs destroy $snapshot" |
| 126 | echo "Imported and tested $instance" |
| 127 | fi |