fix/instalacion-desatendida (#23)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit

Reviewed-on: #23
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-31 15:00:05 +00:00
committed by acazares
parent c4de2f1438
commit 2bbbeff3ed
7 changed files with 1012 additions and 82 deletions

View File

@@ -0,0 +1,231 @@
/**
* 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,
probeWindowsElevation,
psEncoded,
shQuote,
type LinuxPrivilege
} from './cras-install';
import { probeRemoteSystem } from './cras-verify';
import { listCrasTargetInventory } from './cras-releases';
import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } 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): 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'}`
);
}
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';
});
return alive
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' }
: {
ok: false,
detail:
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
'config\\logs en el servidor: el agente está fallando al iniciar.'
};
}
/**
* 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.
*/
async function resolveLinuxPrefix(restoreTargetId: number): 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;
}
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);
}
if (system.verdict === 'linux') {
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
const prefix = await resolveLinuxPrefix(restoreTargetId);
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)
}
});
}
}
}