fix(cras-install): el panel no puede creerse un exito que no ocurrio #30
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user