Un agente anterior a 1.1.0 no reporta su install_path, así que el panel caía al default de plataforma y ACTUALIZAR abortaba cuando la instalación vivía en otra carpeta (p. ej. C:\Aduanasoft\CloudRestoreAS-win). Era un huevo-y-gallina: la ruta se empieza a reportar en 1.1.x, que es justo lo que no se podía instalar. Se agrega la captura manual de la ruta por servidor. No hace falta esquema nuevo ni lógica de resolución nueva: el upsert del estado ya usa install_path = COALESCE(EXCLUDED.install_path, actual), así que el valor capturado sobrevive los reportes sin ruta del agente viejo, y resolveInstallPath y cras-verify ya leen esa misma columna. Cuando el servidor quede en 1.1.x su propio reporte lo sustituye por la ruta real. De paso, dos fallos de configuración que costaron el diagnóstico: - GITEA_TOKEN con los `<>` de la plantilla se veía como un 401 opaco de Gitea, idéntico al de un token revocado. Se valida la forma antes de llamar, el aviso sale al cargar la pantalla y 401 y 403 dejan de colapsar al mismo texto. Un rechazo ahora deja línea en los logs: no dejaba ninguna. - PANEL_PUBLIC_URL sin el puerto apuntaba a otro servicio. Esa URL se siembra en el config/.env del destino también al ACTUALIZAR, así que rompería un agente que ya reportaba: se avisa y se aborta antes de tocar el servidor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
594 lines
24 KiB
TypeScript
594 lines
24 KiB
TypeScript
/**
|
|
* Versiones de CloudRestoreAS: catálogo publicado en Gitea e instalación remota por servidor.
|
|
* Solo administradores.
|
|
*
|
|
* Los binarios se publican en el registro de paquetes genéricos de Gitea desde el build local
|
|
* (`packaging/scripts/publish-release.sh`). Aquí se sincroniza el catálogo, se elige qué
|
|
* versión está activa por plataforma, y se instala o actualiza cada servidor por SSH.
|
|
*
|
|
* No hay subida de archivos por el navegador a propósito: los artefactos pesan ~270 MB y su
|
|
* origen de verdad es Gitea, no una carga manual.
|
|
*/
|
|
import { redirect, fail } from '@sveltejs/kit';
|
|
import type { PageServerLoad, Actions } from './$types';
|
|
import { env } from '$env/dynamic/private';
|
|
import { verifyToken } from '$lib/server/auth';
|
|
import { getUserById } from '$lib/server/users';
|
|
import {
|
|
activateCrasRelease,
|
|
deactivateCrasRelease,
|
|
deleteCrasRelease,
|
|
getCrasReleaseById,
|
|
listCrasReleases,
|
|
listCrasTargetInventory,
|
|
listInstallRuns,
|
|
reapStaleInstallRuns
|
|
} from '$lib/server/cras-releases';
|
|
import {
|
|
cacheUsage,
|
|
ensureCached,
|
|
isCached,
|
|
pruneCache,
|
|
prunePlan,
|
|
removeCached,
|
|
ArtifactError,
|
|
type PrunePlan
|
|
} from '$lib/server/cras-artifacts';
|
|
import {
|
|
installPlatformVerdict,
|
|
isInsideWorkFolder,
|
|
isSafeInstallPath,
|
|
platformLabel
|
|
} from '$lib/cras-version';
|
|
import { setCloudRestoreInstallPath } from '$lib/server/controldesk-pg';
|
|
import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync';
|
|
import {
|
|
GiteaError,
|
|
giteaTokenProblem,
|
|
isGiteaConfigured,
|
|
packageLocation
|
|
} from '$lib/server/gitea-packages';
|
|
import { installCrasOnTarget, InstallError } from '$lib/server/cras-install';
|
|
import { logger } from '$lib/server/logger';
|
|
|
|
async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) {
|
|
const token = cookies.get('session_token');
|
|
if (!token) throw redirect(303, '/login');
|
|
|
|
const session = verifyToken(token);
|
|
if (!session) throw redirect(303, '/login');
|
|
|
|
const currentUser = await getUserById(session.userId);
|
|
if (!currentUser || !currentUser.es_admin) throw redirect(303, '/');
|
|
return currentUser;
|
|
}
|
|
|
|
/**
|
|
* URL base del panel que se siembra en el .env del agente. El agente vive en otro servidor,
|
|
* así que tiene que ser una URL alcanzable desde la red, no localhost.
|
|
*/
|
|
function panelApiUrl(): string {
|
|
return (env.PANEL_PUBLIC_URL || env.ORIGIN || '').trim();
|
|
}
|
|
|
|
function panelApiToken(): string {
|
|
return (env.CLOUDRESTORE_API_TOKEN || '').trim();
|
|
}
|
|
|
|
/**
|
|
* Aviso cuando PANEL_PUBLIC_URL apunta a un origen distinto del que sirve el panel.
|
|
*
|
|
* Esta URL se siembra en el config/.env del agente, y el instalador la reescribe en CADA
|
|
* actualización, no solo al instalar. Una URL equivocada no falla al capturarla: rompe el servidor
|
|
* la próxima vez que se actualice, y de golpe —el agente deja de reportar y de resolver rutas de
|
|
* restauración—. En producción le faltaba el puerto y el 443 de ese host lo sirve otro servicio,
|
|
* así que todo lo que pidiera el agente habría caído en un 404 ajeno.
|
|
*
|
|
* Se compara contra ORIGIN porque el navegador ya lo validó: adapter-node rechaza los POST cuyo
|
|
* Origin no cuadre, así que si la pantalla responde, ORIGIN es el origen real del panel.
|
|
*/
|
|
function panelUrlOriginMismatch(): string | null {
|
|
const declared = (env.PANEL_PUBLIC_URL || '').trim();
|
|
const origin = (env.ORIGIN || '').trim();
|
|
if (!declared || !origin) return null;
|
|
|
|
let declaredUrl: URL;
|
|
let originUrl: URL;
|
|
try {
|
|
declaredUrl = new URL(declared);
|
|
originUrl = new URL(origin);
|
|
} catch {
|
|
return `PANEL_PUBLIC_URL no es una URL válida (${declared}): el agente no sabría a dónde reportar.`;
|
|
}
|
|
// `host` incluye el puerto, que es justo lo que se omitió en producción.
|
|
if (declaredUrl.host === originUrl.host) return null;
|
|
return (
|
|
`PANEL_PUBLIC_URL (${declaredUrl.origin}) no coincide con el origen del panel ` +
|
|
`(${originUrl.origin}). Es la URL que se siembra en el agente al instalar y al actualizar, ` +
|
|
'así que apuntaría a otro servicio. Corrige el puerto o comenta PANEL_PUBLIC_URL para que ' +
|
|
'tome ORIGIN.'
|
|
);
|
|
}
|
|
|
|
export const load: PageServerLoad = async ({ cookies }) => {
|
|
const currentUser = await requireAdmin(cookies);
|
|
let dbWarning: string | null = null;
|
|
|
|
// Un reinicio del panel deja runs colgados en 'running' que bloquearían nuevas
|
|
// instalaciones en ese destino; se cierran al entrar a la pantalla.
|
|
try {
|
|
const reaped = await reapStaleInstallRuns();
|
|
if (reaped > 0) {
|
|
logger.warn({
|
|
message: 'Instalaciones de CRAS marcadas como interrumpidas al cargar la pantalla',
|
|
context: { count: reaped }
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Error cerrando instalaciones interrumpidas:', e);
|
|
}
|
|
|
|
let releases: Awaited<ReturnType<typeof listCrasReleases>> = [];
|
|
try {
|
|
releases = await listCrasReleases();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error('Error listando cras_releases:', e);
|
|
dbWarning = `No se pudo cargar el catálogo de versiones: ${msg}`;
|
|
}
|
|
|
|
let inventory: Awaited<ReturnType<typeof listCrasTargetInventory>> = [];
|
|
try {
|
|
inventory = await listCrasTargetInventory();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error('Error armando el inventario de servidores:', e);
|
|
dbWarning = dbWarning ?? `No se pudo cargar el inventario de servidores: ${msg}`;
|
|
}
|
|
|
|
let runs: Awaited<ReturnType<typeof listInstallRuns>> = [];
|
|
try {
|
|
runs = await listInstallRuns(20);
|
|
} catch (e) {
|
|
console.error('Error leyendo la bitácora de instalaciones:', e);
|
|
}
|
|
|
|
// Qué versiones están en caché local: sin artefacto en disco la instalación primero
|
|
// tiene que bajar ~270 MB de Gitea, y conviene que se vea antes de empezar.
|
|
const cached: Record<number, boolean> = {};
|
|
for (const release of releases) {
|
|
try {
|
|
cached[release.id] = await isCached(release.version, release.file_name);
|
|
} catch {
|
|
cached[release.id] = false;
|
|
}
|
|
}
|
|
|
|
let usage: Awaited<ReturnType<typeof cacheUsage>> = { total_bytes: 0, versions: [] };
|
|
try {
|
|
usage = await cacheUsage();
|
|
} catch (e) {
|
|
console.error('Error midiendo la caché de artefactos:', e);
|
|
}
|
|
|
|
// Qué se borraría al podar, con el mismo criterio con que se ejecuta: el control de
|
|
// "Liberar" tiene que decir cuánto libera antes de que el operador confirme, y quedar
|
|
// apagado cuando no hay nada que liberar (con ≤3 versiones en caché no borra nada).
|
|
let prune: PrunePlan = { versions: [], bytes: 0 };
|
|
try {
|
|
prune = await prunePlan(releases.filter((r) => r.is_active).map((r) => r.version));
|
|
} catch (e) {
|
|
console.error('Error calculando el plan de poda de la caché:', e);
|
|
}
|
|
|
|
// Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar.
|
|
const configWarnings: string[] = [];
|
|
// Cubre el token ausente y el mal capturado. Antes solo se avisaba del ausente, así que un
|
|
// valor con los `<>` de la plantilla solo se manifestaba como un 401 al presionar Sincronizar.
|
|
const tokenProblem = giteaTokenProblem();
|
|
if (tokenProblem) {
|
|
configWarnings.push(`${tokenProblem}: no se puede sincronizar ni descargar versiones.`);
|
|
}
|
|
if (!panelApiToken()) {
|
|
configWarnings.push(
|
|
'CLOUDRESTORE_API_TOKEN no está configurado: no se puede sembrar la configuración del agente al instalar.'
|
|
);
|
|
}
|
|
if (!panelApiUrl()) {
|
|
configWarnings.push(
|
|
'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.'
|
|
);
|
|
}
|
|
const originMismatch = panelUrlOriginMismatch();
|
|
if (originMismatch) {
|
|
configWarnings.push(originMismatch);
|
|
}
|
|
|
|
return {
|
|
currentUser,
|
|
releases,
|
|
inventory,
|
|
runs,
|
|
cached,
|
|
usage,
|
|
prune,
|
|
dbWarning,
|
|
configWarnings,
|
|
giteaConfigured: isGiteaConfigured(),
|
|
packageLocation: packageLocation()
|
|
};
|
|
};
|
|
|
|
function parseId(data: FormData, field: string): number | null {
|
|
const raw = data.get(field)?.toString().trim();
|
|
const id = Number(raw);
|
|
return Number.isInteger(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
export const actions: Actions = {
|
|
/** Lee la API de paquetes de Gitea y registra las versiones que encuentre. */
|
|
sync: async ({ cookies }) => {
|
|
await requireAdmin(cookies);
|
|
if (!isGiteaConfigured()) {
|
|
return fail(503, { error: 'El panel no tiene configurado GITEA_TOKEN.' });
|
|
}
|
|
try {
|
|
const result = await syncCrasReleasesFromGitea();
|
|
const parts = [
|
|
`${result.versions_seen} versión(es) en Gitea`,
|
|
`${result.artifacts_registered} artefacto(s) registrados`,
|
|
`${result.artifacts_new} nuevo(s)`
|
|
];
|
|
return {
|
|
success: `Sincronización completa: ${parts.join(', ')}.`,
|
|
// Lo omitido se reporta en lugar de descartarse: un artefacto invisible se
|
|
// traduce en un servidor al que nunca se le ofrece la actualización.
|
|
skipped: result.skipped,
|
|
warnings: result.warnings
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof GiteaError ? e.message : e instanceof Error ? e.message : String(e);
|
|
return fail(502, { error: `No se pudo sincronizar con Gitea: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/** Marca una versión como la activa de su plataforma+arquitectura. */
|
|
activate: async ({ cookies, request }) => {
|
|
await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const id = parseId(data, 'id');
|
|
if (!id) return fail(400, { error: 'Versión inválida.' });
|
|
|
|
try {
|
|
const release = await activateCrasRelease(id);
|
|
if (!release) return fail(404, { error: 'La versión ya no existe.' });
|
|
return {
|
|
success: `${release.version} (${release.platform}/${release.arch}) quedó como versión activa.`
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo activar la versión: ${msg}` });
|
|
}
|
|
},
|
|
|
|
deactivate: async ({ cookies, request }) => {
|
|
await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const id = parseId(data, 'id');
|
|
if (!id) return fail(400, { error: 'Versión inválida.' });
|
|
try {
|
|
await deactivateCrasRelease(id);
|
|
return { success: 'Versión desactivada.' };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo desactivar: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/** Descarga el artefacto a la caché local y verifica su sha256. */
|
|
precache: async ({ cookies, request }) => {
|
|
await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const id = parseId(data, 'id');
|
|
if (!id) return fail(400, { error: 'Versión inválida.' });
|
|
|
|
const release = await getCrasReleaseById(id);
|
|
if (!release) return fail(404, { error: 'La versión ya no existe.' });
|
|
|
|
try {
|
|
const result = await ensureCached(
|
|
release.version,
|
|
release.file_name,
|
|
release.sha256,
|
|
release.file_size
|
|
);
|
|
return {
|
|
success: result.downloaded
|
|
? `${release.file_name} descargado y verificado (${formatBytes(result.size)}).`
|
|
: `${release.file_name} ya estaba en caché.`
|
|
};
|
|
} catch (e) {
|
|
const status = e instanceof ArtifactError ? e.status : 500;
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(status === 409 ? 409 : 502, { error: msg });
|
|
}
|
|
},
|
|
|
|
uncache: async ({ cookies, request }) => {
|
|
await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const id = parseId(data, 'id');
|
|
if (!id) return fail(400, { error: 'Versión inválida.' });
|
|
|
|
const release = await getCrasReleaseById(id);
|
|
if (!release) return fail(404, { error: 'La versión ya no existe.' });
|
|
try {
|
|
await removeCached(release.version, release.file_name);
|
|
return { success: `${release.file_name} eliminado de la caché local.` };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo liberar la caché: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/** Poda la caché conservando las versiones activas y las más recientes. */
|
|
prune: async ({ cookies }) => {
|
|
await requireAdmin(cookies);
|
|
try {
|
|
const releases = await listCrasReleases();
|
|
const pinned = releases.filter((r) => r.is_active).map((r) => r.version);
|
|
const removed = await pruneCache(pinned);
|
|
return {
|
|
success: removed.length
|
|
? `Se liberaron ${removed.length} versión(es) de la caché: ${removed.join(', ')}.`
|
|
: 'No había versiones para liberar.'
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo podar la caché: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Borra una versión del catálogo. No toca Gitea: los paquetes genéricos son inmutables
|
|
* y siguen siendo el respaldo, así que un sync posterior la vuelve a registrar.
|
|
*/
|
|
deleteRelease: async ({ cookies, request }) => {
|
|
await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const id = parseId(data, 'id');
|
|
if (!id) return fail(400, { error: 'Versión inválida.' });
|
|
|
|
const release = await getCrasReleaseById(id);
|
|
if (!release) return fail(404, { error: 'La versión ya no existe.' });
|
|
if (release.is_active) {
|
|
return fail(409, {
|
|
error: 'No se puede borrar la versión activa. Activa otra antes de quitarla.'
|
|
});
|
|
}
|
|
try {
|
|
await deleteCrasRelease(id);
|
|
return {
|
|
success: `${release.version} (${release.platform}) quitada del catálogo. Sigue publicada en Gitea.`
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo quitar del catálogo: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Fija a mano la ruta de instalación de un servidor.
|
|
*
|
|
* Un agente anterior a 1.1.0 no reporta su install_path, así que el panel cae al default de
|
|
* plataforma y ACTUALIZAR aborta cuando la instalación vive en otra carpeta. Sin esta captura
|
|
* ese servidor no se podría actualizar nunca: la ruta empieza a reportarse en 1.1.x, que es
|
|
* justo lo que no se puede instalar. En cuanto el servidor quede en 1.1.x su propio reporte
|
|
* sustituye este valor por la ruta real.
|
|
*/
|
|
setInstallPath: async ({ cookies, request }) => {
|
|
const currentUser = await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const targetId = parseId(data, 'target_id');
|
|
const rawPath = data.get('install_path')?.toString().trim() ?? '';
|
|
|
|
if (!targetId) return fail(400, { error: 'Servidor inválido.' });
|
|
if (!rawPath) return fail(400, { error: 'Captura la ruta de instalación.' });
|
|
|
|
let target: Awaited<ReturnType<typeof listCrasTargetInventory>>[number] | undefined;
|
|
try {
|
|
target = (await listCrasTargetInventory()).find(
|
|
(t) => t.restore_target_id === targetId
|
|
);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo leer el inventario de servidores: ${msg}` });
|
|
}
|
|
if (!target) return fail(404, { error: 'El servidor de restauración ya no existe.' });
|
|
|
|
// Sin plataforma no hay con qué validar: las reglas de ruta son distintas en Windows y
|
|
// en Linux, y aplicar las equivocadas dejaría pasar una ruta que el instalador no puede
|
|
// usar.
|
|
if (!target.platform) {
|
|
return fail(422, {
|
|
error:
|
|
`No se conoce la plataforma de ${target.name}, y sin ella no se puede validar ` +
|
|
'la ruta. Captura el campo SO en Servidores de Restauración.'
|
|
});
|
|
}
|
|
if (!isSafeInstallPath(rawPath, target.platform)) {
|
|
return fail(422, {
|
|
error:
|
|
`"${rawPath}" no es una ruta válida para ${platformLabel(target.platform)}. ` +
|
|
'Debe ser absoluta, sin comillas ni metacaracteres, sin ".." y sin rutas de red.'
|
|
});
|
|
}
|
|
|
|
// Se normaliza igual que effectiveInstallPath (cras-version.ts) para que el valor guardado
|
|
// sea comparable con el que reporte el agente después y no queden dos formas del mismo path.
|
|
const installPath = rawPath.replace(/[/\\]+$/, '');
|
|
|
|
// La raíz de una unidad pasa isSafeInstallPath pero no es un destino de instalación: ahí
|
|
// el instalador aplicaría ACLs y borrados recursivos sobre todo el disco. Solo se puede
|
|
// llegar por captura manual, porque el agente reporta la carpeta de su ejecutable.
|
|
if (!installPath || /^[A-Za-z]:$/.test(installPath)) {
|
|
return fail(422, {
|
|
error: 'La ruta no puede ser la raíz de la unidad; usa una carpeta.'
|
|
});
|
|
}
|
|
|
|
if (isInsideWorkFolder(installPath, [target.input_folder, target.processed_folder])) {
|
|
return fail(409, {
|
|
error:
|
|
`${installPath} está dentro de una carpeta de trabajo de ${target.name}. ` +
|
|
'Ahí el agente tomaría su propio binario por un respaldo a procesar.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const updated = await setCloudRestoreInstallPath(target.name, installPath);
|
|
if (!updated) {
|
|
return fail(409, {
|
|
error:
|
|
`${target.name} no tiene ningún reporte del agente todavía, así que no hay ` +
|
|
'instancia a la que fijarle la ruta. Espera a que reporte por primera vez.'
|
|
});
|
|
}
|
|
logger.info({
|
|
message: 'Ruta de instalación de CRAS fijada a mano',
|
|
context: {
|
|
target: target.name,
|
|
install_path: installPath,
|
|
previous: target.reported_install_path,
|
|
set_by: currentUser.username
|
|
}
|
|
});
|
|
return {
|
|
success:
|
|
`Ruta de ${target.name} fijada en ${installPath}. ` +
|
|
'Verifica antes de actualizar; cuando el servidor quede en 1.1.x el agente ' +
|
|
'reportará su ruta y este valor se corrige solo.'
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo guardar la ruta: ${msg}` });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos
|
|
* subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso.
|
|
*/
|
|
install: async ({ cookies, request }) => {
|
|
const currentUser = await requireAdmin(cookies);
|
|
const data = await request.formData();
|
|
const targetId = parseId(data, 'target_id');
|
|
const releaseId = parseId(data, 'release_id');
|
|
const modeRaw = data.get('mode')?.toString().trim();
|
|
const autostartRaw = data.get('autostart')?.toString().trim();
|
|
|
|
if (!targetId || !releaseId) {
|
|
return fail(400, { error: 'Servidor o versión inválidos.' });
|
|
}
|
|
const mode = modeRaw === 'update' ? 'update' : 'install';
|
|
const autostart =
|
|
autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service';
|
|
const platformAck = data.get('platform_ack')?.toString() ?? '';
|
|
|
|
// Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda
|
|
// que había —assertSystemMatches, dentro del instalador— ya corre con la sesión SSH
|
|
// abierta: el operador se llevaba un run fallido en la bitácora en lugar de un aviso.
|
|
const release = await getCrasReleaseById(releaseId);
|
|
if (!release) {
|
|
return fail(404, { error: 'La versión indicada no existe en el catálogo.' });
|
|
}
|
|
|
|
let target: Awaited<ReturnType<typeof listCrasTargetInventory>>[number] | undefined;
|
|
try {
|
|
target = (await listCrasTargetInventory()).find(
|
|
(t) => t.restore_target_id === targetId
|
|
);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(500, { error: `No se pudo validar el servidor destino: ${msg}` });
|
|
}
|
|
if (!target) {
|
|
return fail(404, { error: 'El servidor de restauración ya no existe.' });
|
|
}
|
|
if (target.running_install_id) {
|
|
return fail(409, {
|
|
error:
|
|
`Ya hay una instalación en curso en ${target.name} ` +
|
|
`(run #${target.running_install_id}). Espera a que termine antes de lanzar otra.`
|
|
});
|
|
}
|
|
|
|
const verdict = installPlatformVerdict(target.platform, release.platform, platformAck);
|
|
if (verdict === 'mismatch') {
|
|
return fail(409, {
|
|
error:
|
|
`${target.name} es ${platformLabel(target.platform)} y ${release.version} es ` +
|
|
`${platformLabel(release.platform)}. Elige el artefacto de la plataforma correcta.`
|
|
});
|
|
}
|
|
if (verdict === 'needs-ack') {
|
|
return fail(422, {
|
|
error:
|
|
`No se pudo determinar la plataforma de ${target.name}. Confirma en el ` +
|
|
'formulario que el artefacto corresponde al sistema del servidor, o captura ' +
|
|
'el campo SO en Servidores de Restauración.'
|
|
});
|
|
}
|
|
|
|
const apiUrl = panelApiUrl();
|
|
const apiToken = panelApiToken();
|
|
if (!apiUrl || !apiToken) {
|
|
return fail(500, {
|
|
error:
|
|
'Falta configurar PANEL_PUBLIC_URL y CLOUDRESTORE_API_TOKEN en el panel: ' +
|
|
'sin ellos el agente instalado no sabría a dónde reportar.'
|
|
});
|
|
}
|
|
// Se aborta antes de tocar el servidor: esta URL se escribe sobre el config/.env del
|
|
// destino también al ACTUALIZAR, así que dejarla pasar rompería un agente que hoy sí
|
|
// reporta. Es más barato corregir el .env del panel que reparar el del servidor.
|
|
const originMismatch = panelUrlOriginMismatch();
|
|
if (originMismatch) {
|
|
return fail(500, { error: originMismatch });
|
|
}
|
|
|
|
try {
|
|
const outcome = await installCrasOnTarget({
|
|
restoreTargetId: targetId,
|
|
releaseId,
|
|
mode,
|
|
startedBy: currentUser.username,
|
|
panelApiUrl: apiUrl,
|
|
panelApiToken: apiToken,
|
|
autostart
|
|
});
|
|
if (!outcome.ok) {
|
|
return fail(502, {
|
|
error: `La instalación de ${outcome.version} falló: ${outcome.error}`,
|
|
runId: outcome.runId
|
|
});
|
|
}
|
|
return {
|
|
success: `${outcome.version} instalada. El servidor reportará su versión en el próximo ciclo.`,
|
|
runId: outcome.runId
|
|
};
|
|
} catch (e) {
|
|
const status = e instanceof InstallError ? e.status : 500;
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return fail(status === 404 || status === 409 ? status : 500, { error: msg });
|
|
}
|
|
}
|
|
};
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
return `${bytes} B`;
|
|
}
|