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
@@ -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
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user