feat: add safe miner control foundation
This commit is contained in:
1 parent
6cd876b4b3
commit
f5965af914
39 files changed
+2952
-491
No files matched your search
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
|
||||
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
minerops_agent_id: str = "plexus-windows-agent"
|
||||
minerops_agent_shared_secret: str = ""
|
||||
|
||||
jwt_secret_key: str
|
||||
jwt_access_token_minutes: int = 30
|
||||
jwt_refresh_token_days: int = 14
|
||||
|
||||
@@ -48,6 +48,38 @@ def miner_detail_content() -> str:
|
||||
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel miner-controls" id="minerControlsSection"
|
||||
aria-labelledby="minerControlsTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
|
||||
<h2 id="minerControlsTitle">Controls</h2></div>
|
||||
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
|
||||
</div>
|
||||
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
|
||||
Loading factual capability and recent operations.
|
||||
</p>
|
||||
<div id="minerControlCapabilities" class="control-capability-grid"></div>
|
||||
<div id="minerControlActions" class="miner-control-actions" hidden>
|
||||
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
|
||||
<form id="minerPerformanceControl" class="performance-control" hidden>
|
||||
<label for="minerPerformanceMode">Performance mode</label>
|
||||
<select id="minerPerformanceMode">
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<button class="command-button" type="submit">Set mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p id="minerControlPermission" class="permission-note" hidden>
|
||||
Your account can inspect control capability and history but cannot queue controls.
|
||||
</p>
|
||||
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
|
||||
<div class="table-wrap compact-history-table">
|
||||
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
|
||||
<th>Verification</th><th>Created</th></tr></thead>
|
||||
<tbody id="minerControlHistory"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
||||
<h2 id="minerLocationTitle">Location</h2></div></div>
|
||||
|
||||
@@ -1722,3 +1722,52 @@ button.map-position:hover {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
.control-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.control-capability-grid > div {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.miner-control-actions {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.performance-control label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-callout {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-left: 4px solid var(--warn);
|
||||
background: #fff8e6;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.control-capability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const minerId = document.body.dataset.minerId;
|
||||
const returnTo = document.body.dataset.returnTo || "";
|
||||
const canControl = document.body.dataset.canControlMiners === "true";
|
||||
const el = {
|
||||
loading: document.getElementById("minerDetailLoading"),
|
||||
error: document.getElementById("minerDetailError"),
|
||||
@@ -32,7 +33,17 @@
|
||||
identityHistory: document.getElementById("minerIdentityHistory"),
|
||||
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
||||
observations: document.getElementById("minerObservations"),
|
||||
observationsBody: document.getElementById("minerObservationsBody")
|
||||
observationsBody: document.getElementById("minerObservationsBody"),
|
||||
controlsRefresh: document.getElementById("minerControlsRefresh"),
|
||||
controlsSummary: document.getElementById("minerControlsSummary"),
|
||||
controlCapabilities: document.getElementById("minerControlCapabilities"),
|
||||
controlActions: document.getElementById("minerControlActions"),
|
||||
controlPermission: document.getElementById("minerControlPermission"),
|
||||
controlResult: document.getElementById("minerControlResult"),
|
||||
serviceControl: document.getElementById("minerServiceControl"),
|
||||
performanceForm: document.getElementById("minerPerformanceControl"),
|
||||
performanceMode: document.getElementById("minerPerformanceMode"),
|
||||
controlHistory: document.getElementById("minerControlHistory")
|
||||
};
|
||||
|
||||
function create(tag, className, text) {
|
||||
@@ -121,6 +132,123 @@
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
async function mutate(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.assign("/login?next=" + encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
));
|
||||
throw new Error("Login required");
|
||||
}
|
||||
let payload = null;
|
||||
try { payload = await response.json(); } catch (_error) { payload = null; }
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && payload.detail
|
||||
? String(payload.detail) : "Request failed (" + response.status + ")");
|
||||
}
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
function capabilityText(capability) {
|
||||
if (!capability) return "Unknown";
|
||||
const reason = capability.reason ? " / " + words(capability.reason) : "";
|
||||
return words(capability.state) + reason;
|
||||
}
|
||||
|
||||
function operationStatus(operation) {
|
||||
if (operation.status === "pending") return "Pending";
|
||||
if (operation.status === "claimed") return "Running";
|
||||
if (operation.status === "failed") return "Failed";
|
||||
if (operation.status === "cancelled") return "Cancelled";
|
||||
if (operation.verification_status === "confirmed") return "Confirmed";
|
||||
if (operation.verification_status === "unconfirmed") return "Unconfirmed";
|
||||
if (operation.verification_status === "verification_failed") return "Verification failed";
|
||||
if (operation.verification_status === "unavailable") return "Verification unavailable";
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
function renderControls(data) {
|
||||
el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) +
|
||||
" / Known mode: " + words(data.performance_mode);
|
||||
el.controlCapabilities.replaceChildren();
|
||||
[["Agent", data.agent], ["Service control", data.service_control],
|
||||
["Performance mode", data.performance_mode_control]].forEach(function (item) {
|
||||
const block = create("div");
|
||||
block.append(create("span", "secondary", item[0]),
|
||||
create("strong", "", capabilityText(item[1])));
|
||||
el.controlCapabilities.appendChild(block);
|
||||
});
|
||||
|
||||
el.controlPermission.hidden = canControl;
|
||||
el.controlActions.hidden = !canControl;
|
||||
el.serviceControl.hidden = true;
|
||||
el.performanceForm.hidden = true;
|
||||
if (canControl && data.available_service_action) {
|
||||
el.serviceControl.dataset.action = data.available_service_action;
|
||||
el.serviceControl.textContent = data.available_service_action === "suspend"
|
||||
? "Suspend miner" : "Resume miner";
|
||||
el.serviceControl.hidden = false;
|
||||
}
|
||||
if (canControl && data.performance_mode_control.state === "supported") {
|
||||
if (data.performance_mode) el.performanceMode.value = data.performance_mode;
|
||||
el.performanceForm.hidden = false;
|
||||
}
|
||||
|
||||
el.controlHistory.replaceChildren();
|
||||
(data.recent_operations || []).forEach(function (operation) {
|
||||
const row = create("tr");
|
||||
[words(operation.action), display(operation.requested_value, "None"),
|
||||
operationStatus(operation), words(operation.verification_status),
|
||||
dateTime(operation.created_at)].forEach(function (value) {
|
||||
row.appendChild(create("td", "", value));
|
||||
});
|
||||
el.controlHistory.appendChild(row);
|
||||
});
|
||||
if (!(data.recent_operations || []).length) {
|
||||
const row = create("tr");
|
||||
const cell = create("td", "empty-copy", "No control operations recorded.");
|
||||
cell.colSpan = 5;
|
||||
row.appendChild(cell);
|
||||
el.controlHistory.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControls() {
|
||||
el.controlsSummary.textContent = "Loading control capability and history.";
|
||||
try {
|
||||
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls");
|
||||
renderControls(data);
|
||||
} catch (error) {
|
||||
el.controlsSummary.textContent = "Controls unavailable: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueControl(action, requestedValue) {
|
||||
const description = action === "performance_mode"
|
||||
? "set performance mode to " + requestedValue : action + " this miner";
|
||||
if (!window.confirm("Confirm: " + description + "?")) return;
|
||||
const pathAction = action === "performance_mode" ? "performance-mode" : action;
|
||||
const body = action === "performance_mode" ? { mode: requestedValue } : {};
|
||||
el.controlResult.hidden = false;
|
||||
el.controlResult.textContent = "Queueing control operation.";
|
||||
try {
|
||||
const operation = await mutate(
|
||||
"/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction,
|
||||
body
|
||||
);
|
||||
el.controlResult.textContent = "Pending: " + words(operation.action) +
|
||||
" operation " + operation.operation_id + ".";
|
||||
await loadControls();
|
||||
} catch (error) {
|
||||
el.controlResult.textContent = "Control was not queued: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
const wrap = create("div", "table-wrap compact-history-table");
|
||||
const tableNode = create("table");
|
||||
@@ -413,8 +541,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadOverview();
|
||||
loadControls();
|
||||
}
|
||||
|
||||
el.retry.addEventListener("click", loadOverview);
|
||||
el.refresh.addEventListener("click", loadOverview);
|
||||
el.refresh.addEventListener("click", refreshAll);
|
||||
el.controlsRefresh.addEventListener("click", loadControls);
|
||||
el.serviceControl.addEventListener("click", function () {
|
||||
queueControl(el.serviceControl.dataset.action, null);
|
||||
});
|
||||
el.performanceForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
queueControl("performance_mode", el.performanceMode.value);
|
||||
});
|
||||
el.identityHistory.addEventListener("toggle", function () {
|
||||
if (el.identityHistory.open) loadIdentityHistory();
|
||||
});
|
||||
@@ -422,4 +563,5 @@
|
||||
if (el.observations.open) loadObservations();
|
||||
});
|
||||
loadOverview();
|
||||
loadControls();
|
||||
})();
|
||||
@@ -521,6 +521,7 @@ def render_app_page(
|
||||
</head>
|
||||
<body data-view="{escape(view)}"
|
||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
|
||||
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
|
||||
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||
<div class="app-shell">
|
||||
|
||||
@@ -757,6 +757,17 @@ def component_request(path: str) -> bool:
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
parts = path.split("/")
|
||||
if (
|
||||
len(parts) >= 5
|
||||
and parts[:3] == ["", "api", "miners"]
|
||||
and parts[4] == "controls"
|
||||
):
|
||||
if method == "GET" and len(parts) == 5:
|
||||
return "miners.view"
|
||||
if method == "POST" and len(parts) == 6 and parts[5] in {
|
||||
"suspend", "resume", "performance-mode"
|
||||
}:
|
||||
return "miners.control"
|
||||
if (
|
||||
method == "GET"
|
||||
and len(parts) == 5
|
||||
|
||||
+20
-165
@@ -11,6 +11,8 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||
|
||||
from app.config import settings
|
||||
from app.agent_commands.router import router as agent_commands_router
|
||||
from app.miner_control.router import router as miner_control_router
|
||||
from app.dashboard.router import router as dashboard_router
|
||||
from app.identity import (
|
||||
COOKIE_TOKEN_HINT,
|
||||
@@ -63,6 +65,8 @@ app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(frontend_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(miner_control_router)
|
||||
app.include_router(agent_commands_router)
|
||||
app.include_router(components_router)
|
||||
app.include_router(locations_router)
|
||||
app.include_router(dashboard_router)
|
||||
@@ -4010,6 +4014,8 @@ def minerops_process_due_scheduled_bulk_actions():
|
||||
conn.close()
|
||||
|
||||
|
||||
app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions
|
||||
|
||||
|
||||
def minerops_reconcile_scheduled_bulk_action_statuses():
|
||||
minerops_ensure_scheduled_bulk_actions_table()
|
||||
@@ -24985,8 +24991,7 @@ async def minerops_hub_mode_js():
|
||||
# -----------------------------
|
||||
|
||||
def minerops_agent_secret_expected() -> str:
|
||||
import os
|
||||
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
|
||||
return settings.minerops_agent_shared_secret.strip()
|
||||
|
||||
|
||||
def minerops_require_agent_secret(request: Request):
|
||||
@@ -25089,6 +25094,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
body = {}
|
||||
|
||||
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
|
||||
site_id = str(body.get("site_id") or "").strip()
|
||||
site_name = str(body.get("site_name") or "Plexus").strip()
|
||||
hostname = str(body.get("hostname") or "").strip()
|
||||
version = str(body.get("version") or "").strip()
|
||||
@@ -25101,6 +25107,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
@@ -25108,9 +25115,10 @@ async def minerops_agent_checkin(request: Request):
|
||||
last_checkin_at,
|
||||
last_payload
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb)
|
||||
VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb)
|
||||
ON CONFLICT (agent_id)
|
||||
DO UPDATE SET
|
||||
site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id),
|
||||
site_name = EXCLUDED.site_name,
|
||||
hostname = EXCLUDED.hostname,
|
||||
version = EXCLUDED.version,
|
||||
@@ -25119,6 +25127,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""",
|
||||
agent_id,
|
||||
site_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
@@ -25301,12 +25310,11 @@ async def minerops_agent_setup_info(current_user: dict = Depends(get_current_use
|
||||
return {
|
||||
"ok": True,
|
||||
"hub_url": "https://plexus.lussier.cc",
|
||||
"agent_id": "plexus-windows-agent",
|
||||
"agent_id": settings.minerops_agent_id,
|
||||
"site_name": "Plexus",
|
||||
"agent_secret": secret,
|
||||
"secret_length": len(secret),
|
||||
"secret_configured": bool(secret),
|
||||
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
|
||||
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
|
||||
"message": "Agent credentials are provisioned locally and are never returned to browsers.",
|
||||
}
|
||||
|
||||
|
||||
@@ -25353,7 +25361,7 @@ async def minerops_agent_setup_page():
|
||||
<div class="card">
|
||||
<h2>Agent Connection Info</h2>
|
||||
<div class="warn">
|
||||
Keep this secret private. Anyone with the secret can submit agent data to this hub.
|
||||
Agent credentials must be provisioned directly on the Windows host.
|
||||
</div>
|
||||
|
||||
<label>Hub URL</label>
|
||||
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page():
|
||||
<label>Site Name</label>
|
||||
<input id="siteName" readonly>
|
||||
|
||||
<label>Agent Shared Secret</label>
|
||||
<input id="agentSecret" type="password" readonly>
|
||||
|
||||
<p>
|
||||
<button onclick="toggleSecret()">Show / Hide Secret</button>
|
||||
<button onclick="copySecret()">Copy Secret</button>
|
||||
<button onclick="copyConfig()">Copy Full Config JSON</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Windows Agent Config JSON</h2>
|
||||
<p class="small">Paste this into <strong>C:\\MinerOpsAgent\\agent-config.json</strong> if you need to recreate the agent config.</p>
|
||||
<textarea id="configJson" readonly></textarea>
|
||||
<p id="secretStatus" class="small"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -25427,42 +25422,14 @@ async function loadSetup() {
|
||||
document.getElementById("hubUrl").value = data.hub_url || "";
|
||||
document.getElementById("agentId").value = data.agent_id || "";
|
||||
document.getElementById("siteName").value = data.site_name || "";
|
||||
document.getElementById("agentSecret").value = data.agent_secret || "";
|
||||
|
||||
const config = {
|
||||
agent_id: data.agent_id,
|
||||
site_name: data.site_name,
|
||||
hub_url: data.hub_url,
|
||||
agent_secret: data.agent_secret,
|
||||
poll_interval_seconds: 120,
|
||||
ip_ranges: [
|
||||
{ name: "Unit 4", cidr: "10.30.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
|
||||
{ name: "Unit 6", cidr: "10.80.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
|
||||
{ name: "Unit 12", cidr: "10.90.0.0/22", enabled: false, api_port: 4028, api3_port: 4433 }
|
||||
]
|
||||
};
|
||||
|
||||
document.getElementById("configJson").value = JSON.stringify(config, null, 2);
|
||||
document.getElementById("secretStatus").textContent = data.secret_configured
|
||||
? "A shared Agent secret is configured on the Hub. Its value is not exposed here."
|
||||
: "The Hub does not currently have an Agent secret configured.";
|
||||
|
||||
status.className = "ok";
|
||||
status.textContent = "Agent setup info loaded.";
|
||||
}
|
||||
|
||||
function toggleSecret() {
|
||||
const input = document.getElementById("agentSecret");
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(document.getElementById("agentSecret").value);
|
||||
alert("Secret copied.");
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
await navigator.clipboard.writeText(document.getElementById("configJson").value);
|
||||
alert("Config JSON copied.");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadSetup);
|
||||
</script>
|
||||
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
|
||||
@@ -25939,118 +25906,6 @@ async def minerops_agent_command_status(current_user: dict = Depends(get_current
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/agent/commands/pending")
|
||||
async def minerops_agent_commands_pending_v2(request: Request):
|
||||
# __SCHEDULE_DUE_HOOK_V2__
|
||||
try:
|
||||
minerops_process_due_scheduled_bulk_actions()
|
||||
except NameError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"Scheduled bulk action due processing error: {exc}")
|
||||
|
||||
minerops_require_agent_secret(request)
|
||||
|
||||
await minerops_ensure_agent_command_queue_table()
|
||||
|
||||
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
|
||||
|
||||
conn = await minerops_command_db_connect()
|
||||
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET
|
||||
command_status = 'claimed',
|
||||
claimed_at = now()
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = $1
|
||||
AND command_status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
command_type,
|
||||
request_payload,
|
||||
created_at
|
||||
""",
|
||||
agent_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
commands = []
|
||||
|
||||
for row in rows:
|
||||
commands.append(
|
||||
{
|
||||
"command_id": row["id"],
|
||||
"command_type": row["command_type"],
|
||||
"request_payload": row["request_payload"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/agent/commands/result")
|
||||
async def minerops_agent_commands_result_v2(request: Request):
|
||||
import json
|
||||
|
||||
minerops_require_agent_secret(request)
|
||||
|
||||
body = await request.json()
|
||||
|
||||
command_id = str(body.get("command_id") or "").strip()
|
||||
status = str(body.get("status") or "").strip().lower()
|
||||
result_payload = body.get("result_payload") or {}
|
||||
error_message = body.get("error_message")
|
||||
|
||||
if not command_id:
|
||||
raise HTTPException(status_code=400, detail="Missing command_id.")
|
||||
|
||||
if status not in {"completed", "failed"}:
|
||||
status = "completed"
|
||||
|
||||
await minerops_ensure_agent_command_queue_table()
|
||||
|
||||
conn = await minerops_command_db_connect()
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET
|
||||
command_status = $2,
|
||||
result_payload = $3::jsonb,
|
||||
error_message = $4,
|
||||
completed_at = now()
|
||||
WHERE id = $1
|
||||
""",
|
||||
command_id,
|
||||
status,
|
||||
json.dumps(result_payload),
|
||||
error_message,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"command_id": command_id,
|
||||
"status": status,
|
||||
"message": "Agent command result received.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Phase 5K.5: Agent control web page
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed miner control domain."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for miner control."""
|
||||
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
def get_miner_control_service() -> MinerControlService:
|
||||
return MinerControlService(MinerControlRepository())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Miner control domain exceptions."""
|
||||
|
||||
|
||||
class MinerControlError(Exception):
|
||||
"""Base miner control error."""
|
||||
|
||||
|
||||
class ControlledMinerNotFound(MinerControlError):
|
||||
"""The durable miner UUID is unknown."""
|
||||
|
||||
|
||||
class MinerControlUnavailable(MinerControlError):
|
||||
"""The requested action cannot be safely queued."""
|
||||
|
||||
|
||||
class MinerControlConflict(MinerControlError):
|
||||
"""Another live control operation already exists."""
|
||||
|
||||
|
||||
class MinerControlActorRequired(MinerControlError):
|
||||
"""An authenticated actor UUID is required."""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Typed contracts for miner control capabilities and operations."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ControlAction = Literal["suspend", "resume", "performance_mode"]
|
||||
CapabilityState = Literal["supported", "unavailable", "unknown"]
|
||||
QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"]
|
||||
VerificationStatus = Literal[
|
||||
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||
]
|
||||
PerformanceMode = Literal["low", "normal", "high"]
|
||||
|
||||
|
||||
class EmptyControlRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PerformanceModeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: PerformanceMode
|
||||
|
||||
|
||||
class ControlCapability(BaseModel):
|
||||
state: CapabilityState
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ControlAgentAvailability(BaseModel):
|
||||
state: CapabilityState
|
||||
reason: str | None = None
|
||||
last_checkin_at: AwareDatetime | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class MinerControlOperation(BaseModel):
|
||||
operation_id: str
|
||||
miner_id: UUID
|
||||
action: ControlAction
|
||||
requested_value: str | None = None
|
||||
status: QueueStatus
|
||||
verification_status: VerificationStatus | None = None
|
||||
command_accepted: bool | None = None
|
||||
observed_performance_mode: PerformanceMode | None = None
|
||||
failure_code: str | None = None
|
||||
created_at: AwareDatetime
|
||||
claimed_at: AwareDatetime | None = None
|
||||
completed_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class MinerControlProjection(BaseModel):
|
||||
miner_id: UUID
|
||||
runtime_state: str | None = None
|
||||
performance_mode: PerformanceMode | None = None
|
||||
agent: ControlAgentAvailability
|
||||
service_control: ControlCapability
|
||||
performance_mode_control: ControlCapability
|
||||
available_service_action: Literal["suspend", "resume"] | None = None
|
||||
recent_operations: list[MinerControlOperation]
|
||||
|
||||
|
||||
class MinerControlResponse(BaseModel):
|
||||
data: MinerControlProjection
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class MinerControlAccepted(BaseModel):
|
||||
operation_id: str
|
||||
miner_id: UUID
|
||||
action: ControlAction
|
||||
requested_value: str | None = None
|
||||
status: Literal["pending"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MinerControlAcceptedResponse(BaseModel):
|
||||
data: MinerControlAccepted
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,135 @@
|
||||
"""PostgreSQL persistence for typed miner control operations."""
|
||||
|
||||
from collections.abc import Callable
|
||||
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.miner_control.exceptions import MinerControlConflict
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control"
|
||||
|
||||
|
||||
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 MinerControlRepository:
|
||||
"""Resolve control facts and persist queue/audit rows atomically."""
|
||||
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_control_context(self, miner_id: UUID) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
|
||||
miners.status AS runtime_state,
|
||||
miners.configured_performance_mode,
|
||||
miners.api3_supported, miners.api3_api_switch,
|
||||
agents.agent_id, agents.last_checkin_at,
|
||||
agents.version AS agent_version, agents.last_payload
|
||||
FROM miners
|
||||
LEFT JOIN minerops_site_agents AS agents
|
||||
ON agents.site_id = miners.site_id
|
||||
WHERE miners.id = %s
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id AS operation_id, command_status AS status,
|
||||
request_payload, result_payload, created_at,
|
||||
claimed_at, completed_at
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_type = 'miner_control'
|
||||
AND request_payload ->> 'miner_id' = %s
|
||||
AND request_payload ->> 'schema_version' = '1'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(str(miner_id), limit),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def enqueue_with_audit(
|
||||
self,
|
||||
*,
|
||||
operation_id: str,
|
||||
miner_id: UUID,
|
||||
agent_id: str,
|
||||
actor_user_id: UUID,
|
||||
actor_username: str,
|
||||
request_payload: dict[str, Any],
|
||||
audit_payload: dict[str, Any],
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_agent_command_queue (
|
||||
id, agent_id, command_type, command_status,
|
||||
requested_by_user_id, requested_by_username, request_payload
|
||||
)
|
||||
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s)
|
||||
RETURNING id AS operation_id, command_status AS status, created_at
|
||||
""",
|
||||
(
|
||||
operation_id, agent_id, str(actor_user_id), actor_username,
|
||||
Jsonb(request_payload),
|
||||
),
|
||||
)
|
||||
operation = dict(cur.fetchone())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id, action, entity_type, entity_id,
|
||||
new_value_json, related_miner_asset_id
|
||||
)
|
||||
VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s)
|
||||
""",
|
||||
(actor_user_id, miner_id, Jsonb(audit_payload), miner_id),
|
||||
)
|
||||
connection.commit()
|
||||
return operation
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT:
|
||||
raise MinerControlConflict(
|
||||
"A pending or running control operation already exists for this miner"
|
||||
) from exc
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""HTTP mapping for typed miner controls."""
|
||||
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.identity import require_permission
|
||||
from app.miner_control.dependencies import get_miner_control_service
|
||||
from app.miner_control.exceptions import (
|
||||
ControlledMinerNotFound,
|
||||
MinerControlConflict,
|
||||
MinerControlUnavailable,
|
||||
)
|
||||
from app.miner_control.models import (
|
||||
EmptyControlRequest,
|
||||
MinerControlAcceptedResponse,
|
||||
MinerControlResponse,
|
||||
PerformanceModeRequest,
|
||||
)
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/miners", tags=["miner-controls"])
|
||||
ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)]
|
||||
ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
ControlUser = Annotated[dict, Depends(require_permission("miners.control"))]
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> None:
|
||||
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
|
||||
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||
origin = request.headers.get("origin")
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
|
||||
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||
|
||||
|
||||
def _actor(current_user: dict) -> tuple[UUID, str]:
|
||||
return UUID(str(current_user["id"])), str(
|
||||
current_user.get("username") or current_user.get("email") or "unknown"
|
||||
)
|
||||
|
||||
|
||||
def _queue(
|
||||
service: MinerControlService,
|
||||
miner_id: UUID,
|
||||
action: str,
|
||||
requested_value: str | None,
|
||||
current_user: dict,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
actor_id, username = _actor(current_user)
|
||||
try:
|
||||
operation = service.queue_control(
|
||||
miner_id,
|
||||
action,
|
||||
requested_value=requested_value,
|
||||
actor_user_id=actor_id,
|
||||
actor_username=username,
|
||||
)
|
||||
except ControlledMinerNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||
except MinerControlUnavailable as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except MinerControlConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return MinerControlAcceptedResponse(data=operation)
|
||||
|
||||
|
||||
@router.get("/{miner_id}/controls", response_model=MinerControlResponse)
|
||||
def get_miner_controls(
|
||||
miner_id: UUID,
|
||||
current_user: ControlViewer,
|
||||
service: ControlService,
|
||||
) -> MinerControlResponse:
|
||||
del current_user
|
||||
try:
|
||||
return MinerControlResponse(data=service.get_controls(miner_id))
|
||||
except ControlledMinerNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/suspend",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def suspend_miner(
|
||||
miner_id: UUID,
|
||||
payload: EmptyControlRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
del payload
|
||||
return _queue(service, miner_id, "suspend", None, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/resume",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def resume_miner(
|
||||
miner_id: UUID,
|
||||
payload: EmptyControlRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
del payload
|
||||
return _queue(service, miner_id, "resume", None, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/performance-mode",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def set_performance_mode(
|
||||
miner_id: UUID,
|
||||
payload: PerformanceModeRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
return _queue(service, miner_id, "performance_mode", payload.mode, current_user)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Business rules for typed, Agent-routed miner controls."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.miner_control.exceptions import (
|
||||
ControlledMinerNotFound,
|
||||
MinerControlActorRequired,
|
||||
MinerControlUnavailable,
|
||||
)
|
||||
from app.miner_control.models import (
|
||||
ControlAgentAvailability,
|
||||
ControlCapability,
|
||||
MinerControlAccepted,
|
||||
MinerControlOperation,
|
||||
MinerControlProjection,
|
||||
)
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
|
||||
|
||||
KNOWN_MODES = {"low", "normal", "high"}
|
||||
RUNNING_STATES = {"online", "running"}
|
||||
STOPPED_STATES = {"suspended", "stopped"}
|
||||
|
||||
|
||||
class MinerControlService:
|
||||
def __init__(self, repository: MinerControlRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _capability(context: dict) -> ControlCapability:
|
||||
if not context.get("site_id"):
|
||||
return ControlCapability(state="unavailable", reason="site_unassigned")
|
||||
if not context.get("agent_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_unassigned")
|
||||
if not context.get("current_ip"):
|
||||
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||
if context.get("api3_supported") is False:
|
||||
return ControlCapability(state="unavailable", reason="api3_unsupported")
|
||||
if context.get("api3_supported") is not True:
|
||||
return ControlCapability(state="unknown", reason="api3_support_unknown")
|
||||
|
||||
payload = context.get("last_payload") or {}
|
||||
if payload.get("control_available") is False:
|
||||
reason = payload.get("control_unavailable_reason")
|
||||
return ControlCapability(
|
||||
state="unavailable",
|
||||
reason=reason if reason == "credentials_missing" else "agent_unavailable",
|
||||
)
|
||||
if payload.get("control_available") is not True:
|
||||
return ControlCapability(
|
||||
state="unknown", reason="agent_capability_unknown"
|
||||
)
|
||||
return ControlCapability(state="supported")
|
||||
|
||||
@staticmethod
|
||||
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
||||
request = row.get("request_payload") or {}
|
||||
result = row.get("result_payload") or {}
|
||||
mode = result.get("observed_performance_mode")
|
||||
return MinerControlOperation(
|
||||
operation_id=row["operation_id"],
|
||||
miner_id=miner_id,
|
||||
action=request.get("action"),
|
||||
requested_value=request.get("requested_value"),
|
||||
status=row["status"],
|
||||
verification_status=result.get("verification_status"),
|
||||
command_accepted=result.get("command_accepted"),
|
||||
observed_performance_mode=mode if mode in KNOWN_MODES else None,
|
||||
failure_code=result.get("failure_code"),
|
||||
created_at=row["created_at"],
|
||||
claimed_at=row.get("claimed_at"),
|
||||
completed_at=row.get("completed_at"),
|
||||
)
|
||||
|
||||
def get_controls(self, miner_id: UUID) -> MinerControlProjection:
|
||||
context = self.repository.get_control_context(miner_id)
|
||||
if not context:
|
||||
raise ControlledMinerNotFound("Miner not found")
|
||||
capability = self._capability(context)
|
||||
runtime = str(context.get("runtime_state") or "").strip().lower() or None
|
||||
service_action = None
|
||||
if capability.state == "supported":
|
||||
if runtime in RUNNING_STATES:
|
||||
service_action = "suspend"
|
||||
elif runtime in STOPPED_STATES:
|
||||
service_action = "resume"
|
||||
configured_mode = str(context.get("configured_performance_mode") or "").lower()
|
||||
return MinerControlProjection(
|
||||
miner_id=miner_id,
|
||||
runtime_state=runtime,
|
||||
performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None),
|
||||
agent=ControlAgentAvailability(
|
||||
state=capability.state,
|
||||
reason=capability.reason,
|
||||
last_checkin_at=context.get("last_checkin_at"),
|
||||
version=context.get("agent_version"),
|
||||
),
|
||||
service_control=capability,
|
||||
performance_mode_control=capability,
|
||||
available_service_action=service_action,
|
||||
recent_operations=[
|
||||
self._operation(miner_id, row)
|
||||
for row in self.repository.list_recent(miner_id, limit=5)
|
||||
],
|
||||
)
|
||||
|
||||
def queue_control(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
action: str,
|
||||
*,
|
||||
requested_value: str | None,
|
||||
actor_user_id: UUID,
|
||||
actor_username: str,
|
||||
) -> MinerControlAccepted:
|
||||
if not actor_user_id:
|
||||
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
||||
context = self.repository.get_control_context(miner_id)
|
||||
if not context:
|
||||
raise ControlledMinerNotFound("Miner not found")
|
||||
capability = self._capability(context)
|
||||
if capability.state != "supported":
|
||||
raise MinerControlUnavailable(
|
||||
capability.reason or "control_capability_not_supported"
|
||||
)
|
||||
|
||||
runtime = str(context.get("runtime_state") or "").strip().lower()
|
||||
if action == "suspend" and runtime not in RUNNING_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_running")
|
||||
if action == "resume" and runtime not in STOPPED_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_stopped")
|
||||
if action == "performance_mode" and requested_value not in KNOWN_MODES:
|
||||
raise MinerControlUnavailable("invalid_performance_mode")
|
||||
|
||||
operation_id = str(uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
"ip_address": context["current_ip"],
|
||||
}
|
||||
operation = self.repository.enqueue_with_audit(
|
||||
operation_id=operation_id,
|
||||
miner_id=miner_id,
|
||||
agent_id=context["agent_id"],
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
request_payload=payload,
|
||||
audit_payload={
|
||||
"operation_id": operation_id,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
},
|
||||
)
|
||||
return MinerControlAccepted(
|
||||
operation_id=operation["operation_id"],
|
||||
miner_id=miner_id,
|
||||
action=action,
|
||||
requested_value=requested_value,
|
||||
status=operation["status"],
|
||||
created_at=operation["created_at"],
|
||||
)
|
||||
Reference in new issue
Block a user