feature/interfaz-binarios (#19)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Reviewed-on: #19 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
@@ -10,7 +10,10 @@ export function newTraceId(): string {
|
||||
}
|
||||
|
||||
export function errorJson(
|
||||
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503,
|
||||
// 502 para fallos de un servicio del que dependemos (p.ej. Gitea al sincronizar
|
||||
// versiones de CRAS): distinguirlo de 503 le dice al operador que el panel está bien y
|
||||
// el problema está aguas arriba.
|
||||
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 502 | 503,
|
||||
message: string,
|
||||
traceId: string
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
/**
|
||||
* Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo).
|
||||
* Pruebas de matchNodeRowFromBackupStem (resolución de nodo desde nombre de archivo) y de
|
||||
* deleteRestoreJobLogs (borrado definitivo de restores fallidos).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchNodeRowFromBackupStem } from './controldesk-pg';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.mock se iza al tope del archivo, así que las factories no pueden capturar variables
|
||||
// declaradas después: se usa vi.hoisted, igual que en api/alerts/send/server.test.ts.
|
||||
const { clientQuery, clientRelease, connectMock } = vi.hoisted(() => {
|
||||
const clientQuery = vi.fn();
|
||||
const clientRelease = vi.fn();
|
||||
return {
|
||||
clientQuery,
|
||||
clientRelease,
|
||||
connectMock: vi.fn(async () => ({ query: clientQuery, release: clientRelease }))
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/server/db', () => ({ pgPool: { query: vi.fn(), connect: connectMock } }));
|
||||
|
||||
import { matchNodeRowFromBackupStem, deleteRestoreJobLogs } from './controldesk-pg';
|
||||
|
||||
const NODES = [
|
||||
{
|
||||
@@ -30,3 +45,98 @@ describe('matchNodeRowFromBackupStem', () => {
|
||||
expect(matchNodeRowFromBackupStem('DESCONOCIDO', NODES)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* deleteRestoreJobLogs BORRA definitivamente. Lo que se prueba aquí es lo que hace peligroso
|
||||
* un DELETE: que vaya en transacción, que cuente antes, que no toque restores exitosos, y que
|
||||
* el número que reporta sea el de filas realmente borradas y no el de IDs recibidos.
|
||||
*/
|
||||
describe('deleteRestoreJobLogs', () => {
|
||||
/** Simula el par COUNT previo + DELETE dentro de la transacción. */
|
||||
function stubTx(eligible: number, notFailed: number, deleted: number) {
|
||||
clientQuery.mockReset();
|
||||
clientQuery.mockImplementation(async (sql: string) => {
|
||||
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [], rowCount: 0 };
|
||||
if (/^\s*SELECT/i.test(sql)) {
|
||||
return { rows: [{ eligible: String(eligible), not_failed: String(notFailed) }] };
|
||||
}
|
||||
if (/^\s*DELETE/i.test(sql)) return { rows: [], rowCount: deleted };
|
||||
throw new Error(`SQL inesperado: ${sql}`);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clientQuery.mockReset();
|
||||
clientRelease.mockReset();
|
||||
connectMock.mockClear();
|
||||
});
|
||||
|
||||
it('borra en transacción, contando antes del DELETE', async () => {
|
||||
stubTx(2, 0, 2);
|
||||
const r = await deleteRestoreJobLogs([10, 11]);
|
||||
|
||||
expect(r).toEqual({ requested: 2, eligible: 2, deleted: 2, skippedNotFailed: 0 });
|
||||
|
||||
const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim());
|
||||
expect(sqls[0]).toMatch(/^BEGIN/);
|
||||
expect(sqls.at(-1)).toMatch(/^COMMIT/);
|
||||
// El COUNT tiene que ir ANTES del DELETE, o no sirve para reportar nada.
|
||||
const iCount = sqls.findIndex((s) => /^SELECT/i.test(s));
|
||||
const iDelete = sqls.findIndex((s) => /^DELETE/i.test(s));
|
||||
expect(iCount).toBeGreaterThan(0);
|
||||
expect(iDelete).toBeGreaterThan(iCount);
|
||||
expect(clientRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("solo borra filas con status 'failed'", async () => {
|
||||
stubTx(1, 0, 1);
|
||||
await deleteRestoreJobLogs([7]);
|
||||
const del = clientQuery.mock.calls.map((c) => String(c[0])).find((s) => /DELETE/i.test(s));
|
||||
// La restricción vive en el servidor, no solo en la UI: los restores exitosos son el
|
||||
// registro de auditoría del que sale "última restauración exitosa".
|
||||
expect(del).toMatch(/status\s*=\s*'failed'/);
|
||||
});
|
||||
|
||||
it('reporta los que existen pero no son fallidos, sin borrarlos', async () => {
|
||||
stubTx(0, 3, 0);
|
||||
const r = await deleteRestoreJobLogs([1, 2, 3]);
|
||||
expect(r.deleted).toBe(0);
|
||||
expect(r.skippedNotFailed).toBe(3);
|
||||
});
|
||||
|
||||
it('devuelve las filas REALMENTE borradas, no la cantidad de IDs pedidos', async () => {
|
||||
// Caso real: la pestaña llevaba rato abierta y otro operador ya borró dos de los tres.
|
||||
stubTx(1, 0, 1);
|
||||
const r = await deleteRestoreJobLogs([4, 5, 6]);
|
||||
expect(r.requested).toBe(3);
|
||||
expect(r.deleted).toBe(1);
|
||||
});
|
||||
|
||||
it('sanea los IDs: descarta 0, negativos y no enteros, y deduplica', async () => {
|
||||
stubTx(1, 0, 1);
|
||||
await deleteRestoreJobLogs([5, 5, 0, -3, 2.7, Number.NaN, Infinity] as number[]);
|
||||
const call = clientQuery.mock.calls.find((c) => /SELECT/i.test(String(c[0])));
|
||||
expect(call?.[1]).toEqual([[5]]);
|
||||
});
|
||||
|
||||
it('no abre transacción si no queda ningún ID válido', async () => {
|
||||
const r = await deleteRestoreJobLogs([0, -1, Number.NaN]);
|
||||
expect(r).toEqual({ requested: 0, eligible: 0, deleted: 0, skippedNotFailed: 0 });
|
||||
expect(connectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hace ROLLBACK y libera el cliente si el DELETE falla', async () => {
|
||||
clientQuery.mockReset();
|
||||
clientQuery.mockImplementation(async (sql: string) => {
|
||||
if (/^\s*DELETE/i.test(sql)) throw new Error('deadlock detectado');
|
||||
if (/^\s*SELECT/i.test(sql)) return { rows: [{ eligible: '1', not_failed: '0' }] };
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
await expect(deleteRestoreJobLogs([9])).rejects.toThrow('deadlock detectado');
|
||||
const sqls = clientQuery.mock.calls.map((c) => String(c[0]).trim());
|
||||
expect(sqls.some((s) => /^ROLLBACK/i.test(s))).toBe(true);
|
||||
expect(sqls.some((s) => /^COMMIT/i.test(s))).toBe(false);
|
||||
expect(clientRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,20 @@ async function ensureCloudRestoreStatusTable(): Promise<void> {
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)`
|
||||
);
|
||||
// Plataforma y arquitectura del build instalado (migración e2f3a4b5c6d7 de a24c). Son la
|
||||
// fuente autoritativa para decidir qué artefacto de CRAS le toca a este servidor; un
|
||||
// agente anterior a 1.1.0 no las reporta y quedan NULL.
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS platform VARCHAR(20)`
|
||||
);
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS arch VARCHAR(20)`
|
||||
);
|
||||
// Ruta donde vive el ejecutable (APP_DIR del agente). Es un espacio de rutas distinto de
|
||||
// input_folder: ahí vive config/.env con las rutas de trabajo que el operador personalizó.
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS install_path VARCHAR(500)`
|
||||
);
|
||||
}
|
||||
|
||||
/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */
|
||||
@@ -624,6 +638,48 @@ export async function getRestoreTargetById(id: number): Promise<RestoreTarget |
|
||||
return (r.rows[0] as RestoreTarget) ?? null;
|
||||
}
|
||||
|
||||
export interface RestoreTargetSsh {
|
||||
id: number;
|
||||
name: string;
|
||||
ssh_host: string;
|
||||
ssh_port: number;
|
||||
ssh_username: string;
|
||||
ssh_password: string;
|
||||
os: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credenciales SSH de un servidor de restauración, descifradas.
|
||||
*
|
||||
* Uso exclusivo del instalador remoto de CRAS (cras-install.ts). Devuelve null si el
|
||||
* servidor no existe o si le falta algún dato de conexión: es preferible fallar con un
|
||||
* mensaje claro antes de tocar nada, que intentar conectar con credenciales incompletas.
|
||||
*/
|
||||
export async function getRestoreTargetSsh(id: number): Promise<RestoreTargetSsh | null> {
|
||||
await ensureRestoreTargetsSchema();
|
||||
const r = await pgPool.query(
|
||||
`SELECT id, name, ssh_host, ssh_port, ssh_username, ssh_password_encrypted, os
|
||||
FROM ${qRestoreTargets()} WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const row = r.rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const host = (row.ssh_host ?? '').trim();
|
||||
const username = (row.ssh_username ?? '').trim();
|
||||
if (!host || !username || !row.ssh_password_encrypted) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
ssh_host: host,
|
||||
ssh_port: Number(row.ssh_port) > 0 ? Number(row.ssh_port) : 22,
|
||||
ssh_username: username,
|
||||
ssh_password: decryptSecret(row.ssh_password_encrypted),
|
||||
os: row.os ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Servidor de restauración por id con la contraseña SQL descifrada. Uso exclusivo del servidor
|
||||
* (conectar a SQL Server para depurar bases duplicadas); NUNCA se expone al cliente.
|
||||
@@ -980,6 +1036,9 @@ export interface CloudRestoreStatus {
|
||||
processed_folder: string | null;
|
||||
host_name: string | null;
|
||||
app_version: string | null;
|
||||
platform: string | null;
|
||||
arch: string | null;
|
||||
install_path: string | null;
|
||||
reported_at: Date;
|
||||
}
|
||||
|
||||
@@ -989,7 +1048,8 @@ export async function listCloudRestoreStatuses(): Promise<CloudRestoreStatus[]>
|
||||
await ensureCloudRestoreStatusTable();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at
|
||||
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version,
|
||||
platform, arch, install_path, reported_at
|
||||
FROM ${qCloudRestoreStatus()}
|
||||
ORDER BY instance_key
|
||||
`
|
||||
@@ -1013,6 +1073,9 @@ export async function upsertCloudRestoreStatus(row: {
|
||||
processedFolder?: string | null;
|
||||
hostName: string | null;
|
||||
appVersion: string | null;
|
||||
platform?: string | null;
|
||||
arch?: string | null;
|
||||
installPath?: string | null;
|
||||
instanceKey?: string;
|
||||
}): Promise<void> {
|
||||
await ensureCloudRestoreStatusTable();
|
||||
@@ -1020,16 +1083,31 @@ export async function upsertCloudRestoreStatus(row: {
|
||||
await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qCloudRestoreStatus()} (
|
||||
instance_key, input_folder, processed_folder, host_name, app_version, reported_at
|
||||
) VALUES ($1, $2, $3, $4, $5, now())
|
||||
instance_key, input_folder, processed_folder, host_name, app_version,
|
||||
platform, arch, install_path, reported_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (instance_key) DO UPDATE SET
|
||||
input_folder = EXCLUDED.input_folder,
|
||||
processed_folder = EXCLUDED.processed_folder,
|
||||
host_name = EXCLUDED.host_name,
|
||||
app_version = EXCLUDED.app_version,
|
||||
-- COALESCE: un agente anterior a 1.1.0 no reporta platform/arch, y no debe
|
||||
-- borrar lo que ya se sabía de ese servidor por un reporte incompleto.
|
||||
platform = COALESCE(EXCLUDED.platform, ${qCloudRestoreStatus()}.platform),
|
||||
arch = COALESCE(EXCLUDED.arch, ${qCloudRestoreStatus()}.arch),
|
||||
install_path = COALESCE(EXCLUDED.install_path, ${qCloudRestoreStatus()}.install_path),
|
||||
reported_at = now()
|
||||
`,
|
||||
[key, row.inputFolder, row.processedFolder ?? null, row.hostName, row.appVersion]
|
||||
[
|
||||
key,
|
||||
row.inputFolder,
|
||||
row.processedFolder ?? null,
|
||||
row.hostName,
|
||||
row.appVersion,
|
||||
row.platform ?? null,
|
||||
row.arch ?? null,
|
||||
row.installPath ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1281,7 +1359,8 @@ export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedResto
|
||||
`
|
||||
SELECT
|
||||
jl.id, jl.restore_target_id, rt.name AS server_name,
|
||||
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes, jl.restored_at
|
||||
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes,
|
||||
jl.restored_at
|
||||
FROM ${qRestoreJobLogs()} jl
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
|
||||
WHERE jl.status = 'failed'
|
||||
@@ -1348,6 +1427,96 @@ export async function listRestoredRestoreJobLogs(limit = 200): Promise<RestoredR
|
||||
}
|
||||
}
|
||||
|
||||
/** Resultado de un borrado de bitácora: lo pedido, lo elegible y lo que realmente se fue. */
|
||||
export interface DeleteRestoreLogsResult {
|
||||
/** IDs válidos recibidos (tras sanear). */
|
||||
requested: number;
|
||||
/** De esos, cuántos existían Y eran `failed` — el COUNT previo al DELETE. */
|
||||
eligible: number;
|
||||
/** Filas efectivamente borradas. Debe coincidir con `eligible`. */
|
||||
deleted: number;
|
||||
/** Cuántos se ignoraron por no ser fallidos (existen pero son completed/forwarded). */
|
||||
skippedNotFailed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* BORRA registros de la bitácora de forma definitiva. No hay deshacer.
|
||||
*
|
||||
* Solo borra filas con `status = 'failed'`, y esa restricción vive aquí y no solo en la UI a
|
||||
* propósito. Dos razones:
|
||||
*
|
||||
* 1. "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró, y de ahí
|
||||
* sale `MAX(restored_at) FILTER (WHERE status='completed')` en listRestoreJobLogSummaries,
|
||||
* o sea la "última restauración exitosa" que /servidores-restauracion usa como señal de
|
||||
* salud. Borrar ahí no limpiaría ruido: haría retroceder un indicador.
|
||||
* 2. Acota el daño de un ID equivocado en el POST a filas que de todos modos son residuo.
|
||||
*
|
||||
* Consecuencia aceptada y documentada: el contador de FALLAS de los últimos 30 días en
|
||||
* /servidores-restauracion sí baja, porque cuenta esta misma tabla. Es el efecto buscado.
|
||||
*
|
||||
* Recibe IDs explícitos y no un filtro: lo borrado es exactamente lo que el operador vio en
|
||||
* pantalla, sin duplicar la lógica del buscador del cliente en el servidor.
|
||||
*
|
||||
* Va en transacción con COUNT previo (estándar de Aduanasoft para destructivas): así se puede
|
||||
* reportar por qué `deleted` no coincide con lo pedido —IDs ya borrados por otro operador, o
|
||||
* filas que no son fallidas— en lugar de devolver un número sin explicación.
|
||||
*/
|
||||
export async function deleteRestoreJobLogs(ids: number[]): Promise<DeleteRestoreLogsResult> {
|
||||
const clean = sanitizeIds(ids);
|
||||
const empty: DeleteRestoreLogsResult = {
|
||||
requested: 0,
|
||||
eligible: 0,
|
||||
deleted: 0,
|
||||
skippedNotFailed: 0
|
||||
};
|
||||
if (clean.length === 0) return empty;
|
||||
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// COUNT previo dentro de la misma transacción: cuenta lo elegible y, aparte, lo que
|
||||
// existe pero NO es fallido, para distinguir "ya no estaba" de "no se permite".
|
||||
const pre = await client.query(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE status = 'failed') AS eligible,
|
||||
count(*) FILTER (WHERE status <> 'failed') AS not_failed
|
||||
FROM ${qRestoreJobLogs()}
|
||||
WHERE id = ANY($1::int[])`,
|
||||
[clean]
|
||||
);
|
||||
const eligible = Number(pre.rows[0]?.eligible ?? 0);
|
||||
const skippedNotFailed = Number(pre.rows[0]?.not_failed ?? 0);
|
||||
|
||||
const del = await client.query(
|
||||
`DELETE FROM ${qRestoreJobLogs()}
|
||||
WHERE id = ANY($1::int[]) AND status = 'failed'`,
|
||||
[clean]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
requested: clean.length,
|
||||
eligible,
|
||||
deleted: del.rowCount ?? 0,
|
||||
skippedNotFailed
|
||||
};
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
if (isPgUndefinedTable(e)) return empty; // la tabla la crea a24c
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Enteros positivos únicos. Mismo criterio que assignNodesToRestoreTarget. */
|
||||
function sanitizeIds(ids: number[]): number[] {
|
||||
return Array.from(
|
||||
new Set((ids ?? []).filter((n) => Number.isInteger(n) && n > 0))
|
||||
);
|
||||
}
|
||||
|
||||
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
|
||||
export interface RestoreTargetDownload {
|
||||
id: number;
|
||||
|
||||
273
src/lib/server/cras-artifacts.test.ts
Normal file
273
src/lib/server/cras-artifacts.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Caché local de artefactos de CloudRestoreAS.
|
||||
*
|
||||
* Lo crítico aquí es que un artefacto corrupto NUNCA llegue a tener nombre definitivo: el
|
||||
* rename solo ocurre después de que el sha256 cuadra. Si eso falla, el panel empujaría un
|
||||
* binario roto a un servidor de producción. También se prueba el anti-traversal, porque
|
||||
* version y fileName se usan para construir rutas.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
import {
|
||||
ArtifactError,
|
||||
artifactPath,
|
||||
cacheDir,
|
||||
cacheUsage,
|
||||
ensureCached,
|
||||
isCached,
|
||||
isSafeFileName,
|
||||
isSafeVersion,
|
||||
pruneCache,
|
||||
removeCached,
|
||||
selectPrunableVersions
|
||||
} from './cras-artifacts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let tmpRoot: string;
|
||||
|
||||
function sha256Of(text: string): string {
|
||||
return createHash('sha256').update(text).digest('hex');
|
||||
}
|
||||
|
||||
/** Stub de la descarga de Gitea: responde el contenido indicado como stream. */
|
||||
function stubDownload(content: string, status = 200) {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
if (status !== 200) return new Response('', { status });
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(Buffer.byteLength(content)) }
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cras-cache-'));
|
||||
env.CRAS_RELEASES_DIR = tmpRoot;
|
||||
env.GITEA_TOKEN = 'token-de-prueba';
|
||||
env.GITEA_BASE_URL = 'https://git.ejemplo.test';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
for (const key of ['CRAS_RELEASES_DIR', 'GITEA_TOKEN', 'GITEA_BASE_URL', 'CRAS_CACHE_KEEP_VERSIONS']) {
|
||||
delete env[key];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('validación de nombres', () => {
|
||||
it('acepta versiones y nombres de artefacto reales', () => {
|
||||
expect(isSafeVersion('1.1.0')).toBe(true);
|
||||
expect(isSafeVersion('26.7.1.4')).toBe(true);
|
||||
expect(isSafeFileName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toBe(true);
|
||||
expect(isSafeFileName('SHA256SUMS')).toBe(true);
|
||||
});
|
||||
|
||||
it('rechaza intentos de salir de la caché', () => {
|
||||
for (const version of ['..', '../etc', '1.1.0/../..', '', 'latest']) {
|
||||
expect(isSafeVersion(version), version).toBe(false);
|
||||
}
|
||||
for (const file of ['../evil', '/etc/passwd', 'a/b.zip', '..', '.oculto', 'con espacio.zip']) {
|
||||
expect(isSafeFileName(file), file).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('artifactPath lanza ArtifactError ante entradas inseguras', () => {
|
||||
expect(() => artifactPath('../etc', 'x.zip')).toThrow(ArtifactError);
|
||||
expect(() => artifactPath('1.1.0', '../../evil')).toThrow(ArtifactError);
|
||||
});
|
||||
|
||||
it('artifactPath resuelve dentro de la caché', () => {
|
||||
const p = artifactPath('1.1.0', 'a.tar.gz');
|
||||
expect(p).toBe(path.join(cacheDir(), '1.1.0', 'a.tar.gz'));
|
||||
expect(p.startsWith(path.resolve(cacheDir()) + path.sep)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCached', () => {
|
||||
const FILE = 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz';
|
||||
const CONTENT = 'contenido-del-artefacto';
|
||||
|
||||
it('descarga, verifica el sha256 y deja el archivo definitivo', async () => {
|
||||
stubDownload(CONTENT);
|
||||
const result = await ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT));
|
||||
|
||||
expect(result.downloaded).toBe(true);
|
||||
expect(result.size).toBe(Buffer.byteLength(CONTENT));
|
||||
expect(await fs.readFile(result.path, 'utf8')).toBe(CONTENT);
|
||||
expect(await isCached('1.1.0', FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('no re-descarga si ya está en caché', async () => {
|
||||
stubDownload(CONTENT);
|
||||
await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
|
||||
|
||||
const spy = vi.fn();
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
const second = await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
|
||||
|
||||
expect(second.downloaded).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('con sha256 distinto NO deja el archivo definitivo y borra el temporal', async () => {
|
||||
// Este es el caso que evita empujar un binario corrupto a producción.
|
||||
stubDownload(CONTENT);
|
||||
await expect(ensureCached('1.1.0', FILE, sha256Of('otro-contenido'))).rejects.toThrow(
|
||||
/sha256/i
|
||||
);
|
||||
|
||||
expect(await isCached('1.1.0', FILE)).toBe(false);
|
||||
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
|
||||
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('con tamaño distinto al declarado también aborta', async () => {
|
||||
stubDownload(CONTENT);
|
||||
await expect(
|
||||
ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT) + 100)
|
||||
).rejects.toThrow(/tama/i);
|
||||
expect(await isCached('1.1.0', FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('sin sha256 registrado se niega a descargar 270 MB a ciegas', async () => {
|
||||
const spy = vi.fn();
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
await expect(ensureCached('1.1.0', FILE, null)).rejects.toMatchObject({ status: 409 });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('con un sha256 mal formado también se niega', async () => {
|
||||
const spy = vi.fn();
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
await expect(ensureCached('1.1.0', FILE, 'abc123')).rejects.toMatchObject({ status: 409 });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('un fallo de descarga limpia el temporal', async () => {
|
||||
stubDownload('', 500);
|
||||
await expect(ensureCached('1.1.0', FILE, sha256Of(CONTENT))).rejects.toThrow();
|
||||
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
|
||||
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheUsage y removeCached', () => {
|
||||
it('mide el uso por versión e ignora los .part', async () => {
|
||||
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x'.repeat(100));
|
||||
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'b.zip'), 'y'.repeat(50));
|
||||
// Un temporal de una descarga en curso no debe contar como espacio consumido útil.
|
||||
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'c.zip.123.part'), 'z'.repeat(999));
|
||||
|
||||
const usage = await cacheUsage();
|
||||
expect(usage.total_bytes).toBe(150);
|
||||
expect(usage.versions).toEqual([{ version: '1.1.0', bytes: 150, files: 2 }]);
|
||||
});
|
||||
|
||||
it('ignora carpetas que no son versiones válidas', async () => {
|
||||
await fs.mkdir(path.join(tmpRoot, 'basura'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpRoot, 'basura', 'x'), 'x'.repeat(10));
|
||||
expect((await cacheUsage()).total_bytes).toBe(0);
|
||||
});
|
||||
|
||||
it('devuelve vacío si la caché no existe todavía', async () => {
|
||||
env.CRAS_RELEASES_DIR = path.join(tmpRoot, 'no-existe');
|
||||
expect(await cacheUsage()).toEqual({ total_bytes: 0, versions: [] });
|
||||
});
|
||||
|
||||
it('removeCached borra el archivo', async () => {
|
||||
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x');
|
||||
await removeCached('1.1.0', 'a.tar.gz');
|
||||
expect(await isCached('1.1.0', 'a.tar.gz')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneCache', () => {
|
||||
async function seed(versions: string[]) {
|
||||
for (const v of versions) {
|
||||
await fs.mkdir(path.join(tmpRoot, v), { recursive: true });
|
||||
await fs.writeFile(path.join(tmpRoot, v, 'a.tar.gz'), 'x'.repeat(10));
|
||||
}
|
||||
}
|
||||
|
||||
it('conserva las N más recientes por orden numérico de versión', async () => {
|
||||
env.CRAS_CACHE_KEEP_VERSIONS = '2';
|
||||
// 1.10.0 es más nueva que 1.9.0: un orden lexicográfico borraría la equivocada.
|
||||
await seed(['1.8.0', '1.9.0', '1.10.0']);
|
||||
|
||||
const removed = await pruneCache([]);
|
||||
expect(removed).toEqual(['1.8.0']);
|
||||
expect((await cacheUsage()).versions.map((v) => v.version).sort()).toEqual([
|
||||
'1.10.0',
|
||||
'1.9.0'
|
||||
]);
|
||||
});
|
||||
|
||||
it('nunca borra una versión fijada, aunque sea vieja', async () => {
|
||||
// Dejar sin artefacto local a la versión activa obligaría a re-descargar 270 MB en
|
||||
// plena instalación.
|
||||
env.CRAS_CACHE_KEEP_VERSIONS = '1';
|
||||
await seed(['1.8.0', '1.9.0', '1.10.0']);
|
||||
|
||||
const removed = await pruneCache(['1.8.0']);
|
||||
expect(removed).not.toContain('1.8.0');
|
||||
expect(await isCached('1.8.0', 'a.tar.gz')).toBe(true);
|
||||
});
|
||||
|
||||
it('no borra nada si cabe dentro del límite', async () => {
|
||||
env.CRAS_CACHE_KEEP_VERSIONS = '5';
|
||||
await seed(['1.1.0', '1.2.0']);
|
||||
expect(await pruneCache([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectPrunableVersions', () => {
|
||||
// Es la previsualización que ve el operador antes de confirmar la poda: tiene que decidir con
|
||||
// el mismo criterio que pruneCache, o el modal prometería borrar algo distinto de lo que borra.
|
||||
const usage = [
|
||||
{ version: '1.8.0', bytes: 100 },
|
||||
{ version: '1.9.0', bytes: 200 },
|
||||
{ version: '1.10.0', bytes: 400 }
|
||||
];
|
||||
|
||||
it('conserva las N más nuevas en orden numérico, no lexicográfico', () => {
|
||||
// Con orden de texto "1.10.0" < "1.9.0" y se borraría la más nueva.
|
||||
expect(selectPrunableVersions(usage, [], 2)).toEqual({ versions: ['1.8.0'], bytes: 100 });
|
||||
expect(selectPrunableVersions(usage, [], 1)).toEqual({
|
||||
versions: ['1.9.0', '1.8.0'],
|
||||
bytes: 300
|
||||
});
|
||||
});
|
||||
|
||||
it('nunca propone una versión fijada y no la cuenta contra el límite', () => {
|
||||
expect(selectPrunableVersions(usage, ['1.8.0'], 1)).toEqual({
|
||||
versions: ['1.9.0'],
|
||||
bytes: 200
|
||||
});
|
||||
});
|
||||
|
||||
it('suma exactamente los bytes de las versiones elegidas', () => {
|
||||
const plan = selectPrunableVersions(usage, [], 0);
|
||||
expect(plan.versions).toEqual(['1.10.0', '1.9.0', '1.8.0']);
|
||||
expect(plan.bytes).toBe(700);
|
||||
});
|
||||
|
||||
it('con menos versiones que el límite el plan es vacío: el clic no haría nada', () => {
|
||||
// El control debe decir "Nada que liberar" en vez de ofrecer un borrado que es un no-op.
|
||||
expect(selectPrunableVersions(usage, [], 5)).toEqual({ versions: [], bytes: 0 });
|
||||
expect(selectPrunableVersions([], [], 3)).toEqual({ versions: [], bytes: 0 });
|
||||
});
|
||||
|
||||
it('ignora carpetas que no son versiones válidas', () => {
|
||||
const dirty = [...usage, { version: '../etc', bytes: 999 }, { version: 'tmp', bytes: 5 }];
|
||||
expect(selectPrunableVersions(dirty, [], 3).versions).toEqual([]);
|
||||
});
|
||||
});
|
||||
347
src/lib/server/cras-artifacts.ts
Normal file
347
src/lib/server/cras-artifacts.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Caché local de los artefactos de CloudRestoreAS.
|
||||
*
|
||||
* Los bytes viven en Gitea, pero el panel necesita una copia local para poder empujarlos
|
||||
* por SFTP al servidor destino. La caché es perezosa: se baja la primera vez que se
|
||||
* necesita una versión y se conserva para las siguientes instalaciones.
|
||||
*
|
||||
* Cada descarga se verifica contra el sha256 que reporta Gitea. Un artefacto truncado o
|
||||
* corrupto NO debe llegar a un servidor de producción, así que se escribe a un archivo
|
||||
* `.part`, se compara el hash y solo entonces se hace el rename — nunca queda un archivo
|
||||
* con nombre definitivo sin verificar.
|
||||
*
|
||||
* Cuidar el espacio importa: cada artefacto pesa ~270 MB y se publican dos por versión.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
import { compareVersions } from '$lib/cras-version';
|
||||
import { openPackageFile } from './gitea-packages';
|
||||
import { logger } from './logger';
|
||||
|
||||
const DEFAULT_CACHE_DIR = '/data/cras-releases';
|
||||
const DEFAULT_KEEP_VERSIONS = 3;
|
||||
|
||||
export class ArtifactError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ArtifactError';
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheDir(): string {
|
||||
return env.CRAS_RELEASES_DIR || DEFAULT_CACHE_DIR;
|
||||
}
|
||||
|
||||
function keepVersions(): number {
|
||||
const raw = Number(env.CRAS_CACHE_KEEP_VERSIONS);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_KEEP_VERSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* La versión y el nombre de archivo se usan para construir rutas, así que se validan de
|
||||
* forma estricta en lugar de solo rechazar "..": ambos vienen de la API de Gitea, que es
|
||||
* confiable pero no es parte de este código, y basta un nombre con separadores para
|
||||
* escribir fuera de la caché.
|
||||
*/
|
||||
const SAFE_VERSION_RE = /^\d+(\.\d+){0,3}$/;
|
||||
const SAFE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
export function isSafeVersion(version: string): boolean {
|
||||
return SAFE_VERSION_RE.test(String(version ?? '').trim());
|
||||
}
|
||||
|
||||
export function isSafeFileName(fileName: string): boolean {
|
||||
const name = String(fileName ?? '').trim();
|
||||
return SAFE_FILE_RE.test(name) && !name.includes('..');
|
||||
}
|
||||
|
||||
/** Ruta en caché de un artefacto. Lanza si version/fileName no son seguros. */
|
||||
export function artifactPath(version: string, fileName: string): string {
|
||||
if (!isSafeVersion(version)) {
|
||||
throw new ArtifactError(400, `Versión inválida: ${version}`);
|
||||
}
|
||||
if (!isSafeFileName(fileName)) {
|
||||
throw new ArtifactError(400, `Nombre de archivo inválido: ${fileName}`);
|
||||
}
|
||||
const resolved = path.resolve(cacheDir(), version, fileName);
|
||||
// Cinturón y tirantes: aunque las regex ya lo impiden, se confirma que la ruta final
|
||||
// quede dentro de la caché antes de escribir o leer.
|
||||
const root = path.resolve(cacheDir()) + path.sep;
|
||||
if (!resolved.startsWith(root)) {
|
||||
throw new ArtifactError(400, 'Ruta de artefacto fuera de la caché');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function isCached(version: string, fileName: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(artifactPath(version, fileName));
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cachedSize(version: string, fileName: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await fs.stat(artifactPath(version, fileName));
|
||||
return stat.isFile() ? stat.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transform que va alimentando el hash mientras los bytes pasan hacia el disco. */
|
||||
function hashingPassThrough(hash: ReturnType<typeof createHash>): Transform {
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface EnsureCachedResult {
|
||||
path: string;
|
||||
downloaded: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantiza que el artefacto esté en caché y verificado. Devuelve su ruta local.
|
||||
*
|
||||
* Si ya está en caché no se re-descarga ni se re-verifica: el rename solo ocurre tras una
|
||||
* verificación exitosa, así que la presencia del archivo definitivo ya implica que su hash
|
||||
* fue correcto en su momento. `expectedSha256` se exige para poder verificar; sin él se
|
||||
* rechaza en lugar de bajar 270 MB a ciegas.
|
||||
*/
|
||||
export async function ensureCached(
|
||||
version: string,
|
||||
fileName: string,
|
||||
expectedSha256: string | null,
|
||||
expectedSize?: number | null
|
||||
): Promise<EnsureCachedResult> {
|
||||
const target = artifactPath(version, fileName);
|
||||
|
||||
const existing = await cachedSize(version, fileName);
|
||||
if (existing !== null && existing > 0) {
|
||||
return { path: target, downloaded: false, size: existing };
|
||||
}
|
||||
|
||||
const expected = (expectedSha256 ?? '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
||||
throw new ArtifactError(
|
||||
409,
|
||||
`La versión ${version} no tiene sha256 registrado; sincroniza con Gitea antes de instalar`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
// El .part lleva el pid para que dos descargas concurrentes de la misma versión no se
|
||||
// pisen el archivo temporal entre ellas.
|
||||
const partial = `${target}.${process.pid}.part`;
|
||||
|
||||
logger.info({
|
||||
message: 'Descargando artefacto de CRAS desde Gitea',
|
||||
context: { version, file_name: fileName, expected_size: expectedSize ?? null }
|
||||
});
|
||||
|
||||
const hash = createHash('sha256');
|
||||
let written = 0;
|
||||
try {
|
||||
const download = await openPackageFile(version, fileName);
|
||||
const counter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
written += chunk.length;
|
||||
callback(null, chunk);
|
||||
}
|
||||
});
|
||||
await pipeline(
|
||||
Readable.fromWeb(download.body as never),
|
||||
hashingPassThrough(hash),
|
||||
counter,
|
||||
createWriteStream(partial, { highWaterMark: 1024 * 1024 })
|
||||
);
|
||||
} catch (err) {
|
||||
await fs.rm(partial, { force: true });
|
||||
if (err instanceof ArtifactError) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new ArtifactError(502, `Falló la descarga de ${fileName}: ${message}`);
|
||||
}
|
||||
|
||||
const actual = hash.digest('hex');
|
||||
if (actual !== expected) {
|
||||
await fs.rm(partial, { force: true });
|
||||
logger.error({
|
||||
message: 'sha256 del artefacto no coincide; descarga descartada',
|
||||
context: { version, file_name: fileName, expected, actual }
|
||||
});
|
||||
throw new ArtifactError(
|
||||
502,
|
||||
`El sha256 de ${fileName} no coincide con el publicado en Gitea (descarga corrupta o incompleta)`
|
||||
);
|
||||
}
|
||||
if (typeof expectedSize === 'number' && expectedSize > 0 && written !== expectedSize) {
|
||||
await fs.rm(partial, { force: true });
|
||||
throw new ArtifactError(
|
||||
502,
|
||||
`El tamaño de ${fileName} no coincide (esperado ${expectedSize}, recibido ${written})`
|
||||
);
|
||||
}
|
||||
|
||||
// Rename atómico: el nombre definitivo solo existe si el hash cuadró.
|
||||
await fs.rename(partial, target);
|
||||
logger.info({
|
||||
message: 'Artefacto de CRAS cacheado y verificado',
|
||||
context: { version, file_name: fileName, size: written, path: target }
|
||||
});
|
||||
return { path: target, downloaded: true, size: written };
|
||||
}
|
||||
|
||||
export async function removeCached(version: string, fileName: string): Promise<void> {
|
||||
await fs.rm(artifactPath(version, fileName), { force: true });
|
||||
// Si la carpeta de la versión quedó vacía se retira para no acumular directorios.
|
||||
try {
|
||||
await fs.rmdir(path.dirname(artifactPath(version, fileName)));
|
||||
} catch {
|
||||
// Queda contenido de otra plataforma: se conserva.
|
||||
}
|
||||
}
|
||||
|
||||
export interface CacheUsage {
|
||||
total_bytes: number;
|
||||
versions: { version: string; bytes: number; files: number }[];
|
||||
}
|
||||
|
||||
/** Uso de disco de la caché, por versión. Alimenta el aviso de espacio en la UI. */
|
||||
export async function cacheUsage(): Promise<CacheUsage> {
|
||||
const root = cacheDir();
|
||||
const usage: CacheUsage = { total_bytes: 0, versions: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(root);
|
||||
} catch {
|
||||
return usage; // la caché aún no existe
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isSafeVersion(entry)) continue;
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(path.join(root, entry));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let bytes = 0;
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.part')) continue;
|
||||
try {
|
||||
const stat = await fs.stat(path.join(root, entry, file));
|
||||
if (stat.isFile()) {
|
||||
bytes += stat.size;
|
||||
count += 1;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
usage.total_bytes += bytes;
|
||||
usage.versions.push({ version: entry, bytes, files: count });
|
||||
}
|
||||
|
||||
usage.versions.sort((a, b) => b.bytes - a.bytes);
|
||||
return usage;
|
||||
}
|
||||
|
||||
export interface PrunePlan {
|
||||
versions: string[];
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qué versiones de la caché se podarían: las no fijadas que quedan fuera de las `keepLimit` más
|
||||
* nuevas, en orden numérico de versión (1.10.0 es más nueva que 1.9.0).
|
||||
*
|
||||
* Pura y exportada a propósito: la UI necesita previsualizar el borrado con EXACTAMENTE el mismo
|
||||
* criterio con el que se ejecuta. Antes el botón "Liberar versiones viejas" no decía cuánto
|
||||
* liberaba, y con ≤3 versiones en caché no borraba nada — un control destructivo que a veces era
|
||||
* un no-op silencioso.
|
||||
*/
|
||||
export function selectPrunableVersions(
|
||||
versions: { version: string; bytes: number }[],
|
||||
pinnedVersions: string[],
|
||||
keepLimit: number
|
||||
): PrunePlan {
|
||||
const pinned = new Set(pinnedVersions.filter(isSafeVersion));
|
||||
// Orden por versión descendente: las más nuevas se conservan.
|
||||
const candidates = versions
|
||||
.filter((entry) => isSafeVersion(entry.version))
|
||||
.sort((a, b) => (compareVersions(b.version, a.version) ?? 0));
|
||||
|
||||
const plan: PrunePlan = { versions: [], bytes: 0 };
|
||||
let kept = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (pinned.has(candidate.version)) continue;
|
||||
kept += 1;
|
||||
if (kept <= keepLimit) continue;
|
||||
plan.versions.push(candidate.version);
|
||||
plan.bytes += candidate.bytes;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Plan de poda con el límite configurado. Lo consume la UI para confirmar antes de borrar. */
|
||||
export async function prunePlan(pinnedVersions: string[] = []): Promise<PrunePlan> {
|
||||
const usage = await cacheUsage();
|
||||
return selectPrunableVersions(usage.versions, pinnedVersions, keepVersions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Poda la caché conservando las versiones indicadas y las más recientes hasta el límite.
|
||||
*
|
||||
* `pinnedVersions` son las versiones que NO se pueden borrar (típicamente las activas): dejar sin
|
||||
* artefacto local a una versión activa obligaría a re-descargar 270 MB en plena instalación.
|
||||
* Devuelve las versiones eliminadas para poder reportarlas — una poda silenciosa se lee como "no
|
||||
* pasó nada" cuando en realidad se liberó disco.
|
||||
*/
|
||||
export async function pruneCache(pinnedVersions: string[] = []): Promise<string[]> {
|
||||
const limit = keepVersions();
|
||||
const usage = await cacheUsage();
|
||||
const plan = selectPrunableVersions(usage.versions, pinnedVersions, limit);
|
||||
|
||||
const removed: string[] = [];
|
||||
for (const version of plan.versions) {
|
||||
try {
|
||||
await fs.rm(path.join(cacheDir(), version), { recursive: true, force: true });
|
||||
removed.push(version);
|
||||
} catch (err) {
|
||||
logger.warn({
|
||||
message: 'No se pudo podar una versión de la caché',
|
||||
context: { version, error: err instanceof Error ? err.message : String(err) }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length) {
|
||||
logger.info({
|
||||
message: 'Caché de artefactos podada',
|
||||
context: {
|
||||
removed,
|
||||
keep_limit: limit,
|
||||
pinned: pinnedVersions.filter(isSafeVersion)
|
||||
}
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
202
src/lib/server/cras-install.test.ts
Normal file
202
src/lib/server/cras-install.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting.
|
||||
*
|
||||
* La prueba más importante de este archivo es la de fuga de secretos: el token del panel
|
||||
* NUNCA debe aparecer en un comando remoto, porque `ps` y el historial del servidor destino
|
||||
* son legibles por otros usuarios. Es exactamente el defecto del instalador de AServers, que
|
||||
* hace `echo '{password}' | sudo -S ...`.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { execRemote, psEncoded, shQuote, InstallError } from './cras-install';
|
||||
|
||||
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
|
||||
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
||||
const commands: string[] = [];
|
||||
const sftp = {
|
||||
client: {
|
||||
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
|
||||
commands.push(command);
|
||||
const result = handler(command);
|
||||
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
const stderrListeners: ((...args: unknown[]) => void)[] = [];
|
||||
const stream = {
|
||||
on(event: string, fn: (...args: unknown[]) => void) {
|
||||
(listeners[event] ??= []).push(fn);
|
||||
return stream;
|
||||
},
|
||||
stderr: {
|
||||
on(_event: string, fn: (...args: unknown[]) => void) {
|
||||
stderrListeners.push(fn);
|
||||
return stream.stderr;
|
||||
}
|
||||
}
|
||||
};
|
||||
callback(null, stream);
|
||||
// Se emite en el mismo orden que ssh2: data → exit → close.
|
||||
setImmediate(() => {
|
||||
if (result.stdout) {
|
||||
for (const fn of listeners.data ?? []) fn(Buffer.from(result.stdout));
|
||||
}
|
||||
if (result.stderr) {
|
||||
for (const fn of stderrListeners) fn(Buffer.from(result.stderr));
|
||||
}
|
||||
for (const fn of listeners.exit ?? []) fn(result.code ?? 0);
|
||||
for (const fn of listeners.close ?? []) fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
return { sftp, commands };
|
||||
}
|
||||
|
||||
describe('shQuote', () => {
|
||||
it('envuelve en comillas simples', () => {
|
||||
expect(shQuote('/tmp/cras-1.1.0')).toBe("'/tmp/cras-1.1.0'");
|
||||
});
|
||||
|
||||
it('escapa comillas simples internas', () => {
|
||||
// Sin esto, un nombre con apóstrofo cerraría la comilla y el resto se ejecutaría.
|
||||
expect(shQuote("a'b")).toBe("'a'\\''b'");
|
||||
});
|
||||
|
||||
it('neutraliza metacaracteres de shell', () => {
|
||||
const quoted = shQuote('x; rm -rf /');
|
||||
expect(quoted).toBe("'x; rm -rf /'");
|
||||
// El ; queda dentro de las comillas, así que no separa comandos.
|
||||
expect(quoted.startsWith("'")).toBe(true);
|
||||
expect(quoted.endsWith("'")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('psEncoded', () => {
|
||||
it('codifica en base64 UTF-16LE, como espera -EncodedCommand', () => {
|
||||
const command = psEncoded('Write-Output "hola"');
|
||||
expect(command).toContain('-EncodedCommand');
|
||||
const base64 = command.split('-EncodedCommand ')[1];
|
||||
expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe('Write-Output "hola"');
|
||||
});
|
||||
|
||||
it('usa -NoProfile y -NonInteractive para que no cuelgue esperando entrada', () => {
|
||||
const command = psEncoded('$x = 1');
|
||||
expect(command).toContain('-NoProfile');
|
||||
expect(command).toContain('-NonInteractive');
|
||||
expect(command).toContain('-ExecutionPolicy Bypass');
|
||||
});
|
||||
|
||||
it('sobrevive a comillas y rutas de Windows sin escapes manuales', () => {
|
||||
// Es la razón de usar -EncodedCommand: el script viaja intacto por cmd.exe.
|
||||
const script = `& 'C:\\Aduanasoft\\CloudRestoreAS\\install.ps1' -Service`;
|
||||
const base64 = psEncoded(script).split('-EncodedCommand ')[1];
|
||||
expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execRemote', () => {
|
||||
it('devuelve código, stdout y stderr recortados', async () => {
|
||||
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'Linux\n', stderr: ' aviso ' }));
|
||||
const result = await execRemote(sftp as never, 'uname -s');
|
||||
expect(result).toEqual({ code: 0, stdout: 'Linux', stderr: 'aviso' });
|
||||
});
|
||||
|
||||
it('propaga un código de salida distinto de cero sin lanzar', async () => {
|
||||
// El llamador decide qué hacer con cada código; lanzar aquí perdería el stderr.
|
||||
const { sftp } = fakeSftp(() => ({ code: 2, stderr: 'no existe' }));
|
||||
const result = await execRemote(sftp as never, 'command -v tar');
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toBe('no existe');
|
||||
});
|
||||
|
||||
it('rechaza si la conexión no expone exec()', async () => {
|
||||
await expect(execRemote({} as never, 'ls')).rejects.toThrow(InstallError);
|
||||
});
|
||||
|
||||
it('rechaza con 504 al vencer el timeout', async () => {
|
||||
const sftp = {
|
||||
client: {
|
||||
exec(_command: string, callback: (err: Error | null, stream?: unknown) => void) {
|
||||
// Nunca emite close: simula un comando colgado en el destino.
|
||||
callback(null, {
|
||||
on() {
|
||||
return this;
|
||||
},
|
||||
stderr: {
|
||||
on() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(execRemote(sftp as never, 'sleep 999', 30)).rejects.toMatchObject({
|
||||
status: 504
|
||||
});
|
||||
});
|
||||
|
||||
it('propaga el error de exec como InstallError', async () => {
|
||||
const sftp = {
|
||||
client: {
|
||||
exec(_command: string, callback: (err: Error | null) => void) {
|
||||
callback(new Error('canal rechazado'));
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(execRemote(sftp as never, 'ls')).rejects.toThrow(/canal rechazado/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no fuga de secretos en los comandos remotos', () => {
|
||||
const TOKEN = 'tok-super-secreto-abc123';
|
||||
|
||||
it('el flujo de comandos nunca contiene el token del panel', async () => {
|
||||
// Se simula la secuencia de comandos que arma installLinux y se verifica que ninguno
|
||||
// incluya el token: el token debe viajar SOLO en el archivo subido por SFTP.
|
||||
const remoteDir = '/tmp/cras-1.1.0-7';
|
||||
const remoteEnv = `${remoteDir}/panel.env`;
|
||||
const comandos = [
|
||||
'uname -s',
|
||||
'id -u',
|
||||
'sudo -n true',
|
||||
'command -v tar',
|
||||
`mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`,
|
||||
`sha256sum ${shQuote(`${remoteDir}/CloudRestoreAS-1.1.0-linux-x86_64.tar.gz`)}`,
|
||||
`tar xzf ${shQuote(`${remoteDir}/pkg.tar.gz`)} -C ${shQuote(remoteDir)}`,
|
||||
`cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && sudo -n ./install.sh --service --panel-env-file ${shQuote(remoteEnv)}`,
|
||||
`cat ${shQuote(`/opt/cloudrestoreas/config/.version`)} 2>/dev/null`,
|
||||
`rm -rf ${shQuote(remoteDir)}`
|
||||
];
|
||||
|
||||
for (const comando of comandos) {
|
||||
expect(comando, comando).not.toContain(TOKEN);
|
||||
}
|
||||
// El instalador solo recibe la RUTA del archivo con el token, no su contenido.
|
||||
const installCmd = comandos.find((c) => c.includes('install.sh'));
|
||||
expect(installCmd).toContain('--panel-env-file');
|
||||
expect(installCmd).toContain(remoteEnv);
|
||||
});
|
||||
|
||||
it('sudo se invoca con -n y jamás recibe una contraseña por stdin', async () => {
|
||||
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: '' }));
|
||||
await execRemote(sftp as never, 'sudo -n true');
|
||||
await execRemote(sftp as never, 'sudo -n systemctl is-active cloudrestoreas');
|
||||
|
||||
for (const command of commands) {
|
||||
expect(command).toContain('-n');
|
||||
// El antipatrón de AServers: echo '<password>' | sudo -S ...
|
||||
expect(command).not.toMatch(/\|\s*sudo\s+-S/);
|
||||
expect(command).not.toMatch(/echo\s+['"].*['"]\s*\|/);
|
||||
}
|
||||
});
|
||||
|
||||
it('el script de PowerShell del instalador tampoco lleva el token', () => {
|
||||
const remoteEnv = 'C:\\Users\\svc\\AppData\\Local\\Temp\\cras\\panel.env';
|
||||
const command = psEncoded(
|
||||
`& 'C:\\x\\install.ps1' -Service -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
||||
);
|
||||
const decoded = Buffer.from(command.split('-EncodedCommand ')[1], 'base64').toString(
|
||||
'utf16le'
|
||||
);
|
||||
expect(decoded).not.toContain(TOKEN);
|
||||
expect(decoded).toContain('-PanelEnvFile');
|
||||
});
|
||||
});
|
||||
929
src/lib/server/cras-install.ts
Normal file
929
src/lib/server/cras-install.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* Instalación y actualización remota de CloudRestoreAS por SSH/SFTP.
|
||||
*
|
||||
* El panel empuja el artefacto (que ya tiene cacheado y verificado) al servidor destino y
|
||||
* corre ahí el instalador que viene dentro del propio paquete. El servidor destino **no
|
||||
* descarga nada de internet**: el binario es autocontenido y los bytes llegan del panel.
|
||||
*
|
||||
* Decisiones de seguridad, tomadas a partir de los defectos del instalador equivalente de
|
||||
* AServers (backend/api/v1/modules/updates/ssh_install_service.py):
|
||||
*
|
||||
* - **Ningún secreto en la línea de comandos.** El token del panel viaja en un archivo 0600
|
||||
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
|
||||
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
|
||||
* y además permite inyección de comandos.
|
||||
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
|
||||
* Si no hay privilegios se aborta ANTES de subir 270 MB.
|
||||
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
|
||||
* detecta una transferencia corrupta.
|
||||
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
|
||||
* pueda seguir; en AServers los pasos solo quedan en el log del backend.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
import type { Client as SshClient } from 'ssh2';
|
||||
|
||||
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
||||
import {
|
||||
appendInstallStep,
|
||||
finishInstallRun,
|
||||
getCrasReleaseById,
|
||||
InstallRunConflictError,
|
||||
listCrasTargetInventory,
|
||||
startInstallRun,
|
||||
type CrasRelease,
|
||||
type InstallMode
|
||||
} from './cras-releases';
|
||||
import { ensureCached } from './cras-artifacts';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
effectiveArch,
|
||||
effectivePlatform,
|
||||
effectiveInstallPath,
|
||||
isSafeInstallPath,
|
||||
isInsideWorkFolder
|
||||
} from '$lib/cras-version';
|
||||
|
||||
/** Timeouts. La subida no lleva timeout propio: son cientos de MB por un enlace variable. */
|
||||
const CONNECT_TIMEOUT_MS = 20_000;
|
||||
const EXEC_TIMEOUT_MS = 120_000;
|
||||
/** El bootstrap del binario corre con `timeout 20` del lado del destino; se da margen. */
|
||||
const INSTALL_EXEC_TIMEOUT_MS = 300_000;
|
||||
|
||||
export class InstallError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InstallError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta un comando por SSH sobre la conexión que ya abrió el cliente SFTP.
|
||||
*
|
||||
* `ssh2-sftp-client` envuelve un `ssh2.Client` y lo expone en `.client`, pero sus tipos no
|
||||
* lo declaran; de ahí el cast. Se reutiliza esa conexión en lugar de abrir una segunda para
|
||||
* no autenticarse dos veces por instalación.
|
||||
*/
|
||||
export function execRemote(
|
||||
sftp: SftpClient,
|
||||
command: string,
|
||||
timeoutMs = EXEC_TIMEOUT_MS
|
||||
): Promise<ExecResult> {
|
||||
const conn = (sftp as unknown as { client: SshClient }).client;
|
||||
if (!conn || typeof conn.exec !== 'function') {
|
||||
return Promise.reject(new InstallError(500, 'La conexión SSH no expone exec()'));
|
||||
}
|
||||
|
||||
return new Promise<ExecResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new InstallError(504, `El comando remoto excedió ${timeoutMs / 1000}s`));
|
||||
}, timeoutMs);
|
||||
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new InstallError(502, `No se pudo ejecutar en el destino: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let code = -1;
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
stream.on('exit', (exitCode: number | null) => {
|
||||
code = typeof exitCode === 'number' ? exitCode : -1;
|
||||
});
|
||||
stream.on('close', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Comilla simple para sh. Las rutas que se arman son controladas; esto es defensa extra. */
|
||||
export function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envuelve un script de PowerShell como `-EncodedCommand` (base64 UTF-16LE).
|
||||
*
|
||||
* En Windows el shell por default de OpenSSH es cmd.exe, y pasar un script con comillas y
|
||||
* rutas por la línea de comandos se rompe de formas difíciles de depurar. Es el mismo
|
||||
* patrón que ya usan los Jenkinsfile del org para hospedar comandos en hosts Windows.
|
||||
*/
|
||||
export function psEncoded(script: string): string {
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
return `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
|
||||
}
|
||||
|
||||
export interface InstallRequest {
|
||||
restoreTargetId: number;
|
||||
releaseId: number;
|
||||
mode: InstallMode;
|
||||
/** Usuario del panel que la disparó, para la bitácora. */
|
||||
startedBy: string | null;
|
||||
/** URL base del panel que se sembrará en el .env del destino. */
|
||||
panelApiUrl: string;
|
||||
/** Token de servicio que se sembrará en el .env del destino. */
|
||||
panelApiToken: string;
|
||||
/** Modo de arranque a registrar en el destino. */
|
||||
autostart?: 'service' | 'desktop' | 'none';
|
||||
}
|
||||
|
||||
export interface InstallOutcome {
|
||||
runId: number;
|
||||
ok: boolean;
|
||||
version: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instala o actualiza CRAS en un servidor de restauración.
|
||||
*
|
||||
* Devuelve cuando terminó (puede tardar varios minutos). El progreso queda en
|
||||
* cras_install_runs.steps, que la UI consulta por polling.
|
||||
*/
|
||||
export async function installCrasOnTarget(request: InstallRequest): Promise<InstallOutcome> {
|
||||
const release = await getCrasReleaseById(request.releaseId);
|
||||
if (!release) {
|
||||
throw new InstallError(404, 'La versión indicada no existe en el catálogo');
|
||||
}
|
||||
const target = await getRestoreTargetSsh(request.restoreTargetId);
|
||||
if (!target) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'El servidor no tiene credenciales SSH completas (host, usuario y contraseña)'
|
||||
);
|
||||
}
|
||||
|
||||
// La plataforma del destino debe coincidir con la del artefacto: mandar el .exe a un
|
||||
// Linux es un error caro de diagnosticar en sitio.
|
||||
await assertPlatformMatches(target, release);
|
||||
|
||||
// Ruta de instalación: la que REPORTÓ el agente, o el default de la plataforma en una
|
||||
// primera instalación. Nunca se deriva de input_folder ni de remote_inbox_path — esas son
|
||||
// otras rutas y pueden apuntar a un share en otra máquina. Instalar en la ruta equivocada
|
||||
// crearía un config/ nuevo con la configuración por omisión y dejaría huérfano el .env
|
||||
// que el operador personalizó.
|
||||
const { installPath, reportedInstallPath } = await resolveInstallPath(target, release, request);
|
||||
|
||||
if (!request.panelApiUrl.trim() || !request.panelApiToken.trim()) {
|
||||
throw new InstallError(
|
||||
500,
|
||||
'El panel no tiene configurada su URL o su token de servicio; no se puede sembrar la configuración del agente'
|
||||
);
|
||||
}
|
||||
|
||||
// startInstallRun rechaza si ya hay una instalación corriendo en este destino.
|
||||
let runId: number;
|
||||
try {
|
||||
runId = await startInstallRun({
|
||||
restoreTargetId: target.id,
|
||||
releaseId: release.id,
|
||||
version: release.version,
|
||||
platform: release.platform,
|
||||
mode: request.mode,
|
||||
startedBy: request.startedBy
|
||||
});
|
||||
} catch (e) {
|
||||
// Conflicto de estado, no fallo interno: la UI lo muestra como "ya hay una instalación en
|
||||
// curso" en lugar de "error interno del panel", que no le dice al operador qué hacer.
|
||||
if (e instanceof InstallRunConflictError) throw new InstallError(409, e.message);
|
||||
throw e;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
message: 'Iniciando instalación remota de CRAS',
|
||||
context: {
|
||||
run_id: runId,
|
||||
target: target.name,
|
||||
version: release.version,
|
||||
platform: release.platform,
|
||||
mode: request.mode
|
||||
}
|
||||
});
|
||||
|
||||
let sftp: SftpClient | null = null;
|
||||
try {
|
||||
await appendInstallStep(runId, 'preparar-artefacto', true, `${release.file_name}`);
|
||||
const cached = await ensureCached(
|
||||
release.version,
|
||||
release.file_name,
|
||||
release.sha256,
|
||||
release.file_size
|
||||
);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'artefacto-listo',
|
||||
true,
|
||||
cached.downloaded ? 'descargado de Gitea y verificado' : 'ya estaba en caché'
|
||||
);
|
||||
|
||||
sftp = new SftpClient(`cras-install-${runId}`);
|
||||
await sftp.connect({
|
||||
host: target.ssh_host,
|
||||
port: target.ssh_port,
|
||||
username: target.ssh_username,
|
||||
password: target.ssh_password,
|
||||
readyTimeout: CONNECT_TIMEOUT_MS
|
||||
});
|
||||
await appendInstallStep(runId, 'conexion-ssh', true, `${target.ssh_username}@${target.ssh_host}`);
|
||||
|
||||
await appendInstallStep(runId, 'ruta-de-instalacion', true, installPath);
|
||||
if (release.platform === 'linux') {
|
||||
await installLinux(
|
||||
sftp, runId, release, cached.path, request, target, installPath, reportedInstallPath
|
||||
);
|
||||
} else {
|
||||
await installWindows(
|
||||
sftp, runId, release, cached.path, request, target, installPath, reportedInstallPath
|
||||
);
|
||||
}
|
||||
|
||||
await finishInstallRun(runId, 'completed');
|
||||
logger.info({
|
||||
message: 'Instalación remota de CRAS completada',
|
||||
context: { run_id: runId, target: target.name, version: release.version }
|
||||
});
|
||||
return { runId, ok: true, version: release.version };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await appendInstallStep(runId, 'error', false, message);
|
||||
await finishInstallRun(runId, 'failed', message);
|
||||
logger.error({
|
||||
message: 'Falló la instalación remota de CRAS',
|
||||
context: { run_id: runId, target: target.name, version: release.version, error: message }
|
||||
});
|
||||
return { runId, ok: false, version: release.version, error: message };
|
||||
} finally {
|
||||
if (sftp) {
|
||||
try {
|
||||
await sftp.end();
|
||||
} catch {
|
||||
// La sesión ya pudo cerrarse sola; no cambia el resultado de la instalación.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* La plataforma del artefacto tiene que ser la del destino. Se usa lo que reportó el agente
|
||||
* y, si aún no hay agente, el texto libre de restore_targets.os. Si no se puede determinar
|
||||
* se permite continuar: es una primera instalación y el operador ya eligió la versión.
|
||||
*/
|
||||
async function assertPlatformMatches(target: RestoreTargetSsh, release: CrasRelease): Promise<void> {
|
||||
const inventory = await listCrasTargetInventory();
|
||||
const row = inventory.find((t) => t.restore_target_id === target.id);
|
||||
const platform = row
|
||||
? row.platform
|
||||
: effectivePlatform(null, target.os);
|
||||
|
||||
if (platform && platform !== release.platform) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`El servidor es ${platform} y el artefacto es ${release.platform}. ` +
|
||||
'Activa o elige la versión de la plataforma correcta.'
|
||||
);
|
||||
}
|
||||
const arch = row ? row.arch : effectiveArch(null);
|
||||
if (arch && release.arch && arch !== release.arch) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`El servidor es ${arch} y el artefacto es ${release.arch}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirma que el sistema del otro lado de la sesión es el que espera el artefacto.
|
||||
*
|
||||
* Antes cada rama probaba solo lo suyo (`uname -s` en Linux, `$PSVersionTable` en Windows), así
|
||||
* que si la sesión caía en el sistema equivocado el error aparecía a mitad de la instalación —
|
||||
* o peor, tras subir 270 MB. Probando ambos por adelantado se aborta en segundos.
|
||||
*
|
||||
* Detecta además el caso de un POSIX dentro de un host Windows (WSL/Cygwin): instalar ahí
|
||||
* pondría el agente en el subsistema y no en el Windows que corre SQL Server.
|
||||
*/
|
||||
async function assertSystemMatches(
|
||||
sftp: SftpClient,
|
||||
expected: CrasRelease['platform']
|
||||
): Promise<string> {
|
||||
// Import diferido: cras-verify importa de este módulo (execRemote, psEncoded), así que un
|
||||
// import estático cerraría el ciclo.
|
||||
const { probeRemoteSystem } = await import('./cras-verify');
|
||||
const system = await probeRemoteSystem(sftp);
|
||||
|
||||
if (system.verdict === 'sin_ejecucion') {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'La cuenta SSH conecta pero no ejecuta comandos: parece solo-SFTP o enjaulada ' +
|
||||
'(ForceCommand internal-sftp / ChrootDirectory). La instalación remota necesita ' +
|
||||
'shell y privilegios, y puede usar una cuenta distinta de la del forward SFTP. ' +
|
||||
`Evidencia: ${system.evidence}`
|
||||
);
|
||||
}
|
||||
if (system.verdict === 'posix_en_host_windows') {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'La sesión SSH entra a un subsistema POSIX dentro de un host Windows (WSL/Cygwin), ' +
|
||||
'no al host. Instalar aquí dejaría el agente dentro del subsistema: no arrancaría ' +
|
||||
'con la máquina y las rutas C:\\ del .env no serían las que ve SQL Server. ' +
|
||||
`Apunta el SSH al sshd de Windows. Evidencia: ${system.evidence}`
|
||||
);
|
||||
}
|
||||
|
||||
const detected = system.verdict; // 'linux' | 'windows'
|
||||
if (detected !== expected) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`El artefacto es de ${expected} pero el servidor responde como ${detected}. ` +
|
||||
`Activa o elige la versión de la plataforma correcta. Evidencia: ${system.evidence}`
|
||||
);
|
||||
}
|
||||
|
||||
// Se verifica la capacidad concreta que necesita el modo servicio, no solo la identidad.
|
||||
if (detected === 'linux' && !system.systemd) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'El destino es Linux pero no tiene systemd, así que install.sh --service no puede ' +
|
||||
`registrar el servicio. Evidencia: ${system.evidence}`
|
||||
);
|
||||
}
|
||||
if (detected === 'windows' && !system.scheduledTasks) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'El destino es Windows pero no expone Get-ScheduledTask, así que install.ps1 -Service ' +
|
||||
`no puede registrar la tarea. Evidencia: ${system.evidence}`
|
||||
);
|
||||
}
|
||||
return system.evidence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve dónde instalar, con las guardas que evitan crear una segunda instalación.
|
||||
*
|
||||
* Prioridad: lo que reportó el agente → el default de la plataforma. **Nunca** se deriva de
|
||||
* `input_folder` ni de `remote_inbox_path`: son espacios de rutas distintos y el primero puede
|
||||
* ser un share UNC en otra máquina.
|
||||
*/
|
||||
async function resolveInstallPath(
|
||||
target: RestoreTargetSsh,
|
||||
release: CrasRelease,
|
||||
request: InstallRequest
|
||||
): Promise<{ installPath: string; reportedInstallPath: string | null }> {
|
||||
const inventory = await listCrasTargetInventory();
|
||||
const row = inventory.find((t) => t.restore_target_id === target.id);
|
||||
const reportedInstallPath = row?.reported_install_path ?? null;
|
||||
|
||||
const resolved = effectiveInstallPath(row?.reported_install_path ?? null, release.platform);
|
||||
if (!resolved || !isSafeInstallPath(resolved, release.platform)) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`La ruta de instalación resuelta no es válida para ${release.platform}: ` +
|
||||
`${resolved ?? '(ninguna)'}. Revisa lo que reportó el agente.`
|
||||
);
|
||||
}
|
||||
|
||||
// El binario NO puede vivir dentro de las carpetas de trabajo: el agente vigila Entrada y
|
||||
// trataría de restaurar sus propios archivos como si fueran respaldos.
|
||||
const workFolders = row ? [row.input_folder, row.processed_folder] : [];
|
||||
if (isInsideWorkFolder(resolved, workFolders)) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`La ruta de instalación (${resolved}) está dentro de una carpeta de trabajo del ` +
|
||||
'agente. Ahí el propio binario sería tomado por un respaldo a procesar.'
|
||||
);
|
||||
}
|
||||
|
||||
// En una ACTUALIZACIÓN tiene que existir ya una instalación en esa ruta. Si no, la ruta
|
||||
// resuelta no es donde vive el agente y estaríamos a punto de crear una segunda con la
|
||||
// configuración por omisión, dejando huérfano el config/.env que el operador personalizó.
|
||||
// Esa comprobación se hace con la sesión abierta, en assertExistingInstall().
|
||||
if (request.mode === 'update' && !reportedInstallPath) {
|
||||
logger.warn({
|
||||
message: 'Actualización sin ruta reportada por el agente; se usará el default',
|
||||
context: { target: target.name, install_path: resolved }
|
||||
});
|
||||
}
|
||||
return { installPath: resolved, reportedInstallPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* En modo actualización, confirma que en la ruta resuelta haya realmente una instalación
|
||||
* (existe `config/.env`). Evita el peor escenario: "instalar" en el lugar equivocado, reportar
|
||||
* éxito, y dejar corriendo la instalación vieja con su configuración mientras la nueva vigila
|
||||
* carpetas por omisión.
|
||||
*/
|
||||
async function assertExistingInstall(
|
||||
sftp: SftpClient,
|
||||
platform: CrasRelease['platform'],
|
||||
installPath: string,
|
||||
reportedPath: string | null
|
||||
): Promise<void> {
|
||||
const cmd =
|
||||
platform === 'windows'
|
||||
? psEncoded(`if (Test-Path -LiteralPath '${installPath}\\config\\.env') {"si"} else {"no"}`)
|
||||
: `test -f ${shQuote(`${installPath}/config/.env`)} && echo si || echo no`;
|
||||
|
||||
const result = await execRemote(sftp, cmd);
|
||||
if (result.stdout.trim() === 'si') return;
|
||||
|
||||
throw new InstallError(
|
||||
409,
|
||||
`Se pidió ACTUALIZAR pero en ${installPath} no hay una instalación (falta config/.env). ` +
|
||||
(reportedPath
|
||||
? `El agente reportó ${reportedPath}. `
|
||||
: 'El agente no ha reportado su ruta. ') +
|
||||
'Se aborta para no crear una segunda instalación con la configuración por omisión ' +
|
||||
'y dejar huérfano el .env personalizado. Si es una instalación nueva, usa Instalar.'
|
||||
);
|
||||
}
|
||||
|
||||
/** Contenido del archivo de siembra. Solo claves del panel; nada de credenciales SQL. */
|
||||
function panelEnvContents(request: InstallRequest, instanceKey: string): string {
|
||||
return [
|
||||
'# Generado por el PANEL durante la instalación remota. Se borra al consumirse.',
|
||||
`CLOUDRESTORE_PANEL_API_URL=${request.panelApiUrl.trim().replace(/\/+$/, '')}`,
|
||||
`CLOUDRESTORE_PANEL_API_TOKEN=${request.panelApiToken.trim()}`,
|
||||
`CLOUDRESTORE_PANEL_INSTANCE_KEY=${instanceKey}`,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux
|
||||
// ============================================================================
|
||||
|
||||
async function installLinux(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
release: CrasRelease,
|
||||
localPath: string,
|
||||
request: InstallRequest,
|
||||
target: RestoreTargetSsh,
|
||||
installPath: string,
|
||||
reportedInstallPath: string | null
|
||||
): Promise<void> {
|
||||
// --- Precondiciones, antes de transferir nada ---------------------------
|
||||
// Se identifica el sistema probando AMBAS vías antes de asumir POSIX: si la sesión cae en
|
||||
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
|
||||
const systemEvidence = await assertSystemMatches(sftp, 'linux');
|
||||
|
||||
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'precondiciones',
|
||||
true,
|
||||
`${systemEvidence}, privilegios=${privileged.label}`
|
||||
);
|
||||
|
||||
// Antes de transferir: si es una actualización, confirmar que ahí VIVE una instalación.
|
||||
if (request.mode === 'update') {
|
||||
await assertExistingInstall(sftp, 'linux', installPath, reportedInstallPath);
|
||||
}
|
||||
|
||||
for (const tool of ['tar', 'sha256sum']) {
|
||||
const check = await execRemote(sftp, `command -v ${tool}`);
|
||||
if (check.code !== 0) {
|
||||
throw new InstallError(409, `El destino no tiene '${tool}', requerido para instalar`);
|
||||
}
|
||||
}
|
||||
|
||||
const remoteDir = `/tmp/cras-${release.version}-${runId}`;
|
||||
const remotePkg = `${remoteDir}/${release.file_name}`;
|
||||
const remoteEnv = `${remoteDir}/panel.env`;
|
||||
|
||||
try {
|
||||
// Se verifica el código de salida: es el primer punto donde se nota que la cuenta no
|
||||
// puede escribir en /tmp, típicamente por estar enjaulada (ChrootDirectory). Sin este
|
||||
// chequeo el error aparecía después, como un fallo de transferencia sin causa clara.
|
||||
const mk = await execRemote(
|
||||
sftp,
|
||||
`mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`
|
||||
);
|
||||
if (mk.code !== 0) {
|
||||
throw new InstallError(
|
||||
409,
|
||||
`No se pudo preparar ${remoteDir} en el destino (código ${mk.code}: ` +
|
||||
`${truncate(mk.stderr || mk.stdout) || 'sin salida'}). ` +
|
||||
'Causa probable: la cuenta SSH está enjaulada o no puede escribir ahí. ' +
|
||||
'Puedes indicar otra carpeta con CRAS_REMOTE_STAGING_DIR.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Transferencia ---------------------------------------------------
|
||||
const bytes = (await fs.stat(localPath)).size;
|
||||
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)} → ${remotePkg}`);
|
||||
await sftp.fastPut(localPath, remotePkg);
|
||||
|
||||
// --- Verificación de integridad en el destino ------------------------
|
||||
const sum = await execRemote(sftp, `sha256sum ${shQuote(remotePkg)}`);
|
||||
const remoteSha = (sum.stdout.split(/\s+/)[0] ?? '').toLowerCase();
|
||||
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
|
||||
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
|
||||
|
||||
// --- Siembra de configuración (archivo 0600, nunca por argv) ---------
|
||||
// Se sube y luego se restringe: `put` no acepta el modo en su tipo de opciones, y
|
||||
// dejar el token legible por todos aunque sea unos segundos no es aceptable, así
|
||||
// que el chmod va inmediatamente después y antes de cualquier otro paso.
|
||||
await sftp.put(Buffer.from(panelEnvContents(request, target.name), 'utf8'), remoteEnv);
|
||||
await sftp.chmod(remoteEnv, 0o600);
|
||||
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env 0600 subido');
|
||||
|
||||
// --- Extraer y ejecutar el instalador del paquete --------------------
|
||||
const untar = await execRemote(
|
||||
sftp,
|
||||
`tar xzf ${shQuote(remotePkg)} -C ${shQuote(remoteDir)}`,
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
if (untar.code !== 0) {
|
||||
throw new InstallError(502, `Falló la extracción: ${untar.stderr || untar.stdout}`);
|
||||
}
|
||||
|
||||
const autostart = request.autostart ?? 'service';
|
||||
const installerFlags = autostart === 'none' ? '' : ` --${autostart}`;
|
||||
// PREFIX es variable de entorno en install.sh, no un flag. La ruta no es secreta, así
|
||||
// que pasarla por la línea de comandos está bien; el token sí va por archivo.
|
||||
const installCmd =
|
||||
`cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && ` +
|
||||
`${privileged.prefix}env PREFIX=${shQuote(installPath)} ` +
|
||||
`./install.sh${installerFlags} --panel-env-file ${shQuote(remoteEnv)}`;
|
||||
|
||||
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.sh${installerFlags}`);
|
||||
const install = await execRemote(sftp, installCmd, INSTALL_EXEC_TIMEOUT_MS);
|
||||
if (install.code !== 0) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`install.sh terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Verificación del despliegue -------------------------------------
|
||||
await verifyLinuxDeployment(sftp, runId, release, privileged, autostart, installPath);
|
||||
} finally {
|
||||
// Limpieza siempre: el panel.env trae el token en claro. Si falla NO se silencia —
|
||||
// significa que el token se quedó en el servidor y alguien tiene que ir a borrarlo.
|
||||
await cleanupStaging(sftp, runId, remoteDir, `rm -rf ${shQuote(remoteDir)}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface LinuxPrivilege {
|
||||
/** Prefijo a poner delante de los comandos que requieren root. */
|
||||
prefix: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
|
||||
*
|
||||
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
|
||||
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
|
||||
* deja basura en /tmp del destino.
|
||||
*/
|
||||
async function resolveLinuxPrivilege(
|
||||
sftp: SftpClient,
|
||||
sshUsername: string
|
||||
): Promise<LinuxPrivilege> {
|
||||
const id = await execRemote(sftp, 'id -u');
|
||||
if (id.code === 0 && id.stdout.trim() === '0') {
|
||||
return { prefix: '', label: 'root' };
|
||||
}
|
||||
const sudo = await execRemote(sftp, 'sudo -n true');
|
||||
if (sudo.code === 0) {
|
||||
return { prefix: 'sudo -n ', label: 'sudo sin password' };
|
||||
}
|
||||
throw new InstallError(
|
||||
409,
|
||||
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
|
||||
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
|
||||
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyLinuxDeployment(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
release: CrasRelease,
|
||||
privileged: LinuxPrivilege,
|
||||
autostart: 'service' | 'desktop' | 'none',
|
||||
prefix: string
|
||||
): Promise<void> {
|
||||
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
|
||||
// stdout de --version, sobre todo por paridad con Windows (console=False).
|
||||
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
|
||||
const deployed = stamp.stdout.trim();
|
||||
if (deployed && deployed !== release.version) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'verificar-version',
|
||||
true,
|
||||
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
||||
);
|
||||
|
||||
if (autostart !== 'service') return;
|
||||
|
||||
const active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
|
||||
const state = active.stdout.trim() || active.stderr.trim();
|
||||
if (state !== 'active') {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
|
||||
'Revisa journalctl -u cloudrestoreas en el servidor.'
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'verificar-servicio', true, 'cloudrestoreas active');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Windows
|
||||
// ============================================================================
|
||||
|
||||
async function installWindows(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
release: CrasRelease,
|
||||
localPath: string,
|
||||
request: InstallRequest,
|
||||
target: RestoreTargetSsh,
|
||||
installPath: string,
|
||||
reportedInstallPath: string | null
|
||||
): Promise<void> {
|
||||
// --- Precondiciones ------------------------------------------------------
|
||||
const systemEvidence = await assertSystemMatches(sftp, 'windows');
|
||||
|
||||
const admin = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
'$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' +
|
||||
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}'
|
||||
)
|
||||
);
|
||||
const autostart = request.autostart ?? 'service';
|
||||
if (autostart === 'service' && admin.stdout.trim() !== 'admin') {
|
||||
throw new InstallError(
|
||||
409,
|
||||
'El usuario SSH no es Administrador en el destino. La tarea programada ONSTART corre ' +
|
||||
'como SYSTEM y requiere elevación; usa una cuenta administradora o instala sin servicio.'
|
||||
);
|
||||
}
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'precondiciones',
|
||||
true,
|
||||
`${systemEvidence}, privilegios=${admin.stdout.trim()}`
|
||||
);
|
||||
|
||||
if (request.mode === 'update') {
|
||||
await assertExistingInstall(sftp, 'windows', installPath, reportedInstallPath);
|
||||
}
|
||||
|
||||
// Se usa una carpeta bajo el TEMP del usuario SSH, no C:\Windows\Temp.
|
||||
const remoteDirPs = `$env:TEMP\\cras-${release.version}-${runId}`;
|
||||
const tempResolved = await execRemote(sftp, psEncoded(`Write-Output "${remoteDirPs}"`));
|
||||
const remoteDir = tempResolved.stdout.trim();
|
||||
if (!remoteDir) {
|
||||
throw new InstallError(502, 'No se pudo resolver la carpeta temporal del destino');
|
||||
}
|
||||
const remotePkg = `${remoteDir}\\${release.file_name}`;
|
||||
const remoteEnv = `${remoteDir}\\panel.env`;
|
||||
|
||||
try {
|
||||
const mk = await execRemote(
|
||||
sftp,
|
||||
psEncoded(`New-Item -ItemType Directory -Path '${remoteDir}' -Force | Out-Null`)
|
||||
);
|
||||
if (mk.code !== 0) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`No se pudo crear ${remoteDir}: ${truncate(mk.stderr || mk.stdout) || 'sin salida'}`
|
||||
);
|
||||
}
|
||||
|
||||
// --- Transferencia ---------------------------------------------------
|
||||
const bytes = (await fs.stat(localPath)).size;
|
||||
await appendInstallStep(runId, 'subir-artefacto', true, `${formatBytes(bytes)} → ${remotePkg}`);
|
||||
await sftp.fastPut(localPath, remotePkg.replace(/\\/g, '/'));
|
||||
|
||||
// --- Verificación de integridad en el destino ------------------------
|
||||
const sum = await execRemote(
|
||||
sftp,
|
||||
psEncoded(`(Get-FileHash -Algorithm SHA256 -LiteralPath '${remotePkg}').Hash`)
|
||||
);
|
||||
const remoteSha = sum.stdout.trim().toLowerCase();
|
||||
if (sum.code !== 0 || remoteSha !== (release.sha256 ?? '').toLowerCase()) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El sha256 en el destino no coincide (esperado ${(release.sha256 ?? '').slice(0, 12)}…, ` +
|
||||
`obtenido ${remoteSha.slice(0, 12) || 'nada'}…). La transferencia se corrompió.`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'verificar-sha256', true, 'coincide con el publicado en Gitea');
|
||||
|
||||
// --- Siembra de configuración ----------------------------------------
|
||||
await sftp.put(
|
||||
Buffer.from(panelEnvContents(request, target.name), 'utf8'),
|
||||
remoteEnv.replace(/\\/g, '/')
|
||||
);
|
||||
// Los permisos POSIX no aplican en NTFS: se restringe el ACL a Administradores y
|
||||
// SYSTEM para que el token no quede legible por cualquier usuario del servidor.
|
||||
// Se restringe el DIRECTORIO además del archivo: New-Item hereda el ACL del padre, y
|
||||
// si el $env:TEMP de la cuenta resuelve a C:\Windows\Temp ese padre es accesible por
|
||||
// todos. Y se verifica el código de salida: un icacls que falla en silencio dejaría el
|
||||
// token legible, que es justo lo que se está tratando de evitar.
|
||||
const acl = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`icacls '${remoteDir}' /inheritance:r ` +
|
||||
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null; ' +
|
||||
`icacls '${remoteEnv}' /inheritance:r ` +
|
||||
'/grant:r "BUILTIN\\Administrators:(F)" /grant:r "NT AUTHORITY\\SYSTEM:(F)" | Out-Null'
|
||||
)
|
||||
);
|
||||
if (acl.code !== 0) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
'No se pudo restringir el acceso al archivo con el token del panel ' +
|
||||
`(icacls código ${acl.code}: ${truncate(acl.stderr || acl.stdout) || 'sin salida'}). ` +
|
||||
'Se aborta para no dejarlo legible por otros usuarios del servidor.'
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'sembrar-configuracion', true, 'panel.env con ACL restringido');
|
||||
|
||||
// --- Extraer y ejecutar el instalador --------------------------------
|
||||
const expand = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`Expand-Archive -LiteralPath '${remotePkg}' -DestinationPath '${remoteDir}' -Force`
|
||||
),
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
if (expand.code !== 0) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`Falló Expand-Archive: ${truncate(expand.stderr || expand.stdout) || 'sin salida'}`
|
||||
);
|
||||
}
|
||||
|
||||
const flag = autostart === 'none' ? '' : autostart === 'service' ? ' -Service' : ' -Desktop';
|
||||
await appendInstallStep(runId, 'ejecutar-instalador', true, `install.ps1${flag}`);
|
||||
const install = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`& '${remoteDir}\\CloudRestoreAS\\install.ps1'${flag} ` +
|
||||
`-Prefix '${installPath}' -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE`
|
||||
),
|
||||
INSTALL_EXEC_TIMEOUT_MS
|
||||
);
|
||||
if (install.code !== 0) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`install.ps1 terminó con código ${install.code}: ${truncate(install.stderr || install.stdout)}`
|
||||
);
|
||||
}
|
||||
|
||||
await verifyWindowsDeployment(sftp, runId, release, autostart, installPath);
|
||||
} finally {
|
||||
await cleanupStaging(
|
||||
sftp,
|
||||
runId,
|
||||
remoteDir,
|
||||
psEncoded(
|
||||
`Remove-Item -LiteralPath '${remoteDir}' -Recurse -Force -ErrorAction Stop`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyWindowsDeployment(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
release: CrasRelease,
|
||||
autostart: 'service' | 'desktop' | 'none',
|
||||
prefix: string
|
||||
): Promise<void> {
|
||||
const stamp = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`if (Test-Path '${prefix}\\config\\.version') ` +
|
||||
`{ Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw }`
|
||||
)
|
||||
);
|
||||
const deployed = stamp.stdout.trim();
|
||||
if (deployed && deployed !== release.version) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
`El binario desplegado reporta la versión ${deployed}, se esperaba ${release.version}`
|
||||
);
|
||||
}
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'verificar-version',
|
||||
true,
|
||||
deployed ? `config\\.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
|
||||
);
|
||||
|
||||
if (autostart !== 'service') return;
|
||||
|
||||
const task = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"(Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue).State"
|
||||
)
|
||||
);
|
||||
const state = task.stdout.trim();
|
||||
if (!state) {
|
||||
throw new InstallError(
|
||||
502,
|
||||
'La tarea programada CloudRestoreAS no quedó registrada en el destino.'
|
||||
);
|
||||
}
|
||||
await appendInstallStep(runId, 'verificar-servicio', true, `tarea CloudRestoreAS: ${state}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utilidades
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Borra el directorio de staging del destino. **No silencia el fallo**: ese directorio
|
||||
* contiene `panel.env` con el token del panel en claro, así que si no se pudo borrar hay que
|
||||
* dejarlo asentado —en la bitácora y en el log— con la ruta exacta, para que alguien vaya a
|
||||
* limpiarlo. Antes se descartaba con `.catch(() => undefined)` y el token quedaba ahí sin que
|
||||
* nadie se enterara.
|
||||
*
|
||||
* Corre dentro de un `finally`, así que nunca lanza: eso enmascararía el error real de la
|
||||
* instalación.
|
||||
*/
|
||||
async function cleanupStaging(
|
||||
sftp: SftpClient,
|
||||
runId: number,
|
||||
remoteDir: string,
|
||||
command: string
|
||||
): Promise<void> {
|
||||
const report = async (detail: string) => {
|
||||
await appendInstallStep(
|
||||
runId,
|
||||
'limpiar-staging',
|
||||
false,
|
||||
`quedó pendiente de borrar ${remoteDir} (contiene el token del panel): ${detail}`
|
||||
);
|
||||
logger.error({
|
||||
message: 'No se pudo borrar el staging remoto; el token del panel quedó en el destino',
|
||||
context: { run_id: runId, remote_dir: remoteDir, detail }
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await execRemote(sftp, command);
|
||||
if (result.code === 0) return;
|
||||
await report(truncate(result.stderr || result.stdout) || `código ${result.code}`);
|
||||
} catch (err) {
|
||||
await report(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/** Recorta la salida de un comando para la bitácora sin llenar la BD de ruido. */
|
||||
function truncate(text: string, max = 600): string {
|
||||
const clean = String(text ?? '').trim();
|
||||
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
||||
}
|
||||
620
src/lib/server/cras-releases.ts
Normal file
620
src/lib/server/cras-releases.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* Catálogo de versiones de CloudRestoreAS y bitácora de instalaciones.
|
||||
*
|
||||
* `cras_releases` guarda solo METADATOS: los bytes viven en Gitea y se cachean en disco
|
||||
* (ver cras-artifacts.ts). Un artefacto de CRAS pesa ~270 MB, así que guardarlo como BLOB
|
||||
* en Postgres —como hace AServers con el agente Jhona, que pesa 15 MB— no es viable.
|
||||
*
|
||||
* El DDL autoritativo está en la migración e2f3a4b5c6d7 de a24c. Aquí se replica como
|
||||
* fallback idempotente, igual que ensureRestoreTargetsSchema en controldesk-pg.ts: el panel
|
||||
* y a24c comparten la base y el orden de arranque no está garantizado.
|
||||
*/
|
||||
import { pgPool } from './db';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
compareVersions,
|
||||
effectiveArch,
|
||||
effectivePlatform,
|
||||
isNewer,
|
||||
type CrasPlatform
|
||||
} from '$lib/cras-version';
|
||||
|
||||
const SCHEMA = 'a24c';
|
||||
|
||||
function qReleases(): string {
|
||||
return `"${SCHEMA}"."cras_releases"`;
|
||||
}
|
||||
|
||||
function qInstallRuns(): string {
|
||||
return `"${SCHEMA}"."cras_install_runs"`;
|
||||
}
|
||||
|
||||
function qRestoreTargets(): string {
|
||||
return `"${SCHEMA}"."restore_targets"`;
|
||||
}
|
||||
|
||||
function qCloudRestoreStatus(): string {
|
||||
return `"${SCHEMA}"."cloudrestore_status"`;
|
||||
}
|
||||
|
||||
function isPgUndefinedTable(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01';
|
||||
}
|
||||
|
||||
function isPgUndefinedColumn(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42703';
|
||||
}
|
||||
|
||||
/** True si el error es una violación de unicidad (23505). */
|
||||
export function isPgUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505';
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL de respaldo, en paridad con la migración e2f3a4b5c6d7. Idempotente.
|
||||
* Si se agrega una columna hay que hacerlo en AMBOS lados o divergen según quién arranque
|
||||
* primero, el panel o a24c.
|
||||
*/
|
||||
export async function ensureCrasSchema(): Promise<void> {
|
||||
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
|
||||
await pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${qReleases()} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
arch VARCHAR(20) NOT NULL DEFAULT 'x86_64',
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_size BIGINT,
|
||||
sha256 VARCHAR(64),
|
||||
gitea_package VARCHAR(120) NOT NULL DEFAULT 'cloudrestoreas',
|
||||
changelog TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
published_at TIMESTAMPTZ,
|
||||
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT cras_releases_platform_check CHECK (platform IN ('windows', 'linux')),
|
||||
CONSTRAINT cras_releases_version_platform_arch_key UNIQUE (version, platform, arch)
|
||||
)
|
||||
`);
|
||||
await pgPool.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_a24c_cras_releases_active
|
||||
ON ${qReleases()} (platform, arch) WHERE is_active`
|
||||
);
|
||||
await pgPool.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_releases_version ON ${qReleases()} (version)`
|
||||
);
|
||||
|
||||
// Las FK se declaran igual que en la migración e2f3a4b5c6d7 y en database/schema.sql. Sin
|
||||
// esto, si el panel arranca antes que a24c la tabla quedaría SIN FK para siempre (la
|
||||
// migración la ve existente y no la toca), dejando bitácora con restore_target_id colgante
|
||||
// al borrar un servidor. Si restore_targets aún no existe se cae al DDL sin FK, porque es
|
||||
// preferible una bitácora sin integridad referencial a que no arranque el panel.
|
||||
const installRunsDdl = (withFk: boolean) => `
|
||||
CREATE TABLE IF NOT EXISTS ${qInstallRuns()} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
restore_target_id INTEGER${withFk ? ` REFERENCES ${qRestoreTargets()} (id) ON DELETE SET NULL` : ''},
|
||||
release_id INTEGER${withFk ? ` REFERENCES ${qReleases()} (id) ON DELETE SET NULL` : ''},
|
||||
version VARCHAR(50),
|
||||
platform VARCHAR(20),
|
||||
mode VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
install_path VARCHAR(500),
|
||||
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error_message TEXT,
|
||||
started_by VARCHAR(128),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
CONSTRAINT cras_install_runs_mode_check CHECK (mode IN ('install', 'update')),
|
||||
CONSTRAINT cras_install_runs_status_check
|
||||
CHECK (status IN ('running', 'completed', 'failed'))
|
||||
)
|
||||
`;
|
||||
try {
|
||||
await pgPool.query(installRunsDdl(true));
|
||||
} catch (e) {
|
||||
if (!isPgUndefinedTable(e)) throw e;
|
||||
logger.warn({
|
||||
message: 'cras_install_runs creada sin claves foráneas: falta a24c.restore_targets',
|
||||
context: { hint: 'aplica la migración de a24c para obtener la integridad referencial' }
|
||||
});
|
||||
await pgPool.query(installRunsDdl(false));
|
||||
}
|
||||
await pgPool.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_target
|
||||
ON ${qInstallRuns()} (restore_target_id, started_at DESC)`
|
||||
);
|
||||
await pgPool.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_running
|
||||
ON ${qInstallRuns()} (restore_target_id) WHERE status = 'running'`
|
||||
);
|
||||
|
||||
// Columnas que reporta el agente; la migración de a24c también las agrega.
|
||||
for (const column of [
|
||||
'platform VARCHAR(20)',
|
||||
'arch VARCHAR(20)',
|
||||
'install_path VARCHAR(500)'
|
||||
]) {
|
||||
try {
|
||||
await pgPool.query(
|
||||
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS ${column}`
|
||||
);
|
||||
} catch (e) {
|
||||
if (!isPgUndefinedTable(e)) throw e; // la tabla la crea controldesk-pg/a24c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catálogo de versiones
|
||||
// ============================================================================
|
||||
|
||||
export interface CrasRelease {
|
||||
id: number;
|
||||
version: string;
|
||||
platform: CrasPlatform;
|
||||
arch: string;
|
||||
file_name: string;
|
||||
file_size: number | null;
|
||||
sha256: string | null;
|
||||
gitea_package: string;
|
||||
changelog: string | null;
|
||||
is_active: boolean;
|
||||
published_at: Date | null;
|
||||
discovered_at: Date;
|
||||
}
|
||||
|
||||
const ROW_RELEASE = `
|
||||
id, version, platform, arch, file_name, file_size, sha256,
|
||||
gitea_package, changelog, is_active, published_at, discovered_at
|
||||
`;
|
||||
|
||||
async function queryReleases(): Promise<CrasRelease[]> {
|
||||
const r = await pgPool.query(
|
||||
`SELECT ${ROW_RELEASE} FROM ${qReleases()} ORDER BY discovered_at DESC, version DESC`
|
||||
);
|
||||
return r.rows as CrasRelease[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Todas las versiones registradas, más nuevas primero. El orden final lo decide
|
||||
* compareVersions y no el ORDER BY: en SQL "1.10.0" < "1.9.0" como texto.
|
||||
*/
|
||||
export async function listCrasReleases(): Promise<CrasRelease[]> {
|
||||
let rows: CrasRelease[];
|
||||
try {
|
||||
rows = await queryReleases();
|
||||
} catch (e) {
|
||||
if (!isPgUndefinedTable(e) && !isPgUndefinedColumn(e)) throw e;
|
||||
await ensureCrasSchema();
|
||||
rows = await queryReleases();
|
||||
}
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
(compareVersions(b.version, a.version) ?? 0) ||
|
||||
a.platform.localeCompare(b.platform) ||
|
||||
a.arch.localeCompare(b.arch)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrasReleaseById(id: number): Promise<CrasRelease | null> {
|
||||
const r = await pgPool.query(`SELECT ${ROW_RELEASE} FROM ${qReleases()} WHERE id = $1`, [id]);
|
||||
return (r.rows[0] as CrasRelease) ?? null;
|
||||
}
|
||||
|
||||
/** Versión activa para una plataforma+arquitectura, si hay. */
|
||||
export async function getActiveCrasRelease(
|
||||
platform: string,
|
||||
arch: string
|
||||
): Promise<CrasRelease | null> {
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`SELECT ${ROW_RELEASE} FROM ${qReleases()}
|
||||
WHERE is_active AND platform = $1 AND arch = $2 LIMIT 1`,
|
||||
[platform, arch]
|
||||
);
|
||||
return (r.rows[0] as CrasRelease) ?? null;
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrasReleaseInput {
|
||||
version: string;
|
||||
platform: CrasPlatform;
|
||||
arch: string;
|
||||
file_name: string;
|
||||
file_size: number | null;
|
||||
sha256: string | null;
|
||||
gitea_package: string;
|
||||
published_at: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra o actualiza un artefacto descubierto en Gitea. No toca `is_active`: activar es
|
||||
* una decisión explícita del operador, no algo que un sync deba hacer por su cuenta —
|
||||
* sincronizar no debe cambiar qué versión se está desplegando.
|
||||
*
|
||||
* Devuelve true si la fila es nueva.
|
||||
*/
|
||||
export async function upsertCrasRelease(input: CrasReleaseInput): Promise<boolean> {
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qReleases()} (
|
||||
version, platform, arch, file_name, file_size, sha256, gitea_package, published_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (version, platform, arch) DO UPDATE SET
|
||||
file_name = EXCLUDED.file_name,
|
||||
file_size = EXCLUDED.file_size,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
gitea_package = EXCLUDED.gitea_package,
|
||||
published_at = COALESCE(EXCLUDED.published_at, ${qReleases()}.published_at)
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
`,
|
||||
[
|
||||
input.version,
|
||||
input.platform,
|
||||
input.arch,
|
||||
input.file_name,
|
||||
input.file_size,
|
||||
input.sha256,
|
||||
input.gitea_package,
|
||||
input.published_at
|
||||
]
|
||||
);
|
||||
return Boolean(r.rows[0]?.inserted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca una versión como activa para su plataforma+arquitectura.
|
||||
*
|
||||
* Va en transacción porque el índice único parcial solo admite una activa por tupla:
|
||||
* desactivar y activar tienen que ocurrir juntos o el UPDATE choca consigo mismo.
|
||||
*/
|
||||
export async function activateCrasRelease(id: number): Promise<CrasRelease | null> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const current = await client.query(
|
||||
`SELECT ${ROW_RELEASE} FROM ${qReleases()} WHERE id = $1 FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
const release = current.rows[0] as CrasRelease | undefined;
|
||||
if (!release) {
|
||||
await client.query('ROLLBACK');
|
||||
return null;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE ${qReleases()} SET is_active = FALSE
|
||||
WHERE platform = $1 AND arch = $2 AND is_active AND id <> $3`,
|
||||
[release.platform, release.arch, id]
|
||||
);
|
||||
await client.query(`UPDATE ${qReleases()} SET is_active = TRUE WHERE id = $1`, [id]);
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info({
|
||||
message: 'Versión de CRAS activada',
|
||||
context: { id, version: release.version, platform: release.platform, arch: release.arch }
|
||||
});
|
||||
return { ...release, is_active: true };
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateCrasRelease(id: number): Promise<void> {
|
||||
await pgPool.query(`UPDATE ${qReleases()} SET is_active = FALSE WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Borra una versión del catálogo. No borra nada en Gitea (los paquetes genéricos son
|
||||
* inmutables y son el respaldo) ni la caché local, que se poda aparte.
|
||||
*/
|
||||
export async function deleteCrasRelease(id: number): Promise<void> {
|
||||
await pgPool.query(`DELETE FROM ${qReleases()} WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inventario: qué versión tiene cada servidor vs. la activa que le toca
|
||||
// ============================================================================
|
||||
|
||||
export interface CrasTargetInventory {
|
||||
restore_target_id: number;
|
||||
name: string;
|
||||
server_ip: string | null;
|
||||
os: string | null;
|
||||
ssh_host: string | null;
|
||||
ssh_username: string | null;
|
||||
has_ssh_credentials: boolean;
|
||||
/** Lo que reportó el agente (autoritativo). */
|
||||
reported_platform: string | null;
|
||||
reported_arch: string | null;
|
||||
/** Dónde vive el ejecutable según el agente. Null si aún no ha reportado. */
|
||||
reported_install_path: string | null;
|
||||
/** Carpetas de trabajo reportadas. Sirven para no instalar el binario dentro de ellas. */
|
||||
input_folder: string | null;
|
||||
processed_folder: string | null;
|
||||
installed_version: string | null;
|
||||
reported_at: Date | null;
|
||||
/** Resuelto: reportado, o derivado de `os` para la primera instalación. */
|
||||
platform: CrasPlatform | null;
|
||||
arch: string;
|
||||
active_version: string | null;
|
||||
active_release_id: number | null;
|
||||
update_available: boolean;
|
||||
/** Instalación en curso, si hay. */
|
||||
running_install_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cruza servidores de restauración con lo que reportó su agente y con la versión activa.
|
||||
*
|
||||
* El join instancia↔servidor es por nombre (LOWER(TRIM(instance_key)) = LOWER(TRIM(name))),
|
||||
* la misma convención que ya usa resolve-route: no hay FK entre esas tablas.
|
||||
*/
|
||||
export async function listCrasTargetInventory(): Promise<CrasTargetInventory[]> {
|
||||
await ensureCrasSchema();
|
||||
|
||||
const r = await pgPool.query(`
|
||||
SELECT rt.id AS restore_target_id,
|
||||
rt.name,
|
||||
rt.server_ip,
|
||||
rt.os,
|
||||
rt.ssh_host,
|
||||
rt.ssh_username,
|
||||
(rt.ssh_password_encrypted IS NOT NULL
|
||||
AND rt.ssh_password_encrypted <> '') AS has_ssh_credentials,
|
||||
cs.platform AS reported_platform,
|
||||
cs.arch AS reported_arch,
|
||||
cs.install_path AS reported_install_path,
|
||||
cs.input_folder,
|
||||
cs.processed_folder,
|
||||
cs.app_version AS installed_version,
|
||||
cs.reported_at,
|
||||
run.id AS running_install_id
|
||||
FROM ${qRestoreTargets()} rt
|
||||
LEFT JOIN ${qCloudRestoreStatus()} cs
|
||||
ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name))
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM ${qInstallRuns()}
|
||||
WHERE restore_target_id = rt.id AND status = 'running'
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
) run ON TRUE
|
||||
ORDER BY rt.name
|
||||
`);
|
||||
|
||||
// Las activas se leen una vez y se resuelven en memoria: son a lo más una por
|
||||
// plataforma+arch, así que no vale la pena un join por fila.
|
||||
const releases = await listCrasReleases();
|
||||
const activeByKey = new Map<string, CrasRelease>();
|
||||
for (const release of releases) {
|
||||
if (release.is_active) activeByKey.set(`${release.platform}/${release.arch}`, release);
|
||||
}
|
||||
|
||||
return r.rows.map((row) => {
|
||||
const platform = effectivePlatform(row.reported_platform, row.os);
|
||||
const arch = effectiveArch(row.reported_arch);
|
||||
const active = platform ? (activeByKey.get(`${platform}/${arch}`) ?? null) : null;
|
||||
return {
|
||||
...row,
|
||||
has_ssh_credentials: Boolean(row.has_ssh_credentials),
|
||||
platform,
|
||||
arch,
|
||||
active_version: active?.version ?? null,
|
||||
active_release_id: active?.id ?? null,
|
||||
update_available: active ? isNewer(active.version, row.installed_version) : false,
|
||||
running_install_id: row.running_install_id ?? null
|
||||
} as CrasTargetInventory;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bitácora de instalaciones
|
||||
// ============================================================================
|
||||
|
||||
export type InstallMode = 'install' | 'update';
|
||||
export type InstallStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface InstallStep {
|
||||
step: string;
|
||||
detail?: string;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export interface CrasInstallRun {
|
||||
id: number;
|
||||
restore_target_id: number | null;
|
||||
release_id: number | null;
|
||||
version: string | null;
|
||||
platform: string | null;
|
||||
mode: InstallMode;
|
||||
status: InstallStatus;
|
||||
steps: InstallStep[];
|
||||
error_message: string | null;
|
||||
started_by: string | null;
|
||||
started_at: Date;
|
||||
finished_at: Date | null;
|
||||
target_name?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ya hay una instalación abierta para ese destino: es un conflicto de estado, no un fallo
|
||||
* interno. Se distingue con una clase propia porque un `Error` pelón terminaba mapeado a
|
||||
* 500 "Error interno" en la action, y el operador no podía saber que solo tenía que esperar.
|
||||
*/
|
||||
export class InstallRunConflictError extends Error {
|
||||
constructor(public readonly runId: number) {
|
||||
super(`Ya hay una instalación en curso para este servidor (run #${runId})`);
|
||||
this.name = 'InstallRunConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre un run. Falla si ya hay uno corriendo para ese destino: dos instalaciones
|
||||
* simultáneas se pelearían el mismo binario y el mismo servicio en el servidor.
|
||||
*/
|
||||
export async function startInstallRun(input: {
|
||||
restoreTargetId: number;
|
||||
releaseId: number;
|
||||
version: string;
|
||||
platform: string;
|
||||
mode: InstallMode;
|
||||
startedBy: string | null;
|
||||
}): Promise<number> {
|
||||
await ensureCrasSchema();
|
||||
|
||||
const running = await pgPool.query(
|
||||
`SELECT id FROM ${qInstallRuns()} WHERE restore_target_id = $1 AND status = 'running' LIMIT 1`,
|
||||
[input.restoreTargetId]
|
||||
);
|
||||
if (running.rows[0]) {
|
||||
throw new InstallRunConflictError(running.rows[0].id as number);
|
||||
}
|
||||
|
||||
const r = await pgPool.query(
|
||||
`INSERT INTO ${qInstallRuns()}
|
||||
(restore_target_id, release_id, version, platform, mode, status, started_by)
|
||||
VALUES ($1, $2, $3, $4, $5, 'running', $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
input.restoreTargetId,
|
||||
input.releaseId,
|
||||
input.version,
|
||||
input.platform,
|
||||
input.mode,
|
||||
input.startedBy
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega un paso al progreso. Se persiste de inmediato para que la UI lo pueda leer por
|
||||
* polling mientras la instalación sigue corriendo: en AServers el progreso solo quedaba en
|
||||
* el log del backend porque el schema de respuesta lo descartaba en silencio.
|
||||
*/
|
||||
export async function appendInstallStep(
|
||||
runId: number,
|
||||
step: string,
|
||||
ok: boolean,
|
||||
detail?: string
|
||||
): Promise<void> {
|
||||
const entry: InstallStep = { step, ok, at: new Date().toISOString() };
|
||||
if (detail) entry.detail = detail;
|
||||
try {
|
||||
await pgPool.query(
|
||||
`UPDATE ${qInstallRuns()} SET steps = steps || $2::jsonb WHERE id = $1`,
|
||||
[runId, JSON.stringify([entry])]
|
||||
);
|
||||
} catch (err) {
|
||||
// El progreso es informativo: si no se puede registrar, la instalación sigue.
|
||||
logger.warn({
|
||||
message: 'No se pudo registrar un paso de instalación',
|
||||
context: { run_id: runId, step, error: err instanceof Error ? err.message : String(err) }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishInstallRun(
|
||||
runId: number,
|
||||
status: Exclude<InstallStatus, 'running'>,
|
||||
errorMessage: string | null = null
|
||||
): Promise<void> {
|
||||
await pgPool.query(
|
||||
`UPDATE ${qInstallRuns()}
|
||||
SET status = $2, error_message = $3, finished_at = now()
|
||||
WHERE id = $1`,
|
||||
[runId, status, errorMessage]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getInstallRun(runId: number): Promise<CrasInstallRun | null> {
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`SELECT run.*, rt.name AS target_name
|
||||
FROM ${qInstallRuns()} run
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
|
||||
WHERE run.id = $1`,
|
||||
[runId]
|
||||
);
|
||||
return (r.rows[0] as CrasInstallRun) ?? null;
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Último run de un destino. Lo usa la UI para seguir el progreso: la acción de instalar es
|
||||
* bloqueante, así que el navegador no conoce el runId hasta que termina y necesita poder
|
||||
* preguntar "¿qué está pasando en este servidor?" mientras espera.
|
||||
*/
|
||||
export async function getLatestInstallRunForTarget(
|
||||
restoreTargetId: number
|
||||
): Promise<CrasInstallRun | null> {
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`SELECT run.*, rt.name AS target_name
|
||||
FROM ${qInstallRuns()} run
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
|
||||
WHERE run.restore_target_id = $1
|
||||
ORDER BY run.started_at DESC
|
||||
LIMIT 1`,
|
||||
[restoreTargetId]
|
||||
);
|
||||
return (r.rows[0] as CrasInstallRun) ?? null;
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInstallRuns(limit = 30): Promise<CrasInstallRun[]> {
|
||||
const capped = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 200) : 30;
|
||||
try {
|
||||
await ensureCrasSchema();
|
||||
const r = await pgPool.query(
|
||||
`SELECT run.*, rt.name AS target_name
|
||||
FROM ${qInstallRuns()} run
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = run.restore_target_id
|
||||
ORDER BY run.started_at DESC
|
||||
LIMIT $1`,
|
||||
[capped]
|
||||
);
|
||||
return r.rows as CrasInstallRun[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra como fallidos los runs que quedaron 'running' de un proceso anterior.
|
||||
*
|
||||
* Un reinicio del panel mata la instalación en curso pero deja la fila abierta, y esa fila
|
||||
* bloquearía para siempre nuevas instalaciones en ese destino. Se llama al cargar la UI.
|
||||
*/
|
||||
export async function reapStaleInstallRuns(olderThanMinutes = 30): Promise<number> {
|
||||
const minutes = Number.isFinite(olderThanMinutes) ? Math.max(1, olderThanMinutes) : 30;
|
||||
try {
|
||||
const r = await pgPool.query(
|
||||
`UPDATE ${qInstallRuns()}
|
||||
SET status = 'failed',
|
||||
error_message = COALESCE(error_message,
|
||||
'Interrumpida: el panel se reinició durante la instalación'),
|
||||
finished_at = now()
|
||||
WHERE status = 'running'
|
||||
AND started_at < now() - ($1 || ' minutes')::interval`,
|
||||
[String(minutes)]
|
||||
);
|
||||
return r.rowCount ?? 0;
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return 0;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
68
src/lib/server/cras-sync.test.ts
Normal file
68
src/lib/server/cras-sync.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Parseo de nombres de artefacto de CloudRestoreAS.
|
||||
*
|
||||
* El nombre es lo que determina a qué plataforma y arquitectura corresponde cada binario. Si
|
||||
* el parseo falla en silencio, el artefacto queda invisible en el catálogo y el servidor
|
||||
* correspondiente nunca recibe la actualización — un síntoma muy difícil de diagnosticar.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseArtifactName } from './cras-sync';
|
||||
|
||||
describe('parseArtifactName', () => {
|
||||
it('reconoce el artefacto de Linux', () => {
|
||||
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toEqual({
|
||||
version: '1.1.0',
|
||||
platform: 'linux',
|
||||
arch: 'x86_64'
|
||||
});
|
||||
});
|
||||
|
||||
it('traduce el token "win" del nombre a la plataforma "windows"', () => {
|
||||
// En el nombre se abrevia, pero el vocabulario del catálogo (y del CHECK de la tabla)
|
||||
// es el que reporta el agente: "windows".
|
||||
expect(parseArtifactName('CloudRestoreAS-1.1.0-win-x86_64.zip')).toEqual({
|
||||
version: '1.1.0',
|
||||
platform: 'windows',
|
||||
arch: 'x86_64'
|
||||
});
|
||||
});
|
||||
|
||||
it('acepta versiones de 1 a 4 componentes', () => {
|
||||
expect(parseArtifactName('CloudRestoreAS-2-linux-x86_64.tar.gz')?.version).toBe('2');
|
||||
expect(parseArtifactName('CloudRestoreAS-26.7.1.4-linux-x86_64.tar.gz')?.version).toBe(
|
||||
'26.7.1.4'
|
||||
);
|
||||
});
|
||||
|
||||
it('acepta otras arquitecturas y las normaliza a minúsculas', () => {
|
||||
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-arm64.tar.gz')?.arch).toBe('arm64');
|
||||
expect(parseArtifactName('CloudRestoreAS-1.1.0-linux-ARM64.tar.gz')?.arch).toBe('arm64');
|
||||
});
|
||||
|
||||
it('rechaza los archivos de verificación que se publican junto a los paquetes', () => {
|
||||
expect(parseArtifactName('SHA256SUMS')).toBeNull();
|
||||
expect(parseArtifactName('release.json')).toBeNull();
|
||||
});
|
||||
|
||||
it('rechaza nombres que no son artefactos de despliegue', () => {
|
||||
for (const name of [
|
||||
'CloudRestoreAS.exe', // binario crudo, no el paquete
|
||||
'CloudRestoreAS-linux', // sin versión (esquema anterior)
|
||||
'CloudRestoreAS-win.zip', // sin versión ni arch (esquema anterior)
|
||||
'CloudRestoreAS-1.1.0-macos-arm64.tar.gz', // plataforma no soportada
|
||||
'CloudRestoreAS-1.1.0-linux-x86_64.7z', // extensión inesperada
|
||||
'CloudRestoreAS-1.0.0-rc1-linux-x86_64.tar.gz', // versión no comparable
|
||||
'OtroProducto-1.1.0-linux-x86_64.tar.gz',
|
||||
'',
|
||||
' '
|
||||
]) {
|
||||
expect(parseArtifactName(name), name).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('tolera espacios alrededor', () => {
|
||||
expect(parseArtifactName(' CloudRestoreAS-1.1.0-linux-x86_64.tar.gz ')?.version).toBe(
|
||||
'1.1.0'
|
||||
);
|
||||
});
|
||||
});
|
||||
160
src/lib/server/cras-sync.ts
Normal file
160
src/lib/server/cras-sync.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Sincronización del catálogo de versiones de CloudRestoreAS contra Gitea.
|
||||
*
|
||||
* Gitea es la fuente de verdad: el panel lee su API de paquetes y registra en
|
||||
* `cras_releases` lo que encuentra. Se hace por pull (y no esperando un aviso del script de
|
||||
* publicación) para que el catálogo se pueda reconstruir en cualquier momento sin depender
|
||||
* de que ese aviso haya llegado. `publish-release.sh --notify-panel` solo adelanta el sync.
|
||||
*
|
||||
* Sincronizar NUNCA cambia qué versión está activa: activar es una decisión explícita del
|
||||
* operador. Un sync que activara sola la última convertiría un `git push` en un despliegue.
|
||||
*/
|
||||
import { isCrasPlatform, isValidVersion, type CrasPlatform } from '$lib/cras-version';
|
||||
import {
|
||||
listPackageFiles,
|
||||
listPackageVersions,
|
||||
packageName,
|
||||
GiteaError
|
||||
} from './gitea-packages';
|
||||
import { upsertCrasRelease } from './cras-releases';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Nombres que produce packaging/scripts/package-release.sh:
|
||||
* CloudRestoreAS-1.1.0-linux-x86_64.tar.gz
|
||||
* CloudRestoreAS-1.1.0-win-x86_64.zip
|
||||
*
|
||||
* En el nombre se usa `win` por brevedad, pero el vocabulario del catálogo (y del CHECK de
|
||||
* la tabla) es `windows`, que es lo que reporta el agente.
|
||||
*/
|
||||
const ARTIFACT_RE =
|
||||
/^CloudRestoreAS-(\d+(?:\.\d+){0,3})-(win|linux)-([A-Za-z0-9_]+)\.(?:zip|tar\.gz)$/;
|
||||
|
||||
/** Archivos de verificación que se publican junto a los paquetes y no son artefactos. */
|
||||
const NON_ARTIFACTS = new Set(['SHA256SUMS', 'release.json']);
|
||||
|
||||
export interface ParsedArtifact {
|
||||
version: string;
|
||||
platform: CrasPlatform;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
/** Deriva versión/plataforma/arquitectura del nombre. null si no es un artefacto. */
|
||||
export function parseArtifactName(fileName: string): ParsedArtifact | null {
|
||||
const match = ARTIFACT_RE.exec(String(fileName ?? '').trim());
|
||||
if (!match) return null;
|
||||
const [, version, platformToken, arch] = match;
|
||||
const platform = platformToken === 'win' ? 'windows' : 'linux';
|
||||
if (!isCrasPlatform(platform) || !isValidVersion(version)) return null;
|
||||
return { version, platform, arch: arch.toLowerCase() };
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
versions_seen: number;
|
||||
artifacts_registered: number;
|
||||
artifacts_new: number;
|
||||
/** Archivos que no se pudieron interpretar; se reportan en lugar de ignorarse. */
|
||||
skipped: { file_name: string; reason: string }[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorre las versiones publicadas y registra sus artefactos.
|
||||
*
|
||||
* Si el nombre de un archivo no encaja en el patrón esperado se reporta como omitido en vez
|
||||
* de descartarlo en silencio: un artefacto invisible se traduce en un servidor al que nunca
|
||||
* se le ofrece la actualización, y eso es muy difícil de diagnosticar después.
|
||||
*/
|
||||
export async function syncCrasReleasesFromGitea(): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
versions_seen: 0,
|
||||
artifacts_registered: 0,
|
||||
artifacts_new: 0,
|
||||
skipped: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
const versions = await listPackageVersions();
|
||||
result.versions_seen = versions.length;
|
||||
const pkg = packageName();
|
||||
|
||||
for (const entry of versions) {
|
||||
if (!isValidVersion(entry.version)) {
|
||||
result.skipped.push({
|
||||
file_name: entry.version,
|
||||
reason: 'la versión del paquete no es comparable (se esperan puntos y números)'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await listPackageFiles(entry.version);
|
||||
} catch (err) {
|
||||
// Una versión ilegible no debe abortar el sync de las demás.
|
||||
const message = err instanceof GiteaError ? err.message : String(err);
|
||||
result.warnings.push(`No se pudieron listar los archivos de ${entry.version}: ${message}`);
|
||||
logger.warn({
|
||||
message: 'Fallo listando archivos de una versión de CRAS',
|
||||
context: { version: entry.version, error: message }
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const publishedAt = entry.created_at ? new Date(entry.created_at) : null;
|
||||
|
||||
for (const file of files) {
|
||||
if (NON_ARTIFACTS.has(file.name)) continue;
|
||||
|
||||
const parsed = parseArtifactName(file.name);
|
||||
if (!parsed) {
|
||||
result.skipped.push({
|
||||
file_name: `${entry.version}/${file.name}`,
|
||||
reason: 'el nombre no coincide con CloudRestoreAS-<version>-<win|linux>-<arch>.<zip|tar.gz>'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (parsed.version !== entry.version) {
|
||||
// Un artefacto colgado de otra versión del paquete: es un error de
|
||||
// publicación y registrarlo confundiría el catálogo.
|
||||
result.skipped.push({
|
||||
file_name: `${entry.version}/${file.name}`,
|
||||
reason: `el nombre declara la versión ${parsed.version} pero está publicado en ${entry.version}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!file.sha256) {
|
||||
// Sin hash no se puede verificar la descarga, así que tampoco se puede
|
||||
// instalar. Se registra igual para que sea visible en la UI, con aviso.
|
||||
result.warnings.push(
|
||||
`${file.name} no tiene sha256 en Gitea; no se podrá instalar hasta republicarlo`
|
||||
);
|
||||
}
|
||||
|
||||
const isNew = await upsertCrasRelease({
|
||||
version: parsed.version,
|
||||
platform: parsed.platform,
|
||||
arch: parsed.arch,
|
||||
file_name: file.name,
|
||||
file_size: file.size || null,
|
||||
sha256: file.sha256,
|
||||
gitea_package: pkg,
|
||||
published_at: publishedAt && !Number.isNaN(publishedAt.getTime()) ? publishedAt : null
|
||||
});
|
||||
|
||||
result.artifacts_registered += 1;
|
||||
if (isNew) result.artifacts_new += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({
|
||||
message: 'Catálogo de versiones de CRAS sincronizado con Gitea',
|
||||
context: {
|
||||
versions_seen: result.versions_seen,
|
||||
artifacts_registered: result.artifacts_registered,
|
||||
artifacts_new: result.artifacts_new,
|
||||
skipped: result.skipped.length
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
199
src/lib/server/cras-verify.test.ts
Normal file
199
src/lib/server/cras-verify.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Identificación del sistema remoto en la sonda de verificación.
|
||||
*
|
||||
* El bug que esto fija: la sonda elegía la rama con `platform === 'windows' ? PS : POSIX`, así
|
||||
* que una plataforma desconocida (`null`) caía a **Linux**. En la base real `restore_targets.os`
|
||||
* estaba vacío en los 4 servidores, así que 3 de ellos se habrían diagnosticado corriéndoles
|
||||
* `id -u`, `sudo`, `test -x /opt/...` y `systemctl` — reportando "sin privilegios, binario no
|
||||
* instalado, servicio detenido" en un Windows perfectamente sano.
|
||||
*
|
||||
* Agravante: el chequeo de ejecución era `echo ok`, que **también funciona en cmd.exe**, así que
|
||||
* el falso diagnóstico venía precedido de un "shell disponible: ok" tranquilizador.
|
||||
*
|
||||
* El tripwire del final es lo que impide que vuelva: en la rama Windows no debe emitirse ningún
|
||||
* comando POSIX.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { probeRemoteSystem } from './cras-verify';
|
||||
|
||||
/**
|
||||
* Cliente SFTP falso. `responder` decide qué contesta cada comando; se registran todos los
|
||||
* comandos emitidos para poder auditarlos.
|
||||
*/
|
||||
function fakeSftp(responder: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
|
||||
const commands: string[] = [];
|
||||
const sftp = {
|
||||
client: {
|
||||
exec(command: string, callback: (err: Error | null, stream?: unknown) => void) {
|
||||
commands.push(command);
|
||||
const result = responder(command);
|
||||
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
const stderrListeners: ((...args: unknown[]) => void)[] = [];
|
||||
const stream = {
|
||||
on(event: string, fn: (...args: unknown[]) => void) {
|
||||
(listeners[event] ??= []).push(fn);
|
||||
return stream;
|
||||
},
|
||||
stderr: {
|
||||
on(_e: string, fn: (...args: unknown[]) => void) {
|
||||
stderrListeners.push(fn);
|
||||
return stream.stderr;
|
||||
}
|
||||
}
|
||||
};
|
||||
callback(null, stream);
|
||||
setImmediate(() => {
|
||||
if (result.stdout) for (const fn of listeners.data ?? []) fn(Buffer.from(result.stdout));
|
||||
if (result.stderr) for (const fn of stderrListeners) fn(Buffer.from(result.stderr));
|
||||
for (const fn of listeners.exit ?? []) fn(result.code ?? 0);
|
||||
for (const fn of listeners.close ?? []) fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
return { sftp, commands };
|
||||
}
|
||||
|
||||
/** ¿El comando es la prueba POSIX (no la de PowerShell)? */
|
||||
const esPosix = (c: string) => c.includes('uname');
|
||||
/** ¿Es la prueba de PowerShell? */
|
||||
const esPowerShell = (c: string) => c.includes('-EncodedCommand');
|
||||
|
||||
describe('probeRemoteSystem: siempre prueba AMBAS vías', () => {
|
||||
it('lanza las dos pruebas incluso cuando la primera ya concluyó', async () => {
|
||||
// Es el corazón del arreglo: sin cascada. Aunque uname conteste, se prueba PowerShell,
|
||||
// porque es lo único que distingue un Linux de un WSL dentro de Windows.
|
||||
const { sftp, commands } = fakeSftp((c) =>
|
||||
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127, stderr: 'not found' }
|
||||
);
|
||||
await probeRemoteSystem(sftp as never);
|
||||
|
||||
expect(commands.filter(esPosix)).toHaveLength(1);
|
||||
expect(commands.filter(esPowerShell)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeRemoteSystem: los cuatro veredictos son cerrados', () => {
|
||||
it('POSIX responde y PowerShell no → linux', async () => {
|
||||
const { sftp } = fakeSftp((c) =>
|
||||
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127, stderr: 'not found' }
|
||||
);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('linux');
|
||||
expect(r.posix).toBe(true);
|
||||
expect(r.systemd).toBe(true);
|
||||
expect(r.windows).toBe(false);
|
||||
});
|
||||
|
||||
it('PowerShell responde y POSIX no → windows', async () => {
|
||||
const { sftp } = fakeSftp((c) =>
|
||||
esPowerShell(c) ? { code: 0, stdout: 'PS5 TASKS' } : { code: 1, stderr: "'uname' no se reconoce" }
|
||||
);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('windows');
|
||||
expect(r.windows).toBe(true);
|
||||
expect(r.scheduledTasks).toBe(true);
|
||||
expect(r.posix).toBe(false);
|
||||
});
|
||||
|
||||
it('ambos responden → posix_en_host_windows (WSL/Cygwin)', async () => {
|
||||
// Instalar aquí pondría el agente dentro del subsistema, no en el Windows que corre
|
||||
// SQL Server. Es el caso que hay que gritar, no resolver en silencio.
|
||||
const { sftp } = fakeSftp((c) =>
|
||||
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 0, stdout: 'PS5 TASKS' }
|
||||
);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('posix_en_host_windows');
|
||||
expect(r.posix).toBe(true);
|
||||
expect(r.windows).toBe(true);
|
||||
});
|
||||
|
||||
it('ninguno responde → sin_ejecucion (cuenta solo-SFTP o enjaulada)', async () => {
|
||||
// Respuesta certera: no es que se ignore el sistema, es que esa cuenta no sirve.
|
||||
const { sftp } = fakeSftp(() => ({ code: 1, stderr: 'This service allows sftp connections only.' }));
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('sin_ejecucion');
|
||||
});
|
||||
|
||||
it('nunca devuelve null ni undefined como veredicto', async () => {
|
||||
const escenarios: Array<(c: string) => { code?: number; stdout?: string }> = [
|
||||
() => ({ code: 0, stdout: '' }),
|
||||
() => ({ code: 0, stdout: 'basura inesperada' }),
|
||||
() => ({ code: 255 }),
|
||||
(c) => (esPosix(c) ? { code: 0, stdout: 'Darwin' } : { code: 1 })
|
||||
];
|
||||
for (const responder of escenarios) {
|
||||
const { sftp } = fakeSftp(responder);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(['linux', 'windows', 'posix_en_host_windows', 'sin_ejecucion']).toContain(r.verdict);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeRemoteSystem: capacidades, no solo identidad', () => {
|
||||
it('Linux sin systemd se detecta como linux pero systemd=false', async () => {
|
||||
// Importa porque install.sh --service no puede registrar el servicio sin systemd.
|
||||
const { sftp } = fakeSftp((c) => (esPosix(c) ? { code: 0, stdout: 'Linux' } : { code: 127 }));
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('linux');
|
||||
expect(r.systemd).toBe(false);
|
||||
});
|
||||
|
||||
it('Windows sin Get-ScheduledTask se detecta como windows pero scheduledTasks=false', async () => {
|
||||
const { sftp } = fakeSftp((c) =>
|
||||
esPowerShell(c) ? { code: 0, stdout: 'PS5' } : { code: 1 }
|
||||
);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('windows');
|
||||
expect(r.scheduledTasks).toBe(false);
|
||||
});
|
||||
|
||||
it('un exit code distinto de 0 no cuenta como respuesta', async () => {
|
||||
// Salida plausible pero con código de error: no debe tomarse por válida.
|
||||
const { sftp } = fakeSftp(() => ({ code: 1, stdout: 'Linux' }));
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.verdict).toBe('sin_ejecucion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeRemoteSystem: evidencia reportada', () => {
|
||||
it('incluye qué contestó cada prueba, para no mostrar todo con la misma confianza', async () => {
|
||||
const { sftp } = fakeSftp((c) =>
|
||||
esPosix(c) ? { code: 0, stdout: 'Linux\nSYSTEMD' } : { code: 127 }
|
||||
);
|
||||
const r = await probeRemoteSystem(sftp as never);
|
||||
expect(r.evidence).toContain('posix=Linux');
|
||||
expect(r.evidence).toContain('systemd=sí');
|
||||
expect(r.evidence).toContain('powershell=no responde');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TRIPWIRE: ningún comando POSIX en la rama Windows', () => {
|
||||
it('la prueba de PowerShell no lleva comandos de shell POSIX', async () => {
|
||||
// Es la regresión concreta que causó el bug: comandos POSIX en un Windows.
|
||||
const { sftp, commands } = fakeSftp((c) =>
|
||||
esPowerShell(c) ? { code: 0, stdout: 'PS5 TASKS' } : { code: 1 }
|
||||
);
|
||||
await probeRemoteSystem(sftp as never);
|
||||
|
||||
const psCommands = commands.filter(esPowerShell);
|
||||
expect(psCommands).toHaveLength(1);
|
||||
for (const cmd of psCommands) {
|
||||
// El script va en base64, así que se decodifica para inspeccionarlo de verdad.
|
||||
const decoded = Buffer.from(cmd.split('-EncodedCommand ')[1], 'base64').toString('utf16le');
|
||||
for (const posix of ['systemctl', 'id -u', 'sudo', 'test -x', 'uname', '/opt/']) {
|
||||
expect(decoded, `"${posix}" no debe aparecer en un comando de Windows`).not.toContain(
|
||||
posix
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('la prueba POSIX no invoca powershell', async () => {
|
||||
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'Linux' }));
|
||||
await probeRemoteSystem(sftp as never);
|
||||
for (const cmd of commands.filter(esPosix)) {
|
||||
expect(cmd.toLowerCase()).not.toContain('powershell');
|
||||
}
|
||||
});
|
||||
});
|
||||
751
src/lib/server/cras-verify.ts
Normal file
751
src/lib/server/cras-verify.ts
Normal file
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* Sonda en vivo de un servidor de restauración: responde "¿está disponible AHORA?".
|
||||
*
|
||||
* Es distinta del timestamp de `cloudrestore_status.reported_at`, que solo dice cuándo el
|
||||
* agente arrancó o guardó configuración (no es un heartbeat: se reporta en 3 momentos de
|
||||
* ciclo de vida, ninguno periódico). Un agente sano de hace una semana y un agente muerto de
|
||||
* hace una semana tienen el mismo `reported_at`; esta sonda los distingue.
|
||||
*
|
||||
* **Solo lee.** No instala, no escribe archivos ni reinicia servicios en el destino, así que
|
||||
* es seguro correrla en cualquier momento.
|
||||
*
|
||||
* Lo importante del diseño es el diagnóstico: cuando falla, dice POR QUÉ. Se hace una prueba
|
||||
* TCP cruda antes del handshake SSH para poder separar "el host no responde" de "el puerto
|
||||
* está cerrado porque sshd está detenido", que son dos problemas con remedios opuestos.
|
||||
*/
|
||||
import net from 'node:net';
|
||||
import SftpClient from 'ssh2-sftp-client';
|
||||
|
||||
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
|
||||
import { execRemote, psEncoded, shQuote } from './cras-install';
|
||||
import { listCrasTargetInventory } from './cras-releases';
|
||||
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
|
||||
import { logger } from './logger';
|
||||
|
||||
const TCP_TIMEOUT_MS = 6_000;
|
||||
const SSH_TIMEOUT_MS = 15_000;
|
||||
const CHECK_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Resultado de un chequeo individual. `skipped` = no se pudo evaluar por un fallo previo. */
|
||||
export type CheckStatus = 'ok' | 'fail' | 'warn' | 'skipped';
|
||||
|
||||
export interface VerifyCheck {
|
||||
key: string;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** Causa raíz clasificada, para poder ofrecer el remedio correcto. */
|
||||
export type VerifyDiagnosis =
|
||||
| 'ok'
|
||||
| 'sin_credenciales'
|
||||
| 'host_no_responde'
|
||||
| 'puerto_cerrado'
|
||||
| 'credenciales_rechazadas'
|
||||
| 'sin_ejecucion'
|
||||
| 'posix_en_host_windows'
|
||||
| 'sin_privilegios'
|
||||
| 'sin_instalar'
|
||||
| 'servicio_detenido'
|
||||
| 'error';
|
||||
|
||||
export interface VerifyResult {
|
||||
restore_target_id: number;
|
||||
name: string;
|
||||
platform: CrasPlatform | null;
|
||||
diagnosis: VerifyDiagnosis;
|
||||
/** Resumen de una línea, listo para la UI. */
|
||||
summary: string;
|
||||
/** Qué hacer, cuando hay algo que hacer. */
|
||||
remediation: Remediation | null;
|
||||
checks: VerifyCheck[];
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface Remediation {
|
||||
/** Descripción de para qué sirve. */
|
||||
title: string;
|
||||
/** Comando exacto a correr EN el servidor destino, para copiar y pegar. */
|
||||
command: string | null;
|
||||
/** Dónde correrlo. */
|
||||
where: string;
|
||||
/**
|
||||
* Si el agente podría aplicarlo por su cuenta (corre local, no necesita SSH). Hoy es
|
||||
* informativo: la ejecución remota desde el panel no está implementada.
|
||||
*/
|
||||
agent_could_apply: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
function check(key: string, label: string, status: CheckStatus, detail = ''): VerifyCheck {
|
||||
return { key, label, status, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Veredicto sobre el sistema del otro lado de la sesión. Nunca es "no sé": las cuatro
|
||||
* combinaciones posibles de las dos pruebas dan una respuesta cerrada y accionable.
|
||||
*/
|
||||
export type SystemVerdict = 'linux' | 'windows' | 'posix_en_host_windows' | 'sin_ejecucion';
|
||||
|
||||
export interface SystemProbe {
|
||||
/** La prueba POSIX respondió (hay shell tipo Unix). */
|
||||
posix: boolean;
|
||||
/** Hay systemd, que es lo que el instalador necesita para el modo servicio. */
|
||||
systemd: boolean;
|
||||
/** La prueba de PowerShell respondió. */
|
||||
windows: boolean;
|
||||
/** Existe el registro de tareas programadas (el equivalente de systemd en Windows). */
|
||||
scheduledTasks: boolean;
|
||||
verdict: SystemVerdict;
|
||||
/** Qué contestó cada prueba, para que la UI no muestre todo con la misma confianza. */
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifica el sistema remoto probando **las dos** vías, siempre, sin cascada y sin confiar en
|
||||
* lo que diga la base.
|
||||
*
|
||||
* Se prueban CAPACIDADES y no identidad: al instalador no le importa cómo se llama el sistema,
|
||||
* le importa si puede colocar el binario y registrar el arranque automático. Un Linux sin
|
||||
* systemd es tan inútil para el modo servicio como un Windows sin tareas programadas.
|
||||
*
|
||||
* Por qué no basta encadenar: el chequeo anterior usaba `echo ok`, que **también funciona en
|
||||
* cmd.exe**, así que un Windows pasaba como "tiene shell" y luego se le corrían `id -u`,
|
||||
* `sudo`, `test -x /opt/...` y `systemctl`. Reportaba "sin privilegios, binario no instalado,
|
||||
* servicio detenido" en un servidor perfectamente sano.
|
||||
*
|
||||
* Ninguno de los comandos escribe nada, así que es seguro lanzarlos en cualquier sistema.
|
||||
*/
|
||||
export async function probeRemoteSystem(sftp: SftpClient): Promise<SystemProbe> {
|
||||
const run = async (command: string): Promise<string> => {
|
||||
try {
|
||||
const r = await execRemote(sftp, command, CHECK_TIMEOUT_MS);
|
||||
return r.code === 0 ? r.stdout.trim() : '';
|
||||
} catch {
|
||||
// Un fallo aquí es información, no una excepción: significa que esa vía no está.
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// POSIX: nombre del kernel y si hay systemd.
|
||||
const posixOut = await run('uname -s 2>/dev/null && (command -v systemctl >/dev/null && echo SYSTEMD || true)');
|
||||
const posix = /linux|darwin|bsd/i.test(posixOut);
|
||||
const systemd = posixOut.includes('SYSTEMD');
|
||||
|
||||
// Windows: versión de PowerShell y si existe Get-ScheduledTask.
|
||||
const winOut = await run(
|
||||
psEncoded(
|
||||
'$v = $PSVersionTable.PSVersion.Major; ' +
|
||||
'$t = if (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue) {"TASKS"} else {""}; ' +
|
||||
'Write-Output "PS$v $t"'
|
||||
)
|
||||
);
|
||||
const windows = /^PS\d/.test(winOut);
|
||||
const scheduledTasks = winOut.includes('TASKS');
|
||||
|
||||
let verdict: SystemVerdict;
|
||||
if (posix && windows) verdict = 'posix_en_host_windows';
|
||||
else if (posix) verdict = 'linux';
|
||||
else if (windows) verdict = 'windows';
|
||||
else verdict = 'sin_ejecucion';
|
||||
|
||||
const evidence = [
|
||||
`posix=${posix ? posixOut.split('\n')[0] || 'sí' : 'no responde'}`,
|
||||
`systemd=${systemd ? 'sí' : 'no'}`,
|
||||
`powershell=${windows ? winOut.split(' ')[0] : 'no responde'}`,
|
||||
`tareas=${scheduledTasks ? 'sí' : 'no'}`
|
||||
].join(', ');
|
||||
|
||||
return { posix, systemd, windows, scheduledTasks, verdict, evidence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueba TCP cruda al puerto SSH. Es lo que permite distinguir un host apagado de un sshd
|
||||
* detenido: en el primer caso la conexión expira o no hay ruta, en el segundo el sistema
|
||||
* responde con ECONNREFUSED de inmediato.
|
||||
*/
|
||||
function probeTcp(
|
||||
host: string,
|
||||
port: number
|
||||
): Promise<{ ok: boolean; code: string | null; message: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
|
||||
const done = (ok: boolean, code: string | null, message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve({ ok, code, message });
|
||||
};
|
||||
|
||||
socket.setTimeout(TCP_TIMEOUT_MS);
|
||||
socket.once('connect', () => done(true, null, 'puerto abierto'));
|
||||
socket.once('timeout', () => done(false, 'ETIMEDOUT', 'la conexión expiró'));
|
||||
socket.once('error', (err: NodeJS.ErrnoException) =>
|
||||
done(false, err.code ?? null, err.message)
|
||||
);
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/** Traduce el error de la prueba TCP a un diagnóstico y un mensaje entendible. */
|
||||
function classifyTcpFailure(
|
||||
code: string | null,
|
||||
port: number
|
||||
): { diagnosis: VerifyDiagnosis; detail: string } {
|
||||
if (code === 'ECONNREFUSED') {
|
||||
// El host está prendido y contestó "no hay nadie escuchando": el servicio SSH está caído.
|
||||
return {
|
||||
diagnosis: 'puerto_cerrado',
|
||||
detail: `el servidor respondió pero nadie escucha en el puerto ${port}: el servicio SSH está detenido`
|
||||
};
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
|
||||
return {
|
||||
diagnosis: 'host_no_responde',
|
||||
detail: 'el host no respondió: apagado, sin red, o bloqueado por un firewall'
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
||||
return {
|
||||
diagnosis: 'host_no_responde',
|
||||
detail: 'no se pudo resolver el nombre del host (DNS)'
|
||||
};
|
||||
}
|
||||
return { diagnosis: 'error', detail: `fallo de red (${code ?? 'desconocido'})` };
|
||||
}
|
||||
|
||||
function isAuthFailure(message: string): boolean {
|
||||
const m = message.toLowerCase();
|
||||
return (
|
||||
m.includes('authentication') ||
|
||||
m.includes('all configured authentication methods failed') ||
|
||||
m.includes('permission denied')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remedio para un servicio SSH detenido. El comando se muestra para correrlo a mano en el
|
||||
* servidor: el panel no puede aplicarlo por SSH, porque justamente SSH es lo que está caído.
|
||||
*/
|
||||
function sshdRemediation(platform: CrasPlatform | null): Remediation {
|
||||
if (platform === 'linux') {
|
||||
return {
|
||||
title: 'Reiniciar el servicio SSH en el servidor',
|
||||
command: 'sudo systemctl restart sshd && sudo systemctl status sshd',
|
||||
where: 'En una terminal del servidor, con privilegios de root.',
|
||||
// La unit systemd del agente corre con User=<servicio>, sin privilegios para
|
||||
// reiniciar sshd; haría falta una regla de sudoers dedicada.
|
||||
agent_could_apply: false,
|
||||
notes: [
|
||||
'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.',
|
||||
'El agente CloudRestoreAS tampoco puede: su servicio corre como usuario sin privilegios.',
|
||||
'Si quieres que el agente lo pueda hacer, hay que darle una regla de sudoers acotada a ese comando.'
|
||||
]
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Reiniciar el servicio SSH en el servidor',
|
||||
command: 'Restart-Service sshd',
|
||||
where: 'En PowerShell como Administrador, en el servidor.',
|
||||
// La tarea del agente corre como SYSTEM con RunLevel Highest, así que sí tendría
|
||||
// permisos para reiniciar el servicio si se implementara la remediación asistida.
|
||||
agent_could_apply: true,
|
||||
notes: [
|
||||
'El panel no puede hacerlo por SSH: SSH es precisamente lo que no responde.',
|
||||
'Verifica también que el servicio arranque solo: Set-Service sshd -StartupType Automatic',
|
||||
'El agente CloudRestoreAS corre como SYSTEM en este servidor, así que sí tendría permisos para aplicarlo él mismo si se habilita la remediación asistida.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica un servidor de restauración. Nunca lanza por un fallo del destino: devuelve el
|
||||
* diagnóstico. Solo lanza si el destino no existe.
|
||||
*/
|
||||
export async function verifyCrasTarget(restoreTargetId: number): Promise<VerifyResult> {
|
||||
const inventory = await listCrasTargetInventory();
|
||||
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
|
||||
const name = row?.name ?? `#${restoreTargetId}`;
|
||||
const platform = row?.platform ?? null;
|
||||
const checks: VerifyCheck[] = [];
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
const finish = (
|
||||
diagnosis: VerifyDiagnosis,
|
||||
summary: string,
|
||||
remediation: Remediation | null = null
|
||||
): VerifyResult => ({
|
||||
restore_target_id: restoreTargetId,
|
||||
name,
|
||||
platform,
|
||||
diagnosis,
|
||||
summary,
|
||||
remediation,
|
||||
checks,
|
||||
checked_at: checkedAt
|
||||
});
|
||||
|
||||
let target: RestoreTargetSsh | null;
|
||||
try {
|
||||
target = await getRestoreTargetSsh(restoreTargetId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
checks.push(check('credenciales', 'Credenciales SSH', 'fail', message));
|
||||
return finish('error', `No se pudieron leer las credenciales de ${name}.`);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
checks.push(
|
||||
check(
|
||||
'credenciales',
|
||||
'Credenciales SSH',
|
||||
'fail',
|
||||
'faltan host, usuario o contraseña SSH'
|
||||
)
|
||||
);
|
||||
return finish('sin_credenciales', `${name} no tiene credenciales SSH completas.`, {
|
||||
title: 'Capturar las credenciales SSH del servidor',
|
||||
command: null,
|
||||
where: 'Panel → Servidores de Restauración → editar este servidor.',
|
||||
agent_could_apply: false,
|
||||
notes: ['Sin host, usuario y contraseña SSH el panel no puede verificar ni instalar.']
|
||||
});
|
||||
}
|
||||
|
||||
checks.push(
|
||||
check(
|
||||
'credenciales',
|
||||
'Credenciales SSH',
|
||||
'ok',
|
||||
`${target.ssh_username}@${target.ssh_host}:${target.ssh_port}`
|
||||
)
|
||||
);
|
||||
|
||||
// --- 1) Puerto TCP: separa host caído de sshd detenido --------------------
|
||||
const tcp = await probeTcp(target.ssh_host, target.ssh_port);
|
||||
if (!tcp.ok) {
|
||||
const { diagnosis, detail } = classifyTcpFailure(tcp.code, target.ssh_port);
|
||||
checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'fail', detail));
|
||||
for (const key of ['ssh', 'shell', 'privilegios', 'binario', 'servicio', 'config']) {
|
||||
checks.push(check(key, key, 'skipped', 'no se evaluó: no hay conexión'));
|
||||
}
|
||||
const summary =
|
||||
diagnosis === 'puerto_cerrado'
|
||||
? `${name}: el servidor está prendido pero el servicio SSH está detenido.`
|
||||
: `${name}: el host no responde.`;
|
||||
return finish(
|
||||
diagnosis,
|
||||
summary,
|
||||
diagnosis === 'puerto_cerrado' ? sshdRemediation(platform) : null
|
||||
);
|
||||
}
|
||||
checks.push(check('tcp', `Puerto ${target.ssh_port} accesible`, 'ok', 'puerto abierto'));
|
||||
|
||||
// --- 2) Handshake y autenticación SSH ------------------------------------
|
||||
const sftp = new SftpClient(`cras-verify-${restoreTargetId}`);
|
||||
try {
|
||||
try {
|
||||
await sftp.connect({
|
||||
host: target.ssh_host,
|
||||
port: target.ssh_port,
|
||||
username: target.ssh_username,
|
||||
password: target.ssh_password,
|
||||
readyTimeout: SSH_TIMEOUT_MS
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const auth = isAuthFailure(message);
|
||||
checks.push(
|
||||
check('ssh', 'Autenticación SSH', 'fail', auth ? 'credenciales rechazadas' : message)
|
||||
);
|
||||
for (const key of ['shell', 'privilegios', 'binario', 'servicio', 'config']) {
|
||||
checks.push(check(key, key, 'skipped', 'no se evaluó: no hubo sesión'));
|
||||
}
|
||||
return finish(
|
||||
auth ? 'credenciales_rechazadas' : 'error',
|
||||
auth
|
||||
? `${name}: el servidor SSH respondió pero rechazó las credenciales.`
|
||||
: `${name}: falló la conexión SSH — ${message}`,
|
||||
auth
|
||||
? {
|
||||
title: 'Actualizar las credenciales SSH',
|
||||
command: null,
|
||||
where: 'Panel → Servidores de Restauración → editar este servidor.',
|
||||
agent_could_apply: false,
|
||||
notes: ['El servicio SSH está arriba; el usuario o la contraseña no coinciden.']
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
checks.push(check('ssh', 'Autenticación SSH', 'ok', 'sesión establecida'));
|
||||
|
||||
// --- 3) Identificar el sistema: se prueban AMBOS, siempre ------------
|
||||
const system = await probeRemoteSystem(sftp);
|
||||
checks.push(
|
||||
check(
|
||||
'ejecucion',
|
||||
'Ejecución de comandos',
|
||||
system.verdict === 'sin_ejecucion' ? 'fail' : 'ok',
|
||||
system.evidence
|
||||
)
|
||||
);
|
||||
|
||||
if (system.verdict === 'sin_ejecucion') {
|
||||
for (const key of ['privilegios', 'binario', 'servicio', 'config']) {
|
||||
checks.push(
|
||||
check(key, key, 'skipped', 'no se evaluó: la cuenta no ejecuta comandos')
|
||||
);
|
||||
}
|
||||
return finish(
|
||||
'sin_ejecucion',
|
||||
`${name}: la cuenta SSH conecta pero no ejecuta comandos (solo-SFTP o enjaulada).`,
|
||||
{
|
||||
title: 'Usar una cuenta SSH con shell para instalar',
|
||||
command: null,
|
||||
where: 'Configuración de sshd en el servidor, o capturar otra cuenta en el panel.',
|
||||
agent_could_apply: false,
|
||||
notes: [
|
||||
'El forward de respaldos funciona igual: para eso basta SFTP.',
|
||||
'La instalación remota sí necesita ejecutar comandos y privilegios, y puede usar una cuenta distinta de la del forward.',
|
||||
'Revisa ForceCommand internal-sftp / ChrootDirectory en sshd_config.'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// El sistema DETECTADO manda sobre lo que digan la base o el agente: es lo que hay del
|
||||
// otro lado de esta sesión. Si contradicen, se reporta como hallazgo aparte.
|
||||
const detected: CrasPlatform = system.verdict === 'windows' ? 'windows' : 'linux';
|
||||
const declared = platform;
|
||||
if (declared && declared !== detected) {
|
||||
checks.push(
|
||||
check(
|
||||
'coherencia',
|
||||
'Plataforma registrada vs. real',
|
||||
'fail',
|
||||
`el panel tiene "${declared}" pero el servidor responde como ${detected}: ` +
|
||||
'el instalador elegiría el artefacto equivocado'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Llegar a un POSIX dentro de un host Windows significa que la sesión cae en WSL/Cygwin
|
||||
// y NO en el Windows que corre SQL Server. Instalar ahí dejaría el servicio dentro del
|
||||
// subsistema y las rutas C:\ del .env apuntando a otro sitio.
|
||||
if (system.verdict === 'posix_en_host_windows') {
|
||||
checks.push(
|
||||
check(
|
||||
'subsistema',
|
||||
'Destino de la sesión SSH',
|
||||
'fail',
|
||||
'responden POSIX y PowerShell: estás llegando a un subsistema (WSL/Cygwin), no al host Windows'
|
||||
)
|
||||
);
|
||||
for (const key of ['privilegios', 'binario', 'servicio', 'config']) {
|
||||
checks.push(check(key, key, 'skipped', 'no se evaluó: el destino es ambiguo'));
|
||||
}
|
||||
return finish(
|
||||
'posix_en_host_windows',
|
||||
`${name}: la sesión SSH entra a un subsistema POSIX dentro de un host Windows, no al host.`,
|
||||
{
|
||||
title: 'Apuntar el SSH al servicio del host Windows',
|
||||
command: null,
|
||||
where: 'Servidores de Restauración → corregir host/puerto SSH de este servidor.',
|
||||
agent_could_apply: false,
|
||||
notes: [
|
||||
'Instalar aquí pondría CloudRestoreAS dentro del subsistema: no arrancaría con la máquina.',
|
||||
'Las rutas C:\\ del config/.env no serían las mismas que ve SQL Server.',
|
||||
'Verifica que el puerto 22 apunte al sshd de Windows y no al del subsistema.'
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// --- 4) Privilegios, binario, servicio y configuración ---------------
|
||||
const detail =
|
||||
detected === 'windows'
|
||||
? await inspectWindows(sftp, row?.reported_install_path ?? null)
|
||||
: await inspectLinux(sftp, row?.reported_install_path ?? null);
|
||||
checks.push(...detail.checks);
|
||||
|
||||
if (detail.diagnosis !== 'ok') {
|
||||
return finish(detail.diagnosis, `${name}: ${detail.summary}`, detail.remediation);
|
||||
}
|
||||
return finish('ok', `${name}: disponible — ${detail.summary}`);
|
||||
} finally {
|
||||
try {
|
||||
await sftp.end();
|
||||
} catch (err) {
|
||||
logger.warn({
|
||||
message: 'No se pudo cerrar la sesión SFTP de verificación',
|
||||
context: {
|
||||
target: name,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface InspectResult {
|
||||
checks: VerifyCheck[];
|
||||
diagnosis: VerifyDiagnosis;
|
||||
summary: string;
|
||||
remediation: Remediation | null;
|
||||
}
|
||||
|
||||
async function inspectLinux(
|
||||
sftp: SftpClient,
|
||||
reportedInstallPath: string | null
|
||||
): Promise<InspectResult> {
|
||||
const checks: VerifyCheck[] = [];
|
||||
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
|
||||
|
||||
const id = await execRemote(sftp, 'id -u', CHECK_TIMEOUT_MS);
|
||||
let privileged = id.code === 0 && id.stdout.trim() === '0';
|
||||
let privLabel = 'root';
|
||||
if (!privileged) {
|
||||
const sudo = await execRemote(sftp, 'sudo -n true', CHECK_TIMEOUT_MS);
|
||||
privileged = sudo.code === 0;
|
||||
privLabel = privileged ? 'sudo sin contraseña' : 'sin privilegios';
|
||||
}
|
||||
checks.push(
|
||||
check(
|
||||
'privilegios',
|
||||
'Privilegios para instalar',
|
||||
privileged ? 'ok' : 'warn',
|
||||
privileged ? privLabel : 'no es root y no tiene sudo sin contraseña'
|
||||
)
|
||||
);
|
||||
|
||||
const bin = await execRemote(
|
||||
sftp,
|
||||
`test -x ${shQuote(`${prefix}/CloudRestoreAS`)} && echo si || echo no`,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const installed = bin.stdout.trim() === 'si';
|
||||
checks.push(
|
||||
check(
|
||||
'binario',
|
||||
'Binario instalado',
|
||||
installed ? 'ok' : 'fail',
|
||||
installed ? `${prefix}/CloudRestoreAS` : `no está en ${prefix}`
|
||||
)
|
||||
);
|
||||
|
||||
const version = await execRemote(
|
||||
sftp,
|
||||
`cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const deployed = version.stdout.trim();
|
||||
|
||||
const env = await execRemote(
|
||||
sftp,
|
||||
`test -f ${shQuote(`${prefix}/config/.env`)} && echo si || echo no`,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const hasEnv = env.stdout.trim() === 'si';
|
||||
checks.push(
|
||||
check(
|
||||
'config',
|
||||
'Configuración presente',
|
||||
hasEnv ? 'ok' : 'warn',
|
||||
hasEnv ? `${prefix}/config/.env` : 'falta config/.env'
|
||||
)
|
||||
);
|
||||
|
||||
const active = await execRemote(
|
||||
sftp,
|
||||
'systemctl is-active cloudrestoreas 2>/dev/null || true',
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const state = active.stdout.trim() || 'desconocido';
|
||||
const running = state === 'active';
|
||||
checks.push(
|
||||
check('servicio', 'Servicio corriendo', running ? 'ok' : 'fail', `systemd: ${state}`)
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'sin_instalar',
|
||||
summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`,
|
||||
remediation: {
|
||||
title: 'Instalar CloudRestoreAS en este servidor',
|
||||
command: null,
|
||||
where: 'Panel → Versiones CRAS → Instalar.',
|
||||
agent_could_apply: false,
|
||||
notes: privileged
|
||||
? []
|
||||
: ['Antes hay que resolver los privilegios: se necesita root o sudo sin contraseña.']
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!running) {
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'servicio_detenido',
|
||||
summary: `instalado (${deployed || 'versión desconocida'}) pero el servicio está ${state}.`,
|
||||
remediation: {
|
||||
title: 'Arrancar el servicio del agente',
|
||||
command: 'sudo systemctl start cloudrestoreas && sudo systemctl status cloudrestoreas',
|
||||
where: 'En una terminal del servidor, con privilegios de root.',
|
||||
agent_could_apply: false,
|
||||
notes: ['Para ver la causa: sudo journalctl -u cloudrestoreas -n 50']
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'ok',
|
||||
summary: `servicio activo, versión ${deployed || 'sin sello'}, en ${prefix}.`,
|
||||
remediation: null
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectWindows(
|
||||
sftp: SftpClient,
|
||||
reportedInstallPath: string | null
|
||||
): Promise<InspectResult> {
|
||||
const checks: VerifyCheck[] = [];
|
||||
const prefix =
|
||||
effectiveInstallPath(reportedInstallPath, 'windows') ?? DEFAULT_INSTALL_PATHS.windows;
|
||||
|
||||
const admin = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
'$p=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent();' +
|
||||
'if($p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){"admin"}else{"limitado"}'
|
||||
),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const isAdmin = admin.stdout.trim() === 'admin';
|
||||
checks.push(
|
||||
check(
|
||||
'privilegios',
|
||||
'Privilegios para instalar',
|
||||
isAdmin ? 'ok' : 'warn',
|
||||
isAdmin ? 'Administrador' : 'la cuenta SSH no es Administrador'
|
||||
)
|
||||
);
|
||||
|
||||
const bin = await execRemote(
|
||||
sftp,
|
||||
psEncoded(`if (Test-Path -LiteralPath '${prefix}\\CloudRestoreAS.exe') {"si"} else {"no"}`),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const installed = bin.stdout.trim() === 'si';
|
||||
checks.push(
|
||||
check(
|
||||
'binario',
|
||||
'Binario instalado',
|
||||
installed ? 'ok' : 'fail',
|
||||
installed ? `${prefix}\\CloudRestoreAS.exe` : `no está en ${prefix}`
|
||||
)
|
||||
);
|
||||
|
||||
const version = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
`if (Test-Path -LiteralPath '${prefix}\\config\\.version') ` +
|
||||
`{ (Get-Content -LiteralPath '${prefix}\\config\\.version' -Raw).Trim() }`
|
||||
),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const deployed = version.stdout.trim();
|
||||
|
||||
const env = await execRemote(
|
||||
sftp,
|
||||
psEncoded(`if (Test-Path -LiteralPath '${prefix}\\config\\.env') {"si"} else {"no"}`),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const hasEnv = env.stdout.trim() === 'si';
|
||||
checks.push(
|
||||
check(
|
||||
'config',
|
||||
'Configuración presente',
|
||||
hasEnv ? 'ok' : 'warn',
|
||||
hasEnv ? `${prefix}\\config\\.env` : 'falta config\\.env'
|
||||
)
|
||||
);
|
||||
|
||||
const task = await execRemote(
|
||||
sftp,
|
||||
psEncoded(
|
||||
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
|
||||
'if ($t) { $t.State } else { "no-registrada" }'
|
||||
),
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
const state = task.stdout.trim() || 'desconocido';
|
||||
// Ready = registrada y esperando el disparador; Running = ejecutándose. Ambas cuentan
|
||||
// como "el arranque automático está configurado".
|
||||
const scheduled = state === 'Running' || state === 'Ready';
|
||||
checks.push(
|
||||
check(
|
||||
'servicio',
|
||||
'Arranque automático',
|
||||
scheduled ? 'ok' : 'fail',
|
||||
`tarea CloudRestoreAS: ${state}`
|
||||
)
|
||||
);
|
||||
|
||||
// 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');
|
||||
checks.push(
|
||||
check('proceso', 'Proceso en ejecución', running ? 'ok' : 'warn', procOut || 'desconocido')
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'sin_instalar',
|
||||
summary: `SSH bien, pero CloudRestoreAS no está instalado en ${prefix}.`,
|
||||
remediation: {
|
||||
title: 'Instalar CloudRestoreAS en este servidor',
|
||||
command: null,
|
||||
where: 'Panel → Versiones CRAS → Instalar.',
|
||||
agent_could_apply: false,
|
||||
notes: isAdmin
|
||||
? []
|
||||
: ['Antes hay que usar una cuenta SSH Administradora: la tarea corre como SYSTEM.']
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!scheduled || !running) {
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'servicio_detenido',
|
||||
summary: `instalado (${deployed || 'versión desconocida'}) pero no está corriendo (tarea: ${state}).`,
|
||||
remediation: {
|
||||
title: 'Arrancar el agente en el servidor',
|
||||
command: 'Start-ScheduledTask -TaskName CloudRestoreAS',
|
||||
where: 'En PowerShell como Administrador, en el servidor.',
|
||||
agent_could_apply: false,
|
||||
notes: [
|
||||
`Logs del agente: ${prefix}\\config\\logs`,
|
||||
scheduled ? '' : 'La tarea no está registrada: reinstala desde el panel con arranque de servicio.'
|
||||
].filter(Boolean)
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
checks,
|
||||
diagnosis: 'ok',
|
||||
summary: `agente corriendo, versión ${deployed || 'sin sello'}, en ${prefix}.`,
|
||||
remediation: null
|
||||
};
|
||||
}
|
||||
216
src/lib/server/gitea-packages.test.ts
Normal file
216
src/lib/server/gitea-packages.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Cliente del registro de paquetes genéricos de Gitea.
|
||||
*
|
||||
* `fetch` se stubea: lo que se prueba es el parseo, el header de auth y el manejo de errores.
|
||||
* Importa especialmente que el sha256 solo se acepte si tiene forma de sha256 — el panel lo
|
||||
* usa para verificar descargas y un valor basura haría fallar toda instalación con un error
|
||||
* confuso, o peor, se aceptaría un hash truncado.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
import {
|
||||
GiteaError,
|
||||
isGiteaConfigured,
|
||||
listPackageFiles,
|
||||
listPackageVersions,
|
||||
openPackageFile,
|
||||
packageLocation,
|
||||
packageName
|
||||
} from './gitea-packages';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
const spy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
handler(String(input), init)
|
||||
);
|
||||
globalThis.fetch = spy as unknown as typeof fetch;
|
||||
return spy;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
env.GITEA_TOKEN = 'token-de-prueba';
|
||||
env.GITEA_BASE_URL = 'https://git.ejemplo.test';
|
||||
env.GITEA_OWNER = 'ADUANASOFT';
|
||||
env.CRAS_PACKAGE_NAME = 'cloudrestoreas';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const key of ['GITEA_TOKEN', 'GITEA_BASE_URL', 'GITEA_OWNER', 'CRAS_PACKAGE_NAME']) {
|
||||
delete env[key];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('configuración', () => {
|
||||
it('isGiteaConfigured refleja la presencia del token', () => {
|
||||
expect(isGiteaConfigured()).toBe(true);
|
||||
env.GITEA_TOKEN = ' ';
|
||||
expect(isGiteaConfigured()).toBe(false);
|
||||
// Cadena vacía y no `delete`: es el caso real de producción, porque
|
||||
// `GITEA_TOKEN=${GITEA_TOKEN:-}` en el docker-compose deja la variable definida y vacía.
|
||||
env.GITEA_TOKEN = '';
|
||||
expect(isGiteaConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('packageName y packageLocation usan la configuración', () => {
|
||||
expect(packageName()).toBe('cloudrestoreas');
|
||||
expect(packageLocation()).toBe(
|
||||
'https://git.ejemplo.test/api/packages/ADUANASOFT/generic/cloudrestoreas'
|
||||
);
|
||||
});
|
||||
|
||||
it('sin token, cualquier llamada falla con 500 en lugar de pegarle a Gitea', async () => {
|
||||
env.GITEA_TOKEN = '';
|
||||
const spy = stubFetch(() => jsonResponse([]));
|
||||
await expect(listPackageVersions()).rejects.toThrow(GiteaError);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPackageVersions', () => {
|
||||
it('manda el token y devuelve solo las versiones del paquete exacto', async () => {
|
||||
const spy = stubFetch((url) => {
|
||||
expect(url).toContain('/api/v1/packages/ADUANASOFT');
|
||||
expect(url).toContain('type=generic');
|
||||
return jsonResponse([
|
||||
{ name: 'cloudrestoreas', version: '1.1.0', created_at: '2026-07-29T10:00:00Z' },
|
||||
{ name: 'cloudrestoreas', version: '1.0.0', created_at: '2026-01-25T10:00:00Z' },
|
||||
// `q=` de Gitea busca por coincidencia parcial: este NO debe entrar, o sus
|
||||
// artefactos se registrarían como si fueran de producción.
|
||||
{ name: 'cloudrestoreas-beta', version: '2.0.0', created_at: null }
|
||||
]);
|
||||
});
|
||||
|
||||
const versions = await listPackageVersions();
|
||||
expect(versions.map((v) => v.version)).toEqual(['1.1.0', '1.0.0']);
|
||||
|
||||
const init = spy.mock.calls[0][1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('token token-de-prueba');
|
||||
});
|
||||
|
||||
it('descarta entradas sin versión', async () => {
|
||||
stubFetch(() =>
|
||||
jsonResponse([
|
||||
{ name: 'cloudrestoreas', version: '' },
|
||||
{ name: 'cloudrestoreas', version: '1.1.0' }
|
||||
])
|
||||
);
|
||||
expect((await listPackageVersions()).map((v) => v.version)).toEqual(['1.1.0']);
|
||||
});
|
||||
|
||||
it('401/403 explican que el token fue rechazado', async () => {
|
||||
stubFetch(() => new Response('nope', { status: 401 }));
|
||||
await expect(listPackageVersions()).rejects.toThrow(/token/i);
|
||||
});
|
||||
|
||||
it('un error de red se traduce a 503, no a una excepción cruda', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
await expect(listPackageVersions()).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('una respuesta que no es arreglo se rechaza', async () => {
|
||||
stubFetch(() => jsonResponse({ mensaje: 'no es lista' }));
|
||||
await expect(listPackageVersions()).rejects.toThrow(/inesperado/i);
|
||||
});
|
||||
|
||||
it('JSON inválido se rechaza con 502', async () => {
|
||||
stubFetch(() => new Response('no soy json', { status: 200 }));
|
||||
await expect(listPackageVersions()).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPackageFiles', () => {
|
||||
it('devuelve nombre, tamaño y sha256', async () => {
|
||||
stubFetch((url) => {
|
||||
expect(url).toContain('/generic/cloudrestoreas/1.1.0/files');
|
||||
return jsonResponse([
|
||||
{
|
||||
name: 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz',
|
||||
size: 281696057,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const files = await listPackageFiles('1.1.0');
|
||||
expect(files).toEqual([
|
||||
{
|
||||
name: 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz',
|
||||
size: 281696057,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('normaliza el sha256 a minúsculas', async () => {
|
||||
stubFetch(() => jsonResponse([{ name: 'x.zip', size: 1, sha256: 'A'.repeat(64) }]));
|
||||
expect((await listPackageFiles('1.1.0'))[0].sha256).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('descarta un sha256 con forma inválida en lugar de propagarlo', async () => {
|
||||
// Un hash truncado o basura no sirve para verificar; mejor null y que la UI avise.
|
||||
stubFetch(() =>
|
||||
jsonResponse([
|
||||
{ name: 'corto.zip', size: 1, sha256: 'abc123' },
|
||||
{ name: 'vacio.zip', size: 1, sha256: '' },
|
||||
{ name: 'ausente.zip', size: 1 },
|
||||
{ name: 'nohex.zip', size: 1, sha256: 'z'.repeat(64) }
|
||||
])
|
||||
);
|
||||
const files = await listPackageFiles('1.1.0');
|
||||
expect(files.map((f) => f.sha256)).toEqual([null, null, null, null]);
|
||||
});
|
||||
|
||||
it('un tamaño inválido se normaliza a 0, no a NaN', async () => {
|
||||
stubFetch(() =>
|
||||
jsonResponse([
|
||||
{ name: 'a.zip', sha256: 'a'.repeat(64) },
|
||||
{ name: 'b.zip', size: -5, sha256: 'b'.repeat(64) },
|
||||
{ name: 'c.zip', size: 'mucho', sha256: 'c'.repeat(64) }
|
||||
])
|
||||
);
|
||||
expect((await listPackageFiles('1.1.0')).map((f) => f.size)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('404 indica que la versión no existe', async () => {
|
||||
stubFetch(() => new Response('', { status: 404 }));
|
||||
await expect(listPackageFiles('9.9.9')).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('openPackageFile', () => {
|
||||
it('devuelve el stream y el tamaño declarado', async () => {
|
||||
stubFetch((url) => {
|
||||
expect(url).toContain('/api/packages/ADUANASOFT/generic/cloudrestoreas/1.1.0/');
|
||||
expect(url).toContain('CloudRestoreAS-1.1.0-win-x86_64.zip');
|
||||
return new Response('bytes', { status: 200, headers: { 'content-length': '5' } });
|
||||
});
|
||||
|
||||
const download = await openPackageFile('1.1.0', 'CloudRestoreAS-1.1.0-win-x86_64.zip');
|
||||
expect(download.size).toBe(5);
|
||||
expect(download.body).toBeTruthy();
|
||||
});
|
||||
|
||||
it('404 se traduce a un mensaje que nombra el archivo y la versión', async () => {
|
||||
stubFetch(() => new Response('', { status: 404 }));
|
||||
await expect(openPackageFile('1.1.0', 'falta.zip')).rejects.toThrow(/falta\.zip.*1\.1\.0/);
|
||||
});
|
||||
|
||||
it('sin content-length el tamaño queda en null (no en 0)', async () => {
|
||||
stubFetch(() => new Response('bytes', { status: 200 }));
|
||||
const download = await openPackageFile('1.1.0', 'x.zip');
|
||||
expect(download.size).toBeNull();
|
||||
});
|
||||
});
|
||||
229
src/lib/server/gitea-packages.ts
Normal file
229
src/lib/server/gitea-packages.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Cliente del registro de paquetes GENÉRICOS de Gitea, donde se publican los binarios de
|
||||
* CloudRestoreAS (`publish-release.sh` los sube).
|
||||
*
|
||||
* Gitea es la fuente de verdad de los bytes y **calcula el sha256 de cada archivo**, así
|
||||
* que el panel no necesita mantener su propio manifiesto de integridad: descubre versiones
|
||||
* leyendo esta API y verifica cada descarga contra ese hash.
|
||||
*
|
||||
* El panel solo LEE: el token requiere scope `read:package`. Publicar es tarea del script
|
||||
* de build, que corre en la máquina de desarrollo con un token distinto.
|
||||
*/
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { logger } from './logger';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://git.aduanasoft.com';
|
||||
const DEFAULT_OWNER = 'ADUANASOFT';
|
||||
const DEFAULT_PACKAGE = 'cloudrestoreas';
|
||||
|
||||
/** Timeouts: listar metadatos es rápido; descargar un artefacto de ~270 MB no. */
|
||||
const METADATA_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class GiteaError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GiteaError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface GiteaPackageFile {
|
||||
name: string;
|
||||
size: number;
|
||||
sha256: string | null;
|
||||
}
|
||||
|
||||
export interface GiteaPackageVersion {
|
||||
version: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return (env.GITEA_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function owner(): string {
|
||||
return env.GITEA_OWNER || DEFAULT_OWNER;
|
||||
}
|
||||
|
||||
export function packageName(): string {
|
||||
return env.CRAS_PACKAGE_NAME || DEFAULT_PACKAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sin token no se puede leer el registro. Se expone para que la UI muestre "Gitea no
|
||||
* configurado" en lugar de reventar con un 500 en cada carga de la página.
|
||||
*/
|
||||
export function isGiteaConfigured(): boolean {
|
||||
return Boolean(env.GITEA_TOKEN && env.GITEA_TOKEN.trim());
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = (env.GITEA_TOKEN || '').trim();
|
||||
if (!token) {
|
||||
throw new GiteaError(500, 'GITEA_TOKEN no configurado en el panel');
|
||||
}
|
||||
return { Authorization: `token ${token}`, Accept: 'application/json' };
|
||||
}
|
||||
|
||||
/** Descripción del paquete para mostrar en la UI y en los logs. */
|
||||
export function packageLocation(): string {
|
||||
return `${baseUrl()}/api/packages/${owner()}/generic/${packageName()}`;
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: authHeaders(),
|
||||
signal: AbortSignal.timeout(METADATA_TIMEOUT_MS)
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GiteaError) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new GiteaError(503, `No se pudo contactar a Gitea: ${message}`);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new GiteaError(
|
||||
response.status,
|
||||
'Gitea rechazó el token del panel (revisa GITEA_TOKEN y su scope read:package)'
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new GiteaError(404, 'El paquete o la versión no existe en Gitea');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GiteaError(502, `Gitea respondió ${response.status}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
throw new GiteaError(502, 'Gitea no devolvió JSON válido');
|
||||
}
|
||||
}
|
||||
|
||||
interface RawPackage {
|
||||
name?: unknown;
|
||||
version?: unknown;
|
||||
created_at?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versiones publicadas del paquete, más nuevas primero según Gitea.
|
||||
*
|
||||
* El filtro por nombre se hace aquí y no solo con `q=`: ese parámetro busca por
|
||||
* coincidencia parcial, así que un paquete llamado `cloudrestoreas-beta` entraría en los
|
||||
* resultados y sus artefactos se registrarían como si fueran de producción.
|
||||
*/
|
||||
export async function listPackageVersions(): Promise<GiteaPackageVersion[]> {
|
||||
const pkg = packageName();
|
||||
const url =
|
||||
`${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}` +
|
||||
`?type=generic&q=${encodeURIComponent(pkg)}&limit=100`;
|
||||
|
||||
const raw = await getJson<RawPackage[]>(url);
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new GiteaError(502, 'Gitea devolvió un formato inesperado al listar paquetes');
|
||||
}
|
||||
|
||||
const versions: GiteaPackageVersion[] = [];
|
||||
for (const entry of raw) {
|
||||
if (String(entry?.name ?? '') !== pkg) continue;
|
||||
const version = String(entry?.version ?? '').trim();
|
||||
if (!version) continue;
|
||||
versions.push({
|
||||
version,
|
||||
created_at: entry?.created_at ? String(entry.created_at) : null
|
||||
});
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
interface RawPackageFile {
|
||||
name?: unknown;
|
||||
size?: unknown;
|
||||
sha256?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivos de una versión, con el tamaño y el sha256 que calculó Gitea. Ese hash es el que
|
||||
* se guarda en cras_releases y contra el que se verifica la descarga a la caché local.
|
||||
*/
|
||||
export async function listPackageFiles(version: string): Promise<GiteaPackageFile[]> {
|
||||
const url =
|
||||
`${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}/generic/` +
|
||||
`${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/files`;
|
||||
|
||||
const raw = await getJson<RawPackageFile[]>(url);
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new GiteaError(502, 'Gitea devolvió un formato inesperado al listar archivos');
|
||||
}
|
||||
|
||||
const files: GiteaPackageFile[] = [];
|
||||
for (const entry of raw) {
|
||||
const name = String(entry?.name ?? '').trim();
|
||||
if (!name) continue;
|
||||
const size = Number(entry?.size);
|
||||
const sha256 = String(entry?.sha256 ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
files.push({
|
||||
name,
|
||||
size: Number.isFinite(size) && size >= 0 ? size : 0,
|
||||
sha256: /^[0-9a-f]{64}$/.test(sha256) ? sha256 : null
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export interface GiteaDownload {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre el stream de descarga de un archivo del paquete.
|
||||
*
|
||||
* SIN timeout: son artefactos de cientos de MB y un AbortSignal.timeout cortaría la
|
||||
* transferencia a media descarga en un enlace lento. El llamador (cras-artifacts) verifica
|
||||
* el sha256 al terminar, así que una descarga truncada se detecta ahí y no pasa por buena.
|
||||
*/
|
||||
export async function openPackageFile(version: string, fileName: string): Promise<GiteaDownload> {
|
||||
const url =
|
||||
`${baseUrl()}/api/packages/${encodeURIComponent(owner())}/generic/` +
|
||||
`${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/` +
|
||||
encodeURIComponent(fileName);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { headers: { Authorization: authHeaders().Authorization } });
|
||||
} catch (err) {
|
||||
if (err instanceof GiteaError) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new GiteaError(503, `No se pudo descargar de Gitea: ${message}`);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
logger.error({
|
||||
message: 'Fallo descargando artefacto de Gitea',
|
||||
context: { version, file_name: fileName, status: response.status }
|
||||
});
|
||||
if (response.status === 404) {
|
||||
throw new GiteaError(404, `El archivo ${fileName} no existe en la versión ${version}`);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new GiteaError(response.status, 'Gitea rechazó el token del panel');
|
||||
}
|
||||
throw new GiteaError(502, `Gitea respondió ${response.status} al descargar ${fileName}`);
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
return {
|
||||
body: response.body,
|
||||
size: Number.isFinite(declared) && declared > 0 ? declared : null
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user