feat: add safe miner control foundation
This commit is contained in:
1 parent
6cd876b4b3
commit
f5965af914
39 files changed
+2952
-491
No files matched your search
@@ -1,2 +1,4 @@
|
||||
*.ps1 -text
|
||||
|
||||
|
||||
*.ps1 whitespace=cr-at-eol
|
||||
@@ -55,6 +55,7 @@ function Invoke-AgentApi {
|
||||
|
||||
$headers = @{
|
||||
"X-MinerOps-Agent-Secret" = $secret
|
||||
"X-MinerOps-Agent-ID" = [string]$config.agent_id
|
||||
}
|
||||
|
||||
if ($Body -ne $null) {
|
||||
@@ -94,7 +95,7 @@ function Get-PendingAgentCommands {
|
||||
$config = Read-AgentConfig
|
||||
$agentId = [string]$config.agent_id
|
||||
|
||||
$result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending?agent_id=$agentId"
|
||||
$result = Invoke-AgentApi -Method "GET" -Path "/api/agent/commands/pending"
|
||||
|
||||
if ($result.commands) {
|
||||
return @($result.commands)
|
||||
@@ -981,8 +982,8 @@ function Invoke-MinerOpsApi3Service {
|
||||
[string]$ServiceAction
|
||||
)
|
||||
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super"
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super"
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue ""
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue ""
|
||||
$portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433"
|
||||
|
||||
try {
|
||||
@@ -1019,322 +1020,322 @@ function Invoke-MinerOpsApi3Service {
|
||||
return $result
|
||||
}
|
||||
|
||||
|
||||
function Normalize-MinerOpsIpAddress {
|
||||
param(
|
||||
[string]$IpAddress
|
||||
)
|
||||
|
||||
$value = ([string]$IpAddress).Trim()
|
||||
|
||||
if ($value.Contains("/")) {
|
||||
$value = $value.Split("/")[0]
|
||||
}
|
||||
|
||||
return $value
|
||||
}
|
||||
|
||||
|
||||
function Invoke-MinerOpsApi3AuthenticatedCommand {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IpAddress,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Command,
|
||||
|
||||
[object]$Param = $null
|
||||
)
|
||||
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue "super"
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue "super"
|
||||
$portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433"
|
||||
|
||||
try {
|
||||
$port = [int]$portRaw
|
||||
} catch {
|
||||
$port = 4433
|
||||
}
|
||||
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port
|
||||
$salt = [string]$saltInfo.salt
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp
|
||||
|
||||
$payload = @{
|
||||
cmd = $Command
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $account
|
||||
}
|
||||
|
||||
if ($null -ne $Param) {
|
||||
$payload["param"] = $Param
|
||||
}
|
||||
|
||||
$result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload
|
||||
|
||||
$result["salt_response"] = $saltInfo.salt_response
|
||||
$result["api3_command"] = $Command
|
||||
$result["api3_param"] = $Param
|
||||
$result["api3_account"] = $account
|
||||
|
||||
if ([int]$result.response.code -ne 0) {
|
||||
throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-MinerOpsPayloadValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] $Payload,
|
||||
[Parameter(Mandatory=$true)] [string] $Name
|
||||
)
|
||||
|
||||
if ($null -eq $Payload) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Payload -is [hashtable]) {
|
||||
if ($Payload.ContainsKey($Name)) {
|
||||
return $Payload[$Name]
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Payload.PSObject.Properties[$Name]
|
||||
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsLegacy4028Command {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $IpAddress,
|
||||
[Parameter(Mandatory=$true)] [string] $CommandJson,
|
||||
[int] $Port = 4028,
|
||||
[int] $TimeoutMs = 7000
|
||||
)
|
||||
|
||||
$normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null)
|
||||
|
||||
if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||
try { $client.Close() } catch {}
|
||||
throw "Legacy 4028 connection timed out to $normalizedIp`:$Port"
|
||||
}
|
||||
|
||||
$client.EndConnect($connectResult)
|
||||
|
||||
try {
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$buffer = New-Object byte[] 65535
|
||||
$builder = New-Object System.Text.StringBuilder
|
||||
|
||||
do {
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -gt 0) {
|
||||
[void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read))
|
||||
}
|
||||
} while ($stream.DataAvailable)
|
||||
|
||||
$rawResponse = $builder.ToString().Trim([char]0).Trim()
|
||||
|
||||
$parsedResponse = $null
|
||||
|
||||
try {
|
||||
if ($rawResponse) {
|
||||
$parsedResponse = $rawResponse | ConvertFrom-Json
|
||||
}
|
||||
} catch {
|
||||
$parsedResponse = $null
|
||||
}
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
control_method = "legacy_4028"
|
||||
port = $Port
|
||||
request_json = $CommandJson
|
||||
raw_response = $rawResponse
|
||||
parsed_response = $parsedResponse
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try { $stream.Close() } catch {}
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsM30Legacy4028Test {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] $Payload
|
||||
)
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address"
|
||||
|
||||
if (-not $ipAddress) {
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip"
|
||||
}
|
||||
|
||||
if (-not $ipAddress) {
|
||||
throw "Legacy 4028 test failed: no IP address in payload."
|
||||
}
|
||||
|
||||
$legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json"
|
||||
$legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command"
|
||||
|
||||
if (-not $legacyJson) {
|
||||
if (-not $legacyCommand) {
|
||||
throw "Legacy 4028 test failed: no legacy command in payload."
|
||||
}
|
||||
|
||||
$legacyJson = '{"command":"' + $legacyCommand + '"}'
|
||||
}
|
||||
|
||||
Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand"
|
||||
|
||||
$result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
action = "legacy_4028"
|
||||
test_lab = "m30"
|
||||
ip_address = $ipAddress
|
||||
legacy_4028_command = $legacyCommand
|
||||
legacy_4028_request = $legacyJson
|
||||
legacy_4028_response = $result.raw_response
|
||||
legacy_4028_parsed = $result.parsed_response
|
||||
control_method = "legacy_4028"
|
||||
}
|
||||
}
|
||||
|
||||
function Normalize-MinerOpsIpAddress {
|
||||
param(
|
||||
[string]$IpAddress
|
||||
)
|
||||
|
||||
$value = ([string]$IpAddress).Trim()
|
||||
|
||||
if ($value.Contains("/")) {
|
||||
$value = $value.Split("/")[0]
|
||||
}
|
||||
|
||||
return $value
|
||||
}
|
||||
|
||||
|
||||
function Invoke-MinerOpsApi3AuthenticatedCommand {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IpAddress,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Command,
|
||||
|
||||
[object]$Param = $null
|
||||
)
|
||||
|
||||
$account = Get-MinerOpsApi3Credential -Name "api3_account" -DefaultValue ""
|
||||
$password = Get-MinerOpsApi3Credential -Name "api3_password" -DefaultValue ""
|
||||
$portRaw = Get-MinerOpsApi3Credential -Name "api3_port" -DefaultValue "4433"
|
||||
|
||||
try {
|
||||
$port = [int]$portRaw
|
||||
} catch {
|
||||
$port = 4433
|
||||
}
|
||||
|
||||
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $port
|
||||
$salt = [string]$saltInfo.salt
|
||||
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$token = Get-MinerOpsApi3Token -Command $Command -Password $password -Salt $salt -Timestamp $timestamp
|
||||
|
||||
$payload = @{
|
||||
cmd = $Command
|
||||
ts = $timestamp
|
||||
token = $token
|
||||
account = $account
|
||||
}
|
||||
|
||||
if ($null -ne $Param) {
|
||||
$payload["param"] = $Param
|
||||
}
|
||||
|
||||
$result = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress -Port $port -Payload $payload
|
||||
|
||||
$result["salt_response"] = $saltInfo.salt_response
|
||||
$result["api3_command"] = $Command
|
||||
$result["api3_param"] = $Param
|
||||
$result["api3_account"] = $account
|
||||
|
||||
if ([int]$result.response.code -ne 0) {
|
||||
throw "API3 $Command failed: $($result.response | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-MinerOpsPayloadValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] $Payload,
|
||||
[Parameter(Mandatory=$true)] [string] $Name
|
||||
)
|
||||
|
||||
if ($null -eq $Payload) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($Payload -is [hashtable]) {
|
||||
if ($Payload.ContainsKey($Name)) {
|
||||
return $Payload[$Name]
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Payload.PSObject.Properties[$Name]
|
||||
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsLegacy4028Command {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] [string] $IpAddress,
|
||||
[Parameter(Mandatory=$true)] [string] $CommandJson,
|
||||
[int] $Port = 4028,
|
||||
[int] $TimeoutMs = 7000
|
||||
)
|
||||
|
||||
$normalizedIp = Normalize-MinerOpsIpAddress -IpAddress $IpAddress
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$connectResult = $client.BeginConnect($normalizedIp, $Port, $null, $null)
|
||||
|
||||
if (-not $connectResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||
try { $client.Close() } catch {}
|
||||
throw "Legacy 4028 connection timed out to $normalizedIp`:$Port"
|
||||
}
|
||||
|
||||
$client.EndConnect($connectResult)
|
||||
|
||||
try {
|
||||
$stream = $client.GetStream()
|
||||
$stream.ReadTimeout = $TimeoutMs
|
||||
$stream.WriteTimeout = $TimeoutMs
|
||||
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($CommandJson)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush()
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
$buffer = New-Object byte[] 65535
|
||||
$builder = New-Object System.Text.StringBuilder
|
||||
|
||||
do {
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
if ($read -gt 0) {
|
||||
[void]$builder.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $read))
|
||||
}
|
||||
} while ($stream.DataAvailable)
|
||||
|
||||
$rawResponse = $builder.ToString().Trim([char]0).Trim()
|
||||
|
||||
$parsedResponse = $null
|
||||
|
||||
try {
|
||||
if ($rawResponse) {
|
||||
$parsedResponse = $rawResponse | ConvertFrom-Json
|
||||
}
|
||||
} catch {
|
||||
$parsedResponse = $null
|
||||
}
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
control_method = "legacy_4028"
|
||||
port = $Port
|
||||
request_json = $CommandJson
|
||||
raw_response = $rawResponse
|
||||
parsed_response = $parsedResponse
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try { $stream.Close() } catch {}
|
||||
try { $client.Close() } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MinerOpsM30Legacy4028Test {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)] $Payload
|
||||
)
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "ip_address"
|
||||
|
||||
if (-not $ipAddress) {
|
||||
$ipAddress = Get-MinerOpsPayloadValue -Payload $Payload -Name "current_ip"
|
||||
}
|
||||
|
||||
if (-not $ipAddress) {
|
||||
throw "Legacy 4028 test failed: no IP address in payload."
|
||||
}
|
||||
|
||||
$legacyJson = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_json"
|
||||
$legacyCommand = Get-MinerOpsPayloadValue -Payload $Payload -Name "legacy_4028_command"
|
||||
|
||||
if (-not $legacyJson) {
|
||||
if (-not $legacyCommand) {
|
||||
throw "Legacy 4028 test failed: no legacy command in payload."
|
||||
}
|
||||
|
||||
$legacyJson = '{"command":"' + $legacyCommand + '"}'
|
||||
}
|
||||
|
||||
Write-AgentLog "Running legacy 4028 M30 test against $ipAddress command=$legacyCommand"
|
||||
|
||||
$result = Invoke-MinerOpsLegacy4028Command -IpAddress $ipAddress -CommandJson $legacyJson -Port 4028
|
||||
|
||||
return @{
|
||||
success = $true
|
||||
action = "legacy_4028"
|
||||
test_lab = "m30"
|
||||
ip_address = $ipAddress
|
||||
legacy_4028_command = $legacyCommand
|
||||
legacy_4028_request = $legacyJson
|
||||
legacy_4028_response = $result.raw_response
|
||||
legacy_4028_parsed = $result.parsed_response
|
||||
control_method = "legacy_4028"
|
||||
}
|
||||
}
|
||||
|
||||
function Run-MinerControlCommand {
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[object]$Payload
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[object]$Payload
|
||||
)
|
||||
# MinerOps M30 legacy 4028 test path
|
||||
if ($Payload -is [string]) {
|
||||
try {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) {
|
||||
return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload
|
||||
}
|
||||
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
if ($null -eq $Payload) {
|
||||
throw "Miner control payload was empty."
|
||||
}
|
||||
|
||||
$action = ([string]$Payload.action).ToLower()
|
||||
$minerId = [string]$Payload.miner_id
|
||||
$macAddress = [string]$Payload.mac_address
|
||||
$dryRun = [bool]$Payload.dry_run
|
||||
|
||||
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||
|
||||
if ($ipAddress.Contains("/")) {
|
||||
$ipAddress = $ipAddress.Split("/")[0]
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||
throw "Miner control payload did not include ip_address."
|
||||
}
|
||||
|
||||
$serviceAction = ""
|
||||
$api3Command = ""
|
||||
$api3Param = $null
|
||||
$performanceMode = ""
|
||||
|
||||
if ($action -eq "reboot") {
|
||||
$serviceAction = "restart"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "suspend") {
|
||||
$serviceAction = "stop"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "resume") {
|
||||
$serviceAction = "start"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "performance_mode") {
|
||||
$performanceMode = ([string]$Payload.performance_mode).ToLower()
|
||||
|
||||
if ($performanceMode -in @("low", "normal", "high")) {
|
||||
$api3Command = "set.miner.power_mode"
|
||||
$api3Param = $performanceMode
|
||||
} else {
|
||||
throw "Invalid performance_mode: $performanceMode"
|
||||
}
|
||||
} else {
|
||||
throw "Unsupported miner control action: $action"
|
||||
}
|
||||
|
||||
Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun"
|
||||
|
||||
if ($dryRun) {
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $true
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "Dry run only. No miner command sent."
|
||||
}
|
||||
}
|
||||
|
||||
if ($action -eq "performance_mode") {
|
||||
$api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param
|
||||
} else {
|
||||
$api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction
|
||||
}
|
||||
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $false
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "API3 miner control command sent by Windows Agent."
|
||||
api3_result = $api3Result
|
||||
}
|
||||
}
|
||||
# MinerOps M30 legacy 4028 test path
|
||||
if ($Payload -is [string]) {
|
||||
try {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if ($Payload -and (($Payload.control_method -eq "legacy_4028") -or ($Payload.action -eq "legacy_4028"))) {
|
||||
return Invoke-MinerOpsM30Legacy4028Test -Payload $Payload
|
||||
}
|
||||
|
||||
|
||||
if ($Payload -is [string]) {
|
||||
$Payload = $Payload | ConvertFrom-Json
|
||||
}
|
||||
|
||||
if ($null -eq $Payload) {
|
||||
throw "Miner control payload was empty."
|
||||
}
|
||||
|
||||
$action = ([string]$Payload.action).ToLower()
|
||||
$minerId = [string]$Payload.miner_id
|
||||
$macAddress = [string]$Payload.mac_address
|
||||
$dryRun = [bool]$Payload.dry_run
|
||||
|
||||
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||
|
||||
if ($ipAddress.Contains("/")) {
|
||||
$ipAddress = $ipAddress.Split("/")[0]
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||
throw "Miner control payload did not include ip_address."
|
||||
}
|
||||
|
||||
$serviceAction = ""
|
||||
$api3Command = ""
|
||||
$api3Param = $null
|
||||
$performanceMode = ""
|
||||
|
||||
if ($action -eq "reboot") {
|
||||
$serviceAction = "restart"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "suspend") {
|
||||
$serviceAction = "stop"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "resume") {
|
||||
$serviceAction = "start"
|
||||
$api3Command = "set.miner.service"
|
||||
} elseif ($action -eq "performance_mode") {
|
||||
$performanceMode = ([string]$Payload.performance_mode).ToLower()
|
||||
|
||||
if ($performanceMode -in @("low", "normal", "high")) {
|
||||
$api3Command = "set.miner.power_mode"
|
||||
$api3Param = $performanceMode
|
||||
} else {
|
||||
throw "Invalid performance_mode: $performanceMode"
|
||||
}
|
||||
} else {
|
||||
throw "Unsupported miner control action: $action"
|
||||
}
|
||||
|
||||
Write-AgentLog "Miner control requested. Action=$action API3Command=$api3Command API3Service=$serviceAction PerformanceMode=$performanceMode IP=$ipAddress MAC=$macAddress DryRun=$dryRun"
|
||||
|
||||
if ($dryRun) {
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $true
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "Dry run only. No miner command sent."
|
||||
}
|
||||
}
|
||||
|
||||
if ($action -eq "performance_mode") {
|
||||
$api3Result = Invoke-MinerOpsApi3AuthenticatedCommand -IpAddress $ipAddress -Command $api3Command -Param $api3Param
|
||||
} else {
|
||||
$api3Result = Invoke-MinerOpsApi3Service -IpAddress $ipAddress -ServiceAction $serviceAction
|
||||
}
|
||||
|
||||
return @{
|
||||
ok = $true
|
||||
dry_run = $false
|
||||
action = $action
|
||||
api3_command = $api3Command
|
||||
api3_service_action = $serviceAction
|
||||
performance_mode = $performanceMode
|
||||
ip_address = $ipAddress
|
||||
miner_id = $minerId
|
||||
mac_address = $macAddress
|
||||
message = "API3 miner control command sent by Windows Agent."
|
||||
api3_result = $api3Result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Process-PendingCommands {
|
||||
@@ -1416,6 +1417,10 @@ function Process-PendingCommands {
|
||||
return $commands.Count
|
||||
}
|
||||
|
||||
$controlModule = Join-Path $PSScriptRoot "MinerOpsControl.ps1"
|
||||
if (!(Test-Path $controlModule)) { throw "Missing typed control module: $controlModule" }
|
||||
. $controlModule
|
||||
|
||||
Write-AgentLog "MinerOps Agent service script started."
|
||||
Save-AgentState -Status "starting" -Message "Agent starting."
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0-alpha
|
||||
@@ -0,0 +1,62 @@
|
||||
#Requires -RunAsAdministrator
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Target = "C:\MinerOpsAgent"
|
||||
$BackupRoot = Join-Path $Target "backups"
|
||||
$Backup = Join-Path $BackupRoot (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
$RuntimeFiles = @(
|
||||
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||
"VERSION"
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
|
||||
$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) })
|
||||
if ($existing.Count -gt 0) {
|
||||
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
|
||||
foreach ($name in $existing) {
|
||||
Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($name in $RuntimeFiles) {
|
||||
$source = Join-Path $PSScriptRoot $name
|
||||
if (!(Test-Path $source)) { throw "Deployment source is missing $name" }
|
||||
Copy-Item $source (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)
|
||||
|
||||
$definitions = @(
|
||||
@{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system },
|
||||
@{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null },
|
||||
@{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system },
|
||||
@{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system }
|
||||
)
|
||||
foreach ($definition in $definitions) {
|
||||
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
||||
(Join-Path $Target $definition.Script)
|
||||
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
|
||||
if ($definition.Principal) {
|
||||
Register-ScheduledTask -TaskName $definition.Name -Action $action `
|
||||
-Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null
|
||||
} else {
|
||||
Register-ScheduledTask -TaskName $definition.Name -Action $action `
|
||||
-Trigger $definition.Trigger -Force | Out-Null
|
||||
}
|
||||
}
|
||||
Write-Host "MinerOps Windows Agent installed. Existing config, state, and logs were preserved."
|
||||
} catch {
|
||||
if (Test-Path $Backup) {
|
||||
foreach ($name in $existing) {
|
||||
Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
$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."
|
||||
@@ -0,0 +1,21 @@
|
||||
#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."
|
||||
@@ -0,0 +1,137 @@
|
||||
"""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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Authenticated Agent command transport for MinerOps."""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Dependencies for the authenticated Agent queue protocol."""
|
||||
|
||||
import hmac
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
from app.agent_commands.repository import AgentCommandRepository
|
||||
from app.agent_commands.service import AgentCommandService
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_agent_command_service() -> AgentCommandService:
|
||||
return AgentCommandService(AgentCommandRepository())
|
||||
|
||||
|
||||
def 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,
|
||||
) -> str:
|
||||
expected_id = settings.minerops_agent_id.strip()
|
||||
expected_secret = settings.minerops_agent_shared_secret.strip()
|
||||
if not expected_id or not expected_secret:
|
||||
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
|
||||
|
||||
secret = (provided_secret or "").strip()
|
||||
if not secret or not hmac.compare_digest(secret, expected_secret):
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
|
||||
|
||||
identity = (agent_id or "").strip()
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Missing Agent identity")
|
||||
if not hmac.compare_digest(identity, expected_id):
|
||||
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
|
||||
return identity
|
||||
@@ -0,0 +1,17 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -16,6 +16,9 @@ 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
|
||||
|
||||
@@ -48,6 +48,38 @@ 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>
|
||||
|
||||
@@ -1722,3 +1722,52 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
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"),
|
||||
@@ -32,7 +33,17 @@
|
||||
identityHistory: document.getElementById("minerIdentityHistory"),
|
||||
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
||||
observations: document.getElementById("minerObservations"),
|
||||
observationsBody: document.getElementById("minerObservationsBody")
|
||||
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")
|
||||
};
|
||||
|
||||
function create(tag, className, text) {
|
||||
@@ -121,6 +132,123 @@
|
||||
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");
|
||||
@@ -413,8 +541,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadOverview();
|
||||
loadControls();
|
||||
}
|
||||
|
||||
el.retry.addEventListener("click", loadOverview);
|
||||
el.refresh.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.identityHistory.addEventListener("toggle", function () {
|
||||
if (el.identityHistory.open) loadIdentityHistory();
|
||||
});
|
||||
@@ -422,4 +563,5 @@
|
||||
if (el.observations.open) loadObservations();
|
||||
});
|
||||
loadOverview();
|
||||
loadControls();
|
||||
})();
|
||||
@@ -521,6 +521,7 @@ 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">
|
||||
|
||||
@@ -757,6 +757,17 @@ 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
|
||||
|
||||
+20
-165
@@ -11,6 +11,8 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||
|
||||
from app.config import settings
|
||||
from app.agent_commands.router import router as agent_commands_router
|
||||
from app.miner_control.router import router as miner_control_router
|
||||
from app.dashboard.router import router as dashboard_router
|
||||
from app.identity import (
|
||||
COOKIE_TOKEN_HINT,
|
||||
@@ -63,6 +65,8 @@ app = FastAPI(title=settings.app_name)
|
||||
app.include_router(identity_router)
|
||||
app.include_router(frontend_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(miner_control_router)
|
||||
app.include_router(agent_commands_router)
|
||||
app.include_router(components_router)
|
||||
app.include_router(locations_router)
|
||||
app.include_router(dashboard_router)
|
||||
@@ -4010,6 +4014,8 @@ def minerops_process_due_scheduled_bulk_actions():
|
||||
conn.close()
|
||||
|
||||
|
||||
app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions
|
||||
|
||||
|
||||
def minerops_reconcile_scheduled_bulk_action_statuses():
|
||||
minerops_ensure_scheduled_bulk_actions_table()
|
||||
@@ -24985,8 +24991,7 @@ async def minerops_hub_mode_js():
|
||||
# -----------------------------
|
||||
|
||||
def minerops_agent_secret_expected() -> str:
|
||||
import os
|
||||
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
|
||||
return settings.minerops_agent_shared_secret.strip()
|
||||
|
||||
|
||||
def minerops_require_agent_secret(request: Request):
|
||||
@@ -25089,6 +25094,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
body = {}
|
||||
|
||||
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
|
||||
site_id = str(body.get("site_id") or "").strip()
|
||||
site_name = str(body.get("site_name") or "Plexus").strip()
|
||||
hostname = str(body.get("hostname") or "").strip()
|
||||
version = str(body.get("version") or "").strip()
|
||||
@@ -25101,6 +25107,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
@@ -25108,9 +25115,10 @@ async def minerops_agent_checkin(request: Request):
|
||||
last_checkin_at,
|
||||
last_payload
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, now(), $6::jsonb)
|
||||
VALUES ($1, NULLIF($2, '')::uuid, $3, $4, $5, $6, now(), $7::jsonb)
|
||||
ON CONFLICT (agent_id)
|
||||
DO UPDATE SET
|
||||
site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id),
|
||||
site_name = EXCLUDED.site_name,
|
||||
hostname = EXCLUDED.hostname,
|
||||
version = EXCLUDED.version,
|
||||
@@ -25119,6 +25127,7 @@ async def minerops_agent_checkin(request: Request):
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""",
|
||||
agent_id,
|
||||
site_id,
|
||||
site_name,
|
||||
hostname,
|
||||
version,
|
||||
@@ -25301,12 +25310,11 @@ async def minerops_agent_setup_info(current_user: dict = Depends(get_current_use
|
||||
return {
|
||||
"ok": True,
|
||||
"hub_url": "https://plexus.lussier.cc",
|
||||
"agent_id": "plexus-windows-agent",
|
||||
"agent_id": settings.minerops_agent_id,
|
||||
"site_name": "Plexus",
|
||||
"agent_secret": secret,
|
||||
"secret_length": len(secret),
|
||||
"secret_configured": bool(secret),
|
||||
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
|
||||
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
|
||||
"message": "Agent credentials are provisioned locally and are never returned to browsers.",
|
||||
}
|
||||
|
||||
|
||||
@@ -25353,7 +25361,7 @@ async def minerops_agent_setup_page():
|
||||
<div class="card">
|
||||
<h2>Agent Connection Info</h2>
|
||||
<div class="warn">
|
||||
Keep this secret private. Anyone with the secret can submit agent data to this hub.
|
||||
Agent credentials must be provisioned directly on the Windows host.
|
||||
</div>
|
||||
|
||||
<label>Hub URL</label>
|
||||
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page():
|
||||
<label>Site Name</label>
|
||||
<input id="siteName" readonly>
|
||||
|
||||
<label>Agent Shared Secret</label>
|
||||
<input id="agentSecret" type="password" readonly>
|
||||
|
||||
<p>
|
||||
<button onclick="toggleSecret()">Show / Hide Secret</button>
|
||||
<button onclick="copySecret()">Copy Secret</button>
|
||||
<button onclick="copyConfig()">Copy Full Config JSON</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Windows Agent Config JSON</h2>
|
||||
<p class="small">Paste this into <strong>C:\\MinerOpsAgent\\agent-config.json</strong> if you need to recreate the agent config.</p>
|
||||
<textarea id="configJson" readonly></textarea>
|
||||
<p id="secretStatus" class="small"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -25427,42 +25422,14 @@ async function loadSetup() {
|
||||
document.getElementById("hubUrl").value = data.hub_url || "";
|
||||
document.getElementById("agentId").value = data.agent_id || "";
|
||||
document.getElementById("siteName").value = data.site_name || "";
|
||||
document.getElementById("agentSecret").value = data.agent_secret || "";
|
||||
|
||||
const config = {
|
||||
agent_id: data.agent_id,
|
||||
site_name: data.site_name,
|
||||
hub_url: data.hub_url,
|
||||
agent_secret: data.agent_secret,
|
||||
poll_interval_seconds: 120,
|
||||
ip_ranges: [
|
||||
{ name: "Unit 4", cidr: "10.30.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
|
||||
{ name: "Unit 6", cidr: "10.80.0.0/22", enabled: true, api_port: 4028, api3_port: 4433 },
|
||||
{ name: "Unit 12", cidr: "10.90.0.0/22", enabled: false, api_port: 4028, api3_port: 4433 }
|
||||
]
|
||||
};
|
||||
|
||||
document.getElementById("configJson").value = JSON.stringify(config, null, 2);
|
||||
document.getElementById("secretStatus").textContent = data.secret_configured
|
||||
? "A shared Agent secret is configured on the Hub. Its value is not exposed here."
|
||||
: "The Hub does not currently have an Agent secret configured.";
|
||||
|
||||
status.className = "ok";
|
||||
status.textContent = "Agent setup info loaded.";
|
||||
}
|
||||
|
||||
function toggleSecret() {
|
||||
const input = document.getElementById("agentSecret");
|
||||
input.type = input.type === "password" ? "text" : "password";
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(document.getElementById("agentSecret").value);
|
||||
alert("Secret copied.");
|
||||
}
|
||||
|
||||
async function copyConfig() {
|
||||
await navigator.clipboard.writeText(document.getElementById("configJson").value);
|
||||
alert("Config JSON copied.");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadSetup);
|
||||
</script>
|
||||
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
|
||||
@@ -25939,118 +25906,6 @@ async def minerops_agent_command_status(current_user: dict = Depends(get_current
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/agent/commands/pending")
|
||||
async def minerops_agent_commands_pending_v2(request: Request):
|
||||
# __SCHEDULE_DUE_HOOK_V2__
|
||||
try:
|
||||
minerops_process_due_scheduled_bulk_actions()
|
||||
except NameError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print(f"Scheduled bulk action due processing error: {exc}")
|
||||
|
||||
minerops_require_agent_secret(request)
|
||||
|
||||
await minerops_ensure_agent_command_queue_table()
|
||||
|
||||
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
|
||||
|
||||
conn = await minerops_command_db_connect()
|
||||
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET
|
||||
command_status = 'claimed',
|
||||
claimed_at = now()
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM minerops_agent_command_queue
|
||||
WHERE agent_id = $1
|
||||
AND command_status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
command_type,
|
||||
request_payload,
|
||||
created_at
|
||||
""",
|
||||
agent_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
commands = []
|
||||
|
||||
for row in rows:
|
||||
commands.append(
|
||||
{
|
||||
"command_id": row["id"],
|
||||
"command_type": row["command_type"],
|
||||
"request_payload": row["request_payload"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"commands": commands,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/agent/commands/result")
|
||||
async def minerops_agent_commands_result_v2(request: Request):
|
||||
import json
|
||||
|
||||
minerops_require_agent_secret(request)
|
||||
|
||||
body = await request.json()
|
||||
|
||||
command_id = str(body.get("command_id") or "").strip()
|
||||
status = str(body.get("status") or "").strip().lower()
|
||||
result_payload = body.get("result_payload") or {}
|
||||
error_message = body.get("error_message")
|
||||
|
||||
if not command_id:
|
||||
raise HTTPException(status_code=400, detail="Missing command_id.")
|
||||
|
||||
if status not in {"completed", "failed"}:
|
||||
status = "completed"
|
||||
|
||||
await minerops_ensure_agent_command_queue_table()
|
||||
|
||||
conn = await minerops_command_db_connect()
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE minerops_agent_command_queue
|
||||
SET
|
||||
command_status = $2,
|
||||
result_payload = $3::jsonb,
|
||||
error_message = $4,
|
||||
completed_at = now()
|
||||
WHERE id = $1
|
||||
""",
|
||||
command_id,
|
||||
status,
|
||||
json.dumps(result_payload),
|
||||
error_message,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"command_id": command_id,
|
||||
"status": status,
|
||||
"message": "Agent command result received.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Phase 5K.5: Agent control web page
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed miner control domain."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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, 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 minerops_site_agents AS agents
|
||||
ON agents.site_id = miners.site_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()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""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("site_id"):
|
||||
return ControlCapability(state="unavailable", reason="site_unassigned")
|
||||
if not context.get("agent_id"):
|
||||
return ControlCapability(state="unavailable", reason="agent_unassigned")
|
||||
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"],
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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_commands.dependencies import 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 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.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.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()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""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(),
|
||||
"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 = (
|
||||
({"site_id": None}, "unavailable", "site_unassigned"),
|
||||
({"agent_id": None}, "unavailable", "agent_unassigned"),
|
||||
({"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()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""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")
|
||||
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 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, asset_tag, current_ip, status, api3_supported,
|
||||
configured_performance_mode, first_seen_at, last_seen_at
|
||||
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
||||
'normal', NULL, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
|
||||
api3_supported = true
|
||||
""", (MINER_ID, SITE_ID)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id, site_id, site_name, last_payload
|
||||
) VALUES (%s, %s, 'Control Test Site', %s)
|
||||
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""", (AGENT_ID, SITE_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 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()
|
||||
@@ -498,10 +498,12 @@ class MinerDetailFrontendTests(unittest.TestCase):
|
||||
r'type="button">Refresh</button>',
|
||||
)
|
||||
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'el.refresh.addEventListener("click", loadOverview);',
|
||||
self.assertIn('el.refresh.addEventListener("click", refreshAll);', source)
|
||||
refresh = re.search(
|
||||
r"function refreshAll\(\) \{\s*loadOverview\(\);\s*loadControls\(\);\s*\}",
|
||||
source,
|
||||
)
|
||||
self.assertIsNotNone(refresh)
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
|
||||
|
||||
@@ -562,9 +564,14 @@ class MinerDetailFrontendTests(unittest.TestCase):
|
||||
self.assertIn("/events", source)
|
||||
self.assertIn("/parts", source)
|
||||
self.assertNotIn("setInterval", source)
|
||||
self.assertEqual(source.count('method: "POST"'), 1)
|
||||
self.assertIn('"/controls/" + pathAction', source)
|
||||
self.assertIn(
|
||||
'action === "performance_mode" ? "performance-mode" : action', source
|
||||
)
|
||||
for mutation in (
|
||||
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
|
||||
'method: "DELETE"', "reboot", "change-mac", "manual-details",
|
||||
'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"',
|
||||
"reboot", "change-mac", "manual-details",
|
||||
):
|
||||
self.assertNotIn(mutation, source)
|
||||
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||
EXPECTED_HEAD = "0014_repair_parts_tracking"
|
||||
EXPECTED_HEAD = "0015_miner_control_foundation"
|
||||
|
||||
|
||||
def database_payload(revision=EXPECTED_HEAD):
|
||||
|
||||
@@ -78,8 +78,8 @@ migration_head="$(
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
|
||||
)"
|
||||
if [[ "${migration_head}" != "0014_repair_parts_tracking" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0014_repair_parts_tracking." >&2
|
||||
if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then
|
||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]]
|
||||
[[ "${current_revision}" == *"0015_miner_control_foundation"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_CONTROL_MIGRATION_PROJECT:-minerops-control-migration-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
|
||||
psql_db() {
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
|
||||
}
|
||||
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo "Testing 0015 preflight failures from the previous head..."
|
||||
run_alembic upgrade 0014_repair_parts_tracking
|
||||
psql_db -c "INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status) VALUES ('unknown-status', 'test-agent', 'test', 'mystery')"
|
||||
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
|
||||
echo "Unknown queue status unexpectedly passed migration preflight" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"unsupported command_status values: mystery"* ]]
|
||||
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id = 'unknown-status'"
|
||||
|
||||
duplicate_miner="00000000-0000-4000-8000-000000001599"
|
||||
psql_db <<SQL
|
||||
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status, request_payload)
|
||||
VALUES
|
||||
('duplicate-one', 'test-agent', 'miner_control', 'pending', '{"miner_id":"${duplicate_miner}"}'),
|
||||
('duplicate-two', 'test-agent', 'miner_control', 'claimed', '{"miner_id":"${duplicate_miner}"}');
|
||||
SQL
|
||||
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
|
||||
echo "Duplicate live controls unexpectedly passed migration preflight" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${output}" == *"more than one pending or claimed miner_control"* ]]
|
||||
[[ "${output}" == *"${duplicate_miner}"* ]]
|
||||
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id IN ('duplicate-one', 'duplicate-two')"
|
||||
|
||||
psql_db <<'SQL'
|
||||
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||
VALUES ('legacy-unassigned-agent', 'Legacy Display Name');
|
||||
INSERT INTO minerops_agent_command_queue (
|
||||
id, agent_id, command_type, command_status, request_payload
|
||||
) VALUES (
|
||||
'preserved-history', 'legacy-unassigned-agent', 'miner_control', 'completed',
|
||||
'{"miner_id":"00000000-0000-4000-8000-000000001598","action":"suspend"}'
|
||||
);
|
||||
SQL
|
||||
run_alembic upgrade 0015_miner_control_foundation
|
||||
[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]]
|
||||
|
||||
echo "Checking assignment, status, uniqueness, history, and preservation..."
|
||||
psql_db <<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT site_id IS NOT NULL FROM minerops_site_agents
|
||||
WHERE agent_id = 'legacy-unassigned-agent') THEN
|
||||
RAISE EXCEPTION 'Legacy site_name was silently converted to site_id';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM minerops_agent_command_queue
|
||||
WHERE id = 'preserved-history' AND command_status = 'completed') THEN
|
||||
RAISE EXCEPTION 'Historical queue row was not preserved';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_minerops_agent_command_queue_status') THEN
|
||||
RAISE EXCEPTION 'Queue status constraint missing';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'uq_agent_queue_live_miner_control')
|
||||
OR NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'idx_agent_queue_miner_control_history') THEN
|
||||
RAISE EXCEPTION 'Miner control queue indexes missing';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES ('00000000-0000-4000-8000-000000001590', 'Control Migration Site');
|
||||
UPDATE minerops_site_agents SET site_id = '00000000-0000-4000-8000-000000001590'
|
||||
WHERE agent_id = 'legacy-unassigned-agent';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO minerops_site_agents (agent_id, site_id, site_name)
|
||||
VALUES ('duplicate-site-agent', '00000000-0000-4000-8000-000000001590', 'Duplicate');
|
||||
RAISE EXCEPTION 'Duplicate durable site assignment was accepted';
|
||||
EXCEPTION WHEN unique_violation THEN NULL;
|
||||
END;
|
||||
BEGIN
|
||||
DELETE FROM sites WHERE id = '00000000-0000-4000-8000-000000001590';
|
||||
RAISE EXCEPTION 'Assigned site deletion was accepted';
|
||||
EXCEPTION WHEN foreign_key_violation THEN NULL;
|
||||
END;
|
||||
BEGIN
|
||||
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status)
|
||||
VALUES ('bad-status-after', 'legacy-unassigned-agent', 'test', 'mystery');
|
||||
RAISE EXCEPTION 'Unknown status passed CHECK';
|
||||
EXCEPTION WHEN check_violation THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
echo "Testing downgrade, historical preservation, and re-upgrade..."
|
||||
run_alembic downgrade 0014_repair_parts_tracking
|
||||
[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
|
||||
[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_agent_command_queue WHERE id = 'preserved-history'")" == "1" ]]
|
||||
[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.columns WHERE table_name='minerops_site_agents' AND column_name='site_id'")" == "0" ]]
|
||||
run_alembic upgrade 0015_miner_control_foundation
|
||||
|
||||
echo "Testing empty database through head..."
|
||||
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
||||
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
||||
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
|
||||
|
||||
echo "Miner Control Foundation migration tests passed."
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
project_name="${MINEROPS_CONTROL_TEST_PROJECT:-minerops-control-test-$$}"
|
||||
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if (( status != 0 )); then
|
||||
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||
fi
|
||||
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||
for source_file in backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
||||
permissions="$(stat -c '%a' "${source_file}")"
|
||||
(( (10#${permissions} % 10) & 4 )) || {
|
||||
echo "${source_file} is not readable by the non-root test container" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
for source_directory in agents agents/windows agents/windows/tests; do
|
||||
permissions="$(stat -c '%a' "${source_directory}")"
|
||||
(( (10#${permissions} % 10) & 1 )) || {
|
||||
echo "${source_directory} is not traversable by the non-root test container" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
"${compose[@]}" up --detach postgres
|
||||
for attempt in $(seq 1 60); do
|
||||
"${compose[@]}" exec -T postgres sh -ec \
|
||||
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
"${compose[@]}" build backend
|
||||
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
-v "${repo_root}/agents:/agents:ro" \
|
||||
backend python -m unittest -v \
|
||||
tests.test_miner_control \
|
||||
tests.test_agent_commands \
|
||||
tests.test_miner_control_postgres
|
||||
|
||||
echo "Miner Control Foundation focused tests passed."
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
service="agents/windows/MinerOpsAgentService.ps1"
|
||||
control="agents/windows/MinerOpsControl.ps1"
|
||||
rg -q 'X-MinerOps-Agent-ID' "${service}"
|
||||
! rg -q 'commands/pending\?agent_id=' "${service}"
|
||||
! rg -q 'DefaultValue "super"' "${service}"
|
||||
rg -q 'get\.miner\.setting' "${control}"
|
||||
rg -q '\$attempt -le 3' "${control}"
|
||||
rg -q 'VerificationStatus "unconfirmed"' "${control}"
|
||||
for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_response'; do
|
||||
! rg -q "${secret_field}" "${control}"
|
||||
done
|
||||
|
||||
if command -v pwsh >/dev/null 2>&1; then
|
||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||
else
|
||||
echo "pwsh is not installed; deterministic PowerShell harness retained but not executed."
|
||||
fi
|
||||
echo "Windows Agent source contract checks passed."
|
||||
Reference in new issue
Block a user