fix(cras-install): negarse a actualizar Windows con un artefacto anterior a los arreglos
El 1.1.3 publicado en Gitea se construyo antes de los arreglos, y el panel lo desplego igual. Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20s —insuficiente para desempacar un onefile de ~250 MB con el antivirus escaneando, asi que muere antes de escribir config\.version—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el binario del mismo paquete lleva el runner.py que ignora --headless en Windows, asi que como SYSTEM en la sesion 0 Qt no puede crear su plataforma y el agente muere. El servidor se quedo sin agente viejo y sin agente nuevo. La sonda que lo detecta ya existia, pero solo lo asentaba como paso informativo y seguia adelante — y seguir adelante es lo que rompe el servidor. Ahora checkWindowsArtifactUpdateSupport lanza 409 al ACTUALIZAR, nombrando la version y la consecuencia; en instalacion limpia solo avisa, porque ahi no hay agente que perder. Es una funcion aparte y no una comprobacion enterrada en installWindows: es una politica con consecuencias, merece nombre y prueba propia. Ademas, dos cosas para que el proximo fallo se explique solo: - El crash log se busca en las CINCO ubicaciones posibles, no solo junto al ejecutable. runner.py tiene tres candidatos y los dos ultimos, para un proceso que corre como SYSTEM, caen bajo C:\Windows\System32\config\systemprofile — precisamente donde estaba la evidencia de este fallo, y por eso el error salio sin crash log. El mensaje dice ademas de cual se leyo. - Se sondea la cuenta de la tarea despues de instalar y se asienta el cambio. El salto Administrator -> SYSTEM es lo que rompio este servidor y ocurria en silencio. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
probeInPlaceUpdate,
|
||||
probeLinuxElevation,
|
||||
alignWindowsTask,
|
||||
checkWindowsArtifactUpdateSupport,
|
||||
normalizeWindowsPath,
|
||||
probeWindowsAgentProcess,
|
||||
probeWindowsElevation,
|
||||
@@ -471,6 +472,45 @@ describe('comparación de rutas de Windows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Artefactos anteriores a los arreglos de actualización.
|
||||
*
|
||||
* El caso real: se publicó un 1.1.3 construido antes de los arreglos, y el panel lo desplegó. Ese
|
||||
* instalador mata el agente, cambia la tarea a SYSTEM y no comprueba que vuelva; y su binario ignora
|
||||
* `--headless` en Windows, así que como SYSTEM en la sesión 0 no levanta. El servidor se quedó sin
|
||||
* agente viejo y sin agente nuevo.
|
||||
*/
|
||||
describe('checkWindowsArtifactUpdateSupport', () => {
|
||||
it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => {
|
||||
try {
|
||||
checkWindowsArtifactUpdateSupport('update', false, '1.1.3', 'Principal');
|
||||
throw new Error('se esperaba que lanzara');
|
||||
} catch (e) {
|
||||
const err = e as InstallError;
|
||||
expect(err).toBeInstanceOf(InstallError);
|
||||
// 409 y no 502: es un conflicto de estado —este artefacto no sirve para esto—, no una
|
||||
// avería del servidor destino.
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.message).toContain('1.1.3');
|
||||
expect(err.message).toContain('Principal');
|
||||
// El mensaje tiene que decir la CONSECUENCIA, no solo que falta un parámetro.
|
||||
expect(err.message).toMatch(/SIN agente/);
|
||||
}
|
||||
});
|
||||
|
||||
it('INSTALAR limpio con el mismo artefacto solo avisa', () => {
|
||||
// Ahí no hay agente en marcha que perder, así que negarse solo estorbaría.
|
||||
const aviso = checkWindowsArtifactUpdateSupport('install', false, '1.1.3', 'Principal');
|
||||
expect(aviso).toContain('1.1.3');
|
||||
expect(aviso).toMatch(/Se instala igual/);
|
||||
});
|
||||
|
||||
it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => {
|
||||
expect(checkWindowsArtifactUpdateSupport('update', true, '1.1.4', 'Principal')).toBe('');
|
||||
expect(checkWindowsArtifactUpdateSupport('install', true, '1.1.4', 'Principal')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sondas de ruta en Windows', () => {
|
||||
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
|
||||
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
||||
@@ -646,11 +686,18 @@ describe('verifyWindowsDeployment', () => {
|
||||
/** Ruta que la tarea tiene registrada en su acción. */
|
||||
taskExecute?: string;
|
||||
crashLog?: string;
|
||||
/** De cuál de las rutas candidatas se leyó el crash log. */
|
||||
crashFrom?: 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('crash.log')) {
|
||||
// El lector responde `DE|<ruta>` y luego el contenido, para poder decir de dónde salió.
|
||||
if (!opts.crashLog) return { code: 0, stdout: '' };
|
||||
const de = opts.crashFrom ?? `${PREFIJO}\\CloudRestoreAS-crash.log`;
|
||||
return { code: 0, stdout: `DE|${de}\n${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' };
|
||||
@@ -752,6 +799,33 @@ describe('verifyWindowsDeployment', () => {
|
||||
expect(mensaje).toContain('ImportError');
|
||||
});
|
||||
|
||||
it('el crash log se encuentra también en el perfil de SYSTEM', async () => {
|
||||
// Es el caso que importa: un agente lanzado por una tarea que corre como SYSTEM no escribe
|
||||
// junto al ejecutable, sino bajo C:\Windows\System32\config\systemprofile. Mirar solo la
|
||||
// primera ubicación dejaba el arranque fallido sin explicación.
|
||||
const desde =
|
||||
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log';
|
||||
const { sftp } = fakeWindows({
|
||||
stamp: '',
|
||||
taskState: 'Ready',
|
||||
running: false,
|
||||
crashLog: 'RuntimeError: could not load the Qt platform plugin "windows"',
|
||||
crashFrom: desde
|
||||
});
|
||||
const mensaje = await mensajeDeFallo(
|
||||
sftp as never,
|
||||
1,
|
||||
RELEASE,
|
||||
'service',
|
||||
PREFIJO,
|
||||
false,
|
||||
true
|
||||
);
|
||||
// Dice QUÉ pasó y DE DÓNDE lo sacó, para no tener que buscarlo a ciegas en el servidor.
|
||||
expect(mensaje).toContain('Qt platform plugin');
|
||||
expect(mensaje).toContain(desde);
|
||||
});
|
||||
|
||||
it('sin crash log el mensaje no se rompe', async () => {
|
||||
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
|
||||
const mensaje = await mensajeDeFallo(
|
||||
|
||||
@@ -1229,6 +1229,49 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿Se puede desplegar este artefacto en Windows sin romper el servidor?
|
||||
*
|
||||
* Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los
|
||||
* arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente.
|
||||
* Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente
|
||||
* para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir
|
||||
* `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
|
||||
* binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como
|
||||
* SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin
|
||||
* agente nuevo.
|
||||
*
|
||||
* Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo
|
||||
* después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que
|
||||
* perder y negarse solo estorbaría.
|
||||
*
|
||||
* Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con
|
||||
* consecuencias: merece nombre propio y prueba propia.
|
||||
*/
|
||||
export function checkWindowsArtifactUpdateSupport(
|
||||
mode: InstallMode,
|
||||
supportsInPlace: boolean,
|
||||
version: string,
|
||||
targetName: string
|
||||
): string {
|
||||
if (supportsInPlace) return '';
|
||||
|
||||
const aviso =
|
||||
`El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` +
|
||||
'(no declara -UpdateInPlace).';
|
||||
if (mode === 'update') {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` +
|
||||
'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' +
|
||||
'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' +
|
||||
'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' +
|
||||
'actualiza a esa.'
|
||||
);
|
||||
}
|
||||
return `${aviso} Se instala igual: no hay un agente en marcha que perder.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
|
||||
*
|
||||
@@ -1341,22 +1384,50 @@ export async function restartWindowsAgent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Cola del crash log que el agente escribe junto a su ejecutable cuando no consigue arrancar.
|
||||
* Cola del crash log que el agente escribe 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.
|
||||
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
|
||||
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
|
||||
* `%TEMP%\CloudRestoreAS-crash.log`.
|
||||
*
|
||||
* Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por
|
||||
* una tarea que corre como SYSTEM resuelve las otras dos bajo
|
||||
* `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que
|
||||
* fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X".
|
||||
*
|
||||
* Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre
|
||||
* como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de
|
||||
* SYSTEM en vez de confiar en la expansión.
|
||||
*/
|
||||
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
|
||||
async function readWindowsCrashLog(
|
||||
sftp: SftpClient,
|
||||
prefix: string
|
||||
): Promise<{ text: string; from: string }> {
|
||||
const candidatos = [
|
||||
`${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`,
|
||||
'$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log',
|
||||
'$env:TEMP\\CloudRestoreAS-crash.log',
|
||||
// El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la
|
||||
// tarea programada como SYSTEM y no la sesión SSH.
|
||||
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log',
|
||||
'C:\\Windows\\Temp\\CloudRestoreAS-crash.log'
|
||||
];
|
||||
|
||||
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 }'
|
||||
`$rutas = @("${candidatos.join('","')}"); ` +
|
||||
'foreach ($r in $rutas) { ' +
|
||||
'if (Test-Path -LiteralPath $r) { ' +
|
||||
"Write-Output ('DE|' + $r); " +
|
||||
'(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }'
|
||||
)
|
||||
);
|
||||
return result.stdout.trim();
|
||||
|
||||
const salida = result.stdout.trim();
|
||||
if (!salida.startsWith('DE|')) return { text: '', from: '' };
|
||||
const [cabecera, ...resto] = salida.split('\n');
|
||||
return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() };
|
||||
}
|
||||
|
||||
async function installWindows(
|
||||
@@ -1542,16 +1613,18 @@ async function installWindows(
|
||||
)
|
||||
);
|
||||
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 avisoArtefacto = checkWindowsArtifactUpdateSupport(
|
||||
request.mode,
|
||||
supportsInPlace,
|
||||
release.version,
|
||||
target.name
|
||||
);
|
||||
if (avisoArtefacto) {
|
||||
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
|
||||
}
|
||||
|
||||
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
|
||||
const flag = inPlaceUpdate
|
||||
? ' -UpdateInPlace'
|
||||
: autostart === 'none'
|
||||
@@ -1586,11 +1659,26 @@ async function installWindows(
|
||||
);
|
||||
}
|
||||
|
||||
// El instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada:
|
||||
// `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la
|
||||
// sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que
|
||||
// cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico.
|
||||
const taskDespues = await probeWindowsTask(sftp);
|
||||
if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) {
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'cambio-de-cuenta-de-la-tarea',
|
||||
true,
|
||||
`el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'} → ` +
|
||||
`${taskDespues.principal || '(sin declarar)'}`
|
||||
);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) {
|
||||
const proc = await restartWindowsAgent(sftp, targetPath);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
@@ -1696,7 +1784,7 @@ export async function verifyWindowsDeployment(
|
||||
`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}. ` +
|
||||
`${pistas.join('; ')}.` +
|
||||
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
|
||||
(crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
|
||||
` Logs en ${prefix}\\config\\logs.`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user