diff --git a/agents/windows/.gitattributes b/agents/windows/.gitattributes index 9f88a27..9a3e5d8 100644 --- a/agents/windows/.gitattributes +++ b/agents/windows/.gitattributes @@ -1,2 +1,4 @@ *.ps1 -text + +*.ps1 whitespace=cr-at-eol diff --git a/agents/windows/MinerOpsAgentService.ps1 b/agents/windows/MinerOpsAgentService.ps1 index 57a812f..8d9e97f 100644 --- a/agents/windows/MinerOpsAgentService.ps1 +++ b/agents/windows/MinerOpsAgentService.ps1 @@ -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." diff --git a/agents/windows/MinerOpsControl.ps1 b/agents/windows/MinerOpsControl.ps1 new file mode 100644 index 0000000..55387d7 --- /dev/null +++ b/agents/windows/MinerOpsControl.ps1 @@ -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 +} diff --git a/agents/windows/VERSION b/agents/windows/VERSION new file mode 100644 index 0000000..dadcca1 --- /dev/null +++ b/agents/windows/VERSION @@ -0,0 +1 @@ +1.0.0-alpha diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 new file mode 100644 index 0000000..a0abec4 --- /dev/null +++ b/agents/windows/install.ps1 @@ -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 +} diff --git a/agents/windows/tests/Test-MinerOpsControl.ps1 b/agents/windows/tests/Test-MinerOpsControl.ps1 new file mode 100644 index 0000000..c1cf328 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsControl.ps1 @@ -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." diff --git a/agents/windows/uninstall.ps1 b/agents/windows/uninstall.ps1 new file mode 100644 index 0000000..0de7e6d --- /dev/null +++ b/agents/windows/uninstall.ps1 @@ -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." diff --git a/backend/alembic/versions/0015_miner_control_foundation.py b/backend/alembic/versions/0015_miner_control_foundation.py new file mode 100644 index 0000000..583e9c1 --- /dev/null +++ b/backend/alembic/versions/0015_miner_control_foundation.py @@ -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") diff --git a/backend/app/agent_commands/__init__.py b/backend/app/agent_commands/__init__.py new file mode 100644 index 0000000..30c60af --- /dev/null +++ b/backend/app/agent_commands/__init__.py @@ -0,0 +1 @@ +"""Authenticated Agent command transport for MinerOps.""" diff --git a/backend/app/agent_commands/dependencies.py b/backend/app/agent_commands/dependencies.py new file mode 100644 index 0000000..94c1bd7 --- /dev/null +++ b/backend/app/agent_commands/dependencies.py @@ -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 diff --git a/backend/app/agent_commands/exceptions.py b/backend/app/agent_commands/exceptions.py new file mode 100644 index 0000000..3b7aa2d --- /dev/null +++ b/backend/app/agent_commands/exceptions.py @@ -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.""" diff --git a/backend/app/agent_commands/models.py b/backend/app/agent_commands/models.py new file mode 100644 index 0000000..dd8585f --- /dev/null +++ b/backend/app/agent_commands/models.py @@ -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"] diff --git a/backend/app/agent_commands/repository.py b/backend/app/agent_commands/repository.py new file mode 100644 index 0000000..1909f54 --- /dev/null +++ b/backend/app/agent_commands/repository.py @@ -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() diff --git a/backend/app/agent_commands/router.py b/backend/app/agent_commands/router.py new file mode 100644 index 0000000..8c27086 --- /dev/null +++ b/backend/app/agent_commands/router.py @@ -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) diff --git a/backend/app/agent_commands/service.py b/backend/app/agent_commands/service.py new file mode 100644 index 0000000..8148478 --- /dev/null +++ b/backend/app/agent_commands/service.py @@ -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, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 32348d6..84c56e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py index c06723d..65b985b 100644 --- a/backend/app/frontend/miner_detail_view.py +++ b/backend/app/frontend/miner_detail_view.py @@ -48,6 +48,38 @@ def miner_detail_content() -> str:
+
+

Agent-routed actions

+

Controls

+ +
+

+ Loading factual capability and recent operations. +

+
+ + + +
+ + +
ActionRequestedStatusVerificationCreated
+
+

Physical context

Location

diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index 01055f5..d405bb4 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -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; + } +} diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js index f128d8f..af1756b 100644 --- a/backend/app/frontend/static/miner-detail.js +++ b/backend/app/frontend/static/miner-detail.js @@ -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(); })(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 87fb8e3..c5908bc 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -521,6 +521,7 @@ def render_app_page(
diff --git a/backend/app/identity.py b/backend/app/identity.py index 15d8dae..c6c8323 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 239462a..8c5ab70 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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():

Agent Connection Info

- 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.
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page(): - - - -

- - - -

-
- -
-

Windows Agent Config JSON

-

Paste this into C:\\MinerOpsAgent\\agent-config.json if you need to recreate the agent config.

- +

@@ -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); @@ -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 diff --git a/backend/app/miner_control/__init__.py b/backend/app/miner_control/__init__.py new file mode 100644 index 0000000..d261d88 --- /dev/null +++ b/backend/app/miner_control/__init__.py @@ -0,0 +1 @@ +"""Typed miner control domain.""" diff --git a/backend/app/miner_control/dependencies.py b/backend/app/miner_control/dependencies.py new file mode 100644 index 0000000..0003016 --- /dev/null +++ b/backend/app/miner_control/dependencies.py @@ -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()) diff --git a/backend/app/miner_control/exceptions.py b/backend/app/miner_control/exceptions.py new file mode 100644 index 0000000..24281dd --- /dev/null +++ b/backend/app/miner_control/exceptions.py @@ -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.""" diff --git a/backend/app/miner_control/models.py b/backend/app/miner_control/models.py new file mode 100644 index 0000000..3abf6e3 --- /dev/null +++ b/backend/app/miner_control/models.py @@ -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 diff --git a/backend/app/miner_control/repository.py b/backend/app/miner_control/repository.py new file mode 100644 index 0000000..ce9ca1d --- /dev/null +++ b/backend/app/miner_control/repository.py @@ -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() diff --git a/backend/app/miner_control/router.py b/backend/app/miner_control/router.py new file mode 100644 index 0000000..777f850 --- /dev/null +++ b/backend/app/miner_control/router.py @@ -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) diff --git a/backend/app/miner_control/service.py b/backend/app/miner_control/service.py new file mode 100644 index 0000000..3f8a602 --- /dev/null +++ b/backend/app/miner_control/service.py @@ -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"], + ) diff --git a/backend/tests/test_agent_commands.py b/backend/tests/test_agent_commands.py new file mode 100644 index 0000000..f569033 --- /dev/null +++ b/backend/tests/test_agent_commands.py @@ -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() diff --git a/backend/tests/test_miner_control.py b/backend/tests/test_miner_control.py new file mode 100644 index 0000000..5f092e9 --- /dev/null +++ b/backend/tests/test_miner_control.py @@ -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() diff --git a/backend/tests/test_miner_control_postgres.py b/backend/tests/test_miner_control_postgres.py new file mode 100644 index 0000000..87651a2 --- /dev/null +++ b/backend/tests/test_miner_control_postgres.py @@ -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() diff --git a/backend/tests/test_miner_detail.py b/backend/tests/test_miner_detail.py index d6679d3..029691f 100644 --- a/backend/tests/test_miner_detail.py +++ b/backend/tests/test_miner_detail.py @@ -498,10 +498,12 @@ class MinerDetailFrontendTests(unittest.TestCase): r'type="button">Refresh', ) 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"): diff --git a/backend/tests/test_system_health.py b/backend/tests/test_system_health.py index 8221afc..93a9669 100644 --- a/backend/tests/test_system_health.py +++ b/backend/tests/test_system_health.py @@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService APP_ROOT = Path(__file__).parents[1] / "app" -EXPECTED_HEAD = "0014_repair_parts_tracking" +EXPECTED_HEAD = "0015_miner_control_foundation" def database_payload(revision=EXPECTED_HEAD): diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh index c0f1e1a..9cad861 100755 --- a/scripts/seed-development-demo.sh +++ b/scripts/seed-development-demo.sh @@ -78,8 +78,8 @@ migration_head="$( "${compose[@]}" exec -T postgres sh -ec \ 'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"' )" -if [[ "${migration_head}" != "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 diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 531ec18..e8313a5 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]] +[[ "${current_revision}" == *"0015_miner_control_foundation"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-miner-control-migration.sh b/scripts/test-miner-control-migration.sh new file mode 100755 index 0000000..14f5d42 --- /dev/null +++ b/scripts/test-miner-control-migration.sh @@ -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 <&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." diff --git a/scripts/test-miner-control.sh b/scripts/test-miner-control.sh new file mode 100755 index 0000000..c425874 --- /dev/null +++ b/scripts/test-miner-control.sh @@ -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." diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh new file mode 100755 index 0000000..3181024 --- /dev/null +++ b/scripts/test-windows-agent-source.sh @@ -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."