Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390e73d950 |
No files matched your search
@@ -1,12 +0,0 @@
|
||||
# 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*
|
||||
@@ -1,4 +0,0 @@
|
||||
*.ps1 -text
|
||||
|
||||
|
||||
*.ps1 whitespace=cr-at-eol
|
||||
@@ -1,8 +0,0 @@
|
||||
agent-config.json
|
||||
agent-state.json
|
||||
logs/
|
||||
*.bak
|
||||
*.bak.*
|
||||
*-probe-*.json
|
||||
rollback/
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1
|
||||
ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1
|
||||
51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1
|
||||
bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1
|
||||
c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1
|
||||
90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1
|
||||
18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IpAddress,
|
||||
|
||||
[string]$MinerId = ""
|
||||
)
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
|
||||
function Read-AgentConfig {
|
||||
if (!(Test-Path $ConfigPath)) {
|
||||
throw "Agent config not found: $ConfigPath"
|
||||
}
|
||||
|
||||
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Invoke-MinerCommandRaw {
|
||||
param(
|
||||
[string]$IpAddress,
|
||||
[string]$Command,
|
||||
[int]$Port = 4028,
|
||||
[int]$TimeoutMs = 2500
|
||||
)
|
||||
|
||||
$client = $null
|
||||
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
|
||||
|
||||
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$client.EndConnect($async)
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
|
||||
try {
|
||||
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
|
||||
} catch {
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] 262144
|
||||
$chunks = New-Object System.Collections.ArrayList
|
||||
$started = Get-Date
|
||||
|
||||
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
|
||||
if (-not $stream.DataAvailable) {
|
||||
Start-Sleep -Milliseconds 20
|
||||
continue
|
||||
}
|
||||
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -le 0) {
|
||||
break
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt $read; $i++) {
|
||||
[void]$chunks.Add([byte]$buffer[$i])
|
||||
}
|
||||
|
||||
if ($chunks.Count -gt 2097152) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
|
||||
} catch {
|
||||
return ""
|
||||
} finally {
|
||||
if ($client -ne $null) {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Try-ParseJson {
|
||||
param([string]$Raw)
|
||||
|
||||
if (!$Raw -or $Raw.Trim().Length -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return $Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstValue {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string[]]$Names
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$json = $Object | ConvertTo-Json -Depth 80 -Compress
|
||||
|
||||
foreach ($name in $Names) {
|
||||
$escaped = [regex]::Escape($name)
|
||||
|
||||
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
|
||||
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if ($m.Success) {
|
||||
return $m.Groups[1].Value
|
||||
}
|
||||
|
||||
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
|
||||
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if ($m2.Success) {
|
||||
return $m2.Groups[1].Value
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Find-MacAddress {
|
||||
param([string]$Text)
|
||||
|
||||
if (!$Text) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
|
||||
if ($m.Success) {
|
||||
return $m.Value.Replace("-", ":").ToUpper()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
$config = Read-AgentConfig
|
||||
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||
$secret = [string]$config.agent_secret
|
||||
$agentId = [string]$config.agent_id
|
||||
|
||||
$commands = @(
|
||||
"summary",
|
||||
"devs",
|
||||
"edevs",
|
||||
"stats",
|
||||
"pools",
|
||||
"devdetails",
|
||||
"version",
|
||||
"get_version",
|
||||
"get_miner_info"
|
||||
)
|
||||
|
||||
$raws = [ordered]@{}
|
||||
$parsed = [ordered]@{}
|
||||
|
||||
foreach ($cmd in $commands) {
|
||||
Write-Host "Querying $IpAddress command: $cmd"
|
||||
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
|
||||
$raws[$cmd] = $raw
|
||||
$parsed[$cmd] = Try-ParseJson -Raw $raw
|
||||
Write-Host " bytes: $($raw.Length)"
|
||||
}
|
||||
|
||||
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
|
||||
$allParsed = [ordered]@{
|
||||
summary = $parsed["summary"]
|
||||
devs = $parsed["devs"]
|
||||
edevs = $parsed["edevs"]
|
||||
stats = $parsed["stats"]
|
||||
pools = $parsed["pools"]
|
||||
devdetails = $parsed["devdetails"]
|
||||
version = $parsed["version"]
|
||||
get_version = $parsed["get_version"]
|
||||
get_miner_info = $parsed["get_miner_info"]
|
||||
}
|
||||
|
||||
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
|
||||
if (!$mac) {
|
||||
$mac = Find-MacAddress -Text $allText
|
||||
}
|
||||
|
||||
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
|
||||
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
|
||||
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
|
||||
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
|
||||
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
|
||||
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
|
||||
|
||||
if (!$model) {
|
||||
$model = "WhatsMiner"
|
||||
}
|
||||
|
||||
$payload = [ordered]@{
|
||||
ip_address = $IpAddress
|
||||
miner_id = $MinerId
|
||||
mac_address = $mac
|
||||
hostname = $hostname
|
||||
miner_sn = $minerSn
|
||||
model = $model
|
||||
miner_type = $model
|
||||
api_version = $apiVersion
|
||||
firmware_version = $firmware
|
||||
platform = $platform
|
||||
raw_version = @{
|
||||
version = $raws["version"]
|
||||
get_version = $raws["get_version"]
|
||||
}
|
||||
raw_miner_info = @{
|
||||
devs = $raws["devs"]
|
||||
edevs = $raws["edevs"]
|
||||
stats = $raws["stats"]
|
||||
pools = $raws["pools"]
|
||||
devdetails = $raws["devdetails"]
|
||||
get_miner_info = $raws["get_miner_info"]
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Detected:"
|
||||
Write-Host " MAC: $mac"
|
||||
Write-Host " Hostname: $hostname"
|
||||
Write-Host " SN: $minerSn"
|
||||
Write-Host " Model: $model"
|
||||
Write-Host " Firmware: $firmware"
|
||||
Write-Host " API: $apiVersion"
|
||||
Write-Host " Platform: $platform"
|
||||
Write-Host ""
|
||||
|
||||
$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" `
|
||||
-Body ($payload | ConvertTo-Json -Depth 80)
|
||||
|
||||
$response | ConvertTo-Json -Depth 20
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
#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
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$BaseDir = "C:\MinerOpsAgent"
|
||||
$ConfigPath = Join-Path $BaseDir "agent-config.json"
|
||||
$StatePath = Join-Path $BaseDir "agent-state.json"
|
||||
$LogPath = Join-Path $BaseDir "logs\agent.log"
|
||||
$TaskName = "MinerOpsAgent"
|
||||
|
||||
function Read-Config {
|
||||
if (Test-Path $ConfigPath) {
|
||||
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-ConfigText {
|
||||
if (Test-Path $ConfigPath) {
|
||||
return Get-Content $ConfigPath -Raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function Save-ConfigObject {
|
||||
param([object]$Config)
|
||||
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Save-ConfigText {
|
||||
param([string]$Text)
|
||||
$Text | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-AgentTaskText {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if (!$task) { return "Scheduled task not installed" }
|
||||
|
||||
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)"
|
||||
}
|
||||
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "MinerOps Agent"
|
||||
$form.Size = New-Object System.Drawing.Size(950, 780)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
|
||||
$statusLabel = New-Object System.Windows.Forms.Label
|
||||
$statusLabel.Location = New-Object System.Drawing.Point(15, 15)
|
||||
$statusLabel.Size = New-Object System.Drawing.Size(900, 24)
|
||||
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||
$form.Controls.Add($statusLabel)
|
||||
|
||||
$startButton = New-Object System.Windows.Forms.Button
|
||||
$startButton.Text = "Start"
|
||||
$startButton.Location = New-Object System.Drawing.Point(15, 45)
|
||||
$startButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($startButton)
|
||||
|
||||
$stopButton = New-Object System.Windows.Forms.Button
|
||||
$stopButton.Text = "Stop"
|
||||
$stopButton.Location = New-Object System.Drawing.Point(125, 45)
|
||||
$stopButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($stopButton)
|
||||
|
||||
$restartButton = New-Object System.Windows.Forms.Button
|
||||
$restartButton.Text = "Restart"
|
||||
$restartButton.Location = New-Object System.Drawing.Point(235, 45)
|
||||
$restartButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($restartButton)
|
||||
|
||||
$testButton = New-Object System.Windows.Forms.Button
|
||||
$testButton.Text = "Test Hub"
|
||||
$testButton.Location = New-Object System.Drawing.Point(345, 45)
|
||||
$testButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||
$form.Controls.Add($testButton)
|
||||
|
||||
$saveButton = New-Object System.Windows.Forms.Button
|
||||
$saveButton.Text = "Save Config"
|
||||
$saveButton.Location = New-Object System.Drawing.Point(455, 45)
|
||||
$saveButton.Size = New-Object System.Drawing.Size(120, 32)
|
||||
$form.Controls.Add($saveButton)
|
||||
|
||||
$secretLabel = New-Object System.Windows.Forms.Label
|
||||
$secretLabel.Text = "Agent Secret:"
|
||||
$secretLabel.Location = New-Object System.Drawing.Point(15, 90)
|
||||
$secretLabel.Size = New-Object System.Drawing.Size(100, 22)
|
||||
$form.Controls.Add($secretLabel)
|
||||
|
||||
$secretBox = New-Object System.Windows.Forms.TextBox
|
||||
$secretBox.Location = New-Object System.Drawing.Point(115, 87)
|
||||
$secretBox.Size = New-Object System.Drawing.Size(570, 24)
|
||||
$secretBox.UseSystemPasswordChar = $true
|
||||
$form.Controls.Add($secretBox)
|
||||
|
||||
$showSecretCheck = New-Object System.Windows.Forms.CheckBox
|
||||
$showSecretCheck.Text = "Show"
|
||||
$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88)
|
||||
$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24)
|
||||
$form.Controls.Add($showSecretCheck)
|
||||
|
||||
$saveSecretButton = New-Object System.Windows.Forms.Button
|
||||
$saveSecretButton.Text = "Save Secret"
|
||||
$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84)
|
||||
$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30)
|
||||
$form.Controls.Add($saveSecretButton)
|
||||
|
||||
$configBox = New-Object System.Windows.Forms.TextBox
|
||||
$configBox.Location = New-Object System.Drawing.Point(15, 125)
|
||||
$configBox.Size = New-Object System.Drawing.Size(900, 350)
|
||||
$configBox.Multiline = $true
|
||||
$configBox.ScrollBars = "Both"
|
||||
$configBox.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||
$configBox.Text = Read-ConfigText
|
||||
$form.Controls.Add($configBox)
|
||||
|
||||
$stateBox = New-Object System.Windows.Forms.TextBox
|
||||
$stateBox.Location = New-Object System.Drawing.Point(15, 490)
|
||||
$stateBox.Size = New-Object System.Drawing.Size(900, 85)
|
||||
$stateBox.Multiline = $true
|
||||
$stateBox.ScrollBars = "Vertical"
|
||||
$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$form.Controls.Add($stateBox)
|
||||
|
||||
$logBox = New-Object System.Windows.Forms.TextBox
|
||||
$logBox.Location = New-Object System.Drawing.Point(15, 590)
|
||||
$logBox.Size = New-Object System.Drawing.Size(900, 130)
|
||||
$logBox.Multiline = $true
|
||||
$logBox.ScrollBars = "Vertical"
|
||||
$logBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||
$form.Controls.Add($logBox)
|
||||
|
||||
function Refresh-Display {
|
||||
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||
|
||||
$config = Read-Config
|
||||
if ($config -and $secretBox.Text.Length -eq 0) {
|
||||
$secretBox.Text = [string]$config.agent_secret
|
||||
}
|
||||
|
||||
if (Test-Path $StatePath) {
|
||||
$stateBox.Text = Get-Content $StatePath -Raw
|
||||
} else {
|
||||
$stateBox.Text = "No state file yet."
|
||||
}
|
||||
|
||||
if (Test-Path $LogPath) {
|
||||
$logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine
|
||||
} else {
|
||||
$logBox.Text = "No log file yet."
|
||||
}
|
||||
}
|
||||
|
||||
$showSecretCheck.Add_CheckedChanged({
|
||||
$secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked
|
||||
})
|
||||
|
||||
$saveSecretButton.Add_Click({
|
||||
try {
|
||||
$config = $configBox.Text | ConvertFrom-Json
|
||||
$config.agent_secret = $secretBox.Text
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||
$configBox.Text = Get-Content $ConfigPath -Raw
|
||||
[System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.")
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$startButton.Add_Click({
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$stopButton.Add_Click({
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$restartButton.Add_Click({
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
Refresh-Display
|
||||
})
|
||||
|
||||
$saveButton.Add_Click({
|
||||
try {
|
||||
$null = $configBox.Text | ConvertFrom-Json
|
||||
Save-ConfigText -Text $configBox.Text
|
||||
[System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.")
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$testButton.Add_Click({
|
||||
try {
|
||||
$config = $configBox.Text | ConvertFrom-Json
|
||||
if ($secretBox.Text.Length -gt 0) {
|
||||
$config.agent_secret = $secretBox.Text
|
||||
}
|
||||
|
||||
$hub = [string]$config.hub_url
|
||||
$secret = [string]$config.agent_secret
|
||||
|
||||
$headers = @{
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
}
|
||||
|
||||
$result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20
|
||||
[System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name)
|
||||
} catch {
|
||||
[System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message)
|
||||
}
|
||||
})
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 3000
|
||||
$timer.Add_Tick({ Refresh-Display })
|
||||
$timer.Start()
|
||||
|
||||
Refresh-Display
|
||||
[void]$form.ShowDialog()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,108 +0,0 @@
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$TaskName = "MinerOpsAgent"
|
||||
$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1"
|
||||
$LogPath = "C:\MinerOpsAgent\logs\agent.log"
|
||||
$DashboardUrl = "https://plexus.lussier.cc/dashboard"
|
||||
|
||||
function Start-Agent {
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Stop-Agent {
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Restart-Agent {
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Open-Gui {
|
||||
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`""
|
||||
}
|
||||
|
||||
function Open-Dashboard {
|
||||
Start-Process $DashboardUrl
|
||||
}
|
||||
|
||||
function Open-Log {
|
||||
if (Test-Path $LogPath) {
|
||||
Start-Process notepad.exe $LogPath
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AgentStatusText {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
|
||||
if (!$task) {
|
||||
return "MinerOps Agent: task not installed"
|
||||
}
|
||||
|
||||
return "MinerOps Agent: $($task.State)"
|
||||
}
|
||||
|
||||
$notify = New-Object System.Windows.Forms.NotifyIcon
|
||||
$notify.Icon = [System.Drawing.SystemIcons]::Application
|
||||
$notify.Text = Get-AgentStatusText
|
||||
$notify.Visible = $true
|
||||
|
||||
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
||||
|
||||
$itemOpen = $menu.Items.Add("Open Agent GUI")
|
||||
$itemOpen.Add_Click({ Open-Gui })
|
||||
|
||||
$itemDashboard = $menu.Items.Add("Open Plexus Dashboard")
|
||||
$itemDashboard.Add_Click({ Open-Dashboard })
|
||||
|
||||
$menu.Items.Add("-") | Out-Null
|
||||
|
||||
$itemStart = $menu.Items.Add("Start Agent")
|
||||
$itemStart.Add_Click({
|
||||
Start-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$itemStop = $menu.Items.Add("Stop Agent")
|
||||
$itemStop.Add_Click({
|
||||
Stop-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$itemRestart = $menu.Items.Add("Restart Agent")
|
||||
$itemRestart.Add_Click({
|
||||
Restart-Agent
|
||||
Start-Sleep -Seconds 1
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
|
||||
$menu.Items.Add("-") | Out-Null
|
||||
|
||||
$itemLog = $menu.Items.Add("Open Log")
|
||||
$itemLog.Add_Click({ Open-Log })
|
||||
|
||||
$itemExit = $menu.Items.Add("Exit Tray Icon")
|
||||
$itemExit.Add_Click({
|
||||
$notify.Visible = $false
|
||||
$notify.Dispose()
|
||||
[System.Windows.Forms.Application]::Exit()
|
||||
})
|
||||
|
||||
$notify.ContextMenuStrip = $menu
|
||||
|
||||
$notify.Add_DoubleClick({
|
||||
Open-Gui
|
||||
})
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 5000
|
||||
$timer.Add_Tick({
|
||||
$notify.Text = Get-AgentStatusText
|
||||
})
|
||||
$timer.Start()
|
||||
|
||||
[System.Windows.Forms.Application]::Run()
|
||||
@@ -1,247 +0,0 @@
|
||||
param(
|
||||
[int] $Limit = 25,
|
||||
[int] $TimeoutMs = 2500
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
|
||||
if (-not (Test-Path $ConfigPath)) {
|
||||
throw "Missing config file: $ConfigPath"
|
||||
}
|
||||
|
||||
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
$HubUrl = [string]$Config.hub_url
|
||||
$HubUrl = $HubUrl.TrimEnd("/")
|
||||
$AgentId = [string]$Config.agent_id
|
||||
$SiteName = [string]$Config.site_name
|
||||
$Secret = [string]$Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $Secret
|
||||
}
|
||||
|
||||
function Invoke-WhatsMinerApi3Read {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||
[Parameter(Mandatory=$true)] [hashtable] $Request,
|
||||
[int] $TimeoutMs = 2500
|
||||
)
|
||||
|
||||
$client = $null
|
||||
$stream = $null
|
||||
|
||||
try {
|
||||
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
|
||||
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
|
||||
$connect = $client.BeginConnect($Ip, 4433, $null, $null)
|
||||
|
||||
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||
try { $client.Close() } catch {}
|
||||
throw "Timed out connecting to $Ip`:4433"
|
||||
}
|
||||
|
||||
$client.EndConnect($connect)
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$stream.Write($length, 0, 4)
|
||||
$stream.Write($payload, 0, $payload.Length)
|
||||
$stream.Flush()
|
||||
|
||||
$lenBuf = New-Object byte[] 4
|
||||
$offset = 0
|
||||
|
||||
while ($offset -lt 4) {
|
||||
$read = $stream.Read($lenBuf, $offset, 4 - $offset)
|
||||
|
||||
if ($read -le 0) {
|
||||
throw "No response length received."
|
||||
}
|
||||
|
||||
$offset += $read
|
||||
}
|
||||
|
||||
$responseLength = [BitConverter]::ToInt32($lenBuf, 0)
|
||||
|
||||
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
|
||||
throw "Invalid response length: $responseLength"
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] $responseLength
|
||||
$offset = 0
|
||||
|
||||
while ($offset -lt $responseLength) {
|
||||
$read = $stream.Read($buffer, $offset, $responseLength - $offset)
|
||||
|
||||
if ($read -le 0) {
|
||||
throw "Response ended early at $offset of $responseLength bytes."
|
||||
}
|
||||
|
||||
$offset += $read
|
||||
}
|
||||
|
||||
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength)
|
||||
$parsed = $raw | ConvertFrom-Json
|
||||
|
||||
return [pscustomobject]@{
|
||||
ok = $true
|
||||
parsed = $parsed
|
||||
error = $null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return [pscustomobject]@{
|
||||
ok = $false
|
||||
parsed = $null
|
||||
error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($stream) { try { $stream.Close() } catch {} }
|
||||
if ($client) { try { $client.Close() } catch {} }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Api3OkCommand {
|
||||
param(
|
||||
[string] $Ip,
|
||||
[hashtable] $Request
|
||||
)
|
||||
|
||||
$result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs
|
||||
|
||||
if (-not $result.ok) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($result.parsed.code -ne 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $result.parsed
|
||||
}
|
||||
|
||||
Write-Host "Fetching known miners from $HubUrl"
|
||||
|
||||
$known = Invoke-RestMethod `
|
||||
-Uri "$HubUrl/api/agent/known-miners" `
|
||||
-Headers $Headers `
|
||||
-Method Get
|
||||
|
||||
$miners = @($known.miners)
|
||||
|
||||
if ($Limit -gt 0) {
|
||||
$miners = @($miners | Select-Object -First $Limit)
|
||||
}
|
||||
|
||||
Write-Host "API3 enrichment check count: $($miners.Count)"
|
||||
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
$checked = 0
|
||||
$api3Ok = 0
|
||||
$api3Failed = 0
|
||||
|
||||
foreach ($miner in $miners) {
|
||||
$checked += 1
|
||||
$ip = [string]$miner.ip_address
|
||||
$mac = [string]$miner.mac_address
|
||||
|
||||
if (-not $ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "[$checked/$($miners.Count)] $ip $mac"
|
||||
|
||||
$deviceInfoResult = Invoke-WhatsMinerApi3Read `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.device.info" } `
|
||||
-TimeoutMs $TimeoutMs
|
||||
|
||||
if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) {
|
||||
$api3Failed += 1
|
||||
|
||||
$err = $deviceInfoResult.error
|
||||
|
||||
if ($deviceInfoResult.ok) {
|
||||
$err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)"
|
||||
}
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $false
|
||||
api3_error = $err
|
||||
device_info = $null
|
||||
miner_setting = $null
|
||||
miner_status = $null
|
||||
fan_setting = $null
|
||||
custom_data = $null
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
$api3Ok += 1
|
||||
|
||||
$minerSetting = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.miner.setting" }
|
||||
|
||||
$minerStatus = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" }
|
||||
|
||||
$fanSetting = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.fan.setting" }
|
||||
|
||||
$customData = Invoke-Api3OkCommand `
|
||||
-Ip $ip `
|
||||
-Request @{ cmd = "get.device.custom_data" }
|
||||
|
||||
$items.Add([pscustomobject]@{
|
||||
ip = $ip
|
||||
mac = $mac
|
||||
api3_supported = $true
|
||||
api3_error = $null
|
||||
device_info = $deviceInfoResult.parsed
|
||||
miner_setting = $minerSetting
|
||||
miner_status = $minerStatus
|
||||
fan_setting = $fanSetting
|
||||
custom_data = $customData
|
||||
})
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
agent_id = $AgentId
|
||||
site_name = $SiteName
|
||||
items = @($items.ToArray())
|
||||
}
|
||||
|
||||
$json = $payload | ConvertTo-Json -Depth 80
|
||||
|
||||
Write-Host "Posting API3 enrichment results to hub..."
|
||||
$result = Invoke-RestMethod `
|
||||
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
||||
-Method Post `
|
||||
-Headers $Headers `
|
||||
-ContentType "application/json" `
|
||||
-Body $json
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local checked: $checked"
|
||||
Write-Host "API3 OK: $api3Ok"
|
||||
Write-Host "API3 failed: $api3Failed"
|
||||
Write-Host ""
|
||||
$result | ConvertTo-Json -Depth 20
|
||||
@@ -1,201 +0,0 @@
|
||||
# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1.
|
||||
|
||||
function Get-MinerOpsAgentVersion {
|
||||
$versionPath = Join-Path $PSScriptRoot "VERSION"
|
||||
if (Test-Path $versionPath) {
|
||||
return ([string](Get-Content $versionPath -Raw)).Trim()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function Get-MinerOpsControlCredentials {
|
||||
$config = Read-AgentConfig
|
||||
$account = ([string]$config.api3_account).Trim()
|
||||
$password = [string]$config.api3_password
|
||||
$port = 4433
|
||||
if ($config.api3_port) {
|
||||
try { $port = [int]$config.api3_port } catch { $port = 4433 }
|
||||
}
|
||||
return @{
|
||||
available = (-not [string]::IsNullOrWhiteSpace($account)) -and
|
||||
(-not [string]::IsNullOrWhiteSpace($password))
|
||||
account = $account
|
||||
password = $password
|
||||
port = $port
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsControlApi3Command {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$IpAddress,
|
||||
[Parameter(Mandatory=$true)][string]$Command,
|
||||
[object]$Param = $null
|
||||
)
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
if (-not $credentials.available) { throw "credentials_missing" }
|
||||
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password `
|
||||
-Salt ([string]$saltInfo.salt) -Timestamp $timestamp
|
||||
$payload = @{
|
||||
cmd = $Command
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $credentials.account
|
||||
}
|
||||
if ($null -ne $Param) { $payload["param"] = $Param }
|
||||
$response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress `
|
||||
-Port $credentials.port -Payload $payload
|
||||
if ([int]$response.response.code -ne 0) { throw "command_rejected" }
|
||||
return $response
|
||||
}
|
||||
|
||||
function New-MinerOpsControlResult {
|
||||
param(
|
||||
[bool]$Accepted,
|
||||
[bool]$VerificationAttempted,
|
||||
[string]$VerificationStatus,
|
||||
[string]$ObservedMode = "",
|
||||
[string]$FailureCode = "",
|
||||
[string]$QueueStatus = "completed"
|
||||
)
|
||||
return [ordered]@{
|
||||
schema_version = 1
|
||||
command_accepted = $Accepted
|
||||
verification_attempted = $VerificationAttempted
|
||||
verification_status = $VerificationStatus
|
||||
observed_service_state = $null
|
||||
observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null }
|
||||
failure_code = if ($FailureCode) { $FailureCode } else { $null }
|
||||
_queue_status = $QueueStatus
|
||||
}
|
||||
}
|
||||
|
||||
function Run-MinerControlCommand {
|
||||
param([Parameter(Mandatory=$true)][object]$Payload)
|
||||
|
||||
try {
|
||||
if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json }
|
||||
$action = ([string]$Payload.action).Trim().ToLower()
|
||||
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||
$requested = ([string]$Payload.requested_value).Trim().ToLower()
|
||||
if (-not $requested -and $Payload.performance_mode) {
|
||||
$requested = ([string]$Payload.performance_mode).Trim().ToLower()
|
||||
}
|
||||
if ($action -notin @("suspend", "resume", "performance_mode")) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "invalid_action" -QueueStatus "failed"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "target_missing" -QueueStatus "failed"
|
||||
}
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
if (-not $credentials.available) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "credentials_missing" -QueueStatus "failed"
|
||||
}
|
||||
|
||||
if ($action -eq "performance_mode") {
|
||||
if ($requested -notin @("low", "normal", "high")) {
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode "invalid_mode" -QueueStatus "failed"
|
||||
}
|
||||
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "set.miner.power_mode" -Param $requested | Out-Null
|
||||
|
||||
$observed = ""
|
||||
$readUnavailable = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
$read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "get.miner.setting"
|
||||
$candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower()
|
||||
if ($candidate -in @("low", "normal", "high")) {
|
||||
$observed = $candidate
|
||||
if ($observed -eq $requested) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "confirmed" `
|
||||
-ObservedMode $observed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ($_.Exception.Message -match "unsupported|unknown_command") {
|
||||
$readUnavailable = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 }
|
||||
}
|
||||
if ($readUnavailable) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "unavailable"
|
||||
}
|
||||
if ($observed) {
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true `
|
||||
-VerificationStatus "verification_failed" -ObservedMode $observed
|
||||
}
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $true -VerificationStatus "unconfirmed"
|
||||
}
|
||||
|
||||
$serviceAction = if ($action -eq "suspend") { "stop" } else { "start" }
|
||||
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||
-Command "set.miner.service" -Param $serviceAction | Out-Null
|
||||
return New-MinerOpsControlResult -Accepted $true `
|
||||
-VerificationAttempted $false -VerificationStatus "unconfirmed"
|
||||
} catch {
|
||||
$failure = if ($_.Exception.Message -eq "credentials_missing") {
|
||||
"credentials_missing"
|
||||
} else { "write_rejected" }
|
||||
return New-MinerOpsControlResult -Accepted $false `
|
||||
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||
-FailureCode $failure -QueueStatus "failed"
|
||||
}
|
||||
}
|
||||
|
||||
function Send-AgentCommandResult {
|
||||
param(
|
||||
[string]$CommandId,
|
||||
[string]$Status,
|
||||
[object]$ResultPayload = $null,
|
||||
[string]$ErrorMessage = $null
|
||||
)
|
||||
if ($ResultPayload -and $ResultPayload._queue_status) {
|
||||
$Status = [string]$ResultPayload._queue_status
|
||||
$ResultPayload.Remove("_queue_status")
|
||||
$ErrorMessage = if ($ResultPayload.failure_code) {
|
||||
[string]$ResultPayload.failure_code
|
||||
} else { $null }
|
||||
}
|
||||
$body = [ordered]@{
|
||||
command_id = $CommandId
|
||||
status = $Status
|
||||
result_payload = $ResultPayload
|
||||
error_message = $ErrorMessage
|
||||
}
|
||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body
|
||||
}
|
||||
|
||||
function Send-AgentCheckin {
|
||||
$config = Read-AgentConfig
|
||||
$credentials = Get-MinerOpsControlCredentials
|
||||
$body = [ordered]@{
|
||||
agent_id = [string]$config.agent_id
|
||||
site_id = [string]$config.site_id
|
||||
site_name = [string]$config.site_name
|
||||
hostname = $env:COMPUTERNAME
|
||||
version = Get-MinerOpsAgentVersion
|
||||
ip_address = ""
|
||||
control_available = [bool]$credentials.available
|
||||
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
|
||||
local_time = (Get-Date).ToString("o")
|
||||
}
|
||||
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||
$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json
|
||||
|
||||
$HubUrl = ($Config.hub_url + "").TrimEnd("/")
|
||||
$AgentId = $Config.agent_id
|
||||
$AgentSecret = $Config.agent_secret
|
||||
|
||||
$Headers = @{
|
||||
"X-MinerOps-Agent-ID" = $AgentId
|
||||
"X-MinerOps-Agent-Secret" = $AgentSecret
|
||||
}
|
||||
|
||||
function Normalize-MinerOpsIp {
|
||||
param([string] $Ip)
|
||||
|
||||
if (-not $Ip) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ($Ip -replace "/32$", "").Trim()
|
||||
}
|
||||
|
||||
function Invoke-MinerOps4028PoolsSimple {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||
[int] $Port = 4028,
|
||||
[int] $TimeoutMs = 5000
|
||||
)
|
||||
|
||||
$client = $null
|
||||
$stream = $null
|
||||
|
||||
try {
|
||||
$json = '{"command":"pools"}'
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
$client.Connect($Ip, $Port)
|
||||
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$buffer = New-Object byte[] 131072
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim()
|
||||
|
||||
if (-not $raw) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parsed = $raw | ConvertFrom-Json
|
||||
|
||||
if (-not $parsed.POOLS) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return @($parsed.POOLS)
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
finally {
|
||||
if ($stream) {
|
||||
try { $stream.Close() } catch {}
|
||||
}
|
||||
|
||||
if ($client) {
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60
|
||||
|
||||
$knownMiners = @()
|
||||
|
||||
if ($response.miners) {
|
||||
$knownMiners = @($response.miners)
|
||||
} elseif ($response.data -and $response.data.miners) {
|
||||
$knownMiners = @($response.data.miners)
|
||||
} elseif ($response.data -and $response.data.known_miners) {
|
||||
$knownMiners = @($response.data.known_miners)
|
||||
}
|
||||
|
||||
Write-Host "Known miners: $($knownMiners.Count)"
|
||||
|
||||
$poolSnapshots = New-Object System.Collections.Generic.List[object]
|
||||
$count = 0
|
||||
|
||||
foreach ($miner in $knownMiners) {
|
||||
$count++
|
||||
|
||||
$ip = Normalize-MinerOpsIp -Ip $miner.ip_address
|
||||
|
||||
if (-not $ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (($count % 25) -eq 0) {
|
||||
Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)."
|
||||
}
|
||||
|
||||
$pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip)
|
||||
|
||||
if ($pools.Count -gt 0) {
|
||||
$poolSnapshots.Add([pscustomobject]@{
|
||||
miner_id = $miner.miner_id
|
||||
ip_address = $ip
|
||||
mac_address = $miner.mac_address
|
||||
model = $miner.model
|
||||
pools = @($pools)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||
|
||||
$body = @{
|
||||
agent_id = $AgentId
|
||||
pool_snapshots = $poolSnapshots
|
||||
} | ConvertTo-Json -Depth 20
|
||||
|
||||
$result = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||
-Headers $Headers `
|
||||
-Body $body `
|
||||
-ContentType "application/json" `
|
||||
-TimeoutSec 180
|
||||
|
||||
$result | ConvertTo-Json -Depth 10
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
$LogDir = "C:\MinerOpsAgent\logs"
|
||||
$LogFile = Join-Path $LogDir "pool-poller.log"
|
||||
|
||||
if (-not (Test-Path $LogDir)) {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
try {
|
||||
Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller."
|
||||
|
||||
$output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1
|
||||
|
||||
foreach ($line in $output) {
|
||||
Add-Content -Path $LogFile -Value "$line"
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
Add-Content -Path $LogFile -Value "$stamp Pool poller finished."
|
||||
Add-Content -Path $LogFile -Value ""
|
||||
}
|
||||
catch {
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)"
|
||||
Add-Content -Path $LogFile -Value ""
|
||||
exit 1
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# MinerOps Windows Agent
|
||||
|
||||
The Windows Agent runs at a mining site and performs local discovery, polling,
|
||||
API3 enrichment, pool polling, and approved queued miner-control operations.
|
||||
MinerOps does not send API3 credentials through the hub command queue; those
|
||||
credentials remain in the Agent's local `agent-config.json`.
|
||||
|
||||
## Recovered Baseline
|
||||
|
||||
The seven runtime and support scripts in this directory were recovered from the
|
||||
live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact
|
||||
pre-change hashes. Historical backups, live configuration, state, logs, and
|
||||
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. `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.
|
||||
|
||||
## Security
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
The older direct Hub API3, `/agent-control`, password-change, bulk-control, and
|
||||
scheduling paths remain available for compatibility and are deprecated for the
|
||||
Internal Alpha. The new Miner Detail controls use only the typed control API and
|
||||
Agent queue; the modular control backend does not connect directly to miners.
|
||||
|
||||
## Deployment
|
||||
|
||||
### New Agent enrollment
|
||||
|
||||
For a new Agent, use Agent Management in MinerOps:
|
||||
|
||||
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:
|
||||
|
||||
.\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. Before replacing runtime
|
||||
files, the installer copies them to a timestamped backup under
|
||||
C:\MinerOpsAgent\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 +0,0 @@
|
||||
1.0.0-alpha
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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,
|
||||
"ip_ranges": [
|
||||
{
|
||||
"name": "Example Range",
|
||||
"cidr": "192.0.2.0/24",
|
||||
"enabled": false,
|
||||
"api_port": 4028,
|
||||
"api3_port": 4433
|
||||
}
|
||||
],
|
||||
"api3_account": "replace-with-api3-account",
|
||||
"api3_password": "replace-with-api3-password",
|
||||
"api3_port": 4433
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
#Requires -RunAsAdministrator
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Target = "C:\MinerOpsAgent"
|
||||
$BackupRoot = Join-Path $Target "backups"
|
||||
$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||
$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix"
|
||||
$RuntimeFiles = @(
|
||||
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||
"VERSION"
|
||||
)
|
||||
|
||||
function Get-MinerOpsScheduledTask {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Stop-MinerOpsScheduledTaskForUpgrade {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
|
||||
)
|
||||
|
||||
Stop-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||
$deadline = [DateTime]::UtcNow.Add($Timeout)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$task = Get-MinerOpsScheduledTask -Name $Name
|
||||
if ($null -eq $task) {
|
||||
throw "Scheduled Task $Name disappeared while stopping it."
|
||||
}
|
||||
if ([string]$task.State -ne "Running") {
|
||||
return
|
||||
}
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
|
||||
throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds."
|
||||
}
|
||||
|
||||
function Start-MinerOpsScheduledTask {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
Start-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||
}
|
||||
|
||||
# Validate the complete source before creating directories, stopping tasks, or
|
||||
# replacing any target file.
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
$source = Join-Path $PSScriptRoot $name
|
||||
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||
throw "Deployment source is missing $name"
|
||||
}
|
||||
}
|
||||
|
||||
$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||
$mainTaskExisted = $null -ne $mainTask
|
||||
$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running"
|
||||
$mainTaskStoppedForUpgrade = $false
|
||||
$deploymentStarted = $false
|
||||
$existing = @()
|
||||
$createdTaskNames = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
|
||||
$existing = @($RuntimeFiles | Where-Object {
|
||||
Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf
|
||||
})
|
||||
if ($existing.Count -gt 0) {
|
||||
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
|
||||
foreach ($name in $existing) {
|
||||
Copy-Item -LiteralPath (Join-Path $Target $name) `
|
||||
-Destination (Join-Path $Backup $name) -Force
|
||||
}
|
||||
}
|
||||
|
||||
if ($mainTaskWasRunning) {
|
||||
$mainTaskStoppedForUpgrade = $true
|
||||
Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent"
|
||||
}
|
||||
|
||||
$deploymentStarted = $true
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) `
|
||||
-Destination (Join-Path $Target $name) -Force
|
||||
}
|
||||
|
||||
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
|
||||
-LogonType ServiceAccount -RunLevel Highest
|
||||
$startup = New-ScheduledTaskTrigger -AtStartup
|
||||
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 5)
|
||||
$commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 72)
|
||||
$agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||
|
||||
$definitions = @(
|
||||
@{
|
||||
Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"
|
||||
Trigger = $startup; Principal = $system; Settings = $agentSettings
|
||||
ExtraArguments = ""
|
||||
},
|
||||
@{
|
||||
Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"
|
||||
Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null
|
||||
Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden"
|
||||
},
|
||||
@{
|
||||
Name = "MinerOpsApi3Enrichment"
|
||||
Script = "MinerOpsApi3EnrichmentPoller.ps1"
|
||||
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||
ExtraArguments = "-Limit 0 -TimeoutMs 8000"
|
||||
},
|
||||
@{
|
||||
Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"
|
||||
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||
ExtraArguments = ""
|
||||
}
|
||||
)
|
||||
foreach ($definition in $definitions) {
|
||||
if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) {
|
||||
Write-Host "Preserving existing Scheduled Task $($definition.Name)."
|
||||
continue
|
||||
}
|
||||
|
||||
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
||||
(Join-Path $Target $definition.Script)
|
||||
if ($definition.ExtraArguments) {
|
||||
$arguments = "$arguments $($definition.ExtraArguments)"
|
||||
}
|
||||
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
|
||||
$registration = @{
|
||||
TaskName = $definition.Name
|
||||
Action = $action
|
||||
Trigger = $definition.Trigger
|
||||
Settings = $definition.Settings
|
||||
}
|
||||
if ($definition.Principal) {
|
||||
$registration.Principal = $definition.Principal
|
||||
}
|
||||
Register-ScheduledTask @registration | Out-Null
|
||||
$createdTaskNames.Add($definition.Name)
|
||||
}
|
||||
|
||||
if ($mainTaskWasRunning) {
|
||||
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||
$mainTaskStoppedForUpgrade = $false
|
||||
}
|
||||
|
||||
Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved."
|
||||
} catch {
|
||||
$deploymentError = $_
|
||||
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
foreach ($taskName in $createdTaskNames) {
|
||||
try {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false `
|
||||
-ErrorAction Stop
|
||||
} catch {
|
||||
$rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
|
||||
if ($deploymentStarted) {
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
try {
|
||||
$targetPath = Join-Path $Target $name
|
||||
if ($existing -contains $name) {
|
||||
Copy-Item -LiteralPath (Join-Path $Backup $name) `
|
||||
-Destination $targetPath -Force
|
||||
} elseif (Test-Path -LiteralPath $targetPath) {
|
||||
Remove-Item -LiteralPath $targetPath -Force
|
||||
}
|
||||
} catch {
|
||||
$rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) {
|
||||
try {
|
||||
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||
$mainTaskStoppedForUpgrade = $false
|
||||
} catch {
|
||||
$rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)")
|
||||
}
|
||||
}
|
||||
|
||||
if ($rollbackErrors.Count -gt 0) {
|
||||
$rollbackSummary = $rollbackErrors -join "; "
|
||||
throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary"
|
||||
}
|
||||
throw $deploymentError
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||
$script:calls = @()
|
||||
$script:outcomes = @()
|
||||
$script:outcomeIndex = 0
|
||||
|
||||
function Read-AgentConfig { return $script:config }
|
||||
. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1")
|
||||
|
||||
function Invoke-MinerOpsControlApi3Command {
|
||||
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||
$script:calls += [pscustomobject]@{ command = $Command; param = $Param }
|
||||
$outcome = $script:outcomes[$script:outcomeIndex]
|
||||
$script:outcomeIndex += 1
|
||||
if ($outcome -is [System.Exception]) { throw $outcome }
|
||||
return $outcome
|
||||
}
|
||||
|
||||
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||
}
|
||||
function Reset-Harness([object[]]$Outcomes) {
|
||||
$script:calls = @()
|
||||
$script:outcomes = @($Outcomes)
|
||||
$script:outcomeIndex = 0
|
||||
}
|
||||
function Response([string]$Mode = "") {
|
||||
return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } }
|
||||
}
|
||||
|
||||
$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" }
|
||||
|
||||
$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 }
|
||||
Reset-Harness @()
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code"
|
||||
Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3"
|
||||
|
||||
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||
Reset-Harness @((Response), (Response "normal"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "confirmed" "Performance confirmation"
|
||||
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write"
|
||||
|
||||
Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "verification_failed" "Contradictory mode"
|
||||
Assert-Equal $script:calls.Count 4 "One write and three bounded reads"
|
||||
|
||||
Reset-Harness @((Response), (Response), (Response), (Response))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads"
|
||||
|
||||
Reset-Harness @((Response), [System.Exception]::new("unknown_command"))
|
||||
$result = Run-MinerControlCommand -Payload $payload
|
||||
Assert-Equal $result.verification_status "unavailable" "Unavailable read path"
|
||||
|
||||
foreach ($action in @("suspend", "resume")) {
|
||||
Reset-Harness @((Response))
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = $action; requested_value = $null; ip_address = "192.0.2.1"
|
||||
}
|
||||
Assert-Equal $result.command_accepted $true "$action accepted"
|
||||
Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed"
|
||||
Assert-Equal $result.observed_service_state $null "$action has no invented state"
|
||||
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write"
|
||||
}
|
||||
|
||||
Reset-Harness @([System.Exception]::new("vendor response included secret"))
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
|
||||
}
|
||||
Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized"
|
||||
$json = $result | ConvertTo-Json -Compress
|
||||
if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" }
|
||||
|
||||
Reset-Harness @()
|
||||
$result = Run-MinerControlCommand -Payload @{
|
||||
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
|
||||
}
|
||||
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
|
||||
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
|
||||
|
||||
Write-Host "MinerOps typed Agent control tests passed."
|
||||
@@ -1,117 +0,0 @@
|
||||
$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."
|
||||
@@ -1,60 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installer, [ref]$tokens, [ref]$parseErrors
|
||||
) | Out-Null
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||
}
|
||||
|
||||
$source = Get-Content -LiteralPath $installer -Raw
|
||||
function Assert-SourceContains {
|
||||
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
||||
if ($source -notmatch $Pattern) { throw $Message }
|
||||
}
|
||||
|
||||
$runtimeMatch = [regex]::Match(
|
||||
$source,
|
||||
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
||||
)
|
||||
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
||||
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
||||
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
||||
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
||||
throw "$forbidden must not be a managed deployment target."
|
||||
}
|
||||
}
|
||||
|
||||
$preflight = $source.IndexOf('# Validate the complete source')
|
||||
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
||||
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||
throw "Source preflight must happen before target changes."
|
||||
}
|
||||
|
||||
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
|
||||
"Existing task preservation guard is missing."
|
||||
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||
"Existing task preservation is not explicit."
|
||||
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||
"Fresh enrichment task arguments are missing."
|
||||
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||
"Bounded main Agent stop behavior is missing."
|
||||
Assert-SourceContains '\$mainTaskWasRunning' `
|
||||
"Main Agent prior running state is not represented."
|
||||
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
||||
"Rollback does not restore managed files from backup."
|
||||
Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||
"Rollback does not remove tasks created by a failed install."
|
||||
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||
"Rollback restart failure is not surfaced."
|
||||
|
||||
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||
}
|
||||
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
||||
throw "Installer must not inspect or print credentials."
|
||||
}
|
||||
|
||||
Write-Host "MinerOps installer source safety tests passed."
|
||||
@@ -1,21 +0,0 @@
|
||||
#Requires -RunAsAdministrator
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Target = "C:\MinerOpsAgent"
|
||||
$Tasks = @(
|
||||
"MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller"
|
||||
)
|
||||
foreach ($name in $Tasks) {
|
||||
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$RuntimeFiles = @(
|
||||
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||
"VERSION"
|
||||
)
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved."
|
||||
+5
-8
@@ -5,18 +5,15 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
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
|
||||
COPY app /app/app
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic /app/alembic
|
||||
|
||||
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"]
|
||||
@@ -1,137 +0,0 @@
|
||||
"""add miner control foundation
|
||||
|
||||
Revision ID: 0015_miner_control_foundation
|
||||
Revises: 0014_repair_parts_tracking
|
||||
Create Date: 2026-08-24
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0015_miner_control_foundation"
|
||||
down_revision: Union[str, None] = "0014_repair_parts_tracking"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled")
|
||||
|
||||
|
||||
def _format_values(values: object) -> str:
|
||||
return ", ".join(str(value) for value in (values or []))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
unknown_statuses = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT array_agg(DISTINCT command_status ORDER BY command_status)
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')
|
||||
"""
|
||||
),
|
||||
).scalar()
|
||||
if unknown_statuses:
|
||||
raise RuntimeError(
|
||||
"Cannot upgrade to 0015: minerops_agent_command_queue contains "
|
||||
f"unsupported command_status values: {_format_values(unknown_statuses)}. "
|
||||
"Correct those rows to pending, claimed, completed, failed, or cancelled "
|
||||
"before retrying the migration."
|
||||
)
|
||||
|
||||
duplicate_live_miners = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT request_payload ->> 'miner_id' AS miner_id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_type = 'miner_control'
|
||||
AND command_status IN ('pending', 'claimed')
|
||||
AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL
|
||||
GROUP BY request_payload ->> 'miner_id'
|
||||
HAVING count(*) > 1
|
||||
ORDER BY request_payload ->> 'miner_id'
|
||||
"""
|
||||
)
|
||||
).scalars().all()
|
||||
if duplicate_live_miners:
|
||||
raise RuntimeError(
|
||||
"Cannot upgrade to 0015: more than one pending or claimed miner_control "
|
||||
"command exists for miner UUID(s): "
|
||||
f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate "
|
||||
"live commands before retrying the migration; no queue history was changed."
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
"sites",
|
||||
["site_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
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.create_check_constraint(
|
||||
"ck_minerops_agent_command_queue_status",
|
||||
"minerops_agent_command_queue",
|
||||
"command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_agent_queue_live_miner_control",
|
||||
"minerops_agent_command_queue",
|
||||
[sa.text("(request_payload ->> 'miner_id')")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"command_type = 'miner_control' "
|
||||
"AND command_status IN ('pending', 'claimed')"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_agent_queue_miner_control_history",
|
||||
"minerops_agent_command_queue",
|
||||
[
|
||||
sa.text("(request_payload ->> 'miner_id')"),
|
||||
sa.text("created_at DESC"),
|
||||
sa.text("id DESC"),
|
||||
],
|
||||
postgresql_where=sa.text("command_type = 'miner_control'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_agent_queue_miner_control_history",
|
||||
table_name="minerops_agent_command_queue",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_agent_queue_live_miner_control",
|
||||
table_name="minerops_agent_command_queue",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_agent_command_queue_status",
|
||||
"minerops_agent_command_queue",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "site_id")
|
||||
@@ -1,365 +0,0 @@
|
||||
"""add agent zones
|
||||
|
||||
Revision ID: 0016_agent_zones
|
||||
Revises: 0015_miner_control_foundation
|
||||
Create Date: 2026-08-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "0016_agent_zones"
|
||||
down_revision: Union[str, None] = "0015_miner_control_foundation"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ZONE_NAME = "Default Zone"
|
||||
|
||||
|
||||
def _format_rows(rows: list[tuple]) -> str:
|
||||
return ", ".join("/".join(str(value) for value in row) for row in rows)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_zones",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"site_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
|
||||
name="ck_agent_zones_name",
|
||||
),
|
||||
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
|
||||
)
|
||||
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
|
||||
op.create_index(
|
||||
"uq_agent_zones_active_site_name",
|
||||
"agent_zones",
|
||||
["site_id", sa.text("lower(name)")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_enabled"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"agent_zone_network_ranges",
|
||||
sa.Column(
|
||||
"id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"agent_zone_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("network", postgresql.CIDR(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
"agent_zone_network_ranges",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"miners",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_miners_agent_zone_requires_site",
|
||||
"miners",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_miners_agent_zone_site",
|
||||
"miners",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
|
||||
|
||||
op.add_column(
|
||||
"minerops_site_agents",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
"agent_zones",
|
||||
["agent_zone_id", "site_id"],
|
||||
["id", "site_id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_snapshots",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"minerops_agent_discoveries",
|
||||
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
"agent_zones",
|
||||
["agent_zone_id"],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
["agent_zone_id"],
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO agent_zones (site_id, name)
|
||||
SELECT sites.id, :default_name
|
||||
FROM sites
|
||||
ORDER BY sites.id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE miners AS miner
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE miner.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND miner.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_site_agents AS agent
|
||||
SET agent_zone_id = zone.id
|
||||
FROM agent_zones AS zone
|
||||
WHERE agent.site_id = zone.site_id
|
||||
AND zone.name = :default_name
|
||||
AND agent.agent_zone_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_snapshots AS snapshot
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE snapshot.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND snapshot.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE minerops_agent_discoveries AS discovery
|
||||
SET agent_zone_id = agent.agent_zone_id
|
||||
FROM minerops_site_agents AS agent
|
||||
WHERE discovery.agent_id = agent.agent_id
|
||||
AND agent.agent_zone_id IS NOT NULL
|
||||
AND discovery.agent_zone_id IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
"minerops_site_agents",
|
||||
["agent_zone_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
duplicate_site_agents = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM minerops_site_agents
|
||||
WHERE site_id IS NOT NULL
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
ORDER BY site_id
|
||||
"""
|
||||
)
|
||||
).all()
|
||||
if duplicate_site_agents:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
|
||||
"assigned within Site(s): "
|
||||
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
|
||||
"until each Site has at most one Agent before retrying; no Agent Zone "
|
||||
"data was changed."
|
||||
)
|
||||
|
||||
non_reducible_zones = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT site_id::text, count(*)
|
||||
FROM agent_zones
|
||||
GROUP BY site_id
|
||||
HAVING count(*) > 1
|
||||
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
|
||||
ORDER BY site_id
|
||||
"""
|
||||
),
|
||||
{"default_name": DEFAULT_ZONE_NAME},
|
||||
).all()
|
||||
range_count = connection.execute(
|
||||
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
|
||||
).scalar_one()
|
||||
if non_reducible_zones or range_count:
|
||||
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
|
||||
f"represented by the one-Agent-per-Site model ({detail}). Remove "
|
||||
"additional/custom Zones and Zone network ranges before retrying; no "
|
||||
"Agent Zone data was changed."
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_minerops_site_agents_site_id",
|
||||
"minerops_site_agents",
|
||||
["site_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"uq_minerops_site_agents_agent_zone_id",
|
||||
table_name="minerops_site_agents",
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||
table_name="minerops_agent_discoveries",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||
"minerops_agent_discoveries",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||
table_name="minerops_agent_snapshots",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||
"minerops_agent_snapshots",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
|
||||
|
||||
op.drop_constraint(
|
||||
"fk_minerops_site_agents_zone_site",
|
||||
"minerops_site_agents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_minerops_site_agents_zone_requires_site",
|
||||
"minerops_site_agents",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("minerops_site_agents", "agent_zone_id")
|
||||
|
||||
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
|
||||
op.drop_constraint(
|
||||
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_miners_agent_zone_requires_site", "miners", type_="check"
|
||||
)
|
||||
op.drop_column("miners", "agent_zone_id")
|
||||
|
||||
op.drop_index(
|
||||
"idx_agent_zone_network_ranges_zone_id",
|
||||
table_name="agent_zone_network_ranges",
|
||||
)
|
||||
op.drop_table("agent_zone_network_ranges")
|
||||
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
|
||||
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
|
||||
op.drop_table("agent_zones")
|
||||
@@ -1,333 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Per-Agent authentication support."""
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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."""
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AgentAuthService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not identity:
|
||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
if credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
supplied_hash,
|
||||
str(credential["agent_secret_hash"]),
|
||||
):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(identity, legacy_id):
|
||||
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||
|
||||
if not hmac.compare_digest(secret, legacy_secret):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
@@ -1 +0,0 @@
|
||||
"""Authenticated Agent command transport for MinerOps."""
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Dependencies for the authenticated Agent queue protocol."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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,17 +0,0 @@
|
||||
"""Domain-neutral Agent command protocol errors."""
|
||||
|
||||
|
||||
class AgentCommandError(Exception):
|
||||
"""Base Agent command protocol error."""
|
||||
|
||||
|
||||
class AgentCommandNotFound(AgentCommandError):
|
||||
"""The submitted command identifier is unknown."""
|
||||
|
||||
|
||||
class AgentCommandConflict(AgentCommandError):
|
||||
"""The queue row cannot accept the requested transition."""
|
||||
|
||||
|
||||
class AgentResultValidationError(AgentCommandError):
|
||||
"""The Agent result does not match its command contract."""
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Typed contracts for the Agent queue protocol."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class AgentCommand(BaseModel):
|
||||
command_id: str
|
||||
command_type: str
|
||||
request_payload: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentCommandListResponse(BaseModel):
|
||||
ok: Literal[True] = True
|
||||
commands: list[AgentCommand]
|
||||
|
||||
|
||||
class MinerControlResultPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal[1]
|
||||
command_accepted: bool
|
||||
verification_attempted: bool
|
||||
verification_status: Literal[
|
||||
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||
]
|
||||
observed_service_state: None = None
|
||||
observed_performance_mode: Literal["low", "normal", "high"] | None = None
|
||||
failure_code: str | None = Field(
|
||||
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_truthfulness(self) -> "MinerControlResultPayload":
|
||||
if self.verification_status == "confirmed" and not self.verification_attempted:
|
||||
raise ValueError("confirmed results require verification_attempted")
|
||||
if not self.command_accepted and not self.failure_code:
|
||||
raise ValueError("rejected commands require a failure_code")
|
||||
return self
|
||||
|
||||
|
||||
class AgentCommandResultRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
command_id: str = Field(min_length=1, max_length=128)
|
||||
status: Literal["completed", "failed"]
|
||||
result_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
error_message: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentCommandResultResponse(BaseModel):
|
||||
ok: Literal[True] = True
|
||||
command_id: str
|
||||
status: Literal["completed", "failed"]
|
||||
@@ -1,130 +0,0 @@
|
||||
"""PostgreSQL persistence for Agent command transport."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
CLAIM_COMMANDS_SQL = """
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = %s
|
||||
AND command_status = 'pending'
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT %s
|
||||
)
|
||||
UPDATE minerops_agent_command_queue AS queue
|
||||
SET command_status = 'claimed', claimed_at = now()
|
||||
FROM candidates
|
||||
WHERE queue.id = candidates.id
|
||||
AND queue.command_status = 'pending'
|
||||
RETURNING queue.id AS command_id, queue.command_type,
|
||||
queue.request_payload, queue.created_at
|
||||
"""
|
||||
|
||||
|
||||
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 AgentCommandRepository:
|
||||
"""Own queue locking and immutable terminal transitions."""
|
||||
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
connection.commit()
|
||||
return rows
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_command(self, command_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, agent_id, command_type, command_status, request_payload
|
||||
FROM minerops_agent_command_queue WHERE id = %s
|
||||
""",
|
||||
(command_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def finish_command(
|
||||
self,
|
||||
command_id: str,
|
||||
agent_id: str,
|
||||
status: str,
|
||||
result_payload: dict[str, Any],
|
||||
error_message: str | None,
|
||||
) -> None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET command_status = %s, result_payload = %s,
|
||||
error_message = %s, completed_at = now()
|
||||
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
|
||||
RETURNING id
|
||||
""",
|
||||
(status, Jsonb(result_payload), error_message, command_id, agent_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
connection.commit()
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id, command_status
|
||||
FROM minerops_agent_command_queue WHERE id = %s
|
||||
""",
|
||||
(command_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
connection.rollback()
|
||||
if not row:
|
||||
raise AgentCommandNotFound("Agent command not found")
|
||||
if row["agent_id"] != agent_id:
|
||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||
raise AgentCommandConflict(
|
||||
f"Agent command is {row['command_status']}, not claimed"
|
||||
)
|
||||
except (AgentCommandNotFound, AgentCommandConflict):
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1,57 +0,0 @@
|
||||
"""HTTP boundary for authenticated Agent command transport."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentCommandNotFound,
|
||||
AgentResultValidationError,
|
||||
)
|
||||
from app.agent_commands.models import (
|
||||
AgentCommandListResponse,
|
||||
AgentCommandResultRequest,
|
||||
AgentCommandResultResponse,
|
||||
)
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
||||
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
||||
|
||||
|
||||
@router.get("/pending", response_model=AgentCommandListResponse)
|
||||
def claim_pending_commands(
|
||||
request: Request, agent_id: AgentIdentity, service: AgentService
|
||||
) -> AgentCommandListResponse:
|
||||
before_claim = getattr(
|
||||
request.app.state, "agent_command_before_claim", None
|
||||
)
|
||||
if callable(before_claim):
|
||||
try:
|
||||
before_claim()
|
||||
except Exception:
|
||||
logger.exception("Scheduled command materialization failed before Agent claim")
|
||||
return AgentCommandListResponse(commands=service.claim(agent_id))
|
||||
|
||||
|
||||
@router.post("/result", response_model=AgentCommandResultResponse)
|
||||
def submit_command_result(
|
||||
request: AgentCommandResultRequest,
|
||||
agent_id: AgentIdentity,
|
||||
service: AgentService,
|
||||
) -> AgentCommandResultResponse:
|
||||
try:
|
||||
service.submit_result(agent_id, request)
|
||||
except AgentCommandNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except AgentCommandConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except AgentResultValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Agent command transport coordination and result validation."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.agent_commands.exceptions import (
|
||||
AgentCommandConflict,
|
||||
AgentCommandNotFound,
|
||||
AgentResultValidationError,
|
||||
)
|
||||
from app.agent_commands.models import (
|
||||
AgentCommand,
|
||||
AgentCommandResultRequest,
|
||||
MinerControlResultPayload,
|
||||
)
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
|
||||
|
||||
class AgentCommandService:
|
||||
def __init__(self, repository: AgentCommandRepository):
|
||||
self.repository = repository
|
||||
|
||||
def claim(self, agent_id: str) -> list[AgentCommand]:
|
||||
return [
|
||||
AgentCommand.model_validate(row)
|
||||
for row in self.repository.claim_pending(agent_id)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _legacy_error(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
|
||||
return sanitized[:255]
|
||||
|
||||
def submit_result(
|
||||
self, agent_id: str, request: AgentCommandResultRequest
|
||||
) -> None:
|
||||
command = self.repository.get_command(request.command_id)
|
||||
if not command:
|
||||
raise AgentCommandNotFound("Agent command not found")
|
||||
if command["agent_id"] != agent_id:
|
||||
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||
if command["command_status"] != "claimed":
|
||||
raise AgentCommandConflict(
|
||||
f"Agent command is {command['command_status']}, not claimed"
|
||||
)
|
||||
|
||||
result_payload = request.result_payload
|
||||
error_message = self._legacy_error(request.error_message)
|
||||
if command["command_type"] == "miner_control":
|
||||
try:
|
||||
result = MinerControlResultPayload.model_validate(request.result_payload)
|
||||
except ValidationError as exc:
|
||||
raise AgentResultValidationError(
|
||||
"Invalid typed miner-control result"
|
||||
) from exc
|
||||
if request.status == "completed" and not result.command_accepted:
|
||||
raise AgentResultValidationError(
|
||||
"Completed miner-control results must be accepted"
|
||||
)
|
||||
if request.status == "failed" and result.command_accepted:
|
||||
raise AgentResultValidationError(
|
||||
"Failed miner-control results cannot be accepted"
|
||||
)
|
||||
action = (command.get("request_payload") or {}).get("action")
|
||||
if (
|
||||
action in {"suspend", "resume"}
|
||||
and result.command_accepted
|
||||
and result.verification_status != "unconfirmed"
|
||||
):
|
||||
raise AgentResultValidationError(
|
||||
"Accepted suspend and resume results must remain unconfirmed"
|
||||
)
|
||||
result_payload = result.model_dump(mode="json")
|
||||
error_message = (
|
||||
result.failure_code.replace("_", " ") if result.failure_code else None
|
||||
)
|
||||
|
||||
self.repository.finish_command(
|
||||
request.command_id,
|
||||
agent_id,
|
||||
request.status,
|
||||
result_payload,
|
||||
error_message,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Secure one-time Agent enrollment."""
|
||||
@@ -1,8 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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."""
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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."""
|
||||
@@ -1,506 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,159 +0,0 @@
|
||||
"""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
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Downloadable MinerOps Agent deployment packages."""
|
||||
@@ -1,164 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Read-only Agent registry projection."""
|
||||
@@ -1,8 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,62 +0,0 @@
|
||||
"""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
|
||||
@@ -1,127 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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),
|
||||
},
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
"""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 +0,0 @@
|
||||
"""Agent Zone routing domain."""
|
||||
@@ -1,8 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,17 +0,0 @@
|
||||
"""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."""
|
||||
@@ -1,114 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,300 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,75 +0,0 @@
|
||||
"""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
|
||||
@@ -1,194 +0,0 @@
|
||||
"""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
|
||||
),
|
||||
)
|
||||
@@ -5,8 +5,6 @@ class Settings(BaseSettings):
|
||||
app_name: str = "MinerOps Console"
|
||||
app_env: str = "development"
|
||||
app_timezone: str = "America/Winnipeg"
|
||||
minerops_build_commit: str | None = None
|
||||
minerops_build_version: str | None = None
|
||||
|
||||
postgres_db: str = "minerops"
|
||||
postgres_user: str = "minerops"
|
||||
@@ -16,9 +14,6 @@ class Settings(BaseSettings):
|
||||
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
minerops_agent_id: str = "plexus-windows-agent"
|
||||
minerops_agent_shared_secret: str = ""
|
||||
|
||||
jwt_secret_key: str
|
||||
jwt_access_token_minutes: int = 30
|
||||
jwt_refresh_token_days: int = 14
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""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>
|
||||
"""
|
||||
@@ -48,38 +48,6 @@ def miner_detail_content() -> str:
|
||||
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel miner-controls" id="minerControlsSection"
|
||||
aria-labelledby="minerControlsTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
|
||||
<h2 id="minerControlsTitle">Controls</h2></div>
|
||||
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
|
||||
</div>
|
||||
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
|
||||
Loading factual capability and recent operations.
|
||||
</p>
|
||||
<div id="minerControlCapabilities" class="control-capability-grid"></div>
|
||||
<div id="minerControlActions" class="miner-control-actions" hidden>
|
||||
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
|
||||
<form id="minerPerformanceControl" class="performance-control" hidden>
|
||||
<label for="minerPerformanceMode">Performance mode</label>
|
||||
<select id="minerPerformanceMode">
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<button class="command-button" type="submit">Set mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<p id="minerControlPermission" class="permission-note" hidden>
|
||||
Your account can inspect control capability and history but cannot queue controls.
|
||||
</p>
|
||||
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
|
||||
<div class="table-wrap compact-history-table">
|
||||
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
|
||||
<th>Verification</th><th>Created</th></tr></thead>
|
||||
<tbody id="minerControlHistory"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
||||
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
||||
<h2 id="minerLocationTitle">Location</h2></div></div>
|
||||
|
||||
@@ -18,7 +18,6 @@ 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:
|
||||
@@ -93,27 +92,6 @@ 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)
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
(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);
|
||||
}());
|
||||
@@ -1722,135 +1722,3 @@ button.map-position:hover {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
.control-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.control-capability-grid > div {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.miner-control-actions {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.performance-control label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-callout {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-left: 4px solid var(--warn);
|
||||
background: #fff8e6;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.control-capability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.miner-control-actions,
|
||||
.performance-control {
|
||||
align-items: stretch;
|
||||
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);
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
const minerId = document.body.dataset.minerId;
|
||||
const returnTo = document.body.dataset.returnTo || "";
|
||||
const canControl = document.body.dataset.canControlMiners === "true";
|
||||
const el = {
|
||||
loading: document.getElementById("minerDetailLoading"),
|
||||
error: document.getElementById("minerDetailError"),
|
||||
@@ -33,17 +32,7 @@
|
||||
identityHistory: document.getElementById("minerIdentityHistory"),
|
||||
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
||||
observations: document.getElementById("minerObservations"),
|
||||
observationsBody: document.getElementById("minerObservationsBody"),
|
||||
controlsRefresh: document.getElementById("minerControlsRefresh"),
|
||||
controlsSummary: document.getElementById("minerControlsSummary"),
|
||||
controlCapabilities: document.getElementById("minerControlCapabilities"),
|
||||
controlActions: document.getElementById("minerControlActions"),
|
||||
controlPermission: document.getElementById("minerControlPermission"),
|
||||
controlResult: document.getElementById("minerControlResult"),
|
||||
serviceControl: document.getElementById("minerServiceControl"),
|
||||
performanceForm: document.getElementById("minerPerformanceControl"),
|
||||
performanceMode: document.getElementById("minerPerformanceMode"),
|
||||
controlHistory: document.getElementById("minerControlHistory")
|
||||
observationsBody: document.getElementById("minerObservationsBody")
|
||||
};
|
||||
|
||||
function create(tag, className, text) {
|
||||
@@ -132,123 +121,6 @@
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
async function mutate(path, body) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.assign("/login?next=" + encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
));
|
||||
throw new Error("Login required");
|
||||
}
|
||||
let payload = null;
|
||||
try { payload = await response.json(); } catch (_error) { payload = null; }
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && payload.detail
|
||||
? String(payload.detail) : "Request failed (" + response.status + ")");
|
||||
}
|
||||
return payload ? payload.data : null;
|
||||
}
|
||||
|
||||
function capabilityText(capability) {
|
||||
if (!capability) return "Unknown";
|
||||
const reason = capability.reason ? " / " + words(capability.reason) : "";
|
||||
return words(capability.state) + reason;
|
||||
}
|
||||
|
||||
function operationStatus(operation) {
|
||||
if (operation.status === "pending") return "Pending";
|
||||
if (operation.status === "claimed") return "Running";
|
||||
if (operation.status === "failed") return "Failed";
|
||||
if (operation.status === "cancelled") return "Cancelled";
|
||||
if (operation.verification_status === "confirmed") return "Confirmed";
|
||||
if (operation.verification_status === "unconfirmed") return "Unconfirmed";
|
||||
if (operation.verification_status === "verification_failed") return "Verification failed";
|
||||
if (operation.verification_status === "unavailable") return "Verification unavailable";
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
function renderControls(data) {
|
||||
el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) +
|
||||
" / Known mode: " + words(data.performance_mode);
|
||||
el.controlCapabilities.replaceChildren();
|
||||
[["Agent", data.agent], ["Service control", data.service_control],
|
||||
["Performance mode", data.performance_mode_control]].forEach(function (item) {
|
||||
const block = create("div");
|
||||
block.append(create("span", "secondary", item[0]),
|
||||
create("strong", "", capabilityText(item[1])));
|
||||
el.controlCapabilities.appendChild(block);
|
||||
});
|
||||
|
||||
el.controlPermission.hidden = canControl;
|
||||
el.controlActions.hidden = !canControl;
|
||||
el.serviceControl.hidden = true;
|
||||
el.performanceForm.hidden = true;
|
||||
if (canControl && data.available_service_action) {
|
||||
el.serviceControl.dataset.action = data.available_service_action;
|
||||
el.serviceControl.textContent = data.available_service_action === "suspend"
|
||||
? "Suspend miner" : "Resume miner";
|
||||
el.serviceControl.hidden = false;
|
||||
}
|
||||
if (canControl && data.performance_mode_control.state === "supported") {
|
||||
if (data.performance_mode) el.performanceMode.value = data.performance_mode;
|
||||
el.performanceForm.hidden = false;
|
||||
}
|
||||
|
||||
el.controlHistory.replaceChildren();
|
||||
(data.recent_operations || []).forEach(function (operation) {
|
||||
const row = create("tr");
|
||||
[words(operation.action), display(operation.requested_value, "None"),
|
||||
operationStatus(operation), words(operation.verification_status),
|
||||
dateTime(operation.created_at)].forEach(function (value) {
|
||||
row.appendChild(create("td", "", value));
|
||||
});
|
||||
el.controlHistory.appendChild(row);
|
||||
});
|
||||
if (!(data.recent_operations || []).length) {
|
||||
const row = create("tr");
|
||||
const cell = create("td", "empty-copy", "No control operations recorded.");
|
||||
cell.colSpan = 5;
|
||||
row.appendChild(cell);
|
||||
el.controlHistory.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControls() {
|
||||
el.controlsSummary.textContent = "Loading control capability and history.";
|
||||
try {
|
||||
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls");
|
||||
renderControls(data);
|
||||
} catch (error) {
|
||||
el.controlsSummary.textContent = "Controls unavailable: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueControl(action, requestedValue) {
|
||||
const description = action === "performance_mode"
|
||||
? "set performance mode to " + requestedValue : action + " this miner";
|
||||
if (!window.confirm("Confirm: " + description + "?")) return;
|
||||
const pathAction = action === "performance_mode" ? "performance-mode" : action;
|
||||
const body = action === "performance_mode" ? { mode: requestedValue } : {};
|
||||
el.controlResult.hidden = false;
|
||||
el.controlResult.textContent = "Queueing control operation.";
|
||||
try {
|
||||
const operation = await mutate(
|
||||
"/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction,
|
||||
body
|
||||
);
|
||||
el.controlResult.textContent = "Pending: " + words(operation.action) +
|
||||
" operation " + operation.operation_id + ".";
|
||||
await loadControls();
|
||||
} catch (error) {
|
||||
el.controlResult.textContent = "Control was not queued: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
const wrap = create("div", "table-wrap compact-history-table");
|
||||
const tableNode = create("table");
|
||||
@@ -541,21 +413,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadOverview();
|
||||
loadControls();
|
||||
}
|
||||
|
||||
el.retry.addEventListener("click", loadOverview);
|
||||
el.refresh.addEventListener("click", refreshAll);
|
||||
el.controlsRefresh.addEventListener("click", loadControls);
|
||||
el.serviceControl.addEventListener("click", function () {
|
||||
queueControl(el.serviceControl.dataset.action, null);
|
||||
});
|
||||
el.performanceForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
queueControl("performance_mode", el.performanceMode.value);
|
||||
});
|
||||
el.refresh.addEventListener("click", loadOverview);
|
||||
el.identityHistory.addEventListener("toggle", function () {
|
||||
if (el.identityHistory.open) loadIdentityHistory();
|
||||
});
|
||||
@@ -563,5 +422,4 @@
|
||||
if (el.observations.open) loadObservations();
|
||||
});
|
||||
loadOverview();
|
||||
loadControls();
|
||||
})();
|
||||
@@ -1,139 +0,0 @@
|
||||
.health-toolbar,
|
||||
.health-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.health-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.health-toolbar h2,
|
||||
.health-toolbar p,
|
||||
.health-section h2,
|
||||
.health-section h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.health-toolbar p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted-text, #52606d);
|
||||
}
|
||||
|
||||
.health-notice {
|
||||
border-left: 4px solid #64748b;
|
||||
background: #f1f5f9;
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.health-notice[data-state="partial"] {
|
||||
border-left-color: #b45309;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.health-notice[data-state="error"] {
|
||||
border-left-color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.health-section {
|
||||
border-top: 1px solid #cbd5e1;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.health-columns,
|
||||
.health-integrity-grid,
|
||||
.health-facts {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.health-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.health-integrity-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.health-facts {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.health-facts-compact {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.health-facts div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-facts dt {
|
||||
color: #52606d;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.health-facts dd {
|
||||
font-weight: 700;
|
||||
margin: 3px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.health-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.health-links a:focus-visible,
|
||||
#healthRefresh:focus-visible {
|
||||
outline: 3px solid #0f766e;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
border-bottom: 1px solid #dbe2e8;
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.health-integrity-grid,
|
||||
.health-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.health-toolbar,
|
||||
.health-section-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.health-columns,
|
||||
.health-integrity-grid,
|
||||
.health-facts,
|
||||
.health-facts-compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#healthRefresh {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
const notice = document.getElementById("healthNotice");
|
||||
const content = document.getElementById("healthContent");
|
||||
const refresh = document.getElementById("healthRefresh");
|
||||
|
||||
function setText(id, value, fallback) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.textContent = value === null || value === undefined || value === ""
|
||||
? (fallback || "Unavailable") : String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
return typeof value === "number" ? value.toLocaleString() : "Unavailable";
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
if (!value) return "Unavailable";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.valueOf()) ? "Unavailable" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function factualAge(value) {
|
||||
if (!value) return "Unavailable";
|
||||
const observed = new Date(value);
|
||||
if (Number.isNaN(observed.valueOf())) return "Unavailable";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - observed.valueOf()) / 1000));
|
||||
if (seconds < 60) return seconds + " seconds ago";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
|
||||
const days = Math.floor(hours / 24);
|
||||
return days + (days === 1 ? " day ago" : " days ago");
|
||||
}
|
||||
|
||||
function replaceFacts(id, facts) {
|
||||
const list = document.getElementById(id);
|
||||
list.replaceChildren();
|
||||
for (const [label, value] of facts) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const detail = document.createElement("dd");
|
||||
term.textContent = label;
|
||||
detail.textContent = value;
|
||||
row.append(term, detail);
|
||||
list.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
const data = payload.data;
|
||||
const app = data.application;
|
||||
const postgres = data.postgresql;
|
||||
const migration = postgres.migration;
|
||||
const telemetry = postgres.telemetry;
|
||||
setText("healthAppStatus", app.status);
|
||||
setText("healthEnvironment", app.environment);
|
||||
setText("healthBuildCommit", app.build_commit);
|
||||
setText("healthBuildVersion", app.build_version);
|
||||
setText("healthServerTime", timestamp(app.server_time));
|
||||
setText("healthMigrationState", migration.state);
|
||||
setText("healthCurrentRevision", migration.current_revision);
|
||||
setText("healthExpectedRevision", migration.expected_revision);
|
||||
setText("healthPostgresqlStatus", postgres.status);
|
||||
setText("healthRedisStatus", data.redis.status);
|
||||
setText("healthSnapshotCount", telemetry ? number(telemetry.snapshot_count) : null);
|
||||
setText("healthOldestObservation", telemetry ? timestamp(telemetry.oldest_observed_at) : null);
|
||||
setText("healthLatestObservation", telemetry ? timestamp(telemetry.latest_observed_at) : null);
|
||||
setText("healthObservationAge", telemetry ? factualAge(telemetry.latest_observed_at) : null);
|
||||
|
||||
const fleet = postgres.fleet;
|
||||
replaceFacts("healthFleetFacts", fleet ? [
|
||||
["Total miners", number(fleet.total_miners)],
|
||||
["Without Asset ID", number(fleet.miners_without_asset_id)],
|
||||
["Unmapped", number(fleet.unmapped_miners)],
|
||||
["In multiple positions", number(fleet.miners_in_multiple_positions)],
|
||||
["Duplicate current MAC groups", number(fleet.duplicate_current_mac_groups)]
|
||||
] : [["Diagnostics", "Unavailable"]]);
|
||||
const locations = postgres.locations;
|
||||
replaceFacts("healthLocationFacts", locations ? [
|
||||
["Sites", number(locations.sites)], ["Rooms", number(locations.rooms)],
|
||||
["Positions", number(locations.positions)],
|
||||
["Occupied", number(locations.occupied_positions)],
|
||||
["Empty", number(locations.empty_positions)],
|
||||
["Legacy unscoped", number(locations.unscoped_legacy_positions)],
|
||||
["Expected MAC mismatches", number(locations.expected_mac_mismatches)]
|
||||
] : [["Diagnostics", "Unavailable"]]);
|
||||
const repairs = postgres.repairs;
|
||||
replaceFacts("healthRepairFacts", repairs ? [
|
||||
["Active modular repairs", number(repairs.active_modular_repairs)],
|
||||
["Duplicate active groups", number(repairs.duplicate_active_repair_groups)],
|
||||
["Legacy repair only", number(repairs.legacy_repair_only_miners)],
|
||||
["Lifecycle repair without active repair", number(repairs.lifecycle_repair_without_active_repair)],
|
||||
["Active repair lifecycle mismatches", number(repairs.active_repair_lifecycle_mismatches)],
|
||||
["Waiting placement", number(repairs.waiting_placement_records)],
|
||||
["Repair events", number(repairs.repair_events)],
|
||||
["Repair parts", number(repairs.repair_parts)]
|
||||
] : [["Diagnostics", "Unavailable"]]);
|
||||
const retention = postgres.retention;
|
||||
replaceFacts("healthRetentionFacts", retention ? [
|
||||
["Raw telemetry retention", retention.raw_retention_enabled ? "Enabled" : "Not enabled"],
|
||||
["Hourly rollups", retention.hourly.table_present ? "Present; manual population" : "Unavailable"],
|
||||
["Hourly latest bucket", timestamp(retention.hourly.latest_bucket)],
|
||||
["Daily rollups", retention.daily.table_present ? "Present; manual population" : "Unavailable"],
|
||||
["Daily latest bucket", timestamp(retention.daily.latest_bucket)],
|
||||
["Latest recorded run", timestamp(retention.latest_recorded_run_at)],
|
||||
["Latest run mode", retention.latest_recorded_run_mode || "None recorded"],
|
||||
["Completeness", "Not guaranteed"]
|
||||
] : [["Diagnostics", "Unavailable"]]);
|
||||
|
||||
const tableBody = document.getElementById("healthTableCounts");
|
||||
tableBody.replaceChildren();
|
||||
const rows = postgres.table_counts || [];
|
||||
for (const table of rows) {
|
||||
const row = document.createElement("tr");
|
||||
const name = document.createElement("th");
|
||||
const count = document.createElement("td");
|
||||
name.scope = "row";
|
||||
name.textContent = table.name;
|
||||
count.textContent = number(table.row_count);
|
||||
row.append(name, count);
|
||||
tableBody.append(row);
|
||||
}
|
||||
if (!rows.length) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = 2;
|
||||
cell.textContent = "Table diagnostics unavailable.";
|
||||
row.append(cell);
|
||||
tableBody.append(row);
|
||||
}
|
||||
content.hidden = false;
|
||||
notice.dataset.state = payload.meta.partial ? "partial" : "ready";
|
||||
notice.textContent = payload.meta.partial
|
||||
? "Diagnostics loaded with unavailable sections: " + payload.meta.unavailable_sections.join(", ") + "."
|
||||
: "Diagnostics refreshed at " + timestamp(payload.meta.generated_at) + ".";
|
||||
}
|
||||
|
||||
async function load() {
|
||||
refresh.disabled = true;
|
||||
notice.dataset.state = "loading";
|
||||
notice.textContent = "Loading diagnostics.";
|
||||
try {
|
||||
const response = await fetch("/api/system/health-summary", {
|
||||
credentials: "same-origin", headers: {"Accept": "application/json"}
|
||||
});
|
||||
const payload = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok) throw new Error("System Health request failed");
|
||||
render(payload);
|
||||
} catch (error) {
|
||||
notice.dataset.state = "error";
|
||||
notice.textContent = "System Health diagnostics could not be loaded.";
|
||||
} finally {
|
||||
refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
refresh.addEventListener("click", load);
|
||||
load();
|
||||
}());
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Semantic content for the Internal Alpha System Health page."""
|
||||
|
||||
|
||||
def system_health_content() -> str:
|
||||
return """
|
||||
<section class="health-toolbar" aria-labelledby="healthOverviewTitle">
|
||||
<div><h2 id="healthOverviewTitle">Operations diagnostics</h2>
|
||||
<p>Availability, data facts, integrity counts, and maintenance state.</p></div>
|
||||
<button class="primary-button" id="healthRefresh" type="button">Refresh</button>
|
||||
</section>
|
||||
<p class="health-notice" id="healthNotice" role="status" aria-live="polite">Loading diagnostics.</p>
|
||||
<div id="healthContent" hidden>
|
||||
<section class="health-section" aria-labelledby="applicationHealthTitle">
|
||||
<h2 id="applicationHealthTitle">Application</h2>
|
||||
<dl class="health-facts">
|
||||
<div><dt>Application</dt><dd id="healthAppStatus">-</dd></div>
|
||||
<div><dt>Environment</dt><dd id="healthEnvironment">-</dd></div>
|
||||
<div><dt>Build commit</dt><dd id="healthBuildCommit">-</dd></div>
|
||||
<div><dt>Build version</dt><dd id="healthBuildVersion">-</dd></div>
|
||||
<div><dt>Server time</dt><dd id="healthServerTime">-</dd></div>
|
||||
<div><dt>Schema</dt><dd id="healthMigrationState">-</dd></div>
|
||||
<div><dt>Database revision</dt><dd id="healthCurrentRevision">-</dd></div>
|
||||
<div><dt>Expected revision</dt><dd id="healthExpectedRevision">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="health-columns">
|
||||
<section class="health-section" aria-labelledby="servicesHealthTitle"><h2 id="servicesHealthTitle">Service availability</h2>
|
||||
<dl class="health-facts health-facts-compact"><div><dt>PostgreSQL</dt><dd id="healthPostgresqlStatus">-</dd></div>
|
||||
<div><dt>Redis</dt><dd id="healthRedisStatus">-</dd></div></dl></section>
|
||||
<section class="health-section" aria-labelledby="telemetryHealthTitle"><h2 id="telemetryHealthTitle">Telemetry</h2>
|
||||
<dl class="health-facts health-facts-compact"><div><dt>Raw snapshots</dt><dd id="healthSnapshotCount">-</dd></div>
|
||||
<div><dt>Oldest observation</dt><dd id="healthOldestObservation">-</dd></div>
|
||||
<div><dt>Latest observation</dt><dd id="healthLatestObservation">-</dd></div>
|
||||
<div><dt>Latest age</dt><dd id="healthObservationAge">-</dd></div></dl></section>
|
||||
</div>
|
||||
<section class="health-section" aria-labelledby="integrityHealthTitle">
|
||||
<div class="health-section-head"><h2 id="integrityHealthTitle">Operational integrity facts</h2>
|
||||
<div class="health-links"><a href="/assets/miners">Fleet</a><a href="/monitoring/map">Mine Map</a>
|
||||
<a href="/operations/repairs">Repair Center</a><a href="/monitoring/analytics">Trends</a></div></div>
|
||||
<div class="health-integrity-grid">
|
||||
<section aria-labelledby="fleetIntegrityTitle"><h3 id="fleetIntegrityTitle">Fleet</h3><dl id="healthFleetFacts" class="health-facts health-facts-compact"></dl></section>
|
||||
<section aria-labelledby="locationIntegrityTitle"><h3 id="locationIntegrityTitle">Locations</h3><dl id="healthLocationFacts" class="health-facts health-facts-compact"></dl></section>
|
||||
<section aria-labelledby="repairIntegrityTitle"><h3 id="repairIntegrityTitle">Repairs</h3><dl id="healthRepairFacts" class="health-facts health-facts-compact"></dl></section>
|
||||
</div>
|
||||
</section>
|
||||
<div class="health-columns">
|
||||
<section class="health-section" aria-labelledby="retentionHealthTitle"><h2 id="retentionHealthTitle">Retention and rollups</h2>
|
||||
<dl id="healthRetentionFacts" class="health-facts health-facts-compact"></dl></section>
|
||||
<section class="health-section" aria-labelledby="tablesHealthTitle"><h2 id="tablesHealthTitle">Operational tables</h2>
|
||||
<div class="table-scroll"><table><thead><tr><th scope="col">Table</th><th scope="col">Rows</th></tr></thead>
|
||||
<tbody id="healthTableCounts"></tbody></table></div></section>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
@@ -5,8 +5,6 @@ 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,8 +13,6 @@ NAVIGATION = (
|
||||
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
|
||||
("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 = {
|
||||
@@ -50,16 +46,6 @@ PAGE_DETAILS = {
|
||||
"Miner Detail",
|
||||
"Current identity, operating facts, location, and authorized history.",
|
||||
),
|
||||
"system_health": (
|
||||
"Administration / Diagnostics",
|
||||
"System Health",
|
||||
"Inspect application services, operational data, and maintenance facts.",
|
||||
),
|
||||
"agent_management": (
|
||||
"Administration / Agents",
|
||||
"Agent Management",
|
||||
"Inspect Agent identities, routing boundaries, and secure enrollment.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -469,11 +455,7 @@ def render_app_page(
|
||||
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||
)
|
||||
content = (
|
||||
agent_management_content(has_permission("agents.manage"))
|
||||
if view == "agent_management"
|
||||
else system_health_content()
|
||||
if view == "system_health"
|
||||
else miner_detail_content()
|
||||
miner_detail_content()
|
||||
if view == "miner_detail"
|
||||
else monitoring_trends_content(has_permission("locations.view"))
|
||||
if view == "analytics"
|
||||
@@ -492,9 +474,7 @@ def render_app_page(
|
||||
)
|
||||
)
|
||||
page_script = (
|
||||
'<script src="/assets/minerops-system-health.js" defer></script>'
|
||||
if view == "system_health"
|
||||
else '<script src="/assets/minerops-miner-detail.js" defer></script>'
|
||||
'<script src="/assets/minerops-miner-detail.js" defer></script>'
|
||||
if view == "miner_detail"
|
||||
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
|
||||
if view == "analytics"
|
||||
@@ -509,9 +489,7 @@ def render_app_page(
|
||||
else ""
|
||||
)
|
||||
page_style = (
|
||||
'<link rel="stylesheet" href="/assets/minerops-system-health.css">'
|
||||
if view == "system_health"
|
||||
else '<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
|
||||
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
|
||||
if view == "analytics" else ""
|
||||
)
|
||||
object_attributes = ""
|
||||
@@ -530,7 +508,6 @@ def render_app_page(
|
||||
</head>
|
||||
<body data-view="{escape(view)}"
|
||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
|
||||
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
|
||||
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||
<div class="app-shell">
|
||||
|
||||
@@ -699,10 +699,8 @@ 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",
|
||||
@@ -759,17 +757,6 @@ def component_request(path: str) -> bool:
|
||||
|
||||
def required_api_permission(method: str, path: str) -> str | None:
|
||||
parts = path.split("/")
|
||||
if (
|
||||
len(parts) >= 5
|
||||
and parts[:3] == ["", "api", "miners"]
|
||||
and parts[4] == "controls"
|
||||
):
|
||||
if method == "GET" and len(parts) == 5:
|
||||
return "miners.view"
|
||||
if method == "POST" and len(parts) == 6 and parts[5] in {
|
||||
"suspend", "resume", "performance-mode"
|
||||
}:
|
||||
return "miners.control"
|
||||
if (
|
||||
method == "GET"
|
||||
and len(parts) == 5
|
||||
@@ -779,14 +766,6 @@ 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/"):
|
||||
|
||||
+1188
-329
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
||||
"""Typed miner control domain."""
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Dependency construction for miner control."""
|
||||
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
def get_miner_control_service() -> MinerControlService:
|
||||
return MinerControlService(MinerControlRepository())
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Miner control domain exceptions."""
|
||||
|
||||
|
||||
class MinerControlError(Exception):
|
||||
"""Base miner control error."""
|
||||
|
||||
|
||||
class ControlledMinerNotFound(MinerControlError):
|
||||
"""The durable miner UUID is unknown."""
|
||||
|
||||
|
||||
class MinerControlUnavailable(MinerControlError):
|
||||
"""The requested action cannot be safely queued."""
|
||||
|
||||
|
||||
class MinerControlConflict(MinerControlError):
|
||||
"""Another live control operation already exists."""
|
||||
|
||||
|
||||
class MinerControlActorRequired(MinerControlError):
|
||||
"""An authenticated actor UUID is required."""
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Typed contracts for miner control capabilities and operations."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ControlAction = Literal["suspend", "resume", "performance_mode"]
|
||||
CapabilityState = Literal["supported", "unavailable", "unknown"]
|
||||
QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"]
|
||||
VerificationStatus = Literal[
|
||||
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||
]
|
||||
PerformanceMode = Literal["low", "normal", "high"]
|
||||
|
||||
|
||||
class EmptyControlRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PerformanceModeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: PerformanceMode
|
||||
|
||||
|
||||
class ControlCapability(BaseModel):
|
||||
state: CapabilityState
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ControlAgentAvailability(BaseModel):
|
||||
state: CapabilityState
|
||||
reason: str | None = None
|
||||
last_checkin_at: AwareDatetime | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class MinerControlOperation(BaseModel):
|
||||
operation_id: str
|
||||
miner_id: UUID
|
||||
action: ControlAction
|
||||
requested_value: str | None = None
|
||||
status: QueueStatus
|
||||
verification_status: VerificationStatus | None = None
|
||||
command_accepted: bool | None = None
|
||||
observed_performance_mode: PerformanceMode | None = None
|
||||
failure_code: str | None = None
|
||||
created_at: AwareDatetime
|
||||
claimed_at: AwareDatetime | None = None
|
||||
completed_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class MinerControlProjection(BaseModel):
|
||||
miner_id: UUID
|
||||
runtime_state: str | None = None
|
||||
performance_mode: PerformanceMode | None = None
|
||||
agent: ControlAgentAvailability
|
||||
service_control: ControlCapability
|
||||
performance_mode_control: ControlCapability
|
||||
available_service_action: Literal["suspend", "resume"] | None = None
|
||||
recent_operations: list[MinerControlOperation]
|
||||
|
||||
|
||||
class MinerControlResponse(BaseModel):
|
||||
data: MinerControlProjection
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class MinerControlAccepted(BaseModel):
|
||||
operation_id: str
|
||||
miner_id: UUID
|
||||
action: ControlAction
|
||||
requested_value: str | None = None
|
||||
status: Literal["pending"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MinerControlAcceptedResponse(BaseModel):
|
||||
data: MinerControlAccepted
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -1,140 +0,0 @@
|
||||
"""PostgreSQL persistence for typed miner control operations."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
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.config import settings
|
||||
from app.miner_control.exceptions import MinerControlConflict
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control"
|
||||
|
||||
|
||||
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 MinerControlRepository:
|
||||
"""Resolve control facts and persist queue/audit rows atomically."""
|
||||
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_control_context(self, miner_id: UUID) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
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.agent_zone_id = miners.agent_zone_id
|
||||
WHERE miners.id = %s
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id AS operation_id, command_status AS status,
|
||||
request_payload, result_payload, created_at,
|
||||
claimed_at, completed_at
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE command_type = 'miner_control'
|
||||
AND request_payload ->> 'miner_id' = %s
|
||||
AND request_payload ->> 'schema_version' = '1'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(str(miner_id), limit),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def enqueue_with_audit(
|
||||
self,
|
||||
*,
|
||||
operation_id: str,
|
||||
miner_id: UUID,
|
||||
agent_id: str,
|
||||
actor_user_id: UUID,
|
||||
actor_username: str,
|
||||
request_payload: dict[str, Any],
|
||||
audit_payload: dict[str, Any],
|
||||
) -> dict:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_agent_command_queue (
|
||||
id, agent_id, command_type, command_status,
|
||||
requested_by_user_id, requested_by_username, request_payload
|
||||
)
|
||||
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s)
|
||||
RETURNING id AS operation_id, command_status AS status, created_at
|
||||
""",
|
||||
(
|
||||
operation_id, agent_id, str(actor_user_id), actor_username,
|
||||
Jsonb(request_payload),
|
||||
),
|
||||
)
|
||||
operation = dict(cur.fetchone())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO audit_logs (
|
||||
actor_user_id, action, entity_type, entity_id,
|
||||
new_value_json, related_miner_asset_id
|
||||
)
|
||||
VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s)
|
||||
""",
|
||||
(actor_user_id, miner_id, Jsonb(audit_payload), miner_id),
|
||||
)
|
||||
connection.commit()
|
||||
return operation
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT:
|
||||
raise MinerControlConflict(
|
||||
"A pending or running control operation already exists for this miner"
|
||||
) from exc
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1,129 +0,0 @@
|
||||
"""HTTP mapping for typed miner controls."""
|
||||
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.identity import require_permission
|
||||
from app.miner_control.dependencies import get_miner_control_service
|
||||
from app.miner_control.exceptions import (
|
||||
ControlledMinerNotFound,
|
||||
MinerControlConflict,
|
||||
MinerControlUnavailable,
|
||||
)
|
||||
from app.miner_control.models import (
|
||||
EmptyControlRequest,
|
||||
MinerControlAcceptedResponse,
|
||||
MinerControlResponse,
|
||||
PerformanceModeRequest,
|
||||
)
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/miners", tags=["miner-controls"])
|
||||
ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)]
|
||||
ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
ControlUser = Annotated[dict, Depends(require_permission("miners.control"))]
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> None:
|
||||
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
|
||||
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||
origin = request.headers.get("origin")
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
|
||||
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||
|
||||
|
||||
def _actor(current_user: dict) -> tuple[UUID, str]:
|
||||
return UUID(str(current_user["id"])), str(
|
||||
current_user.get("username") or current_user.get("email") or "unknown"
|
||||
)
|
||||
|
||||
|
||||
def _queue(
|
||||
service: MinerControlService,
|
||||
miner_id: UUID,
|
||||
action: str,
|
||||
requested_value: str | None,
|
||||
current_user: dict,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
actor_id, username = _actor(current_user)
|
||||
try:
|
||||
operation = service.queue_control(
|
||||
miner_id,
|
||||
action,
|
||||
requested_value=requested_value,
|
||||
actor_user_id=actor_id,
|
||||
actor_username=username,
|
||||
)
|
||||
except ControlledMinerNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||
except MinerControlUnavailable as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except MinerControlConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return MinerControlAcceptedResponse(data=operation)
|
||||
|
||||
|
||||
@router.get("/{miner_id}/controls", response_model=MinerControlResponse)
|
||||
def get_miner_controls(
|
||||
miner_id: UUID,
|
||||
current_user: ControlViewer,
|
||||
service: ControlService,
|
||||
) -> MinerControlResponse:
|
||||
del current_user
|
||||
try:
|
||||
return MinerControlResponse(data=service.get_controls(miner_id))
|
||||
except ControlledMinerNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/suspend",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def suspend_miner(
|
||||
miner_id: UUID,
|
||||
payload: EmptyControlRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
del payload
|
||||
return _queue(service, miner_id, "suspend", None, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/resume",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def resume_miner(
|
||||
miner_id: UUID,
|
||||
payload: EmptyControlRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
del payload
|
||||
return _queue(service, miner_id, "resume", None, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{miner_id}/controls/performance-mode",
|
||||
response_model=MinerControlAcceptedResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def set_performance_mode(
|
||||
miner_id: UUID,
|
||||
payload: PerformanceModeRequest,
|
||||
current_user: ControlUser,
|
||||
service: ControlService,
|
||||
) -> MinerControlAcceptedResponse:
|
||||
return _queue(service, miner_id, "performance_mode", payload.mode, current_user)
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Business rules for typed, Agent-routed miner controls."""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.miner_control.exceptions import (
|
||||
ControlledMinerNotFound,
|
||||
MinerControlActorRequired,
|
||||
MinerControlUnavailable,
|
||||
)
|
||||
from app.miner_control.models import (
|
||||
ControlAgentAvailability,
|
||||
ControlCapability,
|
||||
MinerControlAccepted,
|
||||
MinerControlOperation,
|
||||
MinerControlProjection,
|
||||
)
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
|
||||
|
||||
KNOWN_MODES = {"low", "normal", "high"}
|
||||
RUNNING_STATES = {"online", "running"}
|
||||
STOPPED_STATES = {"suspended", "stopped"}
|
||||
|
||||
|
||||
class MinerControlService:
|
||||
def __init__(self, repository: MinerControlRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _capability(context: dict) -> ControlCapability:
|
||||
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_unavailable")
|
||||
if not context.get("current_ip"):
|
||||
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||
if context.get("api3_supported") is False:
|
||||
return ControlCapability(state="unavailable", reason="api3_unsupported")
|
||||
if context.get("api3_supported") is not True:
|
||||
return ControlCapability(state="unknown", reason="api3_support_unknown")
|
||||
|
||||
payload = context.get("last_payload") or {}
|
||||
if payload.get("control_available") is False:
|
||||
reason = payload.get("control_unavailable_reason")
|
||||
return ControlCapability(
|
||||
state="unavailable",
|
||||
reason=reason if reason == "credentials_missing" else "agent_unavailable",
|
||||
)
|
||||
if payload.get("control_available") is not True:
|
||||
return ControlCapability(
|
||||
state="unknown", reason="agent_capability_unknown"
|
||||
)
|
||||
return ControlCapability(state="supported")
|
||||
|
||||
@staticmethod
|
||||
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
||||
request = row.get("request_payload") or {}
|
||||
result = row.get("result_payload") or {}
|
||||
mode = result.get("observed_performance_mode")
|
||||
return MinerControlOperation(
|
||||
operation_id=row["operation_id"],
|
||||
miner_id=miner_id,
|
||||
action=request.get("action"),
|
||||
requested_value=request.get("requested_value"),
|
||||
status=row["status"],
|
||||
verification_status=result.get("verification_status"),
|
||||
command_accepted=result.get("command_accepted"),
|
||||
observed_performance_mode=mode if mode in KNOWN_MODES else None,
|
||||
failure_code=result.get("failure_code"),
|
||||
created_at=row["created_at"],
|
||||
claimed_at=row.get("claimed_at"),
|
||||
completed_at=row.get("completed_at"),
|
||||
)
|
||||
|
||||
def get_controls(self, miner_id: UUID) -> MinerControlProjection:
|
||||
context = self.repository.get_control_context(miner_id)
|
||||
if not context:
|
||||
raise ControlledMinerNotFound("Miner not found")
|
||||
capability = self._capability(context)
|
||||
runtime = str(context.get("runtime_state") or "").strip().lower() or None
|
||||
service_action = None
|
||||
if capability.state == "supported":
|
||||
if runtime in RUNNING_STATES:
|
||||
service_action = "suspend"
|
||||
elif runtime in STOPPED_STATES:
|
||||
service_action = "resume"
|
||||
configured_mode = str(context.get("configured_performance_mode") or "").lower()
|
||||
return MinerControlProjection(
|
||||
miner_id=miner_id,
|
||||
runtime_state=runtime,
|
||||
performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None),
|
||||
agent=ControlAgentAvailability(
|
||||
state=capability.state,
|
||||
reason=capability.reason,
|
||||
last_checkin_at=context.get("last_checkin_at"),
|
||||
version=context.get("agent_version"),
|
||||
),
|
||||
service_control=capability,
|
||||
performance_mode_control=capability,
|
||||
available_service_action=service_action,
|
||||
recent_operations=[
|
||||
self._operation(miner_id, row)
|
||||
for row in self.repository.list_recent(miner_id, limit=5)
|
||||
],
|
||||
)
|
||||
|
||||
def queue_control(
|
||||
self,
|
||||
miner_id: UUID,
|
||||
action: str,
|
||||
*,
|
||||
requested_value: str | None,
|
||||
actor_user_id: UUID,
|
||||
actor_username: str,
|
||||
) -> MinerControlAccepted:
|
||||
if not actor_user_id:
|
||||
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
||||
context = self.repository.get_control_context(miner_id)
|
||||
if not context:
|
||||
raise ControlledMinerNotFound("Miner not found")
|
||||
capability = self._capability(context)
|
||||
if capability.state != "supported":
|
||||
raise MinerControlUnavailable(
|
||||
capability.reason or "control_capability_not_supported"
|
||||
)
|
||||
|
||||
runtime = str(context.get("runtime_state") or "").strip().lower()
|
||||
if action == "suspend" and runtime not in RUNNING_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_running")
|
||||
if action == "resume" and runtime not in STOPPED_STATES:
|
||||
raise MinerControlUnavailable("miner_not_factually_stopped")
|
||||
if action == "performance_mode" and requested_value not in KNOWN_MODES:
|
||||
raise MinerControlUnavailable("invalid_performance_mode")
|
||||
|
||||
operation_id = str(uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
"ip_address": context["current_ip"],
|
||||
}
|
||||
operation = self.repository.enqueue_with_audit(
|
||||
operation_id=operation_id,
|
||||
miner_id=miner_id,
|
||||
agent_id=context["agent_id"],
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
request_payload=payload,
|
||||
audit_payload={
|
||||
"operation_id": operation_id,
|
||||
"miner_id": str(miner_id),
|
||||
"action": action,
|
||||
"requested_value": requested_value,
|
||||
},
|
||||
)
|
||||
return MinerControlAccepted(
|
||||
operation_id=operation["operation_id"],
|
||||
miner_id=miner_id,
|
||||
action=action,
|
||||
requested_value=requested_value,
|
||||
status=operation["status"],
|
||||
created_at=operation["created_at"],
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""System Health diagnostics domain."""
|
||||
@@ -1,29 +0,0 @@
|
||||
"""External service adapters for System Health."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
RedisFactory = Callable[[], redis.Redis]
|
||||
|
||||
|
||||
def default_redis_factory() -> redis.Redis:
|
||||
return redis.Redis.from_url(
|
||||
settings.redis_url,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
)
|
||||
|
||||
|
||||
class RedisHealthAdapter:
|
||||
def __init__(self, factory: RedisFactory | None = None):
|
||||
self._factory = factory or default_redis_factory
|
||||
|
||||
def ping(self) -> bool:
|
||||
try:
|
||||
return bool(self._factory().ping())
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Startup-cached, non-secret application metadata."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _clean_build_value(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
return cleaned[:128] if cleaned else None
|
||||
|
||||
|
||||
def _expected_migration_head() -> str | None:
|
||||
try:
|
||||
backend_root = Path(__file__).resolve().parents[2]
|
||||
config = Config(str(backend_root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(backend_root / "alembic"))
|
||||
return ScriptDirectory.from_config(config).get_current_head()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationMetadata:
|
||||
environment: str
|
||||
build_commit: str | None
|
||||
build_version: str | None
|
||||
expected_migration_head: str | None
|
||||
|
||||
|
||||
APPLICATION_METADATA = ApplicationMetadata(
|
||||
environment=settings.app_env,
|
||||
build_commit=_clean_build_value(settings.minerops_build_commit),
|
||||
build_version=_clean_build_value(settings.minerops_build_version),
|
||||
expected_migration_head=_expected_migration_head(),
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Dependency construction for System Health."""
|
||||
|
||||
from app.system_health.adapters import RedisHealthAdapter
|
||||
from app.system_health.repository import SystemHealthRepository
|
||||
from app.system_health.service import SystemHealthService
|
||||
|
||||
|
||||
def get_system_health_service() -> SystemHealthService:
|
||||
return SystemHealthService(SystemHealthRepository(), RedisHealthAdapter())
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Authenticated frontend routes for System Health."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from app.frontend.views import render_app_page
|
||||
from app.identity import require_permission, user_has_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
|
||||
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
|
||||
|
||||
|
||||
@router.get("/assets/minerops-system-health.css", include_in_schema=False)
|
||||
def system_health_styles() -> Response:
|
||||
return Response((STATIC_ROOT / "system-health.css").read_text(encoding="utf-8"),
|
||||
media_type="text/css", headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@router.get("/assets/minerops-system-health.js", include_in_schema=False)
|
||||
def system_health_script() -> Response:
|
||||
return Response((STATIC_ROOT / "system-health.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript", headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@router.get("/system-health", response_class=HTMLResponse, include_in_schema=False)
|
||||
def system_health_page(current_user: SystemHealthUser) -> str:
|
||||
return render_app_page(
|
||||
"system_health", current_user,
|
||||
lambda permission: user_has_permission(current_user, permission),
|
||||
)
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Typed contracts for the System Health overview."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
Availability = Literal["available", "unavailable"]
|
||||
MigrationState = Literal["current", "mismatch", "unavailable"]
|
||||
|
||||
|
||||
class ApplicationDiagnostics(BaseModel):
|
||||
status: Literal["available"] = "available"
|
||||
environment: str
|
||||
build_commit: str | None = None
|
||||
build_version: str | None = None
|
||||
server_time: datetime
|
||||
expected_migration_head: str | None = None
|
||||
|
||||
|
||||
class MigrationDiagnostics(BaseModel):
|
||||
state: MigrationState
|
||||
current_revision: str | None = None
|
||||
expected_revision: str | None = None
|
||||
|
||||
|
||||
class TableCount(BaseModel):
|
||||
name: str
|
||||
row_count: int
|
||||
|
||||
|
||||
class TableSize(BaseModel):
|
||||
name: str
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TelemetryDiagnostics(BaseModel):
|
||||
snapshot_count: int
|
||||
oldest_observed_at: datetime | None = None
|
||||
latest_observed_at: datetime | None = None
|
||||
|
||||
|
||||
class FleetIntegrityDiagnostics(BaseModel):
|
||||
total_miners: int
|
||||
miners_without_asset_id: int
|
||||
unmapped_miners: int
|
||||
miners_in_multiple_positions: int
|
||||
duplicate_current_mac_groups: int
|
||||
|
||||
|
||||
class LocationIntegrityDiagnostics(BaseModel):
|
||||
sites: int
|
||||
rooms: int
|
||||
positions: int
|
||||
occupied_positions: int
|
||||
empty_positions: int
|
||||
unscoped_legacy_positions: int
|
||||
expected_mac_mismatches: int
|
||||
|
||||
|
||||
class RepairIntegrityDiagnostics(BaseModel):
|
||||
active_modular_repairs: int
|
||||
duplicate_active_repair_groups: int
|
||||
legacy_repair_only_miners: int
|
||||
lifecycle_repair_without_active_repair: int
|
||||
active_repair_lifecycle_mismatches: int
|
||||
waiting_placement_records: int
|
||||
repair_events: int
|
||||
repair_parts: int
|
||||
|
||||
|
||||
class RollupDiagnostics(BaseModel):
|
||||
table_present: bool
|
||||
row_count: int | None = None
|
||||
latest_bucket: datetime | date | None = None
|
||||
population: Literal["manual"] = "manual"
|
||||
|
||||
|
||||
class RetentionDiagnostics(BaseModel):
|
||||
raw_retention_enabled: Literal[False] = False
|
||||
raw_retention_mode: Literal["not_enabled"] = "not_enabled"
|
||||
hourly: RollupDiagnostics
|
||||
daily: RollupDiagnostics
|
||||
latest_recorded_run_at: datetime | None = None
|
||||
latest_recorded_run_mode: str | None = None
|
||||
completeness: Literal["not_guaranteed"] = "not_guaranteed"
|
||||
|
||||
|
||||
class PostgreSQLDiagnostics(BaseModel):
|
||||
status: Availability
|
||||
migration: MigrationDiagnostics
|
||||
table_counts: list[TableCount] = Field(default_factory=list)
|
||||
largest_tables: list[TableSize] = Field(default_factory=list)
|
||||
telemetry: TelemetryDiagnostics | None = None
|
||||
fleet: FleetIntegrityDiagnostics | None = None
|
||||
locations: LocationIntegrityDiagnostics | None = None
|
||||
repairs: RepairIntegrityDiagnostics | None = None
|
||||
retention: RetentionDiagnostics | None = None
|
||||
|
||||
|
||||
class RedisDiagnostics(BaseModel):
|
||||
status: Availability
|
||||
|
||||
|
||||
class SystemHealthOverviewData(BaseModel):
|
||||
application: ApplicationDiagnostics
|
||||
postgresql: PostgreSQLDiagnostics
|
||||
redis: RedisDiagnostics
|
||||
|
||||
|
||||
class SystemHealthOverviewMeta(BaseModel):
|
||||
generated_at: datetime
|
||||
partial: bool
|
||||
unavailable_sections: list[str] = Field(default_factory=list)
|
||||
frontend_request_count: Literal[1] = 1
|
||||
postgresql_statement_count: Literal[3] = 3
|
||||
redis_call_count: Literal[1] = 1
|
||||
|
||||
|
||||
class SystemHealthOverviewResponse(BaseModel):
|
||||
data: SystemHealthOverviewData
|
||||
meta: SystemHealthOverviewMeta
|
||||
error: None = None
|
||||
@@ -1,179 +0,0 @@
|
||||
"""PostgreSQL diagnostics for System Health."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
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=3,
|
||||
)
|
||||
|
||||
|
||||
MIGRATION_SQL = """
|
||||
SELECT clock_timestamp() AS database_time,
|
||||
(SELECT version_num FROM alembic_version LIMIT 1) AS current_revision
|
||||
"""
|
||||
|
||||
OPERATIONS_SQL = """
|
||||
WITH telemetry AS (
|
||||
SELECT count(*)::bigint AS snapshot_count,
|
||||
min(observed_at) AS oldest_observed_at,
|
||||
max(observed_at) AS latest_observed_at
|
||||
FROM miner_status_snapshots
|
||||
),
|
||||
hourly AS (
|
||||
SELECT count(*)::bigint AS row_count, max(bucket_at) AS latest_bucket
|
||||
FROM miner_status_hourly_rollups
|
||||
),
|
||||
daily AS (
|
||||
SELECT count(*)::bigint AS row_count, max(bucket_date) AS latest_bucket
|
||||
FROM miner_status_daily_rollups
|
||||
),
|
||||
latest_retention AS (
|
||||
SELECT mode, started_at FROM minerops_retention_runs
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
),
|
||||
largest AS (
|
||||
SELECT coalesce(jsonb_agg(to_jsonb(sized) ORDER BY sized.total_bytes DESC), '[]'::jsonb) AS rows
|
||||
FROM (
|
||||
SELECT relname AS name, pg_total_relation_size(relid)::bigint AS total_bytes
|
||||
FROM pg_catalog.pg_statio_user_tables
|
||||
WHERE relname = ANY(%s)
|
||||
ORDER BY pg_total_relation_size(relid) DESC, relname
|
||||
LIMIT 8
|
||||
) AS sized
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM miners)::bigint AS miners,
|
||||
(SELECT count(*) FROM sites)::bigint AS sites,
|
||||
(SELECT count(*) FROM rooms)::bigint AS rooms,
|
||||
(SELECT count(*) FROM location_positions)::bigint AS location_positions,
|
||||
(SELECT count(*) FROM repairs)::bigint AS repairs,
|
||||
(SELECT count(*) FROM repair_events)::bigint AS repair_events,
|
||||
(SELECT count(*) FROM repair_parts)::bigint AS repair_parts,
|
||||
telemetry.snapshot_count, telemetry.oldest_observed_at,
|
||||
telemetry.latest_observed_at,
|
||||
to_regclass('public.miner_status_hourly_rollups') IS NOT NULL AS hourly_present,
|
||||
hourly.row_count AS hourly_rows, hourly.latest_bucket AS hourly_latest,
|
||||
to_regclass('public.miner_status_daily_rollups') IS NOT NULL AS daily_present,
|
||||
daily.row_count AS daily_rows, daily.latest_bucket AS daily_latest,
|
||||
latest_retention.mode AS retention_mode,
|
||||
latest_retention.started_at AS retention_started_at,
|
||||
largest.rows AS largest_tables
|
||||
FROM telemetry CROSS JOIN hourly CROSS JOIN daily CROSS JOIN largest
|
||||
LEFT JOIN latest_retention ON true
|
||||
"""
|
||||
|
||||
INTEGRITY_SQL = """
|
||||
WITH position_counts AS (
|
||||
SELECT miner_id, count(*) AS position_count
|
||||
FROM location_positions WHERE miner_id IS NOT NULL GROUP BY miner_id
|
||||
),
|
||||
active_repairs AS (
|
||||
SELECT id, miner_id FROM repairs
|
||||
WHERE status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
|
||||
),
|
||||
active_repair_groups AS (
|
||||
SELECT miner_id FROM active_repairs GROUP BY miner_id HAVING count(*) > 1
|
||||
),
|
||||
normalized_macs AS (
|
||||
SELECT lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g')) AS mac
|
||||
FROM miners WHERE current_mac IS NOT NULL AND trim(current_mac) <> ''
|
||||
GROUP BY lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||
HAVING count(*) > 1
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM miners WHERE asset_tag IS NULL OR trim(asset_tag) = '')::bigint
|
||||
AS miners_without_asset_id,
|
||||
(SELECT count(*) FROM miners m WHERE NOT EXISTS (
|
||||
SELECT 1 FROM location_positions p WHERE p.miner_id = m.id
|
||||
))::bigint AS unmapped_miners,
|
||||
(SELECT count(*) FROM position_counts WHERE position_count > 1)::bigint
|
||||
AS miners_in_multiple_positions,
|
||||
(SELECT count(*) FROM normalized_macs)::bigint AS duplicate_current_mac_groups,
|
||||
(SELECT count(*) FROM location_positions WHERE room_id IS NULL)::bigint
|
||||
AS unscoped_legacy_positions,
|
||||
(SELECT count(*) FROM location_positions p JOIN miners m ON m.id = p.miner_id
|
||||
WHERE p.expected_mac IS NOT NULL AND trim(p.expected_mac) <> ''
|
||||
AND m.current_mac IS NOT NULL AND trim(m.current_mac) <> ''
|
||||
AND lower(regexp_replace(trim(p.expected_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||
<> lower(regexp_replace(trim(m.current_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||
)::bigint AS expected_mac_mismatches,
|
||||
(SELECT count(*) FROM location_positions WHERE miner_id IS NOT NULL)::bigint
|
||||
AS occupied_positions,
|
||||
(SELECT count(*) FROM location_positions WHERE miner_id IS NULL)::bigint
|
||||
AS empty_positions,
|
||||
(SELECT count(*) FROM active_repairs)::bigint AS active_modular_repairs,
|
||||
(SELECT count(*) FROM active_repair_groups)::bigint AS duplicate_active_repair_groups,
|
||||
(SELECT count(*) FROM miner_location_states state
|
||||
WHERE state.asset_state = 'repair' AND NOT EXISTS (
|
||||
SELECT 1 FROM active_repairs repair WHERE repair.miner_id = state.miner_id
|
||||
))::bigint AS legacy_repair_only_miners,
|
||||
(SELECT count(*) FROM miners m WHERE m.lifecycle_status = 'in_repair'
|
||||
AND NOT EXISTS (SELECT 1 FROM active_repairs r WHERE r.miner_id = m.id))::bigint
|
||||
AS lifecycle_repair_without_active_repair,
|
||||
(SELECT count(*) FROM active_repairs r JOIN miners m ON m.id = r.miner_id
|
||||
WHERE m.lifecycle_status <> 'in_repair')::bigint
|
||||
AS active_repair_lifecycle_mismatches,
|
||||
(SELECT count(*) FROM miner_location_states WHERE asset_state = 'waiting_placement')::bigint
|
||||
AS waiting_placement_records
|
||||
"""
|
||||
|
||||
|
||||
RELEVANT_TABLES = [
|
||||
"miner_status_snapshots", "repair_events", "repair_parts", "repairs",
|
||||
"location_positions", "miners", "miner_status_hourly_rollups",
|
||||
"miner_status_daily_rollups",
|
||||
]
|
||||
|
||||
|
||||
class SystemHealthRepository:
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def read_overview(self) -> dict[str, Any]:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(MIGRATION_SQL)
|
||||
migration = dict(cur.fetchone())
|
||||
cur.execute(OPERATIONS_SQL, (RELEVANT_TABLES,))
|
||||
operations = dict(cur.fetchone())
|
||||
cur.execute(INTEGRITY_SQL)
|
||||
integrity = dict(cur.fetchone())
|
||||
return {
|
||||
"migration": migration,
|
||||
"operations": operations,
|
||||
"integrity": integrity,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"""HTTP boundary for the System Health overview."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.identity import require_permission
|
||||
from app.system_health.dependencies import get_system_health_service
|
||||
from app.system_health.models import SystemHealthOverviewResponse
|
||||
from app.system_health.service import SystemHealthService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system-health"])
|
||||
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
|
||||
SystemHealthServiceDependency = Annotated[
|
||||
SystemHealthService, Depends(get_system_health_service)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/health-summary", response_model=SystemHealthOverviewResponse)
|
||||
def get_system_health_overview(
|
||||
current_user: SystemHealthUser,
|
||||
service: SystemHealthServiceDependency,
|
||||
response: Response,
|
||||
) -> SystemHealthOverviewResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.get_overview()
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Composition and failure semantics for System Health diagnostics."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.system_health.adapters import RedisHealthAdapter
|
||||
from app.system_health.application import APPLICATION_METADATA, ApplicationMetadata
|
||||
from app.system_health.models import (
|
||||
ApplicationDiagnostics, FleetIntegrityDiagnostics, LocationIntegrityDiagnostics,
|
||||
MigrationDiagnostics, PostgreSQLDiagnostics, RedisDiagnostics,
|
||||
RepairIntegrityDiagnostics, RetentionDiagnostics, RollupDiagnostics,
|
||||
SystemHealthOverviewData, SystemHealthOverviewMeta,
|
||||
SystemHealthOverviewResponse, TableCount, TableSize, TelemetryDiagnostics,
|
||||
)
|
||||
from app.system_health.repository import SystemHealthRepository
|
||||
|
||||
|
||||
class SystemHealthService:
|
||||
def __init__(self, repository: SystemHealthRepository,
|
||||
redis_adapter: RedisHealthAdapter,
|
||||
metadata: ApplicationMetadata = APPLICATION_METADATA):
|
||||
self.repository = repository
|
||||
self.redis_adapter = redis_adapter
|
||||
self.metadata = metadata
|
||||
|
||||
def get_overview(self) -> SystemHealthOverviewResponse:
|
||||
generated_at = datetime.now(timezone.utc)
|
||||
unavailable = []
|
||||
try:
|
||||
postgresql = self._postgresql_diagnostics(
|
||||
self.repository.read_overview()
|
||||
)
|
||||
except Exception:
|
||||
unavailable.append("postgresql")
|
||||
postgresql = PostgreSQLDiagnostics(
|
||||
status="unavailable",
|
||||
migration=MigrationDiagnostics(
|
||||
state="unavailable",
|
||||
expected_revision=self.metadata.expected_migration_head,
|
||||
),
|
||||
)
|
||||
redis_available = self.redis_adapter.ping()
|
||||
if not redis_available:
|
||||
unavailable.append("redis")
|
||||
return SystemHealthOverviewResponse(
|
||||
data=SystemHealthOverviewData(
|
||||
application=ApplicationDiagnostics(
|
||||
environment=self.metadata.environment,
|
||||
build_commit=self.metadata.build_commit,
|
||||
build_version=self.metadata.build_version,
|
||||
server_time=generated_at,
|
||||
expected_migration_head=self.metadata.expected_migration_head,
|
||||
),
|
||||
postgresql=postgresql,
|
||||
redis=RedisDiagnostics(
|
||||
status="available" if redis_available else "unavailable"
|
||||
),
|
||||
),
|
||||
meta=SystemHealthOverviewMeta(
|
||||
generated_at=generated_at,
|
||||
partial=bool(unavailable),
|
||||
unavailable_sections=unavailable,
|
||||
),
|
||||
)
|
||||
|
||||
def _postgresql_diagnostics(self, payload: dict) -> PostgreSQLDiagnostics:
|
||||
migration = payload["migration"]
|
||||
operations = payload["operations"]
|
||||
integrity = payload["integrity"]
|
||||
current = migration.get("current_revision")
|
||||
expected = self.metadata.expected_migration_head
|
||||
state = (
|
||||
"unavailable" if not current or not expected
|
||||
else "current" if current == expected else "mismatch"
|
||||
)
|
||||
counts = [
|
||||
TableCount(name=name, row_count=int(operations[name]))
|
||||
for name in (
|
||||
"miners", "sites", "rooms", "location_positions", "repairs",
|
||||
"repair_events", "repair_parts", "snapshot_count",
|
||||
)
|
||||
]
|
||||
return PostgreSQLDiagnostics(
|
||||
status="available",
|
||||
migration=MigrationDiagnostics(
|
||||
state=state, current_revision=current, expected_revision=expected
|
||||
),
|
||||
table_counts=counts,
|
||||
largest_tables=[
|
||||
TableSize(name=row["name"], total_bytes=int(row["total_bytes"]))
|
||||
for row in operations.get("largest_tables") or []
|
||||
],
|
||||
telemetry=TelemetryDiagnostics(
|
||||
snapshot_count=int(operations["snapshot_count"]),
|
||||
oldest_observed_at=operations.get("oldest_observed_at"),
|
||||
latest_observed_at=operations.get("latest_observed_at"),
|
||||
),
|
||||
fleet=FleetIntegrityDiagnostics(
|
||||
total_miners=int(operations["miners"]),
|
||||
miners_without_asset_id=int(integrity["miners_without_asset_id"]),
|
||||
unmapped_miners=int(integrity["unmapped_miners"]),
|
||||
miners_in_multiple_positions=int(integrity["miners_in_multiple_positions"]),
|
||||
duplicate_current_mac_groups=int(integrity["duplicate_current_mac_groups"]),
|
||||
),
|
||||
locations=LocationIntegrityDiagnostics(
|
||||
sites=int(operations["sites"]), rooms=int(operations["rooms"]),
|
||||
positions=int(operations["location_positions"]),
|
||||
occupied_positions=int(integrity["occupied_positions"]),
|
||||
empty_positions=int(integrity["empty_positions"]),
|
||||
unscoped_legacy_positions=int(integrity["unscoped_legacy_positions"]),
|
||||
expected_mac_mismatches=int(integrity["expected_mac_mismatches"]),
|
||||
),
|
||||
repairs=RepairIntegrityDiagnostics(
|
||||
active_modular_repairs=int(integrity["active_modular_repairs"]),
|
||||
duplicate_active_repair_groups=int(integrity["duplicate_active_repair_groups"]),
|
||||
legacy_repair_only_miners=int(integrity["legacy_repair_only_miners"]),
|
||||
lifecycle_repair_without_active_repair=int(integrity["lifecycle_repair_without_active_repair"]),
|
||||
active_repair_lifecycle_mismatches=int(integrity["active_repair_lifecycle_mismatches"]),
|
||||
waiting_placement_records=int(integrity["waiting_placement_records"]),
|
||||
repair_events=int(operations["repair_events"]),
|
||||
repair_parts=int(operations["repair_parts"]),
|
||||
),
|
||||
retention=RetentionDiagnostics(
|
||||
hourly=RollupDiagnostics(
|
||||
table_present=bool(operations["hourly_present"]),
|
||||
row_count=int(operations["hourly_rows"]),
|
||||
latest_bucket=operations.get("hourly_latest"),
|
||||
),
|
||||
daily=RollupDiagnostics(
|
||||
table_present=bool(operations["daily_present"]),
|
||||
row_count=int(operations["daily_rows"]),
|
||||
latest_bucket=operations.get("daily_latest"),
|
||||
),
|
||||
latest_recorded_run_at=operations.get("retention_started_at"),
|
||||
latest_recorded_run_mode=operations.get("retention_mode"),
|
||||
),
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,368 +0,0 @@
|
||||
"""Focused tests for authenticated Agent queue transport."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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,
|
||||
)
|
||||
from app.agent_commands.models import AgentCommandResultRequest
|
||||
from app.agent_commands.repository import CLAIM_COMMANDS_SQL
|
||||
from app.agent_commands.router import router
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def agent_source_root() -> Path | None:
|
||||
container_root = Path("/agents/windows")
|
||||
if container_root.is_dir():
|
||||
return container_root
|
||||
repository_root = Path(__file__).parents[2] / "agents" / "windows"
|
||||
return repository_root if repository_root.is_dir() else None
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, row=None):
|
||||
self.row = row or {
|
||||
"id": "command-1", "agent_id": "test-agent",
|
||||
"command_type": "miner_control", "command_status": "claimed",
|
||||
"request_payload": {"action": "suspend"},
|
||||
}
|
||||
self.finished = []
|
||||
|
||||
def claim_pending(self, agent_id, limit=5):
|
||||
return [{
|
||||
"command_id": "command-1", "command_type": "miner_control",
|
||||
"request_payload": {"action": "suspend"}, "created_at": NOW,
|
||||
}]
|
||||
|
||||
def get_command(self, command_id):
|
||||
return self.row if command_id == self.row.get("id") else None
|
||||
|
||||
def finish_command(self, *args):
|
||||
self.finished.append(args)
|
||||
|
||||
|
||||
def typed_result(**overrides):
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"command_accepted": True,
|
||||
"verification_attempted": False,
|
||||
"verification_status": "unconfirmed",
|
||||
"observed_service_state": None,
|
||||
"observed_performance_mode": None,
|
||||
"failure_code": None,
|
||||
}
|
||||
result.update(overrides)
|
||||
return result
|
||||
|
||||
|
||||
class AgentCommandServiceTests(unittest.TestCase):
|
||||
def test_claim_query_uses_skip_locked_and_deterministic_order(self):
|
||||
self.assertIn("FOR UPDATE SKIP LOCKED", CLAIM_COMMANDS_SQL)
|
||||
self.assertIn("ORDER BY created_at, id", CLAIM_COMMANDS_SQL)
|
||||
self.assertIn("queue.command_status = 'pending'", CLAIM_COMMANDS_SQL)
|
||||
|
||||
def test_typed_result_is_strict_and_sanitized(self):
|
||||
repository = FakeRepository()
|
||||
request = AgentCommandResultRequest(
|
||||
command_id="command-1", status="completed",
|
||||
result_payload=typed_result(), error_message="raw 192.0.2.10 vendor exception",
|
||||
)
|
||||
AgentCommandService(repository).submit_result("test-agent", request)
|
||||
finished = repository.finished[0]
|
||||
self.assertIsNone(finished[4])
|
||||
self.assertNotIn("192.0.2.10", str(finished))
|
||||
|
||||
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
|
||||
request = AgentCommandResultRequest(
|
||||
command_id="command-1", status="completed", result_payload=typed_result()
|
||||
)
|
||||
with self.assertRaises(AgentCommandConflict):
|
||||
AgentCommandService(FakeRepository()).submit_result("wrong-agent", request)
|
||||
for queue_status in ("pending", "completed", "failed", "cancelled"):
|
||||
with self.subTest(status=queue_status), self.assertRaises(AgentCommandConflict):
|
||||
row = FakeRepository().row.copy()
|
||||
row["command_status"] = queue_status
|
||||
AgentCommandService(FakeRepository(row)).submit_result("test-agent", request)
|
||||
|
||||
def test_invalid_status_and_secret_fields_are_rejected(self):
|
||||
with self.assertRaises(Exception):
|
||||
AgentCommandResultRequest(
|
||||
command_id="command-1", status="unknown", result_payload={}
|
||||
)
|
||||
for forbidden in ("password", "account", "token", "raw_response"):
|
||||
with self.subTest(field=forbidden):
|
||||
repository = FakeRepository()
|
||||
request = AgentCommandResultRequest(
|
||||
command_id="command-1", status="completed",
|
||||
result_payload={**typed_result(), forbidden: "sensitive"},
|
||||
)
|
||||
with self.assertRaises(AgentResultValidationError):
|
||||
AgentCommandService(repository).submit_result("test-agent", request)
|
||||
|
||||
def test_suspend_resume_cannot_claim_confirmation(self):
|
||||
for status in ("confirmed", "verification_failed"):
|
||||
with self.subTest(status=status):
|
||||
request = AgentCommandResultRequest(
|
||||
command_id="command-1", status="completed",
|
||||
result_payload=typed_result(
|
||||
verification_attempted=True, verification_status=status
|
||||
),
|
||||
)
|
||||
with self.assertRaises(AgentResultValidationError):
|
||||
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()
|
||||
self.app.include_router(router)
|
||||
self.repository = FakeRepository()
|
||||
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
|
||||
settings.minerops_agent_id = "test-agent"
|
||||
settings.minerops_agent_shared_secret = "test-secret"
|
||||
|
||||
def tearDown(self):
|
||||
settings.minerops_agent_id = self.old_id
|
||||
settings.minerops_agent_shared_secret = self.old_secret
|
||||
self.client.close()
|
||||
|
||||
def headers(self, agent="test-agent"):
|
||||
return {
|
||||
"X-MinerOps-Agent-ID": agent,
|
||||
"X-MinerOps-Agent-Secret": "test-secret",
|
||||
}
|
||||
|
||||
def test_poll_requires_header_identity_and_ignores_query_spoofing(self):
|
||||
before_claim_calls = []
|
||||
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
|
||||
self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401)
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
|
||||
).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(before_claim_calls, [True])
|
||||
self.assertEqual(
|
||||
self.client.get(
|
||||
"/api/agent/commands/pending", headers=self.headers("other")
|
||||
).status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
def test_result_requires_strict_claimed_contract(self):
|
||||
response = self.client.post(
|
||||
"/api/agent/commands/result",
|
||||
headers=self.headers(),
|
||||
json={
|
||||
"command_id": "command-1", "status": "completed",
|
||||
"result_payload": typed_result(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], "completed")
|
||||
self.assertEqual(
|
||||
self.client.post(
|
||||
"/api/agent/commands/result", headers=self.headers(),
|
||||
json={"command_id": "command-1", "status": "other",
|
||||
"result_payload": {}},
|
||||
).status_code,
|
||||
422,
|
||||
)
|
||||
|
||||
|
||||
class AgentSourceContractTests(unittest.TestCase):
|
||||
def test_agent_typed_path_has_locked_commands_and_no_default_credentials(self):
|
||||
root = agent_source_root()
|
||||
if root is None:
|
||||
self.skipTest("Windows Agent source is not mounted")
|
||||
service = (root / "MinerOpsAgentService.ps1").read_text()
|
||||
control = (root / "MinerOpsControl.ps1").read_text()
|
||||
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||
powershell = "\n".join(path.read_text() for path in root.glob("*.ps1"))
|
||||
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)
|
||||
self.assertIn("$Payload.performance_mode", control)
|
||||
self.assertNotIn("api3_account =", control)
|
||||
self.assertNotIn("api3_password =", control)
|
||||
for forbidden in ("raw_response", "raw_command", "raw_exception"):
|
||||
with self.subTest(field=forbidden):
|
||||
self.assertNotIn(forbidden, control.lower())
|
||||
self.assertNotIn('"agent_secret": secret', main)
|
||||
self.assertNotIn('"secret_length":', main)
|
||||
self.assertIn('"secret_configured": bool(secret)', main)
|
||||
|
||||
def test_deployment_preserves_local_state_and_registers_established_tasks(self):
|
||||
root = agent_source_root()
|
||||
if root is None:
|
||||
self.skipTest("Windows Agent source is not mounted")
|
||||
install = (root / "install.ps1").read_text()
|
||||
uninstall = (root / "uninstall.ps1").read_text()
|
||||
for task in (
|
||||
"MinerOpsAgent", "MinerOpsAgentTray",
|
||||
"MinerOpsApi3Enrichment", "MinerOpsPoolPoller",
|
||||
):
|
||||
self.assertIn(task, install)
|
||||
self.assertIn(task, uninstall)
|
||||
for local_path in ("agent-config.json", "agent-state.json", "logs"):
|
||||
self.assertNotIn(f'"{local_path}"', install)
|
||||
self.assertNotIn(f'"{local_path}"', uninstall)
|
||||
self.assertNotIn("agent_secret", install.lower())
|
||||
self.assertNotIn("api3_password", install.lower())
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,157 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,625 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,175 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,120 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,194 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,633 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,198 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,283 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,244 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Focused service, HTTP, frontend, and security tests for miner control v1."""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
from app.miner_control.dependencies import get_miner_control_service
|
||||
from app.miner_control.exceptions import MinerControlConflict, MinerControlUnavailable
|
||||
from app.miner_control.models import MinerControlAccepted
|
||||
from app.miner_control.router import router
|
||||
from app.miner_control.service import MinerControlService
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
|
||||
MINER_ID = UUID("00000000-0000-4000-8000-000000001501")
|
||||
USER_ID = UUID("00000000-0000-4000-8000-000000001502")
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
|
||||
|
||||
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",
|
||||
"api3_supported": True,
|
||||
"api3_api_switch": "on",
|
||||
"agent_id": "test-agent",
|
||||
"last_checkin_at": NOW,
|
||||
"agent_version": "1.0.0-alpha",
|
||||
"last_payload": {"control_available": True},
|
||||
}
|
||||
result.update(overrides)
|
||||
return result
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, miner_context=None, history=None, conflict=False):
|
||||
self.miner_context = context() if miner_context is None else miner_context
|
||||
self.history = history or []
|
||||
self.conflict = conflict
|
||||
self.enqueue_calls = []
|
||||
self.limits = []
|
||||
|
||||
def get_control_context(self, miner_id):
|
||||
self.requested_miner = miner_id
|
||||
return self.miner_context
|
||||
|
||||
def list_recent(self, miner_id, limit=5):
|
||||
self.limits.append(limit)
|
||||
return self.history
|
||||
|
||||
def enqueue_with_audit(self, **kwargs):
|
||||
self.enqueue_calls.append(kwargs)
|
||||
if self.conflict:
|
||||
raise MinerControlConflict("live command exists")
|
||||
return {
|
||||
"operation_id": kwargs["operation_id"],
|
||||
"status": "pending",
|
||||
"created_at": NOW,
|
||||
}
|
||||
|
||||
|
||||
class MinerControlServiceTests(unittest.TestCase):
|
||||
def test_supported_projection_is_factual_and_bounded(self):
|
||||
repository = FakeRepository()
|
||||
result = MinerControlService(repository).get_controls(MINER_ID)
|
||||
self.assertEqual(result.service_control.state, "supported")
|
||||
self.assertEqual(result.performance_mode, "normal")
|
||||
self.assertEqual(result.available_service_action, "suspend")
|
||||
self.assertEqual(repository.limits, [5])
|
||||
|
||||
def test_capability_missing_facts_is_not_optimistic(self):
|
||||
cases = (
|
||||
({"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"),
|
||||
({"last_payload": {}}, "unknown", "agent_capability_unknown"),
|
||||
(
|
||||
{"last_payload": {"control_available": False,
|
||||
"control_unavailable_reason": "credentials_missing"}},
|
||||
"unavailable", "credentials_missing",
|
||||
),
|
||||
)
|
||||
for changes, state, reason in cases:
|
||||
with self.subTest(reason=reason):
|
||||
result = MinerControlService(FakeRepository(context(**changes))).get_controls(MINER_ID)
|
||||
self.assertEqual(result.service_control.state, state)
|
||||
self.assertEqual(result.service_control.reason, reason)
|
||||
|
||||
def test_resume_requires_known_stopped_state(self):
|
||||
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
|
||||
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
|
||||
service.queue_control(
|
||||
MINER_ID, "resume", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
|
||||
def test_queue_contains_routing_but_audit_and_response_do_not(self):
|
||||
repository = FakeRepository()
|
||||
result = MinerControlService(repository).queue_control(
|
||||
MINER_ID, "performance_mode", requested_value="high",
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
call = repository.enqueue_calls[0]
|
||||
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
|
||||
self.assertNotIn("ip_address", call["audit_payload"])
|
||||
self.assertNotIn("agent_id", call["audit_payload"])
|
||||
self.assertNotIn("password", str(call).lower())
|
||||
self.assertNotIn("192.0.2.10", result.model_dump_json())
|
||||
|
||||
def test_no_projection_changes_when_work_is_queued(self):
|
||||
repository = FakeRepository()
|
||||
MinerControlService(repository).queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="operator",
|
||||
)
|
||||
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
|
||||
|
||||
|
||||
class MinerControlRouterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.repository = FakeRepository()
|
||||
self.service = MinerControlService(self.repository)
|
||||
self.app.dependency_overrides[get_miner_control_service] = lambda: self.service
|
||||
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(USER_ID), "username": "operator", "role": "Custom",
|
||||
"permissions": list(permissions),
|
||||
}
|
||||
|
||||
def test_get_requires_authentication_and_miners_view(self):
|
||||
path = f"/api/miners/{MINER_ID}/controls"
|
||||
self.assertEqual(self.client.get(path).status_code, 401)
|
||||
self.authenticate(("miners.control",))
|
||||
self.assertEqual(self.client.get(path).status_code, 403)
|
||||
self.authenticate(("miners.view",))
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
|
||||
|
||||
def test_posts_require_control_and_return_202_bounded_envelope(self):
|
||||
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
||||
self.authenticate(("miners.view",))
|
||||
self.assertEqual(self.client.post(path, json={}).status_code, 403)
|
||||
self.authenticate(("miners.control",))
|
||||
response = self.client.post(path, json={})
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(set(response.json()["data"]), {
|
||||
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
|
||||
})
|
||||
self.assertNotIn("ip", response.text.lower())
|
||||
self.assertNotIn("agent", response.text.lower())
|
||||
|
||||
def test_strict_bodies_reject_routing_commands_and_credentials(self):
|
||||
self.authenticate(("miners.control",))
|
||||
base = f"/api/miners/{MINER_ID}/controls"
|
||||
for body in (
|
||||
{"ip": "192.0.2.1"}, {"agent_id": "other"}, {"dry_run": True},
|
||||
{"raw_command": "reboot"}, {"api_password": "secret"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
self.assertEqual(self.client.post(base + "/suspend", json=body).status_code, 422)
|
||||
self.assertEqual(
|
||||
self.client.post(base + "/performance-mode", json={"mode": "turbo"}).status_code,
|
||||
422,
|
||||
)
|
||||
|
||||
def test_cross_site_rejected_and_same_origin_allowed(self):
|
||||
self.authenticate(("miners.control",))
|
||||
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
||||
self.assertEqual(
|
||||
self.client.post(path, json={}, headers={"Sec-Fetch-Site": "cross-site"}).status_code,
|
||||
403,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
|
||||
202,
|
||||
)
|
||||
|
||||
def test_identity_permission_mapping_is_specific(self):
|
||||
base = f"/api/miners/{MINER_ID}/controls"
|
||||
self.assertEqual(required_api_permission("GET", base), "miners.view")
|
||||
for action in ("suspend", "resume", "performance-mode"):
|
||||
self.assertEqual(
|
||||
required_api_permission("POST", f"{base}/{action}"),
|
||||
"miners.control",
|
||||
)
|
||||
self.assertNotEqual(
|
||||
required_api_permission("POST", base), "miners.control"
|
||||
)
|
||||
|
||||
|
||||
class MinerControlFrontendTests(unittest.TestCase):
|
||||
def test_controls_are_safe_confirmation_gated_and_non_polling(self):
|
||||
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
|
||||
view = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text()
|
||||
self.assertIn('window.confirm("Confirm: "', source)
|
||||
self.assertIn("await loadControls();", source)
|
||||
self.assertNotIn("setInterval", source)
|
||||
self.assertNotIn("innerHTML", source)
|
||||
self.assertIn("minerControlsTitle", view)
|
||||
self.assertIn("Your account can inspect control capability", view)
|
||||
|
||||
def test_new_ui_does_not_use_legacy_control_routes(self):
|
||||
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
|
||||
for legacy in ("agent-control", "/commands/", "api3", "change-password", "reboot"):
|
||||
self.assertNotIn(legacy, source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Real PostgreSQL concurrency and transaction regressions for miner control."""
|
||||
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.agent_commands.models import AgentCommandResultRequest
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
from app.config import settings
|
||||
from app.miner_control.exceptions import MinerControlConflict
|
||||
from app.miner_control.repository import MinerControlRepository
|
||||
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")
|
||||
AGENT_ID = "control-test-agent"
|
||||
|
||||
|
||||
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 result_payload(accepted=True):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"command_accepted": accepted,
|
||||
"verification_attempted": False,
|
||||
"verification_status": "unconfirmed" if accepted else "unavailable",
|
||||
"observed_service_state": None,
|
||||
"observed_performance_mode": None,
|
||||
"failure_code": None if accepted else "write_rejected",
|
||||
}
|
||||
|
||||
|
||||
class MinerControlPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"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)
|
||||
SELECT %s, 'control-test-user', 'Control Test User', 'unused', id
|
||||
FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING
|
||||
""", (USER_ID,)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
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, %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, ZONE_ID)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
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, ZONE_ID, Jsonb({"control_available": True}))
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_agent_command_queue "
|
||||
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
|
||||
(str(MINER_ID), str(OTHER_MINER_ID)),
|
||||
)
|
||||
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:
|
||||
connection.close()
|
||||
|
||||
def setUp(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
|
||||
cur.execute(
|
||||
"DELETE FROM minerops_agent_command_queue "
|
||||
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
|
||||
(str(MINER_ID), str(OTHER_MINER_ID)),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def insert_command(self, command_id, miner_id, status="pending", created=None):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_agent_command_queue (
|
||||
id, agent_id, command_type, command_status, request_payload, created_at
|
||||
) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now()))
|
||||
""",
|
||||
(command_id, AGENT_ID, status,
|
||||
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
|
||||
"action": "suspend", "requested_value": None}), created),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_two_concurrent_claimers_never_receive_same_command(self):
|
||||
self.insert_command("concurrent-one", MINER_ID)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(
|
||||
lambda _value: AgentCommandRepository(connect).claim_pending(AGENT_ID),
|
||||
range(2),
|
||||
))
|
||||
claimed = [row["command_id"] for result in results for row in result]
|
||||
self.assertEqual(claimed, ["concurrent-one"])
|
||||
|
||||
def test_skip_locked_claims_next_available_row(self):
|
||||
self.insert_command(
|
||||
"a-locked", MINER_ID,
|
||||
created=datetime(2026, 8, 24, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
self.insert_command(
|
||||
"b-available", OTHER_MINER_ID,
|
||||
created=datetime(2026, 8, 24, 11, tzinfo=timezone.utc),
|
||||
)
|
||||
locker = connect()
|
||||
try:
|
||||
with locker.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM minerops_agent_command_queue "
|
||||
"WHERE id = 'a-locked' FOR UPDATE"
|
||||
)
|
||||
claimed = AgentCommandRepository(connect).claim_pending(AGENT_ID)
|
||||
self.assertEqual([row["command_id"] for row in claimed], ["b-available"])
|
||||
finally:
|
||||
locker.rollback()
|
||||
locker.close()
|
||||
|
||||
def test_result_binding_claim_requirement_and_terminal_immutability(self):
|
||||
self.insert_command("result-one", MINER_ID, status="claimed")
|
||||
request = AgentCommandResultRequest(
|
||||
command_id="result-one", status="completed", result_payload=result_payload()
|
||||
)
|
||||
service = AgentCommandService(AgentCommandRepository(connect))
|
||||
with self.assertRaises(Exception):
|
||||
service.submit_result("wrong-agent", request)
|
||||
service.submit_result(AGENT_ID, request)
|
||||
with self.assertRaises(Exception):
|
||||
service.submit_result(AGENT_ID, request)
|
||||
|
||||
self.insert_command("pending-result", OTHER_MINER_ID, status="pending")
|
||||
pending = AgentCommandResultRequest(
|
||||
command_id="pending-result", status="failed", result_payload=result_payload(False)
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
service.submit_result(AGENT_ID, pending)
|
||||
|
||||
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
|
||||
service = MinerControlService(MinerControlRepository(connect))
|
||||
first = service.queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
with self.assertRaises(MinerControlConflict):
|
||||
service.queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM audit_logs WHERE entity_id = %s", (MINER_ID,)
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], 1)
|
||||
cur.execute(
|
||||
"UPDATE minerops_agent_command_queue SET command_status = 'completed', "
|
||||
"completed_at = now() WHERE id = %s", (first.operation_id,)
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
second = service.queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||
)
|
||||
self.assertNotEqual(first.operation_id, second.operation_id)
|
||||
|
||||
def test_audit_failure_rolls_back_queue_row(self):
|
||||
service = MinerControlService(MinerControlRepository(connect))
|
||||
operation_count_before = self._operation_count()
|
||||
with self.assertRaises(Exception):
|
||||
service.queue_control(
|
||||
MINER_ID, "suspend", requested_value=None,
|
||||
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
|
||||
actor_username="missing-user",
|
||||
)
|
||||
self.assertEqual(self._operation_count(), operation_count_before)
|
||||
|
||||
def _operation_count(self):
|
||||
connection = connect()
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM minerops_agent_command_queue "
|
||||
"WHERE request_payload ->> 'miner_id' = %s", (str(MINER_ID),)
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user