feat: add begin repair workflow
This commit is contained in:
1 parent
d738b8e27e
commit
f6a2512e23
21 files changed
+3108
-5
No files matched your search
@@ -674,6 +674,8 @@ def machine_agent_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 == "/api/repairs":
|
||||
return "repairs.create"
|
||||
if method == "GET":
|
||||
if path.startswith("/api/system/"):
|
||||
return "system_health.view"
|
||||
|
||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||
|
||||
|
||||
LifecycleStatus = Literal["active", "spare", "retired"]
|
||||
LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
|
||||
|
||||
|
||||
class InventoryTag(BaseModel):
|
||||
|
||||
@@ -24,7 +24,9 @@ from app.inventory.models import (
|
||||
from app.inventory.repository import InventoryRepository
|
||||
|
||||
|
||||
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
|
||||
ALLOWED_LIFECYCLE_STATUSES = frozenset(
|
||||
{"active", "spare", "retired", "in_repair"}
|
||||
)
|
||||
|
||||
|
||||
class InventoryService:
|
||||
|
||||
@@ -18,11 +18,13 @@ 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
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(repairs_router)
|
||||
install_identity_middleware(app)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Repair workflow models, persistence, and business services."""
|
||||
|
||||
from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActorRequired,
|
||||
RepairConflict,
|
||||
RepairError,
|
||||
RepairMinerNotFound,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
RepairReason,
|
||||
RepairStatus,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
__all__ = [
|
||||
"ActiveRepairConflict",
|
||||
"BeginRepairRequest",
|
||||
"BeginRepairResult",
|
||||
"IncompatibleMinerLifecycle",
|
||||
"InconsistentRepairState",
|
||||
"RepairActorRequired",
|
||||
"RepairConflict",
|
||||
"RepairError",
|
||||
"RepairMinerNotFound",
|
||||
"RepairPersistenceError",
|
||||
"RepairReason",
|
||||
"RepairRepository",
|
||||
"RepairService",
|
||||
"RepairStatus",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Repairs domain."""
|
||||
|
||||
from app.repairs.repository import RepairRepository
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
def get_repair_service() -> RepairService:
|
||||
return RepairService(RepairRepository())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Domain exceptions for repair workflows."""
|
||||
|
||||
|
||||
class RepairError(Exception):
|
||||
"""Base exception for repair operations."""
|
||||
|
||||
|
||||
class RepairMinerNotFound(RepairError):
|
||||
"""Raised when the requested miner does not exist."""
|
||||
|
||||
def __init__(self, miner_id: object):
|
||||
self.miner_id = miner_id
|
||||
super().__init__(f"Miner not found: {miner_id}")
|
||||
|
||||
|
||||
class RepairConflict(RepairError):
|
||||
"""Base exception for a conflicting repair state."""
|
||||
|
||||
|
||||
class ActiveRepairConflict(RepairConflict):
|
||||
"""Raised when a miner already has an active repair."""
|
||||
|
||||
def __init__(self, miner_id: object):
|
||||
self.miner_id = miner_id
|
||||
super().__init__(f"Miner already has an active repair: {miner_id}")
|
||||
|
||||
|
||||
class IncompatibleMinerLifecycle(RepairConflict):
|
||||
"""Raised when the miner lifecycle cannot enter repair."""
|
||||
|
||||
def __init__(self, miner_id: object, lifecycle_status: object):
|
||||
self.miner_id = miner_id
|
||||
self.lifecycle_status = lifecycle_status
|
||||
super().__init__(
|
||||
"Miner lifecycle is incompatible with beginning repair: "
|
||||
f"{lifecycle_status}"
|
||||
)
|
||||
|
||||
|
||||
class InconsistentRepairState(RepairConflict):
|
||||
"""Raised when lifecycle state and repair records disagree."""
|
||||
|
||||
def __init__(self, miner_id: object):
|
||||
self.miner_id = miner_id
|
||||
super().__init__(
|
||||
"Miner is marked in repair but has no active repair record"
|
||||
)
|
||||
|
||||
|
||||
class RepairActorRequired(RepairError):
|
||||
"""Raised when an operational workflow lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("An authenticated actor is required to begin repair")
|
||||
|
||||
|
||||
class RepairPersistenceError(RepairError):
|
||||
"""Raised when a required repair persistence operation fails."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Typed contracts for the Repairs domain."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
RepairReason = Literal[
|
||||
"HARDWARE_FAILURE",
|
||||
"LOW_HASHRATE",
|
||||
"CONTROL_BOARD",
|
||||
"HASHBOARD",
|
||||
"PSU",
|
||||
"FAN",
|
||||
"OVERHEATING",
|
||||
"NETWORKING",
|
||||
"PREVENTIVE_MAINTENANCE",
|
||||
"INSPECTION",
|
||||
"OTHER",
|
||||
]
|
||||
RepairStatus = Literal[
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_PARTS",
|
||||
"READY_FOR_TEST",
|
||||
"COMPLETED",
|
||||
"CANCELLED",
|
||||
]
|
||||
RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"]
|
||||
RepairEligibleLifecycle = Literal["active", "spare"]
|
||||
|
||||
|
||||
class BeginRepairRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
miner_id: UUID
|
||||
reason: RepairReason
|
||||
notes: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class RepairStartedPayload(BaseModel):
|
||||
reason: RepairReason
|
||||
from_lifecycle: RepairEligibleLifecycle
|
||||
to_lifecycle: Literal["in_repair"] = "in_repair"
|
||||
|
||||
|
||||
class InitialRepairNotePayload(BaseModel):
|
||||
note: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class BeginRepairResult(BaseModel):
|
||||
repair_id: UUID
|
||||
miner_id: UUID
|
||||
status: Literal["OPEN"]
|
||||
reason: RepairReason
|
||||
opened_at: datetime
|
||||
opened_by: UUID
|
||||
|
||||
|
||||
class BeginRepairResponse(BaseModel):
|
||||
data: BeginRepairResult
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,225 @@
|
||||
"""PostgreSQL persistence for repair workflows."""
|
||||
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.config import settings
|
||||
from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
RepairPersistenceError,
|
||||
)
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
ACTIVE_REPAIR_STATUSES = (
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_PARTS",
|
||||
"READY_FOR_TEST",
|
||||
)
|
||||
ACTIVE_REPAIR_CONSTRAINT = "uq_repairs_one_active_per_miner"
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class RepairRepository:
|
||||
"""Persistence boundary for the atomic Begin Repair workflow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator["RepairRepository"]:
|
||||
if self._connection is not None:
|
||||
yield self
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
transaction_repository = RepairRepository(
|
||||
self._connection_factory,
|
||||
connection=connection,
|
||||
)
|
||||
try:
|
||||
yield transaction_repository
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(
|
||||
self,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
if write:
|
||||
connection.commit()
|
||||
except Exception:
|
||||
if write:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def lock_miner_for_update(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, lifecycle_status
|
||||
FROM miners
|
||||
WHERE id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
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:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
miner_id,
|
||||
status,
|
||||
reason,
|
||||
opened_at,
|
||||
opened_by,
|
||||
completed_at,
|
||||
completed_by
|
||||
FROM repairs
|
||||
WHERE miner_id = %s
|
||||
AND status = ANY(%s)
|
||||
ORDER BY opened_at, id
|
||||
LIMIT 1
|
||||
""",
|
||||
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def create_repair(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
reason: str,
|
||||
opened_by: UUID,
|
||||
) -> dict:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO repairs (miner_id, reason, opened_by)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING
|
||||
id,
|
||||
miner_id,
|
||||
status,
|
||||
reason,
|
||||
opened_at,
|
||||
opened_by,
|
||||
completed_at,
|
||||
completed_by
|
||||
""",
|
||||
(miner_id, reason, opened_by),
|
||||
)
|
||||
except UniqueViolation as exc:
|
||||
if exc.diag.constraint_name == ACTIVE_REPAIR_CONSTRAINT:
|
||||
raise ActiveRepairConflict(miner_id) from exc
|
||||
raise
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
repair_id: UUID,
|
||||
*,
|
||||
sequence: int,
|
||||
event_type: str,
|
||||
created_by: UUID,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO repair_events (
|
||||
repair_id,
|
||||
sequence,
|
||||
event_type,
|
||||
created_by,
|
||||
payload
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id,
|
||||
repair_id,
|
||||
sequence,
|
||||
event_type,
|
||||
created_at,
|
||||
created_by,
|
||||
payload
|
||||
""",
|
||||
(
|
||||
repair_id,
|
||||
sequence,
|
||||
event_type,
|
||||
created_by,
|
||||
Jsonb(dict(payload)),
|
||||
),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def set_miner_lifecycle(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
lifecycle_status: str,
|
||||
) -> None:
|
||||
with self._connection_scope(write=True) as connection:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE miners
|
||||
SET lifecycle_status = %s,
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(lifecycle_status, miner_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise RepairPersistenceError(
|
||||
"Miner lifecycle transition did not update one record"
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""HTTP mapping for explicit repair workflows."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
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 (
|
||||
RepairActorRequired,
|
||||
RepairConflict,
|
||||
RepairMinerNotFound,
|
||||
)
|
||||
from app.repairs.models import BeginRepairRequest, BeginRepairResponse
|
||||
from app.repairs.service import RepairService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
|
||||
require_repair_create = require_permission("repairs.create")
|
||||
|
||||
RepairServiceDependency = Annotated[
|
||||
RepairService,
|
||||
Depends(get_repair_service),
|
||||
]
|
||||
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=BeginRepairResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def begin_repair(
|
||||
request: BeginRepairRequest,
|
||||
service: RepairServiceDependency,
|
||||
current_user: RepairCreateUser,
|
||||
) -> BeginRepairResponse:
|
||||
try:
|
||||
repair = service.begin_repair(
|
||||
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 RepairActorRequired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return BeginRepairResponse(data=repair)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Business rules and orchestration for repair workflows."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.repairs.exceptions import (
|
||||
ActiveRepairConflict,
|
||||
IncompatibleMinerLifecycle,
|
||||
InconsistentRepairState,
|
||||
RepairActorRequired,
|
||||
RepairMinerNotFound,
|
||||
)
|
||||
from app.repairs.models import (
|
||||
BeginRepairRequest,
|
||||
BeginRepairResult,
|
||||
InitialRepairNotePayload,
|
||||
RepairStartedPayload,
|
||||
)
|
||||
from app.repairs.repository import RepairRepository
|
||||
|
||||
|
||||
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
|
||||
REPAIR_LIFECYCLE_STATUS = "in_repair"
|
||||
|
||||
|
||||
class RepairService:
|
||||
"""Explicit operational repair workflows independent of FastAPI."""
|
||||
|
||||
def __init__(self, repository: RepairRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def normalize_note(note: str | None) -> str | None:
|
||||
if note is None:
|
||||
return None
|
||||
normalized = str(note).strip()
|
||||
return normalized or None
|
||||
|
||||
def begin_repair(
|
||||
self,
|
||||
request: BeginRepairRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> BeginRepairResult:
|
||||
if actor_user_id is None:
|
||||
raise RepairActorRequired()
|
||||
|
||||
note = self.normalize_note(request.notes)
|
||||
|
||||
with self.repository.transaction() as repository:
|
||||
miner = repository.lock_miner_for_update(request.miner_id)
|
||||
if not miner:
|
||||
raise RepairMinerNotFound(request.miner_id)
|
||||
|
||||
lifecycle_status = str(miner["lifecycle_status"])
|
||||
active_repair = repository.get_active_repair(request.miner_id)
|
||||
if active_repair:
|
||||
raise ActiveRepairConflict(request.miner_id)
|
||||
if lifecycle_status == REPAIR_LIFECYCLE_STATUS:
|
||||
raise InconsistentRepairState(request.miner_id)
|
||||
if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES:
|
||||
raise IncompatibleMinerLifecycle(
|
||||
request.miner_id,
|
||||
lifecycle_status,
|
||||
)
|
||||
|
||||
repair = repository.create_repair(
|
||||
request.miner_id,
|
||||
request.reason,
|
||||
actor_user_id,
|
||||
)
|
||||
started_payload = RepairStartedPayload(
|
||||
reason=request.reason,
|
||||
from_lifecycle=lifecycle_status,
|
||||
)
|
||||
repository.create_event(
|
||||
repair["id"],
|
||||
sequence=1,
|
||||
event_type="REPAIR_STARTED",
|
||||
created_by=actor_user_id,
|
||||
payload=started_payload.model_dump(mode="json"),
|
||||
)
|
||||
if note is not None:
|
||||
note_payload = InitialRepairNotePayload(note=note)
|
||||
repository.create_event(
|
||||
repair["id"],
|
||||
sequence=2,
|
||||
event_type="INITIAL_NOTE",
|
||||
created_by=actor_user_id,
|
||||
payload=note_payload.model_dump(mode="json"),
|
||||
)
|
||||
repository.set_miner_lifecycle(
|
||||
request.miner_id,
|
||||
REPAIR_LIFECYCLE_STATUS,
|
||||
)
|
||||
|
||||
return BeginRepairResult(
|
||||
repair_id=repair["id"],
|
||||
miner_id=repair["miner_id"],
|
||||
status=repair["status"],
|
||||
reason=repair["reason"],
|
||||
opened_at=repair["opened_at"],
|
||||
opened_by=repair["opened_by"],
|
||||
)
|
||||
Reference in new issue
Block a user