fix/windows-actualizacion-silenciosa (#25)
Reviewed-on: #25 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
@@ -1100,6 +1100,45 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparación de rutas de Windows. Una sola implementación, a propósito.
|
||||
*
|
||||
* La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente
|
||||
* distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los
|
||||
* casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation`
|
||||
* e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del
|
||||
* mismo servidor.
|
||||
*
|
||||
* Lo que hay que normalizar y por qué:
|
||||
* - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada.
|
||||
* - **Barras finales**: una ruta capturada a mano suele traerlas.
|
||||
* - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas
|
||||
* distintas donde el sistema de archivos ve una sola.
|
||||
*
|
||||
* Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de
|
||||
* `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones
|
||||
* distintas — y esa combinación existe en producción.
|
||||
*/
|
||||
export function normalizeWindowsPath(value: string): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^"+|"+$/g, '')
|
||||
.replace(/\\+$/, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */
|
||||
export function sameWindowsPath(a: string, b: string): boolean {
|
||||
const na = normalizeWindowsPath(a);
|
||||
const nb = normalizeWindowsPath(b);
|
||||
return na !== '' && na === nb;
|
||||
}
|
||||
|
||||
/** Ruta del ejecutable del agente dentro de una carpeta de instalación. */
|
||||
export function windowsAgentExe(prefix: string): string {
|
||||
return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
|
||||
}
|
||||
|
||||
export interface WindowsProcessProbe {
|
||||
running: boolean;
|
||||
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
|
||||
@@ -1139,11 +1178,11 @@ export async function probeWindowsAgentProcess(
|
||||
}
|
||||
const joined = out.split('|')[1] ?? '';
|
||||
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
|
||||
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
|
||||
const wanted = windowsAgentExe(prefix);
|
||||
return {
|
||||
running: true,
|
||||
paths,
|
||||
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted)
|
||||
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1190,6 +1229,136 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
|
||||
*
|
||||
* Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede
|
||||
* arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3,
|
||||
* y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde
|
||||
* aquí funciona con cualquier versión ya distribuida.
|
||||
*
|
||||
* Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal,
|
||||
* ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador
|
||||
* le haya hecho.
|
||||
*/
|
||||
export async function alignWindowsTask(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
taskExecute: string,
|
||||
targetPath: string
|
||||
): Promise<boolean> {
|
||||
if (!taskExecute) return false;
|
||||
const wanted = windowsAgentExe(targetPath);
|
||||
if (sameWindowsPath(taskExecute, wanted)) return false;
|
||||
|
||||
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
|
||||
// dentro de un bloque de script es un error.
|
||||
const result = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
"if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " +
|
||||
'$acciones = @(); ' +
|
||||
'foreach ($a in @($t.Actions)) { ' +
|
||||
" $pe = $a.PSObject.Properties['Execute']; " +
|
||||
' if (-not $pe) { $acciones += $a; continue }; ' +
|
||||
" $pa = $a.PSObject.Properties['Arguments']; " +
|
||||
" $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " +
|
||||
' if ($argumentos) { $acciones += New-ScheduledTaskAction ' +
|
||||
`-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` +
|
||||
'else { $acciones += New-ScheduledTaskAction ' +
|
||||
`-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` +
|
||||
"try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " +
|
||||
"-ErrorAction Stop | Out-Null; Write-Output 'ok' } " +
|
||||
"catch { Write-Output ('error|' + $_.Exception.Message) }"
|
||||
)
|
||||
);
|
||||
|
||||
const out = result.stdout.trim();
|
||||
if (out === 'ok') {
|
||||
// Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de
|
||||
// instalación, este es el dato con el que se deshace el cambio a mano.
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'realinear-arranque',
|
||||
true,
|
||||
`la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo
|
||||
// silencioso que se está persiguiendo.
|
||||
throw new InstallError(
|
||||
409,
|
||||
`La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` +
|
||||
`instalar (${wanted}), y no se pudo corregir` +
|
||||
(out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') +
|
||||
'. Sin eso, el arranque automático seguiría levantando la versión anterior.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinicia el agente en Windows y espera a que corra el binario de `prefix`.
|
||||
*
|
||||
* Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría
|
||||
* una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia.
|
||||
*/
|
||||
export async function restartWindowsAgent(
|
||||
sftp: SftpClient,
|
||||
prefix: string,
|
||||
timeoutMs = 60_000
|
||||
): Promise<WindowsProcessProbe> {
|
||||
const exe = windowsAgentExe(prefix);
|
||||
await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
"if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " +
|
||||
"Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " +
|
||||
'Stop-Process -Force -ErrorAction SilentlyContinue; ' +
|
||||
'Start-Sleep -Seconds 3; ' +
|
||||
// Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el
|
||||
// proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen.
|
||||
"if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " +
|
||||
`else { Start-Process -FilePath '${exe}' ` +
|
||||
`-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` +
|
||||
'-WindowStyle Hidden }'
|
||||
),
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = await probeWindowsAgentProcess(sftp, prefix);
|
||||
while (Date.now() < deadline) {
|
||||
// `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de
|
||||
// SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen.
|
||||
if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
probe = await probeWindowsAgentProcess(sftp, prefix);
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cola del crash log que el agente escribe junto a su ejecutable 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.
|
||||
*/
|
||||
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
|
||||
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 }'
|
||||
)
|
||||
);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function installWindows(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
@@ -1417,6 +1586,22 @@ async function installWindows(
|
||||
);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
const proc = await restartWindowsAgent(sftp, targetPath);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'rearrancar-agente',
|
||||
proc.running,
|
||||
proc.running
|
||||
? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
||||
: 'el agente no volvió a arrancar tras realinear la tarea'
|
||||
);
|
||||
}
|
||||
|
||||
await verifyWindowsDeployment(
|
||||
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
|
||||
);
|
||||
@@ -1487,12 +1672,32 @@ export async function verifyWindowsDeployment(
|
||||
// no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva,
|
||||
// que es donde sí es legítima.
|
||||
if (!deployed && isUpdate) {
|
||||
// El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea,
|
||||
// desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el
|
||||
// arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run,
|
||||
// donde sí estaba, no la encuentra nadie.
|
||||
const [task, proc, crash] = await Promise.all([
|
||||
probeWindowsTask(sftp),
|
||||
probeWindowsAgentProcess(sftp, prefix),
|
||||
readWindowsCrashLog(sftp, prefix)
|
||||
]);
|
||||
|
||||
const pistas = [
|
||||
task.exists
|
||||
? `el arranque automático ejecuta ${task.execute || '(sin acción)'}`
|
||||
: 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente',
|
||||
proc.running
|
||||
? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
||||
: 'no hay ningún proceso CloudRestoreAS corriendo'
|
||||
];
|
||||
|
||||
throw new InstallError(
|
||||
502,
|
||||
`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}. ` +
|
||||
'Revisa a qué binario apunta el arranque automático del servidor y los logs en ' +
|
||||
`${prefix}\\config\\logs.`
|
||||
`${pistas.join('; ')}.` +
|
||||
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
|
||||
` Logs en ${prefix}\\config\\logs.`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(
|
||||
|
||||
Reference in New Issue
Block a user