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

@@ -94,6 +94,107 @@ function Get-AgentTask {
Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
} }
function Get-NormalizedPath([string]$Path) {
<#
Normaliza para comparar rutas: la tarea puede guardarlas entrecomilladas, con otra
capitalización o con separador final. Comparar las cadenas en crudo daba falsos negativos.
#>
if (-not $Path) { return '' }
$clean = $Path.Trim().Trim('"')
try { return [System.IO.Path]::GetFullPath($clean).TrimEnd('\') } catch { return $clean.TrimEnd('\') }
}
function Test-SamePath([string]$A, [string]$B) {
if (-not $A -or -not $B) { return $false }
# NTFS no distingue mayúsculas: comparar sensible a caso reportaría dos rutas distintas donde
# el SO ve una sola.
return (Get-NormalizedPath $A) -ieq (Get-NormalizedPath $B)
}
function Get-AgentTaskExecute {
<#
Ruta del ejecutable que la tarea programada tiene registrada en su acción.
Este es el dato que faltaba. Reemplazar el binario es una operación por RUTA, pero
arrancarlo con `Start-ScheduledTask` es una operación por NOMBRE. Si la tarea apunta a otra
carpeta se actualiza un binario y se arranca otro: el servidor se queda en la versión vieja
y la actualización parece haber funcionado.
Se lee la propiedad por reflexión porque no todas las acciones de una tarea son de tipo
Exec (una ComHandler no tiene `Execute`), y con Set-StrictMode tocar una propiedad que no
existe es un error.
#>
$task = Get-AgentTask
if (-not $task) { return '' }
foreach ($action in @($task.Actions)) {
$prop = $action.PSObject.Properties['Execute']
if ($prop -and $prop.Value) { return ([string]$prop.Value).Trim().Trim('"') }
}
return ''
}
function Repair-AgentTaskPath {
<#
Reapunta la acción de la tarea al binario recién instalado, conservando el resto de su
definición (disparador, principal, ajustes) y los argumentos que tuviera: volver a
registrarla desde cero perdería cualquier ajuste que el operador le haya hecho.
#>
param($Task)
$actions = @()
foreach ($action in @($Task.Actions)) {
$execProp = $action.PSObject.Properties['Execute']
if (-not $execProp) { $actions += $action; continue }
$argProp = $action.PSObject.Properties['Arguments']
$arguments = if ($argProp -and $argProp.Value) { [string]$argProp.Value } else { '' }
$actions += if ($arguments) {
New-ScheduledTaskAction -Execute $dest -Argument $arguments -WorkingDirectory $Prefix
} else {
New-ScheduledTaskAction -Execute $dest -WorkingDirectory $Prefix
}
}
try {
Set-ScheduledTask -TaskName $TaskName -Action $actions -ErrorAction Stop | Out-Null
Write-Ok "Tarea '$TaskName' reapuntada a $dest."
} catch {
throw ("La tarea '$TaskName' ejecuta un binario distinto del que se acaba de instalar y no " +
"se pudo corregir ($($_.Exception.Message)). Arrancarla levantaría la versión anterior " +
'y la actualización quedaría sin efecto aparentando haber funcionado, que es peor que ' +
'fallar aquí.')
}
}
function Get-AgentState {
<#
Estado del agente en una sola consulta: si hay algo vivo, si alguno corre el binario de
ESTA instalación, y desde dónde corren los que no.
`PathsReadable` separa "no corre desde aquí" de "no pude ver desde dónde corre": un proceso
de SYSTEM no expone `.Path` a una cuenta sin elevación, y tratar ese caso como "es de otra
instalación" revertiría actualizaciones correctas.
Vive en un solo sitio a propósito. Esta lógica repetida en dos funciones es exactamente
cómo se colaron las divergencias que estamos arreglando.
#>
$procs = @(Get-AgentProcess)
$paths = @()
foreach ($proc in $procs) {
try { if ($proc.Path) { $paths += $proc.Path } } catch { }
}
$fromPrefix = $false
foreach ($path in $paths) {
if (Test-SamePath $path $dest) { $fromPrefix = $true; break }
}
return [pscustomobject]@{
Running = ($procs.Count -gt 0)
PathsReadable = (($procs.Count -eq 0) -or ($paths.Count -gt 0))
FromPrefix = $fromPrefix
Paths = $paths
}
}
function Test-RestoreInProgress { function Test-RestoreInProgress {
<# <#
Una restauración en curso no se interrumpe. El agente no atiende señales de terminación, Una restauración en curso no se interrumpe. El agente no atiende señales de terminación,
@@ -147,6 +248,9 @@ function Start-Agent {
No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin
'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM 'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM
en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana. en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana.
Que la acción de la tarea apunte a `$dest` lo garantiza `Sync-AgentTaskPath`, que corre
justo después de copiar el binario. Aquí ya se puede arrancar sin volver a comprobarlo.
#> #>
if (Get-AgentTask) { if (Get-AgentTask) {
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
@@ -158,15 +262,59 @@ function Start-Agent {
} }
function Wait-AgentAlive { function Wait-AgentAlive {
<#
Espera a que el agente esté vivo, y que sea el binario de ESTA instalación.
La identidad importa: `Get-Process -Name` responde "hay un proceso con ese nombre", no
"corre el binario que instalé". Con la tarea apuntando a otra carpeta, el agente viejo
—que nunca se fue— satisfacía la comprobación por nombre y la actualización pasaba por
buena sin haber cambiado nada.
Si la ruta del proceso no es legible NO se concluye que sea de otra instalación: un proceso
de SYSTEM no expone `.Path` a una cuenta sin elevación. Se acepta por nombre y se avisa,
porque revertir una actualización correcta por falta de información es peor.
#>
param([int]$TimeoutSeconds = 60) param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$foreign = @()
while ((Get-Date) -lt $deadline) { while ((Get-Date) -lt $deadline) {
if (Get-AgentProcess) { return $true } $state = Get-AgentState
if ($state.Running) {
if (-not $state.PathsReadable) {
Write-Warn ("Hay $ProcName corriendo pero no se pudo leer su ruta; se acepta por " +
'nombre. La versión efectiva la confirma el panel con config\.version.')
return $true
}
if ($state.FromPrefix) { return $true }
$foreign = $state.Paths
}
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
} }
# Se avisa una sola vez y al final: dentro del bucle serían treinta líneas iguales.
if ($foreign.Count -gt 0) {
Write-Warn ("Hay $ProcName corriendo desde $($foreign -join ', '), pero no desde $dest.")
}
return $false return $false
} }
function Sync-AgentTaskPath {
<#
Alinea la acción de la tarea programada con el binario recién instalado.
Es la raíz del fallo silencioso: reemplazar el binario es una operación por RUTA, pero
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. En una
instalación fuera de la carpeta por omisión, eso significaba copiar el binario nuevo en un
sitio y arrancar el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
#>
$registered = Get-AgentTaskExecute
if (-not $registered) { return }
if (Test-SamePath $registered $dest) { return }
Write-Warn "La tarea '$TaskName' ejecutaba $registered, no $dest."
Repair-AgentTaskPath -Task (Get-AgentTask)
}
function Copy-Binary { function Copy-Binary {
<# <#
Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden
@@ -269,6 +417,11 @@ if (Test-Path -LiteralPath $dest) {
Copy-Binary -From $src -To $dest Copy-Binary -From $src -To $dest
Write-Ok "Binario instalado en $dest" Write-Ok "Binario instalado en $dest"
# El binario nuevo ya está en su sitio; falta que el arranque automático apunte AHÍ. Va aquí y no
# dentro de Start-Agent para que se corrija aunque en este momento no haya que arrancar nada: una
# tarea desalineada seguiría levantando la versión vieja en el próximo reinicio del servidor.
Sync-AgentTaskPath
# --- Bootstrap de config\ ----------------------------------------------------------- # --- Bootstrap de config\ -----------------------------------------------------------
# El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre # El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre
# una vez acotado por timeout para que el operador ya pueda editar config\.env. # una vez acotado por timeout para que el operador ya pueda editar config\.env.
@@ -304,9 +457,13 @@ if (Test-Path -LiteralPath $envPath) {
function Merge-EnvFile { function Merge-EnvFile {
param([string]$Target, [hashtable]$Values) param([string]$Target, [hashtable]$Values)
$lines = if (Test-Path -LiteralPath $Target) { # El @() envuelve el `if` COMPLETO, no solo el Get-Content. Al asignar la salida de un `if`,
@(Get-Content -LiteralPath $Target -Encoding UTF8) # PowerShell desenrolla un array de un solo elemento a escalar: con un config\.env de UNA línea
} else { @() } # $lines quedaba como String y `$lines.Count` reventaba con Set-StrictMode. Lo destapó la
# emulación de una actualización sobre una instalación con .env mínimo.
$lines = @(
if (Test-Path -LiteralPath $Target) { Get-Content -LiteralPath $Target -Encoding UTF8 }
)
foreach ($key in $Values.Keys) { foreach ($key in $Values.Keys) {
$line = "$key=$($Values[$key])" $line = "$key=$($Values[$key])"
@@ -417,7 +574,16 @@ switch ($Mode) {
$shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted $shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted
if ($shouldBeRunning) { if ($shouldBeRunning) {
if (-not (Get-AgentProcess)) { # La condición mira la RUTA, no solo el nombre. Mientras se copiaba, la tarea pudo relanzar el
# agente desde su ruta anterior (trae RestartCount): con una comprobación por nombre eso pasaba
# por "ya está corriendo", no se arrancaba el nuevo, y la actualización quedaba sin efecto.
$state = Get-AgentState
if (-not ($state.Running -and ($state.FromPrefix -or -not $state.PathsReadable))) {
if ($state.Running) {
Write-Warn ("Hay $ProcName corriendo desde $($state.Paths -join ', '); se termina para " +
'que no compita con el binario recién instalado.')
Stop-Agent -TimeoutSeconds 15 | Out-Null
}
$how = Start-Agent $how = Start-Agent
Write-Step "Rearrancando el agente ($how)" Write-Step "Rearrancando el agente ($how)"
} }

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

View File

@@ -138,6 +138,38 @@ def test_install_ps1_respalda_y_revierte():
assert "Remove-Item -LiteralPath $backup" in ps1 assert "Remove-Item -LiteralPath $backup" in ps1
def test_install_ps1_alinea_la_tarea_con_el_binario_instalado():
"""
El fallo silencioso: reemplazar el binario es una operación por RUTA, pero
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. Con una
instalación fuera de la carpeta por omisión, eso copiaba el binario nuevo en un sitio y
arrancaba el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
Se comprueba con `scripts/emular-actualizacion-windows.ps1`, que reproduce el escenario
completo; esto solo protege de que las piezas desaparezcan en un refactor.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "Sync-AgentTaskPath" in ps1
assert "Get-AgentTaskExecute" in ps1
# La comparación tiene que normalizar: la tarea guarda la ruta entrecomillada y NTFS no
# distingue mayúsculas, así que comparar las cadenas en crudo da falsos negativos.
assert "Test-SamePath" in ps1
# Y la confirmación de arranque tiene que mirar la RUTA del proceso, no solo su nombre.
assert "FromPrefix" in ps1
def test_merge_env_file_no_se_desenrolla_con_una_sola_linea():
"""
`$lines = if (...) { @(Get-Content ...) } else { @() }` desenrolla un array de UN elemento a
escalar al asignarlo, así que con un config\\.env de una sola línea `$lines.Count` reventaba
bajo Set-StrictMode y la siembra de credenciales abortaba la instalación. El `@()` tiene que
envolver el `if` COMPLETO.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "$lines = @(\n" in ps1, "el @() debe envolver el if completo, no solo el Get-Content"
assert "$lines = if (" not in ps1
def test_el_arranque_automatico_de_windows_pide_headless(): def test_el_arranque_automatico_de_windows_pide_headless():
""" """
La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es