1#!/usr/bin/env bash
2set -euo pipefail
3
4service=${1:?usage: tools/import-arr.sh sonarr|radarr [preview-id]}
5[[ $service == sonarr || $service == radarr ]] || { echo 'Expected sonarr or radarr' >&2; exit 1; }
6instance=${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
9source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
10target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
11target_port=${STUDIO_DEPLOY_PORT:-2222}
12remote=(ssh -p "$target_port" "$target_host")
13source_copy_host=$source_host
14source_rsh=ssh
15offline=false
16if [[ -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
24fi
25source_ssh() {
26 if [[ $offline == true ]]; then
27 "${remote[@]}" "$@"
28 else
29 ssh "$source_host" "$@"
30 fi
31}
32if [[ $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
47else
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; }
51fi
52dataset=$("${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
55scratch=$(mktemp -d)
56source_backup=
57cleanup() {
58 rm -rf "$scratch"
59 if [[ -n $source_backup ]]; then
60 source_ssh "rm -f '$source_backup'"
61 fi
62}
63trap cleanup EXIT
64
65source_backup=$(source_ssh "python3 - '/mnt/storage1/apps/$service/$service.db'" <<'PY'
66import os
67import sqlite3
68import sys
69import tempfile
70
71source = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
72fd, name = tempfile.mkstemp(prefix="studio-arr-", suffix=".db")
73os.close(fd)
74try:
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()
81except BaseException:
82 os.unlink(name)
83 raise
84print(name)
85PY
86)
87rsync -a --exclude="/$service.db*" --exclude='/logs.db*' --exclude='/*.pid' --exclude='/logs/' \
88 -e "$source_rsh" "$source_copy_host:/mnt/storage1/apps/$service/" "$scratch/config/"
89if [[ $offline == true ]]; then
90 scp -q -P "$target_port" "$source_copy_host:$source_backup" "$scratch/config/$service.db"
91else
92 scp -q "$source_copy_host:$source_backup" "$scratch/config/$service.db"
93fi
94
95if [[ $instance != "$service" ]]; then
96 "${remote[@]}" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
97fi
98for _ 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
103done
104[[ $running == 0 ]] || { echo "$instance still has a running allocation" >&2; exit 1; }
105
106snapshot="$dataset@before-arr-import-$(date +%s)-$$"
107"${remote[@]}" "zfs snapshot $snapshot"
108rsync -a --delete -e "ssh -p $target_port" "$scratch/config/" "$target_host:$root/config/"
109uid=$("${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\"'"
111local_hash=$(shasum -a 256 "$scratch/config/$service.db" | cut -d ' ' -f 1)
112remote_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
115if [[ $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"
123else
124 python3 "$(dirname "$0")/deploy.py" stage "$service"
125 "${remote[@]}" "zfs destroy $snapshot"
126 echo "Imported and tested $instance"
127fi