WIP: Agent management and self-enrolling Windows packages #4
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# MinerOps repository-root Docker build context
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.dump
|
||||
**/*.sql.gz
|
||||
backend/app/main.py.bak*
|
||||
@@ -0,0 +1,323 @@
|
||||
#Requires -RunAsAdministrator
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Target = "C:\MinerOpsAgent"
|
||||
$ConfigPath = Join-Path $Target "agent-config.json"
|
||||
$PendingConfigPath = Join-Path $Target "agent-config.json.pending"
|
||||
$RecoveryConfigPath = Join-Path $Target "agent-config.recovery.json"
|
||||
$EnrollmentPath = Join-Path $PSScriptRoot "enrollment.json"
|
||||
$Installer = Join-Path $PSScriptRoot "install.ps1"
|
||||
|
||||
$RequiredFiles = @(
|
||||
"install.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
"MinerOpsAgentTray.ps1",
|
||||
"MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||
"MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1",
|
||||
"EnrichOneMiner.ps1",
|
||||
"MinerOpsControl.ps1",
|
||||
"VERSION",
|
||||
"enrollment.json"
|
||||
)
|
||||
|
||||
function Protect-MinerOpsCredentialFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path
|
||||
)
|
||||
|
||||
$acl = New-Object System.Security.AccessControl.FileSecurity
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
|
||||
$identities = @(
|
||||
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")),
|
||||
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")),
|
||||
[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
)
|
||||
|
||||
foreach ($identity in $identities) {
|
||||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
$identity,
|
||||
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.InheritanceFlags]::None,
|
||||
[System.Security.AccessControl.PropagationFlags]::None,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow
|
||||
)
|
||||
|
||||
$acl.AddAccessRule($rule)
|
||||
}
|
||||
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
}
|
||||
|
||||
function ConvertFrom-MinerOpsSecureString {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[Security.SecureString] $SecureValue
|
||||
)
|
||||
|
||||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
|
||||
$SecureValue
|
||||
)
|
||||
|
||||
try {
|
||||
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
||||
}
|
||||
finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
||||
}
|
||||
}
|
||||
|
||||
# Validate the complete package before modifying the Agent host or consuming
|
||||
# the single-use enrollment token.
|
||||
foreach ($name in $RequiredFiles) {
|
||||
$source = Join-Path $PSScriptRoot $name
|
||||
|
||||
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||
throw "Enrollment package is missing $name"
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $ConfigPath) {
|
||||
throw @"
|
||||
C:\MinerOpsAgent\agent-config.json already exists.
|
||||
This enrollment bootstrapper is for a new Agent identity and will not overwrite
|
||||
an existing identity. Use the normal MinerOps upgrade procedure instead.
|
||||
"@
|
||||
}
|
||||
|
||||
foreach ($recoveryPath in @($PendingConfigPath, $RecoveryConfigPath)) {
|
||||
if (Test-Path -LiteralPath $recoveryPath) {
|
||||
throw @"
|
||||
A previous enrollment recovery file exists at:
|
||||
$recoveryPath
|
||||
|
||||
Do not create or redeem another enrollment until that recovery state has been
|
||||
reviewed.
|
||||
"@
|
||||
}
|
||||
}
|
||||
|
||||
$enrollment = Get-Content -LiteralPath $EnrollmentPath -Raw |
|
||||
ConvertFrom-Json
|
||||
|
||||
$HubUrl = ([string] $enrollment.hub_url).TrimEnd("/")
|
||||
$EnrollmentToken = ([string] $enrollment.token).Trim()
|
||||
|
||||
$parsedHub = $null
|
||||
|
||||
if (
|
||||
-not [Uri]::TryCreate(
|
||||
$HubUrl,
|
||||
[UriKind]::Absolute,
|
||||
[ref] $parsedHub
|
||||
) -or
|
||||
$parsedHub.Scheme -notin @("http", "https")
|
||||
) {
|
||||
throw "enrollment.json contains an invalid MinerOps Hub URL."
|
||||
}
|
||||
|
||||
if (
|
||||
$EnrollmentToken.Length -lt 32 -or
|
||||
$EnrollmentToken.Length -gt 256
|
||||
) {
|
||||
throw "enrollment.json contains an invalid enrollment token."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "MinerOps Windows Agent enrollment"
|
||||
Write-Host "Hub: $HubUrl"
|
||||
Write-Host ""
|
||||
Write-Host "API3 credentials stay only on this Windows Agent."
|
||||
Write-Host "Leave the account blank if API3 features are not being configured yet."
|
||||
Write-Host ""
|
||||
|
||||
$Api3Account = (Read-Host "WhatsMiner API3 account").Trim()
|
||||
$Api3PasswordPlain = ""
|
||||
|
||||
if ($Api3Account) {
|
||||
$Api3PasswordSecure = Read-Host `
|
||||
"WhatsMiner API3 password" `
|
||||
-AsSecureString
|
||||
|
||||
$Api3PasswordPlain = ConvertFrom-MinerOpsSecureString `
|
||||
-SecureValue $Api3PasswordSecure
|
||||
}
|
||||
|
||||
$Redeemed = $false
|
||||
$ConfigPersisted = $false
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $Target | Out-Null
|
||||
|
||||
# Create protected empty files BEFORE token redemption. If persistence is
|
||||
# not possible, fail before consuming the single-use token.
|
||||
[IO.File]::WriteAllText($PendingConfigPath, "")
|
||||
Protect-MinerOpsCredentialFile -Path $PendingConfigPath
|
||||
|
||||
[IO.File]::WriteAllText($RecoveryConfigPath, "")
|
||||
Protect-MinerOpsCredentialFile -Path $RecoveryConfigPath
|
||||
|
||||
$requestBody = @{
|
||||
token = $EnrollmentToken
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Redeeming one-time MinerOps enrollment..."
|
||||
|
||||
$response = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "$HubUrl/api/agent/enroll" `
|
||||
-ContentType "application/json" `
|
||||
-Body $requestBody `
|
||||
-TimeoutSec 30
|
||||
|
||||
$Redeemed = $true
|
||||
|
||||
if (-not $response.data) {
|
||||
throw "MinerOps enrollment returned no Agent data."
|
||||
}
|
||||
|
||||
$agent = $response.data
|
||||
|
||||
foreach ($required in @(
|
||||
"agent_id",
|
||||
"agent_secret",
|
||||
"site_id",
|
||||
"site_name",
|
||||
"agent_zone_id",
|
||||
"agent_zone_name"
|
||||
)) {
|
||||
if (-not ([string] $agent.$required).Trim()) {
|
||||
throw "MinerOps enrollment response is missing $required."
|
||||
}
|
||||
}
|
||||
|
||||
$ranges = @()
|
||||
|
||||
foreach ($cidr in @($agent.ip_ranges)) {
|
||||
$network = ([string] $cidr).Trim()
|
||||
|
||||
if (-not $network) {
|
||||
continue
|
||||
}
|
||||
|
||||
$ranges += [pscustomobject]@{
|
||||
name = "$($agent.agent_zone_name) - $network"
|
||||
cidr = $network
|
||||
enabled = $true
|
||||
api_port = 4028
|
||||
api3_port = 4433
|
||||
}
|
||||
}
|
||||
|
||||
$config = [ordered]@{
|
||||
hub_url = $HubUrl
|
||||
agent_id = [string] $agent.agent_id
|
||||
site_id = [string] $agent.site_id
|
||||
agent_zone_id = [string] $agent.agent_zone_id
|
||||
site_name = [string] $agent.site_name
|
||||
agent_secret = [string] $agent.agent_secret
|
||||
poll_interval_seconds = 120
|
||||
ip_ranges = @($ranges)
|
||||
api3_account = $Api3Account
|
||||
api3_password = $Api3PasswordPlain
|
||||
api3_port = 4433
|
||||
}
|
||||
|
||||
$configJson = $config | ConvertTo-Json -Depth 20
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
# Write a protected recovery copy first. If anything after redemption
|
||||
# fails, the permanent Agent credential is not lost with the consumed token.
|
||||
[IO.File]::WriteAllText(
|
||||
$RecoveryConfigPath,
|
||||
$configJson + [Environment]::NewLine,
|
||||
$utf8NoBom
|
||||
)
|
||||
|
||||
[IO.File]::WriteAllText(
|
||||
$PendingConfigPath,
|
||||
$configJson + [Environment]::NewLine,
|
||||
$utf8NoBom
|
||||
)
|
||||
|
||||
Move-Item `
|
||||
-LiteralPath $PendingConfigPath `
|
||||
-Destination $ConfigPath
|
||||
|
||||
$ConfigPersisted = $true
|
||||
|
||||
Remove-Item -LiteralPath $RecoveryConfigPath -Force
|
||||
|
||||
# The extracted bootstrap token is no longer needed once permanent
|
||||
# credentials have been persisted locally.
|
||||
Remove-Item -LiteralPath $EnrollmentPath -Force
|
||||
|
||||
$EnrollmentToken = $null
|
||||
$requestBody = $null
|
||||
$Api3PasswordPlain = $null
|
||||
|
||||
Write-Host "Permanent Agent identity saved locally."
|
||||
Write-Host "Installing MinerOps Agent runtime..."
|
||||
|
||||
& $Installer
|
||||
|
||||
$mainTask = Get-ScheduledTask `
|
||||
-TaskName "MinerOpsAgent" `
|
||||
-ErrorAction Stop
|
||||
|
||||
if ([string] $mainTask.State -ne "Running") {
|
||||
Start-ScheduledTask -TaskName "MinerOpsAgent" -ErrorAction Stop
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "MinerOps Agent enrollment completed."
|
||||
Write-Host "Agent ID: $($agent.agent_id)"
|
||||
Write-Host "Site: $($agent.site_name)"
|
||||
Write-Host "Agent Zone: $($agent.agent_zone_name)"
|
||||
Write-Host ""
|
||||
Write-Host "Delete the downloaded ZIP and extracted setup folder after verification."
|
||||
}
|
||||
catch {
|
||||
if (-not $Redeemed) {
|
||||
foreach ($temporaryPath in @(
|
||||
$PendingConfigPath,
|
||||
$RecoveryConfigPath
|
||||
)) {
|
||||
if (Test-Path -LiteralPath $temporaryPath) {
|
||||
Remove-Item `
|
||||
-LiteralPath $temporaryPath `
|
||||
-Force `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($ConfigPersisted) {
|
||||
Write-Warning @"
|
||||
The enrollment token was consumed and the permanent Agent identity WAS saved
|
||||
to C:\MinerOpsAgent\agent-config.json.
|
||||
|
||||
Do not create a replacement enrollment. Correct the installation problem and
|
||||
rerun install.ps1 from this package.
|
||||
"@
|
||||
}
|
||||
else {
|
||||
Write-Warning @"
|
||||
The enrollment token was consumed.
|
||||
|
||||
A protected credential recovery file may exist at:
|
||||
C:\MinerOpsAgent\agent-config.recovery.json
|
||||
|
||||
Do not create or redeem another enrollment until that recovery file has been
|
||||
reviewed.
|
||||
"@
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
+49
-29
@@ -30,9 +30,12 @@ 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.
|
||||
New Agent identities use per-Agent credentials created through secure
|
||||
one-time enrollment. The permanent Agent secret is returned only to the
|
||||
enrolling Agent and is stored locally in `agent-config.json`; MinerOps stores
|
||||
only its hash. The legacy deployment shared secret remains only for explicitly
|
||||
retained compatibility behavior and must not authenticate a newly enrolled
|
||||
per-Agent identity.
|
||||
|
||||
## Legacy Compatibility
|
||||
|
||||
@@ -43,35 +46,52 @@ 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:
|
||||
### New Agent enrollment
|
||||
|
||||
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.
|
||||
For a new Agent, use Agent Management in MinerOps:
|
||||
|
||||
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.
|
||||
1. Choose the Site and an enabled, unassigned Agent Zone.
|
||||
2. Enter a descriptive Agent name.
|
||||
3. Create a Windows enrollment.
|
||||
4. Download MinerOpsAgent-Windows.zip before the enrollment expires.
|
||||
5. Copy the ZIP to the intended Windows machine and extract it.
|
||||
6. Open Windows PowerShell as Administrator in the extracted
|
||||
MinerOpsAgent-Windows directory.
|
||||
7. Run:
|
||||
|
||||
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
|
||||
.\Enroll-MinerOpsAgent.ps1
|
||||
|
||||
8. Enter the local WhatsMiner API3 account/password when prompted, or leave
|
||||
the account blank if API3 functionality is not being configured yet.
|
||||
9. The bootstrapper exchanges the short-lived token with POST /api/agent/enroll,
|
||||
saves the resulting permanent Agent identity locally, and hands off to the
|
||||
safe runtime installer.
|
||||
10. Verify the Agent appears in Agent Management and checks in successfully.
|
||||
11. Delete the downloaded ZIP and extracted setup directory.
|
||||
|
||||
Do not place enrollment.json, enrollment tokens, Agent secrets, or API3
|
||||
credentials in screenshots, chat, tickets, logs, or source control.
|
||||
|
||||
### Existing Agent upgrade
|
||||
|
||||
For an already enrolled/configured Agent, do not run the enrollment
|
||||
bootstrapper. Copy the new canonical Agent files to a temporary directory and
|
||||
run install.ps1 from an elevated Windows PowerShell session.
|
||||
|
||||
The installer copies only managed runtime code to C:\MinerOpsAgent and
|
||||
preserves agent-config.json, agent-state.json, logs\, backups, probe output,
|
||||
and other runtime data. Existing Scheduled Task definitions are authoritative
|
||||
and are not rewritten. Missing tasks are created for a fresh or incomplete
|
||||
installation without hard-coded user names.
|
||||
|
||||
If MinerOpsAgent was running before an upgrade, the installer stops only that
|
||||
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||
again. A previously stopped Agent remains stopped. The tray and periodic tasks
|
||||
are not unnecessarily restarted.
|
||||
again. A previously stopped Agent remains stopped. Before replacing runtime
|
||||
files, the installer copies them to a timestamped backup under
|
||||
C:\MinerOpsAgent\backups.
|
||||
|
||||
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.
|
||||
A failed deployment restores managed files, removes tasks created by that
|
||||
failed attempt, and restores the main Agent's prior running state.
|
||||
|
||||
uninstall.ps1 unregisters the four MinerOps Scheduled Tasks and removes runtime
|
||||
code while preserving local configuration, state, logs, and backups.
|
||||
@@ -0,0 +1,117 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$bootstrap = Join-Path $root "Enroll-MinerOpsAgent.ps1"
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$bootstrap,
|
||||
[ref] $tokens,
|
||||
[ref] $parseErrors
|
||||
) | Out-Null
|
||||
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw "Enroll-MinerOpsAgent.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||
}
|
||||
|
||||
$source = Get-Content -LiteralPath $bootstrap -Raw
|
||||
|
||||
function Assert-SourceContains {
|
||||
param(
|
||||
[Parameter(Mandatory)][string] $Pattern,
|
||||
[Parameter(Mandatory)][string] $Message
|
||||
)
|
||||
|
||||
if ($source -notmatch $Pattern) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SourceContains 'enrollment\.json' `
|
||||
"Bootstrap enrollment file is not explicit."
|
||||
|
||||
Assert-SourceContains '/api/agent/enroll' `
|
||||
"Bootstrap does not use the secure enrollment endpoint."
|
||||
|
||||
Assert-SourceContains 'Read-Host[\s\S]*-AsSecureString' `
|
||||
"API3 password is not prompted as a SecureString."
|
||||
|
||||
Assert-SourceContains 'agent-config\.json\.pending' `
|
||||
"Atomic pending config path is missing."
|
||||
|
||||
Assert-SourceContains 'agent-config\.recovery\.json' `
|
||||
"Post-redemption credential recovery path is missing."
|
||||
|
||||
Assert-SourceContains 'Protect-MinerOpsCredentialFile' `
|
||||
"Credential file ACL protection is missing."
|
||||
|
||||
Assert-SourceContains 'S-1-5-18' `
|
||||
"SYSTEM ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'S-1-5-32-544' `
|
||||
"Administrators ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'WindowsIdentity\]::GetCurrent\(\)\.User' `
|
||||
"Installing user ACL entry is missing."
|
||||
|
||||
Assert-SourceContains 'Move-Item[\s\S]*\$ConfigPath' `
|
||||
"Atomic final config move is missing."
|
||||
|
||||
Assert-SourceContains '& \$Installer' `
|
||||
"Bootstrap does not hand off to the safe installer."
|
||||
|
||||
Assert-SourceContains 'Start-ScheduledTask -TaskName "MinerOpsAgent"' `
|
||||
"Fresh Agent startup is missing."
|
||||
|
||||
Assert-SourceContains 'Remove-Item -LiteralPath \$EnrollmentPath' `
|
||||
"Consumed extracted enrollment token is not removed."
|
||||
|
||||
$existingConfigGuard = $source.IndexOf(
|
||||
'Test-Path -LiteralPath $ConfigPath'
|
||||
)
|
||||
|
||||
$redeemCall = $source.IndexOf(
|
||||
'"$HubUrl/api/agent/enroll"'
|
||||
)
|
||||
|
||||
if (
|
||||
$existingConfigGuard -lt 0 -or
|
||||
$redeemCall -lt 0 -or
|
||||
$existingConfigGuard -gt $redeemCall
|
||||
) {
|
||||
throw "Existing Agent identity must be rejected before token redemption."
|
||||
}
|
||||
|
||||
$configMove = $source.IndexOf(
|
||||
'-Destination $ConfigPath'
|
||||
)
|
||||
|
||||
$installerCall = $source.IndexOf(
|
||||
'& $Installer'
|
||||
)
|
||||
|
||||
if (
|
||||
$configMove -lt 0 -or
|
||||
$installerCall -lt 0 -or
|
||||
$configMove -gt $installerCall
|
||||
) {
|
||||
throw "Permanent Agent credentials must be persisted before installation."
|
||||
}
|
||||
|
||||
foreach ($sensitiveVariable in @(
|
||||
'EnrollmentToken',
|
||||
'Api3PasswordPlain'
|
||||
)) {
|
||||
if (
|
||||
$source -match (
|
||||
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
|
||||
$sensitiveVariable
|
||||
)
|
||||
) {
|
||||
throw "$sensitiveVariable must not be written to output."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "MinerOps enrollment bootstrap source safety tests passed."
|
||||
+8
-5
@@ -5,15 +5,18 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic /app/alembic
|
||||
COPY backend/app /app/app
|
||||
COPY backend/alembic.ini /app/alembic.ini
|
||||
COPY backend/alembic /app/alembic
|
||||
|
||||
# Canonical Agent source used to construct short-lived enrollment packages.
|
||||
COPY agents/windows /app/agents/windows
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -92,3 +92,7 @@ class AgentEnrollResponse(BaseModel):
|
||||
data: AgentEnrollCredentials
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
|
||||
|
||||
class AgentWindowsPackageRequest(AgentEnrollRequest):
|
||||
"""Short-lived token used to construct a Windows bootstrap package."""
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
|
||||
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||
from app.agent_enrollment.exceptions import (
|
||||
@@ -17,8 +17,15 @@ from app.agent_enrollment.models import (
|
||||
AgentEnrollmentListResponse,
|
||||
AgentEnrollRequest,
|
||||
AgentEnrollResponse,
|
||||
AgentWindowsPackageRequest,
|
||||
)
|
||||
from app.agent_enrollment.service import AgentEnrollmentService
|
||||
from app.agent_packages.windows import (
|
||||
AgentWindowsPackageError,
|
||||
AgentWindowsPackageSourceError,
|
||||
build_windows_agent_package,
|
||||
normalize_hub_url,
|
||||
)
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
@@ -85,6 +92,50 @@ def list_agent_enrollments(
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/api/agent-enrollments/windows-package")
|
||||
def download_windows_agent_package(
|
||||
package_request: AgentWindowsPackageRequest,
|
||||
http_request: Request,
|
||||
current_user: EnrollmentManager,
|
||||
) -> Response:
|
||||
del current_user
|
||||
|
||||
browser_origin = http_request.headers.get("origin", "").strip()
|
||||
|
||||
try:
|
||||
hub_url = normalize_hub_url(browser_origin)
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Windows Agent package downloads require "
|
||||
"a valid browser Origin"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=package_request.token,
|
||||
hub_url=hub_url,
|
||||
)
|
||||
except AgentWindowsPackageSourceError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except AgentWindowsPackageError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return Response(
|
||||
content=package,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Content-Disposition": (
|
||||
'attachment; filename="MinerOpsAgent-Windows.zip"'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/agent/enroll",
|
||||
response_model=AgentEnrollResponse,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Downloadable MinerOps Agent deployment packages."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Build short-lived Windows Agent enrollment packages in memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
WINDOWS_PACKAGE_ROOT = "MinerOpsAgent-Windows"
|
||||
|
||||
WINDOWS_PACKAGE_FILES = (
|
||||
"Enroll-MinerOpsAgent.ps1",
|
||||
"install.ps1",
|
||||
"uninstall.ps1",
|
||||
"MinerOpsAgentService.ps1",
|
||||
"MinerOpsAgentTray.ps1",
|
||||
"MinerOpsAgentGUI.ps1",
|
||||
"MinerOpsApi3EnrichmentPoller.ps1",
|
||||
"MinerOpsPoolPoller.ps1",
|
||||
"MinerOpsPoolPollerTask.ps1",
|
||||
"EnrichOneMiner.ps1",
|
||||
"MinerOpsControl.ps1",
|
||||
"VERSION",
|
||||
"README.md",
|
||||
)
|
||||
|
||||
DEFAULT_WINDOWS_AGENT_SOURCE = Path(
|
||||
os.environ.get(
|
||||
"MINEROPS_WINDOWS_AGENT_SOURCE",
|
||||
"/app/agents/windows",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AgentWindowsPackageError(RuntimeError):
|
||||
"""Base Windows package construction error."""
|
||||
|
||||
|
||||
class AgentWindowsPackageSourceError(AgentWindowsPackageError):
|
||||
"""Canonical Windows Agent source is unavailable or incomplete."""
|
||||
|
||||
|
||||
def normalize_hub_url(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
parsed = urlsplit(candidate)
|
||||
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must use http or https"
|
||||
)
|
||||
|
||||
if not parsed.netloc:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must include a host"
|
||||
)
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must not contain credentials"
|
||||
)
|
||||
|
||||
if parsed.query or parsed.fragment:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must not contain query or fragment data"
|
||||
)
|
||||
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise AgentWindowsPackageError(
|
||||
"MinerOps Hub URL must be an origin without a path"
|
||||
)
|
||||
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def build_windows_agent_package(
|
||||
*,
|
||||
enrollment_token: str,
|
||||
hub_url: str,
|
||||
source_root: Path | None = None,
|
||||
) -> bytes:
|
||||
token = enrollment_token.strip()
|
||||
|
||||
if not 32 <= len(token) <= 256:
|
||||
raise AgentWindowsPackageError(
|
||||
"Enrollment token has an invalid length"
|
||||
)
|
||||
|
||||
normalized_hub_url = normalize_hub_url(hub_url)
|
||||
|
||||
root = (
|
||||
Path(source_root)
|
||||
if source_root is not None
|
||||
else DEFAULT_WINDOWS_AGENT_SOURCE
|
||||
)
|
||||
|
||||
if not root.is_dir():
|
||||
raise AgentWindowsPackageSourceError(
|
||||
"Canonical Windows Agent source is unavailable"
|
||||
)
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name in WINDOWS_PACKAGE_FILES
|
||||
if not (root / name).is_file()
|
||||
]
|
||||
|
||||
if missing:
|
||||
raise AgentWindowsPackageSourceError(
|
||||
"Canonical Windows Agent source is incomplete: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
enrollment = {
|
||||
"hub_url": normalized_hub_url,
|
||||
"token": token,
|
||||
}
|
||||
|
||||
instructions = """MinerOps Windows Agent
|
||||
|
||||
1. Extract this ZIP on the target Windows machine.
|
||||
2. Open Windows PowerShell as Administrator.
|
||||
3. Change to the extracted MinerOpsAgent-Windows folder.
|
||||
4. Run:
|
||||
|
||||
.\\Enroll-MinerOpsAgent.ps1
|
||||
|
||||
The enrollment credential is short-lived and single-use.
|
||||
Do not post enrollment.json in chat, tickets, screenshots, or logs.
|
||||
Delete the ZIP and extracted setup folder after successful installation.
|
||||
"""
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(
|
||||
buffer,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
for name in WINDOWS_PACKAGE_FILES:
|
||||
archive.write(
|
||||
root / name,
|
||||
arcname=f"{WINDOWS_PACKAGE_ROOT}/{name}",
|
||||
)
|
||||
|
||||
archive.writestr(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
|
||||
json.dumps(
|
||||
enrollment,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
archive.writestr(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
|
||||
instructions,
|
||||
)
|
||||
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1 @@
|
||||
"""Read-only Agent registry projection."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependencies for the Agent registry read model."""
|
||||
|
||||
from app.agent_registry.repository import AgentRegistryRepository
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
|
||||
|
||||
def get_agent_registry_service() -> AgentRegistryService:
|
||||
return AgentRegistryService(AgentRegistryRepository())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Typed read models for Agent Management."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CredentialState = Literal["legacy", "active", "revoked"]
|
||||
RoutingState = Literal["assigned", "unassigned", "disabled"]
|
||||
|
||||
|
||||
class AgentRegistrySite(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class AgentRegistryZone(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_enabled: bool
|
||||
assigned_agent_id: str | None = None
|
||||
networks: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryAgent(BaseModel):
|
||||
agent_id: str
|
||||
display_name: str | None = None
|
||||
|
||||
site_id: UUID | None = None
|
||||
site_name: str
|
||||
|
||||
agent_zone_id: UUID | None = None
|
||||
agent_zone_name: str | None = None
|
||||
agent_zone_enabled: bool | None = None
|
||||
|
||||
hostname: str | None = None
|
||||
version: str | None = None
|
||||
ip_address: str | None = None
|
||||
last_checkin_at: datetime
|
||||
|
||||
credential_state: CredentialState
|
||||
credential_created_at: datetime | None = None
|
||||
credential_revoked_at: datetime | None = None
|
||||
|
||||
routing_state: RoutingState
|
||||
|
||||
|
||||
class AgentRegistryData(BaseModel):
|
||||
agents: list[AgentRegistryAgent] = Field(default_factory=list)
|
||||
sites: list[AgentRegistrySite] = Field(default_factory=list)
|
||||
zones: list[AgentRegistryZone] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRegistryResponse(BaseModel):
|
||||
data: AgentRegistryData
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,127 @@
|
||||
"""PostgreSQL reads for the durable Agent registry."""
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def default_connection_factory():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
row_factory=dict_row,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryRepository:
|
||||
def __init__(self, connection_factory=default_connection_factory):
|
||||
self.connection_factory = connection_factory
|
||||
|
||||
def list_registry(self) -> dict[str, list[dict]]:
|
||||
connection = self.connection_factory()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
site.id,
|
||||
site.name
|
||||
FROM sites AS site
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM agent_zones AS zone
|
||||
WHERE zone.site_id = site.id
|
||||
)
|
||||
ORDER BY lower(site.name), site.id
|
||||
"""
|
||||
)
|
||||
sites = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name AS site_name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id AS assigned_agent_id,
|
||||
COALESCE(
|
||||
array_agg(
|
||||
network.network::text
|
||||
ORDER BY network.network::text
|
||||
) FILTER (WHERE network.id IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS networks
|
||||
FROM agent_zones AS zone
|
||||
JOIN sites AS site
|
||||
ON site.id = zone.site_id
|
||||
LEFT JOIN minerops_site_agents AS assigned
|
||||
ON assigned.agent_zone_id = zone.id
|
||||
LEFT JOIN agent_zone_network_ranges AS network
|
||||
ON network.agent_zone_id = zone.id
|
||||
GROUP BY
|
||||
zone.id,
|
||||
zone.site_id,
|
||||
site.name,
|
||||
zone.name,
|
||||
zone.description,
|
||||
zone.is_enabled,
|
||||
assigned.agent_id
|
||||
ORDER BY
|
||||
lower(site.name),
|
||||
lower(zone.name),
|
||||
zone.id
|
||||
"""
|
||||
)
|
||||
zones = cur.fetchall()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
agent.agent_id,
|
||||
agent.display_name,
|
||||
agent.site_id,
|
||||
COALESCE(site.name, agent.site_name) AS site_name,
|
||||
agent.agent_zone_id,
|
||||
zone.name AS agent_zone_name,
|
||||
zone.is_enabled AS agent_zone_enabled,
|
||||
agent.hostname,
|
||||
agent.version,
|
||||
agent.ip_address,
|
||||
agent.last_checkin_at,
|
||||
agent.credential_created_at,
|
||||
agent.credential_revoked_at,
|
||||
CASE
|
||||
WHEN agent.credential_revoked_at IS NOT NULL
|
||||
THEN 'revoked'
|
||||
WHEN agent.agent_secret_hash IS NOT NULL
|
||||
THEN 'active'
|
||||
ELSE 'legacy'
|
||||
END AS credential_state
|
||||
FROM minerops_site_agents AS agent
|
||||
LEFT JOIN sites AS site
|
||||
ON site.id = agent.site_id
|
||||
LEFT JOIN agent_zones AS zone
|
||||
ON zone.id = agent.agent_zone_id
|
||||
ORDER BY
|
||||
lower(COALESCE(site.name, agent.site_name)),
|
||||
lower(COALESCE(agent.display_name, agent.agent_id)),
|
||||
agent.agent_id
|
||||
"""
|
||||
)
|
||||
agents = cur.fetchall()
|
||||
|
||||
return {
|
||||
"sites": sites,
|
||||
"zones": zones,
|
||||
"agents": agents,
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Read-only Agent Management API."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.agent_registry.dependencies import get_agent_registry_service
|
||||
from app.agent_registry.models import AgentRegistryResponse
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["agents"])
|
||||
|
||||
RegistryService = Annotated[
|
||||
AgentRegistryService,
|
||||
Depends(get_agent_registry_service),
|
||||
]
|
||||
|
||||
AgentViewer = Annotated[
|
||||
dict,
|
||||
Depends(require_permission("agents.view")),
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/agents",
|
||||
response_model=AgentRegistryResponse,
|
||||
)
|
||||
def list_agents(
|
||||
response: Response,
|
||||
current_user: AgentViewer,
|
||||
service: RegistryService,
|
||||
) -> AgentRegistryResponse:
|
||||
del current_user
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
data = service.list_registry()
|
||||
|
||||
return AgentRegistryResponse(
|
||||
data=data,
|
||||
meta={
|
||||
"agent_count": len(data.agents),
|
||||
"site_count": len(data.sites),
|
||||
"zone_count": len(data.zones),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Application service for the Agent Management read model."""
|
||||
|
||||
from app.agent_registry.models import (
|
||||
AgentRegistryAgent,
|
||||
AgentRegistryData,
|
||||
AgentRegistrySite,
|
||||
AgentRegistryZone,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def list_registry(self) -> AgentRegistryData:
|
||||
rows = self.repository.list_registry()
|
||||
|
||||
sites = [
|
||||
AgentRegistrySite(**row)
|
||||
for row in rows["sites"]
|
||||
]
|
||||
|
||||
zones = [
|
||||
AgentRegistryZone(**row)
|
||||
for row in rows["zones"]
|
||||
]
|
||||
|
||||
agents: list[AgentRegistryAgent] = []
|
||||
|
||||
for row in rows["agents"]:
|
||||
zone_id = row.get("agent_zone_id")
|
||||
zone_enabled = row.get("agent_zone_enabled")
|
||||
|
||||
if zone_id is None:
|
||||
routing_state = "unassigned"
|
||||
elif zone_enabled is False:
|
||||
routing_state = "disabled"
|
||||
else:
|
||||
routing_state = "assigned"
|
||||
|
||||
agents.append(
|
||||
AgentRegistryAgent(
|
||||
**row,
|
||||
routing_state=routing_state,
|
||||
)
|
||||
)
|
||||
|
||||
return AgentRegistryData(
|
||||
agents=agents,
|
||||
sites=sites,
|
||||
zones=zones,
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Semantic HTML for the Internal Alpha Agent Management page."""
|
||||
|
||||
|
||||
def agent_management_content(can_manage_agents: bool) -> str:
|
||||
management = """
|
||||
<section class="panel agent-enrollment-panel"
|
||||
aria-labelledby="agentEnrollmentCreateTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2 id="agentEnrollmentCreateTitle">Add Agent</h2>
|
||||
<p class="panel-copy">
|
||||
Create a short-lived one-time enrollment for an Agent Zone.
|
||||
Windows is the currently supported deployment platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="agentCreateForm" class="agent-create-form">
|
||||
<div class="field">
|
||||
<label for="agentSite">Site</label>
|
||||
<select id="agentSite" required>
|
||||
<option value="">Choose a site</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentZone">Agent Zone</label>
|
||||
<select id="agentZone" required disabled>
|
||||
<option value="">Choose a site first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentDisplayName">Agent name</label>
|
||||
<input id="agentDisplayName"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
autocomplete="off"
|
||||
placeholder="Example: Plexus Unit 4 Agent"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="agentPlatform">Platform</label>
|
||||
<select id="agentPlatform">
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux-x64" disabled>Linux x64 — coming later</option>
|
||||
<option value="linux-arm64" disabled>Linux ARM64 / Raspberry Pi — coming later</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentCreateSubmit"
|
||||
class="command-button"
|
||||
type="submit">
|
||||
Create Windows enrollment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentCreateStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<section id="agentEnrollmentResult"
|
||||
class="agent-enrollment-result"
|
||||
aria-labelledby="agentEnrollmentResultTitle"
|
||||
hidden>
|
||||
<div>
|
||||
<p class="eyebrow">One-time credential</p>
|
||||
<h3 id="agentEnrollmentResultTitle">
|
||||
Enrollment created
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This token is displayed only for this enrollment step.
|
||||
Keep it private. MinerOps does not store the plaintext token.
|
||||
</p>
|
||||
|
||||
<div class="agent-token-box">
|
||||
<code id="agentEnrollmentToken"></code>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentExpires" class="freshness"></p>
|
||||
|
||||
<div class="agent-form-actions">
|
||||
<button id="agentEnrollmentDownload"
|
||||
class="command-button"
|
||||
type="button">
|
||||
Download Windows package
|
||||
</button>
|
||||
<button id="agentEnrollmentCopy"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Copy token
|
||||
</button>
|
||||
<button id="agentEnrollmentDismiss"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Clear token from screen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="agentEnrollmentCopyStatus"
|
||||
class="freshness"
|
||||
aria-live="polite"></p>
|
||||
|
||||
<p class="panel-copy">
|
||||
The Windows package contains the short-lived enrollment
|
||||
credential and generic MinerOps Agent runtime. Transfer it
|
||||
only to the intended Agent computer and delete the ZIP after
|
||||
successful installation.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
""" if can_manage_agents else """
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Agent deployment</p>
|
||||
<h2>Agent enrollment</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="permission-note">
|
||||
Your account can view Agent status but cannot create Agent
|
||||
enrollments.
|
||||
</p>
|
||||
</section>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div id="agentManagementLoading" class="state-message" role="status">
|
||||
<strong>Loading Agents</strong>
|
||||
<span>Requesting Agent registry and enrollment information.</span>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementError"
|
||||
class="state-message"
|
||||
data-kind="error"
|
||||
role="alert"
|
||||
hidden>
|
||||
<strong>Agent Management unavailable</strong>
|
||||
<span id="agentManagementErrorText">
|
||||
Agent information could not be loaded.
|
||||
</span>
|
||||
<button id="agentManagementRetry"
|
||||
class="command-button"
|
||||
type="button">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="agentManagementContent" hidden>
|
||||
<section class="agent-summary-grid" aria-label="Agent Management summary">
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Registered Agents</span>
|
||||
<strong id="agentCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Agent Zones</span>
|
||||
<strong id="agentZoneCount">-</strong>
|
||||
</div>
|
||||
<div class="panel agent-summary-card">
|
||||
<span>Waiting enrollments</span>
|
||||
<strong id="agentWaitingEnrollmentCount">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="agent-management-columns">
|
||||
<section class="panel" aria-labelledby="registeredAgentsTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Durable identities</p>
|
||||
<h2 id="registeredAgentsTitle">Agents</h2>
|
||||
<p class="panel-copy">
|
||||
Registered Agent identities and their latest factual
|
||||
check-in information.
|
||||
</p>
|
||||
</div>
|
||||
<button id="agentManagementRefresh"
|
||||
class="quiet-button"
|
||||
type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Host</th>
|
||||
<th>Version</th>
|
||||
<th>Last check-in</th>
|
||||
<th>Credential</th>
|
||||
<th>Routing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEmpty" class="panel-copy" hidden>
|
||||
No registered Agents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="agentZonesTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network routing boundaries</p>
|
||||
<h2 id="agentZonesTitle">Agent Zones</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-zone-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Zone</th>
|
||||
<th>Networks</th>
|
||||
<th>Assigned Agent</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentZoneRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentZoneEmpty" class="panel-copy" hidden>
|
||||
No Agent Zones.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{management}
|
||||
|
||||
<section class="panel" aria-labelledby="agentEnrollmentHistoryTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Bootstrap history</p>
|
||||
<h2 id="agentEnrollmentHistoryTitle">Enrollment history</h2>
|
||||
<p class="panel-copy">
|
||||
Metadata only. Plaintext enrollment tokens are never
|
||||
returned by this history view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="agent-enrollment-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent name</th>
|
||||
<th>Site / Zone</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Enrolled Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentEnrollmentRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="agentEnrollmentEmpty" class="panel-copy" hidden>
|
||||
No Agent enrollment records.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/assets/minerops-agent-management.js" defer></script>
|
||||
"""
|
||||
@@ -18,6 +18,7 @@ DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
AgentUser = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||
|
||||
|
||||
def render_page(view: str, current_user: dict, **context) -> str:
|
||||
@@ -92,6 +93,27 @@ def miner_detail_script() -> Response:
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/assets/minerops-agent-management.js",
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "agent-management.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agent/management",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def agent_management_page(current_user: AgentUser) -> str:
|
||||
return render_page("agent_management", current_user)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard_page(current_user: DashboardUser) -> str:
|
||||
return render_page("dashboard", current_user)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "agent_management") return;
|
||||
|
||||
const ids = [
|
||||
"agentManagementLoading",
|
||||
"agentManagementError",
|
||||
"agentManagementErrorText",
|
||||
"agentManagementRetry",
|
||||
"agentManagementContent",
|
||||
"agentManagementRefresh",
|
||||
"agentCount",
|
||||
"agentZoneCount",
|
||||
"agentWaitingEnrollmentCount",
|
||||
"agentRows",
|
||||
"agentEmpty",
|
||||
"agentZoneRows",
|
||||
"agentZoneEmpty",
|
||||
"agentEnrollmentRows",
|
||||
"agentEnrollmentEmpty",
|
||||
"agentCreateForm",
|
||||
"agentSite",
|
||||
"agentZone",
|
||||
"agentDisplayName",
|
||||
"agentPlatform",
|
||||
"agentCreateSubmit",
|
||||
"agentCreateStatus",
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
"agentEnrollmentCopy",
|
||||
"agentEnrollmentDismiss",
|
||||
"agentEnrollmentCopyStatus"
|
||||
];
|
||||
|
||||
const el = {};
|
||||
|
||||
ids.forEach(function (id) {
|
||||
el[id] = document.getElementById(id);
|
||||
});
|
||||
|
||||
let registry = {
|
||||
agents: [],
|
||||
sites: [],
|
||||
zones: []
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
|
||||
function text(value, fallback) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return fallback || "Unavailable";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Never";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) return "Unavailable";
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return text(value, "Unknown")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, function (letter) {
|
||||
return letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function cell(value, className) {
|
||||
const node = document.createElement("td");
|
||||
if (className) node.className = className;
|
||||
node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function setState(name) {
|
||||
el.agentManagementLoading.hidden = name !== "loading";
|
||||
el.agentManagementError.hidden = name !== "error";
|
||||
el.agentManagementContent.hidden = name !== "ready";
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, Object.assign({
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}, options || {}));
|
||||
|
||||
const payload = await response.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload && payload.detail
|
||||
? String(payload.detail)
|
||||
: "Request failed with status " + response.status + ".";
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderAgents() {
|
||||
el.agentRows.replaceChildren();
|
||||
|
||||
registry.agents.forEach(function (agent) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const identity = document.createElement("td");
|
||||
const name = document.createElement("strong");
|
||||
const id = document.createElement("span");
|
||||
|
||||
name.textContent = text(agent.display_name, "Unnamed Agent");
|
||||
id.className = "secondary";
|
||||
id.textContent = agent.agent_id;
|
||||
|
||||
identity.append(name, id);
|
||||
|
||||
const location = [
|
||||
text(agent.site_name, "Unknown site"),
|
||||
text(agent.agent_zone_name, "No Agent Zone")
|
||||
].join(" / ");
|
||||
|
||||
const host = [
|
||||
text(agent.hostname, "Unknown host"),
|
||||
text(agent.ip_address, "No IP")
|
||||
].join(" / ");
|
||||
|
||||
row.append(
|
||||
identity,
|
||||
cell(location),
|
||||
cell(host),
|
||||
cell(text(agent.version, "Unknown")),
|
||||
cell(dateTime(agent.last_checkin_at)),
|
||||
cell(words(agent.credential_state)),
|
||||
cell(words(agent.routing_state))
|
||||
);
|
||||
|
||||
el.agentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEmpty.hidden = registry.agents.length !== 0;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
el.agentZoneRows.replaceChildren();
|
||||
|
||||
registry.zones.forEach(function (zone) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const networks = Array.isArray(zone.networks) && zone.networks.length
|
||||
? zone.networks.join(", ")
|
||||
: "No networks recorded";
|
||||
|
||||
row.append(
|
||||
cell(zone.site_name),
|
||||
cell(zone.name),
|
||||
cell(networks),
|
||||
cell(text(zone.assigned_agent_id, "Unassigned")),
|
||||
cell(zone.is_enabled ? "Enabled" : "Disabled")
|
||||
);
|
||||
|
||||
el.agentZoneRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentZoneEmpty.hidden = registry.zones.length !== 0;
|
||||
}
|
||||
|
||||
function renderEnrollments() {
|
||||
el.agentEnrollmentRows.replaceChildren();
|
||||
|
||||
enrollments.forEach(function (enrollment) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
row.append(
|
||||
cell(enrollment.display_name),
|
||||
cell(
|
||||
enrollment.site_name + " / " +
|
||||
enrollment.agent_zone_name
|
||||
),
|
||||
cell(words(enrollment.status)),
|
||||
cell(dateTime(enrollment.created_at)),
|
||||
cell(dateTime(enrollment.expires_at)),
|
||||
cell(text(enrollment.enrolled_agent_id, "Not enrolled"))
|
||||
);
|
||||
|
||||
el.agentEnrollmentRows.appendChild(row);
|
||||
});
|
||||
|
||||
el.agentEnrollmentEmpty.hidden = enrollments.length !== 0;
|
||||
}
|
||||
|
||||
function populateSites() {
|
||||
if (!el.agentSite) return;
|
||||
|
||||
const selected = el.agentSite.value;
|
||||
|
||||
el.agentSite.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = "Choose a site";
|
||||
el.agentSite.appendChild(placeholder);
|
||||
|
||||
registry.sites.forEach(function (site) {
|
||||
const option = document.createElement("option");
|
||||
option.value = site.id;
|
||||
option.textContent = site.name;
|
||||
el.agentSite.appendChild(option);
|
||||
});
|
||||
|
||||
if ([...el.agentSite.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentSite.value = selected;
|
||||
}
|
||||
|
||||
populateZones();
|
||||
}
|
||||
|
||||
function populateZones() {
|
||||
if (!el.agentZone || !el.agentSite) return;
|
||||
|
||||
const siteId = el.agentSite.value;
|
||||
const selected = el.agentZone.value;
|
||||
|
||||
el.agentZone.replaceChildren();
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
|
||||
if (!siteId) {
|
||||
placeholder.textContent = "Choose a site first";
|
||||
el.agentZone.disabled = true;
|
||||
el.agentZone.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.textContent = "Choose an Agent Zone";
|
||||
el.agentZone.appendChild(placeholder);
|
||||
|
||||
const available = registry.zones.filter(function (zone) {
|
||||
return (
|
||||
zone.site_id === siteId &&
|
||||
zone.is_enabled === true &&
|
||||
!zone.assigned_agent_id
|
||||
);
|
||||
});
|
||||
|
||||
available.forEach(function (zone) {
|
||||
const option = document.createElement("option");
|
||||
option.value = zone.id;
|
||||
option.textContent = zone.name;
|
||||
el.agentZone.appendChild(option);
|
||||
});
|
||||
|
||||
el.agentZone.disabled = available.length === 0;
|
||||
|
||||
if ([...el.agentZone.options].some(function (option) {
|
||||
return option.value === selected;
|
||||
})) {
|
||||
el.agentZone.value = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
el.agentCount.textContent = String(registry.agents.length);
|
||||
el.agentZoneCount.textContent = String(registry.zones.length);
|
||||
|
||||
const waiting = enrollments.filter(function (item) {
|
||||
return item.status === "waiting";
|
||||
}).length;
|
||||
|
||||
el.agentWaitingEnrollmentCount.textContent = String(waiting);
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSummary();
|
||||
renderAgents();
|
||||
renderZones();
|
||||
renderEnrollments();
|
||||
populateSites();
|
||||
}
|
||||
|
||||
async function refreshData(showLoading) {
|
||||
if (showLoading !== false) setState("loading");
|
||||
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
api("/api/agents"),
|
||||
api("/api/agent-enrollments")
|
||||
]);
|
||||
|
||||
registry = results[0] && results[0].data
|
||||
? results[0].data
|
||||
: { agents: [], sites: [], zones: [] };
|
||||
|
||||
enrollments = results[1] && Array.isArray(results[1].data)
|
||||
? results[1].data
|
||||
: [];
|
||||
|
||||
render();
|
||||
setState("ready");
|
||||
} catch (error) {
|
||||
el.agentManagementErrorText.textContent =
|
||||
error.message || "Agent information could not be loaded.";
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function createEnrollment(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!el.agentCreateForm) return;
|
||||
|
||||
el.agentCreateSubmit.disabled = true;
|
||||
el.agentCreateStatus.textContent = "Creating enrollment...";
|
||||
|
||||
try {
|
||||
const payload = await api("/api/agent-enrollments", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
site_id: el.agentSite.value,
|
||||
agent_zone_id: el.agentZone.value,
|
||||
display_name: el.agentDisplayName.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const enrollment = payload.data;
|
||||
|
||||
el.agentEnrollmentToken.textContent =
|
||||
enrollment.enrollment_token;
|
||||
|
||||
el.agentEnrollmentExpires.textContent =
|
||||
"Expires " + dateTime(enrollment.expires_at);
|
||||
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = false;
|
||||
el.agentCreateStatus.textContent = "Enrollment created.";
|
||||
|
||||
el.agentDisplayName.value = "";
|
||||
|
||||
await refreshData(false);
|
||||
} catch (error) {
|
||||
el.agentCreateStatus.textContent =
|
||||
error.message || "Enrollment could not be created.";
|
||||
} finally {
|
||||
el.agentCreateSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWindowsPackage() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
if (!token) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"No enrollment token is available to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
el.agentEnrollmentDownload.disabled = true;
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Building Windows Agent package...";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Accept": "application/zip",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
payload && payload.detail
|
||||
? String(payload.detail)
|
||||
: "Package request failed with status " +
|
||||
response.status + "."
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = downloadUrl;
|
||||
link.download = "MinerOpsAgent-Windows.zip";
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
dismissToken();
|
||||
|
||||
el.agentCreateStatus.textContent =
|
||||
"Windows Agent package downloaded. " +
|
||||
"The on-screen token has been cleared.";
|
||||
} catch (error) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
error.message || "Windows package could not be downloaded.";
|
||||
} finally {
|
||||
el.agentEnrollmentDownload.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const token = el.agentEnrollmentToken.textContent;
|
||||
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Enrollment token copied.";
|
||||
} catch (error) {
|
||||
el.agentEnrollmentCopyStatus.textContent =
|
||||
"Copy was unavailable. Select and copy the token manually.";
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToken() {
|
||||
el.agentEnrollmentToken.textContent = "";
|
||||
el.agentEnrollmentExpires.textContent = "";
|
||||
el.agentEnrollmentCopyStatus.textContent = "";
|
||||
el.agentEnrollmentResult.hidden = true;
|
||||
}
|
||||
|
||||
el.agentManagementRetry.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
|
||||
if (el.agentManagementRefresh) {
|
||||
el.agentManagementRefresh.addEventListener("click", function () {
|
||||
refreshData(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (el.agentSite) {
|
||||
el.agentSite.addEventListener("change", populateZones);
|
||||
}
|
||||
|
||||
if (el.agentCreateForm) {
|
||||
el.agentCreateForm.addEventListener("submit", createEnrollment);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDownload) {
|
||||
el.agentEnrollmentDownload.addEventListener(
|
||||
"click",
|
||||
downloadWindowsPackage
|
||||
);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentCopy) {
|
||||
el.agentEnrollmentCopy.addEventListener("click", copyToken);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDismiss) {
|
||||
el.agentEnrollmentDismiss.addEventListener(
|
||||
"click",
|
||||
dismissToken
|
||||
);
|
||||
}
|
||||
|
||||
refreshData(true);
|
||||
}());
|
||||
@@ -1771,3 +1771,86 @@ button.map-position:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent Management v1 */
|
||||
|
||||
.agent-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-summary-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.agent-summary-card span {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.agent-summary-card strong {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.agent-management-columns {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-table td:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agent-create-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.agent-create-form .agent-form-actions,
|
||||
.agent-create-form #agentCreateStatus {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-enrollment-result {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.agent-token-box {
|
||||
overflow-wrap: anywhere;
|
||||
padding: 14px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.agent-token-box code {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.agent-summary-grid,
|
||||
.agent-create-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent Management action visibility */
|
||||
.agent-management-columns .quiet-button,
|
||||
.agent-enrollment-panel .quiet-button {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from typing import Callable
|
||||
from app.frontend.miner_detail_view import miner_detail_content
|
||||
from app.frontend.monitoring_trends_view import monitoring_trends_content
|
||||
from app.frontend.system_health_view import system_health_content
|
||||
from app.frontend.agent_management_view import agent_management_content
|
||||
|
||||
|
||||
NAVIGATION = (
|
||||
@@ -15,6 +16,7 @@ NAVIGATION = (
|
||||
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
|
||||
("miners", "Miners", "/assets/miners", "miners.view"),
|
||||
("system_health", "System Health", "/system-health", "system_health.view"),
|
||||
("agent_management", "Agents", "/agent/management", "agents.view"),
|
||||
)
|
||||
|
||||
PAGE_DETAILS = {
|
||||
@@ -53,6 +55,11 @@ PAGE_DETAILS = {
|
||||
"System Health",
|
||||
"Inspect application services, operational data, and maintenance facts.",
|
||||
),
|
||||
"agent_management": (
|
||||
"Administration / Agents",
|
||||
"Agent Management",
|
||||
"Inspect Agent identities, routing boundaries, and secure enrollment.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -462,7 +469,9 @@ def render_app_page(
|
||||
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
|
||||
)
|
||||
content = (
|
||||
system_health_content()
|
||||
agent_management_content(has_permission("agents.manage"))
|
||||
if view == "agent_management"
|
||||
else system_health_content()
|
||||
if view == "system_health"
|
||||
else miner_detail_content()
|
||||
if view == "miner_detail"
|
||||
|
||||
@@ -779,10 +779,14 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
return "repairs.read"
|
||||
if component_request(path):
|
||||
return "components.create" if method == "POST" else "components.read"
|
||||
if path == "/api/agents":
|
||||
return "agents.view"
|
||||
if path == "/api/agent-zones":
|
||||
return "agents.view" if method == "GET" else "agents.manage"
|
||||
if path == "/api/agent-enrollments":
|
||||
return "agents.view" if method == "GET" else "agents.manage"
|
||||
if path == "/api/agent-enrollments/windows-package":
|
||||
return "agents.manage"
|
||||
if repair_parts_request(path):
|
||||
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||
if path.startswith("/api/admin/"):
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
|
||||
from app.config import settings
|
||||
from app.agent_commands.dependencies import require_agent_identity
|
||||
from app.agent_commands.router import router as agent_commands_router
|
||||
from app.agent_registry.router import router as agent_registry_router
|
||||
from app.agent_zones.dependencies import get_agent_zone_service
|
||||
from app.agent_zones.models import MinerZoneAssociationResult
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
@@ -73,6 +74,7 @@ app.include_router(frontend_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(miner_control_router)
|
||||
app.include_router(agent_commands_router)
|
||||
app.include_router(agent_registry_router)
|
||||
app.include_router(agent_zones_router)
|
||||
app.include_router(agent_enrollment_router)
|
||||
app.include_router(components_router)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Frontend contract tests for Agent Management."""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend.router import router
|
||||
from app.identity import get_current_user, required_page_permission
|
||||
|
||||
|
||||
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
|
||||
|
||||
|
||||
class AgentManagementFrontendTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def authenticate(self, permissions):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": "00000000-0000-4000-8000-000000009001",
|
||||
"username": "agent-test-user",
|
||||
"display_name": "Agent Test User",
|
||||
"role": "Test Role",
|
||||
"permissions": tuple(permissions),
|
||||
}
|
||||
|
||||
def test_page_requires_agents_view(self):
|
||||
self.authenticate(("unrelated.permission",))
|
||||
self.assertEqual(
|
||||
self.client.get("/agent/management").status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
self.authenticate(("agents.view",))
|
||||
response = self.client.get("/agent/management")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Agent Management", response.text)
|
||||
self.assertIn('href="/agent/management"', response.text)
|
||||
|
||||
def test_manage_controls_follow_agents_manage_permission(self):
|
||||
self.authenticate(("agents.view",))
|
||||
|
||||
restricted = self.client.get("/agent/management").text
|
||||
|
||||
self.assertNotIn('id="agentCreateForm"', restricted)
|
||||
restricted_text = " ".join(restricted.split())
|
||||
self.assertIn(
|
||||
"cannot create Agent enrollments.",
|
||||
restricted_text,
|
||||
)
|
||||
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
managed = self.client.get("/agent/management").text
|
||||
|
||||
self.assertIn('id="agentCreateForm"', managed)
|
||||
self.assertIn('id="agentSite"', managed)
|
||||
self.assertIn('id="agentZone"', managed)
|
||||
self.assertIn('id="agentDisplayName"', managed)
|
||||
self.assertIn("Linux x64 — coming later", managed)
|
||||
self.assertIn("Linux ARM64 / Raspberry Pi — coming later", managed)
|
||||
|
||||
def test_route_and_asset_contract(self):
|
||||
self.assertEqual(
|
||||
required_page_permission("/agent/management"),
|
||||
"agents.view",
|
||||
)
|
||||
|
||||
self.authenticate(("agents.view",))
|
||||
|
||||
page = self.client.get("/agent/management")
|
||||
self.assertIn(
|
||||
"/assets/minerops-agent-management.js",
|
||||
page.text,
|
||||
)
|
||||
|
||||
asset = self.client.get(
|
||||
"/assets/minerops-agent-management.js"
|
||||
)
|
||||
|
||||
self.assertEqual(asset.status_code, 200)
|
||||
self.assertEqual(
|
||||
asset.headers.get("cache-control"),
|
||||
"no-store",
|
||||
)
|
||||
|
||||
def test_javascript_is_secret_conscious_and_safe_dom_only(self):
|
||||
source = (
|
||||
STATIC_ROOT / "agent-management.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('api("/api/agents")', source)
|
||||
self.assertIn('api("/api/agent-enrollments")', source)
|
||||
self.assertIn(
|
||||
'api("/api/agent-enrollments", {',
|
||||
source,
|
||||
)
|
||||
|
||||
for unsafe in (
|
||||
"innerHTML",
|
||||
"outerHTML",
|
||||
"insertAdjacentHTML",
|
||||
"localStorage",
|
||||
"sessionStorage",
|
||||
"console.log",
|
||||
):
|
||||
self.assertNotIn(unsafe, source)
|
||||
|
||||
self.assertIn(
|
||||
"el.agentEnrollmentToken.textContent",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"el.agentEnrollmentToken.textContent = \"\"",
|
||||
source,
|
||||
)
|
||||
|
||||
def test_agent_management_quiet_actions_have_visible_text(self):
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(
|
||||
".agent-management-columns .quiet-button,",
|
||||
css,
|
||||
)
|
||||
self.assertIn(
|
||||
".agent-enrollment-panel .quiet-button {",
|
||||
css,
|
||||
)
|
||||
self.assertIn(
|
||||
"color: var(--ink);",
|
||||
css,
|
||||
)
|
||||
|
||||
def test_windows_package_download_is_ephemeral(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
page = self.client.get("/agent/management").text
|
||||
|
||||
self.assertIn('id="agentEnrollmentDownload"', page)
|
||||
self.assertIn("Download Windows package", page)
|
||||
|
||||
source = (
|
||||
STATIC_ROOT / "agent-management.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(
|
||||
'"/api/agent-enrollments/windows-package"',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'link.download = "MinerOpsAgent-Windows.zip"',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"window.URL.revokeObjectURL(downloadUrl)",
|
||||
source,
|
||||
)
|
||||
self.assertIn("dismissToken();", source)
|
||||
|
||||
for forbidden in (
|
||||
"localStorage",
|
||||
"sessionStorage",
|
||||
"agent_secret",
|
||||
"api3_password",
|
||||
):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Focused tests for the Agent Management read model."""
|
||||
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
from app.identity import required_api_permission
|
||||
|
||||
|
||||
SITE_ID = UUID("00000000-0000-4000-8000-000000001800")
|
||||
ZONE_ID = UUID("00000000-0000-4000-8000-000000001801")
|
||||
|
||||
|
||||
class FakeAgentRegistryRepository:
|
||||
def list_registry(self):
|
||||
return {
|
||||
"sites": [
|
||||
{
|
||||
"id": SITE_ID,
|
||||
"name": "Test Site",
|
||||
}
|
||||
],
|
||||
"zones": [
|
||||
{
|
||||
"id": ZONE_ID,
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"name": "Network A",
|
||||
"description": None,
|
||||
"is_enabled": True,
|
||||
"assigned_agent_id": "agent-test",
|
||||
"networks": ["10.50.0.0/24"],
|
||||
}
|
||||
],
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "agent-test",
|
||||
"display_name": "Test Agent",
|
||||
"site_id": SITE_ID,
|
||||
"site_name": "Test Site",
|
||||
"agent_zone_id": ZONE_ID,
|
||||
"agent_zone_name": "Network A",
|
||||
"agent_zone_enabled": True,
|
||||
"hostname": "test-agent",
|
||||
"version": "1.0.0-alpha",
|
||||
"ip_address": "10.50.0.10",
|
||||
"last_checkin_at": "2026-08-26T20:00:00+00:00",
|
||||
"credential_state": "active",
|
||||
"credential_created_at": "2026-08-26T19:00:00+00:00",
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class AgentRegistryServiceTests(unittest.TestCase):
|
||||
def test_projection_is_factual_and_zone_aware(self):
|
||||
data = AgentRegistryService(
|
||||
FakeAgentRegistryRepository()
|
||||
).list_registry()
|
||||
|
||||
self.assertEqual(len(data.agents), 1)
|
||||
self.assertEqual(len(data.sites), 1)
|
||||
self.assertEqual(len(data.zones), 1)
|
||||
|
||||
agent = data.agents[0]
|
||||
self.assertEqual(agent.agent_id, "agent-test")
|
||||
self.assertEqual(agent.credential_state, "active")
|
||||
self.assertEqual(agent.routing_state, "assigned")
|
||||
self.assertEqual(
|
||||
data.zones[0].networks,
|
||||
["10.50.0.0/24"],
|
||||
)
|
||||
|
||||
def test_unassigned_and_disabled_routing_are_not_fabricated_online_states(self):
|
||||
repository = FakeAgentRegistryRepository()
|
||||
|
||||
repository.list_registry = lambda: {
|
||||
"sites": [],
|
||||
"zones": [],
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "legacy",
|
||||
"display_name": None,
|
||||
"site_id": None,
|
||||
"site_name": "Legacy Site",
|
||||
"agent_zone_id": None,
|
||||
"agent_zone_name": None,
|
||||
"agent_zone_enabled": None,
|
||||
"hostname": None,
|
||||
"version": None,
|
||||
"ip_address": None,
|
||||
"last_checkin_at": "2026-08-26T20:00:00+00:00",
|
||||
"credential_state": "legacy",
|
||||
"credential_created_at": None,
|
||||
"credential_revoked_at": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
data = AgentRegistryService(repository).list_registry()
|
||||
|
||||
self.assertEqual(
|
||||
data.agents[0].routing_state,
|
||||
"unassigned",
|
||||
)
|
||||
self.assertEqual(
|
||||
data.agents[0].credential_state,
|
||||
"legacy",
|
||||
)
|
||||
|
||||
def test_api_permission_mapping_is_explicit(self):
|
||||
self.assertEqual(
|
||||
required_api_permission("GET", "/api/agents"),
|
||||
"agents.view",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""PostgreSQL integration test for the Agent registry projection."""
|
||||
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
|
||||
from app.agent_registry.repository import (
|
||||
AgentRegistryRepository,
|
||||
default_connection_factory,
|
||||
)
|
||||
from app.agent_registry.service import AgentRegistryService
|
||||
|
||||
|
||||
class AgentRegistryPostgresTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.site_id = uuid4()
|
||||
self.zone_id = uuid4()
|
||||
self.agent_id = f"agent-registry-{uuid4().hex}"
|
||||
|
||||
connection = default_connection_factory()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(
|
||||
self.site_id,
|
||||
"Registry Integration Site",
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (
|
||||
id,
|
||||
site_id,
|
||||
name,
|
||||
description,
|
||||
is_enabled
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, true)
|
||||
""",
|
||||
(
|
||||
self.zone_id,
|
||||
self.site_id,
|
||||
"Registry Network",
|
||||
"Integration-test network",
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zone_network_ranges (
|
||||
agent_zone_id,
|
||||
network
|
||||
)
|
||||
VALUES (%s, '10.251.0.0/24'::cidr)
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_name,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
display_name,
|
||||
hostname,
|
||||
version,
|
||||
ip_address,
|
||||
agent_secret_hash,
|
||||
credential_created_at
|
||||
)
|
||||
VALUES (
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
now()
|
||||
)
|
||||
""",
|
||||
(
|
||||
self.agent_id,
|
||||
"Registry Integration Site",
|
||||
self.site_id,
|
||||
self.zone_id,
|
||||
"Registry Test Agent",
|
||||
"registry-agent",
|
||||
"1.0.0-alpha",
|
||||
"10.251.0.10",
|
||||
"a" * 64,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def tearDown(self):
|
||||
connection = default_connection_factory()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(self.agent_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zone_network_ranges
|
||||
WHERE agent_zone_id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM agent_zones
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.zone_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM sites
|
||||
WHERE id = %s
|
||||
""",
|
||||
(self.site_id,),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_registry_joins_site_zone_network_and_redacts_secret_hash(self):
|
||||
data = AgentRegistryService(
|
||||
AgentRegistryRepository()
|
||||
).list_registry()
|
||||
|
||||
agents = [
|
||||
item
|
||||
for item in data.agents
|
||||
if item.agent_id == self.agent_id
|
||||
]
|
||||
|
||||
self.assertEqual(len(agents), 1)
|
||||
|
||||
agent = agents[0]
|
||||
|
||||
self.assertEqual(
|
||||
agent.site_name,
|
||||
"Registry Integration Site",
|
||||
)
|
||||
self.assertEqual(
|
||||
agent.agent_zone_name,
|
||||
"Registry Network",
|
||||
)
|
||||
self.assertEqual(agent.credential_state, "active")
|
||||
self.assertEqual(agent.routing_state, "assigned")
|
||||
|
||||
zones = [
|
||||
zone
|
||||
for zone in data.zones
|
||||
if zone.id == self.zone_id
|
||||
]
|
||||
|
||||
self.assertEqual(len(zones), 1)
|
||||
self.assertEqual(
|
||||
zones[0].networks,
|
||||
["10.251.0.0/24"],
|
||||
)
|
||||
|
||||
serialized = data.model_dump_json()
|
||||
|
||||
self.assertNotIn("agent_secret_hash", serialized)
|
||||
self.assertNotIn("a" * 64, serialized)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,633 @@
|
||||
"""Runtime PostgreSQL isolation tests for Agent-owned machine transport."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.agent_zones.repository import AgentZoneRepository
|
||||
from app.agent_zones.service import AgentZoneService
|
||||
from app.config import settings
|
||||
from app.main import (
|
||||
minerops_agent_api3_enrichment,
|
||||
minerops_agent_miner_enrichment_v2,
|
||||
minerops_agent_miner_enrichment_v3,
|
||||
)
|
||||
|
||||
|
||||
SITE_A = UUID("00000000-0000-4000-8000-000000001710")
|
||||
SITE_B = UUID("00000000-0000-4000-8000-000000001711")
|
||||
ZONE_A = UUID("00000000-0000-4000-8000-000000001712")
|
||||
ZONE_B = UUID("00000000-0000-4000-8000-000000001713")
|
||||
MINER_A = UUID("00000000-0000-4000-8000-000000001714")
|
||||
MINER_B = UUID("00000000-0000-4000-8000-000000001715")
|
||||
|
||||
AGENT_A = "agent-transport-isolation-a"
|
||||
AGENT_B = "agent-transport-isolation-b"
|
||||
|
||||
IP_A = "198.51.100.171"
|
||||
IP_B = "198.51.100.172"
|
||||
|
||||
MAC_A = "02:00:00:00:17:A1"
|
||||
MAC_B = "02:00:00:00:17:B1"
|
||||
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def json_request(path, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
delivered = False
|
||||
|
||||
async def receive():
|
||||
nonlocal delivered
|
||||
|
||||
if delivered:
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": b"",
|
||||
"more_body": False,
|
||||
}
|
||||
|
||||
delivered = True
|
||||
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": body,
|
||||
"more_body": False,
|
||||
}
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode("ascii"),
|
||||
"query_string": b"",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("testclient", 12345),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
class AgentTransportIsolationPostgresTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if os.environ.get(
|
||||
"MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE"
|
||||
) != "1":
|
||||
raise unittest.SkipTest(
|
||||
"Runtime Agent transport isolation is executed "
|
||||
"by its dedicated disposable PostgreSQL runner."
|
||||
)
|
||||
|
||||
connection = connect()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sites (id, name)
|
||||
VALUES
|
||||
(%s, 'Transport Isolation Site A'),
|
||||
(%s, 'Transport Isolation Site B')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(SITE_A, SITE_B),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_zones (
|
||||
id,
|
||||
site_id,
|
||||
name,
|
||||
is_enabled
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
'Transport Isolation Zone A',
|
||||
true
|
||||
),
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
'Transport Isolation Zone B',
|
||||
true
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET is_enabled = true
|
||||
""",
|
||||
(ZONE_A, SITE_A, ZONE_B, SITE_B),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO minerops_site_agents (
|
||||
agent_id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
site_name,
|
||||
last_payload
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
'Transport Isolation Site A',
|
||||
'{}'::jsonb
|
||||
),
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
'Transport Isolation Site B',
|
||||
'{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (agent_id) DO UPDATE
|
||||
SET
|
||||
site_id = EXCLUDED.site_id,
|
||||
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||
site_name = EXCLUDED.site_name,
|
||||
last_payload = EXCLUDED.last_payload
|
||||
""",
|
||||
(
|
||||
AGENT_A,
|
||||
SITE_A,
|
||||
ZONE_A,
|
||||
AGENT_B,
|
||||
SITE_B,
|
||||
ZONE_B,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
# Immutable identity/history rows intentionally are not deleted.
|
||||
# The disposable Compose runner destroys the database volume.
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _delete_optional_rows(cur, table, predicate, params):
|
||||
cur.execute(
|
||||
"SELECT to_regclass(%s)",
|
||||
(f"public.{table}",),
|
||||
)
|
||||
|
||||
if cur.fetchone()[0] is not None:
|
||||
cur.execute(
|
||||
f"DELETE FROM {table} WHERE {predicate}",
|
||||
params,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.zone_service = AgentZoneService(
|
||||
AgentZoneRepository(connect)
|
||||
)
|
||||
|
||||
connection = connect()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO miners (
|
||||
id,
|
||||
site_id,
|
||||
agent_zone_id,
|
||||
asset_tag,
|
||||
current_mac,
|
||||
current_ip,
|
||||
hostname,
|
||||
miner_sn,
|
||||
model,
|
||||
miner_type,
|
||||
api_version,
|
||||
firmware_version,
|
||||
platform,
|
||||
status,
|
||||
notes,
|
||||
api3_supported
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
'TRANSPORT-ZONE-A',
|
||||
%s,
|
||||
%s::inet,
|
||||
'zone-a-before',
|
||||
'ZONE-A-SERIAL',
|
||||
'Zone A Model',
|
||||
'Zone A Model',
|
||||
'1.0-a',
|
||||
'fw-a',
|
||||
'platform-a',
|
||||
'online',
|
||||
'Zone A baseline',
|
||||
false
|
||||
),
|
||||
(
|
||||
%s,
|
||||
%s,
|
||||
%s,
|
||||
'TRANSPORT-ZONE-B',
|
||||
%s,
|
||||
%s::inet,
|
||||
'zone-b-before',
|
||||
'ZONE-B-SERIAL',
|
||||
'Zone B Model',
|
||||
'Zone B Model',
|
||||
'1.0-b',
|
||||
'fw-b',
|
||||
'platform-b',
|
||||
'online',
|
||||
'Zone B baseline',
|
||||
false
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
MINER_A,
|
||||
SITE_A,
|
||||
ZONE_A,
|
||||
MAC_A,
|
||||
IP_A,
|
||||
MINER_B,
|
||||
SITE_B,
|
||||
ZONE_B,
|
||||
MAC_B,
|
||||
IP_B,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _table_exists(self, cur, table):
|
||||
cur.execute(
|
||||
"SELECT to_regclass(%s)",
|
||||
(f"public.{table}",),
|
||||
)
|
||||
return cur.fetchone()[0] is not None
|
||||
|
||||
def _snapshot_rows(self, cur, table):
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT to_jsonb(t)
|
||||
FROM {table} AS t
|
||||
WHERE miner_id IN (%s, %s)
|
||||
ORDER BY to_jsonb(t)::text
|
||||
""",
|
||||
(MINER_A, MINER_B),
|
||||
)
|
||||
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
|
||||
def _count_optional_miner_rows(self, cur, table, miner_id):
|
||||
if not self._table_exists(cur, table):
|
||||
return 0
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM {table}
|
||||
WHERE miner_id = %s
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def _capture_inventory_state(self):
|
||||
connection = connect()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id::text, to_jsonb(m)
|
||||
FROM miners AS m
|
||||
WHERE id IN (%s, %s)
|
||||
ORDER BY id
|
||||
""",
|
||||
(MINER_A, MINER_B),
|
||||
)
|
||||
|
||||
miners = {
|
||||
row[0]: row[1]
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
|
||||
ownerships = self._snapshot_rows(
|
||||
cur,
|
||||
"miner_mac_ownerships",
|
||||
)
|
||||
|
||||
identities = self._snapshot_rows(
|
||||
cur,
|
||||
"miner_network_identities",
|
||||
)
|
||||
|
||||
counts = {}
|
||||
|
||||
for table in (
|
||||
"miner_status_snapshots",
|
||||
"miner_api3_enrichment_snapshots",
|
||||
"miner_api3_hashboard_snapshots",
|
||||
"miner_pool_snapshots",
|
||||
):
|
||||
counts[(table, "A")] = (
|
||||
self._count_optional_miner_rows(
|
||||
cur,
|
||||
table,
|
||||
MINER_A,
|
||||
)
|
||||
)
|
||||
counts[(table, "B")] = (
|
||||
self._count_optional_miner_rows(
|
||||
cur,
|
||||
table,
|
||||
MINER_B,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"miners": miners,
|
||||
"mac_ownerships": ownerships,
|
||||
"network_identities": identities,
|
||||
"snapshot_counts": counts,
|
||||
}
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _cross_zone_api3_probe_count(self):
|
||||
connection = connect()
|
||||
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
if not self._table_exists(
|
||||
cur,
|
||||
"miner_api3_enrichment_snapshots",
|
||||
):
|
||||
return 0
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM miner_api3_enrichment_snapshots
|
||||
WHERE agent_id = %s
|
||||
AND (
|
||||
host(ip_address) = %s
|
||||
OR upper(mac_address) = upper(%s)
|
||||
)
|
||||
""",
|
||||
(AGENT_A, IP_B, MAC_B),
|
||||
)
|
||||
|
||||
return cur.fetchone()[0]
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_v2_cannot_address_other_zone_miner_directly(self):
|
||||
before = self._capture_inventory_state()
|
||||
|
||||
request = json_request(
|
||||
"/api/agent/miner-enrichment-v2",
|
||||
{
|
||||
"miner_id": str(MINER_B),
|
||||
"mac_address": MAC_B,
|
||||
"hostname": "zone-a-must-not-write-this",
|
||||
"model": "cross-zone-v2-probe",
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
asyncio.run(
|
||||
minerops_agent_miner_enrichment_v2(
|
||||
request,
|
||||
AGENT_A,
|
||||
self.zone_service,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.status_code, 404)
|
||||
|
||||
after = self._capture_inventory_state()
|
||||
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_v2_cross_zone_mac_merge_cannot_mutate_either_miner(self):
|
||||
before = self._capture_inventory_state()
|
||||
|
||||
request = json_request(
|
||||
"/api/agent/miner-enrichment-v2",
|
||||
{
|
||||
"miner_id": str(MINER_A),
|
||||
"ip_address": IP_A,
|
||||
"mac_address": MAC_B,
|
||||
"hostname": "cross-zone-merge-probe",
|
||||
"miner_sn": "MUST-NOT-CROSS-ZONES",
|
||||
"model": "Cross Zone Merge Probe",
|
||||
},
|
||||
)
|
||||
|
||||
error = None
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
minerops_agent_miner_enrichment_v2(
|
||||
request,
|
||||
AGENT_A,
|
||||
self.zone_service,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
|
||||
self.assertIsNotNone(
|
||||
error,
|
||||
"A Zone A miner must not successfully claim a Zone B MAC",
|
||||
)
|
||||
|
||||
after = self._capture_inventory_state()
|
||||
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_v3_cannot_match_or_mutate_other_zone_miner(self):
|
||||
before = self._capture_inventory_state()
|
||||
|
||||
request = json_request(
|
||||
"/api/agent/miner-enrichment-v3",
|
||||
{
|
||||
"miner_id": str(MINER_B),
|
||||
"ip_address": IP_B,
|
||||
"mac_address": MAC_B,
|
||||
"hostname": "zone-a-v3-cross-zone-probe",
|
||||
"miner_sn": "V3-MUST-NOT-MUTATE",
|
||||
"model": "V3 Cross Zone Probe",
|
||||
"api_version": "99.99",
|
||||
"firmware_version": "cross-zone",
|
||||
"platform": "cross-zone",
|
||||
"raw_version": {"cross_zone": True},
|
||||
"raw_miner_info": {"cross_zone": True},
|
||||
},
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
minerops_agent_miner_enrichment_v3(
|
||||
request,
|
||||
AGENT_A,
|
||||
self.zone_service,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(result["matched"])
|
||||
|
||||
after = self._capture_inventory_state()
|
||||
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_api3_ingestion_cannot_associate_or_mutate_other_zone_miner(self):
|
||||
before = self._capture_inventory_state()
|
||||
|
||||
self.assertEqual(
|
||||
self._cross_zone_api3_probe_count(),
|
||||
0,
|
||||
)
|
||||
|
||||
request = json_request(
|
||||
"/api/agent/api3-enrichment",
|
||||
{
|
||||
"agent_id": AGENT_B,
|
||||
"site_name": "spoofed-zone-b",
|
||||
"items": [
|
||||
{
|
||||
"ip": IP_B,
|
||||
"mac": MAC_B,
|
||||
"api3_supported": True,
|
||||
"api3_error": None,
|
||||
"device_info": {
|
||||
"msg": {
|
||||
"network": {
|
||||
"ip": IP_B,
|
||||
"mac": MAC_B,
|
||||
},
|
||||
"miner": {
|
||||
"type": "Cross Zone API3 Probe",
|
||||
"miner-sn": "API3-MUST-NOT-MUTATE",
|
||||
},
|
||||
"system": {
|
||||
"api": "99.99",
|
||||
"apiswitch": "on",
|
||||
"fwversion": "cross-zone-fw",
|
||||
"platform": "cross-zone-platform",
|
||||
},
|
||||
"power": {
|
||||
"sn": "cross-zone-power",
|
||||
"model": "cross-zone-psu",
|
||||
},
|
||||
}
|
||||
},
|
||||
"miner_setting": {
|
||||
"msg": {
|
||||
"power-mode": "high",
|
||||
}
|
||||
},
|
||||
"miner_status": {
|
||||
"msg": {
|
||||
"summary": {
|
||||
"hash-average": 999,
|
||||
"power-realtime": 9999,
|
||||
"power-rate": 99,
|
||||
"chip-temp-avg": 99,
|
||||
"chip-temp-max": 100,
|
||||
},
|
||||
"pools": [
|
||||
{
|
||||
"id": 0,
|
||||
"status": "Alive",
|
||||
"account": "cross-zone-worker",
|
||||
"url": "stratum+tcp://invalid.example:3333",
|
||||
}
|
||||
],
|
||||
"edevs": [
|
||||
{
|
||||
"slot": 0,
|
||||
"hash-average": 999,
|
||||
"factory-hash": 999,
|
||||
"freq": 999,
|
||||
"effective-chips": 999,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"fan_setting": {
|
||||
"msg": {
|
||||
"mode": "cross-zone",
|
||||
}
|
||||
},
|
||||
"custom_data": {
|
||||
"msg": {
|
||||
"CustomerSn": "CROSS-ZONE-CUSTOMER",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
minerops_agent_api3_enrichment(
|
||||
request,
|
||||
AGENT_A,
|
||||
self.zone_service,
|
||||
)
|
||||
)
|
||||
|
||||
after = self._capture_inventory_state()
|
||||
|
||||
self.assertEqual(after, before)
|
||||
|
||||
self.assertEqual(
|
||||
self._cross_zone_api3_probe_count(),
|
||||
0,
|
||||
(
|
||||
"Agent A must not persist API3 enrichment associated "
|
||||
"with Zone B identity data"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for downloadable Windows Agent enrollment packages."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.agent_enrollment.router import router
|
||||
from app.agent_packages.windows import (
|
||||
AgentWindowsPackageError,
|
||||
AgentWindowsPackageSourceError,
|
||||
WINDOWS_PACKAGE_FILES,
|
||||
WINDOWS_PACKAGE_ROOT,
|
||||
build_windows_agent_package,
|
||||
normalize_hub_url,
|
||||
)
|
||||
from app.identity import get_current_user, required_api_permission
|
||||
|
||||
|
||||
TOKEN = "t" * 43
|
||||
|
||||
|
||||
class WindowsPackageBuilderTests(unittest.TestCase):
|
||||
def source_tree(self, root: Path):
|
||||
for name in WINDOWS_PACKAGE_FILES:
|
||||
(root / name).write_text(f"source:{name}\n")
|
||||
|
||||
def test_package_contains_only_generic_source_plus_bootstrap_material(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.source_tree(root)
|
||||
|
||||
package = build_windows_agent_package(
|
||||
enrollment_token=TOKEN,
|
||||
hub_url="https://minerops.example.test",
|
||||
source_root=root,
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(package)) as archive:
|
||||
names = set(archive.namelist())
|
||||
|
||||
expected = {
|
||||
f"{WINDOWS_PACKAGE_ROOT}/{name}"
|
||||
for name in WINDOWS_PACKAGE_FILES
|
||||
}
|
||||
|
||||
expected.update(
|
||||
{
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json",
|
||||
f"{WINDOWS_PACKAGE_ROOT}/INSTALL.txt",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(names, expected)
|
||||
|
||||
enrollment = json.loads(
|
||||
archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
self.assertEqual(
|
||||
enrollment["hub_url"],
|
||||
"https://minerops.example.test",
|
||||
)
|
||||
|
||||
serialized = json.dumps(enrollment)
|
||||
|
||||
self.assertNotIn("agent_secret", serialized)
|
||||
self.assertNotIn("api3_password", serialized)
|
||||
self.assertNotIn("api3_account", serialized)
|
||||
self.assertNotIn("agent-config.json", "\n".join(names))
|
||||
|
||||
def test_missing_canonical_source_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.source_tree(root)
|
||||
|
||||
(root / WINDOWS_PACKAGE_FILES[0]).unlink()
|
||||
|
||||
with self.assertRaises(AgentWindowsPackageSourceError):
|
||||
build_windows_agent_package(
|
||||
enrollment_token=TOKEN,
|
||||
hub_url="https://minerops.example.test",
|
||||
source_root=root,
|
||||
)
|
||||
|
||||
def test_hub_url_is_origin_only(self):
|
||||
self.assertEqual(
|
||||
normalize_hub_url("https://minerops.example.test/"),
|
||||
"https://minerops.example.test",
|
||||
)
|
||||
|
||||
for invalid in (
|
||||
"ftp://minerops.example.test",
|
||||
"https://user:pass@minerops.example.test",
|
||||
"https://minerops.example.test/path",
|
||||
"https://minerops.example.test/?token=x",
|
||||
):
|
||||
with self.subTest(value=invalid):
|
||||
with self.assertRaises(AgentWindowsPackageError):
|
||||
normalize_hub_url(invalid)
|
||||
|
||||
|
||||
class WindowsPackageRouteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(router)
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def authenticate(self, permissions):
|
||||
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||
"id": "00000000-0000-4000-8000-000000009100",
|
||||
"username": "package-test",
|
||||
"display_name": "Package Test",
|
||||
"role": "Test",
|
||||
"permissions": tuple(permissions),
|
||||
}
|
||||
|
||||
def test_permission_mapping_is_manage_only(self):
|
||||
self.assertEqual(
|
||||
required_api_permission(
|
||||
"POST",
|
||||
"/api/agent-enrollments/windows-package",
|
||||
),
|
||||
"agents.manage",
|
||||
)
|
||||
|
||||
def test_view_only_user_cannot_download_package(self):
|
||||
self.authenticate(("agents.view",))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
headers={"Origin": "http://testserver"},
|
||||
json={
|
||||
"token": TOKEN,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_manager_download_is_same_origin_and_not_cacheable(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
headers={"Origin": "http://testserver"},
|
||||
json={
|
||||
"token": TOKEN,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.headers["content-type"],
|
||||
"application/zip",
|
||||
)
|
||||
self.assertEqual(
|
||||
response.headers["cache-control"],
|
||||
"no-store",
|
||||
)
|
||||
self.assertIn(
|
||||
'filename="MinerOpsAgent-Windows.zip"',
|
||||
response.headers["content-disposition"],
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
enrollment = json.loads(
|
||||
archive.read(
|
||||
f"{WINDOWS_PACKAGE_ROOT}/enrollment.json"
|
||||
)
|
||||
)
|
||||
self.assertEqual(enrollment["token"], TOKEN)
|
||||
|
||||
def test_package_requires_browser_origin(self):
|
||||
self.authenticate(("agents.view", "agents.manage"))
|
||||
|
||||
response = self.client.post(
|
||||
"/api/agent-enrollments/windows-package",
|
||||
json={
|
||||
"token": TOKEN,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-1
@@ -32,7 +32,8 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
env_file:
|
||||
- .env.dev
|
||||
command:
|
||||
|
||||
+3
-2
@@ -34,7 +34,8 @@
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
container_name: minerops-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -51,4 +52,4 @@
|
||||
|
||||
networks:
|
||||
minerops-net:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="${MINEROPS_AGENT_MANAGEMENT_TEST_PROJECT:-minerops-agent-management-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/app/agent_registry/__init__.py \
|
||||
backend/app/agent_registry/models.py \
|
||||
backend/app/agent_registry/repository.py \
|
||||
backend/app/agent_registry/service.py \
|
||||
backend/app/agent_registry/dependencies.py \
|
||||
backend/app/agent_registry/router.py \
|
||||
backend/tests/test_agent_registry.py \
|
||||
backend/tests/test_agent_registry_postgres.py \
|
||||
backend/tests/test_agent_management_frontend.py \
|
||||
backend/app/frontend/agent_management_view.py \
|
||||
backend/app/frontend/static/agent-management.js
|
||||
do
|
||||
[[ -r "${source_file}" ]] || {
|
||||
echo "Missing ${source_file}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
"${compose[@]}" up --detach postgres
|
||||
|
||||
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
|
||||
then
|
||||
break
|
||||
fi
|
||||
|
||||
(( 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" \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_registry \
|
||||
tests.test_agent_registry_postgres \
|
||||
tests.test_agent_management_frontend
|
||||
|
||||
echo "Agent Management tests passed."
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PROJECT="minerops-agent-transport-isolation-test-$$"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "$PROJECT"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
"${compose[@]}" down \
|
||||
--volumes \
|
||||
--remove-orphans \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== START DISPOSABLE POSTGRES ==="
|
||||
"${compose[@]}" up --detach postgres
|
||||
|
||||
echo
|
||||
echo "=== WAIT FOR POSTGRES ==="
|
||||
|
||||
ready=0
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if "${compose[@]}" exec -T postgres \
|
||||
sh -c 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
>/dev/null 2>&1
|
||||
then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "PostgreSQL did not become ready."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== BUILD BACKEND ==="
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo
|
||||
echo "=== MIGRATE DATABASE ==="
|
||||
"${compose[@]}" run --rm backend \
|
||||
alembic upgrade head
|
||||
|
||||
echo
|
||||
echo "=== RUNTIME AGENT ZONE ISOLATION ==="
|
||||
"${compose[@]}" run --rm \
|
||||
--volume "$PWD/backend/tests:/app/tests:ro" \
|
||||
--env MINEROPS_TRANSPORT_ISOLATION_DISPOSABLE=1 \
|
||||
backend \
|
||||
python -m unittest -v \
|
||||
tests.test_agent_transport_isolation_postgres
|
||||
|
||||
echo
|
||||
echo "Runtime Agent transport Zone-isolation tests passed."
|
||||
@@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")"
|
||||
|
||||
echo "Checking migrations and required schema..."
|
||||
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||
[[ "${current_revision}" == *"0016_agent_zones"* ]]
|
||||
[[ "${current_revision}" == *"0017_agent_enrollment"* ]]
|
||||
|
||||
missing_tables="$(
|
||||
"${compose[@]}" exec -T postgres sh -c \
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
project_name="minerops-windows-package-test-$$"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "${project_name}"
|
||||
--env-file .env.dev
|
||||
-f compose.dev.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
"${compose[@]}" down --volumes --remove-orphans \
|
||||
>/dev/null 2>&1 || true
|
||||
exit "${status}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== BUILD BACKEND WITH CANONICAL AGENT SOURCE ==="
|
||||
"${compose[@]}" build backend
|
||||
|
||||
echo
|
||||
echo "=== BACKEND PACKAGE TESTS ==="
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
-v "${repo_root}/backend/tests:/app/tests:ro" \
|
||||
backend python -m unittest -v \
|
||||
tests.test_agent_windows_package
|
||||
|
||||
echo
|
||||
echo "=== VERIFY CANONICAL SOURCE IS IN IMAGE ==="
|
||||
"${compose[@]}" run --rm --no-deps \
|
||||
backend sh -ec '
|
||||
test -f /app/agents/windows/Enroll-MinerOpsAgent.ps1
|
||||
test -f /app/agents/windows/install.ps1
|
||||
test -f /app/agents/windows/MinerOpsAgentService.ps1
|
||||
test -f /app/agents/windows/VERSION
|
||||
'
|
||||
|
||||
echo
|
||||
echo "=== OFFICIAL POWERSHELL VALIDATION ==="
|
||||
docker run --rm \
|
||||
-v "${repo_root}/agents/windows:/work:ro" \
|
||||
mcr.microsoft.com/powershell:7.4-debian-12 \
|
||||
pwsh -NoLogo -NoProfile -Command '
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
foreach ($script in @(
|
||||
"/work/Enroll-MinerOpsAgent.ps1",
|
||||
"/work/install.ps1"
|
||||
)) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$script,
|
||||
[ref]$tokens,
|
||||
[ref]$errors
|
||||
) | Out-Null
|
||||
|
||||
if ($errors.Count -gt 0) {
|
||||
throw "$script parser errors: $($errors.Message -join "; ")"
|
||||
}
|
||||
|
||||
Write-Host "Parsed $script"
|
||||
}
|
||||
|
||||
& /work/tests/Test-MinerOpsEnrollment.ps1
|
||||
& /work/tests/Test-MinerOpsInstaller.ps1
|
||||
'
|
||||
|
||||
echo
|
||||
echo "Windows Agent package foundation tests passed."
|
||||
Reference in new issue
Block a user