WIP: Agent zones and secure per-Agent enrollment #3
No files matched your search
@@ -144,6 +144,7 @@ function Find-MacAddress {
|
||||
$config = Read-AgentConfig
|
||||
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||
$secret = [string]$config.agent_secret
|
||||
$agentId = [string]$config.agent_id
|
||||
|
||||
$commands = @(
|
||||
"summary",
|
||||
@@ -237,6 +238,7 @@ $response = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
|
||||
-Headers @{
|
||||
"X-MinerOps-Agent-ID" = $agentId
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
} `
|
||||
-ContentType "application/json" `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ $SiteName = [string]$Config.site_name
|
||||
$Secret = [string]$Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $Secret
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ $AgentId = $Config.agent_id
|
||||
$AgentSecret = $Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $AgentSecret
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""add agent zones
|
||||
|
||||
Revision ID: 0016_agent_zones
|
||||
Revises: 0015_miner_control_foundation
|
||||
Create Date: 2026-08-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0016_agent_zones"
|
||||
down_revision: Union[str, None] = "0015_miner_control_foundation"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ZONE_NAME = "Default Zone"
|
||||
|
||||
|
||||
def _format_rows(rows: list[tuple]) -> str:
|
||||
return ", ".join("/".join(str(value) for value in row) for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_zones",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"site_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
|
||||
name="ck_agent_zones_name",
|
||||
),
|
||||
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
|
||||
)
|
||||
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
|
||||
op.create_index(
|
||||
"uq_agent_zones_active_site_name",
|
||||
"agent_zones",
|
||||
["site_id", sa.text("lower(name)")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_zone_network_ranges",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"agent_zone_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("network", postgresql.CIDR(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
"agent_zone_network_ranges",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"miners",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_miners_agent_zone_requires_site",
|
||||
"miners",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_miners_agent_zone_site",
|
||||
"miners",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
|
||||
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_snapshots",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_discoveries",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO agent_zones (site_id, name)
|
||||
SELECT sites.id, :default_name
|
||||
FROM sites
|
||||
ORDER BY sites.id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE miners AS miner
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE miner.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND miner.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE agent.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND agent.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_snapshots AS snapshot
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE snapshot.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND snapshot.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_discoveries AS discovery
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE discovery.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND discovery.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
"minerops_site_agents",
|
||||
["agent_zone_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
duplicate_site_agents = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id IS NOT NULL
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY site_id
|
||||
"""
|
||||
)
|
||||
).all()
|
||||
if duplicate_site_agents:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
|
||||
"assigned within Site(s): "
|
||||
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
|
||||
"until each Site has at most one Agent before retrying; no Agent Zone "
|
||||
"data was changed."
|
||||
)
|
||||
|
||||
non_reducible_zones = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM agent_zones
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
|
||||
ORDER BY site_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
range_count = connection.execute(
|
||||
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
|
||||
).scalar_one()
|
||||
if non_reducible_zones or range_count:
|
||||
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
|
||||
f"represented by the one-Agent-per-Site model ({detail}). Remove "
|
||||
"additional/custom Zones and Zone network ranges before retrying; no "
|
||||
"Agent Zone data was changed."
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
["site_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
table_name="minerops_agent_discoveries",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
table_name="minerops_agent_snapshots",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
|
||||
|
||||
op.drop_constraint(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "agent_zone_id")
|
||||
|
||||
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
|
||||
op.drop_constraint(
|
||||
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_miners_agent_zone_requires_site", "miners", type_="check"
|
||||
)
|
||||
op.drop_column("miners", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
table_name="agent_zone_network_ranges",
|
||||
)
|
||||
op.drop_table("agent_zone_network_ranges")
|
||||
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
|
||||
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
|
||||
op.drop_table("agent_zones")
|
||||
@@ -0,0 +1,333 @@
|
||||
"""add secure Agent enrollment and per-Agent credential storage
|
||||
|
||||
Revision ID: 0017_agent_enrollment
|
||||
Revises: 0016_agent_zones
|
||||
Create Date: 2026-08-26
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0017_agent_enrollment"
|
||||
down_revision: Union[str, None] = "0016_agent_zones"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Per-Agent credentials are additive. Existing Agents retain NULL credential
|
||||
# fields and therefore remain eligible for the temporary legacy shared-secret
|
||||
# authentication path until explicitly migrated.
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("agent_secret_hash", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"credential_created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column(
|
||||
"credential_revoked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_display_name",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
display_name IS NULL
|
||||
OR (
|
||||
btrim(display_name) <> ''
|
||||
AND char_length(display_name) <= 120
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_secret_hash",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
agent_secret_hash IS NULL
|
||||
OR agent_secret_hash ~ '^[0-9a-f]{64}$'
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_pair",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
(
|
||||
agent_secret_hash IS NULL
|
||||
AND credential_created_at IS NULL
|
||||
)
|
||||
OR (
|
||||
agent_secret_hash IS NOT NULL
|
||||
AND credential_created_at IS NOT NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_credential_revoked_time",
|
||||
"minerops_site_agents",
|
||||
"""
|
||||
credential_revoked_at IS NULL
|
||||
OR (
|
||||
credential_created_at IS NOT NULL
|
||||
AND credential_revoked_at >= credential_created_at
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_agent_secret_hash",
|
||||
"minerops_site_agents",
|
||||
["agent_secret_hash"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("agent_secret_hash IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_enrollments",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"site_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"agent_zone_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("display_name", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"expires_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"used_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"revoked_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("enrolled_agent_id", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["agent_zone_id", "site_id"],
|
||||
["agent_zones.id", "agent_zones.site_id"],
|
||||
name="fk_agent_enrollments_zone_site",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["enrolled_agent_id"],
|
||||
["minerops_site_agents.agent_id"],
|
||||
name="fk_agent_enrollments_agent",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"token_hash ~ '^[0-9a-f]{64}$'",
|
||||
name="ck_agent_enrollments_token_hash",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"btrim(display_name) <> '' AND char_length(display_name) <= 120",
|
||||
name="ck_agent_enrollments_display_name",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"expires_at > created_at",
|
||||
name="ck_agent_enrollments_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"used_at IS NULL OR used_at >= created_at",
|
||||
name="ck_agent_enrollments_used_time",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"used_at IS NULL OR used_at <= expires_at",
|
||||
name="ck_agent_enrollments_used_before_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"revoked_at IS NULL OR revoked_at >= created_at",
|
||||
name="ck_agent_enrollments_revoked_time",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"NOT (used_at IS NOT NULL AND revoked_at IS NOT NULL)",
|
||||
name="ck_agent_enrollments_terminal_state",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"""
|
||||
(
|
||||
used_at IS NULL
|
||||
AND enrolled_agent_id IS NULL
|
||||
)
|
||||
OR (
|
||||
used_at IS NOT NULL
|
||||
AND enrolled_agent_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
name="ck_agent_enrollments_agent_when_used",
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_token_hash",
|
||||
"agent_enrollments",
|
||||
["token_hash"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# PostgreSQL cannot use now() in a partial-index predicate because now() is
|
||||
# not immutable. Therefore "open" means unused and unrevoked. Creation code
|
||||
# will revoke an old expired/open enrollment before inserting a replacement.
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_open_zone",
|
||||
"agent_enrollments",
|
||||
["agent_zone_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"used_at IS NULL AND revoked_at IS NULL"
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_agent_enrollments_enrolled_agent",
|
||||
"agent_enrollments",
|
||||
["enrolled_agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("enrolled_agent_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_agent_enrollments_site_id",
|
||||
"agent_enrollments",
|
||||
["site_id"],
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"idx_agent_enrollments_created_at",
|
||||
"agent_enrollments",
|
||||
["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 0016 has nowhere to represent enrollment history or per-Agent
|
||||
# credentials. Refuse before destructive DDL if either has been used.
|
||||
connection = op.get_bind()
|
||||
|
||||
enrollment_count = connection.execute(
|
||||
sa.text("SELECT COUNT(*) FROM agent_enrollments")
|
||||
).scalar_one()
|
||||
|
||||
credential_count = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM minerops_site_agents
|
||||
WHERE display_name IS NOT NULL
|
||||
OR agent_secret_hash IS NOT NULL
|
||||
OR credential_created_at IS NOT NULL
|
||||
OR credential_revoked_at IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
if enrollment_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0017: Agent enrollment history exists"
|
||||
)
|
||||
|
||||
if credential_count:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0017: per-Agent credentials exist"
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"idx_agent_enrollments_created_at",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_agent_enrollments_site_id",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_enrolled_agent",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_open_zone",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_enrollments_token_hash",
|
||||
table_name="agent_enrollments",
|
||||
)
|
||||
op.drop_table("agent_enrollments")
|
||||
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_agent_secret_hash",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_revoked_time",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_credential_pair",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_secret_hash",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_display_name",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
|
||||
op.drop_column("minerops_site_agents", "credential_revoked_at")
|
||||
op.drop_column("minerops_site_agents", "credential_created_at")
|
||||
op.drop_column("minerops_site_agents", "agent_secret_hash")
|
||||
op.drop_column("minerops_site_agents", "display_name")
|
||||
@@ -0,0 +1 @@
|
||||
"""Per-Agent authentication support."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain errors for Agent authentication."""
|
||||
|
||||
|
||||
class AgentAuthenticationError(Exception):
|
||||
"""Base Agent authentication error."""
|
||||
|
||||
|
||||
class AgentAuthenticationUnavailable(AgentAuthenticationError):
|
||||
"""Agent authentication cannot currently be performed."""
|
||||
|
||||
|
||||
class AgentAuthenticationFailed(AgentAuthenticationError):
|
||||
"""The supplied Agent secret is invalid."""
|
||||
|
||||
|
||||
class AgentIdentityUnauthorized(AgentAuthenticationError):
|
||||
"""The supplied Agent identity is not authorized."""
|
||||
|
||||
|
||||
class AgentCredentialRevoked(AgentAuthenticationError):
|
||||
"""The Agent's permanent credential has been revoked."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""PostgreSQL persistence for Agent authentication."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
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 AgentAuthRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_credential(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,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AgentAuthService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not identity:
|
||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
if credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
supplied_hash,
|
||||
str(credential["agent_secret_hash"]),
|
||||
):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(identity, legacy_id):
|
||||
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||
|
||||
if not hmac.compare_digest(secret, legacy_secret):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
@@ -1,37 +1,46 @@
|
||||
"""Dependencies for the authenticated Agent queue protocol."""
|
||||
|
||||
import hmac
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_agent_command_service() -> AgentCommandService:
|
||||
return AgentCommandService(AgentCommandRepository())
|
||||
|
||||
|
||||
def get_agent_auth_service() -> AgentAuthService:
|
||||
return AgentAuthService(AgentAuthRepository())
|
||||
|
||||
|
||||
def require_agent_identity(
|
||||
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
|
||||
provided_secret: Annotated[
|
||||
str | None, Header(alias="X-MinerOps-Agent-Secret")
|
||||
] = None,
|
||||
service: AgentAuthService = Depends(get_agent_auth_service),
|
||||
) -> str:
|
||||
expected_id = settings.minerops_agent_id.strip()
|
||||
expected_secret = settings.minerops_agent_shared_secret.strip()
|
||||
if not expected_id or not expected_secret:
|
||||
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
|
||||
|
||||
secret = (provided_secret or "").strip()
|
||||
if not secret or not hmac.compare_digest(secret, expected_secret):
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
|
||||
|
||||
identity = (agent_id or "").strip()
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Missing Agent identity")
|
||||
if not hmac.compare_digest(identity, expected_id):
|
||||
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
|
||||
return identity
|
||||
try:
|
||||
return service.authenticate(agent_id, provided_secret)
|
||||
except AgentAuthenticationUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Agent authentication is not configured",
|
||||
) from exc
|
||||
except AgentAuthenticationFailed as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
except AgentCredentialRevoked as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except AgentIdentityUnauthorized as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
@@ -0,0 +1 @@
|
||||
"""Secure one-time Agent enrollment."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency wiring for secure Agent enrollment."""
|
||||
|
||||
from app.agent_enrollment.repository import AgentEnrollmentRepository
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
|
||||
|
||||
def get_agent_enrollment_service() -> AgentEnrollmentService:
|
||||
return AgentEnrollmentService(AgentEnrollmentRepository())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain exceptions for secure Agent enrollment."""
|
||||
|
||||
|
||||
class AgentEnrollmentError(Exception):
|
||||
"""Base Agent enrollment error."""
|
||||
|
||||
|
||||
class AgentEnrollmentNotFound(AgentEnrollmentError):
|
||||
"""The requested Site or Agent Zone does not exist."""
|
||||
|
||||
|
||||
class AgentEnrollmentConflict(AgentEnrollmentError):
|
||||
"""The requested enrollment conflicts with current Agent routing state."""
|
||||
|
||||
|
||||
class AgentEnrollmentTokenInvalid(AgentEnrollmentError):
|
||||
"""The supplied bootstrap token cannot be redeemed."""
|
||||
|
||||
|
||||
class AgentEnrollmentCredentialCollision(AgentEnrollmentError):
|
||||
"""A generated machine credential collided with an existing credential."""
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Typed contracts for secure Agent enrollment."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AgentEnrollmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
site_id: UUID
|
||||
agent_zone_id: UUID
|
||||
display_name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def normalize_display_name(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("display_name cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentEnrollmentCreated(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
enrollment_token: str
|
||||
|
||||
|
||||
class AgentEnrollmentCreateResponse(BaseModel):
|
||||
data: AgentEnrollmentCreated
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentEnrollmentSummary(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
created_by_user_id: UUID | None = None
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
enrolled_agent_id: str | None = None
|
||||
status: Literal["waiting", "used", "revoked", "expired"]
|
||||
|
||||
|
||||
class AgentEnrollmentListResponse(BaseModel):
|
||||
data: list[AgentEnrollmentSummary]
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentEnrollRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
token: str = Field(min_length=32, max_length=256)
|
||||
|
||||
@field_validator("token")
|
||||
@classmethod
|
||||
def normalize_token(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("token cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentEnrollCredentials(BaseModel):
|
||||
agent_id: str
|
||||
agent_secret: str
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
agent_zone_id: UUID
|
||||
agent_zone_name: str
|
||||
display_name: str
|
||||
ip_ranges: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentEnrollResponse(BaseModel):
|
||||
data: AgentEnrollCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,506 @@
|
||||
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||
|
||||
|
||||
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 AgentEnrollmentRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def create_enrollment(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
site_id: UUID,
|
||||
agent_zone_id: UUID,
|
||||
display_name: str,
|
||||
actor_user_id: UUID,
|
||||
) -> 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 AS agent_zone_name,
|
||||
zone.is_enabled,
|
||||
site.name AS site_name,
|
||||
agent.agent_id AS assigned_agent_id
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site ON site.id = zone.site_id
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_zone_id = zone.id
|
||||
WHERE zone.id = %s
|
||||
AND zone.site_id = %s
|
||||
FOR UPDATE OF zone
|
||||
""",
|
||||
(agent_zone_id, site_id),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
|
||||
if not zone:
|
||||
raise AgentEnrollmentNotFound(
|
||||
"Agent Zone was not found for the selected Site"
|
||||
)
|
||||
|
||||
if zone["is_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone is disabled"
|
||||
)
|
||||
|
||||
if zone["assigned_agent_id"]:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"SELECT clock_timestamp() AS operation_now"
|
||||
)
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET revoked_at = %s
|
||||
WHERE agent_zone_id = %s
|
||||
AND used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(operation_now, agent_zone_id),
|
||||
)
|
||||
replaced_ids = [
|
||||
str(row["id"]) for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s + interval '30 minutes'
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
created_at,
|
||||
expires_at
|
||||
""",
|
||||
(
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
actor_user_id,
|
||||
operation_now,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
enrollment = dict(cur.fetchone())
|
||||
|
||||
enrollment["site_name"] = zone["site_name"]
|
||||
enrollment["agent_zone_name"] = zone["agent_zone_name"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
'agent.enrollment.created',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"site_id": str(site_id),
|
||||
"agent_zone_id": str(agent_zone_id),
|
||||
"display_name": display_name,
|
||||
"expires_at": enrollment[
|
||||
"expires_at"
|
||||
].isoformat(),
|
||||
"replaced_enrollment_ids": replaced_ids,
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
return enrollment
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated enrollment token collided"
|
||||
) from exc
|
||||
|
||||
if constraint == OPEN_ZONE_CONSTRAINT:
|
||||
raise AgentEnrollmentConflict(
|
||||
"An active enrollment already exists for this Agent Zone"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
agent_zone_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
enrollment.id,
|
||||
enrollment.site_id,
|
||||
site.name AS site_name,
|
||||
enrollment.agent_zone_id,
|
||||
zone.name AS agent_zone_name,
|
||||
enrollment.display_name,
|
||||
enrollment.created_by_user_id,
|
||||
enrollment.created_at,
|
||||
enrollment.expires_at,
|
||||
enrollment.used_at,
|
||||
enrollment.revoked_at,
|
||||
enrollment.enrolled_agent_id,
|
||||
CASE
|
||||
WHEN enrollment.used_at IS NOT NULL
|
||||
THEN 'used'
|
||||
WHEN enrollment.revoked_at IS NOT NULL
|
||||
THEN 'revoked'
|
||||
WHEN enrollment.expires_at <= now()
|
||||
THEN 'expired'
|
||||
ELSE 'waiting'
|
||||
END AS status
|
||||
FROM agent_enrollments AS enrollment
|
||||
JOIN sites AS site
|
||||
ON site.id = enrollment.site_id
|
||||
JOIN agent_zones AS zone
|
||||
ON zone.id = enrollment.agent_zone_id
|
||||
WHERE (%s::uuid IS NULL OR enrollment.site_id = %s::uuid)
|
||||
AND (
|
||||
%s::uuid IS NULL
|
||||
OR enrollment.agent_zone_id = %s::uuid
|
||||
)
|
||||
ORDER BY enrollment.created_at DESC, enrollment.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
agent_zone_id,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def redeem_enrollment(
|
||||
self,
|
||||
*,
|
||||
token_hash: str,
|
||||
agent_id: str,
|
||||
agent_secret_hash: str,
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at,
|
||||
used_at,
|
||||
revoked_at
|
||||
FROM agent_enrollments
|
||||
WHERE token_hash = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(token_hash,),
|
||||
)
|
||||
enrollment = cur.fetchone()
|
||||
|
||||
if not enrollment:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
if (
|
||||
enrollment["used_at"] is not None
|
||||
or enrollment["revoked_at"] is not None
|
||||
):
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled,
|
||||
site.name AS site_name
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site ON site.id = zone.site_id
|
||||
WHERE zone.id = %s
|
||||
AND zone.site_id = %s
|
||||
FOR UPDATE OF zone
|
||||
""",
|
||||
(
|
||||
enrollment["agent_zone_id"],
|
||||
enrollment["site_id"],
|
||||
),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
|
||||
if not zone:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Enrollment Agent Zone no longer exists"
|
||||
)
|
||||
|
||||
if zone["is_enabled"] is not True:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone is disabled"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_zone_id = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
existing_agent = cur.fetchone()
|
||||
|
||||
if existing_agent:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"SELECT clock_timestamp() AS operation_now"
|
||||
)
|
||||
operation_now = cur.fetchone()["operation_now"]
|
||||
|
||||
if enrollment["expires_at"] <= operation_now:
|
||||
raise AgentEnrollmentTokenInvalid(
|
||||
"Invalid or expired enrollment token"
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
enrollment["site_id"],
|
||||
enrollment["agent_zone_id"],
|
||||
zone["site_name"],
|
||||
enrollment["display_name"],
|
||||
agent_secret_hash,
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET used_at = %s,
|
||||
enrolled_agent_id = %s
|
||||
WHERE id = %s
|
||||
RETURNING used_at
|
||||
""",
|
||||
(operation_now, agent_id, enrollment["id"]),
|
||||
)
|
||||
used_at = cur.fetchone()["used_at"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT network::text AS network
|
||||
FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
ORDER BY network, id
|
||||
""",
|
||||
(enrollment["agent_zone_id"],),
|
||||
)
|
||||
networks = [
|
||||
row["network"] for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
new_value_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
'agent.enrollment.redeemed',
|
||||
'agent_enrollment',
|
||||
%s,
|
||||
%s,
|
||||
%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
enrollment["id"],
|
||||
Jsonb(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"site_id": str(enrollment["site_id"]),
|
||||
"agent_zone_id": str(
|
||||
enrollment["agent_zone_id"]
|
||||
),
|
||||
"display_name": enrollment[
|
||||
"display_name"
|
||||
],
|
||||
"used_at": used_at.isoformat(),
|
||||
}
|
||||
),
|
||||
operation_now,
|
||||
),
|
||||
)
|
||||
|
||||
result = {
|
||||
"agent_id": agent_id,
|
||||
"site_id": enrollment["site_id"],
|
||||
"site_name": zone["site_name"],
|
||||
"agent_zone_id": enrollment["agent_zone_id"],
|
||||
"agent_zone_name": zone["agent_zone_name"],
|
||||
"display_name": enrollment["display_name"],
|
||||
"ip_ranges": networks,
|
||||
}
|
||||
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
constraint = exc.diag.constraint_name
|
||||
|
||||
if constraint in {
|
||||
AGENT_ID_CONSTRAINT,
|
||||
AGENT_SECRET_HASH_CONSTRAINT,
|
||||
}:
|
||||
raise AgentEnrollmentCredentialCollision(
|
||||
"Generated Agent credential collided"
|
||||
) from exc
|
||||
|
||||
if constraint == AGENT_ZONE_CONSTRAINT:
|
||||
raise AgentEnrollmentConflict(
|
||||
"Agent Zone already has an assigned Agent"
|
||||
) from exc
|
||||
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""HTTP routes for Agent enrollment and bootstrap."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollmentCreateResponse,
|
||||
AgentEnrollmentListResponse,
|
||||
AgentEnrollRequest,
|
||||
AgentEnrollResponse,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agent-enrollment"])
|
||||
|
||||
EnrollmentService = Annotated[
|
||||
AgentEnrollmentService,
|
||||
Depends(get_agent_enrollment_service),
|
||||
]
|
||||
EnrollmentViewer = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.view")),
|
||||
]
|
||||
EnrollmentManager = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.manage")),
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_enrollment(
|
||||
request: AgentEnrollmentCreateRequest,
|
||||
response: Response,
|
||||
current_user: EnrollmentManager,
|
||||
service: EnrollmentService,
|
||||
) -> AgentEnrollmentCreateResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
enrollment = service.create_enrollment(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
return AgentEnrollmentCreateResponse(data=enrollment)
|
||||
except AgentEnrollmentNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agent-enrollments",
|
||||
response_model=AgentEnrollmentListResponse,
|
||||
)
|
||||
def list_agent_enrollments(
|
||||
current_user: EnrollmentViewer,
|
||||
service: EnrollmentService,
|
||||
site_id: UUID | None = Query(default=None),
|
||||
agent_zone_id: UUID | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> AgentEnrollmentListResponse:
|
||||
del current_user
|
||||
|
||||
return AgentEnrollmentListResponse(
|
||||
data=service.list_enrollments(
|
||||
site_id=site_id,
|
||||
agent_zone_id=agent_zone_id,
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def enroll_agent(
|
||||
request: AgentEnrollRequest,
|
||||
response: Response,
|
||||
service: EnrollmentService,
|
||||
) -> AgentEnrollResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
try:
|
||||
return AgentEnrollResponse(data=service.redeem(request))
|
||||
except AgentEnrollmentTokenInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired enrollment token",
|
||||
) from exc
|
||||
except AgentEnrollmentConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Application service for secure one-time Agent enrollment."""
|
||||
|
||||
import secrets
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentCredentialCollision,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
|
||||
|
||||
MAX_GENERATION_ATTEMPTS = 3
|
||||
|
||||
|
||||
def generate_enrollment_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_agent_secret() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_agent_id() -> str:
|
||||
return f"agent-{uuid4().hex}"
|
||||
|
||||
|
||||
class AgentEnrollmentService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def create_enrollment(
|
||||
self,
|
||||
request: AgentEnrollmentCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID,
|
||||
) -> dict:
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
token = generate_enrollment_token()
|
||||
try:
|
||||
enrollment = self.repository.create_enrollment(
|
||||
token_hash=hash_agent_secret(token),
|
||||
site_id=request.site_id,
|
||||
agent_zone_id=request.agent_zone_id,
|
||||
display_name=request.display_name,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {
|
||||
**enrollment,
|
||||
"enrollment_token": token,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate a unique enrollment token"
|
||||
)
|
||||
|
||||
def list_enrollments(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
agent_zone_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
return self.repository.list_enrollments(
|
||||
site_id=site_id,
|
||||
agent_zone_id=agent_zone_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||
token_hash = hash_agent_secret(request.token)
|
||||
|
||||
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||
agent_id = generate_agent_id()
|
||||
agent_secret = generate_agent_secret()
|
||||
|
||||
try:
|
||||
result = self.repository.redeem_enrollment(
|
||||
token_hash=token_hash,
|
||||
agent_id=agent_id,
|
||||
agent_secret_hash=hash_agent_secret(agent_secret),
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"agent_secret": agent_secret,
|
||||
}
|
||||
except AgentEnrollmentCredentialCollision:
|
||||
continue
|
||||
|
||||
raise AgentEnrollmentConflict(
|
||||
"Unable to generate unique Agent credentials"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent Zone routing domain."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Agent Zone dependency wiring."""
|
||||
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
|
||||
|
||||
def get_agent_zone_service() -> AgentZoneService:
|
||||
return AgentZoneService(AgentZoneRepository())
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Agent Zone domain exceptions."""
|
||||
|
||||
|
||||
class AgentZoneError(Exception):
|
||||
"""Base Agent Zone error."""
|
||||
|
||||
|
||||
class AgentZoneNotFound(AgentZoneError):
|
||||
"""A Site or Agent Zone does not exist."""
|
||||
|
||||
|
||||
class AgentZoneValidationError(AgentZoneError):
|
||||
"""Agent Zone input or assignment is invalid."""
|
||||
|
||||
|
||||
class AgentZoneConflict(AgentZoneError):
|
||||
"""An Agent Zone uniqueness or assignment invariant conflicts."""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Typed Agent Zone and check-in contracts."""
|
||||
|
||||
from ipaddress import ip_network
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class AgentZoneCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
site_id: UUID
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
networks: list[str] = Field(default_factory=list, max_length=64)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("name cannot be blank")
|
||||
return normalized
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def normalize_description(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
@field_validator("networks")
|
||||
@classmethod
|
||||
def normalize_networks(cls, values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
try:
|
||||
network = str(ip_network(value.strip(), strict=True))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid CIDR network: {value}") from exc
|
||||
if network in normalized:
|
||||
raise ValueError(f"duplicate CIDR network: {network}")
|
||||
normalized.append(network)
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentZoneNetwork(BaseModel):
|
||||
id: UUID
|
||||
network: str
|
||||
|
||||
|
||||
class AgentZoneSummary(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_enabled: bool
|
||||
assigned_agent_id: str | None = None
|
||||
networks: list[AgentZoneNetwork] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentZoneListResponse(BaseModel):
|
||||
data: list[AgentZoneSummary]
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentZoneCreateResponse(BaseModel):
|
||||
data: AgentZoneSummary
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentCheckinRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
agent_id: str | None = Field(default=None, max_length=255)
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
site_name: str | None = Field(default=None, max_length=255)
|
||||
hostname: str | None = Field(default=None, max_length=255)
|
||||
version: str | None = Field(default=None, max_length=120)
|
||||
ip_address: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
|
||||
if self.agent_zone_id is not None and self.site_id is None:
|
||||
raise ValueError("site_id is required when agent_zone_id is supplied")
|
||||
return self
|
||||
|
||||
|
||||
class AgentCheckinResponse(BaseModel):
|
||||
ok: bool = True
|
||||
hub_mode: bool = True
|
||||
agent_id: str
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_available: bool
|
||||
routing_reason: str | None = None
|
||||
site_name: str
|
||||
message: str = "Agent check-in received."
|
||||
|
||||
|
||||
class AgentZoneRoutingContext(BaseModel):
|
||||
site_id: UUID | None = None
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
class MinerZoneAssociationResult(BaseModel):
|
||||
agent_zone_id: UUID | None = None
|
||||
routing_reason: str | None = None
|
||||
outcomes: dict[UUID, str] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneConflict,
|
||||
AgentZoneNotFound,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentZoneRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_agent(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||
version, ip_address, last_checkin_at, last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_zone(self, zone_id: UUID) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, name, description, is_enabled,
|
||||
created_at, updated_at
|
||||
FROM agent_zones
|
||||
WHERE id = %s
|
||||
""",
|
||||
(zone_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_usable_zones(self, site_id: UUID) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, name, description, is_enabled
|
||||
FROM agent_zones
|
||||
WHERE site_id = %s AND is_enabled
|
||||
ORDER BY lower(name), id
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT zone.id, zone.site_id, zone.name, zone.description,
|
||||
zone.is_enabled, agent.agent_id AS assigned_agent_id,
|
||||
range.id AS range_id, range.network::text AS network
|
||||
FROM agent_zones AS zone
|
||||
LEFT JOIN minerops_site_agents AS agent
|
||||
ON agent.agent_zone_id = zone.id
|
||||
LEFT JOIN agent_zone_network_ranges AS range
|
||||
ON range.agent_zone_id = zone.id
|
||||
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
|
||||
ORDER BY lower(zone.name), zone.id, range.network, range.id
|
||||
""",
|
||||
(site_id, site_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
zones: dict[UUID, dict] = {}
|
||||
for row in rows:
|
||||
zone = zones.setdefault(
|
||||
row["id"],
|
||||
{
|
||||
"id": row["id"],
|
||||
"site_id": row["site_id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"is_enabled": row["is_enabled"],
|
||||
"assigned_agent_id": row["assigned_agent_id"],
|
||||
"networks": [],
|
||||
},
|
||||
)
|
||||
if row["range_id"] is not None:
|
||||
zone["networks"].append(
|
||||
{"id": row["range_id"], "network": row["network"]}
|
||||
)
|
||||
return list(zones.values())
|
||||
|
||||
def create_zone(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID,
|
||||
name: str,
|
||||
description: str | None,
|
||||
networks: list[str],
|
||||
) -> UUID:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (site_id, name, description)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, name, description),
|
||||
)
|
||||
zone_id = cur.fetchone()["id"]
|
||||
for network in networks:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
VALUES (%s, %s::cidr)
|
||||
""",
|
||||
(zone_id, network),
|
||||
)
|
||||
connection.commit()
|
||||
return zone_id
|
||||
except ForeignKeyViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneNotFound("Site not found") from exc
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict(
|
||||
"An enabled Agent Zone with this name already exists at the Site"
|
||||
) from exc
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def save_checkin(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
site_id: UUID | None,
|
||||
agent_zone_id: UUID | None,
|
||||
site_name: str,
|
||||
hostname: str | None,
|
||||
version: str | None,
|
||||
ip_address: str | None,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||
version, ip_address, last_checkin_at, last_payload
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
site_id = EXCLUDED.site_id,
|
||||
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||
site_name = EXCLUDED.site_name,
|
||||
hostname = EXCLUDED.hostname,
|
||||
version = EXCLUDED.version,
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
last_checkin_at = now(),
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""",
|
||||
(
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
ip_address,
|
||||
Jsonb(payload),
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict(
|
||||
"The Agent Zone already has an assigned Agent"
|
||||
) from exc
|
||||
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
|
||||
connection.rollback()
|
||||
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_agent_assignment(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
|
||||
zone.is_enabled AS zone_enabled
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
|
||||
WHERE agent.agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def assign_miners_to_zone(
|
||||
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||
) -> dict[UUID, str]:
|
||||
unique_ids = list(dict.fromkeys(miner_ids))
|
||||
if not unique_ids:
|
||||
return {}
|
||||
connection = self._connection_factory()
|
||||
outcomes: dict[UUID, str] = {}
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, site_id FROM agent_zones WHERE id = %s",
|
||||
(agent_zone_id,),
|
||||
)
|
||||
zone = cur.fetchone()
|
||||
if not zone:
|
||||
raise AgentZoneNotFound("Agent Zone not found")
|
||||
for miner_id in unique_ids:
|
||||
cur.execute(
|
||||
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
|
||||
(miner_id,),
|
||||
)
|
||||
miner = cur.fetchone()
|
||||
if not miner:
|
||||
outcomes[miner_id] = "miner_not_found"
|
||||
elif miner["agent_zone_id"] == agent_zone_id:
|
||||
outcomes[miner_id] = "preserved"
|
||||
elif miner["agent_zone_id"] is not None:
|
||||
outcomes[miner_id] = "agent_zone_conflict"
|
||||
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||
outcomes[miner_id] = "site_conflict"
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE miners
|
||||
SET site_id = %s, agent_zone_id = %s, updated_at = now()
|
||||
WHERE id = %s AND agent_zone_id IS NULL
|
||||
""",
|
||||
(zone["site_id"], agent_zone_id, miner_id),
|
||||
)
|
||||
outcomes[miner_id] = "assigned"
|
||||
connection.commit()
|
||||
return outcomes
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""HTTP mapping for Agent Zones and Agent check-in."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.agent_commands.dependencies import require_agent_identity
|
||||
from app.agent_zones.dependencies import get_agent_zone_service
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneConflict,
|
||||
AgentZoneNotFound,
|
||||
AgentZoneValidationError,
|
||||
)
|
||||
from app.agent_zones.models import (
|
||||
AgentCheckinRequest,
|
||||
AgentCheckinResponse,
|
||||
AgentZoneCreateRequest,
|
||||
AgentZoneCreateResponse,
|
||||
AgentZoneListResponse,
|
||||
)
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agent-zones"])
|
||||
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
|
||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
|
||||
|
||||
|
||||
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
|
||||
def list_agent_zones(
|
||||
current_user: ZoneViewer,
|
||||
service: ZoneService,
|
||||
site_id: UUID | None = Query(default=None),
|
||||
) -> AgentZoneListResponse:
|
||||
del current_user
|
||||
return AgentZoneListResponse(data=service.list_zones(site_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/agent-zones",
|
||||
response_model=AgentZoneCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_agent_zone(
|
||||
request: AgentZoneCreateRequest,
|
||||
current_user: ZoneManager,
|
||||
service: ZoneService,
|
||||
) -> AgentZoneCreateResponse:
|
||||
del current_user
|
||||
try:
|
||||
return AgentZoneCreateResponse(data=service.create_zone(request))
|
||||
except AgentZoneNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentZoneConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
|
||||
def agent_checkin(
|
||||
request: AgentCheckinRequest,
|
||||
agent_id: AgentIdentity,
|
||||
service: ZoneService,
|
||||
) -> AgentCheckinResponse:
|
||||
try:
|
||||
return service.checkin(agent_id, request)
|
||||
except AgentZoneNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentZoneConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except AgentZoneValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Business rules for Agent Zone routing and transitional check-in."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.agent_zones.exceptions import (
|
||||
AgentZoneNotFound,
|
||||
AgentZoneValidationError,
|
||||
)
|
||||
from app.agent_zones.models import (
|
||||
AgentCheckinRequest,
|
||||
AgentCheckinResponse,
|
||||
AgentZoneRoutingContext,
|
||||
AgentZoneCreateRequest,
|
||||
AgentZoneSummary,
|
||||
MinerZoneAssociationResult,
|
||||
)
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"agent_secret",
|
||||
"api3_account",
|
||||
"api3_password",
|
||||
"password",
|
||||
"salt",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _sanitize(item)
|
||||
for key, item in value.items()
|
||||
if key.lower() not in SENSITIVE_KEYS
|
||||
and not any(
|
||||
marker in key.lower()
|
||||
for marker in ("secret", "password", "token", "salt")
|
||||
)
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class AgentZoneService:
|
||||
@staticmethod
|
||||
def validate_agent_identity(
|
||||
authenticated_agent_id: str, claimed_agent_id: str | None
|
||||
) -> None:
|
||||
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
|
||||
raise AgentZoneValidationError(
|
||||
"Payload agent_id does not match authenticated Agent identity"
|
||||
)
|
||||
|
||||
def __init__(self, repository: AgentZoneRepository):
|
||||
self.repository = repository
|
||||
|
||||
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
|
||||
return [
|
||||
AgentZoneSummary.model_validate(row)
|
||||
for row in self.repository.list_zones(site_id)
|
||||
]
|
||||
|
||||
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
|
||||
zone_id = self.repository.create_zone(
|
||||
site_id=request.site_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
networks=request.networks,
|
||||
)
|
||||
for zone in self.list_zones(request.site_id):
|
||||
if zone.id == zone_id:
|
||||
return zone
|
||||
raise AgentZoneNotFound("Created Agent Zone was not found")
|
||||
|
||||
def checkin(
|
||||
self, authenticated_agent_id: str, request: AgentCheckinRequest
|
||||
) -> AgentCheckinResponse:
|
||||
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||
|
||||
existing = self.repository.get_agent(authenticated_agent_id)
|
||||
requested_site = request.site_id
|
||||
requested_zone = request.agent_zone_id
|
||||
final_site = requested_site or (existing or {}).get("site_id")
|
||||
final_zone: UUID | None = None
|
||||
reason: str | None = None
|
||||
|
||||
established_zone = None
|
||||
established_zone_id = (existing or {}).get("agent_zone_id")
|
||||
if established_zone_id is not None:
|
||||
established_zone = self.repository.get_zone(established_zone_id)
|
||||
if not established_zone:
|
||||
raise AgentZoneValidationError(
|
||||
"Existing Agent Zone assignment is invalid"
|
||||
)
|
||||
if (
|
||||
requested_site is not None
|
||||
and established_zone["site_id"] != requested_site
|
||||
):
|
||||
raise AgentZoneValidationError(
|
||||
"Agent cannot change Sites while assigned to an Agent Zone"
|
||||
)
|
||||
|
||||
if requested_zone is not None:
|
||||
zone = self.repository.get_zone(requested_zone)
|
||||
if not zone:
|
||||
raise AgentZoneNotFound("Agent Zone not found")
|
||||
if zone["site_id"] != requested_site:
|
||||
raise AgentZoneValidationError(
|
||||
"Agent Zone does not belong to the supplied Site"
|
||||
)
|
||||
if not zone["is_enabled"]:
|
||||
raise AgentZoneValidationError("Agent Zone is disabled")
|
||||
if established_zone_id is not None and established_zone_id != requested_zone:
|
||||
raise AgentZoneValidationError(
|
||||
"Agent is already assigned to a different Agent Zone"
|
||||
)
|
||||
final_zone = requested_zone
|
||||
elif established_zone is not None:
|
||||
final_site = established_zone["site_id"]
|
||||
final_zone = established_zone["id"]
|
||||
if not established_zone["is_enabled"]:
|
||||
reason = "agent_zone_disabled"
|
||||
elif final_site is not None:
|
||||
usable = self.repository.list_usable_zones(final_site)
|
||||
if len(usable) == 1:
|
||||
final_zone = usable[0]["id"]
|
||||
elif not usable:
|
||||
reason = "agent_zone_unavailable"
|
||||
else:
|
||||
reason = "agent_zone_ambiguous"
|
||||
else:
|
||||
reason = "site_unassigned"
|
||||
|
||||
site_name = (request.site_name or "").strip()
|
||||
if not site_name:
|
||||
site_name = str((existing or {}).get("site_name") or "Unassigned")
|
||||
payload = _sanitize(request.model_dump(mode="json"))
|
||||
self.repository.save_checkin(
|
||||
agent_id=authenticated_agent_id,
|
||||
site_id=final_site,
|
||||
agent_zone_id=final_zone,
|
||||
site_name=site_name,
|
||||
hostname=(request.hostname or "").strip() or None,
|
||||
version=(request.version or "").strip() or None,
|
||||
ip_address=(request.ip_address or "").strip() or None,
|
||||
payload=payload,
|
||||
)
|
||||
routing_available = final_zone is not None and reason is None
|
||||
return AgentCheckinResponse(
|
||||
agent_id=authenticated_agent_id,
|
||||
site_id=final_site,
|
||||
agent_zone_id=final_zone,
|
||||
routing_available=routing_available,
|
||||
routing_reason=reason,
|
||||
site_name=site_name,
|
||||
)
|
||||
|
||||
def routing_context(
|
||||
self, agent_id: str, recorded_zone_id: UUID | None = None
|
||||
) -> AgentZoneRoutingContext:
|
||||
assignment = self.repository.get_agent_assignment(agent_id)
|
||||
if not assignment or assignment.get("agent_zone_id") is None:
|
||||
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
|
||||
zone_id = assignment["agent_zone_id"]
|
||||
if recorded_zone_id is not None and recorded_zone_id != zone_id:
|
||||
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
|
||||
if assignment.get("zone_enabled") is not True:
|
||||
return AgentZoneRoutingContext(
|
||||
site_id=assignment.get("site_id"),
|
||||
agent_zone_id=zone_id,
|
||||
routing_reason="agent_zone_disabled",
|
||||
)
|
||||
return AgentZoneRoutingContext(
|
||||
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||
)
|
||||
|
||||
def associate_miners_from_agent(
|
||||
self, agent_id: str, miner_ids: list[UUID]
|
||||
) -> MinerZoneAssociationResult:
|
||||
routing = self.routing_context(agent_id)
|
||||
if routing.routing_reason:
|
||||
return MinerZoneAssociationResult(
|
||||
agent_zone_id=routing.agent_zone_id,
|
||||
routing_reason=routing.routing_reason,
|
||||
)
|
||||
return MinerZoneAssociationResult(
|
||||
agent_zone_id=routing.agent_zone_id,
|
||||
outcomes=self.repository.assign_miners_to_zone(
|
||||
miner_ids, routing.agent_zone_id
|
||||
),
|
||||
)
|
||||
@@ -699,8 +699,10 @@ def required_page_permission(path: str) -> str:
|
||||
|
||||
def machine_agent_request(path: str) -> bool:
|
||||
exact = {
|
||||
"/api/agent/enroll",
|
||||
"/api/agent/checkin",
|
||||
"/api/agent/config",
|
||||
"/api/agent/known-miners",
|
||||
"/api/agent/miner-snapshots",
|
||||
"/api/agent/pool-snapshots",
|
||||
"/api/agent/commands/pending",
|
||||
@@ -777,6 +779,10 @@ 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 path == "/api/agent-enrollments":
|
||||
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/"):
|
||||
|
||||
+300
-282
File diff suppressed because it is too large.
Load diff
@@ -40,15 +40,20 @@ class MinerControlRepository:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
|
||||
SELECT miners.id, miners.site_id, miners.agent_zone_id,
|
||||
zones.id IS NOT NULL AS agent_zone_exists,
|
||||
zones.is_enabled AS agent_zone_enabled,
|
||||
host(miners.current_ip) AS current_ip,
|
||||
miners.status AS runtime_state,
|
||||
miners.configured_performance_mode,
|
||||
miners.api3_supported, miners.api3_api_switch,
|
||||
agents.agent_id, agents.last_checkin_at,
|
||||
agents.version AS agent_version, agents.last_payload
|
||||
FROM miners
|
||||
LEFT JOIN agent_zones AS zones
|
||||
ON zones.id = miners.agent_zone_id
|
||||
LEFT JOIN minerops_site_agents AS agents
|
||||
ON agents.site_id = miners.site_id
|
||||
ON agents.agent_zone_id = miners.agent_zone_id
|
||||
WHERE miners.id = %s
|
||||
""",
|
||||
(miner_id,),
|
||||
|
||||
@@ -29,10 +29,14 @@ class MinerControlService:
|
||||
|
||||
@staticmethod
|
||||
def _capability(context: dict) -> ControlCapability:
|
||||
if not context.get("site_id"):
|
||||
return ControlCapability(state="unavailable", reason="site_unassigned")
|
||||
if not context.get("agent_zone_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
|
||||
if context.get("agent_zone_exists") is not True:
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_missing")
|
||||
if context.get("agent_zone_enabled") is not True:
|
||||
return ControlCapability(state="unavailable", reason="agent_zone_disabled")
|
||||
if not context.get("agent_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_unassigned")
|
||||
return ControlCapability(state="unavailable", reason="agent_unavailable")
|
||||
if not context.get("current_ip"):
|
||||
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||
if context.get("api3_supported") is False:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Focused tests for per-Agent authentication and legacy compatibility."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.config import settings
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeAgentAuthRepository:
|
||||
def __init__(self, rows=None):
|
||||
self.rows = rows or {}
|
||||
|
||||
def get_credential(self, agent_id):
|
||||
return self.rows.get(agent_id)
|
||||
|
||||
|
||||
class AgentAuthServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_id = settings.minerops_agent_id
|
||||
self.old_secret = settings.minerops_agent_shared_secret
|
||||
settings.minerops_agent_id = "legacy-agent"
|
||||
settings.minerops_agent_shared_secret = "legacy-secret"
|
||||
|
||||
def tearDown(self):
|
||||
settings.minerops_agent_id = self.old_id
|
||||
settings.minerops_agent_shared_secret = self.old_secret
|
||||
|
||||
def service(self, rows=None):
|
||||
return AgentAuthService(FakeAgentAuthRepository(rows))
|
||||
|
||||
def test_high_entropy_secret_hash_is_sha256_hex(self):
|
||||
digest = hash_agent_secret("example-secret")
|
||||
self.assertEqual(len(digest), 64)
|
||||
self.assertRegex(digest, r"^[0-9a-f]{64}$")
|
||||
|
||||
def test_legacy_agent_without_registry_row_still_authenticates(self):
|
||||
self.assertEqual(
|
||||
self.service().authenticate("legacy-agent", "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_legacy_agent_with_null_credential_still_authenticates(self):
|
||||
rows = {
|
||||
"legacy-agent": {
|
||||
"agent_id": "legacy-agent",
|
||||
"agent_secret_hash": None,
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
self.service(rows).authenticate("legacy-agent", "legacy-secret"),
|
||||
"legacy-agent",
|
||||
)
|
||||
|
||||
def test_per_agent_credential_authenticates_independently(self):
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
self.service(rows).authenticate("agent-new", "unique-secret"),
|
||||
"agent-new",
|
||||
)
|
||||
|
||||
def test_legacy_secret_never_authenticates_per_agent_identity(self):
|
||||
settings.minerops_agent_id = "agent-new"
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate("agent-new", "legacy-secret")
|
||||
|
||||
def test_wrong_per_agent_secret_is_rejected(self):
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
}
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service(rows).authenticate("agent-new", "wrong-secret")
|
||||
|
||||
def test_revoked_per_agent_credential_never_falls_back(self):
|
||||
settings.minerops_agent_id = "agent-new"
|
||||
rows = {
|
||||
"agent-new": {
|
||||
"agent_id": "agent-new",
|
||||
"agent_secret_hash": hash_agent_secret("unique-secret"),
|
||||
"credential_created_at": NOW,
|
||||
"credential_revoked_at": NOW,
|
||||
}
|
||||
}
|
||||
for secret in ("unique-secret", "legacy-secret"):
|
||||
with self.subTest(secret=secret), self.assertRaises(AgentCredentialRevoked):
|
||||
self.service(rows).authenticate("agent-new", secret)
|
||||
|
||||
def test_unknown_identity_does_not_use_shared_secret(self):
|
||||
with self.assertRaises(AgentIdentityUnauthorized):
|
||||
self.service().authenticate("unknown-agent", "legacy-secret")
|
||||
|
||||
def test_missing_identity_or_secret_is_rejected(self):
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate(None, "legacy-secret")
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
self.service().authenticate("legacy-agent", None)
|
||||
|
||||
def test_legacy_path_reports_unavailable_if_not_configured(self):
|
||||
settings.minerops_agent_shared_secret = ""
|
||||
with self.assertRaises(AgentAuthenticationUnavailable):
|
||||
self.service().authenticate("legacy-agent", "anything")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,7 +7,11 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_commands.dependencies import get_agent_command_service
|
||||
from app.agent_auth.service import AgentAuthService
|
||||
from app.agent_commands.dependencies import (
|
||||
get_agent_auth_service,
|
||||
get_agent_command_service,
|
||||
)
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentResultValidationError,
|
||||
@@ -123,6 +127,11 @@ class AgentCommandServiceTests(unittest.TestCase):
|
||||
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
||||
|
||||
|
||||
class FakeAgentAuthRepository:
|
||||
def get_credential(self, agent_id):
|
||||
return None
|
||||
|
||||
|
||||
class AgentCommandRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
@@ -131,6 +140,9 @@ class AgentCommandRouterTests(unittest.TestCase):
|
||||
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
|
||||
self.repository
|
||||
)
|
||||
self.app.dependency_overrides[get_agent_auth_service] = lambda: AgentAuthService(
|
||||
FakeAgentAuthRepository()
|
||||
)
|
||||
self.client = TestClient(self.app)
|
||||
self.old_id = settings.minerops_agent_id
|
||||
self.old_secret = settings.minerops_agent_shared_secret
|
||||
@@ -199,6 +211,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)
|
||||
@@ -233,3 +247,122 @@ class AgentSourceContractTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class AgentTransportIsolationSourceTests(unittest.TestCase):
|
||||
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
|
||||
|
||||
known_start = main.index('@app.get("/api/agent/known-miners")')
|
||||
known_end = main.index("# Phase 5J:", known_start)
|
||||
known = main[known_start:known_end]
|
||||
|
||||
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
|
||||
pool_end = main.index("\n@app.", pool_start + 1)
|
||||
pool = main[pool_start:pool_end]
|
||||
|
||||
self.assertIn('"/api/agent/known-miners"', identity)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", known)
|
||||
self.assertIn("zone_service.routing_context", known)
|
||||
self.assertIn("miners.agent_zone_id = $1::uuid", known)
|
||||
self.assertNotIn("minerops_require_agent_secret", known)
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", pool)
|
||||
self.assertIn("zone_service.routing_context", pool)
|
||||
self.assertIn("agent_id = authenticated_agent_id", pool)
|
||||
self.assertIn("agent_zone_id = %s", pool)
|
||||
self.assertNotIn('body.get("agent_id")', pool)
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
|
||||
|
||||
def test_windows_machine_pollers_send_agent_identity(self):
|
||||
root = agent_source_root()
|
||||
if root is None:
|
||||
self.skipTest("Windows Agent source is not mounted")
|
||||
|
||||
for filename in (
|
||||
"MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||
"EnrichOneMiner.ps1",
|
||||
):
|
||||
with self.subTest(filename=filename):
|
||||
source = (root / filename).read_text()
|
||||
self.assertIn("X-MinerOps-Agent-ID", source)
|
||||
self.assertIn("X-MinerOps-Agent-Secret", source)
|
||||
|
||||
|
||||
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
|
||||
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
decorators = (
|
||||
'@app.post("/api/agent/miner-enrichment")',
|
||||
'@app.post("/api/agent/miner-enrichment-v2")',
|
||||
'@app.post("/api/agent/miner-enrichment-v3")',
|
||||
)
|
||||
|
||||
for decorator in decorators:
|
||||
with self.subTest(decorator=decorator):
|
||||
start = main.index(decorator)
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_zone_id", route)
|
||||
self.assertNotIn("minerops_require_agent_secret", route)
|
||||
|
||||
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
|
||||
v2_end = main.index("\n@app.", v2_start + 1)
|
||||
v2 = main[v2_start:v2_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $2::uuid",
|
||||
v2,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $3::uuid",
|
||||
v2,
|
||||
)
|
||||
|
||||
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
|
||||
v3_end = main.index("\n@app.", v3_start + 1)
|
||||
v3 = main[v3_start:v3_end]
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $8::uuid",
|
||||
v3,
|
||||
)
|
||||
self.assertIn(
|
||||
"AND agent_zone_id = $7::uuid",
|
||||
v3,
|
||||
)
|
||||
|
||||
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.post("/api/agent/api3-enrichment")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("Depends(require_agent_identity)", route)
|
||||
self.assertIn("zone_service.routing_context", route)
|
||||
self.assertIn("agent_id = authenticated_agent_id", route)
|
||||
self.assertIn("agent_zone_id = %s", route)
|
||||
self.assertIn("if miner_id is None:", route)
|
||||
self.assertIn("continue", route)
|
||||
|
||||
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
|
||||
self.assertNotIn('body.get("agent_id")', route)
|
||||
self.assertNotIn('body.get("site_name")', route)
|
||||
self.assertNotIn('request.headers.get("x-agent-secret")', route)
|
||||
|
||||
def test_legacy_config_remains_explicitly_legacy_only(self):
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
|
||||
start = main.index('@app.get("/api/agent/config")')
|
||||
end = main.index("\n@app.", start + 1)
|
||||
route = main[start:end]
|
||||
|
||||
self.assertIn("minerops_require_agent_secret(request)", route)
|
||||
self.assertIn('"site_name": "Plexus"', route)
|
||||
self.assertNotIn("Depends(require_agent_identity)", route)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Focused tests for secure Agent enrollment."""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from app.agent_auth.service import hash_agent_secret
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.identity import machine_agent_request, required_api_permission
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001700")
|
||||
ZONE_ID = UUID("00000000-0000-4000-8000-000000001701")
|
||||
USER_ID = UUID("00000000-0000-4000-8000-000000001702")
|
||||
ENROLLMENT_ID = UUID("00000000-0000-4000-8000-000000001703")
|
||||
NOW = datetime(2026, 8, 26, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeAgentEnrollmentRepository:
|
||||
def __init__(self):
|
||||
self.created = None
|
||||
self.redeemed = None
|
||||
|
||||
def create_enrollment(self, **kwargs):
|
||||
self.created = kwargs
|
||||
return {
|
||||
"id": ENROLLMENT_ID,
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": kwargs["display_name"],
|
||||
"created_at": NOW,
|
||||
"expires_at": NOW + timedelta(minutes=30),
|
||||
}
|
||||
|
||||
def list_enrollments(self, **kwargs):
|
||||
return []
|
||||
|
||||
def redeem_enrollment(self, **kwargs):
|
||||
self.redeemed = kwargs
|
||||
return {
|
||||
"agent_id": kwargs["agent_id"],
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Test Zone",
|
||||
"display_name": "Test Agent",
|
||||
"ip_ranges": ["10.30.0.0/22"],
|
||||
}
|
||||
|
||||
|
||||
class AgentEnrollmentServiceTests(unittest.TestCase):
|
||||
def test_create_returns_plaintext_token_once_but_repository_gets_only_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=SITE_ID,
|
||||
agent_zone_id=ZONE_ID,
|
||||
display_name=" Test Agent ",
|
||||
),
|
||||
actor_user_id=USER_ID,
|
||||
)
|
||||
|
||||
token = result["enrollment_token"]
|
||||
|
||||
self.assertGreaterEqual(len(token), 32)
|
||||
self.assertNotEqual(repository.created["token_hash"], token)
|
||||
self.assertEqual(
|
||||
repository.created["token_hash"],
|
||||
hash_agent_secret(token),
|
||||
)
|
||||
self.assertRegex(
|
||||
repository.created["token_hash"],
|
||||
r"^[0-9a-f]{64}$",
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.created["display_name"],
|
||||
"Test Agent",
|
||||
)
|
||||
|
||||
serialized = repr(repository.created)
|
||||
self.assertNotIn(token, serialized)
|
||||
|
||||
def test_redeem_returns_plaintext_secret_but_repository_gets_only_hash(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token="A" * 43,
|
||||
)
|
||||
)
|
||||
|
||||
secret = result["agent_secret"]
|
||||
|
||||
self.assertGreaterEqual(len(secret), 32)
|
||||
self.assertNotEqual(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
secret,
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
hash_agent_secret(secret),
|
||||
)
|
||||
self.assertRegex(
|
||||
repository.redeemed["agent_secret_hash"],
|
||||
r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
self.assertRegex(
|
||||
result["agent_id"],
|
||||
r"^agent-[0-9a-f]{32}$",
|
||||
)
|
||||
|
||||
serialized = repr(repository.redeemed)
|
||||
self.assertNotIn(secret, serialized)
|
||||
|
||||
def test_enrollment_admin_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agent-enrollments"),
|
||||
"agents.view",
|
||||
)
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", "/api/agent-enrollments"),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_bootstrap_endpoint_is_machine_exempt_but_admin_endpoint_is_not(self):
|
||||
self.assertTrue(machine_agent_request("/api/agent/enroll"))
|
||||
self.assertFalse(
|
||||
machine_agent_request("/api/agent-enrollments")
|
||||
)
|
||||
|
||||
def test_generated_identifiers_are_bounded(self):
|
||||
repository = FakeAgentEnrollmentRepository()
|
||||
service = AgentEnrollmentService(repository)
|
||||
|
||||
result = service.redeem(
|
||||
AgentEnrollRequest(token="B" * 43)
|
||||
)
|
||||
|
||||
self.assertLessEqual(len(result["agent_id"]), 255)
|
||||
self.assertTrue(
|
||||
re.fullmatch(r"agent-[0-9a-f]{32}", result["agent_id"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,625 @@
|
||||
"""PostgreSQL integration tests for secure Agent enrollment."""
|
||||
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.agent_auth.exceptions import AgentAuthenticationFailed
|
||||
from app.agent_auth.repository import AgentAuthRepository
|
||||
from app.agent_auth.service import AgentAuthService, hash_agent_secret
|
||||
from app.agent_enrollment.exceptions import (
|
||||
AgentEnrollmentConflict,
|
||||
AgentEnrollmentNotFound,
|
||||
AgentEnrollmentTokenInvalid,
|
||||
)
|
||||
from app.agent_enrollment.models import (
|
||||
AgentEnrollmentCreateRequest,
|
||||
AgentEnrollRequest,
|
||||
)
|
||||
from app.agent_enrollment.repository import (
|
||||
AgentEnrollmentRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_zones.models import AgentCheckinRequest
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
|
||||
|
||||
class AgentEnrollmentPostgresTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.site_id = uuid4()
|
||||
self.zone_id = uuid4()
|
||||
self.site_name = f"Enrollment Test Site {self.site_id.hex[:8]}"
|
||||
self.zone_name = f"Enrollment Test Zone {self.zone_id.hex[:8]}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(self.site_id, self.site_name),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (
|
||||
id,
|
||||
site_id,
|
||||
name
|
||||
)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(
|
||||
self.zone_id,
|
||||
self.site_id,
|
||||
self.zone_name,
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (
|
||||
agent_zone_id,
|
||||
network
|
||||
)
|
||||
VALUES (%s, '10.250.0.0/24'::cidr)
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM audit_logs
|
||||
WHERE entity_type = 'agent_enrollment'
|
||||
AND (
|
||||
new_value_json ->> 'site_id' = %s
|
||||
OR entity_id IN (
|
||||
SELECT id
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
)
|
||||
)
|
||||
""",
|
||||
(str(self.site_id), self.site_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zones
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM sites
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_enrollment(self, display_name="Integration Agent"):
|
||||
# created_by_user_id is nullable at the persistence layer.
|
||||
# Router-level tests separately prove that the real admin path supplies
|
||||
# the authenticated user's UUID.
|
||||
return self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name=display_name,
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
def test_replacement_revokes_previous_open_enrollment(self):
|
||||
first = self.create_enrollment("First Agent")
|
||||
second = self.create_enrollment("Replacement Agent")
|
||||
|
||||
self.assertNotEqual(
|
||||
first["enrollment_token"],
|
||||
second["enrollment_token"],
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
token_hash,
|
||||
used_at,
|
||||
revoked_at
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
by_hash = {
|
||||
row["token_hash"]: row
|
||||
for row in rows
|
||||
}
|
||||
|
||||
first_hash = hash_agent_secret(
|
||||
first["enrollment_token"]
|
||||
)
|
||||
second_hash = hash_agent_secret(
|
||||
second["enrollment_token"]
|
||||
)
|
||||
|
||||
self.assertIsNotNone(by_hash[first_hash]["revoked_at"])
|
||||
self.assertIsNone(by_hash[first_hash]["used_at"])
|
||||
|
||||
self.assertIsNone(by_hash[second_hash]["revoked_at"])
|
||||
self.assertIsNone(by_hash[second_hash]["used_at"])
|
||||
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token=first["enrollment_token"]
|
||||
)
|
||||
)
|
||||
|
||||
listed = self.service.list_enrollments(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
)
|
||||
|
||||
self.assertEqual(len(listed), 2)
|
||||
|
||||
for item in listed:
|
||||
self.assertNotIn("token_hash", item)
|
||||
self.assertNotIn("agent_secret_hash", item)
|
||||
self.assertNotIn("enrollment_token", item)
|
||||
self.assertNotIn("agent_secret", item)
|
||||
|
||||
def test_concurrent_creation_leaves_exactly_one_open_enrollment(self):
|
||||
def create_one(number):
|
||||
service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
return service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name=f"Concurrent Agent {number}",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(create_one, (1, 2))
|
||||
)
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS total,
|
||||
count(*) FILTER (
|
||||
WHERE used_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
) AS open_count,
|
||||
count(*) FILTER (
|
||||
WHERE revoked_at IS NOT NULL
|
||||
) AS revoked_count,
|
||||
count(*) FILTER (
|
||||
WHERE revoked_at IS NOT NULL
|
||||
AND revoked_at < created_at
|
||||
) AS invalid_revocation_count
|
||||
FROM agent_enrollments
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
counts = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(counts["total"], 2)
|
||||
self.assertEqual(counts["open_count"], 1)
|
||||
self.assertEqual(counts["revoked_count"], 1)
|
||||
self.assertEqual(counts["invalid_revocation_count"], 0)
|
||||
|
||||
def test_concurrent_redemption_has_exactly_one_winner(self):
|
||||
enrollment = self.create_enrollment()
|
||||
token = enrollment["enrollment_token"]
|
||||
|
||||
def redeem_once():
|
||||
service = AgentEnrollmentService(
|
||||
AgentEnrollmentRepository()
|
||||
)
|
||||
try:
|
||||
return (
|
||||
"success",
|
||||
service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
),
|
||||
)
|
||||
except AgentEnrollmentTokenInvalid:
|
||||
return ("invalid", None)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(lambda _: redeem_once(), range(2))
|
||||
)
|
||||
|
||||
successes = [
|
||||
result
|
||||
for status, result in results
|
||||
if status == "success"
|
||||
]
|
||||
failures = [
|
||||
status
|
||||
for status, _ in results
|
||||
if status == "invalid"
|
||||
]
|
||||
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(failures), 1)
|
||||
|
||||
credentials = successes[0]
|
||||
agent_id = credentials["agent_id"]
|
||||
agent_secret = credentials["agent_secret"]
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at,
|
||||
last_payload::text AS last_payload
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id = %s
|
||||
AND agent_zone_id = %s
|
||||
""",
|
||||
(self.site_id, self.zone_id),
|
||||
)
|
||||
agents = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
used_at,
|
||||
revoked_at,
|
||||
enrolled_agent_id,
|
||||
token_hash
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
enrollment_row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT new_value_json::text AS payload
|
||||
FROM audit_logs
|
||||
WHERE entity_type = 'agent_enrollment'
|
||||
AND entity_id = %s
|
||||
ORDER BY created_at
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
audit_rows = cur.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(len(agents), 1)
|
||||
agent = agents[0]
|
||||
|
||||
self.assertEqual(agent["agent_id"], agent_id)
|
||||
self.assertEqual(
|
||||
agent["agent_secret_hash"],
|
||||
hash_agent_secret(agent_secret),
|
||||
)
|
||||
self.assertIsNotNone(agent["credential_created_at"])
|
||||
self.assertIsNone(agent["credential_revoked_at"])
|
||||
|
||||
self.assertIsNotNone(enrollment_row["used_at"])
|
||||
self.assertIsNone(enrollment_row["revoked_at"])
|
||||
self.assertEqual(
|
||||
enrollment_row["enrolled_agent_id"],
|
||||
agent_id,
|
||||
)
|
||||
|
||||
# Neither plaintext credential may persist in operational state.
|
||||
persisted_text = "\n".join(
|
||||
[
|
||||
str(enrollment_row),
|
||||
str(agent),
|
||||
*(row["payload"] for row in audit_rows),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertNotIn(token, persisted_text)
|
||||
self.assertNotIn(agent_secret, persisted_text)
|
||||
self.assertNotIn(agent_secret, agent["last_payload"])
|
||||
|
||||
# The newly issued per-Agent credential must immediately authenticate.
|
||||
auth_service = AgentAuthService(
|
||||
AgentAuthRepository()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
auth_service.authenticate(
|
||||
agent_id,
|
||||
agent_secret,
|
||||
),
|
||||
agent_id,
|
||||
)
|
||||
|
||||
with self.assertRaises(AgentAuthenticationFailed):
|
||||
auth_service.authenticate(
|
||||
agent_id,
|
||||
"definitely-not-the-issued-secret",
|
||||
)
|
||||
|
||||
# Normal check-in must never mutate the credential.
|
||||
secret_hash_before = agent["agent_secret_hash"]
|
||||
|
||||
AgentZoneService(
|
||||
AgentZoneRepository()
|
||||
).checkin(
|
||||
agent_id,
|
||||
AgentCheckinRequest(
|
||||
site_id=self.site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
site_name=self.site_name,
|
||||
hostname="integration-test-agent",
|
||||
version="integration-test",
|
||||
),
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
after_checkin = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(
|
||||
after_checkin["agent_secret_hash"],
|
||||
secret_hash_before,
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
after_checkin["credential_created_at"]
|
||||
)
|
||||
self.assertIsNone(
|
||||
after_checkin["credential_revoked_at"]
|
||||
)
|
||||
|
||||
def test_expired_token_is_rejected_without_side_effects(self):
|
||||
enrollment = self.create_enrollment()
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_enrollments
|
||||
SET created_at = now() - interval '2 hours',
|
||||
expires_at = now() - interval '1 hour'
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentTokenInvalid):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(
|
||||
token=enrollment["enrollment_token"]
|
||||
)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, revoked_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
agent_count = cur.fetchone()["count"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(row["used_at"])
|
||||
self.assertIsNone(row["revoked_at"])
|
||||
self.assertIsNone(row["enrolled_agent_id"])
|
||||
self.assertEqual(agent_count, 0)
|
||||
|
||||
def test_disabled_and_occupied_zone_do_not_consume_token(self):
|
||||
enrollment = self.create_enrollment()
|
||||
token = enrollment["enrollment_token"]
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_zones
|
||||
SET is_enabled = false
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE agent_zones
|
||||
SET is_enabled = true
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
f"legacy-{uuid4().hex}",
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
self.site_name,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentConflict):
|
||||
self.service.redeem(
|
||||
AgentEnrollRequest(token=token)
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT used_at, revoked_at, enrolled_agent_id
|
||||
FROM agent_enrollments
|
||||
WHERE id = %s
|
||||
""",
|
||||
(enrollment["id"],),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertIsNone(row["used_at"])
|
||||
self.assertIsNone(row["revoked_at"])
|
||||
self.assertIsNone(row["enrolled_agent_id"])
|
||||
|
||||
def test_site_zone_mismatch_is_rejected_before_creation(self):
|
||||
wrong_site_id = uuid4()
|
||||
|
||||
with self.assertRaises(AgentEnrollmentNotFound):
|
||||
self.service.create_enrollment(
|
||||
AgentEnrollmentCreateRequest(
|
||||
site_id=wrong_site_id,
|
||||
agent_zone_id=self.zone_id,
|
||||
display_name="Wrong Site Agent",
|
||||
),
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
connection = default_connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM agent_enrollments
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
count = cur.fetchone()["count"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Focused model, service, router, and architecture tests for Agent Zones v1."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_commands.dependencies import require_agent_identity
|
||||
from app.agent_zones.dependencies import get_agent_zone_service
|
||||
from app.agent_zones.exceptions import AgentZoneValidationError
|
||||
from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest
|
||||
from app.agent_zones.router import router
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001601")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001602")
|
||||
ZONE_A = UUID("00000000-0000-4000-8000-000000001611")
|
||||
ZONE_B = UUID("00000000-0000-4000-8000-000000001612")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001621")
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
|
||||
|
||||
def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"):
|
||||
return {
|
||||
"id": zone_id,
|
||||
"site_id": site_id,
|
||||
"name": name,
|
||||
"description": None,
|
||||
"is_enabled": enabled,
|
||||
"assigned_agent_id": None,
|
||||
"networks": [],
|
||||
}
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self):
|
||||
self.zones = {ZONE_A: zone()}
|
||||
self.agent = None
|
||||
self.saved = []
|
||||
self.assign_result = {MINER_A: "assigned"}
|
||||
|
||||
def get_agent(self, agent_id):
|
||||
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
|
||||
|
||||
def get_zone(self, zone_id):
|
||||
return self.zones.get(zone_id)
|
||||
|
||||
def list_usable_zones(self, site_id):
|
||||
return [
|
||||
value for value in self.zones.values()
|
||||
if value["site_id"] == site_id and value["is_enabled"]
|
||||
]
|
||||
|
||||
def list_zones(self, site_id=None):
|
||||
return [
|
||||
value for value in self.zones.values()
|
||||
if site_id is None or value["site_id"] == site_id
|
||||
]
|
||||
|
||||
def create_zone(self, *, site_id, name, description, networks):
|
||||
created_id = uuid4()
|
||||
self.zones[created_id] = {
|
||||
**zone(created_id, site_id, name=name),
|
||||
"description": description,
|
||||
"networks": [
|
||||
{"id": uuid4(), "network": network} for network in networks
|
||||
],
|
||||
}
|
||||
return created_id
|
||||
|
||||
def save_checkin(self, **kwargs):
|
||||
self.saved.append(kwargs)
|
||||
self.agent = {
|
||||
"agent_id": kwargs["agent_id"],
|
||||
"site_id": kwargs["site_id"],
|
||||
"agent_zone_id": kwargs["agent_zone_id"],
|
||||
"site_name": kwargs["site_name"],
|
||||
}
|
||||
|
||||
def get_agent_assignment(self, agent_id):
|
||||
if not self.agent or self.agent["agent_id"] != agent_id:
|
||||
return None
|
||||
zone_row = self.zones.get(self.agent.get("agent_zone_id"))
|
||||
return {
|
||||
**self.agent,
|
||||
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
|
||||
}
|
||||
|
||||
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
|
||||
self.assigned = (list(miner_ids), agent_zone_id)
|
||||
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
|
||||
for miner_id in miner_ids}
|
||||
|
||||
|
||||
class AgentZoneModelTests(unittest.TestCase):
|
||||
def test_names_and_networks_are_normalized(self):
|
||||
request = AgentZoneCreateRequest(
|
||||
site_id=SITE_A,
|
||||
name=" Main Room ",
|
||||
description=" Local VLAN ",
|
||||
networks=["10.30.0.0/24", "2001:db8::/64"],
|
||||
)
|
||||
self.assertEqual(request.name, "Main Room")
|
||||
self.assertEqual(request.description, "Local VLAN")
|
||||
self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"])
|
||||
|
||||
def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self):
|
||||
for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]):
|
||||
with self.subTest(networks=networks), self.assertRaises(Exception):
|
||||
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks)
|
||||
with self.assertRaises(Exception):
|
||||
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True)
|
||||
|
||||
|
||||
class AgentZoneServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.service = AgentZoneService(self.repository)
|
||||
|
||||
def checkin(self, **kwargs):
|
||||
return self.service.checkin(
|
||||
"agent-a",
|
||||
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
|
||||
)
|
||||
|
||||
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
|
||||
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.assertTrue(first.routing_available)
|
||||
self.assertEqual(second.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
|
||||
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
|
||||
self.service.checkin(
|
||||
"agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A)
|
||||
)
|
||||
|
||||
def test_established_agent_cannot_change_zone_within_site(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2")
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B)
|
||||
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
|
||||
|
||||
def test_established_agent_site_conflict_precedes_zone_conflict(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
|
||||
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
|
||||
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B)
|
||||
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
|
||||
|
||||
def test_omitted_zone_preserves_established_assignment_and_site(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
result = self.service.checkin(
|
||||
"agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North")
|
||||
)
|
||||
self.assertEqual(result.site_id, SITE_A)
|
||||
self.assertEqual(result.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
|
||||
single = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(single.agent_zone_id, ZONE_A)
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {}
|
||||
zero = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = None
|
||||
self.repository.zones = {
|
||||
ZONE_A: zone(),
|
||||
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
|
||||
}
|
||||
many = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
|
||||
self.assertIsNone(many.agent_zone_id)
|
||||
|
||||
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
|
||||
self.repository.zones[ZONE_A]["is_enabled"] = False
|
||||
unassigned = self.checkin(site_id=SITE_A)
|
||||
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
|
||||
self.repository.agent = {
|
||||
"agent_id": "agent-a", "site_id": SITE_A,
|
||||
"agent_zone_id": ZONE_A, "site_name": "North",
|
||||
}
|
||||
existing = self.service.checkin(
|
||||
"agent-a", AgentCheckinRequest(agent_id="agent-a")
|
||||
)
|
||||
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
|
||||
self.assertEqual(existing.agent_zone_id, ZONE_A)
|
||||
|
||||
def test_payload_is_sanitized_before_persistence(self):
|
||||
request = AgentCheckinRequest.model_validate({
|
||||
"agent_id": "agent-a", "site_id": str(SITE_A),
|
||||
"api3_password": "secret", "nested": {"access_token": "secret"},
|
||||
"control_available": True,
|
||||
})
|
||||
self.service.checkin("agent-a", request)
|
||||
payload = self.repository.saved[-1]["payload"]
|
||||
self.assertNotIn("password", str(payload).lower())
|
||||
self.assertNotIn("token", str(payload).lower())
|
||||
self.assertTrue(payload["control_available"])
|
||||
|
||||
def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
self.repository.assign_result[MINER_A] = "agent_zone_conflict"
|
||||
result = self.service.associate_miners_from_agent("agent-a", [MINER_A])
|
||||
self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict")
|
||||
self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A))
|
||||
|
||||
def test_routing_context_rejects_stale_recorded_zone(self):
|
||||
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
|
||||
result = self.service.routing_context("agent-a", ZONE_B)
|
||||
self.assertEqual(result.routing_reason, "agent_zone_conflict")
|
||||
self.assertIsNone(result.site_id)
|
||||
|
||||
|
||||
class AgentZoneRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = FakeRepository()
|
||||
self.service = AgentZoneService(self.repository)
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service
|
||||
self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a"
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(self, permissions):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": str(uuid4()), "username": "operator", "role": "Custom",
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def test_list_and_create_use_existing_agent_permissions(self):
|
||||
self.assertEqual(self.client.get("/api/agent-zones").status_code, 401)
|
||||
self.authenticate(("agents.view",))
|
||||
self.assertEqual(self.client.get("/api/agent-zones").status_code, 200)
|
||||
self.assertEqual(
|
||||
self.client.post(
|
||||
"/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"}
|
||||
).status_code,
|
||||
403,
|
||||
)
|
||||
self.authenticate(("agents.manage",))
|
||||
response = self.client.post(
|
||||
"/api/agent-zones",
|
||||
json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["data"]["name"], "Room 2")
|
||||
|
||||
def test_checkin_uses_authenticated_agent_identity(self):
|
||||
response = self.client.post(
|
||||
"/api/agent/checkin",
|
||||
json={"agent_id": "agent-a", "site_id": str(SITE_A),
|
||||
"agent_zone_id": str(ZONE_A)},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["agent_id"], "agent-a")
|
||||
self.assertNotIn("secret", response.text.lower())
|
||||
|
||||
def test_permission_and_architecture_audits(self):
|
||||
self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view")
|
||||
self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage")
|
||||
router_source = (APP_ROOT / "agent_zones" / "router.py").read_text()
|
||||
service_source = (APP_ROOT / "agent_zones" / "service.py").read_text()
|
||||
repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text()
|
||||
self.assertNotIn("psycopg", router_source)
|
||||
self.assertNotIn("SELECT ", router_source)
|
||||
self.assertNotIn("fastapi", service_source.lower())
|
||||
self.assertNotIn("fastapi", repository_source.lower())
|
||||
self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Real PostgreSQL integrity and routing tests for Agent Zones v1."""
|
||||
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.config import settings
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001631")
|
||||
ZONE_A = UUID("00000000-0000-4000-8000-000000001632")
|
||||
ZONE_B = UUID("00000000-0000-4000-8000-000000001633")
|
||||
ZONE_C = UUID("00000000-0000-4000-8000-000000001636")
|
||||
MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634")
|
||||
MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635")
|
||||
AGENT_A = "agent-zone-postgres-a"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentZonePostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), "
|
||||
"(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING",
|
||||
(SITE_A, SITE_B),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO agent_zones (id, site_id, name) VALUES "
|
||||
"(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), "
|
||||
"(%s, %s, 'Unstaffed Zone') "
|
||||
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
|
||||
(ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO miners (id, site_id, asset_tag, current_ip, status, "
|
||||
"api3_supported, first_seen_at, last_seen_at) VALUES "
|
||||
"(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), "
|
||||
"(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) "
|
||||
"ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL",
|
||||
(MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||
(ZONE_B, MINER_OTHER),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO minerops_site_agents ("
|
||||
"agent_id, site_id, agent_zone_id, site_name, last_payload) "
|
||||
"VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') "
|
||||
"ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, "
|
||||
"agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload",
|
||||
(AGENT_A, SITE_A, ZONE_A),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'")
|
||||
cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER))
|
||||
cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
|
||||
cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
|
||||
cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def setUp(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE agent_zones SET is_enabled = true "
|
||||
"WHERE id IN (%s, %s, %s)",
|
||||
(ZONE_A, ZONE_B, ZONE_C),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE miners SET agent_zone_id = NULL WHERE id = %s",
|
||||
(MINER_UNASSIGNED,),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s",
|
||||
(SITE_B, ZONE_B, MINER_OTHER),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s "
|
||||
"WHERE agent_id = %s",
|
||||
(SITE_A, ZONE_A, AGENT_A),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_repository_lists_multiple_canonical_networks(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO agent_zone_network_ranges (agent_zone_id, network) "
|
||||
"VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') "
|
||||
"ON CONFLICT DO NOTHING",
|
||||
(ZONE_A, ZONE_A),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
listed = AgentZoneRepository(connect).list_zones(SITE_A)
|
||||
self.assertEqual([item["network"] for item in listed[0]["networks"]],
|
||||
["10.30.0.0/24", "10.31.0.0/24"])
|
||||
|
||||
def test_active_names_are_case_insensitively_unique_per_site(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')",
|
||||
(SITE_A,),
|
||||
)
|
||||
finally:
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
def test_one_agent_per_zone_but_multiple_zones_per_site(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO minerops_site_agents "
|
||||
"(agent_id, site_id, agent_zone_id, site_name) "
|
||||
"VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')",
|
||||
(SITE_A, ZONE_A),
|
||||
)
|
||||
finally:
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
def test_composite_foreign_keys_reject_cross_site_assignment(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||
(ZONE_A, MINER_OTHER),
|
||||
)
|
||||
finally:
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
|
||||
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
|
||||
repository = AgentZoneRepository(connect)
|
||||
outcomes = repository.assign_miners_to_zone(
|
||||
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
|
||||
)
|
||||
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
|
||||
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)",
|
||||
(MINER_UNASSIGNED, MINER_OTHER),
|
||||
)
|
||||
assignments = dict(cur.fetchall())
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
|
||||
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
|
||||
|
||||
def test_disabled_zone_blocks_control_without_site_fallback(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||
(ZONE_A, MINER_UNASSIGNED),
|
||||
)
|
||||
cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
try:
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
|
||||
finally:
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_enabled_zone_without_agent_does_not_fall_back_within_site(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
|
||||
(ZONE_C, MINER_UNASSIGNED),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_unavailable")
|
||||
|
||||
def test_null_zone_does_not_fall_back_to_site_agent(self):
|
||||
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
|
||||
MINER_UNASSIGNED
|
||||
)
|
||||
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,6 +26,9 @@ def context(**overrides):
|
||||
result = {
|
||||
"id": MINER_ID,
|
||||
"site_id": uuid4(),
|
||||
"agent_zone_id": uuid4(),
|
||||
"agent_zone_exists": True,
|
||||
"agent_zone_enabled": True,
|
||||
"current_ip": "192.0.2.10",
|
||||
"runtime_state": "online",
|
||||
"configured_performance_mode": "normal",
|
||||
@@ -78,8 +81,10 @@ class MinerControlServiceTests(unittest.TestCase):
|
||||
|
||||
def test_capability_missing_facts_is_not_optimistic(self):
|
||||
cases = (
|
||||
({"site_id": None}, "unavailable", "site_unassigned"),
|
||||
({"agent_id": None}, "unavailable", "agent_unassigned"),
|
||||
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
|
||||
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
|
||||
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
|
||||
({"agent_id": None}, "unavailable", "agent_unavailable"),
|
||||
({"current_ip": None}, "unavailable", "current_ip_missing"),
|
||||
({"api3_supported": False}, "unavailable", "api3_unsupported"),
|
||||
({"api3_supported": None}, "unknown", "api3_support_unknown"),
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
|
||||
ZONE_ID = UUID("00000000-0000-4000-8000-000000001514")
|
||||
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
|
||||
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
|
||||
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
|
||||
@@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
|
||||
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO agent_zones (id, site_id, name) "
|
||||
"VALUES (%s, %s, 'Control Test Zone') "
|
||||
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
|
||||
(ZONE_ID, SITE_ID),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO users (id, username, display_name, password_hash, role_id)
|
||||
@@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
id, site_id, asset_tag, current_ip, status, api3_supported,
|
||||
id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported,
|
||||
configured_performance_mode, first_seen_at, last_seen_at
|
||||
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
||||
) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
||||
'normal', NULL, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
|
||||
api3_supported = true
|
||||
""", (MINER_ID, SITE_ID)
|
||||
""", (MINER_ID, SITE_ID, ZONE_ID)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, site_name, last_payload
|
||||
) VALUES (%s, %s, 'Control Test Site', %s)
|
||||
agent_id, site_id, agent_zone_id, site_name, last_payload
|
||||
) VALUES (%s, %s, %s, 'Control Test Site', %s)
|
||||
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
|
||||
""", (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True}))
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
@@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase):
|
||||
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
|
||||
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
|
||||
cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,))
|
||||
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
|
||||
connection.commit()
|
||||
finally:
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
EXPECTED_HEAD = "0015_miner_control_foundation"
|
||||
EXPECTED_HEAD = "0017_agent_enrollment"
|
||||
|
||||
|
||||
def database_payload(revision=EXPECTED_HEAD):
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project="minerops-agent-enrollment-migration-$$"
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
"${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env.dev
|
||||
set +a
|
||||
|
||||
echo "Starting disposable PostgreSQL..."
|
||||
"${compose[@]}" up -d postgres
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
for attempt in $(seq 1 60); do
|
||||
if "${compose[@]}" exec -T postgres \
|
||||
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 60 ]]; then
|
||||
echo "PostgreSQL did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Building backend with migration 0017..."
|
||||
"${compose[@]}" build backend >/dev/null
|
||||
|
||||
run_backend() {
|
||||
"${compose[@]}" run --rm --no-deps backend "$@"
|
||||
}
|
||||
|
||||
psql_exec() {
|
||||
"${compose[@]}" exec -T postgres \
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
-U "${POSTGRES_USER}" \
|
||||
-d "${POSTGRES_DB}" "$@"
|
||||
}
|
||||
|
||||
echo "Testing upgrade through 0016..."
|
||||
run_backend alembic upgrade 0016_agent_zones
|
||||
|
||||
revision="$(
|
||||
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||
)"
|
||||
[[ "${revision}" == "0016_agent_zones" ]]
|
||||
|
||||
echo "Testing 0016 -> 0017..."
|
||||
run_backend alembic upgrade 0017_agent_enrollment
|
||||
|
||||
revision="$(
|
||||
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||
)"
|
||||
[[ "${revision}" == "0017_agent_enrollment" ]]
|
||||
|
||||
echo "Checking new Agent credential columns..."
|
||||
for column in \
|
||||
display_name \
|
||||
agent_secret_hash \
|
||||
credential_created_at \
|
||||
credential_revoked_at
|
||||
do
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'minerops_site_agents'
|
||||
AND column_name = '${column}'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "1" ]]
|
||||
done
|
||||
|
||||
echo "Checking enrollment table..."
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'agent_enrollments'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "1" ]]
|
||||
|
||||
echo "Checking expected indexes..."
|
||||
for index in \
|
||||
uq_minerops_site_agents_agent_secret_hash \
|
||||
uq_agent_enrollments_token_hash \
|
||||
uq_agent_enrollments_open_zone \
|
||||
uq_agent_enrollments_enrolled_agent \
|
||||
idx_agent_enrollments_site_id \
|
||||
idx_agent_enrollments_created_at
|
||||
do
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname = '${index}'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "1" ]]
|
||||
done
|
||||
|
||||
echo "Testing immediate legal downgrade..."
|
||||
run_backend alembic downgrade 0016_agent_zones
|
||||
|
||||
revision="$(
|
||||
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||
)"
|
||||
[[ "${revision}" == "0016_agent_zones" ]]
|
||||
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'agent_enrollments'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "0" ]]
|
||||
|
||||
echo "Re-upgrading to 0017..."
|
||||
run_backend alembic upgrade 0017_agent_enrollment
|
||||
|
||||
echo "Creating isolated Site and Agent Zone fixtures..."
|
||||
psql_exec <<'SQL'
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'Enrollment Migration Test Site'
|
||||
);
|
||||
|
||||
INSERT INTO agent_zones (
|
||||
id,
|
||||
site_id,
|
||||
name,
|
||||
description,
|
||||
is_enabled
|
||||
)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000001701',
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'Enrollment Test Zone',
|
||||
'Disposable migration test',
|
||||
true
|
||||
);
|
||||
SQL
|
||||
|
||||
echo "Testing database constraints..."
|
||||
|
||||
psql_exec <<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
'not-a-sha256-hash',
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'00000000-0000-4000-8000-000000001701',
|
||||
'Invalid Hash',
|
||||
now() + interval '30 minutes'
|
||||
);
|
||||
RAISE EXCEPTION 'invalid token hash was accepted';
|
||||
EXCEPTION
|
||||
WHEN check_violation THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
psql_exec <<'SQL'
|
||||
INSERT INTO agent_enrollments (
|
||||
id,
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
'00000000-0000-4000-8000-000000001710',
|
||||
repeat('a', 64),
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'00000000-0000-4000-8000-000000001701',
|
||||
'Enrollment Test Agent',
|
||||
now() + interval '30 minutes'
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO agent_enrollments (
|
||||
token_hash,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
repeat('b', 64),
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'00000000-0000-4000-8000-000000001701',
|
||||
'Second Open Enrollment',
|
||||
now() + interval '30 minutes'
|
||||
);
|
||||
RAISE EXCEPTION 'second open enrollment was accepted';
|
||||
EXCEPTION
|
||||
WHEN unique_violation THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
echo "Testing credential hash integrity and uniqueness..."
|
||||
|
||||
psql_exec <<'SQL'
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
)
|
||||
VALUES (
|
||||
'agent-enrollment-test',
|
||||
'00000000-0000-4000-8000-000000001700',
|
||||
'00000000-0000-4000-8000-000000001701',
|
||||
'Enrollment Migration Test Site',
|
||||
'Enrollment Test Agent',
|
||||
repeat('c', 64),
|
||||
now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_name,
|
||||
display_name,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
)
|
||||
VALUES (
|
||||
'agent-enrollment-duplicate-secret',
|
||||
'Enrollment Migration Test Site',
|
||||
'Duplicate Secret',
|
||||
repeat('c', 64),
|
||||
now()
|
||||
);
|
||||
RAISE EXCEPTION 'duplicate Agent secret hash was accepted';
|
||||
EXCEPTION
|
||||
WHEN unique_violation THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
echo "Testing downgrade refusal after feature use..."
|
||||
|
||||
set +e
|
||||
downgrade_output="$(
|
||||
run_backend alembic downgrade 0016_agent_zones 2>&1
|
||||
)"
|
||||
downgrade_status=$?
|
||||
set -e
|
||||
|
||||
if [[ "${downgrade_status}" -eq 0 ]]; then
|
||||
echo "Downgrade unexpectedly succeeded after enrollment/credential use" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Eq \
|
||||
'Cannot downgrade 0017: Agent enrollment history exists|Cannot downgrade 0017: per-Agent credentials exist' \
|
||||
<<<"${downgrade_output}"
|
||||
|
||||
revision="$(
|
||||
psql_exec -Atc "SELECT version_num FROM alembic_version"
|
||||
)"
|
||||
[[ "${revision}" == "0017_agent_enrollment" ]]
|
||||
|
||||
echo "Confirming refusal happened before destructive schema changes..."
|
||||
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'agent_enrollments'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "1" ]]
|
||||
|
||||
count="$(
|
||||
psql_exec -Atc "
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'minerops_site_agents'
|
||||
AND column_name = 'agent_secret_hash'
|
||||
"
|
||||
)"
|
||||
[[ "${count}" == "1" ]]
|
||||
|
||||
echo "Agent enrollment migration lifecycle checks passed."
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_AGENT_ENROLLMENT_TEST_PROJECT:-minerops-agent-enrollment-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 120 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_enrollment.py \
|
||||
backend/tests/test_agent_enrollment_postgres.py \
|
||||
backend/tests/test_agent_auth.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" \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_auth \
|
||||
tests.test_agent_enrollment \
|
||||
tests.test_agent_enrollment_postgres
|
||||
|
||||
echo "Secure Agent enrollment focused tests passed."
|
||||
Executable
+186
@@ -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 <<SQL
|
||||
INSERT INTO sites (id, name) VALUES
|
||||
('${site_a}', 'Agent Zone Site A'),
|
||||
('${site_b}', 'Agent Zone Site B');
|
||||
INSERT INTO miners (id, site_id, asset_tag, first_seen_at, last_seen_at)
|
||||
VALUES ('${miner_a}', '${site_a}', 'AGENT-ZONE-MIGRATION', NULL, NULL);
|
||||
INSERT INTO minerops_site_agents (agent_id, site_id, site_name)
|
||||
VALUES ('zone-agent-a', '${site_a}', 'Agent Zone Site A');
|
||||
INSERT INTO minerops_agent_snapshots (agent_id, site_name, miner_count, payload)
|
||||
VALUES ('zone-agent-a', 'Agent Zone Site A', 0, '{}');
|
||||
INSERT INTO minerops_agent_discoveries (
|
||||
id, agent_id, site_name, ip_address, raw_payload, discovery_status
|
||||
) VALUES (
|
||||
'agent-zone-discovery', 'zone-agent-a', 'Agent Zone Site A',
|
||||
'192.0.2.90', '{}', 'new'
|
||||
);
|
||||
SQL
|
||||
run_alembic upgrade 0016_agent_zones
|
||||
[[ "$(run_alembic current 2>/dev/null)" == *"0016_agent_zones"* ]]
|
||||
|
||||
psql_db <<SQL
|
||||
DO \$\$
|
||||
DECLARE zone_a uuid;
|
||||
BEGIN
|
||||
SELECT id INTO zone_a FROM agent_zones
|
||||
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||
IF zone_a IS NULL THEN
|
||||
RAISE EXCEPTION 'Default Zone was not created for Site A';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM miners WHERE id = '${miner_a}') IS DISTINCT FROM zone_a THEN
|
||||
RAISE EXCEPTION 'Miner was not backfilled to its Site default Zone';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM minerops_site_agents WHERE agent_id = 'zone-agent-a')
|
||||
IS DISTINCT FROM zone_a THEN
|
||||
RAISE EXCEPTION 'Agent was not backfilled to its Site default Zone';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM minerops_agent_snapshots WHERE agent_id = 'zone-agent-a' LIMIT 1)
|
||||
IS DISTINCT FROM zone_a THEN
|
||||
RAISE EXCEPTION 'Snapshot Zone backfill failed';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM minerops_agent_discoveries WHERE id = 'agent-zone-discovery')
|
||||
IS DISTINCT FROM zone_a THEN
|
||||
RAISE EXCEPTION 'Discovery Zone backfill failed';
|
||||
END IF;
|
||||
IF (SELECT agent_zone_id FROM minerops_site_agents
|
||||
WHERE agent_id = 'legacy-zone-unassigned') IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Legacy Agent without a durable Site was guessed into a Zone';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'uq_minerops_site_agents_agent_zone_id') THEN
|
||||
RAISE EXCEPTION 'One-Agent-per-Zone index missing';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'uq_minerops_site_agents_site_id') THEN
|
||||
RAISE EXCEPTION 'One-Agent-per-Site index was not removed';
|
||||
END IF;
|
||||
END \$\$;
|
||||
SQL
|
||||
|
||||
echo "Testing CIDR, active-name, cross-Site, and one-Agent-per-Zone integrity..."
|
||||
psql_db <<SQL
|
||||
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||
SELECT id, '10.30.0.0/24'::cidr FROM agent_zones
|
||||
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||
DO \$\$
|
||||
DECLARE zone_a uuid;
|
||||
BEGIN
|
||||
SELECT id INTO zone_a FROM agent_zones
|
||||
WHERE site_id = '${site_a}' AND name = 'Default Zone';
|
||||
IF (SELECT network::text FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = zone_a) <> '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 <<SQL
|
||||
WITH created AS (
|
||||
INSERT INTO agent_zones (site_id, name)
|
||||
VALUES ('${site_a}', 'Room 2') RETURNING id
|
||||
)
|
||||
INSERT INTO minerops_site_agents (agent_id, site_id, agent_zone_id, site_name)
|
||||
SELECT 'zone-agent-b', '${site_a}', id, 'Agent Zone Site A' FROM created;
|
||||
SQL
|
||||
if output="$(run_alembic downgrade 0015_miner_control_foundation 2>&1)"; then
|
||||
echo "Downgrade unexpectedly accepted multiple Agents at one Site" >&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."
|
||||
Executable
+53
@@ -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."
|
||||
@@ -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 \
|
||||
|
||||
@@ -16,7 +16,7 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
for source_file in backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
||||
for source_file in backend/tests/test_agent_auth.py backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.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
|
||||
@@ -44,6 +44,7 @@ done
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
-v "${repo_root}/agents:/agents:ro" \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_auth \
|
||||
tests.test_miner_control \
|
||||
tests.test_agent_commands \
|
||||
tests.test_miner_control_postgres
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user