From bd93f49113d33dacc4821d70bb2162ed038c7abf Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 21:49:11 +0000 Subject: [PATCH] fix(cras-install): el panel no puede creerse un exito que no ocurrio (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La bitacora del run 26 tenia la respuesta: la sonda decia que install.ps1 NO EXISTE y el paso siguiente, "Ejecutar instalador", salia en verde. Comprobado con PowerShell real por que: & 'C:\no-existe.ps1' -Service; exit $LASTEXITCODE -> exit 0 $LASTEXITCODE no se fija nunca (no corrio ningun comando nativo), asi que `exit $null` da 0. El panel leia 0 y asentaba el paso como exitoso. NADA se instalaba, y por eso seguia viva la 1.1.0: el agente viejo nunca se detuvo porque el instalador nunca se ejecuto. Esa evidencia falsa mando el diagnostico a otra parte durante varios runs. - El script emite un CENTINELA `CRAS-FIN|` y el panel lo EXIGE. Sin el, el instalador no termino, de lo que de el codigo de salida. Un fallo no es una respuesta — la misma leccion que la sonda, ahora en la invocacion. Validado con PowerShell real: instalador normal -> CRAS-FIN|0; instalador que sale 75 -> CRAS-FIN|75 (la traduccion a "restauracion en curso" sigue viva); instalador inexistente -> CRAS-ERROR| y codigo 90 en vez de un 0 silencioso. - "El instalador no esta" pasa a ser un hecho aparte de "no pude leerlo", y aborta con 502. Antes se colapsaba en `desconocido`, el run continuaba sabiendolo ya, gastaba el intento entero y acababa culpando al agente de no escribir su sello. - Se asienta el CONTENIDO del staging tras extraer. Sin eso no se distinguia "el instalador no esta", "esta en otra ruta" y "esta pero no se puede leer", que son tres arreglos distintos. Y dos cambios en el endurecimiento del token, cada uno defendible por si solo: - Se restringe SOLO panel.env, no la carpeta. La restriccion de la carpeta era un extra —su motivo declarado, que el archivo heredara una ACL permisiva al crearse, ya lo cubre la ACE del propio archivo— y a cambio dejaba la carpeta con ACE NO heredables, de modo que lo creado dentro despues podia quedar sin permisos utilizables. Ahi se extrae el artefacto: era candidato serio a explicar por que el instalador "no existia". - Por SID y no por nombre de grupo. En un Windows en espanol `BUILTIN\Administrators` no resuelve y el icacls falla entero; comprobado. S-1-5-32-544 y S-1-5-18 valen en cualquier idioma. Co-Authored-By: Claude Opus 5 (1M context) Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/30 Co-authored-by: hreyes Co-committed-by: hreyes --- src/lib/server/cras-install.test.ts | 34 +++++++-- src/lib/server/cras-install.ts | 104 ++++++++++++++++++++++++---- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 35916fd..49e127d 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -515,6 +515,29 @@ describe('checkWindowsArtifactUpdateSupport', () => { expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); }); + it('si el instalador NO ESTÁ, aborta: no hay nada que ejecutar', () => { + // Es distinto de "no sé": aquí sí se sabe, y es lo peor posible. Antes se trataba como duda + // y el run continuaba, tocaba el servidor y acababa culpando al agente. + const falta = { answer: 'sin-archivo', detail: 'no existe C:\\tmp\\install.ps1' } as const; + try { + checkWindowsArtifactUpdateSupport('update', falta, '1.1.4', 'Principal'); + throw new Error('se esperaba que lanzara'); + } catch (e) { + const err = e as InstallError; + expect(err).toBeInstanceOf(InstallError); + expect(err.status).toBe(502); + expect(err.message).toContain('C:\\tmp\\install.ps1'); + expect(err.message).toMatch(/contenido-staging/); + } + }); + + it('y también aborta al instalar limpio: sin instalador no se instala nada', () => { + const falta = { answer: 'sin-archivo', detail: 'no existe X' } as const; + expect(() => + checkWindowsArtifactUpdateSupport('install', falta, '1.1.4', 'Principal') + ).toThrow(InstallError); + }); + it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => { // La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se // decidía un 409. Con un artefacto correcto, eso era un candado permanente. @@ -582,11 +605,14 @@ describe('probeInstallerUpdateSupport', () => { expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); }); - it('el archivo que no existe se distingue de un instalador antiguo', async () => { - const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); + it('el archivo que no existe NO es "desconocido": es un hecho, y el peor', async () => { + // Que no haya instalador es definitivo — no hay nada que ejecutar. Colapsarlo en + // "desconocido" hacía que el run siguiera adelante sabiéndolo ya, gastara el intento entero + // y acabara culpando al agente de no escribir su sello. + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'sin-archivo' })); const r = await probeInstallerUpdateSupport(sftp as never, RUTA); - expect(r.answer).toBe('desconocido'); - expect(r.detail).toBe('no existe el archivo'); + expect(r.answer).toBe('sin-archivo'); + expect(r.detail).toContain(RUTA); }); it('una salida inesperada no se interpreta como respuesta', async () => { diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 04a8b99..a462204 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1295,7 +1295,13 @@ export async function probeWindowsTask(sftp: SftpClient): Promise l.trim()).filter(Boolean).join(', ') || + `no se pudo listar ${remoteDir} (código ${listado.code})` + ); + // 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 @@ -1781,14 +1829,42 @@ async function installWindows( ? ' -Service' : ' -Desktop'; await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); + // El script emite un CENTINELA y el panel lo exige. + // + // Antes se invocaba con `& '' …; exit $LASTEXITCODE`, y eso no distingue "corrió y + // salió 0" de "no llegó a correr": si la invocación falla —porque el archivo no está, por + // ejemplo— `$LASTEXITCODE` no se fija nunca (no corrió ningún comando nativo), así que + // `exit $null` da 0. Comprobado con PowerShell real. El panel leía 0, asentaba + // "Ejecutar instalador ✓", y el servidor seguía intacto con su agente viejo — durante + // varios runs, con esa evidencia falsa mandando el diagnóstico a otra parte. + // + // Con el centinela, la ausencia de CRAS-FIN significa "no terminó", dé lo que dé el código. const install = await execRemote( sftp, psEncoded( - `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + - `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` + "$ErrorActionPreference = 'Stop'; " + + 'try { ' + + `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + + `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; ` + + '$c = 0; if ($null -ne $LASTEXITCODE) { $c = $LASTEXITCODE } } ' + + "catch { Write-Output ('CRAS-ERROR|' + $_.Exception.Message); exit 90 }; " + + "Write-Output ('CRAS-FIN|' + $c); exit $c" ), INSTALL_EXEC_TIMEOUT_MS ); + + const salidaInstalador = `${install.stdout}\n${install.stderr}`; + if (!salidaInstalador.includes('CRAS-FIN|')) { + const detalle = salidaInstalador.includes('CRAS-ERROR|') + ? salidaInstalador.split('CRAS-ERROR|')[1].split('\n')[0].trim() + : truncate(install.stderr || install.stdout) || 'sin salida'; + throw new InstallError( + 502, + `install.ps1 no llegó a terminar en ${target.name}: ${detalle}. El servidor quedó ` + + 'como estaba. Revisa el paso "contenido-staging" de este run para ver qué dejó ' + + 'la extracción del artefacto.' + ); + } // 75 = EX_TEMPFAIL, igual que en install.sh: hay una restauración en curso. Es un // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una // avería que no existe.