Files
minerops/scripts/test-begin-repair-migration.sh
T

557 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_REPAIR_MIGRATION_PROJECT:-minerops-repair-migration-$$}"
empty_database="minerops_repair_empty"
compose=(
docker compose
--project-name "${project_name}"
--env-file .env.dev
-f compose.dev.yaml
)
cleanup() {
status=$?
if (( status != 0 )); then
echo
echo "Begin Repair 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 "$@"
}
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 the previous head to Begin Repair..."
run_alembic upgrade 0009_inventory_asset_id_history
previous_revision="$(run_alembic current 2>/dev/null)"
[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]]
run_alembic upgrade head
current_revision="$(run_alembic current 2>/dev/null)"
[[ "${current_revision}" == *"0010_begin_repair"* ]]
echo "Checking repair schema, constraints, permissions, and lifecycle..."
"${compose[@]}" exec -T postgres sh -ec \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
<<'SQL'
DO $$
DECLARE
v_actor UUID := '00000000-0000-0000-0000-000000001001';
v_miner UUID := '00000000-0000-0000-0000-000000001002';
v_other_miner UUID := '00000000-0000-0000-0000-000000001003';
v_invalid_miner UUID := '00000000-0000-0000-0000-000000001004';
v_repair UUID;
v_other_repair UUID;
v_role_id UUID;
v_count INTEGER;
BEGIN
IF to_regclass('public.repairs') IS NULL
OR to_regclass('public.repair_events') IS NULL THEN
RAISE EXCEPTION 'Repair workflow tables were not created';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'repairs'
AND column_name IN (
'id', 'miner_id', 'status', 'reason', 'opened_at', 'opened_by'
)
AND is_nullable <> 'NO'
) OR EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'repairs'
AND column_name IN ('completed_at', 'completed_by')
AND is_nullable <> 'YES'
) THEN
RAISE EXCEPTION 'Repair column nullability is incorrect';
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'repairs'
AND indexname = 'uq_repairs_one_active_per_miner'
AND indexdef LIKE '%UNIQUE%'
AND indexdef LIKE '%WHERE (status = ANY%'
) THEN
RAISE EXCEPTION 'Active repair partial unique index is missing';
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'repair_events'
AND indexname = 'idx_repair_events_repair_created'
AND indexdef LIKE '%repair_id, created_at, sequence, id%'
) THEN
RAISE EXCEPTION 'Repair event chronological index is missing';
END IF;
IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
RAISE EXCEPTION 'repairs.create permission was not registered once';
END IF;
SELECT COUNT(*)
INTO v_count
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.create'
AND roles.name IN ('Admin', 'Manager', 'Technician');
IF v_count <> 3 THEN
RAISE EXCEPTION 'Expected Admin, Manager, and Technician repair access';
END IF;
IF EXISTS (
SELECT 1
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.create'
AND roles.name IN ('Read Only', 'Viewer', 'Auditor')
) THEN
RAISE EXCEPTION 'A read-only role received repairs.create';
END IF;
INSERT INTO permissions (key, description)
VALUES ('repairs.create', 'Begin repairs for miner assets')
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.create'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING;
IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
RAISE EXCEPTION 'Permission seeding is not idempotent';
END IF;
SELECT id INTO v_role_id FROM roles WHERE name = 'Admin';
INSERT INTO users (
id, username, display_name, password_hash, role_id
) VALUES (
v_actor,
'begin-repair-migration-test',
'Begin Repair Migration Test',
'not-used',
v_role_id
);
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
VALUES
(v_miner, 'REPAIR-MIGRATION-001', 'unknown', 'active'),
(v_other_miner, 'REPAIR-MIGRATION-002', 'unknown', 'spare'),
(v_invalid_miner, 'REPAIR-MIGRATION-003', 'unknown', 'active');
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_miner, 'CONTROL_BOARD', v_actor)
RETURNING id INTO v_repair;
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES
(
v_repair,
1,
'REPAIR_STARTED',
v_actor,
'{"reason":"CONTROL_BOARD","from_lifecycle":"active","to_lifecycle":"in_repair"}'
),
(
v_repair,
2,
'INITIAL_NOTE',
v_actor,
'{"note":"Migration test note."}'
);
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_other_miner, 'FAN', v_actor)
RETURNING id INTO v_other_repair;
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_other_repair,
1,
'REPAIR_STARTED',
v_actor,
'{"reason":"FAN","from_lifecycle":"spare","to_lifecycle":"in_repair"}'
);
UPDATE miners
SET lifecycle_status = 'in_repair'
WHERE id IN (v_miner, v_other_miner);
IF EXISTS (
SELECT 1
FROM repairs
WHERE id = v_repair
AND (
status <> 'OPEN'
OR completed_at IS NOT NULL
OR completed_by IS NOT NULL
OR opened_at IS NULL
)
) THEN
RAISE EXCEPTION 'New repair defaults or completion fields are invalid';
END IF;
IF (
SELECT array_agg(event_type ORDER BY sequence)
FROM repair_events
WHERE repair_id = v_repair
) <> ARRAY['REPAIR_STARTED', 'INITIAL_NOTE'] THEN
RAISE EXCEPTION 'Initial event ordering is incorrect';
END IF;
IF (
SELECT payload ->> 'from_lifecycle'
FROM repair_events
WHERE repair_id = v_other_repair
AND event_type = 'REPAIR_STARTED'
) <> 'spare' THEN
RAISE EXCEPTION 'Spare lifecycle history was not preserved';
END IF;
BEGIN
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_miner, 'PSU', v_actor);
RAISE EXCEPTION 'A duplicate active repair was accepted';
EXCEPTION WHEN unique_violation THEN
NULL;
END;
BEGIN
INSERT INTO repairs (miner_id, status, reason, opened_by)
VALUES (v_invalid_miner, 'BROKEN', 'OTHER', v_actor);
RAISE EXCEPTION 'Unsupported repair status was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_invalid_miner, 'NOT_A_REASON', v_actor);
RAISE EXCEPTION 'Unsupported repair reason was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_repair, 3, 'UNKNOWN_EVENT', v_actor, '{}'::jsonb
);
RAISE EXCEPTION 'Unsupported repair event type was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_repair, 3, 'INITIAL_NOTE', v_actor, '{"note":" "}'::jsonb
);
RAISE EXCEPTION 'Blank initial note payload was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_repair, 2, 'INITIAL_NOTE', v_actor, '{"note":"duplicate"}'::jsonb
);
RAISE EXCEPTION 'Duplicate event sequence was accepted';
EXCEPTION WHEN unique_violation THEN
NULL;
END;
BEGIN
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (gen_random_uuid(), 'OTHER', v_actor);
RAISE EXCEPTION 'Unknown miner repair was accepted';
EXCEPTION WHEN foreign_key_violation THEN
NULL;
END;
BEGIN
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_invalid_miner, 'OTHER', gen_random_uuid());
RAISE EXCEPTION 'Repair with an unknown actor was accepted';
EXCEPTION WHEN foreign_key_violation THEN
NULL;
END;
BEGIN
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (v_invalid_miner, 'OTHER', NULL);
RAISE EXCEPTION 'Repair without an actor was accepted';
EXCEPTION WHEN not_null_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_repair,
3,
'INITIAL_NOTE',
NULL,
'{"note":"missing actor"}'::jsonb
);
RAISE EXCEPTION 'Repair event without an actor was accepted';
EXCEPTION WHEN not_null_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
gen_random_uuid(),
1,
'INITIAL_NOTE',
v_actor,
'{"note":"unknown repair"}'::jsonb
);
RAISE EXCEPTION 'Event for an unknown repair was accepted';
EXCEPTION WHEN foreign_key_violation THEN
NULL;
END;
BEGIN
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
) VALUES (
v_repair,
3,
'INITIAL_NOTE',
gen_random_uuid(),
'{"note":"unknown actor"}'::jsonb
);
RAISE EXCEPTION 'Repair event with an unknown actor was accepted';
EXCEPTION WHEN foreign_key_violation THEN
NULL;
END;
BEGIN
UPDATE miners SET lifecycle_status = 'unknown' WHERE id = v_miner;
RAISE EXCEPTION 'Unsupported miner lifecycle was accepted';
EXCEPTION WHEN check_violation THEN
NULL;
END;
END
$$;
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
SELECT
gen_random_uuid(),
'REPAIR-STATUS-' || status,
'unknown',
'active'
FROM (
VALUES
('OPEN'),
('IN_PROGRESS'),
('WAITING_FOR_PARTS'),
('READY_FOR_TEST'),
('COMPLETED'),
('CANCELLED')
) AS supported(status);
INSERT INTO repairs (
miner_id,
status,
reason,
opened_by,
completed_at,
completed_by
)
SELECT
miners.id,
supported.status,
'OTHER',
actor.id,
CASE WHEN supported.status = 'COMPLETED' THEN now() END,
CASE WHEN supported.status = 'COMPLETED' THEN actor.id END
FROM (
VALUES
('OPEN'),
('IN_PROGRESS'),
('WAITING_FOR_PARTS'),
('READY_FOR_TEST'),
('COMPLETED'),
('CANCELLED')
) AS supported(status)
JOIN miners ON miners.asset_tag = 'REPAIR-STATUS-' || supported.status
CROSS JOIN (
SELECT id FROM users WHERE username = 'begin-repair-migration-test'
) actor;
INSERT INTO miners (id, asset_tag, status, lifecycle_status)
SELECT
gen_random_uuid(),
'REPAIR-REASON-' || reason,
'unknown',
'active'
FROM (
VALUES
('HARDWARE_FAILURE'),
('LOW_HASHRATE'),
('CONTROL_BOARD'),
('HASHBOARD'),
('PSU'),
('FAN'),
('OVERHEATING'),
('NETWORKING'),
('PREVENTIVE_MAINTENANCE'),
('INSPECTION'),
('OTHER')
) AS supported(reason);
INSERT INTO repairs (
miner_id,
status,
reason,
opened_by,
completed_at,
completed_by
)
SELECT
miners.id,
'COMPLETED',
supported.reason,
actor.id,
now(),
actor.id
FROM (
VALUES
('HARDWARE_FAILURE'),
('LOW_HASHRATE'),
('CONTROL_BOARD'),
('HASHBOARD'),
('PSU'),
('FAN'),
('OVERHEATING'),
('NETWORKING'),
('PREVENTIVE_MAINTENANCE'),
('INSPECTION'),
('OTHER')
) AS supported(reason)
JOIN miners ON miners.asset_tag = 'REPAIR-REASON-' || supported.reason
CROSS JOIN (
SELECT id FROM users WHERE username = 'begin-repair-migration-test'
) actor;
SQL
echo "Checking downgrade refuses missing lifecycle history..."
"${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, status, lifecycle_status)
VALUES (
'00000000-0000-0000-0000-000000001999',
'REPAIR-MIGRATION-INCONSISTENT',
'unknown',
'in_repair'
);
SQL
if downgrade_error="$(run_alembic downgrade 0009_inventory_asset_id_history 2>&1)"; then
echo "Downgrade accepted missing lifecycle history." >&2
exit 1
fi
[[ "${downgrade_error}" == *"do not have exactly one active REPAIR_STARTED event"* ]]
failed_revision="$(run_alembic current 2>/dev/null)"
[[ "${failed_revision}" == *"0010_begin_repair"* ]]
"${compose[@]}" exec -T postgres sh -ec \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
DELETE FROM miners
WHERE id = '00000000-0000-0000-0000-000000001999';
SQL
echo "Downgrading and re-upgrading Begin Repair..."
run_alembic downgrade 0009_inventory_asset_id_history
downgrade_state="$(
"${compose[@]}" exec -T postgres sh -ec \
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
<<'SQL'
SELECT
to_regclass('public.repairs') IS NULL
AND to_regclass('public.repair_events') IS NULL
AND NOT EXISTS (
SELECT 1 FROM permissions WHERE key = 'repairs.create'
)
AND NOT EXISTS (
SELECT 1 FROM miners WHERE lifecycle_status = 'in_repair'
)
AND (
SELECT lifecycle_status
FROM miners
WHERE asset_tag = 'REPAIR-MIGRATION-001'
) = 'active'
AND (
SELECT lifecycle_status
FROM miners
WHERE asset_tag = 'REPAIR-MIGRATION-002'
) = 'spare';
SQL
)"
[[ "${downgrade_state}" == "t" ]]
run_alembic upgrade head
reupgraded_revision="$(run_alembic current 2>/dev/null)"
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
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_repair_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}" == *"0010_begin_repair"* ]]
echo "Begin Repair migration test passed."