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

+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)