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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-22 12:18:12 -05:00
parent 43fef6048e
commit fa4ddbe164
15 changed files with 249 additions and 12 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog - project.linkedcorp.net # 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 ## [2.1.0] - 2026-09-22
### Added ### Added
- Nuevo espacio de marca en la barra lateral (arriba a la izquierda): si el sistema tiene un - Nuevo espacio de marca en la barra lateral (arriba a la izquierda): si el sistema tiene un
+35
View File
@@ -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 '{...}' 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 <backend-service>, /usr/bin/systemctl restart <frontend-service>' \
| sudo tee /etc/sudoers.d/deploy-update-<dominio>
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/<dominio>/scripts
cp /opt/apps/<dominio>/source/scripts/apply-update.sh /opt/apps/<dominio>/scripts/
chmod +x /opt/apps/<dominio>/scripts/apply-update.sh
# 3. agregar al backend/.env (el de runtime, NO el de source/backend)
cat >> /opt/apps/<dominio>/backend/.env <<EOF
UPDATE_APP_DIR=/opt/apps/<dominio>
UPDATE_BACKEND_SERVICE=<backend-service>
UPDATE_FRONTEND_SERVICE=<frontend-service>
EOF
sudo systemctl restart <backend-service>
```
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 ### 13. Limpiar archivos `.md` del source
```bash ```bash
+16 -1
View File
@@ -2,7 +2,7 @@
## Estado actual ## Estado actual
- Version actual: `2.1.0` - Version actual: `2.2.0`
- Estado: `operativo` - Estado: `operativo`
- Ultima validacion: `2026-09-22` - Ultima validacion: `2026-09-22`
@@ -562,6 +562,21 @@ iRedMail):
vuelve a ser siempre el de LinkedCorp (desarrollador), ya no configurable. 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. - 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 ## Comandos utiles
```bash ```bash
+1 -1
View File
@@ -1 +1 @@
2.1.0 2.2.0
+8
View File
@@ -20,3 +20,11 @@ STORAGE_ROOT=./storage
# El SMTP real NO se configura aqui: lo carga el superadmin desde Configuracion > Correo (SMTP) # 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 # 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. # 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/<dominio>
# UPDATE_BACKEND_SERVICE=<nombre-servicio-backend>
# UPDATE_FRONTEND_SERVICE=<nombre-servicio-frontend>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.1.0", "version": "2.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+8
View File
@@ -33,4 +33,12 @@ export const env = {
jwtExpires: process.env.JWT_EXPIRES || '8h', jwtExpires: process.env.JWT_EXPIRES || '8h',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196', corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196',
storageRoot: process.env.STORAGE_ROOT || './storage', 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,
},
}; };
@@ -4,3 +4,7 @@ import * as service from './service.js';
export const getUpdateStatus = asyncHandler(async (req, res) => { export const getUpdateStatus = asyncHandler(async (req, res) => {
ok(res, await service.checkForUpdates()); ok(res, await service.checkForUpdates());
}); });
export const applyUpdate = asyncHandler(async (req, res) => {
ok(res, await service.applyUpdate(req.currentUser.id));
});
+2 -1
View File
@@ -1,10 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js'; import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
import { getUpdateStatus } from './controller.js'; import { getUpdateStatus, applyUpdate } from './controller.js';
const router = Router(); const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS)); router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
router.get('/status', getUpdateStatus); router.get('/status', getUpdateStatus);
router.post('/apply', applyUpdate);
export default router; export default router;
+48 -1
View File
@@ -1,7 +1,11 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import * as repo from './repository.js'; 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION'); const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
@@ -62,17 +66,22 @@ function getLocalMigrationNames() {
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); 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() { export async function checkForUpdates() {
const currentVersion = getLocalVersion(); const currentVersion = getLocalVersion();
const changelogText = await repo.fetchChangelog(); const changelogText = await repo.fetchChangelog();
const allEntries = parseChangelog(changelogText); const allEntries = parseChangelog(changelogText);
const applyAvailable = isApplyAvailable();
const pending = allEntries const pending = allEntries
.filter((e) => compareVersions(e.version, currentVersion) > 0) .filter((e) => compareVersions(e.version, currentVersion) > 0)
.sort((a, b) => compareVersions(a.version, b.version)); .sort((a, b) => compareVersions(a.version, b.version));
if (pending.length === 0) { 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; let previousInChain = currentVersion;
@@ -98,5 +107,43 @@ export async function checkForUpdates() {
overallCriticality, overallCriticality,
hasMigrations, hasMigrations,
pending: pendingWithMeta, 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 };
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.1.0", "version": "2.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+16
View File
@@ -82,6 +82,14 @@ export const messages = {
updater_criticality_media: 'Media', updater_criticality_media: 'Media',
updater_criticality_baja: 'Baja', 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_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', config_error_logs: 'Errores del sistema',
error_logs_clear: 'Limpiar registro', error_logs_clear: 'Limpiar registro',
@@ -364,6 +372,14 @@ export const messages = {
updater_criticality_media: 'Medium', updater_criticality_media: 'Medium',
updater_criticality_baja: 'Low', 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_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', config_error_logs: 'System errors',
error_logs_clear: 'Clear log', error_logs_clear: 'Clear log',
@@ -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 { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { getUpdateStatus } from '../../services/updater.js'; import { getUpdateStatus, applyUpdate } from '../../services/updater.js';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { Badge } from '../../components/Badge.jsx'; import { Badge } from '../../components/Badge.jsx';
import { InfoBar } from '../../components/InfoBar.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' }; const CRITICALITY_COLOR = { alta: 'danger', media: 'warning', baja: 'success' };
export default function UpdaterPanel() { export default function UpdaterPanel() {
const { t } = useUi(); const { t, openDialog, pushToast } = useUi();
const { data: status, loading, error, reload } = useAsync(getUpdateStatus, []); 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 ( return (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
<Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading}> <Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading || applying}>
{t('updater_check')} {t('updater_check')}
</Button> </Button>
{status && !status.upToDate && status.applyAvailable && (
<Button variant="accent" icon={Download} onClick={confirmApply} disabled={applying}>
{applying ? t('updater_applying') : t('updater_apply')}
</Button>
)}
{status && <span className="text-muted" style={{ fontSize: '.78rem' }}>{t('updater_current_version')} v{status.currentVersion}</span>} {status && <span className="text-muted" style={{ fontSize: '.78rem' }}>{t('updater_current_version')} v{status.currentVersion}</span>}
</div> </div>
{applying && <InfoBar tone="warning" dismissible={false}>{t('updater_applying_hint')}</InfoBar>}
{loading && <div className="text-muted">{t('common_loading')}</div>} {loading && <div className="text-muted">{t('common_loading')}</div>}
{error && <InfoBar tone="error" dismissible={false}>{error}</InfoBar>} {error && <InfoBar tone="error" dismissible={false}>{error}</InfoBar>}
@@ -31,6 +60,9 @@ export default function UpdaterPanel() {
{status && !status.upToDate && ( {status && !status.upToDate && (
<div> <div>
<InfoBar tone="error" dismissible={false}>{t('updater_restart_required')}</InfoBar> <InfoBar tone="error" dismissible={false}>{t('updater_restart_required')}</InfoBar>
{!status.applyAvailable && (
<InfoBar tone="warning" dismissible={false}>{t('updater_apply_not_configured')}</InfoBar>
)}
{status.hasMigrations && ( {status.hasMigrations && (
<InfoBar tone="warning" dismissible={false}>{t('updater_has_migrations')}</InfoBar> <InfoBar tone="warning" dismissible={false}>{t('updater_has_migrations')}</InfoBar>
)} )}
+1
View File
@@ -1,3 +1,4 @@
import { api } from './api.js'; import { api } from './api.js';
export const getUpdateStatus = () => api.get('/updater/status'); export const getUpdateStatus = () => api.get('/updater/status');
export const applyUpdate = () => api.post('/updater/apply');
+58
View File
@@ -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 ==="