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>
250 lines
9.7 KiB
TypeScript
250 lines
9.7 KiB
TypeScript
/**
|
|
* Arrancar el agente de un servidor de restauración, desde el panel.
|
|
*
|
|
* Existe porque el diagnóstico sin acción no sirve de nada. La pantalla de Verificar sabía
|
|
* detectar "instalado pero detenido" y respondía con un comando de PowerShell y un botón de
|
|
* *Copiar*, dejando al operador la tarea de entrar por RDP o SSH al servidor y pegarlo. El panel
|
|
* ya tiene la sesión SSH, las credenciales y la elevación resueltas: pedirle eso al operador era
|
|
* gratuito para nosotros y caro para él.
|
|
*
|
|
* Solo arranca. No instala, no actualiza, no reescribe configuración y no detiene nada: si algo
|
|
* sale mal, el peor caso es un servidor que sigue exactamente como estaba.
|
|
*/
|
|
import SftpClient from 'ssh2-sftp-client';
|
|
|
|
import { getRestoreTargetSsh } from './controldesk-pg';
|
|
import {
|
|
execRemote,
|
|
probeLinuxElevation,
|
|
probeWindowsAgentProcess,
|
|
probeWindowsElevation,
|
|
psEncoded,
|
|
shQuote,
|
|
type LinuxPrivilege
|
|
} from './cras-install';
|
|
import { probeRemoteSystem } from './cras-verify';
|
|
import { listCrasTargetInventory } from './cras-releases';
|
|
import {
|
|
effectiveInstallPath,
|
|
DEFAULT_INSTALL_PATHS,
|
|
type CrasPlatform
|
|
} from '$lib/cras-version';
|
|
import type { ApiErrorStatus } from './api-error';
|
|
import { logger } from './logger';
|
|
|
|
const CONNECT_TIMEOUT_MS = 20_000;
|
|
/** Margen para que el agente aparezca en la tabla de procesos tras pedir el arranque. */
|
|
const ALIVE_TIMEOUT_MS = 45_000;
|
|
const POLL_INTERVAL_MS = 2_000;
|
|
|
|
export class AgentControlError extends Error {
|
|
constructor(
|
|
public status: ApiErrorStatus,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = 'AgentControlError';
|
|
}
|
|
}
|
|
|
|
export interface StartAgentOutcome {
|
|
ok: boolean;
|
|
/** Qué se hizo y con qué resultado, en una línea, para mostrar tal cual en la UI. */
|
|
detail: string;
|
|
}
|
|
|
|
/**
|
|
* Sondea hasta que el agente aparezca vivo, o hasta agotar el margen.
|
|
*
|
|
* Se espera de verdad en lugar de responder en cuanto el comando de arranque retorna: tanto
|
|
* `Start-ScheduledTask` como `systemctl start` vuelven enseguida, y el binario es un onefile de
|
|
* ~270 MB que tarda en desempacarse. Contestar "arrancado" ahí sería la misma mentira que
|
|
* cometía la verificación de la instalación.
|
|
*/
|
|
async function waitAlive(check: () => Promise<boolean>): Promise<boolean> {
|
|
const deadline = Date.now() + ALIVE_TIMEOUT_MS;
|
|
for (;;) {
|
|
if (await check()) return true;
|
|
if (Date.now() >= deadline) return false;
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
}
|
|
}
|
|
|
|
async function startOnWindows(sftp: SftpClient, prefix: string): Promise<StartAgentOutcome> {
|
|
const privileged = await probeWindowsElevation(sftp);
|
|
if (privileged.elevation !== 'admin') {
|
|
throw new AgentControlError(
|
|
409,
|
|
`No se puede arrancar la tarea programada: ${privileged.detail}`
|
|
);
|
|
}
|
|
|
|
const start = await execRemote(
|
|
sftp,
|
|
psEncoded(
|
|
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
|
'if (-not $t) { Write-Output "sin-tarea"; exit 0 }; ' +
|
|
"Start-ScheduledTask -TaskName 'CloudRestoreAS'; Write-Output 'arrancada'"
|
|
)
|
|
);
|
|
if (start.stdout.trim() === 'sin-tarea') {
|
|
throw new AgentControlError(
|
|
409,
|
|
'En el servidor no hay una tarea programada CloudRestoreAS que arrancar. Reinstala ' +
|
|
'desde el panel eligiendo el arranque de servicio.'
|
|
);
|
|
}
|
|
if (start.code !== 0) {
|
|
throw new AgentControlError(
|
|
502,
|
|
`Start-ScheduledTask falló: ${start.stderr || start.stdout || 'sin salida'}`
|
|
);
|
|
}
|
|
|
|
// 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 () => {
|
|
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);
|
|
});
|
|
|
|
if (alive) {
|
|
return {
|
|
ok: true,
|
|
detail:
|
|
'Tarea CloudRestoreAS arrancada y proceso en ejecución' +
|
|
(ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.')
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
detail: ultima.running
|
|
? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` +
|
|
'arranque automático apunta a otra instalación.'
|
|
: 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
|
|
`${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ruta donde vive el agente, para poder reconocer su proceso.
|
|
*
|
|
* 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 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, platform) ??
|
|
DEFAULT_INSTALL_PATHS[platform]
|
|
);
|
|
}
|
|
|
|
async function startOnLinux(
|
|
sftp: SftpClient,
|
|
privileged: LinuxPrivilege,
|
|
prefix: string
|
|
): Promise<StartAgentOutcome> {
|
|
const pgrep = `pgrep -f ${shQuote(`^${prefix}/CloudRestoreAS`)} >/dev/null && echo si || echo no`;
|
|
const isAlive = async () => (await execRemote(sftp, pgrep)).stdout.trim() === 'si';
|
|
|
|
// Unit de sistema primero: es la instalación recomendada. Si no hay elevación, `prefix` va
|
|
// vacío y systemctl fallará solo, sin efectos: ahí se pasa al unit de usuario.
|
|
let how = '';
|
|
const system = await execRemote(
|
|
sftp,
|
|
`${privileged.prefix}systemctl start cloudrestoreas`,
|
|
undefined,
|
|
privileged.stdin
|
|
);
|
|
if (system.code === 0) {
|
|
how = `systemctl start cloudrestoreas (elevación: ${privileged.label || 'ninguna'})`;
|
|
} else {
|
|
// Instalación sin privilegios: el unit vive en el bus del propio usuario. XDG_RUNTIME_DIR
|
|
// va explícito porque un `exec` de SSH no es una sesión de login y no siempre lo trae.
|
|
const user = await execRemote(
|
|
sftp,
|
|
'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user start cloudrestoreas'
|
|
);
|
|
if (user.code !== 0) {
|
|
throw new AgentControlError(
|
|
502,
|
|
'No se pudo arrancar el servicio ni como unit de sistema ni como unit de usuario. ' +
|
|
`Sistema: ${system.stderr || system.stdout || 'sin salida'}. ` +
|
|
`Usuario: ${user.stderr || user.stdout || 'sin salida'}.`
|
|
);
|
|
}
|
|
how = 'systemctl --user start cloudrestoreas';
|
|
}
|
|
|
|
const alive = await waitAlive(isAlive);
|
|
return alive
|
|
? { ok: true, detail: `${how}: el agente está en ejecución.` }
|
|
: {
|
|
ok: false,
|
|
detail:
|
|
`${how} no devolvió error, pero el proceso no apareció. Revisa ` +
|
|
'`journalctl -u cloudrestoreas -n 50` en el servidor.'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Arranca el agente en el destino indicado. Lanza `AgentControlError` con el estado HTTP que
|
|
* corresponde cuando el servidor no está en condiciones de que se le pida esto.
|
|
*/
|
|
export async function startCrasAgent(restoreTargetId: number): Promise<StartAgentOutcome> {
|
|
const target = await getRestoreTargetSsh(restoreTargetId);
|
|
if (!target) {
|
|
throw new AgentControlError(
|
|
409,
|
|
'El servidor no tiene credenciales SSH completas (host, usuario y contraseña).'
|
|
);
|
|
}
|
|
|
|
const sftp = new SftpClient(`cras-start-${restoreTargetId}`);
|
|
try {
|
|
await sftp.connect({
|
|
host: target.ssh_host,
|
|
port: target.ssh_port,
|
|
username: target.ssh_username,
|
|
password: target.ssh_password,
|
|
readyTimeout: CONNECT_TIMEOUT_MS
|
|
});
|
|
|
|
const system = await probeRemoteSystem(sftp);
|
|
if (system.verdict === 'windows') {
|
|
return await startOnWindows(sftp, await resolvePrefix(restoreTargetId, 'windows'));
|
|
}
|
|
if (system.verdict === 'linux') {
|
|
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
|
|
const prefix = await resolvePrefix(restoreTargetId, 'linux');
|
|
return await startOnLinux(sftp, privileged, prefix);
|
|
}
|
|
throw new AgentControlError(
|
|
409,
|
|
`No se pudo determinar el sistema del destino (${system.verdict}). Evidencia: ` +
|
|
`${system.evidence}`
|
|
);
|
|
} finally {
|
|
try {
|
|
await sftp.end();
|
|
} catch (err) {
|
|
logger.warn({
|
|
message: 'No se pudo cerrar la sesión SFTP de arranque del agente',
|
|
context: {
|
|
target: target.name,
|
|
error: err instanceof Error ? err.message : String(err)
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|