fix: preserve Windows agent tasks during upgrades
This commit is contained in:
1 parent
f5965af914
commit
d8cd7ad058
4 files changed
+255
-34
No files matched your search
@@ -51,15 +51,24 @@ Deploy Agent compatibility before enabling Hub enforcement of the
|
|||||||
reports the expected non-secret `VERSION`.
|
reports the expected non-secret `VERSION`.
|
||||||
5. Enable the matching Agent ID and shared secret on the Hub.
|
5. Enable the matching Agent ID and shared secret on the Hub.
|
||||||
|
|
||||||
The installer copies runtime code to `C:\MinerOpsAgent`, preserves
|
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
|
||||||
`agent-config.json`, `agent-state.json`, and `logs\`, and registers or
|
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
|
||||||
updates `MinerOpsAgent`, `MinerOpsAgentTray`,
|
output, and other runtime data. During an upgrade, existing definitions for
|
||||||
`MinerOpsApi3Enrichment`, and `MinerOpsPoolPoller`.
|
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
|
||||||
|
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
|
||||||
|
created for a fresh or incomplete installation without hard-coded user names.
|
||||||
|
|
||||||
|
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
|
||||||
|
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
|
||||||
|
again. A previously stopped Agent remains stopped. The tray and periodic tasks
|
||||||
|
are not unnecessarily restarted.
|
||||||
|
|
||||||
Before replacing existing runtime files, the installer copies them to a
|
Before replacing existing runtime files, the installer copies them to a
|
||||||
timestamped folder under `C:\MinerOpsAgent\backups`. To roll back, stop the
|
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
|
||||||
Agent tasks, copy the selected backup's runtime files back into
|
automatically restores those managed files, removes tasks created by that
|
||||||
`C:\MinerOpsAgent`, and restart the tasks. `uninstall.ps1` unregisters the
|
failed attempt, and restores the main Agent's prior running state. For a manual
|
||||||
four tasks and removes runtime code while preserving local configuration,
|
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.
|
state, logs, and backups.
|
||||||
|
|
||||||
+165
-25
@@ -3,7 +3,9 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
$Target = "C:\MinerOpsAgent"
|
$Target = "C:\MinerOpsAgent"
|
||||||
$BackupRoot = Join-Path $Target "backups"
|
$BackupRoot = Join-Path $Target "backups"
|
||||||
$Backup = Join-Path $BackupRoot (Get-Date -Format "yyyyMMdd-HHmmss")
|
$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
|
$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix"
|
||||||
$RuntimeFiles = @(
|
$RuntimeFiles = @(
|
||||||
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
|
||||||
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
|
||||||
@@ -11,52 +13,190 @@ $RuntimeFiles = @(
|
|||||||
"VERSION"
|
"VERSION"
|
||||||
)
|
)
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
|
function Get-MinerOpsScheduledTask {
|
||||||
$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) })
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
if ($existing.Count -gt 0) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
|
return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||||
foreach ($name in $existing) {
|
}
|
||||||
Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force
|
|
||||||
|
function Stop-MinerOpsScheduledTaskForUpgrade {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Name,
|
||||||
|
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
|
||||||
|
)
|
||||||
|
|
||||||
|
Stop-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||||
|
$deadline = [DateTime]::UtcNow.Add($Timeout)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
$task = Get-MinerOpsScheduledTask -Name $Name
|
||||||
|
if ($null -eq $task) {
|
||||||
|
throw "Scheduled Task $Name disappeared while stopping it."
|
||||||
|
}
|
||||||
|
if ([string]$task.State -ne "Running") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
|
|
||||||
|
throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-MinerOpsScheduledTask {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
Start-ScheduledTask -TaskName $Name -ErrorAction Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate the complete source before creating directories, stopping tasks, or
|
||||||
|
# replacing any target file.
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
$source = Join-Path $PSScriptRoot $name
|
||||||
|
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||||
|
throw "Deployment source is missing $name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskExisted = $null -ne $mainTask
|
||||||
|
$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
$deploymentStarted = $false
|
||||||
|
$existing = @()
|
||||||
|
$createdTaskNames = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
|
||||||
|
$existing = @($RuntimeFiles | Where-Object {
|
||||||
|
Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf
|
||||||
|
})
|
||||||
|
if ($existing.Count -gt 0) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
|
||||||
|
foreach ($name in $existing) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Target $name) `
|
||||||
|
-Destination (Join-Path $Backup $name) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainTaskWasRunning) {
|
||||||
|
$mainTaskStoppedForUpgrade = $true
|
||||||
|
Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent"
|
||||||
|
}
|
||||||
|
|
||||||
|
$deploymentStarted = $true
|
||||||
foreach ($name in $RuntimeFiles) {
|
foreach ($name in $RuntimeFiles) {
|
||||||
$source = Join-Path $PSScriptRoot $name
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) `
|
||||||
if (!(Test-Path $source)) { throw "Deployment source is missing $name" }
|
-Destination (Join-Path $Target $name) -Force
|
||||||
Copy-Item $source (Join-Path $Target $name) -Force
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
|
||||||
|
-LogonType ServiceAccount -RunLevel Highest
|
||||||
$startup = New-ScheduledTaskTrigger -AtStartup
|
$startup = New-ScheduledTaskTrigger -AtStartup
|
||||||
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||||
-RepetitionInterval (New-TimeSpan -Minutes 5)
|
-RepetitionInterval (New-TimeSpan -Minutes 5)
|
||||||
|
$commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||||
|
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Hours 72)
|
||||||
|
$agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
|
||||||
|
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||||
|
|
||||||
$definitions = @(
|
$definitions = @(
|
||||||
@{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system },
|
@{
|
||||||
@{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null },
|
Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"
|
||||||
@{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system },
|
Trigger = $startup; Principal = $system; Settings = $agentSettings
|
||||||
@{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system }
|
ExtraArguments = ""
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"
|
||||||
|
Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null
|
||||||
|
Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsApi3Enrichment"
|
||||||
|
Script = "MinerOpsApi3EnrichmentPoller.ps1"
|
||||||
|
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||||
|
ExtraArguments = "-Limit 0 -TimeoutMs 8000"
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"
|
||||||
|
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
|
||||||
|
ExtraArguments = ""
|
||||||
|
}
|
||||||
)
|
)
|
||||||
foreach ($definition in $definitions) {
|
foreach ($definition in $definitions) {
|
||||||
|
if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) {
|
||||||
|
Write-Host "Preserving existing Scheduled Task $($definition.Name)."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
|
||||||
(Join-Path $Target $definition.Script)
|
(Join-Path $Target $definition.Script)
|
||||||
|
if ($definition.ExtraArguments) {
|
||||||
|
$arguments = "$arguments $($definition.ExtraArguments)"
|
||||||
|
}
|
||||||
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
|
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
|
||||||
|
$registration = @{
|
||||||
|
TaskName = $definition.Name
|
||||||
|
Action = $action
|
||||||
|
Trigger = $definition.Trigger
|
||||||
|
Settings = $definition.Settings
|
||||||
|
}
|
||||||
if ($definition.Principal) {
|
if ($definition.Principal) {
|
||||||
Register-ScheduledTask -TaskName $definition.Name -Action $action `
|
$registration.Principal = $definition.Principal
|
||||||
-Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null
|
|
||||||
} else {
|
|
||||||
Register-ScheduledTask -TaskName $definition.Name -Action $action `
|
|
||||||
-Trigger $definition.Trigger -Force | Out-Null
|
|
||||||
}
|
}
|
||||||
|
Register-ScheduledTask @registration | Out-Null
|
||||||
|
$createdTaskNames.Add($definition.Name)
|
||||||
}
|
}
|
||||||
Write-Host "MinerOps Windows Agent installed. Existing config, state, and logs were preserved."
|
|
||||||
|
if ($mainTaskWasRunning) {
|
||||||
|
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved."
|
||||||
} catch {
|
} catch {
|
||||||
if (Test-Path $Backup) {
|
$deploymentError = $_
|
||||||
foreach ($name in $existing) {
|
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
|
||||||
Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force
|
|
||||||
|
foreach ($taskName in $createdTaskNames) {
|
||||||
|
try {
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false `
|
||||||
|
-ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw
|
|
||||||
|
if ($deploymentStarted) {
|
||||||
|
foreach ($name in $RuntimeFiles) {
|
||||||
|
try {
|
||||||
|
$targetPath = Join-Path $Target $name
|
||||||
|
if ($existing -contains $name) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Backup $name) `
|
||||||
|
-Destination $targetPath -Force
|
||||||
|
} elseif (Test-Path -LiteralPath $targetPath) {
|
||||||
|
Remove-Item -LiteralPath $targetPath -Force
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) {
|
||||||
|
try {
|
||||||
|
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
|
||||||
|
$mainTaskStoppedForUpgrade = $false
|
||||||
|
} catch {
|
||||||
|
$rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rollbackErrors.Count -gt 0) {
|
||||||
|
$rollbackSummary = $rollbackErrors -join "; "
|
||||||
|
throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary"
|
||||||
|
}
|
||||||
|
throw $deploymentError
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$installer, [ref]$tokens, [ref]$parseErrors
|
||||||
|
) | Out-Null
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = Get-Content -LiteralPath $installer -Raw
|
||||||
|
function Assert-SourceContains {
|
||||||
|
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
|
||||||
|
if ($source -notmatch $Pattern) { throw $Message }
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeMatch = [regex]::Match(
|
||||||
|
$source,
|
||||||
|
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
|
||||||
|
)
|
||||||
|
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
|
||||||
|
$runtimeFiles = $runtimeMatch.Groups["files"].Value
|
||||||
|
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
|
||||||
|
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
|
||||||
|
throw "$forbidden must not be a managed deployment target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$preflight = $source.IndexOf('# Validate the complete source')
|
||||||
|
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
|
||||||
|
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
|
||||||
|
throw "Source preflight must happen before target changes."
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
|
||||||
|
"Existing task preservation guard is missing."
|
||||||
|
Assert-SourceContains 'Preserving existing Scheduled Task' `
|
||||||
|
"Existing task preservation is not explicit."
|
||||||
|
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
|
||||||
|
"Fresh enrichment task arguments are missing."
|
||||||
|
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
|
||||||
|
"Bounded main Agent stop behavior is missing."
|
||||||
|
Assert-SourceContains '\$mainTaskWasRunning' `
|
||||||
|
"Main Agent prior running state is not represented."
|
||||||
|
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
|
||||||
|
"Rollback does not restore managed files from backup."
|
||||||
|
Assert-SourceContains 'Unregister-ScheduledTask' `
|
||||||
|
"Rollback does not remove tasks created by a failed install."
|
||||||
|
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
|
||||||
|
"Rollback restart failure is not surfaced."
|
||||||
|
|
||||||
|
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
|
||||||
|
throw "Installer must not force-overwrite Scheduled Tasks."
|
||||||
|
}
|
||||||
|
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
|
||||||
|
throw "Installer must not inspect or print credentials."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "MinerOps installer source safety tests passed."
|
||||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
service="agents/windows/MinerOpsAgentService.ps1"
|
service="agents/windows/MinerOpsAgentService.ps1"
|
||||||
control="agents/windows/MinerOpsControl.ps1"
|
control="agents/windows/MinerOpsControl.ps1"
|
||||||
|
installer="agents/windows/install.ps1"
|
||||||
rg -q 'X-MinerOps-Agent-ID' "${service}"
|
rg -q 'X-MinerOps-Agent-ID' "${service}"
|
||||||
! rg -q 'commands/pending\?agent_id=' "${service}"
|
! rg -q 'commands/pending\?agent_id=' "${service}"
|
||||||
! rg -q 'DefaultValue "super"' "${service}"
|
! rg -q 'DefaultValue "super"' "${service}"
|
||||||
@@ -13,9 +14,20 @@ for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_respon
|
|||||||
! rg -q "${secret_field}" "${control}"
|
! rg -q "${secret_field}" "${control}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rg -q 'Get-MinerOpsScheduledTask' "${installer}"
|
||||||
|
rg -q 'Stop-MinerOpsScheduledTaskForUpgrade' "${installer}"
|
||||||
|
rg -q 'mainTaskWasRunning' "${installer}"
|
||||||
|
rg -q 'Preserving existing Scheduled Task' "${installer}"
|
||||||
|
rg -q -- '-Limit 0 -TimeoutMs 8000' "${installer}"
|
||||||
|
rg -q 'Unregister-ScheduledTask' "${installer}"
|
||||||
|
rg -q 'could not restart restored MinerOpsAgent' "${installer}"
|
||||||
|
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
|
||||||
|
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
|
||||||
|
|
||||||
if command -v pwsh >/dev/null 2>&1; then
|
if command -v pwsh >/dev/null 2>&1; then
|
||||||
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
|
||||||
|
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
|
||||||
else
|
else
|
||||||
echo "pwsh is not installed; deterministic PowerShell harness retained but not executed."
|
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
|
||||||
fi
|
fi
|
||||||
echo "Windows Agent source contract checks passed."
|
echo "Windows Agent source contract checks passed."
|
||||||
Reference in new issue
Block a user