feat: add control board replacement workflow

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

No files matched your search

+28
View File
@@ -107,3 +107,31 @@ class RepairActiveMismatch(RepairConflict):
class RepairPersistenceError(RepairError):
"""Raised when a required repair persistence operation fails."""
class ControlBoardReplacementActorRequired(RepairError):
"""Raised when a board replacement lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to replace a control board")
class ControlBoardReplacementConflict(RepairConflict):
"""Raised when current repair, component, or MAC state blocks replacement."""
class InvalidMacAddress(RepairError):
"""Raised when a replacement MAC is malformed or prohibited."""
def __init__(self, message: str = "New MAC address is invalid"):
super().__init__(message)
class MacOwnershipConflict(ControlBoardReplacementConflict):
"""Raised when a MAC belongs to another durable miner."""
def __init__(self, normalized_mac: str):
self.normalized_mac = normalized_mac
super().__init__(
"MAC address has current or historical ownership on another miner"
)
+66
View File
@@ -32,6 +32,7 @@ RepairEventType = Literal[
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
@@ -53,6 +54,37 @@ class RepairCompleteRequest(BaseModel):
completion_summary: str | None = Field(default=None, max_length=4000)
ControlBoardReplacementReason = Literal[
"failed",
"upgrade",
"diagnostic_swap",
"preventive_maintenance",
"temporary_donor",
"unknown",
]
RemovedBoardDisposition = Literal[
"repair_inventory",
"sent_for_repair",
"scrapped",
"retained_for_diagnosis",
"returned_to_vendor",
"unknown",
]
class ControlBoardReplacementRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
new_mac_address: str = Field(min_length=1, max_length=64)
new_control_board_serial: str | None = Field(..., max_length=255)
board_model: str | None = Field(default=None, max_length=255)
board_revision: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class RepairStartedPayload(BaseModel):
reason: RepairReason
from_lifecycle: RepairEligibleLifecycle
@@ -70,6 +102,40 @@ class RepairCompletedPayload(BaseModel):
completion_summary: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacedPayload(BaseModel):
old_mac_address: str | None
new_mac_address: str
old_control_board_serial: str | None
new_control_board_serial: str | None
old_board_model: str | None = None
new_board_model: str | None = None
old_board_revision: str | None = None
new_board_revision: str | None = None
new_firmware_version: str | None = None
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacementResult(BaseModel):
miner_id: UUID
asset_id: str | None = None
repair_id: UUID
installation_id: UUID
event_id: UUID
event_sequence: int = Field(ge=1)
current_mac: str
control_board_serial: str | None
replaced_at: AwareDatetime
replaced_by: UUID
class ControlBoardReplacementResponse(BaseModel):
data: ControlBoardReplacementResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class BeginRepairResult(BaseModel):
repair_id: UUID
miner_id: UUID
+257 -2
View File
@@ -6,13 +6,15 @@ from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.errors import CheckViolation, LockNotAvailable, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.config import settings
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementConflict,
MacOwnershipConflict,
RepairPersistenceError,
)
@@ -64,6 +66,26 @@ class RepairRepository:
try:
yield transaction_repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name in {
"uq_miner_mac_ownerships_owner",
"uq_miner_network_identities_normalized_mac",
"uq_miners_current_normalized_mac",
"uq_control_board_installations_current_miner",
"uq_control_board_installations_current_mac",
}:
raise ControlBoardReplacementConflict(
"Concurrent control-board replacement or MAC ownership conflict"
) from exc
raise
except CheckViolation as exc:
connection.rollback()
if exc.diag.constraint_name == "ck_current_control_board_projection":
raise ControlBoardReplacementConflict(
"Control-board projection would contradict authoritative history"
) from exc
raise
except Exception:
connection.rollback()
raise
@@ -97,7 +119,8 @@ class RepairRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, lifecycle_status
SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn,
firmware_version
FROM miners
WHERE id = %s
FOR UPDATE
@@ -107,6 +130,27 @@ class RepairRepository:
row = cur.fetchone()
return dict(row) if row else None
def lock_miner_for_control_board_replacement(
self, miner_id: UUID
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
try:
cur.execute(
"""
SELECT id, asset_tag, lifecycle_status, current_mac,
control_board_sn, firmware_version
FROM miners WHERE id = %s FOR UPDATE NOWAIT
""",
(miner_id,),
)
except LockNotAvailable as exc:
raise ControlBoardReplacementConflict(
"A control-board replacement is already in progress"
) from exc
row = cur.fetchone()
return dict(row) if row else None
def get_active_repair(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
@@ -342,6 +386,217 @@ class RepairRepository:
row = cur.fetchone()
return dict(row) if row else None
def lock_active_repair_for_miner(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, miner_id, status, reason, opened_at, opened_by
FROM repairs
WHERE miner_id = %s AND status = ANY(%s)
ORDER BY opened_at, id
LIMIT 1
FOR UPDATE
""",
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_current_control_board(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT * FROM miner_control_board_installations
WHERE miner_id = %s AND removed_at IS NULL
FOR UPDATE
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_legacy_control_board(self, miner: Mapping[str, Any]) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
mac_address = miner.get("current_mac")
normalized_mac = (
"".join(ch for ch in str(mac_address).lower() if ch in "0123456789abcdef")
if mac_address else None
)
if normalized_mac and len(normalized_mac) == 12:
mac_address = ":".join(
normalized_mac[index:index + 2].upper()
for index in range(0, 12, 2)
)
elif normalized_mac:
normalized_mac = None
mac_address = None
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, mac_address, normalized_mac,
board_serial, firmware_version
)
VALUES (%s, 'legacy_projection', %s, %s, %s, %s)
RETURNING *
""",
(miner["id"], mac_address, normalized_mac,
miner.get("control_board_sn"), miner.get("firmware_version")),
)
return dict(cur.fetchone())
def claim_mac_ownership(
self, normalized_mac: str, miner_id: UUID, actor_user_id: UUID
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(normalized_mac,),
)
cur.execute(
"SELECT miner_id FROM miner_mac_ownerships "
"WHERE normalized_mac = %s FOR UPDATE",
(normalized_mac,),
)
row = cur.fetchone()
if row and row["miner_id"] != miner_id:
raise MacOwnershipConflict(normalized_mac)
if row is None:
cur.execute(
"""
INSERT INTO miner_mac_ownerships (
normalized_mac, miner_id, claimed_at, claimed_by
) VALUES (%s, %s, now(), %s)
""",
(normalized_mac, miner_id, actor_user_id),
)
def close_control_board(
self, installation_id: UUID, *, repair_id: UUID, actor_user_id: UUID,
disposition: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE miner_control_board_installations
SET removed_at = now(), removed_by = %s,
removed_repair_id = %s, removed_disposition = %s
WHERE id = %s AND removed_at IS NULL
RETURNING *
""",
(actor_user_id, repair_id, disposition, installation_id),
)
row = cur.fetchone()
if row is None:
raise RepairPersistenceError(
"Current control-board installation was not closed"
)
return dict(row)
def create_control_board_installation(
self, *, miner_id: UUID, repair_id: UUID, mac_address: str,
normalized_mac: str, board_serial: str | None, board_model: str | None,
board_revision: str | None, firmware_version: str | None,
replacement_reason: str, notes: str | None, actor_user_id: UUID
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, installed_repair_id, mac_address,
normalized_mac, board_serial, board_model, board_revision,
firmware_version, replacement_reason, installed_at,
installed_by, notes
) VALUES (
%s, 'replacement', %s, %s, %s, %s, %s, %s, %s, %s,
now(), %s, %s
) RETURNING *
""",
(miner_id, repair_id, mac_address, normalized_mac, board_serial,
board_model, board_revision, firmware_version,
replacement_reason, actor_user_id, notes),
)
return dict(cur.fetchone())
def set_current_control_board(
self, miner_id: UUID, *, mac_address: str, board_serial: str | None
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE miners SET current_mac = %s, control_board_sn = %s,
updated_at = now() WHERE id = %s
""",
(mac_address, board_serial, miner_id),
)
if cur.rowcount != 1:
raise RepairPersistenceError(
"Current miner control-board projection was not updated"
)
def ensure_network_identity_history(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
)
SELECT %s, %s, 'legacy_projection', false, NULL, NULL, now()
WHERE NOT EXISTS (
SELECT 1 FROM miner_network_identities
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
)
""",
(miner_id, mac_address, miner_id, normalized_mac),
)
def set_current_network_identity(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"UPDATE miner_network_identities SET is_current = false, "
"updated_at = now() WHERE miner_id = %s AND is_current",
(miner_id,),
)
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = true, associated_at = now(), updated_at = now()
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING *
""",
(miner_id, normalized_mac),
)
row = cur.fetchone()
if row is None:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
) VALUES (%s, %s, 'control_board_replacement', true,
NULL, NULL, now())
RETURNING *
""",
(miner_id, mac_address),
)
row = cur.fetchone()
return dict(row)
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+37
View File
@@ -8,6 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from app.identity import require_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
@@ -16,6 +18,8 @@ from app.repairs.exceptions import (
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacementRequest,
ControlBoardReplacementResponse,
BeginRepairResponse,
RepairCompleteRequest,
RepairDetailResponse,
@@ -25,9 +29,11 @@ from app.repairs.service import RepairService
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
control_board_router = APIRouter(prefix="/api/miners", tags=["miners"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
require_control_board_replace = require_permission("miners.replace_control_board")
RepairServiceDependency = Annotated[
RepairService,
@@ -36,6 +42,37 @@ RepairServiceDependency = Annotated[
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)]
@control_board_router.post(
"/{miner_id}/replace-control-board",
response_model=ControlBoardReplacementResponse,
)
def replace_control_board(
miner_id: UUID,
request: ControlBoardReplacementRequest,
service: RepairServiceDependency,
current_user: ControlBoardReplaceUser,
) -> ControlBoardReplacementResponse:
try:
result = service.replace_control_board(
miner_id, request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Miner not found"
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
return ControlBoardReplacementResponse(data=result)
@router.post(
+157
View File
@@ -2,8 +2,13 @@
from uuid import UUID
import re
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementActorRequired,
ControlBoardReplacementConflict,
InvalidMacAddress,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
@@ -17,6 +22,9 @@ from app.repairs.exceptions import (
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacedPayload,
ControlBoardReplacementRequest,
ControlBoardReplacementResult,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
@@ -143,6 +151,155 @@ class RepairService:
opened_by=repair["opened_by"],
)
@staticmethod
def normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
@staticmethod
def normalize_mac_address(value: str) -> tuple[str, str]:
raw = str(value).strip()
if not raw or re.fullmatch(r"[0-9A-Fa-f:\-.\s]+", raw) is None:
raise InvalidMacAddress()
normalized = re.sub(r"[^0-9A-Fa-f]", "", raw).lower()
if len(normalized) != 12:
raise InvalidMacAddress()
first_octet = int(normalized[:2], 16)
if normalized == "000000000000":
raise InvalidMacAddress("All-zero MAC addresses are not allowed")
if normalized == "ffffffffffff":
raise InvalidMacAddress("Broadcast MAC addresses are not allowed")
if first_octet & 1:
raise InvalidMacAddress("Multicast MAC addresses are not allowed")
canonical = ":".join(
normalized[index:index + 2].upper()
for index in range(0, 12, 2)
)
return canonical, normalized
def replace_control_board(
self, miner_id: UUID, request: ControlBoardReplacementRequest, *,
actor_user_id: UUID | None
) -> ControlBoardReplacementResult:
if actor_user_id is None:
raise ControlBoardReplacementActorRequired()
new_mac, new_mac_normalized = self.normalize_mac_address(
request.new_mac_address
)
new_serial = self.normalize_optional_text(
request.new_control_board_serial
)
board_model = self.normalize_optional_text(request.board_model)
board_revision = self.normalize_optional_text(request.board_revision)
firmware_version = self.normalize_optional_text(request.firmware_version)
notes = self.normalize_note(request.notes)
with self.repository.transaction() as repository:
miner = repository.lock_miner_for_control_board_replacement(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
if str(miner["lifecycle_status"]) != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(
miner_id, miner["lifecycle_status"]
)
repair = repository.lock_active_repair_for_miner(miner_id)
if repair is None:
raise ControlBoardReplacementConflict(
"Miner has no active repair"
)
current_board = repository.lock_current_control_board(miner_id)
if current_board is None:
if not miner.get("current_mac") and not miner.get("control_board_sn"):
raise ControlBoardReplacementConflict(
"Miner has no current control-board identity to replace"
)
current_board = repository.create_legacy_control_board(miner)
old_mac = current_board.get("mac_address") or miner.get("current_mac")
old_mac_canonical = None
old_mac_normalized = None
if old_mac:
try:
old_mac_canonical, old_mac_normalized = (
self.normalize_mac_address(str(old_mac))
)
except InvalidMacAddress:
old_mac_canonical = str(old_mac)
if old_mac_normalized == new_mac_normalized:
raise ControlBoardReplacementConflict(
"New MAC address is the same as the current MAC address"
)
if old_mac_normalized is not None:
repository.ensure_network_identity_history(
miner_id, mac_address=old_mac_canonical,
normalized_mac=old_mac_normalized
)
repository.claim_mac_ownership(
new_mac_normalized, miner_id, actor_user_id
)
previous_board = repository.close_control_board(
current_board["id"],
repair_id=repair["id"],
actor_user_id=actor_user_id,
disposition=request.removed_board_disposition,
)
installation = repository.create_control_board_installation(
miner_id=miner_id,
repair_id=repair["id"],
mac_address=new_mac,
normalized_mac=new_mac_normalized,
board_serial=new_serial,
board_model=board_model,
board_revision=board_revision,
firmware_version=firmware_version,
replacement_reason=request.replacement_reason,
notes=notes,
actor_user_id=actor_user_id,
)
repository.set_current_network_identity(
miner_id, mac_address=new_mac,
normalized_mac=new_mac_normalized
)
repository.set_current_control_board(
miner_id, mac_address=new_mac, board_serial=new_serial
)
next_sequence = repository.get_next_event_sequence(repair["id"])
payload = ControlBoardReplacedPayload(
old_mac_address=old_mac_canonical,
new_mac_address=new_mac,
old_control_board_serial=previous_board.get("board_serial"),
new_control_board_serial=new_serial,
old_board_model=previous_board.get("board_model"),
new_board_model=board_model,
old_board_revision=previous_board.get("board_revision"),
new_board_revision=board_revision,
new_firmware_version=firmware_version,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=notes,
)
event = repository.create_event(
repair["id"], sequence=next_sequence,
event_type="CONTROL_BOARD_REPLACED",
created_by=actor_user_id,
payload=payload.model_dump(mode="json", exclude_none=False),
)
return ControlBoardReplacementResult(
miner_id=miner_id, asset_id=miner.get("asset_tag"),
repair_id=repair["id"], installation_id=installation["id"],
event_id=event["id"], event_sequence=event["sequence"],
current_mac=new_mac, control_board_serial=new_serial,
replaced_at=event["created_at"], replaced_by=actor_user_id,
)
def complete_repair(
self,
repair_id: UUID,