From d8cd7ad058e440e7fa0ee2581a5a10097aefb3cc Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 25 Aug 2026 15:56:35 +0000 Subject: [PATCH] fix: preserve Windows agent tasks during upgrades --- agents/windows/README.md | 25 ++- agents/windows/install.ps1 | 190 +++++++++++++++--- .../windows/tests/Test-MinerOpsInstaller.ps1 | 60 ++++++ scripts/test-windows-agent-source.sh | 14 +- 4 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 agents/windows/tests/Test-MinerOpsInstaller.ps1 diff --git a/agents/windows/README.md b/agents/windows/README.md index 3b59509..5bbc9d5 100644 --- a/agents/windows/README.md +++ b/agents/windows/README.md @@ -51,15 +51,24 @@ Deploy Agent compatibility before enabling Hub enforcement of the reports the expected non-secret `VERSION`. 5. Enable the matching Agent ID and shared secret on the Hub. -The installer copies runtime code to `C:\MinerOpsAgent`, preserves -`agent-config.json`, `agent-state.json`, and `logs\`, and registers or -updates `MinerOpsAgent`, `MinerOpsAgentTray`, -`MinerOpsApi3Enrichment`, and `MinerOpsPoolPoller`. +The installer copies only managed runtime code to `C:\MinerOpsAgent` and +preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe +output, and other runtime data. During an upgrade, existing definitions for +`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and +`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are +created for a fresh or incomplete installation without hard-coded user names. + +If `MinerOpsAgent` was running before an upgrade, the installer stops only that +Scheduled Task, waits for it to stop, replaces the managed files, and starts it +again. A previously stopped Agent remains stopped. The tray and periodic tasks +are not unnecessarily restarted. Before replacing existing runtime files, the installer copies them to a -timestamped folder under `C:\MinerOpsAgent\backups`. To roll back, stop the -Agent tasks, copy the selected backup's runtime files back into -`C:\MinerOpsAgent`, and restart the tasks. `uninstall.ps1` unregisters the -four tasks and removes runtime code while preserving local configuration, +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. diff --git a/agents/windows/install.ps1 b/agents/windows/install.ps1 index a0abec4..4025bdc 100644 --- a/agents/windows/install.ps1 +++ b/agents/windows/install.ps1 @@ -3,7 +3,9 @@ $ErrorActionPreference = "Stop" $Target = "C:\MinerOpsAgent" $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 = @( "MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1", "MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1", @@ -11,52 +13,190 @@ $RuntimeFiles = @( "VERSION" ) -New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null -$existing = @($RuntimeFiles | Where-Object { Test-Path (Join-Path $Target $_) }) -if ($existing.Count -gt 0) { - New-Item -ItemType Directory -Force -Path $Backup | Out-Null - foreach ($name in $existing) { - Copy-Item (Join-Path $Target $name) (Join-Path $Backup $name) -Force +function Get-MinerOpsScheduledTask { + param([Parameter(Mandatory)][string]$Name) + + return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue +} + +function Stop-MinerOpsScheduledTaskForUpgrade { + param( + [Parameter(Mandatory)][string]$Name, + [TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30) + ) + + Stop-ScheduledTask -TaskName $Name -ErrorAction Stop + $deadline = [DateTime]::UtcNow.Add($Timeout) + do { + Start-Sleep -Milliseconds 500 + $task = Get-MinerOpsScheduledTask -Name $Name + if ($null -eq $task) { + throw "Scheduled Task $Name disappeared while stopping it." + } + if ([string]$task.State -ne "Running") { + return + } + } while ([DateTime]::UtcNow -lt $deadline) + + throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds." +} + +function Start-MinerOpsScheduledTask { + param([Parameter(Mandatory)][string]$Name) + + Start-ScheduledTask -TaskName $Name -ErrorAction Stop +} + +# Validate the complete source before creating directories, stopping tasks, or +# replacing any target file. +foreach ($name in $RuntimeFiles) { + $source = Join-Path $PSScriptRoot $name + if (!(Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Deployment source is missing $name" } } +$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent" +$mainTaskExisted = $null -ne $mainTask +$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running" +$mainTaskStoppedForUpgrade = $false +$deploymentStarted = $false +$existing = @() +$createdTaskNames = [System.Collections.Generic.List[string]]::new() + try { + New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null + $existing = @($RuntimeFiles | Where-Object { + Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf + }) + if ($existing.Count -gt 0) { + New-Item -ItemType Directory -Force -Path $Backup | Out-Null + foreach ($name in $existing) { + Copy-Item -LiteralPath (Join-Path $Target $name) ` + -Destination (Join-Path $Backup $name) -Force + } + } + + if ($mainTaskWasRunning) { + $mainTaskStoppedForUpgrade = $true + Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent" + } + + $deploymentStarted = $true foreach ($name in $RuntimeFiles) { - $source = Join-Path $PSScriptRoot $name - if (!(Test-Path $source)) { throw "Deployment source is missing $name" } - Copy-Item $source (Join-Path $Target $name) -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) ` + -Destination (Join-Path $Target $name) -Force } $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $system = New-ScheduledTaskPrincipal -UserId "SYSTEM" ` + -LogonType ServiceAccount -RunLevel Highest $startup = New-ScheduledTaskTrigger -AtStartup $fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes 5) + $commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` + -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 72) + $agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew ` + -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) $definitions = @( - @{ Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"; Trigger = $startup; Principal = $system }, - @{ Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"; Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null }, - @{ Name = "MinerOpsApi3Enrichment"; Script = "MinerOpsApi3EnrichmentPoller.ps1"; Trigger = $fiveMinutes; Principal = $system }, - @{ Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"; Trigger = $fiveMinutes; Principal = $system } + @{ + Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1" + Trigger = $startup; Principal = $system; Settings = $agentSettings + ExtraArguments = "" + }, + @{ + Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1" + Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null + Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden" + }, + @{ + Name = "MinerOpsApi3Enrichment" + Script = "MinerOpsApi3EnrichmentPoller.ps1" + Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings + ExtraArguments = "-Limit 0 -TimeoutMs 8000" + }, + @{ + Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1" + Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings + ExtraArguments = "" + } ) foreach ($definition in $definitions) { + if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) { + Write-Host "Preserving existing Scheduled Task $($definition.Name)." + continue + } + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f ` (Join-Path $Target $definition.Script) + if ($definition.ExtraArguments) { + $arguments = "$arguments $($definition.ExtraArguments)" + } $action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments + $registration = @{ + TaskName = $definition.Name + Action = $action + Trigger = $definition.Trigger + Settings = $definition.Settings + } if ($definition.Principal) { - Register-ScheduledTask -TaskName $definition.Name -Action $action ` - -Trigger $definition.Trigger -Principal $definition.Principal -Force | Out-Null - } else { - Register-ScheduledTask -TaskName $definition.Name -Action $action ` - -Trigger $definition.Trigger -Force | Out-Null + $registration.Principal = $definition.Principal } + 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 { - if (Test-Path $Backup) { - foreach ($name in $existing) { - Copy-Item (Join-Path $Backup $name) (Join-Path $Target $name) -Force + $deploymentError = $_ + $rollbackErrors = [System.Collections.Generic.List[string]]::new() + + foreach ($taskName in $createdTaskNames) { + try { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false ` + -ErrorAction Stop + } catch { + $rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)") } } - 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 } diff --git a/agents/windows/tests/Test-MinerOpsInstaller.ps1 b/agents/windows/tests/Test-MinerOpsInstaller.ps1 new file mode 100644 index 0000000..b57b433 --- /dev/null +++ b/agents/windows/tests/Test-MinerOpsInstaller.ps1 @@ -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*@\((?.*?)\)' +) +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." diff --git a/scripts/test-windows-agent-source.sh b/scripts/test-windows-agent-source.sh index 3181024..6506221 100755 --- a/scripts/test-windows-agent-source.sh +++ b/scripts/test-windows-agent-source.sh @@ -3,6 +3,7 @@ set -euo pipefail service="agents/windows/MinerOpsAgentService.ps1" control="agents/windows/MinerOpsControl.ps1" +installer="agents/windows/install.ps1" rg -q 'X-MinerOps-Agent-ID' "${service}" ! rg -q 'commands/pending\?agent_id=' "${service}" ! rg -q 'DefaultValue "super"' "${service}" @@ -13,9 +14,20 @@ for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_respon ! rg -q "${secret_field}" "${control}" done +rg -q 'Get-MinerOpsScheduledTask' "${installer}" +rg -q 'Stop-MinerOpsScheduledTaskForUpgrade' "${installer}" +rg -q 'mainTaskWasRunning' "${installer}" +rg -q 'Preserving existing Scheduled Task' "${installer}" +rg -q -- '-Limit 0 -TimeoutMs 8000' "${installer}" +rg -q 'Unregister-ScheduledTask' "${installer}" +rg -q 'could not restart restored MinerOpsAgent' "${installer}" +! rg -q 'Register-ScheduledTask.*-Force' "${installer}" +! rg -q 'agent_secret|api3_password|api3_account' "${installer}" + if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1 + pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1 else - echo "pwsh is not installed; deterministic PowerShell harness retained but not executed." + echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed." fi echo "Windows Agent source contract checks passed."