fix/windows-actualizacion-silenciosa #27
@@ -29,7 +29,9 @@ import {
|
|||||||
probeInPlaceUpdate,
|
probeInPlaceUpdate,
|
||||||
probeLinuxElevation,
|
probeLinuxElevation,
|
||||||
alignWindowsTask,
|
alignWindowsTask,
|
||||||
|
checkWindowsArtifactUpdateSupport,
|
||||||
normalizeWindowsPath,
|
normalizeWindowsPath,
|
||||||
|
probeInstallerUpdateSupport,
|
||||||
probeWindowsAgentProcess,
|
probeWindowsAgentProcess,
|
||||||
probeWindowsElevation,
|
probeWindowsElevation,
|
||||||
probeWindowsTask,
|
probeWindowsTask,
|
||||||
@@ -471,6 +473,124 @@ 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', () => {
|
||||||
|
const SI = { answer: 'si', detail: '' } as const;
|
||||||
|
const NO = { answer: 'no', detail: '' } as const;
|
||||||
|
const NOSE = { answer: 'desconocido', detail: 'Get-Content: acceso denegado' } as const;
|
||||||
|
|
||||||
|
it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => {
|
||||||
|
try {
|
||||||
|
checkWindowsArtifactUpdateSupport('update', NO, '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', NO, '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', SI, '1.1.4', 'Principal')).toBe('');
|
||||||
|
expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const aviso = checkWindowsArtifactUpdateSupport('update', NOSE, '1.1.4', 'Principal');
|
||||||
|
expect(aviso).toMatch(/No se pudo determinar/);
|
||||||
|
// Y dice POR QUÉ no se pudo, que es lo que evita adivinar la próxima vez.
|
||||||
|
expect(aviso).toContain('acceso denegado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tampoco bloquea al instalar cuando no se pudo determinar', () => {
|
||||||
|
expect(checkWindowsArtifactUpdateSupport('install', NOSE, '1.1.4', 'Principal')).toMatch(
|
||||||
|
/No se pudo determinar/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La sonda que lee el instalador del artefacto.
|
||||||
|
*
|
||||||
|
* Lo que se protege aquí es la distinción entre "no lo declara" y "no pude leerlo". La versión
|
||||||
|
* anterior preguntaba por `(Get-Command …).Parameters`, funcionaba en una máquina de desarrollo y
|
||||||
|
* fallaba en el servidor real; como cualquier fallo se leía como "no", el panel acabó rechazando un
|
||||||
|
* artefacto correcto.
|
||||||
|
*/
|
||||||
|
describe('probeInstallerUpdateSupport', () => {
|
||||||
|
const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1';
|
||||||
|
|
||||||
|
it('lee el texto del script en vez de compilarlo', async () => {
|
||||||
|
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' }));
|
||||||
|
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
|
||||||
|
expect(r.answer).toBe('si');
|
||||||
|
|
||||||
|
const script = Buffer.from(
|
||||||
|
commands[0].split('-EncodedCommand ')[1] ?? '',
|
||||||
|
'base64'
|
||||||
|
).toString('utf16le');
|
||||||
|
// Nada de reflexión: en Constrained Language Mode el acceso a .Parameters puede fallar
|
||||||
|
// mientras `& install.ps1` sigue funcionando, que es lo que se observó en el servidor.
|
||||||
|
expect(script).not.toContain('Get-Command');
|
||||||
|
expect(script).not.toContain('.Parameters');
|
||||||
|
expect(script).toContain('Get-Content');
|
||||||
|
// Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente.
|
||||||
|
expect(script).toContain('switch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un "no" limpio sigue siendo un no', async () => {
|
||||||
|
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'no' }));
|
||||||
|
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('no');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('código de salida distinto de cero es DESCONOCIDO, nunca "no"', async () => {
|
||||||
|
const { sftp } = fakeSftp(() => ({ code: 1, stdout: '', stderr: 'AmsiScanBuffer bloqueó' }));
|
||||||
|
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
|
||||||
|
expect(r.answer).toBe('desconocido');
|
||||||
|
expect(r.detail).toContain('AmsiScanBuffer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stdout vacío con código 0 también es DESCONOCIDO', async () => {
|
||||||
|
// Es la forma exacta en que fallaba la sonda vieja: sin salida, y leído como respuesta.
|
||||||
|
const { sftp } = fakeSftp(() => ({ code: 0, stdout: ' ' }));
|
||||||
|
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' }));
|
||||||
|
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
|
||||||
|
expect(r.answer).toBe('desconocido');
|
||||||
|
expect(r.detail).toBe('no existe el archivo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una salida inesperada no se interpreta como respuesta', async () => {
|
||||||
|
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'WARNING: algo raro' }));
|
||||||
|
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sondas de ruta en Windows', () => {
|
describe('sondas de ruta en Windows', () => {
|
||||||
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
|
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
|
||||||
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
||||||
@@ -499,12 +619,22 @@ describe('sondas de ruta en Windows', () => {
|
|||||||
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
|
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
|
||||||
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
|
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
|
||||||
expect(await probeWindowsTask(sftp as never)).toEqual({
|
expect(await probeWindowsTask(sftp as never)).toEqual({
|
||||||
|
answered: true,
|
||||||
exists: true,
|
exists: true,
|
||||||
execute: 'C:\\Otra\\CloudRestoreAS.exe',
|
execute: 'C:\\Otra\\CloudRestoreAS.exe',
|
||||||
principal: 'SYSTEM'
|
principal: 'SYSTEM'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('una sonda que no responde no significa "no hay tarea"', async () => {
|
||||||
|
// Con `answered: false` el llamador sabe que no se sabe. Antes, un comando fallido se leía
|
||||||
|
// como "no hay tarea registrada" y de ahí salían decisiones sobre elevación y realineación.
|
||||||
|
const { sftp } = fakeSftp(() => ({ code: 1, stdout: '' }));
|
||||||
|
const r = await probeWindowsTask(sftp as never);
|
||||||
|
expect(r.answered).toBe(false);
|
||||||
|
expect(r.exists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('probeWindowsTask sin tarea registrada', async () => {
|
it('probeWindowsTask sin tarea registrada', async () => {
|
||||||
const { sftp } = psFake('no||');
|
const { sftp } = psFake('no||');
|
||||||
const r = await probeWindowsTask(sftp as never);
|
const r = await probeWindowsTask(sftp as never);
|
||||||
@@ -646,11 +776,18 @@ describe('verifyWindowsDeployment', () => {
|
|||||||
/** Ruta que la tarea tiene registrada en su acción. */
|
/** Ruta que la tarea tiene registrada en su acción. */
|
||||||
taskExecute?: string;
|
taskExecute?: string;
|
||||||
crashLog?: string;
|
crashLog?: string;
|
||||||
|
/** De cuál de las rutas candidatas se leyó el crash log. */
|
||||||
|
crashFrom?: string;
|
||||||
}) {
|
}) {
|
||||||
return fakeSftp((command) => {
|
return fakeSftp((command) => {
|
||||||
const encoded = command.split('-EncodedCommand ')[1] ?? '';
|
const encoded = command.split('-EncodedCommand ')[1] ?? '';
|
||||||
const script = Buffer.from(encoded, 'base64').toString('utf16le');
|
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('config\\.version')) return { code: 0, stdout: opts.stamp };
|
||||||
if (script.includes('Get-Process')) {
|
if (script.includes('Get-Process')) {
|
||||||
if (!opts.running) return { code: 0, stdout: 'detenido' };
|
if (!opts.running) return { code: 0, stdout: 'detenido' };
|
||||||
@@ -752,6 +889,33 @@ describe('verifyWindowsDeployment', () => {
|
|||||||
expect(mensaje).toContain('ImportError');
|
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 () => {
|
it('sin crash log el mensaje no se rompe', async () => {
|
||||||
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
|
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
|
||||||
const mensaje = await mensajeDeFallo(
|
const mensaje = await mensajeDeFallo(
|
||||||
|
|||||||
@@ -1041,7 +1041,7 @@ async function verifyLinuxDeployment(
|
|||||||
// Windows
|
// Windows
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado';
|
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido';
|
||||||
|
|
||||||
export interface WindowsPrivilege {
|
export interface WindowsPrivilege {
|
||||||
elevation: WindowsElevation;
|
elevation: WindowsElevation;
|
||||||
@@ -1075,7 +1075,22 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (probe.stdout.trim()) {
|
const salida = probe.stdout.trim();
|
||||||
|
// Sin respuesta no se afirma que la cuenta sea limitada: ese veredicto gobierna un 409, y
|
||||||
|
// deducirlo de un comando que no contestó rechazaría instalaciones perfectamente válidas.
|
||||||
|
if (probe.code !== 0 || !salida) {
|
||||||
|
return {
|
||||||
|
elevation: 'desconocido',
|
||||||
|
label: 'privilegios no determinados',
|
||||||
|
detail:
|
||||||
|
`la sonda de privilegios no respondió (código ${probe.code})` +
|
||||||
|
(probe.stderr ? `: ${truncate(probe.stderr)}` : '') +
|
||||||
|
'. Se continúa: el propio instalador fallará con un mensaje claro si de verdad ' +
|
||||||
|
'faltan permisos.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (salida) {
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return { elevation: 'admin', label: 'administrador', detail: '' };
|
return { elevation: 'admin', label: 'administrador', detail: '' };
|
||||||
case 'token-filtrado':
|
case 'token-filtrado':
|
||||||
@@ -1140,6 +1155,12 @@ export function windowsAgentExe(prefix: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowsProcessProbe {
|
export interface WindowsProcessProbe {
|
||||||
|
/**
|
||||||
|
* La sonda obtuvo una respuesta del destino. En false NO se sabe si hay agente corriendo, y
|
||||||
|
* quien decida un fallo duro debe exigir una respuesta afirmativa en vez de asumirla por
|
||||||
|
* ausencia: leer "el comando no respondió" como "no hay proceso" tumba actualizaciones buenas.
|
||||||
|
*/
|
||||||
|
answered: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
|
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
|
||||||
paths: string[];
|
paths: string[];
|
||||||
@@ -1173,13 +1194,19 @@ export async function probeWindowsAgentProcess(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const out = probe.stdout.trim();
|
const out = probe.stdout.trim();
|
||||||
|
// Sin respuesta no se sabe nada. Antes esto se leía como "detenido", que es una afirmación que
|
||||||
|
// la sonda nunca hizo.
|
||||||
|
if (probe.code !== 0 || !out) {
|
||||||
|
return { answered: false, running: false, paths: [], fromPrefix: false };
|
||||||
|
}
|
||||||
if (!out.startsWith('corriendo')) {
|
if (!out.startsWith('corriendo')) {
|
||||||
return { running: false, paths: [], fromPrefix: false };
|
return { answered: true, running: false, paths: [], fromPrefix: false };
|
||||||
}
|
}
|
||||||
const joined = out.split('|')[1] ?? '';
|
const joined = out.split('|')[1] ?? '';
|
||||||
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
|
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
|
||||||
const wanted = windowsAgentExe(prefix);
|
const wanted = windowsAgentExe(prefix);
|
||||||
return {
|
return {
|
||||||
|
answered: true,
|
||||||
running: true,
|
running: true,
|
||||||
paths,
|
paths,
|
||||||
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
|
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
|
||||||
@@ -1187,6 +1214,8 @@ export async function probeWindowsAgentProcess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowsTaskProbe {
|
export interface WindowsTaskProbe {
|
||||||
|
/** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */
|
||||||
|
answered: boolean;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
|
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
|
||||||
execute: string;
|
execute: string;
|
||||||
@@ -1221,14 +1250,138 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [flag, execute, principal] = probe.stdout.trim().split('|');
|
const salida = probe.stdout.trim();
|
||||||
|
if (probe.code !== 0 || !salida) {
|
||||||
|
return { answered: false, exists: false, execute: '', principal: '' };
|
||||||
|
}
|
||||||
|
const [flag, execute, principal] = salida.split('|');
|
||||||
return {
|
return {
|
||||||
|
answered: true,
|
||||||
exists: flag === 'si',
|
exists: flag === 'si',
|
||||||
execute: (execute ?? '').trim(),
|
execute: (execute ?? '').trim(),
|
||||||
principal: (principal ?? '').trim()
|
principal: (principal ?? '').trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuesta de una sonda que puede no saber.
|
||||||
|
*
|
||||||
|
* `desconocido` NO es `no`. Confundirlos es lo que dejó bloqueadas las actualizaciones con un
|
||||||
|
* artefacto perfectamente bueno: la sonda anterior colapsaba cualquier fallo —una excepción, un
|
||||||
|
* código de salida distinto de cero, stdout vacío— en "el instalador no lo soporta", y sobre esa
|
||||||
|
* lectura se decidía un 409. Mientras solo elegía entre dos banderas el falso negativo degradaba;
|
||||||
|
* en cuanto gobernó un rechazo, se volvió un candado.
|
||||||
|
*/
|
||||||
|
export interface ProbeAnswer {
|
||||||
|
answer: 'si' | 'no' | 'desconocido';
|
||||||
|
/** Por qué no se pudo determinar. Vacío cuando hay respuesta. */
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿El `install.ps1` del artefacto declara `-UpdateInPlace`?
|
||||||
|
*
|
||||||
|
* Se pregunta leyendo el TEXTO del script, no pidiéndole a PowerShell que lo compile y exponga sus
|
||||||
|
* parámetros. `(Get-Command …).Parameters` funcionaba en una máquina de desarrollo y fallaba en el
|
||||||
|
* servidor real; el candidato más probable es Constrained Language Mode (AppLocker/WDAC son
|
||||||
|
* habituales en servidores endurecidos), que restringe el acceso a propiedades de objetos .NET
|
||||||
|
* mientras `& install.ps1` sigue funcionando — justo lo que se observaba. Un `-match` sobre el
|
||||||
|
* contenido no compila nada, no toca reflexión y ninguna política lo bloquea.
|
||||||
|
*
|
||||||
|
* Se ancla a la DECLARACIÓN (`[switch]$UpdateInPlace`) y no a una mención suelta, para que un
|
||||||
|
* comentario que nombre el parámetro no dé un falso positivo.
|
||||||
|
*/
|
||||||
|
export async function probeInstallerUpdateSupport(
|
||||||
|
sftp: SftpClient,
|
||||||
|
installerPath: string
|
||||||
|
): Promise<ProbeAnswer> {
|
||||||
|
const probe = await execRemote(
|
||||||
|
sftp,
|
||||||
|
psEncoded(
|
||||||
|
`$p = '${installerPath}'; ` +
|
||||||
|
"if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " +
|
||||||
|
'try { ' +
|
||||||
|
'$t = Get-Content -LiteralPath $p -Raw -ErrorAction Stop; ' +
|
||||||
|
// El [regex]::IsMatch evita depender de $matches y del operador -match, que en
|
||||||
|
// modo restringido puede comportarse distinto.
|
||||||
|
'if ([regex]::IsMatch($t, \'\\[switch\\]\\s*\\$UpdateInPlace\')) ' +
|
||||||
|
"{ Write-Output 'si' } else { Write-Output 'no' } } " +
|
||||||
|
"catch { Write-Output ('desconocido|' + $_.Exception.Message) }"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const salida = probe.stdout.trim();
|
||||||
|
// El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso
|
||||||
|
// como una respuesta es precisamente el error que se está corrigiendo.
|
||||||
|
if (probe.code !== 0 || !salida) {
|
||||||
|
return {
|
||||||
|
answer: 'desconocido',
|
||||||
|
detail:
|
||||||
|
`la sonda no respondió (código ${probe.code})` +
|
||||||
|
(probe.stderr ? `: ${truncate(probe.stderr)}` : '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' };
|
||||||
|
return {
|
||||||
|
answer: 'desconocido',
|
||||||
|
detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿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,
|
||||||
|
soporte: ProbeAnswer,
|
||||||
|
version: string,
|
||||||
|
targetName: string
|
||||||
|
): string {
|
||||||
|
if (soporte.answer === 'si') return '';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// dato que convierte la próxima sorpresa en un diagnóstico de diez segundos.
|
||||||
|
if (soporte.answer === 'desconocido') {
|
||||||
|
return (
|
||||||
|
`No se pudo determinar si el install.ps1 de ${version} soporta -UpdateInPlace ` +
|
||||||
|
`(${soporte.detail || 'sin detalle'}). Se instala con el modo de arranque normal en vez ` +
|
||||||
|
'de bloquear: no saberlo no es lo mismo que saber que no.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
|
||||||
*
|
*
|
||||||
@@ -1341,22 +1494,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
|
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
|
||||||
* (`_crash_log_targets()`), y hasta ahora nadie lo leía nunca: el operador recibía "no arrancó" en
|
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
|
||||||
* lugar de "no arrancó porque X". Devuelve cadena vacía si no existe.
|
* `%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(
|
const result = await execRemote(
|
||||||
sftp,
|
sftp,
|
||||||
psEncoded(
|
psEncoded(
|
||||||
`$p = '${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log'; ` +
|
`$rutas = @("${candidatos.join('","')}"); ` +
|
||||||
'if (Test-Path -LiteralPath $p) { ' +
|
'foreach ($r in $rutas) { ' +
|
||||||
'(Get-Content -LiteralPath $p -Tail 20) -join [Environment]::NewLine }'
|
'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(
|
async function installWindows(
|
||||||
@@ -1531,27 +1712,21 @@ async function installWindows(
|
|||||||
//
|
//
|
||||||
// Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e
|
// 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
|
// 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
|
// NamedParameterNotFound SIN ejecutar una sola línea.
|
||||||
// `param()` del propio script en vez de mantener una tabla de versiones aquí.
|
|
||||||
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
|
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
|
||||||
const supportsProbe = await execRemote(
|
const soporte = await probeInstallerUpdateSupport(sftp, installerPs1);
|
||||||
sftp,
|
|
||||||
psEncoded(
|
const avisoArtefacto = checkWindowsArtifactUpdateSupport(
|
||||||
`if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` +
|
request.mode,
|
||||||
"{'si'} else {'no'}"
|
soporte,
|
||||||
)
|
release.version,
|
||||||
);
|
target.name
|
||||||
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'
|
|
||||||
);
|
);
|
||||||
|
if (avisoArtefacto) {
|
||||||
|
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inPlaceUpdate = request.mode === 'update' && soporte.answer === 'si';
|
||||||
const flag = inPlaceUpdate
|
const flag = inPlaceUpdate
|
||||||
? ' -UpdateInPlace'
|
? ' -UpdateInPlace'
|
||||||
: autostart === 'none'
|
: autostart === 'none'
|
||||||
@@ -1586,11 +1761,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
|
// 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
|
// 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
|
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
|
||||||
// actualización termina en verde sin haber cambiado nada.
|
// 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);
|
const proc = await restartWindowsAgent(sftp, targetPath);
|
||||||
await appendInstallStep(
|
await appendInstallStep(
|
||||||
runId,
|
runId,
|
||||||
@@ -1696,7 +1886,7 @@ export async function verifyWindowsDeployment(
|
|||||||
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
|
`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}. ` +
|
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
|
||||||
`${pistas.join('; ')}.` +
|
`${pistas.join('; ')}.` +
|
||||||
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
|
(crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
|
||||||
` Logs en ${prefix}\\config\\logs.`
|
` Logs en ${prefix}\\config\\logs.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user