fix/windows-actualizacion-silenciosa (#26)

Reviewed-on: #26
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-31 17:57:10 +00:00
committed by acazares
parent 76138429cf
commit d2d8d4c108
2 changed files with 183 additions and 21 deletions

View File

@@ -1229,6 +1229,49 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
};
}
/**
* ¿Se puede desplegar este artefacto en Windows sin romper el servidor?
*
* Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los
* arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente.
* Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente
* para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir
* `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
* binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como
* SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin
* agente nuevo.
*
* Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo
* después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que
* perder y negarse solo estorbaría.
*
* Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con
* consecuencias: merece nombre propio y prueba propia.
*/
export function checkWindowsArtifactUpdateSupport(
mode: InstallMode,
supportsInPlace: boolean,
version: string,
targetName: string
): string {
if (supportsInPlace) return '';
const aviso =
`El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` +
'(no declara -UpdateInPlace).';
if (mode === 'update') {
throw new InstallError(
409,
`${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` +
'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' +
'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' +
'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' +
'actualiza a esa.'
);
}
return `${aviso} Se instala igual: no hay un agente en marcha que perder.`;
}
/**
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
*
@@ -1341,22 +1384,50 @@ export async function restartWindowsAgent(
}
/**
* Cola del crash log que el agente escribe junto a su ejecutable cuando no consigue arrancar.
* Cola del crash log que el agente escribe cuando no consigue arrancar.
*
* `runner.py` lo escribe precisamente para que un arranque fallido sea visible
* (`_crash_log_targets()`), y hasta ahora nadie lo leía nunca: el operador recibía "no arrancó" en
* lugar de "no arrancó porque X". Devuelve cadena vacía si no existe.
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
* `%TEMP%\CloudRestoreAS-crash.log`.
*
* Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por
* una tarea que corre como SYSTEM resuelve las otras dos bajo
* `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que
* fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X".
*
* Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre
* como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de
* SYSTEM en vez de confiar en la expansión.
*/
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
async function readWindowsCrashLog(
sftp: SftpClient,
prefix: string
): Promise<{ text: string; from: string }> {
const candidatos = [
`${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`,
'$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log',
'$env:TEMP\\CloudRestoreAS-crash.log',
// El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la
// tarea programada como SYSTEM y no la sesión SSH.
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log',
'C:\\Windows\\Temp\\CloudRestoreAS-crash.log'
];
const result = await execRemote(
sftp,
psEncoded(
`$p = '${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log'; ` +
'if (Test-Path -LiteralPath $p) { ' +
'(Get-Content -LiteralPath $p -Tail 20) -join [Environment]::NewLine }'
`$rutas = @("${candidatos.join('","')}"); ` +
'foreach ($r in $rutas) { ' +
'if (Test-Path -LiteralPath $r) { ' +
"Write-Output ('DE|' + $r); " +
'(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }'
)
);
return result.stdout.trim();
const salida = result.stdout.trim();
if (!salida.startsWith('DE|')) return { text: '', from: '' };
const [cabecera, ...resto] = salida.split('\n');
return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() };
}
async function installWindows(
@@ -1542,16 +1613,18 @@ async function installWindows(
)
);
const supportsInPlace = supportsProbe.stdout.trim() === 'si';
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
if (request.mode === 'update' && !supportsInPlace) {
await appendInstallStep(
runId,
'instalador-sin-update-in-place',
true,
`${release.version} trae un install.ps1 que no soporta -UpdateInPlace; se instala ` +
'con el modo de arranque normal'
);
const avisoArtefacto = checkWindowsArtifactUpdateSupport(
request.mode,
supportsInPlace,
release.version,
target.name
);
if (avisoArtefacto) {
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
}
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
const flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
@@ -1586,11 +1659,26 @@ async function installWindows(
);
}
// El instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada:
// `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la
// sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que
// cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico.
const taskDespues = await probeWindowsTask(sftp);
if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) {
await appendInstallStep(
runId,
'cambio-de-cuenta-de-la-tarea',
true,
`el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'}` +
`${taskDespues.principal || '(sin declarar)'}`
);
}
// El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático
// apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
// actualización termina en verde sin haber cambiado nada.
if (await alignWindowsTask(sftp, runId, task.execute, targetPath)) {
if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) {
const proc = await restartWindowsAgent(sftp, targetPath);
await appendInstallStep(
runId,
@@ -1696,7 +1784,7 @@ export async function verifyWindowsDeployment(
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
`${pistas.join('; ')}.` +
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
(crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
` Logs en ${prefix}\\config\\logs.`
);
}