From 82309db79aaf524d13129f9069026603dfc9f836 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 26 Aug 2026 15:48:59 +0000 Subject: [PATCH] feat: add agent zones --- agents/windows/MinerOpsAgentService.ps1 | 8 + agents/windows/README.md | 7 +- agents/windows/config.example.json | 1 + backend/alembic/versions/0016_agent_zones.py | 365 +++++++++++++++++ backend/app/agent_zones/__init__.py | 1 + backend/app/agent_zones/dependencies.py | 8 + backend/app/agent_zones/exceptions.py | 17 + backend/app/agent_zones/models.py | 114 ++++++ backend/app/agent_zones/repository.py | 300 ++++++++++++++ backend/app/agent_zones/router.py | 75 ++++ backend/app/agent_zones/service.py | 194 +++++++++ backend/app/identity.py | 2 + backend/app/main.py | 389 ++++++++----------- backend/app/miner_control/repository.py | 9 +- backend/app/miner_control/service.py | 10 +- backend/tests/test_agent_commands.py | 2 + backend/tests/test_agent_zones.py | 283 ++++++++++++++ backend/tests/test_agent_zones_postgres.py | 244 ++++++++++++ backend/tests/test_miner_control.py | 9 +- backend/tests/test_miner_control_postgres.py | 22 +- backend/tests/test_system_health.py | 2 +- docs/design/AGENT_ZONES.md | 132 +++++++ scripts/seed-development-demo.sh | 4 +- scripts/test-agent-zones-migration.sh | 186 +++++++++ scripts/test-agent-zones.sh | 53 +++ scripts/test-dev-baseline.sh | 2 +- scripts/test-windows-agent-source.sh | 4 + sql/development-demo.sql | 52 ++- sql/verify-development-schema.sql | 2 + 29 files changed, 2242 insertions(+), 255 deletions(-) create mode 100644 backend/alembic/versions/0016_agent_zones.py create mode 100644 backend/app/agent_zones/__init__.py create mode 100644 backend/app/agent_zones/dependencies.py create mode 100644 backend/app/agent_zones/exceptions.py create mode 100644 backend/app/agent_zones/models.py create mode 100644 backend/app/agent_zones/repository.py create mode 100644 backend/app/agent_zones/router.py create mode 100644 backend/app/agent_zones/service.py create mode 100644 backend/tests/test_agent_zones.py create mode 100644 backend/tests/test_agent_zones_postgres.py create mode 100644 docs/design/AGENT_ZONES.md create mode 100755 scripts/test-agent-zones-migration.sh create mode 100755 scripts/test-agent-zones.sh diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 index 8d9e97f..f7d9cf6 100644 --- a/agents/windows/MinerOpsAgentService.ps1 +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -78,6 +78,14 @@ function Send-AgentCheckin { local_time = (Get-Date).ToString("o") } + if (-not [string]::IsNullOrWhiteSpace([string]$config.site_id)) { + $body["site_id"] = [string]$config.site_id + } + + if (-not [string]::IsNullOrWhiteSpace([string]$config.agent_zone_id)) { + $body["agent_zone_id"] = [string]$config.agent_zone_id + } + return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body } diff --git a/agents/windows/README.md b/agents/windows/README.md index 5bbc9d5..9a4a3c0 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -15,8 +15,11 @@ probe output are intentionally excluded. ## Local Files Create `agent-config.json` from `config.example.json` on the Agent host. Never -commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is -retained only for transitional display compatibility. +commit that file. `site_id` is the durable MinerOps site UUID. `agent_zone_id` is the optional +durable network-reachability Zone UUID; older configurations may omit it only +when the Hub can preserve an established assignment or resolve exactly one +enabled Zone at the Site. `site_name` is retained only for transitional display +compatibility. The Agent writes runtime state to `agent-state.json` and logs under `logs/`. Installation and upgrades preserve all three paths. diff --git a/agents/windows/config.example.json b/agents/windows/config.example.json index ead5687..b4f27b6 100644 --- a/agents/windows/config.example.json +++ b/agents/windows/config.example.json @@ -2,6 +2,7 @@ "hub_url": "https://minerops.example.invalid", "agent_id": "replace-with-agent-id", "site_id": "00000000-0000-0000-0000-000000000000", + "agent_zone_id": "00000000-0000-0000-0000-000000000000", "site_name": "Example Site", "agent_secret": "replace-with-agent-secret", "poll_interval_seconds": 120, diff --git a/backend/alembic/versions/0016_agent_zones.py b/backend/alembic/versions/0016_agent_zones.py new file mode 100644 index 0000000..1e544c6 --- /dev/null +++ b/backend/alembic/versions/0016_agent_zones.py @@ -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") diff --git a/backend/app/agent_zones/__init__.py b/backend/app/agent_zones/__init__.py new file mode 100644 index 0000000..12c105a --- /dev/null +++ b/backend/app/agent_zones/__init__.py @@ -0,0 +1 @@ +"""Agent Zone routing domain.""" diff --git a/backend/app/agent_zones/dependencies.py b/backend/app/agent_zones/dependencies.py new file mode 100644 index 0000000..23b1396 --- /dev/null +++ b/backend/app/agent_zones/dependencies.py @@ -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()) diff --git a/backend/app/agent_zones/exceptions.py b/backend/app/agent_zones/exceptions.py new file mode 100644 index 0000000..6821fb0 --- /dev/null +++ b/backend/app/agent_zones/exceptions.py @@ -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.""" diff --git a/backend/app/agent_zones/models.py b/backend/app/agent_zones/models.py new file mode 100644 index 0000000..987774c --- /dev/null +++ b/backend/app/agent_zones/models.py @@ -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) diff --git a/backend/app/agent_zones/repository.py b/backend/app/agent_zones/repository.py new file mode 100644 index 0000000..afc34d7 --- /dev/null +++ b/backend/app/agent_zones/repository.py @@ -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() diff --git a/backend/app/agent_zones/router.py b/backend/app/agent_zones/router.py new file mode 100644 index 0000000..771351d --- /dev/null +++ b/backend/app/agent_zones/router.py @@ -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 diff --git a/backend/app/agent_zones/service.py b/backend/app/agent_zones/service.py new file mode 100644 index 0000000..01cd57b --- /dev/null +++ b/backend/app/agent_zones/service.py @@ -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 + ), + ) diff --git a/backend/app/identity.py b/backend/app/identity.py index c6c8323..f3ac3fe 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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/"): diff --git a/backend/app/main.py b/backend/app/main.py index 8c5ab70..1d7d2df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/miner_control/repository.py b/backend/app/miner_control/repository.py index ce9ca1d..a723524 100644 --- a/backend/app/miner_control/repository.py +++ b/backend/app/miner_control/repository.py @@ -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,), diff --git a/backend/app/miner_control/service.py b/backend/app/miner_control/service.py index 3f8a602..4987c69 100644 --- a/backend/app/miner_control/service.py +++ b/backend/app/miner_control/service.py @@ -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: diff --git a/backend/tests/test_agent_commands.py b/backend/tests/test_agent_commands.py index f569033..dc9ee48 100644 --- a/backend/tests/test_agent_commands.py +++ b/backend/tests/test_agent_commands.py @@ -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) diff --git a/backend/tests/test_agent_zones.py b/backend/tests/test_agent_zones.py new file mode 100644 index 0000000..ee70dd7 --- /dev/null +++ b/backend/tests/test_agent_zones.py @@ -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() diff --git a/backend/tests/test_agent_zones_postgres.py b/backend/tests/test_agent_zones_postgres.py new file mode 100644 index 0000000..0b1f50a --- /dev/null +++ b/backend/tests/test_agent_zones_postgres.py @@ -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() diff --git a/backend/tests/test_miner_control.py b/backend/tests/test_miner_control.py index 5f092e9..e24cc9d 100644 --- a/backend/tests/test_miner_control.py +++ b/backend/tests/test_miner_control.py @@ -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"), diff --git a/backend/tests/test_miner_control_postgres.py b/backend/tests/test_miner_control_postgres.py index 87651a2..7381ba3 100644 --- a/backend/tests/test_miner_control_postgres.py +++ b/backend/tests/test_miner_control_postgres.py @@ -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: diff --git a/backend/tests/test_system_health.py b/backend/tests/test_system_health.py index 93a9669..e86cb44 100644 --- a/backend/tests/test_system_health.py +++ b/backend/tests/test_system_health.py @@ -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): diff --git a/docs/design/AGENT_ZONES.md b/docs/design/AGENT_ZONES.md new file mode 100644 index 0000000..41fe011 --- /dev/null +++ b/docs/design/AGENT_ZONES.md @@ -0,0 +1,132 @@ +# Agent Zones + +## Purpose + +An Agent Zone is MinerOps' durable network-reachability boundary. A Site is an +operational location and may contain networks that cannot be reached by one +Agent computer. Agent Zones let those networks remain within one Site without +making Site identity determine control routing. + +The implemented relationship is: + +```text +Site + -> Agent Zone + -> Agent installation + -> miners reachable through that Zone +``` + +`agent_id` remains the durable Agent-installation identity. Agent Zone identity +is a UUID independent of the Agent computer assigned to it. + +## Site, Zone, And Agent + +A Site groups rooms and operational records. An Agent Zone groups one or more +CIDR network ranges that are reachable from an Agent installation. The ranges +are factual Zone configuration for future enrollment and do not implement an +arbitrary routing table. + +A Site can contain multiple Zones. For example: + +```text +Plexus Mining Site + Main Room 10.30.0.0/24 -> Agent A + Room 2 10.40.0.0/24 -> Agent B + Immersion Room 10.50.0.0/24 -> Agent C +``` + +Zone names are unique case-insensitively among enabled Zones at the same Site. +They are not globally unique. A Zone can contain multiple canonical PostgreSQL +`CIDR` ranges. + +## Routing Authority + +Miner control resolves only from Hub-owned facts: + +```text +miner UUID + -> miners.agent_zone_id + -> Agent assigned to that Zone + -> miner current IP recorded by MinerOps + -> local API3 operation +``` + +A control request from a browser contains none of the routing inputs. It cannot +select an IP address, Agent, Agent Zone, raw vendor command, credentials, or a +dry-run mode. MinerOps never falls back to another Agent merely because it is +assigned to the same Site. + +A miner without a Zone is unavailable with `agent_zone_unassigned`. A missing +or disabled Zone is unavailable with `agent_zone_disabled`. A Zone without an +assigned Agent is unavailable with `agent_unavailable`. + +## Durable Miner Ownership + +`miners.agent_zone_id` is the authoritative reachability assignment. Snapshot +and discovery ingestion can assign a currently unassigned miner when the +authenticated Agent has a valid enabled Zone and Site boundaries agree. + +An observation from a different Zone does not move an assigned miner. MinerOps +records or returns `agent_zone_conflict` or `site_conflict` while preserving the +existing assignment. Snapshot and discovery payloads may still be staged as +factual observations. Explicit administrative reassignment is deferred. + +## Agent Assignment + +Agent Zones v1 permits at most one Agent installation per non-null Zone. A Site +can therefore have multiple Agents when they occupy different Zones. Primary +and standby Agents, automatic failover, and Agent high availability are not +implemented. + +An enabled Zone is usable for transitional check-in and control routing. A +disabled Zone preserves its durable associations but cannot be selected for a +new check-in and cannot route miner control. + +## Check-In Compatibility + +The Windows Agent may send `site_id` and optional `agent_zone_id` during +check-in. Existing configurations that omit `agent_zone_id` remain compatible: + +- An established valid assignment is preserved. +- An unassigned Agent can resolve automatically only when the supplied Site has + exactly one enabled Zone. +- Zero enabled Zones returns `agent_zone_unavailable`. +- Multiple enabled Zones returns `agent_zone_ambiguous`. +- MinerOps never chooses the first Zone. + +An explicit Zone must exist, be enabled, and belong to the supplied Site. An +Agent cannot move its established Zone or Site through check-in. Later +administrative Agent management will own reassignment. + +## Permissions And APIs + +The implemented management foundation reuses existing permissions: + +- `GET /api/agent-zones` requires `agents.view`. +- `POST /api/agent-zones` requires `agents.manage`. +- `POST /api/agent/checkin` requires authenticated Agent identity. + +The existing role matrix grants `agents.view` to Admin, Manager, Technician, +and Read Only. `agents.manage` is granted to Admin and Manager. No Agent Zone UI +is implemented in this milestone. + +## Migration Compatibility + +Migration 0016 creates one deterministic `Default Zone` for each pre-existing +Site. Existing miners and Agent assignments with a durable `site_id` are +backfilled to that Site's default Zone. Existing snapshots and discoveries are +associated when their durable Agent assignment makes that association factual. +Rows without a safe Site association remain unassigned. + +Downgrade to migration 0015 is allowed only when the Zone configuration can be +represented by its one-Agent-per-Site model. Downgrade refuses before +structural changes when a Site has multiple Agents, non-default Zone topology, +custom Zone state, or network ranges. + +## Future Relationship + +One-time Agent enrollment tokens, Settings -> Agents, installer downloads, +Linux Agents, update management, administrative reassignment, and primary or +standby Agents are future work. An enrollment flow can use a Zone's configured +network ranges, but enrollment tokens and automatic range delivery are not +implemented by Agent Zones v1. diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh index 9cad861..d232985 100755 --- a/scripts/seed-development-demo.sh +++ b/scripts/seed-development-demo.sh @@ -78,8 +78,8 @@ migration_head="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"' )" -if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then - echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2 +if [[ "${migration_head}" != "0016_agent_zones" ]]; then + echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0016_agent_zones." >&2 exit 1 fi diff --git a/scripts/test-agent-zones-migration.sh b/scripts/test-agent-zones-migration.sh new file mode 100755 index 0000000..6c497fc --- /dev/null +++ b/scripts/test-agent-zones-migration.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_AGENT_ZONES_MIGRATION_PROJECT:-minerops-agent-zones-migration-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT +run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; } +psql_db() { + "${compose[@]}" exec -T postgres sh -ec \ + 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@" +} + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend + +site_a="00000000-0000-4000-8000-000000001690" +site_b="00000000-0000-4000-8000-000000001691" +miner_a="00000000-0000-4000-8000-000000001692" + +echo "Testing production path 0014 -> 0015 -> 0016 and truthful unassigned data..." +run_alembic upgrade 0014_repair_parts_tracking +psql_db -c "INSERT INTO minerops_site_agents (agent_id, site_name) VALUES ('legacy-zone-unassigned', 'Legacy Display')" +run_alembic upgrade 0015_miner_control_foundation +psql_db </dev/null)" == *"0016_agent_zones"* ]] + +psql_db < '10.30.0.0/24' THEN + RAISE EXCEPTION 'CIDR was not stored canonically'; + END IF; + BEGIN + INSERT INTO agent_zones (site_id, name) VALUES ('${site_a}', 'default zone'); + RAISE EXCEPTION 'Case-insensitive active Zone duplicate was accepted'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + BEGIN + INSERT INTO minerops_site_agents (agent_id, site_id, agent_zone_id, site_name) + VALUES ('duplicate-zone-agent', '${site_a}', zone_a, 'Duplicate'); + RAISE EXCEPTION 'Second Agent in one Zone was accepted'; + EXCEPTION WHEN unique_violation THEN NULL; + END; + BEGIN + UPDATE miners SET site_id = '${site_b}' WHERE id = '${miner_a}'; + RAISE EXCEPTION 'Cross-Site miner Zone combination was accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; +END \$\$; +SQL + +echo "Confirming CIDR values with host bits are rejected..." +if psql_db -c "INSERT INTO agent_zone_network_ranges (agent_zone_id, network) SELECT id, '10.30.0.7/24'::cidr FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Default Zone'"; then + echo "Host-address CIDR unexpectedly passed PostgreSQL validation" >&2 + exit 1 +fi + + +echo "Testing downgrade refusal for irreducible network configuration..." +if output="$(run_alembic downgrade 0015_miner_control_foundation 2>&1)"; then + echo "Downgrade unexpectedly accepted Agent Zone network ranges" >&2 + exit 1 +fi +[[ "${output}" == *"cannot be represented by the one-Agent-per-Site model"* ]] +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM agent_zone_network_ranges")" == "1" ]] +psql_db -c "DELETE FROM agent_zone_network_ranges" + +echo "Testing downgrade refusal with multiple Agents at one Site..." +psql_db <&2 + exit 1 +fi +[[ "${output}" == *"multiple Agent installations are assigned"* ]] +[[ "${output}" == *"${site_a}"* ]] +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE site_id='${site_a}'")" == "2" ]] +psql_db -c "DELETE FROM minerops_site_agents WHERE agent_id = 'zone-agent-b'; DELETE FROM agent_zones WHERE site_id = '${site_a}' AND name = 'Room 2'" + +echo "Testing legal downgrade, data preservation, and re-upgrade..." +run_alembic downgrade 0015_miner_control_foundation +[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM miners WHERE id='${miner_a}'")" == "1" ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_site_agents WHERE agent_id='zone-agent-a'")" == "1" ]] +[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.tables WHERE table_name='agent_zones'")" == "0" ]] +run_alembic upgrade 0016_agent_zones +[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]] + +echo "Testing empty database through head..." +"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_agent_zones_empty' +"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_agent_zones_empty backend alembic upgrade head +empty_revision="$("${compose[@]}" exec -T postgres sh -ec \ + 'psql -U "$POSTGRES_USER" -d minerops_agent_zones_empty -Atc "SELECT version_num FROM alembic_version"')" +[[ "${empty_revision}" == "0016_agent_zones" ]] + +echo "Agent Zones migration lifecycle checks passed." diff --git a/scripts/test-agent-zones.sh b/scripts/test-agent-zones.sh new file mode 100755 index 0000000..9030be7 --- /dev/null +++ b/scripts/test-agent-zones.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" +project_name="${MINEROPS_AGENT_ZONES_TEST_PROJECT:-minerops-agent-zones-test-$$}" +compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml) + +cleanup() { + status=$? + if (( status != 0 )); then + "${compose[@]}" logs --no-color --tail 160 postgres || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "${status}" +} +trap cleanup EXIT + +[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; } +for source_file in \ + backend/tests/test_agent_zones.py \ + backend/tests/test_agent_zones_postgres.py \ + backend/tests/test_miner_control.py \ + backend/tests/test_miner_control_postgres.py \ + backend/tests/test_agent_commands.py; do + permissions="$(stat -c '%a' "${source_file}")" + (( (10#${permissions} % 10) & 4 )) || { + echo "${source_file} is not readable by the non-root test container" >&2 + exit 1 + } +done + +"${compose[@]}" up --detach postgres +for attempt in $(seq 1 60); do + "${compose[@]}" exec -T postgres sh -ec \ + 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break + (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; } + sleep 1 +done +"${compose[@]}" build backend +"${compose[@]}" run --rm --no-deps backend alembic upgrade head +"${compose[@]}" run --rm --no-deps \ + -v "${repo_root}/backend/tests:/app/tests:ro" \ + -v "${repo_root}/agents:/agents:ro" \ + backend python -m unittest -v \ + tests.test_agent_zones \ + tests.test_agent_zones_postgres \ + tests.test_miner_control \ + tests.test_miner_control_postgres \ + tests.test_agent_commands + +scripts/test-windows-agent-source.sh +echo "Agent Zones focused tests passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index e8313a5..f4db3be 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0015_miner_control_foundation"* ]] +[[ "${current_revision}" == *"0016_agent_zones"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh index 6506221..c9a0d85 100755 --- a/scripts/test-windows-agent-source.sh +++ b/scripts/test-windows-agent-source.sh @@ -5,6 +5,10 @@ service="agents/windows/MinerOpsAgentService.ps1" control="agents/windows/MinerOpsControl.ps1" installer="agents/windows/install.ps1" rg -q 'X-MinerOps-Agent-ID' "${service}" +rg -Fq '$config.agent_zone_id' "${service}" +rg -Fq '$body["agent_zone_id"]' "${service}" +rg -q '"agent_zone_id"' agents/windows/config.example.json +rg -q '`agent_zone_id` is the optional' agents/windows/README.md ! rg -q 'commands/pending\?agent_id=' "${service}" ! rg -q 'DefaultValue "super"' "${service}" rg -q 'get\.miner\.setting' "${control}" diff --git a/sql/development-demo.sql b/sql/development-demo.sql index 274de11..7b89d4e 100644 --- a/sql/development-demo.sql +++ b/sql/development-demo.sql @@ -40,6 +40,54 @@ ON CONFLICT (id) DO UPDATE SET description = EXCLUDED.description, updated_at = now(); +INSERT INTO agent_zones (id, site_id, name, description, is_enabled) +VALUES ( + 'd0000000-0000-4000-8000-000000000201', + 'd0000000-0000-4000-8000-000000000001', + 'Demo Operations Network', + 'Synthetic development-only Agent reachability Zone.', + true +) +ON CONFLICT (id) DO UPDATE SET + site_id = EXCLUDED.site_id, + name = EXCLUDED.name, + description = EXCLUDED.description, + is_enabled = EXCLUDED.is_enabled, + updated_at = now(); + +INSERT INTO agent_zone_network_ranges ( + id, agent_zone_id, network +) VALUES ( + 'd0000000-0000-4000-8000-000000000202', + 'd0000000-0000-4000-8000-000000000201', + '192.0.2.0/24'::cidr +) +ON CONFLICT (id) DO UPDATE SET + agent_zone_id = EXCLUDED.agent_zone_id, + network = EXCLUDED.network; + +INSERT INTO minerops_site_agents ( + agent_id, site_id, agent_zone_id, site_name, hostname, version, + last_checkin_at, last_payload +) VALUES ( + 'plexus-windows-agent', + 'd0000000-0000-4000-8000-000000000001', + 'd0000000-0000-4000-8000-000000000201', + 'Demo Mining Site', + 'demo-agent', + 'development-fixture', + now(), + jsonb_build_object('control_available', true, 'synthetic', true) +) +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, + last_checkin_at = EXCLUDED.last_checkin_at, + last_payload = EXCLUDED.last_payload; + INSERT INTO rooms (id, site_id, name, description, sort_order) VALUES ( @@ -94,7 +142,7 @@ WITH demo_miners AS ( FROM generate_series(1, 30) AS series(i) ) INSERT INTO miners ( - id, site_id, room_id, asset_tag, current_mac, current_ip, hostname, + id, site_id, agent_zone_id, room_id, asset_tag, current_mac, current_ip, hostname, miner_sn, power_sn, control_board_sn, model, miner_type, api_version, firmware_version, platform, chip, status, lifecycle_status, notes, first_seen_at, last_seen_at, created_at, updated_at @@ -102,6 +150,7 @@ INSERT INTO miners ( SELECT id, 'd0000000-0000-4000-8000-000000000001', + 'd0000000-0000-4000-8000-000000000201', room_id, format('DEMO-MIN-%s', lpad(i::text, 3, '0')), mac, @@ -126,6 +175,7 @@ SELECT FROM demo_miners ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id, + agent_zone_id = EXCLUDED.agent_zone_id, room_id = EXCLUDED.room_id, asset_tag = EXCLUDED.asset_tag, current_mac = EXCLUDED.current_mac, diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index f9a9546..a05b1b2 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -26,6 +26,8 @@ WITH expected(table_name) AS ( ('miner_pool_snapshots'), ('miner_api3_enrichment_snapshots'), ('miner_api3_hashboard_snapshots'), + ('agent_zones'), + ('agent_zone_network_ranges'), ('minerops_site_agents'), ('minerops_agent_snapshots'), ('minerops_agent_command_results'),