From bcf1b4e3cfd39a7002e18e2a4b68534e81ed5815 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 14:34:16 +0000 Subject: [PATCH] feat: add control board replacement workflow --- .../0013_control_board_replacement.py | 436 ++++++++++ backend/app/identity.py | 9 + backend/app/main.py | 340 ++------ backend/app/repairs/exceptions.py | 28 + backend/app/repairs/models.py | 66 ++ backend/app/repairs/repository.py | 259 +++++- backend/app/repairs/router.py | 37 + backend/app/repairs/service.py | 157 ++++ .../tests/test_control_board_replacement.py | 804 ++++++++++++++++++ docs/ROADMAP.md | 11 +- docs/releases/v0.2-repair-workflow.md | 12 +- ...est-control-board-replacement-migration.sh | 131 +++ scripts/test-control-board-replacement.sh | 35 + scripts/test-dev-baseline.sh | 68 +- scripts/test-return-to-service-migration.sh | 6 +- sql/verify-development-schema.sql | 2 + 16 files changed, 2122 insertions(+), 279 deletions(-) create mode 100644 backend/alembic/versions/0013_control_board_replacement.py create mode 100644 backend/tests/test_control_board_replacement.py create mode 100755 scripts/test-control-board-replacement-migration.sh create mode 100755 scripts/test-control-board-replacement.sh diff --git a/backend/alembic/versions/0013_control_board_replacement.py b/backend/alembic/versions/0013_control_board_replacement.py new file mode 100644 index 0000000..fc5600f --- /dev/null +++ b/backend/alembic/versions/0013_control_board_replacement.py @@ -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'") diff --git a/backend/app/identity.py b/backend/app/identity.py index eabf514..4a734e0 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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): diff --git a/backend/app/main.py b/backend/app/main.py index 8f17469..e61915d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index 65ac36c..03142b4 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -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" + ) diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index 5ae1cb4..1d4b990 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index dd20ba1..3b7f35f 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -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: diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index 7f010ca..7f20261 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -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( diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index 648036c..3beb1cb 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -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, diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py new file mode 100644 index 0000000..f571c99 --- /dev/null +++ b/backend/tests/test_control_board_replacement.py @@ -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() diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c681da1..0cf013c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,11 +99,12 @@ durable asset record. Repairs, maintenance, work orders, parts usage, equipment movement, testing, and return-to-service history become coherent operational records rather than disconnected actions. -The Begin Repair, read-only Repair Timeline, and Return to Service workflows are -complete. An authorized actor can open a repair against a durable miner UUID, -record sequenced events, and move the miner into or out of repair atomically. -Authorized readers can inspect the repair and its ordered immutable events. -Later repair lifecycle workflows remain planned. +The Begin Repair, read-only Repair Timeline, Return to Service, and Control Board +Replacement workflows are complete. An authorized actor can open a repair +against a durable miner UUID, record sequenced events, move the miner into or out +of repair atomically, and replace a control board without changing the durable +asset identity. Authorized readers can inspect the repair and its ordered +immutable events. Later repair lifecycle workflows remain planned. Technician experience is a priority. Responsive and QR-assisted workflows may reduce lookup and data-entry effort where they reinforce the same asset record. diff --git a/docs/releases/v0.2-repair-workflow.md b/docs/releases/v0.2-repair-workflow.md index d185fce..2f76d6d 100644 --- a/docs/releases/v0.2-repair-workflow.md +++ b/docs/releases/v0.2-repair-workflow.md @@ -16,10 +16,10 @@ and immutable history. - [x] Begin Repair (`POST /api/repairs`) - [x] Repair detail and timeline read APIs - [x] Return to Service (`POST /api/repairs/{repair_id}/complete`) +- [x] Control Board Replacement ## Planned -- [ ] Control Board Replacement - [ ] Repair Parts Tracking These planned items refer to explicit workflows in the modular Repairs domain. @@ -39,11 +39,9 @@ not make these workflows complete. - operator behavior is documented - the release candidate is built from a clean, reproducible repository state -## Candidate Task Order +## Remaining Task -1. `004-control-board-replacement.md` -2. `005-repair-parts-tracking.md` +1. `005-repair-parts-tracking.md` -These are proposed future files under `docs/tasks/`. They do not exist yet and -must not be treated as approved specifications until they are created and -reviewed. +This task is specified under `docs/tasks/`, but remains a planned workflow and +must not be treated as implemented. diff --git a/scripts/test-control-board-replacement-migration.sh b/scripts/test-control-board-replacement-migration.sh new file mode 100755 index 0000000..1645a64 --- /dev/null +++ b/scripts/test-control-board-replacement-migration.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_CONTROL_BOARD_MIGRATION_PROJECT:-minerops-control-board-migration-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT +run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; } + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || exit 1 + sleep 1 +done +"${compose[@]}" build backend + +run_alembic upgrade 0012_return_to_service +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO users (id, username, display_name, password_hash, role_id) +SELECT '00000000-0000-0000-0000-000000001401', 'board-migration', + 'Board Migration', 'unused', id FROM roles WHERE name = 'Admin'; +INSERT INTO miners ( + id, asset_tag, current_mac, control_board_sn, lifecycle_status, + status, first_seen_at, last_seen_at +) VALUES ( + '00000000-0000-0000-0000-000000001402', 'BOARD-LEGACY', + '02-AA-BB-CC-DD-01', 'LEGACY-BOARD', 'active', 'unknown', NULL, NULL +); +SQL + +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +INSERT INTO miners ( + id, asset_tag, current_mac, lifecycle_status, status, + first_seen_at, last_seen_at +) VALUES ( + '00000000-0000-0000-0000-000000001404', 'BOARD-CONFLICT', + '02:AA:BB:CC:DD:01', 'active', 'unknown', NULL, NULL +); +SQL +if preflight_error="$(run_alembic upgrade head 2>&1)"; then + echo "Duplicate normalized MAC preflight did not stop the upgrade." >&2 + exit 1 +fi +[[ "${preflight_error}" == *"normalized MAC values have multiple miner owners"* ]] +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +DELETE FROM miners WHERE id = '00000000-0000-0000-0000-000000001404'; +SQL + +run_alembic upgrade head +[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]] +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +DO $$ +DECLARE roles_for_permission text[]; +BEGIN + IF to_regclass('public.miner_control_board_installations') IS NULL + OR to_regclass('public.miner_mac_ownerships') IS NULL THEN + RAISE EXCEPTION 'Task 004 tables are missing'; + END IF; + IF EXISTS (SELECT 1 FROM miner_control_board_installations) THEN + RAISE EXCEPTION 'Migration fabricated installation history'; + END IF; + SELECT array_agg(role.name ORDER BY role.name) INTO roles_for_permission + FROM role_permissions rp JOIN roles role ON role.id = rp.role_id + JOIN permissions permission ON permission.id = rp.permission_id + WHERE permission.key = 'miners.replace_control_board'; + IF roles_for_permission <> ARRAY['Admin', 'Manager', 'Technician'] THEN + RAISE EXCEPTION 'Unexpected permission roles: %', roles_for_permission; + END IF; + IF (SELECT miner_id FROM miner_mac_ownerships WHERE normalized_mac = '02aabbccdd01') + <> '00000000-0000-0000-0000-000000001402'::uuid THEN + RAISE EXCEPTION 'Legacy MAC ownership was not preserved'; + END IF; +END $$; +SQL + +run_alembic downgrade 0012_return_to_service +run_alembic upgrade head + +"${compose[@]}" exec -T postgres sh -ec \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' +UPDATE miners +SET lifecycle_status = 'in_repair', current_mac = '02:AA:BB:CC:DD:02', + control_board_sn = 'NEW-BOARD' +WHERE id = '00000000-0000-0000-0000-000000001402'; +INSERT INTO miner_network_identities ( + miner_id, mac_address, source, is_current, first_seen_at, last_seen_at, associated_at +) VALUES ( + '00000000-0000-0000-0000-000000001402', '02:AA:BB:CC:DD:02', + 'control_board_replacement', true, NULL, NULL, now() +); +INSERT INTO repairs (id, miner_id, reason, opened_by) VALUES ( + '00000000-0000-0000-0000-000000001403', + '00000000-0000-0000-0000-000000001402', 'CONTROL_BOARD', + '00000000-0000-0000-0000-000000001401' +); +INSERT INTO miner_control_board_installations ( + miner_id, record_origin, installed_repair_id, mac_address, normalized_mac, board_serial, + replacement_reason, installed_at, installed_by +) VALUES ( + '00000000-0000-0000-0000-000000001402', 'replacement', + '00000000-0000-0000-0000-000000001403', '02:AA:BB:CC:DD:02', + '02aabbccdd02', 'NEW-BOARD', 'failed', now(), + '00000000-0000-0000-0000-000000001401' +); +SQL +if downgrade_error="$(run_alembic downgrade 0012_return_to_service 2>&1)"; then + echo "Downgrade discarded authoritative control-board history." >&2 + exit 1 +fi +[[ "${downgrade_error}" == *"authoritative installation records or events"* ]] +[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]] + +database="minerops_control_board_empty" +"${compose[@]}" exec -T postgres sh -ec \ + 'createdb -U "$POSTGRES_USER" '"${database}" +"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic upgrade head +[[ "$("${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]] diff --git a/scripts/test-control-board-replacement.sh b/scripts/test-control-board-replacement.sh new file mode 100755 index 0000000..fa3f769 --- /dev/null +++ b/scripts/test-control-board-replacement.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +project_name="${MINEROPS_CONTROL_BOARD_TEST_PROJECT:-minerops-control-board-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 120 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest -v tests.test_control_board_replacement +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + backend python -m unittest discover -v -s tests diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 379b887..323a8a4 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -62,8 +62,52 @@ fi echo "Validating Compose configuration..." MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet -echo "Building and starting disposable development stack..." -MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build +echo "Starting disposable data services..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach postgres redis + +echo "Waiting for PostgreSQL initialization and Redis..." +for attempt in $(seq 1 180); do + postgres_ready=false + redis_ready=false + if "${compose[@]}" exec -T postgres sh -ec \ + 'test "$(cat /proc/1/comm)" = "postgres"; psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "SELECT 1"' \ + 2>/dev/null | grep -qx '1'; then + postgres_ready=true + fi + if "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx 'PONG'; then + redis_ready=true + fi + if [[ "${postgres_ready}" == "true" && "${redis_ready}" == "true" ]]; then + break + fi + if (( attempt == 180 )); then + echo "Disposable data services did not become ready within 180 seconds." >&2 + exit 1 + fi + sleep 1 +done + +echo "Building and starting the disposable backend..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build --no-deps backend +backend_container="$("${compose[@]}" ps -q backend)" +for attempt in $(seq 1 180); do + backend_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${backend_container}")" + if [[ "${backend_health}" == "healthy" ]]; then + break + fi + if [[ "${backend_health}" == "exited" || "${backend_health}" == "dead" ]]; then + echo "Disposable backend exited before becoming healthy." >&2 + exit 1 + fi + if (( attempt == 180 )); then + echo "Disposable backend did not become healthy within 180 seconds." >&2 + exit 1 + fi + sleep 1 +done + +echo "Starting the disposable gateway..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --no-deps gateway echo "Waiting for the gateway health endpoint..." health_file="${tmp_prefix}-health.json" @@ -88,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0012_return_to_service"* ]] +[[ "${current_revision}" == *"0013_control_board_replacement"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -168,6 +212,8 @@ echo "Checking Begin Repair permission and workflow..." INSERT INTO miners ( id, asset_tag, + current_mac, + control_board_sn, status, lifecycle_status, first_seen_at, @@ -176,6 +222,8 @@ INSERT INTO miners ( VALUES ( '00000000-0000-0000-0000-000000000981', 'SMOKE-REPAIR-001', + '02:AA:BB:CC:EE:01', + 'SMOKE-CB-OLD', 'unknown', 'active', NULL, @@ -202,19 +250,31 @@ smoke_repair_id="$( | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])' )" [[ -n "${smoke_repair_id}" ]] +[[ "$( + status_for "${readonly_cookie}" POST /api/miners/00000000-0000-0000-0000-000000000981/replace-control-board \ + '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed"}' +)" == "403" ]] +board_response="$(curl --fail --silent -b "${technician_cookie}" \ + -H 'Content-Type: application/json' \ + --data '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed","notes":"Smoke replacement."}' \ + "${base_url}/api/miners/00000000-0000-0000-0000-000000000981/replace-control-board")" +[[ "${board_response}" == *'"current_mac":"02:AA:BB:CC:EE:02"'* ]] +[[ "${board_response}" == *'"event_sequence":3'* ]] readonly_repair_detail="$( curl --fail --silent -b "${readonly_cookie}" \ "${base_url}/api/repairs/${smoke_repair_id}" )" [[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]] [[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]] -[[ "${readonly_repair_detail}" == *'"latest_event_sequence":2'* ]] +[[ "${readonly_repair_detail}" == *'"latest_event_sequence":3'* ]] readonly_repair_events="$( curl --fail --silent -b "${readonly_cookie}" \ "${base_url}/api/repairs/${smoke_repair_id}/events" )" [[ "${readonly_repair_events}" == *'"sequence":1'* ]] [[ "${readonly_repair_events}" == *'"sequence":2'* ]] +[[ "${readonly_repair_events}" == *'"sequence":3'* ]] +[[ "${readonly_repair_events}" == *'"event_type":"CONTROL_BOARD_REPLACED"'* ]] [[ "${readonly_repair_events}" == *'"event_type":"REPAIR_STARTED"'* ]] [[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]] [[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]] diff --git a/scripts/test-return-to-service-migration.sh b/scripts/test-return-to-service-migration.sh index 964cf70..ab4ea0d 100755 --- a/scripts/test-return-to-service-migration.sh +++ b/scripts/test-return-to-service-migration.sh @@ -96,7 +96,7 @@ INSERT INTO repairs ( SQL echo "Upgrading previous head to Return to Service..." -run_alembic upgrade head +run_alembic upgrade 0012_return_to_service current_revision="$(run_alembic current 2>/dev/null)" [[ "${current_revision}" == *"0012_return_to_service"* ]] @@ -244,7 +244,7 @@ SQL [[ "${downgrade_state}" == "t" ]] downgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${downgraded_revision}" == *"0011_repair_timeline"* ]] -run_alembic upgrade head +run_alembic upgrade 0012_return_to_service reupgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${reupgraded_revision}" == *"0012_return_to_service"* ]] @@ -284,7 +284,7 @@ echo "Migrating an empty database from base through head..." -c "CREATE DATABASE minerops_return_empty"' >/dev/null "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ - backend alembic upgrade head + backend alembic upgrade 0012_return_to_service empty_revision="$( "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index ba67376..5887ce5 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -9,6 +9,8 @@ WITH expected(table_name) AS ( ('miner_asset_id_history'), ('repairs'), ('repair_events'), + ('miner_control_board_installations'), + ('miner_mac_ownerships'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'),