Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cd7ad058 | ||
|
|
f5965af914 | ||
|
|
6cd876b4b3 |
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
*.ps1 -text
|
||||||
|
|
||||||
|
|
||||||
|
*.ps1 whitespace=cr-at-eol
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
agent-config.json
|
||||||
|
agent-state.json
|
||||||
|
logs/
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
*-probe-*.json
|
||||||
|
rollback/
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1
|
||||||
|
ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1
|
||||||
|
51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1
|
||||||
|
bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1
|
||||||
|
c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1
|
||||||
|
90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1
|
||||||
|
18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1
|
||||||
|
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$IpAddress,
|
||||||
|
|
||||||
|
[string]$MinerId = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||||
|
|
||||||
|
function Read-AgentConfig {
|
||||||
|
if (!(Test-Path $ConfigPath)) {
|
||||||
|
throw "Agent config not found: $ConfigPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-MinerCommandRaw {
|
||||||
|
param(
|
||||||
|
[string]$IpAddress,
|
||||||
|
[string]$Command,
|
||||||
|
[int]$Port = 4028,
|
||||||
|
[int]$TimeoutMs = 2500
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
|
||||||
|
|
||||||
|
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($async)
|
||||||
|
$client.ReceiveTimeout = $TimeoutMs
|
||||||
|
$client.SendTimeout = $TimeoutMs
|
||||||
|
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
|
||||||
|
$stream.Write($payload, 0, $payload.Length)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer = New-Object byte[] 262144
|
||||||
|
$chunks = New-Object System.Collections.ArrayList
|
||||||
|
$started = Get-Date
|
||||||
|
|
||||||
|
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
|
||||||
|
if (-not $stream.DataAvailable) {
|
||||||
|
Start-Sleep -Milliseconds 20
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||||
|
|
||||||
|
if ($read -le 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt $read; $i++) {
|
||||||
|
[void]$chunks.Add([byte]$buffer[$i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chunks.Count -gt 2097152) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
} finally {
|
||||||
|
if ($client -ne $null) {
|
||||||
|
$client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Try-ParseJson {
|
||||||
|
param([string]$Raw)
|
||||||
|
|
||||||
|
if (!$Raw -or $Raw.Trim().Length -eq 0) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $Raw | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-FirstValue {
|
||||||
|
param(
|
||||||
|
[object]$Object,
|
||||||
|
[string[]]$Names
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $Object) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $Object | ConvertTo-Json -Depth 80 -Compress
|
||||||
|
|
||||||
|
foreach ($name in $Names) {
|
||||||
|
$escaped = [regex]::Escape($name)
|
||||||
|
|
||||||
|
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
|
||||||
|
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
if ($m.Success) {
|
||||||
|
return $m.Groups[1].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
|
||||||
|
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
if ($m2.Success) {
|
||||||
|
return $m2.Groups[1].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-MacAddress {
|
||||||
|
param([string]$Text)
|
||||||
|
|
||||||
|
if (!$Text) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
|
||||||
|
if ($m.Success) {
|
||||||
|
return $m.Value.Replace("-", ":").ToUpper()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = Read-AgentConfig
|
||||||
|
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
|
||||||
|
$secret = [string]$config.agent_secret
|
||||||
|
|
||||||
|
$commands = @(
|
||||||
|
"summary",
|
||||||
|
"devs",
|
||||||
|
"edevs",
|
||||||
|
"stats",
|
||||||
|
"pools",
|
||||||
|
"devdetails",
|
||||||
|
"version",
|
||||||
|
"get_version",
|
||||||
|
"get_miner_info"
|
||||||
|
)
|
||||||
|
|
||||||
|
$raws = [ordered]@{}
|
||||||
|
$parsed = [ordered]@{}
|
||||||
|
|
||||||
|
foreach ($cmd in $commands) {
|
||||||
|
Write-Host "Querying $IpAddress command: $cmd"
|
||||||
|
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
|
||||||
|
$raws[$cmd] = $raw
|
||||||
|
$parsed[$cmd] = Try-ParseJson -Raw $raw
|
||||||
|
Write-Host " bytes: $($raw.Length)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
|
||||||
|
$allParsed = [ordered]@{
|
||||||
|
summary = $parsed["summary"]
|
||||||
|
devs = $parsed["devs"]
|
||||||
|
edevs = $parsed["edevs"]
|
||||||
|
stats = $parsed["stats"]
|
||||||
|
pools = $parsed["pools"]
|
||||||
|
devdetails = $parsed["devdetails"]
|
||||||
|
version = $parsed["version"]
|
||||||
|
get_version = $parsed["get_version"]
|
||||||
|
get_miner_info = $parsed["get_miner_info"]
|
||||||
|
}
|
||||||
|
|
||||||
|
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
|
||||||
|
if (!$mac) {
|
||||||
|
$mac = Find-MacAddress -Text $allText
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
|
||||||
|
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
|
||||||
|
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
|
||||||
|
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
|
||||||
|
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
|
||||||
|
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
|
||||||
|
|
||||||
|
if (!$model) {
|
||||||
|
$model = "WhatsMiner"
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [ordered]@{
|
||||||
|
ip_address = $IpAddress
|
||||||
|
miner_id = $MinerId
|
||||||
|
mac_address = $mac
|
||||||
|
hostname = $hostname
|
||||||
|
miner_sn = $minerSn
|
||||||
|
model = $model
|
||||||
|
miner_type = $model
|
||||||
|
api_version = $apiVersion
|
||||||
|
firmware_version = $firmware
|
||||||
|
platform = $platform
|
||||||
|
raw_version = @{
|
||||||
|
version = $raws["version"]
|
||||||
|
get_version = $raws["get_version"]
|
||||||
|
}
|
||||||
|
raw_miner_info = @{
|
||||||
|
devs = $raws["devs"]
|
||||||
|
edevs = $raws["edevs"]
|
||||||
|
stats = $raws["stats"]
|
||||||
|
pools = $raws["pools"]
|
||||||
|
devdetails = $raws["devdetails"]
|
||||||
|
get_miner_info = $raws["get_miner_info"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Detected:"
|
||||||
|
Write-Host " MAC: $mac"
|
||||||
|
Write-Host " Hostname: $hostname"
|
||||||
|
Write-Host " SN: $minerSn"
|
||||||
|
Write-Host " Model: $model"
|
||||||
|
Write-Host " Firmware: $firmware"
|
||||||
|
Write-Host " API: $apiVersion"
|
||||||
|
Write-Host " Platform: $platform"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$response = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
|
||||||
|
-Headers @{
|
||||||
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
|
} `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body ($payload | ConvertTo-Json -Depth 80)
|
||||||
|
|
||||||
|
$response | ConvertTo-Json -Depth 20
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$BaseDir = "C:\MinerOpsAgent"
|
||||||
|
$ConfigPath = Join-Path $BaseDir "agent-config.json"
|
||||||
|
$StatePath = Join-Path $BaseDir "agent-state.json"
|
||||||
|
$LogPath = Join-Path $BaseDir "logs\agent.log"
|
||||||
|
$TaskName = "MinerOpsAgent"
|
||||||
|
|
||||||
|
function Read-Config {
|
||||||
|
if (Test-Path $ConfigPath) {
|
||||||
|
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-ConfigText {
|
||||||
|
if (Test-Path $ConfigPath) {
|
||||||
|
return Get-Content $ConfigPath -Raw
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-ConfigObject {
|
||||||
|
param([object]$Config)
|
||||||
|
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-ConfigText {
|
||||||
|
param([string]$Text)
|
||||||
|
$Text | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AgentTaskText {
|
||||||
|
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
if (!$task) { return "Scheduled task not installed" }
|
||||||
|
|
||||||
|
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = New-Object System.Windows.Forms.Form
|
||||||
|
$form.Text = "MinerOps Agent"
|
||||||
|
$form.Size = New-Object System.Drawing.Size(950, 780)
|
||||||
|
$form.StartPosition = "CenterScreen"
|
||||||
|
|
||||||
|
$statusLabel = New-Object System.Windows.Forms.Label
|
||||||
|
$statusLabel.Location = New-Object System.Drawing.Point(15, 15)
|
||||||
|
$statusLabel.Size = New-Object System.Drawing.Size(900, 24)
|
||||||
|
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||||
|
$form.Controls.Add($statusLabel)
|
||||||
|
|
||||||
|
$startButton = New-Object System.Windows.Forms.Button
|
||||||
|
$startButton.Text = "Start"
|
||||||
|
$startButton.Location = New-Object System.Drawing.Point(15, 45)
|
||||||
|
$startButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||||
|
$form.Controls.Add($startButton)
|
||||||
|
|
||||||
|
$stopButton = New-Object System.Windows.Forms.Button
|
||||||
|
$stopButton.Text = "Stop"
|
||||||
|
$stopButton.Location = New-Object System.Drawing.Point(125, 45)
|
||||||
|
$stopButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||||
|
$form.Controls.Add($stopButton)
|
||||||
|
|
||||||
|
$restartButton = New-Object System.Windows.Forms.Button
|
||||||
|
$restartButton.Text = "Restart"
|
||||||
|
$restartButton.Location = New-Object System.Drawing.Point(235, 45)
|
||||||
|
$restartButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||||
|
$form.Controls.Add($restartButton)
|
||||||
|
|
||||||
|
$testButton = New-Object System.Windows.Forms.Button
|
||||||
|
$testButton.Text = "Test Hub"
|
||||||
|
$testButton.Location = New-Object System.Drawing.Point(345, 45)
|
||||||
|
$testButton.Size = New-Object System.Drawing.Size(100, 32)
|
||||||
|
$form.Controls.Add($testButton)
|
||||||
|
|
||||||
|
$saveButton = New-Object System.Windows.Forms.Button
|
||||||
|
$saveButton.Text = "Save Config"
|
||||||
|
$saveButton.Location = New-Object System.Drawing.Point(455, 45)
|
||||||
|
$saveButton.Size = New-Object System.Drawing.Size(120, 32)
|
||||||
|
$form.Controls.Add($saveButton)
|
||||||
|
|
||||||
|
$secretLabel = New-Object System.Windows.Forms.Label
|
||||||
|
$secretLabel.Text = "Agent Secret:"
|
||||||
|
$secretLabel.Location = New-Object System.Drawing.Point(15, 90)
|
||||||
|
$secretLabel.Size = New-Object System.Drawing.Size(100, 22)
|
||||||
|
$form.Controls.Add($secretLabel)
|
||||||
|
|
||||||
|
$secretBox = New-Object System.Windows.Forms.TextBox
|
||||||
|
$secretBox.Location = New-Object System.Drawing.Point(115, 87)
|
||||||
|
$secretBox.Size = New-Object System.Drawing.Size(570, 24)
|
||||||
|
$secretBox.UseSystemPasswordChar = $true
|
||||||
|
$form.Controls.Add($secretBox)
|
||||||
|
|
||||||
|
$showSecretCheck = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$showSecretCheck.Text = "Show"
|
||||||
|
$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88)
|
||||||
|
$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24)
|
||||||
|
$form.Controls.Add($showSecretCheck)
|
||||||
|
|
||||||
|
$saveSecretButton = New-Object System.Windows.Forms.Button
|
||||||
|
$saveSecretButton.Text = "Save Secret"
|
||||||
|
$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84)
|
||||||
|
$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30)
|
||||||
|
$form.Controls.Add($saveSecretButton)
|
||||||
|
|
||||||
|
$configBox = New-Object System.Windows.Forms.TextBox
|
||||||
|
$configBox.Location = New-Object System.Drawing.Point(15, 125)
|
||||||
|
$configBox.Size = New-Object System.Drawing.Size(900, 350)
|
||||||
|
$configBox.Multiline = $true
|
||||||
|
$configBox.ScrollBars = "Both"
|
||||||
|
$configBox.Font = New-Object System.Drawing.Font("Consolas", 10)
|
||||||
|
$configBox.Text = Read-ConfigText
|
||||||
|
$form.Controls.Add($configBox)
|
||||||
|
|
||||||
|
$stateBox = New-Object System.Windows.Forms.TextBox
|
||||||
|
$stateBox.Location = New-Object System.Drawing.Point(15, 490)
|
||||||
|
$stateBox.Size = New-Object System.Drawing.Size(900, 85)
|
||||||
|
$stateBox.Multiline = $true
|
||||||
|
$stateBox.ScrollBars = "Vertical"
|
||||||
|
$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||||
|
$form.Controls.Add($stateBox)
|
||||||
|
|
||||||
|
$logBox = New-Object System.Windows.Forms.TextBox
|
||||||
|
$logBox.Location = New-Object System.Drawing.Point(15, 590)
|
||||||
|
$logBox.Size = New-Object System.Drawing.Size(900, 130)
|
||||||
|
$logBox.Multiline = $true
|
||||||
|
$logBox.ScrollBars = "Vertical"
|
||||||
|
$logBox.Font = New-Object System.Drawing.Font("Consolas", 9)
|
||||||
|
$form.Controls.Add($logBox)
|
||||||
|
|
||||||
|
function Refresh-Display {
|
||||||
|
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
|
||||||
|
|
||||||
|
$config = Read-Config
|
||||||
|
if ($config -and $secretBox.Text.Length -eq 0) {
|
||||||
|
$secretBox.Text = [string]$config.agent_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $StatePath) {
|
||||||
|
$stateBox.Text = Get-Content $StatePath -Raw
|
||||||
|
} else {
|
||||||
|
$stateBox.Text = "No state file yet."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $LogPath) {
|
||||||
|
$logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine
|
||||||
|
} else {
|
||||||
|
$logBox.Text = "No log file yet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$showSecretCheck.Add_CheckedChanged({
|
||||||
|
$secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked
|
||||||
|
})
|
||||||
|
|
||||||
|
$saveSecretButton.Add_Click({
|
||||||
|
try {
|
||||||
|
$config = $configBox.Text | ConvertFrom-Json
|
||||||
|
$config.agent_secret = $secretBox.Text
|
||||||
|
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
|
||||||
|
$configBox.Text = Get-Content $ConfigPath -Raw
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.")
|
||||||
|
} catch {
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$startButton.Add_Click({
|
||||||
|
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Refresh-Display
|
||||||
|
})
|
||||||
|
|
||||||
|
$stopButton.Add_Click({
|
||||||
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Refresh-Display
|
||||||
|
})
|
||||||
|
|
||||||
|
$restartButton.Add_Click({
|
||||||
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Refresh-Display
|
||||||
|
})
|
||||||
|
|
||||||
|
$saveButton.Add_Click({
|
||||||
|
try {
|
||||||
|
$null = $configBox.Text | ConvertFrom-Json
|
||||||
|
Save-ConfigText -Text $configBox.Text
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.")
|
||||||
|
} catch {
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$testButton.Add_Click({
|
||||||
|
try {
|
||||||
|
$config = $configBox.Text | ConvertFrom-Json
|
||||||
|
if ($secretBox.Text.Length -gt 0) {
|
||||||
|
$config.agent_secret = $secretBox.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
$hub = [string]$config.hub_url
|
||||||
|
$secret = [string]$config.agent_secret
|
||||||
|
|
||||||
|
$headers = @{
|
||||||
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name)
|
||||||
|
} catch {
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$timer = New-Object System.Windows.Forms.Timer
|
||||||
|
$timer.Interval = 3000
|
||||||
|
$timer.Add_Tick({ Refresh-Display })
|
||||||
|
$timer.Start()
|
||||||
|
|
||||||
|
Refresh-Display
|
||||||
|
[void]$form.ShowDialog()
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,108 @@
|
|||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$TaskName = "MinerOpsAgent"
|
||||||
|
$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1"
|
||||||
|
$LogPath = "C:\MinerOpsAgent\logs\agent.log"
|
||||||
|
$DashboardUrl = "https://plexus.lussier.cc/dashboard"
|
||||||
|
|
||||||
|
function Start-Agent {
|
||||||
|
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Agent {
|
||||||
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Restart-Agent {
|
||||||
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Open-Gui {
|
||||||
|
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`""
|
||||||
|
}
|
||||||
|
|
||||||
|
function Open-Dashboard {
|
||||||
|
Start-Process $DashboardUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
function Open-Log {
|
||||||
|
if (Test-Path $LogPath) {
|
||||||
|
Start-Process notepad.exe $LogPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AgentStatusText {
|
||||||
|
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if (!$task) {
|
||||||
|
return "MinerOps Agent: task not installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "MinerOps Agent: $($task.State)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$notify = New-Object System.Windows.Forms.NotifyIcon
|
||||||
|
$notify.Icon = [System.Drawing.SystemIcons]::Application
|
||||||
|
$notify.Text = Get-AgentStatusText
|
||||||
|
$notify.Visible = $true
|
||||||
|
|
||||||
|
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
||||||
|
|
||||||
|
$itemOpen = $menu.Items.Add("Open Agent GUI")
|
||||||
|
$itemOpen.Add_Click({ Open-Gui })
|
||||||
|
|
||||||
|
$itemDashboard = $menu.Items.Add("Open Plexus Dashboard")
|
||||||
|
$itemDashboard.Add_Click({ Open-Dashboard })
|
||||||
|
|
||||||
|
$menu.Items.Add("-") | Out-Null
|
||||||
|
|
||||||
|
$itemStart = $menu.Items.Add("Start Agent")
|
||||||
|
$itemStart.Add_Click({
|
||||||
|
Start-Agent
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$notify.Text = Get-AgentStatusText
|
||||||
|
})
|
||||||
|
|
||||||
|
$itemStop = $menu.Items.Add("Stop Agent")
|
||||||
|
$itemStop.Add_Click({
|
||||||
|
Stop-Agent
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$notify.Text = Get-AgentStatusText
|
||||||
|
})
|
||||||
|
|
||||||
|
$itemRestart = $menu.Items.Add("Restart Agent")
|
||||||
|
$itemRestart.Add_Click({
|
||||||
|
Restart-Agent
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$notify.Text = Get-AgentStatusText
|
||||||
|
})
|
||||||
|
|
||||||
|
$menu.Items.Add("-") | Out-Null
|
||||||
|
|
||||||
|
$itemLog = $menu.Items.Add("Open Log")
|
||||||
|
$itemLog.Add_Click({ Open-Log })
|
||||||
|
|
||||||
|
$itemExit = $menu.Items.Add("Exit Tray Icon")
|
||||||
|
$itemExit.Add_Click({
|
||||||
|
$notify.Visible = $false
|
||||||
|
$notify.Dispose()
|
||||||
|
[System.Windows.Forms.Application]::Exit()
|
||||||
|
})
|
||||||
|
|
||||||
|
$notify.ContextMenuStrip = $menu
|
||||||
|
|
||||||
|
$notify.Add_DoubleClick({
|
||||||
|
Open-Gui
|
||||||
|
})
|
||||||
|
|
||||||
|
$timer = New-Object System.Windows.Forms.Timer
|
||||||
|
$timer.Interval = 5000
|
||||||
|
$timer.Add_Tick({
|
||||||
|
$notify.Text = Get-AgentStatusText
|
||||||
|
})
|
||||||
|
$timer.Start()
|
||||||
|
|
||||||
|
[System.Windows.Forms.Application]::Run()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
param(
|
||||||
|
[int] $Limit = 25,
|
||||||
|
[int] $TimeoutMs = 2500
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ConfigPath)) {
|
||||||
|
throw "Missing config file: $ConfigPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||||
|
$HubUrl = [string]$Config.hub_url
|
||||||
|
$HubUrl = $HubUrl.TrimEnd("/")
|
||||||
|
$AgentId = [string]$Config.agent_id
|
||||||
|
$SiteName = [string]$Config.site_name
|
||||||
|
$Secret = [string]$Config.agent_secret
|
||||||
|
|
||||||
|
$Headers = @{
|
||||||
|
"X-MinerOps-Agent-Secret" = $Secret
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WhatsMinerApi3Read {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||||
|
[Parameter(Mandatory=$true)] [hashtable] $Request,
|
||||||
|
[int] $TimeoutMs = 2500
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = $null
|
||||||
|
$stream = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
|
||||||
|
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||||
|
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
$client.ReceiveTimeout = $TimeoutMs
|
||||||
|
$client.SendTimeout = $TimeoutMs
|
||||||
|
|
||||||
|
$connect = $client.BeginConnect($Ip, 4433, $null, $null)
|
||||||
|
|
||||||
|
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||||
|
try { $client.Close() } catch {}
|
||||||
|
throw "Timed out connecting to $Ip`:4433"
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($connect)
|
||||||
|
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$stream.ReadTimeout = $TimeoutMs
|
||||||
|
$stream.WriteTimeout = $TimeoutMs
|
||||||
|
|
||||||
|
$stream.Write($length, 0, 4)
|
||||||
|
$stream.Write($payload, 0, $payload.Length)
|
||||||
|
$stream.Flush()
|
||||||
|
|
||||||
|
$lenBuf = New-Object byte[] 4
|
||||||
|
$offset = 0
|
||||||
|
|
||||||
|
while ($offset -lt 4) {
|
||||||
|
$read = $stream.Read($lenBuf, $offset, 4 - $offset)
|
||||||
|
|
||||||
|
if ($read -le 0) {
|
||||||
|
throw "No response length received."
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += $read
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseLength = [BitConverter]::ToInt32($lenBuf, 0)
|
||||||
|
|
||||||
|
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
|
||||||
|
throw "Invalid response length: $responseLength"
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer = New-Object byte[] $responseLength
|
||||||
|
$offset = 0
|
||||||
|
|
||||||
|
while ($offset -lt $responseLength) {
|
||||||
|
$read = $stream.Read($buffer, $offset, $responseLength - $offset)
|
||||||
|
|
||||||
|
if ($read -le 0) {
|
||||||
|
throw "Response ended early at $offset of $responseLength bytes."
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += $read
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength)
|
||||||
|
$parsed = $raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
ok = $true
|
||||||
|
parsed = $parsed
|
||||||
|
error = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
ok = $false
|
||||||
|
parsed = $null
|
||||||
|
error = $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($stream) { try { $stream.Close() } catch {} }
|
||||||
|
if ($client) { try { $client.Close() } catch {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Api3OkCommand {
|
||||||
|
param(
|
||||||
|
[string] $Ip,
|
||||||
|
[hashtable] $Request
|
||||||
|
)
|
||||||
|
|
||||||
|
$result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs
|
||||||
|
|
||||||
|
if (-not $result.ok) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result.parsed.code -ne 0) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result.parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Fetching known miners from $HubUrl"
|
||||||
|
|
||||||
|
$known = Invoke-RestMethod `
|
||||||
|
-Uri "$HubUrl/api/agent/known-miners" `
|
||||||
|
-Headers $Headers `
|
||||||
|
-Method Get
|
||||||
|
|
||||||
|
$miners = @($known.miners)
|
||||||
|
|
||||||
|
if ($Limit -gt 0) {
|
||||||
|
$miners = @($miners | Select-Object -First $Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "API3 enrichment check count: $($miners.Count)"
|
||||||
|
|
||||||
|
$items = New-Object System.Collections.Generic.List[object]
|
||||||
|
$checked = 0
|
||||||
|
$api3Ok = 0
|
||||||
|
$api3Failed = 0
|
||||||
|
|
||||||
|
foreach ($miner in $miners) {
|
||||||
|
$checked += 1
|
||||||
|
$ip = [string]$miner.ip_address
|
||||||
|
$mac = [string]$miner.mac_address
|
||||||
|
|
||||||
|
if (-not $ip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[$checked/$($miners.Count)] $ip $mac"
|
||||||
|
|
||||||
|
$deviceInfoResult = Invoke-WhatsMinerApi3Read `
|
||||||
|
-Ip $ip `
|
||||||
|
-Request @{ cmd = "get.device.info" } `
|
||||||
|
-TimeoutMs $TimeoutMs
|
||||||
|
|
||||||
|
if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) {
|
||||||
|
$api3Failed += 1
|
||||||
|
|
||||||
|
$err = $deviceInfoResult.error
|
||||||
|
|
||||||
|
if ($deviceInfoResult.ok) {
|
||||||
|
$err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$items.Add([pscustomobject]@{
|
||||||
|
ip = $ip
|
||||||
|
mac = $mac
|
||||||
|
api3_supported = $false
|
||||||
|
api3_error = $err
|
||||||
|
device_info = $null
|
||||||
|
miner_setting = $null
|
||||||
|
miner_status = $null
|
||||||
|
fan_setting = $null
|
||||||
|
custom_data = $null
|
||||||
|
})
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$api3Ok += 1
|
||||||
|
|
||||||
|
$minerSetting = Invoke-Api3OkCommand `
|
||||||
|
-Ip $ip `
|
||||||
|
-Request @{ cmd = "get.miner.setting" }
|
||||||
|
|
||||||
|
$minerStatus = Invoke-Api3OkCommand `
|
||||||
|
-Ip $ip `
|
||||||
|
-Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" }
|
||||||
|
|
||||||
|
$fanSetting = Invoke-Api3OkCommand `
|
||||||
|
-Ip $ip `
|
||||||
|
-Request @{ cmd = "get.fan.setting" }
|
||||||
|
|
||||||
|
$customData = Invoke-Api3OkCommand `
|
||||||
|
-Ip $ip `
|
||||||
|
-Request @{ cmd = "get.device.custom_data" }
|
||||||
|
|
||||||
|
$items.Add([pscustomobject]@{
|
||||||
|
ip = $ip
|
||||||
|
mac = $mac
|
||||||
|
api3_supported = $true
|
||||||
|
api3_error = $null
|
||||||
|
device_info = $deviceInfoResult.parsed
|
||||||
|
miner_setting = $minerSetting
|
||||||
|
miner_status = $minerStatus
|
||||||
|
fan_setting = $fanSetting
|
||||||
|
custom_data = $customData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = @{
|
||||||
|
agent_id = $AgentId
|
||||||
|
site_name = $SiteName
|
||||||
|
items = @($items.ToArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $payload | ConvertTo-Json -Depth 80
|
||||||
|
|
||||||
|
Write-Host "Posting API3 enrichment results to hub..."
|
||||||
|
$result = Invoke-RestMethod `
|
||||||
|
-Uri "$HubUrl/api/agent/api3-enrichment" `
|
||||||
|
-Method Post `
|
||||||
|
-Headers $Headers `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $json
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Local checked: $checked"
|
||||||
|
Write-Host "API3 OK: $api3Ok"
|
||||||
|
Write-Host "API3 failed: $api3Failed"
|
||||||
|
Write-Host ""
|
||||||
|
$result | ConvertTo-Json -Depth 20
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1.
|
||||||
|
|
||||||
|
function Get-MinerOpsAgentVersion {
|
||||||
|
$versionPath = Join-Path $PSScriptRoot "VERSION"
|
||||||
|
if (Test-Path $versionPath) {
|
||||||
|
return ([string](Get-Content $versionPath -Raw)).Trim()
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MinerOpsControlCredentials {
|
||||||
|
$config = Read-AgentConfig
|
||||||
|
$account = ([string]$config.api3_account).Trim()
|
||||||
|
$password = [string]$config.api3_password
|
||||||
|
$port = 4433
|
||||||
|
if ($config.api3_port) {
|
||||||
|
try { $port = [int]$config.api3_port } catch { $port = 4433 }
|
||||||
|
}
|
||||||
|
return @{
|
||||||
|
available = (-not [string]::IsNullOrWhiteSpace($account)) -and
|
||||||
|
(-not [string]::IsNullOrWhiteSpace($password))
|
||||||
|
account = $account
|
||||||
|
password = $password
|
||||||
|
port = $port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-MinerOpsControlApi3Command {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$IpAddress,
|
||||||
|
[Parameter(Mandatory=$true)][string]$Command,
|
||||||
|
[object]$Param = $null
|
||||||
|
)
|
||||||
|
$credentials = Get-MinerOpsControlCredentials
|
||||||
|
if (-not $credentials.available) { throw "credentials_missing" }
|
||||||
|
|
||||||
|
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port
|
||||||
|
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||||
|
$token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password `
|
||||||
|
-Salt ([string]$saltInfo.salt) -Timestamp $timestamp
|
||||||
|
$payload = @{
|
||||||
|
cmd = $Command
|
||||||
|
ts = $timestamp
|
||||||
|
token = $token
|
||||||
|
account = $credentials.account
|
||||||
|
}
|
||||||
|
if ($null -ne $Param) { $payload["param"] = $Param }
|
||||||
|
$response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress `
|
||||||
|
-Port $credentials.port -Payload $payload
|
||||||
|
if ([int]$response.response.code -ne 0) { throw "command_rejected" }
|
||||||
|
return $response
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-MinerOpsControlResult {
|
||||||
|
param(
|
||||||
|
[bool]$Accepted,
|
||||||
|
[bool]$VerificationAttempted,
|
||||||
|
[string]$VerificationStatus,
|
||||||
|
[string]$ObservedMode = "",
|
||||||
|
[string]$FailureCode = "",
|
||||||
|
[string]$QueueStatus = "completed"
|
||||||
|
)
|
||||||
|
return [ordered]@{
|
||||||
|
schema_version = 1
|
||||||
|
command_accepted = $Accepted
|
||||||
|
verification_attempted = $VerificationAttempted
|
||||||
|
verification_status = $VerificationStatus
|
||||||
|
observed_service_state = $null
|
||||||
|
observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null }
|
||||||
|
failure_code = if ($FailureCode) { $FailureCode } else { $null }
|
||||||
|
_queue_status = $QueueStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-MinerControlCommand {
|
||||||
|
param([Parameter(Mandatory=$true)][object]$Payload)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json }
|
||||||
|
$action = ([string]$Payload.action).Trim().ToLower()
|
||||||
|
$ipAddress = ([string]$Payload.ip_address).Trim()
|
||||||
|
$requested = ([string]$Payload.requested_value).Trim().ToLower()
|
||||||
|
if (-not $requested -and $Payload.performance_mode) {
|
||||||
|
$requested = ([string]$Payload.performance_mode).Trim().ToLower()
|
||||||
|
}
|
||||||
|
if ($action -notin @("suspend", "resume", "performance_mode")) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
|
-FailureCode "invalid_action" -QueueStatus "failed"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
|
-FailureCode "target_missing" -QueueStatus "failed"
|
||||||
|
}
|
||||||
|
$credentials = Get-MinerOpsControlCredentials
|
||||||
|
if (-not $credentials.available) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
|
-FailureCode "credentials_missing" -QueueStatus "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action -eq "performance_mode") {
|
||||||
|
if ($requested -notin @("low", "normal", "high")) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
|
-FailureCode "invalid_mode" -QueueStatus "failed"
|
||||||
|
}
|
||||||
|
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||||
|
-Command "set.miner.power_mode" -Param $requested | Out-Null
|
||||||
|
|
||||||
|
$observed = ""
|
||||||
|
$readUnavailable = $false
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
$read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||||
|
-Command "get.miner.setting"
|
||||||
|
$candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower()
|
||||||
|
if ($candidate -in @("low", "normal", "high")) {
|
||||||
|
$observed = $candidate
|
||||||
|
if ($observed -eq $requested) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $true `
|
||||||
|
-VerificationAttempted $true -VerificationStatus "confirmed" `
|
||||||
|
-ObservedMode $observed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if ($_.Exception.Message -match "unsupported|unknown_command") {
|
||||||
|
$readUnavailable = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 }
|
||||||
|
}
|
||||||
|
if ($readUnavailable) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $true `
|
||||||
|
-VerificationAttempted $true -VerificationStatus "unavailable"
|
||||||
|
}
|
||||||
|
if ($observed) {
|
||||||
|
return New-MinerOpsControlResult -Accepted $true `
|
||||||
|
-VerificationAttempted $true `
|
||||||
|
-VerificationStatus "verification_failed" -ObservedMode $observed
|
||||||
|
}
|
||||||
|
return New-MinerOpsControlResult -Accepted $true `
|
||||||
|
-VerificationAttempted $true -VerificationStatus "unconfirmed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$serviceAction = if ($action -eq "suspend") { "stop" } else { "start" }
|
||||||
|
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
|
||||||
|
-Command "set.miner.service" -Param $serviceAction | Out-Null
|
||||||
|
return New-MinerOpsControlResult -Accepted $true `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unconfirmed"
|
||||||
|
} catch {
|
||||||
|
$failure = if ($_.Exception.Message -eq "credentials_missing") {
|
||||||
|
"credentials_missing"
|
||||||
|
} else { "write_rejected" }
|
||||||
|
return New-MinerOpsControlResult -Accepted $false `
|
||||||
|
-VerificationAttempted $false -VerificationStatus "unavailable" `
|
||||||
|
-FailureCode $failure -QueueStatus "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-AgentCommandResult {
|
||||||
|
param(
|
||||||
|
[string]$CommandId,
|
||||||
|
[string]$Status,
|
||||||
|
[object]$ResultPayload = $null,
|
||||||
|
[string]$ErrorMessage = $null
|
||||||
|
)
|
||||||
|
if ($ResultPayload -and $ResultPayload._queue_status) {
|
||||||
|
$Status = [string]$ResultPayload._queue_status
|
||||||
|
$ResultPayload.Remove("_queue_status")
|
||||||
|
$ErrorMessage = if ($ResultPayload.failure_code) {
|
||||||
|
[string]$ResultPayload.failure_code
|
||||||
|
} else { $null }
|
||||||
|
}
|
||||||
|
$body = [ordered]@{
|
||||||
|
command_id = $CommandId
|
||||||
|
status = $Status
|
||||||
|
result_payload = $ResultPayload
|
||||||
|
error_message = $ErrorMessage
|
||||||
|
}
|
||||||
|
return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-AgentCheckin {
|
||||||
|
$config = Read-AgentConfig
|
||||||
|
$credentials = Get-MinerOpsControlCredentials
|
||||||
|
$body = [ordered]@{
|
||||||
|
agent_id = [string]$config.agent_id
|
||||||
|
site_id = [string]$config.site_id
|
||||||
|
site_name = [string]$config.site_name
|
||||||
|
hostname = $env:COMPUTERNAME
|
||||||
|
version = Get-MinerOpsAgentVersion
|
||||||
|
ip_address = ""
|
||||||
|
control_available = [bool]$credentials.available
|
||||||
|
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
|
||||||
|
local_time = (Get-Date).ToString("o")
|
||||||
|
}
|
||||||
|
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
|
||||||
|
$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json
|
||||||
|
|
||||||
|
$HubUrl = ($Config.hub_url + "").TrimEnd("/")
|
||||||
|
$AgentId = $Config.agent_id
|
||||||
|
$AgentSecret = $Config.agent_secret
|
||||||
|
|
||||||
|
$Headers = @{
|
||||||
|
"X-MinerOps-Agent-Secret" = $AgentSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-MinerOpsIp {
|
||||||
|
param([string] $Ip)
|
||||||
|
|
||||||
|
if (-not $Ip) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($Ip -replace "/32$", "").Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-MinerOps4028PoolsSimple {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)] [string] $Ip,
|
||||||
|
[int] $Port = 4028,
|
||||||
|
[int] $TimeoutMs = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = $null
|
||||||
|
$stream = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$json = '{"command":"pools"}'
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
$client.ReceiveTimeout = $TimeoutMs
|
||||||
|
$client.SendTimeout = $TimeoutMs
|
||||||
|
$client.Connect($Ip, $Port)
|
||||||
|
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$stream.ReadTimeout = $TimeoutMs
|
||||||
|
$stream.WriteTimeout = $TimeoutMs
|
||||||
|
|
||||||
|
$bytes = [System.Text.Encoding]::ASCII.GetBytes($json)
|
||||||
|
$stream.Write($bytes, 0, $bytes.Length)
|
||||||
|
$stream.Flush()
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
$buffer = New-Object byte[] 131072
|
||||||
|
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||||
|
|
||||||
|
if ($read -le 0) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim()
|
||||||
|
|
||||||
|
if (-not $raw) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = $raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
if (-not $parsed.POOLS) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($parsed.POOLS)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($stream) {
|
||||||
|
try { $stream.Close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($client) {
|
||||||
|
try { $client.Close() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60
|
||||||
|
|
||||||
|
$knownMiners = @()
|
||||||
|
|
||||||
|
if ($response.miners) {
|
||||||
|
$knownMiners = @($response.miners)
|
||||||
|
} elseif ($response.data -and $response.data.miners) {
|
||||||
|
$knownMiners = @($response.data.miners)
|
||||||
|
} elseif ($response.data -and $response.data.known_miners) {
|
||||||
|
$knownMiners = @($response.data.known_miners)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Known miners: $($knownMiners.Count)"
|
||||||
|
|
||||||
|
$poolSnapshots = New-Object System.Collections.Generic.List[object]
|
||||||
|
$count = 0
|
||||||
|
|
||||||
|
foreach ($miner in $knownMiners) {
|
||||||
|
$count++
|
||||||
|
|
||||||
|
$ip = Normalize-MinerOpsIp -Ip $miner.ip_address
|
||||||
|
|
||||||
|
if (-not $ip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($count % 25) -eq 0) {
|
||||||
|
Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip)
|
||||||
|
|
||||||
|
if ($pools.Count -gt 0) {
|
||||||
|
$poolSnapshots.Add([pscustomobject]@{
|
||||||
|
miner_id = $miner.miner_id
|
||||||
|
ip_address = $ip
|
||||||
|
mac_address = $miner.mac_address
|
||||||
|
model = $miner.model
|
||||||
|
pools = @($pools)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
|
||||||
|
|
||||||
|
$body = @{
|
||||||
|
agent_id = $AgentId
|
||||||
|
pool_snapshots = $poolSnapshots
|
||||||
|
} | ConvertTo-Json -Depth 20
|
||||||
|
|
||||||
|
$result = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri "$HubUrl/api/agent/pool-snapshots" `
|
||||||
|
-Headers $Headers `
|
||||||
|
-Body $body `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-TimeoutSec 180
|
||||||
|
|
||||||
|
$result | ConvertTo-Json -Depth 10
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
$LogDir = "C:\MinerOpsAgent\logs"
|
||||||
|
$LogFile = Join-Path $LogDir "pool-poller.log"
|
||||||
|
|
||||||
|
if (-not (Test-Path $LogDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
|
||||||
|
try {
|
||||||
|
Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller."
|
||||||
|
|
||||||
|
$output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1
|
||||||
|
|
||||||
|
foreach ($line in $output) {
|
||||||
|
Add-Content -Path $LogFile -Value "$line"
|
||||||
|
}
|
||||||
|
|
||||||
|
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
Add-Content -Path $LogFile -Value "$stamp Pool poller finished."
|
||||||
|
Add-Content -Path $LogFile -Value ""
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||||
|
Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)"
|
||||||
|
Add-Content -Path $LogFile -Value ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# MinerOps Windows Agent
|
||||||
|
|
||||||
|
The Windows Agent runs at a mining site and performs local discovery, polling,
|
||||||
|
API3 enrichment, pool polling, and approved queued miner-control operations.
|
||||||
|
MinerOps does not send API3 credentials through the hub command queue; those
|
||||||
|
credentials remain in the Agent's local `agent-config.json`.
|
||||||
|
|
||||||
|
## Recovered Baseline
|
||||||
|
|
||||||
|
The seven runtime and support scripts in this directory were recovered from the
|
||||||
|
live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact
|
||||||
|
pre-change hashes. Historical backups, live configuration, state, logs, and
|
||||||
|
probe output are intentionally excluded.
|
||||||
|
|
||||||
|
## Local Files
|
||||||
|
|
||||||
|
Create `agent-config.json` from `config.example.json` on the Agent host. Never
|
||||||
|
commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is
|
||||||
|
retained only for transitional display compatibility.
|
||||||
|
|
||||||
|
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
|
||||||
|
Installation and upgrades preserve all three paths.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
API3 account and password values are local secrets. Missing credentials make
|
||||||
|
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
|
||||||
|
must not be written to normal logs or returned to MinerOps command history.
|
||||||
|
|
||||||
|
The Internal Alpha supports one Agent identity protected by the deployment
|
||||||
|
shared secret. A second active Agent requires per-Agent credentials before it
|
||||||
|
may be enabled.
|
||||||
|
|
||||||
|
## Legacy Compatibility
|
||||||
|
|
||||||
|
The older direct Hub API3, `/agent-control`, password-change, bulk-control, and
|
||||||
|
scheduling paths remain available for compatibility and are deprecated for the
|
||||||
|
Internal Alpha. The new Miner Detail controls use only the typed control API and
|
||||||
|
Agent queue; the modular control backend does not connect directly to miners.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deploy Agent compatibility before enabling Hub enforcement of the
|
||||||
|
`X-MinerOps-Agent-ID` header:
|
||||||
|
|
||||||
|
1. Copy this directory to a temporary folder on the Windows Agent host.
|
||||||
|
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
|
||||||
|
secrets as installer or scheduled-task arguments.
|
||||||
|
3. Run `install.ps1` from an elevated Windows PowerShell session.
|
||||||
|
4. Confirm the four scheduled tasks are registered and that Agent check-in
|
||||||
|
reports the expected non-secret `VERSION`.
|
||||||
|
5. Enable the matching Agent ID and shared secret on the Hub.
|
||||||
|
|
||||||
|
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
|
||||||
|
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
|
||||||
|
output, and other runtime data. During an upgrade, existing definitions for
|
||||||
|
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
|
||||||
|
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
|
||||||
|
created for a fresh or incomplete installation without hard-coded user names.
|
||||||
|
|
||||||
|
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
|
||||||
|
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||||
|
again. A previously stopped Agent remains stopped. The tray and periodic tasks
|
||||||
|
are not unnecessarily restarted.
|
||||||
|
|
||||||
|
Before replacing existing runtime files, the installer copies them to a
|
||||||
|
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
|
||||||
|
automatically restores those managed files, removes tasks created by that
|
||||||
|
failed attempt, and restores the main Agent's prior running state. For a manual
|
||||||
|
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
|
||||||
|
and restart it only if it was previously running. `uninstall.ps1` unregisters
|
||||||
|
the four tasks and removes runtime code while preserving local configuration,
|
||||||
|
state, logs, and backups.
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.0-alpha
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"hub_url": "https://minerops.example.invalid",
|
||||||
|
"agent_id": "replace-with-agent-id",
|
||||||
|
"site_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"site_name": "Example Site",
|
||||||
|
"agent_secret": "replace-with-agent-secret",
|
||||||
|
"poll_interval_seconds": 120,
|
||||||
|
"ip_ranges": [
|
||||||
|
{
|
||||||
|
"name": "Example Range",
|
||||||
|
"cidr": "192.0.2.0/24",
|
||||||
|
"enabled": false,
|
||||||
|
"api_port": 4028,
|
||||||
|
"api3_port": 4433
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api3_account": "replace-with-api3-account",
|
||||||
|
"api3_password": "replace-with-api3-password",
|
||||||
|
"api3_port": 4433
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Target = "C:\MinerOpsAgent"
|
||||||
|
$BackupRoot = Join-Path $Target "backups"
|
||||||
|
$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
|
$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix"
|
||||||
|
$RuntimeFiles = @(
|
||||||
|
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||||
|
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||||
|
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||||
|
"VERSION"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Get-MinerOpsScheduledTask {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-MinerOpsScheduledTaskForUpgrade {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Name,
|
||||||
|
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
|
||||||
|
)
|
||||||
|
|
||||||
|
Stop-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||||
|
$deadline = [DateTime]::UtcNow.Add($Timeout)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$task = Get-MinerOpsScheduledTask -Name $Name
|
||||||
|
if ($null -eq $task) {
|
||||||
|
throw "Scheduled Task $Name disappeared while stopping it."
|
||||||
|
}
|
||||||
|
if ([string]$task.State -ne "Running") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
|
|
||||||
|
throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-MinerOpsScheduledTask {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
Start-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate the complete source before creating directories, stopping tasks, or
|
||||||
|
# replacing any target file.
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
$source = Join-Path $PSScriptRoot $name
|
||||||
|
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||||
|
throw "Deployment source is missing $name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskExisted = $null -ne $mainTask
|
||||||
|
$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
$deploymentStarted = $false
|
||||||
|
$existing = @()
|
||||||
|
$createdTaskNames = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
|
||||||
|
$existing = @($RuntimeFiles | Where-Object {
|
||||||
|
Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf
|
||||||
|
})
|
||||||
|
if ($existing.Count -gt 0) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
|
||||||
|
foreach ($name in $existing) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Target $name) `
|
||||||
|
-Destination (Join-Path $Backup $name) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainTaskWasRunning) {
|
||||||
|
$mainTaskStoppedForUpgrade = $true
|
||||||
|
Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent"
|
||||||
|
}
|
||||||
|
|
||||||
|
$deploymentStarted = $true
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) `
|
||||||
|
-Destination (Join-Path $Target $name) -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
|
||||||
|
-LogonType ServiceAccount -RunLevel Highest
|
||||||
|
$startup = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||||
|
-RepetitionInterval (New-TimeSpan -Minutes 5)
|
||||||
|
$commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||||
|
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Hours 72)
|
||||||
|
$agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||||
|
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||||
|
|
||||||
|
$definitions = @(
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"
|
||||||
|
Trigger = $startup; Principal = $system; Settings = $agentSettings
|
||||||
|
ExtraArguments = ""
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"
|
||||||
|
Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null
|
||||||
|
Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsApi3Enrichment"
|
||||||
|
Script = "MinerOpsApi3EnrichmentPoller.ps1"
|
||||||
|
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||||
|
ExtraArguments = "-Limit 0 -TimeoutMs 8000"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"
|
||||||
|
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||||
|
ExtraArguments = ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
foreach ($definition in $definitions) {
|
||||||
|
if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) {
|
||||||
|
Write-Host "Preserving existing Scheduled Task $($definition.Name)."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
||||||
|
(Join-Path $Target $definition.Script)
|
||||||
|
if ($definition.ExtraArguments) {
|
||||||
|
$arguments = "$arguments $($definition.ExtraArguments)"
|
||||||
|
}
|
||||||
|
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
|
||||||
|
$registration = @{
|
||||||
|
TaskName = $definition.Name
|
||||||
|
Action = $action
|
||||||
|
Trigger = $definition.Trigger
|
||||||
|
Settings = $definition.Settings
|
||||||
|
}
|
||||||
|
if ($definition.Principal) {
|
||||||
|
$registration.Principal = $definition.Principal
|
||||||
|
}
|
||||||
|
Register-ScheduledTask @registration | Out-Null
|
||||||
|
$createdTaskNames.Add($definition.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainTaskWasRunning) {
|
||||||
|
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved."
|
||||||
|
} catch {
|
||||||
|
$deploymentError = $_
|
||||||
|
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
foreach ($taskName in $createdTaskNames) {
|
||||||
|
try {
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false `
|
||||||
|
-ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deploymentStarted) {
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
try {
|
||||||
|
$targetPath = Join-Path $Target $name
|
||||||
|
if ($existing -contains $name) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Backup $name) `
|
||||||
|
-Destination $targetPath -Force
|
||||||
|
} elseif (Test-Path -LiteralPath $targetPath) {
|
||||||
|
Remove-Item -LiteralPath $targetPath -Force
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) {
|
||||||
|
try {
|
||||||
|
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rollbackErrors.Count -gt 0) {
|
||||||
|
$rollbackSummary = $rollbackErrors -join "; "
|
||||||
|
throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary"
|
||||||
|
}
|
||||||
|
throw $deploymentError
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||||
|
$script:calls = @()
|
||||||
|
$script:outcomes = @()
|
||||||
|
$script:outcomeIndex = 0
|
||||||
|
|
||||||
|
function Read-AgentConfig { return $script:config }
|
||||||
|
. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1")
|
||||||
|
|
||||||
|
function Invoke-MinerOpsControlApi3Command {
|
||||||
|
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
|
||||||
|
$script:calls += [pscustomobject]@{ command = $Command; param = $Param }
|
||||||
|
$outcome = $script:outcomes[$script:outcomeIndex]
|
||||||
|
$script:outcomeIndex += 1
|
||||||
|
if ($outcome -is [System.Exception]) { throw $outcome }
|
||||||
|
return $outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||||
|
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||||
|
}
|
||||||
|
function Reset-Harness([object[]]$Outcomes) {
|
||||||
|
$script:calls = @()
|
||||||
|
$script:outcomes = @($Outcomes)
|
||||||
|
$script:outcomeIndex = 0
|
||||||
|
}
|
||||||
|
function Response([string]$Mode = "") {
|
||||||
|
return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" }
|
||||||
|
|
||||||
|
$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 }
|
||||||
|
Reset-Harness @()
|
||||||
|
$result = Run-MinerControlCommand -Payload $payload
|
||||||
|
Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code"
|
||||||
|
Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3"
|
||||||
|
|
||||||
|
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
|
||||||
|
Reset-Harness @((Response), (Response "normal"))
|
||||||
|
$result = Run-MinerControlCommand -Payload $payload
|
||||||
|
Assert-Equal $result.verification_status "confirmed" "Performance confirmation"
|
||||||
|
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write"
|
||||||
|
|
||||||
|
Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low"))
|
||||||
|
$result = Run-MinerControlCommand -Payload $payload
|
||||||
|
Assert-Equal $result.verification_status "verification_failed" "Contradictory mode"
|
||||||
|
Assert-Equal $script:calls.Count 4 "One write and three bounded reads"
|
||||||
|
|
||||||
|
Reset-Harness @((Response), (Response), (Response), (Response))
|
||||||
|
$result = Run-MinerControlCommand -Payload $payload
|
||||||
|
Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads"
|
||||||
|
|
||||||
|
Reset-Harness @((Response), [System.Exception]::new("unknown_command"))
|
||||||
|
$result = Run-MinerControlCommand -Payload $payload
|
||||||
|
Assert-Equal $result.verification_status "unavailable" "Unavailable read path"
|
||||||
|
|
||||||
|
foreach ($action in @("suspend", "resume")) {
|
||||||
|
Reset-Harness @((Response))
|
||||||
|
$result = Run-MinerControlCommand -Payload @{
|
||||||
|
action = $action; requested_value = $null; ip_address = "192.0.2.1"
|
||||||
|
}
|
||||||
|
Assert-Equal $result.command_accepted $true "$action accepted"
|
||||||
|
Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed"
|
||||||
|
Assert-Equal $result.observed_service_state $null "$action has no invented state"
|
||||||
|
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write"
|
||||||
|
}
|
||||||
|
|
||||||
|
Reset-Harness @([System.Exception]::new("vendor response included secret"))
|
||||||
|
$result = Run-MinerControlCommand -Payload @{
|
||||||
|
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
|
||||||
|
}
|
||||||
|
Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized"
|
||||||
|
$json = $result | ConvertTo-Json -Compress
|
||||||
|
if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" }
|
||||||
|
|
||||||
|
Reset-Harness @()
|
||||||
|
$result = Run-MinerControlCommand -Payload @{
|
||||||
|
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
|
||||||
|
}
|
||||||
|
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
|
||||||
|
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
|
||||||
|
|
||||||
|
Write-Host "MinerOps typed Agent control tests passed."
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$installer, [ref]$tokens, [ref]$parseErrors
|
||||||
|
) | Out-Null
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = Get-Content -LiteralPath $installer -Raw
|
||||||
|
function Assert-SourceContains {
|
||||||
|
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
||||||
|
if ($source -notmatch $Pattern) { throw $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeMatch = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
||||||
|
)
|
||||||
|
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
||||||
|
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
||||||
|
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
||||||
|
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
||||||
|
throw "$forbidden must not be a managed deployment target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$preflight = $source.IndexOf('# Validate the complete source')
|
||||||
|
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
||||||
|
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||||
|
throw "Source preflight must happen before target changes."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
|
||||||
|
"Existing task preservation guard is missing."
|
||||||
|
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||||
|
"Existing task preservation is not explicit."
|
||||||
|
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||||
|
"Fresh enrichment task arguments are missing."
|
||||||
|
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||||
|
"Bounded main Agent stop behavior is missing."
|
||||||
|
Assert-SourceContains '\$mainTaskWasRunning' `
|
||||||
|
"Main Agent prior running state is not represented."
|
||||||
|
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
||||||
|
"Rollback does not restore managed files from backup."
|
||||||
|
Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||||
|
"Rollback does not remove tasks created by a failed install."
|
||||||
|
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||||
|
"Rollback restart failure is not surfaced."
|
||||||
|
|
||||||
|
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||||
|
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||||
|
}
|
||||||
|
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
||||||
|
throw "Installer must not inspect or print credentials."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps installer source safety tests passed."
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$Target = "C:\MinerOpsAgent"
|
||||||
|
$Tasks = @(
|
||||||
|
"MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller"
|
||||||
|
)
|
||||||
|
foreach ($name in $Tasks) {
|
||||||
|
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$RuntimeFiles = @(
|
||||||
|
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||||
|
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||||
|
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
|
||||||
|
"VERSION"
|
||||||
|
)
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved."
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""add miner control foundation
|
||||||
|
|
||||||
|
Revision ID: 0015_miner_control_foundation
|
||||||
|
Revises: 0014_repair_parts_tracking
|
||||||
|
Create Date: 2026-08-24
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0015_miner_control_foundation"
|
||||||
|
down_revision: Union[str, None] = "0014_repair_parts_tracking"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_values(values: object) -> str:
|
||||||
|
return ", ".join(str(value) for value in (values or []))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
unknown_statuses = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT array_agg(DISTINCT command_status ORDER BY command_status)
|
||||||
|
FROM minerops_agent_command_queue
|
||||||
|
WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
).scalar()
|
||||||
|
if unknown_statuses:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot upgrade to 0015: minerops_agent_command_queue contains "
|
||||||
|
f"unsupported command_status values: {_format_values(unknown_statuses)}. "
|
||||||
|
"Correct those rows to pending, claimed, completed, failed, or cancelled "
|
||||||
|
"before retrying the migration."
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate_live_miners = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT request_payload ->> 'miner_id' AS miner_id
|
||||||
|
FROM minerops_agent_command_queue
|
||||||
|
WHERE command_type = 'miner_control'
|
||||||
|
AND command_status IN ('pending', 'claimed')
|
||||||
|
AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL
|
||||||
|
GROUP BY request_payload ->> 'miner_id'
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY request_payload ->> 'miner_id'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if duplicate_live_miners:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot upgrade to 0015: more than one pending or claimed miner_control "
|
||||||
|
"command exists for miner UUID(s): "
|
||||||
|
f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate "
|
||||||
|
"live commands before retrying the migration; no queue history was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_site_agents_site_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"sites",
|
||||||
|
["site_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_site_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["site_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("site_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_agent_command_queue_status",
|
||||||
|
"minerops_agent_command_queue",
|
||||||
|
"command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_queue_live_miner_control",
|
||||||
|
"minerops_agent_command_queue",
|
||||||
|
[sa.text("(request_payload ->> 'miner_id')")],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"command_type = 'miner_control' "
|
||||||
|
"AND command_status IN ('pending', 'claimed')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_queue_miner_control_history",
|
||||||
|
"minerops_agent_command_queue",
|
||||||
|
[
|
||||||
|
sa.text("(request_payload ->> 'miner_id')"),
|
||||||
|
sa.text("created_at DESC"),
|
||||||
|
sa.text("id DESC"),
|
||||||
|
],
|
||||||
|
postgresql_where=sa.text("command_type = 'miner_control'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_queue_miner_control_history",
|
||||||
|
table_name="minerops_agent_command_queue",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_queue_live_miner_control",
|
||||||
|
table_name="minerops_agent_command_queue",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_agent_command_queue_status",
|
||||||
|
"minerops_agent_command_queue",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_site_id",
|
||||||
|
table_name="minerops_site_agents",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_site_agents_site_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_site_agents", "site_id")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Authenticated Agent command transport for MinerOps."""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Dependencies for the authenticated Agent queue protocol."""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_command_service() -> AgentCommandService:
|
||||||
|
return AgentCommandService(AgentCommandRepository())
|
||||||
|
|
||||||
|
|
||||||
|
def require_agent_identity(
|
||||||
|
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
|
||||||
|
provided_secret: Annotated[
|
||||||
|
str | None, Header(alias="X-MinerOps-Agent-Secret")
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
expected_id = settings.minerops_agent_id.strip()
|
||||||
|
expected_secret = settings.minerops_agent_shared_secret.strip()
|
||||||
|
if not expected_id or not expected_secret:
|
||||||
|
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
|
||||||
|
|
||||||
|
secret = (provided_secret or "").strip()
|
||||||
|
if not secret or not hmac.compare_digest(secret, expected_secret):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
identity = (agent_id or "").strip()
|
||||||
|
if not identity:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing Agent identity")
|
||||||
|
if not hmac.compare_digest(identity, expected_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
|
||||||
|
return identity
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Domain-neutral Agent command protocol errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandError(Exception):
|
||||||
|
"""Base Agent command protocol error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandNotFound(AgentCommandError):
|
||||||
|
"""The submitted command identifier is unknown."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandConflict(AgentCommandError):
|
||||||
|
"""The queue row cannot accept the requested transition."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResultValidationError(AgentCommandError):
|
||||||
|
"""The Agent result does not match its command contract."""
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Typed contracts for the Agent queue protocol."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommand(BaseModel):
|
||||||
|
command_id: str
|
||||||
|
command_type: str
|
||||||
|
request_payload: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandListResponse(BaseModel):
|
||||||
|
ok: Literal[True] = True
|
||||||
|
commands: list[AgentCommand]
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlResultPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
schema_version: Literal[1]
|
||||||
|
command_accepted: bool
|
||||||
|
verification_attempted: bool
|
||||||
|
verification_status: Literal[
|
||||||
|
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||||
|
]
|
||||||
|
observed_service_state: None = None
|
||||||
|
observed_performance_mode: Literal["low", "normal", "high"] | None = None
|
||||||
|
failure_code: str | None = Field(
|
||||||
|
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_truthfulness(self) -> "MinerControlResultPayload":
|
||||||
|
if self.verification_status == "confirmed" and not self.verification_attempted:
|
||||||
|
raise ValueError("confirmed results require verification_attempted")
|
||||||
|
if not self.command_accepted and not self.failure_code:
|
||||||
|
raise ValueError("rejected commands require a failure_code")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResultRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
command_id: str = Field(min_length=1, max_length=128)
|
||||||
|
status: Literal["completed", "failed"]
|
||||||
|
result_payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
error_message: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResultResponse(BaseModel):
|
||||||
|
ok: Literal[True] = True
|
||||||
|
command_id: str
|
||||||
|
status: Literal["completed", "failed"]
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""PostgreSQL persistence for Agent command transport."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
CLAIM_COMMANDS_SQL = """
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT id
|
||||||
|
FROM minerops_agent_command_queue
|
||||||
|
WHERE agent_id = %s
|
||||||
|
AND command_status = 'pending'
|
||||||
|
ORDER BY created_at, id
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT %s
|
||||||
|
)
|
||||||
|
UPDATE minerops_agent_command_queue AS queue
|
||||||
|
SET command_status = 'claimed', claimed_at = now()
|
||||||
|
FROM candidates
|
||||||
|
WHERE queue.id = candidates.id
|
||||||
|
AND queue.command_status = 'pending'
|
||||||
|
RETURNING queue.id AS command_id, queue.command_type,
|
||||||
|
queue.request_payload, queue.created_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandRepository:
|
||||||
|
"""Own queue locking and immutable terminal transitions."""
|
||||||
|
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
|
||||||
|
rows = [dict(row) for row in cur.fetchall()]
|
||||||
|
connection.commit()
|
||||||
|
return rows
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_command(self, command_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, agent_id, command_type, command_status, request_payload
|
||||||
|
FROM minerops_agent_command_queue WHERE id = %s
|
||||||
|
""",
|
||||||
|
(command_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def finish_command(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
status: str,
|
||||||
|
result_payload: dict[str, Any],
|
||||||
|
error_message: str | None,
|
||||||
|
) -> None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_command_queue
|
||||||
|
SET command_status = %s, result_payload = %s,
|
||||||
|
error_message = %s, completed_at = now()
|
||||||
|
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(status, Jsonb(result_payload), error_message, command_id, agent_id),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
connection.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id, command_status
|
||||||
|
FROM minerops_agent_command_queue WHERE id = %s
|
||||||
|
""",
|
||||||
|
(command_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
connection.rollback()
|
||||||
|
if not row:
|
||||||
|
raise AgentCommandNotFound("Agent command not found")
|
||||||
|
if row["agent_id"] != agent_id:
|
||||||
|
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||||
|
raise AgentCommandConflict(
|
||||||
|
f"Agent command is {row['command_status']}, not claimed"
|
||||||
|
)
|
||||||
|
except (AgentCommandNotFound, AgentCommandConflict):
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""HTTP boundary for authenticated Agent command transport."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
|
||||||
|
from app.agent_commands.exceptions import (
|
||||||
|
AgentCommandConflict,
|
||||||
|
AgentCommandNotFound,
|
||||||
|
AgentResultValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_commands.models import (
|
||||||
|
AgentCommandListResponse,
|
||||||
|
AgentCommandResultRequest,
|
||||||
|
AgentCommandResultResponse,
|
||||||
|
)
|
||||||
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
|
||||||
|
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||||
|
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending", response_model=AgentCommandListResponse)
|
||||||
|
def claim_pending_commands(
|
||||||
|
request: Request, agent_id: AgentIdentity, service: AgentService
|
||||||
|
) -> AgentCommandListResponse:
|
||||||
|
before_claim = getattr(
|
||||||
|
request.app.state, "agent_command_before_claim", None
|
||||||
|
)
|
||||||
|
if callable(before_claim):
|
||||||
|
try:
|
||||||
|
before_claim()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduled command materialization failed before Agent claim")
|
||||||
|
return AgentCommandListResponse(commands=service.claim(agent_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/result", response_model=AgentCommandResultResponse)
|
||||||
|
def submit_command_result(
|
||||||
|
request: AgentCommandResultRequest,
|
||||||
|
agent_id: AgentIdentity,
|
||||||
|
service: AgentService,
|
||||||
|
) -> AgentCommandResultResponse:
|
||||||
|
try:
|
||||||
|
service.submit_result(agent_id, request)
|
||||||
|
except AgentCommandNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentCommandConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except AgentResultValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Agent command transport coordination and result validation."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.agent_commands.exceptions import (
|
||||||
|
AgentCommandConflict,
|
||||||
|
AgentCommandNotFound,
|
||||||
|
AgentResultValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_commands.models import (
|
||||||
|
AgentCommand,
|
||||||
|
AgentCommandResultRequest,
|
||||||
|
MinerControlResultPayload,
|
||||||
|
)
|
||||||
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandService:
|
||||||
|
def __init__(self, repository: AgentCommandRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def claim(self, agent_id: str) -> list[AgentCommand]:
|
||||||
|
return [
|
||||||
|
AgentCommand.model_validate(row)
|
||||||
|
for row in self.repository.claim_pending(agent_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_error(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
|
||||||
|
return sanitized[:255]
|
||||||
|
|
||||||
|
def submit_result(
|
||||||
|
self, agent_id: str, request: AgentCommandResultRequest
|
||||||
|
) -> None:
|
||||||
|
command = self.repository.get_command(request.command_id)
|
||||||
|
if not command:
|
||||||
|
raise AgentCommandNotFound("Agent command not found")
|
||||||
|
if command["agent_id"] != agent_id:
|
||||||
|
raise AgentCommandConflict("Agent command belongs to another Agent")
|
||||||
|
if command["command_status"] != "claimed":
|
||||||
|
raise AgentCommandConflict(
|
||||||
|
f"Agent command is {command['command_status']}, not claimed"
|
||||||
|
)
|
||||||
|
|
||||||
|
result_payload = request.result_payload
|
||||||
|
error_message = self._legacy_error(request.error_message)
|
||||||
|
if command["command_type"] == "miner_control":
|
||||||
|
try:
|
||||||
|
result = MinerControlResultPayload.model_validate(request.result_payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise AgentResultValidationError(
|
||||||
|
"Invalid typed miner-control result"
|
||||||
|
) from exc
|
||||||
|
if request.status == "completed" and not result.command_accepted:
|
||||||
|
raise AgentResultValidationError(
|
||||||
|
"Completed miner-control results must be accepted"
|
||||||
|
)
|
||||||
|
if request.status == "failed" and result.command_accepted:
|
||||||
|
raise AgentResultValidationError(
|
||||||
|
"Failed miner-control results cannot be accepted"
|
||||||
|
)
|
||||||
|
action = (command.get("request_payload") or {}).get("action")
|
||||||
|
if (
|
||||||
|
action in {"suspend", "resume"}
|
||||||
|
and result.command_accepted
|
||||||
|
and result.verification_status != "unconfirmed"
|
||||||
|
):
|
||||||
|
raise AgentResultValidationError(
|
||||||
|
"Accepted suspend and resume results must remain unconfirmed"
|
||||||
|
)
|
||||||
|
result_payload = result.model_dump(mode="json")
|
||||||
|
error_message = (
|
||||||
|
result.failure_code.replace("_", " ") if result.failure_code else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.repository.finish_command(
|
||||||
|
request.command_id,
|
||||||
|
agent_id,
|
||||||
|
request.status,
|
||||||
|
result_payload,
|
||||||
|
error_message,
|
||||||
|
)
|
||||||
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
redis_url: str = "redis://redis:6379/0"
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
minerops_agent_id: str = "plexus-windows-agent"
|
||||||
|
minerops_agent_shared_secret: str = ""
|
||||||
|
|
||||||
jwt_secret_key: str
|
jwt_secret_key: str
|
||||||
jwt_access_token_minutes: int = 30
|
jwt_access_token_minutes: int = 30
|
||||||
jwt_refresh_token_days: int = 14
|
jwt_refresh_token_days: int = 14
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ def miner_detail_content() -> str:
|
|||||||
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="panel miner-controls" id="minerControlsSection"
|
||||||
|
aria-labelledby="minerControlsTitle">
|
||||||
|
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
|
||||||
|
<h2 id="minerControlsTitle">Controls</h2></div>
|
||||||
|
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
|
||||||
|
</div>
|
||||||
|
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
|
||||||
|
Loading factual capability and recent operations.
|
||||||
|
</p>
|
||||||
|
<div id="minerControlCapabilities" class="control-capability-grid"></div>
|
||||||
|
<div id="minerControlActions" class="miner-control-actions" hidden>
|
||||||
|
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
|
||||||
|
<form id="minerPerformanceControl" class="performance-control" hidden>
|
||||||
|
<label for="minerPerformanceMode">Performance mode</label>
|
||||||
|
<select id="minerPerformanceMode">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
<button class="command-button" type="submit">Set mode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p id="minerControlPermission" class="permission-note" hidden>
|
||||||
|
Your account can inspect control capability and history but cannot queue controls.
|
||||||
|
</p>
|
||||||
|
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
|
||||||
|
<div class="table-wrap compact-history-table">
|
||||||
|
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
|
||||||
|
<th>Verification</th><th>Created</th></tr></thead>
|
||||||
|
<tbody id="minerControlHistory"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
|
||||||
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
|
||||||
<h2 id="minerLocationTitle">Location</h2></div></div>
|
<h2 id="minerLocationTitle">Location</h2></div></div>
|
||||||
|
|||||||
@@ -1722,3 +1722,52 @@ button.map-position:hover {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
|
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-capability-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-capability-grid > div {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miner-control-actions,
|
||||||
|
.performance-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miner-control-actions {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-control label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-callout {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border-left: 4px solid var(--warn);
|
||||||
|
background: #fff8e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.control-capability-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miner-control-actions,
|
||||||
|
.performance-control {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
const minerId = document.body.dataset.minerId;
|
const minerId = document.body.dataset.minerId;
|
||||||
const returnTo = document.body.dataset.returnTo || "";
|
const returnTo = document.body.dataset.returnTo || "";
|
||||||
|
const canControl = document.body.dataset.canControlMiners === "true";
|
||||||
const el = {
|
const el = {
|
||||||
loading: document.getElementById("minerDetailLoading"),
|
loading: document.getElementById("minerDetailLoading"),
|
||||||
error: document.getElementById("minerDetailError"),
|
error: document.getElementById("minerDetailError"),
|
||||||
@@ -32,7 +33,17 @@
|
|||||||
identityHistory: document.getElementById("minerIdentityHistory"),
|
identityHistory: document.getElementById("minerIdentityHistory"),
|
||||||
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
|
||||||
observations: document.getElementById("minerObservations"),
|
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) {
|
function create(tag, className, text) {
|
||||||
@@ -121,6 +132,123 @@
|
|||||||
return payload ? payload.data : null;
|
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) {
|
function table(headers, rows) {
|
||||||
const wrap = create("div", "table-wrap compact-history-table");
|
const wrap = create("div", "table-wrap compact-history-table");
|
||||||
const tableNode = create("table");
|
const tableNode = create("table");
|
||||||
@@ -413,8 +541,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
loadOverview();
|
||||||
|
loadControls();
|
||||||
|
}
|
||||||
|
|
||||||
el.retry.addEventListener("click", loadOverview);
|
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 () {
|
el.identityHistory.addEventListener("toggle", function () {
|
||||||
if (el.identityHistory.open) loadIdentityHistory();
|
if (el.identityHistory.open) loadIdentityHistory();
|
||||||
});
|
});
|
||||||
@@ -422,4 +563,5 @@
|
|||||||
if (el.observations.open) loadObservations();
|
if (el.observations.open) loadObservations();
|
||||||
});
|
});
|
||||||
loadOverview();
|
loadOverview();
|
||||||
|
loadControls();
|
||||||
})();
|
})();
|
||||||
@@ -521,6 +521,7 @@ def render_app_page(
|
|||||||
</head>
|
</head>
|
||||||
<body data-view="{escape(view)}"
|
<body data-view="{escape(view)}"
|
||||||
data-can-view-miners="{str(can_view_miners).lower()}"
|
data-can-view-miners="{str(can_view_miners).lower()}"
|
||||||
|
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
|
||||||
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
|
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
|
||||||
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
<a class="skip-link" href="#mainContent">Skip to main content</a>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
|
|||||||
@@ -757,6 +757,17 @@ def component_request(path: str) -> bool:
|
|||||||
|
|
||||||
def required_api_permission(method: str, path: str) -> str | None:
|
def required_api_permission(method: str, path: str) -> str | None:
|
||||||
parts = path.split("/")
|
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 (
|
if (
|
||||||
method == "GET"
|
method == "GET"
|
||||||
and len(parts) == 5
|
and len(parts) == 5
|
||||||
|
|||||||
+20
-165
@@ -11,6 +11,8 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta
|
|||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||||
|
|
||||||
from app.config import settings
|
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.dashboard.router import router as dashboard_router
|
||||||
from app.identity import (
|
from app.identity import (
|
||||||
COOKIE_TOKEN_HINT,
|
COOKIE_TOKEN_HINT,
|
||||||
@@ -63,6 +65,8 @@ app = FastAPI(title=settings.app_name)
|
|||||||
app.include_router(identity_router)
|
app.include_router(identity_router)
|
||||||
app.include_router(frontend_router)
|
app.include_router(frontend_router)
|
||||||
app.include_router(inventory_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(components_router)
|
||||||
app.include_router(locations_router)
|
app.include_router(locations_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -4010,6 +4014,8 @@ def minerops_process_due_scheduled_bulk_actions():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
app.state.agent_command_before_claim = minerops_process_due_scheduled_bulk_actions
|
||||||
|
|
||||||
|
|
||||||
def minerops_reconcile_scheduled_bulk_action_statuses():
|
def minerops_reconcile_scheduled_bulk_action_statuses():
|
||||||
minerops_ensure_scheduled_bulk_actions_table()
|
minerops_ensure_scheduled_bulk_actions_table()
|
||||||
@@ -24985,8 +24991,7 @@ async def minerops_hub_mode_js():
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
|
||||||
def minerops_agent_secret_expected() -> str:
|
def minerops_agent_secret_expected() -> str:
|
||||||
import os
|
return settings.minerops_agent_shared_secret.strip()
|
||||||
return str(os.environ.get("MINEROPS_AGENT_SHARED_SECRET", "") or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def minerops_require_agent_secret(request: Request):
|
def minerops_require_agent_secret(request: Request):
|
||||||
@@ -25089,6 +25094,7 @@ async def minerops_agent_checkin(request: Request):
|
|||||||
body = {}
|
body = {}
|
||||||
|
|
||||||
agent_id = str(body.get("agent_id") or "plexus-windows-agent").strip()
|
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()
|
site_name = str(body.get("site_name") or "Plexus").strip()
|
||||||
hostname = str(body.get("hostname") or "").strip()
|
hostname = str(body.get("hostname") or "").strip()
|
||||||
version = str(body.get("version") 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 (
|
INSERT INTO minerops_site_agents (
|
||||||
agent_id,
|
agent_id,
|
||||||
|
site_id,
|
||||||
site_name,
|
site_name,
|
||||||
hostname,
|
hostname,
|
||||||
version,
|
version,
|
||||||
@@ -25108,9 +25115,10 @@ async def minerops_agent_checkin(request: Request):
|
|||||||
last_checkin_at,
|
last_checkin_at,
|
||||||
last_payload
|
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)
|
ON CONFLICT (agent_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
|
site_id = COALESCE(EXCLUDED.site_id, minerops_site_agents.site_id),
|
||||||
site_name = EXCLUDED.site_name,
|
site_name = EXCLUDED.site_name,
|
||||||
hostname = EXCLUDED.hostname,
|
hostname = EXCLUDED.hostname,
|
||||||
version = EXCLUDED.version,
|
version = EXCLUDED.version,
|
||||||
@@ -25119,6 +25127,7 @@ async def minerops_agent_checkin(request: Request):
|
|||||||
last_payload = EXCLUDED.last_payload
|
last_payload = EXCLUDED.last_payload
|
||||||
""",
|
""",
|
||||||
agent_id,
|
agent_id,
|
||||||
|
site_id,
|
||||||
site_name,
|
site_name,
|
||||||
hostname,
|
hostname,
|
||||||
version,
|
version,
|
||||||
@@ -25301,12 +25310,11 @@ async def minerops_agent_setup_info(current_user: dict = Depends(get_current_use
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"hub_url": "https://plexus.lussier.cc",
|
"hub_url": "https://plexus.lussier.cc",
|
||||||
"agent_id": "plexus-windows-agent",
|
"agent_id": settings.minerops_agent_id,
|
||||||
"site_name": "Plexus",
|
"site_name": "Plexus",
|
||||||
"agent_secret": secret,
|
"secret_configured": bool(secret),
|
||||||
"secret_length": len(secret),
|
|
||||||
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
|
"config_path": "C:\\MinerOpsAgent\\agent-config.json",
|
||||||
"message": "Keep this secret private. It allows the Windows Agent to push updates to the hub.",
|
"message": "Agent credentials are provisioned locally and are never returned to browsers.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -25353,7 +25361,7 @@ async def minerops_agent_setup_page():
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Agent Connection Info</h2>
|
<h2>Agent Connection Info</h2>
|
||||||
<div class="warn">
|
<div class="warn">
|
||||||
Keep this secret private. Anyone with the secret can submit agent data to this hub.
|
Agent credentials must be provisioned directly on the Windows host.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>Hub URL</label>
|
<label>Hub URL</label>
|
||||||
@@ -25365,20 +25373,7 @@ async def minerops_agent_setup_page():
|
|||||||
<label>Site Name</label>
|
<label>Site Name</label>
|
||||||
<input id="siteName" readonly>
|
<input id="siteName" readonly>
|
||||||
|
|
||||||
<label>Agent Shared Secret</label>
|
<p id="secretStatus" class="small"></p>
|
||||||
<input id="agentSecret" type="password" readonly>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<button onclick="toggleSecret()">Show / Hide Secret</button>
|
|
||||||
<button onclick="copySecret()">Copy Secret</button>
|
|
||||||
<button onclick="copyConfig()">Copy Full Config JSON</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Windows Agent Config JSON</h2>
|
|
||||||
<p class="small">Paste this into <strong>C:\\MinerOpsAgent\\agent-config.json</strong> if you need to recreate the agent config.</p>
|
|
||||||
<textarea id="configJson" readonly></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -25427,42 +25422,14 @@ async function loadSetup() {
|
|||||||
document.getElementById("hubUrl").value = data.hub_url || "";
|
document.getElementById("hubUrl").value = data.hub_url || "";
|
||||||
document.getElementById("agentId").value = data.agent_id || "";
|
document.getElementById("agentId").value = data.agent_id || "";
|
||||||
document.getElementById("siteName").value = data.site_name || "";
|
document.getElementById("siteName").value = data.site_name || "";
|
||||||
document.getElementById("agentSecret").value = data.agent_secret || "";
|
document.getElementById("secretStatus").textContent = data.secret_configured
|
||||||
|
? "A shared Agent secret is configured on the Hub. Its value is not exposed here."
|
||||||
const config = {
|
: "The Hub does not currently have an Agent secret configured.";
|
||||||
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);
|
|
||||||
|
|
||||||
status.className = "ok";
|
status.className = "ok";
|
||||||
status.textContent = "Agent setup info loaded.";
|
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);
|
document.addEventListener("DOMContentLoaded", loadSetup);
|
||||||
</script>
|
</script>
|
||||||
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
|
<script src="/assets/minerops-miner-detail-extra.js?v=1"></script>
|
||||||
@@ -25939,118 +25906,6 @@ async def minerops_agent_command_status(current_user: dict = Depends(get_current
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/agent/commands/pending")
|
|
||||||
async def minerops_agent_commands_pending_v2(request: Request):
|
|
||||||
# __SCHEDULE_DUE_HOOK_V2__
|
|
||||||
try:
|
|
||||||
minerops_process_due_scheduled_bulk_actions()
|
|
||||||
except NameError:
|
|
||||||
pass
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"Scheduled bulk action due processing error: {exc}")
|
|
||||||
|
|
||||||
minerops_require_agent_secret(request)
|
|
||||||
|
|
||||||
await minerops_ensure_agent_command_queue_table()
|
|
||||||
|
|
||||||
agent_id = request.query_params.get("agent_id") or "plexus-windows-agent"
|
|
||||||
|
|
||||||
conn = await minerops_command_db_connect()
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
UPDATE minerops_agent_command_queue
|
|
||||||
SET
|
|
||||||
command_status = 'claimed',
|
|
||||||
claimed_at = now()
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT id
|
|
||||||
FROM minerops_agent_command_queue
|
|
||||||
WHERE agent_id = $1
|
|
||||||
AND command_status = 'pending'
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 5
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
command_type,
|
|
||||||
request_payload,
|
|
||||||
created_at
|
|
||||||
""",
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
commands = []
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
commands.append(
|
|
||||||
{
|
|
||||||
"command_id": row["id"],
|
|
||||||
"command_type": row["command_type"],
|
|
||||||
"request_payload": row["request_payload"],
|
|
||||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"commands": commands,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/agent/commands/result")
|
|
||||||
async def minerops_agent_commands_result_v2(request: Request):
|
|
||||||
import json
|
|
||||||
|
|
||||||
minerops_require_agent_secret(request)
|
|
||||||
|
|
||||||
body = await request.json()
|
|
||||||
|
|
||||||
command_id = str(body.get("command_id") or "").strip()
|
|
||||||
status = str(body.get("status") or "").strip().lower()
|
|
||||||
result_payload = body.get("result_payload") or {}
|
|
||||||
error_message = body.get("error_message")
|
|
||||||
|
|
||||||
if not command_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing command_id.")
|
|
||||||
|
|
||||||
if status not in {"completed", "failed"}:
|
|
||||||
status = "completed"
|
|
||||||
|
|
||||||
await minerops_ensure_agent_command_queue_table()
|
|
||||||
|
|
||||||
conn = await minerops_command_db_connect()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
UPDATE minerops_agent_command_queue
|
|
||||||
SET
|
|
||||||
command_status = $2,
|
|
||||||
result_payload = $3::jsonb,
|
|
||||||
error_message = $4,
|
|
||||||
completed_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
""",
|
|
||||||
command_id,
|
|
||||||
status,
|
|
||||||
json.dumps(result_payload),
|
|
||||||
error_message,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"command_id": command_id,
|
|
||||||
"status": status,
|
|
||||||
"message": "Agent command result received.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Phase 5K.5: Agent control web page
|
# Phase 5K.5: Agent control web page
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Typed miner control domain."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Dependency construction for miner control."""
|
||||||
|
|
||||||
|
from app.miner_control.repository import MinerControlRepository
|
||||||
|
from app.miner_control.service import MinerControlService
|
||||||
|
|
||||||
|
|
||||||
|
def get_miner_control_service() -> MinerControlService:
|
||||||
|
return MinerControlService(MinerControlRepository())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Miner control domain exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlError(Exception):
|
||||||
|
"""Base miner control error."""
|
||||||
|
|
||||||
|
|
||||||
|
class ControlledMinerNotFound(MinerControlError):
|
||||||
|
"""The durable miner UUID is unknown."""
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlUnavailable(MinerControlError):
|
||||||
|
"""The requested action cannot be safely queued."""
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlConflict(MinerControlError):
|
||||||
|
"""Another live control operation already exists."""
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlActorRequired(MinerControlError):
|
||||||
|
"""An authenticated actor UUID is required."""
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Typed contracts for miner control capabilities and operations."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
ControlAction = Literal["suspend", "resume", "performance_mode"]
|
||||||
|
CapabilityState = Literal["supported", "unavailable", "unknown"]
|
||||||
|
QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"]
|
||||||
|
VerificationStatus = Literal[
|
||||||
|
"confirmed", "verification_failed", "unconfirmed", "unavailable"
|
||||||
|
]
|
||||||
|
PerformanceMode = Literal["low", "normal", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyControlRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceModeRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
mode: PerformanceMode
|
||||||
|
|
||||||
|
|
||||||
|
class ControlCapability(BaseModel):
|
||||||
|
state: CapabilityState
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ControlAgentAvailability(BaseModel):
|
||||||
|
state: CapabilityState
|
||||||
|
reason: str | None = None
|
||||||
|
last_checkin_at: AwareDatetime | None = None
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlOperation(BaseModel):
|
||||||
|
operation_id: str
|
||||||
|
miner_id: UUID
|
||||||
|
action: ControlAction
|
||||||
|
requested_value: str | None = None
|
||||||
|
status: QueueStatus
|
||||||
|
verification_status: VerificationStatus | None = None
|
||||||
|
command_accepted: bool | None = None
|
||||||
|
observed_performance_mode: PerformanceMode | None = None
|
||||||
|
failure_code: str | None = None
|
||||||
|
created_at: AwareDatetime
|
||||||
|
claimed_at: AwareDatetime | None = None
|
||||||
|
completed_at: AwareDatetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlProjection(BaseModel):
|
||||||
|
miner_id: UUID
|
||||||
|
runtime_state: str | None = None
|
||||||
|
performance_mode: PerformanceMode | None = None
|
||||||
|
agent: ControlAgentAvailability
|
||||||
|
service_control: ControlCapability
|
||||||
|
performance_mode_control: ControlCapability
|
||||||
|
available_service_action: Literal["suspend", "resume"] | None = None
|
||||||
|
recent_operations: list[MinerControlOperation]
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlResponse(BaseModel):
|
||||||
|
data: MinerControlProjection
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlAccepted(BaseModel):
|
||||||
|
operation_id: str
|
||||||
|
miner_id: UUID
|
||||||
|
action: ControlAction
|
||||||
|
requested_value: str | None = None
|
||||||
|
status: Literal["pending"]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlAcceptedResponse(BaseModel):
|
||||||
|
data: MinerControlAccepted
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""PostgreSQL persistence for typed miner control operations."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.miner_control.exceptions import MinerControlConflict
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control"
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlRepository:
|
||||||
|
"""Resolve control facts and persist queue/audit rows atomically."""
|
||||||
|
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def get_control_context(self, miner_id: UUID) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
|
||||||
|
miners.status AS runtime_state,
|
||||||
|
miners.configured_performance_mode,
|
||||||
|
miners.api3_supported, miners.api3_api_switch,
|
||||||
|
agents.agent_id, agents.last_checkin_at,
|
||||||
|
agents.version AS agent_version, agents.last_payload
|
||||||
|
FROM miners
|
||||||
|
LEFT JOIN minerops_site_agents AS agents
|
||||||
|
ON agents.site_id = miners.site_id
|
||||||
|
WHERE miners.id = %s
|
||||||
|
""",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id AS operation_id, command_status AS status,
|
||||||
|
request_payload, result_payload, created_at,
|
||||||
|
claimed_at, completed_at
|
||||||
|
FROM minerops_agent_command_queue
|
||||||
|
WHERE command_type = 'miner_control'
|
||||||
|
AND request_payload ->> 'miner_id' = %s
|
||||||
|
AND request_payload ->> 'schema_version' = '1'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(str(miner_id), limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def enqueue_with_audit(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation_id: str,
|
||||||
|
miner_id: UUID,
|
||||||
|
agent_id: str,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
actor_username: str,
|
||||||
|
request_payload: dict[str, Any],
|
||||||
|
audit_payload: dict[str, Any],
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_agent_command_queue (
|
||||||
|
id, agent_id, command_type, command_status,
|
||||||
|
requested_by_user_id, requested_by_username, request_payload
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s)
|
||||||
|
RETURNING id AS operation_id, command_status AS status, created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
operation_id, agent_id, str(actor_user_id), actor_username,
|
||||||
|
Jsonb(request_payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
operation = dict(cur.fetchone())
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id, action, entity_type, entity_id,
|
||||||
|
new_value_json, related_miner_asset_id
|
||||||
|
)
|
||||||
|
VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(actor_user_id, miner_id, Jsonb(audit_payload), miner_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return operation
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT:
|
||||||
|
raise MinerControlConflict(
|
||||||
|
"A pending or running control operation already exists for this miner"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""HTTP mapping for typed miner controls."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.identity import require_permission
|
||||||
|
from app.miner_control.dependencies import get_miner_control_service
|
||||||
|
from app.miner_control.exceptions import (
|
||||||
|
ControlledMinerNotFound,
|
||||||
|
MinerControlConflict,
|
||||||
|
MinerControlUnavailable,
|
||||||
|
)
|
||||||
|
from app.miner_control.models import (
|
||||||
|
EmptyControlRequest,
|
||||||
|
MinerControlAcceptedResponse,
|
||||||
|
MinerControlResponse,
|
||||||
|
PerformanceModeRequest,
|
||||||
|
)
|
||||||
|
from app.miner_control.service import MinerControlService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/miners", tags=["miner-controls"])
|
||||||
|
ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)]
|
||||||
|
ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||||
|
ControlUser = Annotated[dict, Depends(require_permission("miners.control"))]
|
||||||
|
|
||||||
|
|
||||||
|
def require_same_origin(request: Request) -> None:
|
||||||
|
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
|
||||||
|
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin:
|
||||||
|
parsed = urlsplit(origin)
|
||||||
|
if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
|
||||||
|
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(current_user: dict) -> tuple[UUID, str]:
|
||||||
|
return UUID(str(current_user["id"])), str(
|
||||||
|
current_user.get("username") or current_user.get("email") or "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _queue(
|
||||||
|
service: MinerControlService,
|
||||||
|
miner_id: UUID,
|
||||||
|
action: str,
|
||||||
|
requested_value: str | None,
|
||||||
|
current_user: dict,
|
||||||
|
) -> MinerControlAcceptedResponse:
|
||||||
|
actor_id, username = _actor(current_user)
|
||||||
|
try:
|
||||||
|
operation = service.queue_control(
|
||||||
|
miner_id,
|
||||||
|
action,
|
||||||
|
requested_value=requested_value,
|
||||||
|
actor_user_id=actor_id,
|
||||||
|
actor_username=username,
|
||||||
|
)
|
||||||
|
except ControlledMinerNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||||
|
except MinerControlUnavailable as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except MinerControlConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return MinerControlAcceptedResponse(data=operation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{miner_id}/controls", response_model=MinerControlResponse)
|
||||||
|
def get_miner_controls(
|
||||||
|
miner_id: UUID,
|
||||||
|
current_user: ControlViewer,
|
||||||
|
service: ControlService,
|
||||||
|
) -> MinerControlResponse:
|
||||||
|
del current_user
|
||||||
|
try:
|
||||||
|
return MinerControlResponse(data=service.get_controls(miner_id))
|
||||||
|
except ControlledMinerNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Miner not found") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{miner_id}/controls/suspend",
|
||||||
|
response_model=MinerControlAcceptedResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
dependencies=[Depends(require_same_origin)],
|
||||||
|
)
|
||||||
|
def suspend_miner(
|
||||||
|
miner_id: UUID,
|
||||||
|
payload: EmptyControlRequest,
|
||||||
|
current_user: ControlUser,
|
||||||
|
service: ControlService,
|
||||||
|
) -> MinerControlAcceptedResponse:
|
||||||
|
del payload
|
||||||
|
return _queue(service, miner_id, "suspend", None, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{miner_id}/controls/resume",
|
||||||
|
response_model=MinerControlAcceptedResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
dependencies=[Depends(require_same_origin)],
|
||||||
|
)
|
||||||
|
def resume_miner(
|
||||||
|
miner_id: UUID,
|
||||||
|
payload: EmptyControlRequest,
|
||||||
|
current_user: ControlUser,
|
||||||
|
service: ControlService,
|
||||||
|
) -> MinerControlAcceptedResponse:
|
||||||
|
del payload
|
||||||
|
return _queue(service, miner_id, "resume", None, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{miner_id}/controls/performance-mode",
|
||||||
|
response_model=MinerControlAcceptedResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
dependencies=[Depends(require_same_origin)],
|
||||||
|
)
|
||||||
|
def set_performance_mode(
|
||||||
|
miner_id: UUID,
|
||||||
|
payload: PerformanceModeRequest,
|
||||||
|
current_user: ControlUser,
|
||||||
|
service: ControlService,
|
||||||
|
) -> MinerControlAcceptedResponse:
|
||||||
|
return _queue(service, miner_id, "performance_mode", payload.mode, current_user)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Business rules for typed, Agent-routed miner controls."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.miner_control.exceptions import (
|
||||||
|
ControlledMinerNotFound,
|
||||||
|
MinerControlActorRequired,
|
||||||
|
MinerControlUnavailable,
|
||||||
|
)
|
||||||
|
from app.miner_control.models import (
|
||||||
|
ControlAgentAvailability,
|
||||||
|
ControlCapability,
|
||||||
|
MinerControlAccepted,
|
||||||
|
MinerControlOperation,
|
||||||
|
MinerControlProjection,
|
||||||
|
)
|
||||||
|
from app.miner_control.repository import MinerControlRepository
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_MODES = {"low", "normal", "high"}
|
||||||
|
RUNNING_STATES = {"online", "running"}
|
||||||
|
STOPPED_STATES = {"suspended", "stopped"}
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlService:
|
||||||
|
def __init__(self, repository: MinerControlRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _capability(context: dict) -> ControlCapability:
|
||||||
|
if not context.get("site_id"):
|
||||||
|
return ControlCapability(state="unavailable", reason="site_unassigned")
|
||||||
|
if not context.get("agent_id"):
|
||||||
|
return ControlCapability(state="unavailable", reason="agent_unassigned")
|
||||||
|
if not context.get("current_ip"):
|
||||||
|
return ControlCapability(state="unavailable", reason="current_ip_missing")
|
||||||
|
if context.get("api3_supported") is False:
|
||||||
|
return ControlCapability(state="unavailable", reason="api3_unsupported")
|
||||||
|
if context.get("api3_supported") is not True:
|
||||||
|
return ControlCapability(state="unknown", reason="api3_support_unknown")
|
||||||
|
|
||||||
|
payload = context.get("last_payload") or {}
|
||||||
|
if payload.get("control_available") is False:
|
||||||
|
reason = payload.get("control_unavailable_reason")
|
||||||
|
return ControlCapability(
|
||||||
|
state="unavailable",
|
||||||
|
reason=reason if reason == "credentials_missing" else "agent_unavailable",
|
||||||
|
)
|
||||||
|
if payload.get("control_available") is not True:
|
||||||
|
return ControlCapability(
|
||||||
|
state="unknown", reason="agent_capability_unknown"
|
||||||
|
)
|
||||||
|
return ControlCapability(state="supported")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
|
||||||
|
request = row.get("request_payload") or {}
|
||||||
|
result = row.get("result_payload") or {}
|
||||||
|
mode = result.get("observed_performance_mode")
|
||||||
|
return MinerControlOperation(
|
||||||
|
operation_id=row["operation_id"],
|
||||||
|
miner_id=miner_id,
|
||||||
|
action=request.get("action"),
|
||||||
|
requested_value=request.get("requested_value"),
|
||||||
|
status=row["status"],
|
||||||
|
verification_status=result.get("verification_status"),
|
||||||
|
command_accepted=result.get("command_accepted"),
|
||||||
|
observed_performance_mode=mode if mode in KNOWN_MODES else None,
|
||||||
|
failure_code=result.get("failure_code"),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
claimed_at=row.get("claimed_at"),
|
||||||
|
completed_at=row.get("completed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_controls(self, miner_id: UUID) -> MinerControlProjection:
|
||||||
|
context = self.repository.get_control_context(miner_id)
|
||||||
|
if not context:
|
||||||
|
raise ControlledMinerNotFound("Miner not found")
|
||||||
|
capability = self._capability(context)
|
||||||
|
runtime = str(context.get("runtime_state") or "").strip().lower() or None
|
||||||
|
service_action = None
|
||||||
|
if capability.state == "supported":
|
||||||
|
if runtime in RUNNING_STATES:
|
||||||
|
service_action = "suspend"
|
||||||
|
elif runtime in STOPPED_STATES:
|
||||||
|
service_action = "resume"
|
||||||
|
configured_mode = str(context.get("configured_performance_mode") or "").lower()
|
||||||
|
return MinerControlProjection(
|
||||||
|
miner_id=miner_id,
|
||||||
|
runtime_state=runtime,
|
||||||
|
performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None),
|
||||||
|
agent=ControlAgentAvailability(
|
||||||
|
state=capability.state,
|
||||||
|
reason=capability.reason,
|
||||||
|
last_checkin_at=context.get("last_checkin_at"),
|
||||||
|
version=context.get("agent_version"),
|
||||||
|
),
|
||||||
|
service_control=capability,
|
||||||
|
performance_mode_control=capability,
|
||||||
|
available_service_action=service_action,
|
||||||
|
recent_operations=[
|
||||||
|
self._operation(miner_id, row)
|
||||||
|
for row in self.repository.list_recent(miner_id, limit=5)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def queue_control(
|
||||||
|
self,
|
||||||
|
miner_id: UUID,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
requested_value: str | None,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
actor_username: str,
|
||||||
|
) -> MinerControlAccepted:
|
||||||
|
if not actor_user_id:
|
||||||
|
raise MinerControlActorRequired("Authenticated actor UUID is required")
|
||||||
|
context = self.repository.get_control_context(miner_id)
|
||||||
|
if not context:
|
||||||
|
raise ControlledMinerNotFound("Miner not found")
|
||||||
|
capability = self._capability(context)
|
||||||
|
if capability.state != "supported":
|
||||||
|
raise MinerControlUnavailable(
|
||||||
|
capability.reason or "control_capability_not_supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = str(context.get("runtime_state") or "").strip().lower()
|
||||||
|
if action == "suspend" and runtime not in RUNNING_STATES:
|
||||||
|
raise MinerControlUnavailable("miner_not_factually_running")
|
||||||
|
if action == "resume" and runtime not in STOPPED_STATES:
|
||||||
|
raise MinerControlUnavailable("miner_not_factually_stopped")
|
||||||
|
if action == "performance_mode" and requested_value not in KNOWN_MODES:
|
||||||
|
raise MinerControlUnavailable("invalid_performance_mode")
|
||||||
|
|
||||||
|
operation_id = str(uuid4())
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"action": action,
|
||||||
|
"requested_value": requested_value,
|
||||||
|
"ip_address": context["current_ip"],
|
||||||
|
}
|
||||||
|
operation = self.repository.enqueue_with_audit(
|
||||||
|
operation_id=operation_id,
|
||||||
|
miner_id=miner_id,
|
||||||
|
agent_id=context["agent_id"],
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_username=actor_username,
|
||||||
|
request_payload=payload,
|
||||||
|
audit_payload={
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"miner_id": str(miner_id),
|
||||||
|
"action": action,
|
||||||
|
"requested_value": requested_value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return MinerControlAccepted(
|
||||||
|
operation_id=operation["operation_id"],
|
||||||
|
miner_id=miner_id,
|
||||||
|
action=action,
|
||||||
|
requested_value=requested_value,
|
||||||
|
status=operation["status"],
|
||||||
|
created_at=operation["created_at"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Focused tests for authenticated Agent queue transport."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.agent_commands.dependencies import get_agent_command_service
|
||||||
|
from app.agent_commands.exceptions import (
|
||||||
|
AgentCommandConflict,
|
||||||
|
AgentResultValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_commands.models import AgentCommandResultRequest
|
||||||
|
from app.agent_commands.repository import CLAIM_COMMANDS_SQL
|
||||||
|
from app.agent_commands.router import router
|
||||||
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_source_root() -> Path | None:
|
||||||
|
container_root = Path("/agents/windows")
|
||||||
|
if container_root.is_dir():
|
||||||
|
return container_root
|
||||||
|
repository_root = Path(__file__).parents[2] / "agents" / "windows"
|
||||||
|
return repository_root if repository_root.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, row=None):
|
||||||
|
self.row = row or {
|
||||||
|
"id": "command-1", "agent_id": "test-agent",
|
||||||
|
"command_type": "miner_control", "command_status": "claimed",
|
||||||
|
"request_payload": {"action": "suspend"},
|
||||||
|
}
|
||||||
|
self.finished = []
|
||||||
|
|
||||||
|
def claim_pending(self, agent_id, limit=5):
|
||||||
|
return [{
|
||||||
|
"command_id": "command-1", "command_type": "miner_control",
|
||||||
|
"request_payload": {"action": "suspend"}, "created_at": NOW,
|
||||||
|
}]
|
||||||
|
|
||||||
|
def get_command(self, command_id):
|
||||||
|
return self.row if command_id == self.row.get("id") else None
|
||||||
|
|
||||||
|
def finish_command(self, *args):
|
||||||
|
self.finished.append(args)
|
||||||
|
|
||||||
|
|
||||||
|
def typed_result(**overrides):
|
||||||
|
result = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"command_accepted": True,
|
||||||
|
"verification_attempted": False,
|
||||||
|
"verification_status": "unconfirmed",
|
||||||
|
"observed_service_state": None,
|
||||||
|
"observed_performance_mode": None,
|
||||||
|
"failure_code": None,
|
||||||
|
}
|
||||||
|
result.update(overrides)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandServiceTests(unittest.TestCase):
|
||||||
|
def test_claim_query_uses_skip_locked_and_deterministic_order(self):
|
||||||
|
self.assertIn("FOR UPDATE SKIP LOCKED", CLAIM_COMMANDS_SQL)
|
||||||
|
self.assertIn("ORDER BY created_at, id", CLAIM_COMMANDS_SQL)
|
||||||
|
self.assertIn("queue.command_status = 'pending'", CLAIM_COMMANDS_SQL)
|
||||||
|
|
||||||
|
def test_typed_result_is_strict_and_sanitized(self):
|
||||||
|
repository = FakeRepository()
|
||||||
|
request = AgentCommandResultRequest(
|
||||||
|
command_id="command-1", status="completed",
|
||||||
|
result_payload=typed_result(), error_message="raw 192.0.2.10 vendor exception",
|
||||||
|
)
|
||||||
|
AgentCommandService(repository).submit_result("test-agent", request)
|
||||||
|
finished = repository.finished[0]
|
||||||
|
self.assertIsNone(finished[4])
|
||||||
|
self.assertNotIn("192.0.2.10", str(finished))
|
||||||
|
|
||||||
|
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
|
||||||
|
request = AgentCommandResultRequest(
|
||||||
|
command_id="command-1", status="completed", result_payload=typed_result()
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentCommandConflict):
|
||||||
|
AgentCommandService(FakeRepository()).submit_result("wrong-agent", request)
|
||||||
|
for queue_status in ("pending", "completed", "failed", "cancelled"):
|
||||||
|
with self.subTest(status=queue_status), self.assertRaises(AgentCommandConflict):
|
||||||
|
row = FakeRepository().row.copy()
|
||||||
|
row["command_status"] = queue_status
|
||||||
|
AgentCommandService(FakeRepository(row)).submit_result("test-agent", request)
|
||||||
|
|
||||||
|
def test_invalid_status_and_secret_fields_are_rejected(self):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
AgentCommandResultRequest(
|
||||||
|
command_id="command-1", status="unknown", result_payload={}
|
||||||
|
)
|
||||||
|
for forbidden in ("password", "account", "token", "raw_response"):
|
||||||
|
with self.subTest(field=forbidden):
|
||||||
|
repository = FakeRepository()
|
||||||
|
request = AgentCommandResultRequest(
|
||||||
|
command_id="command-1", status="completed",
|
||||||
|
result_payload={**typed_result(), forbidden: "sensitive"},
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentResultValidationError):
|
||||||
|
AgentCommandService(repository).submit_result("test-agent", request)
|
||||||
|
|
||||||
|
def test_suspend_resume_cannot_claim_confirmation(self):
|
||||||
|
for status in ("confirmed", "verification_failed"):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
request = AgentCommandResultRequest(
|
||||||
|
command_id="command-1", status="completed",
|
||||||
|
result_payload=typed_result(
|
||||||
|
verification_attempted=True, verification_status=status
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaises(AgentResultValidationError):
|
||||||
|
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.repository = FakeRepository()
|
||||||
|
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
|
||||||
|
self.repository
|
||||||
|
)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
self.old_id = settings.minerops_agent_id
|
||||||
|
self.old_secret = settings.minerops_agent_shared_secret
|
||||||
|
settings.minerops_agent_id = "test-agent"
|
||||||
|
settings.minerops_agent_shared_secret = "test-secret"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
settings.minerops_agent_id = self.old_id
|
||||||
|
settings.minerops_agent_shared_secret = self.old_secret
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def headers(self, agent="test-agent"):
|
||||||
|
return {
|
||||||
|
"X-MinerOps-Agent-ID": agent,
|
||||||
|
"X-MinerOps-Agent-Secret": "test-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_poll_requires_header_identity_and_ignores_query_spoofing(self):
|
||||||
|
before_claim_calls = []
|
||||||
|
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
|
||||||
|
self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(
|
||||||
|
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
|
||||||
|
).status_code,
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
self.assertEqual(before_claim_calls, [True])
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(
|
||||||
|
"/api/agent/commands/pending", headers=self.headers("other")
|
||||||
|
).status_code,
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_result_requires_strict_claimed_contract(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/agent/commands/result",
|
||||||
|
headers=self.headers(),
|
||||||
|
json={
|
||||||
|
"command_id": "command-1", "status": "completed",
|
||||||
|
"result_payload": typed_result(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["status"], "completed")
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
"/api/agent/commands/result", headers=self.headers(),
|
||||||
|
json={"command_id": "command-1", "status": "other",
|
||||||
|
"result_payload": {}},
|
||||||
|
).status_code,
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSourceContractTests(unittest.TestCase):
|
||||||
|
def test_agent_typed_path_has_locked_commands_and_no_default_credentials(self):
|
||||||
|
root = agent_source_root()
|
||||||
|
if root is None:
|
||||||
|
self.skipTest("Windows Agent source is not mounted")
|
||||||
|
service = (root / "MinerOpsAgentService.ps1").read_text()
|
||||||
|
control = (root / "MinerOpsControl.ps1").read_text()
|
||||||
|
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
|
||||||
|
powershell = "\n".join(path.read_text() for path in root.glob("*.ps1"))
|
||||||
|
self.assertNotIn('DefaultValue "super"', service)
|
||||||
|
self.assertNotIn('DefaultValue "super"', powershell)
|
||||||
|
self.assertIn('"X-MinerOps-Agent-ID"', service)
|
||||||
|
self.assertNotIn("?agent_id=", service)
|
||||||
|
self.assertIn('"get.miner.setting"', control)
|
||||||
|
self.assertIn("$attempt -le 3", control)
|
||||||
|
self.assertIn("$Payload.performance_mode", control)
|
||||||
|
self.assertNotIn("api3_account =", control)
|
||||||
|
self.assertNotIn("api3_password =", control)
|
||||||
|
for forbidden in ("raw_response", "raw_command", "raw_exception"):
|
||||||
|
with self.subTest(field=forbidden):
|
||||||
|
self.assertNotIn(forbidden, control.lower())
|
||||||
|
self.assertNotIn('"agent_secret": secret', main)
|
||||||
|
self.assertNotIn('"secret_length":', main)
|
||||||
|
self.assertIn('"secret_configured": bool(secret)', main)
|
||||||
|
|
||||||
|
def test_deployment_preserves_local_state_and_registers_established_tasks(self):
|
||||||
|
root = agent_source_root()
|
||||||
|
if root is None:
|
||||||
|
self.skipTest("Windows Agent source is not mounted")
|
||||||
|
install = (root / "install.ps1").read_text()
|
||||||
|
uninstall = (root / "uninstall.ps1").read_text()
|
||||||
|
for task in (
|
||||||
|
"MinerOpsAgent", "MinerOpsAgentTray",
|
||||||
|
"MinerOpsApi3Enrichment", "MinerOpsPoolPoller",
|
||||||
|
):
|
||||||
|
self.assertIn(task, install)
|
||||||
|
self.assertIn(task, uninstall)
|
||||||
|
for local_path in ("agent-config.json", "agent-state.json", "logs"):
|
||||||
|
self.assertNotIn(f'"{local_path}"', install)
|
||||||
|
self.assertNotIn(f'"{local_path}"', uninstall)
|
||||||
|
self.assertNotIn("agent_secret", install.lower())
|
||||||
|
self.assertNotIn("api3_password", install.lower())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Focused service, HTTP, frontend, and security tests for miner control v1."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.identity import get_current_user, required_api_permission
|
||||||
|
from app.miner_control.dependencies import get_miner_control_service
|
||||||
|
from app.miner_control.exceptions import MinerControlConflict, MinerControlUnavailable
|
||||||
|
from app.miner_control.models import MinerControlAccepted
|
||||||
|
from app.miner_control.router import router
|
||||||
|
from app.miner_control.service import MinerControlService
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
|
||||||
|
MINER_ID = UUID("00000000-0000-4000-8000-000000001501")
|
||||||
|
USER_ID = UUID("00000000-0000-4000-8000-000000001502")
|
||||||
|
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||||
|
|
||||||
|
|
||||||
|
def context(**overrides):
|
||||||
|
result = {
|
||||||
|
"id": MINER_ID,
|
||||||
|
"site_id": uuid4(),
|
||||||
|
"current_ip": "192.0.2.10",
|
||||||
|
"runtime_state": "online",
|
||||||
|
"configured_performance_mode": "normal",
|
||||||
|
"api3_supported": True,
|
||||||
|
"api3_api_switch": "on",
|
||||||
|
"agent_id": "test-agent",
|
||||||
|
"last_checkin_at": NOW,
|
||||||
|
"agent_version": "1.0.0-alpha",
|
||||||
|
"last_payload": {"control_available": True},
|
||||||
|
}
|
||||||
|
result.update(overrides)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, miner_context=None, history=None, conflict=False):
|
||||||
|
self.miner_context = context() if miner_context is None else miner_context
|
||||||
|
self.history = history or []
|
||||||
|
self.conflict = conflict
|
||||||
|
self.enqueue_calls = []
|
||||||
|
self.limits = []
|
||||||
|
|
||||||
|
def get_control_context(self, miner_id):
|
||||||
|
self.requested_miner = miner_id
|
||||||
|
return self.miner_context
|
||||||
|
|
||||||
|
def list_recent(self, miner_id, limit=5):
|
||||||
|
self.limits.append(limit)
|
||||||
|
return self.history
|
||||||
|
|
||||||
|
def enqueue_with_audit(self, **kwargs):
|
||||||
|
self.enqueue_calls.append(kwargs)
|
||||||
|
if self.conflict:
|
||||||
|
raise MinerControlConflict("live command exists")
|
||||||
|
return {
|
||||||
|
"operation_id": kwargs["operation_id"],
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": NOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlServiceTests(unittest.TestCase):
|
||||||
|
def test_supported_projection_is_factual_and_bounded(self):
|
||||||
|
repository = FakeRepository()
|
||||||
|
result = MinerControlService(repository).get_controls(MINER_ID)
|
||||||
|
self.assertEqual(result.service_control.state, "supported")
|
||||||
|
self.assertEqual(result.performance_mode, "normal")
|
||||||
|
self.assertEqual(result.available_service_action, "suspend")
|
||||||
|
self.assertEqual(repository.limits, [5])
|
||||||
|
|
||||||
|
def test_capability_missing_facts_is_not_optimistic(self):
|
||||||
|
cases = (
|
||||||
|
({"site_id": None}, "unavailable", "site_unassigned"),
|
||||||
|
({"agent_id": None}, "unavailable", "agent_unassigned"),
|
||||||
|
({"current_ip": None}, "unavailable", "current_ip_missing"),
|
||||||
|
({"api3_supported": False}, "unavailable", "api3_unsupported"),
|
||||||
|
({"api3_supported": None}, "unknown", "api3_support_unknown"),
|
||||||
|
({"last_payload": {}}, "unknown", "agent_capability_unknown"),
|
||||||
|
(
|
||||||
|
{"last_payload": {"control_available": False,
|
||||||
|
"control_unavailable_reason": "credentials_missing"}},
|
||||||
|
"unavailable", "credentials_missing",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for changes, state, reason in cases:
|
||||||
|
with self.subTest(reason=reason):
|
||||||
|
result = MinerControlService(FakeRepository(context(**changes))).get_controls(MINER_ID)
|
||||||
|
self.assertEqual(result.service_control.state, state)
|
||||||
|
self.assertEqual(result.service_control.reason, reason)
|
||||||
|
|
||||||
|
def test_resume_requires_known_stopped_state(self):
|
||||||
|
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
|
||||||
|
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
|
||||||
|
service.queue_control(
|
||||||
|
MINER_ID, "resume", requested_value=None,
|
||||||
|
actor_user_id=USER_ID, actor_username="operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_queue_contains_routing_but_audit_and_response_do_not(self):
|
||||||
|
repository = FakeRepository()
|
||||||
|
result = MinerControlService(repository).queue_control(
|
||||||
|
MINER_ID, "performance_mode", requested_value="high",
|
||||||
|
actor_user_id=USER_ID, actor_username="operator",
|
||||||
|
)
|
||||||
|
call = repository.enqueue_calls[0]
|
||||||
|
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
|
||||||
|
self.assertNotIn("ip_address", call["audit_payload"])
|
||||||
|
self.assertNotIn("agent_id", call["audit_payload"])
|
||||||
|
self.assertNotIn("password", str(call).lower())
|
||||||
|
self.assertNotIn("192.0.2.10", result.model_dump_json())
|
||||||
|
|
||||||
|
def test_no_projection_changes_when_work_is_queued(self):
|
||||||
|
repository = FakeRepository()
|
||||||
|
MinerControlService(repository).queue_control(
|
||||||
|
MINER_ID, "suspend", requested_value=None,
|
||||||
|
actor_user_id=USER_ID, actor_username="operator",
|
||||||
|
)
|
||||||
|
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.repository = FakeRepository()
|
||||||
|
self.service = MinerControlService(self.repository)
|
||||||
|
self.app.dependency_overrides[get_miner_control_service] = lambda: self.service
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(USER_ID), "username": "operator", "role": "Custom",
|
||||||
|
"permissions": list(permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_requires_authentication_and_miners_view(self):
|
||||||
|
path = f"/api/miners/{MINER_ID}/controls"
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 401)
|
||||||
|
self.authenticate(("miners.control",))
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 403)
|
||||||
|
self.authenticate(("miners.view",))
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
|
||||||
|
|
||||||
|
def test_posts_require_control_and_return_202_bounded_envelope(self):
|
||||||
|
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
||||||
|
self.authenticate(("miners.view",))
|
||||||
|
self.assertEqual(self.client.post(path, json={}).status_code, 403)
|
||||||
|
self.authenticate(("miners.control",))
|
||||||
|
response = self.client.post(path, json={})
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
self.assertEqual(set(response.json()["data"]), {
|
||||||
|
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
|
||||||
|
})
|
||||||
|
self.assertNotIn("ip", response.text.lower())
|
||||||
|
self.assertNotIn("agent", response.text.lower())
|
||||||
|
|
||||||
|
def test_strict_bodies_reject_routing_commands_and_credentials(self):
|
||||||
|
self.authenticate(("miners.control",))
|
||||||
|
base = f"/api/miners/{MINER_ID}/controls"
|
||||||
|
for body in (
|
||||||
|
{"ip": "192.0.2.1"}, {"agent_id": "other"}, {"dry_run": True},
|
||||||
|
{"raw_command": "reboot"}, {"api_password": "secret"},
|
||||||
|
):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
self.assertEqual(self.client.post(base + "/suspend", json=body).status_code, 422)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(base + "/performance-mode", json={"mode": "turbo"}).status_code,
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_site_rejected_and_same_origin_allowed(self):
|
||||||
|
self.authenticate(("miners.control",))
|
||||||
|
path = f"/api/miners/{MINER_ID}/controls/suspend"
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(path, json={}, headers={"Sec-Fetch-Site": "cross-site"}).status_code,
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
|
||||||
|
202,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_identity_permission_mapping_is_specific(self):
|
||||||
|
base = f"/api/miners/{MINER_ID}/controls"
|
||||||
|
self.assertEqual(required_api_permission("GET", base), "miners.view")
|
||||||
|
for action in ("suspend", "resume", "performance-mode"):
|
||||||
|
self.assertEqual(
|
||||||
|
required_api_permission("POST", f"{base}/{action}"),
|
||||||
|
"miners.control",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
required_api_permission("POST", base), "miners.control"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlFrontendTests(unittest.TestCase):
|
||||||
|
def test_controls_are_safe_confirmation_gated_and_non_polling(self):
|
||||||
|
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
|
||||||
|
view = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text()
|
||||||
|
self.assertIn('window.confirm("Confirm: "', source)
|
||||||
|
self.assertIn("await loadControls();", source)
|
||||||
|
self.assertNotIn("setInterval", source)
|
||||||
|
self.assertNotIn("innerHTML", source)
|
||||||
|
self.assertIn("minerControlsTitle", view)
|
||||||
|
self.assertIn("Your account can inspect control capability", view)
|
||||||
|
|
||||||
|
def test_new_ui_does_not_use_legacy_control_routes(self):
|
||||||
|
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
|
||||||
|
for legacy in ("agent-control", "/commands/", "api3", "change-password", "reboot"):
|
||||||
|
self.assertNotIn(legacy, source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Real PostgreSQL concurrency and transaction regressions for miner control."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.agent_commands.models import AgentCommandResultRequest
|
||||||
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
from app.config import settings
|
||||||
|
from app.miner_control.exceptions import MinerControlConflict
|
||||||
|
from app.miner_control.repository import MinerControlRepository
|
||||||
|
from app.miner_control.service import MinerControlService
|
||||||
|
|
||||||
|
|
||||||
|
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
|
||||||
|
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
|
||||||
|
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
|
||||||
|
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
|
||||||
|
AGENT_ID = "control-test-agent"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host, port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db, user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password, connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def result_payload(accepted=True):
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"command_accepted": accepted,
|
||||||
|
"verification_attempted": False,
|
||||||
|
"verification_status": "unconfirmed" if accepted else "unavailable",
|
||||||
|
"observed_service_state": None,
|
||||||
|
"observed_performance_mode": None,
|
||||||
|
"failure_code": None if accepted else "write_rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MinerControlPostgresTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
|
||||||
|
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, username, display_name, password_hash, role_id)
|
||||||
|
SELECT %s, 'control-test-user', 'Control Test User', 'unused', id
|
||||||
|
FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (USER_ID,)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO miners (
|
||||||
|
id, site_id, asset_tag, current_ip, status, api3_supported,
|
||||||
|
configured_performance_mode, first_seen_at, last_seen_at
|
||||||
|
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
|
||||||
|
'normal', NULL, NULL)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||||
|
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
|
||||||
|
api3_supported = true
|
||||||
|
""", (MINER_ID, SITE_ID)
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, site_name, last_payload
|
||||||
|
) VALUES (%s, %s, 'Control Test Site', %s)
|
||||||
|
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
|
||||||
|
last_payload = EXCLUDED.last_payload
|
||||||
|
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_agent_command_queue "
|
||||||
|
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
|
||||||
|
(str(MINER_ID), str(OTHER_MINER_ID)),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
|
||||||
|
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
|
||||||
|
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
|
||||||
|
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM minerops_agent_command_queue "
|
||||||
|
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
|
||||||
|
(str(MINER_ID), str(OTHER_MINER_ID)),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def insert_command(self, command_id, miner_id, status="pending", created=None):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_agent_command_queue (
|
||||||
|
id, agent_id, command_type, command_status, request_payload, created_at
|
||||||
|
) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now()))
|
||||||
|
""",
|
||||||
|
(command_id, AGENT_ID, status,
|
||||||
|
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
|
||||||
|
"action": "suspend", "requested_value": None}), created),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_two_concurrent_claimers_never_receive_same_command(self):
|
||||||
|
self.insert_command("concurrent-one", MINER_ID)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
results = list(pool.map(
|
||||||
|
lambda _value: AgentCommandRepository(connect).claim_pending(AGENT_ID),
|
||||||
|
range(2),
|
||||||
|
))
|
||||||
|
claimed = [row["command_id"] for result in results for row in result]
|
||||||
|
self.assertEqual(claimed, ["concurrent-one"])
|
||||||
|
|
||||||
|
def test_skip_locked_claims_next_available_row(self):
|
||||||
|
self.insert_command(
|
||||||
|
"a-locked", MINER_ID,
|
||||||
|
created=datetime(2026, 8, 24, 10, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.insert_command(
|
||||||
|
"b-available", OTHER_MINER_ID,
|
||||||
|
created=datetime(2026, 8, 24, 11, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
locker = connect()
|
||||||
|
try:
|
||||||
|
with locker.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM minerops_agent_command_queue "
|
||||||
|
"WHERE id = 'a-locked' FOR UPDATE"
|
||||||
|
)
|
||||||
|
claimed = AgentCommandRepository(connect).claim_pending(AGENT_ID)
|
||||||
|
self.assertEqual([row["command_id"] for row in claimed], ["b-available"])
|
||||||
|
finally:
|
||||||
|
locker.rollback()
|
||||||
|
locker.close()
|
||||||
|
|
||||||
|
def test_result_binding_claim_requirement_and_terminal_immutability(self):
|
||||||
|
self.insert_command("result-one", MINER_ID, status="claimed")
|
||||||
|
request = AgentCommandResultRequest(
|
||||||
|
command_id="result-one", status="completed", result_payload=result_payload()
|
||||||
|
)
|
||||||
|
service = AgentCommandService(AgentCommandRepository(connect))
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
service.submit_result("wrong-agent", request)
|
||||||
|
service.submit_result(AGENT_ID, request)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
service.submit_result(AGENT_ID, request)
|
||||||
|
|
||||||
|
self.insert_command("pending-result", OTHER_MINER_ID, status="pending")
|
||||||
|
pending = AgentCommandResultRequest(
|
||||||
|
command_id="pending-result", status="failed", result_payload=result_payload(False)
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
service.submit_result(AGENT_ID, pending)
|
||||||
|
|
||||||
|
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
|
||||||
|
service = MinerControlService(MinerControlRepository(connect))
|
||||||
|
first = service.queue_control(
|
||||||
|
MINER_ID, "suspend", requested_value=None,
|
||||||
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
|
)
|
||||||
|
with self.assertRaises(MinerControlConflict):
|
||||||
|
service.queue_control(
|
||||||
|
MINER_ID, "suspend", requested_value=None,
|
||||||
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
|
)
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM audit_logs WHERE entity_id = %s", (MINER_ID,)
|
||||||
|
)
|
||||||
|
self.assertEqual(cur.fetchone()[0], 1)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE minerops_agent_command_queue SET command_status = 'completed', "
|
||||||
|
"completed_at = now() WHERE id = %s", (first.operation_id,)
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
second = service.queue_control(
|
||||||
|
MINER_ID, "suspend", requested_value=None,
|
||||||
|
actor_user_id=USER_ID, actor_username="control-test-user",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(first.operation_id, second.operation_id)
|
||||||
|
|
||||||
|
def test_audit_failure_rolls_back_queue_row(self):
|
||||||
|
service = MinerControlService(MinerControlRepository(connect))
|
||||||
|
operation_count_before = self._operation_count()
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
service.queue_control(
|
||||||
|
MINER_ID, "suspend", requested_value=None,
|
||||||
|
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
|
||||||
|
actor_username="missing-user",
|
||||||
|
)
|
||||||
|
self.assertEqual(self._operation_count(), operation_count_before)
|
||||||
|
|
||||||
|
def _operation_count(self):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM minerops_agent_command_queue "
|
||||||
|
"WHERE request_payload ->> 'miner_id' = %s", (str(MINER_ID),)
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -498,10 +498,12 @@ class MinerDetailFrontendTests(unittest.TestCase):
|
|||||||
r'type="button">Refresh</button>',
|
r'type="button">Refresh</button>',
|
||||||
)
|
)
|
||||||
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
|
||||||
self.assertIn(
|
self.assertIn('el.refresh.addEventListener("click", refreshAll);', source)
|
||||||
'el.refresh.addEventListener("click", loadOverview);',
|
refresh = re.search(
|
||||||
|
r"function refreshAll\(\) \{\s*loadOverview\(\);\s*loadControls\(\);\s*\}",
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
self.assertIsNotNone(refresh)
|
||||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
|
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("/events", source)
|
||||||
self.assertIn("/parts", source)
|
self.assertIn("/parts", source)
|
||||||
self.assertNotIn("setInterval", 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 (
|
for mutation in (
|
||||||
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
|
'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"',
|
||||||
'method: "DELETE"', "reboot", "change-mac", "manual-details",
|
"reboot", "change-mac", "manual-details",
|
||||||
):
|
):
|
||||||
self.assertNotIn(mutation, source)
|
self.assertNotIn(mutation, source)
|
||||||
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
|
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.system_health.service import SystemHealthService
|
|||||||
|
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).parents[1] / "app"
|
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):
|
def database_payload(revision=EXPECTED_HEAD):
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ migration_head="$(
|
|||||||
"${compose[@]}" exec -T postgres sh -ec \
|
"${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"'
|
'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
|
if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then
|
||||||
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0014_repair_parts_tracking." >&2
|
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")"
|
|||||||
|
|
||||||
echo "Checking migrations and required schema..."
|
echo "Checking migrations and required schema..."
|
||||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
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="$(
|
missing_tables="$(
|
||||||
"${compose[@]}" exec -T postgres sh -c \
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
|||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_CONTROL_MIGRATION_PROJECT:-minerops-control-migration-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
|
||||||
|
psql_db() {
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Testing 0015 preflight failures from the previous head..."
|
||||||
|
run_alembic upgrade 0014_repair_parts_tracking
|
||||||
|
psql_db -c "INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status) VALUES ('unknown-status', 'test-agent', 'test', 'mystery')"
|
||||||
|
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
|
||||||
|
echo "Unknown queue status unexpectedly passed migration preflight" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"unsupported command_status values: mystery"* ]]
|
||||||
|
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id = 'unknown-status'"
|
||||||
|
|
||||||
|
duplicate_miner="00000000-0000-4000-8000-000000001599"
|
||||||
|
psql_db <<SQL
|
||||||
|
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status, request_payload)
|
||||||
|
VALUES
|
||||||
|
('duplicate-one', 'test-agent', 'miner_control', 'pending', '{"miner_id":"${duplicate_miner}"}'),
|
||||||
|
('duplicate-two', 'test-agent', 'miner_control', 'claimed', '{"miner_id":"${duplicate_miner}"}');
|
||||||
|
SQL
|
||||||
|
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
|
||||||
|
echo "Duplicate live controls unexpectedly passed migration preflight" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ "${output}" == *"more than one pending or claimed miner_control"* ]]
|
||||||
|
[[ "${output}" == *"${duplicate_miner}"* ]]
|
||||||
|
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id IN ('duplicate-one', 'duplicate-two')"
|
||||||
|
|
||||||
|
psql_db <<'SQL'
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_name)
|
||||||
|
VALUES ('legacy-unassigned-agent', 'Legacy Display Name');
|
||||||
|
INSERT INTO minerops_agent_command_queue (
|
||||||
|
id, agent_id, command_type, command_status, request_payload
|
||||||
|
) VALUES (
|
||||||
|
'preserved-history', 'legacy-unassigned-agent', 'miner_control', 'completed',
|
||||||
|
'{"miner_id":"00000000-0000-4000-8000-000000001598","action":"suspend"}'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
run_alembic upgrade 0015_miner_control_foundation
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]]
|
||||||
|
|
||||||
|
echo "Checking assignment, status, uniqueness, history, and preservation..."
|
||||||
|
psql_db <<'SQL'
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT site_id IS NOT NULL FROM minerops_site_agents
|
||||||
|
WHERE agent_id = 'legacy-unassigned-agent') THEN
|
||||||
|
RAISE EXCEPTION 'Legacy site_name was silently converted to site_id';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM minerops_agent_command_queue
|
||||||
|
WHERE id = 'preserved-history' AND command_status = 'completed') THEN
|
||||||
|
RAISE EXCEPTION 'Historical queue row was not preserved';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'ck_minerops_agent_command_queue_status') THEN
|
||||||
|
RAISE EXCEPTION 'Queue status constraint missing';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||||
|
WHERE indexname = 'uq_agent_queue_live_miner_control')
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM pg_indexes
|
||||||
|
WHERE indexname = 'idx_agent_queue_miner_control_history') THEN
|
||||||
|
RAISE EXCEPTION 'Miner control queue indexes missing';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
INSERT INTO sites (id, name)
|
||||||
|
VALUES ('00000000-0000-4000-8000-000000001590', 'Control Migration Site');
|
||||||
|
UPDATE minerops_site_agents SET site_id = '00000000-0000-4000-8000-000000001590'
|
||||||
|
WHERE agent_id = 'legacy-unassigned-agent';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO minerops_site_agents (agent_id, site_id, site_name)
|
||||||
|
VALUES ('duplicate-site-agent', '00000000-0000-4000-8000-000000001590', 'Duplicate');
|
||||||
|
RAISE EXCEPTION 'Duplicate durable site assignment was accepted';
|
||||||
|
EXCEPTION WHEN unique_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM sites WHERE id = '00000000-0000-4000-8000-000000001590';
|
||||||
|
RAISE EXCEPTION 'Assigned site deletion was accepted';
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status)
|
||||||
|
VALUES ('bad-status-after', 'legacy-unassigned-agent', 'test', 'mystery');
|
||||||
|
RAISE EXCEPTION 'Unknown status passed CHECK';
|
||||||
|
EXCEPTION WHEN check_violation THEN NULL;
|
||||||
|
END;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Testing downgrade, historical preservation, and re-upgrade..."
|
||||||
|
run_alembic downgrade 0014_repair_parts_tracking
|
||||||
|
[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_agent_command_queue WHERE id = 'preserved-history'")" == "1" ]]
|
||||||
|
[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.columns WHERE table_name='minerops_site_agents' AND column_name='site_id'")" == "0" ]]
|
||||||
|
run_alembic upgrade 0015_miner_control_foundation
|
||||||
|
|
||||||
|
echo "Testing empty database through head..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
|
||||||
|
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
|
||||||
|
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
|
||||||
|
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
|
||||||
|
|
||||||
|
echo "Miner Control Foundation migration tests passed."
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
project_name="${MINEROPS_CONTROL_TEST_PROJECT:-minerops-control-test-$$}"
|
||||||
|
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status != 0 )); then
|
||||||
|
"${compose[@]}" logs --no-color --tail 120 postgres || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
|
||||||
|
for source_file in backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
|
||||||
|
permissions="$(stat -c '%a' "${source_file}")"
|
||||||
|
(( (10#${permissions} % 10) & 4 )) || {
|
||||||
|
echo "${source_file} is not readable by the non-root test container" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
for source_directory in agents agents/windows agents/windows/tests; do
|
||||||
|
permissions="$(stat -c '%a' "${source_directory}")"
|
||||||
|
(( (10#${permissions} % 10) & 1 )) || {
|
||||||
|
echo "${source_directory} is not traversable by the non-root test container" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
"${compose[@]}" up --detach postgres
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
"${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
|
||||||
|
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
-v "${repo_root}/agents:/agents:ro" \
|
||||||
|
backend python -m unittest -v \
|
||||||
|
tests.test_miner_control \
|
||||||
|
tests.test_agent_commands \
|
||||||
|
tests.test_miner_control_postgres
|
||||||
|
|
||||||
|
echo "Miner Control Foundation focused tests passed."
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
service="agents/windows/MinerOpsAgentService.ps1"
|
||||||
|
control="agents/windows/MinerOpsControl.ps1"
|
||||||
|
installer="agents/windows/install.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
|
||||||
|
|
||||||
|
rg -q 'Get-MinerOpsScheduledTask' "${installer}"
|
||||||
|
rg -q 'Stop-MinerOpsScheduledTaskForUpgrade' "${installer}"
|
||||||
|
rg -q 'mainTaskWasRunning' "${installer}"
|
||||||
|
rg -q 'Preserving existing Scheduled Task' "${installer}"
|
||||||
|
rg -q -- '-Limit 0 -TimeoutMs 8000' "${installer}"
|
||||||
|
rg -q 'Unregister-ScheduledTask' "${installer}"
|
||||||
|
rg -q 'could not restart restored MinerOpsAgent' "${installer}"
|
||||||
|
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
|
||||||
|
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
|
||||||
|
|
||||||
|
if command -v pwsh >/dev/null 2>&1; then
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
|
||||||
|
else
|
||||||
|
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
||||||
|
fi
|
||||||
|
echo "Windows Agent source contract checks passed."
|
||||||
Reference in new issue
Block a user