feat: add control board replacement workflow
This commit is contained in:
1 parent
62c91a2c3e
commit
bcf1b4e3cf
16 files changed
+2122
-279
No files matched your search
@@ -0,0 +1,436 @@
|
|||||||
|
"""add control board replacement workflow
|
||||||
|
|
||||||
|
Revision ID: 0013_control_board_replacement
|
||||||
|
Revises: 0012_return_to_service
|
||||||
|
Create Date: 2026-08-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0013_control_board_replacement"
|
||||||
|
down_revision: Union[str, None] = "0012_return_to_service"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
REPAIR_REASONS = (
|
||||||
|
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
|
||||||
|
"PSU", "FAN", "OVERHEATING", "NETWORKING",
|
||||||
|
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
|
||||||
|
)
|
||||||
|
REPLACEMENT_REASONS = (
|
||||||
|
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
|
||||||
|
"temporary_donor", "unknown",
|
||||||
|
)
|
||||||
|
DISPOSITIONS = (
|
||||||
|
"repair_inventory", "sent_for_repair", "scrapped",
|
||||||
|
"retained_for_diagnosis", "returned_to_vendor", "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted(values: Sequence[str]) -> str:
|
||||||
|
return ", ".join(f"'{value}'" for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_event_constraints(include_replacement: bool) -> None:
|
||||||
|
event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"]
|
||||||
|
if include_replacement:
|
||||||
|
event_types.append("CONTROL_BOARD_REPLACED")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_type",
|
||||||
|
"repair_events",
|
||||||
|
f"event_type IN ({_quoted(event_types)})",
|
||||||
|
)
|
||||||
|
replacement_clause = ""
|
||||||
|
if include_replacement:
|
||||||
|
replacement_clause = (
|
||||||
|
") OR (event_type = 'CONTROL_BOARD_REPLACED' "
|
||||||
|
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
|
||||||
|
"'old_control_board_serial', 'new_control_board_serial', "
|
||||||
|
"'replacement_reason', 'removed_board_disposition'] "
|
||||||
|
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
|
||||||
|
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
|
||||||
|
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
||||||
|
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
|
||||||
|
"NOT IN ('000000000000', 'ffffffffffff') "
|
||||||
|
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
|
||||||
|
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
|
||||||
|
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
|
||||||
|
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
|
||||||
|
"AND trim(payload ->> 'old_control_board_serial') <> '' "
|
||||||
|
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
|
||||||
|
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
|
||||||
|
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
|
||||||
|
"AND trim(payload ->> 'new_control_board_serial') <> '' "
|
||||||
|
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
|
||||||
|
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
|
||||||
|
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
|
||||||
|
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
|
||||||
|
"jsonb_typeof(payload -> 'notes') = 'string' "
|
||||||
|
"AND trim(payload ->> 'notes') <> '' "
|
||||||
|
"AND char_length(payload ->> 'notes') <= 4000))"
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_repair_events_payload_shape",
|
||||||
|
"repair_events",
|
||||||
|
"(event_type = 'REPAIR_STARTED' "
|
||||||
|
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
|
||||||
|
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
|
||||||
|
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
|
||||||
|
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
|
||||||
|
"event_type = 'INITIAL_NOTE' "
|
||||||
|
"AND jsonb_typeof(payload -> 'note') = 'string' "
|
||||||
|
"AND trim(payload ->> 'note') <> '') OR ("
|
||||||
|
"event_type = 'REPAIR_COMPLETED' "
|
||||||
|
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
|
||||||
|
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
|
||||||
|
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
|
||||||
|
"AND (NOT payload ? 'pre_repair_lifecycle' "
|
||||||
|
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
|
||||||
|
"AND (NOT payload ? 'completion_summary' OR ("
|
||||||
|
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
|
||||||
|
"AND trim(payload ->> 'completion_summary') <> '' "
|
||||||
|
"AND char_length(payload ->> 'completion_summary') <= 4000))"
|
||||||
|
f"{replacement_clause})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
DECLARE conflicts TEXT;
|
||||||
|
BEGIN
|
||||||
|
WITH identities AS (
|
||||||
|
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac,
|
||||||
|
miner_id
|
||||||
|
FROM miner_network_identities
|
||||||
|
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
|
||||||
|
UNION ALL
|
||||||
|
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id
|
||||||
|
FROM miners
|
||||||
|
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12
|
||||||
|
), duplicate_owners AS (
|
||||||
|
SELECT mac
|
||||||
|
FROM identities
|
||||||
|
GROUP BY mac
|
||||||
|
HAVING count(DISTINCT miner_id) > 1
|
||||||
|
)
|
||||||
|
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners;
|
||||||
|
IF conflicts IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts,
|
||||||
|
HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts
|
||||||
|
FROM (
|
||||||
|
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac
|
||||||
|
FROM miner_network_identities
|
||||||
|
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
|
||||||
|
GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) AS duplicate_identity_mac;
|
||||||
|
IF conflicts IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts,
|
||||||
|
HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text)
|
||||||
|
INTO conflicts
|
||||||
|
FROM (
|
||||||
|
SELECT miner_id FROM miner_network_identities
|
||||||
|
WHERE is_current GROUP BY miner_id HAVING count(*) > 1
|
||||||
|
) AS duplicate_current;
|
||||||
|
IF conflicts IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts,
|
||||||
|
HINT = 'Choose one current network identity for each listed miner before upgrading.';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO permissions (key, description)
|
||||||
|
VALUES ('miners.replace_control_board', 'Replace miner control boards during repair')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id FROM roles
|
||||||
|
JOIN permissions ON permissions.key = 'miners.replace_control_board'
|
||||||
|
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"miner_network_identities",
|
||||||
|
sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.alter_column("miner_network_identities", "first_seen_at", nullable=True)
|
||||||
|
op.alter_column("miner_network_identities", "last_seen_at", nullable=True)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac
|
||||||
|
ON miner_network_identities (
|
||||||
|
regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
|
||||||
|
)
|
||||||
|
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12;
|
||||||
|
CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner
|
||||||
|
ON miner_network_identities (miner_id) WHERE is_current;
|
||||||
|
CREATE UNIQUE INDEX uq_miners_current_normalized_mac
|
||||||
|
ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'))
|
||||||
|
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12;
|
||||||
|
ALTER TABLE miner_network_identities
|
||||||
|
ADD CONSTRAINT ck_miner_network_identities_mac_valid
|
||||||
|
CHECK (
|
||||||
|
source NOT IN ('control_board_replacement', 'legacy_projection') OR (
|
||||||
|
mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$'
|
||||||
|
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff')
|
||||||
|
AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0
|
||||||
|
)
|
||||||
|
) NOT VALID;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"miner_mac_ownerships",
|
||||||
|
sa.Column("normalized_mac", sa.Text(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"miner_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"claimed_by", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"normalized_mac ~ '^[0-9a-f]{12}$' "
|
||||||
|
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
|
||||||
|
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0",
|
||||||
|
name="ck_miner_mac_ownerships_valid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"])
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id)
|
||||||
|
SELECT mac, min(miner_id::text)::uuid FROM (
|
||||||
|
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id
|
||||||
|
FROM miner_network_identities
|
||||||
|
UNION ALL
|
||||||
|
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners
|
||||||
|
) AS known
|
||||||
|
WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff')
|
||||||
|
AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0
|
||||||
|
GROUP BY mac;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"miner_control_board_installations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("record_origin", sa.Text(), nullable=False),
|
||||||
|
sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
|
||||||
|
sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
|
||||||
|
sa.Column("mac_address", sa.Text(), nullable=True),
|
||||||
|
sa.Column("normalized_mac", sa.Text(), nullable=True),
|
||||||
|
sa.Column("board_serial", sa.Text(), nullable=True),
|
||||||
|
sa.Column("board_model", sa.Text(), nullable=True),
|
||||||
|
sa.Column("board_revision", sa.Text(), nullable=True),
|
||||||
|
sa.Column("firmware_version", sa.Text(), nullable=True),
|
||||||
|
sa.Column("replacement_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
|
||||||
|
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
|
||||||
|
sa.Column("removed_disposition", sa.Text(), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"normalized_mac IS NULL OR ("
|
||||||
|
"mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
|
||||||
|
"AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac "
|
||||||
|
"AND normalized_mac ~ '^[0-9a-f]{12}$' "
|
||||||
|
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
|
||||||
|
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)",
|
||||||
|
name="ck_control_board_installations_mac_normalized",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"),
|
||||||
|
sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"),
|
||||||
|
sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"),
|
||||||
|
sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"),
|
||||||
|
sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"),
|
||||||
|
sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"),
|
||||||
|
sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"),
|
||||||
|
sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"),
|
||||||
|
sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(record_origin = 'legacy_projection' AND installed_repair_id IS NULL "
|
||||||
|
"AND installed_at IS NULL AND installed_by IS NULL "
|
||||||
|
"AND replacement_reason IS NULL) OR "
|
||||||
|
"(record_origin = 'replacement' AND installed_repair_id IS NOT NULL "
|
||||||
|
"AND installed_at IS NOT NULL AND installed_by IS NOT NULL "
|
||||||
|
"AND replacement_reason IS NOT NULL)",
|
||||||
|
name="ck_control_board_installations_install_state",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL"))
|
||||||
|
op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL"))
|
||||||
|
op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"])
|
||||||
|
op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"])
|
||||||
|
op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"])
|
||||||
|
op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"])
|
||||||
|
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb;
|
||||||
|
BEGIN
|
||||||
|
row_data := to_jsonb(NEW);
|
||||||
|
mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g');
|
||||||
|
target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
|
||||||
|
IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at)
|
||||||
|
VALUES (mac, target_owner, now())
|
||||||
|
ON CONFLICT (normalized_mac) DO NOTHING;
|
||||||
|
SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE;
|
||||||
|
IF existing_owner <> (target_owner) THEN
|
||||||
|
RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
|
||||||
|
CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
|
||||||
|
CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership();
|
||||||
|
|
||||||
|
|
||||||
|
CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
RAISE EXCEPTION 'Network identity history is immutable';
|
||||||
|
END IF;
|
||||||
|
IF NEW.miner_id <> OLD.miner_id
|
||||||
|
OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g')
|
||||||
|
<> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN
|
||||||
|
RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history();
|
||||||
|
|
||||||
|
|
||||||
|
CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE target_miner uuid; mismatch boolean; row_data jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
row_data := to_jsonb(OLD);
|
||||||
|
ELSE
|
||||||
|
row_data := to_jsonb(NEW);
|
||||||
|
END IF;
|
||||||
|
target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM miner_control_board_installations
|
||||||
|
WHERE miner_id = target_miner
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM miner_control_board_installations board
|
||||||
|
JOIN miners miner ON miner.id = board.miner_id
|
||||||
|
JOIN miner_network_identities identity
|
||||||
|
ON identity.miner_id = board.miner_id AND identity.is_current
|
||||||
|
WHERE board.miner_id = target_miner AND board.removed_at IS NULL
|
||||||
|
AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
|
||||||
|
AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial
|
||||||
|
AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
|
||||||
|
) INTO mismatch;
|
||||||
|
IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END $$;
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
|
||||||
|
|
||||||
|
CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF;
|
||||||
|
IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF;
|
||||||
|
IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN
|
||||||
|
RAISE EXCEPTION 'Control-board installation identity is immutable';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||||
|
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||||
|
_create_event_constraints(True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DO $$ DECLARE history_count bigint; BEGIN
|
||||||
|
SELECT count(*) INTO history_count FROM miner_control_board_installations;
|
||||||
|
history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED');
|
||||||
|
IF history_count > 0 THEN
|
||||||
|
RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN
|
||||||
|
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL)
|
||||||
|
OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN
|
||||||
|
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
|
||||||
|
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
|
||||||
|
_create_event_constraints(False)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations;
|
||||||
|
DROP FUNCTION minerops_protect_control_board_history();
|
||||||
|
DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations;
|
||||||
|
DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities;
|
||||||
|
DROP TRIGGER trg_miners_check_current_board_projection ON miners;
|
||||||
|
DROP FUNCTION minerops_check_current_board_projection();
|
||||||
|
DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships;
|
||||||
|
DROP FUNCTION minerops_protect_mac_ownership();
|
||||||
|
DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities;
|
||||||
|
DROP FUNCTION minerops_protect_network_identity_history();
|
||||||
|
DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities;
|
||||||
|
DROP TRIGGER trg_miners_claim_mac_owner ON miners;
|
||||||
|
DROP FUNCTION minerops_claim_mac_owner();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.drop_table("miner_control_board_installations")
|
||||||
|
op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships")
|
||||||
|
op.drop_table("miner_mac_ownerships")
|
||||||
|
op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check")
|
||||||
|
op.drop_index("uq_miners_current_normalized_mac", table_name="miners")
|
||||||
|
op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities")
|
||||||
|
op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities")
|
||||||
|
op.alter_column("miner_network_identities", "last_seen_at", nullable=False)
|
||||||
|
op.alter_column("miner_network_identities", "first_seen_at", nullable=False)
|
||||||
|
op.drop_column("miner_network_identities", "associated_at")
|
||||||
|
connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')")
|
||||||
|
connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'")
|
||||||
@@ -694,6 +694,15 @@ def repair_completion_request(path: str) -> bool:
|
|||||||
def required_api_permission(method: str, path: str) -> str | None:
|
def required_api_permission(method: str, path: str) -> str | None:
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
return "users.manage"
|
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":
|
if method == "POST" and path == "/api/repairs":
|
||||||
return "repairs.create"
|
return "repairs.create"
|
||||||
if method == "POST" and repair_completion_request(path):
|
if method == "POST" and repair_completion_request(path):
|
||||||
|
|||||||
+82
-258
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -7,7 +8,7 @@ import redis
|
|||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
|
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.config import settings
|
||||||
from app.identity import (
|
from app.identity import (
|
||||||
@@ -18,7 +19,23 @@ from app.identity import (
|
|||||||
router as identity_router,
|
router as identity_router,
|
||||||
)
|
)
|
||||||
from app.inventory.router import router as inventory_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)
|
app = FastAPI(title=settings.app_name)
|
||||||
@@ -15430,33 +15447,17 @@ def delete_miner_maintenance_event(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
class MinerMacChangeRequest(BaseModel):
|
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
|
old_mac: str | None = None
|
||||||
new_ip: str | None = None
|
new_ip: str | None = None
|
||||||
event_title: str | None = None
|
event_title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
component_serial: str | None = None
|
component_serial: str | None = Field(default=None, max_length=255)
|
||||||
downtime_minutes: int | None = None
|
downtime_minutes: int | None = Field(default=None, ge=0)
|
||||||
|
removed_board_disposition: RemovedBoardDisposition | None = None
|
||||||
|
replacement_reason: ControlBoardReplacementReason | 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")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/miners/{miner_id}/change-mac")
|
@app.post("/api/miners/{miner_id}/change-mac")
|
||||||
def change_miner_mac_address(
|
def change_miner_mac_address(
|
||||||
@@ -15464,244 +15465,66 @@ def change_miner_mac_address(
|
|||||||
request: MinerMacChangeRequest,
|
request: MinerMacChangeRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ensure_maintenance_tables()
|
"""Compatibility alias for the repair-scoped replacement workflow."""
|
||||||
|
if request.replacement_reason is None or request.removed_board_disposition is None:
|
||||||
new_mac = normalize_mac_display(request.new_mac)
|
raise HTTPException(
|
||||||
new_mac_key = normalize_mac_key_for_change(new_mac)
|
status_code=422,
|
||||||
|
detail=(
|
||||||
if not new_mac_key or len(new_mac_key) != 12:
|
"The compatibility route requires replacement_reason and "
|
||||||
raise HTTPException(status_code=400, detail="New MAC address is invalid")
|
"removed_board_disposition; use the repair-scoped replacement fields"
|
||||||
|
),
|
||||||
conn = None
|
)
|
||||||
|
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:
|
try:
|
||||||
conn = get_db_connection()
|
replacement = ControlBoardReplacementRequest(
|
||||||
|
new_mac_address=request.new_mac,
|
||||||
user_id = get_current_user_id_for_maintenance(current_user)
|
new_control_board_serial=request.component_serial,
|
||||||
|
removed_board_disposition=request.removed_board_disposition,
|
||||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
replacement_reason=request.replacement_reason,
|
||||||
cur.execute(
|
notes=request.description,
|
||||||
"""
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="Legacy change-mac request is invalid"
|
||||||
|
) from exc
|
||||||
|
|
||||||
except Exception:
|
try:
|
||||||
if conn is not None:
|
result = RepairService(RepairRepository()).replace_control_board(
|
||||||
conn.rollback()
|
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")
|
@app.get("/api/maintenance/events")
|
||||||
def list_all_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.
|
# Register UUID-based Repair routes after legacy static Repair paths.
|
||||||
app.include_router(repairs_router)
|
app.include_router(repairs_router)
|
||||||
|
app.include_router(control_board_router)
|
||||||
@@ -107,3 +107,31 @@ class RepairActiveMismatch(RepairConflict):
|
|||||||
|
|
||||||
class RepairPersistenceError(RepairError):
|
class RepairPersistenceError(RepairError):
|
||||||
"""Raised when a required repair persistence operation fails."""
|
"""Raised when a required repair persistence operation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class ControlBoardReplacementActorRequired(RepairError):
|
||||||
|
"""Raised when a board replacement lacks an authenticated actor."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("An authenticated actor is required to replace a control board")
|
||||||
|
|
||||||
|
|
||||||
|
class ControlBoardReplacementConflict(RepairConflict):
|
||||||
|
"""Raised when current repair, component, or MAC state blocks replacement."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMacAddress(RepairError):
|
||||||
|
"""Raised when a replacement MAC is malformed or prohibited."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "New MAC address is invalid"):
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class MacOwnershipConflict(ControlBoardReplacementConflict):
|
||||||
|
"""Raised when a MAC belongs to another durable miner."""
|
||||||
|
|
||||||
|
def __init__(self, normalized_mac: str):
|
||||||
|
self.normalized_mac = normalized_mac
|
||||||
|
super().__init__(
|
||||||
|
"MAC address has current or historical ownership on another miner"
|
||||||
|
)
|
||||||
@@ -32,6 +32,7 @@ RepairEventType = Literal[
|
|||||||
"REPAIR_STARTED",
|
"REPAIR_STARTED",
|
||||||
"INITIAL_NOTE",
|
"INITIAL_NOTE",
|
||||||
"REPAIR_COMPLETED",
|
"REPAIR_COMPLETED",
|
||||||
|
"CONTROL_BOARD_REPLACED",
|
||||||
]
|
]
|
||||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||||
RepairPostLifecycle = Literal["active", "spare"]
|
RepairPostLifecycle = Literal["active", "spare"]
|
||||||
@@ -53,6 +54,37 @@ class RepairCompleteRequest(BaseModel):
|
|||||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
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):
|
class RepairStartedPayload(BaseModel):
|
||||||
reason: RepairReason
|
reason: RepairReason
|
||||||
from_lifecycle: RepairEligibleLifecycle
|
from_lifecycle: RepairEligibleLifecycle
|
||||||
@@ -70,6 +102,40 @@ class RepairCompletedPayload(BaseModel):
|
|||||||
completion_summary: str | None = Field(default=None, max_length=4000)
|
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):
|
class BeginRepairResult(BaseModel):
|
||||||
repair_id: UUID
|
repair_id: UUID
|
||||||
miner_id: UUID
|
miner_id: UUID
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ from typing import Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from psycopg.errors import UniqueViolation
|
from psycopg.errors import CheckViolation, LockNotAvailable, UniqueViolation
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
ActiveRepairConflict,
|
ActiveRepairConflict,
|
||||||
|
ControlBoardReplacementConflict,
|
||||||
|
MacOwnershipConflict,
|
||||||
RepairPersistenceError,
|
RepairPersistenceError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +66,26 @@ class RepairRepository:
|
|||||||
try:
|
try:
|
||||||
yield transaction_repository
|
yield transaction_repository
|
||||||
connection.commit()
|
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:
|
except Exception:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
raise
|
raise
|
||||||
@@ -97,7 +119,8 @@ class RepairRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, lifecycle_status
|
SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn,
|
||||||
|
firmware_version
|
||||||
FROM miners
|
FROM miners
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -107,6 +130,27 @@ class RepairRepository:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
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:
|
def get_active_repair(self, miner_id: UUID) -> dict | None:
|
||||||
with self._connection_scope() as connection:
|
with self._connection_scope() as connection:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
@@ -342,6 +386,217 @@ class RepairRepository:
|
|||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
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:
|
def get_repair_detail(self, repair_id: UUID) -> dict | None:
|
||||||
with self._connection_scope() as connection:
|
with self._connection_scope() as connection:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from app.identity import require_permission
|
from app.identity import require_permission
|
||||||
from app.repairs.dependencies import get_repair_service
|
from app.repairs.dependencies import get_repair_service
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
|
ControlBoardReplacementActorRequired,
|
||||||
|
InvalidMacAddress,
|
||||||
RepairActorRequired,
|
RepairActorRequired,
|
||||||
RepairCompletionActorRequired,
|
RepairCompletionActorRequired,
|
||||||
RepairConflict,
|
RepairConflict,
|
||||||
@@ -16,6 +18,8 @@ from app.repairs.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.repairs.models import (
|
from app.repairs.models import (
|
||||||
BeginRepairRequest,
|
BeginRepairRequest,
|
||||||
|
ControlBoardReplacementRequest,
|
||||||
|
ControlBoardReplacementResponse,
|
||||||
BeginRepairResponse,
|
BeginRepairResponse,
|
||||||
RepairCompleteRequest,
|
RepairCompleteRequest,
|
||||||
RepairDetailResponse,
|
RepairDetailResponse,
|
||||||
@@ -25,9 +29,11 @@ from app.repairs.service import RepairService
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
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_create = require_permission("repairs.create")
|
||||||
require_repair_read = require_permission("repairs.read")
|
require_repair_read = require_permission("repairs.read")
|
||||||
require_repair_complete = require_permission("repairs.complete")
|
require_repair_complete = require_permission("repairs.complete")
|
||||||
|
require_control_board_replace = require_permission("miners.replace_control_board")
|
||||||
|
|
||||||
RepairServiceDependency = Annotated[
|
RepairServiceDependency = Annotated[
|
||||||
RepairService,
|
RepairService,
|
||||||
@@ -36,6 +42,37 @@ RepairServiceDependency = Annotated[
|
|||||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||||
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
|
||||||
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
|
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(
|
@router.post(
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
ActiveRepairConflict,
|
ActiveRepairConflict,
|
||||||
|
ControlBoardReplacementActorRequired,
|
||||||
|
ControlBoardReplacementConflict,
|
||||||
|
InvalidMacAddress,
|
||||||
IncompatibleMinerLifecycle,
|
IncompatibleMinerLifecycle,
|
||||||
InconsistentRepairState,
|
InconsistentRepairState,
|
||||||
RepairActiveMismatch,
|
RepairActiveMismatch,
|
||||||
@@ -17,6 +22,9 @@ from app.repairs.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.repairs.models import (
|
from app.repairs.models import (
|
||||||
BeginRepairRequest,
|
BeginRepairRequest,
|
||||||
|
ControlBoardReplacedPayload,
|
||||||
|
ControlBoardReplacementRequest,
|
||||||
|
ControlBoardReplacementResult,
|
||||||
BeginRepairResult,
|
BeginRepairResult,
|
||||||
InitialRepairNotePayload,
|
InitialRepairNotePayload,
|
||||||
RepairActor,
|
RepairActor,
|
||||||
@@ -143,6 +151,155 @@ class RepairService:
|
|||||||
opened_by=repair["opened_by"],
|
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(
|
def complete_repair(
|
||||||
self,
|
self,
|
||||||
repair_id: UUID,
|
repair_id: UUID,
|
||||||
|
|||||||
@@ -0,0 +1,804 @@
|
|||||||
|
import ast
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest.mock import patch
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from psycopg.errors import CheckViolation, RaiseException, UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.identity import get_current_user, required_api_permission
|
||||||
|
from app.repairs.dependencies import get_repair_service
|
||||||
|
from app.repairs.exceptions import (
|
||||||
|
ControlBoardReplacementConflict,
|
||||||
|
InvalidMacAddress,
|
||||||
|
MacOwnershipConflict,
|
||||||
|
RepairMinerNotFound,
|
||||||
|
RepairMinerNotInRepair,
|
||||||
|
)
|
||||||
|
from app.repairs.models import (
|
||||||
|
BeginRepairRequest,
|
||||||
|
ControlBoardReplacementRequest,
|
||||||
|
ControlBoardReplacementResult,
|
||||||
|
)
|
||||||
|
from app.repairs.repository import RepairRepository
|
||||||
|
from app.repairs.router import control_board_router
|
||||||
|
from app.repairs.service import RepairService
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def request(**overrides):
|
||||||
|
values = {
|
||||||
|
"new_mac_address": "02:AA:BB:CC:DD:02",
|
||||||
|
"new_control_board_serial": " CB-NEW ",
|
||||||
|
"board_model": " Model X ",
|
||||||
|
"board_revision": " Rev B ",
|
||||||
|
"firmware_version": " 1.2.3 ",
|
||||||
|
"removed_board_disposition": "sent_for_repair",
|
||||||
|
"replacement_reason": "failed",
|
||||||
|
"notes": " Board would not boot. ",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return ControlBoardReplacementRequest(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReplacementRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self.miner_id = uuid4()
|
||||||
|
self.repair_id = uuid4()
|
||||||
|
self.actor_id = uuid4()
|
||||||
|
self.miner = {
|
||||||
|
"id": self.miner_id,
|
||||||
|
"asset_tag": "MINER-004",
|
||||||
|
"lifecycle_status": "in_repair",
|
||||||
|
"current_mac": "02:AA:BB:CC:DD:01",
|
||||||
|
"control_board_sn": "CB-OLD",
|
||||||
|
"firmware_version": "1.0.0",
|
||||||
|
}
|
||||||
|
self.repair = {"id": self.repair_id, "miner_id": self.miner_id}
|
||||||
|
self.installations = []
|
||||||
|
self.events = [{"sequence": 1}]
|
||||||
|
self.identities = []
|
||||||
|
self.calls = []
|
||||||
|
self.fail_at = None
|
||||||
|
self.owner = None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(self):
|
||||||
|
self.calls.append("transaction")
|
||||||
|
snapshot = deepcopy(
|
||||||
|
(self.miner, self.installations, self.events, self.identities)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield self
|
||||||
|
except Exception:
|
||||||
|
self.miner, self.installations, self.events, self.identities = snapshot
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _fail(self, name):
|
||||||
|
self.calls.append(name)
|
||||||
|
if self.fail_at == name:
|
||||||
|
raise RuntimeError(name)
|
||||||
|
|
||||||
|
def lock_miner_for_control_board_replacement(self, miner_id):
|
||||||
|
self._fail("lock_miner")
|
||||||
|
return self.miner if miner_id == self.miner_id else None
|
||||||
|
|
||||||
|
def lock_active_repair_for_miner(self, miner_id):
|
||||||
|
self._fail("lock_repair")
|
||||||
|
return self.repair if miner_id == self.miner_id else None
|
||||||
|
|
||||||
|
def lock_current_control_board(self, miner_id):
|
||||||
|
self._fail("lock_board")
|
||||||
|
current = [row for row in self.installations if row["removed_at"] is None]
|
||||||
|
return current[0] if current else None
|
||||||
|
|
||||||
|
def create_legacy_control_board(self, miner):
|
||||||
|
self._fail("create_legacy_board")
|
||||||
|
row = {
|
||||||
|
"id": uuid4(), "miner_id": miner["id"],
|
||||||
|
"mac_address": miner["current_mac"],
|
||||||
|
"board_serial": miner["control_board_sn"],
|
||||||
|
"board_model": None, "board_revision": None,
|
||||||
|
"removed_at": None,
|
||||||
|
}
|
||||||
|
self.installations.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def ensure_network_identity_history(self, miner_id, **values):
|
||||||
|
self._fail("ensure_old_identity")
|
||||||
|
self.identities.append({"miner_id": miner_id, "is_current": False, **values})
|
||||||
|
|
||||||
|
def claim_mac_ownership(self, normalized_mac, miner_id, actor_user_id):
|
||||||
|
self._fail("claim_mac")
|
||||||
|
if self.owner is not None and self.owner != miner_id:
|
||||||
|
raise MacOwnershipConflict(normalized_mac)
|
||||||
|
self.owner = miner_id
|
||||||
|
|
||||||
|
def close_control_board(self, installation_id, **values):
|
||||||
|
self._fail("close_board")
|
||||||
|
row = next(row for row in self.installations if row["id"] == installation_id)
|
||||||
|
row.update(removed_at=NOW, **values)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def create_control_board_installation(self, **values):
|
||||||
|
self._fail("create_board")
|
||||||
|
row = {"id": uuid4(), "removed_at": None, **values, "installed_at": NOW}
|
||||||
|
self.installations.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def set_current_network_identity(self, miner_id, **values):
|
||||||
|
self._fail("set_identity")
|
||||||
|
self.identities.append({"miner_id": miner_id, **values})
|
||||||
|
return self.identities[-1]
|
||||||
|
|
||||||
|
def set_current_control_board(self, miner_id, *, mac_address, board_serial):
|
||||||
|
self._fail("set_projection")
|
||||||
|
self.miner.update(current_mac=mac_address, control_board_sn=board_serial)
|
||||||
|
|
||||||
|
def get_next_event_sequence(self, repair_id):
|
||||||
|
self._fail("next_sequence")
|
||||||
|
return max(event["sequence"] for event in self.events) + 1
|
||||||
|
|
||||||
|
def create_event(self, repair_id, **values):
|
||||||
|
self._fail("create_event")
|
||||||
|
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
|
||||||
|
self.events.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
class ControlBoardReplacementServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.repository = FakeReplacementRepository()
|
||||||
|
self.service = RepairService(self.repository)
|
||||||
|
|
||||||
|
def replace(self, replacement=None):
|
||||||
|
return self.service.replace_control_board(
|
||||||
|
self.repository.miner_id,
|
||||||
|
replacement or request(),
|
||||||
|
actor_user_id=self.repository.actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_replacement_is_atomic_and_preserves_durable_identity(self):
|
||||||
|
miner_id = self.repository.miner["id"]
|
||||||
|
asset_id = self.repository.miner["asset_tag"]
|
||||||
|
result = self.replace()
|
||||||
|
|
||||||
|
self.assertEqual(result.miner_id, miner_id)
|
||||||
|
self.assertEqual(result.asset_id, asset_id)
|
||||||
|
self.assertEqual(result.current_mac, "02:AA:BB:CC:DD:02")
|
||||||
|
self.assertEqual(result.control_board_serial, "CB-NEW")
|
||||||
|
self.assertEqual(self.repository.miner["id"], miner_id)
|
||||||
|
self.assertEqual(self.repository.miner["asset_tag"], asset_id)
|
||||||
|
self.assertEqual(len([x for x in self.repository.installations if x["removed_at"] is None]), 1)
|
||||||
|
event = self.repository.events[-1]
|
||||||
|
self.assertEqual(event["event_type"], "CONTROL_BOARD_REPLACED")
|
||||||
|
self.assertEqual(event["sequence"], 2)
|
||||||
|
self.assertEqual(event["created_by"], self.repository.actor_id)
|
||||||
|
self.assertEqual(event["payload"]["old_mac_address"], "02:AA:BB:CC:DD:01")
|
||||||
|
self.assertEqual(event["payload"]["old_control_board_serial"], "CB-OLD")
|
||||||
|
self.assertEqual(event["payload"]["notes"], "Board would not boot.")
|
||||||
|
|
||||||
|
def test_mac_normalization_and_prohibited_values(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.normalize_mac_address("02-aA-bb-cc-dd-02"),
|
||||||
|
("02:AA:BB:CC:DD:02", "02aabbccdd02"),
|
||||||
|
)
|
||||||
|
for value in (
|
||||||
|
"bad", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF",
|
||||||
|
"01:00:5E:00:00:01",
|
||||||
|
):
|
||||||
|
with self.subTest(value=value), self.assertRaises(InvalidMacAddress):
|
||||||
|
self.service.normalize_mac_address(value)
|
||||||
|
|
||||||
|
def test_conflicting_states_do_not_write(self):
|
||||||
|
cases = ("active", "spare", "retired")
|
||||||
|
for lifecycle in cases:
|
||||||
|
with self.subTest(lifecycle=lifecycle):
|
||||||
|
repository = FakeReplacementRepository()
|
||||||
|
repository.miner["lifecycle_status"] = lifecycle
|
||||||
|
with self.assertRaises(RepairMinerNotInRepair):
|
||||||
|
RepairService(repository).replace_control_board(
|
||||||
|
repository.miner_id, request(),
|
||||||
|
actor_user_id=repository.actor_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(repository.events, [{"sequence": 1}])
|
||||||
|
|
||||||
|
def test_same_and_historically_owned_mac_conflict(self):
|
||||||
|
with self.assertRaises(ControlBoardReplacementConflict):
|
||||||
|
self.replace(request(new_mac_address="02:AA:BB:CC:DD:01"))
|
||||||
|
repository = FakeReplacementRepository()
|
||||||
|
repository.owner = uuid4()
|
||||||
|
with self.assertRaises(MacOwnershipConflict):
|
||||||
|
RepairService(repository).replace_control_board(
|
||||||
|
repository.miner_id, request(), actor_user_id=repository.actor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_miner_and_active_repair_conflict(self):
|
||||||
|
with self.assertRaises(RepairMinerNotFound):
|
||||||
|
self.service.replace_control_board(
|
||||||
|
uuid4(), request(), actor_user_id=self.repository.actor_id
|
||||||
|
)
|
||||||
|
self.repository.repair = None
|
||||||
|
with self.assertRaises(ControlBoardReplacementConflict):
|
||||||
|
self.replace()
|
||||||
|
|
||||||
|
def test_each_meaningful_write_failure_rolls_back(self):
|
||||||
|
for step in (
|
||||||
|
"claim_mac", "close_board", "create_board", "set_identity",
|
||||||
|
"set_projection", "create_event",
|
||||||
|
):
|
||||||
|
with self.subTest(step=step):
|
||||||
|
repository = FakeReplacementRepository()
|
||||||
|
repository.fail_at = step
|
||||||
|
original = deepcopy(repository.miner)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
RepairService(repository).replace_control_board(
|
||||||
|
repository.miner_id, request(),
|
||||||
|
actor_user_id=repository.actor_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(repository.miner, original)
|
||||||
|
self.assertEqual(repository.installations, [])
|
||||||
|
self.assertEqual(repository.events, [{"sequence": 1}])
|
||||||
|
|
||||||
|
|
||||||
|
class StubReplacementService:
|
||||||
|
def __init__(self):
|
||||||
|
self.error = None
|
||||||
|
self.result = None
|
||||||
|
self.request = None
|
||||||
|
self.actor = None
|
||||||
|
|
||||||
|
def replace_control_board(self, miner_id, request, *, actor_user_id):
|
||||||
|
self.request = request
|
||||||
|
self.actor = actor_user_id
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
class ControlBoardReplacementRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.service = StubReplacementService()
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(control_board_router)
|
||||||
|
self.app.dependency_overrides[get_repair_service] = lambda: self.service
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
self.actor = uuid4()
|
||||||
|
self.miner = uuid4()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions=("miners.replace_control_board",), role="Technician"):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(self.actor), "role": role,
|
||||||
|
"permissions": list(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def post(self, body=None):
|
||||||
|
return self.client.post(
|
||||||
|
f"/api/miners/{self.miner}/replace-control-board",
|
||||||
|
json=(request().model_dump(mode="json") if body is None else body),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authentication_and_permission_are_required(self):
|
||||||
|
self.assertEqual(self.post().status_code, 401)
|
||||||
|
self.authenticate(permissions=("miners.update",))
|
||||||
|
self.assertEqual(self.post().status_code, 403)
|
||||||
|
|
||||||
|
def test_success_uses_typed_envelope_and_authenticated_actor(self):
|
||||||
|
self.authenticate()
|
||||||
|
self.service.result = ControlBoardReplacementResult(
|
||||||
|
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||||
|
installation_id=uuid4(), event_id=uuid4(), event_sequence=4,
|
||||||
|
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
|
||||||
|
replaced_at=NOW, replaced_by=self.actor,
|
||||||
|
)
|
||||||
|
response = self.post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["miner_id"], str(self.miner))
|
||||||
|
self.assertEqual(response.json()["meta"], {})
|
||||||
|
self.assertIsNone(response.json()["error"])
|
||||||
|
self.assertEqual(self.service.actor, self.actor)
|
||||||
|
|
||||||
|
def test_validation_and_domain_error_mapping(self):
|
||||||
|
self.authenticate()
|
||||||
|
self.assertEqual(self.post({"new_mac_address": "bad"}).status_code, 422)
|
||||||
|
self.service.error = RepairMinerNotFound(self.miner)
|
||||||
|
self.assertEqual(self.post().status_code, 404)
|
||||||
|
self.service.error = ControlBoardReplacementConflict("conflict")
|
||||||
|
self.assertEqual(self.post().status_code, 409)
|
||||||
|
self.service.error = InvalidMacAddress()
|
||||||
|
self.assertEqual(self.post().status_code, 422)
|
||||||
|
|
||||||
|
def test_route_and_permission_mapping_are_exact(self):
|
||||||
|
routes = [
|
||||||
|
(route.path, route.methods) for route in control_board_router.routes
|
||||||
|
if hasattr(route, "methods")
|
||||||
|
]
|
||||||
|
self.assertEqual(routes, [
|
||||||
|
("/api/miners/{miner_id}/replace-control-board", {"POST"})
|
||||||
|
])
|
||||||
|
path = f"/api/miners/{self.miner}/replace-control-board"
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("POST", path),
|
||||||
|
"miners.replace_control_board",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allowed_roles_and_read_only_matrix(self):
|
||||||
|
self.service.result = ControlBoardReplacementResult(
|
||||||
|
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||||
|
installation_id=uuid4(), event_id=uuid4(), event_sequence=2,
|
||||||
|
current_mac="02:AA:BB:CC:DD:02", control_board_serial=None,
|
||||||
|
replaced_at=NOW, replaced_by=self.actor,
|
||||||
|
)
|
||||||
|
for role in ("Admin", "Manager", "Technician"):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
self.authenticate(role=role)
|
||||||
|
self.assertEqual(self.post().status_code, 200)
|
||||||
|
self.authenticate(permissions=(), role="Read Only")
|
||||||
|
self.assertEqual(self.post().status_code, 403)
|
||||||
|
|
||||||
|
def test_request_contract_rejects_invalid_shapes(self):
|
||||||
|
self.authenticate()
|
||||||
|
valid = request().model_dump(mode="json")
|
||||||
|
invalid_bodies = []
|
||||||
|
missing_serial = dict(valid)
|
||||||
|
missing_serial.pop("new_control_board_serial")
|
||||||
|
invalid_bodies.append(missing_serial)
|
||||||
|
for field, value in (
|
||||||
|
("new_mac_address", ""),
|
||||||
|
("notes", "x" * 4001),
|
||||||
|
("replacement_reason", "invented"),
|
||||||
|
("removed_board_disposition", "invented"),
|
||||||
|
):
|
||||||
|
body = dict(valid)
|
||||||
|
body[field] = value
|
||||||
|
invalid_bodies.append(body)
|
||||||
|
unknown = dict(valid)
|
||||||
|
unknown["created_by"] = str(self.actor)
|
||||||
|
invalid_bodies.append(unknown)
|
||||||
|
for body in invalid_bodies:
|
||||||
|
with self.subTest(body=body):
|
||||||
|
self.assertEqual(self.post(body).status_code, 422)
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_has_no_sql_driver_or_transaction_management(self):
|
||||||
|
path = Path(__file__).parents[1] / "app" / "repairs" / "router.py"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
modules = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
modules.extend(alias.name for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
modules.append(node.module)
|
||||||
|
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules))
|
||||||
|
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", ".COMMIT(", ".ROLLBACK("):
|
||||||
|
self.assertNotIn(keyword, source.upper())
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyChangeMacCompatibilityTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from app import main as main_module
|
||||||
|
|
||||||
|
self.main = main_module
|
||||||
|
self.service = StubReplacementService()
|
||||||
|
self.actor = uuid4()
|
||||||
|
self.miner = uuid4()
|
||||||
|
self.service.result = ControlBoardReplacementResult(
|
||||||
|
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
|
||||||
|
installation_id=uuid4(), event_id=uuid4(), event_sequence=3,
|
||||||
|
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
|
||||||
|
replaced_at=NOW, replaced_by=self.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def legacy_request(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"new_mac": "02:AA:BB:CC:DD:02",
|
||||||
|
"component_serial": "CB-NEW",
|
||||||
|
"description": "Replaced during repair.",
|
||||||
|
"replacement_reason": "failed",
|
||||||
|
"removed_board_disposition": "sent_for_repair",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return self.main.MinerMacChangeRequest(**values)
|
||||||
|
|
||||||
|
def call(self, legacy_request):
|
||||||
|
with patch.object(self.main, "RepairRepository"), patch.object(
|
||||||
|
self.main, "RepairService", return_value=self.service
|
||||||
|
):
|
||||||
|
return self.main.change_miner_mac_address(
|
||||||
|
str(self.miner), legacy_request, {"id": str(self.actor)}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alias_uses_same_permission_and_domain_workflow(self):
|
||||||
|
path = f"/api/miners/{self.miner}/change-mac"
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("POST", path),
|
||||||
|
"miners.replace_control_board",
|
||||||
|
)
|
||||||
|
response = self.call(self.legacy_request())
|
||||||
|
self.assertEqual(self.service.actor, self.actor)
|
||||||
|
self.assertEqual(self.service.request.replacement_reason, "failed")
|
||||||
|
self.assertEqual(
|
||||||
|
self.service.request.removed_board_disposition, "sent_for_repair"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response["data"]["miner"]["current_mac"],
|
||||||
|
"02:AA:BB:CC:DD:02",
|
||||||
|
)
|
||||||
|
self.assertIn("control_board_replacement", response["data"])
|
||||||
|
self.assertNotIn("maintenance_event", response["data"])
|
||||||
|
|
||||||
|
def test_alias_does_not_fabricate_required_workflow_fields(self):
|
||||||
|
for changes in (
|
||||||
|
{"replacement_reason": None},
|
||||||
|
{"removed_board_disposition": None},
|
||||||
|
{"new_ip": "192.0.2.4"},
|
||||||
|
{"downtime_minutes": 10},
|
||||||
|
):
|
||||||
|
with self.subTest(changes=changes), self.assertRaises(HTTPException) as caught:
|
||||||
|
self.call(self.legacy_request(**changes))
|
||||||
|
self.assertEqual(caught.exception.status_code, 422)
|
||||||
|
|
||||||
|
def test_alias_maps_domain_conflicts_without_internal_error(self):
|
||||||
|
self.service.error = ControlBoardReplacementConflict("Miner has no active repair")
|
||||||
|
with self.assertRaises(HTTPException) as caught:
|
||||||
|
self.call(self.legacy_request())
|
||||||
|
self.assertEqual(caught.exception.status_code, 409)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlBoardReplacementDatabaseTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.repository = RepairRepository()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.actor = uuid4()
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
|
||||||
|
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
|
||||||
|
cur.execute(
|
||||||
|
"TRUNCATE miner_control_board_installations, "
|
||||||
|
"miner_network_identities, miner_mac_ownerships"
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM repair_events")
|
||||||
|
cur.execute("DELETE FROM repairs")
|
||||||
|
cur.execute("DELETE FROM miner_tags")
|
||||||
|
cur.execute("DELETE FROM miner_asset_id_history")
|
||||||
|
cur.execute("DELETE FROM miners")
|
||||||
|
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, display_name, password_hash, role_id
|
||||||
|
)
|
||||||
|
SELECT %s, %s, 'Board Test Actor', 'unused', id
|
||||||
|
FROM roles WHERE name = 'Admin'
|
||||||
|
""",
|
||||||
|
(self.actor, f"board-test-{self.actor}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
|
||||||
|
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
|
||||||
|
cur.execute(
|
||||||
|
"TRUNCATE miner_control_board_installations, "
|
||||||
|
"miner_network_identities, miner_mac_ownerships"
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM repair_events")
|
||||||
|
cur.execute("DELETE FROM repairs")
|
||||||
|
cur.execute("DELETE FROM miner_tags")
|
||||||
|
cur.execute("DELETE FROM miner_asset_id_history")
|
||||||
|
cur.execute("DELETE FROM miners")
|
||||||
|
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
|
||||||
|
|
||||||
|
|
||||||
|
def create_repairing_miner(self, mac_address, serial):
|
||||||
|
asset_id = f"BOARD-{uuid4()}"
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miners (
|
||||||
|
asset_tag, current_mac, control_board_sn, status,
|
||||||
|
lifecycle_status, first_seen_at, last_seen_at
|
||||||
|
) VALUES (%s, %s, %s, 'unknown', 'active', NULL, NULL)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(asset_id, mac_address, serial),
|
||||||
|
)
|
||||||
|
miner_id = cur.fetchone()["id"]
|
||||||
|
repair = RepairService(self.repository).begin_repair(
|
||||||
|
BeginRepairRequest(miner_id=miner_id, reason="CONTROL_BOARD"),
|
||||||
|
actor_user_id=self.actor,
|
||||||
|
)
|
||||||
|
return miner_id, asset_id, repair.repair_id
|
||||||
|
|
||||||
|
def replace(self, miner_id, mac_address="02:AA:BB:CC:EE:02"):
|
||||||
|
return RepairService(self.repository).replace_control_board(
|
||||||
|
miner_id, request(new_mac_address=mac_address),
|
||||||
|
actor_user_id=self.actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetchall(self, sql, values=()):
|
||||||
|
with self.repository._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(sql, values)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def test_database_success_preserves_identity_and_authoritative_history(self):
|
||||||
|
miner_id, asset_id, repair_id = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:EE:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
result = self.replace(miner_id)
|
||||||
|
miner = self.fetchall(
|
||||||
|
"SELECT * FROM miners WHERE id = %s", (miner_id,)
|
||||||
|
)[0]
|
||||||
|
boards = self.fetchall(
|
||||||
|
"SELECT * FROM miner_control_board_installations "
|
||||||
|
"WHERE miner_id = %s ORDER BY created_at, id", (miner_id,)
|
||||||
|
)
|
||||||
|
identities = self.fetchall(
|
||||||
|
"SELECT * FROM miner_network_identities "
|
||||||
|
"WHERE miner_id = %s ORDER BY is_current, mac_address", (miner_id,)
|
||||||
|
)
|
||||||
|
events = self.fetchall(
|
||||||
|
"SELECT * FROM repair_events WHERE repair_id = %s "
|
||||||
|
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
|
||||||
|
)
|
||||||
|
self.assertEqual(result.miner_id, miner_id)
|
||||||
|
self.assertEqual(miner["asset_tag"], asset_id)
|
||||||
|
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EE:02")
|
||||||
|
self.assertEqual(len(boards), 2)
|
||||||
|
self.assertEqual(sum(row["removed_at"] is None for row in boards), 1)
|
||||||
|
legacy = next(row for row in boards if row["record_origin"] == "legacy_projection")
|
||||||
|
current = next(row for row in boards if row["removed_at"] is None)
|
||||||
|
self.assertIsNone(legacy["installed_at"])
|
||||||
|
self.assertIsNone(legacy["installed_by"])
|
||||||
|
self.assertEqual(current["record_origin"], "replacement")
|
||||||
|
self.assertEqual(current["installed_by"], self.actor)
|
||||||
|
self.assertEqual(current["normalized_mac"], "02aabbccee02")
|
||||||
|
self.assertEqual(len(identities), 2)
|
||||||
|
self.assertEqual(sum(row["is_current"] for row in identities), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
next(row for row in identities if row["is_current"])["mac_address"],
|
||||||
|
"02:AA:BB:CC:EE:02",
|
||||||
|
)
|
||||||
|
self.assertTrue(any(row["mac_address"] == "02:AA:BB:CC:EE:01" for row in identities))
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["created_by"], self.actor)
|
||||||
|
self.assertEqual(events[0]["sequence"], result.event_sequence)
|
||||||
|
|
||||||
|
def test_database_allows_omitted_optional_notes(self):
|
||||||
|
miner_id, _, repair_id = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:EA:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
RepairService(self.repository).replace_control_board(
|
||||||
|
miner_id,
|
||||||
|
request(new_mac_address="02:AA:BB:CC:EA:02", notes=None),
|
||||||
|
actor_user_id=self.actor,
|
||||||
|
)
|
||||||
|
event = self.fetchall(
|
||||||
|
"SELECT payload FROM repair_events WHERE repair_id = %s "
|
||||||
|
"AND event_type = 'CONTROL_BOARD_REPLACED'",
|
||||||
|
(repair_id,),
|
||||||
|
)[0]
|
||||||
|
self.assertIn("notes", event["payload"])
|
||||||
|
self.assertIsNone(event["payload"]["notes"])
|
||||||
|
|
||||||
|
def test_database_constraints_prevent_drift_and_history_mutation(self):
|
||||||
|
miner_id, _, repair_id = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:ED:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
self.replace(miner_id, "02:AA:BB:CC:ED:02")
|
||||||
|
|
||||||
|
with self.assertRaises(UniqueViolation):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miner_control_board_installations (
|
||||||
|
miner_id, record_origin, board_serial
|
||||||
|
) VALUES (%s, 'legacy_projection', 'SECOND')
|
||||||
|
""",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
with self.assertRaises(CheckViolation):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miners SET control_board_sn = 'DRIFT' "
|
||||||
|
"WHERE id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
with self.assertRaises(CheckViolation):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE miner_network_identities SET is_current = false "
|
||||||
|
"WHERE miner_id = %s AND is_current",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM miner_network_identities "
|
||||||
|
"WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM miner_control_board_installations "
|
||||||
|
"WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
for statement in (
|
||||||
|
"UPDATE miner_mac_ownerships SET miner_id = gen_random_uuid() "
|
||||||
|
"WHERE normalized_mac = '02aabbcced02'",
|
||||||
|
"DELETE FROM miner_mac_ownerships "
|
||||||
|
"WHERE normalized_mac = '02aabbcced02'",
|
||||||
|
):
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(statement)
|
||||||
|
with self.assertRaises(UniqueViolation):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO repair_events (
|
||||||
|
repair_id, sequence, event_type, created_by, payload
|
||||||
|
)
|
||||||
|
SELECT repair_id, sequence, event_type, created_by, payload
|
||||||
|
FROM repair_events
|
||||||
|
WHERE repair_id = %s ORDER BY sequence LIMIT 1
|
||||||
|
""",
|
||||||
|
(repair_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_database_rejects_prohibited_component_mac(self):
|
||||||
|
miner_id, _, _ = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:EC:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
for mac_address, normalized_mac in (
|
||||||
|
("01:00:5E:00:00:01", "01005e000001"),
|
||||||
|
("00:00:00:00:00:00", "000000000000"),
|
||||||
|
("FF:FF:FF:FF:FF:FF", "ffffffffffff"),
|
||||||
|
("02-AA-BB-CC-EC-02", "02aabbccec02"),
|
||||||
|
):
|
||||||
|
with self.subTest(mac_address=mac_address), self.assertRaises(CheckViolation):
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miner_control_board_installations (
|
||||||
|
miner_id, record_origin, mac_address, normalized_mac
|
||||||
|
) VALUES (%s, 'legacy_projection', %s, %s)
|
||||||
|
""",
|
||||||
|
(miner_id, mac_address, normalized_mac),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_failure_rolls_back_every_replacement_write(self):
|
||||||
|
miner_id, _, repair_id = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:EF:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
with self.repository._connection_scope(write=True) as connection:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION test_fail_board_event() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.event_type = 'CONTROL_BOARD_REPLACED' THEN
|
||||||
|
RAISE EXCEPTION 'forced replacement event failure';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$ LANGUAGE plpgsql
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"CREATE TRIGGER test_board_event_failure BEFORE INSERT ON "
|
||||||
|
"repair_events FOR EACH ROW EXECUTE FUNCTION test_fail_board_event()"
|
||||||
|
)
|
||||||
|
with self.assertRaises(RaiseException):
|
||||||
|
self.replace(miner_id, "02:AA:BB:CC:EF:02")
|
||||||
|
miner = self.fetchall("SELECT * FROM miners WHERE id = %s", (miner_id,))[0]
|
||||||
|
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EF:01")
|
||||||
|
self.assertEqual(self.fetchall(
|
||||||
|
"SELECT * FROM miner_control_board_installations WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
), [])
|
||||||
|
self.assertEqual(self.fetchall(
|
||||||
|
"SELECT * FROM miner_network_identities WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
), [])
|
||||||
|
self.assertEqual(len(self.fetchall(
|
||||||
|
"SELECT * FROM repair_events WHERE repair_id = %s", (repair_id,)
|
||||||
|
)), 1)
|
||||||
|
owners = self.fetchall(
|
||||||
|
"SELECT normalized_mac FROM miner_mac_ownerships WHERE miner_id = %s",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
self.assertEqual(owners, [{"normalized_mac": "02aabbccef01"}])
|
||||||
|
|
||||||
|
def test_concurrent_replacements_on_one_miner_succeed_once(self):
|
||||||
|
miner_id, _, repair_id = self.create_repairing_miner(
|
||||||
|
"02:AA:BB:CC:F0:01", "CB-OLD"
|
||||||
|
)
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def replace(mac_address):
|
||||||
|
barrier.wait()
|
||||||
|
return self.replace(miner_id, mac_address)
|
||||||
|
|
||||||
|
outcomes = []
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(replace, "02:AA:BB:CC:F0:02"),
|
||||||
|
executor.submit(replace, "02:AA:BB:CC:F0:04"),
|
||||||
|
]
|
||||||
|
for future in futures:
|
||||||
|
try:
|
||||||
|
outcomes.append(future.result(timeout=15))
|
||||||
|
except ControlBoardReplacementConflict as exc:
|
||||||
|
outcomes.append(exc)
|
||||||
|
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
|
||||||
|
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
|
||||||
|
self.assertEqual(len(self.fetchall(
|
||||||
|
"SELECT * FROM miner_control_board_installations "
|
||||||
|
"WHERE miner_id = %s AND removed_at IS NULL", (miner_id,)
|
||||||
|
)), 1)
|
||||||
|
self.assertEqual(len(self.fetchall(
|
||||||
|
"SELECT * FROM repair_events WHERE repair_id = %s "
|
||||||
|
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
|
||||||
|
)), 1)
|
||||||
|
|
||||||
|
def test_two_miners_cannot_concurrently_claim_one_mac(self):
|
||||||
|
first, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:01", "CB-1")
|
||||||
|
second, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:02", "CB-2")
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def replace(miner_id):
|
||||||
|
barrier.wait()
|
||||||
|
return self.replace(miner_id, "02:AA:BB:CC:F1:04")
|
||||||
|
|
||||||
|
outcomes = []
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [executor.submit(replace, first), executor.submit(replace, second)]
|
||||||
|
for future in futures:
|
||||||
|
try:
|
||||||
|
outcomes.append(future.result(timeout=15))
|
||||||
|
except ControlBoardReplacementConflict as exc:
|
||||||
|
outcomes.append(exc)
|
||||||
|
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
|
||||||
|
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
|
||||||
|
owner = self.fetchall(
|
||||||
|
"SELECT miner_id FROM miner_mac_ownerships "
|
||||||
|
"WHERE normalized_mac = '02aabbccf104'"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(owner), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+6
-5
@@ -99,11 +99,12 @@ durable asset record. Repairs, maintenance, work orders, parts usage, equipment
|
|||||||
movement, testing, and return-to-service history become coherent operational
|
movement, testing, and return-to-service history become coherent operational
|
||||||
records rather than disconnected actions.
|
records rather than disconnected actions.
|
||||||
|
|
||||||
The Begin Repair, read-only Repair Timeline, and Return to Service workflows are
|
The Begin Repair, read-only Repair Timeline, Return to Service, and Control Board
|
||||||
complete. An authorized actor can open a repair against a durable miner UUID,
|
Replacement workflows are complete. An authorized actor can open a repair
|
||||||
record sequenced events, and move the miner into or out of repair atomically.
|
against a durable miner UUID, record sequenced events, move the miner into or out
|
||||||
Authorized readers can inspect the repair and its ordered immutable events.
|
of repair atomically, and replace a control board without changing the durable
|
||||||
Later repair lifecycle workflows remain planned.
|
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
|
Technician experience is a priority. Responsive and QR-assisted workflows may
|
||||||
reduce lookup and data-entry effort where they reinforce the same asset record.
|
reduce lookup and data-entry effort where they reinforce the same asset record.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ and immutable history.
|
|||||||
- [x] Begin Repair (`POST /api/repairs`)
|
- [x] Begin Repair (`POST /api/repairs`)
|
||||||
- [x] Repair detail and timeline read APIs
|
- [x] Repair detail and timeline read APIs
|
||||||
- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
|
- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
|
||||||
|
- [x] Control Board Replacement
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
- [ ] Control Board Replacement
|
|
||||||
- [ ] Repair Parts Tracking
|
- [ ] Repair Parts Tracking
|
||||||
|
|
||||||
These planned items refer to explicit workflows in the modular Repairs domain.
|
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
|
- operator behavior is documented
|
||||||
- the release candidate is built from a clean, reproducible repository state
|
- the release candidate is built from a clean, reproducible repository state
|
||||||
|
|
||||||
## Candidate Task Order
|
## Remaining Task
|
||||||
|
|
||||||
1. `004-control-board-replacement.md`
|
1. `005-repair-parts-tracking.md`
|
||||||
2. `005-repair-parts-tracking.md`
|
|
||||||
|
|
||||||
These are proposed future files under `docs/tasks/`. They do not exist yet and
|
This task is specified under `docs/tasks/`, but remains a planned workflow and
|
||||||
must not be treated as approved specifications until they are created and
|
must not be treated as implemented.
|
||||||
reviewed.
|
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_CONTROL_BOARD_MIGRATION_PROJECT:-minerops-control-board-migration-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || exit 1
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
run_alembic upgrade 0012_return_to_service
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO users (id, username, display_name, password_hash, role_id)
|
||||||
|
SELECT '00000000-0000-0000-0000-000000001401', 'board-migration',
|
||||||
|
'Board Migration', 'unused', id FROM roles WHERE name = 'Admin';
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, asset_tag, current_mac, control_board_sn, lifecycle_status,
|
||||||
|
status, first_seen_at, last_seen_at
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001402', 'BOARD-LEGACY',
|
||||||
|
'02-AA-BB-CC-DD-01', 'LEGACY-BOARD', 'active', 'unknown', NULL, NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, asset_tag, current_mac, lifecycle_status, status,
|
||||||
|
first_seen_at, last_seen_at
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001404', 'BOARD-CONFLICT',
|
||||||
|
'02:AA:BB:CC:DD:01', 'active', 'unknown', NULL, NULL
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
if preflight_error="$(run_alembic upgrade head 2>&1)"; then
|
||||||
|
echo "Duplicate normalized MAC preflight did not stop the upgrade." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${preflight_error}" == *"normalized MAC values have multiple miner owners"* ]]
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
DELETE FROM miners WHERE id = '00000000-0000-0000-0000-000000001404';
|
||||||
|
SQL
|
||||||
|
|
||||||
|
run_alembic upgrade head
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE roles_for_permission text[];
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.miner_control_board_installations') IS NULL
|
||||||
|
OR to_regclass('public.miner_mac_ownerships') IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Task 004 tables are missing';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM miner_control_board_installations) THEN
|
||||||
|
RAISE EXCEPTION 'Migration fabricated installation history';
|
||||||
|
END IF;
|
||||||
|
SELECT array_agg(role.name ORDER BY role.name) INTO roles_for_permission
|
||||||
|
FROM role_permissions rp JOIN roles role ON role.id = rp.role_id
|
||||||
|
JOIN permissions permission ON permission.id = rp.permission_id
|
||||||
|
WHERE permission.key = 'miners.replace_control_board';
|
||||||
|
IF roles_for_permission <> ARRAY['Admin', 'Manager', 'Technician'] THEN
|
||||||
|
RAISE EXCEPTION 'Unexpected permission roles: %', roles_for_permission;
|
||||||
|
END IF;
|
||||||
|
IF (SELECT miner_id FROM miner_mac_ownerships WHERE normalized_mac = '02aabbccdd01')
|
||||||
|
<> '00000000-0000-0000-0000-000000001402'::uuid THEN
|
||||||
|
RAISE EXCEPTION 'Legacy MAC ownership was not preserved';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
run_alembic downgrade 0012_return_to_service
|
||||||
|
run_alembic upgrade head
|
||||||
|
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
|
||||||
|
UPDATE miners
|
||||||
|
SET lifecycle_status = 'in_repair', current_mac = '02:AA:BB:CC:DD:02',
|
||||||
|
control_board_sn = 'NEW-BOARD'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000001402';
|
||||||
|
INSERT INTO miner_network_identities (
|
||||||
|
miner_id, mac_address, source, is_current, first_seen_at, last_seen_at, associated_at
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001402', '02:AA:BB:CC:DD:02',
|
||||||
|
'control_board_replacement', true, NULL, NULL, now()
|
||||||
|
);
|
||||||
|
INSERT INTO repairs (id, miner_id, reason, opened_by) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001403',
|
||||||
|
'00000000-0000-0000-0000-000000001402', 'CONTROL_BOARD',
|
||||||
|
'00000000-0000-0000-0000-000000001401'
|
||||||
|
);
|
||||||
|
INSERT INTO miner_control_board_installations (
|
||||||
|
miner_id, record_origin, installed_repair_id, mac_address, normalized_mac, board_serial,
|
||||||
|
replacement_reason, installed_at, installed_by
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000001402', 'replacement',
|
||||||
|
'00000000-0000-0000-0000-000000001403', '02:AA:BB:CC:DD:02',
|
||||||
|
'02aabbccdd02', 'NEW-BOARD', 'failed', now(),
|
||||||
|
'00000000-0000-0000-0000-000000001401'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
if downgrade_error="$(run_alembic downgrade 0012_return_to_service 2>&1)"; then
|
||||||
|
echo "Downgrade discarded authoritative control-board history." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${downgrade_error}" == *"authoritative installation records or events"* ]]
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||||
|
|
||||||
|
database="minerops_control_board_empty"
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'createdb -U "$POSTGRES_USER" '"${database}"
|
||||||
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic upgrade head
|
||||||
|
[[ "$("${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_CONTROL_BOARD_TEST_PROJECT:-minerops-control-board-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v tests.test_control_board_replacement
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest discover -v -s tests
|
||||||
@@ -62,8 +62,52 @@ fi
|
|||||||
echo "Validating Compose configuration..."
|
echo "Validating Compose configuration..."
|
||||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
|
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
|
||||||
|
|
||||||
echo "Building and starting disposable development stack..."
|
echo "Starting disposable data services..."
|
||||||
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build
|
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..."
|
echo "Waiting for the gateway health endpoint..."
|
||||||
health_file="${tmp_prefix}-health.json"
|
health_file="${tmp_prefix}-health.json"
|
||||||
@@ -88,7 +132,7 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
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="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
@@ -168,6 +212,8 @@ echo "Checking Begin Repair permission and workflow..."
|
|||||||
INSERT INTO miners (
|
INSERT INTO miners (
|
||||||
id,
|
id,
|
||||||
asset_tag,
|
asset_tag,
|
||||||
|
current_mac,
|
||||||
|
control_board_sn,
|
||||||
status,
|
status,
|
||||||
lifecycle_status,
|
lifecycle_status,
|
||||||
first_seen_at,
|
first_seen_at,
|
||||||
@@ -176,6 +222,8 @@ INSERT INTO miners (
|
|||||||
VALUES (
|
VALUES (
|
||||||
'00000000-0000-0000-0000-000000000981',
|
'00000000-0000-0000-0000-000000000981',
|
||||||
'SMOKE-REPAIR-001',
|
'SMOKE-REPAIR-001',
|
||||||
|
'02:AA:BB:CC:EE:01',
|
||||||
|
'SMOKE-CB-OLD',
|
||||||
'unknown',
|
'unknown',
|
||||||
'active',
|
'active',
|
||||||
NULL,
|
NULL,
|
||||||
@@ -202,19 +250,31 @@ smoke_repair_id="$(
|
|||||||
| python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])'
|
| python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])'
|
||||||
)"
|
)"
|
||||||
[[ -n "${smoke_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="$(
|
readonly_repair_detail="$(
|
||||||
curl --fail --silent -b "${readonly_cookie}" \
|
curl --fail --silent -b "${readonly_cookie}" \
|
||||||
"${base_url}/api/repairs/${smoke_repair_id}"
|
"${base_url}/api/repairs/${smoke_repair_id}"
|
||||||
)"
|
)"
|
||||||
[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]]
|
[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]]
|
||||||
[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]]
|
[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]]
|
||||||
[[ "${readonly_repair_detail}" == *'"latest_event_sequence":2'* ]]
|
[[ "${readonly_repair_detail}" == *'"latest_event_sequence":3'* ]]
|
||||||
readonly_repair_events="$(
|
readonly_repair_events="$(
|
||||||
curl --fail --silent -b "${readonly_cookie}" \
|
curl --fail --silent -b "${readonly_cookie}" \
|
||||||
"${base_url}/api/repairs/${smoke_repair_id}/events"
|
"${base_url}/api/repairs/${smoke_repair_id}/events"
|
||||||
)"
|
)"
|
||||||
[[ "${readonly_repair_events}" == *'"sequence":1'* ]]
|
[[ "${readonly_repair_events}" == *'"sequence":1'* ]]
|
||||||
[[ "${readonly_repair_events}" == *'"sequence":2'* ]]
|
[[ "${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":"REPAIR_STARTED"'* ]]
|
||||||
[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]]
|
[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]]
|
||||||
[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]]
|
[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ INSERT INTO repairs (
|
|||||||
SQL
|
SQL
|
||||||
|
|
||||||
echo "Upgrading previous head to Return to Service..."
|
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="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
[[ "${current_revision}" == *"0012_return_to_service"* ]]
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ SQL
|
|||||||
[[ "${downgrade_state}" == "t" ]]
|
[[ "${downgrade_state}" == "t" ]]
|
||||||
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
downgraded_revision="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${downgraded_revision}" == *"0011_repair_timeline"* ]]
|
[[ "${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="$(run_alembic current 2>/dev/null)"
|
||||||
[[ "${reupgraded_revision}" == *"0012_return_to_service"* ]]
|
[[ "${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
|
-c "CREATE DATABASE minerops_return_empty"' >/dev/null
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
-e "POSTGRES_DB=${empty_database}" \
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
backend alembic upgrade head
|
backend alembic upgrade 0012_return_to_service
|
||||||
empty_revision="$(
|
empty_revision="$(
|
||||||
"${compose[@]}" run --rm --no-deps \
|
"${compose[@]}" run --rm --no-deps \
|
||||||
-e "POSTGRES_DB=${empty_database}" \
|
-e "POSTGRES_DB=${empty_database}" \
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ WITH expected(table_name) AS (
|
|||||||
('miner_asset_id_history'),
|
('miner_asset_id_history'),
|
||||||
('repairs'),
|
('repairs'),
|
||||||
('repair_events'),
|
('repair_events'),
|
||||||
|
('miner_control_board_installations'),
|
||||||
|
('miner_mac_ownerships'),
|
||||||
('miner_status_snapshots'),
|
('miner_status_snapshots'),
|
||||||
('miner_status_hourly_rollups'),
|
('miner_status_hourly_rollups'),
|
||||||
('miner_status_daily_rollups'),
|
('miner_status_daily_rollups'),
|
||||||
|
|||||||
Reference in new issue
Block a user