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