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

+9
View File
@@ -694,6 +694,15 @@ def repair_completion_request(path: str) -> bool:
def required_api_permission(method: str, path: str) -> str | None:
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
return "miners.replace_control_board"
if (
method == "POST"
and len(path.split("/")) == 5
and path.split("/")[:3] == ["", "api", "miners"]
and path.split("/")[4] == "replace-control-board"
):
return "miners.replace_control_board"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "POST" and repair_completion_request(path):
+82 -258
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
import jwt
import psycopg
@@ -7,7 +8,7 @@ import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.identity import (
@@ -18,7 +19,23 @@ from app.identity import (
router as identity_router,
)
from app.inventory.router import router as inventory_router
from app.repairs.router import router as repairs_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairConflict,
RepairMinerNotFound,
)
from app.repairs.models import (
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
router as repairs_router,
)
app = FastAPI(title=settings.app_name)
@@ -15430,33 +15447,17 @@ def delete_miner_maintenance_event(
conn.close()
class MinerMacChangeRequest(BaseModel):
new_mac: str
model_config = ConfigDict(extra="forbid")
new_mac: str = Field(min_length=1, max_length=64)
old_mac: str | None = None
new_ip: str | None = None
event_title: str | None = None
description: str | None = None
component_serial: str | None = None
downtime_minutes: int | None = None
def normalize_mac_display(mac: str | None):
if mac is None:
return None
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return str(mac).strip().upper()
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
def normalize_mac_key_for_change(mac: str | None):
if mac is None:
return None
return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
description: str | None = Field(default=None, max_length=4000)
component_serial: str | None = Field(default=None, max_length=255)
downtime_minutes: int | None = Field(default=None, ge=0)
removed_board_disposition: RemovedBoardDisposition | None = None
replacement_reason: ControlBoardReplacementReason | None = None
@app.post("/api/miners/{miner_id}/change-mac")
def change_miner_mac_address(
@@ -15464,244 +15465,66 @@ def change_miner_mac_address(
request: MinerMacChangeRequest,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
new_mac = normalize_mac_display(request.new_mac)
new_mac_key = normalize_mac_key_for_change(new_mac)
if not new_mac_key or len(new_mac_key) != 12:
raise HTTPException(status_code=400, detail="New MAC address is invalid")
conn = None
"""Compatibility alias for the repair-scoped replacement workflow."""
if request.replacement_reason is None or request.removed_board_disposition is None:
raise HTTPException(
status_code=422,
detail=(
"The compatibility route requires replacement_reason and "
"removed_board_disposition; use the repair-scoped replacement fields"
),
)
if request.new_ip is not None or request.downtime_minutes is not None:
raise HTTPException(
status_code=422,
detail=(
"The repair-scoped compatibility route does not change IP addresses "
"or record mutable downtime values"
),
)
try:
parsed_miner_id = UUID(miner_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc
try:
conn = get_db_connection()
user_id = get_current_user_id_for_maintenance(current_user)
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
hostname
FROM miners
WHERE id = %s::uuid;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner = dict(miner)
old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac"))
old_ip = miner.get("current_ip")
new_ip = request.new_ip or old_ip
if normalize_mac_key_for_change(old_mac) == new_mac_key:
raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC")
cur.execute(
"""
UPDATE miners
SET
current_mac = %s,
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip)
WHERE id = %s::uuid
RETURNING
id::text,
current_mac,
host(current_ip) AS current_ip;
""",
(
new_mac,
new_ip,
miner_id,
),
)
updated_miner = dict(cur.fetchone())
# Mark all known identities for this miner as not current.
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = false
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
# If this MAC already exists in the identity history, mark it current.
cur.execute(
"""
UPDATE miner_network_identities
SET
is_current = true,
ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address),
hostname = COALESCE(NULLIF(%s, ''), hostname),
last_seen_at = now()
WHERE miner_id = %s::uuid
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING id::text;
""",
(
new_ip,
miner.get("hostname"),
miner_id,
new_mac_key,
),
)
existing_identity = cur.fetchone()
if not existing_identity:
cur.execute(
"""
INSERT INTO miner_network_identities (
id,
miner_id,
mac_address,
ip_address,
hostname,
first_seen_at,
last_seen_at,
is_current
)
VALUES (
%s::uuid,
%s::uuid,
%s,
NULLIF(%s, '')::inet,
%s,
now(),
now(),
true
);
""",
(
str(uuid4()),
miner_id,
new_mac,
new_ip,
miner.get("hostname"),
),
)
event_id = str(uuid4())
event_title = request.event_title or "Control board / MAC address changed"
description_parts = []
if request.description:
description_parts.append(request.description)
description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.")
if old_ip or new_ip:
description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.")
event_description = " ".join(description_parts)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
old_ip,
new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'control_board_changed',
'completed',
%s,
%s,
now(),
%s,
%s,
NULLIF(%s, '')::inet,
NULLIF(%s, '')::inet,
'control_board',
'control_board',
%s,
%s,
%s::uuid
)
RETURNING
id::text,
miner_id::text,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
host(old_ip) AS old_ip,
host(new_ip) AS new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id::text,
created_at,
updated_at;
""",
(
event_id,
miner_id,
event_title,
event_description,
old_mac,
new_mac,
old_ip,
new_ip,
request.component_serial,
request.downtime_minutes,
user_id,
),
)
maintenance_event = dict(cur.fetchone())
conn.commit()
return api_response(
data={
"miner": updated_miner,
"maintenance_event": maintenance_event,
}
replacement = ControlBoardReplacementRequest(
new_mac_address=request.new_mac,
new_control_board_serial=request.component_serial,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=request.description,
)
except ValidationError as exc:
raise HTTPException(
status_code=422, detail="Legacy change-mac request is invalid"
) from exc
except Exception:
if conn is not None:
conn.rollback()
try:
result = RepairService(RepairRepository()).replace_control_board(
parsed_miner_id,
replacement,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except RepairConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise
replacement_data = result.model_dump(mode="json")
return api_response(
data={
"miner": {
"id": replacement_data["miner_id"],
"current_mac": replacement_data["current_mac"],
"control_board_sn": replacement_data["control_board_serial"],
},
"control_board_replacement": replacement_data,
}
)
finally:
if conn is not None:
conn.close()
@app.get("/api/maintenance/events")
def list_all_maintenance_events(
@@ -35674,3 +35497,4 @@ def minerops_repair_parts_summary_api(
# Register UUID-based Repair routes after legacy static Repair paths.
app.include_router(repairs_router)
app.include_router(control_board_router)
+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,