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>
This commit is contained in:
2026-07-31 10:28:39 -06:00
parent 2bbbeff3ed
commit 9a4124d636
3 changed files with 353 additions and 53 deletions

View File

@@ -28,7 +28,9 @@ import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
psEncoded,
shQuote,
verifyWindowsDeployment,
@@ -419,25 +421,91 @@ 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.
*/
describe('sondas de ruta en Windows', () => {
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
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);
});
});
/**
* 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;
}) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
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}` };
}
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' };
@@ -475,6 +543,37 @@ 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/);
});
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,96 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
}
}
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 = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
return {
running: true,
paths,
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === 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()
};
}
async function installWindows(
sftp: SftpClient,
runId: number,
@@ -1116,36 +1206,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 +1355,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 +1395,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
);
@@ -1275,7 +1418,7 @@ async function installWindows(
}
await verifyWindowsDeployment(
sftp, runId, release, autostart, installPath, inPlaceUpdate
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
);
} finally {
await cleanupStaging(
@@ -1295,7 +1438,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 +1450,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 +1477,24 @@ 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) {
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.`
);
}
await appendInstallStep(
runId,
'verificar-version',
@@ -1341,14 +1506,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 +1530,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 +1565,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)')
);
}

View File

@@ -20,6 +20,8 @@ import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import {
execRemote,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsTask,
psEncoded,
readUnitProps,
shQuote,
@@ -821,19 +823,43 @@ 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 = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
const taskAlineada =
!taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase();
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) {