From 14b611c5816e7c7d4175841d12f29bfdc6d2ac03 Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 30 Jul 2026 13:52:45 +0000 Subject: [PATCH] feature/interfaz-binarios (#19) Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/19 Co-authored-by: hreyes Co-committed-by: hreyes --- .env.example | 30 + .gitignore | 1 + Jenkinsfile | 4 + README.md | 45 + database/schema.sql | 71 + docker-compose.prod.yml | 27 +- docker-compose.yml | 13 + src/lib/components/AppShell.svelte | 3 +- src/lib/components/Spinner.svelte | 34 + src/lib/cras-install-progress.test.ts | 106 + src/lib/cras-install-progress.ts | 119 + src/lib/cras-version.test.ts | 203 ++ src/lib/cras-version.ts | 240 ++ src/lib/icons/README.md | 36 + .../icons/material-icons-outlined.codepoints | 2195 +++++++++++++++++ src/lib/icons/material-icons.test.ts | 186 ++ src/lib/restore-filter.test.ts | 170 ++ src/lib/restore-filter.ts | 91 + src/lib/server/api-error.ts | 5 +- src/lib/server/controldesk-pg.test.ts | 116 +- src/lib/server/controldesk-pg.ts | 179 +- src/lib/server/cras-artifacts.test.ts | 273 ++ src/lib/server/cras-artifacts.ts | 347 +++ src/lib/server/cras-install.test.ts | 202 ++ src/lib/server/cras-install.ts | 929 +++++++ src/lib/server/cras-releases.ts | 620 +++++ src/lib/server/cras-sync.test.ts | 68 + src/lib/server/cras-sync.ts | 160 ++ src/lib/server/cras-verify.test.ts | 199 ++ src/lib/server/cras-verify.ts | 751 ++++++ src/lib/server/gitea-packages.test.ts | 216 ++ src/lib/server/gitea-packages.ts | 229 ++ src/routes/+page.server.ts | 78 +- src/routes/+page.svelte | 305 ++- src/routes/api/restore/agent-sync/+server.ts | 67 + .../api/restore/instance-config/+server.ts | 72 +- .../servidores-restauracion/+page.svelte | 11 +- src/routes/versiones-cras/+page.server.ts | 437 ++++ src/routes/versiones-cras/+page.svelte | 1419 +++++++++++ .../versiones-cras/install-runs/+server.ts | 68 + src/routes/versiones-cras/verify/+server.ts | 59 + 41 files changed, 10355 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/Spinner.svelte create mode 100644 src/lib/cras-install-progress.test.ts create mode 100644 src/lib/cras-install-progress.ts create mode 100644 src/lib/cras-version.test.ts create mode 100644 src/lib/cras-version.ts create mode 100644 src/lib/icons/README.md create mode 100644 src/lib/icons/material-icons-outlined.codepoints create mode 100644 src/lib/icons/material-icons.test.ts create mode 100644 src/lib/restore-filter.test.ts create mode 100644 src/lib/restore-filter.ts create mode 100644 src/lib/server/cras-artifacts.test.ts create mode 100644 src/lib/server/cras-artifacts.ts create mode 100644 src/lib/server/cras-install.test.ts create mode 100644 src/lib/server/cras-install.ts create mode 100644 src/lib/server/cras-releases.ts create mode 100644 src/lib/server/cras-sync.test.ts create mode 100644 src/lib/server/cras-sync.ts create mode 100644 src/lib/server/cras-verify.test.ts create mode 100644 src/lib/server/cras-verify.ts create mode 100644 src/lib/server/gitea-packages.test.ts create mode 100644 src/lib/server/gitea-packages.ts create mode 100644 src/routes/api/restore/agent-sync/+server.ts create mode 100644 src/routes/versiones-cras/+page.server.ts create mode 100644 src/routes/versiones-cras/+page.svelte create mode 100644 src/routes/versiones-cras/install-runs/+server.ts create mode 100644 src/routes/versiones-cras/verify/+server.ts diff --git a/.env.example b/.env.example index 7ce9eb9..c75e5b2 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,33 @@ SECRET_KEY= # Puede quedar vacía en instalaciones nuevas. Generar (si aplica) con: # node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" ENCRYPTION_KEY= + +# ============================================================================ +# Distribución de versiones de CloudRestoreAS (pantalla /versiones-cras) +# +# Los binarios se publican en el registro de paquetes GENÉRICOS de Gitea desde el build +# local del repo CloudRecoveryAS (`build-all.sh --publish`). El panel los descubre leyendo +# esa API, los descarga verificando el sha256 que Gitea calcula, y los instala en cada +# servidor de restauración por SSH/SFTP. +# ============================================================================ + +# Instancia de Gitea y organización dueña del paquete. +GITEA_BASE_URL=https://git.aduanasoft.com +GITEA_OWNER=ADUANASOFT +CRAS_PACKAGE_NAME=cloudrestoreas + +# PAT de Gitea. El panel solo LEE el registro, así que basta el scope `read:package`. +# (El token con `write:package` vive en la máquina de build, no aquí.) +# Sin este token /versiones-cras carga pero avisa que no puede sincronizar. +GITEA_TOKEN= + +# Caché local de artefactos. Cada uno pesa ~270 MB y se publican dos por versión, así que la +# carpeta crece ~540 MB por versión; CRAS_CACHE_KEEP_VERSIONS limita cuántas se conservan +# (las versiones activas nunca se borran). +CRAS_RELEASES_DIR=./local-cras-releases +CRAS_CACHE_KEEP_VERSIONS=3 + +# URL con la que el agente instalado reportará al panel. El instalador remoto la siembra en el +# config/.env del servidor destino, así que TIENE que ser alcanzable desde esos servidores (no +# localhost). Si se omite se usa ORIGIN. +PANEL_PUBLIC_URL= diff --git a/.gitignore b/.gitignore index 8d4b112..8135789 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ certs/ *.crt *.p12 *.pfx +local-cras-releases/ diff --git a/Jenkinsfile b/Jenkinsfile index b28469e..39971b6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -69,6 +69,10 @@ pipeline { npm config set strict-ssl false npm ci --legacy-peer-deps npm run check + # `check` es svelte-kit sync + tsc --noEmit, y tsc NO revisa las plantillas .svelte: + # sin los tests unitarios el pipeline no detectaba, por ejemplo, un nombre de icono + # que no existe en la fuente (se renderiza como texto literal y desborda el layout). + npm run test ' ''' } diff --git a/README.md b/README.md index fe3c0c6..23dcf58 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,51 @@ docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest - **Servidores de Restauración** (`/servidores-restauracion`): admin de Alfa/Omega/Gamma y carpeta de entrada reportada por CloudRestoreAS (solo lectura). - Contrato y prueba local: ver `INTEGRACION_PANEL.md` en el repo CloudRecoveryAS (mismo token en `CLOUDRESTORE_API_TOKEN` y en la config PANEL de CloudRestoreAS). +### Versiones CRAS (`/versiones-cras`) — instalación y actualización remota + +Distribución de los binarios del agente. Los artefactos se publican en el registro de +paquetes **genéricos de Gitea** desde el build local del repo CloudRecoveryAS +(`build-all.sh --publish`); el panel los descubre leyendo esa API y los despliega por SSH. + +``` +build local (WSL) → Gitea: ADUANASOFT/generic/cloudrestoreas/ + ↓ (el panel lee la API y guarda metadatos + sha256) + a24c.cras_releases → caché en CRAS_RELEASES_DIR + ↓ (SFTP + exec) + servidor de restauración +``` + +Flujo de operación: + +1. **Sincronizar con Gitea** — registra las versiones publicadas en `a24c.cras_releases`. + Nunca activa nada por su cuenta: publicar no debe equivaler a desplegar. +2. **Activar** — marca la versión que se instalará, **una por plataforma+arquitectura** + (índice único parcial en la BD, para que Windows y Linux tengan cada una la suya). +3. **Precargar** *(opcional)* — descarga y verifica el artefacto antes de instalar, para + separar el tiempo de descarga del de instalación. +4. **Instalar / Actualizar** por servidor — sube el paquete por SFTP, **verifica el sha256 en + el destino**, corre el instalador que viene dentro del paquete y siembra `config/.env` con + la URL, el token y la instancia: el servidor queda operativo sin configuración manual. + +Detalles que importan: + +- Los binarios **no** se guardan en Postgres (pesan ~270 MB); la BD solo tiene metadatos. +- El servidor destino **no descarga nada de internet**: el binario es autocontenido. +- El token del panel viaja al destino en un archivo `0600` por SFTP, **nunca como argumento + de un comando** (sería visible en `ps` y en el historial del servidor). +- Se exige **root o `sudo -n`** en Linux (y cuenta administradora en Windows), validado + *antes* de transferir el artefacto. No se le pasa la contraseña a `sudo`. +- El progreso se persiste paso a paso en `a24c.cras_install_runs.steps`; la UI lo consulta + por polling en `/versiones-cras/install-runs`. +- No hay subida de archivos por el navegador a propósito: la fuente de verdad es Gitea. + +Variables nuevas en `.env`: `GITEA_BASE_URL`, `GITEA_OWNER`, `GITEA_TOKEN` +(scope `read:package`), `CRAS_PACKAGE_NAME`, `CRAS_RELEASES_DIR`, +`CRAS_CACHE_KEEP_VERSIONS`, `PANEL_PUBLIC_URL`. Ver `.env.example`. + +Esquema: migración `e2f3a4b5c6d7` en el repo a24c (autoritativa), replicada en +`database/schema.sql` y en el fallback `ensureCrasSchema()` de `cras-releases.ts`. + ## Notas de Migración - **DataTables**: Se inicializan en el cliente dentro de `onMount` para mantener la compatibilidad con las tablas interactivas originales. diff --git a/database/schema.sql b/database/schema.sql index 0a37f84..366ab3c 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -164,5 +164,76 @@ CREATE TABLE IF NOT EXISTS a24c.cloudrestore_status ( reported_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Identidad del build instalado, reportada por el agente en instance-config. Es la fuente +-- autoritativa para decidir qué artefacto de CRAS le toca a cada servidor; un agente anterior +-- a 1.1.0 no las manda y quedan NULL (el panel cae a restore_targets.os). +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500); +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS platform VARCHAR(20); +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS arch VARCHAR(20); +-- Carpeta del ejecutable (APP_DIR). Distinta de input_folder: ahí vive config/.env. +ALTER TABLE a24c.cloudrestore_status ADD COLUMN IF NOT EXISTS install_path VARCHAR(500); + COMMENT ON TABLE a24c.cloudrestore_status IS 'Carpeta de entrada vigente reportada por CloudRestoreAS. Solo lectura en el panel.'; + +-- ============================================================================ +-- Distribución de versiones de CloudRestoreAS (migración a24c e2f3a4b5c6d7). +-- +-- Los binarios NO viven aquí: se publican en el registro de paquetes genéricos de Gitea +-- (ADUANASOFT/generic/cloudrestoreas/) y el panel los cachea en disco. Un artefacto +-- pesa ~270 MB, así que guardarlo como BLOB en la base no es viable. +-- ============================================================================ + +-- Catálogo de versiones publicadas (metadatos + el sha256 que calcula Gitea). +CREATE TABLE IF NOT EXISTS a24c.cras_releases ( + 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) +); + +-- Una sola versión activa POR plataforma+arquitectura, garantizado en la base: así Windows y +-- Linux pueden tener cada una la suya, a diferencia de un is_active singleton global. +CREATE UNIQUE INDEX IF NOT EXISTS idx_a24c_cras_releases_active + ON a24c.cras_releases (platform, arch) WHERE is_active; +CREATE INDEX IF NOT EXISTS idx_a24c_cras_releases_version + ON a24c.cras_releases (version); + +-- Bitácora de instalaciones remotas, con progreso paso a paso para que la UI lo siga. +CREATE TABLE IF NOT EXISTS a24c.cras_install_runs ( + id SERIAL PRIMARY KEY, + restore_target_id INTEGER REFERENCES a24c.restore_targets (id) ON DELETE SET NULL, + release_id INTEGER REFERENCES a24c.cras_releases (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')) +); + +CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_target + ON a24c.cras_install_runs (restore_target_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_a24c_cras_install_runs_running + ON a24c.cras_install_runs (restore_target_id) WHERE status = 'running'; + +COMMENT ON TABLE a24c.cras_releases IS + 'Catálogo de versiones de CloudRestoreAS publicadas en Gitea. Solo metadatos; los binarios viven en Gitea.'; +COMMENT ON TABLE a24c.cras_install_runs IS + 'Bitácora de instalaciones remotas de CloudRestoreAS, con progreso paso a paso en steps.'; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 369cb8f..9e56053 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,12 +24,33 @@ services: # Clave Fernet compartida con a24c para cifrar la contraseña SQL de los nodos. # DEBE ser idéntica a la SECRET_KEY del backend de a24c. - SECRET_KEY=${SECRET_KEY} - # [LEGADO] Solo si existen credenciales en el formato antiguo `gcm:` por migrar. + # [LEGADO] Solo si existen credenciales en el formato antiguo `gcm:` por migrar. Hoy TODAS + # las de restore_targets siguen en ese formato, así que el instalador remoto de CRAS aún + # depende de esta clave para descifrar la contraseña SSH del destino. - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + # Token de servicio que usan los agentes CloudRestoreAS en /api/restore/*. Obligatorio a + # propósito: service-auth.ts falla CERRADO con 500 si falta, así que sin `:?` el deploy + # arrancaría bien y los agentes recibirían 500 sin que nadie sepa por qué. Mismo patrón + # que MINIO_ROOT_USER en el compose de producción de a24c. + - CLOUDRESTORE_API_TOKEN=${CLOUDRESTORE_API_TOKEN:?CLOUDRESTORE_API_TOKEN es obligatorio} + # Ruta de respaldos montada desde el host - BACKUP_PATH=/data/backups + # --- Distribución de versiones de CloudRestoreAS ----------------------- + # Gitea es la fuente de verdad de los binarios; el panel solo LEE (scope read:package). + - GITEA_BASE_URL=${GITEA_BASE_URL:-https://git.aduanasoft.com} + - GITEA_OWNER=${GITEA_OWNER:-ADUANASOFT} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - CRAS_PACKAGE_NAME=${CRAS_PACKAGE_NAME:-cloudrestoreas} + # Caché local de artefactos (~270 MB cada uno, 2 por versión). + - CRAS_RELEASES_DIR=/data/cras-releases + - CRAS_CACHE_KEEP_VERSIONS=${CRAS_CACHE_KEEP_VERSIONS:-3} + # URL con la que el agente instalado reportará al panel. Debe ser alcanzable desde los + # servidores de restauración, no localhost. + - PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-${ORIGIN}} + - PORT=3000 - ORIGIN=${ORIGIN} - NODE_ENV=production @@ -43,6 +64,10 @@ services: - SMTP_USE_TLS=${SMTP_USE_TLS:-true} volumes: - ${BACKUP_HOST_PATH}:/data/backups + # Caché de artefactos de CRAS. Va en un bind-mount del host (no en un volumen anónimo) + # para que sobreviva a los redeploy: re-descargar cientos de MB en cada actualización + # del panel sería desperdicio. + - ${CRAS_RELEASES_HOST_PATH:-./data/cras-releases}:/data/cras-releases extra_hosts: - "host.docker.internal:host-gateway" networks: diff --git a/docker-compose.yml b/docker-compose.yml index 0ed1ca0..d02a3e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,10 +41,23 @@ services: # Clave Fernet compartida con a24c (debe coincidir con la SECRET_KEY de a24c) - SECRET_KEY=${SECRET_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} + + # --- Distribución de versiones de CloudRestoreAS ----------------------- + # Gitea es la fuente de verdad de los binarios; el panel solo LEE (scope read:package). + - GITEA_BASE_URL=${GITEA_BASE_URL:-https://git.aduanasoft.com} + - GITEA_OWNER=${GITEA_OWNER:-ADUANASOFT} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - CRAS_PACKAGE_NAME=${CRAS_PACKAGE_NAME:-cloudrestoreas} + - CRAS_RELEASES_DIR=/data/cras-releases + - CRAS_CACHE_KEEP_VERSIONS=${CRAS_CACHE_KEEP_VERSIONS:-3} + # URL con la que el agente instalado reportará al panel; en dev apunta al host. + - PANEL_PUBLIC_URL=${PANEL_PUBLIC_URL:-https://localhost:3000} volumes: # Map the actual backup folder from host to the container's backup path # Local dev: carpeta escribible del repo con respaldos sintéticos (read-only en el contenedor) - ./local-backups:/data/backups:ro + # Caché de artefactos de CRAS (~270 MB cada uno). Escribible: el panel descarga aquí. + - ./local-cras-releases:/data/cras-releases extra_hosts: - "host.docker.internal:host-gateway" depends_on: diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 1897deb..9eab508 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -129,7 +129,8 @@ { href: '/reportes', label: 'Reportes', icon: 'assessment' }, { href: '/usuarios', label: 'Gestión de Usuarios', icon: 'manage_accounts' }, { href: '/servidores-restauracion', label: 'Servidores de Restauración', icon: 'storage' }, - { href: '/servidores-restauracion/depuracion', label: 'Depurar Duplicadas', icon: 'cleaning_services' } + { href: '/servidores-restauracion/depuracion', label: 'Depurar Duplicadas', icon: 'cleaning_services' }, + { href: '/versiones-cras', label: 'Versiones CRAS', icon: 'system_update' } ]; function isActive(href: string): boolean { diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte new file mode 100644 index 0000000..b044319 --- /dev/null +++ b/src/lib/components/Spinner.svelte @@ -0,0 +1,34 @@ + + + diff --git a/src/lib/cras-install-progress.test.ts b/src/lib/cras-install-progress.test.ts new file mode 100644 index 0000000..a33bdc0 --- /dev/null +++ b/src/lib/cras-install-progress.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + INSTALL_PHASE_STARTING, + INSTALL_STEP_LABEL, + INSTALL_STEP_SEQUENCE, + currentPhaseLabel, + expectedStepCount, + formatElapsed, + progressPercent, + type InstallStepView +} from './cras-install-progress'; + +function step(name: string, ok = true): InstallStepView { + return { step: name, at: '2026-07-29T20:00:00.000Z', ok }; +} + +describe('currentPhaseLabel', () => { + it('sin pasos anuncia la apertura de la instalación', () => { + expect(currentPhaseLabel([])).toBe(INSTALL_PHASE_STARTING); + }); + + it('traduce el último paso a la fase que sigue en curso, no a "terminado"', () => { + // El caso que reportó el operador: `subir-artefacto` se persiste ANTES del fastPut de + // ~270 MB, así que un ✓ en ese paso significa "empezó la subida". + expect(currentPhaseLabel([step('preparar-artefacto'), step('subir-artefacto')])).toBe( + 'Subiendo el artefacto por SFTP…' + ); + expect(currentPhaseLabel([step('preparar-artefacto')])).toBe( + 'Descargando de Gitea y verificando sha256…' + ); + expect(currentPhaseLabel([step('ejecutar-instalador')])).toBe( + 'Corriendo el instalador en el servidor…' + ); + }); + + it('un paso fallido es el desenlace, no una fase en curso', () => { + expect(currentPhaseLabel([step('precondiciones'), step('error', false)])).toBe('Error'); + expect(currentPhaseLabel([step('subir-artefacto', false)])).toBe('Subir artefacto por SFTP'); + }); + + it('un paso desconocido no rompe la fila viva', () => { + expect(currentPhaseLabel([step('paso-nuevo-del-instalador')])).toBe('Trabajando…'); + }); +}); + +describe('expectedStepCount', () => { + it('cuenta verificar-servicio solo en modo servicio', () => { + // `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso. + expect(expectedStepCount('service')).toBe(11); + expect(expectedStepCount('desktop')).toBe(10); + expect(expectedStepCount('none')).toBe(10); + }); +}); + +describe('progressPercent', () => { + it('nunca llega a 100 mientras el run sigue corriendo', () => { + expect(progressPercent(11, 'service', 'running')).toBe(95); + expect(progressPercent(50, 'service', 'running')).toBe(95); + }); + + it('100 solo cuando el backend cerró el run como completado', () => { + expect(progressPercent(11, 'service', 'completed')).toBe(100); + expect(progressPercent(0, 'service', 'completed')).toBe(100); + }); + + it('escala con los pasos emitidos y el denominador del modo de arranque', () => { + expect(progressPercent(0, 'service', 'running')).toBe(0); + expect(progressPercent(5, 'none', 'running')).toBe(50); + // Mismo conteo de pasos, denominador distinto: 6/11 vs 6/10. + expect(progressPercent(6, 'service', 'running')).toBe(55); + expect(progressPercent(6, 'none', 'running')).toBe(60); + }); + + it('un run fallido conserva el avance parcial en lugar de saltar a 100', () => { + expect(progressPercent(3, 'service', 'failed')).toBe(27); + }); +}); + +describe('formatElapsed', () => { + it('formatea mm:ss y h:mm:ss', () => { + expect(formatElapsed(0)).toBe('00:00'); + expect(formatElapsed(65_000)).toBe('01:05'); + expect(formatElapsed(599_000)).toBe('09:59'); + expect(formatElapsed(3_725_000)).toBe('1:02:05'); + }); + + it('un reloj desfasado no produce tiempos negativos', () => { + expect(formatElapsed(-5_000)).toBe('00:00'); + }); +}); + +describe('cobertura de las etiquetas', () => { + it('todo paso del camino feliz tiene etiqueta y fase siguiente', () => { + for (const name of INSTALL_STEP_SEQUENCE) { + expect(INSTALL_STEP_LABEL[name], `falta etiqueta de ${name}`).toBeTruthy(); + expect(currentPhaseLabel([step(name)]), `falta fase de ${name}`).not.toBe( + 'Trabajando…' + ); + } + }); + + it('los pasos fuera del camino feliz también tienen etiqueta', () => { + expect(INSTALL_STEP_LABEL['limpiar-staging']).toBeTruthy(); + expect(INSTALL_STEP_LABEL.error).toBeTruthy(); + }); +}); diff --git a/src/lib/cras-install-progress.ts b/src/lib/cras-install-progress.ts new file mode 100644 index 0000000..1059a8b --- /dev/null +++ b/src/lib/cras-install-progress.ts @@ -0,0 +1,119 @@ +/** + * Fases de una instalación remota de CloudRestoreAS, para la UI de /versiones-cras. + * + * Módulo PURO (se consume desde el navegador): sin acceso a BD, red ni filesystem. + * + * Los pasos que persiste el instalador (`cras-install.ts`) son marcas de ARRANQUE de cada fase, + * no de terminación: `preparar-artefacto` se escribe antes de bajar el artefacto de Gitea y + * `subir-artefacto` antes del `fastPut` de ~270 MB. La pantalla tiene que traducir "último paso + * emitido" a "esto es lo que está pasando ahora", o una subida en curso se ve idéntica a una + * terminada — que es justo lo que reportó el operador. + */ + +export interface InstallStepView { + step: string; + detail?: string; + at: string; + ok: boolean; +} + +export type AutostartMode = 'service' | 'desktop' | 'none'; +export type RunStatus = 'running' | 'completed' | 'failed'; + +/** + * Orden canónico del camino feliz, idéntico en Linux y Windows (`cras-install.ts`). + * `verificar-servicio` es el único condicionado: solo se emite con arranque en modo servicio. + */ +export const INSTALL_STEP_SEQUENCE = [ + 'preparar-artefacto', + 'artefacto-listo', + 'conexion-ssh', + 'ruta-de-instalacion', + 'precondiciones', + 'subir-artefacto', + 'verificar-sha256', + 'sembrar-configuracion', + 'ejecutar-instalador', + 'verificar-version', + 'verificar-servicio' +] as const; + +/** Etiquetas legibles. Incluye los pasos fuera del camino feliz (`limpiar-staging`, `error`). */ +export const INSTALL_STEP_LABEL: Record = { + 'preparar-artefacto': 'Preparar artefacto', + 'artefacto-listo': 'Artefacto en caché y verificado', + 'conexion-ssh': 'Conexión SSH', + 'ruta-de-instalacion': 'Ruta de instalación', + precondiciones: 'Precondiciones del destino', + 'subir-artefacto': 'Subir artefacto por SFTP', + 'verificar-sha256': 'sha256 verificado en el destino', + 'sembrar-configuracion': 'Sembrar config/.env', + 'ejecutar-instalador': 'Ejecutar instalador', + 'verificar-version': 'Verificar versión desplegada', + 'verificar-servicio': 'Verificar servicio', + 'limpiar-staging': 'Limpiar carpeta temporal', + error: 'Error' +}; + +/** + * Qué está ocurriendo AHORA según el último paso emitido: la fase que arrancó ese paso y que + * todavía no termina. Estas son las esperas largas de una instalación. + */ +const PHASE_IN_PROGRESS: Record = { + 'preparar-artefacto': 'Descargando de Gitea y verificando sha256…', + 'artefacto-listo': 'Abriendo la sesión SSH/SFTP…', + 'conexion-ssh': 'Resolviendo la ruta de instalación…', + 'ruta-de-instalacion': 'Validando sistema, privilegios y herramientas del destino…', + precondiciones: 'Preparando la carpeta temporal del destino…', + 'subir-artefacto': 'Subiendo el artefacto por SFTP…', + 'verificar-sha256': 'Sembrando config/.env en el destino…', + 'sembrar-configuracion': 'Extrayendo el paquete e instalando…', + 'ejecutar-instalador': 'Corriendo el instalador en el servidor…', + 'verificar-version': 'Verificando el servicio…', + 'verificar-servicio': 'Limpiando la carpeta temporal…' +}; + +export const INSTALL_PHASE_STARTING = 'Abriendo la instalación…'; +const INSTALL_PHASE_UNKNOWN = 'Trabajando…'; + +/** Descripción de la fase en curso, para la fila viva del bloque de progreso. */ +export function currentPhaseLabel(steps: InstallStepView[]): string { + const last = steps.at(-1); + if (!last) return INSTALL_PHASE_STARTING; + // Un paso con ok=false ya es el desenlace: no hay fase siguiente que anunciar. + if (!last.ok) return INSTALL_STEP_LABEL[last.step] ?? last.step; + return PHASE_IN_PROGRESS[last.step] ?? INSTALL_PHASE_UNKNOWN; +} + +/** Pasos esperados del camino feliz según el modo de arranque. */ +export function expectedStepCount(autostart: AutostartMode): number { + return autostart === 'service' + ? INSTALL_STEP_SEQUENCE.length + : INSTALL_STEP_SEQUENCE.length - 1; +} + +/** + * Avance 0-100. Se topa en 95 mientras corre: llegar a 100 antes de que el backend cierre el run + * es justo el engaño que se está corrigiendo. + */ +export function progressPercent( + stepCount: number, + autostart: AutostartMode, + status: RunStatus +): number { + if (status === 'completed') return 100; + const total = expectedStepCount(autostart); + const raw = total > 0 ? Math.round((stepCount / total) * 100) : 0; + return Math.min(Math.max(raw, 0), 95); +} + +/** mm:ss, o h:mm:ss cuando pasa de la hora. Los negativos se tratan como 0 (relojes desfasados). */ +export function formatElapsed(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} diff --git a/src/lib/cras-version.test.ts b/src/lib/cras-version.test.ts new file mode 100644 index 0000000..9941016 --- /dev/null +++ b/src/lib/cras-version.test.ts @@ -0,0 +1,203 @@ +/** + * Comparación de versiones y normalización de plataforma para CloudRestoreAS. + * + * Es la lógica que decide si a un servidor le falta actualizarse, así que los casos que se + * prueban son los que romperían eso en silencio: comparación lexicográfica (1.10 vs 1.9), + * versiones de distinta longitud, y valores no comparables. + */ +import { describe, expect, it } from 'vitest'; +import { + compareVersions, + effectiveArch, + effectivePlatform, + installPlatformVerdict, + isCrasPlatform, + isNewer, + isValidVersion, + osToPlatform, + parseVersion, + platformLabel, + DEFAULT_ARCH +} from './cras-version'; + +describe('isValidVersion', () => { + it('acepta versiones de puntos y números', () => { + for (const v of ['1', '1.0', '1.1.0', '26.7.1.4']) { + expect(isValidVersion(v), v).toBe(true); + } + }); + + it('rechaza lo que el PANEL no podría ordenar', () => { + for (const v of ['1.0.0-rc1', 'v1.0.0', '1.0.0a', '', ' ', 'latest', '1.2.3.4.5']) { + expect(isValidVersion(v), String(v)).toBe(false); + } + }); + + it('rechaza valores que no son cadena', () => { + expect(isValidVersion(null)).toBe(false); + expect(isValidVersion(undefined)).toBe(false); + expect(isValidVersion(110)).toBe(false); + }); +}); + +describe('parseVersion', () => { + it('descompone en enteros', () => { + expect(parseVersion('1.10.2')).toEqual([1, 10, 2]); + expect(parseVersion(' 2.0 ')).toEqual([2, 0]); + }); + + it('devuelve null si no es comparable', () => { + expect(parseVersion('1.0.0-rc1')).toBeNull(); + }); +}); + +describe('compareVersions', () => { + it('compara numéricamente, no como texto', () => { + // El error clásico: como cadenas, "1.9.0" > "1.10.0". + expect(compareVersions('1.10.0', '1.9.0')).toBe(1); + expect(compareVersions('1.9.0', '1.10.0')).toBe(-1); + }); + + it('rellena con ceros las de distinta longitud', () => { + expect(compareVersions('1.2', '1.2.0')).toBe(0); + expect(compareVersions('1.2.1', '1.2')).toBe(1); + expect(compareVersions('2', '1.9.9.9')).toBe(1); + }); + + it('es simétrica', () => { + expect(compareVersions('1.1.0', '1.1.0')).toBe(0); + expect(compareVersions('26.7.1.4', '26.7.1.3')).toBe(1); + expect(compareVersions('26.7.1.3', '26.7.1.4')).toBe(-1); + }); + + it('devuelve null cuando alguna no es comparable', () => { + expect(compareVersions('1.0.0-rc1', '1.0.0')).toBeNull(); + expect(compareVersions('1.0.0', 'latest')).toBeNull(); + }); +}); + +describe('isNewer', () => { + it('detecta una versión más nueva', () => { + expect(isNewer('1.2.0', '1.1.0')).toBe(true); + expect(isNewer('1.10.0', '1.9.0')).toBe(true); + }); + + it('no ofrece downgrade ni reinstalación de la misma', () => { + expect(isNewer('1.1.0', '1.2.0')).toBe(false); + expect(isNewer('1.1.0', '1.1.0')).toBe(false); + }); + + it('sin versión instalada, hay algo que instalar', () => { + expect(isNewer('1.1.0', null)).toBe(true); + expect(isNewer('1.1.0', undefined)).toBe(true); + expect(isNewer('1.1.0', ' ')).toBe(true); + }); + + it('ante una versión no comparable prefiere NO ofrecer nada', () => { + // Preferible perderse una actualización que empujar un downgrade por un dato sucio. + expect(isNewer('1.0.0-rc1', '1.0.0')).toBe(false); + expect(isNewer('1.2.0', 'desconocida')).toBe(false); + }); +}); + +describe('osToPlatform', () => { + it('reconoce Windows en texto libre', () => { + for (const os of ['Windows Server 2019', 'windows 11', 'WIN SERVER 2022', 'Win']) { + expect(osToPlatform(os), os).toBe('windows'); + } + }); + + it('reconoce distribuciones de Linux', () => { + for (const os of [ + 'Ubuntu 22.04', + 'Debian 12', + 'CentOS 7', + 'RHEL 9', + 'Red Hat Enterprise', + 'Rocky Linux 9', + 'AlmaLinux', + 'SUSE', + 'Oracle Linux 8' + ]) { + expect(osToPlatform(os), os).toBe('linux'); + } + }); + + it('devuelve null si no puede decidir, en lugar de adivinar', () => { + // Adivinar mandaría el binario equivocado; la UI pide elegir a mano. + for (const os of ['', ' ', null, undefined, 'servidor de la sucursal', 'macOS 14']) { + expect(osToPlatform(os as string), String(os)).toBeNull(); + } + }); +}); + +describe('effectivePlatform', () => { + it('lo reportado por el agente manda sobre el texto libre', () => { + expect(effectivePlatform('linux', 'Windows Server 2019')).toBe('linux'); + expect(effectivePlatform('windows', 'Ubuntu 22.04')).toBe('windows'); + }); + + it('cae al texto libre cuando el agente no reportó', () => { + expect(effectivePlatform(null, 'Ubuntu 22.04')).toBe('linux'); + expect(effectivePlatform(' ', 'Windows Server 2022')).toBe('windows'); + }); + + it('ignora una plataforma reportada inválida', () => { + expect(effectivePlatform('solaris', 'Ubuntu 22.04')).toBe('linux'); + expect(effectivePlatform('solaris', null)).toBeNull(); + }); +}); + +describe('effectiveArch', () => { + it('usa la reportada y normaliza a minúsculas', () => { + expect(effectiveArch('X86_64')).toBe('x86_64'); + expect(effectiveArch('arm64')).toBe('arm64'); + }); + + it('asume la única arquitectura que se publica hoy si falta', () => { + expect(effectiveArch(null)).toBe(DEFAULT_ARCH); + expect(effectiveArch(' ')).toBe(DEFAULT_ARCH); + }); +}); + +describe('isCrasPlatform y platformLabel', () => { + it('valida el vocabulario del CHECK de cras_releases', () => { + expect(isCrasPlatform('windows')).toBe(true); + expect(isCrasPlatform('linux')).toBe(true); + expect(isCrasPlatform('solaris')).toBe(false); + expect(isCrasPlatform(null)).toBe(false); + }); + + it('etiqueta legible, incluso sin dato', () => { + expect(platformLabel('windows')).toBe('Windows'); + expect(platformLabel('linux')).toBe('Linux'); + expect(platformLabel(null)).toBe('Sin determinar'); + }); +}); + +describe('installPlatformVerdict', () => { + it('con plataforma conocida solo deja pasar el artefacto que corresponde', () => { + expect(installPlatformVerdict('linux', 'linux', null)).toBe('ok'); + expect(installPlatformVerdict('windows', 'linux', null)).toBe('mismatch'); + expect(installPlatformVerdict('linux', 'windows', null)).toBe('mismatch'); + }); + + it('una confirmación no puede pasar por encima de una plataforma conocida', () => { + // El destino es Windows y el operador confirma "linux": sigue siendo un desajuste. + expect(installPlatformVerdict('windows', 'linux', 'linux')).toBe('mismatch'); + }); + + it('sin plataforma determinada exige confirmación explícita del operador', () => { + // El defecto que se corrige: el modal preseleccionaba el primer artefacto del catálogo + // (el más recién descubierto, hoy Linux) para un servidor "Sin determinar". + expect(installPlatformVerdict(null, 'linux', null)).toBe('needs-ack'); + expect(installPlatformVerdict(null, 'linux', '')).toBe('needs-ack'); + expect(installPlatformVerdict(undefined, 'windows', undefined)).toBe('needs-ack'); + }); + + it('la confirmación tiene que coincidir con la plataforma del artefacto', () => { + expect(installPlatformVerdict(null, 'linux', 'linux')).toBe('ok'); + expect(installPlatformVerdict(null, 'linux', 'windows')).toBe('needs-ack'); + expect(installPlatformVerdict(null, 'windows', ' WINDOWS ')).toBe('ok'); + }); +}); diff --git a/src/lib/cras-version.ts b/src/lib/cras-version.ts new file mode 100644 index 0000000..5457ab1 --- /dev/null +++ b/src/lib/cras-version.ts @@ -0,0 +1,240 @@ +/** + * Comparación de versiones de CloudRestoreAS y normalización de plataforma. + * + * Módulo PURO (sin acceso a red ni a BD) para que sea testeable y usable tanto en el + * servidor como en el navegador. Es la pieza que decide si un servidor tiene una versión + * vieja, así que un error aquí se traduce en actualizaciones que nunca se ofrecen o que se + * ofrecen en reversa. + */ + +/** Plataformas para las que se publican artefactos (espejo del CHECK de cras_releases). */ +export const CRAS_PLATFORMS = ['windows', 'linux'] as const; +export type CrasPlatform = (typeof CRAS_PLATFORMS)[number]; + +export const DEFAULT_ARCH = 'x86_64'; + +export function isCrasPlatform(value: unknown): value is CrasPlatform { + return typeof value === 'string' && (CRAS_PLATFORMS as readonly string[]).includes(value); +} + +/** + * Versión válida: solo puntos y números (1.1.0, 26.7.1.4). CloudRestoreAS lo garantiza en + * package-release.sh, que aborta el empaquetado si el formato no cumple. + */ +const VERSION_RE = /^\d+(\.\d+){0,3}$/; + +export function isValidVersion(value: unknown): boolean { + return typeof value === 'string' && VERSION_RE.test(value.trim()); +} + +/** Convierte "1.10.2" en [1, 10, 2]. Devuelve null si no es una versión comparable. */ +export function parseVersion(value: string): number[] | null { + const raw = String(value ?? '').trim(); + if (!VERSION_RE.test(raw)) return null; + return raw.split('.').map((part) => Number.parseInt(part, 10)); +} + +/** + * Orden numérico por componente, rellenando con ceros: "1.2" == "1.2.0" y "1.10" > "1.9" + * (una comparación de cadenas daría lo contrario, que es el error clásico). + * + * Devuelve -1 si a < b, 0 si son equivalentes, 1 si a > b, y null si alguna no es + * comparable — el llamador debe tratar null como "no sé", nunca como "iguales". + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 | null { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return null; + + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const x = left[i] ?? 0; + const y = right[i] ?? 0; + if (x < y) return -1; + if (x > y) return 1; + } + return 0; +} + +/** + * ¿`candidate` es más nueva que `installed`? + * + * Sin versión instalada (servidor sin agente todavía) cuenta como que sí hay algo que + * instalar. Si alguna versión no es comparable devuelve false: es preferible no ofrecer + * una actualización que ofrecer un downgrade por una comparación inválida. + */ +export function isNewer(candidate: string, installed: string | null | undefined): boolean { + if (!isValidVersion(candidate)) return false; + const current = (installed ?? '').trim(); + if (!current) return true; + return compareVersions(candidate, current) === 1; +} + +/** + * Normaliza el texto libre de `restore_targets.os` a una plataforma. + * + * Ese campo lo captura a mano el operador ("Windows Server 2019", "Ubuntu 22.04"), así que + * solo sirve como respaldo para la PRIMERA instalación, cuando el agente todavía no ha + * reportado su plataforma real. Devuelve null si no se puede decidir — la UI entonces pide + * elegir a mano en lugar de adivinar y mandar el binario equivocado. + */ +export function osToPlatform(os: string | null | undefined): CrasPlatform | null { + const text = (os ?? '').trim().toLowerCase(); + if (!text) return null; + if (text.includes('windows') || text.includes('win server') || /\bwin\b/.test(text)) { + return 'windows'; + } + const linuxHints = [ + 'linux', + 'ubuntu', + 'debian', + 'centos', + 'rhel', + 'red hat', + 'redhat', + 'fedora', + 'suse', + 'rocky', + 'alma', + 'oracle linux' + ]; + if (linuxHints.some((hint) => text.includes(hint))) return 'linux'; + return null; +} + +/** + * Plataforma efectiva de un destino: lo que reportó el agente manda sobre el texto libre. + * `reported` viene de cloudrestore_status.platform y es autoritativo. + */ +export function effectivePlatform( + reported: string | null | undefined, + os: string | null | undefined +): CrasPlatform | null { + const value = (reported ?? '').trim().toLowerCase(); + if (isCrasPlatform(value)) return value; + return osToPlatform(os); +} + +/** Arquitectura efectiva; si el agente no la reportó se asume la única que se publica hoy. */ +export function effectiveArch(reported: string | null | undefined): string { + const value = (reported ?? '').trim().toLowerCase(); + return value || DEFAULT_ARCH; +} + +// ============================================================================ +// Ruta de instalación +// +// Es un espacio de rutas SEPARADO de `input_folder` (la carpeta que vigila el agente, que +// puede ser un share UNC en otra máquina) y de `remote_inbox_path`. La reporta el agente +// (`APP_DIR` = carpeta del ejecutable), y ahí vive `config/.env` con las rutas de trabajo +// que el operador haya personalizado. Instalar en la ruta equivocada crea una segunda +// instalación con la configuración default y deja huérfano el `.env` personalizado. +// ============================================================================ + +export const DEFAULT_INSTALL_PATHS: Record = { + linux: '/opt/cloudrestoreas', + windows: 'C:\\Aduanasoft\\CloudRestoreAS' +}; + +/** + * Valida una ruta de instalación. Se aplica en serio porque el valor llega **por red** desde + * el agente y termina interpolado en un comando de shell y en un script de PowerShell. + * + * Se rechazan las rutas UNC a propósito: el bootstrap escribe `config/` junto al ejecutable, y + * una tarea programada como SYSTEM no ve shares de red, así que un agente instalado en un + * share no podría arrancar. + */ +export function isSafeInstallPath(path: unknown, platform: CrasPlatform): boolean { + const value = String(path ?? '').trim(); + if (!value || value.length > 400) return false; + // Comillas, metacaracteres de shell y saltos de línea: fuera, sin excepciones. + if (/["'`$;&|<>\n\r*?()[\]{}!]/.test(value)) return false; + if (value.includes('..')) return false; + + if (platform === 'windows') { + if (value.startsWith('\\\\')) return false; // UNC + return /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value); + } + if (value.startsWith('//')) return false; // equivalente POSIX de un share + return /^\/[A-Za-z0-9 ._/-]*$/.test(value); +} + +/** + * Ruta efectiva: la que reportó el agente si es válida, o el default de la plataforma. + * Devuelve null si no se conoce la plataforma — sin ella no hay default que aplicar. + */ +export function effectiveInstallPath( + reported: string | null | undefined, + platform: CrasPlatform | null | undefined +): string | null { + if (!platform || !isCrasPlatform(platform)) return null; + const value = String(reported ?? '').trim(); + if (value && isSafeInstallPath(value, platform)) { + return value.replace(/[/\\]+$/, '') || value; + } + return DEFAULT_INSTALL_PATHS[platform]; +} + +/** True si la ruta reportada por el agente es la default de su plataforma. */ +export function isDefaultInstallPath( + path: string | null | undefined, + platform: CrasPlatform | null | undefined +): boolean { + if (!platform || !isCrasPlatform(platform)) return false; + return String(path ?? '').trim() === DEFAULT_INSTALL_PATHS[platform]; +} + +/** + * ¿La ruta de instalación cae dentro de una carpeta de trabajo del agente? + * + * Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa + * carpeta y trataría de procesar sus propios archivos como si fueran respaldos. + */ +export function isInsideWorkFolder( + installPath: string | null | undefined, + workFolders: (string | null | undefined)[] +): boolean { + const norm = (p: string | null | undefined) => + String(p ?? '') + .trim() + .replace(/\\/g, '/') + .replace(/\/+$/, '') + .toLowerCase(); + + const target = norm(installPath); + if (!target) return false; + return workFolders.some((folder) => { + const base = norm(folder); + if (!base) return false; + return target === base || target.startsWith(`${base}/`) || base.startsWith(`${target}/`); + }); +} + +/** Etiqueta legible para la UI. */ +export function platformLabel(platform: string | null | undefined): string { + if (platform === 'windows') return 'Windows'; + if (platform === 'linux') return 'Linux'; + return 'Sin determinar'; +} + +/** + * ¿Se puede instalar un artefacto de `releasePlatform` en un destino cuya plataforma efectiva es + * `targetPlatform`? + * + * Cuando el panel NO pudo determinar la plataforma del destino no se adivina: se exige una + * confirmación explícita del operador (`ack`) que coincida con la plataforma del artefacto. Sin + * esto, el modal preseleccionaba el primer artefacto del catálogo y un solo clic podía mandar el + * .tar.gz de Linux a un Windows; el fallo aparecía hasta el destino, ya con la sesión SSH abierta + * y un run fallido en la bitácora. + * + * La misma función se usa en la UI (para habilitar el botón) y en la action `install` (como red + * de seguridad ante un formulario viejo o un POST a mano), así que la regla vive en un solo lugar. + */ +export function installPlatformVerdict( + targetPlatform: CrasPlatform | null | undefined, + releasePlatform: CrasPlatform, + ack: string | null | undefined +): 'ok' | 'mismatch' | 'needs-ack' { + if (targetPlatform) return targetPlatform === releasePlatform ? 'ok' : 'mismatch'; + return (ack ?? '').trim().toLowerCase() === releasePlatform ? 'ok' : 'needs-ack'; +} diff --git a/src/lib/icons/README.md b/src/lib/icons/README.md new file mode 100644 index 0000000..b70ccae --- /dev/null +++ b/src/lib/icons/README.md @@ -0,0 +1,36 @@ +# Ligaduras de la fuente de iconos + +`material-icons-outlined.codepoints` es la lista **autoritativa** de ligaduras de la fuente que +carga el panel en `src/app.html`: + +```html + +``` + +Es el set **clásico** (Material Icons Outlined, 2195 ligaduras), no Material **Symbols** (4267). +El archivo viene del release oficial `google/material-design-icons`, licencia Apache-2.0, y es el +mismo con el que se genera el `.woff2` que sirve Google: + +``` +font/MaterialIconsOutlined-Regular.codepoints +``` + +## Por qué existe esta lista en el repo + +Cuando el nombre de un icono no existe en la fuente, el navegador **no falla**: renderiza el nombre +como texto literal. Un `progress_activity` (que es de Material Symbols) se pinta como la cadena +"progress_activity" de ~200 px a los 24 px que fija la clase `.material-icons-outlined`, y con +`animate-spin` la hace girar. Eso desbordó el pie del modal de instalación de `/versiones-cras`: +el botón de submit creció, su etiqueta se partió palabra por palabra y el botón "Cerrar" quedó +recortado fuera del panel. + +Ni `tsc --noEmit` ni el build detectan esto — no es un error de tipos ni de sintaxis. La única +compuerta es `material-icons.test.ts`, que valida contra esta lista todas las ligaduras usadas en +`src/`. + +## Si se cambia la fuente + +Si algún día se migra a Material Symbols (`app.html` + la clase en las plantillas), hay que +reemplazar este archivo por `MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].codepoints` del mismo +release. El test tiene una aserción que falla a propósito si la lista se cambia sin migrar +`app.html`, para que las dos cosas no se desincronicen. diff --git a/src/lib/icons/material-icons-outlined.codepoints b/src/lib/icons/material-icons-outlined.codepoints new file mode 100644 index 0000000..df7be3b --- /dev/null +++ b/src/lib/icons/material-icons-outlined.codepoints @@ -0,0 +1,2195 @@ +10k e951 +10mp e952 +11mp e953 +123 eb8d +12mp e954 +13mp e955 +14mp e956 +15mp e957 +16mp e958 +17mp e959 +18_up_rating f8fd +18mp e95a +19mp e95b +1k e95c +1k_plus e95d +1x_mobiledata efcd +20mp e95e +21mp e95f +22mp e960 +23mp e961 +24mp e962 +2k e963 +2k_plus e964 +2mp e965 +30fps efce +30fps_select efcf +360 e577 +3d_rotation e84d +3g_mobiledata efd0 +3k e966 +3k_plus e967 +3mp e968 +3p efd1 +4g_mobiledata efd2 +4g_plus_mobiledata efd3 +4k e072 +4k_plus e969 +4mp e96a +5g ef38 +5k e96b +5k_plus e96c +5mp e96d +60fps efd4 +60fps_select efd5 +6_ft_apart f21e +6k e96e +6k_plus e96f +6mp e970 +7k e971 +7k_plus e972 +7mp e973 +8k e974 +8k_plus e975 +8mp e976 +9k e977 +9k_plus e978 +9mp e979 +abc eb94 +ac_unit eb3b +access_alarm e190 +access_alarms e191 +access_time e192 +access_time_filled efd6 +accessibility e84e +accessibility_new e92c +accessible e914 +accessible_forward e934 +account_balance e84f +account_balance_wallet e850 +account_box e851 +account_circle e853 +account_tree e97a +ad_units ef39 +adb e60e +add e145 +add_a_photo e439 +add_alarm e193 +add_alert e003 +add_box e146 +add_business e729 +add_card eb86 +add_chart e97b +add_circle e147 +add_circle_outline e148 +add_comment e266 +add_home f8eb +add_home_work f8ed +add_ic_call e97c +add_link e178 +add_location e567 +add_location_alt ef3a +add_moderator e97d +add_photo_alternate e43e +add_reaction e1d3 +add_road ef3b +add_shopping_cart e854 +add_task f23a +add_to_drive e65c +add_to_home_screen e1fe +add_to_photos e39d +add_to_queue e05c +addchart ef3c +adf_scanner eada +adjust e39e +admin_panel_settings ef3d +adobe ea96 +ads_click e762 +agriculture ea79 +air efd8 +airline_seat_flat e630 +airline_seat_flat_angled e631 +airline_seat_individual_suite e632 +airline_seat_legroom_extra e633 +airline_seat_legroom_normal e634 +airline_seat_legroom_reduced e635 +airline_seat_recline_extra e636 +airline_seat_recline_normal e637 +airline_stops e7d0 +airlines e7ca +airplane_ticket efd9 +airplanemode_active e195 +airplanemode_inactive e194 +airplanemode_off e194 +airplanemode_on e195 +airplay e055 +airport_shuttle eb3c +alarm e855 +alarm_add e856 +alarm_off e857 +alarm_on e858 +album e019 +align_horizontal_center e00f +align_horizontal_left e00d +align_horizontal_right e010 +align_vertical_bottom e015 +align_vertical_center e011 +align_vertical_top e00c +all_inbox e97f +all_inclusive eb3d +all_out e90b +alt_route f184 +alternate_email e0e6 +amp_stories ea13 +analytics ef3e +anchor f1cd +android e859 +animation e71c +announcement e85a +aod efda +apartment ea40 +api f1b7 +app_blocking ef3f +app_registration ef40 +app_settings_alt ef41 +app_shortcut eae4 +apple ea80 +approval e982 +apps e5c3 +apps_outage e7cc +architecture ea3b +archive e149 +area_chart e770 +arrow_back e5c4 +arrow_back_ios e5e0 +arrow_back_ios_new e2ea +arrow_circle_down f181 +arrow_circle_left eaa7 +arrow_circle_right eaaa +arrow_circle_up f182 +arrow_downward e5db +arrow_drop_down e5c5 +arrow_drop_down_circle e5c6 +arrow_drop_up e5c7 +arrow_forward e5c8 +arrow_forward_ios e5e1 +arrow_left e5de +arrow_outward f8ce +arrow_right e5df +arrow_right_alt e941 +arrow_upward e5d8 +art_track e060 +article ef42 +aspect_ratio e85b +assessment e85c +assignment e85d +assignment_ind e85e +assignment_late e85f +assignment_return e860 +assignment_returned e861 +assignment_turned_in e862 +assist_walker f8d5 +assistant e39f +assistant_direction e988 +assistant_photo e3a0 +assured_workload eb6f +atm e573 +attach_email ea5e +attach_file e226 +attach_money e227 +attachment e2bc +attractions ea52 +attribution efdb +audio_file eb82 +audiotrack e3a1 +auto_awesome e65f +auto_awesome_mosaic e660 +auto_awesome_motion e661 +auto_delete ea4c +auto_fix_high e663 +auto_fix_normal e664 +auto_fix_off e665 +auto_graph e4fb +auto_mode ec20 +auto_stories e666 +autofps_select efdc +autorenew e863 +av_timer e01b +baby_changing_station f19b +back_hand e764 +backpack f19c +backspace e14a +backup e864 +backup_table ef43 +badge ea67 +bakery_dining ea53 +balance eaf6 +balcony e58f +ballot e172 +bar_chart e26b +batch_prediction f0f5 +bathroom efdd +bathtub ea41 +battery_0_bar ebdc +battery_1_bar ebd9 +battery_2_bar ebe0 +battery_3_bar ebdd +battery_4_bar ebe2 +battery_5_bar ebd4 +battery_6_bar ebd2 +battery_alert e19c +battery_charging_full e1a3 +battery_full e1a4 +battery_saver efde +battery_std e1a5 +battery_unknown e1a6 +beach_access eb3e +bed efdf +bedroom_baby efe0 +bedroom_child efe1 +bedroom_parent efe2 +bedtime ef44 +bedtime_off eb76 +beenhere e52d +bento f1f4 +bike_scooter ef45 +biotech ea3a +blender efe3 +blind f8d6 +blinds e286 +blinds_closed ec1f +block e14b +bloodtype efe4 +bluetooth e1a7 +bluetooth_audio e60f +bluetooth_connected e1a8 +bluetooth_disabled e1a9 +bluetooth_drive efe5 +bluetooth_searching e1aa +blur_circular e3a2 +blur_linear e3a3 +blur_off e3a4 +blur_on e3a5 +bolt ea0b +book e865 +book_online f217 +bookmark e866 +bookmark_add e598 +bookmark_added e599 +bookmark_border e867 +bookmark_outline e867 +bookmark_remove e59a +bookmarks e98b +border_all e228 +border_bottom e229 +border_clear e22a +border_color e22b +border_horizontal e22c +border_inner e22d +border_left e22e +border_outer e22f +border_right e230 +border_style e231 +border_top e232 +border_vertical e233 +boy eb67 +branding_watermark e06b +breakfast_dining ea54 +brightness_1 e3a6 +brightness_2 e3a7 +brightness_3 e3a8 +brightness_4 e3a9 +brightness_5 e3aa +brightness_6 e3ab +brightness_7 e3ac +brightness_auto e1ab +brightness_high e1ac +brightness_low e1ad +brightness_medium e1ae +broadcast_on_home f8f8 +broadcast_on_personal f8f9 +broken_image e3ad +browse_gallery ebd1 +browser_not_supported ef47 +browser_updated e7cf +brunch_dining ea73 +brush e3ae +bubble_chart e6dd +bug_report e868 +build e869 +build_circle ef48 +bungalow e591 +burst_mode e43c +bus_alert e98f +business e0af +business_center eb3f +cabin e589 +cable efe6 +cached e86a +cake e7e9 +calculate ea5f +calendar_month ebcc +calendar_today e935 +calendar_view_day e936 +calendar_view_month efe7 +calendar_view_week efe8 +call e0b0 +call_end e0b1 +call_made e0b2 +call_merge e0b3 +call_missed e0b4 +call_missed_outgoing e0e4 +call_received e0b5 +call_split e0b6 +call_to_action e06c +camera e3af +camera_alt e3b0 +camera_enhance e8fc +camera_front e3b1 +camera_indoor efe9 +camera_outdoor efea +camera_rear e3b2 +camera_roll e3b3 +cameraswitch efeb +campaign ef49 +cancel e5c9 +cancel_presentation e0e9 +cancel_schedule_send ea39 +candlestick_chart ead4 +car_crash ebf2 +car_rental ea55 +car_repair ea56 +card_giftcard e8f6 +card_membership e8f7 +card_travel e8f8 +carpenter f1f8 +cases e992 +casino eb40 +cast e307 +cast_connected e308 +cast_for_education efec +castle eab1 +catching_pokemon e508 +category e574 +celebration ea65 +cell_tower ebba +cell_wifi e0ec +center_focus_strong e3b4 +center_focus_weak e3b5 +chair efed +chair_alt efee +chalet e585 +change_circle e2e7 +change_history e86b +charging_station f19d +chat e0b7 +chat_bubble e0ca +chat_bubble_outline e0cb +check e5ca +check_box e834 +check_box_outline_blank e835 +check_circle e86c +check_circle_outline e92d +checklist e6b1 +checklist_rtl e6b3 +checkroom f19e +chevron_left e5cb +chevron_right e5cc +child_care eb41 +child_friendly eb42 +chrome_reader_mode e86d +church eaae +circle ef4a +circle_notifications e994 +class e86e +clean_hands f21f +cleaning_services f0ff +clear e14c +clear_all e0b8 +close e5cd +close_fullscreen f1cf +closed_caption e01c +closed_caption_disabled f1dc +closed_caption_off e996 +cloud e2bd +cloud_circle e2be +cloud_done e2bf +cloud_download e2c0 +cloud_off e2c1 +cloud_queue e2c2 +cloud_sync eb5a +cloud_upload e2c3 +co2 e7b0 +co_present eaf0 +code e86f +code_off e4f3 +coffee efef +coffee_maker eff0 +collections e3b6 +collections_bookmark e431 +color_lens e3b7 +colorize e3b8 +comment e0b9 +comment_bank ea4e +comments_disabled e7a2 +commit eaf5 +commute e940 +compare e3b9 +compare_arrows e915 +compass_calibration e57c +compost e761 +compress e94d +computer e30a +confirmation_num e638 +confirmation_number e638 +connect_without_contact f223 +connected_tv e998 +connecting_airports e7c9 +construction ea3c +contact_emergency f8d1 +contact_mail e0d0 +contact_page f22e +contact_phone e0cf +contact_support e94c +contactless ea71 +contacts e0ba +content_copy f08a +content_cut f08b +content_paste f098 +content_paste_go ea8e +content_paste_off e4f8 +content_paste_search ea9b +contrast eb37 +control_camera e074 +control_point e3ba +control_point_duplicate e3bb +cookie eaac +copy f08a +copy_all e2ec +copyright e90c +coronavirus f221 +corporate_fare f1d0 +cottage e587 +countertops f1f7 +create e150 +create_new_folder e2cc +credit_card e870 +credit_card_off e4f4 +credit_score eff1 +crib e588 +crisis_alert ebe9 +crop e3be +crop_16_9 e3bc +crop_3_2 e3bd +crop_5_4 e3bf +crop_7_5 e3c0 +crop_din e3c1 +crop_free e3c2 +crop_landscape e3c3 +crop_original e3c4 +crop_portrait e3c5 +crop_rotate e437 +crop_square e3c6 +cruelty_free e799 +css eb93 +currency_bitcoin ebc5 +currency_exchange eb70 +currency_franc eafa +currency_lira eaef +currency_pound eaf1 +currency_ruble eaec +currency_rupee eaf7 +currency_yen eafb +currency_yuan eaf9 +curtains ec1e +curtains_closed ec1d +cut f08b +cyclone ebd5 +dangerous e99a +dark_mode e51c +dashboard e871 +dashboard_customize e99b +data_array ead1 +data_exploration e76f +data_object ead3 +data_saver_off eff2 +data_saver_on eff3 +data_thresholding eb9f +data_usage e1af +dataset f8ee +dataset_linked f8ef +date_range e916 +deblur eb77 +deck ea42 +dehaze e3c7 +delete e872 +delete_forever e92b +delete_outline e92e +delete_sweep e16c +delivery_dining ea72 +density_large eba9 +density_medium eb9e +density_small eba8 +departure_board e576 +description e873 +deselect ebb6 +design_services f10a +desk f8f4 +desktop_access_disabled e99d +desktop_mac e30b +desktop_windows e30c +details e3c8 +developer_board e30d +developer_board_off e4ff +developer_mode e1b0 +device_hub e335 +device_thermostat e1ff +device_unknown e339 +devices e1b1 +devices_fold ebde +devices_other e337 +dialer_sip e0bb +dialpad e0bc +diamond ead5 +difference eb7d +dining eff4 +dinner_dining ea57 +directions e52e +directions_bike e52f +directions_boat e532 +directions_boat_filled eff5 +directions_bus e530 +directions_bus_filled eff6 +directions_car e531 +directions_car_filled eff7 +directions_ferry e532 +directions_off f10f +directions_railway e534 +directions_railway_filled eff8 +directions_run e566 +directions_subway e533 +directions_subway_filled eff9 +directions_train e534 +directions_transit e535 +directions_transit_filled effa +directions_walk e536 +dirty_lens ef4b +disabled_by_default f230 +disabled_visible e76e +disc_full e610 +discord ea6c +discount ebc9 +display_settings eb97 +diversity_1 f8d7 +diversity_2 f8d8 +diversity_3 f8d9 +dnd_forwardslash e611 +dns e875 +do_disturb f08c +do_disturb_alt f08d +do_disturb_off f08e +do_disturb_on f08f +do_not_disturb e612 +do_not_disturb_alt e611 +do_not_disturb_off e643 +do_not_disturb_on e644 +do_not_disturb_on_total_silence effb +do_not_step f19f +do_not_touch f1b0 +dock e30e +document_scanner e5fa +domain e7ee +domain_add eb62 +domain_disabled e0ef +domain_verification ef4c +done e876 +done_all e877 +done_outline e92f +donut_large e917 +donut_small e918 +door_back effc +door_front effd +door_sliding effe +doorbell efff +double_arrow ea50 +downhill_skiing e509 +download f090 +download_done f091 +download_for_offline f000 +downloading f001 +drafts e151 +drag_handle e25d +drag_indicator e945 +draw e746 +drive_eta e613 +drive_file_move e675 +drive_file_move_rtl e76d +drive_file_rename_outline e9a2 +drive_folder_upload e9a3 +dry f1b3 +dry_cleaning ea58 +duo e9a5 +dvr e1b2 +dynamic_feed ea14 +dynamic_form f1bf +e_mobiledata f002 +earbuds f003 +earbuds_battery f004 +east f1df +eco ea35 +edgesensor_high f005 +edgesensor_low f006 +edit e3c9 +edit_attributes e578 +edit_calendar e742 +edit_location e568 +edit_location_alt e1c5 +edit_note e745 +edit_notifications e525 +edit_off e950 +edit_road ef4d +egg eacc +egg_alt eac8 +eject e8fb +elderly f21a +elderly_woman eb69 +electric_bike eb1b +electric_bolt ec1c +electric_car eb1c +electric_meter ec1b +electric_moped eb1d +electric_rickshaw eb1e +electric_scooter eb1f +electrical_services f102 +elevator f1a0 +email e0be +emergency e1eb +emergency_recording ebf4 +emergency_share ebf6 +emoji_emotions ea22 +emoji_events ea23 +emoji_flags ea1a +emoji_food_beverage ea1b +emoji_nature ea1c +emoji_objects ea24 +emoji_people ea1d +emoji_symbols ea1e +emoji_transportation ea1f +energy_savings_leaf ec1a +engineering ea3d +enhance_photo_translate e8fc +enhanced_encryption e63f +equalizer e01d +error e000 +error_outline e001 +escalator f1a1 +escalator_warning f1ac +euro ea15 +euro_symbol e926 +ev_station e56d +event e878 +event_available e614 +event_busy e615 +event_note e616 +event_repeat eb7b +event_seat e903 +exit_to_app e879 +expand e94f +expand_circle_down e7cd +expand_less e5ce +expand_more e5cf +explicit e01e +explore e87a +explore_off e9a8 +exposure e3ca +exposure_minus_1 e3cb +exposure_minus_2 e3cc +exposure_neg_1 e3cb +exposure_neg_2 e3cc +exposure_plus_1 e3cd +exposure_plus_2 e3ce +exposure_zero e3cf +extension e87b +extension_off e4f5 +face e87c +face_2 f8da +face_3 f8db +face_4 f8dc +face_5 f8dd +face_6 f8de +face_retouching_natural ef4e +face_retouching_off f007 +face_unlock f008 +facebook f234 +fact_check f0c5 +factory ebbc +family_restroom f1a2 +fast_forward e01f +fast_rewind e020 +fastfood e57a +favorite e87d +favorite_border e87e +favorite_outline e87e +fax ead8 +featured_play_list e06d +featured_video e06e +feed f009 +feedback e87f +female e590 +fence f1f6 +festival ea68 +fiber_dvr e05d +fiber_manual_record e061 +fiber_new e05e +fiber_pin e06a +fiber_smart_record e062 +file_copy e173 +file_download e2c4 +file_download_done e9aa +file_download_off e4fe +file_open eaf3 +file_present ea0e +file_upload e2c6 +filter e3d3 +filter_1 e3d0 +filter_2 e3d1 +filter_3 e3d2 +filter_4 e3d4 +filter_5 e3d5 +filter_6 e3d6 +filter_7 e3d7 +filter_8 e3d8 +filter_9 e3d9 +filter_9_plus e3da +filter_alt ef4f +filter_alt_off eb32 +filter_b_and_w e3db +filter_center_focus e3dc +filter_drama e3dd +filter_frames e3de +filter_hdr e3df +filter_list e152 +filter_list_off eb57 +filter_none e3e0 +filter_tilt_shift e3e2 +filter_vintage e3e3 +find_in_page e880 +find_replace e881 +fingerprint e90d +fire_extinguisher f1d8 +fire_hydrant_alt f8f1 +fire_truck f8f2 +fireplace ea43 +first_page e5dc +fit_screen ea10 +fitbit e82b +fitness_center eb43 +flag e153 +flag_circle eaf8 +flaky ef50 +flare e3e4 +flash_auto e3e5 +flash_off e3e6 +flash_on e3e7 +flashlight_off f00a +flashlight_on f00b +flatware f00c +flight e539 +flight_class e7cb +flight_land e904 +flight_takeoff e905 +flip e3e8 +flip_camera_android ea37 +flip_camera_ios ea38 +flip_to_back e882 +flip_to_front e883 +flood ebe6 +flourescent f00d +fluorescent f00d +flutter_dash e00b +fmd_bad f00e +fmd_good f00f +folder e2c7 +folder_copy ebbd +folder_delete eb34 +folder_off eb83 +folder_open e2c8 +folder_shared e2c9 +folder_special e617 +folder_zip eb2c +follow_the_signs f222 +font_download e167 +font_download_off e4f9 +food_bank f1f2 +forest ea99 +fork_left eba0 +fork_right ebac +format_align_center e234 +format_align_justify e235 +format_align_left e236 +format_align_right e237 +format_bold e238 +format_clear e239 +format_color_fill e23a +format_color_reset e23b +format_color_text e23c +format_indent_decrease e23d +format_indent_increase e23e +format_italic e23f +format_line_spacing e240 +format_list_bulleted e241 +format_list_numbered e242 +format_list_numbered_rtl e267 +format_overline eb65 +format_paint e243 +format_quote e244 +format_shapes e25e +format_size e245 +format_strikethrough e246 +format_textdirection_l_to_r e247 +format_textdirection_r_to_l e248 +format_underline e765 +format_underlined e765 +fort eaad +forum e0bf +forward e154 +forward_10 e056 +forward_30 e057 +forward_5 e058 +forward_to_inbox f187 +foundation f200 +free_breakfast eb44 +free_cancellation e748 +front_hand e769 +fullscreen e5d0 +fullscreen_exit e5d1 +functions e24a +g_mobiledata f010 +g_translate e927 +gamepad e30f +games e021 +garage f011 +gas_meter ec19 +gavel e90e +generating_tokens e749 +gesture e155 +get_app e884 +gif e908 +gif_box e7a3 +girl eb68 +gite e58b +golf_course eb45 +gpp_bad f012 +gpp_good f013 +gpp_maybe f014 +gps_fixed e1b3 +gps_not_fixed e1b4 +gps_off e1b5 +grade e885 +gradient e3e9 +grading ea4f +grain e3ea +graphic_eq e1b8 +grass f205 +grid_3x3 f015 +grid_4x4 f016 +grid_goldenratio f017 +grid_off e3eb +grid_on e3ec +grid_view e9b0 +group e7ef +group_add e7f0 +group_off e747 +group_remove e7ad +group_work e886 +groups f233 +groups_2 f8df +groups_3 f8e0 +h_mobiledata f018 +h_plus_mobiledata f019 +hail e9b1 +handshake ebcb +handyman f10b +hardware ea59 +hd e052 +hdr_auto f01a +hdr_auto_select f01b +hdr_enhanced_select ef51 +hdr_off e3ed +hdr_off_select f01c +hdr_on e3ee +hdr_on_select f01d +hdr_plus f01e +hdr_strong e3f1 +hdr_weak e3f2 +headphones f01f +headphones_battery f020 +headset e310 +headset_mic e311 +headset_off e33a +healing e3f3 +health_and_safety e1d5 +hearing e023 +hearing_disabled f104 +heart_broken eac2 +heat_pump ec18 +height ea16 +help e887 +help_center f1c0 +help_outline e8fd +hevc f021 +hexagon eb39 +hide_image f022 +hide_source f023 +high_quality e024 +highlight e25f +highlight_alt ef52 +highlight_off e888 +highlight_remove e888 +hiking e50a +history e889 +history_edu ea3e +history_toggle_off f17d +hive eaa6 +hls eb8a +hls_off eb8c +holiday_village e58a +home e88a +home_max f024 +home_mini f025 +home_repair_service f100 +home_work ea09 +horizontal_distribute e014 +horizontal_rule f108 +horizontal_split e947 +hot_tub eb46 +hotel e53a +hotel_class e743 +hourglass_bottom ea5c +hourglass_disabled ef53 +hourglass_empty e88b +hourglass_full e88c +hourglass_top ea5b +house ea44 +house_siding f202 +houseboat e584 +how_to_reg e174 +how_to_vote e175 +html eb7e +http e902 +https e88d +hub e9f4 +hvac f10e +ice_skating e50b +icecream ea69 +image e3f4 +image_aspect_ratio e3f5 +image_not_supported f116 +image_search e43f +imagesearch_roller e9b4 +import_contacts e0e0 +import_export e0c3 +important_devices e912 +inbox e156 +incomplete_circle e79b +indeterminate_check_box e909 +info e88e +input e890 +insert_chart e24b +insert_chart_outlined e26a +insert_comment e24c +insert_drive_file e24d +insert_emoticon e24e +insert_invitation e24f +insert_link e250 +insert_page_break eaca +insert_photo e251 +insights f092 +install_desktop eb71 +install_mobile eb72 +integration_instructions ef54 +interests e7c8 +interpreter_mode e83b +inventory e179 +inventory_2 e1a1 +invert_colors e891 +invert_colors_off e0c4 +invert_colors_on e891 +ios_share e6b8 +iron e583 +iso e3f6 +javascript eb7c +join_full eaeb +join_inner eaf4 +join_left eaf2 +join_right eaea +kayaking e50c +kebab_dining e842 +key e73c +key_off eb84 +keyboard e312 +keyboard_alt f028 +keyboard_arrow_down e313 +keyboard_arrow_left e314 +keyboard_arrow_right e315 +keyboard_arrow_up e316 +keyboard_backspace e317 +keyboard_capslock e318 +keyboard_command_key eae7 +keyboard_control eae1 +keyboard_control_key eae6 +keyboard_double_arrow_down ead0 +keyboard_double_arrow_left eac3 +keyboard_double_arrow_right eac9 +keyboard_double_arrow_up eacf +keyboard_hide e31a +keyboard_option_key eae8 +keyboard_return e31b +keyboard_tab e31c +keyboard_voice e31d +king_bed ea45 +kitchen eb47 +kitesurfing e50d +label e892 +label_important e937 +label_off e9b6 +lan eb2f +landscape e3f7 +landslide ebd7 +language e894 +laptop e31e +laptop_chromebook e31f +laptop_mac e320 +laptop_windows e321 +last_page e5dd +launch e895 +layers e53b +layers_clear e53c +leaderboard f20c +leak_add e3f8 +leak_remove e3f9 +leave_bags_at_home f23b +legend_toggle f11b +lens e3fa +lens_blur f029 +library_add e02e +library_add_check e9b7 +library_books e02f +library_music e030 +light f02a +light_mode e518 +lightbulb e0f0 +lightbulb_circle ebfe +line_axis ea9a +line_style e919 +line_weight e91a +linear_scale e260 +link e157 +link_off e16f +linked_camera e438 +liquor ea60 +list e896 +list_alt e0ee +live_help e0c6 +live_tv e639 +living f02b +local_activity e53f +local_airport e53d +local_atm e53e +local_attraction e53f +local_bar e540 +local_cafe e541 +local_car_wash e542 +local_convenience_store e543 +local_dining e556 +local_drink e544 +local_fire_department ef55 +local_florist e545 +local_gas_station e546 +local_grocery_store e547 +local_hospital e548 +local_hotel e549 +local_laundry_service e54a +local_library e54b +local_mall e54c +local_movies e54d +local_offer e54e +local_parking e54f +local_pharmacy e550 +local_phone e551 +local_pizza e552 +local_play e553 +local_police ef56 +local_post_office e554 +local_print_shop e555 +local_printshop e555 +local_restaurant e556 +local_see e557 +local_shipping e558 +local_taxi e559 +location_city e7f1 +location_disabled e1b6 +location_history e55a +location_off e0c7 +location_on e0c8 +location_searching e1b7 +lock e897 +lock_clock ef57 +lock_open e898 +lock_person f8f3 +lock_reset eade +login ea77 +logo_dev ead6 +logout e9ba +looks e3fc +looks_3 e3fb +looks_4 e3fd +looks_5 e3fe +looks_6 e3ff +looks_one e400 +looks_two e401 +loop e028 +loupe e402 +low_priority e16d +loyalty e89a +lte_mobiledata f02c +lte_plus_mobiledata f02d +luggage f235 +lunch_dining ea61 +lyrics ec0b +macro_off f8d2 +mail e158 +mail_lock ec0a +mail_outline e0e1 +male e58e +man e4eb +man_2 f8e1 +man_3 f8e2 +man_4 f8e3 +manage_accounts f02e +manage_history ebe7 +manage_search f02f +map e55b +maps_home_work f030 +maps_ugc ef58 +margin e9bb +mark_as_unread e9bc +mark_chat_read f18b +mark_chat_unread f189 +mark_email_read f18c +mark_email_unread f18a +mark_unread_chat_alt eb9d +markunread e159 +markunread_mailbox e89b +masks f218 +maximize e930 +media_bluetooth_off f031 +media_bluetooth_on f032 +mediation efa7 +medical_information ebed +medical_services f109 +medication f033 +medication_liquid ea87 +meeting_room eb4f +memory e322 +menu e5d2 +menu_book ea19 +menu_open e9bd +merge eb98 +merge_type e252 +message e0c9 +messenger e0ca +messenger_outline e0cb +mic e029 +mic_external_off ef59 +mic_external_on ef5a +mic_none e02a +mic_off e02b +microwave f204 +military_tech ea3f +minimize e931 +minor_crash ebf1 +miscellaneous_services f10c +missed_video_call e073 +mms e618 +mobile_friendly e200 +mobile_off e201 +mobile_screen_share e0e7 +mobiledata_off f034 +mode f097 +mode_comment e253 +mode_edit e254 +mode_edit_outline f035 +mode_fan_off ec17 +mode_night f036 +mode_of_travel e7ce +mode_standby f037 +model_training f0cf +monetization_on e263 +money e57d +money_off e25c +money_off_csred f038 +monitor ef5b +monitor_heart eaa2 +monitor_weight f039 +monochrome_photos e403 +mood e7f2 +mood_bad e7f3 +moped eb28 +more e619 +more_horiz eae1 +more_time ea5d +more_vert e5d4 +mosque eab2 +motion_photos_auto f03a +motion_photos_off e9c0 +motion_photos_on e9c1 +motion_photos_pause f227 +motion_photos_paused e9c2 +motorcycle e91b +mouse e323 +move_down eb61 +move_to_inbox e168 +move_up eb64 +movie e02c +movie_creation e404 +movie_filter e43a +moving e501 +mp e9c3 +multiline_chart e6df +multiple_stop f1b9 +multitrack_audio e1b8 +museum ea36 +music_note e405 +music_off e440 +music_video e063 +my_library_add e02e +my_library_books e02f +my_library_music e030 +my_location e55c +nat ef5c +nature e406 +nature_people e407 +navigate_before e408 +navigate_next e409 +navigation e55d +near_me e569 +near_me_disabled f1ef +nearby_error f03b +nearby_off f03c +nest_cam_wired_stand ec16 +network_cell e1b9 +network_check e640 +network_locked e61a +network_ping ebca +network_wifi e1ba +network_wifi_1_bar ebe4 +network_wifi_2_bar ebd6 +network_wifi_3_bar ebe1 +new_label e609 +new_releases e031 +newspaper eb81 +next_plan ef5d +next_week e16a +nfc e1bb +night_shelter f1f1 +nightlife ea62 +nightlight f03d +nightlight_round ef5e +nights_stay ea46 +no_accounts f03e +no_adult_content f8fe +no_backpack f237 +no_cell f1a4 +no_crash ebf0 +no_drinks f1a5 +no_encryption e641 +no_encryption_gmailerrorred f03f +no_flash f1a6 +no_food f1a7 +no_luggage f23b +no_meals f1d6 +no_meeting_room eb4e +no_photography f1a8 +no_sim e0cc +no_stroller f1af +no_transfer f1d5 +noise_aware ebec +noise_control_off ebf3 +nordic_walking e50e +north f1e0 +north_east f1e1 +north_west f1e2 +not_accessible f0fe +not_interested e033 +not_listed_location e575 +not_started f0d1 +note e06f +note_add e89c +note_alt f040 +notes e26c +notification_add e399 +notification_important e004 +notifications e7f4 +notifications_active e7f7 +notifications_none e7f5 +notifications_off e7f6 +notifications_on e7f7 +notifications_paused e7f8 +now_wallpaper e75f +now_widgets e75e +numbers eac7 +offline_bolt e932 +offline_pin e90a +offline_share e9c5 +oil_barrel ec15 +on_device_training ebfd +ondemand_video e63a +online_prediction f0eb +opacity e91c +open_in_browser e89d +open_in_full f1ce +open_in_new e89e +open_in_new_off e4f6 +open_with e89f +other_houses e58c +outbond f228 +outbound e1ca +outbox ef5f +outdoor_grill ea47 +outlet f1d4 +outlined_flag e16e +output ebbe +padding e9c8 +pages e7f9 +pageview e8a0 +paid f041 +palette e40a +pan_tool e925 +pan_tool_alt ebb9 +panorama e40b +panorama_fish_eye e40c +panorama_fisheye e40c +panorama_horizontal e40d +panorama_horizontal_select ef60 +panorama_photosphere e9c9 +panorama_photosphere_select e9ca +panorama_vertical e40e +panorama_vertical_select ef61 +panorama_wide_angle e40f +panorama_wide_angle_select ef62 +paragliding e50f +park ea63 +party_mode e7fa +password f042 +paste f098 +pattern f043 +pause e034 +pause_circle e1a2 +pause_circle_filled e035 +pause_circle_outline e036 +pause_presentation e0ea +payment e8a1 +payments ef63 +paypal ea8d +pedal_bike eb29 +pending ef64 +pending_actions f1bb +pentagon eb50 +people e7fb +people_alt ea21 +people_outline e7fc +percent eb58 +perm_camera_mic e8a2 +perm_contact_cal e8a3 +perm_contact_calendar e8a3 +perm_data_setting e8a4 +perm_device_info e8a5 +perm_device_information e8a5 +perm_identity e8a6 +perm_media e8a7 +perm_phone_msg e8a8 +perm_scan_wifi e8a9 +person e7fd +person_2 f8e4 +person_3 f8e5 +person_4 f8e6 +person_add e7fe +person_add_alt ea4d +person_add_alt_1 ef65 +person_add_disabled e9cb +person_off e510 +person_outline e7ff +person_pin e55a +person_pin_circle e56a +person_remove ef66 +person_remove_alt_1 ef67 +person_search f106 +personal_injury e6da +personal_video e63b +pest_control f0fa +pest_control_rodent f0fd +pets e91d +phishing ead7 +phone e0cd +phone_android e324 +phone_bluetooth_speaker e61b +phone_callback e649 +phone_disabled e9cc +phone_enabled e9cd +phone_forwarded e61c +phone_in_talk e61d +phone_iphone e325 +phone_locked e61e +phone_missed e61f +phone_paused e620 +phonelink e326 +phonelink_erase e0db +phonelink_lock e0dc +phonelink_off e327 +phonelink_ring e0dd +phonelink_setup e0de +photo e410 +photo_album e411 +photo_camera e412 +photo_camera_back ef68 +photo_camera_front ef69 +photo_filter e43b +photo_library e413 +photo_size_select_actual e432 +photo_size_select_large e433 +photo_size_select_small e434 +php eb8f +piano e521 +piano_off e520 +picture_as_pdf e415 +picture_in_picture e8aa +picture_in_picture_alt e911 +pie_chart e6c4 +pie_chart_outline f044 +pin f045 +pin_drop e55e +pin_end e767 +pin_invoke e763 +pinch eb38 +pivot_table_chart e9ce +pix eaa3 +place e55f +plagiarism ea5a +play_arrow e037 +play_circle e1c4 +play_circle_fill e038 +play_circle_filled e038 +play_circle_outline e039 +play_disabled ef6a +play_for_work e906 +play_lesson f047 +playlist_add e03b +playlist_add_check e065 +playlist_add_check_circle e7e6 +playlist_add_circle e7e5 +playlist_play e05f +playlist_remove eb80 +plumbing f107 +plus_one e800 +podcasts f048 +point_of_sale f17e +policy ea17 +poll e801 +polyline ebbb +polymer e8ab +pool eb48 +portable_wifi_off e0ce +portrait e416 +post_add ea20 +power e63c +power_input e336 +power_off e646 +power_settings_new e8ac +precision_manufacturing f049 +pregnant_woman e91e +present_to_all e0df +preview f1c5 +price_change f04a +price_check f04b +print e8ad +print_disabled e9cf +priority_high e645 +privacy_tip f0dc +private_connectivity e744 +production_quantity_limits e1d1 +propane ec14 +propane_tank ec13 +psychology ea4a +psychology_alt f8ea +public e80b +public_off f1ca +publish e255 +published_with_changes f232 +punch_clock eaa8 +push_pin f10d +qr_code ef6b +qr_code_2 e00a +qr_code_scanner f206 +query_builder e8ae +query_stats e4fc +question_answer e8af +question_mark eb8b +queue e03c +queue_music e03d +queue_play_next e066 +quick_contacts_dialer e0cf +quick_contacts_mail e0d0 +quickreply ef6c +quiz f04c +quora ea98 +r_mobiledata f04d +radar f04e +radio e03e +radio_button_checked e837 +radio_button_off e836 +radio_button_on e837 +radio_button_unchecked e836 +railway_alert e9d1 +ramen_dining ea64 +ramp_left eb9c +ramp_right eb96 +rate_review e560 +raw_off f04f +raw_on f050 +read_more ef6d +real_estate_agent e73a +receipt e8b0 +receipt_long ef6e +recent_actors e03f +recommend e9d2 +record_voice_over e91f +rectangle eb54 +recycling e760 +reddit eaa0 +redeem e8b1 +redo e15a +reduce_capacity f21c +refresh e5d5 +remember_me f051 +remove e15b +remove_circle e15c +remove_circle_outline e15d +remove_done e9d3 +remove_from_queue e067 +remove_moderator e9d4 +remove_red_eye e417 +remove_road ebfc +remove_shopping_cart e928 +reorder e8fe +repartition f8e8 +repeat e040 +repeat_on e9d6 +repeat_one e041 +repeat_one_on e9d7 +replay e042 +replay_10 e059 +replay_30 e05a +replay_5 e05b +replay_circle_filled e9d8 +reply e15e +reply_all e15f +report e160 +report_gmailerrorred f052 +report_off e170 +report_problem e8b2 +request_page f22c +request_quote f1b6 +reset_tv e9d9 +restart_alt f053 +restaurant e56c +restaurant_menu e561 +restore e8b3 +restore_from_trash e938 +restore_page e929 +reviews f054 +rice_bowl f1f5 +ring_volume e0d1 +rocket eba5 +rocket_launch eb9b +roller_shades ec12 +roller_shades_closed ec11 +roller_skating ebcd +roofing f201 +room e8b4 +room_preferences f1b8 +room_service eb49 +rotate_90_degrees_ccw e418 +rotate_90_degrees_cw eaab +rotate_left e419 +rotate_right e41a +roundabout_left eb99 +roundabout_right eba3 +rounded_corner e920 +route eacd +router e328 +rowing e921 +rss_feed e0e5 +rsvp f055 +rtt e9ad +rule f1c2 +rule_folder f1c9 +run_circle ef6f +running_with_errors e51d +rv_hookup e642 +safety_check ebef +safety_divider e1cc +sailing e502 +sanitizer f21d +satellite e562 +satellite_alt eb3a +save e161 +save_alt e171 +save_as eb60 +saved_search ea11 +savings e2eb +scale eb5f +scanner e329 +scatter_plot e268 +schedule e8b5 +schedule_send ea0a +schema e4fd +school e80c +science ea4b +score e269 +scoreboard ebd0 +screen_lock_landscape e1be +screen_lock_portrait e1bf +screen_lock_rotation e1c0 +screen_rotation e1c1 +screen_rotation_alt ebee +screen_search_desktop ef70 +screen_share e0e2 +screenshot f056 +screenshot_monitor ec08 +scuba_diving ebce +sd e9dd +sd_card e623 +sd_card_alert f057 +sd_storage e1c2 +search e8b6 +search_off ea76 +security e32a +security_update f058 +security_update_good f059 +security_update_warning f05a +segment e94b +select_all e162 +self_improvement ea78 +sell f05b +send e163 +send_and_archive ea0c +send_time_extension eadb +send_to_mobile f05c +sensor_door f1b5 +sensor_occupied ec10 +sensor_window f1b4 +sensors e51e +sensors_off e51f +sentiment_dissatisfied e811 +sentiment_neutral e812 +sentiment_satisfied e813 +sentiment_satisfied_alt e0ed +sentiment_very_dissatisfied e814 +sentiment_very_satisfied e815 +set_meal f1ea +settings e8b8 +settings_accessibility f05d +settings_applications e8b9 +settings_backup_restore e8ba +settings_bluetooth e8bb +settings_brightness e8bd +settings_cell e8bc +settings_display e8bd +settings_ethernet e8be +settings_input_antenna e8bf +settings_input_component e8c0 +settings_input_composite e8c1 +settings_input_hdmi e8c2 +settings_input_svideo e8c3 +settings_overscan e8c4 +settings_phone e8c5 +settings_power e8c6 +settings_remote e8c7 +settings_suggest f05e +settings_system_daydream e1c3 +settings_voice e8c8 +severe_cold ebd3 +shape_line f8d3 +share e80d +share_arrival_time e524 +share_location f05f +shield e9e0 +shield_moon eaa9 +shop e8c9 +shop_2 e19e +shop_two e8ca +shopify ea9d +shopping_bag f1cc +shopping_basket e8cb +shopping_cart e8cc +shopping_cart_checkout eb88 +short_text e261 +shortcut f060 +show_chart e6e1 +shower f061 +shuffle e043 +shuffle_on e9e1 +shutter_speed e43d +sick f220 +sign_language ebe5 +signal_cellular_0_bar f0a8 +signal_cellular_4_bar e1c8 +signal_cellular_alt e202 +signal_cellular_alt_1_bar ebdf +signal_cellular_alt_2_bar ebe3 +signal_cellular_connected_no_internet_0_bar f0ac +signal_cellular_connected_no_internet_4_bar e1cd +signal_cellular_no_sim e1ce +signal_cellular_nodata f062 +signal_cellular_null e1cf +signal_cellular_off e1d0 +signal_wifi_0_bar f0b0 +signal_wifi_4_bar e1d8 +signal_wifi_4_bar_lock e1d9 +signal_wifi_bad f063 +signal_wifi_connected_no_internet_4 f064 +signal_wifi_off e1da +signal_wifi_statusbar_4_bar f065 +signal_wifi_statusbar_connected_no_internet_4 f066 +signal_wifi_statusbar_null f067 +signpost eb91 +sim_card e32b +sim_card_alert e624 +sim_card_download f068 +single_bed ea48 +sip f069 +skateboarding e511 +skip_next e044 +skip_previous e045 +sledding e512 +slideshow e41b +slow_motion_video e068 +smart_button f1c1 +smart_display f06a +smart_screen f06b +smart_toy f06c +smartphone e32c +smoke_free eb4a +smoking_rooms eb4b +sms e625 +sms_failed e626 +snapchat ea6e +snippet_folder f1c7 +snooze e046 +snowboarding e513 +snowmobile e503 +snowshoeing e514 +soap f1b2 +social_distance e1cb +solar_power ec0f +sort e164 +sort_by_alpha e053 +sos ebf7 +soup_kitchen e7d3 +source f1c4 +south f1e3 +south_america e7e4 +south_east f1e4 +south_west f1e5 +spa eb4c +space_bar e256 +space_dashboard e66b +spatial_audio ebeb +spatial_audio_off ebe8 +spatial_tracking ebea +speaker e32d +speaker_group e32e +speaker_notes e8cd +speaker_notes_off e92a +speaker_phone e0d2 +speed e9e4 +spellcheck e8ce +splitscreen f06d +spoke e9a7 +sports ea30 +sports_bar f1f3 +sports_baseball ea51 +sports_basketball ea26 +sports_cricket ea27 +sports_esports ea28 +sports_football ea29 +sports_golf ea2a +sports_gymnastics ebc4 +sports_handball ea33 +sports_hockey ea2b +sports_kabaddi ea34 +sports_martial_arts eae9 +sports_mma ea2c +sports_motorsports ea2d +sports_rugby ea2e +sports_score f06e +sports_soccer ea2f +sports_tennis ea32 +sports_volleyball ea31 +square eb36 +square_foot ea49 +ssid_chart eb66 +stacked_bar_chart e9e6 +stacked_line_chart f22b +stadium eb90 +stairs f1a9 +star e838 +star_border e83a +star_border_purple500 f099 +star_half e839 +star_outline f06f +star_purple500 f09a +star_rate f0ec +stars e8d0 +start e089 +stay_current_landscape e0d3 +stay_current_portrait e0d4 +stay_primary_landscape e0d5 +stay_primary_portrait e0d6 +sticky_note_2 f1fc +stop e047 +stop_circle ef71 +stop_screen_share e0e3 +storage e1db +store e8d1 +store_mall_directory e563 +storefront ea12 +storm f070 +straight eb95 +straighten e41c +stream e9e9 +streetview e56e +strikethrough_s e257 +stroller f1ae +style e41d +subdirectory_arrow_left e5d9 +subdirectory_arrow_right e5da +subject e8d2 +subscript f111 +subscriptions e064 +subtitles e048 +subtitles_off ef72 +subway e56f +summarize f071 +superscript f112 +supervised_user_circle e939 +supervisor_account e8d3 +support ef73 +support_agent f0e2 +surfing e515 +surround_sound e049 +swap_calls e0d7 +swap_horiz e8d4 +swap_horizontal_circle e933 +swap_vert e8d5 +swap_vert_circle e8d6 +swap_vertical_circle e8d6 +swipe e9ec +swipe_down eb53 +swipe_down_alt eb30 +swipe_left eb59 +swipe_left_alt eb33 +swipe_right eb52 +swipe_right_alt eb56 +swipe_up eb2e +swipe_up_alt eb35 +swipe_vertical eb51 +switch_access_shortcut e7e1 +switch_access_shortcut_add e7e2 +switch_account e9ed +switch_camera e41e +switch_left f1d1 +switch_right f1d2 +switch_video e41f +synagogue eab0 +sync e627 +sync_alt ea18 +sync_disabled e628 +sync_lock eaee +sync_problem e629 +system_security_update f072 +system_security_update_good f073 +system_security_update_warning f074 +system_update e62a +system_update_alt e8d7 +system_update_tv e8d7 +tab e8d8 +tab_unselected e8d9 +table_bar ead2 +table_chart e265 +table_restaurant eac6 +table_rows f101 +table_view f1be +tablet e32f +tablet_android e330 +tablet_mac e331 +tag e9ef +tag_faces e420 +takeout_dining ea74 +tap_and_play e62b +tapas f1e9 +task f075 +task_alt e2e6 +taxi_alert ef74 +telegram ea6b +temple_buddhist eab3 +temple_hindu eaaf +terminal eb8e +terrain e564 +text_decrease eadd +text_fields e262 +text_format e165 +text_increase eae2 +text_rotate_up e93a +text_rotate_vertical e93b +text_rotation_angledown e93c +text_rotation_angleup e93d +text_rotation_down e93e +text_rotation_none e93f +text_snippet f1c6 +textsms e0d8 +texture e421 +theater_comedy ea66 +theaters e8da +thermostat f076 +thermostat_auto f077 +thumb_down e8db +thumb_down_alt e816 +thumb_down_off_alt e9f2 +thumb_up e8dc +thumb_up_alt e817 +thumb_up_off_alt e9f3 +thumbs_up_down e8dd +thunderstorm ebdb +tiktok ea7e +time_to_leave e62c +timelapse e422 +timeline e922 +timer e425 +timer_10 e423 +timer_10_select f07a +timer_3 e424 +timer_3_select f07b +timer_off e426 +tips_and_updates e79a +tire_repair ebc8 +title e264 +toc e8de +today e8df +toggle_off e9f5 +toggle_on e9f6 +token ea25 +toll e8e0 +tonality e427 +topic f1c8 +tornado e199 +touch_app e913 +tour ef75 +toys e332 +track_changes e8e1 +traffic e565 +train e570 +tram e571 +transcribe f8ec +transfer_within_a_station e572 +transform e428 +transgender e58d +transit_enterexit e579 +translate e8e2 +travel_explore e2db +trending_down e8e3 +trending_flat e8e4 +trending_neutral e8e4 +trending_up e8e5 +trip_origin e57b +troubleshoot e1d2 +try f07c +tsunami ebd8 +tty f1aa +tune e429 +tungsten f07d +turn_left eba6 +turn_right ebab +turn_sharp_left eba7 +turn_sharp_right ebaa +turn_slight_left eba4 +turn_slight_right eb9a +turned_in e8e6 +turned_in_not e8e7 +tv e333 +tv_off e647 +two_wheeler e9f9 +type_specimen f8f0 +u_turn_left eba1 +u_turn_right eba2 +umbrella f1ad +unarchive e169 +undo e166 +unfold_less e5d6 +unfold_less_double f8cf +unfold_more e5d7 +unfold_more_double f8d0 +unpublished f236 +unsubscribe e0eb +upcoming f07e +update e923 +update_disabled e075 +upgrade f0fb +upload f09b +upload_file e9fc +usb e1e0 +usb_off e4fa +vaccines e138 +vape_free ebc6 +vaping_rooms ebcf +verified ef76 +verified_user e8e8 +vertical_align_bottom e258 +vertical_align_center e259 +vertical_align_top e25a +vertical_distribute e076 +vertical_shades ec0e +vertical_shades_closed ec0d +vertical_split e949 +vibration e62d +video_call e070 +video_camera_back f07f +video_camera_front f080 +video_chat f8a0 +video_collection e04a +video_file eb87 +video_label e071 +video_library e04a +video_settings ea75 +video_stable f081 +videocam e04b +videocam_off e04c +videogame_asset e338 +videogame_asset_off e500 +view_agenda e8e9 +view_array e8ea +view_carousel e8eb +view_column e8ec +view_comfortable e42a +view_comfy e42a +view_comfy_alt eb73 +view_compact e42b +view_compact_alt eb74 +view_cozy eb75 +view_day e8ed +view_headline e8ee +view_in_ar e9fe +view_kanban eb7f +view_list e8ef +view_module e8f0 +view_quilt e8f1 +view_sidebar f114 +view_stream e8f2 +view_timeline eb85 +view_week e8f3 +vignette e435 +villa e586 +visibility e8f4 +visibility_off e8f5 +voice_chat e62e +voice_over_off e94a +voicemail e0d9 +volcano ebda +volume_down e04d +volume_mute e04e +volume_off e04f +volume_up e050 +volunteer_activism ea70 +vpn_key e0da +vpn_key_off eb7a +vpn_lock e62f +vrpano f082 +wallet f8ff +wallet_giftcard e8f6 +wallet_membership e8f7 +wallet_travel e8f8 +wallpaper e75f +warehouse ebb8 +warning e002 +warning_amber f083 +wash f1b1 +watch e334 +watch_later e924 +watch_off eae3 +water f084 +water_damage f203 +water_drop e798 +waterfall_chart ea00 +waves e176 +waving_hand e766 +wb_auto e42c +wb_cloudy e42d +wb_incandescent e42e +wb_iridescent e436 +wb_shade ea01 +wb_sunny e430 +wb_twilight e1c6 +wc e63d +web e051 +web_asset e069 +web_asset_off e4f7 +web_stories e595 +webhook eb92 +wechat ea81 +weekend e16b +west f1e6 +whatshot e80e +wheelchair_pickup f1ab +where_to_vote e177 +widgets e75e +width_full f8f5 +width_normal f8f6 +width_wide f8f7 +wifi e63e +wifi_1_bar e4ca +wifi_2_bar e4d9 +wifi_calling ef77 +wifi_calling_3 f085 +wifi_channel eb6a +wifi_find eb31 +wifi_lock e1e1 +wifi_off e648 +wifi_password eb6b +wifi_protected_setup f0fc +wifi_tethering e1e2 +wifi_tethering_error f086 +wifi_tethering_error_rounded f086 +wifi_tethering_off f087 +wind_power ec0c +window f088 +wine_bar f1e8 +woman e13e +woman_2 f8e7 +woo_commerce ea6d +wordpress ea9f +work e8f9 +work_history ec09 +work_off e942 +work_outline e943 +workspace_premium e7af +workspaces e1a0 +wrap_text e25b +wrong_location ef78 +wysiwyg f1c3 +yard f089 +youtube_searched_for e8fa +zoom_in e8ff +zoom_in_map eb2d +zoom_out e900 +zoom_out_map e56b diff --git a/src/lib/icons/material-icons.test.ts b/src/lib/icons/material-icons.test.ts new file mode 100644 index 0000000..da483fb --- /dev/null +++ b/src/lib/icons/material-icons.test.ts @@ -0,0 +1,186 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Guardia contra ligaduras fantasma en la fuente de iconos. + * + * Cuando el nombre de un icono no existe en la fuente cargada, el navegador NO falla: + * renderiza el nombre como texto literal. Un `progress_activity` (de Material Symbols, 2023) + * se pinta como la cadena "progress_activity" de ~200 px a los 24 px que fija la clase + * `.material-icons-outlined`, y con `animate-spin` la hace girar. Eso desbordó el pie del modal + * de instalación de /versiones-cras y recortó el botón "Cerrar" fuera del panel. + * + * Ni `tsc --noEmit` ni el build lo detectan: no es error de tipos ni de sintaxis. Este test es + * la única compuerta. + */ + +const SRC_DIR = fileURLToPath(new URL('../../', import.meta.url)); +const CODEPOINTS_FILE = fileURLToPath( + new URL('./material-icons-outlined.codepoints', import.meta.url) +); + +const ICON_CLASS = 'material-icons-outlined'; + +/** Cuerpo del span que es exactamente un nombre de icono. */ +const LIGATURE_BODY = /^[a-z0-9][a-z0-9_]*$/; +/** + * Literales en posición de comparación (`tone === 'warn'`) no son nombres de icono: se + * descartan antes de extraer, o cada `class:text-red-600={c.status === 'fail'}` metería basura. + */ +const COMPARISON = /[=!]==?\s*'[a-z0-9_]*'/g; +/** Literales de una expresión inline: `{cond ? 'check_circle' : 'error_outline'}`. */ +const EXPRESSION_LITERAL = /'([a-z][a-z0-9_]{2,})'/g; +/** Nombres declarados como dato: `{ key: 'x', icon: 'inventory_2' }`. */ +const ICON_PROPERTY = /\bicon:\s*'([a-z0-9][a-z0-9_]*)'/g; +/** Mapas de nombres de icono: `const CHECK_ICON: Record = { ... };`. */ +const ICON_MAP = /const\s+[A-Z0-9_]*ICON[A-Z0-9_]*[^=]*=\s*\{([\s\S]*?)\n\s*\};/g; + +/** + * Lista blanca autoritativa: el .codepoints del release de google/material-design-icons, el + * mismo con el que se genera el .woff2 que sirve Google. Una lista curada a mano no sirve: + * cualquiera le agregaría el nombre nuevo sin verificar que exista, que es justo el error que + * este test previene. + */ +const VALID_LIGATURES = new Set( + readFileSync(CODEPOINTS_FILE, 'utf8') + .split('\n') + .map((line) => line.trim().split(' ')[0]) + .filter(Boolean) +); + +interface Usage { + name: string; + file: string; + line: number; +} + +function walk(dir: string, extensions: string[]): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...walk(full, extensions)); + else if (extensions.some((ext) => entry.name.endsWith(ext))) found.push(full); + } + return found; +} + +function lineOf(text: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) if (text[i] === '\n') line += 1; + return line; +} + +/** Primer '>' que cierra la etiqueta, ignorando las flechas '=>' de los handlers. */ +function findTagEnd(text: string, from: number): number { + for (let i = from; i < text.length; i++) { + if (text[i] === '>' && text[i - 1] !== '=') return i; + } + return -1; +} + +function literalsIn(expression: string): string[] { + return [...expression.replace(COMPARISON, '').matchAll(EXPRESSION_LITERAL)].map((m) => m[1]); +} + +/** Ligaduras escritas dentro de un elemento que lleva la clase de iconos. */ +function fromIconElements(text: string, file: string): Usage[] { + const found: Usage[] = []; + let cursor = 0; + for (;;) { + const at = text.indexOf(ICON_CLASS, cursor); + if (at === -1) break; + cursor = at + ICON_CLASS.length; + + const tagEnd = findTagEnd(text, cursor); + if (tagEnd === -1) break; + const nextTag = text.indexOf('<', tagEnd + 1); + const body = text.slice(tagEnd + 1, nextTag === -1 ? text.length : nextTag).trim(); + const line = lineOf(text, at); + + if (LIGATURE_BODY.test(body)) { + found.push({ name: body, file, line }); + } else { + // Expresión inline. Las referencias (`{item.icon}`) las cubre fromIconData. + for (const name of literalsIn(body)) found.push({ name, file, line }); + } + if (nextTag !== -1) cursor = nextTag; + } + return found; +} + +/** + * Nombres que viven como dato en lugar de en la plantilla: propiedades `icon:` (nav.ts, + * AppShell, STATUS_META) y mapas `const *ICON* = { ... }` (CHECK_ICON). Convención: todo mapa de + * nombres de icono debe llamarse *ICON* o exponerlos en una propiedad `icon`, o este test no lo ve. + */ +function fromIconData(text: string, file: string): Usage[] { + const found: Usage[] = []; + for (const match of text.matchAll(ICON_PROPERTY)) { + found.push({ name: match[1], file, line: lineOf(text, match.index ?? 0) }); + } + for (const declaration of text.matchAll(ICON_MAP)) { + const line = lineOf(text, declaration.index ?? 0); + for (const name of literalsIn(declaration[1])) found.push({ name, file, line }); + } + return found; +} + +function usagesIn(file: string): Usage[] { + const text = readFileSync(file, 'utf8'); + return file.endsWith('.svelte') + ? [...fromIconElements(text, file), ...fromIconData(text, file)] + : fromIconData(text, file); +} + +describe('ligaduras de Material Icons Outlined', () => { + const files = walk(SRC_DIR, ['.svelte', '.ts']).filter((file) => !file.endsWith('.test.ts')); + + it('carga el set clásico completo como lista blanca', () => { + expect(VALID_LIGATURES.size).toBeGreaterThan(2000); + expect(VALID_LIGATURES.has('sync')).toBe(true); + expect(VALID_LIGATURES.has('autorenew')).toBe(true); + // De Material Symbols (2023), no del set clásico. Si esta aserción empieza a fallar es + // que alguien cambió el .codepoints por el de Symbols sin migrar app.html. + expect(VALID_LIGATURES.has('progress_activity')).toBe(false); + }); + + it('detecta ligaduras en las cuatro formas de uso del panel', () => { + const names = new Set(files.flatMap((file) => usagesIn(file)).map((usage) => usage.name)); + // Si el extractor se rompe, dejaría de proteger sin dar señal: estas cuatro formas + // cubren todo lo que hay hoy en src/. + expect(names.has('network_check')).toBe(true); // literal en el cuerpo del span + expect(names.has('chevron_left')).toBe(true); // ternario inline (AppShell) + expect(names.has('remove_circle_outline')).toBe(true); // mapa CHECK_ICON + expect(names.has('cleaning_services')).toBe(true); // propiedad icon: (AppShell) + expect(names.size).toBeGreaterThan(50); + }); + + it('todas las ligaduras usadas existen en la fuente que carga el panel', () => { + const invalid = files + .flatMap((file) => usagesIn(file)) + .filter((usage) => !VALID_LIGATURES.has(usage.name)) + .map((usage) => `${relative(SRC_DIR, usage.file)}:${usage.line} → "${usage.name}"`); + + expect( + invalid, + 'Estas ligaduras no existen en Material Icons Outlined. El navegador renderiza el ' + + 'nombre como TEXTO LITERAL en lugar del icono y desborda el contenedor. Usa un ' + + 'nombre del set clásico (ver material-icons-outlined.codepoints) o un SVG inline ' + + '(ver $lib/components/Spinner.svelte):\n' + + invalid.join('\n') + ).toEqual([]); + }); + + it('la fuente declarada es la misma cuya clase usan las plantillas', () => { + const appHtml = readFileSync(join(SRC_DIR, 'app.html'), 'utf8'); + const appCss = readFileSync(join(SRC_DIR, 'app.css'), 'utf8'); + // Servida por CDN (hoy) o self-hosted con @font-face en app.css (paso siguiente). + const declared = + appHtml.includes('Material+Icons+Outlined') || appCss.includes(`.${ICON_CLASS}`); + expect(declared, 'Nadie declara la fuente de iconos que usan las plantillas').toBe(true); + // Una migración a Symbols a medias dejaría las 110 ligaduras sin su clase. + expect(appHtml).not.toContain('Material+Symbols'); + }); +}); diff --git a/src/lib/restore-filter.test.ts b/src/lib/restore-filter.test.ts new file mode 100644 index 0000000..b0c5f20 --- /dev/null +++ b/src/lib/restore-filter.test.ts @@ -0,0 +1,170 @@ +/** + * Filtrado de las listas de restauraciones. + * + * Lo que más importa aquí: que los campos nulos no revienten (la mitad de las columnas de + * `restore_job_logs` son nullable y los JOIN a `restore_targets`/`database_nodes` son LEFT, así + * que `server_name`, `node_key` y `client_name` llegan null con frecuencia) y que buscar dentro + * de `error_message` funcione, que es lo que permite agrupar fallos por causa. + */ +import { describe, expect, it } from 'vitest'; +import { + filterFailed, + filterRestored, + matchesFailed, + matchesRestored, + normalizeTerm +} from './restore-filter'; + +const RESTAURADO = { + id: 1, + server_name: 'Alfa', + node_key: 'GENERICA-01', + client_name: 'Importadora del Norte', + db_name: 'GENERICA_TEST', + filename: 'GENERICA-TEST.ZIP' +}; + +const FALLIDO = { + id: 2, + server_name: 'Omega', + db_name: 'EMPRESA_DB', + filename: 'EMPRESA.ZIP.001', + error_message: 'Timeout al conectar con SQL Server tras 60s' +}; + +describe('normalizeTerm', () => { + it('recorta y baja a minúsculas', () => { + expect(normalizeTerm(' Alfa ')).toBe('alfa'); + }); + + it('nulo, undefined y espacios son "sin filtro"', () => { + expect(normalizeTerm(null)).toBe(''); + expect(normalizeTerm(undefined)).toBe(''); + expect(normalizeTerm(' ')).toBe(''); + }); +}); + +describe('matchesRestored', () => { + it('encuentra por cada uno de sus cinco campos', () => { + for (const term of ['alfa', 'generica-01', 'importadora', 'generica_test', '.zip']) { + expect(matchesRestored(RESTAURADO, term), term).toBe(true); + } + }); + + it('es insensible a mayúsculas en el dato, no solo en el término', () => { + // El dato viene en MAYÚSCULAS de los sistemas legados; el término en minúsculas. + expect(matchesRestored(RESTAURADO, 'generica-test.zip')).toBe(true); + }); + + it('no encuentra lo que no está', () => { + expect(matchesRestored(RESTAURADO, 'omega')).toBe(false); + }); + + it('término vacío siempre coincide', () => { + expect(matchesRestored(RESTAURADO, '')).toBe(true); + }); + + it('no revienta con todos los campos nulos', () => { + const vacio = { + server_name: null, + node_key: null, + client_name: null, + db_name: null, + filename: null + }; + expect(matchesRestored(vacio, 'algo')).toBe(false); + expect(matchesRestored(vacio, '')).toBe(true); + }); + + it('no revienta con campos ausentes', () => { + expect(matchesRestored({}, 'algo')).toBe(false); + }); + + it('no confunde campos al concatenar', () => { + // "alfagenerica" no debe coincidir: la concatenación lleva separador. + expect(matchesRestored(RESTAURADO, 'alfagenerica')).toBe(false); + expect(matchesRestored(RESTAURADO, 'alfa generica-01')).toBe(true); + }); +}); + +describe('matchesFailed', () => { + it('busca dentro del mensaje de error', () => { + // Es la razón de incluir error_message: agrupar por causa. + expect(matchesFailed(FALLIDO, 'timeout')).toBe(true); + expect(matchesFailed(FALLIDO, 'sql server')).toBe(true); + }); + + it('encuentra por servidor, base y archivo', () => { + for (const term of ['omega', 'empresa_db', 'empresa.zip.001']) { + expect(matchesFailed(FALLIDO, term), term).toBe(true); + } + }); + + it('no revienta sin mensaje de error', () => { + const sinError = { ...FALLIDO, error_message: null }; + expect(matchesFailed(sinError, 'timeout')).toBe(false); + expect(matchesFailed(sinError, 'omega')).toBe(true); + }); + + it('no busca en campos que no le corresponden', () => { + // Los fallidos no traen client_name; un término que solo esté ahí no debe coincidir. + expect(matchesFailed({ ...FALLIDO, ...{ client_name: 'Importadora' } }, 'importadora')).toBe( + false + ); + }); +}); + +describe('filterRestored / filterFailed', () => { + const restaurados = [ + RESTAURADO, + { ...RESTAURADO, id: 2, server_name: 'Omega', filename: 'OTRA.ZIP' } + ]; + + it('término vacío devuelve todas las filas', () => { + expect(filterRestored(restaurados, '')).toHaveLength(2); + expect(filterRestored(restaurados, ' ')).toHaveLength(2); + expect(filterRestored(restaurados, null)).toHaveLength(2); + }); + + it('filtra por término', () => { + expect(filterRestored(restaurados, 'omega').map((r) => r.id)).toEqual([2]); + expect(filterRestored(restaurados, 'otra.zip').map((r) => r.id)).toEqual([2]); + }); + + it('devuelve vacío cuando nada coincide', () => { + expect(filterRestored(restaurados, 'nada-de-esto')).toEqual([]); + }); + + it('tolera null y undefined como lista', () => { + expect(filterRestored(null, 'x')).toEqual([]); + expect(filterRestored(undefined, '')).toEqual([]); + expect(filterFailed(null, 'x')).toEqual([]); + }); + + it('no muta el arreglo original', () => { + const original = [...restaurados]; + filterRestored(restaurados, 'omega'); + expect(restaurados).toEqual(original); + }); + + it('devuelve una copia, no la misma referencia, con término vacío', () => { + // Importa porque la UI puede reordenar el resultado sin tocar la data del load. + const result = filterRestored(restaurados, ''); + expect(result).not.toBe(restaurados); + expect(result).toEqual(restaurados); + }); + + it('preserva el orden de entrada (restored_at DESC del SQL)', () => { + const muchos = [1, 2, 3, 4].map((id) => ({ ...RESTAURADO, id })); + expect(filterRestored(muchos, 'alfa').map((r) => r.id)).toEqual([1, 2, 3, 4]); + }); + + it('filterFailed encuentra por mensaje de error en una lista', () => { + const fallidos = [ + FALLIDO, + { ...FALLIDO, id: 3, error_message: 'No se encontró el archivo .bak' } + ]; + expect(filterFailed(fallidos, 'timeout').map((f) => f.id)).toEqual([2]); + expect(filterFailed(fallidos, '.bak').map((f) => f.id)).toEqual([3]); + }); +}); diff --git a/src/lib/restore-filter.ts b/src/lib/restore-filter.ts new file mode 100644 index 0000000..4535f06 --- /dev/null +++ b/src/lib/restore-filter.ts @@ -0,0 +1,91 @@ +/** + * Filtrado de las listas de restauraciones del dashboard. + * + * Vive fuera del `.svelte` para poder probarse: los otros seis filtros del dashboard están + * declarados dentro de `+page.svelte` y ninguno tiene prueba. Este módulo abre el camino para + * migrar los demás sin reescribirlos. + * + * Convención del repo, respetada aquí: `String(x ?? '').toLowerCase()` + `.includes()`. Sin + * debounce ni normalización de acentos, porque ningún buscador del panel los usa y tener dos + * comportamientos distintos sería peor que no tenerlos. Si se agregan, va en todos a la vez. + */ + +/** Campos por los que se busca en Respaldos Restaurados. */ +export interface RestoredSearchable { + server_name?: string | null; + node_key?: string | null; + client_name?: string | null; + db_name?: string | null; + filename?: string | null; +} + +/** Campos por los que se busca en Restores Fallidos. */ +export interface FailedSearchable { + server_name?: string | null; + db_name?: string | null; + filename?: string | null; + error_message?: string | null; +} + +/** Normaliza el término: recortado y en minúsculas. Cadena vacía = sin filtro. */ +export function normalizeTerm(term: string | null | undefined): string { + return String(term ?? '') + .trim() + .toLowerCase(); +} + +/** + * Concatena los campos en una sola cadena en minúsculas para buscar de un tirón. Es el truco + * compacto que ya usa `servidores-restauracion/+page.svelte`, y evita repetir `.toLowerCase()` + * por campo en cada fila. + */ +function haystack(values: (string | null | undefined)[]): string { + return values.map((v) => String(v ?? '')).join(' ').toLowerCase(); +} + +export function matchesRestored(row: RestoredSearchable, term: string): boolean { + if (!term) return true; + return haystack([ + row.server_name, + row.node_key, + row.client_name, + row.db_name, + row.filename + ]).includes(term); +} + +/** + * En los fallidos se busca también dentro de `error_message`: es lo que permite agrupar por + * causa (escribir "timeout" y ver todos los que fallaron por lo mismo). + */ +export function matchesFailed(row: FailedSearchable, term: string): boolean { + if (!term) return true; + return haystack([ + row.server_name, + row.db_name, + row.filename, + row.error_message + ]).includes(term); +} + +/** Filtra Respaldos Restaurados. Con término vacío devuelve el mismo arreglo recibido. */ +export function filterRestored( + rows: readonly T[] | null | undefined, + term: string | null | undefined +): T[] { + const list = rows ?? []; + const needle = normalizeTerm(term); + if (!needle) return [...list]; + return list.filter((row) => matchesRestored(row, needle)); +} + +/** Filtra Restores Fallidos. Con término vacío devuelve el mismo arreglo recibido. */ +export function filterFailed( + rows: readonly T[] | null | undefined, + term: string | null | undefined +): T[] { + const list = rows ?? []; + const needle = normalizeTerm(term); + if (!needle) return [...list]; + return list.filter((row) => matchesFailed(row, needle)); +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index ff8000c..d182183 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -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 ) { diff --git a/src/lib/server/controldesk-pg.test.ts b/src/lib/server/controldesk-pg.test.ts index 1ab1ba8..9e0365a 100644 --- a/src/lib/server/controldesk-pg.test.ts +++ b/src/lib/server/controldesk-pg.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index a2a2726..4c7fd6e 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -69,6 +69,20 @@ async function ensureCloudRestoreStatusTable(): Promise { 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 { + 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 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 { 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 { + 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; diff --git a/src/lib/server/cras-artifacts.test.ts b/src/lib/server/cras-artifacts.test.ts new file mode 100644 index 0000000..57f0b7b --- /dev/null +++ b/src/lib/server/cras-artifacts.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/server/cras-artifacts.ts b/src/lib/server/cras-artifacts.ts new file mode 100644 index 0000000..9ccdf98 --- /dev/null +++ b/src/lib/server/cras-artifacts.ts @@ -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 { + 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 { + 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): 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts new file mode 100644 index 0000000..f7febd5 --- /dev/null +++ b/src/lib/server/cras-install.test.ts @@ -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 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 '' | 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'); + }); +}); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts new file mode 100644 index 0000000..406b6a5 --- /dev/null +++ b/src/lib/server/cras-install.ts @@ -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 { + 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((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 { + 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 { + 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 { + // 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 { + 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 { + // --- 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 { + 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 { + // 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 { + // --- 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 { + 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 { + 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; +} diff --git a/src/lib/server/cras-releases.ts b/src/lib/server/cras-releases.ts new file mode 100644 index 0000000..badf4c0 --- /dev/null +++ b/src/lib/server/cras-releases.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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, + errorMessage: string | null = null +): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/server/cras-sync.test.ts b/src/lib/server/cras-sync.test.ts new file mode 100644 index 0000000..7867e38 --- /dev/null +++ b/src/lib/server/cras-sync.test.ts @@ -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' + ); + }); +}); diff --git a/src/lib/server/cras-sync.ts b/src/lib/server/cras-sync.ts new file mode 100644 index 0000000..1e31e59 --- /dev/null +++ b/src/lib/server/cras-sync.ts @@ -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 { + 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---.' + }); + 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; +} diff --git a/src/lib/server/cras-verify.test.ts b/src/lib/server/cras-verify.test.ts new file mode 100644 index 0000000..2bbf883 --- /dev/null +++ b/src/lib/server/cras-verify.test.ts @@ -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 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'); + } + }); +}); diff --git a/src/lib/server/cras-verify.ts b/src/lib/server/cras-verify.ts new file mode 100644 index 0000000..07a7384 --- /dev/null +++ b/src/lib/server/cras-verify.ts @@ -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 { + const run = async (command: string): Promise => { + 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=, 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 { + 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 { + 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 { + 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 + }; +} diff --git a/src/lib/server/gitea-packages.test.ts b/src/lib/server/gitea-packages.test.ts new file mode 100644 index 0000000..1cda4c0 --- /dev/null +++ b/src/lib/server/gitea-packages.test.ts @@ -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) { + 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).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(); + }); +}); diff --git a/src/lib/server/gitea-packages.ts b/src/lib/server/gitea-packages.ts new file mode 100644 index 0000000..0c82fe5 --- /dev/null +++ b/src/lib/server/gitea-packages.ts @@ -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 { + 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(url: string): Promise { + 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 { + const pkg = packageName(); + const url = + `${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}` + + `?type=generic&q=${encodeURIComponent(pkg)}&limit=100`; + + const raw = await getJson(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 { + const url = + `${baseUrl()}/api/v1/packages/${encodeURIComponent(owner())}/generic/` + + `${encodeURIComponent(packageName())}/${encodeURIComponent(version)}/files`; + + const raw = await getJson(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; + 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 { + 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 + }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 54892af..04b89ff 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -25,7 +25,8 @@ import { deletePortalUser, listRestoreTargets, listRestoredRestoreJobLogs, - listFailedRestoreJobLogs + listFailedRestoreJobLogs, + deleteRestoreJobLogs } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -63,6 +64,21 @@ function parseRestoreTargetId(formData: FormData): number | null { return Number.isInteger(n) && n > 0 ? n : null; } +/** + * IDs de la bitácora que llegan del formulario como lista separada por comas. Se sanean a + * enteros positivos aquí y otra vez en la capa de datos: el valor viene del navegador. + */ +function parseIdList(raw: FormDataEntryValue | null): number[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + const ids = raw + .split(',') + .map((part) => Number(part.trim())) + .filter((n) => Number.isInteger(n) && n > 0); + // Tope defensivo: las vistas cargan a lo más 200 filas, así que una lista mucho mayor + // significa que alguien está armando la petición a mano. + return Array.from(new Set(ids)).slice(0, 500); +} + /** Verifica que el solicitante sea administrador (autorización en backend, no solo UI). */ async function isAdmin(cookies: import('@sveltejs/kit').Cookies): Promise { const token = cookies.get('session_token'); @@ -117,6 +133,11 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi const SQL_LOAD_TIMEOUT_MS = Number(env.PANEL_SQL_LOAD_TIMEOUT_MS) || 12000; export const load: PageServerLoad = async ({ cookies }) => { + // OJO: este load NO debe referenciar `url`. SvelteKit rastrea qué propiedades del evento + // usa, y en cuanto usa `url` cada cambio de `?view=` vuelve a ejecutar TODO este load + // —consultas a SQL Server por nodo, bundles, catálogo y alertas—, con lo que conmutar de + // pestaña deja de ser instantáneo. Ver AppShell.svelte:146. + // 1. Auth Check - Verificar token JWT const token = cookies.get('session_token'); if (!token) { @@ -578,6 +599,61 @@ export const actions: Actions = { } }, + /** + * BORRA registros de restores fallidos. Es definitivo: no existe action inversa. + * + * Recibe los IDs que el operador tenía en pantalla (posiblemente filtrados por el + * buscador), no un filtro, para que lo borrado sea exactamente lo que vio. + * + * Devuelve el conteo REAL de filas borradas y no la cantidad de IDs recibidos: si otro + * operador ya los borró, o si la pestaña llevaba rato abierta, el mensaje debe decir la + * verdad en lugar de afirmar que se borró algo que ya no estaba. + */ + deleteRestores: async ({ request, cookies }) => { + // Guard explícito: este archivo tiene el helper y el comentario de "autorización en + // backend, no solo UI", pero varias de sus actions no lo llaman. + if (!(await isAdmin(cookies))) { + return { success: false, message: 'Requiere permisos de administrador.' }; + } + try { + const formData = await request.formData(); + const ids = parseIdList(formData.get('ids')); + if (ids.length === 0) { + return { success: false, message: 'No se recibió ningún registro que borrar.' }; + } + + const session = verifyToken(cookies.get('session_token') ?? ''); + const user = session ? await getUserById(session.userId) : null; + const result = await deleteRestoreJobLogs(ids); + + // El panel no tiene tabla de auditoría y el borrado es irreversible, así que el log + // del servidor es el único rastro de quién lo hizo. No es un print de depuración. + if (result.deleted > 0) { + console.warn( + `[bitácora] ${user?.username ?? 'desconocido'} borró ${result.deleted} registro(s) de restores fallidos (IDs: ${ids.join(',')})` + ); + } + + // Los tres casos se distinguen: nada existía, algo no era fallido, o se borró. + let message: string; + if (result.deleted === 0 && result.skippedNotFailed > 0) { + message = 'Solo se pueden borrar restores fallidos; esos registros no lo son.'; + } else if (result.deleted === 0) { + message = 'Esos registros ya no existían. Recarga la vista.'; + } else { + message = `${result.deleted} registro(s) borrado(s) definitivamente.`; + if (result.deleted < result.requested) { + message += ` ${result.requested - result.deleted} ya no existía(n).`; + } + } + + return { success: true, deleted: result.deleted, message }; + } catch (e: any) { + console.error('Error borrando registros de restauración:', e); + return { success: false, message: e.message }; + } + }, + deleteDatabase: async ({ request }) => { try { const formData = await request.formData(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c8687f4..1d4dd96 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,7 +4,9 @@ import { invalidateAll, goto, afterNavigate } from '$app/navigation'; import { page } from '$app/stores'; import { PANEL_VIEWS, isPanelView, type PanelViewKey } from '$lib/nav'; + import { filterRestored, filterFailed } from '$lib/restore-filter'; import AppShell from '$lib/components/AppShell.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; type KitActionPayload = { success?: boolean; message?: string }; @@ -131,6 +133,8 @@ let scrollRootAlerts = $state(null); let scrollRootDbMgmt = $state(null); let scrollRootUsuarios = $state(null); + let scrollRootRestored = $state(null); + let scrollRootFailed = $state(null); async function fillScrollPortUntilOverflow( el: HTMLElement | null, @@ -168,6 +172,8 @@ let visibleDatabases = $state(PAGE_SIZE); let visibleMain = $state(PAGE_SIZE); let visibleAlerts = $state(PAGE_SIZE); + let visibleRestored = $state(PAGE_SIZE); + let visibleFailed = $state(PAGE_SIZE); // Buscadores let mainSearch = $state(''); @@ -231,6 +237,59 @@ // ---- Gestión de usuarios portal (PostgreSQL a24c.portal_users) ---- let usuariosList = $state(untrack(() => (data.usuariosList ?? []) as any[])); let usuariosSearch = $state(''); + let restoredSearch = $state(''); + let failedSearch = $state(''); + + // ---- Borrar registros de restores fallidos --------------------------------------- + // BORRA de verdad: la fila deja de existir en restore_job_logs, no hay deshacer, y el + // contador de fallas de los últimos 30 días de /servidores-restauracion baja porque cuenta + // esa misma tabla. El modal de confirmación lo dice explícitamente. + // Solo aplica a fallidos, y el servidor también lo restringe (ver deleteRestoreJobLogs): + // "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró. + let confirmDelete = $state<{ ids: number[]; label: string } | null>(null); + let deleting = $state(false); + let deleteNotice = $state(null); + let deleteError = $state(null); + + /** + * Borra por POST a la action. Se confía en el conteo que devuelve el servidor y no en la + * longitud de la lista: si otro operador ya los borró, o la pestaña llevaba rato abierta, + * el mensaje debe decir la verdad. + */ + async function submitDelete(ids: number[]) { + if (ids.length === 0) return; + deleting = true; + try { + const body = new FormData(); + body.set('ids', ids.join(',')); + const res = await fetch('?/deleteRestores', { method: 'POST', body }); + const result = parseKitAction(await res.text()); + const errMsg = kitActionErrorMessage(res, result); + if (errMsg) { + deleteError = errMsg; + deleteNotice = null; + return; + } + const payload = + result?.type === 'success' ? (result.data as Record | undefined) : undefined; + if (payload?.success === false) { + deleteError = String(payload.message ?? 'No se pudo completar la acción.'); + deleteNotice = null; + return; + } + + deleteError = null; + deleteNotice = String(payload?.message ?? 'Listo.'); + await invalidateAll(); + } catch (e) { + deleteError = e instanceof Error ? e.message : String(e); + deleteNotice = null; + } finally { + deleting = false; + confirmDelete = null; + } + } + let visibleUsuarios = $state(PAGE_SIZE); let showUsuarioModal = $state(false); @@ -800,6 +859,11 @@ }); }; + // Buscadores de las dos vistas de restauraciones. El filtrado vive en $lib/restore-filter + // para poder probarse; los otros seis filtros del dashboard están inline y sin prueba. + const restoredRowsLive = $derived.by(() => filterRestored(restoredBackups, restoredSearch)); + const failedRowsLive = $derived.by(() => filterFailed(failedRestores, failedSearch)); + const mainRowsLive = $derived.by(() => filterMainRows()); const backupRowsLive = $derived.by(() => filterBackups()); const clientsTableRowsLive = $derived.by(() => filterClients()); @@ -821,6 +885,38 @@ }) ); }); + $effect(() => { + if (activeView !== 'restored') return; + void restoredRowsLive.length; + void restoredSearch; + void visibleRestored; + void tick().then(() => + fillScrollPortUntilOverflow( + scrollRootRestored, + restoredRowsLive.length, + () => visibleRestored, + (n) => { + visibleRestored = n; + } + ) + ); + }); + $effect(() => { + if (activeView !== 'failed') return; + void failedRowsLive.length; + void failedSearch; + void visibleFailed; + void tick().then(() => + fillScrollPortUntilOverflow( + scrollRootFailed, + failedRowsLive.length, + () => visibleFailed, + (n) => { + visibleFailed = n; + } + ) + ); + }); $effect(() => { if (activeView !== 'backups') return; void backupRowsLive.length; @@ -1022,6 +1118,8 @@ else if (activeView === 'backups') visibleBackups = PAGE_SIZE; else if (activeView === 'clients') visibleClients = PAGE_SIZE; else if (activeView === 'alerts') visibleAlerts = PAGE_SIZE; + else if (activeView === 'restored') visibleRestored = PAGE_SIZE; + else if (activeView === 'failed') visibleFailed = PAGE_SIZE; else if (activeView === 'databases') { visibleDatabases = PAGE_SIZE; visibleUsuarios = PAGE_SIZE; @@ -1032,6 +1130,14 @@ void mainSearch; visibleMain = PAGE_SIZE; }); + $effect(() => { + void restoredSearch; + visibleRestored = PAGE_SIZE; + }); + $effect(() => { + void failedSearch; + visibleFailed = PAGE_SIZE; + }); $effect(() => { void backupSearch; visibleBackups = PAGE_SIZE; @@ -1580,9 +1686,48 @@ {data.errors.restores} {/if} -
+
+ + Mostrando {Math.min(restoredRowsLive.length, visibleRestored)} + de {restoredRowsLive.length} + {#if restoredRowsLive.length !== restoredBackups.length} + (filtradas de {restoredBackups.length}) + {/if} + +
+
+ + search + + + {#if restoredSearch} + + {/if} +
+
+
+
+ tryExpandVisibleOnScroll(e.currentTarget, restoredRowsLive.length, visibleRestored, (n) => { + visibleRestored = n; + })} + class="bg-white shadow-sm ring-1 ring-slate-200 {TABLE_SCROLL_WRAPPER_CLASS}" + > - + @@ -1593,7 +1738,7 @@ - {#each restoredBackups as r (r.id)} + {#each sliceVisible(restoredRowsLive, visibleRestored) as r (r.id)} {/each} @@ -1641,9 +1790,77 @@ {data.errors.restores} {/if} -
+ {#if deleteError} +
+ {deleteError} +
+ {/if} + {#if deleteNotice} + +
+ {deleteNotice} +
+ {/if} +
+ + Mostrando {Math.min(failedRowsLive.length, visibleFailed)} + de {failedRowsLive.length} + {#if failedRowsLive.length !== failedRestores.length} + (filtradas de {failedRestores.length}) + {/if} + +
+ {#if data.currentUser?.es_admin && failedRowsLive.length > 0} + + + {/if} +
+ + search + + + {#if failedSearch} + + {/if} +
+
+
+
+ tryExpandVisibleOnScroll(e.currentTarget, failedRowsLive.length, visibleFailed, (n) => { + visibleFailed = n; + })} + class="bg-white shadow-sm ring-1 ring-slate-200 {TABLE_SCROLL_WRAPPER_CLASS}" + >
Servidor Nodo / Cliente
{r.server_name ?? '—'} @@ -1617,7 +1762,11 @@ {:else}
- No hay respaldos restaurados registrados. + {#if restoredSearch} + Ningún resultado para "{restoredSearch}". + {:else} + No hay respaldos restaurados registrados. + {/if}
- + @@ -1653,7 +1870,7 @@ - {#each failedRestores as f (f.id)} + {#each sliceVisible(failedRowsLive, visibleFailed) as f (f.id)} {:else} {/each} @@ -2306,6 +2541,60 @@ {/if} +{#if confirmDelete} + {@const target = confirmDelete} + + +{/if} diff --git a/src/routes/api/restore/agent-sync/+server.ts b/src/routes/api/restore/agent-sync/+server.ts new file mode 100644 index 0000000..ff4bc3e --- /dev/null +++ b/src/routes/api/restore/agent-sync/+server.ts @@ -0,0 +1,67 @@ +/** + * POST /api/restore/agent-sync + * + * Dispara la sincronización del catálogo de versiones de CloudRestoreAS contra Gitea. + * Autenticado por token Bearer (CLOUDRESTORE_API_TOKEN), el mismo que usan los agentes: + * lo llama `packaging/scripts/publish-release.sh --notify-panel` justo después de publicar, + * para que la versión nueva aparezca sin esperar a que un admin abra /versiones-cras. + * + * NO activa nada: sincronizar solo registra lo que existe en Gitea. Activar una versión es + * una decisión explícita del operador desde la UI — si un sync activara sola la última, + * publicar se volvería desplegar. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkServiceToken } from '$lib/server/service-auth'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; +import { GiteaError, isGiteaConfigured } from '$lib/server/gitea-packages'; +import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; + +export const POST: RequestHandler = async ({ request }) => { + const traceId = newTraceId(); + + const auth = checkServiceToken(request); + if (!auth.ok) { + if (auth.status === 500) { + logger.error({ trace_id: traceId, message: 'CLOUDRESTORE_API_TOKEN no configurado' }); + return errorJson(500, 'Servicio no configurado', traceId); + } + return errorJson(401, 'Token de servicio ausente o inválido', traceId); + } + + if (!isGiteaConfigured()) { + logger.error({ + trace_id: traceId, + message: 'GITEA_TOKEN no configurado; no se puede sincronizar el catálogo de CRAS' + }); + return errorJson(503, 'El panel no tiene configurado el acceso a Gitea', traceId); + } + + try { + const result = await syncCrasReleasesFromGitea(); + return json({ + ok: true, + versions: result.versions_seen, + registered: result.artifacts_registered, + discovered: result.artifacts_new, + skipped: result.skipped, + warnings: result.warnings, + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error sincronizando el catálogo de versiones de CRAS', + context: { error: message } + }); + if (err instanceof GiteaError) { + // Se propaga la causa real (token rechazado, Gitea caído) en lugar de un 500 + // genérico: el script de publicación muestra este código al operador. + const status = err.status === 404 ? 404 : err.status >= 500 ? 502 : 503; + return errorJson(status, `Gitea: ${err.message}`, traceId); + } + return errorJson(500, 'Error interno al sincronizar con Gitea', traceId); + } +}; diff --git a/src/routes/api/restore/instance-config/+server.ts b/src/routes/api/restore/instance-config/+server.ts index 5baf5c9..0483f5d 100644 --- a/src/routes/api/restore/instance-config/+server.ts +++ b/src/routes/api/restore/instance-config/+server.ts @@ -1,16 +1,27 @@ /** * POST /api/restore/instance-config * - * CloudRestoreAS reporta la carpeta de entrada vigente. Autenticado por token - * Bearer (CLOUDRESTORE_API_TOKEN). El panel solo consulta este dato en la UI admin; - * no hay escritura desde el navegador. + * CloudRestoreAS reporta su carpeta de entrada y la identidad del build instalado. + * Autenticado por token Bearer (CLOUDRESTORE_API_TOKEN). El panel solo consulta estos datos + * en la UI admin; no hay escritura desde el navegador. * * Body esperado: * { * "input_folder": "\\\\servidor\\compartida\\backups", + * "processed_folder": "\\\\servidor\\compartida\\Procesados", // opcional * "host_name": "WIN-RESTORE-01", - * "app_version": "1.0.0" + * "app_version": "1.1.0", + * "platform": "windows", // opcional; "windows" | "linux" + * "arch": "x86_64", // opcional + * "install_path": "/opt/cloudrestoreas", // opcional; carpeta del ejecutable + * "instance_key": "Alfa" // = restore_targets.name * } + * + * `platform` y `arch` identifican el build instalado y son la fuente autoritativa para + * decidir qué artefacto de CRAS le corresponde a este servidor al instalar o actualizar + * (a24c.cras_releases se llavea por version + platform + arch). Un agente anterior a 1.1.0 + * no las manda: se aceptan ausentes y el panel cae al texto libre de restore_targets.os + * para la primera instalación. */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -18,11 +29,16 @@ import { checkServiceToken } from '$lib/server/service-auth'; import { upsertCloudRestoreStatus } from '$lib/server/controldesk-pg'; import { errorJson, newTraceId } from '$lib/server/api-error'; import { logger } from '$lib/server/logger'; +import { CRAS_PLATFORMS, isSafeInstallPath, type CrasPlatform } from '$lib/cras-version'; interface InstanceConfigPayload { input_folder?: unknown; + processed_folder?: unknown; host_name?: unknown; app_version?: unknown; + platform?: unknown; + arch?: unknown; + install_path?: unknown; instance_key?: unknown; } @@ -32,6 +48,23 @@ function asOptionalString(value: unknown): string | null { return s ? s : null; } +/** + * Normaliza la plataforma reportada. Un valor desconocido se descarta (queda null) en lugar + * de guardarse: cras_releases.platform tiene un CHECK, así que una plataforma inventada no + * podría emparejar con ninguna versión y el servidor quedaría sin actualizaciones sin que + * nadie sepa por qué. + */ +function asPlatform(value: unknown): string | null { + const s = (asOptionalString(value) ?? '').toLowerCase(); + return (CRAS_PLATFORMS as readonly string[]).includes(s) ? s : null; +} + +/** El arch se limita a un token simple: forma parte de los nombres de artefacto. */ +function asArch(value: unknown): string | null { + const s = (asOptionalString(value) ?? '').toLowerCase(); + return /^[a-z0-9_]{1,20}$/.test(s) ? s : null; +} + export const POST: RequestHandler = async ({ request }) => { const traceId = newTraceId(); @@ -57,12 +90,43 @@ export const POST: RequestHandler = async ({ request }) => { } const instanceKey = asOptionalString(body.instance_key) ?? 'default'; + const platform = asPlatform(body.platform); + const arch = asArch(body.arch); + // La ruta se valida contra la plataforma: llega por red y el instalador la interpola en un + // comando de shell y en un script de PowerShell. Si no pasa, se guarda null y el panel cae + // al default de la plataforma en lugar de confiar en un valor sospechoso. + const rawInstallPath = asOptionalString(body.install_path); + const installPath = + rawInstallPath && platform && isSafeInstallPath(rawInstallPath, platform as CrasPlatform) + ? rawInstallPath + : null; + if (rawInstallPath && !installPath) { + logger.warn({ + trace_id: traceId, + message: 'install_path reportado por CloudRestoreAS rechazado por la validación', + context: { instance_key: instanceKey, install_path: rawInstallPath, platform } + }); + } + + // Se avisa cuando el agente mandó algo que no se pudo interpretar: descartarlo en + // silencio dejaría al servidor sin plataforma y sin pista de por qué. + if (body.platform !== undefined && body.platform !== null && platform === null) { + logger.warn({ + trace_id: traceId, + message: 'Plataforma reportada por CloudRestoreAS no reconocida; se ignora', + context: { instance_key: instanceKey, platform: String(body.platform) } + }); + } try { await upsertCloudRestoreStatus({ inputFolder, + processedFolder: asOptionalString(body.processed_folder), hostName: asOptionalString(body.host_name), appVersion: asOptionalString(body.app_version), + platform, + arch, + installPath, instanceKey }); return json({ ok: true, trace_id: traceId }); diff --git a/src/routes/servidores-restauracion/+page.svelte b/src/routes/servidores-restauracion/+page.svelte index ca82276..481f93d 100644 --- a/src/routes/servidores-restauracion/+page.svelte +++ b/src/routes/servidores-restauracion/+page.svelte @@ -3,6 +3,7 @@ import { enhance, deserialize } from '$app/forms'; import { invalidateAll } from '$app/navigation'; import AppShell from '$lib/components/AppShell.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; import type { CloudRestoreStatus, RestoreTarget, AssignmentNode } from '$lib/server/controldesk-pg'; import { categorize, @@ -888,7 +889,7 @@ class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60 disabled:cursor-not-allowed" > {#if globalLoading} - progress_activity + {/if} {globalLoading ? 'Calculando…' : 'Recalcular distribución global'} @@ -1029,7 +1030,7 @@ class="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed" > {#if globalApplying} - progress_activity + {/if} {globalApplying ? 'Aplicando…' : 'Confirmar y aplicar'} @@ -1314,7 +1315,7 @@ class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60 disabled:cursor-not-allowed" > {#if sizesLoading} - progress_activity + {/if} {sizesLoading ? 'Cargando tamaños…' : 'Calcular óptimo para este servidor'} @@ -1493,7 +1494,7 @@ @@ -1574,7 +1575,7 @@
{#if logsLoading}

- progress_activity + Cargando bitácora…

{:else if logsError} diff --git a/src/routes/versiones-cras/+page.server.ts b/src/routes/versiones-cras/+page.server.ts new file mode 100644 index 0000000..731ead2 --- /dev/null +++ b/src/routes/versiones-cras/+page.server.ts @@ -0,0 +1,437 @@ +/** + * Versiones de CloudRestoreAS: catálogo publicado en Gitea e instalación remota por servidor. + * Solo administradores. + * + * Los binarios se publican en el registro de paquetes genéricos de Gitea desde el build local + * (`packaging/scripts/publish-release.sh`). Aquí se sincroniza el catálogo, se elige qué + * versión está activa por plataforma, y se instala o actualiza cada servidor por SSH. + * + * No hay subida de archivos por el navegador a propósito: los artefactos pesan ~270 MB y su + * origen de verdad es Gitea, no una carga manual. + */ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { env } from '$env/dynamic/private'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { + activateCrasRelease, + deactivateCrasRelease, + deleteCrasRelease, + getCrasReleaseById, + listCrasReleases, + listCrasTargetInventory, + listInstallRuns, + reapStaleInstallRuns +} from '$lib/server/cras-releases'; +import { + cacheUsage, + ensureCached, + isCached, + pruneCache, + prunePlan, + removeCached, + ArtifactError, + type PrunePlan +} from '$lib/server/cras-artifacts'; +import { installPlatformVerdict, platformLabel } from '$lib/cras-version'; +import { syncCrasReleasesFromGitea } from '$lib/server/cras-sync'; +import { GiteaError, isGiteaConfigured, packageLocation } from '$lib/server/gitea-packages'; +import { installCrasOnTarget, InstallError } from '$lib/server/cras-install'; +import { logger } from '$lib/server/logger'; + +async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) throw redirect(303, '/'); + return currentUser; +} + +/** + * URL base del panel que se siembra en el .env del agente. El agente vive en otro servidor, + * así que tiene que ser una URL alcanzable desde la red, no localhost. + */ +function panelApiUrl(): string { + return (env.PANEL_PUBLIC_URL || env.ORIGIN || '').trim(); +} + +function panelApiToken(): string { + return (env.CLOUDRESTORE_API_TOKEN || '').trim(); +} + +export const load: PageServerLoad = async ({ cookies }) => { + const currentUser = await requireAdmin(cookies); + let dbWarning: string | null = null; + + // Un reinicio del panel deja runs colgados en 'running' que bloquearían nuevas + // instalaciones en ese destino; se cierran al entrar a la pantalla. + try { + const reaped = await reapStaleInstallRuns(); + if (reaped > 0) { + logger.warn({ + message: 'Instalaciones de CRAS marcadas como interrumpidas al cargar la pantalla', + context: { count: reaped } + }); + } + } catch (e) { + console.error('Error cerrando instalaciones interrumpidas:', e); + } + + let releases: Awaited> = []; + try { + releases = await listCrasReleases(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error listando cras_releases:', e); + dbWarning = `No se pudo cargar el catálogo de versiones: ${msg}`; + } + + let inventory: Awaited> = []; + try { + inventory = await listCrasTargetInventory(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('Error armando el inventario de servidores:', e); + dbWarning = dbWarning ?? `No se pudo cargar el inventario de servidores: ${msg}`; + } + + let runs: Awaited> = []; + try { + runs = await listInstallRuns(20); + } catch (e) { + console.error('Error leyendo la bitácora de instalaciones:', e); + } + + // Qué versiones están en caché local: sin artefacto en disco la instalación primero + // tiene que bajar ~270 MB de Gitea, y conviene que se vea antes de empezar. + const cached: Record = {}; + for (const release of releases) { + try { + cached[release.id] = await isCached(release.version, release.file_name); + } catch { + cached[release.id] = false; + } + } + + let usage: Awaited> = { total_bytes: 0, versions: [] }; + try { + usage = await cacheUsage(); + } catch (e) { + console.error('Error midiendo la caché de artefactos:', e); + } + + // Qué se borraría al podar, con el mismo criterio con que se ejecuta: el control de + // "Liberar" tiene que decir cuánto libera antes de que el operador confirme, y quedar + // apagado cuando no hay nada que liberar (con ≤3 versiones en caché no borra nada). + let prune: PrunePlan = { versions: [], bytes: 0 }; + try { + prune = await prunePlan(releases.filter((r) => r.is_active).map((r) => r.version)); + } catch (e) { + console.error('Error calculando el plan de poda de la caché:', e); + } + + // Avisos de configuración: sin estos datos la pantalla se ve bien pero no puede operar. + const configWarnings: string[] = []; + if (!isGiteaConfigured()) { + configWarnings.push( + 'GITEA_TOKEN no está configurado en el panel: no se puede sincronizar ni descargar versiones.' + ); + } + if (!panelApiToken()) { + configWarnings.push( + 'CLOUDRESTORE_API_TOKEN no está configurado: no se puede sembrar la configuración del agente al instalar.' + ); + } + if (!panelApiUrl()) { + configWarnings.push( + 'PANEL_PUBLIC_URL (u ORIGIN) no está configurado: el agente no sabría a qué URL reportar.' + ); + } + + return { + currentUser, + releases, + inventory, + runs, + cached, + usage, + prune, + dbWarning, + configWarnings, + giteaConfigured: isGiteaConfigured(), + packageLocation: packageLocation() + }; +}; + +function parseId(data: FormData, field: string): number | null { + const raw = data.get(field)?.toString().trim(); + const id = Number(raw); + return Number.isInteger(id) && id > 0 ? id : null; +} + +export const actions: Actions = { + /** Lee la API de paquetes de Gitea y registra las versiones que encuentre. */ + sync: async ({ cookies }) => { + await requireAdmin(cookies); + if (!isGiteaConfigured()) { + return fail(503, { error: 'El panel no tiene configurado GITEA_TOKEN.' }); + } + try { + const result = await syncCrasReleasesFromGitea(); + const parts = [ + `${result.versions_seen} versión(es) en Gitea`, + `${result.artifacts_registered} artefacto(s) registrados`, + `${result.artifacts_new} nuevo(s)` + ]; + return { + success: `Sincronización completa: ${parts.join(', ')}.`, + // Lo omitido se reporta en lugar de descartarse: un artefacto invisible se + // traduce en un servidor al que nunca se le ofrece la actualización. + skipped: result.skipped, + warnings: result.warnings + }; + } catch (e) { + const msg = e instanceof GiteaError ? e.message : e instanceof Error ? e.message : String(e); + return fail(502, { error: `No se pudo sincronizar con Gitea: ${msg}` }); + } + }, + + /** Marca una versión como la activa de su plataforma+arquitectura. */ + activate: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + try { + const release = await activateCrasRelease(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + return { + success: `${release.version} (${release.platform}/${release.arch}) quedó como versión activa.` + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo activar la versión: ${msg}` }); + } + }, + + deactivate: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + try { + await deactivateCrasRelease(id); + return { success: 'Versión desactivada.' }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo desactivar: ${msg}` }); + } + }, + + /** Descarga el artefacto a la caché local y verifica su sha256. */ + precache: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + + try { + const result = await ensureCached( + release.version, + release.file_name, + release.sha256, + release.file_size + ); + return { + success: result.downloaded + ? `${release.file_name} descargado y verificado (${formatBytes(result.size)}).` + : `${release.file_name} ya estaba en caché.` + }; + } catch (e) { + const status = e instanceof ArtifactError ? e.status : 500; + const msg = e instanceof Error ? e.message : String(e); + return fail(status === 409 ? 409 : 502, { error: msg }); + } + }, + + uncache: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + try { + await removeCached(release.version, release.file_name); + return { success: `${release.file_name} eliminado de la caché local.` }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo liberar la caché: ${msg}` }); + } + }, + + /** Poda la caché conservando las versiones activas y las más recientes. */ + prune: async ({ cookies }) => { + await requireAdmin(cookies); + try { + const releases = await listCrasReleases(); + const pinned = releases.filter((r) => r.is_active).map((r) => r.version); + const removed = await pruneCache(pinned); + return { + success: removed.length + ? `Se liberaron ${removed.length} versión(es) de la caché: ${removed.join(', ')}.` + : 'No había versiones para liberar.' + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo podar la caché: ${msg}` }); + } + }, + + /** + * Borra una versión del catálogo. No toca Gitea: los paquetes genéricos son inmutables + * y siguen siendo el respaldo, así que un sync posterior la vuelve a registrar. + */ + deleteRelease: async ({ cookies, request }) => { + await requireAdmin(cookies); + const data = await request.formData(); + const id = parseId(data, 'id'); + if (!id) return fail(400, { error: 'Versión inválida.' }); + + const release = await getCrasReleaseById(id); + if (!release) return fail(404, { error: 'La versión ya no existe.' }); + if (release.is_active) { + return fail(409, { + error: 'No se puede borrar la versión activa. Activa otra antes de quitarla.' + }); + } + try { + await deleteCrasRelease(id); + return { + success: `${release.version} (${release.platform}) quitada del catálogo. Sigue publicada en Gitea.` + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo quitar del catálogo: ${msg}` }); + } + }, + + /** + * Instala o actualiza CRAS en un servidor. Bloqueante: puede tardar varios minutos + * subiendo ~270 MB. La UI hace polling de cras_install_runs.steps para el progreso. + */ + install: async ({ cookies, request }) => { + const currentUser = await requireAdmin(cookies); + const data = await request.formData(); + const targetId = parseId(data, 'target_id'); + const releaseId = parseId(data, 'release_id'); + const modeRaw = data.get('mode')?.toString().trim(); + const autostartRaw = data.get('autostart')?.toString().trim(); + + if (!targetId || !releaseId) { + return fail(400, { error: 'Servidor o versión inválidos.' }); + } + const mode = modeRaw === 'update' ? 'update' : 'install'; + const autostart = + autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service'; + const platformAck = data.get('platform_ack')?.toString() ?? ''; + + // Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda + // que había —assertSystemMatches, dentro del instalador— ya corre con la sesión SSH + // abierta: el operador se llevaba un run fallido en la bitácora en lugar de un aviso. + const release = await getCrasReleaseById(releaseId); + if (!release) { + return fail(404, { error: 'La versión indicada no existe en el catálogo.' }); + } + + let target: Awaited>[number] | undefined; + try { + target = (await listCrasTargetInventory()).find( + (t) => t.restore_target_id === targetId + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return fail(500, { error: `No se pudo validar el servidor destino: ${msg}` }); + } + if (!target) { + return fail(404, { error: 'El servidor de restauración ya no existe.' }); + } + if (target.running_install_id) { + return fail(409, { + error: + `Ya hay una instalación en curso en ${target.name} ` + + `(run #${target.running_install_id}). Espera a que termine antes de lanzar otra.` + }); + } + + const verdict = installPlatformVerdict(target.platform, release.platform, platformAck); + if (verdict === 'mismatch') { + return fail(409, { + error: + `${target.name} es ${platformLabel(target.platform)} y ${release.version} es ` + + `${platformLabel(release.platform)}. Elige el artefacto de la plataforma correcta.` + }); + } + if (verdict === 'needs-ack') { + return fail(422, { + error: + `No se pudo determinar la plataforma de ${target.name}. Confirma en el ` + + 'formulario que el artefacto corresponde al sistema del servidor, o captura ' + + 'el campo SO en Servidores de Restauración.' + }); + } + + const apiUrl = panelApiUrl(); + const apiToken = panelApiToken(); + if (!apiUrl || !apiToken) { + return fail(500, { + error: + 'Falta configurar PANEL_PUBLIC_URL y CLOUDRESTORE_API_TOKEN en el panel: ' + + 'sin ellos el agente instalado no sabría a dónde reportar.' + }); + } + + try { + const outcome = await installCrasOnTarget({ + restoreTargetId: targetId, + releaseId, + mode, + startedBy: currentUser.username, + panelApiUrl: apiUrl, + panelApiToken: apiToken, + autostart + }); + if (!outcome.ok) { + return fail(502, { + error: `La instalación de ${outcome.version} falló: ${outcome.error}`, + runId: outcome.runId + }); + } + return { + success: `${outcome.version} instalada. El servidor reportará su versión en el próximo ciclo.`, + runId: outcome.runId + }; + } catch (e) { + const status = e instanceof InstallError ? e.status : 500; + const msg = e instanceof Error ? e.message : String(e); + return fail(status === 404 || status === 409 ? status : 500, { error: msg }); + } + } +}; + +function formatBytes(bytes: number): string { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} diff --git a/src/routes/versiones-cras/+page.svelte b/src/routes/versiones-cras/+page.svelte new file mode 100644 index 0000000..e11e815 --- /dev/null +++ b/src/routes/versiones-cras/+page.svelte @@ -0,0 +1,1419 @@ + + + { + if (e.key === 'Escape' && anyModalOpen) closeTopModal(); + }} +/> + + + +
+ + {#if data.dbWarning} +
+ error_outline + {data.dbWarning} +
+ {/if} + + + {#if (data.configWarnings ?? []).length} +
+ + warning_amber + {data.configWarnings.length} aviso(s) de configuración del panel + +
    + {#each data.configWarnings as warning}
  • {warning}
  • {/each} +
+
+ {/if} + + +
+ {#if showFormBanner && form?.error} +
+ error_outline +

{form.error}

+ +
+ {/if} + {#if showFormBanner && form?.success} +
+ check_circle +

{form.success}

+ +
+ {/if} + + + {#if showFormBanner && form?.skipped?.length} +
+

Archivos omitidos en la sincronización:

+
    + {#each form.skipped as item} +
  • {item.file_name} — {item.reason}
  • + {/each} +
+
+ {/if} + {#if showFormBanner && form?.warnings?.length} +
+
    + {#each form.warnings as warning}
  • {warning}
  • {/each} +
+
+ {/if} +
+ + +
+
+
+

Catálogo de versiones

+

+ Los binarios se publican en Gitea desde el build local + (build-all.sh --publish). El panel los descubre + aquí, los descarga verificando su sha256, y los instala por SSH. +

+

+ inventory_2 + {data.packageLocation} +

+
+
+ + +
+ +
+ {releases.length} artefacto(s) en catálogo + {activeReleases.length} activo(s) + 0} class:font-semibold={pendingUpdates > 0}> + {pendingUpdates} servidor(es) con actualización pendiente + + + Caché local: {formatBytes(data.usage?.total_bytes)} + en {data.usage?.versions?.length ?? 0} versión(es) + + + {#if data.prune?.versions?.length} + + {:else if (data.usage?.versions?.length ?? 0) > 0} + + Nada que liberar + + {/if} +
+
+ + +
+ +

+ La versión activa es por plataforma: pueden estar activas al mismo + tiempo la de Linux y la de Windows. Es la que se propone al instalar en cada servidor. +

+
+
Servidor Base / Archivo
{f.server_name ?? '—'} @@ -1671,12 +1888,30 @@ {:else} {/if} + {#if data.currentUser?.es_admin} + + {/if}
- No hay restores fallidos registrados. + {#if failedSearch} + Ningún resultado para "{failedSearch}". + {:else} + No hay restores fallidos registrados. + {/if}
+ + + + + + + + + + + + + {#each releases as release (release.id)} + + + + + + + + + + {:else} + + + + {/each} + +
VersiónPlataformaTamañosha256PublicadaEstadoAcciones
+ {release.version} +
{release.file_name}
+
+ {platformLabel(release.platform)} + / {release.arch} + {formatBytes(release.file_size)} + {#if release.sha256} + + {:else} + + sin hash + + {/if} + {formatDate(release.published_at)} +
+ {#if release.is_active} + + Activa en {platformLabel(release.platform)} + + {/if} + {#if cached[release.id]} + + En caché + + {:else} + + No descargada + + {/if} +
+
+
+ {#if !release.is_active} +
+ + +
+ {:else} +
+ + +
+ {/if} + + {#if cached[release.id]} +
+ + +
+ {:else} +
+ + +
+ {/if} + + +
+
+ {#if data.giteaConfigured} + No hay versiones registradas. Usa Sincronizar con Gitea + para descubrir lo que ya esté publicado. + {:else} + Configura GITEA_TOKEN en el panel para + poder leer el catálogo de Gitea. + {/if} +
+
+
+ + +
+

Servidores de restauración

+
+ + + + + + + + + + + + + + {#each inventory as target (target.restore_target_id)} + + + + + + + + + {:else} + + + + {/each} + +
ServidorPlataformaInstalada + Le toca + Último reporteAcciones
+ {target.name} + {#if target.ssh_host} +
+ {target.ssh_username}@{target.ssh_host} +
+ {/if} +
+ {platformLabel(target.platform)} + {#if target.platform} + / {target.arch} + {/if} + {#if !target.reported_platform && target.platform} +
+ (según SO capturado) +
+ {/if} +
+ {target.installed_version ?? '—'} + + {#if target.active_version} + {target.active_version} + {#if target.update_available} + + actualización disponible + + {/if} + {:else if target.platform} + sin versión activa + {:else} + plataforma sin determinar + {/if} + {formatDate(target.reported_at)} + {#if isInstalling(target)} + + + instalación en curso + + {:else} + + + {/if} +
+ No hay servidores de restauración dados de alta. +
+
+
+ + + {#if runs.length} +
+

Últimas instalaciones

+
+ + + + + + + + + + + + + {#each runs as run (run.id)} + + + + + + + + + {/each} + +
ServidorVersiónModoEstadoInicioPor
{run.target_name ?? '—'}{run.version ?? '—'} + {run.mode === 'update' ? 'Actualización' : 'Instalación'} + + {#if run.status === 'completed'} + completada + {:else if run.status === 'failed'} + + fallida + + {:else} + en curso + {/if} + {formatDate(run.started_at)}{run.started_by ?? '—'}
+
+
+ {/if} + + + +{#if verifyResult || verifyError} + +{/if} + + +{#if installTarget} + {@const target = installTarget} + +{/if} + + +{#if confirmDelete} + {@const release = confirmDelete} + +{/if} + + +{#if confirmPrune} + +{/if} + diff --git a/src/routes/versiones-cras/install-runs/+server.ts b/src/routes/versiones-cras/install-runs/+server.ts new file mode 100644 index 0000000..7baf869 --- /dev/null +++ b/src/routes/versiones-cras/install-runs/+server.ts @@ -0,0 +1,68 @@ +/** + * GET /versiones-cras/install-runs?runId= + * GET /versiones-cras/install-runs?targetId= → el run más reciente de ese servidor + * + * Progreso de una instalación remota. La UI lo consulta por polling mientras la acción + * `install` sigue corriendo: una instalación sube ~270 MB y tarda minutos, así que el + * operador necesita ver en qué paso va en lugar de un spinner opaco. + * + * La consulta por `targetId` existe porque la acción es bloqueante: el navegador no conoce + * el runId hasta que la instalación termina, así que durante la espera solo puede preguntar + * por el servidor. + * + * Autenticado con la cookie de admin, igual que la página (este repo no tiene + * hooks.server.ts y repite el guard por ruta). + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { getInstallRun, getLatestInstallRunForTarget } from '$lib/server/cras-releases'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const traceId = newTraceId(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorJson(401, 'Sesión no válida', traceId); + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorJson(403, 'Requiere permisos de administrador', traceId); + } + + const runId = Number(url.searchParams.get('runId')); + const targetId = Number(url.searchParams.get('targetId')); + const byRun = Number.isInteger(runId) && runId > 0; + const byTarget = Number.isInteger(targetId) && targetId > 0; + if (!byRun && !byTarget) { + return errorJson(400, 'Indica runId o targetId', traceId); + } + + try { + const run = byRun ? await getInstallRun(runId) : await getLatestInstallRunForTarget(targetId); + if (!run) return errorJson(404, 'No hay instalaciones registradas', traceId); + return json({ + id: run.id, + status: run.status, + mode: run.mode, + version: run.version, + platform: run.platform, + target_name: run.target_name ?? null, + steps: run.steps ?? [], + error_message: run.error_message, + started_at: run.started_at, + finished_at: run.finished_at, + trace_id: traceId + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error leyendo el progreso de una instalación de CRAS', + context: { run_id: runId, error: message } + }); + return errorJson(500, 'Error interno al leer el progreso', traceId); + } +}; diff --git a/src/routes/versiones-cras/verify/+server.ts b/src/routes/versiones-cras/verify/+server.ts new file mode 100644 index 0000000..c5435cd --- /dev/null +++ b/src/routes/versiones-cras/verify/+server.ts @@ -0,0 +1,59 @@ +/** + * GET /versiones-cras/verify?targetId= + * + * Sonda en vivo de un servidor de restauración: contesta "¿está disponible AHORA?" con el + * detalle de POR QUÉ cuando no lo está, y el remedio cuando hay uno. + * + * Es distinto de `cloudrestore_status.reported_at`, que solo dice cuándo el agente arrancó o + * guardó configuración (no es un heartbeat). Un agente sano de hace una semana y uno muerto de + * hace una semana tienen el mismo timestamp; esta sonda los distingue. + * + * Solo lee del destino: no instala, no escribe ni reinicia nada, así que es seguro llamarla + * en cualquier momento. Autenticada con la cookie de admin, igual que la página. + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { verifyCrasTarget } from '$lib/server/cras-verify'; +import { errorJson, newTraceId } from '$lib/server/api-error'; +import { logger } from '$lib/server/logger'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const traceId = newTraceId(); + + const token = cookies.get('session_token'); + const session = token ? verifyToken(token) : null; + if (!session) return errorJson(401, 'Sesión no válida', traceId); + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.es_admin) { + return errorJson(403, 'Requiere permisos de administrador', traceId); + } + + const targetId = Number(url.searchParams.get('targetId')); + if (!Number.isInteger(targetId) || targetId <= 0) { + return errorJson(400, 'targetId inválido', traceId); + } + + try { + const result = await verifyCrasTarget(targetId); + logger.info({ + trace_id: traceId, + message: 'Verificación de restaurador', + context: { + target: result.name, + diagnosis: result.diagnosis, + started_by: currentUser.username + } + }); + return json({ ...result, trace_id: traceId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ + trace_id: traceId, + message: 'Error verificando un restaurador', + context: { target_id: targetId, error: message } + }); + return errorJson(500, `Error interno al verificar: ${message}`, traceId); + } +};