feat: add agent zones

This commit is contained in:
lussierj committed 2026-08-26 15:48:59 +00:00
1 parent d8cd7ad058
commit 82309db79a
29 files changed
+2242 -255

No files matched your search

@@ -0,0 +1,365 @@
"""add agent zones
Revision ID: 0016_agent_zones
Revises: 0015_miner_control_foundation
Create Date: 2026-08-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0016_agent_zones"
down_revision: Union[str, None] = "0015_miner_control_foundation"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEFAULT_ZONE_NAME = "Default Zone"
def _format_rows(rows: list[tuple]) -> str:
return ", ".join("/".join(str(value) for value in row) for row in rows)
def upgrade() -> None:
op.create_table(
"agent_zones",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"site_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
name="ck_agent_zones_name",
),
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
)
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
op.create_index(
"uq_agent_zones_active_site_name",
"agent_zones",
["site_id", sa.text("lower(name)")],
unique=True,
postgresql_where=sa.text("is_enabled"),
)
op.create_table(
"agent_zone_network_ranges",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"agent_zone_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("network", postgresql.CIDR(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint(
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
),
)
op.create_index(
"idx_agent_zone_network_ranges_zone_id",
"agent_zone_network_ranges",
["agent_zone_id"],
)
op.add_column(
"miners",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_check_constraint(
"ck_miners_agent_zone_requires_site",
"miners",
"agent_zone_id IS NULL OR site_id IS NOT NULL",
)
op.create_foreign_key(
"fk_miners_agent_zone_site",
"miners",
"agent_zones",
["agent_zone_id", "site_id"],
["id", "site_id"],
ondelete="RESTRICT",
)
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
op.add_column(
"minerops_site_agents",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_check_constraint(
"ck_minerops_site_agents_zone_requires_site",
"minerops_site_agents",
"agent_zone_id IS NULL OR site_id IS NOT NULL",
)
op.create_foreign_key(
"fk_minerops_site_agents_zone_site",
"minerops_site_agents",
"agent_zones",
["agent_zone_id", "site_id"],
["id", "site_id"],
ondelete="RESTRICT",
)
op.add_column(
"minerops_agent_snapshots",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
"agent_zones",
["agent_zone_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
["agent_zone_id"],
)
op.add_column(
"minerops_agent_discoveries",
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
"agent_zones",
["agent_zone_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
["agent_zone_id"],
)
connection = op.get_bind()
connection.execute(
sa.text(
"""
INSERT INTO agent_zones (site_id, name)
SELECT sites.id, :default_name
FROM sites
ORDER BY sites.id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE miners AS miner
SET agent_zone_id = zone.id
FROM agent_zones AS zone
WHERE miner.site_id = zone.site_id
AND zone.name = :default_name
AND miner.agent_zone_id IS NULL
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE minerops_site_agents AS agent
SET agent_zone_id = zone.id
FROM agent_zones AS zone
WHERE agent.site_id = zone.site_id
AND zone.name = :default_name
AND agent.agent_zone_id IS NULL
"""
),
{"default_name": DEFAULT_ZONE_NAME},
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_snapshots AS snapshot
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE snapshot.agent_id = agent.agent_id
AND agent.agent_zone_id IS NOT NULL
AND snapshot.agent_zone_id IS NULL
"""
)
)
connection.execute(
sa.text(
"""
UPDATE minerops_agent_discoveries AS discovery
SET agent_zone_id = agent.agent_zone_id
FROM minerops_site_agents AS agent
WHERE discovery.agent_id = agent.agent_id
AND agent.agent_zone_id IS NOT NULL
AND discovery.agent_zone_id IS NULL
"""
)
)
op.create_index(
"uq_minerops_site_agents_agent_zone_id",
"minerops_site_agents",
["agent_zone_id"],
unique=True,
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
)
op.drop_index(
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
)
def downgrade() -> None:
connection = op.get_bind()
duplicate_site_agents = connection.execute(
sa.text(
"""
SELECT site_id::text, count(*)
FROM minerops_site_agents
WHERE site_id IS NOT NULL
GROUP BY site_id
HAVING count(*) > 1
ORDER BY site_id
"""
)
).all()
if duplicate_site_agents:
raise RuntimeError(
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
"assigned within Site(s): "
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
"until each Site has at most one Agent before retrying; no Agent Zone "
"data was changed."
)
non_reducible_zones = connection.execute(
sa.text(
"""
SELECT site_id::text, count(*)
FROM agent_zones
GROUP BY site_id
HAVING count(*) > 1
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
ORDER BY site_id
"""
),
{"default_name": DEFAULT_ZONE_NAME},
).all()
range_count = connection.execute(
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
).scalar_one()
if non_reducible_zones or range_count:
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
raise RuntimeError(
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
f"represented by the one-Agent-per-Site model ({detail}). Remove "
"additional/custom Zones and Zone network ranges before retrying; no "
"Agent Zone data was changed."
)
op.create_index(
"uq_minerops_site_agents_site_id",
"minerops_site_agents",
["site_id"],
unique=True,
postgresql_where=sa.text("site_id IS NOT NULL"),
)
op.drop_index(
"uq_minerops_site_agents_agent_zone_id",
table_name="minerops_site_agents",
)
op.drop_index(
"idx_minerops_agent_discoveries_agent_zone_id",
table_name="minerops_agent_discoveries",
)
op.drop_constraint(
"fk_minerops_agent_discoveries_agent_zone_id",
"minerops_agent_discoveries",
type_="foreignkey",
)
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
op.drop_index(
"idx_minerops_agent_snapshots_agent_zone_id",
table_name="minerops_agent_snapshots",
)
op.drop_constraint(
"fk_minerops_agent_snapshots_agent_zone_id",
"minerops_agent_snapshots",
type_="foreignkey",
)
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
op.drop_constraint(
"fk_minerops_site_agents_zone_site",
"minerops_site_agents",
type_="foreignkey",
)
op.drop_constraint(
"ck_minerops_site_agents_zone_requires_site",
"minerops_site_agents",
type_="check",
)
op.drop_column("minerops_site_agents", "agent_zone_id")
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
op.drop_constraint(
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
)
op.drop_constraint(
"ck_miners_agent_zone_requires_site", "miners", type_="check"
)
op.drop_column("miners", "agent_zone_id")
op.drop_index(
"idx_agent_zone_network_ranges_zone_id",
table_name="agent_zone_network_ranges",
)
op.drop_table("agent_zone_network_ranges")
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
op.drop_table("agent_zones")
+1
View File
@@ -0,0 +1 @@
"""Agent Zone routing domain."""
+8
View File
@@ -0,0 +1,8 @@
"""Agent Zone dependency wiring."""
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
def get_agent_zone_service() -> AgentZoneService:
return AgentZoneService(AgentZoneRepository())
+17
View File
@@ -0,0 +1,17 @@
"""Agent Zone domain exceptions."""
class AgentZoneError(Exception):
"""Base Agent Zone error."""
class AgentZoneNotFound(AgentZoneError):
"""A Site or Agent Zone does not exist."""
class AgentZoneValidationError(AgentZoneError):
"""Agent Zone input or assignment is invalid."""
class AgentZoneConflict(AgentZoneError):
"""An Agent Zone uniqueness or assignment invariant conflicts."""
+114
View File
@@ -0,0 +1,114 @@
"""Typed Agent Zone and check-in contracts."""
from ipaddress import ip_network
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class AgentZoneCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
site_id: UUID
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
networks: list[str] = Field(default_factory=list, max_length=64)
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name cannot be blank")
return normalized
@field_validator("description")
@classmethod
def normalize_description(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
@field_validator("networks")
@classmethod
def normalize_networks(cls, values: list[str]) -> list[str]:
normalized: list[str] = []
for value in values:
try:
network = str(ip_network(value.strip(), strict=True))
except ValueError as exc:
raise ValueError(f"invalid CIDR network: {value}") from exc
if network in normalized:
raise ValueError(f"duplicate CIDR network: {network}")
normalized.append(network)
return normalized
class AgentZoneNetwork(BaseModel):
id: UUID
network: str
class AgentZoneSummary(BaseModel):
id: UUID
site_id: UUID
name: str
description: str | None = None
is_enabled: bool
assigned_agent_id: str | None = None
networks: list[AgentZoneNetwork] = Field(default_factory=list)
class AgentZoneListResponse(BaseModel):
data: list[AgentZoneSummary]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentZoneCreateResponse(BaseModel):
data: AgentZoneSummary
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentCheckinRequest(BaseModel):
model_config = ConfigDict(extra="allow")
agent_id: str | None = Field(default=None, max_length=255)
site_id: UUID | None = None
agent_zone_id: UUID | None = None
site_name: str | None = Field(default=None, max_length=255)
hostname: str | None = Field(default=None, max_length=255)
version: str | None = Field(default=None, max_length=120)
ip_address: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
if self.agent_zone_id is not None and self.site_id is None:
raise ValueError("site_id is required when agent_zone_id is supplied")
return self
class AgentCheckinResponse(BaseModel):
ok: bool = True
hub_mode: bool = True
agent_id: str
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_available: bool
routing_reason: str | None = None
site_name: str
message: str = "Agent check-in received."
class AgentZoneRoutingContext(BaseModel):
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_reason: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
outcomes: dict[UUID, str] = Field(default_factory=dict)
+300
View File
@@ -0,0 +1,300 @@
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
from collections.abc import Callable, Iterable
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
)
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
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 AgentZoneRepository:
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_agent(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def get_zone(self, zone_id: UUID) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled,
created_at, updated_at
FROM agent_zones
WHERE id = %s
""",
(zone_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def list_usable_zones(self, site_id: UUID) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled
FROM agent_zones
WHERE site_id = %s AND is_enabled
ORDER BY lower(name), id
""",
(site_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT zone.id, zone.site_id, zone.name, zone.description,
zone.is_enabled, agent.agent_id AS assigned_agent_id,
range.id AS range_id, range.network::text AS network
FROM agent_zones AS zone
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_zone_id = zone.id
LEFT JOIN agent_zone_network_ranges AS range
ON range.agent_zone_id = zone.id
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
ORDER BY lower(zone.name), zone.id, range.network, range.id
""",
(site_id, site_id),
)
rows = cur.fetchall()
finally:
connection.close()
zones: dict[UUID, dict] = {}
for row in rows:
zone = zones.setdefault(
row["id"],
{
"id": row["id"],
"site_id": row["site_id"],
"name": row["name"],
"description": row["description"],
"is_enabled": row["is_enabled"],
"assigned_agent_id": row["assigned_agent_id"],
"networks": [],
},
)
if row["range_id"] is not None:
zone["networks"].append(
{"id": row["range_id"], "network": row["network"]}
)
return list(zones.values())
def create_zone(
self,
*,
site_id: UUID,
name: str,
description: str | None,
networks: list[str],
) -> UUID:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO agent_zones (site_id, name, description)
VALUES (%s, %s, %s)
RETURNING id
""",
(site_id, name, description),
)
zone_id = cur.fetchone()["id"]
for network in networks:
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, %s::cidr)
""",
(zone_id, network),
)
connection.commit()
return zone_id
except ForeignKeyViolation as exc:
connection.rollback()
raise AgentZoneNotFound("Site not found") from exc
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"An enabled Agent Zone with this name already exists at the Site"
) from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def save_checkin(
self,
*,
agent_id: str,
site_id: UUID | None,
agent_zone_id: UUID | None,
site_name: str,
hostname: str | None,
version: str | None,
ip_address: str | None,
payload: dict[str, Any],
) -> None:
connection = self._connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
ON CONFLICT (agent_id) DO UPDATE SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
(
agent_id,
site_id,
agent_zone_id,
site_name,
hostname,
version,
ip_address,
Jsonb(payload),
),
)
connection.commit()
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"The Agent Zone already has an assigned Agent"
) from exc
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
connection.rollback()
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_agent_assignment(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
zone.is_enabled AS zone_enabled
FROM minerops_site_agents AS agent
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
WHERE agent.agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
unique_ids = list(dict.fromkeys(miner_ids))
if not unique_ids:
return {}
connection = self._connection_factory()
outcomes: dict[UUID, str] = {}
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT id, site_id FROM agent_zones WHERE id = %s",
(agent_zone_id,),
)
zone = cur.fetchone()
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
for miner_id in unique_ids:
cur.execute(
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
outcomes[miner_id] = "miner_not_found"
elif miner["agent_zone_id"] == agent_zone_id:
outcomes[miner_id] = "preserved"
elif miner["agent_zone_id"] is not None:
outcomes[miner_id] = "agent_zone_conflict"
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
connection.commit()
return outcomes
except Exception:
connection.rollback()
raise
finally:
connection.close()
+75
View File
@@ -0,0 +1,75 @@
"""HTTP mapping for Agent Zones and Agent check-in."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneCreateRequest,
AgentZoneCreateResponse,
AgentZoneListResponse,
)
from app.agent_zones.service import AgentZoneService
from app.identity import require_permission
router = APIRouter(tags=["agent-zones"])
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
def list_agent_zones(
current_user: ZoneViewer,
service: ZoneService,
site_id: UUID | None = Query(default=None),
) -> AgentZoneListResponse:
del current_user
return AgentZoneListResponse(data=service.list_zones(site_id))
@router.post(
"/api/agent-zones",
response_model=AgentZoneCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_zone(
request: AgentZoneCreateRequest,
current_user: ZoneManager,
service: ZoneService,
) -> AgentZoneCreateResponse:
del current_user
try:
return AgentZoneCreateResponse(data=service.create_zone(request))
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
def agent_checkin(
request: AgentCheckinRequest,
agent_id: AgentIdentity,
service: ZoneService,
) -> AgentCheckinResponse:
try:
return service.checkin(agent_id, request)
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentZoneValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+194
View File
@@ -0,0 +1,194 @@
"""Business rules for Agent Zone routing and transitional check-in."""
from typing import Any
from uuid import UUID
from app.agent_zones.exceptions import (
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
MinerZoneAssociationResult,
)
from app.agent_zones.repository import AgentZoneRepository
SENSITIVE_KEYS = {
"agent_secret",
"api3_account",
"api3_password",
"password",
"salt",
"token",
}
def _sanitize(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _sanitize(item)
for key, item in value.items()
if key.lower() not in SENSITIVE_KEYS
and not any(
marker in key.lower()
for marker in ("secret", "password", "token", "salt")
)
}
if isinstance(value, list):
return [_sanitize(item) for item in value]
return value
class AgentZoneService:
@staticmethod
def validate_agent_identity(
authenticated_agent_id: str, claimed_agent_id: str | None
) -> None:
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
raise AgentZoneValidationError(
"Payload agent_id does not match authenticated Agent identity"
)
def __init__(self, repository: AgentZoneRepository):
self.repository = repository
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
return [
AgentZoneSummary.model_validate(row)
for row in self.repository.list_zones(site_id)
]
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
zone_id = self.repository.create_zone(
site_id=request.site_id,
name=request.name,
description=request.description,
networks=request.networks,
)
for zone in self.list_zones(request.site_id):
if zone.id == zone_id:
return zone
raise AgentZoneNotFound("Created Agent Zone was not found")
def checkin(
self, authenticated_agent_id: str, request: AgentCheckinRequest
) -> AgentCheckinResponse:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
final_zone: UUID | None = None
reason: str | None = None
established_zone = None
established_zone_id = (existing or {}).get("agent_zone_id")
if established_zone_id is not None:
established_zone = self.repository.get_zone(established_zone_id)
if not established_zone:
raise AgentZoneValidationError(
"Existing Agent Zone assignment is invalid"
)
if (
requested_site is not None
and established_zone["site_id"] != requested_site
):
raise AgentZoneValidationError(
"Agent cannot change Sites while assigned to an Agent Zone"
)
if requested_zone is not None:
zone = self.repository.get_zone(requested_zone)
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
if zone["site_id"] != requested_site:
raise AgentZoneValidationError(
"Agent Zone does not belong to the supplied Site"
)
if not zone["is_enabled"]:
raise AgentZoneValidationError("Agent Zone is disabled")
if established_zone_id is not None and established_zone_id != requested_zone:
raise AgentZoneValidationError(
"Agent is already assigned to a different Agent Zone"
)
final_zone = requested_zone
elif established_zone is not None:
final_site = established_zone["site_id"]
final_zone = established_zone["id"]
if not established_zone["is_enabled"]:
reason = "agent_zone_disabled"
elif final_site is not None:
usable = self.repository.list_usable_zones(final_site)
if len(usable) == 1:
final_zone = usable[0]["id"]
elif not usable:
reason = "agent_zone_unavailable"
else:
reason = "agent_zone_ambiguous"
else:
reason = "site_unassigned"
site_name = (request.site_name or "").strip()
if not site_name:
site_name = str((existing or {}).get("site_name") or "Unassigned")
payload = _sanitize(request.model_dump(mode="json"))
self.repository.save_checkin(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
site_name=site_name,
hostname=(request.hostname or "").strip() or None,
version=(request.version or "").strip() or None,
ip_address=(request.ip_address or "").strip() or None,
payload=payload,
)
routing_available = final_zone is not None and reason is None
return AgentCheckinResponse(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
routing_available=routing_available,
routing_reason=reason,
site_name=site_name,
)
def routing_context(
self, agent_id: str, recorded_zone_id: UUID | None = None
) -> AgentZoneRoutingContext:
assignment = self.repository.get_agent_assignment(agent_id)
if not assignment or assignment.get("agent_zone_id") is None:
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
zone_id = assignment["agent_zone_id"]
if recorded_zone_id is not None and recorded_zone_id != zone_id:
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
if assignment.get("zone_enabled") is not True:
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"),
agent_zone_id=zone_id,
routing_reason="agent_zone_disabled",
)
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"), agent_zone_id=zone_id
)
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
routing = self.routing_context(agent_id)
if routing.routing_reason:
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
routing_reason=routing.routing_reason,
)
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
outcomes=self.repository.assign_miners_to_zone(
miner_ids, routing.agent_zone_id
),
)
+2
View File
@@ -777,6 +777,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if path == "/api/agent-zones":
return "agents.view" if method == "GET" else "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
+154 -235
View File
@@ -11,7 +11,12 @@ 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.dependencies import require_agent_identity
from app.agent_commands.router import router as agent_commands_router
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.models import MinerZoneAssociationResult
from app.agent_zones.service import AgentZoneService
from app.agent_zones.router import router as agent_zones_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 (
@@ -67,6 +72,7 @@ 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(agent_zones_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
@@ -25081,70 +25087,6 @@ async def startup_ensure_minerops_agent_tables():
await ensure_minerops_agent_tables()
@app.post("/api/agent/checkin")
async def minerops_agent_checkin(request: Request):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
body = await request.json()
except Exception:
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()
ip_address = str(body.get("ip_address") or "").strip()
conn = await minerops_command_db_connect()
try:
await conn.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
site_name,
hostname,
version,
ip_address,
last_checkin_at,
last_payload
)
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,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
agent_id,
site_id,
site_name,
hostname,
version,
ip_address,
json.dumps(body),
)
finally:
await conn.close()
return {
"ok": True,
"hub_mode": True,
"agent_id": agent_id,
"site_name": site_name,
"message": "Agent check-in received.",
}
@app.get("/api/agent/config")
async def minerops_agent_config(request: Request):
@@ -25183,10 +25125,13 @@ async def minerops_agent_config(request: Request):
@app.post("/api/agent/miner-snapshots")
async def minerops_agent_miner_snapshots(request: Request):
async def minerops_agent_miner_snapshots(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
minerops_require_agent_secret(request)
await ensure_minerops_agent_tables()
try:
@@ -25194,30 +25139,39 @@ async def minerops_agent_miner_snapshots(request: Request):
except Exception:
body = {}
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
zone_service.validate_agent_identity(
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
)
site_name = str(body.get("site_name") or "Unassigned").strip()
miners = body.get("miners")
if not isinstance(miners, list):
miners = []
conn = await minerops_command_db_connect()
miner_ids = []
for item in miners:
if not isinstance(item, dict):
continue
try:
miner_ids.append(UUID(str(item.get("miner_id") or "")))
except ValueError:
continue
ownership = zone_service.associate_miners_from_agent(
authenticated_agent_id, miner_ids
)
conn = await minerops_command_db_connect()
try:
row = await conn.fetchrow(
"""
INSERT INTO minerops_agent_snapshots (
agent_id,
site_name,
snapshot_kind,
miner_count,
payload
agent_id, agent_zone_id, site_name, snapshot_kind,
miner_count, payload
)
VALUES ($1, $2, 'miner_poll', $3, $4::jsonb)
VALUES ($1, $2::uuid, $3, 'miner_poll', $4, $5::jsonb)
RETURNING id, received_at
""",
agent_id,
authenticated_agent_id,
str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
site_name,
len(miners),
json.dumps(body),
@@ -25225,13 +25179,16 @@ async def minerops_agent_miner_snapshots(request: Request):
finally:
await conn.close()
apply_result = await minerops_apply_agent_snapshot_to_live_tables(body)
apply_result = await minerops_apply_agent_snapshot_to_live_tables(
body, ownership
)
return {
"ok": True,
"snapshot_id": row["id"] if row else None,
"received_at": row["received_at"].isoformat() if row and row["received_at"] else None,
"miner_count": len(miners),
"agent_zone_id": str(ownership.agent_zone_id) if ownership.agent_zone_id else None,
"routing_reason": ownership.routing_reason,
"apply_result": apply_result,
"message": "Miner snapshot received, staged, and applied to live tables.",
}
@@ -25570,23 +25527,22 @@ def minerops_agent_parse_summary(miner_payload: dict) -> dict:
}
async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> dict:
async def minerops_apply_agent_snapshot_to_live_tables(
snapshot_body: dict, ownership: MinerZoneAssociationResult
) -> dict:
import json
import uuid
from datetime import datetime, timezone
miners = snapshot_body.get("miners")
if not isinstance(miners, list):
miners = []
now = datetime.now(timezone.utc)
applied = 0
online_count = 0
offline_count = 0
errors = []
conn = await minerops_command_db_connect()
try:
@@ -25597,29 +25553,38 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
miner_id = str(item.get("miner_id") or "").strip()
ip_address = str(item.get("ip_address") or "").strip()
online = bool(item.get("online"))
if not miner_id:
continue
try:
miner_uuid = UUID(miner_id)
except ValueError:
errors.append({"miner_id": miner_id, "error": "invalid_miner_id"})
continue
if ownership.routing_reason:
errors.append(
{"miner_id": miner_id, "error": ownership.routing_reason}
)
continue
zone_outcome = ownership.outcomes.get(miner_uuid, "miner_not_found")
if zone_outcome not in {"assigned", "preserved"}:
errors.append({"miner_id": miner_id, "error": zone_outcome})
continue
parsed = minerops_agent_parse_summary(item)
raw_summary = parsed["raw"]
msg = parsed["msg"]
status = "online" if online else "offline"
mhs_av = minerops_agent_to_float(msg.get("MHS av"))
mhs_1m = minerops_agent_to_float(msg.get("MHS 1m"))
mhs_15m = minerops_agent_to_float(msg.get("MHS 15m"))
hs_rt = minerops_agent_to_float(msg.get("HS RT"))
power_w = minerops_agent_to_float(msg.get("Power"))
power_rate = minerops_agent_to_float(msg.get("Power Rate"))
env_temp = minerops_agent_to_float(msg.get("Env Temp"))
chip_temp_min = minerops_agent_to_float(msg.get("Chip Temp Min"))
chip_temp_max = minerops_agent_to_float(msg.get("Chip Temp Max"))
chip_temp_avg = minerops_agent_to_float(msg.get("Chip Temp Avg"))
fan_in = minerops_agent_to_int(msg.get("Fan Speed In"))
fan_out = minerops_agent_to_int(msg.get("Fan Speed Out"))
uptime = minerops_agent_to_int(msg.get("Uptime"))
@@ -25629,12 +25594,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
try:
if online:
online_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
SET status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
last_seen_at = $4,
updated_at = now()
@@ -25645,54 +25608,20 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
ip_address,
now,
)
await conn.execute(
"""
INSERT INTO miner_status_snapshots (
id,
miner_id,
observed_ip,
observed_at,
mhs_av,
mhs_1m,
mhs_15m,
hs_rt,
power_w,
power_rate_w_per_th,
env_temp_c,
chip_temp_min_c,
chip_temp_max_c,
chip_temp_avg_c,
fan_speed_in_rpm,
fan_speed_out_rpm,
uptime_seconds,
hash_stable,
pool_rejected_percent,
raw_summary_json,
created_at
id, miner_id, observed_ip, observed_at, mhs_av,
mhs_1m, mhs_15m, hs_rt, power_w,
power_rate_w_per_th, env_temp_c, chip_temp_min_c,
chip_temp_max_c, chip_temp_avg_c, fan_speed_in_rpm,
fan_speed_out_rpm, uptime_seconds, hash_stable,
pool_rejected_percent, raw_summary_json, created_at
)
VALUES (
$1::uuid,
$2::uuid,
NULLIF($3, '')::inet,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13,
$14,
$15,
$16,
$17,
$18,
$19,
$20::jsonb,
now()
$1::uuid, $2::uuid, NULLIF($3, '')::inet, $4,
$5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, $20::jsonb, now()
)
""",
str(uuid.uuid4()),
@@ -25718,12 +25647,10 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
)
else:
offline_count += 1
await conn.execute(
"""
UPDATE miners
SET
status = $2,
SET status = $2,
current_ip = COALESCE(NULLIF($3, '')::inet, current_ip),
updated_at = now()
WHERE id = $1::uuid
@@ -25732,19 +25659,12 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
status,
ip_address,
)
applied += 1
except Exception as exc:
if len(errors) < 20:
errors.append(
{
"miner_id": miner_id,
"ip_address": ip_address,
"error": str(exc),
}
{"miner_id": miner_id, "ip_address": ip_address, "error": str(exc)}
)
finally:
await conn.close()
@@ -25756,7 +25676,6 @@ async def minerops_apply_agent_snapshot_to_live_tables(snapshot_body: dict) -> d
}
# -----------------------------
# Phase 5K: Agent command queue
# -----------------------------
@@ -26639,24 +26558,28 @@ async def minerops_queue_agent_scan_new_miners(current_user: dict = Depends(get_
@app.post("/api/agent/discoveries")
async def minerops_agent_post_discoveries(request: Request):
async def minerops_agent_post_discoveries(
request: Request,
authenticated_agent_id: str = Depends(require_agent_identity),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
import json
import uuid
minerops_require_agent_secret(request)
await minerops_ensure_agent_discovery_table()
body = await request.json()
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
site_name = str(body.get("site_name") or "Plexus").strip()
zone_service.validate_agent_identity(
authenticated_agent_id, str(body.get("agent_id") or "").strip() or None
)
site_name = str(body.get("site_name") or "Unassigned").strip()
discoveries = body.get("discoveries") or []
if not isinstance(discoveries, list):
discoveries = []
ownership_context = zone_service.associate_miners_from_agent(
authenticated_agent_id, []
)
conn = await minerops_command_db_connect()
inserted = 0
matched_existing = 0
staged_new = 0
@@ -26666,42 +26589,38 @@ async def minerops_agent_post_discoveries(request: Request):
for item in discoveries:
if not isinstance(item, dict):
continue
ip_address = str(item.get("ip_address") or "").strip()
mac_address = str(item.get("mac_address") or "").strip().upper()
room_name = str(item.get("room_name") or "").strip()
model = str(item.get("model") or "").strip()
hostname = str(item.get("hostname") or "").strip()
miner_sn = str(item.get("miner_sn") or "").strip()
matched = None
if mac_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE upper(current_mac) = upper($1)
LIMIT 1
""",
"SELECT id FROM miners WHERE upper(current_mac) = upper($1) LIMIT 1",
mac_address,
)
if not matched and ip_address:
matched = await conn.fetchrow(
"""
SELECT id
FROM miners
WHERE current_ip = NULLIF($1, '')::inet
LIMIT 1
""",
"SELECT id FROM miners WHERE current_ip = NULLIF($1, '')::inet LIMIT 1",
ip_address,
)
status = "known" if matched else "new"
if matched:
matched_existing += 1
association = zone_service.associate_miners_from_agent(
authenticated_agent_id, [matched["id"]]
)
outcome = association.outcomes.get(matched["id"])
if outcome not in {"assigned", "preserved"}:
status = "zone_conflict" if outcome else "known"
if outcome:
errors.append(
{"miner_id": str(matched["id"]), "error": outcome}
)
else:
staged_new += 1
@@ -26709,45 +26628,23 @@ async def minerops_agent_post_discoveries(request: Request):
await conn.execute(
"""
INSERT INTO minerops_agent_discoveries (
id,
agent_id,
site_name,
room_name,
ip_address,
mac_address,
model,
hostname,
miner_sn,
raw_payload,
discovery_status,
matched_miner_id,
first_seen_at,
last_seen_at,
created_at,
updated_at
)
VALUES (
$1,
$2,
$3,
NULLIF($4, ''),
NULLIF($5, '')::inet,
NULLIF($6, ''),
NULLIF($7, ''),
NULLIF($8, ''),
NULLIF($9, ''),
$10::jsonb,
$11,
$12::uuid,
now(),
now(),
now(),
now()
id, agent_id, agent_zone_id, site_name, room_name,
ip_address, mac_address, model, hostname, miner_sn,
raw_payload, discovery_status, matched_miner_id,
first_seen_at, last_seen_at, created_at, updated_at
) VALUES (
$1, $2, $3::uuid, $4, NULLIF($5, ''),
NULLIF($6, '')::inet, NULLIF($7, ''), NULLIF($8, ''),
NULLIF($9, ''), NULLIF($10, ''), $11::jsonb, $12,
$13::uuid, now(), now(), now(), now()
)
ON CONFLICT (id) DO NOTHING
""",
str(uuid.uuid4()),
agent_id,
authenticated_agent_id,
str(ownership_context.agent_zone_id)
if ownership_context.agent_zone_id
else None,
site_name,
room_name,
ip_address,
@@ -26759,12 +26656,12 @@ async def minerops_agent_post_discoveries(request: Request):
status,
str(matched["id"]) if matched else None,
)
inserted += 1
except Exception as exc:
if len(errors) < 20:
errors.append({"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)})
errors.append(
{"ip_address": ip_address, "mac_address": mac_address, "error": str(exc)}
)
finally:
await conn.close()
@@ -26774,6 +26671,10 @@ async def minerops_agent_post_discoveries(request: Request):
"inserted": inserted,
"matched_existing": matched_existing,
"staged_new": staged_new,
"agent_zone_id": str(ownership_context.agent_zone_id)
if ownership_context.agent_zone_id
else None,
"routing_reason": ownership_context.routing_reason,
"errors": errors,
"message": "Discovery results received.",
}
@@ -26841,9 +26742,12 @@ async def minerops_agent_discoveries(current_user: dict = Depends(get_current_us
@app.post("/api/agent/discoveries/review")
async def minerops_agent_discovery_review(request: Request, current_user: dict = Depends(get_current_user)):
import asyncpg
async def minerops_agent_discovery_review(
request: Request,
current_user: dict = Depends(get_current_user),
zone_service: AgentZoneService = Depends(get_agent_zone_service),
):
del current_user
payload = await request.json()
ip_address = str(payload.get("ip_address") or "").strip()
@@ -26864,6 +26768,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
SELECT
id,
agent_id,
agent_zone_id,
site_name,
room_name,
ip_address::text AS ip_address,
@@ -26936,6 +26841,12 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
""", ip_address)
if existing:
association = zone_service.associate_miners_from_agent(
discovery["agent_id"], [UUID(existing["id"])]
)
ownership_status = association.outcomes.get(
UUID(existing["id"]), association.routing_reason or "preserved"
)
await conn.execute("""
UPDATE minerops_agent_discoveries
SET
@@ -26950,29 +26861,34 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
"action": action,
"ip_address": ip_address,
"miner_id": existing["id"],
"ownership_status": ownership_status,
"message": "Discovery already matched an existing inventory miner."
}
room_id = None
site_id = None
routing = zone_service.routing_context(
discovery["agent_id"], discovery["agent_zone_id"]
)
site_id = routing.site_id if routing.routing_reason is None else None
agent_zone_id = (
routing.agent_zone_id if routing.routing_reason is None else None
)
room_name = discovery["room_name"]
if room_name:
try:
room = await conn.fetchrow("""
SELECT id::text AS id, site_id::text AS site_id
FROM rooms
WHERE lower(name) = lower($1)
LIMIT 1
""", str(room_name))
if room:
room_id = room["id"]
site_id = room["site_id"]
except Exception:
room_id = None
site_id = None
if room_name and site_id:
room = await conn.fetchrow(
"""
SELECT id::text AS id
FROM rooms
WHERE site_id = $1::uuid AND lower(name) = lower($2)
ORDER BY id
LIMIT 1
""",
str(site_id),
str(room_name),
)
if room:
room_id = room["id"]
model = discovery["model"] or "WhatsMiner"
hostname = discovery["hostname"]
@@ -26981,6 +26897,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
new_miner = await conn.fetchrow("""
INSERT INTO miners (
site_id,
agent_zone_id,
room_id,
current_mac,
current_ip,
@@ -26998,14 +26915,15 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
VALUES (
$1::uuid,
$2::uuid,
$3,
$4::inet,
$5,
$3::uuid,
$4,
$5::inet,
$6,
$7,
$7,
'discovered',
$8,
$8,
'discovered',
$9,
now(),
now(),
now(),
@@ -27014,6 +26932,7 @@ async def minerops_agent_discovery_review(request: Request, current_user: dict =
RETURNING id::text AS id
""",
site_id,
agent_zone_id,
room_id,
mac,
ip_address,
+7 -2
View File
@@ -40,15 +40,20 @@ class MinerControlRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
SELECT miners.id, miners.site_id, miners.agent_zone_id,
zones.id IS NOT NULL AS agent_zone_exists,
zones.is_enabled AS agent_zone_enabled,
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 agent_zones AS zones
ON zones.id = miners.agent_zone_id
LEFT JOIN minerops_site_agents AS agents
ON agents.site_id = miners.site_id
ON agents.agent_zone_id = miners.agent_zone_id
WHERE miners.id = %s
""",
(miner_id,),
+7 -3
View File
@@ -29,10 +29,14 @@ class MinerControlService:
@staticmethod
def _capability(context: dict) -> ControlCapability:
if not context.get("site_id"):
return ControlCapability(state="unavailable", reason="site_unassigned")
if not context.get("agent_zone_id"):
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
if context.get("agent_zone_exists") is not True:
return ControlCapability(state="unavailable", reason="agent_zone_missing")
if context.get("agent_zone_enabled") is not True:
return ControlCapability(state="unavailable", reason="agent_zone_disabled")
if not context.get("agent_id"):
return ControlCapability(state="unavailable", reason="agent_unassigned")
return ControlCapability(state="unavailable", reason="agent_unavailable")
if not context.get("current_ip"):
return ControlCapability(state="unavailable", reason="current_ip_missing")
if context.get("api3_supported") is False:
+2
View File
@@ -199,6 +199,8 @@ class AgentSourceContractTests(unittest.TestCase):
self.assertNotIn('DefaultValue "super"', service)
self.assertNotIn('DefaultValue "super"', powershell)
self.assertIn('"X-MinerOps-Agent-ID"', service)
self.assertIn('.agent_zone_id', service)
self.assertIn('"agent_zone_id"', (root / "config.example.json").read_text())
self.assertNotIn("?agent_id=", service)
self.assertIn('"get.miner.setting"', control)
self.assertIn("$attempt -le 3", control)
+283
View File
@@ -0,0 +1,283 @@
"""Focused model, service, router, and architecture tests for Agent Zones v1."""
import unittest
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import AgentZoneValidationError
from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest
from app.agent_zones.router import router
from app.agent_zones.service import AgentZoneService
from app.identity import get_current_user, required_api_permission
SITE_A = UUID("00000000-0000-4000-8000-000000001601")
SITE_B = UUID("00000000-0000-4000-8000-000000001602")
ZONE_A = UUID("00000000-0000-4000-8000-000000001611")
ZONE_B = UUID("00000000-0000-4000-8000-000000001612")
MINER_A = UUID("00000000-0000-4000-8000-000000001621")
APP_ROOT = Path(__file__).parents[1] / "app"
def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"):
return {
"id": zone_id,
"site_id": site_id,
"name": name,
"description": None,
"is_enabled": enabled,
"assigned_agent_id": None,
"networks": [],
}
class FakeRepository:
def __init__(self):
self.zones = {ZONE_A: zone()}
self.agent = None
self.saved = []
self.assign_result = {MINER_A: "assigned"}
def get_agent(self, agent_id):
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
def get_zone(self, zone_id):
return self.zones.get(zone_id)
def list_usable_zones(self, site_id):
return [
value for value in self.zones.values()
if value["site_id"] == site_id and value["is_enabled"]
]
def list_zones(self, site_id=None):
return [
value for value in self.zones.values()
if site_id is None or value["site_id"] == site_id
]
def create_zone(self, *, site_id, name, description, networks):
created_id = uuid4()
self.zones[created_id] = {
**zone(created_id, site_id, name=name),
"description": description,
"networks": [
{"id": uuid4(), "network": network} for network in networks
],
}
return created_id
def save_checkin(self, **kwargs):
self.saved.append(kwargs)
self.agent = {
"agent_id": kwargs["agent_id"],
"site_id": kwargs["site_id"],
"agent_zone_id": kwargs["agent_zone_id"],
"site_name": kwargs["site_name"],
}
def get_agent_assignment(self, agent_id):
if not self.agent or self.agent["agent_id"] != agent_id:
return None
zone_row = self.zones.get(self.agent.get("agent_zone_id"))
return {
**self.agent,
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
}
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(miner_ids), agent_zone_id)
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
for miner_id in miner_ids}
class AgentZoneModelTests(unittest.TestCase):
def test_names_and_networks_are_normalized(self):
request = AgentZoneCreateRequest(
site_id=SITE_A,
name=" Main Room ",
description=" Local VLAN ",
networks=["10.30.0.0/24", "2001:db8::/64"],
)
self.assertEqual(request.name, "Main Room")
self.assertEqual(request.description, "Local VLAN")
self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"])
def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self):
for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]):
with self.subTest(networks=networks), self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks)
with self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True)
class AgentZoneServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
def checkin(self, **kwargs):
return self.service.checkin(
"agent-a",
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
)
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.assertTrue(first.routing_available)
self.assertEqual(second.agent_zone_id, ZONE_A)
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A)
)
def test_established_agent_cannot_change_zone_within_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2")
with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_established_agent_site_conflict_precedes_zone_conflict(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_omitted_zone_preserves_established_assignment_and_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North")
)
self.assertEqual(result.site_id, SITE_A)
self.assertEqual(result.agent_zone_id, ZONE_A)
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
single = self.checkin(site_id=SITE_A)
self.assertEqual(single.agent_zone_id, ZONE_A)
self.repository.agent = None
self.repository.zones = {}
zero = self.checkin(site_id=SITE_A)
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
self.repository.agent = None
self.repository.zones = {
ZONE_A: zone(),
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
}
many = self.checkin(site_id=SITE_A)
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
self.assertIsNone(many.agent_zone_id)
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
self.repository.zones[ZONE_A]["is_enabled"] = False
unassigned = self.checkin(site_id=SITE_A)
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
self.repository.agent = {
"agent_id": "agent-a", "site_id": SITE_A,
"agent_zone_id": ZONE_A, "site_name": "North",
}
existing = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a")
)
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
self.assertEqual(existing.agent_zone_id, ZONE_A)
def test_payload_is_sanitized_before_persistence(self):
request = AgentCheckinRequest.model_validate({
"agent_id": "agent-a", "site_id": str(SITE_A),
"api3_password": "secret", "nested": {"access_token": "secret"},
"control_available": True,
})
self.service.checkin("agent-a", request)
payload = self.repository.saved[-1]["payload"]
self.assertNotIn("password", str(payload).lower())
self.assertNotIn("token", str(payload).lower())
self.assertTrue(payload["control_available"])
def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.assign_result[MINER_A] = "agent_zone_conflict"
result = self.service.associate_miners_from_agent("agent-a", [MINER_A])
self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict")
self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A))
def test_routing_context_rejects_stale_recorded_zone(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.routing_context("agent-a", ZONE_B)
self.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertIsNone(result.site_id)
class AgentZoneRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service
self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a"
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "username": "operator", "role": "Custom",
"permissions": list(permissions),
}
def test_list_and_create_use_existing_agent_permissions(self):
self.assertEqual(self.client.get("/api/agent-zones").status_code, 401)
self.authenticate(("agents.view",))
self.assertEqual(self.client.get("/api/agent-zones").status_code, 200)
self.assertEqual(
self.client.post(
"/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"}
).status_code,
403,
)
self.authenticate(("agents.manage",))
response = self.client.post(
"/api/agent-zones",
json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["name"], "Room 2")
def test_checkin_uses_authenticated_agent_identity(self):
response = self.client.post(
"/api/agent/checkin",
json={"agent_id": "agent-a", "site_id": str(SITE_A),
"agent_zone_id": str(ZONE_A)},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["agent_id"], "agent-a")
self.assertNotIn("secret", response.text.lower())
def test_permission_and_architecture_audits(self):
self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view")
self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage")
router_source = (APP_ROOT / "agent_zones" / "router.py").read_text()
service_source = (APP_ROOT / "agent_zones" / "service.py").read_text()
repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text()
self.assertNotIn("psycopg", router_source)
self.assertNotIn("SELECT ", router_source)
self.assertNotIn("fastapi", service_source.lower())
self.assertNotIn("fastapi", repository_source.lower())
self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text())
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
"""Real PostgreSQL integrity and routing tests for Agent Zones v1."""
import unittest
from uuid import UUID
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from app.agent_zones.repository import AgentZoneRepository
from app.config import settings
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
SITE_B = UUID("00000000-0000-4000-8000-000000001631")
ZONE_A = UUID("00000000-0000-4000-8000-000000001632")
ZONE_B = UUID("00000000-0000-4000-8000-000000001633")
ZONE_C = UUID("00000000-0000-4000-8000-000000001636")
MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634")
MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635")
AGENT_A = "agent-zone-postgres-a"
def connect():
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 AgentZonePostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), "
"(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING",
(SITE_A, SITE_B),
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) VALUES "
"(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), "
"(%s, %s, 'Unstaffed Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A),
)
cur.execute(
"INSERT INTO miners (id, site_id, asset_tag, current_ip, status, "
"api3_supported, first_seen_at, last_seen_at) VALUES "
"(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), "
"(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) "
"ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL",
(MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B),
)
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_B, MINER_OTHER),
)
cur.execute(
"INSERT INTO minerops_site_agents ("
"agent_id, site_id, agent_zone_id, site_name, last_payload) "
"VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') "
"ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, "
"agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload",
(AGENT_A, SITE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'")
cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER))
cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B))
connection.commit()
finally:
connection.close()
def setUp(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE agent_zones SET is_enabled = true "
"WHERE id IN (%s, %s, %s)",
(ZONE_A, ZONE_B, ZONE_C),
)
cur.execute(
"UPDATE miners SET agent_zone_id = NULL WHERE id = %s",
(MINER_UNASSIGNED,),
)
cur.execute(
"UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s",
(SITE_B, ZONE_B, MINER_OTHER),
)
cur.execute(
"UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s "
"WHERE agent_id = %s",
(SITE_A, ZONE_A, AGENT_A),
)
connection.commit()
finally:
connection.close()
def test_repository_lists_multiple_canonical_networks(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zone_network_ranges (agent_zone_id, network) "
"VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') "
"ON CONFLICT DO NOTHING",
(ZONE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
listed = AgentZoneRepository(connect).list_zones(SITE_A)
self.assertEqual([item["network"] for item in listed[0]["networks"]],
["10.30.0.0/24", "10.31.0.0/24"])
def test_active_names_are_case_insensitively_unique_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')",
(SITE_A,),
)
finally:
connection.rollback()
connection.close()
def test_one_agent_per_zone_but_multiple_zones_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO minerops_site_agents "
"(agent_id, site_id, agent_zone_id, site_name) "
"VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')",
(SITE_A, ZONE_A),
)
finally:
connection.rollback()
connection.close()
def test_composite_foreign_keys_reject_cross_site_assignment(self):
connection = connect()
try:
with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_OTHER),
)
finally:
connection.rollback()
connection.close()
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
repository = AgentZoneRepository(connect)
outcomes = repository.assign_miners_to_zone(
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
)
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)",
(MINER_UNASSIGNED, MINER_OTHER),
)
assignments = dict(cur.fetchall())
finally:
connection.close()
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
def test_disabled_zone_blocks_control_without_site_fallback(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_UNASSIGNED),
)
cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
try:
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
finally:
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
def test_enabled_zone_without_agent_does_not_fall_back_within_site(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_C, MINER_UNASSIGNED),
)
connection.commit()
finally:
connection.close()
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_unavailable")
def test_null_zone_does_not_fall_back_to_site_agent(self):
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -26,6 +26,9 @@ def context(**overrides):
result = {
"id": MINER_ID,
"site_id": uuid4(),
"agent_zone_id": uuid4(),
"agent_zone_exists": True,
"agent_zone_enabled": True,
"current_ip": "192.0.2.10",
"runtime_state": "online",
"configured_performance_mode": "normal",
@@ -78,8 +81,10 @@ class MinerControlServiceTests(unittest.TestCase):
def test_capability_missing_facts_is_not_optimistic(self):
cases = (
({"site_id": None}, "unavailable", "site_unassigned"),
({"agent_id": None}, "unavailable", "agent_unassigned"),
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
({"agent_id": None}, "unavailable", "agent_unavailable"),
({"current_ip": None}, "unavailable", "current_ip_missing"),
({"api3_supported": False}, "unavailable", "api3_unsupported"),
({"api3_supported": None}, "unknown", "api3_support_unknown"),
+16 -6
View File
@@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001514")
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
@@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase):
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) "
"VALUES (%s, %s, 'Control Test Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_ID, SITE_ID),
)
cur.execute(
"""
INSERT INTO users (id, username, display_name, password_hash, role_id)
@@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute(
"""
INSERT INTO miners (
id, site_id, asset_tag, current_ip, status, api3_supported,
id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported,
configured_performance_mode, first_seen_at, last_seen_at
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
'normal', NULL, NULL)
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
api3_supported = true
""", (MINER_ID, SITE_ID)
""", (MINER_ID, SITE_ID, ZONE_ID)
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, site_name, last_payload
) VALUES (%s, %s, 'Control Test Site', %s)
agent_id, site_id, agent_zone_id, site_name, last_payload
) VALUES (%s, %s, %s, 'Control Test Site', %s)
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
last_payload = EXCLUDED.last_payload
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
""", (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True}))
)
connection.commit()
finally:
@@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,))
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
connection.commit()
finally:
+1 -1
View File
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
APP_ROOT = Path(__file__).parents[1] / "app"
EXPECTED_HEAD = "0015_miner_control_foundation"
EXPECTED_HEAD = "0016_agent_zones"
def database_payload(revision=EXPECTED_HEAD):