feat: add control board replacement workflow
This commit is contained in:
1 parent
62c91a2c3e
commit
bcf1b4e3cf
16 files changed
+2122
-279
No files matched your search
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_CONTROL_BOARD_MIGRATION_PROJECT:-minerops-control-board-migration-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || exit 1
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
|
||||
run_alembic upgrade 0012_return_to_service
|
||||
"${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-000000001401', 'board-migration',
|
||||
'Board Migration', 'unused', id FROM roles WHERE name = 'Admin';
|
||||
INSERT INTO miners (
|
||||
id, asset_tag, current_mac, control_board_sn, lifecycle_status,
|
||||
status, first_seen_at, last_seen_at
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001402', 'BOARD-LEGACY',
|
||||
'02-AA-BB-CC-DD-01', 'LEGACY-BOARD', 'active', 'unknown', NULL, NULL
|
||||
);
|
||||
SQL
|
||||
|
||||
"${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, lifecycle_status, status,
|
||||
first_seen_at, last_seen_at
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001404', 'BOARD-CONFLICT',
|
||||
'02:AA:BB:CC:DD:01', 'active', 'unknown', NULL, NULL
|
||||
);
|
||||
SQL
|
||||
if preflight_error="$(run_alembic upgrade head 2>&1)"; then
|
||||
echo "Duplicate normalized MAC preflight did not stop the upgrade." >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${preflight_error}" == *"normalized MAC values have multiple miner owners"* ]]
|
||||
"${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-000000001404';
|
||||
SQL
|
||||
|
||||
run_alembic upgrade head
|
||||
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
DO $$
|
||||
DECLARE roles_for_permission text[];
|
||||
BEGIN
|
||||
IF to_regclass('public.miner_control_board_installations') IS NULL
|
||||
OR to_regclass('public.miner_mac_ownerships') IS NULL THEN
|
||||
RAISE EXCEPTION 'Task 004 tables are missing';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM miner_control_board_installations) THEN
|
||||
RAISE EXCEPTION 'Migration fabricated installation history';
|
||||
END IF;
|
||||
SELECT array_agg(role.name ORDER BY role.name) INTO roles_for_permission
|
||||
FROM role_permissions rp JOIN roles role ON role.id = rp.role_id
|
||||
JOIN permissions permission ON permission.id = rp.permission_id
|
||||
WHERE permission.key = 'miners.replace_control_board';
|
||||
IF roles_for_permission <> ARRAY['Admin', 'Manager', 'Technician'] THEN
|
||||
RAISE EXCEPTION 'Unexpected permission roles: %', roles_for_permission;
|
||||
END IF;
|
||||
IF (SELECT miner_id FROM miner_mac_ownerships WHERE normalized_mac = '02aabbccdd01')
|
||||
<> '00000000-0000-0000-0000-000000001402'::uuid THEN
|
||||
RAISE EXCEPTION 'Legacy MAC ownership was not preserved';
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
run_alembic downgrade 0012_return_to_service
|
||||
run_alembic upgrade head
|
||||
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||
UPDATE miners
|
||||
SET lifecycle_status = 'in_repair', current_mac = '02:AA:BB:CC:DD:02',
|
||||
control_board_sn = 'NEW-BOARD'
|
||||
WHERE id = '00000000-0000-0000-0000-000000001402';
|
||||
INSERT INTO miner_network_identities (
|
||||
miner_id, mac_address, source, is_current, first_seen_at, last_seen_at, associated_at
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001402', '02:AA:BB:CC:DD:02',
|
||||
'control_board_replacement', true, NULL, NULL, now()
|
||||
);
|
||||
INSERT INTO repairs (id, miner_id, reason, opened_by) VALUES (
|
||||
'00000000-0000-0000-0000-000000001403',
|
||||
'00000000-0000-0000-0000-000000001402', 'CONTROL_BOARD',
|
||||
'00000000-0000-0000-0000-000000001401'
|
||||
);
|
||||
INSERT INTO miner_control_board_installations (
|
||||
miner_id, record_origin, installed_repair_id, mac_address, normalized_mac, board_serial,
|
||||
replacement_reason, installed_at, installed_by
|
||||
) VALUES (
|
||||
'00000000-0000-0000-0000-000000001402', 'replacement',
|
||||
'00000000-0000-0000-0000-000000001403', '02:AA:BB:CC:DD:02',
|
||||
'02aabbccdd02', 'NEW-BOARD', 'failed', now(),
|
||||
'00000000-0000-0000-0000-000000001401'
|
||||
);
|
||||
SQL
|
||||
if downgrade_error="$(run_alembic downgrade 0012_return_to_service 2>&1)"; then
|
||||
echo "Downgrade discarded authoritative control-board history." >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${downgrade_error}" == *"authoritative installation records or events"* ]]
|
||||
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||
|
||||
database="minerops_control_board_empty"
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'createdb -U "$POSTGRES_USER" '"${database}"
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic upgrade head
|
||||
[[ "$("${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_CONTROL_BOARD_TEST_PROJECT:-minerops-control-board-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
backend python -m unittest -v tests.test_control_board_replacement
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
backend python -m unittest discover -v -s tests
|
||||
@@ -62,8 +62,52 @@ fi
|
||||
echo "Validating Compose configuration..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
|
||||
|
||||
echo "Building and starting disposable development stack..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build
|
||||
echo "Starting disposable data services..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach postgres redis
|
||||
|
||||
echo "Waiting for PostgreSQL initialization and Redis..."
|
||||
for attempt in $(seq 1 180); do
|
||||
postgres_ready=false
|
||||
redis_ready=false
|
||||
if "${compose[@]}" exec -T postgres sh -ec \
|
||||
'test "$(cat /proc/1/comm)" = "postgres"; psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "SELECT 1"' \
|
||||
2>/dev/null | grep -qx '1'; then
|
||||
postgres_ready=true
|
||||
fi
|
||||
if "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx 'PONG'; then
|
||||
redis_ready=true
|
||||
fi
|
||||
if [[ "${postgres_ready}" == "true" && "${redis_ready}" == "true" ]]; then
|
||||
break
|
||||
fi
|
||||
if (( attempt == 180 )); then
|
||||
echo "Disposable data services did not become ready within 180 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Building and starting the disposable backend..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build --no-deps backend
|
||||
backend_container="$("${compose[@]}" ps -q backend)"
|
||||
for attempt in $(seq 1 180); do
|
||||
backend_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${backend_container}")"
|
||||
if [[ "${backend_health}" == "healthy" ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "${backend_health}" == "exited" || "${backend_health}" == "dead" ]]; then
|
||||
echo "Disposable backend exited before becoming healthy." >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( attempt == 180 )); then
|
||||
echo "Disposable backend did not become healthy within 180 seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Starting the disposable gateway..."
|
||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --no-deps gateway
|
||||
|
||||
echo "Waiting for the gateway health endpoint..."
|
||||
health_file="${tmp_prefix}-health.json"
|
||||
@@ -88,7 +132,7 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||
[[ "${current_revision}" == *"0013_control_board_replacement"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
@@ -168,6 +212,8 @@ echo "Checking Begin Repair permission and workflow..."
|
||||
INSERT INTO miners (
|
||||
id,
|
||||
asset_tag,
|
||||
current_mac,
|
||||
control_board_sn,
|
||||
status,
|
||||
lifecycle_status,
|
||||
first_seen_at,
|
||||
@@ -176,6 +222,8 @@ INSERT INTO miners (
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000981',
|
||||
'SMOKE-REPAIR-001',
|
||||
'02:AA:BB:CC:EE:01',
|
||||
'SMOKE-CB-OLD',
|
||||
'unknown',
|
||||
'active',
|
||||
NULL,
|
||||
@@ -202,19 +250,31 @@ smoke_repair_id="$(
|
||||
| python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])'
|
||||
)"
|
||||
[[ -n "${smoke_repair_id}" ]]
|
||||
[[ "$(
|
||||
status_for "${readonly_cookie}" POST /api/miners/00000000-0000-0000-0000-000000000981/replace-control-board \
|
||||
'{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed"}'
|
||||
)" == "403" ]]
|
||||
board_response="$(curl --fail --silent -b "${technician_cookie}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed","notes":"Smoke replacement."}' \
|
||||
"${base_url}/api/miners/00000000-0000-0000-0000-000000000981/replace-control-board")"
|
||||
[[ "${board_response}" == *'"current_mac":"02:AA:BB:CC:EE:02"'* ]]
|
||||
[[ "${board_response}" == *'"event_sequence":3'* ]]
|
||||
readonly_repair_detail="$(
|
||||
curl --fail --silent -b "${readonly_cookie}" \
|
||||
"${base_url}/api/repairs/${smoke_repair_id}"
|
||||
)"
|
||||
[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]]
|
||||
[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]]
|
||||
[[ "${readonly_repair_detail}" == *'"latest_event_sequence":2'* ]]
|
||||
[[ "${readonly_repair_detail}" == *'"latest_event_sequence":3'* ]]
|
||||
readonly_repair_events="$(
|
||||
curl --fail --silent -b "${readonly_cookie}" \
|
||||
"${base_url}/api/repairs/${smoke_repair_id}/events"
|
||||
)"
|
||||
[[ "${readonly_repair_events}" == *'"sequence":1'* ]]
|
||||
[[ "${readonly_repair_events}" == *'"sequence":2'* ]]
|
||||
[[ "${readonly_repair_events}" == *'"sequence":3'* ]]
|
||||
[[ "${readonly_repair_events}" == *'"event_type":"CONTROL_BOARD_REPLACED"'* ]]
|
||||
[[ "${readonly_repair_events}" == *'"event_type":"REPAIR_STARTED"'* ]]
|
||||
[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]]
|
||||
[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]]
|
||||
|
||||
@@ -96,7 +96,7 @@ INSERT INTO repairs (
|
||||
SQL
|
||||
|
||||
echo "Upgrading previous head to Return to Service..."
|
||||
run_alembic upgrade head
|
||||
run_alembic upgrade 0012_return_to_service
|
||||
current_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
@@ -244,7 +244,7 @@ SQL
|
||||
[[ "${downgrade_state}" == "t" ]]
|
||||
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${downgraded_revision}" == *"0011_repair_timeline"* ]]
|
||||
run_alembic upgrade head
|
||||
run_alembic upgrade 0012_return_to_service
|
||||
reupgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||
[[ "${reupgraded_revision}" == *"0012_return_to_service"* ]]
|
||||
|
||||
@@ -284,7 +284,7 @@ echo "Migrating an empty database from base through head..."
|
||||
-c "CREATE DATABASE minerops_return_empty"' >/dev/null
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-e "POSTGRES_DB=${empty_database}" \
|
||||
backend alembic upgrade head
|
||||
backend alembic upgrade 0012_return_to_service
|
||||
empty_revision="$(
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-e "POSTGRES_DB=${empty_database}" \
|
||||
|
||||
Reference in new issue
Block a user