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
@@ -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'")
|
||||
@@ -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
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user