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