Files
homelab/scripts/immich-import-legacy-db
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

217 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Import the OLD ZimaOS/CasaOS Immich database into the NixOS-managed one.
# Run ONCE on jupiter, as root, after the first `./deploy switch jupiter` that
# ships services/media/immich.nix (the empty `immich` DB must already exist).
#
# Move the media files separately FIRST (a same-filesystem rename, instant
# even at 9.1G) — move the CONTENTS of /mnt/data/Immich/upload into
# /mnt/data/AppData/immich, not the directory itself, or it nests under
# .../immich/upload and every thumbnail lookup ENOENTs:
# systemctl stop immich-server immich-machine-learning
# mv /mnt/data/Immich/upload/* /mnt/data/AppData/immich/
# chown -R immich:immich /mnt/data/AppData/immich && chmod 700 /mnt/data/AppData/immich
#
# The legacy cluster is Postgres 14 + VectorChord 0.3.0 + pgvector 0.8.1 (the
# same extensions nixpkgs ships), so this is a plain version-upgrade
# dump/restore — smart-search and face embeddings come across intact with no
# ML rerun needed.
set -euo pipefail
LEGACY="${LEGACY:-/mnt/data/Immich/pg-data}"
WORK="${WORK:-/var/tmp/immich-import}"
# Pinned to exactly what the legacy cluster's pg_extension records (vchord
# 0.3.0/pgvector 0.8.1) so it reads its own indexes unmodified; the dump/
# restore rebuilds indexes from scratch on the target's newer versions, so
# only the index definitions need to stay valid.
IMAGE="${IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvector0.8.1}"
CTR=immich-legacy-pg
PORT="${PORT:-15432}"
LEGACY_DB="${LEGACY_DB:-immich}"
LEGACY_USER="${LEGACY_USER:-}" # empty = probe for it (see below)
STAGING_DB=immich_import
die() { echo "error: $*" >&2; exit 1; }
step() { echo; echo "== $*"; }
[ "$(id -u)" = 0 ] || die "run as root"
[ -d "$LEGACY" ] || die "no legacy PGDATA at $LEGACY"
command -v podman >/dev/null || die "podman not found"
command -v pg_dump >/dev/null || die "pg_dump not found (is postgresql on this host?)"
systemctl is-active --quiet postgresql || die "postgresql is not running"
# immich must be down: it runs schema migrations at startup, and we are about
# to replace the schema underneath it.
systemctl stop immich-server immich-machine-learning 2>/dev/null || true
step "copying legacy PGDATA -> $WORK/pgdata (original stays untouched)"
rm -rf "$WORK"; mkdir -p "$WORK"
cp -a "$LEGACY" "$WORK/pgdata"
# A crashed cluster leaves this behind; it makes the container refuse to start.
rm -f "$WORK/pgdata/postmaster.pid"
# The marketplace app's original POSTGRES_PASSWORD is long gone, and
# POSTGRES_HOST_AUTH_METHOD only applies when the image initializes a cluster
# (not an existing one) — so pg_hba is REPLACED outright (not appended, since
# it's first-match-wins) to trust this scratch copy while it's dumped.
cat > "$WORK/pgdata/pg_hba.conf" <<'EOF'
local all all trust
host all all 0.0.0.0/0 trust
host all all ::/0 trust
EOF
step "booting Postgres 14 + VectorChord on the copy"
podman rm -f "$CTR" 2>/dev/null || true
podman run -d --name "$CTR" \
-v "$WORK/pgdata:/var/lib/postgresql/data:Z" \
-p "127.0.0.1:$PORT:5432" \
-e POSTGRES_HOST_AUTH_METHOD=trust \
"$IMAGE" >/dev/null
trap 'podman rm -f "$CTR" >/dev/null 2>&1 || true' EXIT
for _ in $(seq 1 60); do
# No -U: the role is probed for below, and pg_isready only checks that the
# postmaster is accepting connections at all.
if podman exec "$CTR" pg_isready >/dev/null 2>&1; then ready=1; break; fi
sleep 2
done
[ "${ready:-}" = 1 ] || { podman logs --tail 30 "$CTR"; die "legacy postgres never became ready"; }
# The original POSTGRES_USER isn't recorded on disk and wasn't necessarily
# "postgres" (this marketplace app used "casaos"), and pg_isready reports
# ready even for a role that doesn't exist — so probe for one that can
# actually log in.
if [ -z "$LEGACY_USER" ] || ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqt >/dev/null 2>&1; then
for candidate in casaos immich postgres; do
if podman exec "$CTR" psql -U "$candidate" -lqt >/dev/null 2>&1; then
LEGACY_USER="$candidate"
echo ">> legacy superuser role: $LEGACY_USER"
break
fi
done
fi
[ -n "$LEGACY_USER" ] || die "no usable login role found (tried casaos/immich/postgres) —
re-run with LEGACY_USER=<role>"
echo ">> databases in the legacy cluster:"
podman exec "$CTR" psql -U "$LEGACY_USER" -lqt | cut -d'|' -f1 | sed 's/^/ /'
if ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqtA -F'|' | cut -d'|' -f1 | grep -qx "$LEGACY_DB"; then
found="$(podman exec "$CTR" psql -U "$LEGACY_USER" -lqtA -F'|' | cut -d'|' -f1 \
| grep -vE '^(template[01]|postgres)$' | grep -v '^$' | head -1)"
[ -n "$found" ] || die "no non-system database found — set LEGACY_DB=<name> and re-run"
echo ">> database '$LEGACY_DB' not found, using '$found'"
LEGACY_DB="$found"
fi
step "legacy versions"
podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" \
-c 'select extname, extversion from pg_extension order by extname'
# Immich switched from TypeORM to Kysely, so the table changed name; try both.
echo ">> last applied immich migration:"
podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" -tAc \
'select name from kysely_migration order by timestamp desc limit 1' 2>/dev/null \
|| podman exec "$CTR" psql -U "$LEGACY_USER" -d "$LEGACY_DB" -tAc \
'select name from migrations order by timestamp desc limit 1' 2>/dev/null \
|| echo " (neither kysely_migration nor migrations exists — unusual, check the dump)"
step "dumping $LEGACY_DB with the local pg_dump ($(pg_dump --version | awk '{print $3}'))"
# Embeddings INCLUDED: same extension on both ends, so they restore as-is.
pg_dump -h 127.0.0.1 -p "$PORT" -U "$LEGACY_USER" -d "$LEGACY_DB" \
--no-owner --no-acl \
> "$WORK/immich.sql"
echo ">> dump: $(du -h "$WORK/immich.sql" | cut -f1)"
podman rm -f "$CTR" >/dev/null; trap - EXIT
step "restoring into scratch DB $STAGING_DB"
sudo -u postgres psql -qc "DROP DATABASE IF EXISTS $STAGING_DB"
sudo -u postgres psql -qc "CREATE DATABASE $STAGING_DB OWNER immich"
sudo -u postgres psql -qd "$STAGING_DB" \
-c 'CREATE EXTENSION IF NOT EXISTS vector' \
-c 'CREATE EXTENSION IF NOT EXISTS vchord CASCADE'
# Not -v ON_ERROR_STOP=1: the pre-created extensions make the dump's own
# CREATE EXTENSION lines complain harmlessly. Errors are counted, not hidden.
sudo -u postgres psql -d "$STAGING_DB" -f "$WORK/immich.sql" > "$WORK/restore.log" 2>&1 || true
echo ">> errors logged: $(grep -c '^ERROR' "$WORK/restore.log" || true) (see $WORK/restore.log)"
grep '^ERROR' "$WORK/restore.log" | sort -u | head -10 | sed 's/^/ /' || true
step "handing ownership to the immich role"
# immich's own ALTER TABLE migrations need it to own its schema, but plain
# `REASSIGN OWNED BY postgres` also sweeps up system objects and fails on ones
# the database system requires — so ownership is walked table-by-table
# instead, skipping extension-owned routines/types, which correctly stay with
# postgres.
sudo -u postgres psql -qd "$STAGING_DB" <<'SQL'
ALTER SCHEMA public OWNER TO immich;
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT c.relkind AS kind, n.nspname AS ns, c.relname AS name
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r','p','v','m','S','f')
LOOP
EXECUTE format('ALTER %s %I.%I OWNER TO immich',
CASE r.kind WHEN 'S' THEN 'SEQUENCE'
WHEN 'v' THEN 'VIEW'
WHEN 'm' THEN 'MATERIALIZED VIEW'
WHEN 'f' THEN 'FOREIGN TABLE'
ELSE 'TABLE' END, r.ns, r.name);
END LOOP;
FOR r IN
SELECT p.oid::regprocedure AS sig FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.prokind IN ('f','p')
AND p.oid NOT IN (SELECT objid FROM pg_depend WHERE deptype = 'e' AND classid = 'pg_proc'::regclass)
LOOP EXECUTE format('ALTER ROUTINE %s OWNER TO immich', r.sig); END LOOP;
FOR r IN
SELECT t.oid::regtype AS name FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typtype IN ('e','c','d')
AND NOT EXISTS (SELECT 1 FROM pg_class c WHERE c.reltype = t.oid AND c.relkind <> 'c')
AND t.oid NOT IN (SELECT objid FROM pg_depend WHERE deptype = 'e' AND classid = 'pg_type'::regclass)
LOOP EXECUTE format('ALTER TYPE %s OWNER TO immich', r.name); END LOOP;
END $$;
SQL
still_wrong="$(sudo -u postgres psql -tAd "$STAGING_DB" -c \
"select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r','p','v','m','S','f')
and c.relowner <> 'immich'::regrole")"
[ "$still_wrong" = 0 ] || die "$still_wrong objects still not owned by immich — nothing swapped"
echo ">> all public objects owned by immich"
step "what came across"
# Immich 2.x table names (singular, and "user" is a reserved word). The legacy
# cluster holds 666 assets / 2 users / 227k geodata rows — expect those back.
sudo -u postgres psql -d "$STAGING_DB" -c \
"select 'asset' t, count(*) from asset
union all select 'album', count(*) from album
union all select 'person', count(*) from person
union all select 'user', count(*) from \"user\"
union all select 'geodata_places', count(*) from geodata_places
union all select 'smart_search', count(*) from smart_search
union all select 'face_search', count(*) from face_search" 2>&1 || \
die "staging DB looks wrong — nothing was swapped, inspect $WORK/restore.log"
echo
echo "The scratch DB is populated. Swapping REPLACES the live (empty) immich DB."
read -rp ">> type 'swap' to promote $STAGING_DB to immich: " ok
[ "$ok" = swap ] || { echo "left in place as $STAGING_DB — nothing changed"; exit 0; }
step "promoting"
sudo -u postgres psql -qc "ALTER DATABASE immich RENAME TO immich_empty_$(date +%s)"
sudo -u postgres psql -qc "ALTER DATABASE $STAGING_DB RENAME TO immich"
step "rebuilding the vector indexes for VectorChord 1.1.1"
# Upstream requires a REINDEX after a vchord version change. Skipped silently
# if immich named them differently in this schema version.
sudo -u postgres psql -d immich -c 'REINDEX INDEX face_index' 2>/dev/null || true
sudo -u postgres psql -d immich -c 'REINDEX INDEX clip_index' 2>/dev/null || true
step "starting immich (it will run its own migrations up to 2.7.5 now)"
systemctl start immich-server immich-machine-learning
echo ">> follow with: journalctl -fu immich-server"
echo ">> legacy data still intact at /mnt/data/Immich — delete only once happy"