Compare commits

...

3 Commits

Author SHA1 Message Date
9b05361a00 refactor(cras-install): una sola comparacion de rutas de Windows, y hacer visible el destino
La pregunta "son la misma ruta?" estaba resuelta tres veces con tres criterios:
normalizeWindowsPath recortaba comillas multiples, probeWindowsAgentProcess una
sola, y cras-verify ninguna. Coincidian en los casos reales, pero este modulo ya
lleva escrito lo que cuesta esa duplicacion — probeLinuxElevation e inspectLinux
tuvieron copias paralelas y las dos pantallas acabaron diciendo cosas distintas
del mismo servidor. Ahora es sameWindowsPath() + windowsAgentExe(), usadas en los
tres sitios.

Lo que NO se hace, y queda documentado en el codigo: comparar por prefijo.
C:\Aduanasoft\CloudRestoreAS es prefijo de cadena de
C:\Aduanasoft\CloudRestoreAS-win, y esa combinacion existe en produccion.

Pruebas del par peligroso en las dos direcciones. Comprobado que MUERDEN:
sustituyendo la igualdad por startsWith, falla la comparacion a nivel de carpeta.
Las de rutas completas de .exe no fallan, y eso tambien es informacion — son
estructuralmente inmunes porque el caracter que difiere llega antes del final, asi
que el riesgo vive solo en la comparacion de directorios.

Y el formulario dice a que carpeta va a instalar. Antes la ruta solo aparecia
dentro del aviso de "instalar limpio", que se pinta unicamente si el panel ya
conoce la version instalada: en un servidor con ruta personalizada y version
desconocida el operador no la veia hasta que el run fallaba. Se distingue "ruta
registrada" de "por omision" — no si la capturo el agente o una persona, porque
ambas viven en la misma columna y el panel no puede saberlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:10:07 -06:00
3572aa974b fix(cras-install): realinear la tarea de Windows desde el panel, y decir por que fallo
La actualizacion ya fallaba ruidosamente en vez de mentir, pero el arreglo no
podia llegar al servidor: el install.ps1 que se ejecuta viaja DENTRO del zip, y
el artefacto 1.1.3 se construyo antes de que el instalador aprendiera a
realinear la tarea. Verificado sobre el zip: trae -UpdateInPlace y cero
Sync-AgentTaskPath. Un instalador ya publicado no se arregla hacia atras.

- alignWindowsTask realinea la accion de la tarea por SSH tras instalar el
  binario, conservando disparador, principal, ajustes y argumentos, y asienta la
  ruta ANTERIOR y la nueva para que el cambio sea reversible si la ruta declarada
  estuviera mal. Si no se puede corregir, aborta con 409 nombrando ambas: seguir
  significaria arrancar a sabiendas el binario viejo. Funciona con cualquier
  artefacto ya distribuido.
- restartWindowsAgent rearranca y espera a que corra el binario del prefijo, no
  cualquier proceso con ese nombre. Vive en cras-install y no en
  cras-agent-control porque este es el modulo de abajo; al reves habria un ciclo.

Y el diagnostico deja de ser una tarea para el operador. El error de sello
ausente decia "revisa a que binario apunta el arranque automatico" cuando el
panel YA lo sabe, y la bitacora del run donde si estaba no la encontraba nadie
("no se donde verlo"). Ahora se sondea tarea y proceso ANTES de evaluar el sello
y el mensaje nombra a que apunta la tarea, desde donde corre el proceso, y la
cola de CloudRestoreAS-crash.log — que runner.py escribe precisamente para esto
y que nunca se leia. El banner de error apunta al run por numero.

startOnWindows deja de esperar por nombre y usa la misma sonda de ruta, para que
Verificar no pueda contradecir al instalador.

PowerShell generado validado con PowerShell real: los seis scripts parsean, y el
de realineacion ejercitado contra una tarea simulada reapunta la accion Exec,
conserva los argumentos y respeta las acciones que no son Exec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:57:16 -06:00
9a4124d636 fix(cras-install): dejar de dar por buena una actualizacion que no surtio efecto
El run de Windows terminaba en verde sobre un servidor que seguia con la version
anterior. La verificacion no podia detectarlo:

- El sello config\.version ausente se toleraba SIEMPRE. Los agentes anteriores a
  1.1.1 no lo escribian, asi que al actualizar uno de esos `deployed` llegaba
  vacio y no se comprobaba ninguna version — justo la combinacion que importa.
  Ahora, en una ACTUALIZACION, un sello ausente es un fallo: el binario nuevo lo
  escribe en ensure_runtime_layout(), asi que su ausencia significa que lo que
  corre no es el que se instalo. La tolerancia se queda solo en instalacion nueva.
- `Get-Process -Name` responde "hay un proceso con ese nombre", no "corre el
  binario que instale". Se compara la RUTA del ejecutable; si no es legible no se
  concluye que sea ajeno, porque un proceso de SYSTEM no la expone sin elevacion.
- Se interroga la tarea programada a fondo: su accion dice DONDE arranca el
  agente de verdad y se asienta en la bitacora (el dato que habria explicado esto
  en diez segundos), y cuando el agente no reporta su ruta —nada anterior a 1.1.1
  lo hace— se usa la de la tarea en vez del default.

Y dos correcciones de lo anterior:

- needsElevation exigia admin en cuanto la tarea EXISTIA, bloqueando de entrada
  toda actualizacion sobre un servidor ya instalado. Ahora depende de que la
  tarea corra como SYSTEM, que es lo que de verdad obliga a elevar; y el token
  filtrado por UAC deja de rechazarse por adelantado: se intenta y se reporta lo
  que responda el servidor.
- -UpdateInPlace se pasaba siempre, pero solo existe desde 1.1.3 y install.ps1
  usa [CmdletBinding()], asi que con un artefacto anterior fallaba con
  NamedParameterNotFound sin ejecutar una linea. Se le pregunta a PowerShell por
  el param() del propio artefacto en vez de mantener una tabla de versiones.

Verificar comparte las dos sondas nuevas para que no pueda contradecir al
instalador, y gana un chequeo de "el arranque apunta a la instalacion".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:39 -06:00
5 changed files with 855 additions and 81 deletions

View File

@@ -16,6 +16,7 @@ import { getRestoreTargetSsh } from './controldesk-pg';
import { import {
execRemote, execRemote,
probeLinuxElevation, probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsElevation, probeWindowsElevation,
psEncoded, psEncoded,
shQuote, shQuote,
@@ -23,7 +24,11 @@ import {
} from './cras-install'; } from './cras-install';
import { probeRemoteSystem } from './cras-verify'; import { probeRemoteSystem } from './cras-verify';
import { listCrasTargetInventory } from './cras-releases'; 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 type { ApiErrorStatus } from './api-error';
import { logger } from './logger'; 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); const privileged = await probeWindowsElevation(sftp);
if (privileged.elevation !== 'admin') { if (privileged.elevation !== 'admin') {
throw new AgentControlError( 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 alive = await waitAlive(async () => {
const proc = await execRemote( ultima = await probeWindowsAgentProcess(sftp, prefix);
sftp, // `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM
psEncoded( // no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta.
"if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0);
'{"si"} else {"no"}'
)
);
return proc.stdout.trim() === 'si';
}); });
return alive if (alive) {
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' } return {
: { ok: true,
ok: false,
detail: detail:
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' + 'Tarea CloudRestoreAS arrancada y proceso en ejecución' +
'config\\logs en el servidor: el agente está fallando al iniciar.' (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. * 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 * Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el
* la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que * NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra
* nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón. * 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 inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === restoreTargetId); 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( async function startOnLinux(
@@ -203,11 +221,11 @@ export async function startCrasAgent(restoreTargetId: number): Promise<StartAgen
const system = await probeRemoteSystem(sftp); const system = await probeRemoteSystem(sftp);
if (system.verdict === 'windows') { if (system.verdict === 'windows') {
return await startOnWindows(sftp); return await startOnWindows(sftp, await resolvePrefix(restoreTargetId, 'windows'));
} }
if (system.verdict === 'linux') { if (system.verdict === 'linux') {
const privileged = await probeLinuxElevation(sftp, target.ssh_password); 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); return await startOnLinux(sftp, privileged, prefix);
} }
throw new AgentControlError( throw new AgentControlError(

View File

@@ -28,7 +28,12 @@ import {
execRemote, execRemote,
probeInPlaceUpdate, probeInPlaceUpdate,
probeLinuxElevation, probeLinuxElevation,
alignWindowsTask,
normalizeWindowsPath,
probeWindowsAgentProcess,
probeWindowsElevation, probeWindowsElevation,
probeWindowsTask,
sameWindowsPath,
psEncoded, psEncoded,
shQuote, shQuote,
verifyWindowsDeployment, 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. * 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 * Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada:
* 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 * 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE
* se asentaba como completada y nadie se enteraba hasta que alguien pulsaba Verificar. * 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', () => { describe('verifyWindowsDeployment', () => {
const RELEASE = { version: '1.1.2' } as never; const RELEASE = { version: '1.1.2' } as never;
const PREFIJO = 'C:\\CRAS';
/** Responde a cada script de PowerShell según lo que pregunte. */ /** 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) => { 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('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')) {
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 }; if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' }; return { code: 1, stdout: '' };
@@ -475,6 +699,95 @@ describe('verifyWindowsDeployment', () => {
verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS')
).rejects.toThrow(/reporta la versión 1\.1\.1/); ).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/);
});
}); });
/** /**

View File

@@ -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( async function installWindows(
sftp: SftpClient, sftp: SftpClient,
runId: number, runId: number,
@@ -1116,36 +1375,66 @@ async function installWindows(
const privileged = await probeWindowsElevation(sftp); const privileged = await probeWindowsElevation(sftp);
const autostart = request.autostart ?? 'service'; const autostart = request.autostart ?? 'service';
// La elevación hace falta para MANDAR sobre la tarea, que corre como SYSTEM: registrarla en // La tarea programada se interroga a fondo, no solo si existe. Su acción dice DÓNDE vive
// una instalación nueva, o pararla y arrancarla en una actualización. Se comprueba si existe // realmente el agente, y su principal dice si hace falta elevación para tocarla. Sin ese dato
// en lugar de deducirlo del modo, porque un servidor puede tener la tarea puesta aunque esta // se dio por buena una actualización que reemplazó el binario de una carpeta y arrancó el de
// corrida pida otro arranque, y ahí igual hay que poder detenerla. // otra: el run terminaba en verde y el servidor seguía con la versión anterior.
const taskProbe = await execRemote( const task = await probeWindowsTask(sftp);
sftp, await appendInstallStep(
psEncoded( runId,
"if (Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue) " + 'arranque-automatico-actual',
'{"si"} else {"no"}' 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( throw new InstallError(
409, 409,
`No se puede operar la tarea programada de ${target.name}: ${privileged.detail}` `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( await appendInstallStep(
runId, runId,
'precondiciones', 'precondiciones',
true, true,
`${systemEvidence}, privilegios=${privileged.label}` + `${systemEvidence}, privilegios=${privileged.label}`
(taskExists ? ', tarea CloudRestoreAS ya registrada' : '')
); );
if (request.mode === 'update') { 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. // 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 // Actualizar va por -UpdateInPlace, sea cual sea el arranque configurado: no vuelve a
// vuelve a registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), // registrar la tarea (así no pisa ajustes que el operador haya hecho sobre ella), se salta
// se salta el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y // el bootstrap —una segunda instancia purgaría el Temp\ de la que está viva— y trae respaldo
// trae respaldo con reversión automática si el binario nuevo no levanta. // con reversión automática si el binario nuevo no levanta.
const inPlaceUpdate = request.mode === 'update'; //
// 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 const flag = inPlaceUpdate
? ' -UpdateInPlace' ? ' -UpdateInPlace'
: autostart === 'none' : autostart === 'none'
@@ -1252,7 +1564,7 @@ async function installWindows(
sftp, sftp,
psEncoded( psEncoded(
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` + `& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` `-Prefix '${targetPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
), ),
INSTALL_EXEC_TIMEOUT_MS 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( await verifyWindowsDeployment(
sftp, runId, release, autostart, installPath, inPlaceUpdate sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
); );
} finally { } finally {
await cleanupStaging( await cleanupStaging(
@@ -1295,7 +1623,8 @@ export async function verifyWindowsDeployment(
release: CrasRelease, release: CrasRelease,
autostart: AutostartMode, autostart: AutostartMode,
prefix: string, prefix: string,
inPlaceUpdate = false inPlaceUpdate = false,
isUpdate = false
): Promise<void> { ): Promise<void> {
// El sello config\.version lo escribe el bootstrap del binario. // 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 // 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. // margen. Se sondea hasta que coincida, o hasta agotar los intentos.
let deployed = ''; 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) { for (let attempt = 0; attempt < stampAttempts; attempt += 1) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000)); if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2000));
const stamp = await execRemote( 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( await appendInstallStep(
runId, runId,
'verificar-version', '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 // 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 // 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. // pulsaba Verificar. install.ps1 ya garantiza el rearranque; esto lo confirma desde fuera.
const proc = await execRemote( const proc = await probeWindowsAgentProcess(sftp, prefix);
sftp, const running = proc.running;
psEncoded(
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + // Corriendo, sí, pero ¿el binario de ESTA instalación? Preguntar solo por el nombre respondía
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' // "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.'
); );
const running = proc.stdout.trim().startsWith('corriendo'); }
if (autostart !== 'service') { if (autostart !== 'service') {
// Sin tarea que consultar, el proceso es lo único que hay. En 'desktop' el agente arranca // 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, runId,
'verificar-proceso', 'verificar-proceso',
running || autostart === 'desktop', 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; return;
} }
@@ -1391,7 +1770,8 @@ export async function verifyWindowsDeployment(
runId, runId,
'verificar-servicio', 'verificar-servicio',
true, 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)')
); );
} }

View File

@@ -20,10 +20,14 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import { import {
execRemote, execRemote,
probeLinuxElevation, probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsTask,
psEncoded, psEncoded,
readUnitProps, readUnitProps,
sameWindowsPath,
shQuote, shQuote,
unitUserOrRoot unitUserOrRoot,
windowsAgentExe
} from './cras-install'; } from './cras-install';
import { listCrasTargetInventory } from './cras-releases'; import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version'; 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. // A qué ejecutable apunta el arranque automático. Es un chequeo aparte porque una tarea que
const proc = await execRemote( // apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el
sftp, // binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después.
psEncoded( const taskProbe = await probeWindowsTask(sftp);
"$p = Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue; " + const esperado = windowsAgentExe(prefix);
'if ($p) { "corriendo:" + $p.Count } else { "detenido" }' // 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.
CHECK_TIMEOUT_MS const taskAlineada = !taskProbe.execute || sameWindowsPath(taskProbe.execute, esperado);
); if (taskProbe.exists) {
const procOut = proc.stdout.trim();
const running = procOut.startsWith('corriendo');
checks.push( checks.push(
check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido') 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',
!proc.running ? 'warn' : proc.paths.length && !proc.fromPrefix ? 'fail' : 'ok',
detalleProc
)
); );
if (!installed) { if (!installed) {

View File

@@ -5,7 +5,7 @@
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import AppShell from '$lib/components/AppShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import { effectiveInstallPath, platformLabel } from '$lib/cras-version'; import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version';
import { import {
INSTALL_STEP_LABEL, INSTALL_STEP_LABEL,
currentPhaseLabel, currentPhaseLabel,
@@ -463,7 +463,18 @@
{#if showFormBanner && form?.error} {#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"> <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> <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 <button
type="button" type="button"
aria-label="Cerrar aviso" aria-label="Cerrar aviso"
@@ -1335,6 +1346,29 @@
{/if} {/if}
</div> </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> <div>
<label for="autostart" class="block text-sm font-medium text-gray-700"> <label for="autostart" class="block text-sm font-medium text-gray-700">
Arranque automático Arranque automático