#!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${repo_root}" project_name="${MINEROPS_RETURN_MIGRATION_PROJECT:-minerops-return-migration-$$}" empty_database="minerops_return_empty" compose=( docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml ) cleanup() { status=$? if (( status != 0 )); then echo echo "Return to Service migration test failed. Recent PostgreSQL logs:" "${compose[@]}" logs --no-color --tail 160 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 "$@" } 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 "Preparing previous head and truthful legacy completion data..." run_alembic upgrade 0011_repair_timeline previous_revision="$(run_alembic current 2>/dev/null)" [[ "${previous_revision}" == *"0011_repair_timeline"* ]] "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' INSERT INTO users ( id, username, display_name, password_hash, role_id ) SELECT '00000000-0000-0000-0000-000000001301', 'return-migration-test', 'Return Migration Test', 'not-used', id FROM roles WHERE name = 'Admin'; INSERT INTO miners ( id, asset_tag, status, lifecycle_status, first_seen_at, last_seen_at ) VALUES ( '00000000-0000-0000-0000-000000001302', 'RETURN-MIGRATION-LEGACY', 'unknown', 'active', NULL, NULL ); INSERT INTO repairs ( id, miner_id, status, reason, opened_by, completed_at, completed_by ) VALUES ( '00000000-0000-0000-0000-000000001303', '00000000-0000-0000-0000-000000001302', 'COMPLETED', 'OTHER', '00000000-0000-0000-0000-000000001301', now(), '00000000-0000-0000-0000-000000001301' ); SQL echo "Upgrading previous head to Return to Service..." run_alembic upgrade head current_revision="$(run_alembic current 2>/dev/null)" [[ "${current_revision}" == *"0012_return_to_service"* ]] echo "Checking schema, permission matrix, constraints, and legacy preservation..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' DO $$ DECLARE v_roles TEXT[]; v_constraint_count INTEGER; BEGIN IF ( SELECT count(*) FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'repairs' AND column_name IN ('completion_summary', 'resulting_lifecycle') ) <> 2 THEN RAISE EXCEPTION 'Return to Service columns are missing'; END IF; IF EXISTS ( SELECT 1 FROM repairs WHERE id = '00000000-0000-0000-0000-000000001303' AND ( completion_summary IS NOT NULL OR resulting_lifecycle IS NOT NULL ) ) THEN RAISE EXCEPTION 'Legacy completion metadata was fabricated'; END IF; IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1 THEN RAISE EXCEPTION 'repairs.complete permission was not registered once'; END IF; SELECT array_agg(roles.name ORDER BY roles.name) INTO v_roles FROM role_permissions JOIN roles ON roles.id = role_permissions.role_id JOIN permissions ON permissions.id = role_permissions.permission_id WHERE permissions.key = 'repairs.complete'; IF v_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN RAISE EXCEPTION 'Unexpected repairs.complete role matrix: %', v_roles; END IF; SELECT count(*) INTO v_constraint_count FROM pg_constraint WHERE conrelid IN ('repairs'::regclass, 'repair_events'::regclass) AND conname IN ( 'ck_repairs_resulting_lifecycle', 'ck_repairs_completion_summary', 'ck_repairs_completion_metadata_state', 'ck_repair_events_type', 'ck_repair_events_payload_shape' ); IF v_constraint_count <> 5 THEN RAISE EXCEPTION 'Expected Return to Service constraints are missing'; END IF; IF ( SELECT convalidated FROM pg_constraint WHERE conrelid = 'repairs'::regclass AND conname = 'ck_repairs_completion_metadata_state' ) THEN RAISE EXCEPTION 'Legacy completion compatibility constraint was unexpectedly validated'; END IF; BEGIN INSERT INTO repairs ( miner_id, status, reason, opened_by, completed_at, completed_by ) VALUES ( '00000000-0000-0000-0000-000000001302', 'COMPLETED', 'OTHER', '00000000-0000-0000-0000-000000001301', now(), '00000000-0000-0000-0000-000000001301' ); RAISE EXCEPTION 'New completed repair without resulting lifecycle was accepted'; EXCEPTION WHEN check_violation THEN NULL; END; END $$; INSERT INTO permissions (key, description) VALUES ('repairs.complete', 'Return repaired miners to service') ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description; INSERT INTO role_permissions (role_id, permission_id) SELECT roles.id, permissions.id FROM roles JOIN permissions ON permissions.key = 'repairs.complete' WHERE roles.name IN ('Admin', 'Manager', 'Technician') ON CONFLICT DO NOTHING; DO $$ BEGIN IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1 OR ( SELECT count(*) FROM role_permissions JOIN permissions ON permissions.id = role_permissions.permission_id WHERE permissions.key = 'repairs.complete' ) <> 3 THEN RAISE EXCEPTION 'repairs.complete seeding is not idempotent'; END IF; END $$; SQL echo "Downgrading and re-upgrading without new completion history..." run_alembic downgrade 0011_repair_timeline downgrade_state="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL' SELECT NOT EXISTS ( SELECT 1 FROM permissions WHERE key = 'repairs.complete' ) AND EXISTS ( SELECT 1 FROM permissions WHERE key = 'repairs.read' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'repairs' AND column_name IN ('completion_summary', 'resulting_lifecycle') ) AND EXISTS ( SELECT 1 FROM repairs WHERE id = '00000000-0000-0000-0000-000000001303' AND status = 'COMPLETED' AND completed_at IS NOT NULL AND completed_by IS NOT NULL ); SQL )" [[ "${downgrade_state}" == "t" ]] downgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${downgraded_revision}" == *"0011_repair_timeline"* ]] run_alembic upgrade head reupgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${reupgraded_revision}" == *"0012_return_to_service"* ]] echo "Checking downgrade refusal after authoritative completion history..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' UPDATE repairs SET completion_summary = 'Migration completion', resulting_lifecycle = 'active' WHERE id = '00000000-0000-0000-0000-000000001303'; INSERT INTO repair_events ( repair_id, sequence, event_type, created_by, payload ) VALUES ( '00000000-0000-0000-0000-000000001303', 1, 'REPAIR_COMPLETED', '00000000-0000-0000-0000-000000001301', '{ "previous_miner_lifecycle":"in_repair", "resulting_miner_lifecycle":"active", "completion_summary":"Migration completion" }'::jsonb ); SQL if downgrade_error="$(run_alembic downgrade 0011_repair_timeline 2>&1)"; then echo "Downgrade discarded Return to Service history." >&2 exit 1 fi [[ "${downgrade_error}" == *"would lose authoritative history"* ]] failed_revision="$(run_alembic current 2>/dev/null)" [[ "${failed_revision}" == *"0012_return_to_service"* ]] echo "Migrating an empty database from base through head..." "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \ -c "CREATE DATABASE minerops_return_empty"' >/dev/null "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ backend alembic upgrade head empty_revision="$( "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ backend alembic current 2>/dev/null )" [[ "${empty_revision}" == *"0012_return_to_service"* ]] echo "Return to Service migration test passed."