feat: add safe miner control foundation

This commit is contained in:
lussierj committed 2026-08-24 20:49:28 +00:00
1 parent 6cd876b4b3
commit f5965af914
39 files changed
+2952 -491

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Authenticated Agent command transport for MinerOps."""
@@ -0,0 +1,37 @@
"""Dependencies for the authenticated Agent queue protocol."""
import hmac
from typing import Annotated
from fastapi import Header, HTTPException
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.service import AgentCommandService
from app.config import settings
def get_agent_command_service() -> AgentCommandService:
return AgentCommandService(AgentCommandRepository())
def require_agent_identity(
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
provided_secret: Annotated[
str | None, Header(alias="X-MinerOps-Agent-Secret")
] = None,
) -> str:
expected_id = settings.minerops_agent_id.strip()
expected_secret = settings.minerops_agent_shared_secret.strip()
if not expected_id or not expected_secret:
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
secret = (provided_secret or "").strip()
if not secret or not hmac.compare_digest(secret, expected_secret):
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
identity = (agent_id or "").strip()
if not identity:
raise HTTPException(status_code=401, detail="Missing Agent identity")
if not hmac.compare_digest(identity, expected_id):
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
return identity
+17
View File
@@ -0,0 +1,17 @@
"""Domain-neutral Agent command protocol errors."""
class AgentCommandError(Exception):
"""Base Agent command protocol error."""
class AgentCommandNotFound(AgentCommandError):
"""The submitted command identifier is unknown."""
class AgentCommandConflict(AgentCommandError):
"""The queue row cannot accept the requested transition."""
class AgentResultValidationError(AgentCommandError):
"""The Agent result does not match its command contract."""
+57
View File
@@ -0,0 +1,57 @@
"""Typed contracts for the Agent queue protocol."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class AgentCommand(BaseModel):
command_id: str
command_type: str
request_payload: dict[str, Any]
created_at: datetime
class AgentCommandListResponse(BaseModel):
ok: Literal[True] = True
commands: list[AgentCommand]
class MinerControlResultPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[1]
command_accepted: bool
verification_attempted: bool
verification_status: Literal[
"confirmed", "verification_failed", "unconfirmed", "unavailable"
]
observed_service_state: None = None
observed_performance_mode: Literal["low", "normal", "high"] | None = None
failure_code: str | None = Field(
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
)
@model_validator(mode="after")
def validate_truthfulness(self) -> "MinerControlResultPayload":
if self.verification_status == "confirmed" and not self.verification_attempted:
raise ValueError("confirmed results require verification_attempted")
if not self.command_accepted and not self.failure_code:
raise ValueError("rejected commands require a failure_code")
return self
class AgentCommandResultRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command_id: str = Field(min_length=1, max_length=128)
status: Literal["completed", "failed"]
result_payload: dict[str, Any] = Field(default_factory=dict)
error_message: str | None = Field(default=None, max_length=255)
class AgentCommandResultResponse(BaseModel):
ok: Literal[True] = True
command_id: str
status: Literal["completed", "failed"]
+130
View File
@@ -0,0 +1,130 @@
"""PostgreSQL persistence for Agent command transport."""
from collections.abc import Callable
from typing import Any
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
CLAIM_COMMANDS_SQL = """
WITH candidates AS (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = %s
AND command_status = 'pending'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT %s
)
UPDATE minerops_agent_command_queue AS queue
SET command_status = 'claimed', claimed_at = now()
FROM candidates
WHERE queue.id = candidates.id
AND queue.command_status = 'pending'
RETURNING queue.id AS command_id, queue.command_type,
queue.request_payload, queue.created_at
"""
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 AgentCommandRepository:
"""Own queue locking and immutable terminal transitions."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
rows = [dict(row) for row in cur.fetchall()]
connection.commit()
return rows
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_command(self, command_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, agent_id, command_type, command_status, request_payload
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def finish_command(
self,
command_id: str,
agent_id: str,
status: str,
result_payload: dict[str, Any],
error_message: str | None,
) -> None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET command_status = %s, result_payload = %s,
error_message = %s, completed_at = now()
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
RETURNING id
""",
(status, Jsonb(result_payload), error_message, command_id, agent_id),
)
if cur.fetchone():
connection.commit()
return
cur.execute(
"""
SELECT agent_id, command_status
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
connection.rollback()
if not row:
raise AgentCommandNotFound("Agent command not found")
if row["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
raise AgentCommandConflict(
f"Agent command is {row['command_status']}, not claimed"
)
except (AgentCommandNotFound, AgentCommandConflict):
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
+57
View File
@@ -0,0 +1,57 @@
"""HTTP boundary for authenticated Agent command transport."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommandListResponse,
AgentCommandResultRequest,
AgentCommandResultResponse,
)
from app.agent_commands.service import AgentCommandService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
@router.get("/pending", response_model=AgentCommandListResponse)
def claim_pending_commands(
request: Request, agent_id: AgentIdentity, service: AgentService
) -> AgentCommandListResponse:
before_claim = getattr(
request.app.state, "agent_command_before_claim", None
)
if callable(before_claim):
try:
before_claim()
except Exception:
logger.exception("Scheduled command materialization failed before Agent claim")
return AgentCommandListResponse(commands=service.claim(agent_id))
@router.post("/result", response_model=AgentCommandResultResponse)
def submit_command_result(
request: AgentCommandResultRequest,
agent_id: AgentIdentity,
service: AgentService,
) -> AgentCommandResultResponse:
try:
service.submit_result(agent_id, request)
except AgentCommandNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentCommandConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentResultValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
+87
View File
@@ -0,0 +1,87 @@
"""Agent command transport coordination and result validation."""
import re
from pydantic import ValidationError
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommand,
AgentCommandResultRequest,
MinerControlResultPayload,
)
from app.agent_commands.repository import AgentCommandRepository
class AgentCommandService:
def __init__(self, repository: AgentCommandRepository):
self.repository = repository
def claim(self, agent_id: str) -> list[AgentCommand]:
return [
AgentCommand.model_validate(row)
for row in self.repository.claim_pending(agent_id)
]
@staticmethod
def _legacy_error(value: str | None) -> str | None:
if not value:
return None
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
return sanitized[:255]
def submit_result(
self, agent_id: str, request: AgentCommandResultRequest
) -> None:
command = self.repository.get_command(request.command_id)
if not command:
raise AgentCommandNotFound("Agent command not found")
if command["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
if command["command_status"] != "claimed":
raise AgentCommandConflict(
f"Agent command is {command['command_status']}, not claimed"
)
result_payload = request.result_payload
error_message = self._legacy_error(request.error_message)
if command["command_type"] == "miner_control":
try:
result = MinerControlResultPayload.model_validate(request.result_payload)
except ValidationError as exc:
raise AgentResultValidationError(
"Invalid typed miner-control result"
) from exc
if request.status == "completed" and not result.command_accepted:
raise AgentResultValidationError(
"Completed miner-control results must be accepted"
)
if request.status == "failed" and result.command_accepted:
raise AgentResultValidationError(
"Failed miner-control results cannot be accepted"
)
action = (command.get("request_payload") or {}).get("action")
if (
action in {"suspend", "resume"}
and result.command_accepted
and result.verification_status != "unconfirmed"
):
raise AgentResultValidationError(
"Accepted suspend and resume results must remain unconfirmed"
)
result_payload = result.model_dump(mode="json")
error_message = (
result.failure_code.replace("_", " ") if result.failure_code else None
)
self.repository.finish_command(
request.command_id,
agent_id,
request.status,
result_payload,
error_message,
)