fix(agent99): restore autonomous recovery transport

This commit is contained in:
ogt
2026-07-10 21:21:27 +08:00
parent 6da5564d62
commit b86229e9fb
8 changed files with 772 additions and 12 deletions

View File

@@ -42,13 +42,20 @@ function Copy-AgentFile {
}
$agentFiles = @(
"agent99-bootstrap.ps1",
"agent99-contract-check.ps1",
"agent99-control-plane.ps1",
"agent99-deploy.ps1",
"agent99-register-tasks.ps1",
"agent99-submit-request.ps1",
"agent99-telegram-inbox.ps1",
"agent99-sre-alert-inbox.ps1",
"agent99-sre-alert-relay.ps1",
"host-reboot-scorecard.ps1"
"agent99-submit-request.ps1",
"agent99-synthetic-tests.ps1",
"agent99-telegram-inbox.ps1",
"agent99-windows-update-policy.ps1",
"host-reboot-scorecard.ps1",
"agent99.config.example.json",
"agent99.config.99.example.json"
)
foreach ($agentFile in $agentFiles) {
Copy-AgentFile $agentFile

View File

@@ -0,0 +1,95 @@
param(
[string]$SourceRoot = (Split-Path -Parent $MyInvocation.MyCommand.Path),
[string]$OutputPath = ""
)
$ErrorActionPreference = "Stop"
$checks = @()
$failures = @()
function Add-Check {
param([string]$Name, [bool]$Ok, [string]$Detail)
$item = [pscustomobject]@{ name = $Name; ok = $Ok; detail = $Detail }
$script:checks += $item
if (-not $Ok) { $script:failures += $item }
}
$requiredFiles = @(
"agent99-bootstrap.ps1",
"agent99-contract-check.ps1",
"agent99-control-plane.ps1",
"agent99-deploy.ps1",
"agent99-register-tasks.ps1",
"agent99-sre-alert-inbox.ps1",
"agent99-sre-alert-relay.ps1",
"agent99-submit-request.ps1",
"agent99-synthetic-tests.ps1",
"agent99-telegram-inbox.ps1",
"agent99-windows-update-policy.ps1",
"host-reboot-scorecard.ps1"
)
foreach ($name in $requiredFiles) {
$path = Join-Path $SourceRoot $name
Add-Check "file:$name" (Test-Path $path) $path
}
foreach ($file in Get-ChildItem -Path $SourceRoot -Filter "*.ps1" -File) {
$tokens = $null
$errors = $null
$sourceText = [IO.File]::ReadAllText($file.FullName, [Text.Encoding]::UTF8)
[System.Management.Automation.Language.Parser]::ParseInput($sourceText, [ref]$tokens, [ref]$errors) | Out-Null
Add-Check "powershell_parse:$($file.Name)" ($errors.Count -eq 0) $(if ($errors.Count -eq 0) { "parse_ok" } else { "parse_errors=$($errors.Count)" })
}
foreach ($name in @("agent99.config.example.json", "agent99.config.99.example.json")) {
$path = Join-Path $SourceRoot $name
try {
$config = Get-Content $path -Raw | ConvertFrom-Json
$hasHosts = @($config.hosts).Count -eq 5
$hasRoutes = @($config.publicUrls).Count -ge 5
$hasSlo = [bool]($config.recoverySlo -and [int]$config.recoverySlo.targetMinutes -eq 10)
$hasIdentity = [bool]($config.sshIdentityFile -and $config.sshIdentityFile -match "agent99_ed25519$")
$hasJump = [bool]($config.k3s -and $config.k3s.jumpHost -and $config.k3s.preferJumpHost -eq $true)
$hasAutoRecovery = [bool]($config.autoRecovery -and $config.autoRecovery.enabled -eq $true)
$ok = $hasHosts -and $hasRoutes -and $hasSlo -and $hasIdentity -and $hasJump -and $hasAutoRecovery
Add-Check "config:$name" $ok "hosts=$(@($config.hosts).Count) routes=$(@($config.publicUrls).Count) slo10m=$hasSlo identity=$hasIdentity jump=$hasJump autoRecovery=$hasAutoRecovery"
} catch {
Add-Check "config:$name" $false "json_parse_failed: $($_.Exception.Message)"
}
}
$control = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-control-plane.ps1"), [Text.Encoding]::UTF8)
$inbox = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-sre-alert-inbox.ps1"), [Text.Encoding]::UTF8)
$telegram = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-telegram-inbox.ps1"), [Text.Encoding]::UTF8)
$bootstrap = [IO.File]::ReadAllText((Join-Path $SourceRoot "agent99-bootstrap.ps1"), [Text.Encoding]::UTF8)
Add-Check "transport:dedicated_identity" ($control.Contains("sshIdentityFile") -and $control.Contains("IdentitiesOnly=yes")) "dedicated SSH identity is enforced"
Add-Check "transport:jump_first" ($control.Contains("preferJumpHost") -and $control.Contains("ssh_jump_fallback")) "preferred jump with bounded fallback is present"
Add-Check "recovery:auto_trigger" ($control.Contains("Get-AgentBootState") -and $control.Contains("Start-AgentRecoveryFromObservation") -and $control.Contains("recovery_already_queued")) "boot and host-down recovery use a single-flight queue"
Add-Check "recovery:slo" ($control.Contains("recovery_slo_result") -and $control.Contains("withinSlo")) "10-minute recovery evidence is present"
Add-Check "outcome:verified" ($control.Contains("Get-AgentOutcomeContract") -and $control.Contains('schemaVersion = "agent99_outcome_contract_v1"')) "verified outcome contract is present"
Add-Check "problem_counts:v2" ($control.Contains('schemaVersion = "agent99_problem_counts_v2"') -and $control.Contains("totalVerifiedResolved")) "verified problem counts are present"
Add-Check "runtime:manifest" ($control.Contains("Test-AgentRuntimeManifest") -and $bootstrap.Contains('schemaVersion = "agent99_runtime_manifest_v1"')) "runtime source drift is self-checked"
Add-Check "routing:structured" ($inbox.Contains("Resolve-AgentAlertRoute") -and $inbox.Contains('schemaVersion = "agent99_sre_alert_single_flight_v1"')) "structured route and local single-flight are present"
Add-Check "routing:telegram" ($telegram.Contains('schemaVersion = "agent99_alert_route_v1"') -and $telegram.Contains("autoIngestMonitoringAlerts")) "Telegram structured ingress is present"
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
sourceRoot = $SourceRoot
ok = [bool](@($failures).Count -eq 0)
checkCount = @($checks).Count
failureCount = @($failures).Count
checks = $checks
failures = $failures
}
if ($OutputPath) {
$parent = Split-Path -Parent $OutputPath
if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
$result | ConvertTo-Json -Depth 8 | Set-Content -Path $OutputPath -Encoding UTF8
}
$result | ConvertTo-Json -Depth 8
if (-not $result.ok) { exit 1 }
exit 0

View File

@@ -34,6 +34,11 @@ function Load-Config {
$Config = Load-Config
$SshUser = if ($Config.sshUser) { $Config.sshUser } else { "wooo" }
$SshIdentityFile = if ($Config.PSObject.Properties["sshIdentityFile"] -and $Config.sshIdentityFile) {
[Environment]::ExpandEnvironmentVariables([string]$Config.sshIdentityFile)
} else {
""
}
$script:Events = @()
$script:TelegramAttempts = @()
$script:SuppressAgentAlerts = [bool]$SuppressAlerts
@@ -56,6 +61,11 @@ function Get-HostSshUser {
$SshUser
}
function Get-AgentSshIdentityArguments {
if (-not $SshIdentityFile) { return @() }
@("-i", $SshIdentityFile, "-o", "IdentitiesOnly=yes")
}
function Send-AgentTelegramRelay {
param(
[string]$Message,
@@ -85,7 +95,7 @@ function Send-AgentTelegramRelay {
$remotePhotoPath = "/tmp/" + $safePhotoName
$containerPhotoPath = "/tmp/" + $safePhotoName
$relayUser = Get-HostSshUser $relayHost
$scpArgs = @(
$scpArgs = @(Get-AgentSshIdentityArguments) + @(
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "NumberOfPasswordPrompts=0",
@@ -1044,6 +1054,143 @@ function Record-AgentEvent {
}
}
function Get-AgentBootState {
$stateDir = Join-Path $Config.agentRoot "state"
$statePath = Join-Path $stateDir "boot-state.json"
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$bootTime = ([datetime]$os.LastBootUpTime).ToString("o")
} catch {
Write-AgentLog "boot_state_read_failed error=$($_.Exception.Message)"
return [pscustomobject]@{ ok = $false; detected = $false; reason = "boot_time_unavailable"; statePath = $statePath }
}
$previous = $null
if (Test-Path $statePath) {
try { $previous = Get-Content $statePath -Raw | ConvertFrom-Json } catch { $previous = $null }
}
$detected = [bool]($previous -and $previous.bootTime -and ([string]$previous.bootTime -ne $bootTime))
$result = [pscustomobject]@{
ok = $true
detected = $detected
bootTime = $bootTime
previousBootTime = if ($previous) { [string]$previous.bootTime } else { $null }
statePath = $statePath
}
$result | ConvertTo-Json -Depth 5 | Set-Content -Path $statePath -Encoding UTF8
if ($detected) {
Record-AgentEvent "host_reboot_detected" "warning" "偵測到 99 主機重新啟動Agent99 已進入受控恢復主線。" $result -Alert
}
$result
}
function Get-AgentAutoRecoveryConfig {
$configured = if ($Config.PSObject.Properties["autoRecovery"]) { $Config.autoRecovery } else { $null }
[pscustomobject]@{
enabled = [bool](-not $configured -or -not $configured.PSObject.Properties["enabled"] -or (Convert-AgentBool $configured.enabled))
triggerOnHostUnreachable = [bool](-not $configured -or -not $configured.PSObject.Properties["triggerOnHostUnreachable"] -or (Convert-AgentBool $configured.triggerOnHostUnreachable))
minimumUnreachableHosts = if ($configured -and $configured.PSObject.Properties["minimumUnreachableHosts"]) { [math]::Max(1, [int]$configured.minimumUnreachableHosts) } else { 1 }
cooldownMinutes = if ($configured -and $configured.PSObject.Properties["cooldownMinutes"]) { [math]::Max(1, [int]$configured.cooldownMinutes) } else { 10 }
}
}
function Start-AgentRecoveryFromObservation {
param(
[string]$Reason,
[object]$Observation = $null
)
$autoRecovery = Get-AgentAutoRecoveryConfig
if (-not $autoRecovery.enabled) {
return [pscustomobject]@{ ok = $false; queued = $false; reason = "auto_recovery_disabled"; trigger = $Reason }
}
$queueDir = Join-Path $Config.agentRoot "queue"
$stateDir = Join-Path $Config.agentRoot "state"
$statePath = Join-Path $stateDir "recovery-trigger-state.json"
New-Item -ItemType Directory -Force -Path $queueDir, $stateDir | Out-Null
foreach ($file in @(Get-ChildItem -Path $queueDir -Filter "*.json" -File -ErrorAction SilentlyContinue)) {
try {
$pending = Get-Content $file.FullName -Raw | ConvertFrom-Json
if ([string]$pending.mode -eq "Recover") {
return [pscustomobject]@{ ok = $true; queued = $false; suppressed = $true; reason = "recovery_already_queued"; queuePath = $file.FullName; trigger = $Reason }
}
} catch {
Write-AgentLog "recovery_queue_probe_failed path=$($file.FullName) error=$($_.Exception.Message)"
}
}
if (Test-Path $statePath) {
try {
$state = Get-Content $statePath -Raw | ConvertFrom-Json
$lastRequestedAt = [datetime]$state.lastRequestedAt
$ageMinutes = ((Get-Date) - $lastRequestedAt).TotalMinutes
if ($ageMinutes -lt $autoRecovery.cooldownMinutes) {
return [pscustomobject]@{ ok = $true; queued = $false; suppressed = $true; reason = "recovery_trigger_cooldown"; ageMinutes = [math]::Round($ageMinutes, 2); trigger = $Reason }
}
} catch {
Write-AgentLog "recovery_trigger_state_read_failed error=$($_.Exception.Message)"
}
}
$id = "auto-recover-" + (Get-Date -Format "yyyyMMdd-HHmmss") + "-" + ([guid]::NewGuid().ToString("N").Substring(0, 8))
$queuePath = Join-Path $queueDir "$id.json"
$temporaryPath = "$queuePath.tmp"
$createdAt = Get-Date -Format o
$request = [pscustomobject]@{
id = $id
mode = "Recover"
controlledApply = $true
requestedBy = "Agent99"
source = "agent99-recovery-observer"
externalId = $Reason
reason = $Reason
instruction = "Agent99 detected $Reason; run the allowlisted VM, host, Harbor, K3s, public-route, and freshness recovery chain."
observation = $Observation
createdAt = $createdAt
}
$request | ConvertTo-Json -Depth 8 | Set-Content -Path $temporaryPath -Encoding UTF8
Move-Item -Force $temporaryPath $queuePath
[pscustomobject]@{
schemaVersion = "agent99_recovery_trigger_state_v1"
lastRequestedAt = $createdAt
reason = $Reason
queueId = $id
queuePath = $queuePath
} | ConvertTo-Json -Depth 6 | Set-Content -Path $statePath -Encoding UTF8
$result = [pscustomobject]@{ ok = $true; queued = $true; suppressed = $false; reason = $Reason; id = $id; queuePath = $queuePath }
Record-AgentEvent "recovery_auto_triggered" "warning" "偵測到 $Reason;已排入單一受控 Recover 佇列。" $result -Alert
$result
}
function Get-AgentRecoverySloConfig {
$configured = if ($Config.PSObject.Properties["recoverySlo"]) { $Config.recoverySlo } else { $null }
[pscustomobject]@{
targetMinutes = if ($configured -and $configured.PSObject.Properties["targetMinutes"]) { [int]$configured.targetMinutes } else { 10 }
hostTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["hostTimeoutSeconds"]) { [int]$configured.hostTimeoutSeconds } else { 90 }
harborTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["harborTimeoutSeconds"]) { [int]$configured.harborTimeoutSeconds } else { 120 }
workloadTimeoutSeconds = if ($configured -and $configured.PSObject.Properties["workloadTimeoutSeconds"]) { [int]$configured.workloadTimeoutSeconds } else { 180 }
}
}
function New-AgentRecoveryPhase {
param(
[string]$Name,
[string]$Status,
[double]$ElapsedSeconds,
[string]$Evidence = ""
)
[pscustomobject]@{
name = $Name
status = $Status
elapsedSeconds = [math]::Round($ElapsedSeconds, 1)
evidence = if ($Evidence) { $Evidence } else { $null }
}
}
function Invoke-SshText {
param(
[string]$TargetHost,
@@ -1056,7 +1203,7 @@ function Invoke-SshText {
$last = $null
$userToUse = if ($User) { $User } else { Get-HostSshUser $TargetHost }
for ($attempt = 1; $attempt -le $Retries; $attempt++) {
$args = @(
$args = @(Get-AgentSshIdentityArguments) + @(
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "NumberOfPasswordPrompts=0",
@@ -1143,17 +1290,30 @@ function Invoke-HostSshText {
[int]$Retries = 1
)
$direct = Invoke-SshText $TargetHost $Command $TimeoutSeconds $Retries
if ($direct.ok -or -not $Config.k3s.jumpHost -or $TargetHost -eq $Config.k3s.jumpHost) {
$direct | Add-Member -NotePropertyName route -NotePropertyValue "direct" -Force
return $direct
}
$jumpHost = if ($Config.k3s -and $Config.k3s.PSObject.Properties["jumpHost"]) { [string]$Config.k3s.jumpHost } else { "" }
$preferJumpHost = [bool]($Config.k3s -and $Config.k3s.PSObject.Properties["preferJumpHost"] -and (Convert-AgentBool $Config.k3s.preferJumpHost))
$directHosts = if ($Config.k3s -and $Config.k3s.PSObject.Properties["directHosts"]) { @($Config.k3s.directHosts) } else { @() }
$canUseJump = [bool]($jumpHost -and $TargetHost -ne $jumpHost -and $TargetHost -notin $directHosts)
$quotedCommand = Quote-ShSingle $Command
$targetUser = Get-HostSshUser $TargetHost
$jumpCommand = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 -l $targetUser $TargetHost $quotedCommand"
$viaJump = Invoke-SshText $Config.k3s.jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$($Config.k3s.jumpHost)" -Force
if ($preferJumpHost -and $canUseJump) {
$viaJump = Invoke-SshText $jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$jumpHost" -Force
if ($viaJump.ok) { return $viaJump }
Write-AgentLog "ssh_jump_fallback host=$TargetHost jumpHost=$jumpHost exit=$($viaJump.exitCode)"
}
$direct = Invoke-SshText $TargetHost $Command $TimeoutSeconds $Retries
if ($direct.ok -or -not $canUseJump -or $preferJumpHost) {
$direct | Add-Member -NotePropertyName route -NotePropertyValue "direct" -Force
return $direct
}
$viaJump = Invoke-SshText $jumpHost $jumpCommand $TimeoutSeconds $Retries
$viaJump | Add-Member -NotePropertyName route -NotePropertyValue "via_$jumpHost" -Force
return $viaJump
}
@@ -3684,13 +3844,38 @@ $backupHealth = $null
$providerFreshness = $null
$securityTriage = $null
$controlTick = $null
$bootState = $null
$recoveryTrigger = $null
$recoverySlo = $null
$recoveryPhases = @()
$recoveryStartedAt = if ($Mode -eq "Recover") { Get-Date } else { $null }
$recoveryConfig = Get-AgentRecoverySloConfig
if ($Mode -in @("Status", "Recover")) {
$bootState = Get-AgentBootState
}
if ($Mode -eq "Status" -and $bootState -and $bootState.detected) {
$recoveryTrigger = Start-AgentRecoveryFromObservation "host_reboot_detected" $bootState
}
if ($Mode -in @("Recover", "StartVMs")) {
$vmResults = Start-ConfiguredVMs
if ($Mode -eq "Recover") {
$vmFailures = @($vmResults | Where-Object { $_.ok -eq $false -and -not $_.skipped }).Count
$recoveryPhases += New-AgentRecoveryPhase "vm_start" $(if ($vmFailures -eq 0) { "ok" } else { "degraded" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover")) {
$hostResults = Test-HostReachability
$unreachableHosts = @($hostResults | Where-Object { -not $_.ping })
if ($Mode -eq "Recover") {
$recoveryPhases += New-AgentRecoveryPhase "host_reachability" $(if ($unreachableHosts.Count -eq 0) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
$autoRecovery = Get-AgentAutoRecoveryConfig
if ($Mode -eq "Status" -and -not $recoveryTrigger -and $autoRecovery.triggerOnHostUnreachable -and $unreachableHosts.Count -ge $autoRecovery.minimumUnreachableHosts) {
$recoveryTrigger = Start-AgentRecoveryFromObservation "host_unreachable_detected" ([pscustomobject]@{ hosts = @($unreachableHosts.host); count = $unreachableHosts.Count })
}
}
if ($Mode -in @("Status", "Recover", "HarborRepair")) {
@@ -3703,6 +3888,10 @@ if ($Mode -in @("Recover", "HarborRepair") -and $harbor) {
Start-Sleep -Seconds 3
$harbor = Get-HarborState
}
if ($Mode -eq "Recover") {
$harborReady = [bool]($harbor.coreHealthy -and $harbor.redisUp -and $harbor.registryHealthy)
$recoveryPhases += New-AgentRecoveryPhase "harbor_registry" $(if ($harborReady) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover", "AwoooRepair")) {
@@ -3716,10 +3905,17 @@ if ($Mode -in @("Recover", "AwoooRepair") -and $awooo) {
Start-Sleep -Seconds 3
$awooo = Get-AwoooState
}
if ($Mode -eq "Recover") {
$workloadsReady = [bool]($awooo.deployReady -and -not $awooo.imagePullFailure -and -not $awooo.crashFailure)
$recoveryPhases += New-AgentRecoveryPhase "k3s_workloads" $(if ($workloadsReady) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover", "PublicSmoke")) {
$public = Test-PublicRoutes
if ($Mode -eq "Recover") {
$recoveryPhases += New-AgentRecoveryPhase "public_routes" $(if (@($public | Where-Object { -not $_.ok }).Count -eq 0) { "ok" } else { "failed" }) (((Get-Date) - $recoveryStartedAt).TotalSeconds)
}
}
if ($Mode -in @("Status", "Recover", "Perf")) {
@@ -3769,12 +3965,34 @@ $backupWarning = if ($backupHealth -and $backupHealth.severity -eq "warning") {
$providerFreshnessWarning = if ($providerFreshness) { 1 } else { 0 }
$securityTriageWarning = if ($securityTriage -and $securityTriage.hitCount -gt 0) { 1 } else { 0 }
$summarySeverity = if ($publicFailures -gt 0 -or $aiFailures -gt 0 -or $perfCritical -gt 0 -or $selfHealthCritical -gt 0 -or $backupCritical -gt 0) { "critical" } elseif ($perfWarning -gt 0 -or $selfHealthWarning -gt 0 -or $backupWarning -gt 0 -or $providerFreshnessWarning -gt 0 -or $securityTriageWarning -gt 0) { "warning" } else { "info" }
if ($Mode -eq "Recover") {
$elapsedSeconds = [math]::Round(((Get-Date) - $recoveryStartedAt).TotalSeconds, 1)
$unreachableCount = @($hostResults | Where-Object { -not $_.ping }).Count
$recoveryCompleted = [bool]($unreachableCount -eq 0 -and $harbor -and $harbor.coreHealthy -and $harbor.redisUp -and $harbor.registryHealthy -and $awooo -and $awooo.deployReady -and -not $awooo.imagePullFailure -and -not $awooo.crashFailure -and $publicFailures -eq 0)
$withinSlo = [bool]($recoveryCompleted -and $elapsedSeconds -le ($recoveryConfig.targetMinutes * 60))
$recoverySlo = [pscustomobject]@{
targetMinutes = $recoveryConfig.targetMinutes
startedAt = $recoveryStartedAt.ToString("o")
completedAt = (Get-Date).ToString("o")
elapsedSeconds = $elapsedSeconds
deadline = $recoveryStartedAt.AddMinutes($recoveryConfig.targetMinutes).ToString("o")
completed = $recoveryCompleted
withinSlo = $withinSlo
phases = @($recoveryPhases)
}
$sloSeverity = if ($withinSlo) { "info" } elseif ($recoveryCompleted) { "warning" } else { "critical" }
$sloMessage = if ($withinSlo) { "恢復完成,耗時 $elapsedSeconds 秒,符合 $($recoveryConfig.targetMinutes) 分鐘 SLA。" } elseif ($recoveryCompleted) { "恢復完成但超過 $($recoveryConfig.targetMinutes) 分鐘 SLA耗時 $elapsedSeconds 秒。" } else { "恢復尚未完成,耗時 $elapsedSeconds 秒;請依階段 evidence 繼續處置。" }
Record-AgentEvent "recovery_slo_result" $sloSeverity $sloMessage $recoverySlo -Alert
}
Record-AgentEvent "agent_complete" $summarySeverity "mode=$Mode publicFailures=$publicFailures aiFailures=$aiFailures perfCritical=$perfCritical perfWarning=$perfWarning selfHealth=$($selfHealth.severity) backupHealth=$($backupHealth.severity) providerFreshness=$($providerFreshness.status) securityTriage=$($securityTriage.status) evidence=$jsonPath" $null -NoAlert
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
mode = $Mode
controlledApply = [bool]$ControlledApply
bootState = $bootState
recoveryTrigger = $recoveryTrigger
recoverySlo = $recoverySlo
vmResults = $vmResults
hosts = $hostResults
harbor = $harbor

243
agent99-deploy.ps1 Normal file
View File

@@ -0,0 +1,243 @@
param(
[string]$SourceRoot = (Split-Path -Parent $MyInvocation.MyCommand.Path),
[string]$AgentRoot = "C:\Wooo\Agent99",
[string]$SourceRevision = "",
[switch]$RegisterTasks,
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BinDir = Join-Path $AgentRoot "bin"
$ConfigPath = Join-Path $AgentRoot "config\agent99.config.json"
$EvidenceDir = Join-Path $AgentRoot "evidence"
$StateDir = Join-Path $AgentRoot "state"
$DeployRoot = Join-Path $AgentRoot "deploy"
$StageDir = Join-Path $DeployRoot "staging-$stamp"
$BackupDir = Join-Path $DeployRoot "backup-$stamp"
$ManifestPath = Join-Path $StateDir "runtime-manifest.json"
$EvidencePath = Join-Path $EvidenceDir "agent99-deploy-$stamp.json"
$runtimeFiles = @(
"agent99-bootstrap.ps1",
"agent99-contract-check.ps1",
"agent99-control-plane.ps1",
"agent99-deploy.ps1",
"agent99-register-tasks.ps1",
"agent99-sre-alert-inbox.ps1",
"agent99-sre-alert-relay.ps1",
"agent99-submit-request.ps1",
"agent99-synthetic-tests.ps1",
"agent99-telegram-inbox.ps1",
"agent99-windows-update-policy.ps1",
"host-reboot-scorecard.ps1",
"agent99.config.example.json",
"agent99.config.99.example.json"
)
function Ensure-Utf8Bom {
param([string]$Path)
$bytes = [IO.File]::ReadAllBytes($Path)
$text = [Text.Encoding]::UTF8.GetString($bytes)
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
[IO.File]::WriteAllText($Path, $text, (New-Object System.Text.UTF8Encoding($true)))
}
function Add-DefaultProperty {
param([object]$Object, [string]$Name, [object]$Value)
if (-not $Object.PSObject.Properties[$Name] -or $null -eq $Object.PSObject.Properties[$Name].Value) {
if ($Object.PSObject.Properties[$Name]) {
$Object.PSObject.Properties[$Name].Value = $Value
} else {
$Object | Add-Member -MemberType NoteProperty -Name $Name -Value $Value
}
}
}
function Write-DeployEvidence {
param([bool]$Ok, [string]$Status, [string]$ErrorText, [object[]]$Files, [object]$TaskResult)
New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null
[pscustomobject]@{
schemaVersion = "agent99_deploy_receipt_v2"
timestamp = (Get-Date -Format o)
ok = $Ok
status = $Status
sourceRoot = $SourceRoot
sourceRevision = $SourceRevision
agentRoot = $AgentRoot
stageDir = $StageDir
backupDir = $BackupDir
runtimeManifest = $ManifestPath
validateOnly = [bool]$ValidateOnly
registerTasks = [bool]$RegisterTasks
error = $ErrorText
files = $Files
taskRegistration = $TaskResult
} | ConvertTo-Json -Depth 10 | Set-Content -Path $EvidencePath -Encoding UTF8
}
function Restore-Agent99Deployment {
param([object[]]$Rows, [bool]$ConfigBackedUp, [bool]$ManifestBackedUp)
foreach ($item in $Rows) {
$backupPath = Join-Path $BackupDir $item.name
if ($item.existed -and (Test-Path $backupPath)) {
Copy-Item -Force $backupPath $item.livePath
} elseif (-not $item.existed -and (Test-Path $item.livePath)) {
Remove-Item -Force $item.livePath
}
}
if ($ConfigBackedUp) { Copy-Item -Force (Join-Path $BackupDir "agent99.config.json") $ConfigPath }
if ($ManifestBackedUp) {
Copy-Item -Force (Join-Path $BackupDir "runtime-manifest.json") $ManifestPath
} elseif (Test-Path $ManifestPath) {
Remove-Item -Force $ManifestPath
}
}
if (-not $SourceRevision) {
try {
$revision = & git -C $SourceRoot rev-parse HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $revision) { $SourceRevision = ([string]$revision).Trim() }
} catch {
$SourceRevision = ""
}
}
if (-not $SourceRevision) { $SourceRevision = "unknown" }
New-Item -ItemType Directory -Force -Path $StageDir, $EvidenceDir, $StateDir | Out-Null
foreach ($name in $runtimeFiles) {
$source = Join-Path $SourceRoot $name
if (-not (Test-Path $source)) { throw "Required deployment file missing: $source" }
$target = Join-Path $StageDir $name
Copy-Item -Force $source $target
if ($name.EndsWith(".ps1")) { Ensure-Utf8Bom $target }
}
& (Join-Path $StageDir "agent99-contract-check.ps1") -SourceRoot $StageDir | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-DeployEvidence $false "preflight_failed" "contract_check_failed" @() $null
throw "Agent99 staged contract check failed."
}
& (Join-Path $StageDir "agent99-synthetic-tests.ps1") -SourceRoot $StageDir -WorkRoot (Join-Path $StageDir "synthetic-work") | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-DeployEvidence $false "preflight_failed" "synthetic_tests_failed" @() $null
throw "Agent99 staged synthetic tests failed."
}
$stagedFiles = @($runtimeFiles | ForEach-Object {
$path = Join-Path $StageDir $_
[pscustomobject]@{ name = $_; sha256 = (Get-FileHash -Algorithm SHA256 -Path $path).Hash.ToLowerInvariant() }
})
if ($ValidateOnly) {
Write-DeployEvidence $true "validated" $null $stagedFiles $null
[pscustomobject]@{ ok = $true; status = "validated"; sourceRevision = $SourceRevision; evidence = $EvidencePath; files = $stagedFiles } | ConvertTo-Json -Depth 8
exit 0
}
New-Item -ItemType Directory -Force -Path $BinDir, $BackupDir | Out-Null
$promotionRows = @()
$configBackedUp = $false
$manifestBackedUp = $false
try {
foreach ($name in $runtimeFiles) {
$livePath = Join-Path $BinDir $name
$existed = Test-Path $livePath
if ($existed) { Copy-Item -Force $livePath (Join-Path $BackupDir $name) }
$promotionRows += [pscustomobject]@{ name = $name; livePath = $livePath; existed = [bool]$existed }
Copy-Item -Force (Join-Path $StageDir $name) $livePath
}
if (-not (Test-Path $ConfigPath)) { throw "Live Agent99 config not found: $ConfigPath" }
Copy-Item -Force $ConfigPath (Join-Path $BackupDir "agent99.config.json")
$configBackedUp = $true
if (Test-Path $ManifestPath) {
Copy-Item -Force $ManifestPath (Join-Path $BackupDir "runtime-manifest.json")
$manifestBackedUp = $true
}
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
Add-DefaultProperty $config "sshIdentityFile" (Join-Path $AgentRoot "keys\agent99_ed25519")
Add-DefaultProperty $config "k3s" ([pscustomobject]@{})
Add-DefaultProperty $config.k3s "jumpHost" "192.168.0.110"
Add-DefaultProperty $config.k3s "preferJumpHost" $true
Add-DefaultProperty $config.k3s "directHosts" @("192.168.0.110", "192.168.0.112")
Add-DefaultProperty $config "autoRecovery" ([pscustomobject]@{})
Add-DefaultProperty $config.autoRecovery "enabled" $true
Add-DefaultProperty $config.autoRecovery "triggerOnHostUnreachable" $true
Add-DefaultProperty $config.autoRecovery "minimumUnreachableHosts" 1
Add-DefaultProperty $config.autoRecovery "cooldownMinutes" 10
Add-DefaultProperty $config "recoverySlo" ([pscustomobject]@{})
Add-DefaultProperty $config.recoverySlo "targetMinutes" 10
Add-DefaultProperty $config.recoverySlo "hostTimeoutSeconds" 90
Add-DefaultProperty $config.recoverySlo "harborTimeoutSeconds" 120
Add-DefaultProperty $config.recoverySlo "workloadTimeoutSeconds" 180
Add-DefaultProperty $config "telegram" ([pscustomobject]@{})
Add-DefaultProperty $config.telegram "repeatAfterMinutes" 120
Add-DefaultProperty $config "performance" ([pscustomobject]@{})
Add-DefaultProperty $config.performance "processCpuHostPercentWarning" 25
Add-DefaultProperty $config.performance "processCpuHostPercentCritical" 50
$config | ConvertTo-Json -Depth 20 | Set-Content -Path $ConfigPath -Encoding UTF8
$manifestRows = @()
foreach ($staged in $stagedFiles) {
$livePath = Join-Path $BinDir $staged.name
$runtimeSha = if (Test-Path $livePath) { (Get-FileHash -Algorithm SHA256 -Path $livePath).Hash.ToLowerInvariant() } else { "" }
$matched = [bool]($runtimeSha -and $runtimeSha -eq $staged.sha256)
$manifestRows += [pscustomobject]@{
name = $staged.name
sourceExists = $true
runtimeExists = [bool](Test-Path $livePath)
sourceSha256 = $staged.sha256
runtimeSha256 = $runtimeSha
contentMatched = $matched
matched = $matched
}
}
$runtimeMatched = [bool](@($manifestRows | Where-Object { -not $_.matched }).Count -eq 0)
[pscustomobject]@{
schemaVersion = "agent99_runtime_manifest_v1"
generatedAt = (Get-Date -Format o)
sourceRevision = $SourceRevision
runtimeMatched = $runtimeMatched
fileCount = $manifestRows.Count
mismatchCount = @($manifestRows | Where-Object { -not $_.matched }).Count
files = $manifestRows
} | ConvertTo-Json -Depth 10 | Set-Content -Path $ManifestPath -Encoding UTF8
if (-not $runtimeMatched) { throw "Promoted Agent99 runtime does not match staged source." }
& (Join-Path $BinDir "agent99-contract-check.ps1") -SourceRoot $BinDir | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Post-promotion Agent99 contract check failed." }
& (Join-Path $BinDir "agent99-synthetic-tests.ps1") -SourceRoot $BinDir -WorkRoot (Join-Path $StageDir "live-synthetic-work") | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Post-promotion Agent99 synthetic tests failed." }
} catch {
Restore-Agent99Deployment $promotionRows $configBackedUp $manifestBackedUp
Write-DeployEvidence $false "rolled_back" $_.Exception.Message $stagedFiles $null
throw
}
$taskResult = $null
if ($RegisterTasks) {
try {
& (Join-Path $BinDir "agent99-register-tasks.ps1") -AgentRoot $AgentRoot | Out-Null
$taskResult = [pscustomobject]@{ ok = $true; status = "registered" }
} catch {
$taskResult = [pscustomobject]@{ ok = $false; status = "registration_failed"; error = $_.Exception.Message }
}
}
$deployOk = [bool](-not $taskResult -or $taskResult.ok)
$deployStatus = if ($deployOk) { "deployed" } else { "deployed_task_registration_failed" }
Write-DeployEvidence $deployOk $deployStatus $(if ($taskResult -and -not $taskResult.ok) { $taskResult.error } else { $null }) $stagedFiles $taskResult
[pscustomobject]@{
ok = $deployOk
status = $deployStatus
sourceRevision = $SourceRevision
evidence = $EvidencePath
backupDir = $BackupDir
runtimeManifest = $ManifestPath
taskRegistration = $taskResult
files = $stagedFiles
} | ConvertTo-Json -Depth 10
if (-not $deployOk) { exit 2 }
exit 0

View File

@@ -0,0 +1,86 @@
param(
[string]$SourceRoot = (Split-Path -Parent $MyInvocation.MyCommand.Path),
[string]$WorkRoot = (Join-Path $env:TEMP ("agent99-synthetic-" + (Get-Date -Format "yyyyMMdd-HHmmss")))
)
$ErrorActionPreference = "Stop"
$checks = @()
function Add-SyntheticCheck {
param([string]$Name, [bool]$Ok, [string]$Detail)
$script:checks += [pscustomobject]@{ name = $Name; ok = $Ok; detail = $Detail }
}
Remove-Item -Recurse -Force $WorkRoot -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $WorkRoot | Out-Null
$inbox = Join-Path $SourceRoot "agent99-sre-alert-inbox.ps1"
$routingOutput = @(& $inbox -AgentRoot $WorkRoot -SelfTest 2>&1) -join "`n"
$routingExitCode = $LASTEXITCODE
try {
$routing = $routingOutput | ConvertFrom-Json
Add-SyntheticCheck "routing:self_test" ($routingExitCode -eq 0 -and $routing.ok -eq $true) "passed=$($routing.passedCount)/$($routing.caseCount) exit=$routingExitCode"
Add-SyntheticCheck "routing:provider_freshness" (@($routing.results | Where-Object { $_.alertId -eq "selftest-provider-freshness" -and $_.mode -eq "ProviderFreshness" }).Count -eq 1) "provider freshness structured route"
Add-SyntheticCheck "routing:reboot_slo" (@($routing.results | Where-Object { $_.alertId -eq "selftest-reboot-slo" -and $_.mode -eq "Recover" -and $_.routeSource -eq "suggestedMode" }).Count -eq 1) "reboot SLO has priority over contaminated text"
} catch {
Add-SyntheticCheck "routing:self_test" $false "invalid self-test JSON: $($_.Exception.Message)"
}
$tokens = $null
$errors = $null
$controlPath = Join-Path $SourceRoot "agent99-control-plane.ps1"
$controlSource = [IO.File]::ReadAllText($controlPath, [Text.Encoding]::UTF8)
$ast = [System.Management.Automation.Language.Parser]::ParseInput($controlSource, [ref]$tokens, [ref]$errors)
$formatFunctions = @{}
foreach ($functionName in @("Get-AgentObjectValue", "Limit-AgentTextLine", "Format-AgentMetricForCard", "Format-AgentTelegramText")) {
$definition = $ast.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName
}, $true)
if ($definition) {
Invoke-Expression $definition.Extent.Text
$formatFunctions[$functionName] = $true
}
}
if ($formatFunctions.ContainsKey("Format-AgentTelegramText")) {
$perfCard = Format-AgentTelegramText "warning" "performance_warning" "test" ([pscustomobject]@{
host = "192.168.0.120"
reasons = @("perf_readback_failed")
loadPerCore = $null
memAvailablePercent = $null
diskUsedPercent = $null
})
$providerCard = Format-AgentTelegramText "warning" "provider_freshness_triage" "test" ([pscustomobject]@{
candidateId = "pf-test"
target = "provider_freshness_signal"
hitCount = 2
missingFields = @("lastSeen")
status = "handled_blocked_by_trust_gate"
candidatePath = "C:\Wooo\Agent99\state\pf-test.json"
})
$cards = "$perfCard`n$providerCard"
Add-SyntheticCheck "telegram:localized" ([regex]::IsMatch($cards, "[^\x00-\x7F]")) "localized card is present"
Add-SyntheticCheck "telegram:no_blank_metrics" (-not $perfCard.Contains("load/core=, ")) "failed readback renders unavailable values"
Add-SyntheticCheck "telegram:provider_handled" ($providerCard.Contains("handled_blocked_by_trust_gate") -and $providerCard.Contains("Agent99")) "handled state and agent are visible"
Add-SyntheticCheck "telegram:no_raw_python" (-not $cards.Contains("urllib.request")) "relay source is absent from user-facing card"
} else {
Add-SyntheticCheck "telegram:formatter" $false "Format-AgentTelegramText not found"
}
$control = $controlSource
Add-SyntheticCheck "recovery:single_flight_queue" ($control.Contains("recovery_already_queued") -and $control.Contains("recovery_trigger_cooldown") -and $control.Contains('mode = "Recover"')) "automatic recovery is queued and deduplicated"
Add-SyntheticCheck "transport:identity_jump" ($control.Contains("IdentitiesOnly=yes") -and $control.Contains("preferJumpHost") -and $control.Contains("directHosts")) "dedicated identity and bounded routes are present"
$failures = @($checks | Where-Object { -not $_.ok })
$result = [pscustomobject]@{
timestamp = (Get-Date -Format o)
ok = [bool]($failures.Count -eq 0)
workRoot = $WorkRoot
checkCount = $checks.Count
failureCount = $failures.Count
checks = $checks
failures = $failures
}
$result | ConvertTo-Json -Depth 8
if (-not $result.ok) { exit 1 }
exit 0

View File

@@ -2,6 +2,7 @@
"agentName": "Agent99",
"agentRoot": "C:\\Wooo\\Agent99",
"sshUser": "wooo",
"sshIdentityFile": "C:\\Wooo\\Agent99\\keys\\agent99_ed25519",
"sshUsers": {
"192.168.0.112": "kali",
"192.168.0.188": "ollama"
@@ -48,9 +49,26 @@
],
"k3s": {
"jumpHost": "192.168.0.110",
"preferJumpHost": true,
"directHosts": [
"192.168.0.110",
"192.168.0.112"
],
"primary": "192.168.0.120",
"namespace": "awoooi-prod"
},
"autoRecovery": {
"enabled": true,
"triggerOnHostUnreachable": true,
"minimumUnreachableHosts": 1,
"cooldownMinutes": 10
},
"recoverySlo": {
"targetMinutes": 10,
"hostTimeoutSeconds": 90,
"harborTimeoutSeconds": 120,
"workloadTimeoutSeconds": 180
},
"publicUrls": [
"awoooi.wooo.work",
"stock.wooo.work",

View File

@@ -2,6 +2,7 @@
"agentName": "Agent99",
"agentRoot": "C:\\Wooo\\Agent99",
"sshUser": "wooo",
"sshIdentityFile": "C:\\Wooo\\Agent99\\keys\\agent99_ed25519",
"sshUsers": {},
"vmrunPath": "",
"hosts": [
@@ -38,6 +39,28 @@
"vmx": ""
}
],
"k3s": {
"jumpHost": "192.168.0.110",
"preferJumpHost": true,
"directHosts": [
"192.168.0.110",
"192.168.0.112"
],
"primary": "192.168.0.120",
"namespace": "awoooi-prod"
},
"autoRecovery": {
"enabled": true,
"triggerOnHostUnreachable": true,
"minimumUnreachableHosts": 1,
"cooldownMinutes": 10
},
"recoverySlo": {
"targetMinutes": 10,
"hostTimeoutSeconds": 90,
"harborTimeoutSeconds": 120,
"workloadTimeoutSeconds": 180
},
"publicUrls": [
"awoooi.wooo.work",
"stock.wooo.work",

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
CONTROL = ROOT / "agent99-control-plane.ps1"
DEPLOY = ROOT / "agent99-deploy.ps1"
BOOTSTRAP = ROOT / "agent99-bootstrap.ps1"
def test_agent99_uses_dedicated_identity_and_preferred_jump_route() -> None:
source = CONTROL.read_text(encoding="utf-8")
function = source[source.index("function Invoke-HostSshText") :]
function = function[: function.index("function Convert-AgentDouble")]
assert '"sshIdentityFile"' in source
assert '"IdentitiesOnly=yes"' in source
assert 'PSObject.Properties["preferJumpHost"]' in function
assert 'PSObject.Properties["directHosts"]' in function
assert function.index("if ($preferJumpHost -and $canUseJump)") < function.index(
"$direct = Invoke-SshText"
)
assert 'Write-AgentLog "ssh_jump_fallback' in function
def test_agent99_auto_recovery_is_single_flight_and_slo_measured() -> None:
source = CONTROL.read_text(encoding="utf-8")
trigger = source[source.index("function Start-AgentRecoveryFromObservation") :]
trigger = trigger[: trigger.index("function Get-AgentRecoverySloConfig")]
assert 'reason = "recovery_already_queued"' in trigger
assert 'reason = "recovery_trigger_cooldown"' in trigger
assert 'source = "agent99-recovery-observer"' in trigger
assert 'mode = "Recover"' in trigger
assert "Start-Process" not in trigger
assert 'Record-AgentEvent "recovery_slo_result"' in source
assert "withinSlo = $withinSlo" in source
def test_agent99_99_config_has_bounded_transport_and_recovery_defaults() -> None:
config = json.loads((ROOT / "agent99.config.99.example.json").read_text())
assert config["sshIdentityFile"].endswith(r"keys\agent99_ed25519")
assert config["k3s"]["jumpHost"] == "192.168.0.110"
assert config["k3s"]["preferJumpHost"] is True
assert config["k3s"]["directHosts"] == ["192.168.0.110", "192.168.0.112"]
assert config["autoRecovery"]["enabled"] is True
assert config["autoRecovery"]["cooldownMinutes"] == 10
assert config["recoverySlo"]["targetMinutes"] == 10
def test_agent99_deployer_verifies_promoted_runtime_and_writes_revision() -> None:
deploy = DEPLOY.read_text(encoding="utf-8")
bootstrap = BOOTSTRAP.read_text(encoding="utf-8")
assert 'schemaVersion = "agent99_deploy_receipt_v2"' in deploy
assert 'schemaVersion = "agent99_runtime_manifest_v1"' in deploy
assert "sourceRevision = $SourceRevision" in deploy
assert 'Join-Path $BinDir "agent99-contract-check.ps1"' in deploy
assert 'Join-Path $BinDir "agent99-synthetic-tests.ps1"' in deploy
assert "if (-not $runtimeMatched)" in deploy
for name in (
"agent99-contract-check.ps1",
"agent99-deploy.ps1",
"agent99-synthetic-tests.ps1",
):
assert f'"{name}"' in deploy
assert f'"{name}"' in bootstrap