Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cd7ad058 | ||
|
|
f5965af914 | ||
|
|
6cd876b4b3 | ||
|
|
c3501b987e |
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,
|
||||||
|
)
|
||||||
@@ -5,6 +5,8 @@ class Settings(BaseSettings):
|
|||||||
app_name: str = "MinerOps Console"
|
app_name: str = "MinerOps Console"
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
app_timezone: str = "America/Winnipeg"
|
app_timezone: str = "America/Winnipeg"
|
||||||
|
minerops_build_commit: str | None = None
|
||||||
|
minerops_build_version: str | None = None
|
||||||
|
|
||||||
postgres_db: str = "minerops"
|
postgres_db: str = "minerops"
|
||||||
postgres_user: str = "minerops"
|
postgres_user: str = "minerops"
|
||||||
@@ -14,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();
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
.health-toolbar,
|
||||||
|
.health-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-toolbar h2,
|
||||||
|
.health-toolbar p,
|
||||||
|
.health-section h2,
|
||||||
|
.health-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-toolbar p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted-text, #52606d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-notice {
|
||||||
|
border-left: 4px solid #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-notice[data-state="partial"] {
|
||||||
|
border-left-color: #b45309;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-notice[data-state="error"] {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-section {
|
||||||
|
border-top: 1px solid #cbd5e1;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-columns,
|
||||||
|
.health-integrity-grid,
|
||||||
|
.health-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-columns {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-integrity-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-facts {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-facts-compact {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-facts div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-facts dt {
|
||||||
|
color: #52606d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-facts dd {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 3px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-links a:focus-visible,
|
||||||
|
#healthRefresh:focus-visible {
|
||||||
|
outline: 3px solid #0f766e;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
border-bottom: 1px solid #dbe2e8;
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.health-integrity-grid,
|
||||||
|
.health-facts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.health-toolbar,
|
||||||
|
.health-section-head {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-columns,
|
||||||
|
.health-integrity-grid,
|
||||||
|
.health-facts,
|
||||||
|
.health-facts-compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#healthRefresh {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const notice = document.getElementById("healthNotice");
|
||||||
|
const content = document.getElementById("healthContent");
|
||||||
|
const refresh = document.getElementById("healthRefresh");
|
||||||
|
|
||||||
|
function setText(id, value, fallback) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value === null || value === undefined || value === ""
|
||||||
|
? (fallback || "Unavailable") : String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value) {
|
||||||
|
return typeof value === "number" ? value.toLocaleString() : "Unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value) {
|
||||||
|
if (!value) return "Unavailable";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.valueOf()) ? "Unavailable" : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function factualAge(value) {
|
||||||
|
if (!value) return "Unavailable";
|
||||||
|
const observed = new Date(value);
|
||||||
|
if (Number.isNaN(observed.valueOf())) return "Unavailable";
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - observed.valueOf()) / 1000));
|
||||||
|
if (seconds < 60) return seconds + " seconds ago";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return days + (days === 1 ? " day ago" : " days ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFacts(id, facts) {
|
||||||
|
const list = document.getElementById(id);
|
||||||
|
list.replaceChildren();
|
||||||
|
for (const [label, value] of facts) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const term = document.createElement("dt");
|
||||||
|
const detail = document.createElement("dd");
|
||||||
|
term.textContent = label;
|
||||||
|
detail.textContent = value;
|
||||||
|
row.append(term, detail);
|
||||||
|
list.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(payload) {
|
||||||
|
const data = payload.data;
|
||||||
|
const app = data.application;
|
||||||
|
const postgres = data.postgresql;
|
||||||
|
const migration = postgres.migration;
|
||||||
|
const telemetry = postgres.telemetry;
|
||||||
|
setText("healthAppStatus", app.status);
|
||||||
|
setText("healthEnvironment", app.environment);
|
||||||
|
setText("healthBuildCommit", app.build_commit);
|
||||||
|
setText("healthBuildVersion", app.build_version);
|
||||||
|
setText("healthServerTime", timestamp(app.server_time));
|
||||||
|
setText("healthMigrationState", migration.state);
|
||||||
|
setText("healthCurrentRevision", migration.current_revision);
|
||||||
|
setText("healthExpectedRevision", migration.expected_revision);
|
||||||
|
setText("healthPostgresqlStatus", postgres.status);
|
||||||
|
setText("healthRedisStatus", data.redis.status);
|
||||||
|
setText("healthSnapshotCount", telemetry ? number(telemetry.snapshot_count) : null);
|
||||||
|
setText("healthOldestObservation", telemetry ? timestamp(telemetry.oldest_observed_at) : null);
|
||||||
|
setText("healthLatestObservation", telemetry ? timestamp(telemetry.latest_observed_at) : null);
|
||||||
|
setText("healthObservationAge", telemetry ? factualAge(telemetry.latest_observed_at) : null);
|
||||||
|
|
||||||
|
const fleet = postgres.fleet;
|
||||||
|
replaceFacts("healthFleetFacts", fleet ? [
|
||||||
|
["Total miners", number(fleet.total_miners)],
|
||||||
|
["Without Asset ID", number(fleet.miners_without_asset_id)],
|
||||||
|
["Unmapped", number(fleet.unmapped_miners)],
|
||||||
|
["In multiple positions", number(fleet.miners_in_multiple_positions)],
|
||||||
|
["Duplicate current MAC groups", number(fleet.duplicate_current_mac_groups)]
|
||||||
|
] : [["Diagnostics", "Unavailable"]]);
|
||||||
|
const locations = postgres.locations;
|
||||||
|
replaceFacts("healthLocationFacts", locations ? [
|
||||||
|
["Sites", number(locations.sites)], ["Rooms", number(locations.rooms)],
|
||||||
|
["Positions", number(locations.positions)],
|
||||||
|
["Occupied", number(locations.occupied_positions)],
|
||||||
|
["Empty", number(locations.empty_positions)],
|
||||||
|
["Legacy unscoped", number(locations.unscoped_legacy_positions)],
|
||||||
|
["Expected MAC mismatches", number(locations.expected_mac_mismatches)]
|
||||||
|
] : [["Diagnostics", "Unavailable"]]);
|
||||||
|
const repairs = postgres.repairs;
|
||||||
|
replaceFacts("healthRepairFacts", repairs ? [
|
||||||
|
["Active modular repairs", number(repairs.active_modular_repairs)],
|
||||||
|
["Duplicate active groups", number(repairs.duplicate_active_repair_groups)],
|
||||||
|
["Legacy repair only", number(repairs.legacy_repair_only_miners)],
|
||||||
|
["Lifecycle repair without active repair", number(repairs.lifecycle_repair_without_active_repair)],
|
||||||
|
["Active repair lifecycle mismatches", number(repairs.active_repair_lifecycle_mismatches)],
|
||||||
|
["Waiting placement", number(repairs.waiting_placement_records)],
|
||||||
|
["Repair events", number(repairs.repair_events)],
|
||||||
|
["Repair parts", number(repairs.repair_parts)]
|
||||||
|
] : [["Diagnostics", "Unavailable"]]);
|
||||||
|
const retention = postgres.retention;
|
||||||
|
replaceFacts("healthRetentionFacts", retention ? [
|
||||||
|
["Raw telemetry retention", retention.raw_retention_enabled ? "Enabled" : "Not enabled"],
|
||||||
|
["Hourly rollups", retention.hourly.table_present ? "Present; manual population" : "Unavailable"],
|
||||||
|
["Hourly latest bucket", timestamp(retention.hourly.latest_bucket)],
|
||||||
|
["Daily rollups", retention.daily.table_present ? "Present; manual population" : "Unavailable"],
|
||||||
|
["Daily latest bucket", timestamp(retention.daily.latest_bucket)],
|
||||||
|
["Latest recorded run", timestamp(retention.latest_recorded_run_at)],
|
||||||
|
["Latest run mode", retention.latest_recorded_run_mode || "None recorded"],
|
||||||
|
["Completeness", "Not guaranteed"]
|
||||||
|
] : [["Diagnostics", "Unavailable"]]);
|
||||||
|
|
||||||
|
const tableBody = document.getElementById("healthTableCounts");
|
||||||
|
tableBody.replaceChildren();
|
||||||
|
const rows = postgres.table_counts || [];
|
||||||
|
for (const table of rows) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const name = document.createElement("th");
|
||||||
|
const count = document.createElement("td");
|
||||||
|
name.scope = "row";
|
||||||
|
name.textContent = table.name;
|
||||||
|
count.textContent = number(table.row_count);
|
||||||
|
row.append(name, count);
|
||||||
|
tableBody.append(row);
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.colSpan = 2;
|
||||||
|
cell.textContent = "Table diagnostics unavailable.";
|
||||||
|
row.append(cell);
|
||||||
|
tableBody.append(row);
|
||||||
|
}
|
||||||
|
content.hidden = false;
|
||||||
|
notice.dataset.state = payload.meta.partial ? "partial" : "ready";
|
||||||
|
notice.textContent = payload.meta.partial
|
||||||
|
? "Diagnostics loaded with unavailable sections: " + payload.meta.unavailable_sections.join(", ") + "."
|
||||||
|
: "Diagnostics refreshed at " + timestamp(payload.meta.generated_at) + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
refresh.disabled = true;
|
||||||
|
notice.dataset.state = "loading";
|
||||||
|
notice.textContent = "Loading diagnostics.";
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/system/health-summary", {
|
||||||
|
credentials: "same-origin", headers: {"Accept": "application/json"}
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(function () { return {}; });
|
||||||
|
if (!response.ok) throw new Error("System Health request failed");
|
||||||
|
render(payload);
|
||||||
|
} catch (error) {
|
||||||
|
notice.dataset.state = "error";
|
||||||
|
notice.textContent = "System Health diagnostics could not be loaded.";
|
||||||
|
} finally {
|
||||||
|
refresh.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh.addEventListener("click", load);
|
||||||
|
load();
|
||||||
|
}());
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Semantic content for the Internal Alpha System Health page."""
|
||||||
|
|
||||||
|
|
||||||
|
def system_health_content() -> str:
|
||||||
|
return """
|
||||||
|
<section class="health-toolbar" aria-labelledby="healthOverviewTitle">
|
||||||
|
<div><h2 id="healthOverviewTitle">Operations diagnostics</h2>
|
||||||
|
<p>Availability, data facts, integrity counts, and maintenance state.</p></div>
|
||||||
|
<button class="primary-button" id="healthRefresh" type="button">Refresh</button>
|
||||||
|
</section>
|
||||||
|
<p class="health-notice" id="healthNotice" role="status" aria-live="polite">Loading diagnostics.</p>
|
||||||
|
<div id="healthContent" hidden>
|
||||||
|
<section class="health-section" aria-labelledby="applicationHealthTitle">
|
||||||
|
<h2 id="applicationHealthTitle">Application</h2>
|
||||||
|
<dl class="health-facts">
|
||||||
|
<div><dt>Application</dt><dd id="healthAppStatus">-</dd></div>
|
||||||
|
<div><dt>Environment</dt><dd id="healthEnvironment">-</dd></div>
|
||||||
|
<div><dt>Build commit</dt><dd id="healthBuildCommit">-</dd></div>
|
||||||
|
<div><dt>Build version</dt><dd id="healthBuildVersion">-</dd></div>
|
||||||
|
<div><dt>Server time</dt><dd id="healthServerTime">-</dd></div>
|
||||||
|
<div><dt>Schema</dt><dd id="healthMigrationState">-</dd></div>
|
||||||
|
<div><dt>Database revision</dt><dd id="healthCurrentRevision">-</dd></div>
|
||||||
|
<div><dt>Expected revision</dt><dd id="healthExpectedRevision">-</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<div class="health-columns">
|
||||||
|
<section class="health-section" aria-labelledby="servicesHealthTitle"><h2 id="servicesHealthTitle">Service availability</h2>
|
||||||
|
<dl class="health-facts health-facts-compact"><div><dt>PostgreSQL</dt><dd id="healthPostgresqlStatus">-</dd></div>
|
||||||
|
<div><dt>Redis</dt><dd id="healthRedisStatus">-</dd></div></dl></section>
|
||||||
|
<section class="health-section" aria-labelledby="telemetryHealthTitle"><h2 id="telemetryHealthTitle">Telemetry</h2>
|
||||||
|
<dl class="health-facts health-facts-compact"><div><dt>Raw snapshots</dt><dd id="healthSnapshotCount">-</dd></div>
|
||||||
|
<div><dt>Oldest observation</dt><dd id="healthOldestObservation">-</dd></div>
|
||||||
|
<div><dt>Latest observation</dt><dd id="healthLatestObservation">-</dd></div>
|
||||||
|
<div><dt>Latest age</dt><dd id="healthObservationAge">-</dd></div></dl></section>
|
||||||
|
</div>
|
||||||
|
<section class="health-section" aria-labelledby="integrityHealthTitle">
|
||||||
|
<div class="health-section-head"><h2 id="integrityHealthTitle">Operational integrity facts</h2>
|
||||||
|
<div class="health-links"><a href="/assets/miners">Fleet</a><a href="/monitoring/map">Mine Map</a>
|
||||||
|
<a href="/operations/repairs">Repair Center</a><a href="/monitoring/analytics">Trends</a></div></div>
|
||||||
|
<div class="health-integrity-grid">
|
||||||
|
<section aria-labelledby="fleetIntegrityTitle"><h3 id="fleetIntegrityTitle">Fleet</h3><dl id="healthFleetFacts" class="health-facts health-facts-compact"></dl></section>
|
||||||
|
<section aria-labelledby="locationIntegrityTitle"><h3 id="locationIntegrityTitle">Locations</h3><dl id="healthLocationFacts" class="health-facts health-facts-compact"></dl></section>
|
||||||
|
<section aria-labelledby="repairIntegrityTitle"><h3 id="repairIntegrityTitle">Repairs</h3><dl id="healthRepairFacts" class="health-facts health-facts-compact"></dl></section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="health-columns">
|
||||||
|
<section class="health-section" aria-labelledby="retentionHealthTitle"><h2 id="retentionHealthTitle">Retention and rollups</h2>
|
||||||
|
<dl id="healthRetentionFacts" class="health-facts health-facts-compact"></dl></section>
|
||||||
|
<section class="health-section" aria-labelledby="tablesHealthTitle"><h2 id="tablesHealthTitle">Operational tables</h2>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th scope="col">Table</th><th scope="col">Rows</th></tr></thead>
|
||||||
|
<tbody id="healthTableCounts"></tbody></table></div></section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
@@ -5,6 +5,7 @@ from typing import Callable
|
|||||||
|
|
||||||
from app.frontend.miner_detail_view import miner_detail_content
|
from app.frontend.miner_detail_view import miner_detail_content
|
||||||
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||||
|
from app.frontend.system_health_view import system_health_content
|
||||||
|
|
||||||
|
|
||||||
NAVIGATION = (
|
NAVIGATION = (
|
||||||
@@ -13,6 +14,7 @@ NAVIGATION = (
|
|||||||
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
|
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
|
||||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||||
|
("system_health", "System Health", "/system-health", "system_health.view"),
|
||||||
)
|
)
|
||||||
|
|
||||||
PAGE_DETAILS = {
|
PAGE_DETAILS = {
|
||||||
@@ -46,6 +48,11 @@ PAGE_DETAILS = {
|
|||||||
"Miner Detail",
|
"Miner Detail",
|
||||||
"Current identity, operating facts, location, and authorized history.",
|
"Current identity, operating facts, location, and authorized history.",
|
||||||
),
|
),
|
||||||
|
"system_health": (
|
||||||
|
"Administration / Diagnostics",
|
||||||
|
"System Health",
|
||||||
|
"Inspect application services, operational data, and maintenance facts.",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -455,7 +462,9 @@ def render_app_page(
|
|||||||
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||||
)
|
)
|
||||||
content = (
|
content = (
|
||||||
miner_detail_content()
|
system_health_content()
|
||||||
|
if view == "system_health"
|
||||||
|
else miner_detail_content()
|
||||||
if view == "miner_detail"
|
if view == "miner_detail"
|
||||||
else monitoring_trends_content(has_permission("locations.view"))
|
else monitoring_trends_content(has_permission("locations.view"))
|
||||||
if view == "analytics"
|
if view == "analytics"
|
||||||
@@ -474,7 +483,9 @@ def render_app_page(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
page_script = (
|
page_script = (
|
||||||
'<script src="/assets/minerops-miner-detail.js" defer></script>'
|
'<script src="/assets/minerops-system-health.js" defer></script>'
|
||||||
|
if view == "system_health"
|
||||||
|
else '<script src="/assets/minerops-miner-detail.js" defer></script>'
|
||||||
if view == "miner_detail"
|
if view == "miner_detail"
|
||||||
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
|
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
|
||||||
if view == "analytics"
|
if view == "analytics"
|
||||||
@@ -489,7 +500,9 @@ def render_app_page(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
page_style = (
|
page_style = (
|
||||||
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
|
'<link rel="stylesheet" href="/assets/minerops-system-health.css">'
|
||||||
|
if view == "system_health"
|
||||||
|
else '<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
|
||||||
if view == "analytics" else ""
|
if view == "analytics" else ""
|
||||||
)
|
)
|
||||||
object_attributes = ""
|
object_attributes = ""
|
||||||
@@ -508,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
|
||||||
|
|||||||
+24
-903
File diff suppressed because it is too large.
Load diff
@@ -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 @@
|
|||||||
|
"""System Health diagnostics domain."""
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""External service adapters for System Health."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
RedisFactory = Callable[[], redis.Redis]
|
||||||
|
|
||||||
|
|
||||||
|
def default_redis_factory() -> redis.Redis:
|
||||||
|
return redis.Redis.from_url(
|
||||||
|
settings.redis_url,
|
||||||
|
socket_connect_timeout=2,
|
||||||
|
socket_timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisHealthAdapter:
|
||||||
|
def __init__(self, factory: RedisFactory | None = None):
|
||||||
|
self._factory = factory or default_redis_factory
|
||||||
|
|
||||||
|
def ping(self) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(self._factory().ping())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Startup-cached, non-secret application metadata."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_build_value(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
cleaned = value.strip()
|
||||||
|
return cleaned[:128] if cleaned else None
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_migration_head() -> str | None:
|
||||||
|
try:
|
||||||
|
backend_root = Path(__file__).resolve().parents[2]
|
||||||
|
config = Config(str(backend_root / "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", str(backend_root / "alembic"))
|
||||||
|
return ScriptDirectory.from_config(config).get_current_head()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ApplicationMetadata:
|
||||||
|
environment: str
|
||||||
|
build_commit: str | None
|
||||||
|
build_version: str | None
|
||||||
|
expected_migration_head: str | None
|
||||||
|
|
||||||
|
|
||||||
|
APPLICATION_METADATA = ApplicationMetadata(
|
||||||
|
environment=settings.app_env,
|
||||||
|
build_commit=_clean_build_value(settings.minerops_build_commit),
|
||||||
|
build_version=_clean_build_value(settings.minerops_build_version),
|
||||||
|
expected_migration_head=_expected_migration_head(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Dependency construction for System Health."""
|
||||||
|
|
||||||
|
from app.system_health.adapters import RedisHealthAdapter
|
||||||
|
from app.system_health.repository import SystemHealthRepository
|
||||||
|
from app.system_health.service import SystemHealthService
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_health_service() -> SystemHealthService:
|
||||||
|
return SystemHealthService(SystemHealthRepository(), RedisHealthAdapter())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Authenticated frontend routes for System Health."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
|
from app.frontend.views import render_app_page
|
||||||
|
from app.identity import require_permission, user_has_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["frontend"])
|
||||||
|
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
|
||||||
|
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/minerops-system-health.css", include_in_schema=False)
|
||||||
|
def system_health_styles() -> Response:
|
||||||
|
return Response((STATIC_ROOT / "system-health.css").read_text(encoding="utf-8"),
|
||||||
|
media_type="text/css", headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/minerops-system-health.js", include_in_schema=False)
|
||||||
|
def system_health_script() -> Response:
|
||||||
|
return Response((STATIC_ROOT / "system-health.js").read_text(encoding="utf-8"),
|
||||||
|
media_type="text/javascript", headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system-health", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
def system_health_page(current_user: SystemHealthUser) -> str:
|
||||||
|
return render_app_page(
|
||||||
|
"system_health", current_user,
|
||||||
|
lambda permission: user_has_permission(current_user, permission),
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Typed contracts for the System Health overview."""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
Availability = Literal["available", "unavailable"]
|
||||||
|
MigrationState = Literal["current", "mismatch", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationDiagnostics(BaseModel):
|
||||||
|
status: Literal["available"] = "available"
|
||||||
|
environment: str
|
||||||
|
build_commit: str | None = None
|
||||||
|
build_version: str | None = None
|
||||||
|
server_time: datetime
|
||||||
|
expected_migration_head: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationDiagnostics(BaseModel):
|
||||||
|
state: MigrationState
|
||||||
|
current_revision: str | None = None
|
||||||
|
expected_revision: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TableCount(BaseModel):
|
||||||
|
name: str
|
||||||
|
row_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TableSize(BaseModel):
|
||||||
|
name: str
|
||||||
|
total_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryDiagnostics(BaseModel):
|
||||||
|
snapshot_count: int
|
||||||
|
oldest_observed_at: datetime | None = None
|
||||||
|
latest_observed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FleetIntegrityDiagnostics(BaseModel):
|
||||||
|
total_miners: int
|
||||||
|
miners_without_asset_id: int
|
||||||
|
unmapped_miners: int
|
||||||
|
miners_in_multiple_positions: int
|
||||||
|
duplicate_current_mac_groups: int
|
||||||
|
|
||||||
|
|
||||||
|
class LocationIntegrityDiagnostics(BaseModel):
|
||||||
|
sites: int
|
||||||
|
rooms: int
|
||||||
|
positions: int
|
||||||
|
occupied_positions: int
|
||||||
|
empty_positions: int
|
||||||
|
unscoped_legacy_positions: int
|
||||||
|
expected_mac_mismatches: int
|
||||||
|
|
||||||
|
|
||||||
|
class RepairIntegrityDiagnostics(BaseModel):
|
||||||
|
active_modular_repairs: int
|
||||||
|
duplicate_active_repair_groups: int
|
||||||
|
legacy_repair_only_miners: int
|
||||||
|
lifecycle_repair_without_active_repair: int
|
||||||
|
active_repair_lifecycle_mismatches: int
|
||||||
|
waiting_placement_records: int
|
||||||
|
repair_events: int
|
||||||
|
repair_parts: int
|
||||||
|
|
||||||
|
|
||||||
|
class RollupDiagnostics(BaseModel):
|
||||||
|
table_present: bool
|
||||||
|
row_count: int | None = None
|
||||||
|
latest_bucket: datetime | date | None = None
|
||||||
|
population: Literal["manual"] = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionDiagnostics(BaseModel):
|
||||||
|
raw_retention_enabled: Literal[False] = False
|
||||||
|
raw_retention_mode: Literal["not_enabled"] = "not_enabled"
|
||||||
|
hourly: RollupDiagnostics
|
||||||
|
daily: RollupDiagnostics
|
||||||
|
latest_recorded_run_at: datetime | None = None
|
||||||
|
latest_recorded_run_mode: str | None = None
|
||||||
|
completeness: Literal["not_guaranteed"] = "not_guaranteed"
|
||||||
|
|
||||||
|
|
||||||
|
class PostgreSQLDiagnostics(BaseModel):
|
||||||
|
status: Availability
|
||||||
|
migration: MigrationDiagnostics
|
||||||
|
table_counts: list[TableCount] = Field(default_factory=list)
|
||||||
|
largest_tables: list[TableSize] = Field(default_factory=list)
|
||||||
|
telemetry: TelemetryDiagnostics | None = None
|
||||||
|
fleet: FleetIntegrityDiagnostics | None = None
|
||||||
|
locations: LocationIntegrityDiagnostics | None = None
|
||||||
|
repairs: RepairIntegrityDiagnostics | None = None
|
||||||
|
retention: RetentionDiagnostics | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RedisDiagnostics(BaseModel):
|
||||||
|
status: Availability
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthOverviewData(BaseModel):
|
||||||
|
application: ApplicationDiagnostics
|
||||||
|
postgresql: PostgreSQLDiagnostics
|
||||||
|
redis: RedisDiagnostics
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthOverviewMeta(BaseModel):
|
||||||
|
generated_at: datetime
|
||||||
|
partial: bool
|
||||||
|
unavailable_sections: list[str] = Field(default_factory=list)
|
||||||
|
frontend_request_count: Literal[1] = 1
|
||||||
|
postgresql_statement_count: Literal[3] = 3
|
||||||
|
redis_call_count: Literal[1] = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthOverviewResponse(BaseModel):
|
||||||
|
data: SystemHealthOverviewData
|
||||||
|
meta: SystemHealthOverviewMeta
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""PostgreSQL diagnostics for System Health."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
|
||||||
|
def default_connection_factory() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
connect_timeout=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION_SQL = """
|
||||||
|
SELECT clock_timestamp() AS database_time,
|
||||||
|
(SELECT version_num FROM alembic_version LIMIT 1) AS current_revision
|
||||||
|
"""
|
||||||
|
|
||||||
|
OPERATIONS_SQL = """
|
||||||
|
WITH telemetry AS (
|
||||||
|
SELECT count(*)::bigint AS snapshot_count,
|
||||||
|
min(observed_at) AS oldest_observed_at,
|
||||||
|
max(observed_at) AS latest_observed_at
|
||||||
|
FROM miner_status_snapshots
|
||||||
|
),
|
||||||
|
hourly AS (
|
||||||
|
SELECT count(*)::bigint AS row_count, max(bucket_at) AS latest_bucket
|
||||||
|
FROM miner_status_hourly_rollups
|
||||||
|
),
|
||||||
|
daily AS (
|
||||||
|
SELECT count(*)::bigint AS row_count, max(bucket_date) AS latest_bucket
|
||||||
|
FROM miner_status_daily_rollups
|
||||||
|
),
|
||||||
|
latest_retention AS (
|
||||||
|
SELECT mode, started_at FROM minerops_retention_runs
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
),
|
||||||
|
largest AS (
|
||||||
|
SELECT coalesce(jsonb_agg(to_jsonb(sized) ORDER BY sized.total_bytes DESC), '[]'::jsonb) AS rows
|
||||||
|
FROM (
|
||||||
|
SELECT relname AS name, pg_total_relation_size(relid)::bigint AS total_bytes
|
||||||
|
FROM pg_catalog.pg_statio_user_tables
|
||||||
|
WHERE relname = ANY(%s)
|
||||||
|
ORDER BY pg_total_relation_size(relid) DESC, relname
|
||||||
|
LIMIT 8
|
||||||
|
) AS sized
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM miners)::bigint AS miners,
|
||||||
|
(SELECT count(*) FROM sites)::bigint AS sites,
|
||||||
|
(SELECT count(*) FROM rooms)::bigint AS rooms,
|
||||||
|
(SELECT count(*) FROM location_positions)::bigint AS location_positions,
|
||||||
|
(SELECT count(*) FROM repairs)::bigint AS repairs,
|
||||||
|
(SELECT count(*) FROM repair_events)::bigint AS repair_events,
|
||||||
|
(SELECT count(*) FROM repair_parts)::bigint AS repair_parts,
|
||||||
|
telemetry.snapshot_count, telemetry.oldest_observed_at,
|
||||||
|
telemetry.latest_observed_at,
|
||||||
|
to_regclass('public.miner_status_hourly_rollups') IS NOT NULL AS hourly_present,
|
||||||
|
hourly.row_count AS hourly_rows, hourly.latest_bucket AS hourly_latest,
|
||||||
|
to_regclass('public.miner_status_daily_rollups') IS NOT NULL AS daily_present,
|
||||||
|
daily.row_count AS daily_rows, daily.latest_bucket AS daily_latest,
|
||||||
|
latest_retention.mode AS retention_mode,
|
||||||
|
latest_retention.started_at AS retention_started_at,
|
||||||
|
largest.rows AS largest_tables
|
||||||
|
FROM telemetry CROSS JOIN hourly CROSS JOIN daily CROSS JOIN largest
|
||||||
|
LEFT JOIN latest_retention ON true
|
||||||
|
"""
|
||||||
|
|
||||||
|
INTEGRITY_SQL = """
|
||||||
|
WITH position_counts AS (
|
||||||
|
SELECT miner_id, count(*) AS position_count
|
||||||
|
FROM location_positions WHERE miner_id IS NOT NULL GROUP BY miner_id
|
||||||
|
),
|
||||||
|
active_repairs AS (
|
||||||
|
SELECT id, miner_id FROM repairs
|
||||||
|
WHERE status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
|
||||||
|
),
|
||||||
|
active_repair_groups AS (
|
||||||
|
SELECT miner_id FROM active_repairs GROUP BY miner_id HAVING count(*) > 1
|
||||||
|
),
|
||||||
|
normalized_macs AS (
|
||||||
|
SELECT lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g')) AS mac
|
||||||
|
FROM miners WHERE current_mac IS NOT NULL AND trim(current_mac) <> ''
|
||||||
|
GROUP BY lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||||
|
HAVING count(*) > 1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM miners WHERE asset_tag IS NULL OR trim(asset_tag) = '')::bigint
|
||||||
|
AS miners_without_asset_id,
|
||||||
|
(SELECT count(*) FROM miners m WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM location_positions p WHERE p.miner_id = m.id
|
||||||
|
))::bigint AS unmapped_miners,
|
||||||
|
(SELECT count(*) FROM position_counts WHERE position_count > 1)::bigint
|
||||||
|
AS miners_in_multiple_positions,
|
||||||
|
(SELECT count(*) FROM normalized_macs)::bigint AS duplicate_current_mac_groups,
|
||||||
|
(SELECT count(*) FROM location_positions WHERE room_id IS NULL)::bigint
|
||||||
|
AS unscoped_legacy_positions,
|
||||||
|
(SELECT count(*) FROM location_positions p JOIN miners m ON m.id = p.miner_id
|
||||||
|
WHERE p.expected_mac IS NOT NULL AND trim(p.expected_mac) <> ''
|
||||||
|
AND m.current_mac IS NOT NULL AND trim(m.current_mac) <> ''
|
||||||
|
AND lower(regexp_replace(trim(p.expected_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||||
|
<> lower(regexp_replace(trim(m.current_mac), '[^0-9A-Fa-f]', '', 'g'))
|
||||||
|
)::bigint AS expected_mac_mismatches,
|
||||||
|
(SELECT count(*) FROM location_positions WHERE miner_id IS NOT NULL)::bigint
|
||||||
|
AS occupied_positions,
|
||||||
|
(SELECT count(*) FROM location_positions WHERE miner_id IS NULL)::bigint
|
||||||
|
AS empty_positions,
|
||||||
|
(SELECT count(*) FROM active_repairs)::bigint AS active_modular_repairs,
|
||||||
|
(SELECT count(*) FROM active_repair_groups)::bigint AS duplicate_active_repair_groups,
|
||||||
|
(SELECT count(*) FROM miner_location_states state
|
||||||
|
WHERE state.asset_state = 'repair' AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM active_repairs repair WHERE repair.miner_id = state.miner_id
|
||||||
|
))::bigint AS legacy_repair_only_miners,
|
||||||
|
(SELECT count(*) FROM miners m WHERE m.lifecycle_status = 'in_repair'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM active_repairs r WHERE r.miner_id = m.id))::bigint
|
||||||
|
AS lifecycle_repair_without_active_repair,
|
||||||
|
(SELECT count(*) FROM active_repairs r JOIN miners m ON m.id = r.miner_id
|
||||||
|
WHERE m.lifecycle_status <> 'in_repair')::bigint
|
||||||
|
AS active_repair_lifecycle_mismatches,
|
||||||
|
(SELECT count(*) FROM miner_location_states WHERE asset_state = 'waiting_placement')::bigint
|
||||||
|
AS waiting_placement_records
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
RELEVANT_TABLES = [
|
||||||
|
"miner_status_snapshots", "repair_events", "repair_parts", "repairs",
|
||||||
|
"location_positions", "miners", "miner_status_hourly_rollups",
|
||||||
|
"miner_status_daily_rollups",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
connection_factory: ConnectionFactory | None = None,
|
||||||
|
*,
|
||||||
|
connection: psycopg.Connection | None = None,
|
||||||
|
):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
self._connection = connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||||
|
if self._connection is not None:
|
||||||
|
yield self._connection
|
||||||
|
return
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def read_overview(self) -> dict[str, Any]:
|
||||||
|
with self._connection_scope() as connection:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(MIGRATION_SQL)
|
||||||
|
migration = dict(cur.fetchone())
|
||||||
|
cur.execute(OPERATIONS_SQL, (RELEVANT_TABLES,))
|
||||||
|
operations = dict(cur.fetchone())
|
||||||
|
cur.execute(INTEGRITY_SQL)
|
||||||
|
integrity = dict(cur.fetchone())
|
||||||
|
return {
|
||||||
|
"migration": migration,
|
||||||
|
"operations": operations,
|
||||||
|
"integrity": integrity,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""HTTP boundary for the System Health overview."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
|
||||||
|
from app.identity import require_permission
|
||||||
|
from app.system_health.dependencies import get_system_health_service
|
||||||
|
from app.system_health.models import SystemHealthOverviewResponse
|
||||||
|
from app.system_health.service import SystemHealthService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/system", tags=["system-health"])
|
||||||
|
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
|
||||||
|
SystemHealthServiceDependency = Annotated[
|
||||||
|
SystemHealthService, Depends(get_system_health_service)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health-summary", response_model=SystemHealthOverviewResponse)
|
||||||
|
def get_system_health_overview(
|
||||||
|
current_user: SystemHealthUser,
|
||||||
|
service: SystemHealthServiceDependency,
|
||||||
|
response: Response,
|
||||||
|
) -> SystemHealthOverviewResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return service.get_overview()
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Composition and failure semantics for System Health diagnostics."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.system_health.adapters import RedisHealthAdapter
|
||||||
|
from app.system_health.application import APPLICATION_METADATA, ApplicationMetadata
|
||||||
|
from app.system_health.models import (
|
||||||
|
ApplicationDiagnostics, FleetIntegrityDiagnostics, LocationIntegrityDiagnostics,
|
||||||
|
MigrationDiagnostics, PostgreSQLDiagnostics, RedisDiagnostics,
|
||||||
|
RepairIntegrityDiagnostics, RetentionDiagnostics, RollupDiagnostics,
|
||||||
|
SystemHealthOverviewData, SystemHealthOverviewMeta,
|
||||||
|
SystemHealthOverviewResponse, TableCount, TableSize, TelemetryDiagnostics,
|
||||||
|
)
|
||||||
|
from app.system_health.repository import SystemHealthRepository
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthService:
|
||||||
|
def __init__(self, repository: SystemHealthRepository,
|
||||||
|
redis_adapter: RedisHealthAdapter,
|
||||||
|
metadata: ApplicationMetadata = APPLICATION_METADATA):
|
||||||
|
self.repository = repository
|
||||||
|
self.redis_adapter = redis_adapter
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
def get_overview(self) -> SystemHealthOverviewResponse:
|
||||||
|
generated_at = datetime.now(timezone.utc)
|
||||||
|
unavailable = []
|
||||||
|
try:
|
||||||
|
postgresql = self._postgresql_diagnostics(
|
||||||
|
self.repository.read_overview()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
unavailable.append("postgresql")
|
||||||
|
postgresql = PostgreSQLDiagnostics(
|
||||||
|
status="unavailable",
|
||||||
|
migration=MigrationDiagnostics(
|
||||||
|
state="unavailable",
|
||||||
|
expected_revision=self.metadata.expected_migration_head,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
redis_available = self.redis_adapter.ping()
|
||||||
|
if not redis_available:
|
||||||
|
unavailable.append("redis")
|
||||||
|
return SystemHealthOverviewResponse(
|
||||||
|
data=SystemHealthOverviewData(
|
||||||
|
application=ApplicationDiagnostics(
|
||||||
|
environment=self.metadata.environment,
|
||||||
|
build_commit=self.metadata.build_commit,
|
||||||
|
build_version=self.metadata.build_version,
|
||||||
|
server_time=generated_at,
|
||||||
|
expected_migration_head=self.metadata.expected_migration_head,
|
||||||
|
),
|
||||||
|
postgresql=postgresql,
|
||||||
|
redis=RedisDiagnostics(
|
||||||
|
status="available" if redis_available else "unavailable"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
meta=SystemHealthOverviewMeta(
|
||||||
|
generated_at=generated_at,
|
||||||
|
partial=bool(unavailable),
|
||||||
|
unavailable_sections=unavailable,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _postgresql_diagnostics(self, payload: dict) -> PostgreSQLDiagnostics:
|
||||||
|
migration = payload["migration"]
|
||||||
|
operations = payload["operations"]
|
||||||
|
integrity = payload["integrity"]
|
||||||
|
current = migration.get("current_revision")
|
||||||
|
expected = self.metadata.expected_migration_head
|
||||||
|
state = (
|
||||||
|
"unavailable" if not current or not expected
|
||||||
|
else "current" if current == expected else "mismatch"
|
||||||
|
)
|
||||||
|
counts = [
|
||||||
|
TableCount(name=name, row_count=int(operations[name]))
|
||||||
|
for name in (
|
||||||
|
"miners", "sites", "rooms", "location_positions", "repairs",
|
||||||
|
"repair_events", "repair_parts", "snapshot_count",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return PostgreSQLDiagnostics(
|
||||||
|
status="available",
|
||||||
|
migration=MigrationDiagnostics(
|
||||||
|
state=state, current_revision=current, expected_revision=expected
|
||||||
|
),
|
||||||
|
table_counts=counts,
|
||||||
|
largest_tables=[
|
||||||
|
TableSize(name=row["name"], total_bytes=int(row["total_bytes"]))
|
||||||
|
for row in operations.get("largest_tables") or []
|
||||||
|
],
|
||||||
|
telemetry=TelemetryDiagnostics(
|
||||||
|
snapshot_count=int(operations["snapshot_count"]),
|
||||||
|
oldest_observed_at=operations.get("oldest_observed_at"),
|
||||||
|
latest_observed_at=operations.get("latest_observed_at"),
|
||||||
|
),
|
||||||
|
fleet=FleetIntegrityDiagnostics(
|
||||||
|
total_miners=int(operations["miners"]),
|
||||||
|
miners_without_asset_id=int(integrity["miners_without_asset_id"]),
|
||||||
|
unmapped_miners=int(integrity["unmapped_miners"]),
|
||||||
|
miners_in_multiple_positions=int(integrity["miners_in_multiple_positions"]),
|
||||||
|
duplicate_current_mac_groups=int(integrity["duplicate_current_mac_groups"]),
|
||||||
|
),
|
||||||
|
locations=LocationIntegrityDiagnostics(
|
||||||
|
sites=int(operations["sites"]), rooms=int(operations["rooms"]),
|
||||||
|
positions=int(operations["location_positions"]),
|
||||||
|
occupied_positions=int(integrity["occupied_positions"]),
|
||||||
|
empty_positions=int(integrity["empty_positions"]),
|
||||||
|
unscoped_legacy_positions=int(integrity["unscoped_legacy_positions"]),
|
||||||
|
expected_mac_mismatches=int(integrity["expected_mac_mismatches"]),
|
||||||
|
),
|
||||||
|
repairs=RepairIntegrityDiagnostics(
|
||||||
|
active_modular_repairs=int(integrity["active_modular_repairs"]),
|
||||||
|
duplicate_active_repair_groups=int(integrity["duplicate_active_repair_groups"]),
|
||||||
|
legacy_repair_only_miners=int(integrity["legacy_repair_only_miners"]),
|
||||||
|
lifecycle_repair_without_active_repair=int(integrity["lifecycle_repair_without_active_repair"]),
|
||||||
|
active_repair_lifecycle_mismatches=int(integrity["active_repair_lifecycle_mismatches"]),
|
||||||
|
waiting_placement_records=int(integrity["waiting_placement_records"]),
|
||||||
|
repair_events=int(operations["repair_events"]),
|
||||||
|
repair_parts=int(operations["repair_parts"]),
|
||||||
|
),
|
||||||
|
retention=RetentionDiagnostics(
|
||||||
|
hourly=RollupDiagnostics(
|
||||||
|
table_present=bool(operations["hourly_present"]),
|
||||||
|
row_count=int(operations["hourly_rows"]),
|
||||||
|
latest_bucket=operations.get("hourly_latest"),
|
||||||
|
),
|
||||||
|
daily=RollupDiagnostics(
|
||||||
|
table_present=bool(operations["daily_present"]),
|
||||||
|
row_count=int(operations["daily_rows"]),
|
||||||
|
latest_bucket=operations.get("daily_latest"),
|
||||||
|
),
|
||||||
|
latest_recorded_run_at=operations.get("retention_started_at"),
|
||||||
|
latest_recorded_run_mode=operations.get("retention_mode"),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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"):
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.identity import get_current_user
|
||||||
|
from app.system_health.application import ApplicationMetadata
|
||||||
|
from app.system_health.dependencies import get_system_health_service
|
||||||
|
from app.system_health.frontend import router as frontend_router
|
||||||
|
from app.system_health.repository import SystemHealthRepository
|
||||||
|
from app.system_health.router import router as api_router
|
||||||
|
from app.system_health.service import SystemHealthService
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).parents[1] / "app"
|
||||||
|
EXPECTED_HEAD = "0015_miner_control_foundation"
|
||||||
|
|
||||||
|
|
||||||
|
def database_payload(revision=EXPECTED_HEAD):
|
||||||
|
return {
|
||||||
|
"migration": {"current_revision": revision},
|
||||||
|
"operations": {
|
||||||
|
"miners": 4, "sites": 1, "rooms": 2, "location_positions": 3,
|
||||||
|
"repairs": 1, "repair_events": 2, "repair_parts": 1,
|
||||||
|
"snapshot_count": 5,
|
||||||
|
"oldest_observed_at": datetime(2026, 8, 18, tzinfo=timezone.utc),
|
||||||
|
"latest_observed_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
|
||||||
|
"hourly_present": True, "hourly_rows": 2,
|
||||||
|
"hourly_latest": datetime(2026, 8, 19, tzinfo=timezone.utc),
|
||||||
|
"daily_present": True, "daily_rows": 1,
|
||||||
|
"daily_latest": datetime(2026, 8, 19, tzinfo=timezone.utc).date(),
|
||||||
|
"retention_mode": "dry_run",
|
||||||
|
"retention_started_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
|
||||||
|
"largest_tables": [{"name": "miners", "total_bytes": 8192}],
|
||||||
|
},
|
||||||
|
"integrity": {
|
||||||
|
"miners_without_asset_id": 1, "unmapped_miners": 1,
|
||||||
|
"miners_in_multiple_positions": 0, "duplicate_current_mac_groups": 0,
|
||||||
|
"unscoped_legacy_positions": 1, "expected_mac_mismatches": 1,
|
||||||
|
"occupied_positions": 2, "empty_positions": 1,
|
||||||
|
"active_modular_repairs": 1, "duplicate_active_repair_groups": 0,
|
||||||
|
"legacy_repair_only_miners": 0,
|
||||||
|
"lifecycle_repair_without_active_repair": 0,
|
||||||
|
"active_repair_lifecycle_mismatches": 0,
|
||||||
|
"waiting_placement_records": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
def __init__(self, payload=None, error=None):
|
||||||
|
self.payload = payload or database_payload()
|
||||||
|
self.error = error
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def read_overview(self):
|
||||||
|
self.calls += 1
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
def __init__(self, available=True):
|
||||||
|
self.available = available
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
self.calls += 1
|
||||||
|
return self.available
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(head=EXPECTED_HEAD):
|
||||||
|
return ApplicationMetadata(
|
||||||
|
environment="test", build_commit=None, build_version="0.1-test",
|
||||||
|
expected_migration_head=head,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthServiceTests(unittest.TestCase):
|
||||||
|
def test_builds_typed_factual_overview_without_health_score(self):
|
||||||
|
repository, redis = FakeRepository(), FakeRedis()
|
||||||
|
result = SystemHealthService(repository, redis, metadata()).get_overview()
|
||||||
|
self.assertEqual(result.data.postgresql.migration.state, "current")
|
||||||
|
self.assertEqual(result.data.postgresql.telemetry.snapshot_count, 5)
|
||||||
|
self.assertEqual(result.data.postgresql.fleet.unmapped_miners, 1)
|
||||||
|
self.assertFalse(result.data.postgresql.retention.raw_retention_enabled)
|
||||||
|
self.assertEqual(result.data.postgresql.retention.hourly.population, "manual")
|
||||||
|
self.assertEqual(result.data.postgresql.retention.completeness, "not_guaranteed")
|
||||||
|
self.assertIsNone(result.data.application.build_commit)
|
||||||
|
self.assertFalse(result.meta.partial)
|
||||||
|
self.assertEqual((repository.calls, redis.calls), (1, 1))
|
||||||
|
self.assertNotIn("score", result.model_dump_json().lower())
|
||||||
|
|
||||||
|
def test_migration_mismatch_is_factual(self):
|
||||||
|
result = SystemHealthService(
|
||||||
|
FakeRepository(database_payload("0013_control_board_replacement")),
|
||||||
|
FakeRedis(), metadata(),
|
||||||
|
).get_overview()
|
||||||
|
self.assertEqual(result.data.postgresql.migration.state, "mismatch")
|
||||||
|
self.assertEqual(
|
||||||
|
result.data.postgresql.migration.current_revision,
|
||||||
|
"0013_control_board_replacement",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_postgresql_failure_is_sanitized_and_redis_remains_available(self):
|
||||||
|
secret = "postgresql://user:password@private-host/minerops"
|
||||||
|
result = SystemHealthService(
|
||||||
|
FakeRepository(error=RuntimeError(secret)), FakeRedis(), metadata()
|
||||||
|
).get_overview()
|
||||||
|
self.assertTrue(result.meta.partial)
|
||||||
|
self.assertEqual(result.meta.unavailable_sections, ["postgresql"])
|
||||||
|
self.assertEqual(result.data.postgresql.status, "unavailable")
|
||||||
|
self.assertEqual(result.data.redis.status, "available")
|
||||||
|
self.assertNotIn(secret, result.model_dump_json())
|
||||||
|
|
||||||
|
def test_redis_failure_preserves_database_diagnostics(self):
|
||||||
|
result = SystemHealthService(
|
||||||
|
FakeRepository(), FakeRedis(False), metadata()
|
||||||
|
).get_overview()
|
||||||
|
self.assertEqual(result.data.postgresql.status, "available")
|
||||||
|
self.assertEqual(result.data.redis.status, "unavailable")
|
||||||
|
self.assertEqual(result.meta.unavailable_sections, ["redis"])
|
||||||
|
|
||||||
|
|
||||||
|
class StubService:
|
||||||
|
def get_overview(self):
|
||||||
|
return SystemHealthService(
|
||||||
|
FakeRepository(), FakeRedis(), metadata()
|
||||||
|
).get_overview()
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthRouterTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(api_router)
|
||||||
|
self.app.dependency_overrides[get_system_health_service] = StubService
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_requires_authentication_and_system_health_permission(self):
|
||||||
|
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 401)
|
||||||
|
self.authenticate(("dashboard.view",))
|
||||||
|
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 403)
|
||||||
|
|
||||||
|
def test_returns_typed_no_store_envelope(self):
|
||||||
|
self.authenticate(("system_health.view",))
|
||||||
|
response = self.client.get("/api/system/health-summary")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["data"]["postgresql"]["status"], "available")
|
||||||
|
self.assertEqual(payload["meta"]["postgresql_statement_count"], 3)
|
||||||
|
self.assertIsNone(payload["error"])
|
||||||
|
|
||||||
|
|
||||||
|
class CountingCursor(psycopg.Cursor):
|
||||||
|
statements = 0
|
||||||
|
|
||||||
|
def execute(self, query, params=None, *, prepare=None, binary=None):
|
||||||
|
type(self).statements += 1
|
||||||
|
return super().execute(
|
||||||
|
query, params, prepare=prepare, binary=binary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthRepositoryIntegrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.connection = psycopg.connect(
|
||||||
|
host=settings.postgres_host, port=settings.postgres_port,
|
||||||
|
dbname=settings.postgres_db, user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password, cursor_factory=CountingCursor,
|
||||||
|
)
|
||||||
|
CountingCursor.statements = 0
|
||||||
|
self.repository = SystemHealthRepository(connection=self.connection)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.connection.rollback()
|
||||||
|
self.connection.close()
|
||||||
|
|
||||||
|
def test_real_postgresql_overview_uses_exactly_three_statements(self):
|
||||||
|
site_id, room_id, mapped_id, unmapped_id = (
|
||||||
|
uuid4(), uuid4(), uuid4(), uuid4()
|
||||||
|
)
|
||||||
|
suffix = uuid4().hex[:10]
|
||||||
|
with self.connection.cursor() as cur:
|
||||||
|
cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)",
|
||||||
|
(site_id, "Health Site " + suffix))
|
||||||
|
cur.execute("INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
|
||||||
|
(room_id, site_id, "Health Room " + suffix))
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO miners (id, asset_tag, lifecycle_status, current_mac) "
|
||||||
|
"VALUES (%s, %s, 'active', %s), (%s, NULL, 'active', NULL)",
|
||||||
|
(mapped_id, "HEALTH-" + suffix, "02:00:00:00:00:01", unmapped_id),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO location_positions "
|
||||||
|
"(room_id, room_name, rack_name, shelf_name, position_index, "
|
||||||
|
"miner_id, expected_mac) VALUES (%s, %s, %s, '1', 1, %s, %s), "
|
||||||
|
"(NULL, %s, %s, '1', 2, NULL, NULL)",
|
||||||
|
(room_id, "Health Room " + suffix, "Rack " + suffix, mapped_id,
|
||||||
|
"02:00:00:00:00:02", "Legacy " + suffix, "Rack " + suffix),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO miner_status_snapshots (miner_id, observed_at, mhs_av) "
|
||||||
|
"VALUES (%s, %s, 0)",
|
||||||
|
(mapped_id, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)),
|
||||||
|
)
|
||||||
|
CountingCursor.statements = 0
|
||||||
|
result = self.repository.read_overview()
|
||||||
|
self.assertEqual(CountingCursor.statements, 3)
|
||||||
|
self.assertGreaterEqual(result["operations"]["miners"], 2)
|
||||||
|
self.assertGreaterEqual(result["operations"]["snapshot_count"], 1)
|
||||||
|
self.assertEqual(result["migration"]["current_revision"], EXPECTED_HEAD)
|
||||||
|
self.assertGreaterEqual(result["integrity"]["unmapped_miners"], 1)
|
||||||
|
self.assertGreaterEqual(result["integrity"]["unscoped_legacy_positions"], 1)
|
||||||
|
self.assertGreaterEqual(result["integrity"]["expected_mac_mismatches"], 1)
|
||||||
|
self.assertTrue(result["operations"]["hourly_present"])
|
||||||
|
self.assertTrue(result["operations"]["daily_present"])
|
||||||
|
|
||||||
|
def test_existing_role_matrix_preserves_read_only_grant(self):
|
||||||
|
with self.connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT roles.name FROM role_permissions "
|
||||||
|
"JOIN roles ON roles.id = role_permissions.role_id "
|
||||||
|
"JOIN permissions ON permissions.id = role_permissions.permission_id "
|
||||||
|
"WHERE permissions.key = 'system_health.view' ORDER BY roles.name"
|
||||||
|
)
|
||||||
|
roles = [row[0] for row in cur.fetchall()]
|
||||||
|
self.assertEqual(roles, ["Admin", "Manager", "Read Only"])
|
||||||
|
|
||||||
|
|
||||||
|
class SystemHealthFrontendAndArchitectureTests(unittest.TestCase):
|
||||||
|
def test_page_is_semantic_safe_and_one_request(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(frontend_router)
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": str(uuid4()), "display_name": "Health <Operator>",
|
||||||
|
"role": "Manager", "permissions": ["system_health.view"],
|
||||||
|
}
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/system-health")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("System Health | MinerOps", response.text)
|
||||||
|
self.assertNotIn("Health <Operator>", response.text)
|
||||||
|
self.assertIn("aria-live=\"polite\"", response.text)
|
||||||
|
client.close()
|
||||||
|
script = (APP_ROOT / "frontend/static/system-health.js").read_text()
|
||||||
|
self.assertEqual(script.count('fetch("/api/system/health-summary"'), 1)
|
||||||
|
self.assertNotIn("setInterval", script)
|
||||||
|
self.assertNotIn("innerHTML", script)
|
||||||
|
self.assertIn("textContent", script)
|
||||||
|
|
||||||
|
def test_routes_are_unique_and_legacy_source_diagnostics_are_removed(self):
|
||||||
|
source = (APP_ROOT / "main.py").read_text()
|
||||||
|
self.assertNotIn("source_lines", source)
|
||||||
|
self.assertNotIn("recent_patch_markers", source)
|
||||||
|
from app.main import app
|
||||||
|
page = [r for r in app.routes if getattr(r, "path", None) == "/system-health"]
|
||||||
|
api = [r for r in app.routes if getattr(r, "path", None) == "/api/system/health-summary"]
|
||||||
|
self.assertEqual(len(page), 1)
|
||||||
|
self.assertEqual(len(api), 1)
|
||||||
|
self.assertEqual(page[0].methods, {"GET"})
|
||||||
|
self.assertEqual(api[0].methods, {"GET"})
|
||||||
|
|
||||||
|
def test_layers_and_security_boundaries(self):
|
||||||
|
router_source = (APP_ROOT / "system_health/router.py").read_text()
|
||||||
|
service_source = (APP_ROOT / "system_health/service.py").read_text()
|
||||||
|
repository_source = (APP_ROOT / "system_health/repository.py").read_text()
|
||||||
|
ast.parse(router_source)
|
||||||
|
ast.parse(service_source)
|
||||||
|
self.assertNotIn("SELECT ", router_source)
|
||||||
|
self.assertNotIn("SELECT ", service_source)
|
||||||
|
self.assertNotIn("fastapi", repository_source.lower())
|
||||||
|
self.assertNotIn("fastapi", service_source.lower())
|
||||||
|
static = "\n".join(
|
||||||
|
(APP_ROOT / path).read_text()
|
||||||
|
for path in (
|
||||||
|
"frontend/static/system-health.js",
|
||||||
|
"frontend/static/system-health.css",
|
||||||
|
)
|
||||||
|
).lower()
|
||||||
|
for forbidden in (
|
||||||
|
"postgres_password", "redis_url", "jwt_secret", "connection string",
|
||||||
|
"stack trace", "password hash", "api key",
|
||||||
|
):
|
||||||
|
self.assertNotIn(forbidden, static)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
readonly source_files=(
|
||||||
|
backend/app/config.py
|
||||||
|
backend/app/frontend/static/system-health.css
|
||||||
|
backend/app/frontend/static/system-health.js
|
||||||
|
backend/app/frontend/system_health_view.py
|
||||||
|
backend/app/frontend/views.py
|
||||||
|
backend/app/main.py
|
||||||
|
backend/app/system_health/__init__.py
|
||||||
|
backend/app/system_health/adapters.py
|
||||||
|
backend/app/system_health/application.py
|
||||||
|
backend/app/system_health/dependencies.py
|
||||||
|
backend/app/system_health/frontend.py
|
||||||
|
backend/app/system_health/models.py
|
||||||
|
backend/app/system_health/repository.py
|
||||||
|
backend/app/system_health/router.py
|
||||||
|
backend/app/system_health/service.py
|
||||||
|
backend/tests/__init__.py
|
||||||
|
backend/tests/test_frontend_shell.py
|
||||||
|
backend/tests/test_system_health.py
|
||||||
|
)
|
||||||
|
|
||||||
|
for file in "${source_files[@]}"; do
|
||||||
|
[[ -r "${file}" ]] || { echo "Unreadable System Health input: ${file}" >&2; exit 1; }
|
||||||
|
[[ "$(stat -c '%a' "${file}")" == "644" ]] || {
|
||||||
|
echo "System Health source/test mode must be 0644: ${file}" >&2; exit 1;
|
||||||
|
}
|
||||||
|
done
|
||||||
|
[[ "$(stat -c '%a' scripts/test-system-health.sh)" == "755" ]] || {
|
||||||
|
echo "scripts/test-system-health.sh must have mode 0755." >&2; exit 1;
|
||||||
|
}
|
||||||
|
echo "System Health file-mode preflight passed."
|
||||||
|
|
||||||
|
python3 -m py_compile backend/app/config.py backend/app/frontend/system_health_view.py backend/app/frontend/views.py backend/app/main.py backend/app/system_health/*.py backend/tests/__init__.py backend/tests/test_frontend_shell.py backend/tests/test_system_health.py
|
||||||
|
node --check backend/app/frontend/static/system-health.js
|
||||||
|
bash -n scripts/test-system-health.sh
|
||||||
|
|
||||||
|
[[ -f .env.dev ]] || { echo "Missing .env.dev." >&2; exit 1; }
|
||||||
|
project_name="${MINEROPS_SYSTEM_HEALTH_TEST_PROJECT:-minerops-system-health-$$}"
|
||||||
|
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 100 postgres redis || true
|
||||||
|
fi
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
"${compose[@]}" up --detach postgres redis
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if "${compose[@]}" exec -T postgres sh -ec \
|
||||||
|
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 \
|
||||||
|
&& "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx PONG; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
(( attempt < 60 )) || { echo "Dependencies did not become ready." >&2; exit 1; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||||
|
backend python -m unittest -v tests.test_system_health tests.test_frontend_shell
|
||||||
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