#!/usr/bin/env bash set -euo pipefail project_name="${MINEROPS_INVENTORY_MIGRATION_PROJECT:-minerops-inventory-migration-$$}" compose=( docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml ) empty_database="minerops_inventory_empty" cleanup() { status=$? if (( status != 0 )); then echo echo "Migration test failed. Recent PostgreSQL logs:" "${compose[@]}" logs --no-color --tail 120 postgres || true fi "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true exit "${status}" } trap cleanup EXIT if [[ ! -f .env.dev ]]; then echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2 exit 1 fi run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@" } run_empty_alembic() { "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ backend alembic "$@" } echo "Starting disposable PostgreSQL..." "${compose[@]}" up --detach postgres for attempt in $(seq 1 60); do if "${compose[@]}" exec -T postgres sh -ec \ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then break fi if (( attempt == 60 )); then echo "PostgreSQL did not become ready." >&2 exit 1 fi sleep 1 done echo "Building migration image..." "${compose[@]}" build backend echo "Upgrading to the pre-inventory schema..." run_alembic upgrade 0007_identity_and_roles echo "Seeding existing miner data and conflicting Asset IDs..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' INSERT INTO miners ( id, asset_tag, current_mac, current_ip, miner_sn, power_sn, status, first_seen_at, last_seen_at, created_at, updated_at ) VALUES ( '00000000-0000-0000-0000-000000000801', 'ASSET-EXISTING', 'AA:BB:CC:DD:EE:01', '192.0.2.81', 'MINER-SN-EXISTING', 'PSU-SN-EXISTING', 'online', '2026-01-01 00:00:00+00', '2026-01-02 00:00:00+00', '2025-12-31 00:00:00+00', '2026-01-02 00:00:00+00' ); INSERT INTO miners (id, asset_tag, status) VALUES ('00000000-0000-0000-0000-000000000810', 'CaseAsset', 'discovered'), ('00000000-0000-0000-0000-000000000811', 'caseasset', 'discovered'), ('00000000-0000-0000-0000-000000000812', 'SpaceAsset', 'discovered'), ('00000000-0000-0000-0000-000000000813', ' SpaceAsset ', 'discovered'); SQL echo "Checking duplicate Asset ID preflight failure..." set +e migration_failure_output="$(run_alembic upgrade head 2>&1)" migration_failure_status=$? set -e if (( migration_failure_status == 0 )); then echo "Migration unexpectedly accepted conflicting existing Asset IDs." >&2 exit 1 fi for expected_text in \ "Cannot enforce globally unique Asset IDs" \ "caseasset" \ "spaceasset" \ "Correct the conflicting miners.asset_tag values before upgrading"; do if [[ "${migration_failure_output}" != *"${expected_text}"* ]]; then echo "Migration failure did not include expected text: ${expected_text}" >&2 echo "${migration_failure_output}" >&2 exit 1 fi done "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' DO $$ BEGIN IF (SELECT version_num FROM alembic_version) <> '0007_identity_and_roles' THEN RAISE EXCEPTION 'Failed migration changed the Alembic revision'; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'miners' AND column_name IN ('control_board_sn', 'lifecycle_status') ) OR to_regclass('public.tags') IS NOT NULL OR to_regclass('public.miner_tags') IS NOT NULL THEN RAISE EXCEPTION 'Failed migration left partial inventory schema behind'; END IF; END $$; DELETE FROM miners WHERE id IN ( '00000000-0000-0000-0000-000000000810', '00000000-0000-0000-0000-000000000811', '00000000-0000-0000-0000-000000000812', '00000000-0000-0000-0000-000000000813' ); SQL echo "Upgrading the corrected populated schema to head..." run_alembic upgrade head echo "Checking constraints, defaults, data preservation, and foreign keys..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' DO $$ DECLARE v_tag_id UUID; v_cascade_tag_id UUID; BEGIN IF NOT EXISTS ( SELECT 1 FROM miners WHERE id = '00000000-0000-0000-0000-000000000801' AND asset_tag = 'ASSET-EXISTING' AND power_sn = 'PSU-SN-EXISTING' AND status = 'online' AND lifecycle_status = 'active' AND first_seen_at = '2026-01-01 00:00:00+00' AND last_seen_at = '2026-01-02 00:00:00+00' ) THEN RAISE EXCEPTION 'Existing miner data was not preserved'; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'miners' AND column_name IN ('first_seen_at', 'last_seen_at') AND (is_nullable <> 'YES' OR column_default IS NULL) ) THEN RAISE EXCEPTION 'Miner seen timestamps should be nullable and retain defaults'; END IF; BEGIN INSERT INTO miners (asset_tag, status, lifecycle_status) VALUES (' asset-existing ', 'manual', 'active'); RAISE EXCEPTION 'Case-insensitive trimmed duplicate Asset ID was accepted'; EXCEPTION WHEN unique_violation THEN NULL; END; INSERT INTO miners ( id, asset_tag, status, first_seen_at, last_seen_at, created_at ) VALUES ( '00000000-0000-0000-0000-000000000803', NULL, 'manual', NULL, NULL, '2026-02-03 04:05:06+00' ); INSERT INTO miners (id, asset_tag, status) VALUES ( '00000000-0000-0000-0000-000000000804', 'ASSET-DEFAULT-SEEN', 'manual' ); INSERT INTO miners (id, asset_tag, status, lifecycle_status) VALUES ('00000000-0000-0000-0000-000000000805', 'ASSET-SPARE', 'manual', 'spare'), ('00000000-0000-0000-0000-000000000806', 'ASSET-RETIRED', 'manual', 'retired'); INSERT INTO miners (asset_tag, status, first_seen_at, last_seen_at) VALUES (NULL, 'manual', NULL, NULL), ('', 'manual', NULL, NULL), (' ', 'manual', NULL, NULL); IF NOT EXISTS ( SELECT 1 FROM miners WHERE id = '00000000-0000-0000-0000-000000000803' AND first_seen_at IS NULL AND last_seen_at IS NULL AND lifecycle_status = 'active' ) THEN RAISE EXCEPTION 'Explicit NULL seen timestamps or lifecycle default were not preserved'; END IF; IF NOT EXISTS ( SELECT 1 FROM miners WHERE id = '00000000-0000-0000-0000-000000000804' AND first_seen_at IS NOT NULL AND last_seen_at IS NOT NULL AND lifecycle_status = 'active' ) THEN RAISE EXCEPTION 'Omitted seen timestamps did not receive defaults'; END IF; BEGIN INSERT INTO miners (asset_tag, status, lifecycle_status) VALUES ('ASSET-BAD-LIFECYCLE', 'manual', 'maintenance'); RAISE EXCEPTION 'Invalid lifecycle status was accepted'; EXCEPTION WHEN check_violation THEN NULL; END; BEGIN INSERT INTO tags (name) VALUES (''); RAISE EXCEPTION 'Blank tag name was accepted'; EXCEPTION WHEN check_violation THEN NULL; END; BEGIN INSERT INTO tags (name) VALUES (' '); RAISE EXCEPTION 'Whitespace-only tag name was accepted'; EXCEPTION WHEN check_violation THEN NULL; END; INSERT INTO tags (name) VALUES ('Priority') RETURNING id INTO v_tag_id; BEGIN INSERT INTO tags (name) VALUES (' priority '); RAISE EXCEPTION 'Case-insensitive trimmed duplicate tag was accepted'; EXCEPTION WHEN unique_violation THEN NULL; END; INSERT INTO miner_tags (miner_id, tag_id) VALUES ('00000000-0000-0000-0000-000000000801', v_tag_id); BEGIN INSERT INTO miner_tags (miner_id, tag_id) VALUES ('00000000-0000-0000-0000-000000000801', v_tag_id); RAISE EXCEPTION 'Duplicate miner-tag association was accepted'; EXCEPTION WHEN unique_violation THEN NULL; END; BEGIN INSERT INTO miner_tags (miner_id, tag_id) VALUES ('00000000-0000-0000-0000-000000000899', v_tag_id); RAISE EXCEPTION 'Invalid miner foreign key was accepted'; EXCEPTION WHEN foreign_key_violation THEN NULL; END; BEGIN INSERT INTO miner_tags (miner_id, tag_id) VALUES ( '00000000-0000-0000-0000-000000000801', '00000000-0000-0000-0000-000000000899' ); RAISE EXCEPTION 'Invalid tag foreign key was accepted'; EXCEPTION WHEN foreign_key_violation THEN NULL; END; DELETE FROM tags WHERE id = v_tag_id; IF EXISTS (SELECT 1 FROM miner_tags WHERE tag_id = v_tag_id) THEN RAISE EXCEPTION 'Deleting a tag did not cascade to miner_tags'; END IF; INSERT INTO tags (name) VALUES ('Cascade') RETURNING id INTO v_cascade_tag_id; INSERT INTO miners (id, asset_tag, status) VALUES ('00000000-0000-0000-0000-000000000802', 'ASSET-CASCADE', 'manual'); INSERT INTO miner_tags (miner_id, tag_id) VALUES ('00000000-0000-0000-0000-000000000802', v_cascade_tag_id); DELETE FROM miners WHERE id = '00000000-0000-0000-0000-000000000802'; IF EXISTS ( SELECT 1 FROM miner_tags WHERE miner_id = '00000000-0000-0000-0000-000000000802' ) THEN RAISE EXCEPTION 'Deleting a miner did not cascade to miner_tags'; END IF; END $$; SQL echo "Downgrading to the current schema..." run_alembic downgrade 0007_identity_and_roles echo "Checking downgrade and deterministic timestamp backfill..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' DO $$ BEGIN IF to_regclass('public.tags') IS NOT NULL OR to_regclass('public.miner_tags') IS NOT NULL THEN RAISE EXCEPTION 'Inventory tag tables remain after downgrade'; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'miners' AND column_name IN ('control_board_sn', 'lifecycle_status') ) THEN RAISE EXCEPTION 'Inventory miner columns remain after downgrade'; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'miners' AND column_name IN ('first_seen_at', 'last_seen_at') AND is_nullable <> 'NO' ) THEN RAISE EXCEPTION 'Miner seen timestamps were not restored to NOT NULL'; END IF; IF EXISTS ( SELECT 1 FROM miners WHERE first_seen_at IS NULL OR last_seen_at IS NULL ) THEN RAISE EXCEPTION 'Null seen timestamps were not backfilled'; END IF; IF NOT EXISTS ( SELECT 1 FROM miners WHERE id = '00000000-0000-0000-0000-000000000803' AND first_seen_at = '2026-02-03 04:05:06+00' AND last_seen_at = '2026-02-03 04:05:06+00' ) THEN RAISE EXCEPTION 'Null seen timestamps were not deterministically backfilled from created_at'; END IF; IF NOT EXISTS ( SELECT 1 FROM miners WHERE id = '00000000-0000-0000-0000-000000000801' AND asset_tag = 'ASSET-EXISTING' AND power_sn = 'PSU-SN-EXISTING' AND status = 'online' ) THEN RAISE EXCEPTION 'Existing miner data was not preserved by downgrade'; END IF; END $$; SQL echo "Re-upgrading the populated schema..." run_alembic upgrade head populated_revision="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT version_num FROM alembic_version"' )" [[ "${populated_revision}" == "0008_manual_miner_inventory" ]] echo "Testing an empty database migration from base through head..." "${compose[@]}" exec -T postgres sh -ec \ 'createdb -U "$POSTGRES_USER" minerops_inventory_empty' run_empty_alembic upgrade head empty_revision="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -U "$POSTGRES_USER" -d minerops_inventory_empty -Atc "SELECT version_num FROM alembic_version"' )" [[ "${empty_revision}" == "0008_manual_miner_inventory" ]] echo "Manual inventory migration tests passed."