From 9a4124d636907d2f259233bc5cfac329b7c99f66 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 10:28:39 -0600 Subject: [PATCH 1/5] fix(cras-install): dejar de dar por buena una actualizacion que no surtio efecto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El run de Windows terminaba en verde sobre un servidor que seguia con la version anterior. La verificacion no podia detectarlo: - El sello config\.version ausente se toleraba SIEMPRE. Los agentes anteriores a 1.1.1 no lo escribian, asi que al actualizar uno de esos `deployed` llegaba vacio y no se comprobaba ninguna version — justo la combinacion que importa. Ahora, en una ACTUALIZACION, un sello ausente es un fallo: el binario nuevo lo escribe en ensure_runtime_layout(), asi que su ausencia significa que lo que corre no es el que se instalo. La tolerancia se queda solo en instalacion nueva. - `Get-Process -Name` responde "hay un proceso con ese nombre", no "corre el binario que instale". Se compara la RUTA del ejecutable; si no es legible no se concluye que sea ajeno, porque un proceso de SYSTEM no la expone sin elevacion. - Se interroga la tarea programada a fondo: su accion dice DONDE arranca el agente de verdad y se asienta en la bitacora (el dato que habria explicado esto en diez segundos), y cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— se usa la de la tarea en vez del default. Y dos correcciones de lo anterior: - needsElevation exigia admin en cuanto la tarea EXISTIA, bloqueando de entrada toda actualizacion sobre un servidor ya instalado. Ahora depende de que la tarea corra como SYSTEM, que es lo que de verdad obliga a elevar; y el token filtrado por UAC deja de rechazarse por adelantado: se intenta y se reporta lo que responda el servidor. - -UpdateInPlace se pasaba siempre, pero solo existe desde 1.1.3 y install.ps1 usa [CmdletBinding()], asi que con un artefacto anterior fallaba con NamedParameterNotFound sin ejecutar una linea. Se le pregunta a PowerShell por el param() del propio artefacto en vez de mantener una tabla de versiones. Verificar comparte las dos sondas nuevas para que no pueda contradecir al instalador, y gana un chequeo de "el arranque apunta a la instalacion". Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-install.test.ts | 111 ++++++++++++- src/lib/server/cras-install.ts | 245 ++++++++++++++++++++++++---- src/lib/server/cras-verify.ts | 50 ++++-- 3 files changed, 353 insertions(+), 53 deletions(-) diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index f021d65..43c5df3 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -28,7 +28,9 @@ import { execRemote, probeInPlaceUpdate, probeLinuxElevation, + probeWindowsAgentProcess, probeWindowsElevation, + probeWindowsTask, psEncoded, shQuote, verifyWindowsDeployment, @@ -419,25 +421,91 @@ describe('probeWindowsElevation', () => { }); }); +/** + * Sondas de Windows que miran la RUTA, no el nombre. + * + * Son la respuesta al fallo silencioso: todo el camino identificaba al agente por NOMBRE, mientras + * 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. + */ +describe('sondas de ruta en Windows', () => { + function psFake(stdout: string) { + return fakeSftp(() => ({ code: 0, stdout })); + } + + 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({ + exists: true, + execute: 'C:\\Otra\\CloudRestoreAS.exe', + principal: 'SYSTEM' + }); + }); + + it('probeWindowsTask sin tarea registrada', async () => { + const { sftp } = psFake('no||'); + const r = await probeWindowsTask(sftp as never); + expect(r.exists).toBe(false); + expect(r.execute).toBe(''); + }); + + it('probeWindowsAgentProcess distingue la instalación propia de otra carpeta', async () => { + const { sftp } = psFake('corriendo|C:\\Otra\\CloudRestoreAS.exe'); + const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); + expect(r.running).toBe(true); + expect(r.fromPrefix).toBe(false); + expect(r.paths).toEqual(['C:\\Otra\\CloudRestoreAS.exe']); + }); + + it('probeWindowsAgentProcess reconoce la propia sin importar mayúsculas', async () => { + // NTFS no distingue caso: comparar sensible reportaría "corre desde otra carpeta" sobre la + // MISMA ruta y haría fallar una actualización correcta. + const { sftp } = psFake('corriendo|c:\\declarada\\cloudrestoreas.exe'); + expect((await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada')).fromPrefix).toBe(true); + }); + + it('vivo con la ruta ilegible no se afirma que sea ajeno', async () => { + // Un proceso de SYSTEM no expone .Path a una cuenta sin elevación. `paths` vacío significa + // "no sé desde dónde corre", y el llamador no debe tratarlo como fallo. + const { sftp } = psFake('corriendo|'); + const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); + expect(r.running).toBe(true); + expect(r.paths).toEqual([]); + expect(r.fromPrefix).toBe(false); + }); +}); + /** * Verificación del despliegue en Windows. * - * El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una - * tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y - * murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador - * se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar. + * Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada: + * + * 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE + * NO ESTÁ CORRIENDO — lo que se ve cuando el agente arrancó y murió a los pocos segundos. + * 2. Un sello `config\.version` ausente se toleraba SIEMPRE. Como los agentes anteriores a 1.1.1 + * no lo escribían, al actualizar uno de esos no se comprobaba ninguna versión. */ describe('verifyWindowsDeployment', () => { const RELEASE = { version: '1.1.2' } as never; + const PREFIJO = 'C:\\CRAS'; + /** Responde a cada script de PowerShell según lo que pregunte. */ - function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) { + function fakeWindows(opts: { + stamp: string; + taskState: string; + running: boolean; + /** Desde dónde corre. Por omisión, el propio prefijo. */ + procPath?: string; + }) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; if (script.includes('Get-Process')) { - return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' }; + if (!opts.running) return { code: 0, stdout: 'detenido' }; + const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`; + return { code: 0, stdout: `corriendo|${ruta}` }; } if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; return { code: 1, stdout: '' }; @@ -475,6 +543,37 @@ describe('verifyWindowsDeployment', () => { verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') ).rejects.toThrow(/reporta la versión 1\.1\.1/); }); + + it('ACTUALIZANDO, un sello ausente es un fallo', async () => { + // El caso real: un agente 1.1.0 no escribía config\.version, así que el sello llegaba vacío, + // el vacío se toleraba y el run terminaba en verde sobre un servidor sin actualizar. + const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) + ).rejects.toThrow(/no escribió config\\\.version/); + }); + + 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 }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, false) + ).resolves.toBeUndefined(); + }); + + it('un agente corriendo desde OTRA carpeta no cuenta como desplegado', async () => { + // Exactamente el fallo silencioso: se reemplazó el binario de C:\CRAS y el arranque + // automático levantó el de otra ruta. Antes pasaba por bueno. + const { sftp } = fakeWindows({ + stamp: '1.1.2', + taskState: 'Running', + running: true, + procPath: 'D:\\Vieja\\CloudRestoreAS.exe' + }); + await expect( + verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) + ).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/); + }); }); /** diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index e61f4d5..2b71c08 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1100,6 +1100,96 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + "$p = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue); " + + "if ($p.Count -eq 0) { Write-Output 'detenido'; return }; " + + '$rutas = @(); ' + + 'foreach ($x in $p) { try { if ($x.Path) { $rutas += $x.Path } } catch { } }; ' + + "Write-Output ('corriendo|' + ($rutas -join ';'))" + ) + ); + + const out = probe.stdout.trim(); + if (!out.startsWith('corriendo')) { + return { running: false, paths: [], fromPrefix: false }; + } + const joined = out.split('|')[1] ?? ''; + const paths = joined.split(';').map((p) => p.trim()).filter(Boolean); + const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase(); + return { + running: true, + paths, + fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted) + }; +} + +export interface WindowsTaskProbe { + exists: boolean; + /** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */ + execute: string; + /** Cuenta con la que corre. `SYSTEM` es lo que obliga a elevar para pararla o arrancarla. */ + principal: string; +} + +/** + * Interroga la tarea programada del agente. + * + * Existe porque todo el camino de Windows identificaba al agente por NOMBRE mientras que lo único + * que se actualiza se identifica por RUTA. `Start-ScheduledTask` ejecuta la ruta registrada en la + * acción de la tarea, no la que acabamos de instalar: en cuanto las dos no coinciden se reemplaza + * un binario y se arranca otro, sin que nada falle. + * + * Las propiedades se leen por reflexión porque no toda acción de una tarea es de tipo Exec (una + * ComHandler no tiene `Execute`). + */ +export async function probeWindowsTask(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + "$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + + "if (-not $t) { Write-Output 'no||'; return }; " + + "$exec = ''; " + + 'foreach ($a in @($t.Actions)) { ' + + "$pr = $a.PSObject.Properties['Execute']; " + + "if ($pr -and $pr.Value) { $exec = ([string]$pr.Value).Trim('\"'); break } }; " + + "$user = ''; " + + "try { if ($t.Principal) { $user = [string]$t.Principal.UserId } } catch { }; " + + "Write-Output ('si|' + $exec + '|' + $user)" + ) + ); + + const [flag, execute, principal] = probe.stdout.trim().split('|'); + return { + exists: flag === 'si', + execute: (execute ?? '').trim(), + principal: (principal ?? '').trim() + }; +} + async function installWindows( sftp: SftpClient, runId: number, @@ -1116,36 +1206,66 @@ async function installWindows( const privileged = await probeWindowsElevation(sftp); const autostart = request.autostart ?? 'service'; - // La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en - // una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe - // en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta - // corrida pida otro arranque, y ahí igual hay que poder detenerla. - const taskProbe = await execRemote( - sftp, - psEncoded( - "if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + - '{"si"} else {"no"}' - ) + // La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive + // realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato + // se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de + // otra: el run terminaba en verde y el servidor seguía con la versión anterior. + const task = await probeWindowsTask(sftp); + await appendInstallStep( + runId, + 'arranque-automatico-actual', + true, + task.exists + ? `tarea CloudRestoreAS ejecuta ${task.execute || '(sin acción)'}` + + (task.principal ? ` como ${task.principal}` : '') + : 'no hay tarea CloudRestoreAS registrada' ); - const taskExists = taskProbe.stdout.trim() === 'si'; - const needsElevation = autostart === 'service' || taskExists; - if (needsElevation && privileged.elevation !== 'admin') { + // Cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— la de la tarea es una + // fuente REAL del destino, no una suposición. Preferirla al default evita el caso en que el + // panel apuntaba a C:\Aduanasoft\CloudRestoreAS mientras el agente vivía en otra carpeta. + let targetPath = installPath; + if (!reportedInstallPath && task.execute) { + const fromTask = task.execute.replace(/\\[^\\]*$/, ''); + if (fromTask && isSafeInstallPath(fromTask, 'windows')) { + targetPath = fromTask; + await appendInstallStep( + runId, + 'ruta-desde-la-tarea', + true, + `el agente no reporta su ruta; se usa la de la tarea: ${targetPath}` + ); + } + } + + // La elevación hace falta para MANDAR sobre la tarea: registrarla en una instalación nueva, o + // pararla y arrancarla cuando corre como SYSTEM. Que la tarea EXISTA no basta —si corre con la + // propia cuenta SSH no hay nada que elevar—, y tratarlo así bloqueaba de entrada cualquier + // actualización sobre un servidor ya instalado. + const taskNeedsAdmin = task.exists && /system|S-1-5-18/i.test(task.principal); + const needsElevation = autostart === 'service' || taskNeedsAdmin; + + if (needsElevation && privileged.elevation === 'limitado') { throw new InstallError( 409, `No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` ); } + // Con el token filtrado por UAC no se rechaza por adelantado: se intenta y se reporta lo que + // responda el servidor. La sonda mide `IsInRole`, que en una sesión de OpenSSH puede decir que + // no aun cuando la operación sí pase; adivinarlo bloqueaba actualizaciones que funcionaban. + if (needsElevation && privileged.elevation === 'token-filtrado') { + await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail); + } await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${privileged.label}` + - (taskExists ? ', tarea CloudRestoreAS ya registrada' : '') + `${systemEvidence}, privilegios=${privileged.label}` ); if (request.mode === 'update') { - await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath); + await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath); } // Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp. @@ -1235,11 +1355,34 @@ async function installWindows( ); } - // Actualizar va SIEMPRE por -UpdateInPlace, sea cual sea el arranque configurado: no - // vuelve a registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), - // se salta el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y - // trae respaldo con reversión automática si el binario nuevo no levanta. - const inPlaceUpdate = request.mode === 'update'; + // Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a + // registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta + // el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo + // con reversión automática si el binario nuevo no levanta. + // + // Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e + // install.ps1 usa [CmdletBinding()], así que pasárselo a uno anterior falla con + // NamedParameterNotFound SIN ejecutar una sola línea. Se le pregunta a PowerShell por el + // `param()` del propio script en vez de mantener una tabla de versiones aquí. + const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`; + const supportsProbe = await execRemote( + sftp, + psEncoded( + `if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` + + "{'si'} else {'no'}" + ) + ); + 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 flag = inPlaceUpdate ? ' -UpdateInPlace' : autostart === 'none' @@ -1252,7 +1395,7 @@ async function installWindows( sftp, psEncoded( `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + - `-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` + `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` ), INSTALL_EXEC_TIMEOUT_MS ); @@ -1275,7 +1418,7 @@ async function installWindows( } await verifyWindowsDeployment( - sftp, runId, release, autostart, installPath, inPlaceUpdate + sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update' ); } finally { await cleanupStaging( @@ -1295,7 +1438,8 @@ export async function verifyWindowsDeployment( release: CrasRelease, autostart: AutostartMode, prefix: string, - inPlaceUpdate = false + inPlaceUpdate = false, + isUpdate = false ): Promise { // El sello config\.version lo escribe el bootstrap del binario. // @@ -1306,7 +1450,10 @@ export async function verifyWindowsDeployment( // desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del // margen. Se sondea hasta que coincida, o hasta agotar los intentos. let deployed = ''; - const stampAttempts = inPlaceUpdate ? 15 : 3; + // Margen generoso: desempacar un onefile de ~270 MB con Defender escaneando cada archivo se + // pasa de largo de los 30s que había antes, y agotar el margen ahora es un FALLO en una + // actualización, así que quedarse corto convertiría un despliegue bueno en un error. + const stampAttempts = isUpdate || inPlaceUpdate ? 45 : 3; for (let attempt = 0; attempt < stampAttempts; attempt += 1) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); const stamp = await execRemote( @@ -1330,6 +1477,24 @@ export async function verifyWindowsDeployment( : '') ); } + // En una ACTUALIZACIÓN, un sello ausente es un fallo, no un aviso. + // + // Esto es lo que dejaba pasar una actualización que no hacía nada. Los agentes anteriores a + // 1.1.1 no escribían config\.version, así que `deployed` llegaba vacío, el vacío se toleraba y + // el paso se asentaba como "se creará al arrancar": en la única combinación que importa + // —actualizar un agente viejo— no se comprobaba ninguna versión. El binario nuevo SÍ lo escribe + // en ensure_runtime_layout(), así que si tras todo el margen sigue sin aparecer, lo que corre + // 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) { + 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.` + ); + } await appendInstallStep( runId, 'verificar-version', @@ -1341,14 +1506,21 @@ export async function verifyWindowsDeployment( // salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el // servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien // pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera. - const proc = await execRemote( - sftp, - psEncoded( - "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + - 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' - ) - ); - const running = proc.stdout.trim().startsWith('corriendo'); + const proc = await probeWindowsAgentProcess(sftp, prefix); + const running = proc.running; + + // Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía + // "hay un proceso llamado CloudRestoreAS", que el agente viejo —nunca detenido, arrancado por + // una tarea que apuntaba a otra carpeta— satisfacía igual de bien. + if (running && proc.paths.length > 0 && !proc.fromPrefix) { + throw new InstallError( + 502, + `Hay un agente corriendo desde ${proc.paths.join(', ')}, no desde ${prefix}. Se instaló ` + + 'el binario nuevo en una carpeta y el arranque automático levanta el de otra, así que ' + + 'la actualización no surtió efecto. Revisa a qué ejecutable apunta la tarea ' + + 'CloudRestoreAS en el servidor.' + ); + } if (autostart !== 'service') { // Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca @@ -1358,7 +1530,9 @@ export async function verifyWindowsDeployment( runId, 'verificar-proceso', running || autostart === 'desktop', - running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución' + running + ? `CloudRestoreAS en ejecución${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}` + : 'CloudRestoreAS no está en ejecución' ); return; } @@ -1391,7 +1565,8 @@ export async function verifyWindowsDeployment( runId, 'verificar-servicio', true, - `tarea CloudRestoreAS: ${state}, proceso en ejecución` + `tarea CloudRestoreAS: ${state}, proceso en ejecución` + + (proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)') ); } diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts index d965249..6e095cc 100644 --- a/src/lib/server/cras-verify.ts +++ b/src/lib/server/cras-verify.ts @@ -20,6 +20,8 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg'; import { execRemote, probeLinuxElevation, + probeWindowsAgentProcess, + probeWindowsTask, psEncoded, readUnitProps, shQuote, @@ -821,19 +823,43 @@ async function inspectWindows( ) ); - // El proceso vivo es la señal más directa de que está trabajando. - const proc = await execRemote( - sftp, - psEncoded( - "$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + - 'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' - ), - CHECK_TIMEOUT_MS - ); - const procOut = proc.stdout.trim(); - const running = procOut.startsWith('corriendo'); + // A qué ejecutable apunta el arranque automático. Es un chequeo aparte porque una tarea que + // 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(); + if (taskProbe.exists) { + checks.push( + check( + 'ruta_arranque', + 'El arranque apunta a la instalación', + taskAlineada ? 'ok' : 'fail', + taskAlineada + ? taskProbe.execute || 'sin acción declarada' + : `la tarea ejecuta ${taskProbe.execute}, no ${esperado}` + ) + ); + } + + // El proceso vivo es la señal más directa de que está trabajando — pero tiene que ser el de + // ESTA instalación. Se comparte la sonda con el instalador para que las dos pantallas no puedan + // contradecirse sobre el mismo servidor. + const proc = await probeWindowsAgentProcess(sftp, prefix); + const running = proc.running; + const detalleProc = !proc.running + ? 'detenido' + : proc.paths.length === 0 + ? 'corriendo (ruta no legible)' + : `corriendo desde ${proc.paths.join(', ')}`; checks.push( - check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido') + check( + 'proceso', + 'Proceso en ejecución', + !proc.running ? 'warn' : proc.paths.length && !proc.fromPrefix ? 'fail' : 'ok', + detalleProc + ) ); if (!installed) { -- 2.49.1 From 3572aa974be13337d3bf8a4e540e18af1baaa6af Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 10:57:16 -0600 Subject: [PATCH 2/5] fix(cras-install): realinear la tarea de Windows desde el panel, y decir por que fallo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La actualizacion ya fallaba ruidosamente en vez de mentir, pero el arreglo no podia llegar al servidor: el install.ps1 que se ejecuta viaja DENTRO del zip, y el artefacto 1.1.3 se construyo antes de que el instalador aprendiera a realinear la tarea. Verificado sobre el zip: trae -UpdateInPlace y cero Sync-AgentTaskPath. Un instalador ya publicado no se arregla hacia atras. - alignWindowsTask realinea la accion de la tarea por SSH tras instalar el binario, conservando disparador, principal, ajustes y argumentos, y asienta la ruta ANTERIOR y la nueva para que el cambio sea reversible si la ruta declarada estuviera mal. Si no se puede corregir, aborta con 409 nombrando ambas: seguir significaria arrancar a sabiendas el binario viejo. Funciona con cualquier artefacto ya distribuido. - restartWindowsAgent rearranca y espera a que corra el binario del prefijo, no cualquier proceso con ese nombre. Vive en cras-install y no en cras-agent-control porque este es el modulo de abajo; al reves habria un ciclo. Y el diagnostico deja de ser una tarea para el operador. El error de sello ausente decia "revisa a que binario apunta el arranque automatico" cuando el panel YA lo sabe, y la bitacora del run donde si estaba no la encontraba nadie ("no se donde verlo"). Ahora se sondea tarea y proceso ANTES de evaluar el sello y el mensaje nombra a que apunta la tarea, desde donde corre el proceso, y la cola de CloudRestoreAS-crash.log — que runner.py escribe precisamente para esto y que nunca se leia. El banner de error apunta al run por numero. startOnWindows deja de esperar por nombre y usa la misma sonda de ruta, para que Verificar no pueda contradecir al instalador. PowerShell generado validado con PowerShell real: los seis scripts parsean, y el de realineacion ejercitado contra una tarea simulada reapunta la accion Exec, conserva los argumentos y respeta las acciones que no son Exec. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-agent-control.ts | 68 ++++++---- src/lib/server/cras-install.test.ts | 138 +++++++++++++++++++ src/lib/server/cras-install.ts | 179 ++++++++++++++++++++++++- src/routes/versiones-cras/+page.svelte | 13 +- 4 files changed, 370 insertions(+), 28 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 { }); }); +/** + * 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('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 +567,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 +633,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..d9cf6ba 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1190,6 +1190,145 @@ export async function probeWindowsTask(sftp: SftpClient): Promise { + if (!taskExecute) return false; + const wanted = `${targetPath.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; + if (normalizeWindowsPath(taskExecute) === normalizeWindowsPath(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 = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; + 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 +1556,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 +1642,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/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte index a31d7d3..3e2809a 100644 --- a/src/routes/versiones-cras/+page.svelte +++ b/src/routes/versiones-cras/+page.svelte @@ -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} +
+ +
+

+ Se instalará en {targetInstallPath} +

+

+ {#if installTarget?.reported_install_path} + Ruta registrada para {installTarget.name}. Se cambia con el lápiz que está + junto al nombre del servidor, en la tabla. + {:else if installTarget?.platform && isDefaultInstallPath(targetInstallPath, installTarget.platform)} + Ruta por omisión: este servidor no tiene ninguna registrada. Si el agente + vive en otra carpeta, captúrala primero con el lápiz de la tabla — instalar + aquí crearía una segunda instalación. + {:else} + Sin ruta registrada para este servidor. + {/if} +

+
+