fix(cras-install): el panel no puede creerse un exito que no ocurrio #30

Merged
acazares merged 1 commits from fix/centinela-instalador into development 2026-07-31 21:49:12 +00:00
2 changed files with 120 additions and 18 deletions

View File

@@ -515,6 +515,29 @@ describe('checkWindowsArtifactUpdateSupport', () => {
expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); 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', () => { 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 // 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. // 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'); expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido');
}); });
it('el archivo que no existe se distingue de un instalador antiguo', async () => { it('el archivo que no existe NO es "desconocido": es un hecho, y el peor', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); // 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); const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
expect(r.answer).toBe('desconocido'); expect(r.answer).toBe('sin-archivo');
expect(r.detail).toBe('no existe el archivo'); expect(r.detail).toContain(RUTA);
}); });
it('una salida inesperada no se interpreta como respuesta', async () => { it('una salida inesperada no se interpreta como respuesta', async () => {

View File

@@ -1295,7 +1295,13 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
* en cuanto gobernó un rechazo, se volvió un candado. * en cuanto gobernó un rechazo, se volvió un candado.
*/ */
export interface ProbeAnswer { export interface ProbeAnswer {
answer: 'si' | 'no' | 'desconocido'; /**
* `sin-archivo` está aparte de `desconocido` a propósito: que el instalador no exista **sí** es
* una respuesta definitiva, y de las peores —no hay nada que ejecutar—, mientras `desconocido`
* es "no pude leerlo". Colapsarlos hacía que el run siguiera adelante sabiendo ya que no había
* instalador, y gastara el resto del intento para acabar culpando al agente.
*/
answer: 'si' | 'no' | 'desconocido' | 'sin-archivo';
/** Por qué no se pudo determinar. Vacío cuando hay respuesta. */ /** Por qué no se pudo determinar. Vacío cuando hay respuesta. */
detail: string; detail: string;
} }
@@ -1321,7 +1327,7 @@ export async function probeInstallerUpdateSupport(
sftp, sftp,
psEncoded( psEncoded(
`$p = '${installerPath}'; ` + `$p = '${installerPath}'; ` +
"if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'sin-archivo'; exit 0 }; " +
'try { ' + 'try { ' +
// Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET. // Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET.
// La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por // La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por
@@ -1353,6 +1359,9 @@ export async function probeInstallerUpdateSupport(
}; };
} }
if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' }; if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' };
if (salida === 'sin-archivo') {
return { answer: 'sin-archivo', detail: `no existe ${installerPath}` };
}
return { return {
answer: 'desconocido', answer: 'desconocido',
detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida) detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida)
@@ -1386,6 +1395,19 @@ export function checkWindowsArtifactUpdateSupport(
): string { ): string {
if (soporte.answer === 'si') return ''; if (soporte.answer === 'si') return '';
// Que el instalador NO ESTÉ no es una duda: es el peor de los hechos posibles, porque no hay
// nada que ejecutar. Antes se colapsaba en `desconocido` y el run seguía adelante sabiéndolo ya,
// gastaba el resto del intento y acababa culpando al agente de no escribir su sello.
if (soporte.answer === 'sin-archivo') {
throw new InstallError(
502,
`El artefacto ${version} se extrajo en ${targetName} pero no dejó el instalador donde ` +
`se esperaba: ${soporte.detail}. No hay nada que ` +
'ejecutar, así que no se toca el servidor. Revisa el paso "contenido-staging" de ' +
'este run para ver qué sí quedó en la carpeta temporal.'
);
}
// No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se // No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se
// sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el // sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el
// dato que convierte la próxima sorpresa en un diagnóstico de diez segundos. // dato que convierte la próxima sorpresa en un diagnóstico de diez segundos.
@@ -1711,19 +1733,26 @@ async function installWindows(
Buffer.from(panelEnvContents(request, target.name), 'utf8'), Buffer.from(panelEnvContents(request, target.name), 'utf8'),
remoteEnv.replace(/\\/g, '/') remoteEnv.replace(/\\/g, '/')
); );
// Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y // Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y SYSTEM
// SYSTEM para que el token no quede legible por cualquier usuario del servidor. // para que el token no quede legible por cualquier usuario del servidor. Se verifica el
// Se restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y // código de salida: un icacls que falla en silencio dejaría el token legible, que es justo
// si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por // lo que se está tratando de evitar.
// todos. Y se verifica el código de salida: un icacls que falla en silencio dejaría el //
// token legible, que es justo lo que se está tratando de evitar. // Se restringe SOLO el archivo, no la carpeta. La restricción 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
// todo lo creado dentro después podía quedar sin permisos utilizables. Ahí se extrae el
// artefacto, así que era un candidato serio a explicar por qué el instalador "no existía".
//
// Y por SID, no por nombre de grupo: en un Windows en español `BUILTIN\Administrators` no
// resuelve y el icacls falla entero ("No se efectuó ninguna asignación entre los nombres de
// cuenta y los identificadores de seguridad"). Comprobado. S-1-5-32-544 es Administradores y
// S-1-5-18 es SYSTEM en cualquier idioma.
const acl = await execRemote( const acl = await execRemote(
sftp, sftp,
psEncoded( psEncoded(
`icacls '${remoteDir}' /inheritance:r ` + `icacls '${remoteEnv}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' + '/grant:r "*S-1-5-32-544:(F)" /grant:r "*S-1-5-18:(F)" | Out-Null'
`icacls '${remoteEnv}' /inheritance:r ` +
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null'
) )
); );
if (acl.code !== 0) { if (acl.code !== 0) {
@@ -1751,6 +1780,25 @@ async function installWindows(
); );
} }
// Qué dejó realmente la extracción, asentado en la bitácora. Sin esto no se distinguía
// "el instalador no está", "está en otra ruta" y "está pero no se puede leer" — y son tres
// arreglos distintos. La sonda decía "no existe el archivo" y no había forma de saber cuál
// de los tres era.
const listado = await execRemote(
sftp,
psEncoded(
`Get-ChildItem -LiteralPath '${remoteDir}' -Force -ErrorAction SilentlyContinue | ` +
'ForEach-Object { $_.Name } | Sort-Object'
)
);
await appendInstallStep(
runId,
'contenido-staging',
true,
listado.stdout.trim().split('\n').map((l) => 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 // 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 // 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 // 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' ? ' -Service'
: ' -Desktop'; : ' -Desktop';
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`); await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
// El script emite un CENTINELA y el panel lo exige.
//
// Antes se invocaba con `& '<ruta>' …; 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( const install = await execRemote(
sftp, sftp,
psEncoded( psEncoded(
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + "$ErrorActionPreference = 'Stop'; " +
`-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` '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 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 // 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 // conflicto de estado, no un fallo; con el 502 genérico el operador salía a buscar una
// avería que no existe. // avería que no existe.