fix/windows-actualizacion-silenciosa #25

Merged
acazares merged 4 commits from fix/windows-actualizacion-silenciosa into development 2026-07-31 17:16:29 +00:00
5 changed files with 509 additions and 35 deletions

View File

@@ -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,
if (alive) {
return {
ok: true,
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.'
'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(

View File

@@ -28,9 +28,12 @@ import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
alignWindowsTask,
normalizeWindowsPath,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
sameWindowsPath,
psEncoded,
shQuote,
verifyWindowsDeployment,
@@ -428,11 +431,71 @@ describe('probeWindowsElevation', () => {
* 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({
@@ -475,6 +538,89 @@ describe('sondas de ruta en Windows', () => {
});
});
/**
* 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.
*
@@ -497,16 +643,26 @@ describe('verifyWindowsDeployment', () => {
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')) {
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: '' };
});
@@ -553,6 +709,64 @@ describe('verifyWindowsDeployment', () => {
).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 });

View File

@@ -1100,6 +1100,45 @@ 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. */
@@ -1139,11 +1178,11 @@ export async function probeWindowsAgentProcess(
}
const joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
const wanted = windowsAgentExe(prefix);
return {
running: true,
paths,
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted)
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
};
}
@@ -1190,6 +1229,136 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
};
}
/**
* 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,
@@ -1417,6 +1586,22 @@ 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, targetPath, inPlaceUpdate, request.mode === 'update'
);
@@ -1487,12 +1672,32 @@ export async function verifyWindowsDeployment(
// 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}. ` +
'Revisa a qué binario apunta el arranque automático del servidor y los logs en ' +
`${prefix}\\config\\logs.`
`${pistas.join('; ')}.` +
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
` Logs en ${prefix}\\config\\logs.`
);
}
await appendInstallStep(

View File

@@ -24,8 +24,10 @@ import {
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';
@@ -827,9 +829,10 @@ async function inspectWindows(
// 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 = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
const taskAlineada =
!taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase();
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(

View File

@@ -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