From fa4ddbe164fed519b37ba68f21d133c26806a75d Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Tue, 22 Sep 2026 12:18:12 -0500 Subject: [PATCH] feat: version 2.2.0 -- boton real de actualizacion (auto-update) POST /api/updater/apply dispara scripts/apply-update.sh (backup + git pull + migraciones + build + restart) en un proceso separado. Apagado por defecto: solo se activa si el servidor define UPDATE_APP_DIR/UPDATE_BACKEND_SERVICE/UPDATE_FRONTEND_SERVICE en su backend/.env. Sin esa config, el panel se comporta igual que antes. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 12 ++++ DEPLOY_WEB.md | 35 +++++++++++ PRODUCCION.md | 17 +++++- VERSION | 2 +- backend/.env.example | 8 +++ backend/package.json | 2 +- backend/src/config/env.js | 8 +++ backend/src/modules/updater/controller.js | 4 ++ backend/src/modules/updater/routes.js | 3 +- backend/src/modules/updater/service.js | 49 +++++++++++++++- frontend/package.json | 2 +- frontend/src/i18n/messages.js | 16 +++++ .../src/pages/Configuracion/UpdaterPanel.jsx | 44 ++++++++++++-- frontend/src/services/updater.js | 1 + scripts/apply-update.sh | 58 +++++++++++++++++++ 15 files changed, 249 insertions(+), 12 deletions(-) create mode 100755 scripts/apply-update.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b37512d..b2fc461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog - project.linkedcorp.net +## [2.2.0] - 2026-09-22 +### Added +- Botón "Actualizar" real en Configuración → Actualizaciones: aplica la actualización sola + (backup de BD, `git pull`, migraciones, build, reinicio de servicios) en vez de solo avisar. + **Apagado por defecto** — solo aparece en un servidor donde el admin configuró explícitamente + `UPDATE_APP_DIR`/`UPDATE_BACKEND_SERVICE`/`UPDATE_FRONTEND_SERVICE` en `backend/.env` y le dio + al usuario `deploy` permiso sudo acotado para reiniciar esos dos servicios puntuales. Sin esa + configuración, el panel sigue funcionando exactamente igual que antes (solo informa). +- Nuevo `scripts/apply-update.sh`: hace el trabajo real, corre desde una copia fuera de + `source/` (nunca desde adentro, porque el propio script hace `git reset --hard` sobre + `source/` mientras se ejecuta). + ## [2.1.0] - 2026-09-22 ### Added - Nuevo espacio de marca en la barra lateral (arriba a la izquierda): si el sistema tiene un diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md index 7ff6dee..2efc627 100644 --- a/DEPLOY_WEB.md +++ b/DEPLOY_WEB.md @@ -209,6 +209,41 @@ curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5196/ curl https://project.linkedcorp.net/api/auth/login -X POST -H 'Content-Type: application/json' -d '{...}' ``` +### 12.5 Habilitar el botón "Actualizar" (auto-actualización, opcional) + +Por defecto el panel Configuración → Actualizaciones solo informa. Para que el botón +"Actualizar" aplique el cambio solo (backup + git pull + migraciones + build + restart), hace +falta en el servidor: + +```bash +# 1. sudo (si no esta instalado) y permiso acotado para el usuario deploy — SOLO reiniciar +# estos dos servicios puntuales, nada mas. +sudo apt-get install -y sudo # si el servidor no lo trae (ej. instalaciones minimas de Debian) +echo 'deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart , /usr/bin/systemctl restart ' \ + | sudo tee /etc/sudoers.d/deploy-update- +sudo visudo -c # valida sintaxis antes de confiar en el archivo + +# 2. copiar el script fuera de source/ (donde vive el runtime, no el checkout de git) +mkdir -p /opt/apps//scripts +cp /opt/apps//source/scripts/apply-update.sh /opt/apps//scripts/ +chmod +x /opt/apps//scripts/apply-update.sh + +# 3. agregar al backend/.env (el de runtime, NO el de source/backend) +cat >> /opt/apps//backend/.env < +UPDATE_BACKEND_SERVICE= +UPDATE_FRONTEND_SERVICE= +EOF + +sudo systemctl restart +``` + +Importante: el script (`scripts/apply-update.sh`) hace `git reset --hard` sobre `source/` +mientras corre — por eso tiene que ejecutarse SIEMPRE desde la copia en +`$UPDATE_APP_DIR/scripts/`, nunca desde dentro de `source/scripts/` directamente. Cada vez que +el script cambie en un release, hay que repetir el paso 2 (copiarlo de nuevo) en un deploy +manual — el propio update automático no se autoactualiza a sí mismo. + ### 13. Limpiar archivos `.md` del source ```bash diff --git a/PRODUCCION.md b/PRODUCCION.md index 92361c8..73bb019 100644 --- a/PRODUCCION.md +++ b/PRODUCCION.md @@ -2,7 +2,7 @@ ## Estado actual -- Version actual: `2.1.0` +- Version actual: `2.2.0` - Estado: `operativo` - Ultima validacion: `2026-09-22` @@ -562,6 +562,21 @@ iRedMail): vuelve a ser siempre el de LinkedCorp (desarrollador), ya no configurable. - Sin migracion (solo frontend). Probado en dev con un logo de prueba antes de subir. +## Actualizacion v2.2.0 (2026-09-22) — boton "Actualizar" real (auto-update) + +- Pedido del usuario: poder aplicar la actualizacion desde el navegador, no solo verla. Se + agrega `POST /api/updater/apply` (superadmin) que dispara `scripts/apply-update.sh` en un + proceso separado (backup + git pull + migraciones + build + restart de los dos servicios). +- Queda **deshabilitado en este servidor (.3)** a proposito — no se configuraron las variables + `UPDATE_APP_DIR`/`UPDATE_BACKEND_SERVICE`/`UPDATE_FRONTEND_SERVICE` en el `.env` de produccion, + asi que el panel de LinkedCorp sigue mostrando solo informacion (sin el boton), igual que + siempre. Se activo unicamente en el servidor de exilaser (`192.168.0.30`) para probarlo — ver + detalle en el propio servidor: `deploy` tiene sudo NOPASSWD acotado a + `systemctl restart project-exilaser-backend project-exilaser-frontend`. +- Sin migracion de esquema. Backend y frontend reconstruidos y reiniciados en `.3` (PID + confirmado), verificado que el panel sigue mostrando el aviso "auto-actualizacion no + configurada" (comportamiento esperado en este servidor). + ## Comandos utiles ```bash diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/backend/.env.example b/backend/.env.example index 0074d7b..26d88ac 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,3 +20,11 @@ STORAGE_ROOT=./storage # El SMTP real NO se configura aqui: lo carga el superadmin desde Configuracion > Correo (SMTP) # en la app (queda guardado cifrado en la tabla system_settings). Sin configurar, los correos de # verificacion/recuperacion se loguean en la consola del backend en vez de enviarse. + +# Auto-actualizacion (boton "Actualizar" en Configuracion > Actualizaciones). OPCIONAL: si se +# deja vacio, el panel solo informa que hay una version nueva (como siempre), sin poder +# aplicarla desde el navegador. Solo tiene sentido en un servidor donde el usuario `deploy` +# tenga sudo NOPASSWD para reiniciar exactamente estos dos servicios — ver DEPLOY_WEB.md. +# UPDATE_APP_DIR=/opt/apps/ +# UPDATE_BACKEND_SERVICE= +# UPDATE_FRONTEND_SERVICE= diff --git a/backend/package.json b/backend/package.json index 2b7510f..3dd0b48 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 1f8fe24..346e0a5 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -33,4 +33,12 @@ export const env = { jwtExpires: process.env.JWT_EXPIRES || '8h', corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196', storageRoot: process.env.STORAGE_ROOT || './storage', + // Auto-actualizacion (boton "Actualizar" del panel Configuracion > Actualizaciones): opcional, + // deshabilitada por completo si UPDATE_APP_DIR no esta seteado en este servidor (ej. no se + // configura en el servidor de LinkedCorp, solo en instalaciones de clientes que lo pidan). + update: { + appDir: process.env.UPDATE_APP_DIR || null, + backendService: process.env.UPDATE_BACKEND_SERVICE || null, + frontendService: process.env.UPDATE_FRONTEND_SERVICE || null, + }, }; diff --git a/backend/src/modules/updater/controller.js b/backend/src/modules/updater/controller.js index 9cbbf75..f86c5f9 100644 --- a/backend/src/modules/updater/controller.js +++ b/backend/src/modules/updater/controller.js @@ -4,3 +4,7 @@ import * as service from './service.js'; export const getUpdateStatus = asyncHandler(async (req, res) => { ok(res, await service.checkForUpdates()); }); + +export const applyUpdate = asyncHandler(async (req, res) => { + ok(res, await service.applyUpdate(req.currentUser.id)); +}); diff --git a/backend/src/modules/updater/routes.js b/backend/src/modules/updater/routes.js index af959b2..56b52b2 100644 --- a/backend/src/modules/updater/routes.js +++ b/backend/src/modules/updater/routes.js @@ -1,10 +1,11 @@ import { Router } from 'express'; import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js'; -import { getUpdateStatus } from './controller.js'; +import { getUpdateStatus, applyUpdate } from './controller.js'; const router = Router(); router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS)); router.get('/status', getUpdateStatus); +router.post('/apply', applyUpdate); export default router; diff --git a/backend/src/modules/updater/service.js b/backend/src/modules/updater/service.js index d769794..69a40de 100644 --- a/backend/src/modules/updater/service.js +++ b/backend/src/modules/updater/service.js @@ -1,7 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import * as repo from './repository.js'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION'); @@ -62,17 +66,22 @@ function getLocalMigrationNames() { return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); } +function isApplyAvailable() { + return !!(env.update.appDir && env.update.backendService && env.update.frontendService); +} + export async function checkForUpdates() { const currentVersion = getLocalVersion(); const changelogText = await repo.fetchChangelog(); const allEntries = parseChangelog(changelogText); + const applyAvailable = isApplyAvailable(); const pending = allEntries .filter((e) => compareVersions(e.version, currentVersion) > 0) .sort((a, b) => compareVersions(a.version, b.version)); if (pending.length === 0) { - return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false }; + return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false, applyAvailable }; } let previousInChain = currentVersion; @@ -98,5 +107,43 @@ export async function checkForUpdates() { overallCriticality, hasMigrations, pending: pendingWithMeta, + applyAvailable, }; } + +let applyInProgress = false; + +// Dispara el script externo (fuera de source/, ver scripts/apply-update.sh) que hace el +// fetch+reset+build+migracion+restart real. Se lanza "detached" porque el propio backend se +// reinicia al final — el request HTTP ya respondio "empezo" mucho antes de que eso pase. +export async function applyUpdate(actorId) { + if (!isApplyAvailable()) { + throw new HttpError(400, 'La auto-actualizacion no esta configurada en este servidor (falta UPDATE_APP_DIR en backend/.env)'); + } + const status = await checkForUpdates(); + if (status.upToDate) { + throw new HttpError(400, 'El sistema ya esta en la ultima version'); + } + if (applyInProgress) { + throw new HttpError(409, 'Ya hay una actualizacion en curso'); + } + + applyInProgress = true; + const scriptPath = path.join(env.update.appDir, 'scripts', 'apply-update.sh'); + const child = spawn('bash', [scriptPath], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + APP_DIR: env.update.appDir, + BACKEND_SERVICE: env.update.backendService, + FRONTEND_SERVICE: env.update.frontendService, + }, + }); + child.unref(); + child.on('exit', () => { applyInProgress = false; }); + + await logAudit({ userId: actorId, action: 'apply_update', entityType: 'updater', entityId: null, detail: { from: status.currentVersion, to: status.latestVersion } }); + + return { started: true, fromVersion: status.currentVersion, toVersion: status.latestVersion }; +} diff --git a/frontend/package.json b/frontend/package.json index b7a1d14..2ce9293 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.1.0", + "version": "2.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index fc0982a..65281e9 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -82,6 +82,14 @@ export const messages = { updater_criticality_media: 'Media', updater_criticality_baja: 'Baja', updater_rollback_hint: 'Antes de actualizar, respalda la base de datos. Si algo sale mal, restaura ese respaldo y volvé al commit anterior a esta actualización en el historial de Gitea.', + updater_apply: 'Actualizar', + updater_applying: 'Actualizando…', + updater_apply_confirm_title: 'Aplicar actualización', + updater_apply_confirm_msg: 'Se va a respaldar la base de datos, descargar el código nuevo, aplicar migraciones si las hay, y reiniciar el sistema. Puede tardar unos minutos y la app quedará momentáneamente fuera de servicio. Vas de', + updater_apply_confirm_ok: 'Sí, actualizar', + updater_apply_started: 'Actualización iniciada. El sistema se reiniciará en unos minutos — refrescá la página luego.', + updater_applying_hint: 'Actualización en curso — no cierres esta pestaña. El sistema se reiniciará solo al terminar.', + updater_apply_not_configured: 'La auto-actualización no está habilitada en este servidor. Pedile a tu administrador que aplique esta actualización por SSH.', config_error_logs: 'Errores del sistema', error_logs_clear: 'Limpiar registro', @@ -364,6 +372,14 @@ export const messages = { updater_criticality_media: 'Medium', updater_criticality_baja: 'Low', updater_rollback_hint: 'Back up the database before updating. If something goes wrong, restore that backup and roll back to the commit before this update in the Gitea history.', + updater_apply: 'Update', + updater_applying: 'Updating…', + updater_apply_confirm_title: 'Apply update', + updater_apply_confirm_msg: 'This will back up the database, pull the new code, apply migrations if any, and restart the system. It may take a few minutes and the app will be briefly unavailable. Going from', + updater_apply_confirm_ok: 'Yes, update', + updater_apply_started: 'Update started. The system will restart in a few minutes — refresh the page afterward.', + updater_applying_hint: 'Update in progress — do not close this tab. The system will restart on its own when done.', + updater_apply_not_configured: 'Auto-update is not enabled on this server. Ask your administrator to apply this update via SSH.', config_error_logs: 'System errors', error_logs_clear: 'Clear log', diff --git a/frontend/src/pages/Configuracion/UpdaterPanel.jsx b/frontend/src/pages/Configuracion/UpdaterPanel.jsx index 4a5dc10..f683bd2 100644 --- a/frontend/src/pages/Configuracion/UpdaterPanel.jsx +++ b/frontend/src/pages/Configuracion/UpdaterPanel.jsx @@ -1,7 +1,8 @@ -import { RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { RefreshCw, Download } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; -import { useAsync } from '../../hooks/useAsync.js'; -import { getUpdateStatus } from '../../services/updater.js'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { getUpdateStatus, applyUpdate } from '../../services/updater.js'; import { Button } from '../../components/Button.jsx'; import { Badge } from '../../components/Badge.jsx'; import { InfoBar } from '../../components/InfoBar.jsx'; @@ -9,18 +10,46 @@ import { InfoBar } from '../../components/InfoBar.jsx'; const CRITICALITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' }; export default function UpdaterPanel() { - const { t } = useUi(); + const { t, openDialog, pushToast } = useUi(); const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []); + const runAction = useAction(); + const [applying, setApplying] = useState(false); + + function confirmApply() { + openDialog({ + tone: 'warning', + title: t('updater_apply_confirm_title'), + message: `${t('updater_apply_confirm_msg')} v${status.currentVersion} → v${status.latestVersion}`, + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('updater_apply_confirm_ok'), variant: 'danger', + onClick: () => runAction(async () => { + setApplying(true); + await applyUpdate(); + pushToast({ tone: 'success', message: t('updater_apply_started') }); + }).finally(() => setApplying(false)), + }, + ], + }); + } return (
-
- + {status && !status.upToDate && status.applyAvailable && ( + + )} {status && {t('updater_current_version')} v{status.currentVersion}}
+ {applying && {t('updater_applying_hint')}} + {loading &&
{t('common_loading')}
} {error && {error}} @@ -31,6 +60,9 @@ export default function UpdaterPanel() { {status && !status.upToDate && (
{t('updater_restart_required')} + {!status.applyAvailable && ( + {t('updater_apply_not_configured')} + )} {status.hasMigrations && ( {t('updater_has_migrations')} )} diff --git a/frontend/src/services/updater.js b/frontend/src/services/updater.js index 31d0f29..d2b4750 100644 --- a/frontend/src/services/updater.js +++ b/frontend/src/services/updater.js @@ -1,3 +1,4 @@ import { api } from './api.js'; export const getUpdateStatus = () => api.get('/updater/status'); +export const applyUpdate = () => api.post('/updater/apply'); diff --git a/scripts/apply-update.sh b/scripts/apply-update.sh new file mode 100755 index 0000000..20318c5 --- /dev/null +++ b/scripts/apply-update.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Ejecuta el update real: lo dispara el backend (POST /api/updater/apply) via un proceso +# separado y detached, nunca corre inline en el request HTTP (el propio backend se reinicia +# al final). +# +# IMPORTANTE: este script se ejecuta desde una copia FUERA de source/ (normalmente +# $APP_DIR/scripts/apply-update.sh) — nunca desde dentro de source/, porque el propio script +# hace `git reset --hard` sobre source/ mientras corre. Si se ejecutara desde ahi, se +# sobreescribiria a si mismo a mitad de ejecucion (comportamiento indefinido en bash). +# +# Variables requeridas en el entorno (via EnvironmentFile del backend, ya cargadas por el +# proceso Node que invoca este script): +# APP_DIR ej. /opt/apps/project.exilaser.net +# BACKEND_SERVICE ej. project-exilaser-backend +# FRONTEND_SERVICE ej. project-exilaser-frontend +# DB_HOST / DB_USER / DB_PASSWORD / DB_NAME (ya vienen del .env del backend) +set -euo pipefail + +: "${APP_DIR:?falta APP_DIR}" +: "${BACKEND_SERVICE:?falta BACKEND_SERVICE}" +: "${FRONTEND_SERVICE:?falta FRONTEND_SERVICE}" + +LOG="$APP_DIR/logs/update.log" +mkdir -p "$APP_DIR/logs" "$APP_DIR/backups" +exec >>"$LOG" 2>&1 + +echo "" +echo "=== update start $(date -Iseconds) ===" + +echo "-- backup de base de datos --" +mysqldump --single-transaction -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" \ + > "$APP_DIR/backups/pre_update_$(date +%Y%m%d_%H%M%S).sql" + +echo "-- git fetch + reset --" +cd "$APP_DIR/source" +git fetch origin main +git reset --hard origin/main +NEW_VERSION="$(cat VERSION)" +echo "version objetivo: $NEW_VERSION" + +echo "-- backend: npm install + migraciones --" +cd "$APP_DIR/source/backend" +npm install --omit=dev +node src/scripts/migrate-db.js + +echo "-- frontend: npm install + build --" +cd "$APP_DIR/source/frontend" +npm install +npm run build + +echo "-- publicando build de frontend --" +rm -rf "$APP_DIR/frontend/dist" +cp -r dist "$APP_DIR/frontend/dist" + +echo "-- reiniciando servicios --" +sudo systemctl restart "$BACKEND_SERVICE" "$FRONTEND_SERVICE" + +echo "=== update done $(date -Iseconds) -- v$NEW_VERSION ==="