feat: add agent zones
This commit is contained in:
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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent Zone routing domain."""
|
||||
@@ -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())
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user