feat: add control board replacement workflow

This commit is contained in:
lussierj committed 2026-08-02 14:34:16 +00:00
1 parent 62c91a2c3e
commit bcf1b4e3cf
16 files changed
+2122 -279

No files matched your search

@@ -0,0 +1,436 @@
"""add control board replacement workflow
Revision ID: 0013_control_board_replacement
Revises: 0012_return_to_service
Create Date: 2026-08-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0013_control_board_replacement"
down_revision: Union[str, None] = "0012_return_to_service"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_REASONS = (
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
"PSU", "FAN", "OVERHEATING", "NETWORKING",
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
)
REPLACEMENT_REASONS = (
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
"temporary_donor", "unknown",
)
DISPOSITIONS = (
"repair_inventory", "sent_for_repair", "scrapped",
"retained_for_diagnosis", "returned_to_vendor", "unknown",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints(include_replacement: bool) -> None:
event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"]
if include_replacement:
event_types.append("CONTROL_BOARD_REPLACED")
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
f"event_type IN ({_quoted(event_types)})",
)
replacement_clause = ""
if include_replacement:
replacement_clause = (
") OR (event_type = 'CONTROL_BOARD_REPLACED' "
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
"'old_control_board_serial', 'new_control_board_serial', "
"'replacement_reason', 'removed_board_disposition'] "
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
"NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
"AND trim(payload ->> 'old_control_board_serial') <> '' "
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
"AND trim(payload ->> 'new_control_board_serial') <> '' "
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000))"
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"(event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> '') OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000))"
f"{replacement_clause})",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE conflicts TEXT;
BEGIN
WITH identities AS (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac,
miner_id
FROM miner_network_identities
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
UNION ALL
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id
FROM miners
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12
), duplicate_owners AS (
SELECT mac
FROM identities
GROUP BY mac
HAVING count(DISTINCT miner_id) > 1
)
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts,
HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.';
END IF;
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts
FROM (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac
FROM miner_network_identities
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
HAVING count(*) > 1
) AS duplicate_identity_mac;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts,
HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.';
END IF;
SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text)
INTO conflicts
FROM (
SELECT miner_id FROM miner_network_identities
WHERE is_current GROUP BY miner_id HAVING count(*) > 1
) AS duplicate_current;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts,
HINT = 'Choose one current network identity for each listed miner before upgrading.';
END IF;
END $$;
"""
)
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('miners.replace_control_board', 'Replace miner control boards during repair')
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 = 'miners.replace_control_board'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING;
"""
)
op.add_column(
"miner_network_identities",
sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True),
)
op.alter_column("miner_network_identities", "first_seen_at", nullable=True)
op.alter_column("miner_network_identities", "last_seen_at", nullable=True)
connection.exec_driver_sql(
"""
CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac
ON miner_network_identities (
regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
)
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12;
CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner
ON miner_network_identities (miner_id) WHERE is_current;
CREATE UNIQUE INDEX uq_miners_current_normalized_mac
ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'))
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12;
ALTER TABLE miner_network_identities
ADD CONSTRAINT ck_miner_network_identities_mac_valid
CHECK (
source NOT IN ('control_board_replacement', 'legacy_projection') OR (
mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$'
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff')
AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0
)
) NOT VALID;
"""
)
op.create_table(
"miner_mac_ownerships",
sa.Column("normalized_mac", sa.Text(), primary_key=True),
sa.Column(
"miner_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"claimed_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
),
sa.CheckConstraint(
"normalized_mac ~ '^[0-9a-f]{12}$' "
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0",
name="ck_miner_mac_ownerships_valid",
),
)
op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"])
connection.exec_driver_sql(
"""
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id)
SELECT mac, min(miner_id::text)::uuid FROM (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id
FROM miner_network_identities
UNION ALL
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners
) AS known
WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff')
AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0
GROUP BY mac;
"""
)
op.create_table(
"miner_control_board_installations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False),
sa.Column("record_origin", sa.Text(), nullable=False),
sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
sa.Column("mac_address", sa.Text(), nullable=True),
sa.Column("normalized_mac", sa.Text(), nullable=True),
sa.Column("board_serial", sa.Text(), nullable=True),
sa.Column("board_model", sa.Text(), nullable=True),
sa.Column("board_revision", sa.Text(), nullable=True),
sa.Column("firmware_version", sa.Text(), nullable=True),
sa.Column("replacement_reason", sa.Text(), nullable=True),
sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_disposition", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"),
sa.CheckConstraint(
"normalized_mac IS NULL OR ("
"mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac "
"AND normalized_mac ~ '^[0-9a-f]{12}$' "
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)",
name="ck_control_board_installations_mac_normalized",
),
sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"),
sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"),
sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"),
sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"),
sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"),
sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"),
sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"),
sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"),
sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"),
sa.CheckConstraint(
"(record_origin = 'legacy_projection' AND installed_repair_id IS NULL "
"AND installed_at IS NULL AND installed_by IS NULL "
"AND replacement_reason IS NULL) OR "
"(record_origin = 'replacement' AND installed_repair_id IS NOT NULL "
"AND installed_at IS NOT NULL AND installed_by IS NOT NULL "
"AND replacement_reason IS NOT NULL)",
name="ck_control_board_installations_install_state",
),
)
op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL"))
op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL"))
op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"])
op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"])
op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"])
op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"])
connection.exec_driver_sql(
"""
CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb;
BEGIN
row_data := to_jsonb(NEW);
mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g');
target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF;
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at)
VALUES (mac, target_owner, now())
ON CONFLICT (normalized_mac) DO NOTHING;
SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE;
IF existing_owner <> (target_owner) THEN
RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow';
END $$;
CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership();
CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Network identity history is immutable';
END IF;
IF NEW.miner_id <> OLD.miner_id
OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g')
<> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN
RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history();
CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE target_miner uuid; mismatch boolean; row_data jsonb;
BEGIN
IF TG_OP = 'DELETE' THEN
row_data := to_jsonb(OLD);
ELSE
row_data := to_jsonb(NEW);
END IF;
target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
SELECT EXISTS (
SELECT 1 FROM miner_control_board_installations
WHERE miner_id = target_miner
) AND NOT EXISTS (
SELECT 1
FROM miner_control_board_installations board
JOIN miners miner ON miner.id = board.miner_id
JOIN miner_network_identities identity
ON identity.miner_id = board.miner_id AND identity.is_current
WHERE board.miner_id = target_miner AND board.removed_at IS NULL
AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial
AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
) INTO mismatch;
IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF;
IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF;
IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN
RAISE EXCEPTION 'Control-board installation identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history();
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(True)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$ DECLARE history_count bigint; BEGIN
SELECT count(*) INTO history_count FROM miner_control_board_installations;
history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED');
IF history_count > 0 THEN
RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.';
END IF;
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.';
END IF;
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL)
OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.';
END IF;
END $$;
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(False)
connection.exec_driver_sql(
"""
DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations;
DROP FUNCTION minerops_protect_control_board_history();
DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations;
DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities;
DROP TRIGGER trg_miners_check_current_board_projection ON miners;
DROP FUNCTION minerops_check_current_board_projection();
DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships;
DROP FUNCTION minerops_protect_mac_ownership();
DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities;
DROP FUNCTION minerops_protect_network_identity_history();
DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities;
DROP TRIGGER trg_miners_claim_mac_owner ON miners;
DROP FUNCTION minerops_claim_mac_owner();
"""
)
op.drop_table("miner_control_board_installations")
op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships")
op.drop_table("miner_mac_ownerships")
op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check")
op.drop_index("uq_miners_current_normalized_mac", table_name="miners")
op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities")
op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities")
op.alter_column("miner_network_identities", "last_seen_at", nullable=False)
op.alter_column("miner_network_identities", "first_seen_at", nullable=False)
op.drop_column("miner_network_identities", "associated_at")
connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')")
connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'")
+9
View File
@@ -694,6 +694,15 @@ def repair_completion_request(path: str) -> bool:
def required_api_permission(method: str, path: str) -> str | None:
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
return "miners.replace_control_board"
if (
method == "POST"
and len(path.split("/")) == 5
and path.split("/")[:3] == ["", "api", "miners"]
and path.split("/")[4] == "replace-control-board"
):
return "miners.replace_control_board"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "POST" and repair_completion_request(path):
+82 -258
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
import jwt
import psycopg
@@ -7,7 +8,7 @@ import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.identity import (
@@ -18,7 +19,23 @@ from app.identity import (
router as identity_router,
)
from app.inventory.router import router as inventory_router
from app.repairs.router import router as repairs_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairConflict,
RepairMinerNotFound,
)
from app.repairs.models import (
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
router as repairs_router,
)
app = FastAPI(title=settings.app_name)
@@ -15430,33 +15447,17 @@ def delete_miner_maintenance_event(
conn.close()
class MinerMacChangeRequest(BaseModel):
new_mac: str
model_config = ConfigDict(extra="forbid")
new_mac: str = Field(min_length=1, max_length=64)
old_mac: str | None = None
new_ip: str | None = None
event_title: str | None = None
description: str | None = None
component_serial: str | None = None
downtime_minutes: int | None = None
def normalize_mac_display(mac: str | None):
if mac is None:
return None
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return str(mac).strip().upper()
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
def normalize_mac_key_for_change(mac: str | None):
if mac is None:
return None
return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
description: str | None = Field(default=None, max_length=4000)
component_serial: str | None = Field(default=None, max_length=255)
downtime_minutes: int | None = Field(default=None, ge=0)
removed_board_disposition: RemovedBoardDisposition | None = None
replacement_reason: ControlBoardReplacementReason | None = None
@app.post("/api/miners/{miner_id}/change-mac")
def change_miner_mac_address(
@@ -15464,244 +15465,66 @@ def change_miner_mac_address(
request: MinerMacChangeRequest,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
new_mac = normalize_mac_display(request.new_mac)
new_mac_key = normalize_mac_key_for_change(new_mac)
if not new_mac_key or len(new_mac_key) != 12:
raise HTTPException(status_code=400, detail="New MAC address is invalid")
conn = None
"""Compatibility alias for the repair-scoped replacement workflow."""
if request.replacement_reason is None or request.removed_board_disposition is None:
raise HTTPException(
status_code=422,
detail=(
"The compatibility route requires replacement_reason and "
"removed_board_disposition; use the repair-scoped replacement fields"
),
)
if request.new_ip is not None or request.downtime_minutes is not None:
raise HTTPException(
status_code=422,
detail=(
"The repair-scoped compatibility route does not change IP addresses "
"or record mutable downtime values"
),
)
try:
parsed_miner_id = UUID(miner_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc
try:
conn = get_db_connection()
user_id = get_current_user_id_for_maintenance(current_user)
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
hostname
FROM miners
WHERE id = %s::uuid;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner = dict(miner)
old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac"))
old_ip = miner.get("current_ip")
new_ip = request.new_ip or old_ip
if normalize_mac_key_for_change(old_mac) == new_mac_key:
raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC")
cur.execute(
"""
UPDATE miners
SET
current_mac = %s,
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip)
WHERE id = %s::uuid
RETURNING
id::text,
current_mac,
host(current_ip) AS current_ip;
""",
(
new_mac,
new_ip,
miner_id,
),
)
updated_miner = dict(cur.fetchone())
# Mark all known identities for this miner as not current.
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = false
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
# If this MAC already exists in the identity history, mark it current.
cur.execute(
"""
UPDATE miner_network_identities
SET
is_current = true,
ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address),
hostname = COALESCE(NULLIF(%s, ''), hostname),
last_seen_at = now()
WHERE miner_id = %s::uuid
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING id::text;
""",
(
new_ip,
miner.get("hostname"),
miner_id,
new_mac_key,
),
)
existing_identity = cur.fetchone()
if not existing_identity:
cur.execute(
"""
INSERT INTO miner_network_identities (
id,
miner_id,
mac_address,
ip_address,
hostname,
first_seen_at,
last_seen_at,
is_current
)
VALUES (
%s::uuid,
%s::uuid,
%s,
NULLIF(%s, '')::inet,
%s,
now(),
now(),
true
);
""",
(
str(uuid4()),
miner_id,
new_mac,
new_ip,
miner.get("hostname"),
),
)
event_id = str(uuid4())
event_title = request.event_title or "Control board / MAC address changed"
description_parts = []
if request.description:
description_parts.append(request.description)
description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.")
if old_ip or new_ip:
description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.")
event_description = " ".join(description_parts)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
old_ip,
new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'control_board_changed',
'completed',
%s,
%s,
now(),
%s,
%s,
NULLIF(%s, '')::inet,
NULLIF(%s, '')::inet,
'control_board',
'control_board',
%s,
%s,
%s::uuid
)
RETURNING
id::text,
miner_id::text,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
host(old_ip) AS old_ip,
host(new_ip) AS new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id::text,
created_at,
updated_at;
""",
(
event_id,
miner_id,
event_title,
event_description,
old_mac,
new_mac,
old_ip,
new_ip,
request.component_serial,
request.downtime_minutes,
user_id,
),
)
maintenance_event = dict(cur.fetchone())
conn.commit()
return api_response(
data={
"miner": updated_miner,
"maintenance_event": maintenance_event,
}
replacement = ControlBoardReplacementRequest(
new_mac_address=request.new_mac,
new_control_board_serial=request.component_serial,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=request.description,
)
except ValidationError as exc:
raise HTTPException(
status_code=422, detail="Legacy change-mac request is invalid"
) from exc
except Exception:
if conn is not None:
conn.rollback()
try:
result = RepairService(RepairRepository()).replace_control_board(
parsed_miner_id,
replacement,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except RepairConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise
replacement_data = result.model_dump(mode="json")
return api_response(
data={
"miner": {
"id": replacement_data["miner_id"],
"current_mac": replacement_data["current_mac"],
"control_board_sn": replacement_data["control_board_serial"],
},
"control_board_replacement": replacement_data,
}
)
finally:
if conn is not None:
conn.close()
@app.get("/api/maintenance/events")
def list_all_maintenance_events(
@@ -35674,3 +35497,4 @@ def minerops_repair_parts_summary_api(
# Register UUID-based Repair routes after legacy static Repair paths.
app.include_router(repairs_router)
app.include_router(control_board_router)
+28
View File
@@ -107,3 +107,31 @@ class RepairActiveMismatch(RepairConflict):
class RepairPersistenceError(RepairError):
"""Raised when a required repair persistence operation fails."""
class ControlBoardReplacementActorRequired(RepairError):
"""Raised when a board replacement lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to replace a control board")
class ControlBoardReplacementConflict(RepairConflict):
"""Raised when current repair, component, or MAC state blocks replacement."""
class InvalidMacAddress(RepairError):
"""Raised when a replacement MAC is malformed or prohibited."""
def __init__(self, message: str = "New MAC address is invalid"):
super().__init__(message)
class MacOwnershipConflict(ControlBoardReplacementConflict):
"""Raised when a MAC belongs to another durable miner."""
def __init__(self, normalized_mac: str):
self.normalized_mac = normalized_mac
super().__init__(
"MAC address has current or historical ownership on another miner"
)
+66
View File
@@ -32,6 +32,7 @@ RepairEventType = Literal[
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
@@ -53,6 +54,37 @@ class RepairCompleteRequest(BaseModel):
completion_summary: str | None = Field(default=None, max_length=4000)
ControlBoardReplacementReason = Literal[
"failed",
"upgrade",
"diagnostic_swap",
"preventive_maintenance",
"temporary_donor",
"unknown",
]
RemovedBoardDisposition = Literal[
"repair_inventory",
"sent_for_repair",
"scrapped",
"retained_for_diagnosis",
"returned_to_vendor",
"unknown",
]
class ControlBoardReplacementRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
new_mac_address: str = Field(min_length=1, max_length=64)
new_control_board_serial: str | None = Field(..., max_length=255)
board_model: str | None = Field(default=None, max_length=255)
board_revision: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class RepairStartedPayload(BaseModel):
reason: RepairReason
from_lifecycle: RepairEligibleLifecycle
@@ -70,6 +102,40 @@ class RepairCompletedPayload(BaseModel):
completion_summary: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacedPayload(BaseModel):
old_mac_address: str | None
new_mac_address: str
old_control_board_serial: str | None
new_control_board_serial: str | None
old_board_model: str | None = None
new_board_model: str | None = None
old_board_revision: str | None = None
new_board_revision: str | None = None
new_firmware_version: str | None = None
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacementResult(BaseModel):
miner_id: UUID
asset_id: str | None = None
repair_id: UUID
installation_id: UUID
event_id: UUID
event_sequence: int = Field(ge=1)
current_mac: str
control_board_serial: str | None
replaced_at: AwareDatetime
replaced_by: UUID
class ControlBoardReplacementResponse(BaseModel):
data: ControlBoardReplacementResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class BeginRepairResult(BaseModel):
repair_id: UUID
miner_id: UUID
+257 -2
View File
@@ -6,13 +6,15 @@ from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.errors import CheckViolation, LockNotAvailable, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.config import settings
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementConflict,
MacOwnershipConflict,
RepairPersistenceError,
)
@@ -64,6 +66,26 @@ class RepairRepository:
try:
yield transaction_repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name in {
"uq_miner_mac_ownerships_owner",
"uq_miner_network_identities_normalized_mac",
"uq_miners_current_normalized_mac",
"uq_control_board_installations_current_miner",
"uq_control_board_installations_current_mac",
}:
raise ControlBoardReplacementConflict(
"Concurrent control-board replacement or MAC ownership conflict"
) from exc
raise
except CheckViolation as exc:
connection.rollback()
if exc.diag.constraint_name == "ck_current_control_board_projection":
raise ControlBoardReplacementConflict(
"Control-board projection would contradict authoritative history"
) from exc
raise
except Exception:
connection.rollback()
raise
@@ -97,7 +119,8 @@ class RepairRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, lifecycle_status
SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn,
firmware_version
FROM miners
WHERE id = %s
FOR UPDATE
@@ -107,6 +130,27 @@ class RepairRepository:
row = cur.fetchone()
return dict(row) if row else None
def lock_miner_for_control_board_replacement(
self, miner_id: UUID
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
try:
cur.execute(
"""
SELECT id, asset_tag, lifecycle_status, current_mac,
control_board_sn, firmware_version
FROM miners WHERE id = %s FOR UPDATE NOWAIT
""",
(miner_id,),
)
except LockNotAvailable as exc:
raise ControlBoardReplacementConflict(
"A control-board replacement is already in progress"
) from exc
row = cur.fetchone()
return dict(row) if row else None
def get_active_repair(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
@@ -342,6 +386,217 @@ class RepairRepository:
row = cur.fetchone()
return dict(row) if row else None
def lock_active_repair_for_miner(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, miner_id, status, reason, opened_at, opened_by
FROM repairs
WHERE miner_id = %s AND status = ANY(%s)
ORDER BY opened_at, id
LIMIT 1
FOR UPDATE
""",
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_current_control_board(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT * FROM miner_control_board_installations
WHERE miner_id = %s AND removed_at IS NULL
FOR UPDATE
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_legacy_control_board(self, miner: Mapping[str, Any]) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
mac_address = miner.get("current_mac")
normalized_mac = (
"".join(ch for ch in str(mac_address).lower() if ch in "0123456789abcdef")
if mac_address else None
)
if normalized_mac and len(normalized_mac) == 12:
mac_address = ":".join(
normalized_mac[index:index + 2].upper()
for index in range(0, 12, 2)
)
elif normalized_mac:
normalized_mac = None
mac_address = None
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, mac_address, normalized_mac,
board_serial, firmware_version
)
VALUES (%s, 'legacy_projection', %s, %s, %s, %s)
RETURNING *
""",
(miner["id"], mac_address, normalized_mac,
miner.get("control_board_sn"), miner.get("firmware_version")),
)
return dict(cur.fetchone())
def claim_mac_ownership(
self, normalized_mac: str, miner_id: UUID, actor_user_id: UUID
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(normalized_mac,),
)
cur.execute(
"SELECT miner_id FROM miner_mac_ownerships "
"WHERE normalized_mac = %s FOR UPDATE",
(normalized_mac,),
)
row = cur.fetchone()
if row and row["miner_id"] != miner_id:
raise MacOwnershipConflict(normalized_mac)
if row is None:
cur.execute(
"""
INSERT INTO miner_mac_ownerships (
normalized_mac, miner_id, claimed_at, claimed_by
) VALUES (%s, %s, now(), %s)
""",
(normalized_mac, miner_id, actor_user_id),
)
def close_control_board(
self, installation_id: UUID, *, repair_id: UUID, actor_user_id: UUID,
disposition: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE miner_control_board_installations
SET removed_at = now(), removed_by = %s,
removed_repair_id = %s, removed_disposition = %s
WHERE id = %s AND removed_at IS NULL
RETURNING *
""",
(actor_user_id, repair_id, disposition, installation_id),
)
row = cur.fetchone()
if row is None:
raise RepairPersistenceError(
"Current control-board installation was not closed"
)
return dict(row)
def create_control_board_installation(
self, *, miner_id: UUID, repair_id: UUID, mac_address: str,
normalized_mac: str, board_serial: str | None, board_model: str | None,
board_revision: str | None, firmware_version: str | None,
replacement_reason: str, notes: str | None, actor_user_id: UUID
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, installed_repair_id, mac_address,
normalized_mac, board_serial, board_model, board_revision,
firmware_version, replacement_reason, installed_at,
installed_by, notes
) VALUES (
%s, 'replacement', %s, %s, %s, %s, %s, %s, %s, %s,
now(), %s, %s
) RETURNING *
""",
(miner_id, repair_id, mac_address, normalized_mac, board_serial,
board_model, board_revision, firmware_version,
replacement_reason, actor_user_id, notes),
)
return dict(cur.fetchone())
def set_current_control_board(
self, miner_id: UUID, *, mac_address: str, board_serial: str | None
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE miners SET current_mac = %s, control_board_sn = %s,
updated_at = now() WHERE id = %s
""",
(mac_address, board_serial, miner_id),
)
if cur.rowcount != 1:
raise RepairPersistenceError(
"Current miner control-board projection was not updated"
)
def ensure_network_identity_history(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
)
SELECT %s, %s, 'legacy_projection', false, NULL, NULL, now()
WHERE NOT EXISTS (
SELECT 1 FROM miner_network_identities
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
)
""",
(miner_id, mac_address, miner_id, normalized_mac),
)
def set_current_network_identity(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"UPDATE miner_network_identities SET is_current = false, "
"updated_at = now() WHERE miner_id = %s AND is_current",
(miner_id,),
)
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = true, associated_at = now(), updated_at = now()
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING *
""",
(miner_id, normalized_mac),
)
row = cur.fetchone()
if row is None:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
) VALUES (%s, %s, 'control_board_replacement', true,
NULL, NULL, now())
RETURNING *
""",
(miner_id, mac_address),
)
row = cur.fetchone()
return dict(row)
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+37
View File
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from app.identity import require_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
@@ -16,6 +18,8 @@ from app.repairs.exceptions import (
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacementRequest,
ControlBoardReplacementResponse,
BeginRepairResponse,
RepairCompleteRequest,
RepairDetailResponse,
@@ -25,9 +29,11 @@ from app.repairs.service import RepairService
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
control_board_router = APIRouter(prefix="/api/miners", tags=["miners"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
require_control_board_replace = require_permission("miners.replace_control_board")
RepairServiceDependency = Annotated[
RepairService,
@@ -36,6 +42,37 @@ RepairServiceDependency = Annotated[
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)]
@control_board_router.post(
"/{miner_id}/replace-control-board",
response_model=ControlBoardReplacementResponse,
)
def replace_control_board(
miner_id: UUID,
request: ControlBoardReplacementRequest,
service: RepairServiceDependency,
current_user: ControlBoardReplaceUser,
) -> ControlBoardReplacementResponse:
try:
result = service.replace_control_board(
miner_id, request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Miner not found"
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
return ControlBoardReplacementResponse(data=result)
@router.post(
+157
View File
@@ -2,8 +2,13 @@
from uuid import UUID
import re
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementActorRequired,
ControlBoardReplacementConflict,
InvalidMacAddress,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
@@ -17,6 +22,9 @@ from app.repairs.exceptions import (
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacedPayload,
ControlBoardReplacementRequest,
ControlBoardReplacementResult,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
@@ -143,6 +151,155 @@ class RepairService:
opened_by=repair["opened_by"],
)
@staticmethod
def normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
@staticmethod
def normalize_mac_address(value: str) -> tuple[str, str]:
raw = str(value).strip()
if not raw or re.fullmatch(r"[0-9A-Fa-f:\-.\s]+", raw) is None:
raise InvalidMacAddress()
normalized = re.sub(r"[^0-9A-Fa-f]", "", raw).lower()
if len(normalized) != 12:
raise InvalidMacAddress()
first_octet = int(normalized[:2], 16)
if normalized == "000000000000":
raise InvalidMacAddress("All-zero MAC addresses are not allowed")
if normalized == "ffffffffffff":
raise InvalidMacAddress("Broadcast MAC addresses are not allowed")
if first_octet & 1:
raise InvalidMacAddress("Multicast MAC addresses are not allowed")
canonical = ":".join(
normalized[index:index + 2].upper()
for index in range(0, 12, 2)
)
return canonical, normalized
def replace_control_board(
self, miner_id: UUID, request: ControlBoardReplacementRequest, *,
actor_user_id: UUID | None
) -> ControlBoardReplacementResult:
if actor_user_id is None:
raise ControlBoardReplacementActorRequired()
new_mac, new_mac_normalized = self.normalize_mac_address(
request.new_mac_address
)
new_serial = self.normalize_optional_text(
request.new_control_board_serial
)
board_model = self.normalize_optional_text(request.board_model)
board_revision = self.normalize_optional_text(request.board_revision)
firmware_version = self.normalize_optional_text(request.firmware_version)
notes = self.normalize_note(request.notes)
with self.repository.transaction() as repository:
miner = repository.lock_miner_for_control_board_replacement(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
if str(miner["lifecycle_status"]) != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(
miner_id, miner["lifecycle_status"]
)
repair = repository.lock_active_repair_for_miner(miner_id)
if repair is None:
raise ControlBoardReplacementConflict(
"Miner has no active repair"
)
current_board = repository.lock_current_control_board(miner_id)
if current_board is None:
if not miner.get("current_mac") and not miner.get("control_board_sn"):
raise ControlBoardReplacementConflict(
"Miner has no current control-board identity to replace"
)
current_board = repository.create_legacy_control_board(miner)
old_mac = current_board.get("mac_address") or miner.get("current_mac")
old_mac_canonical = None
old_mac_normalized = None
if old_mac:
try:
old_mac_canonical, old_mac_normalized = (
self.normalize_mac_address(str(old_mac))
)
except InvalidMacAddress:
old_mac_canonical = str(old_mac)
if old_mac_normalized == new_mac_normalized:
raise ControlBoardReplacementConflict(
"New MAC address is the same as the current MAC address"
)
if old_mac_normalized is not None:
repository.ensure_network_identity_history(
miner_id, mac_address=old_mac_canonical,
normalized_mac=old_mac_normalized
)
repository.claim_mac_ownership(
new_mac_normalized, miner_id, actor_user_id
)
previous_board = repository.close_control_board(
current_board["id"],
repair_id=repair["id"],
actor_user_id=actor_user_id,
disposition=request.removed_board_disposition,
)
installation = repository.create_control_board_installation(
miner_id=miner_id,
repair_id=repair["id"],
mac_address=new_mac,
normalized_mac=new_mac_normalized,
board_serial=new_serial,
board_model=board_model,
board_revision=board_revision,
firmware_version=firmware_version,
replacement_reason=request.replacement_reason,
notes=notes,
actor_user_id=actor_user_id,
)
repository.set_current_network_identity(
miner_id, mac_address=new_mac,
normalized_mac=new_mac_normalized
)
repository.set_current_control_board(
miner_id, mac_address=new_mac, board_serial=new_serial
)
next_sequence = repository.get_next_event_sequence(repair["id"])
payload = ControlBoardReplacedPayload(
old_mac_address=old_mac_canonical,
new_mac_address=new_mac,
old_control_board_serial=previous_board.get("board_serial"),
new_control_board_serial=new_serial,
old_board_model=previous_board.get("board_model"),
new_board_model=board_model,
old_board_revision=previous_board.get("board_revision"),
new_board_revision=board_revision,
new_firmware_version=firmware_version,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=notes,
)
event = repository.create_event(
repair["id"], sequence=next_sequence,
event_type="CONTROL_BOARD_REPLACED",
created_by=actor_user_id,
payload=payload.model_dump(mode="json", exclude_none=False),
)
return ControlBoardReplacementResult(
miner_id=miner_id, asset_id=miner.get("asset_tag"),
repair_id=repair["id"], installation_id=installation["id"],
event_id=event["id"], event_sequence=event["sequence"],
current_mac=new_mac, control_board_serial=new_serial,
replaced_at=event["created_at"], replaced_by=actor_user_id,
)
def complete_repair(
self,
repair_id: UUID,
@@ -0,0 +1,804 @@
import ast
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
import unittest
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from psycopg.errors import CheckViolation, RaiseException, UniqueViolation
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app.identity import get_current_user, required_api_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ControlBoardReplacementConflict,
InvalidMacAddress,
MacOwnershipConflict,
RepairMinerNotFound,
RepairMinerNotInRepair,
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacementRequest,
ControlBoardReplacementResult,
)
from app.repairs.repository import RepairRepository
from app.repairs.router import control_board_router
from app.repairs.service import RepairService
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
def request(**overrides):
values = {
"new_mac_address": "02:AA:BB:CC:DD:02",
"new_control_board_serial": " CB-NEW ",
"board_model": " Model X ",
"board_revision": " Rev B ",
"firmware_version": " 1.2.3 ",
"removed_board_disposition": "sent_for_repair",
"replacement_reason": "failed",
"notes": " Board would not boot. ",
}
values.update(overrides)
return ControlBoardReplacementRequest(**values)
class FakeReplacementRepository:
def __init__(self):
self.miner_id = uuid4()
self.repair_id = uuid4()
self.actor_id = uuid4()
self.miner = {
"id": self.miner_id,
"asset_tag": "MINER-004",
"lifecycle_status": "in_repair",
"current_mac": "02:AA:BB:CC:DD:01",
"control_board_sn": "CB-OLD",
"firmware_version": "1.0.0",
}
self.repair = {"id": self.repair_id, "miner_id": self.miner_id}
self.installations = []
self.events = [{"sequence": 1}]
self.identities = []
self.calls = []
self.fail_at = None
self.owner = None
@contextmanager
def transaction(self):
self.calls.append("transaction")
snapshot = deepcopy(
(self.miner, self.installations, self.events, self.identities)
)
try:
yield self
except Exception:
self.miner, self.installations, self.events, self.identities = snapshot
raise
def _fail(self, name):
self.calls.append(name)
if self.fail_at == name:
raise RuntimeError(name)
def lock_miner_for_control_board_replacement(self, miner_id):
self._fail("lock_miner")
return self.miner if miner_id == self.miner_id else None
def lock_active_repair_for_miner(self, miner_id):
self._fail("lock_repair")
return self.repair if miner_id == self.miner_id else None
def lock_current_control_board(self, miner_id):
self._fail("lock_board")
current = [row for row in self.installations if row["removed_at"] is None]
return current[0] if current else None
def create_legacy_control_board(self, miner):
self._fail("create_legacy_board")
row = {
"id": uuid4(), "miner_id": miner["id"],
"mac_address": miner["current_mac"],
"board_serial": miner["control_board_sn"],
"board_model": None, "board_revision": None,
"removed_at": None,
}
self.installations.append(row)
return row
def ensure_network_identity_history(self, miner_id, **values):
self._fail("ensure_old_identity")
self.identities.append({"miner_id": miner_id, "is_current": False, **values})
def claim_mac_ownership(self, normalized_mac, miner_id, actor_user_id):
self._fail("claim_mac")
if self.owner is not None and self.owner != miner_id:
raise MacOwnershipConflict(normalized_mac)
self.owner = miner_id
def close_control_board(self, installation_id, **values):
self._fail("close_board")
row = next(row for row in self.installations if row["id"] == installation_id)
row.update(removed_at=NOW, **values)
return row
def create_control_board_installation(self, **values):
self._fail("create_board")
row = {"id": uuid4(), "removed_at": None, **values, "installed_at": NOW}
self.installations.append(row)
return row
def set_current_network_identity(self, miner_id, **values):
self._fail("set_identity")
self.identities.append({"miner_id": miner_id, **values})
return self.identities[-1]
def set_current_control_board(self, miner_id, *, mac_address, board_serial):
self._fail("set_projection")
self.miner.update(current_mac=mac_address, control_board_sn=board_serial)
def get_next_event_sequence(self, repair_id):
self._fail("next_sequence")
return max(event["sequence"] for event in self.events) + 1
def create_event(self, repair_id, **values):
self._fail("create_event")
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
self.events.append(row)
return row
class ControlBoardReplacementServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeReplacementRepository()
self.service = RepairService(self.repository)
def replace(self, replacement=None):
return self.service.replace_control_board(
self.repository.miner_id,
replacement or request(),
actor_user_id=self.repository.actor_id,
)
def test_replacement_is_atomic_and_preserves_durable_identity(self):
miner_id = self.repository.miner["id"]
asset_id = self.repository.miner["asset_tag"]
result = self.replace()
self.assertEqual(result.miner_id, miner_id)
self.assertEqual(result.asset_id, asset_id)
self.assertEqual(result.current_mac, "02:AA:BB:CC:DD:02")
self.assertEqual(result.control_board_serial, "CB-NEW")
self.assertEqual(self.repository.miner["id"], miner_id)
self.assertEqual(self.repository.miner["asset_tag"], asset_id)
self.assertEqual(len([x for x in self.repository.installations if x["removed_at"] is None]), 1)
event = self.repository.events[-1]
self.assertEqual(event["event_type"], "CONTROL_BOARD_REPLACED")
self.assertEqual(event["sequence"], 2)
self.assertEqual(event["created_by"], self.repository.actor_id)
self.assertEqual(event["payload"]["old_mac_address"], "02:AA:BB:CC:DD:01")
self.assertEqual(event["payload"]["old_control_board_serial"], "CB-OLD")
self.assertEqual(event["payload"]["notes"], "Board would not boot.")
def test_mac_normalization_and_prohibited_values(self):
self.assertEqual(
self.service.normalize_mac_address("02-aA-bb-cc-dd-02"),
("02:AA:BB:CC:DD:02", "02aabbccdd02"),
)
for value in (
"bad", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF",
"01:00:5E:00:00:01",
):
with self.subTest(value=value), self.assertRaises(InvalidMacAddress):
self.service.normalize_mac_address(value)
def test_conflicting_states_do_not_write(self):
cases = ("active", "spare", "retired")
for lifecycle in cases:
with self.subTest(lifecycle=lifecycle):
repository = FakeReplacementRepository()
repository.miner["lifecycle_status"] = lifecycle
with self.assertRaises(RepairMinerNotInRepair):
RepairService(repository).replace_control_board(
repository.miner_id, request(),
actor_user_id=repository.actor_id,
)
self.assertEqual(repository.events, [{"sequence": 1}])
def test_same_and_historically_owned_mac_conflict(self):
with self.assertRaises(ControlBoardReplacementConflict):
self.replace(request(new_mac_address="02:AA:BB:CC:DD:01"))
repository = FakeReplacementRepository()
repository.owner = uuid4()
with self.assertRaises(MacOwnershipConflict):
RepairService(repository).replace_control_board(
repository.miner_id, request(), actor_user_id=repository.actor_id
)
def test_missing_miner_and_active_repair_conflict(self):
with self.assertRaises(RepairMinerNotFound):
self.service.replace_control_board(
uuid4(), request(), actor_user_id=self.repository.actor_id
)
self.repository.repair = None
with self.assertRaises(ControlBoardReplacementConflict):
self.replace()
def test_each_meaningful_write_failure_rolls_back(self):
for step in (
"claim_mac", "close_board", "create_board", "set_identity",
"set_projection", "create_event",
):
with self.subTest(step=step):
repository = FakeReplacementRepository()
repository.fail_at = step
original = deepcopy(repository.miner)
with self.assertRaises(RuntimeError):
RepairService(repository).replace_control_board(
repository.miner_id, request(),
actor_user_id=repository.actor_id,
)
self.assertEqual(repository.miner, original)
self.assertEqual(repository.installations, [])
self.assertEqual(repository.events, [{"sequence": 1}])
class StubReplacementService:
def __init__(self):
self.error = None
self.result = None
self.request = None
self.actor = None
def replace_control_board(self, miner_id, request, *, actor_user_id):
self.request = request
self.actor = actor_user_id
if self.error:
raise self.error
return self.result
class ControlBoardReplacementRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubReplacementService()
self.app = FastAPI()
self.app.include_router(control_board_router)
self.app.dependency_overrides[get_repair_service] = lambda: self.service
self.client = TestClient(self.app)
self.actor = uuid4()
self.miner = uuid4()
def tearDown(self):
self.client.close()
def authenticate(self, permissions=("miners.replace_control_board",), role="Technician"):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(self.actor), "role": role,
"permissions": list(permissions),
}
def post(self, body=None):
return self.client.post(
f"/api/miners/{self.miner}/replace-control-board",
json=(request().model_dump(mode="json") if body is None else body),
)
def test_authentication_and_permission_are_required(self):
self.assertEqual(self.post().status_code, 401)
self.authenticate(permissions=("miners.update",))
self.assertEqual(self.post().status_code, 403)
def test_success_uses_typed_envelope_and_authenticated_actor(self):
self.authenticate()
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=4,
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
replaced_at=NOW, replaced_by=self.actor,
)
response = self.post()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["miner_id"], str(self.miner))
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
self.assertEqual(self.service.actor, self.actor)
def test_validation_and_domain_error_mapping(self):
self.authenticate()
self.assertEqual(self.post({"new_mac_address": "bad"}).status_code, 422)
self.service.error = RepairMinerNotFound(self.miner)
self.assertEqual(self.post().status_code, 404)
self.service.error = ControlBoardReplacementConflict("conflict")
self.assertEqual(self.post().status_code, 409)
self.service.error = InvalidMacAddress()
self.assertEqual(self.post().status_code, 422)
def test_route_and_permission_mapping_are_exact(self):
routes = [
(route.path, route.methods) for route in control_board_router.routes
if hasattr(route, "methods")
]
self.assertEqual(routes, [
("/api/miners/{miner_id}/replace-control-board", {"POST"})
])
path = f"/api/miners/{self.miner}/replace-control-board"
self.assertEqual(
required_api_permission("POST", path),
"miners.replace_control_board",
)
def test_allowed_roles_and_read_only_matrix(self):
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=2,
current_mac="02:AA:BB:CC:DD:02", control_board_serial=None,
replaced_at=NOW, replaced_by=self.actor,
)
for role in ("Admin", "Manager", "Technician"):
with self.subTest(role=role):
self.authenticate(role=role)
self.assertEqual(self.post().status_code, 200)
self.authenticate(permissions=(), role="Read Only")
self.assertEqual(self.post().status_code, 403)
def test_request_contract_rejects_invalid_shapes(self):
self.authenticate()
valid = request().model_dump(mode="json")
invalid_bodies = []
missing_serial = dict(valid)
missing_serial.pop("new_control_board_serial")
invalid_bodies.append(missing_serial)
for field, value in (
("new_mac_address", ""),
("notes", "x" * 4001),
("replacement_reason", "invented"),
("removed_board_disposition", "invented"),
):
body = dict(valid)
body[field] = value
invalid_bodies.append(body)
unknown = dict(valid)
unknown["created_by"] = str(self.actor)
invalid_bodies.append(unknown)
for body in invalid_bodies:
with self.subTest(body=body):
self.assertEqual(self.post(body).status_code, 422)
def test_router_has_no_sql_driver_or_transaction_management(self):
path = Path(__file__).parents[1] / "app" / "repairs" / "router.py"
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules))
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", ".COMMIT(", ".ROLLBACK("):
self.assertNotIn(keyword, source.upper())
class LegacyChangeMacCompatibilityTests(unittest.TestCase):
def setUp(self):
from app import main as main_module
self.main = main_module
self.service = StubReplacementService()
self.actor = uuid4()
self.miner = uuid4()
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=3,
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
replaced_at=NOW, replaced_by=self.actor,
)
def legacy_request(self, **overrides):
values = {
"new_mac": "02:AA:BB:CC:DD:02",
"component_serial": "CB-NEW",
"description": "Replaced during repair.",
"replacement_reason": "failed",
"removed_board_disposition": "sent_for_repair",
}
values.update(overrides)
return self.main.MinerMacChangeRequest(**values)
def call(self, legacy_request):
with patch.object(self.main, "RepairRepository"), patch.object(
self.main, "RepairService", return_value=self.service
):
return self.main.change_miner_mac_address(
str(self.miner), legacy_request, {"id": str(self.actor)}
)
def test_alias_uses_same_permission_and_domain_workflow(self):
path = f"/api/miners/{self.miner}/change-mac"
self.assertEqual(
required_api_permission("POST", path),
"miners.replace_control_board",
)
response = self.call(self.legacy_request())
self.assertEqual(self.service.actor, self.actor)
self.assertEqual(self.service.request.replacement_reason, "failed")
self.assertEqual(
self.service.request.removed_board_disposition, "sent_for_repair"
)
self.assertEqual(
response["data"]["miner"]["current_mac"],
"02:AA:BB:CC:DD:02",
)
self.assertIn("control_board_replacement", response["data"])
self.assertNotIn("maintenance_event", response["data"])
def test_alias_does_not_fabricate_required_workflow_fields(self):
for changes in (
{"replacement_reason": None},
{"removed_board_disposition": None},
{"new_ip": "192.0.2.4"},
{"downtime_minutes": 10},
):
with self.subTest(changes=changes), self.assertRaises(HTTPException) as caught:
self.call(self.legacy_request(**changes))
self.assertEqual(caught.exception.status_code, 422)
def test_alias_maps_domain_conflicts_without_internal_error(self):
self.service.error = ControlBoardReplacementConflict("Miner has no active repair")
with self.assertRaises(HTTPException) as caught:
self.call(self.legacy_request())
self.assertEqual(caught.exception.status_code, 409)
class ControlBoardReplacementDatabaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = RepairRepository()
def setUp(self):
self.actor = uuid4()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
cur.execute(
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("DELETE FROM repair_events")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, 'Board Test Actor', 'unused', id
FROM roles WHERE name = 'Admin'
""",
(self.actor, f"board-test-{self.actor}"),
)
def tearDown(self):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
cur.execute(
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("DELETE FROM repair_events")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
def create_repairing_miner(self, mac_address, serial):
asset_id = f"BOARD-{uuid4()}"
with self.repository._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miners (
asset_tag, current_mac, control_board_sn, status,
lifecycle_status, first_seen_at, last_seen_at
) VALUES (%s, %s, %s, 'unknown', 'active', NULL, NULL)
RETURNING id
""",
(asset_id, mac_address, serial),
)
miner_id = cur.fetchone()["id"]
repair = RepairService(self.repository).begin_repair(
BeginRepairRequest(miner_id=miner_id, reason="CONTROL_BOARD"),
actor_user_id=self.actor,
)
return miner_id, asset_id, repair.repair_id
def replace(self, miner_id, mac_address="02:AA:BB:CC:EE:02"):
return RepairService(self.repository).replace_control_board(
miner_id, request(new_mac_address=mac_address),
actor_user_id=self.actor,
)
def fetchall(self, sql, values=()):
with self.repository._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(sql, values)
return [dict(row) for row in cur.fetchall()]
def test_database_success_preserves_identity_and_authoritative_history(self):
miner_id, asset_id, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EE:01", "CB-OLD"
)
result = self.replace(miner_id)
miner = self.fetchall(
"SELECT * FROM miners WHERE id = %s", (miner_id,)
)[0]
boards = self.fetchall(
"SELECT * FROM miner_control_board_installations "
"WHERE miner_id = %s ORDER BY created_at, id", (miner_id,)
)
identities = self.fetchall(
"SELECT * FROM miner_network_identities "
"WHERE miner_id = %s ORDER BY is_current, mac_address", (miner_id,)
)
events = self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
)
self.assertEqual(result.miner_id, miner_id)
self.assertEqual(miner["asset_tag"], asset_id)
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EE:02")
self.assertEqual(len(boards), 2)
self.assertEqual(sum(row["removed_at"] is None for row in boards), 1)
legacy = next(row for row in boards if row["record_origin"] == "legacy_projection")
current = next(row for row in boards if row["removed_at"] is None)
self.assertIsNone(legacy["installed_at"])
self.assertIsNone(legacy["installed_by"])
self.assertEqual(current["record_origin"], "replacement")
self.assertEqual(current["installed_by"], self.actor)
self.assertEqual(current["normalized_mac"], "02aabbccee02")
self.assertEqual(len(identities), 2)
self.assertEqual(sum(row["is_current"] for row in identities), 1)
self.assertEqual(
next(row for row in identities if row["is_current"])["mac_address"],
"02:AA:BB:CC:EE:02",
)
self.assertTrue(any(row["mac_address"] == "02:AA:BB:CC:EE:01" for row in identities))
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["created_by"], self.actor)
self.assertEqual(events[0]["sequence"], result.event_sequence)
def test_database_allows_omitted_optional_notes(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EA:01", "CB-OLD"
)
RepairService(self.repository).replace_control_board(
miner_id,
request(new_mac_address="02:AA:BB:CC:EA:02", notes=None),
actor_user_id=self.actor,
)
event = self.fetchall(
"SELECT payload FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'",
(repair_id,),
)[0]
self.assertIn("notes", event["payload"])
self.assertIsNone(event["payload"]["notes"])
def test_database_constraints_prevent_drift_and_history_mutation(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:ED:01", "CB-OLD"
)
self.replace(miner_id, "02:AA:BB:CC:ED:02")
with self.assertRaises(UniqueViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, board_serial
) VALUES (%s, 'legacy_projection', 'SECOND')
""",
(miner_id,),
)
with self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET control_board_sn = 'DRIFT' "
"WHERE id = %s",
(miner_id,),
)
with self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"UPDATE miner_network_identities SET is_current = false "
"WHERE miner_id = %s AND is_current",
(miner_id,),
)
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_network_identities "
"WHERE miner_id = %s",
(miner_id,),
)
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_control_board_installations "
"WHERE miner_id = %s",
(miner_id,),
)
for statement in (
"UPDATE miner_mac_ownerships SET miner_id = gen_random_uuid() "
"WHERE normalized_mac = '02aabbcced02'",
"DELETE FROM miner_mac_ownerships "
"WHERE normalized_mac = '02aabbcced02'",
):
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(statement)
with self.assertRaises(UniqueViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
)
SELECT repair_id, sequence, event_type, created_by, payload
FROM repair_events
WHERE repair_id = %s ORDER BY sequence LIMIT 1
""",
(repair_id,),
)
def test_database_rejects_prohibited_component_mac(self):
miner_id, _, _ = self.create_repairing_miner(
"02:AA:BB:CC:EC:01", "CB-OLD"
)
for mac_address, normalized_mac in (
("01:00:5E:00:00:01", "01005e000001"),
("00:00:00:00:00:00", "000000000000"),
("FF:FF:FF:FF:FF:FF", "ffffffffffff"),
("02-AA-BB-CC-EC-02", "02aabbccec02"),
):
with self.subTest(mac_address=mac_address), self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, mac_address, normalized_mac
) VALUES (%s, 'legacy_projection', %s, %s)
""",
(miner_id, mac_address, normalized_mac),
)
def test_event_failure_rolls_back_every_replacement_write(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EF:01", "CB-OLD"
)
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
CREATE FUNCTION test_fail_board_event() RETURNS trigger AS $$
BEGIN
IF NEW.event_type = 'CONTROL_BOARD_REPLACED' THEN
RAISE EXCEPTION 'forced replacement event failure';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql
"""
)
cur.execute(
"CREATE TRIGGER test_board_event_failure BEFORE INSERT ON "
"repair_events FOR EACH ROW EXECUTE FUNCTION test_fail_board_event()"
)
with self.assertRaises(RaiseException):
self.replace(miner_id, "02:AA:BB:CC:EF:02")
miner = self.fetchall("SELECT * FROM miners WHERE id = %s", (miner_id,))[0]
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EF:01")
self.assertEqual(self.fetchall(
"SELECT * FROM miner_control_board_installations WHERE miner_id = %s",
(miner_id,),
), [])
self.assertEqual(self.fetchall(
"SELECT * FROM miner_network_identities WHERE miner_id = %s",
(miner_id,),
), [])
self.assertEqual(len(self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s", (repair_id,)
)), 1)
owners = self.fetchall(
"SELECT normalized_mac FROM miner_mac_ownerships WHERE miner_id = %s",
(miner_id,),
)
self.assertEqual(owners, [{"normalized_mac": "02aabbccef01"}])
def test_concurrent_replacements_on_one_miner_succeed_once(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:F0:01", "CB-OLD"
)
barrier = Barrier(2)
def replace(mac_address):
barrier.wait()
return self.replace(miner_id, mac_address)
outcomes = []
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(replace, "02:AA:BB:CC:F0:02"),
executor.submit(replace, "02:AA:BB:CC:F0:04"),
]
for future in futures:
try:
outcomes.append(future.result(timeout=15))
except ControlBoardReplacementConflict as exc:
outcomes.append(exc)
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(len(self.fetchall(
"SELECT * FROM miner_control_board_installations "
"WHERE miner_id = %s AND removed_at IS NULL", (miner_id,)
)), 1)
self.assertEqual(len(self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
)), 1)
def test_two_miners_cannot_concurrently_claim_one_mac(self):
first, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:01", "CB-1")
second, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:02", "CB-2")
barrier = Barrier(2)
def replace(miner_id):
barrier.wait()
return self.replace(miner_id, "02:AA:BB:CC:F1:04")
outcomes = []
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(replace, first), executor.submit(replace, second)]
for future in futures:
try:
outcomes.append(future.result(timeout=15))
except ControlBoardReplacementConflict as exc:
outcomes.append(exc)
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
owner = self.fetchall(
"SELECT miner_id FROM miner_mac_ownerships "
"WHERE normalized_mac = '02aabbccf104'"
)
self.assertEqual(len(owner), 1)
if __name__ == "__main__":
unittest.main()