557 lines
16 KiB
Bash
Executable File
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 0010_begin_repair
|
|
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 0010_begin_repair
|
|
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
|
[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
|
|
|
|
echo "Migrating an empty database from base through Begin Repair..."
|
|
"${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 0010_begin_repair
|
|
|
|
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."
|