Author SHA1 Message Date
lussierj abf5654a25 feat: add self-enrolling Windows Agent package 2026-08-28 17:50:39 +00:00
lussierj b9b5e5e36f feat: add agent management 2026-08-27 17:00:56 +00:00
lussierj d9f0da694b feat: add secure agent enrollment 2026-08-26 21:31:09 +00:00
lussierj 82309db79a feat: add agent zones 2026-08-26 15:48:59 +00:00
78 changed files with 8656 additions and 368 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# MinerOps repository-root Docker build context
.git
.env
.env.*
!.env.example
!.env.dev.example
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.dump
**/*.sql.gz
backend/app/main.py.bak*
+2
View File
@@ -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" `
+323
View File
@@ -0,0 +1,323 @@
#Requires -RunAsAdministrator
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$Target = "C:\MinerOpsAgent"
$ConfigPath = Join-Path $Target "agent-config.json"
$PendingConfigPath = Join-Path $Target "agent-config.json.pending"
$RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json"
$EnrollmentPath = Join-Path $PSScriptRoot "enrollment.json"
$Installer = Join-Path $PSScriptRoot "install.ps1"
$RequiredFiles = @(
"install.ps1",
"MinerOpsAgentService.ps1",
"MinerOpsAgentTray.ps1",
"MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1",
"MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1",
"EnrichOneMiner.ps1",
"MinerOpsControl.ps1",
"VERSION",
"enrollment.json"
)
function Protect-MinerOpsCredentialFile {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$identities = @(
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")),
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")),
[System.Security.Principal.WindowsIdentity]::GetCurrent().User
)
foreach ($identity in $identities) {
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$identity,
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.InheritanceFlags]::None,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow
)
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
function ConvertFrom-MinerOpsSecureString {
param(
[Parameter(Mandatory = $true)]
[Security.SecureString] $SecureValue
)
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$SecureValue
)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
# Validate the complete package before modifying the Agent host or consuming
# the single-use enrollment token.
foreach ($name in $RequiredFiles) {
$source = Join-Path $PSScriptRoot $name
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Enrollment package is missing $name"
}
}
if (Test-Path -LiteralPath $ConfigPath) {
throw @"
C:\MinerOpsAgent\agent-config.json already exists.
This enrollment bootstrapper is for a new Agent identity and will not overwrite
an existing identity. Use the normal MinerOps upgrade procedure instead.
"@
}
foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) {
if (Test-Path -LiteralPath $recoveryPath) {
throw @"
A previous enrollment recovery file exists at:
$recoveryPath
Do not create or redeem another enrollment until that recovery state has been
reviewed.
"@
}
}
$enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw |
ConvertFrom-Json
$HubUrl = ([string] $enrollment.hub_url).TrimEnd("/")
$EnrollmentToken = ([string] $enrollment.token).Trim()
$parsedHub = $null
if (
-not [Uri]::TryCreate(
$HubUrl,
[UriKind]::Absolute,
[ref] $parsedHub
) -or
$parsedHub.Scheme -notin @("http", "https")
) {
throw "enrollment.json contains an invalid MinerOps Hub URL."
}
if (
$EnrollmentToken.Length -lt 32 -or
$EnrollmentToken.Length -gt 256
) {
throw "enrollment.json contains an invalid enrollment token."
}
Write-Host ""
Write-Host "MinerOps Windows Agent enrollment"
Write-Host "Hub: $HubUrl"
Write-Host ""
Write-Host "API3 credentials stay only on this Windows Agent."
Write-Host "Leave the account blank if API3 features are not being configured yet."
Write-Host ""
$Api3Account = (Read-Host "WhatsMiner API3 account").Trim()
$Api3PasswordPlain = ""
if ($Api3Account) {
$Api3PasswordSecure = Read-Host `
"WhatsMiner API3 password" `
-AsSecureString
$Api3PasswordPlain = ConvertFrom-MinerOpsSecureString `
-SecureValue $Api3PasswordSecure
}
$Redeemed = $false
$ConfigPersisted = $false
try {
New-Item -ItemType Directory -Force -Path $Target | Out-Null
# Create protected empty files BEFORE token redemption. If persistence is
# not possible, fail before consuming the single-use token.
[IO.File]::WriteAllText($PendingConfigPath, "")
Protect-MinerOpsCredentialFile -Path $PendingConfigPath
[IO.File]::WriteAllText($RecoveryConfigPath, "")
Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath
$requestBody = @{
token = $EnrollmentToken
} | ConvertTo-Json -Compress
Write-Host ""
Write-Host "Redeeming one-time MinerOps enrollment..."
$response = Invoke-RestMethod `
-Method Post `
-Uri "$HubUrl/api/agent/enroll" `
-ContentType "application/json" `
-Body $requestBody `
-TimeoutSec 30
$Redeemed = $true
if (-not $response.data) {
throw "MinerOps enrollment returned no Agent data."
}
$agent = $response.data
foreach ($required in @(
"agent_id",
"agent_secret",
"site_id",
"site_name",
"agent_zone_id",
"agent_zone_name"
)) {
if (-not ([string] $agent.$required).Trim()) {
throw "MinerOps enrollment response is missing $required."
}
}
$ranges = @()
foreach ($cidr in @($agent.ip_ranges)) {
$network = ([string] $cidr).Trim()
if (-not $network) {
continue
}
$ranges += [pscustomobject]@{
name = "$($agent.agent_zone_name) - $network"
cidr = $network
enabled = $true
api_port = 4028
api3_port = 4433
}
}
$config = [ordered]@{
hub_url = $HubUrl
agent_id = [string] $agent.agent_id
site_id = [string] $agent.site_id
agent_zone_id = [string] $agent.agent_zone_id
site_name = [string] $agent.site_name
agent_secret = [string] $agent.agent_secret
poll_interval_seconds = 120
ip_ranges = @($ranges)
api3_account = $Api3Account
api3_password = $Api3PasswordPlain
api3_port = 4433
}
$configJson = $config | ConvertTo-Json -Depth 20
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Write a protected recovery copy first. If anything after redemption
# fails, the permanent Agent credential is not lost with the consumed token.
[IO.File]::WriteAllText(
$RecoveryConfigPath,
$configJson + [Environment]::NewLine,
$utf8NoBom
)
[IO.File]::WriteAllText(
$PendingConfigPath,
$configJson + [Environment]::NewLine,
$utf8NoBom
)
Move-Item `
-LiteralPath $PendingConfigPath `
-Destination $ConfigPath
$ConfigPersisted = $true
Remove-Item -LiteralPath $RecoveryConfigPath -Force
# The extracted bootstrap token is no longer needed once permanent
# credentials have been persisted locally.
Remove-Item -LiteralPath $EnrollmentPath -Force
$EnrollmentToken = $null
$requestBody = $null
$Api3PasswordPlain = $null
Write-Host "Permanent Agent identity saved locally."
Write-Host "Installing MinerOps Agent runtime..."
& $Installer
$mainTask = Get-ScheduledTask `
-TaskName "MinerOpsAgent" `
-ErrorAction Stop
if ([string] $mainTask.State -ne "Running") {
Start-ScheduledTask -TaskName "MinerOpsAgent" -ErrorAction Stop
}
Write-Host ""
Write-Host "MinerOps Agent enrollment completed."
Write-Host "Agent ID: $($agent.agent_id)"
Write-Host "Site: $($agent.site_name)"
Write-Host "Agent Zone: $($agent.agent_zone_name)"
Write-Host ""
Write-Host "Delete the downloaded ZIP and extracted setup folder after verification."
}
catch {
if (-not $Redeemed) {
foreach ($temporaryPath in @(
$PendingConfigPath,
$RecoveryConfigPath
)) {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item `
-LiteralPath $temporaryPath `
-Force `
-ErrorAction SilentlyContinue
}
}
}
elseif ($ConfigPersisted) {
Write-Warning @"
The enrollment token was consumed and the permanent Agent identity WAS saved
to C:\MinerOpsAgent\agent-config.json.
Do not create a replacement enrollment. Correct the installation problem and
rerun install.ps1 from this package.
"@
}
else {
Write-Warning @"
The enrollment token was consumed.
A protected credential recovery file may exist at:
C:\MinerOpsAgent\agent-config.recovery.json
Do not create or redeem another enrollment until that recovery file has been
reviewed.
"@
}
throw
}
+8
View File
@@ -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
}
+1
View File
@@ -8,6 +8,7 @@ $AgentId = $Config.agent_id
$AgentSecret = $Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-ID" = $AgentId
"X-MinerOps-Agent-Secret" = $AgentSecret
}
+54 -31
View File
@@ -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.
@@ -27,9 +30,12 @@ API3 account and password values are local secrets. Missing credentials make
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
must not be written to normal logs or returned to MinerOps command history.
The Internal Alpha supports one Agent identity protected by the deployment
shared secret. A second active Agent requires per-Agent credentials before it
may be enabled.
New Agent identities use per-Agent credentials created through secure
one-time enrollment. The permanent Agent secret is returned only to the
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
only its hash. The legacy deployment shared secret remains only for explicitly
retained compatibility behavior and must not authenticate a newly enrolled
per-Agent identity.
## Legacy Compatibility
@@ -40,35 +46,52 @@ Agent queue; the modular control backend does not connect directly to miners.
## Deployment
Deploy Agent compatibility before enabling Hub enforcement of the
`X-MinerOps-Agent-ID` header:
### New Agent enrollment
1. Copy this directory to a temporary folder on the Windows Agent host.
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
secrets as installer or scheduled-task arguments.
3. Run `install.ps1` from an elevated Windows PowerShell session.
4. Confirm the four scheduled tasks are registered and that Agent check-in
reports the expected non-secret `VERSION`.
5. Enable the matching Agent ID and shared secret on the Hub.
For a new Agent, use Agent Management in MinerOps:
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
output, and other runtime data. During an upgrade, existing definitions for
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
created for a fresh or incomplete installation without hard-coded user names.
1. Choose the Site and an enabled, unassigned Agent Zone.
2. Enter a descriptive Agent name.
3. Create a Windows enrollment.
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
5. Copy the ZIP to the intended Windows machine and extract it.
6. Open Windows PowerShell as Administrator in the extracted
MinerOpsAgent-Windows directory.
7. Run:
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
.\Enroll-MinerOpsAgent.ps1
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
the account blank if API3 functionality is not being configured yet.
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
saves the resulting permanent Agent identity locally, and hands off to the
safe runtime installer.
10. Verify the Agent appears in Agent Management and checks in successfully.
11. Delete the downloaded ZIP and extracted setup directory.
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
credentials in screenshots, chat, tickets, logs, or source control.
### Existing Agent upgrade
For an already enrolled/configured Agent, do not run the enrollment
bootstrapper. Copy the new canonical Agent files to a temporary directory and
run install.ps1 from an elevated Windows PowerShell session.
The installer copies only managed runtime code to C:\MinerOpsAgent and
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
and other runtime data. Existing Scheduled Task definitions are authoritative
and are not rewritten. Missing tasks are created for a fresh or incomplete
installation without hard-coded user names.
If MinerOpsAgent was running before an upgrade, the installer stops only that
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
again. A previously stopped Agent remains stopped. The tray and periodic tasks
are not unnecessarily restarted.
again. A previously stopped Agent remains stopped. Before replacing runtime
files, the installer copies them to a timestamped backup under
C:\MinerOpsAgent\backups.
Before replacing existing runtime files, the installer copies them to a
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
automatically restores those managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state. For a manual
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
and restart it only if it was previously running. `uninstall.ps1` unregisters
the four tasks and removes runtime code while preserving local configuration,
state, logs, and backups.
A failed deployment restores managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state.
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
code while preserving local configuration, state, logs, and backups.
+1
View File
@@ -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,117 @@
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$bootstrap,
[ref] $tokens,
[ref] $parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
}
$source = Get-Content -LiteralPath $bootstrap -Raw
function Assert-SourceContains {
param(
[Parameter(Mandatory)][string] $Pattern,
[Parameter(Mandatory)][string] $Message
)
if ($source -notmatch $Pattern) {
throw $Message
}
}
Assert-SourceContains 'enrollment\.json' `
"Bootstrap enrollment file is not explicit."
Assert-SourceContains '/api/agent/enroll' `
"Bootstrap does not use the secure enrollment endpoint."
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
"API3 password is not prompted as a SecureString."
Assert-SourceContains 'agent-config\.json\.pending' `
"Atomic pending config path is missing."
Assert-SourceContains 'agent-config\.recovery\.json' `
"Post-redemption credential recovery path is missing."
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
"Credential file ACL protection is missing."
Assert-SourceContains 'S-1-5-18' `
"SYSTEM ACL entry is missing."
Assert-SourceContains 'S-1-5-32-544' `
"Administrators ACL entry is missing."
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
"Installing user ACL entry is missing."
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
"Atomic final config move is missing."
Assert-SourceContains '& \$Installer' `
"Bootstrap does not hand off to the safe installer."
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
"Fresh Agent startup is missing."
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
"Consumed extracted enrollment token is not removed."
$existingConfigGuard = $source.IndexOf(
'Test-Path -LiteralPath $ConfigPath'
)
$redeemCall = $source.IndexOf(
'"$HubUrl/api/agent/enroll"'
)
if (
$existingConfigGuard -lt 0 -or
$redeemCall -lt 0 -or
$existingConfigGuard -gt $redeemCall
) {
throw "Existing Agent identity must be rejected before token redemption."
}
$configMove = $source.IndexOf(
'-Destination $ConfigPath'
)
$installerCall = $source.IndexOf(
'& $Installer'
)
if (
$configMove -lt 0 -or
$installerCall -lt 0 -or
$configMove -gt $installerCall
) {
throw "Permanent Agent credentials must be persisted before installation."
}
foreach ($sensitiveVariable in @(
'EnrollmentToken',
'Api3PasswordPlain'
)) {
if (
$source -match (
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
$sensitiveVariable
)
) {
throw "$sensitiveVariable must not be written to output."
}
}
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
+8 -5
View File
@@ -5,15 +5,18 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt /app/requirements.txt
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic
COPY backend/app /app/app
COPY backend/alembic.ini /app/alembic.ini
COPY backend/alembic /app/alembic
# Canonical Agent source used to construct short-lived enrollment packages.
COPY agents/windows /app/agents/windows
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Per-Agent authentication support."""
+21
View File
@@ -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."""
+47
View File
@@ -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()
+66
View File
@@ -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
+27 -18
View File
@@ -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
+1
View File
@@ -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."""
+98
View File
@@ -0,0 +1,98 @@
"""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
class AgentWindowsPackageRequest(AgentEnrollRequest):
"""Short-lived token used to construct a Windows bootstrap package."""
+506
View File
@@ -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()
+159
View File
@@ -0,0 +1,159 @@
"""HTTP routes for Agent enrollment and bootstrap."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, 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,
AgentWindowsPackageRequest,
)
from app.agent_enrollment.service import AgentEnrollmentService
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
build_windows_agent_package,
normalize_hub_url,
)
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-enrollments/windows-package")
def download_windows_agent_package(
package_request: AgentWindowsPackageRequest,
http_request: Request,
current_user: EnrollmentManager,
) -> Response:
del current_user
browser_origin = http_request.headers.get("origin", "").strip()
try:
hub_url = normalize_hub_url(browser_origin)
except AgentWindowsPackageError as exc:
raise HTTPException(
status_code=400,
detail=(
"Windows Agent package downloads require "
"a valid browser Origin"
),
) from exc
try:
package = build_windows_agent_package(
enrollment_token=package_request.token,
hub_url=hub_url,
)
except AgentWindowsPackageSourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except AgentWindowsPackageError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
content=package,
media_type="application/zip",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"Content-Disposition": (
'attachment; filename="MinerOpsAgent-Windows.zip"'
),
},
)
@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
+98
View File
@@ -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"
)
+1
View File
@@ -0,0 +1 @@
"""Downloadable MinerOps Agent deployment packages."""
+164
View File
@@ -0,0 +1,164 @@
"""Build short-lived Windows Agent enrollment packages in memory."""
from __future__ import annotations
import io
import json
import os
import zipfile
from pathlib import Path
from urllib.parse import urlsplit
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
WINDOWS_PACKAGE_FILES = (
"Enroll-MinerOpsAgent.ps1",
"install.ps1",
"uninstall.ps1",
"MinerOpsAgentService.ps1",
"MinerOpsAgentTray.ps1",
"MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1",
"MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1",
"EnrichOneMiner.ps1",
"MinerOpsControl.ps1",
"VERSION",
"README.md",
)
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
os.environ.get(
"MINEROPS_WINDOWS_AGENT_SOURCE",
"/app/agents/windows",
)
)
class AgentWindowsPackageError(RuntimeError):
"""Base Windows package construction error."""
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
"""Canonical Windows Agent source is unavailable or incomplete."""
def normalize_hub_url(value: str) -> str:
candidate = value.strip()
parsed = urlsplit(candidate)
if parsed.scheme not in {"http", "https"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must use http or https"
)
if not parsed.netloc:
raise AgentWindowsPackageError(
"MinerOps Hub URL must include a host"
)
if parsed.username or parsed.password:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain credentials"
)
if parsed.query or parsed.fragment:
raise AgentWindowsPackageError(
"MinerOps Hub URL must not contain query or fragment data"
)
if parsed.path not in {"", "/"}:
raise AgentWindowsPackageError(
"MinerOps Hub URL must be an origin without a path"
)
return f"{parsed.scheme}://{parsed.netloc}"
def build_windows_agent_package(
*,
enrollment_token: str,
hub_url: str,
source_root: Path | None = None,
) -> bytes:
token = enrollment_token.strip()
if not 32 <= len(token) <= 256:
raise AgentWindowsPackageError(
"Enrollment token has an invalid length"
)
normalized_hub_url = normalize_hub_url(hub_url)
root = (
Path(source_root)
if source_root is not None
else DEFAULT_WINDOWS_AGENT_SOURCE
)
if not root.is_dir():
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is unavailable"
)
missing = [
name
for name in WINDOWS_PACKAGE_FILES
if not (root / name).is_file()
]
if missing:
raise AgentWindowsPackageSourceError(
"Canonical Windows Agent source is incomplete: "
+ ", ".join(missing)
)
enrollment = {
"hub_url": normalized_hub_url,
"token": token,
}
instructions = """MinerOps Windows Agent
1. Extract this ZIP on the target Windows machine.
2. Open Windows PowerShell as Administrator.
3. Change to the extracted MinerOpsAgent-Windows folder.
4. Run:
.\\Enroll-MinerOpsAgent.ps1
The enrollment credential is short-lived and single-use.
Do not post enrollment.json in chat, tickets, screenshots, or logs.
Delete the ZIP and extracted setup folder after successful installation.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for name in WINDOWS_PACKAGE_FILES:
archive.write(
root / name,
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
json.dumps(
enrollment,
indent=2,
sort_keys=True,
)
+ "\n",
)
archive.writestr(
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
instructions,
)
return buffer.getvalue()
+1
View File
@@ -0,0 +1 @@
"""Read-only Agent registry projection."""
@@ -0,0 +1,8 @@
"""Dependencies for the Agent registry read model."""
from app.agent_registry.repository import AgentRegistryRepository
from app.agent_registry.service import AgentRegistryService
def get_agent_registry_service() -> AgentRegistryService:
return AgentRegistryService(AgentRegistryRepository())
+62
View File
@@ -0,0 +1,62 @@
"""Typed read models for Agent Management."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
CredentialState = Literal["legacy", "active", "revoked"]
RoutingState = Literal["assigned", "unassigned", "disabled"]
class AgentRegistrySite(BaseModel):
id: UUID
name: str
class AgentRegistryZone(BaseModel):
id: UUID
site_id: UUID
site_name: str
name: str
description: str | None = None
is_enabled: bool
assigned_agent_id: str | None = None
networks: list[str] = Field(default_factory=list)
class AgentRegistryAgent(BaseModel):
agent_id: str
display_name: str | None = None
site_id: UUID | None = None
site_name: str
agent_zone_id: UUID | None = None
agent_zone_name: str | None = None
agent_zone_enabled: bool | None = None
hostname: str | None = None
version: str | None = None
ip_address: str | None = None
last_checkin_at: datetime
credential_state: CredentialState
credential_created_at: datetime | None = None
credential_revoked_at: datetime | None = None
routing_state: RoutingState
class AgentRegistryData(BaseModel):
agents: list[AgentRegistryAgent] = Field(default_factory=list)
sites: list[AgentRegistrySite] = Field(default_factory=list)
zones: list[AgentRegistryZone] = Field(default_factory=list)
class AgentRegistryResponse(BaseModel):
data: AgentRegistryData
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+127
View File
@@ -0,0 +1,127 @@
"""PostgreSQL reads for the durable Agent registry."""
import psycopg
from psycopg.rows import dict_row
from app.config import settings
def default_connection_factory():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
row_factory=dict_row,
)
class AgentRegistryRepository:
def __init__(self, connection_factory=default_connection_factory):
self.connection_factory = connection_factory
def list_registry(self) -> dict[str, list[dict]]:
connection = self.connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT
site.id,
site.name
FROM sites AS site
WHERE EXISTS (
SELECT 1
FROM agent_zones AS zone
WHERE zone.site_id = site.id
)
ORDER BY lower(site.name), site.id
"""
)
sites = cur.fetchall()
cur.execute(
"""
SELECT
zone.id,
zone.site_id,
site.name AS site_name,
zone.name,
zone.description,
zone.is_enabled,
assigned.agent_id AS assigned_agent_id,
COALESCE(
array_agg(
network.network::text
ORDER BY network.network::text
) FILTER (WHERE network.id IS NOT NULL),
ARRAY[]::text[]
) AS networks
FROM agent_zones AS zone
JOIN sites AS site
ON site.id = zone.site_id
LEFT JOIN minerops_site_agents AS assigned
ON assigned.agent_zone_id = zone.id
LEFT JOIN agent_zone_network_ranges AS network
ON network.agent_zone_id = zone.id
GROUP BY
zone.id,
zone.site_id,
site.name,
zone.name,
zone.description,
zone.is_enabled,
assigned.agent_id
ORDER BY
lower(site.name),
lower(zone.name),
zone.id
"""
)
zones = cur.fetchall()
cur.execute(
"""
SELECT
agent.agent_id,
agent.display_name,
agent.site_id,
COALESCE(site.name, agent.site_name) AS site_name,
agent.agent_zone_id,
zone.name AS agent_zone_name,
zone.is_enabled AS agent_zone_enabled,
agent.hostname,
agent.version,
agent.ip_address,
agent.last_checkin_at,
agent.credential_created_at,
agent.credential_revoked_at,
CASE
WHEN agent.credential_revoked_at IS NOT NULL
THEN 'revoked'
WHEN agent.agent_secret_hash IS NOT NULL
THEN 'active'
ELSE 'legacy'
END AS credential_state
FROM minerops_site_agents AS agent
LEFT JOIN sites AS site
ON site.id = agent.site_id
LEFT JOIN agent_zones AS zone
ON zone.id = agent.agent_zone_id
ORDER BY
lower(COALESCE(site.name, agent.site_name)),
lower(COALESCE(agent.display_name, agent.agent_id)),
agent.agent_id
"""
)
agents = cur.fetchall()
return {
"sites": sites,
"zones": zones,
"agents": agents,
}
finally:
connection.close()
+48
View File
@@ -0,0 +1,48 @@
"""Read-only Agent Management API."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.agent_registry.dependencies import get_agent_registry_service
from app.agent_registry.models import AgentRegistryResponse
from app.agent_registry.service import AgentRegistryService
from app.identity import require_permission
router = APIRouter(tags=["agents"])
RegistryService = Annotated[
AgentRegistryService,
Depends(get_agent_registry_service),
]
AgentViewer = Annotated[
dict,
Depends(require_permission("agents.view")),
]
@router.get(
"/api/agents",
response_model=AgentRegistryResponse,
)
def list_agents(
response: Response,
current_user: AgentViewer,
service: RegistryService,
) -> AgentRegistryResponse:
del current_user
response.headers["Cache-Control"] = "no-store"
data = service.list_registry()
return AgentRegistryResponse(
data=data,
meta={
"agent_count": len(data.agents),
"site_count": len(data.sites),
"zone_count": len(data.zones),
},
)
+52
View File
@@ -0,0 +1,52 @@
"""Application service for the Agent Management read model."""
from app.agent_registry.models import (
AgentRegistryAgent,
AgentRegistryData,
AgentRegistrySite,
AgentRegistryZone,
)
class AgentRegistryService:
def __init__(self, repository):
self.repository = repository
def list_registry(self) -> AgentRegistryData:
rows = self.repository.list_registry()
sites = [
AgentRegistrySite(**row)
for row in rows["sites"]
]
zones = [
AgentRegistryZone(**row)
for row in rows["zones"]
]
agents: list[AgentRegistryAgent] = []
for row in rows["agents"]:
zone_id = row.get("agent_zone_id")
zone_enabled = row.get("agent_zone_enabled")
if zone_id is None:
routing_state = "unassigned"
elif zone_enabled is False:
routing_state = "disabled"
else:
routing_state = "assigned"
agents.append(
AgentRegistryAgent(
**row,
routing_state=routing_state,
)
)
return AgentRegistryData(
agents=agents,
sites=sites,
zones=zones,
)
+1
View File
@@ -0,0 +1 @@
"""Agent Zone routing domain."""
+8
View File
@@ -0,0 +1,8 @@
"""Agent Zone dependency wiring."""
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
def get_agent_zone_service() -> AgentZoneService:
return AgentZoneService(AgentZoneRepository())
+17
View File
@@ -0,0 +1,17 @@
"""Agent Zone domain exceptions."""
class AgentZoneError(Exception):
"""Base Agent Zone error."""
class AgentZoneNotFound(AgentZoneError):
"""A Site or Agent Zone does not exist."""
class AgentZoneValidationError(AgentZoneError):
"""Agent Zone input or assignment is invalid."""
class AgentZoneConflict(AgentZoneError):
"""An Agent Zone uniqueness or assignment invariant conflicts."""
+114
View File
@@ -0,0 +1,114 @@
"""Typed Agent Zone and check-in contracts."""
from ipaddress import ip_network
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class AgentZoneCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
site_id: UUID
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
networks: list[str] = Field(default_factory=list, max_length=64)
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name cannot be blank")
return normalized
@field_validator("description")
@classmethod
def normalize_description(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
@field_validator("networks")
@classmethod
def normalize_networks(cls, values: list[str]) -> list[str]:
normalized: list[str] = []
for value in values:
try:
network = str(ip_network(value.strip(), strict=True))
except ValueError as exc:
raise ValueError(f"invalid CIDR network: {value}") from exc
if network in normalized:
raise ValueError(f"duplicate CIDR network: {network}")
normalized.append(network)
return normalized
class AgentZoneNetwork(BaseModel):
id: UUID
network: str
class AgentZoneSummary(BaseModel):
id: UUID
site_id: UUID
name: str
description: str | None = None
is_enabled: bool
assigned_agent_id: str | None = None
networks: list[AgentZoneNetwork] = Field(default_factory=list)
class AgentZoneListResponse(BaseModel):
data: list[AgentZoneSummary]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentZoneCreateResponse(BaseModel):
data: AgentZoneSummary
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class AgentCheckinRequest(BaseModel):
model_config = ConfigDict(extra="allow")
agent_id: str | None = Field(default=None, max_length=255)
site_id: UUID | None = None
agent_zone_id: UUID | None = None
site_name: str | None = Field(default=None, max_length=255)
hostname: str | None = Field(default=None, max_length=255)
version: str | None = Field(default=None, max_length=120)
ip_address: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
if self.agent_zone_id is not None and self.site_id is None:
raise ValueError("site_id is required when agent_zone_id is supplied")
return self
class AgentCheckinResponse(BaseModel):
ok: bool = True
hub_mode: bool = True
agent_id: str
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_available: bool
routing_reason: str | None = None
site_name: str
message: str = "Agent check-in received."
class AgentZoneRoutingContext(BaseModel):
site_id: UUID | None = None
agent_zone_id: UUID | None = None
routing_reason: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
outcomes: dict[UUID, str] = Field(default_factory=dict)
+300
View File
@@ -0,0 +1,300 @@
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
from collections.abc import Callable, Iterable
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
)
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class AgentZoneRepository:
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_agent(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
FROM minerops_site_agents
WHERE agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def get_zone(self, zone_id: UUID) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled,
created_at, updated_at
FROM agent_zones
WHERE id = %s
""",
(zone_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def list_usable_zones(self, site_id: UUID) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, site_id, name, description, is_enabled
FROM agent_zones
WHERE site_id = %s AND is_enabled
ORDER BY lower(name), id
""",
(site_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT zone.id, zone.site_id, zone.name, zone.description,
zone.is_enabled, agent.agent_id AS assigned_agent_id,
range.id AS range_id, range.network::text AS network
FROM agent_zones AS zone
LEFT JOIN minerops_site_agents AS agent
ON agent.agent_zone_id = zone.id
LEFT JOIN agent_zone_network_ranges AS range
ON range.agent_zone_id = zone.id
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
ORDER BY lower(zone.name), zone.id, range.network, range.id
""",
(site_id, site_id),
)
rows = cur.fetchall()
finally:
connection.close()
zones: dict[UUID, dict] = {}
for row in rows:
zone = zones.setdefault(
row["id"],
{
"id": row["id"],
"site_id": row["site_id"],
"name": row["name"],
"description": row["description"],
"is_enabled": row["is_enabled"],
"assigned_agent_id": row["assigned_agent_id"],
"networks": [],
},
)
if row["range_id"] is not None:
zone["networks"].append(
{"id": row["range_id"], "network": row["network"]}
)
return list(zones.values())
def create_zone(
self,
*,
site_id: UUID,
name: str,
description: str | None,
networks: list[str],
) -> UUID:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO agent_zones (site_id, name, description)
VALUES (%s, %s, %s)
RETURNING id
""",
(site_id, name, description),
)
zone_id = cur.fetchone()["id"]
for network in networks:
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
VALUES (%s, %s::cidr)
""",
(zone_id, network),
)
connection.commit()
return zone_id
except ForeignKeyViolation as exc:
connection.rollback()
raise AgentZoneNotFound("Site not found") from exc
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"An enabled Agent Zone with this name already exists at the Site"
) from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def save_checkin(
self,
*,
agent_id: str,
site_id: UUID | None,
agent_zone_id: UUID | None,
site_name: str,
hostname: str | None,
version: str | None,
ip_address: str | None,
payload: dict[str, Any],
) -> None:
connection = self._connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, agent_zone_id, site_name, hostname,
version, ip_address, last_checkin_at, last_payload
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
ON CONFLICT (agent_id) DO UPDATE SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
hostname = EXCLUDED.hostname,
version = EXCLUDED.version,
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
""",
(
agent_id,
site_id,
agent_zone_id,
site_name,
hostname,
version,
ip_address,
Jsonb(payload),
),
)
connection.commit()
except UniqueViolation as exc:
connection.rollback()
raise AgentZoneConflict(
"The Agent Zone already has an assigned Agent"
) from exc
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
connection.rollback()
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_agent_assignment(self, agent_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
zone.is_enabled AS zone_enabled
FROM minerops_site_agents AS agent
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
WHERE agent.agent_id = %s
""",
(agent_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
unique_ids = list(dict.fromkeys(miner_ids))
if not unique_ids:
return {}
connection = self._connection_factory()
outcomes: dict[UUID, str] = {}
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT id, site_id FROM agent_zones WHERE id = %s",
(agent_zone_id,),
)
zone = cur.fetchone()
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
for miner_id in unique_ids:
cur.execute(
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
outcomes[miner_id] = "miner_not_found"
elif miner["agent_zone_id"] == agent_zone_id:
outcomes[miner_id] = "preserved"
elif miner["agent_zone_id"] is not None:
outcomes[miner_id] = "agent_zone_conflict"
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
connection.commit()
return outcomes
except Exception:
connection.rollback()
raise
finally:
connection.close()
+75
View File
@@ -0,0 +1,75 @@
"""HTTP mapping for Agent Zones and Agent check-in."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import (
AgentZoneConflict,
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneCreateRequest,
AgentZoneCreateResponse,
AgentZoneListResponse,
)
from app.agent_zones.service import AgentZoneService
from app.identity import require_permission
router = APIRouter(tags=["agent-zones"])
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
def list_agent_zones(
current_user: ZoneViewer,
service: ZoneService,
site_id: UUID | None = Query(default=None),
) -> AgentZoneListResponse:
del current_user
return AgentZoneListResponse(data=service.list_zones(site_id))
@router.post(
"/api/agent-zones",
response_model=AgentZoneCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def create_agent_zone(
request: AgentZoneCreateRequest,
current_user: ZoneManager,
service: ZoneService,
) -> AgentZoneCreateResponse:
del current_user
try:
return AgentZoneCreateResponse(data=service.create_zone(request))
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
def agent_checkin(
request: AgentCheckinRequest,
agent_id: AgentIdentity,
service: ZoneService,
) -> AgentCheckinResponse:
try:
return service.checkin(agent_id, request)
except AgentZoneNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentZoneConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentZoneValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+194
View File
@@ -0,0 +1,194 @@
"""Business rules for Agent Zone routing and transitional check-in."""
from typing import Any
from uuid import UUID
from app.agent_zones.exceptions import (
AgentZoneNotFound,
AgentZoneValidationError,
)
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
MinerZoneAssociationResult,
)
from app.agent_zones.repository import AgentZoneRepository
SENSITIVE_KEYS = {
"agent_secret",
"api3_account",
"api3_password",
"password",
"salt",
"token",
}
def _sanitize(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _sanitize(item)
for key, item in value.items()
if key.lower() not in SENSITIVE_KEYS
and not any(
marker in key.lower()
for marker in ("secret", "password", "token", "salt")
)
}
if isinstance(value, list):
return [_sanitize(item) for item in value]
return value
class AgentZoneService:
@staticmethod
def validate_agent_identity(
authenticated_agent_id: str, claimed_agent_id: str | None
) -> None:
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
raise AgentZoneValidationError(
"Payload agent_id does not match authenticated Agent identity"
)
def __init__(self, repository: AgentZoneRepository):
self.repository = repository
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
return [
AgentZoneSummary.model_validate(row)
for row in self.repository.list_zones(site_id)
]
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
zone_id = self.repository.create_zone(
site_id=request.site_id,
name=request.name,
description=request.description,
networks=request.networks,
)
for zone in self.list_zones(request.site_id):
if zone.id == zone_id:
return zone
raise AgentZoneNotFound("Created Agent Zone was not found")
def checkin(
self, authenticated_agent_id: str, request: AgentCheckinRequest
) -> AgentCheckinResponse:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
final_zone: UUID | None = None
reason: str | None = None
established_zone = None
established_zone_id = (existing or {}).get("agent_zone_id")
if established_zone_id is not None:
established_zone = self.repository.get_zone(established_zone_id)
if not established_zone:
raise AgentZoneValidationError(
"Existing Agent Zone assignment is invalid"
)
if (
requested_site is not None
and established_zone["site_id"] != requested_site
):
raise AgentZoneValidationError(
"Agent cannot change Sites while assigned to an Agent Zone"
)
if requested_zone is not None:
zone = self.repository.get_zone(requested_zone)
if not zone:
raise AgentZoneNotFound("Agent Zone not found")
if zone["site_id"] != requested_site:
raise AgentZoneValidationError(
"Agent Zone does not belong to the supplied Site"
)
if not zone["is_enabled"]:
raise AgentZoneValidationError("Agent Zone is disabled")
if established_zone_id is not None and established_zone_id != requested_zone:
raise AgentZoneValidationError(
"Agent is already assigned to a different Agent Zone"
)
final_zone = requested_zone
elif established_zone is not None:
final_site = established_zone["site_id"]
final_zone = established_zone["id"]
if not established_zone["is_enabled"]:
reason = "agent_zone_disabled"
elif final_site is not None:
usable = self.repository.list_usable_zones(final_site)
if len(usable) == 1:
final_zone = usable[0]["id"]
elif not usable:
reason = "agent_zone_unavailable"
else:
reason = "agent_zone_ambiguous"
else:
reason = "site_unassigned"
site_name = (request.site_name or "").strip()
if not site_name:
site_name = str((existing or {}).get("site_name") or "Unassigned")
payload = _sanitize(request.model_dump(mode="json"))
self.repository.save_checkin(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
site_name=site_name,
hostname=(request.hostname or "").strip() or None,
version=(request.version or "").strip() or None,
ip_address=(request.ip_address or "").strip() or None,
payload=payload,
)
routing_available = final_zone is not None and reason is None
return AgentCheckinResponse(
agent_id=authenticated_agent_id,
site_id=final_site,
agent_zone_id=final_zone,
routing_available=routing_available,
routing_reason=reason,
site_name=site_name,
)
def routing_context(
self, agent_id: str, recorded_zone_id: UUID | None = None
) -> AgentZoneRoutingContext:
assignment = self.repository.get_agent_assignment(agent_id)
if not assignment or assignment.get("agent_zone_id") is None:
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
zone_id = assignment["agent_zone_id"]
if recorded_zone_id is not None and recorded_zone_id != zone_id:
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
if assignment.get("zone_enabled") is not True:
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"),
agent_zone_id=zone_id,
routing_reason="agent_zone_disabled",
)
return AgentZoneRoutingContext(
site_id=assignment.get("site_id"), agent_zone_id=zone_id
)
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
routing = self.routing_context(agent_id)
if routing.routing_reason:
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
routing_reason=routing.routing_reason,
)
return MinerZoneAssociationResult(
agent_zone_id=routing.agent_zone_id,
outcomes=self.repository.assign_miners_to_zone(
miner_ids, routing.agent_zone_id
),
)
@@ -0,0 +1,274 @@
"""Semantic HTML for the Internal Alpha Agent Management page."""
def agent_management_content(can_manage_agents: bool) -> str:
management = """
<section class="panel agent-enrollment-panel"
aria-labelledby="agentEnrollmentCreateTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Agent deployment</p>
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
<p class="panel-copy">
Create a short-lived one-time enrollment for an Agent Zone.
Windows is the currently supported deployment platform.
</p>
</div>
</div>
<form id="agentCreateForm" class="agent-create-form">
<div class="field">
<label for="agentSite">Site</label>
<select id="agentSite" required>
<option value="">Choose a site</option>
</select>
</div>
<div class="field">
<label for="agentZone">Agent Zone</label>
<select id="agentZone" required disabled>
<option value="">Choose a site first</option>
</select>
</div>
<div class="field">
<label for="agentDisplayName">Agent name</label>
<input id="agentDisplayName"
type="text"
maxlength="120"
autocomplete="off"
placeholder="Example: Plexus Unit 4 Agent"
required>
</div>
<div class="field">
<label for="agentPlatform">Platform</label>
<select id="agentPlatform">
<option value="windows">Windows</option>
<option value="linux-x64" disabled>Linux x64 — coming later</option>
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
</select>
</div>
<div class="agent-form-actions">
<button id="agentCreateSubmit"
class="command-button"
type="submit">
Create Windows enrollment
</button>
</div>
<p id="agentCreateStatus"
class="freshness"
aria-live="polite"></p>
</form>
<section id="agentEnrollmentResult"
class="agent-enrollment-result"
aria-labelledby="agentEnrollmentResultTitle"
hidden>
<div>
<p class="eyebrow">One-time credential</p>
<h3 id="agentEnrollmentResultTitle">
Enrollment created
</h3>
</div>
<p>
This token is displayed only for this enrollment step.
Keep it private. MinerOps does not store the plaintext token.
</p>
<div class="agent-token-box">
<code id="agentEnrollmentToken"></code>
</div>
<p id="agentEnrollmentExpires" class="freshness"></p>
<div class="agent-form-actions">
<button id="agentEnrollmentDownload"
class="command-button"
type="button">
Download Windows package
</button>
<button id="agentEnrollmentCopy"
class="quiet-button"
type="button">
Copy token
</button>
<button id="agentEnrollmentDismiss"
class="quiet-button"
type="button">
Clear token from screen
</button>
</div>
<p id="agentEnrollmentCopyStatus"
class="freshness"
aria-live="polite"></p>
<p class="panel-copy">
The Windows package contains the short-lived enrollment
credential and generic MinerOps Agent runtime. Transfer it
only to the intended Agent computer and delete the ZIP after
successful installation.
</p>
</section>
</section>
""" if can_manage_agents else """
<section class="panel">
<div class="panel-head">
<div>
<p class="eyebrow">Agent deployment</p>
<h2>Agent enrollment</h2>
</div>
</div>
<p class="permission-note">
Your account can view Agent status but cannot create Agent
enrollments.
</p>
</section>
"""
return f"""
<div id="agentManagementLoading" class="state-message" role="status">
<strong>Loading Agents</strong>
<span>Requesting Agent registry and enrollment information.</span>
</div>
<div id="agentManagementError"
class="state-message"
data-kind="error"
role="alert"
hidden>
<strong>Agent Management unavailable</strong>
<span id="agentManagementErrorText">
Agent information could not be loaded.
</span>
<button id="agentManagementRetry"
class="command-button"
type="button">
Retry
</button>
</div>
<div id="agentManagementContent" hidden>
<section class="agent-summary-grid" aria-label="Agent Management summary">
<div class="panel agent-summary-card">
<span>Registered Agents</span>
<strong id="agentCount">-</strong>
</div>
<div class="panel agent-summary-card">
<span>Agent Zones</span>
<strong id="agentZoneCount">-</strong>
</div>
<div class="panel agent-summary-card">
<span>Waiting enrollments</span>
<strong id="agentWaitingEnrollmentCount">-</strong>
</div>
</section>
<div class="agent-management-columns">
<section class="panel" aria-labelledby="registeredAgentsTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Durable identities</p>
<h2 id="registeredAgentsTitle">Agents</h2>
<p class="panel-copy">
Registered Agent identities and their latest factual
check-in information.
</p>
</div>
<button id="agentManagementRefresh"
class="quiet-button"
type="button">
Refresh
</button>
</div>
<div class="table-wrap">
<table class="agent-table">
<thead>
<tr>
<th>Agent</th>
<th>Site / Zone</th>
<th>Host</th>
<th>Version</th>
<th>Last check-in</th>
<th>Credential</th>
<th>Routing</th>
</tr>
</thead>
<tbody id="agentRows"></tbody>
</table>
</div>
<p id="agentEmpty" class="panel-copy" hidden>
No registered Agents.
</p>
</section>
<section class="panel" aria-labelledby="agentZonesTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Network routing boundaries</p>
<h2 id="agentZonesTitle">Agent Zones</h2>
</div>
</div>
<div class="table-wrap">
<table class="agent-zone-table">
<thead>
<tr>
<th>Site</th>
<th>Zone</th>
<th>Networks</th>
<th>Assigned Agent</th>
<th>State</th>
</tr>
</thead>
<tbody id="agentZoneRows"></tbody>
</table>
</div>
<p id="agentZoneEmpty" class="panel-copy" hidden>
No Agent Zones.
</p>
</section>
</div>
{management}
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
<div class="panel-head">
<div>
<p class="eyebrow">Bootstrap history</p>
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
<p class="panel-copy">
Metadata only. Plaintext enrollment tokens are never
returned by this history view.
</p>
</div>
</div>
<div class="table-wrap">
<table class="agent-enrollment-table">
<thead>
<tr>
<th>Agent name</th>
<th>Site / Zone</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Enrolled Agent</th>
</tr>
</thead>
<tbody id="agentEnrollmentRows"></tbody>
</table>
</div>
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
No Agent enrollment records.
</p>
</section>
</div>
<script src="/assets/minerops-agent-management.js" defer></script>
"""
+22
View File
@@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
def render_page(view: str, current_user: dict, **context) -> str:
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
headers={"Cache-Control": "no-store"},
)
@router.get(
"/assets/minerops-agent-management.js",
include_in_schema=False,
)
def agent_management_script() -> Response:
return Response(
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get(
"/agent/management",
response_class=HTMLResponse,
include_in_schema=False,
)
def agent_management_page(current_user: AgentUser) -> str:
return render_page("agent_management", current_user)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard_page(current_user: DashboardUser) -> str:
return render_page("dashboard", current_user)
@@ -0,0 +1,491 @@
(function () {
"use strict";
if (document.body.dataset.view !== "agent_management") return;
const ids = [
"agentManagementLoading",
"agentManagementError",
"agentManagementErrorText",
"agentManagementRetry",
"agentManagementContent",
"agentManagementRefresh",
"agentCount",
"agentZoneCount",
"agentWaitingEnrollmentCount",
"agentRows",
"agentEmpty",
"agentZoneRows",
"agentZoneEmpty",
"agentEnrollmentRows",
"agentEnrollmentEmpty",
"agentCreateForm",
"agentSite",
"agentZone",
"agentDisplayName",
"agentPlatform",
"agentCreateSubmit",
"agentCreateStatus",
"agentEnrollmentResult",
"agentEnrollmentToken",
"agentEnrollmentExpires",
"agentEnrollmentDownload",
"agentEnrollmentCopy",
"agentEnrollmentDismiss",
"agentEnrollmentCopyStatus"
];
const el = {};
ids.forEach(function (id) {
el[id] = document.getElementById(id);
});
let registry = {
agents: [],
sites: [],
zones: []
};
let enrollments = [];
function text(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback || "Unavailable";
}
return String(value);
}
function dateTime(value) {
if (!value) return "Never";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
return parsed.toLocaleString();
}
function words(value) {
return text(value, "Unknown")
.replaceAll("_", " ")
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function cell(value, className) {
const node = document.createElement("td");
if (className) node.className = className;
node.textContent = value;
return node;
}
function setState(name) {
el.agentManagementLoading.hidden = name !== "loading";
el.agentManagementError.hidden = name !== "error";
el.agentManagementContent.hidden = name !== "ready";
}
async function api(path, options) {
const response = await fetch(path, Object.assign({
credentials: "same-origin",
cache: "no-store",
headers: {
"Accept": "application/json"
}
}, options || {}));
const payload = await response.json().catch(function () {
return {};
});
if (!response.ok) {
const detail = payload && payload.detail
? String(payload.detail)
: "Request failed with status " + response.status + ".";
throw new Error(detail);
}
return payload;
}
function renderAgents() {
el.agentRows.replaceChildren();
registry.agents.forEach(function (agent) {
const row = document.createElement("tr");
const identity = document.createElement("td");
const name = document.createElement("strong");
const id = document.createElement("span");
name.textContent = text(agent.display_name, "Unnamed Agent");
id.className = "secondary";
id.textContent = agent.agent_id;
identity.append(name, id);
const location = [
text(agent.site_name, "Unknown site"),
text(agent.agent_zone_name, "No Agent Zone")
].join(" / ");
const host = [
text(agent.hostname, "Unknown host"),
text(agent.ip_address, "No IP")
].join(" / ");
row.append(
identity,
cell(location),
cell(host),
cell(text(agent.version, "Unknown")),
cell(dateTime(agent.last_checkin_at)),
cell(words(agent.credential_state)),
cell(words(agent.routing_state))
);
el.agentRows.appendChild(row);
});
el.agentEmpty.hidden = registry.agents.length !== 0;
}
function renderZones() {
el.agentZoneRows.replaceChildren();
registry.zones.forEach(function (zone) {
const row = document.createElement("tr");
const networks = Array.isArray(zone.networks) && zone.networks.length
? zone.networks.join(", ")
: "No networks recorded";
row.append(
cell(zone.site_name),
cell(zone.name),
cell(networks),
cell(text(zone.assigned_agent_id, "Unassigned")),
cell(zone.is_enabled ? "Enabled" : "Disabled")
);
el.agentZoneRows.appendChild(row);
});
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
}
function renderEnrollments() {
el.agentEnrollmentRows.replaceChildren();
enrollments.forEach(function (enrollment) {
const row = document.createElement("tr");
row.append(
cell(enrollment.display_name),
cell(
enrollment.site_name + " / " +
enrollment.agent_zone_name
),
cell(words(enrollment.status)),
cell(dateTime(enrollment.created_at)),
cell(dateTime(enrollment.expires_at)),
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
);
el.agentEnrollmentRows.appendChild(row);
});
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
}
function populateSites() {
if (!el.agentSite) return;
const selected = el.agentSite.value;
el.agentSite.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Choose a site";
el.agentSite.appendChild(placeholder);
registry.sites.forEach(function (site) {
const option = document.createElement("option");
option.value = site.id;
option.textContent = site.name;
el.agentSite.appendChild(option);
});
if ([...el.agentSite.options].some(function (option) {
return option.value === selected;
})) {
el.agentSite.value = selected;
}
populateZones();
}
function populateZones() {
if (!el.agentZone || !el.agentSite) return;
const siteId = el.agentSite.value;
const selected = el.agentZone.value;
el.agentZone.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
if (!siteId) {
placeholder.textContent = "Choose a site first";
el.agentZone.disabled = true;
el.agentZone.appendChild(placeholder);
return;
}
placeholder.textContent = "Choose an Agent Zone";
el.agentZone.appendChild(placeholder);
const available = registry.zones.filter(function (zone) {
return (
zone.site_id === siteId &&
zone.is_enabled === true &&
!zone.assigned_agent_id
);
});
available.forEach(function (zone) {
const option = document.createElement("option");
option.value = zone.id;
option.textContent = zone.name;
el.agentZone.appendChild(option);
});
el.agentZone.disabled = available.length === 0;
if ([...el.agentZone.options].some(function (option) {
return option.value === selected;
})) {
el.agentZone.value = selected;
}
}
function renderSummary() {
el.agentCount.textContent = String(registry.agents.length);
el.agentZoneCount.textContent = String(registry.zones.length);
const waiting = enrollments.filter(function (item) {
return item.status === "waiting";
}).length;
el.agentWaitingEnrollmentCount.textContent = String(waiting);
}
function render() {
renderSummary();
renderAgents();
renderZones();
renderEnrollments();
populateSites();
}
async function refreshData(showLoading) {
if (showLoading !== false) setState("loading");
try {
const results = await Promise.all([
api("/api/agents"),
api("/api/agent-enrollments")
]);
registry = results[0] && results[0].data
? results[0].data
: { agents: [], sites: [], zones: [] };
enrollments = results[1] && Array.isArray(results[1].data)
? results[1].data
: [];
render();
setState("ready");
} catch (error) {
el.agentManagementErrorText.textContent =
error.message || "Agent information could not be loaded.";
setState("error");
}
}
async function createEnrollment(event) {
event.preventDefault();
if (!el.agentCreateForm) return;
el.agentCreateSubmit.disabled = true;
el.agentCreateStatus.textContent = "Creating enrollment...";
try {
const payload = await api("/api/agent-enrollments", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({
site_id: el.agentSite.value,
agent_zone_id: el.agentZone.value,
display_name: el.agentDisplayName.value.trim()
})
});
const enrollment = payload.data;
el.agentEnrollmentToken.textContent =
enrollment.enrollment_token;
el.agentEnrollmentExpires.textContent =
"Expires " + dateTime(enrollment.expires_at);
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = false;
el.agentCreateStatus.textContent = "Enrollment created.";
el.agentDisplayName.value = "";
await refreshData(false);
} catch (error) {
el.agentCreateStatus.textContent =
error.message || "Enrollment could not be created.";
} finally {
el.agentCreateSubmit.disabled = false;
}
}
async function downloadWindowsPackage() {
const token = el.agentEnrollmentToken.textContent;
if (!token) {
el.agentEnrollmentCopyStatus.textContent =
"No enrollment token is available to download.";
return;
}
el.agentEnrollmentDownload.disabled = true;
el.agentEnrollmentCopyStatus.textContent =
"Building Windows Agent package...";
try {
const response = await fetch(
"/api/agent-enrollments/windows-package",
{
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
"Accept": "application/zip",
"Content-Type": "application/json"
},
body: JSON.stringify({
token: token
})
}
);
if (!response.ok) {
const payload = await response.json().catch(function () {
return {};
});
throw new Error(
payload && payload.detail
? String(payload.detail)
: "Package request failed with status " +
response.status + "."
);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = "MinerOpsAgent-Windows.zip";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
dismissToken();
el.agentCreateStatus.textContent =
"Windows Agent package downloaded. " +
"The on-screen token has been cleared.";
} catch (error) {
el.agentEnrollmentCopyStatus.textContent =
error.message || "Windows package could not be downloaded.";
} finally {
el.agentEnrollmentDownload.disabled = false;
}
}
async function copyToken() {
const token = el.agentEnrollmentToken.textContent;
if (!token) return;
try {
await navigator.clipboard.writeText(token);
el.agentEnrollmentCopyStatus.textContent =
"Enrollment token copied.";
} catch (error) {
el.agentEnrollmentCopyStatus.textContent =
"Copy was unavailable. Select and copy the token manually.";
}
}
function dismissToken() {
el.agentEnrollmentToken.textContent = "";
el.agentEnrollmentExpires.textContent = "";
el.agentEnrollmentCopyStatus.textContent = "";
el.agentEnrollmentResult.hidden = true;
}
el.agentManagementRetry.addEventListener("click", function () {
refreshData(true);
});
if (el.agentManagementRefresh) {
el.agentManagementRefresh.addEventListener("click", function () {
refreshData(true);
});
}
if (el.agentSite) {
el.agentSite.addEventListener("change", populateZones);
}
if (el.agentCreateForm) {
el.agentCreateForm.addEventListener("submit", createEnrollment);
}
if (el.agentEnrollmentDownload) {
el.agentEnrollmentDownload.addEventListener(
"click",
downloadWindowsPackage
);
}
if (el.agentEnrollmentCopy) {
el.agentEnrollmentCopy.addEventListener("click", copyToken);
}
if (el.agentEnrollmentDismiss) {
el.agentEnrollmentDismiss.addEventListener(
"click",
dismissToken
);
}
refreshData(true);
}());
+83
View File
@@ -1771,3 +1771,86 @@ button.map-position:hover {
flex-direction: column;
}
}
/* Agent Management v1 */
.agent-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.agent-summary-card {
display: grid;
gap: 6px;
}
.agent-summary-card span {
font-size: 0.85rem;
}
.agent-summary-card strong {
font-size: 2rem;
}
.agent-management-columns {
display: grid;
gap: 20px;
margin-bottom: 20px;
}
.agent-table td:first-child {
display: grid;
gap: 4px;
}
.agent-create-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.agent-create-form .agent-form-actions,
.agent-create-form #agentCreateStatus {
grid-column: 1 / -1;
}
.agent-form-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.agent-enrollment-result {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.agent-token-box {
overflow-wrap: anywhere;
padding: 14px;
margin: 12px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-muted);
}
.agent-token-box code {
user-select: all;
}
@media (max-width: 760px) {
.agent-summary-grid,
.agent-create-form {
grid-template-columns: 1fr;
}
}
/* Agent Management action visibility */
.agent-management-columns .quiet-button,
.agent-enrollment-panel .quiet-button {
color: var(--ink);
}
+10 -1
View File
@@ -6,6 +6,7 @@ from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
from app.frontend.monitoring_trends_view import monitoring_trends_content
from app.frontend.system_health_view import system_health_content
from app.frontend.agent_management_view import agent_management_content
NAVIGATION = (
@@ -15,6 +16,7 @@ NAVIGATION = (
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"),
("system_health", "System Health", "/system-health", "system_health.view"),
("agent_management", "Agents", "/agent/management", "agents.view"),
)
PAGE_DETAILS = {
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
"System Health",
"Inspect application services, operational data, and maintenance facts.",
),
"agent_management": (
"Administration / Agents",
"Agent Management",
"Inspect Agent identities, routing boundaries, and secure enrollment.",
),
}
@@ -462,7 +469,9 @@ def render_app_page(
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = (
system_health_content()
agent_management_content(has_permission("agents.manage"))
if view == "agent_management"
else system_health_content()
if view == "system_health"
else miner_detail_content()
if view == "miner_detail"
+10
View File
@@ -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,14 @@ 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/agents":
return "agents.view"
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 path == "/api/agent-enrollments/windows-package":
return "agents.manage"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
+310 -290
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -40,15 +40,20 @@ class MinerControlRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
SELECT miners.id, miners.site_id, miners.agent_zone_id,
zones.id IS NOT NULL AS agent_zone_exists,
zones.is_enabled AS agent_zone_enabled,
host(miners.current_ip) AS current_ip,
miners.status AS runtime_state,
miners.configured_performance_mode,
miners.api3_supported, miners.api3_api_switch,
agents.agent_id, agents.last_checkin_at,
agents.version AS agent_version, agents.last_payload
FROM miners
LEFT JOIN agent_zones AS zones
ON zones.id = miners.agent_zone_id
LEFT JOIN minerops_site_agents AS agents
ON agents.site_id = miners.site_id
ON agents.agent_zone_id = miners.agent_zone_id
WHERE miners.id = %s
""",
(miner_id,),
+7 -3
View File
@@ -29,10 +29,14 @@ class MinerControlService:
@staticmethod
def _capability(context: dict) -> ControlCapability:
if not context.get("site_id"):
return ControlCapability(state="unavailable", reason="site_unassigned")
if not context.get("agent_zone_id"):
return ControlCapability(state="unavailable", reason="agent_zone_unassigned")
if context.get("agent_zone_exists") is not True:
return ControlCapability(state="unavailable", reason="agent_zone_missing")
if context.get("agent_zone_enabled") is not True:
return ControlCapability(state="unavailable", reason="agent_zone_disabled")
if not context.get("agent_id"):
return ControlCapability(state="unavailable", reason="agent_unassigned")
return ControlCapability(state="unavailable", reason="agent_unavailable")
if not context.get("current_ip"):
return ControlCapability(state="unavailable", reason="current_ip_missing")
if context.get("api3_supported") is False:
+136
View File
@@ -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()
+134 -1
View File
@@ -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)
+157
View File
@@ -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,175 @@
"""Frontend contract tests for Agent Management."""
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.frontend.router import router
from app.identity import get_current_user, required_page_permission
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
class AgentManagementFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-4000-8000-000000009001",
"username": "agent-test-user",
"display_name": "Agent Test User",
"role": "Test Role",
"permissions": tuple(permissions),
}
def test_page_requires_agents_view(self):
self.authenticate(("unrelated.permission",))
self.assertEqual(
self.client.get("/agent/management").status_code,
403,
)
self.authenticate(("agents.view",))
response = self.client.get("/agent/management")
self.assertEqual(response.status_code, 200)
self.assertIn("Agent Management", response.text)
self.assertIn('href="/agent/management"', response.text)
def test_manage_controls_follow_agents_manage_permission(self):
self.authenticate(("agents.view",))
restricted = self.client.get("/agent/management").text
self.assertNotIn('id="agentCreateForm"', restricted)
restricted_text = " ".join(restricted.split())
self.assertIn(
"cannot create Agent enrollments.",
restricted_text,
)
self.authenticate(("agents.view", "agents.manage"))
managed = self.client.get("/agent/management").text
self.assertIn('id="agentCreateForm"', managed)
self.assertIn('id="agentSite"', managed)
self.assertIn('id="agentZone"', managed)
self.assertIn('id="agentDisplayName"', managed)
self.assertIn("Linux x64 — coming later", managed)
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
def test_route_and_asset_contract(self):
self.assertEqual(
required_page_permission("/agent/management"),
"agents.view",
)
self.authenticate(("agents.view",))
page = self.client.get("/agent/management")
self.assertIn(
"/assets/minerops-agent-management.js",
page.text,
)
asset = self.client.get(
"/assets/minerops-agent-management.js"
)
self.assertEqual(asset.status_code, 200)
self.assertEqual(
asset.headers.get("cache-control"),
"no-store",
)
def test_javascript_is_secret_conscious_and_safe_dom_only(self):
source = (
STATIC_ROOT / "agent-management.js"
).read_text(encoding="utf-8")
self.assertIn('api("/api/agents")', source)
self.assertIn('api("/api/agent-enrollments")', source)
self.assertIn(
'api("/api/agent-enrollments", {',
source,
)
for unsafe in (
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"localStorage",
"sessionStorage",
"console.log",
):
self.assertNotIn(unsafe, source)
self.assertIn(
"el.agentEnrollmentToken.textContent",
source,
)
self.assertIn(
"el.agentEnrollmentToken.textContent = \"\"",
source,
)
def test_agent_management_quiet_actions_have_visible_text(self):
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(
".agent-management-columns .quiet-button,",
css,
)
self.assertIn(
".agent-enrollment-panel .quiet-button {",
css,
)
self.assertIn(
"color: var(--ink);",
css,
)
def test_windows_package_download_is_ephemeral(self):
self.authenticate(("agents.view", "agents.manage"))
page = self.client.get("/agent/management").text
self.assertIn('id="agentEnrollmentDownload"', page)
self.assertIn("Download Windows package", page)
source = (
STATIC_ROOT / "agent-management.js"
).read_text(encoding="utf-8")
self.assertIn(
'"/api/agent-enrollments/windows-package"',
source,
)
self.assertIn(
'link.download = "MinerOpsAgent-Windows.zip"',
source,
)
self.assertIn(
"window.URL.revokeObjectURL(downloadUrl)",
source,
)
self.assertIn("dismissToken();", source)
for forbidden in (
"localStorage",
"sessionStorage",
"agent_secret",
"api3_password",
):
self.assertNotIn(forbidden, source)
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Focused tests for the Agent Management read model."""
import unittest
from uuid import UUID
from app.agent_registry.service import AgentRegistryService
from app.identity import required_api_permission
SITE_ID = UUID("00000000-0000-4000-8000-000000001800")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001801")
class FakeAgentRegistryRepository:
def list_registry(self):
return {
"sites": [
{
"id": SITE_ID,
"name": "Test Site",
}
],
"zones": [
{
"id": ZONE_ID,
"site_id": SITE_ID,
"site_name": "Test Site",
"name": "Network A",
"description": None,
"is_enabled": True,
"assigned_agent_id": "agent-test",
"networks": ["10.50.0.0/24"],
}
],
"agents": [
{
"agent_id": "agent-test",
"display_name": "Test Agent",
"site_id": SITE_ID,
"site_name": "Test Site",
"agent_zone_id": ZONE_ID,
"agent_zone_name": "Network A",
"agent_zone_enabled": True,
"hostname": "test-agent",
"version": "1.0.0-alpha",
"ip_address": "10.50.0.10",
"last_checkin_at": "2026-08-26T20:00:00+00:00",
"credential_state": "active",
"credential_created_at": "2026-08-26T19:00:00+00:00",
"credential_revoked_at": None,
}
],
}
class AgentRegistryServiceTests(unittest.TestCase):
def test_projection_is_factual_and_zone_aware(self):
data = AgentRegistryService(
FakeAgentRegistryRepository()
).list_registry()
self.assertEqual(len(data.agents), 1)
self.assertEqual(len(data.sites), 1)
self.assertEqual(len(data.zones), 1)
agent = data.agents[0]
self.assertEqual(agent.agent_id, "agent-test")
self.assertEqual(agent.credential_state, "active")
self.assertEqual(agent.routing_state, "assigned")
self.assertEqual(
data.zones[0].networks,
["10.50.0.0/24"],
)
def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self):
repository = FakeAgentRegistryRepository()
repository.list_registry = lambda: {
"sites": [],
"zones": [],
"agents": [
{
"agent_id": "legacy",
"display_name": None,
"site_id": None,
"site_name": "Legacy Site",
"agent_zone_id": None,
"agent_zone_name": None,
"agent_zone_enabled": None,
"hostname": None,
"version": None,
"ip_address": None,
"last_checkin_at": "2026-08-26T20:00:00+00:00",
"credential_state": "legacy",
"credential_created_at": None,
"credential_revoked_at": None,
}
],
}
data = AgentRegistryService(repository).list_registry()
self.assertEqual(
data.agents[0].routing_state,
"unassigned",
)
self.assertEqual(
data.agents[0].credential_state,
"legacy",
)
def test_api_permission_mapping_is_explicit(self):
self.assertEqual(
required_api_permission("GET", "/api/agents"),
"agents.view",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,194 @@
"""PostgreSQL integration test for the Agent registry projection."""
import unittest
from uuid import uuid4
from app.agent_registry.repository import (
AgentRegistryRepository,
default_connection_factory,
)
from app.agent_registry.service import AgentRegistryService
class AgentRegistryPostgresTests(unittest.TestCase):
def setUp(self):
self.site_id = uuid4()
self.zone_id = uuid4()
self.agent_id = f"agent-registry-{uuid4().hex}"
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES (%s, %s)
""",
(
self.site_id,
"Registry Integration Site",
),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name,
description,
is_enabled
)
VALUES (%s, %s, %s, %s, true)
""",
(
self.zone_id,
self.site_id,
"Registry Network",
"Integration-test network",
),
)
cur.execute(
"""
INSERT INTO agent_zone_network_ranges (
agent_zone_id,
network
)
VALUES (%s, '10.251.0.0/24'::cidr)
""",
(self.zone_id,),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_name,
site_id,
agent_zone_id,
display_name,
hostname,
version,
ip_address,
agent_secret_hash,
credential_created_at
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
now()
)
""",
(
self.agent_id,
"Registry Integration Site",
self.site_id,
self.zone_id,
"Registry Test Agent",
"registry-agent",
"1.0.0-alpha",
"10.251.0.10",
"a" * 64,
),
)
connection.commit()
finally:
connection.close()
def tearDown(self):
connection = default_connection_factory()
try:
with connection.cursor() as cur:
cur.execute(
"""
DELETE FROM minerops_site_agents
WHERE agent_id = %s
""",
(self.agent_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 test_registry_joins_site_zone_network_and_redacts_secret_hash(self):
data = AgentRegistryService(
AgentRegistryRepository()
).list_registry()
agents = [
item
for item in data.agents
if item.agent_id == self.agent_id
]
self.assertEqual(len(agents), 1)
agent = agents[0]
self.assertEqual(
agent.site_name,
"Registry Integration Site",
)
self.assertEqual(
agent.agent_zone_name,
"Registry Network",
)
self.assertEqual(agent.credential_state, "active")
self.assertEqual(agent.routing_state, "assigned")
zones = [
zone
for zone in data.zones
if zone.id == self.zone_id
]
self.assertEqual(len(zones), 1)
self.assertEqual(
zones[0].networks,
["10.251.0.0/24"],
)
serialized = data.model_dump_json()
self.assertNotIn("agent_secret_hash", serialized)
self.assertNotIn("a" * 64, serialized)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,633 @@
"""Runtime PostgreSQL isolation tests for Agent-owned machine transport."""
import asyncio
import json
import os
import unittest
from uuid import UUID
import psycopg
from fastapi import HTTPException
from starlette.requests import Request
from app.agent_zones.repository import AgentZoneRepository
from app.agent_zones.service import AgentZoneService
from app.config import settings
from app.main import (
minerops_agent_api3_enrichment,
minerops_agent_miner_enrichment_v2,
minerops_agent_miner_enrichment_v3,
)
SITE_A = UUID("00000000-0000-4000-8000-000000001710")
SITE_B = UUID("00000000-0000-4000-8000-000000001711")
ZONE_A = UUID("00000000-0000-4000-8000-000000001712")
ZONE_B = UUID("00000000-0000-4000-8000-000000001713")
MINER_A = UUID("00000000-0000-4000-8000-000000001714")
MINER_B = UUID("00000000-0000-4000-8000-000000001715")
AGENT_A = "agent-transport-isolation-a"
AGENT_B = "agent-transport-isolation-b"
IP_A = "198.51.100.171"
IP_B = "198.51.100.172"
MAC_A = "02:00:00:00:17:A1"
MAC_B = "02:00:00:00:17:B1"
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,
)
def json_request(path, payload):
body = json.dumps(payload).encode("utf-8")
delivered = False
async def receive():
nonlocal delivered
if delivered:
return {
"type": "http.request",
"body": b"",
"more_body": False,
}
delivered = True
return {
"type": "http.request",
"body": body,
"more_body": False,
}
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": b"",
"headers": [(b"content-type", b"application/json")],
"client": ("testclient", 12345),
"server": ("testserver", 80),
}
return Request(scope, receive)
class AgentTransportIsolationPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get(
"MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE"
) != "1":
raise unittest.SkipTest(
"Runtime Agent transport isolation is executed "
"by its dedicated disposable PostgreSQL runner."
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO sites (id, name)
VALUES
(%s, 'Transport Isolation Site A'),
(%s, 'Transport Isolation Site B')
ON CONFLICT (id) DO NOTHING
""",
(SITE_A, SITE_B),
)
cur.execute(
"""
INSERT INTO agent_zones (
id,
site_id,
name,
is_enabled
)
VALUES
(
%s,
%s,
'Transport Isolation Zone A',
true
),
(
%s,
%s,
'Transport Isolation Zone B',
true
)
ON CONFLICT (id) DO UPDATE
SET is_enabled = true
""",
(ZONE_A, SITE_A, ZONE_B, SITE_B),
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id,
site_id,
agent_zone_id,
site_name,
last_payload
)
VALUES
(
%s,
%s,
%s,
'Transport Isolation Site A',
'{}'::jsonb
),
(
%s,
%s,
%s,
'Transport Isolation Site B',
'{}'::jsonb
)
ON CONFLICT (agent_id) DO UPDATE
SET
site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
site_name = EXCLUDED.site_name,
last_payload = EXCLUDED.last_payload
""",
(
AGENT_A,
SITE_A,
ZONE_A,
AGENT_B,
SITE_B,
ZONE_B,
),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
# Immutable identity/history rows intentionally are not deleted.
# The disposable Compose runner destroys the database volume.
pass
@staticmethod
def _delete_optional_rows(cur, table, predicate, params):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
if cur.fetchone()[0] is not None:
cur.execute(
f"DELETE FROM {table} WHERE {predicate}",
params,
)
def setUp(self):
self.zone_service = AgentZoneService(
AgentZoneRepository(connect)
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miners (
id,
site_id,
agent_zone_id,
asset_tag,
current_mac,
current_ip,
hostname,
miner_sn,
model,
miner_type,
api_version,
firmware_version,
platform,
status,
notes,
api3_supported
)
VALUES
(
%s,
%s,
%s,
'TRANSPORT-ZONE-A',
%s,
%s::inet,
'zone-a-before',
'ZONE-A-SERIAL',
'Zone A Model',
'Zone A Model',
'1.0-a',
'fw-a',
'platform-a',
'online',
'Zone A baseline',
false
),
(
%s,
%s,
%s,
'TRANSPORT-ZONE-B',
%s,
%s::inet,
'zone-b-before',
'ZONE-B-SERIAL',
'Zone B Model',
'Zone B Model',
'1.0-b',
'fw-b',
'platform-b',
'online',
'Zone B baseline',
false
)
ON CONFLICT (id) DO NOTHING
""",
(
MINER_A,
SITE_A,
ZONE_A,
MAC_A,
IP_A,
MINER_B,
SITE_B,
ZONE_B,
MAC_B,
IP_B,
),
)
connection.commit()
finally:
connection.close()
def _table_exists(self, cur, table):
cur.execute(
"SELECT to_regclass(%s)",
(f"public.{table}",),
)
return cur.fetchone()[0] is not None
def _snapshot_rows(self, cur, table):
cur.execute(
f"""
SELECT to_jsonb(t)
FROM {table} AS t
WHERE miner_id IN (%s, %s)
ORDER BY to_jsonb(t)::text
""",
(MINER_A, MINER_B),
)
return [row[0] for row in cur.fetchall()]
def _count_optional_miner_rows(self, cur, table, miner_id):
if not self._table_exists(cur, table):
return 0
cur.execute(
f"""
SELECT COUNT(*)
FROM {table}
WHERE miner_id = %s
""",
(miner_id,),
)
return cur.fetchone()[0]
def _capture_inventory_state(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
SELECT id::text, to_jsonb(m)
FROM miners AS m
WHERE id IN (%s, %s)
ORDER BY id
""",
(MINER_A, MINER_B),
)
miners = {
row[0]: row[1]
for row in cur.fetchall()
}
ownerships = self._snapshot_rows(
cur,
"miner_mac_ownerships",
)
identities = self._snapshot_rows(
cur,
"miner_network_identities",
)
counts = {}
for table in (
"miner_status_snapshots",
"miner_api3_enrichment_snapshots",
"miner_api3_hashboard_snapshots",
"miner_pool_snapshots",
):
counts[(table, "A")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_A,
)
)
counts[(table, "B")] = (
self._count_optional_miner_rows(
cur,
table,
MINER_B,
)
)
return {
"miners": miners,
"mac_ownerships": ownerships,
"network_identities": identities,
"snapshot_counts": counts,
}
finally:
connection.close()
def _cross_zone_api3_probe_count(self):
connection = connect()
try:
with connection.cursor() as cur:
if not self._table_exists(
cur,
"miner_api3_enrichment_snapshots",
):
return 0
cur.execute(
"""
SELECT COUNT(*)
FROM miner_api3_enrichment_snapshots
WHERE agent_id = %s
AND (
host(ip_address) = %s
OR upper(mac_address) = upper(%s)
)
""",
(AGENT_A, IP_B, MAC_B),
)
return cur.fetchone()[0]
finally:
connection.close()
def test_v2_cannot_address_other_zone_miner_directly(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_B),
"mac_address": MAC_B,
"hostname": "zone-a-must-not-write-this",
"model": "cross-zone-v2-probe",
},
)
with self.assertRaises(HTTPException) as context:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
self.assertEqual(context.exception.status_code, 404)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v2",
{
"miner_id": str(MINER_A),
"ip_address": IP_A,
"mac_address": MAC_B,
"hostname": "cross-zone-merge-probe",
"miner_sn": "MUST-NOT-CROSS-ZONES",
"model": "Cross Zone Merge Probe",
},
)
error = None
try:
asyncio.run(
minerops_agent_miner_enrichment_v2(
request,
AGENT_A,
self.zone_service,
)
)
except Exception as exc:
error = exc
self.assertIsNotNone(
error,
"A Zone A miner must not successfully claim a Zone B MAC",
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_v3_cannot_match_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
request = json_request(
"/api/agent/miner-enrichment-v3",
{
"miner_id": str(MINER_B),
"ip_address": IP_B,
"mac_address": MAC_B,
"hostname": "zone-a-v3-cross-zone-probe",
"miner_sn": "V3-MUST-NOT-MUTATE",
"model": "V3 Cross Zone Probe",
"api_version": "99.99",
"firmware_version": "cross-zone",
"platform": "cross-zone",
"raw_version": {"cross_zone": True},
"raw_miner_info": {"cross_zone": True},
},
)
result = asyncio.run(
minerops_agent_miner_enrichment_v3(
request,
AGENT_A,
self.zone_service,
)
)
self.assertFalse(result["matched"])
after = self._capture_inventory_state()
self.assertEqual(after, before)
def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self):
before = self._capture_inventory_state()
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
)
request = json_request(
"/api/agent/api3-enrichment",
{
"agent_id": AGENT_B,
"site_name": "spoofed-zone-b",
"items": [
{
"ip": IP_B,
"mac": MAC_B,
"api3_supported": True,
"api3_error": None,
"device_info": {
"msg": {
"network": {
"ip": IP_B,
"mac": MAC_B,
},
"miner": {
"type": "Cross Zone API3 Probe",
"miner-sn": "API3-MUST-NOT-MUTATE",
},
"system": {
"api": "99.99",
"apiswitch": "on",
"fwversion": "cross-zone-fw",
"platform": "cross-zone-platform",
},
"power": {
"sn": "cross-zone-power",
"model": "cross-zone-psu",
},
}
},
"miner_setting": {
"msg": {
"power-mode": "high",
}
},
"miner_status": {
"msg": {
"summary": {
"hash-average": 999,
"power-realtime": 9999,
"power-rate": 99,
"chip-temp-avg": 99,
"chip-temp-max": 100,
},
"pools": [
{
"id": 0,
"status": "Alive",
"account": "cross-zone-worker",
"url": "stratum+tcp://invalid.example:3333",
}
],
"edevs": [
{
"slot": 0,
"hash-average": 999,
"factory-hash": 999,
"freq": 999,
"effective-chips": 999,
}
],
}
},
"fan_setting": {
"msg": {
"mode": "cross-zone",
}
},
"custom_data": {
"msg": {
"CustomerSn": "CROSS-ZONE-CUSTOMER",
}
},
}
],
},
)
asyncio.run(
minerops_agent_api3_enrichment(
request,
AGENT_A,
self.zone_service,
)
)
after = self._capture_inventory_state()
self.assertEqual(after, before)
self.assertEqual(
self._cross_zone_api3_probe_count(),
0,
(
"Agent A must not persist API3 enrichment associated "
"with Zone B identity data"
),
)
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""Tests for downloadable Windows Agent enrollment packages."""
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_enrollment.router import router
from app.agent_packages.windows import (
AgentWindowsPackageError,
AgentWindowsPackageSourceError,
WINDOWS_PACKAGE_FILES,
WINDOWS_PACKAGE_ROOT,
build_windows_agent_package,
normalize_hub_url,
)
from app.identity import get_current_user, required_api_permission
TOKEN = "t" * 43
class WindowsPackageBuilderTests(unittest.TestCase):
def source_tree(self, root: Path):
for name in WINDOWS_PACKAGE_FILES:
(root / name).write_text(f"source:{name}\n")
def test_package_contains_only_generic_source_plus_bootstrap_material(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
package = build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
source_root=root,
)
with zipfile.ZipFile(io.BytesIO(package)) as archive:
names = set(archive.namelist())
expected = {
f"{WINDOWS_PACKAGE_ROOT}/{name}"
for name in WINDOWS_PACKAGE_FILES
}
expected.update(
{
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
}
)
self.assertEqual(names, expected)
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
self.assertEqual(enrollment["token"], TOKEN)
self.assertEqual(
enrollment["hub_url"],
"https://minerops.example.test",
)
serialized = json.dumps(enrollment)
self.assertNotIn("agent_secret", serialized)
self.assertNotIn("api3_password", serialized)
self.assertNotIn("api3_account", serialized)
self.assertNotIn("agent-config.json", "\n".join(names))
def test_missing_canonical_source_fails_closed(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.source_tree(root)
(root / WINDOWS_PACKAGE_FILES[0]).unlink()
with self.assertRaises(AgentWindowsPackageSourceError):
build_windows_agent_package(
enrollment_token=TOKEN,
hub_url="https://minerops.example.test",
source_root=root,
)
def test_hub_url_is_origin_only(self):
self.assertEqual(
normalize_hub_url("https://minerops.example.test/"),
"https://minerops.example.test",
)
for invalid in (
"ftp://minerops.example.test",
"https://user:pass@minerops.example.test",
"https://minerops.example.test/path",
"https://minerops.example.test/?token=x",
):
with self.subTest(value=invalid):
with self.assertRaises(AgentWindowsPackageError):
normalize_hub_url(invalid)
class WindowsPackageRouteTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-4000-8000-000000009100",
"username": "package-test",
"display_name": "Package Test",
"role": "Test",
"permissions": tuple(permissions),
}
def test_permission_mapping_is_manage_only(self):
self.assertEqual(
required_api_permission(
"POST",
"/api/agent-enrollments/windows-package",
),
"agents.manage",
)
def test_view_only_user_cannot_download_package(self):
self.authenticate(("agents.view",))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 403)
def test_manager_download_is_same_origin_and_not_cacheable(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
headers={"Origin": "http://testserver"},
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers["content-type"],
"application/zip",
)
self.assertEqual(
response.headers["cache-control"],
"no-store",
)
self.assertIn(
'filename="MinerOpsAgent-Windows.zip"',
response.headers["content-disposition"],
)
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
enrollment = json.loads(
archive.read(
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
)
)
self.assertEqual(enrollment["token"], TOKEN)
def test_package_requires_browser_origin(self):
self.authenticate(("agents.view", "agents.manage"))
response = self.client.post(
"/api/agent-enrollments/windows-package",
json={
"token": TOKEN,
},
)
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()
+283
View File
@@ -0,0 +1,283 @@
"""Focused model, service, router, and architecture tests for Agent Zones v1."""
import unittest
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_commands.dependencies import require_agent_identity
from app.agent_zones.dependencies import get_agent_zone_service
from app.agent_zones.exceptions import AgentZoneValidationError
from app.agent_zones.models import AgentCheckinRequest, AgentZoneCreateRequest
from app.agent_zones.router import router
from app.agent_zones.service import AgentZoneService
from app.identity import get_current_user, required_api_permission
SITE_A = UUID("00000000-0000-4000-8000-000000001601")
SITE_B = UUID("00000000-0000-4000-8000-000000001602")
ZONE_A = UUID("00000000-0000-4000-8000-000000001611")
ZONE_B = UUID("00000000-0000-4000-8000-000000001612")
MINER_A = UUID("00000000-0000-4000-8000-000000001621")
APP_ROOT = Path(__file__).parents[1] / "app"
def zone(zone_id=ZONE_A, site_id=SITE_A, *, enabled=True, name="Main Room"):
return {
"id": zone_id,
"site_id": site_id,
"name": name,
"description": None,
"is_enabled": enabled,
"assigned_agent_id": None,
"networks": [],
}
class FakeRepository:
def __init__(self):
self.zones = {ZONE_A: zone()}
self.agent = None
self.saved = []
self.assign_result = {MINER_A: "assigned"}
def get_agent(self, agent_id):
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
def get_zone(self, zone_id):
return self.zones.get(zone_id)
def list_usable_zones(self, site_id):
return [
value for value in self.zones.values()
if value["site_id"] == site_id and value["is_enabled"]
]
def list_zones(self, site_id=None):
return [
value for value in self.zones.values()
if site_id is None or value["site_id"] == site_id
]
def create_zone(self, *, site_id, name, description, networks):
created_id = uuid4()
self.zones[created_id] = {
**zone(created_id, site_id, name=name),
"description": description,
"networks": [
{"id": uuid4(), "network": network} for network in networks
],
}
return created_id
def save_checkin(self, **kwargs):
self.saved.append(kwargs)
self.agent = {
"agent_id": kwargs["agent_id"],
"site_id": kwargs["site_id"],
"agent_zone_id": kwargs["agent_zone_id"],
"site_name": kwargs["site_name"],
}
def get_agent_assignment(self, agent_id):
if not self.agent or self.agent["agent_id"] != agent_id:
return None
zone_row = self.zones.get(self.agent.get("agent_zone_id"))
return {
**self.agent,
"zone_enabled": zone_row["is_enabled"] if zone_row else None,
}
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(miner_ids), agent_zone_id)
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
for miner_id in miner_ids}
class AgentZoneModelTests(unittest.TestCase):
def test_names_and_networks_are_normalized(self):
request = AgentZoneCreateRequest(
site_id=SITE_A,
name=" Main Room ",
description=" Local VLAN ",
networks=["10.30.0.0/24", "2001:db8::/64"],
)
self.assertEqual(request.name, "Main Room")
self.assertEqual(request.description, "Local VLAN")
self.assertEqual(request.networks, ["10.30.0.0/24", "2001:db8::/64"])
def test_invalid_host_network_duplicate_and_extra_fields_are_rejected(self):
for networks in (["10.30.0.7/24"], ["10.30.0.0/24", "10.30.0.0/24"]):
with self.subTest(networks=networks), self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", networks=networks)
with self.assertRaises(Exception):
AgentZoneCreateRequest(site_id=SITE_A, name="Zone", unknown=True)
class AgentZoneServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
def checkin(self, **kwargs):
return self.service.checkin(
"agent-a",
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
)
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.assertTrue(first.routing_available)
self.assertEqual(second.agent_zone_id, ZONE_A)
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-b", site_id=SITE_A)
)
def test_established_agent_cannot_change_zone_within_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_A, name="Room 2")
with self.assertRaisesRegex(AgentZoneValidationError, "different Agent Zone"):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_established_agent_site_conflict_precedes_zone_conflict(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.zones[ZONE_B] = zone(ZONE_B, SITE_B, name="Remote")
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_B)
self.assertEqual(self.repository.agent["agent_zone_id"], ZONE_A)
def test_omitted_zone_preserves_established_assignment_and_site(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a", site_name="North")
)
self.assertEqual(result.site_id, SITE_A)
self.assertEqual(result.agent_zone_id, ZONE_A)
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
single = self.checkin(site_id=SITE_A)
self.assertEqual(single.agent_zone_id, ZONE_A)
self.repository.agent = None
self.repository.zones = {}
zero = self.checkin(site_id=SITE_A)
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
self.repository.agent = None
self.repository.zones = {
ZONE_A: zone(),
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
}
many = self.checkin(site_id=SITE_A)
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
self.assertIsNone(many.agent_zone_id)
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
self.repository.zones[ZONE_A]["is_enabled"] = False
unassigned = self.checkin(site_id=SITE_A)
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
self.repository.agent = {
"agent_id": "agent-a", "site_id": SITE_A,
"agent_zone_id": ZONE_A, "site_name": "North",
}
existing = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a")
)
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
self.assertEqual(existing.agent_zone_id, ZONE_A)
def test_payload_is_sanitized_before_persistence(self):
request = AgentCheckinRequest.model_validate({
"agent_id": "agent-a", "site_id": str(SITE_A),
"api3_password": "secret", "nested": {"access_token": "secret"},
"control_available": True,
})
self.service.checkin("agent-a", request)
payload = self.repository.saved[-1]["payload"]
self.assertNotIn("password", str(payload).lower())
self.assertNotIn("token", str(payload).lower())
self.assertTrue(payload["control_available"])
def test_miner_zone_assignment_surfaces_conflicts_without_rehoming(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.assign_result[MINER_A] = "agent_zone_conflict"
result = self.service.associate_miners_from_agent("agent-a", [MINER_A])
self.assertEqual(result.outcomes[MINER_A], "agent_zone_conflict")
self.assertEqual(self.repository.assigned, ([MINER_A], ZONE_A))
def test_routing_context_rejects_stale_recorded_zone(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.routing_context("agent-a", ZONE_B)
self.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertIsNone(result.site_id)
class AgentZoneRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.service = AgentZoneService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_agent_zone_service] = lambda: self.service
self.app.dependency_overrides[require_agent_identity] = lambda: "agent-a"
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "username": "operator", "role": "Custom",
"permissions": list(permissions),
}
def test_list_and_create_use_existing_agent_permissions(self):
self.assertEqual(self.client.get("/api/agent-zones").status_code, 401)
self.authenticate(("agents.view",))
self.assertEqual(self.client.get("/api/agent-zones").status_code, 200)
self.assertEqual(
self.client.post(
"/api/agent-zones", json={"site_id": str(SITE_A), "name": "Room 2"}
).status_code,
403,
)
self.authenticate(("agents.manage",))
response = self.client.post(
"/api/agent-zones",
json={"site_id": str(SITE_A), "name": "Room 2", "networks": ["10.40.0.0/24"]},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["name"], "Room 2")
def test_checkin_uses_authenticated_agent_identity(self):
response = self.client.post(
"/api/agent/checkin",
json={"agent_id": "agent-a", "site_id": str(SITE_A),
"agent_zone_id": str(ZONE_A)},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["agent_id"], "agent-a")
self.assertNotIn("secret", response.text.lower())
def test_permission_and_architecture_audits(self):
self.assertEqual(required_api_permission("GET", "/api/agent-zones"), "agents.view")
self.assertEqual(required_api_permission("POST", "/api/agent-zones"), "agents.manage")
router_source = (APP_ROOT / "agent_zones" / "router.py").read_text()
service_source = (APP_ROOT / "agent_zones" / "service.py").read_text()
repository_source = (APP_ROOT / "agent_zones" / "repository.py").read_text()
self.assertNotIn("psycopg", router_source)
self.assertNotIn("SELECT ", router_source)
self.assertNotIn("fastapi", service_source.lower())
self.assertNotIn("fastapi", repository_source.lower())
self.assertNotIn('@app.post("/api/agent/checkin")', (APP_ROOT / "main.py").read_text())
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
"""Real PostgreSQL integrity and routing tests for Agent Zones v1."""
import unittest
from uuid import UUID
import psycopg
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from app.agent_zones.repository import AgentZoneRepository
from app.config import settings
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
SITE_A = UUID("00000000-0000-4000-8000-000000001630")
SITE_B = UUID("00000000-0000-4000-8000-000000001631")
ZONE_A = UUID("00000000-0000-4000-8000-000000001632")
ZONE_B = UUID("00000000-0000-4000-8000-000000001633")
ZONE_C = UUID("00000000-0000-4000-8000-000000001636")
MINER_UNASSIGNED = UUID("00000000-0000-4000-8000-000000001634")
MINER_OTHER = UUID("00000000-0000-4000-8000-000000001635")
AGENT_A = "agent-zone-postgres-a"
def connect():
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class AgentZonePostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, 'Zone Test A'), "
"(%s, 'Zone Test B') ON CONFLICT (id) DO NOTHING",
(SITE_A, SITE_B),
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) VALUES "
"(%s, %s, 'Main Room'), (%s, %s, 'Remote Room'), "
"(%s, %s, 'Unstaffed Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_A, SITE_A, ZONE_B, SITE_B, ZONE_C, SITE_A),
)
cur.execute(
"INSERT INTO miners (id, site_id, asset_tag, current_ip, status, "
"api3_supported, first_seen_at, last_seen_at) VALUES "
"(%s, %s, 'ZONE-UNASSIGNED', '192.0.2.31', 'online', true, NULL, NULL), "
"(%s, %s, 'ZONE-OTHER', '192.0.2.32', 'online', true, NULL, NULL) "
"ON CONFLICT (id) DO UPDATE SET agent_zone_id = NULL",
(MINER_UNASSIGNED, SITE_A, MINER_OTHER, SITE_B),
)
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_B, MINER_OTHER),
)
cur.execute(
"INSERT INTO minerops_site_agents ("
"agent_id, site_id, agent_zone_id, site_name, last_payload) "
"VALUES (%s, %s, %s, 'Zone Test A', '{\"control_available\":true}') "
"ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id, "
"agent_zone_id = EXCLUDED.agent_zone_id, last_payload = EXCLUDED.last_payload",
(AGENT_A, SITE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id LIKE 'agent-zone-postgres-%'")
cur.execute("DELETE FROM miners WHERE id IN (%s, %s)", (MINER_UNASSIGNED, MINER_OTHER))
cur.execute("DELETE FROM agent_zone_network_ranges WHERE agent_zone_id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM agent_zones WHERE id IN (%s, %s, %s)", (ZONE_A, ZONE_B, ZONE_C))
cur.execute("DELETE FROM sites WHERE id IN (%s, %s)", (SITE_A, SITE_B))
connection.commit()
finally:
connection.close()
def setUp(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE agent_zones SET is_enabled = true "
"WHERE id IN (%s, %s, %s)",
(ZONE_A, ZONE_B, ZONE_C),
)
cur.execute(
"UPDATE miners SET agent_zone_id = NULL WHERE id = %s",
(MINER_UNASSIGNED,),
)
cur.execute(
"UPDATE miners SET site_id = %s, agent_zone_id = %s WHERE id = %s",
(SITE_B, ZONE_B, MINER_OTHER),
)
cur.execute(
"UPDATE minerops_site_agents SET site_id = %s, agent_zone_id = %s "
"WHERE agent_id = %s",
(SITE_A, ZONE_A, AGENT_A),
)
connection.commit()
finally:
connection.close()
def test_repository_lists_multiple_canonical_networks(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zone_network_ranges (agent_zone_id, network) "
"VALUES (%s, '10.30.0.0/24'), (%s, '10.31.0.0/24') "
"ON CONFLICT DO NOTHING",
(ZONE_A, ZONE_A),
)
connection.commit()
finally:
connection.close()
listed = AgentZoneRepository(connect).list_zones(SITE_A)
self.assertEqual([item["network"] for item in listed[0]["networks"]],
["10.30.0.0/24", "10.31.0.0/24"])
def test_active_names_are_case_insensitively_unique_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO agent_zones (site_id, name) VALUES (%s, 'main room')",
(SITE_A,),
)
finally:
connection.rollback()
connection.close()
def test_one_agent_per_zone_but_multiple_zones_per_site(self):
connection = connect()
try:
with self.assertRaises(UniqueViolation), connection.cursor() as cur:
cur.execute(
"INSERT INTO minerops_site_agents "
"(agent_id, site_id, agent_zone_id, site_name) "
"VALUES ('agent-zone-postgres-duplicate', %s, %s, 'Duplicate')",
(SITE_A, ZONE_A),
)
finally:
connection.rollback()
connection.close()
def test_composite_foreign_keys_reject_cross_site_assignment(self):
connection = connect()
try:
with self.assertRaises(ForeignKeyViolation), connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_OTHER),
)
finally:
connection.rollback()
connection.close()
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
repository = AgentZoneRepository(connect)
outcomes = repository.assign_miners_to_zone(
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
)
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT id, agent_zone_id FROM miners WHERE id IN (%s, %s)",
(MINER_UNASSIGNED, MINER_OTHER),
)
assignments = dict(cur.fetchall())
finally:
connection.close()
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
def test_disabled_zone_blocks_control_without_site_fallback(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_A, MINER_UNASSIGNED),
)
cur.execute("UPDATE agent_zones SET is_enabled = false WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
try:
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_zone_disabled")
finally:
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("UPDATE agent_zones SET is_enabled = true WHERE id = %s", (ZONE_A,))
connection.commit()
finally:
connection.close()
def test_enabled_zone_without_agent_does_not_fall_back_within_site(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET agent_zone_id = %s WHERE id = %s",
(ZONE_C, MINER_UNASSIGNED),
)
connection.commit()
finally:
connection.close()
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_unavailable")
def test_null_zone_does_not_fall_back_to_site_agent(self):
projection = MinerControlService(MinerControlRepository(connect)).get_controls(
MINER_UNASSIGNED
)
self.assertEqual(projection.service_control.reason, "agent_zone_unassigned")
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -26,6 +26,9 @@ def context(**overrides):
result = {
"id": MINER_ID,
"site_id": uuid4(),
"agent_zone_id": uuid4(),
"agent_zone_exists": True,
"agent_zone_enabled": True,
"current_ip": "192.0.2.10",
"runtime_state": "online",
"configured_performance_mode": "normal",
@@ -78,8 +81,10 @@ class MinerControlServiceTests(unittest.TestCase):
def test_capability_missing_facts_is_not_optimistic(self):
cases = (
({"site_id": None}, "unavailable", "site_unassigned"),
({"agent_id": None}, "unavailable", "agent_unassigned"),
({"agent_zone_id": None}, "unavailable", "agent_zone_unassigned"),
({"agent_zone_exists": False}, "unavailable", "agent_zone_missing"),
({"agent_zone_enabled": False}, "unavailable", "agent_zone_disabled"),
({"agent_id": None}, "unavailable", "agent_unavailable"),
({"current_ip": None}, "unavailable", "current_ip_missing"),
({"api3_supported": False}, "unavailable", "api3_unsupported"),
({"api3_supported": None}, "unknown", "api3_support_unknown"),
+16 -6
View File
@@ -18,6 +18,7 @@ from app.miner_control.service import MinerControlService
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
ZONE_ID = UUID("00000000-0000-4000-8000-000000001514")
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
@@ -54,6 +55,12 @@ class MinerControlPostgresTests(unittest.TestCase):
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
)
cur.execute(
"INSERT INTO agent_zones (id, site_id, name) "
"VALUES (%s, %s, 'Control Test Zone') "
"ON CONFLICT (id) DO UPDATE SET is_enabled = true",
(ZONE_ID, SITE_ID),
)
cur.execute(
"""
INSERT INTO users (id, username, display_name, password_hash, role_id)
@@ -64,23 +71,25 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute(
"""
INSERT INTO miners (
id, site_id, asset_tag, current_ip, status, api3_supported,
id, site_id, agent_zone_id, asset_tag, current_ip, status, api3_supported,
configured_performance_mode, first_seen_at, last_seen_at
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
) VALUES (%s, %s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
'normal', NULL, NULL)
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
api3_supported = true
""", (MINER_ID, SITE_ID)
""", (MINER_ID, SITE_ID, ZONE_ID)
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, site_name, last_payload
) VALUES (%s, %s, 'Control Test Site', %s)
agent_id, site_id, agent_zone_id, site_name, last_payload
) VALUES (%s, %s, %s, 'Control Test Site', %s)
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
agent_zone_id = EXCLUDED.agent_zone_id,
last_payload = EXCLUDED.last_payload
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
""", (AGENT_ID, SITE_ID, ZONE_ID, Jsonb({"control_available": True}))
)
connection.commit()
finally:
@@ -100,6 +109,7 @@ class MinerControlPostgresTests(unittest.TestCase):
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
cur.execute("DELETE FROM agent_zones WHERE id = %s", (ZONE_ID,))
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
connection.commit()
finally:
+1 -1
View File
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
APP_ROOT = Path(__file__).parents[1] / "app"
EXPECTED_HEAD = "0015_miner_control_foundation"
EXPECTED_HEAD = "0017_agent_enrollment"
def database_payload(revision=EXPECTED_HEAD):
+2 -1
View File
@@ -32,7 +32,8 @@ services:
backend:
build:
context: ./backend
context: .
dockerfile: backend/Dockerfile
env_file:
- .env.dev
command:
+3 -2
View File
@@ -34,7 +34,8 @@
backend:
build:
context: ./backend
context: .
dockerfile: backend/Dockerfile
container_name: minerops-backend
restart: unless-stopped
env_file:
@@ -51,4 +52,4 @@
networks:
minerops-net:
driver: bridge
driver: bridge
+132
View File
@@ -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.
+2 -2
View File
@@ -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
+327
View File
@@ -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."
+69
View File
@@ -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."
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-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/app/agent_registry/__init__.py \
backend/app/agent_registry/models.py \
backend/app/agent_registry/repository.py \
backend/app/agent_registry/service.py \
backend/app/agent_registry/dependencies.py \
backend/app/agent_registry/router.py \
backend/tests/test_agent_registry.py \
backend/tests/test_agent_registry_postgres.py \
backend/tests/test_agent_management_frontend.py \
backend/app/frontend/agent_management_view.py \
backend/app/frontend/static/agent-management.js
do
[[ -r "${source_file}" ]] || {
echo "Missing ${source_file}" >&2
exit 1
}
done
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
if "${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
>/dev/null 2>&1
then
break
fi
(( 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_registry \
tests.test_agent_registry_postgres \
tests.test_agent_management_frontend
echo "Agent Management tests passed."
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
PROJECT="minerops-agent-transport-isolation-test-$$"
compose=(
docker compose
--project-name "$PROJECT"
--env-file .env.dev
-f compose.dev.yaml
)
cleanup() {
"${compose[@]}" down \
--volumes \
--remove-orphans \
>/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "=== START DISPOSABLE POSTGRES ==="
"${compose[@]}" up --detach postgres
echo
echo "=== WAIT FOR POSTGRES ==="
ready=0
for _ in $(seq 1 30); do
if "${compose[@]}" exec -T postgres \
sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
>/dev/null 2>&1
then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "PostgreSQL did not become ready."
exit 1
fi
echo
echo "=== BUILD BACKEND ==="
"${compose[@]}" build backend
echo
echo "=== MIGRATE DATABASE ==="
"${compose[@]}" run --rm backend \
alembic upgrade head
echo
echo "=== RUNTIME AGENT ZONE ISOLATION ==="
"${compose[@]}" run --rm \
--volume "$PWD/backend/tests:/app/tests:ro" \
--env MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE=1 \
backend \
python -m unittest -v \
tests.test_agent_transport_isolation_postgres
echo
echo "Runtime Agent transport Zone-isolation tests passed."
+186
View File
@@ -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."
+53
View File
@@ -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."
+1 -1
View File
@@ -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}" == *"0017_agent_enrollment"* ]]
missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \
+2 -1
View File
@@ -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
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="minerops-windows-package-test-$$"
compose=(
docker compose
--project-name "${project_name}"
--env-file .env.dev
-f compose.dev.yaml
)
cleanup() {
status=$?
"${compose[@]}" down --volumes --remove-orphans \
>/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
echo "=== BUILD BACKEND WITH CANONICAL AGENT SOURCE ==="
"${compose[@]}" build backend
echo
echo "=== BACKEND PACKAGE TESTS ==="
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
backend python -m unittest -v \
tests.test_agent_windows_package
echo
echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ==="
"${compose[@]}" run --rm --no-deps \
backend sh -ec '
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
test -f /app/agents/windows/install.ps1
test -f /app/agents/windows/MinerOpsAgentService.ps1
test -f /app/agents/windows/VERSION
'
echo
echo "=== OFFICIAL POWERSHELL VALIDATION ==="
docker run --rm \
-v "${repo_root}/agents/windows:/work:ro" \
mcr.microsoft.com/powershell:7.4-debian-12 \
pwsh -NoLogo -NoProfile -Command '
$ErrorActionPreference = "Stop"
foreach ($script in @(
"/work/Enroll-MinerOpsAgent.ps1",
"/work/install.ps1"
)) {
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$script,
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -gt 0) {
throw "$script parser errors: $($errors.Message -join "; ")"
}
Write-Host "Parsed $script"
}
& /work/tests/Test-MinerOpsEnrollment.ps1
& /work/tests/Test-MinerOpsInstaller.ps1
'
echo
echo "Windows Agent package foundation tests passed."
+4
View File
@@ -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}"
+51 -1
View File
@@ -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,
+2
View File
@@ -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'),