Compare commits
3 Commits
2bbbeff3ed
...
9b05361a00
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b05361a00 | |||
| 3572aa974b | |||
| 9a4124d636 |
@@ -16,6 +16,7 @@ import { getRestoreTargetSsh } from './controldesk-pg';
|
||||
import {
|
||||
execRemote,
|
||||
probeLinuxElevation,
|
||||
probeWindowsAgentProcess,
|
||||
probeWindowsElevation,
|
||||
psEncoded,
|
||||
shQuote,
|
||||
@@ -23,7 +24,11 @@ import {
|
||||
} from './cras-install';
|
||||
import { probeRemoteSystem } from './cras-verify';
|
||||
import { listCrasTargetInventory } from './cras-releases';
|
||||
import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version';
|
||||
import {
|
||||
effectiveInstallPath,
|
||||
DEFAULT_INSTALL_PATHS,
|
||||
type CrasPlatform
|
||||
} from '$lib/cras-version';
|
||||
import type { ApiErrorStatus } from './api-error';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -65,7 +70,7 @@ async function waitAlive(check: () => Promise<boolean>): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
|
||||
async function startOnWindows(sftp: SftpClient, prefix: string): Promise<StartAgentOutcome> {
|
||||
const privileged = await probeWindowsElevation(sftp);
|
||||
if (privileged.elevation !== 'admin') {
|
||||
throw new AgentControlError(
|
||||
@@ -96,38 +101,51 @@ async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
|
||||
);
|
||||
}
|
||||
|
||||
// Se espera con la MISMA sonda que usa el instalador, que mira la ruta del ejecutable y no solo
|
||||
// el nombre. Con la comprobación por nombre, un agente viejo corriendo desde otra carpeta hacía
|
||||
// pasar por bueno un arranque que no ocurrió.
|
||||
let ultima = await probeWindowsAgentProcess(sftp, prefix);
|
||||
const alive = await waitAlive(async () => {
|
||||
const proc = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
|
||||
'{"si"} else {"no"}'
|
||||
)
|
||||
);
|
||||
return proc.stdout.trim() === 'si';
|
||||
ultima = await probeWindowsAgentProcess(sftp, prefix);
|
||||
// `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM
|
||||
// no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta.
|
||||
return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0);
|
||||
});
|
||||
|
||||
return alive
|
||||
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' }
|
||||
: {
|
||||
ok: false,
|
||||
detail:
|
||||
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
|
||||
'config\\logs en el servidor: el agente está fallando al iniciar.'
|
||||
};
|
||||
if (alive) {
|
||||
return {
|
||||
ok: true,
|
||||
detail:
|
||||
'Tarea CloudRestoreAS arrancada y proceso en ejecución' +
|
||||
(ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.')
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
detail: ultima.running
|
||||
? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` +
|
||||
'arranque automático apunta a otra instalación.'
|
||||
: 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
|
||||
`${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruta donde vive el agente, para poder reconocer su proceso.
|
||||
*
|
||||
* El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva
|
||||
* la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que
|
||||
* nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón.
|
||||
* Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el
|
||||
* NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra
|
||||
* carpeta. En Linux, además, el ancla `^` del patrón de `pgrep` no es opcional: sin ella el `sh -c`
|
||||
* que corre el propio pgrep lleva la ruta en su línea de comandos y haría match consigo mismo,
|
||||
* reportando vivo un agente que nunca arrancó.
|
||||
*/
|
||||
async function resolveLinuxPrefix(restoreTargetId: number): Promise<string> {
|
||||
async function resolvePrefix(restoreTargetId: number, platform: CrasPlatform): Promise<string> {
|
||||
const inventory = await listCrasTargetInventory();
|
||||
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
|
||||
return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
||||
return (
|
||||
effectiveInstallPath(row?.reported_install_path ?? null, platform) ??
|
||||
DEFAULT_INSTALL_PATHS[platform]
|
||||
);
|
||||
}
|
||||
|
||||
async function startOnLinux(
|
||||
@@ -203,11 +221,11 @@ export async function startCrasAgent(restoreTargetId: number): Promise<StartAgen
|
||||
|
||||
const system = await probeRemoteSystem(sftp);
|
||||
if (system.verdict === 'windows') {
|
||||
return await startOnWindows(sftp);
|
||||
return await startOnWindows(sftp, await resolvePrefix(restoreTargetId, 'windows'));
|
||||
}
|
||||
if (system.verdict === 'linux') {
|
||||
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
|
||||
const prefix = await resolveLinuxPrefix(restoreTargetId);
|
||||
const prefix = await resolvePrefix(restoreTargetId, 'linux');
|
||||
return await startOnLinux(sftp, privileged, prefix);
|
||||
}
|
||||
throw new AgentControlError(
|
||||
|
||||
@@ -28,7 +28,12 @@ import {
|
||||
execRemote,
|
||||
probeInPlaceUpdate,
|
||||
probeLinuxElevation,
|
||||
alignWindowsTask,
|
||||
normalizeWindowsPath,
|
||||
probeWindowsAgentProcess,
|
||||
probeWindowsElevation,
|
||||
probeWindowsTask,
|
||||
sameWindowsPath,
|
||||
psEncoded,
|
||||
shQuote,
|
||||
verifyWindowsDeployment,
|
||||
@@ -419,25 +424,244 @@ describe('probeWindowsElevation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Sondas de Windows que miran la RUTA, no el nombre.
|
||||
*
|
||||
* Son la respuesta al fallo silencioso: todo el camino identificaba al agente por NOMBRE, mientras
|
||||
* que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se
|
||||
* reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban.
|
||||
*/
|
||||
/**
|
||||
* Rutas de instalación personalizadas.
|
||||
*
|
||||
* `C:\Aduanasoft\CloudRestoreAS-win` es el peor caso posible y existe en producción: la ruta por
|
||||
* omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Cualquier comparación
|
||||
* hecha con `startsWith` daría por iguales dos instalaciones distintas, y el resultado sería
|
||||
* exactamente el fallo que se estuvo persiguiendo: actualizar una carpeta y arrancar la otra.
|
||||
*
|
||||
* Las pruebas de más abajo usaban nombres sin relación entre sí (`C:\Declarada` contra `C:\Otra`),
|
||||
* así que un `startsWith` mal puesto las pasaba todas.
|
||||
*/
|
||||
describe('comparación de rutas de Windows', () => {
|
||||
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
|
||||
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
||||
|
||||
it('NO confunde la ruta por omisión con una que la tiene como prefijo', () => {
|
||||
expect(sameWindowsPath(DEFECTO, PERSONALIZADA)).toBe(false);
|
||||
// Y en el otro sentido: da igual cuál sea la instalada.
|
||||
expect(sameWindowsPath(PERSONALIZADA, DEFECTO)).toBe(false);
|
||||
expect(
|
||||
sameWindowsPath(`${DEFECTO}\\CloudRestoreAS.exe`, `${PERSONALIZADA}\\CloudRestoreAS.exe`)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('normaliza comillas, barras finales y mayúsculas', () => {
|
||||
// La acción de una tarea guarda la ruta entrecomillada; una ruta capturada a mano suele
|
||||
// traer barra final; y NTFS no distingue caso.
|
||||
expect(sameWindowsPath(`"${PERSONALIZADA}"`, PERSONALIZADA)).toBe(true);
|
||||
expect(sameWindowsPath(`${PERSONALIZADA}\\`, PERSONALIZADA)).toBe(true);
|
||||
expect(sameWindowsPath(PERSONALIZADA.toUpperCase(), PERSONALIZADA)).toBe(true);
|
||||
expect(normalizeWindowsPath(' ""C:\\X\\"" ')).toBe('c:\\x');
|
||||
});
|
||||
|
||||
it('una ruta vacía no es igual a nada, ni a otra vacía', () => {
|
||||
// Si lo fuera, un `execute` sin leer pasaría por "ya está alineada" y no se corregiría nada.
|
||||
expect(sameWindowsPath('', '')).toBe(false);
|
||||
expect(sameWindowsPath('', PERSONALIZADA)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sondas de ruta en Windows', () => {
|
||||
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
|
||||
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
||||
|
||||
function psFake(stdout: string) {
|
||||
return fakeSftp(() => ({ code: 0, stdout }));
|
||||
}
|
||||
|
||||
it('el proceso de la ruta por omisión no cuenta como el de la personalizada', async () => {
|
||||
const { sftp } = psFake(`corriendo|${DEFECTO}\\CloudRestoreAS.exe`);
|
||||
const r = await probeWindowsAgentProcess(sftp as never, PERSONALIZADA);
|
||||
expect(r.running).toBe(true);
|
||||
expect(r.fromPrefix).toBe(false);
|
||||
});
|
||||
|
||||
it('ni al contrario: el de la personalizada no cuenta como el de la de omisión', async () => {
|
||||
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
|
||||
expect((await probeWindowsAgentProcess(sftp as never, DEFECTO)).fromPrefix).toBe(false);
|
||||
});
|
||||
|
||||
it('y el de la personalizada sí cuenta cuando esa es la instalación', async () => {
|
||||
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
|
||||
expect((await probeWindowsAgentProcess(sftp as never, PERSONALIZADA)).fromPrefix).toBe(true);
|
||||
});
|
||||
|
||||
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
|
||||
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
|
||||
expect(await probeWindowsTask(sftp as never)).toEqual({
|
||||
exists: true,
|
||||
execute: 'C:\\Otra\\CloudRestoreAS.exe',
|
||||
principal: 'SYSTEM'
|
||||
});
|
||||
});
|
||||
|
||||
it('probeWindowsTask sin tarea registrada', async () => {
|
||||
const { sftp } = psFake('no||');
|
||||
const r = await probeWindowsTask(sftp as never);
|
||||
expect(r.exists).toBe(false);
|
||||
expect(r.execute).toBe('');
|
||||
});
|
||||
|
||||
it('probeWindowsAgentProcess distingue la instalación propia de otra carpeta', async () => {
|
||||
const { sftp } = psFake('corriendo|C:\\Otra\\CloudRestoreAS.exe');
|
||||
const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada');
|
||||
expect(r.running).toBe(true);
|
||||
expect(r.fromPrefix).toBe(false);
|
||||
expect(r.paths).toEqual(['C:\\Otra\\CloudRestoreAS.exe']);
|
||||
});
|
||||
|
||||
it('probeWindowsAgentProcess reconoce la propia sin importar mayúsculas', async () => {
|
||||
// NTFS no distingue caso: comparar sensible reportaría "corre desde otra carpeta" sobre la
|
||||
// MISMA ruta y haría fallar una actualización correcta.
|
||||
const { sftp } = psFake('corriendo|c:\\declarada\\cloudrestoreas.exe');
|
||||
expect((await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada')).fromPrefix).toBe(true);
|
||||
});
|
||||
|
||||
it('vivo con la ruta ilegible no se afirma que sea ajeno', async () => {
|
||||
// Un proceso de SYSTEM no expone .Path a una cuenta sin elevación. `paths` vacío significa
|
||||
// "no sé desde dónde corre", y el llamador no debe tratarlo como fallo.
|
||||
const { sftp } = psFake('corriendo|');
|
||||
const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada');
|
||||
expect(r.running).toBe(true);
|
||||
expect(r.paths).toEqual([]);
|
||||
expect(r.fromPrefix).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Realineación de la tarea programada DESDE EL PANEL.
|
||||
*
|
||||
* Vive aquí y no solo en install.ps1 porque un instalador ya publicado no se arregla hacia atrás:
|
||||
* el script que se ejecuta en el servidor viaja DENTRO del artefacto, y el de 1.1.3 salió antes de
|
||||
* que install.ps1 aprendiera a realinear la tarea. Sin esto, actualizar a 1.1.3 reemplaza el
|
||||
* binario de una carpeta y `Start-ScheduledTask` levanta el de otra.
|
||||
*/
|
||||
describe('alignWindowsTask', () => {
|
||||
const DESTINO = 'C:\\Aduanasoft\\CloudRestoreAS-win';
|
||||
const ESPERADO = `${DESTINO}\\CloudRestoreAS.exe`;
|
||||
|
||||
function psFake(stdout: string) {
|
||||
return fakeSftp(() => ({ code: 0, stdout }));
|
||||
}
|
||||
|
||||
it('reapunta una tarea que ejecuta otro binario', async () => {
|
||||
const { sftp, commands } = psFake('ok');
|
||||
const cambio = await alignWindowsTask(
|
||||
sftp as never,
|
||||
1,
|
||||
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
|
||||
DESTINO
|
||||
);
|
||||
expect(cambio).toBe(true);
|
||||
const script = Buffer.from(
|
||||
commands[0].split('-EncodedCommand ')[1] ?? '',
|
||||
'base64'
|
||||
).toString('utf16le');
|
||||
expect(script).toContain(ESPERADO);
|
||||
expect(script).toContain('Set-ScheduledTask');
|
||||
// Se conservan los argumentos: reregistrar desde cero perdería ajustes del operador.
|
||||
expect(script).toContain('Arguments');
|
||||
// `$args` es una variable automática de PowerShell; asignarla sería un error en ejecución.
|
||||
expect(script).not.toMatch(/\$args\s*=/);
|
||||
});
|
||||
|
||||
it('no toca una tarea que ya apunta bien', async () => {
|
||||
const { sftp, commands } = psFake('ok');
|
||||
expect(await alignWindowsTask(sftp as never, 1, ESPERADO, DESTINO)).toBe(false);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignora comillas y mayúsculas al comparar', async () => {
|
||||
// La acción de la tarea guarda la ruta entrecomillada y NTFS no distingue caso: comparar en
|
||||
// crudo reescribiría tareas correctas en cada actualización.
|
||||
const { sftp, commands } = psFake('ok');
|
||||
const cambio = await alignWindowsTask(
|
||||
sftp as never,
|
||||
1,
|
||||
`"${ESPERADO.toLowerCase()}"`,
|
||||
DESTINO
|
||||
);
|
||||
expect(cambio).toBe(false);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reapunta cuando la tarea usa la ruta por omisión y la instalación es la personalizada', async () => {
|
||||
// El servidor real: instalado en ...-win, tarea apuntando a la carpeta por omisión. Con una
|
||||
// comparación por prefijo esto se daría por alineado y no se corregiría nada.
|
||||
const { sftp, commands } = psFake('ok');
|
||||
const cambio = await alignWindowsTask(
|
||||
sftp as never,
|
||||
1,
|
||||
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
|
||||
'C:\\Aduanasoft\\CloudRestoreAS-win'
|
||||
);
|
||||
expect(cambio).toBe(true);
|
||||
expect(commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('si no se puede corregir, falla nombrando las dos rutas', async () => {
|
||||
const { sftp } = psFake('error|Acceso denegado');
|
||||
await expect(
|
||||
alignWindowsTask(sftp as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
|
||||
).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/);
|
||||
const { sftp: sftp2 } = psFake('error|Acceso denegado');
|
||||
await expect(
|
||||
alignWindowsTask(sftp2 as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
|
||||
).rejects.toThrow(new RegExp(ESPERADO.replace(/\\/g, '\\\\')));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Verificación del despliegue en Windows.
|
||||
*
|
||||
* El defecto que cubre: se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una
|
||||
* tarea REGISTRADA QUE NO ESTÁ CORRIENDO —exactamente lo que se ve cuando el agente arrancó y
|
||||
* murió a los pocos segundos—, así que una actualización que dejaba el servidor sin restaurador
|
||||
* se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar.
|
||||
* Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada:
|
||||
*
|
||||
* 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE
|
||||
* NO ESTÁ CORRIENDO — lo que se ve cuando el agente arrancó y murió a los pocos segundos.
|
||||
* 2. Un sello `config\.version` ausente se toleraba SIEMPRE. Como los agentes anteriores a 1.1.1
|
||||
* no lo escribían, al actualizar uno de esos no se comprobaba ninguna versión.
|
||||
*/
|
||||
describe('verifyWindowsDeployment', () => {
|
||||
const RELEASE = { version: '1.1.2' } as never;
|
||||
|
||||
const PREFIJO = 'C:\\CRAS';
|
||||
|
||||
/** Responde a cada script de PowerShell según lo que pregunte. */
|
||||
function fakeWindows(opts: { stamp: string; taskState: string; running: boolean }) {
|
||||
function fakeWindows(opts: {
|
||||
stamp: string;
|
||||
taskState: string;
|
||||
running: boolean;
|
||||
/** Desde dónde corre. Por omisión, el propio prefijo. */
|
||||
procPath?: string;
|
||||
/** Ruta que la tarea tiene registrada en su acción. */
|
||||
taskExecute?: string;
|
||||
crashLog?: 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('config\\.version')) return { code: 0, stdout: opts.stamp };
|
||||
if (script.includes('Get-Process')) {
|
||||
return { code: 0, stdout: opts.running ? 'corriendo:1' : 'detenido' };
|
||||
if (!opts.running) return { code: 0, stdout: 'detenido' };
|
||||
const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`;
|
||||
return { code: 0, stdout: `corriendo|${ruta}` };
|
||||
}
|
||||
// La sonda completa de la tarea se distingue de la consulta de estado por que pregunta
|
||||
// también por el principal.
|
||||
if (script.includes('Principal')) {
|
||||
const exec = opts.taskExecute ?? `${PREFIJO}\\CloudRestoreAS.exe`;
|
||||
return { code: 0, stdout: `si|${exec}|SYSTEM` };
|
||||
}
|
||||
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
|
||||
return { code: 1, stdout: '' };
|
||||
@@ -475,6 +699,95 @@ describe('verifyWindowsDeployment', () => {
|
||||
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
|
||||
).rejects.toThrow(/reporta la versión 1\.1\.1/);
|
||||
});
|
||||
|
||||
it('ACTUALIZANDO, un sello ausente es un fallo', async () => {
|
||||
// El caso real: un agente 1.1.0 no escribía config\.version, así que el sello llegaba vacío,
|
||||
// el vacío se toleraba y el run terminaba en verde sobre un servidor sin actualizar.
|
||||
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true });
|
||||
await expect(
|
||||
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true)
|
||||
).rejects.toThrow(/no escribió config\\\.version/);
|
||||
});
|
||||
|
||||
/**
|
||||
* Corre la verificación esperando que falle y devuelve el mensaje. Lanza si NO falla, para que
|
||||
* una comprobación que dejara de detectar el problema no pase como verde con mensaje vacío.
|
||||
*/
|
||||
async function mensajeDeFallo(
|
||||
...args: Parameters<typeof verifyWindowsDeployment>
|
||||
): Promise<string> {
|
||||
try {
|
||||
await verifyWindowsDeployment(...args);
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
throw new Error('se esperaba que verifyWindowsDeployment fallara, y no falló');
|
||||
}
|
||||
|
||||
it('el error del sello trae el diagnóstico hecho, no una tarea que investigar', async () => {
|
||||
// El operador lee el mensaje, no la bitácora del run ("no sé dónde verlo"). El panel ya
|
||||
// tiene a mano a qué apunta la tarea, desde dónde corre el proceso y el crash log: mandarlo
|
||||
// a averiguarlo era pedirle algo que aquí ya se sabía.
|
||||
const { sftp } = fakeWindows({
|
||||
stamp: '',
|
||||
taskState: 'Running',
|
||||
running: true,
|
||||
taskExecute: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
|
||||
procPath: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
|
||||
crashLog: 'ImportError: no se pudo cargar el driver ODBC'
|
||||
});
|
||||
const mensaje = await mensajeDeFallo(
|
||||
sftp as never,
|
||||
1,
|
||||
RELEASE,
|
||||
'service',
|
||||
PREFIJO,
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
expect(mensaje).toContain('C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe');
|
||||
expect(mensaje).toMatch(/arranque automático ejecuta/);
|
||||
expect(mensaje).toMatch(/hay un proceso corriendo desde/);
|
||||
expect(mensaje).toContain('ImportError');
|
||||
});
|
||||
|
||||
it('sin crash log el mensaje no se rompe', async () => {
|
||||
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
|
||||
const mensaje = await mensajeDeFallo(
|
||||
sftp as never,
|
||||
1,
|
||||
RELEASE,
|
||||
'service',
|
||||
PREFIJO,
|
||||
false,
|
||||
true
|
||||
);
|
||||
expect(mensaje).toMatch(/no hay ningún proceso CloudRestoreAS corriendo/);
|
||||
expect(mensaje).not.toContain('Último crash');
|
||||
});
|
||||
|
||||
it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => {
|
||||
// Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía.
|
||||
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true });
|
||||
await expect(
|
||||
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, false)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('un agente corriendo desde OTRA carpeta no cuenta como desplegado', async () => {
|
||||
// Exactamente el fallo silencioso: se reemplazó el binario de C:\CRAS y el arranque
|
||||
// automático levantó el de otra ruta. Antes pasaba por bueno.
|
||||
const { sftp } = fakeWindows({
|
||||
stamp: '1.1.2',
|
||||
taskState: 'Running',
|
||||
running: true,
|
||||
procPath: 'D:\\Vieja\\CloudRestoreAS.exe'
|
||||
});
|
||||
await expect(
|
||||
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true)
|
||||
).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1100,6 +1100,265 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparación de rutas de Windows. Una sola implementación, a propósito.
|
||||
*
|
||||
* La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente
|
||||
* distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los
|
||||
* casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation`
|
||||
* e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del
|
||||
* mismo servidor.
|
||||
*
|
||||
* Lo que hay que normalizar y por qué:
|
||||
* - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada.
|
||||
* - **Barras finales**: una ruta capturada a mano suele traerlas.
|
||||
* - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas
|
||||
* distintas donde el sistema de archivos ve una sola.
|
||||
*
|
||||
* Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de
|
||||
* `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones
|
||||
* distintas — y esa combinación existe en producción.
|
||||
*/
|
||||
export function normalizeWindowsPath(value: string): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^"+|"+$/g, '')
|
||||
.replace(/\\+$/, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */
|
||||
export function sameWindowsPath(a: string, b: string): boolean {
|
||||
const na = normalizeWindowsPath(a);
|
||||
const nb = normalizeWindowsPath(b);
|
||||
return na !== '' && na === nb;
|
||||
}
|
||||
|
||||
/** Ruta del ejecutable del agente dentro de una carpeta de instalación. */
|
||||
export function windowsAgentExe(prefix: string): string {
|
||||
return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
|
||||
}
|
||||
|
||||
export interface WindowsProcessProbe {
|
||||
running: boolean;
|
||||
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
|
||||
paths: string[];
|
||||
/** Alguno corre el binario del prefijo indicado. */
|
||||
fromPrefix: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesos del agente en el destino, con la RUTA de su ejecutable.
|
||||
*
|
||||
* El nombre no distingue instalaciones: `Get-Process -Name CloudRestoreAS` responde igual para el
|
||||
* agente que acabamos de poner y para uno viejo que sigue vivo en otra carpeta.
|
||||
*
|
||||
* Si la ruta no es legible se devuelve `paths` vacío y `fromPrefix` en false, y el llamador NO debe
|
||||
* concluir que es de otra instalación: un proceso de SYSTEM no expone su ruta a una cuenta sin
|
||||
* elevación, y tratar eso como fallo rompería actualizaciones correctas.
|
||||
*/
|
||||
export async function probeWindowsAgentProcess(
|
||||
sftp: SftpClient,
|
||||
prefix: string
|
||||
): Promise<WindowsProcessProbe> {
|
||||
const probe = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$p = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue); " +
|
||||
"if ($p.Count -eq 0) { Write-Output 'detenido'; return }; " +
|
||||
'$rutas = @(); ' +
|
||||
'foreach ($x in $p) { try { if ($x.Path) { $rutas += $x.Path } } catch { } }; ' +
|
||||
"Write-Output ('corriendo|' + ($rutas -join ';'))"
|
||||
)
|
||||
);
|
||||
|
||||
const out = probe.stdout.trim();
|
||||
if (!out.startsWith('corriendo')) {
|
||||
return { running: false, paths: [], fromPrefix: false };
|
||||
}
|
||||
const joined = out.split('|')[1] ?? '';
|
||||
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
|
||||
const wanted = windowsAgentExe(prefix);
|
||||
return {
|
||||
running: true,
|
||||
paths,
|
||||
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
|
||||
};
|
||||
}
|
||||
|
||||
export interface WindowsTaskProbe {
|
||||
exists: boolean;
|
||||
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
|
||||
execute: string;
|
||||
/** Cuenta con la que corre. `SYSTEM` es lo que obliga a elevar para pararla o arrancarla. */
|
||||
principal: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interroga la tarea programada del agente.
|
||||
*
|
||||
* Existe porque todo el camino de Windows identificaba al agente por NOMBRE mientras que lo único
|
||||
* que se actualiza se identifica por RUTA. `Start-ScheduledTask` ejecuta la ruta registrada en la
|
||||
* acción de la tarea, no la que acabamos de instalar: en cuanto las dos no coinciden se reemplaza
|
||||
* un binario y se arranca otro, sin que nada falle.
|
||||
*
|
||||
* Las propiedades se leen por reflexión porque no toda acción de una tarea es de tipo Exec (una
|
||||
* ComHandler no tiene `Execute`).
|
||||
*/
|
||||
export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskProbe> {
|
||||
const probe = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
"if (-not $t) { Write-Output 'no||'; return }; " +
|
||||
"$exec = ''; " +
|
||||
'foreach ($a in @($t.Actions)) { ' +
|
||||
"$pr = $a.PSObject.Properties['Execute']; " +
|
||||
"if ($pr -and $pr.Value) { $exec = ([string]$pr.Value).Trim('\"'); break } }; " +
|
||||
"$user = ''; " +
|
||||
"try { if ($t.Principal) { $user = [string]$t.Principal.UserId } } catch { }; " +
|
||||
"Write-Output ('si|' + $exec + '|' + $user)"
|
||||
)
|
||||
);
|
||||
|
||||
const [flag, execute, principal] = probe.stdout.trim().split('|');
|
||||
return {
|
||||
exists: flag === 'si',
|
||||
execute: (execute ?? '').trim(),
|
||||
principal: (principal ?? '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
|
||||
*
|
||||
* Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede
|
||||
* arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3,
|
||||
* y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde
|
||||
* aquí funciona con cualquier versión ya distribuida.
|
||||
*
|
||||
* Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal,
|
||||
* ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador
|
||||
* le haya hecho.
|
||||
*/
|
||||
export async function alignWindowsTask(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
taskExecute: string,
|
||||
targetPath: string
|
||||
): Promise<boolean> {
|
||||
if (!taskExecute) return false;
|
||||
const wanted = windowsAgentExe(targetPath);
|
||||
if (sameWindowsPath(taskExecute, wanted)) return false;
|
||||
|
||||
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
|
||||
// dentro de un bloque de script es un error.
|
||||
const result = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
"if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " +
|
||||
'$acciones = @(); ' +
|
||||
'foreach ($a in @($t.Actions)) { ' +
|
||||
" $pe = $a.PSObject.Properties['Execute']; " +
|
||||
' if (-not $pe) { $acciones += $a; continue }; ' +
|
||||
" $pa = $a.PSObject.Properties['Arguments']; " +
|
||||
" $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " +
|
||||
' if ($argumentos) { $acciones += New-ScheduledTaskAction ' +
|
||||
`-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` +
|
||||
'else { $acciones += New-ScheduledTaskAction ' +
|
||||
`-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` +
|
||||
"try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " +
|
||||
"-ErrorAction Stop | Out-Null; Write-Output 'ok' } " +
|
||||
"catch { Write-Output ('error|' + $_.Exception.Message) }"
|
||||
)
|
||||
);
|
||||
|
||||
const out = result.stdout.trim();
|
||||
if (out === 'ok') {
|
||||
// Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de
|
||||
// instalación, este es el dato con el que se deshace el cambio a mano.
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'realinear-arranque',
|
||||
true,
|
||||
`la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo
|
||||
// silencioso que se está persiguiendo.
|
||||
throw new InstallError(
|
||||
409,
|
||||
`La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` +
|
||||
`instalar (${wanted}), y no se pudo corregir` +
|
||||
(out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') +
|
||||
'. Sin eso, el arranque automático seguiría levantando la versión anterior.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinicia el agente en Windows y espera a que corra el binario de `prefix`.
|
||||
*
|
||||
* Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría
|
||||
* una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia.
|
||||
*/
|
||||
export async function restartWindowsAgent(
|
||||
sftp: SftpClient,
|
||||
prefix: string,
|
||||
timeoutMs = 60_000
|
||||
): Promise<WindowsProcessProbe> {
|
||||
const exe = windowsAgentExe(prefix);
|
||||
await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
"if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " +
|
||||
"Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " +
|
||||
'Stop-Process -Force -ErrorAction SilentlyContinue; ' +
|
||||
'Start-Sleep -Seconds 3; ' +
|
||||
// Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el
|
||||
// proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen.
|
||||
"if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " +
|
||||
`else { Start-Process -FilePath '${exe}' ` +
|
||||
`-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` +
|
||||
'-WindowStyle Hidden }'
|
||||
),
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = await probeWindowsAgentProcess(sftp, prefix);
|
||||
while (Date.now() < deadline) {
|
||||
// `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de
|
||||
// SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen.
|
||||
if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
probe = await probeWindowsAgentProcess(sftp, prefix);
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cola del crash log que el agente escribe junto a su ejecutable 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.
|
||||
*/
|
||||
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
|
||||
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 }'
|
||||
)
|
||||
);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function installWindows(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
@@ -1116,36 +1375,66 @@ async function installWindows(
|
||||
const privileged = await probeWindowsElevation(sftp);
|
||||
const autostart = request.autostart ?? 'service';
|
||||
|
||||
// La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en
|
||||
// una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe
|
||||
// en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta
|
||||
// corrida pida otro arranque, y ahí igual hay que poder detenerla.
|
||||
const taskProbe = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
|
||||
'{"si"} else {"no"}'
|
||||
)
|
||||
// La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive
|
||||
// realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato
|
||||
// se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de
|
||||
// otra: el run terminaba en verde y el servidor seguía con la versión anterior.
|
||||
const task = await probeWindowsTask(sftp);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'arranque-automatico-actual',
|
||||
true,
|
||||
task.exists
|
||||
? `tarea CloudRestoreAS ejecuta ${task.execute || '(sin acción)'}` +
|
||||
(task.principal ? ` como ${task.principal}` : '')
|
||||
: 'no hay tarea CloudRestoreAS registrada'
|
||||
);
|
||||
const taskExists = taskProbe.stdout.trim() === 'si';
|
||||
const needsElevation = autostart === 'service' || taskExists;
|
||||
|
||||
if (needsElevation && privileged.elevation !== 'admin') {
|
||||
// Cuando el agente no reporta su ruta —nada anterior a 1.1.1 lo hace— la de la tarea es una
|
||||
// fuente REAL del destino, no una suposición. Preferirla al default evita el caso en que el
|
||||
// panel apuntaba a C:\Aduanasoft\CloudRestoreAS mientras el agente vivía en otra carpeta.
|
||||
let targetPath = installPath;
|
||||
if (!reportedInstallPath && task.execute) {
|
||||
const fromTask = task.execute.replace(/\\[^\\]*$/, '');
|
||||
if (fromTask && isSafeInstallPath(fromTask, 'windows')) {
|
||||
targetPath = fromTask;
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'ruta-desde-la-tarea',
|
||||
true,
|
||||
`el agente no reporta su ruta; se usa la de la tarea: ${targetPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// La elevación hace falta para MANDAR sobre la tarea: registrarla en una instalación nueva, o
|
||||
// pararla y arrancarla cuando corre como SYSTEM. Que la tarea EXISTA no basta —si corre con la
|
||||
// propia cuenta SSH no hay nada que elevar—, y tratarlo así bloqueaba de entrada cualquier
|
||||
// actualización sobre un servidor ya instalado.
|
||||
const taskNeedsAdmin = task.exists && /system|S-1-5-18/i.test(task.principal);
|
||||
const needsElevation = autostart === 'service' || taskNeedsAdmin;
|
||||
|
||||
if (needsElevation && privileged.elevation === 'limitado') {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}`
|
||||
);
|
||||
}
|
||||
// Con el token filtrado por UAC no se rechaza por adelantado: se intenta y se reporta lo que
|
||||
// responda el servidor. La sonda mide `IsInRole`, que en una sesión de OpenSSH puede decir que
|
||||
// no aun cuando la operación sí pase; adivinarlo bloqueaba actualizaciones que funcionaban.
|
||||
if (needsElevation && privileged.elevation === 'token-filtrado') {
|
||||
await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail);
|
||||
}
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'precondiciones',
|
||||
true,
|
||||
`${systemEvidence}, privilegios=${privileged.label}` +
|
||||
(taskExists ? ', tarea CloudRestoreAS ya registrada' : '')
|
||||
`${systemEvidence}, privilegios=${privileged.label}`
|
||||
);
|
||||
|
||||
if (request.mode === 'update') {
|
||||
await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath);
|
||||
await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath);
|
||||
}
|
||||
|
||||
// Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp.
|
||||
@@ -1235,11 +1524,34 @@ async function installWindows(
|
||||
);
|
||||
}
|
||||
|
||||
// Actualizar va SIEMPRE 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 con reversión automática si el binario nuevo no levanta.
|
||||
const inPlaceUpdate = request.mode === 'update';
|
||||
// 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
|
||||
// con reversión automática si el binario nuevo no levanta.
|
||||
//
|
||||
// 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
|
||||
// NamedParameterNotFound SIN ejecutar una sola línea. Se le pregunta a PowerShell por el
|
||||
// `param()` del propio script en vez de mantener una tabla de versiones aquí.
|
||||
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
|
||||
const supportsProbe = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` +
|
||||
"{'si'} else {'no'}"
|
||||
)
|
||||
);
|
||||
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 flag = inPlaceUpdate
|
||||
? ' -UpdateInPlace'
|
||||
: autostart === 'none'
|
||||
@@ -1252,7 +1564,7 @@ async function installWindows(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
|
||||
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
||||
`-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
||||
),
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
@@ -1274,8 +1586,24 @@ async function installWindows(
|
||||
);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
const proc = await restartWindowsAgent(sftp, targetPath);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'rearrancar-agente',
|
||||
proc.running,
|
||||
proc.running
|
||||
? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
||||
: 'el agente no volvió a arrancar tras realinear la tarea'
|
||||
);
|
||||
}
|
||||
|
||||
await verifyWindowsDeployment(
|
||||
sftp, runId, release, autostart, installPath, inPlaceUpdate
|
||||
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
|
||||
);
|
||||
} finally {
|
||||
await cleanupStaging(
|
||||
@@ -1295,7 +1623,8 @@ export async function verifyWindowsDeployment(
|
||||
release: CrasRelease,
|
||||
autostart: AutostartMode,
|
||||
prefix: string,
|
||||
inPlaceUpdate = false
|
||||
inPlaceUpdate = false,
|
||||
isUpdate = false
|
||||
): Promise<void> {
|
||||
// El sello config\.version lo escribe el bootstrap del binario.
|
||||
//
|
||||
@@ -1306,7 +1635,10 @@ export async function verifyWindowsDeployment(
|
||||
// desempacar un onefile de ~270 MB con Defender escaneando cada archivo se pasa de largo del
|
||||
// margen. Se sondea hasta que coincida, o hasta agotar los intentos.
|
||||
let deployed = '';
|
||||
const stampAttempts = inPlaceUpdate ? 15 : 3;
|
||||
// Margen generoso: desempacar un onefile de ~270 MB con Defender escaneando cada archivo se
|
||||
// pasa de largo de los 30s que había antes, y agotar el margen ahora es un FALLO en una
|
||||
// actualización, así que quedarse corto convertiría un despliegue bueno en un error.
|
||||
const stampAttempts = isUpdate || inPlaceUpdate ? 45 : 3;
|
||||
for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
|
||||
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const stamp = await execRemote(
|
||||
@@ -1330,6 +1662,44 @@ export async function verifyWindowsDeployment(
|
||||
: '')
|
||||
);
|
||||
}
|
||||
// En una ACTUALIZACIÓN, un sello ausente es un fallo, no un aviso.
|
||||
//
|
||||
// Esto es lo que dejaba pasar una actualización que no hacía nada. Los agentes anteriores a
|
||||
// 1.1.1 no escribían config\.version, así que `deployed` llegaba vacío, el vacío se toleraba y
|
||||
// el paso se asentaba como "se creará al arrancar": en la única combinación que importa
|
||||
// —actualizar un agente viejo— no se comprobaba ninguna versión. El binario nuevo SÍ lo escribe
|
||||
// en ensure_runtime_layout(), así que si tras todo el margen sigue sin aparecer, lo que corre
|
||||
// no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva,
|
||||
// que es donde sí es legítima.
|
||||
if (!deployed && isUpdate) {
|
||||
// El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea,
|
||||
// desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el
|
||||
// arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run,
|
||||
// donde sí estaba, no la encuentra nadie.
|
||||
const [task, proc, crash] = await Promise.all([
|
||||
probeWindowsTask(sftp),
|
||||
probeWindowsAgentProcess(sftp, prefix),
|
||||
readWindowsCrashLog(sftp, prefix)
|
||||
]);
|
||||
|
||||
const pistas = [
|
||||
task.exists
|
||||
? `el arranque automático ejecuta ${task.execute || '(sin acción)'}`
|
||||
: 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente',
|
||||
proc.running
|
||||
? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
|
||||
: 'no hay ningún proceso CloudRestoreAS corriendo'
|
||||
];
|
||||
|
||||
throw new InstallError(
|
||||
502,
|
||||
`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)}` : '') +
|
||||
` Logs en ${prefix}\\config\\logs.`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'verificar-version',
|
||||
@@ -1341,14 +1711,21 @@ export async function verifyWindowsDeployment(
|
||||
// salía aquí cuando el arranque no era 'service', así que una actualización que dejaba el
|
||||
// servidor sin agente se asentaba como completada y nadie se enteraba hasta que alguien
|
||||
// pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera.
|
||||
const proc = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
|
||||
)
|
||||
);
|
||||
const running = proc.stdout.trim().startsWith('corriendo');
|
||||
const proc = await probeWindowsAgentProcess(sftp, prefix);
|
||||
const running = proc.running;
|
||||
|
||||
// Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía
|
||||
// "hay un proceso llamado CloudRestoreAS", que el agente viejo —nunca detenido, arrancado por
|
||||
// una tarea que apuntaba a otra carpeta— satisfacía igual de bien.
|
||||
if (running && proc.paths.length > 0 && !proc.fromPrefix) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`Hay un agente corriendo desde ${proc.paths.join(', ')}, no desde ${prefix}. Se instaló ` +
|
||||
'el binario nuevo en una carpeta y el arranque automático levanta el de otra, así que ' +
|
||||
'la actualización no surtió efecto. Revisa a qué ejecutable apunta la tarea ' +
|
||||
'CloudRestoreAS en el servidor.'
|
||||
);
|
||||
}
|
||||
|
||||
if (autostart !== 'service') {
|
||||
// Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca
|
||||
@@ -1358,7 +1735,9 @@ export async function verifyWindowsDeployment(
|
||||
runId,
|
||||
'verificar-proceso',
|
||||
running || autostart === 'desktop',
|
||||
running ? 'CloudRestoreAS en ejecución' : 'CloudRestoreAS no está en ejecución'
|
||||
running
|
||||
? `CloudRestoreAS en ejecución${proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ''}`
|
||||
: 'CloudRestoreAS no está en ejecución'
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1391,7 +1770,8 @@ export async function verifyWindowsDeployment(
|
||||
runId,
|
||||
'verificar-servicio',
|
||||
true,
|
||||
`tarea CloudRestoreAS: ${state}, proceso en ejecución`
|
||||
`tarea CloudRestoreAS: ${state}, proceso en ejecución` +
|
||||
(proc.paths.length ? ` desde ${proc.paths.join(', ')}` : ' (ruta no legible)')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
||||
import {
|
||||
execRemote,
|
||||
probeLinuxElevation,
|
||||
probeWindowsAgentProcess,
|
||||
probeWindowsTask,
|
||||
psEncoded,
|
||||
readUnitProps,
|
||||
sameWindowsPath,
|
||||
shQuote,
|
||||
unitUserOrRoot
|
||||
unitUserOrRoot,
|
||||
windowsAgentExe
|
||||
} from './cras-install';
|
||||
import { listCrasTargetInventory } from './cras-releases';
|
||||
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
|
||||
@@ -821,19 +825,44 @@ async function inspectWindows(
|
||||
)
|
||||
);
|
||||
|
||||
// El proceso vivo es la señal más directa de que está trabajando.
|
||||
const proc = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }'
|
||||
),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const procOut = proc.stdout.trim();
|
||||
const running = procOut.startsWith('corriendo');
|
||||
// A qué ejecutable apunta el arranque automático. Es un chequeo aparte porque una tarea que
|
||||
// apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el
|
||||
// binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después.
|
||||
const taskProbe = await probeWindowsTask(sftp);
|
||||
const esperado = windowsAgentExe(prefix);
|
||||
// Misma comparación que usa el instalador. Tenerla duplicada aquí con otro criterio dejaba a las
|
||||
// dos pantallas capaces de discrepar sobre el mismo servidor.
|
||||
const taskAlineada = !taskProbe.execute || sameWindowsPath(taskProbe.execute, esperado);
|
||||
if (taskProbe.exists) {
|
||||
checks.push(
|
||||
check(
|
||||
'ruta_arranque',
|
||||
'El arranque apunta a la instalación',
|
||||
taskAlineada ? 'ok' : 'fail',
|
||||
taskAlineada
|
||||
? taskProbe.execute || 'sin acción declarada'
|
||||
: `la tarea ejecuta ${taskProbe.execute}, no ${esperado}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// El proceso vivo es la señal más directa de que está trabajando — pero tiene que ser el de
|
||||
// ESTA instalación. Se comparte la sonda con el instalador para que las dos pantallas no puedan
|
||||
// contradecirse sobre el mismo servidor.
|
||||
const proc = await probeWindowsAgentProcess(sftp, prefix);
|
||||
const running = proc.running;
|
||||
const detalleProc = !proc.running
|
||||
? 'detenido'
|
||||
: proc.paths.length === 0
|
||||
? 'corriendo (ruta no legible)'
|
||||
: `corriendo desde ${proc.paths.join(', ')}`;
|
||||
checks.push(
|
||||
check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido')
|
||||
check(
|
||||
'proceso',
|
||||
'Proceso en ejecución',
|
||||
!proc.running ? 'warn' : proc.paths.length && !proc.fromPrefix ? 'fail' : 'ok',
|
||||
detalleProc
|
||||
)
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import Spinner from '$lib/components/Spinner.svelte';
|
||||
import { effectiveInstallPath, platformLabel } from '$lib/cras-version';
|
||||
import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version';
|
||||
import {
|
||||
INSTALL_STEP_LABEL,
|
||||
currentPhaseLabel,
|
||||
@@ -463,7 +463,18 @@
|
||||
{#if showFormBanner && form?.error}
|
||||
<div class="flex items-start gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
<span class="material-icons-outlined text-base">error_outline</span>
|
||||
<p class="min-w-0 flex-1">{form.error}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p>{form.error}</p>
|
||||
<!-- Los pasos del run SÍ se cargan al fallar, pero viven dentro del modal de
|
||||
instalación y el operador no daba con ellos. Se nombra el run y dónde está,
|
||||
en vez de dar por hecho que se encuentra. -->
|
||||
{#if progressRunId}
|
||||
<p class="mt-1 text-xs text-red-700">
|
||||
Detalle paso a paso en el run #{progressRunId}, en el recuadro de
|
||||
progreso del formulario de instalación.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar aviso"
|
||||
@@ -1335,6 +1346,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ruta de destino, siempre visible.
|
||||
Antes solo aparecía dentro del aviso de "instalar limpio", que se pinta únicamente
|
||||
si el panel ya conoce la versión instalada. En un servidor con ruta personalizada
|
||||
y versión desconocida —el caso que costó dos rondas de diagnóstico— el operador no
|
||||
veía a qué carpeta iba a instalar hasta que el run fallaba. -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs">
|
||||
<p class="text-slate-700">
|
||||
Se instalará en <code class="font-mono text-slate-900">{targetInstallPath}</code>
|
||||
</p>
|
||||
<p class="mt-0.5 text-slate-500">
|
||||
{#if installTarget?.reported_install_path}
|
||||
Ruta registrada para {installTarget.name}. Se cambia con el lápiz que está
|
||||
junto al nombre del servidor, en la tabla.
|
||||
{:else if installTarget?.platform && isDefaultInstallPath(targetInstallPath, installTarget.platform)}
|
||||
Ruta por omisión: este servidor no tiene ninguna registrada. Si el agente
|
||||
vive en otra carpeta, captúrala primero con el lápiz de la tabla — instalar
|
||||
aquí crearía una segunda instalación.
|
||||
{:else}
|
||||
Sin ruta registrada para este servidor.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="autostart" class="block text-sm font-medium text-gray-700">
|
||||
Arranque automático
|
||||
|
||||
Reference in New Issue
Block a user