feat: add self-enrolling Windows Agent package
This commit is contained in:
1 parent
b9b5e5e36f
commit
abf5654a25
20 files changed
+1834
-42
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()
|
||||
@@ -86,6 +86,11 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
<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">
|
||||
@@ -103,9 +108,10 @@ def agent_management_content(can_manage_agents: bool) -> str:
|
||||
aria-live="polite"></p>
|
||||
|
||||
<p class="panel-copy">
|
||||
The self-enrolling Windows installer is the next deployment
|
||||
milestone. For now this verifies the complete secure
|
||||
enrollment workflow.
|
||||
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>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"agentEnrollmentResult",
|
||||
"agentEnrollmentToken",
|
||||
"agentEnrollmentExpires",
|
||||
"agentEnrollmentDownload",
|
||||
"agentEnrollmentCopy",
|
||||
"agentEnrollmentDismiss",
|
||||
"agentEnrollmentCopyStatus"
|
||||
@@ -359,6 +360,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -399,6 +469,13 @@
|
||||
el.agentCreateForm.addEventListener("submit", createEnrollment);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentDownload) {
|
||||
el.agentEnrollmentDownload.addEventListener(
|
||||
"click",
|
||||
downloadWindowsPackage
|
||||
);
|
||||
}
|
||||
|
||||
if (el.agentEnrollmentCopy) {
|
||||
el.agentEnrollmentCopy.addEventListener("click", copyToken);
|
||||
}
|
||||
|
||||
@@ -785,6 +785,8 @@ def required_api_permission(method: str, path: str) -> str | None:
|
||||
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/"):
|
||||
|
||||
@@ -136,6 +136,40 @@ class AgentManagementFrontendTests(unittest.TestCase):
|
||||
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,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
+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