From 76138429cf00b2042d076ad0fc945d4694e1f684 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 17:16:29 +0000 Subject: [PATCH] fix/windows-actualizacion-silenciosa (#25) Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/25 Co-authored-by: hreyes Co-committed-by: hreyes --- src/lib/server/cras-agent-control.ts | 68 +++++--- src/lib/server/cras-install.test.ts | 214 +++++++++++++++++++++++++ src/lib/server/cras-install.ts | 213 +++++++++++++++++++++++- src/lib/server/cras-verify.ts | 11 +- src/routes/versiones-cras/+page.svelte | 38 ++++- 5 files changed, 509 insertions(+), 35 deletions(-) diff --git a/src/lib/server/cras-agent-control.ts b/src/lib/server/cras-agent-control.ts index 578f6b1..53fdd75 100644 --- a/src/lib/server/cras-agent-control.ts +++ b/src/lib/server/cras-agent-control.ts @@ -16,6 +16,7 @@ import { getRestoreTargetSsh } from './controldesk-pg'; import { execRemote, probeLinuxElevation, + probeWindowsAgentProcess, probeWindowsElevation, psEncoded, shQuote, @@ -23,7 +24,11 @@ import { } from './cras-install'; import { probeRemoteSystem } from './cras-verify'; import { listCrasTargetInventory } from './cras-releases'; -import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version'; +import { + effectiveInstallPath, + DEFAULT_INSTALL_PATHS, + type CrasPlatform +} from '$lib/cras-version'; import type { ApiErrorStatus } from './api-error'; import { logger } from './logger'; @@ -65,7 +70,7 @@ async function waitAlive(check: () => Promise): Promise { } } -async function startOnWindows(sftp: SftpClient): Promise { +async function startOnWindows(sftp: SftpClient, prefix: string): Promise { const privileged = await probeWindowsElevation(sftp); if (privileged.elevation !== 'admin') { throw new AgentControlError( @@ -96,38 +101,51 @@ async function startOnWindows(sftp: SftpClient): Promise { ); } + // Se espera con la MISMA sonda que usa el instalador, que mira la ruta del ejecutable y no solo + // el nombre. Con la comprobación por nombre, un agente viejo corriendo desde otra carpeta hacía + // pasar por bueno un arranque que no ocurrió. + let ultima = await probeWindowsAgentProcess(sftp, prefix); const alive = await waitAlive(async () => { - const proc = await execRemote( - sftp, - psEncoded( - "if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + - '{"si"} else {"no"}' - ) - ); - return proc.stdout.trim() === 'si'; + ultima = await probeWindowsAgentProcess(sftp, prefix); + // `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM + // no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta. + return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0); }); - return alive - ? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' } - : { - ok: false, - detail: - 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + - 'config\\logs en el servidor: el agente está fallando al iniciar.' - }; + if (alive) { + return { + ok: true, + detail: + 'Tarea CloudRestoreAS arrancada y proceso en ejecución' + + (ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.') + }; + } + return { + ok: false, + detail: ultima.running + ? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` + + 'arranque automático apunta a otra instalación.' + : 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + + `${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.` + }; } /** * Ruta donde vive el agente, para poder reconocer su proceso. * - * El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva - * la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que - * nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón. + * Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el + * NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra + * carpeta. En Linux, además, el ancla `^` del patrón de `pgrep` no es opcional: sin ella el `sh -c` + * que corre el propio pgrep lleva la ruta en su línea de comandos y haría match consigo mismo, + * reportando vivo un agente que nunca arrancó. */ -async function resolveLinuxPrefix(restoreTargetId: number): Promise { +async function resolvePrefix(restoreTargetId: number, platform: CrasPlatform): Promise { const inventory = await listCrasTargetInventory(); const row = inventory.find((t) => t.restore_target_id === restoreTargetId); - return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux; + return ( + effectiveInstallPath(row?.reported_install_path ?? null, platform) ?? + DEFAULT_INSTALL_PATHS[platform] + ); } async function startOnLinux( @@ -203,11 +221,11 @@ export async function startCrasAgent(restoreTargetId: number): Promise { * que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se * reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban. */ +/** + * Rutas de instalación personalizadas. + * + * `C:\Aduanasoft\CloudRestoreAS-win` es el peor caso posible y existe en producción: la ruta por + * omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Cualquier comparación + * hecha con `startsWith` daría por iguales dos instalaciones distintas, y el resultado sería + * exactamente el fallo que se estuvo persiguiendo: actualizar una carpeta y arrancar la otra. + * + * Las pruebas de más abajo usaban nombres sin relación entre sí (`C:\Declarada` contra `C:\Otra`), + * así que un `startsWith` mal puesto las pasaba todas. + */ +describe('comparación de rutas de Windows', () => { + const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS'; + const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win'; + + it('NO confunde la ruta por omisión con una que la tiene como prefijo', () => { + expect(sameWindowsPath(DEFECTO, PERSONALIZADA)).toBe(false); + // Y en el otro sentido: da igual cuál sea la instalada. + expect(sameWindowsPath(PERSONALIZADA, DEFECTO)).toBe(false); + expect( + sameWindowsPath(`${DEFECTO}\\CloudRestoreAS.exe`, `${PERSONALIZADA}\\CloudRestoreAS.exe`) + ).toBe(false); + }); + + it('normaliza comillas, barras finales y mayúsculas', () => { + // La acción de una tarea guarda la ruta entrecomillada; una ruta capturada a mano suele + // traer barra final; y NTFS no distingue caso. + expect(sameWindowsPath(`"${PERSONALIZADA}"`, PERSONALIZADA)).toBe(true); + expect(sameWindowsPath(`${PERSONALIZADA}\\`, PERSONALIZADA)).toBe(true); + expect(sameWindowsPath(PERSONALIZADA.toUpperCase(), PERSONALIZADA)).toBe(true); + expect(normalizeWindowsPath(' ""C:\\X\\"" ')).toBe('c:\\x'); + }); + + it('una ruta vacía no es igual a nada, ni a otra vacía', () => { + // Si lo fuera, un `execute` sin leer pasaría por "ya está alineada" y no se corregiría nada. + expect(sameWindowsPath('', '')).toBe(false); + expect(sameWindowsPath('', PERSONALIZADA)).toBe(false); + }); +}); + describe('sondas de ruta en Windows', () => { + const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS'; + const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win'; + function psFake(stdout: string) { return fakeSftp(() => ({ code: 0, stdout })); } + it('el proceso de la ruta por omisión no cuenta como el de la personalizada', async () => { + const { sftp } = psFake(`corriendo|${DEFECTO}\\CloudRestoreAS.exe`); + const r = await probeWindowsAgentProcess(sftp as never, PERSONALIZADA); + expect(r.running).toBe(true); + expect(r.fromPrefix).toBe(false); + }); + + it('ni al contrario: el de la personalizada no cuenta como el de la de omisión', async () => { + const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`); + expect((await probeWindowsAgentProcess(sftp as never, DEFECTO)).fromPrefix).toBe(false); + }); + + it('y el de la personalizada sí cuenta cuando esa es la instalación', async () => { + const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`); + expect((await probeWindowsAgentProcess(sftp as never, PERSONALIZADA)).fromPrefix).toBe(true); + }); + it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => { const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); expect(await probeWindowsTask(sftp as never)).toEqual({ @@ -475,6 +538,89 @@ describe('sondas de ruta en Windows', () => { }); }); +/** + * Realineación de la tarea programada DESDE EL PANEL. + * + * Vive aquí y no solo en install.ps1 porque un instalador ya publicado no se arregla hacia atrás: + * el script que se ejecuta en el servidor viaja DENTRO del artefacto, y el de 1.1.3 salió antes de + * que install.ps1 aprendiera a realinear la tarea. Sin esto, actualizar a 1.1.3 reemplaza el + * binario de una carpeta y `Start-ScheduledTask` levanta el de otra. + */ +describe('alignWindowsTask', () => { + const DESTINO = 'C:\\Aduanasoft\\CloudRestoreAS-win'; + const ESPERADO = `${DESTINO}\\CloudRestoreAS.exe`; + + function psFake(stdout: string) { + return fakeSftp(() => ({ code: 0, stdout })); + } + + it('reapunta una tarea que ejecuta otro binario', async () => { + const { sftp, commands } = psFake('ok'); + const cambio = await alignWindowsTask( + sftp as never, + 1, + 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', + DESTINO + ); + expect(cambio).toBe(true); + const script = Buffer.from( + commands[0].split('-EncodedCommand ')[1] ?? '', + 'base64' + ).toString('utf16le'); + expect(script).toContain(ESPERADO); + expect(script).toContain('Set-ScheduledTask'); + // Se conservan los argumentos: reregistrar desde cero perdería ajustes del operador. + expect(script).toContain('Arguments'); + // `$args` es una variable automática de PowerShell; asignarla sería un error en ejecución. + expect(script).not.toMatch(/\$args\s*=/); + }); + + it('no toca una tarea que ya apunta bien', async () => { + const { sftp, commands } = psFake('ok'); + expect(await alignWindowsTask(sftp as never, 1, ESPERADO, DESTINO)).toBe(false); + expect(commands).toHaveLength(0); + }); + + it('ignora comillas y mayúsculas al comparar', async () => { + // La acción de la tarea guarda la ruta entrecomillada y NTFS no distingue caso: comparar en + // crudo reescribiría tareas correctas en cada actualización. + const { sftp, commands } = psFake('ok'); + const cambio = await alignWindowsTask( + sftp as never, + 1, + `"${ESPERADO.toLowerCase()}"`, + DESTINO + ); + expect(cambio).toBe(false); + expect(commands).toHaveLength(0); + }); + + it('reapunta cuando la tarea usa la ruta por omisión y la instalación es la personalizada', async () => { + // El servidor real: instalado en ...-win, tarea apuntando a la carpeta por omisión. Con una + // comparación por prefijo esto se daría por alineado y no se corregiría nada. + const { sftp, commands } = psFake('ok'); + const cambio = await alignWindowsTask( + sftp as never, + 1, + 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', + 'C:\\Aduanasoft\\CloudRestoreAS-win' + ); + expect(cambio).toBe(true); + expect(commands).toHaveLength(1); + }); + + it('si no se puede corregir, falla nombrando las dos rutas', async () => { + const { sftp } = psFake('error|Acceso denegado'); + await expect( + alignWindowsTask(sftp as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO) + ).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/); + const { sftp: sftp2 } = psFake('error|Acceso denegado'); + await expect( + alignWindowsTask(sftp2 as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO) + ).rejects.toThrow(new RegExp(ESPERADO.replace(/\\/g, '\\\\'))); + }); +}); + /** * Verificación del despliegue en Windows. * @@ -497,16 +643,26 @@ describe('verifyWindowsDeployment', () => { running: boolean; /** Desde dónde corre. Por omisión, el propio prefijo. */ procPath?: string; + /** Ruta que la tarea tiene registrada en su acción. */ + taskExecute?: string; + crashLog?: string; }) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); + if (script.includes('crash.log')) return { code: 0, stdout: opts.crashLog ?? '' }; if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; if (script.includes('Get-Process')) { if (!opts.running) return { code: 0, stdout: 'detenido' }; const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`; return { code: 0, stdout: `corriendo|${ruta}` }; } + // La sonda completa de la tarea se distingue de la consulta de estado por que pregunta + // también por el principal. + if (script.includes('Principal')) { + const exec = opts.taskExecute ?? `${PREFIJO}\\CloudRestoreAS.exe`; + return { code: 0, stdout: `si|${exec}|SYSTEM` }; + } if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; return { code: 1, stdout: '' }; }); @@ -553,6 +709,64 @@ describe('verifyWindowsDeployment', () => { ).rejects.toThrow(/no escribió config\\\.version/); }); + /** + * Corre la verificación esperando que falle y devuelve el mensaje. Lanza si NO falla, para que + * una comprobación que dejara de detectar el problema no pase como verde con mensaje vacío. + */ + async function mensajeDeFallo( + ...args: Parameters + ): Promise { + try { + await verifyWindowsDeployment(...args); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + throw new Error('se esperaba que verifyWindowsDeployment fallara, y no falló'); + } + + it('el error del sello trae el diagnóstico hecho, no una tarea que investigar', async () => { + // El operador lee el mensaje, no la bitácora del run ("no sé dónde verlo"). El panel ya + // tiene a mano a qué apunta la tarea, desde dónde corre el proceso y el crash log: mandarlo + // a averiguarlo era pedirle algo que aquí ya se sabía. + const { sftp } = fakeWindows({ + stamp: '', + taskState: 'Running', + running: true, + taskExecute: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', + procPath: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', + crashLog: 'ImportError: no se pudo cargar el driver ODBC' + }); + const mensaje = await mensajeDeFallo( + sftp as never, + 1, + RELEASE, + 'service', + PREFIJO, + false, + true + ); + + expect(mensaje).toContain('C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe'); + expect(mensaje).toMatch(/arranque automático ejecuta/); + expect(mensaje).toMatch(/hay un proceso corriendo desde/); + expect(mensaje).toContain('ImportError'); + }); + + it('sin crash log el mensaje no se rompe', async () => { + const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false }); + const mensaje = await mensajeDeFallo( + sftp as never, + 1, + RELEASE, + 'service', + PREFIJO, + false, + true + ); + expect(mensaje).toMatch(/no hay ningún proceso CloudRestoreAS corriendo/); + expect(mensaje).not.toContain('Último crash'); + }); + it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => { // Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía. const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 2b71c08..6b39608 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1100,6 +1100,45 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise 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 { + 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 { + 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 { + 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( diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index 6e095cc..2e439f1 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -24,8 +24,10 @@ import { probeWindowsTask, psEncoded, readUnitProps, + sameWindowsPath, shQuote, - unitUserOrRoot + unitUserOrRoot, + windowsAgentExe } from './cras-install'; import { listCrasTargetInventory } from './cras-releases'; import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version'; @@ -827,9 +829,10 @@ async function inspectWindows( // apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el // binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después. const taskProbe = await probeWindowsTask(sftp); - const esperado = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; - const taskAlineada = - !taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase(); + const esperado = windowsAgentExe(prefix); + // Misma comparación que usa el instalador. Tenerla duplicada aquí con otro criterio dejaba a las + // dos pantallas capaces de discrepar sobre el mismo servidor. + const taskAlineada = !taskProbe.execute || sameWindowsPath(taskProbe.execute, esperado); if (taskProbe.exists) { checks.push( check( diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index a31d7d3..66f0937 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -5,7 +5,7 @@ import type { SubmitFunction } from '@sveltejs/kit'; import AppShell from '$lib/components/AppShell.svelte'; import Spinner from '$lib/components/Spinner.svelte'; - import { effectiveInstallPath, platformLabel } from '$lib/cras-version'; + import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version'; import { INSTALL_STEP_LABEL, currentPhaseLabel, @@ -463,7 +463,18 @@ {#if showFormBanner && form?.error}
error_outline -

{form.error}

+
+

{form.error}

+ + {#if progressRunId} +

+ Detalle paso a paso en el run #{progressRunId}, en el recuadro de + progreso del formulario de instalación. +

+ {/if} +