fix(install): la actualizacion de Windows decia que funciono y no cambiaba nada

Actualizar un servidor con el agente en una carpeta NO estandar terminaba en
verde y lo dejaba con la version anterior. Todo el camino de Windows
identificaba al agente por NOMBRE, mientras que lo unico que se actualiza se
identifica por RUTA; en cuanto las dos no coincidian, nada fallaba y nada
cambiaba.

- Se alinea la tarea programada con el binario instalado. `Start-ScheduledTask`
  ejecuta la ruta registrada en su accion, no el -Prefix: si difieren, se copiaba
  el binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se
  reapunta conservando disparador, principal, ajustes y argumentos; si no se
  puede corregir, FALLA — arrancar a sabiendas el binario anterior es peor.
- La confirmacion de arranque mira la RUTA del proceso. Un agente viejo que
  nunca se detuvo satisfacia igual de bien un `Get-Process -Name`. Si la ruta no
  es legible (un proceso de SYSTEM no la expone sin elevacion) se acepta por
  nombre y se avisa, en vez de revertir una actualizacion correcta por falta de
  informacion.
- Corregido Merge-EnvFile con un config\.env de UNA linea: al asignar la salida
  de un `if`, PowerShell desenrolla un array de un elemento a escalar, asi que
  $lines.Count reventaba con Set-StrictMode y la siembra abortaba la instalacion.

Nuevo scripts/emular-actualizacion-windows.ps1: monta un agente falso (un .exe
real que se queda vivo), una instalacion en una carpeta y una tarea apuntando a
otra, corre el instalador y dice si la actualizacion surtio efecto. Sin elevacion
y sin tocar la instalacion real de la maquina. Es lo que destapo los dos
defectos: contra el instalador anterior reproduce el sintoma exacto —codigo de
salida 0 y "El agente esta corriendo con el binario nuevo" sobre un servidor
intacto— y contra este confirma que ya surte efecto, sin tocar la tarea cuando
ya estaba bien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:28:05 -06:00
parent 0a62b7d0aa
commit 0ac899f531
3 changed files with 416 additions and 5 deletions

View File

@@ -0,0 +1,213 @@
<#
Emulación end-to-end en Windows de la actualización que "completa y no hace nada".
Reproduce el caso del servidor real: el agente vive en la carpeta que el panel tiene declarada,
pero la tarea programada ejecuta el binario de OTRA carpeta. Reemplazar el binario de la primera
y arrancar la tarea levanta el de la segunda.
Qué es REAL aquí: el agente es un .exe compilado que se queda vivo, los archivos y carpetas, la
copia del binario, la identidad del proceso por ruta, el sello config\.version y la reversión.
El "viejo" NO escribe el sello (igual que 1.1.0) y el "nuevo" sí (igual que 1.1.3) — esa
diferencia es la que hacía que la verificación del panel tolerara el sello vacío.
Qué está simulado y por qué: los cmdlets *-ScheduledTask. Registrar una tarea exige elevación, y
la gracia de esta emulación es poder correrla sin ella. Los sustitutos respetan la semántica que
provoca el fallo: Start-ScheduledTask lanza LA RUTA REGISTRADA EN LA TAREA, no la que se acaba de
instalar. Como install.ps1 se invoca con &, sus llamadas resuelven a estas funciones del ámbito
padre. Si quieres la prueba con una tarea de verdad, corre esto en PowerShell elevado tras
quitar los sustitutos.
Uso:
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1
.\emular-actualizacion-windows.ps1 -Installer ..\install.ps1 -Escenario alineada
Encontró dos defectos que las pruebas de unidad no veían: el rearranque del binario equivocado,
y que Merge-EnvFile reventaba con un config\.env de una sola línea.
#>
param(
# install.ps1 a poner a prueba. Para comparar contra una versión anterior:
# git show <commit>:install.ps1 > install-antes.ps1
[Parameter(Mandatory = $true)][string]$Installer,
[string]$Etiqueta = 'instalador',
# 'desalineada' reproduce el servidor que falla; 'alineada' es el caso normal, para comprobar
# que el arreglo no lo rompe (una tarea que ya apunta bien no debe tocarse).
[ValidateSet('desalineada', 'alineada')][string]$Escenario = 'desalineada',
[string]$Carpeta = 'C:\Users\Public\cras-e2e'
)
$ErrorActionPreference = 'Stop'
$base = $Carpeta
$declarada = Join-Path $base 'declarada' # donde el panel cree que está (y está)
$otra = Join-Path $base 'otra-carpeta' # a donde apunta la tarea
$pkg = Join-Path $base 'pkg'
function Nuevo-AgenteFalso {
param([string]$Destino, [string]$Version, [switch]$EscribeSello)
# Here-strings LITERALES: el C# lleva comillas dobles y en una cadena interpolada de PowerShell
# habría que escaparlas con backtick, no con barra invertida.
$plantilla = @'
using System; using System.IO; using System.Threading;
class P { static void Main() {
string d = AppDomain.CurrentDomain.BaseDirectory;
__CUERPO__
Thread.Sleep(Timeout.Infinite); } }
'@
$cuerpo = if ($EscribeSello) {
@'
Directory.CreateDirectory(Path.Combine(d, "config"));
File.WriteAllText(Path.Combine(d, "config", ".version"), "__VER__");
'@
} else {
' // 1.1.0 no escribia el sello de version'
}
$src = $plantilla.Replace('__CUERPO__', $cuerpo).Replace('__VER__', $Version)
New-Item -ItemType Directory -Path (Split-Path -Parent $Destino) -Force | Out-Null
Add-Type -TypeDefinition $src -OutputAssembly $Destino -OutputType ConsoleApplication
}
function Matar-Agentes {
Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 800
}
# ------------------------------------------------ tarea programada simulada (estado + semántica)
$global:tareaExiste = $true
$global:tareaExecute = if ($Escenario -eq 'alineada') {
Join-Path $declarada 'CloudRestoreAS.exe'
} else {
Join-Path $otra 'CloudRestoreAS.exe'
}
$global:tareaArgs = '--start-engine --headless'
$global:reapuntadaVeces = 0
function Get-ScheduledTask {
[CmdletBinding()] param([string]$TaskName)
if (-not $global:tareaExiste) { return $null }
[pscustomobject]@{
TaskName = 'CloudRestoreAS'
State = 'Running'
Actions = @([pscustomobject]@{
Execute = $global:tareaExecute
Arguments = $global:tareaArgs
})
}
}
function Set-ScheduledTask {
[CmdletBinding()] param([string]$TaskName, $Action)
$global:tareaExecute = $Action[0].Execute
$global:tareaArgs = $Action[0].Arguments
$global:reapuntadaVeces++
return $true
}
function New-ScheduledTaskAction {
[CmdletBinding()] param([string]$Execute, [string]$Argument, [string]$WorkingDirectory)
[pscustomobject]@{ Execute = $Execute; Arguments = $Argument }
}
function Start-ScheduledTask {
# AQUÍ está el corazón del fallo: se lanza la ruta REGISTRADA EN LA TAREA.
[CmdletBinding()] param([string]$TaskName)
Start-Process -FilePath $global:tareaExecute `
-WorkingDirectory (Split-Path -Parent $global:tareaExecute) -WindowStyle Hidden | Out-Null
}
function Stop-ScheduledTask {
[CmdletBinding()] param([string]$TaskName)
Matar-Agentes
}
# ---------------------------------------------------------------- montaje del escenario
Matar-Agentes
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "=== Montando escenario: $Etiqueta ===" -ForegroundColor Cyan
Nuevo-AgenteFalso -Destino (Join-Path $declarada 'CloudRestoreAS.exe') -Version '1.1.0'
New-Item -ItemType Directory -Path (Join-Path $declarada 'config') -Force | Out-Null
# Multilínea, como el que escribe el bootstrap del agente. Con UNA sola línea se destapa un bug
# aparte de Merge-EnvFile (el array se desenrolla a escalar y $lines.Count revienta con StrictMode),
# que el instalador viejo todavía tiene y aquí taparía el fallo que se quiere medir.
Set-Content -Path (Join-Path $declarada 'config\.env') -Value @(
'# Configuracion de CloudRestoreAS',
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
'CLOUDRESTORE_AUTO_START=true'
)
Nuevo-AgenteFalso -Destino (Join-Path $otra 'CloudRestoreAS.exe') -Version '1.1.0'
New-Item -ItemType Directory -Path $pkg -Force | Out-Null
Copy-Item $Installer (Join-Path $pkg 'install.ps1') -Force
Nuevo-AgenteFalso -Destino (Join-Path $pkg 'dist\CloudRestoreAS.exe') -Version '1.1.3' -EscribeSello
$panelEnv = Join-Path $base 'panel.env'
Set-Content -Path $panelEnv -Value @(
'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000',
'CLOUDRESTORE_PANEL_API_TOKEN=token-de-prueba-no-real',
'CLOUDRESTORE_PANEL_INSTANCE_KEY=servidor-de-prueba'
)
# El agente viejo corriendo, lanzado por la tarea desde su carpeta.
Start-ScheduledTask -TaskName 'CloudRestoreAS'
Start-Sleep -Seconds 2
Write-Host " instalacion declarada : $declarada"
Write-Host " tarea ejecuta : $global:tareaExecute"
$antes = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
ForEach-Object { $_.Path } | Select-Object -Unique)
Write-Host " proceso corriendo de : $($antes -join ', ')"
# ---------------------------------------------------------------- la actualización
Write-Host ''
Write-Host '=== Actualizacion, tal como la lanza el panel ===' -ForegroundColor Cyan
$codigo = 0
try {
& (Join-Path $pkg 'install.ps1') -UpdateInPlace -Prefix $declarada -PanelEnvFile $panelEnv
$codigo = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
} catch {
$codigo = 1
Write-Host " [el instalador lanzo] $($_.Exception.Message)" -ForegroundColor Yellow
}
Write-Host "--- codigo de salida: $codigo"
# ---------------------------------------------------------------- veredicto
Start-Sleep -Seconds 3
Write-Host ''
Write-Host '=== RESULTADO ===' -ForegroundColor Cyan
$esperado = Join-Path $declarada 'CloudRestoreAS.exe'
$procAhora = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue |
ForEach-Object { try { $_.Path } catch { '(ilegible)' } } | Select-Object -Unique)
$selloPath = Join-Path $declarada 'config\.version'
$sello = if (Test-Path $selloPath) { (Get-Content $selloPath -Raw).Trim() } else { '(no existe)' }
Write-Host " tarea ejecuta : $global:tareaExecute (reapuntada $global:reapuntadaVeces vez/veces)"
Write-Host " proceso corriendo de : $($procAhora -join ', ')"
Write-Host " config\.version : $sello"
$fallos = @()
if ($global:tareaExecute -ne $esperado) { $fallos += "la tarea sigue apuntando a $global:tareaExecute" }
if ($procAhora -notcontains $esperado) { $fallos += 'el proceso vivo no es el de la instalacion declarada' }
if ($sello -ne '1.1.3') { $fallos += "el sello dice '$sello', se esperaba 1.1.3" }
# Una tarea que ya apuntaba bien no debe reescribirse: hacerlo sin necesidad arriesga perder
# ajustes que el operador le haya hecho.
if ($Escenario -eq 'alineada' -and $global:reapuntadaVeces -ne 0) {
$fallos += "se reescribio una tarea que ya estaba bien ($global:reapuntadaVeces vez/veces)"
}
Write-Host ''
if ($fallos.Count -eq 0 -and $codigo -eq 0) {
Write-Host 'VEREDICTO: la actualizacion SI surtio efecto' -ForegroundColor Green
} elseif ($fallos.Count -eq 0) {
Write-Host "VEREDICTO: surtio efecto pero el instalador salio con $codigo" -ForegroundColor Yellow
} else {
Write-Host 'VEREDICTO: la actualizacion NO surtio efecto' -ForegroundColor Red
$fallos | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
if ($codigo -eq 0) {
Write-Host ' *** y el instalador dijo que TODO BIEN (codigo 0) ***' -ForegroundColor Red
}
}
Matar-Agentes
Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue