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:
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
```bash
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
@@ -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/<dominio>
|
||||
# UPDATE_BACKEND_SERVICE=<nombre-servicio-backend>
|
||||
# UPDATE_FRONTEND_SERVICE=<nombre-servicio-frontend>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Button variant="standard" icon={RefreshCw} onClick={reload} disabled={loading || applying}>
|
||||
{t('updater_check')}
|
||||
</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>}
|
||||
</div>
|
||||
|
||||
{applying && <InfoBar tone="warning" dismissible={false}>{t('updater_applying_hint')}</InfoBar>}
|
||||
|
||||
{loading && <div className="text-muted">{t('common_loading')}</div>}
|
||||
{error && <InfoBar tone="error" dismissible={false}>{error}</InfoBar>}
|
||||
|
||||
@@ -31,6 +60,9 @@ export default function UpdaterPanel() {
|
||||
{status && !status.upToDate && (
|
||||
<div>
|
||||
<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 && (
|
||||
<InfoBar tone="warning" dismissible={false}>{t('updater_has_migrations')}</InfoBar>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const getUpdateStatus = () => api.get('/updater/status');
|
||||
export const applyUpdate = () => api.post('/updater/apply');
|
||||
|
||||
Executable
+58
@@ -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 ==="
|
||||
Reference in New Issue
Block a user