fase 3 (operacion): backups automaticos por instancia con descubrimiento dinamico, verificacion de integridad, retencion GFS 14/8/6, prueba de restauracion semanal, timers systemd y logrotate; panel CLI multi-instancia (admin_instancias.py) + doc de operacion y restauracion

This commit is contained in:
2026-10-11 09:42:08 +00:00
parent dabd8c6bd7
commit 91f909a6d0
12 changed files with 1602 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
# Operación de instancias SKEEN (backups + panel)
Guía de operación comercial de la VPS: cómo funcionan los **backups automáticos
verificados** de cada instancia Odoo y cómo usar el **panel/CLI multi-instancia**.
Todo vive en `infra/ops/`:
| Archivo | Para qué |
|---|---|
| `backup_instancias.sh` | Backup por instancia: dump + config, verificación de integridad, rotación y (opcional) prueba de restauración. |
| `backup.env` | Configuración central: destinos, credenciales de DB, retención, log. |
| `admin_instancias.py` | Panel/CLI multi-instancia (read-only por defecto). |
| `systemd/` | Unidades y timers (`skeen-backup*`, `backup-instancia@`). Se copian a `/etc/systemd/system/`. |
| `logrotate/skeen-backup` | Rotación del log; se copia a `/etc/logrotate.d/skeen-backup`. |
Las instancias **no se listan a mano**: se descubren desde las unidades
`odoo-*.service`. Por cada unidad se lee su `-c <conf>` (de `systemctl cat`) y de
ahí `db_name`, `http_port`, `logfile` y `data_dir`. Añadir una instancia nueva
con `infra/provision/provision_clinica.py` la incluye automáticamente en backups
y panel, sin editar nada de ops.
---
## 1. Backups automáticos
### Qué se respalda
Por cada instancia, en `/root/backups/<slug>/`:
- **Base de datos**: `pg_dump -Fc` → `<db>_<YYYYmmdd_HHMMSS>.dump`.
- Se vuelca primero a `/tmp` (el usuario `postgres` no puede escribir en `/root`)
y se mueve al destino final con `chmod 600 root:root`.
- Se **verifica la integridad** con `pg_restore --list`; si falla, el dump se
marca como corrupto, se borra y se reporta como error.
- **Configuración del host**: `<db>_<YYYYmmdd_HHMMSS>.config.tar.gz` con
`odoo.conf`, la unidad systemd, el vhost nginx (buscado por slug o por puerto)
y los directorios de marca del frontend (`/var/www/<slug>-frontend/brand`,
`/var/www/<slug>-frontend/skeen-brand`, `/var/www/skeen-frontend/skeen-brand`).
Incluye un `MANIFEST.txt`. Pesa unos pocos MB por las tipografías de marca.
### Comando
```bash
/root/skeen-proyecto/infra/ops/backup_instancias.sh # todas las instancias
/root/skeen-proyecto/infra/ops/backup_instancias.sh --instancia skeen
/root/skeen-proyecto/infra/ops/backup_instancias.sh --verificar # + prueba de restauración
/root/skeen-proyecto/infra/ops/backup_instancias.sh --solo-db # sin config
/root/skeen-proyecto/infra/ops/backup_instancias.sh --dry-run # no escribe nada
/root/skeen-proyecto/infra/ops/backup_instancias.sh --help
```
| Flag | Efecto |
|---|---|
| `--instancia <slug>` | Solo esa instancia. |
| `--solo-db` / `--sin-config` | No respalda la configuración del host. |
| `--verificar` | Además restaura el dump a una DB temporal y compara conteos (ver abajo). |
| `--dry-run` | Muestra qué haría (incluida la rotación) sin tocar nada. |
| `--backup-root <dir>` | Destino alternativo (pruebas / recuperación). |
- **Lockfile**: `/var/lock/skeen-backup.lock` (flock). Si ya hay un backup en
curso, la segunda ejecución sale con **exit 3** sin hacer nada.
- **Log**: `/var/log/skeen-backup.log` (una línea por evento y un resumen final
con `ESTADO GLOBAL: OK/FALLO`). Exit code `0` si todo va bien, `1` si alguna
instancia falla. Se rota por logrotate (semanal, 8 copias) y, además, el
script lo trunca si supera `LOG_MAX_BYTES` (5 MiB).
### Programación (systemd)
| Unidad | Cuándo | Qué corre |
|---|---|---|
| `skeen-backup.timer` | diario ~03:20 (`RandomizedDelaySec=1800`) | `skeen-backup.service` → todas las instancias |
| `skeen-backup-verify.timer` | domingos ~04:40 | `skeen-backup-verify.service` → todas con `--verificar` |
| `backup-instancia@.timer` | diario ~03:40 (plantilla, **no habilitada**) | `backup-instancia@<slug>.service` → una instancia concreta |
Los timers tienen `Persistent=true`: si la VPS estaba apagada a la hora prevista,
el backup corre al arrancar.
### Política de retención
Configurable en `backup.env` (`RETAIN_DAILY`, `RETAIN_WEEKLY`, `RETAIN_MONTHLY`).
Por defecto: **14 diarios / 8 semanales / 6 mensuales**.
Es un esquema abuelo-padre-hijo con **buckets exclusivos**: un archivo que ya
quedó retenido como mensual no vuelve a contar para semanal, y así sucesivamente.
1. **Mensual** (hasta 6): el backup más reciente de cada mes calendario.
2. **Semanal** (hasta 8): el más reciente de cada semana ISO no retenido aún.
3. **Diario** (hasta 14): el más reciente de cada día no retenido aún.
4. Todo lo demás se borra.
El backup **más reciente nunca se borra** (el "último bueno"), y por
construcción tampoco se borra el representante mensual. Cuando se borra un dump,
se borra también su `.config.tar.gz` gemelo (misma marca de tiempo).
> Consecuencia útil: varios backups del mismo día no se acumulan sin límite; se
> conservan a lo sumo uno por nivel (mensual/semanal/diario).
---
## 2. Verificación
### Integridad (siempre)
Cada dump se valida con `pg_restore --list` antes de considerarse bueno. Para
verificar a mano:
```bash
for f in /root/backups/*/*.dump; do pg_restore --list "$f" >/dev/null && echo "OK $f" || echo "FALLA $f"; done
```
### Prueba de restauración real (`--verificar`)
Restaura el dump recién creado a una DB temporal `<db>_verify_<fecha>`, cuenta
filas de tablas clave (`res_partner`, `skeen_cita`, `skeen_venta`), compara con
la original y **borra la temporal**:
```bash
/root/skeen-proyecto/infra/ops/backup_instancias.sh --instancia clinica2 --verificar
```
- Requiere **espacio libre** en el filesystem de PostgreSQL de al menos
`VERIFY_MIN_FREE_FACTOR` (2) × tamaño de la base; si no lo hay, la omite con
un aviso en el log (no falla el backup).
- La restauración corre por peer como `postgres` (superusuario), de modo que
extensiones y objetos se recrean sin problemas; la prueba real es la
comparación de conteos.
---
## 3. Restaurar un backup (procedimiento)
Las DB y sus tablas son propiedad del rol `skeen`. Restaura **siempre a una DB
nueva primero** y solo luego promueve, para no dejar la instancia sin datos si el
dump tuviera un problema.
```bash
DUMP=/root/backups/clinica2/clinica2_odoo_20261011_093633.dump
SLUG=clinica2
DB=clinica2_odoo
```
**Paso 1 — Restaurar a una DB nueva y comparar**
```bash
sudo -u postgres createdb -T template0 -O skeen "${DB}_restore"
sudo -u postgres pg_restore --role=skeen --no-privileges -d "${DB}_restore" "$DUMP"
# comparar conteos (deben coincidir)
sudo -u postgres psql -d "$DB" -Atc "select count(*) from res_partner"
sudo -u postgres psql -d "${DB}_restore" -Atc "select count(*) from res_partner"
```
**Paso 2 — Promover la DB restaurada (con parada del servicio)**
```bash
systemctl stop odoo-${SLUG}.service
# cortar conexiones y renombrar
sudo -u postgres psql -Atc "select pg_terminate_backend(pid) from pg_stat_activity \
where datname='${DB}' and pid <> pg_backend_pid()"
sudo -u postgres psql -c "ALTER DATABASE ${DB} RENAME TO ${DB}_pre_restore"
sudo -u postgres psql -c "ALTER DATABASE ${DB}_restore RENAME TO ${DB}"
systemctl start odoo-${SLUG}.service
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:<puerto>/web/login
```
**Paso 3 — Limpieza** (cuando ya validaste)
```bash
sudo -u postgres dropdb "${DB}_pre_restore"
```
**Restauración de emergencia sobre la DB viva** (solo si no puedes parar y la DB
está corrupta; pierde los datos posteriores al dump):
```bash
systemctl stop odoo-${SLUG}.service
sudo -u postgres dropdb "${DB}" # o ALTER DATABASE ... RENAME
sudo -u postgres createdb -T template0 -O skeen "${DB}"
sudo -u postgres pg_restore --role=skeen --no-privileges -d "${DB}" "$DUMP"
systemctl start odoo-${SLUG}.service
```
> El `pg_restore --role=skeen` es importante: hace que los objetos queden del rol
> `skeen` (el que usa Odoo). Sin `--role`, quedarían de `postgres` y el backend
> podría no tener permisos.
> La configuración (`*.config.tar.gz`) se restaura descomprimiendo el tar y
> copiando cada archivo a su ruta (están listados en el `MANIFEST.txt`).
---
## 4. Panel / CLI multi-instancia
`admin_instancias.py` **no requiere dependencias externas** y descubre las
instancias igual que el backup. Por defecto es de **solo lectura**.
```bash
cd /root/skeen-proyecto/infra/ops
./admin_instancias.py listar # tabla (subcomando por defecto)
./admin_instancias.py --json listar # JSON para automatización
```
| Subcomando | Qué muestra |
|---|---|
| `listar` | slug, unit, estado del servicio, puerto, DB, HTTP en `/web/login`, nº de módulos `skeen_*`, último backup (fecha/tamaño), nº de backups, uso de disco de backups y datadir, y si el puerto escucha. |
| `estado <slug>` | Detalle: conf, DB, puerto, HTTP, log, data_dir, vhost, frontend, versión de cada módulo `skeen_*`, backups. |
| `version` | Versión instalada de cada módulo `skeen_*`, en tabla por instancia. |
| `uso` | Disco, RAM, carga del host y tabla de procesos Odoo (CPU/RAM/RSS/uptime/conf). |
| `instalacion <slug>` | Valida que la instancia esté completa: DB, unit activa, conf, vhost, puerto, HTTP, frontend desplegado y marca configurada. Lista lo que falta. |
| `logs <slug> [-n N]` | Tail del log de Odoo de esa instancia y del log de backups. |
| `credenciales <slug> [--mostrar]` | Ruta del archivo de credenciales del provisioning. **Nunca** imprime passwords sin `--mostrar`. |
| `backup <slug> [--verificar] --yes` | Lanza el script de backup de esa instancia. |
| `reiniciar <slug> --yes` | `systemctl restart`. Comprueba el lock y advierte que interrumpe el servicio. |
- `--json` (global o por subcomando) da salida JSON.
- Las acciones que cambian estado (`backup`, `reiniciar`) **exigen `--yes`** y
avisan qué harán antes de hacerlo.
---
## 5. Qué hacer si algo falla
| Síntoma | Diagnóstico | Acción |
|---|---|---|
| **Disco lleno** | `./admin_instancias.py uso`; `df -h /` | Bajar retención en `backup.env` (p. ej. `RETAIN_DAILY=7`), borrar backups viejos de `/root/backups/<slug>/`, revisar `/var/log/skeen-backup.log.1.gz`. La verificación se auto-omite si no hay espacio. |
| **Dump corrupto** | En el log: `dump CORRUPTO (...)`. Comprobar con `pg_restore --list`. | El script ya borra el dump corrupto. Revisar espacio en `/tmp` y salud de PostgreSQL; volver a lanzar el backup. |
| **Backup no se ejecuta** | `systemctl list-timers \| grep skeen`; `journalctl -u skeen-backup.service -n 50`; `tail /var/log/skeen-backup.log` | Verificar el timer (`enabled`) y que el lock no esté tomado por un proceso colgado: `fuser /var/lock/skeen-backup.lock`. |
| **Servicio caído** | `./admin_instancias.py estado <slug>`; `journalctl -u odoo-<slug>.service -n 100` | Reiniciar con `./admin_instancias.py reiniciar <slug> --yes` (o `systemctl restart odoo-<slug>`). Si la DB quedó corrupta, restaurar (sección 3). |
| **Instancia incompleta** | `./admin_instancias.py instalacion <slug>` | Completar lo que falte (vhost, frontend, marca) o re-provisionar con `infra/provision/provision_clinica.py`. |
| **Restauración de emergencia** | La instancia no arranca y el dump más reciente es válido | Sección 3, "Restauración de emergencia". Antes de tocar nada, copia el directorio de datos actual (`/var/lib/odoo*`) por si acaso. |
| **No llegan backups de una instancia nueva** | `./admin_instancias.py listar` no la ve | Comprobar que existe su `odoo-<slug>.service` y que `-c <conf>` apunta a un conf con `db_name`. |
---
## 6. Rutas y estado instalado
| Recurso | Ruta |
|---|---|
| Scripts | `/root/skeen-proyecto/infra/ops/` |
| Config | `/root/skeen-proyecto/infra/ops/backup.env` (chmod 600) |
| Backups | `/root/backups/<slug>/` (`<db>_<fecha>.dump`, `<db>_<fecha>.config.tar.gz`) |
| Log | `/var/log/skeen-backup.log` (+ rotación logrotate) |
| Lock | `/var/lock/skeen-backup.lock` |
| Units | `/etc/systemd/system/skeen-backup*.{service,timer}`, `backup-instancia@.{service,timer}` |
| Logrotate | `/etc/logrotate.d/skeen-backup` |
---
## 7. Componentes opcionales (Docker)
La VPS también corre **Supabase self-hosted** y **WACRM** en Docker. **No** forman
parte del backup por instancia; se manejan aparte. En `backup.env`,
`SUPABASE_BACKUP` y `WACRM_BACKUP` están documentados y en `0`. Los backups
manuales previos de esos componentes viven en `/root/backups/*.sql`
(`supabase_skeen_backup_*.sql`, `wacrm_conversations_*.sql`).

64
infra/ops/README.md Normal file
View File

@@ -0,0 +1,64 @@
# infra/ops — backups automáticos y panel multi-instancia
Operación comercial de la VPS: backups verificados por instancia y un panel/CLI
para administrar todas las instancias SKEEN. Guía completa:
[`../../docs/producto/operacion-instancia.md`](../../docs/producto/operacion-instancia.md).
Las instancias **no se hardcodean**: se descubren desde las unidades
`odoo-*.service` (leyendo de cada una su `-c <conf>`). Una instancia nueva creada
con `infra/provision/` entra sola.
## Comandos rápidos
```bash
cd /root/skeen-proyecto/infra/ops
# ---- Backups ----
./backup_instancias.sh # todas las instancias (dump + config + rotación)
./backup_instancias.sh --instancia skeen # solo una
./backup_instancias.sh --verificar # + prueba de restauración real
./backup_instancias.sh --solo-db # sin respaldar configuración
./backup_instancias.sh --dry-run # simular (incluye plan de rotación)
# ---- Panel ----
./admin_instancias.py listar # tabla de instancias (por defecto)
./admin_instancias.py estado skeen
./admin_instancias.py version
./admin_instancias.py uso
./admin_instancias.py instalacion clinica2
./admin_instancias.py logs clinica2 -n 30
./admin_instancias.py credenciales skeen # solo la ruta
./admin_instancias.py credenciales skeen --mostrar # incluye passwords
./admin_instancias.py backup skeen --yes # acción que cambia estado
./admin_instancias.py --json listar # salida JSON
# ---- Estado de los timers ----
systemctl list-timers | grep skeen
journalctl -u skeen-backup.service -n 50
tail -f /var/log/skeen-backup.log
```
## Retención (en `backup.env`)
Por defecto **14 diarios / 8 semanales / 6 mensuales** (esquema abuelo-padre-hijo
con buckets exclusivos). El backup más reciente y el representante mensual nunca
se borran. Cambiar `RETAIN_*` en `backup.env` para ajustar.
## Instalación de las unidades
```bash
cp systemd/*.service systemd/*.timer /etc/systemd/system/
cp logrotate/skeen-backup /etc/logrotate.d/skeen-backup
systemctl daemon-reload
systemctl enable --now skeen-backup.timer skeen-backup-verify.timer
```
## Estructura
| Archivo | Para qué |
|---|---|
| `backup_instancias.sh` | Backup por instancia (dump + config, integridad, rotación, `--verificar`). |
| `backup.env` | Destinos, credenciales de DB, retención, log. |
| `admin_instancias.py` | Panel/CLI multi-instancia (stdlib, read-only por defecto). |
| `systemd/` | `skeen-backup*` (diario + verificación semanal) y `backup-instancia@` (por instancia). |
| `logrotate/skeen-backup` | Rotación de `/var/log/skeen-backup.log`. |

615
infra/ops/admin_instancias.py Executable file
View File

@@ -0,0 +1,615 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Panel / CLI de administración multi-instancia de SKEEN.
Descubre dinámicamente las instancias a partir de las unidades systemd
`odoo-*.service` (igual que `backup_instancias.sh` y el provisioning): de cada
unidad lee su `-c <conf>` y de ahí la base de datos, el puerto HTTP, el log y el
data_dir. No hay ninguna lista de instancias hardcodeada.
Por defecto todas las consultas son de SOLO LECTURA. Las acciones que cambian
estado (`backup`, `reiniciar`) exigen `--yes` y avisan antes de hacer nada.
Uso:
./admin_instancias.py [--json] listar
./admin_instancias.py estado <slug>
./admin_instancias.py version
./admin_instancias.py uso
./admin_instancias.py instalacion <slug>
./admin_instancias.py logs <slug> [-n N]
./admin_instancias.py credenciales <slug> [--mostrar]
./admin_instancias.py backup <slug> [--verificar] --yes
./admin_instancias.py reiniciar <slug> --yes
Sin dependencias externas (solo stdlib). Pensado para ejecutarse como root.
"""
import argparse
import glob
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from datetime import datetime
OPS_DIR = os.path.dirname(os.path.abspath(__file__))
ENV_FILE = os.path.join(OPS_DIR, 'backup.env')
BACKUP_SCRIPT = os.path.join(OPS_DIR, 'backup_instancias.sh')
DEFAULT_ENV = {
'BACKUP_ROOT': '/root/backups',
'LOCK_FILE': '/var/lock/skeen-backup.lock',
'LOG_FILE': '/var/log/skeen-backup.log',
'RETAIN_DAILY': '14',
'RETAIN_WEEKLY': '8',
'RETAIN_MONTHLY': '6',
}
KEY_TABLES = ('res_partner', 'skeen_cita', 'skeen_venta')
# ----------------------------------------------------------------- utilidades
def load_env(path=ENV_FILE):
env = dict(DEFAULT_ENV)
if os.path.isfile(path):
with open(path, encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
env[k.strip()] = v.strip()
return env
ENV = load_env()
def run(cmd, timeout=30):
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def systemctl(*args):
return run(['systemctl', *args])
def psql(db, sql):
"""Consulta por peer como superusuario local; '' si no se puede."""
if not db:
return ''
cmd = 'psql -d %s -Atc %s' % (shlex.quote(db), shlex.quote(sql))
p = run(['su', 'postgres', '-c', cmd])
return p.stdout.strip() if p.returncode == 0 else ''
def du_bytes(path):
if not path or not os.path.exists(path):
return 0
p = run(['du', '-sb', path])
try:
return int(p.stdout.split()[0])
except (ValueError, IndexError):
return 0
def human(n):
try:
n = float(n)
except (TypeError, ValueError):
return '-'
for unit in ('B', 'K', 'M', 'G', 'T'):
if n < 1024 or unit == 'T':
return ('%.0f%s' % (n, unit)) if unit == 'B' else ('%.1f%s' % (n, unit))
n /= 1024
return '-'
def listening_ports():
p = run(['ss', '-ltnH'])
return set(int(m) for m in re.findall(r':(\d+)\b', p.stdout))
def http_code(port, path='/web/login', timeout=5):
if not port:
return None
try:
req = urllib.request.Request('http://127.0.0.1:%d%s' % (int(port), path))
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
except Exception:
return None
# ------------------------------------------------------- descubrir instancias
def list_unit_names():
p = systemctl('list-unit-files', '--type=service', '--no-legend', '--no-pager')
names = []
for line in p.stdout.splitlines():
parts = line.split()
if parts and re.match(r'^odoo-.*\.service$', parts[0]):
names.append(parts[0])
return sorted(names)
def conf_get(path, key):
if not os.path.isfile(path):
return ''
try:
with open(path, encoding='utf-8', errors='replace') as fh:
inside = False
for raw in fh:
line = raw.strip()
if line.startswith('['):
inside = line == '[options]'
continue
if not inside or not line or line.startswith(('#', ';')) or '=' not in line:
continue
k, v = line.split('=', 1)
if k.strip() == key:
return v.strip()
except OSError:
return ''
return ''
def load_instance(unit):
frag = systemctl('show', unit, '-p', 'FragmentPath', '--value').stdout.strip()
if not frag or not os.path.isfile(frag):
return None
cat = systemctl('cat', unit).stdout
exec_line = ''
for line in cat.splitlines():
if line.strip().startswith('ExecStart='):
exec_line = line.strip()
m = re.search(r'\s-c\s+(\S+)', exec_line)
if not m:
return None
conf = m.group(1)
if not conf.startswith('/'):
conf = os.path.join('/root', conf)
db = conf_get(conf, 'db_name')
if not db:
return None
port = conf_get(conf, 'http_port')
log = ''
m2 = re.search(r'--logfile=(\S+)', exec_line)
if m2:
log = m2.group(1)
log = log or conf_get(conf, 'logfile') or ('/tmp/odoo-%s.log' % unit[len('odoo-'):-len('.service')])
slug = unit[len('odoo-'):-len('.service')]
return {
'slug': slug,
'unit': unit,
'unit_path': frag,
'conf': conf,
'db': db,
'port': int(port) if port.isdigit() else None,
'logfile': log,
'data_dir': conf_get(conf, 'data_dir'),
}
def discover():
out = []
for unit in list_unit_names():
inst = load_instance(unit)
if inst:
out.append(inst)
return out
def find_vhost(slug, port):
for p in ('/etc/nginx/sites-enabled/%s' % slug, '/etc/nginx/sites-available/%s' % slug):
if os.path.isfile(p):
return p
if port:
for base in ('/etc/nginx/sites-enabled', '/etc/nginx/sites-available'):
for f in glob.glob(os.path.join(base, '*')):
try:
with open(f, encoding='utf-8', errors='replace') as fh:
if '127.0.0.1:%d' % port in fh.read():
return f
except OSError:
continue
return ''
# ----------------------------------------------------------- datos de instancia
def backup_state(slug):
d = os.path.join(ENV['BACKUP_ROOT'], slug)
dumps = sorted(glob.glob(os.path.join(d, '*.dump')))
info = {'dir': d, 'count': len(dumps), 'last': None, 'last_size': 0,
'last_mtime': None, 'dir_bytes': 0, 'latest_valid': False}
if dumps:
last = max(dumps, key=os.path.getmtime)
info['last'] = os.path.basename(last)
info['last_size'] = os.path.getsize(last)
info['last_mtime'] = datetime.fromtimestamp(os.path.getmtime(last)).isoformat(timespec='seconds')
info['dir_bytes'] = du_bytes(d) if os.path.isdir(d) else 0
return info
def module_versions(db):
rows = []
out = psql(db, "select name||'|'||coalesce(latest_version,'?') from ir_module_module "
"where name like 'skeen_%' and state='installed' order by name")
for line in out.splitlines():
if '|' in line:
n, v = line.split('|', 1)
rows.append((n, v))
return rows
def db_exists(db):
return psql('postgres', "select 1 from pg_database where datname=%s" % sql_lit(db)) == '1'
def sql_lit(s):
return "'" + str(s).replace("'", "''") + "'"
def gather(inst):
slug = inst['slug']
state = systemctl('is-active', inst['unit']).stdout.strip() or 'unknown'
ports = listening_ports()
bk = backup_state(slug)
d = dict(inst)
d['service'] = state
d['listening'] = bool(inst['port'] and inst['port'] in ports)
d['http_code'] = http_code(inst['port'])
d['db_exists'] = db_exists(inst['db'])
d['modules'] = module_versions(inst['db']) if d['db_exists'] else []
d['backup'] = bk
d['data_bytes'] = du_bytes(inst['data_dir'])
d['vhost'] = find_vhost(slug, inst['port'])
d['frontend_dir'] = '/var/www/%s-frontend' % slug
return d
# ------------------------------------------------------------------- salida
def print_table(headers, rows):
if not rows:
print(' (sin datos)')
return
cols = len(headers)
widths = [len(str(h)) for h in headers]
norm = []
for r in rows:
r = ['' if c is None else str(c) for c in r]
norm.append(r)
for i in range(cols):
widths[i] = max(widths[i], len(r[i]))
def fmt(r):
return ' '.join(r[i].ljust(widths[i]) for i in range(cols))
print(fmt([str(h) for h in headers]))
print('-' * (sum(widths) + 2 * (cols - 1)))
for r in norm:
print(fmt(r))
def cmd_listar(args, insts):
rows = []
for d in insts:
bk = d['backup']
last = bk['last'] or '-'
rows.append([
d['slug'], d['unit'].replace('.service', ''), d['service'],
d['port'] or '-', d['db'],
('200' if d['http_code'] == 200 else (str(d['http_code']) if d['http_code'] else 'X')),
(len(d['modules']) if d['db_exists'] else 'X'),
(bk['last_mtime'] or '-'),
human(bk['last_size']) if bk['count'] else '-',
bk['count'],
human(bk['dir_bytes']),
human(d['data_bytes']),
('si' if d['listening'] else 'no'),
])
headers = ['slug', 'unit', 'svc', 'puerto', 'db', 'http', 'mods',
'ultimo backup', 'tam', 'n', 'backups', 'datadir', 'escucha']
if args.json_global or args.json_sub:
print(json.dumps(insts, default=str, indent=2))
return
print('Instancias SKEEN detectadas (%d):' % len(insts))
print_table(headers, rows)
print('\nRetención: %s diarios / %s semanales / %s mensuales | backups en %s'
% (ENV.get('RETAIN_DAILY'), ENV.get('RETAIN_WEEKLY'),
ENV.get('RETAIN_MONTHLY'), ENV['BACKUP_ROOT']))
return 0
def cmd_estado(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r. Detectadas: %s'
% (args.slug, ', '.join(x['slug'] for x in insts)), file=sys.stderr)
return 1
if args.json_global or args.json_sub:
print(json.dumps(d, default=str, indent=2))
return 0
bk = d['backup']
print('Instancia: %s' % d['slug'])
print('Unidad: %s (%s) -> %s' % (d['unit'], d['service'], d['unit_path']))
print('Conf: %s' % d['conf'])
print('Base datos: %s%s' % (d['db'], '' if d['db_exists'] else ' [NO EXISTE]'))
print('Puerto HTTP: %s escuchando=%s' % (d['port'], 'si' if d['listening'] else 'no'))
print('HTTP /web/login: %s' % (d['http_code'] if d['http_code'] else 'sin respuesta'))
print('Log Odoo: %s' % d['logfile'])
print('data_dir: %s (%s)' % (d['data_dir'] or '-', human(d['data_bytes'])))
print('Vhost nginx: %s' % (d['vhost'] or '-'))
print('Frontend: %s%s' % (d['frontend_dir'],
'' if os.path.isdir(d['frontend_dir']) else ' [falta]'))
print('Módulos skeen instalados: %d' % len(d['modules']))
for n, v in d['modules']:
print(' %-22s %s' % (n, v))
print('Backups: %d archivos, %s en %s' % (bk['count'], human(bk['dir_bytes']), bk['dir']))
print('Último: %s (%s, %s)' % (bk['last'] or '-', human(bk['last_size']), bk['last_mtime'] or '-'))
return 0
def cmd_version(args, insts):
slugs = [d['slug'] for d in insts]
data = {d['slug']: dict(d['modules']) for d in insts}
if args.json_global or args.json_sub:
print(json.dumps(data, indent=2))
return 0
print('Versiones de módulos skeen_* instalados por instancia:\n')
names = sorted({n for d in insts for n, _ in d['modules']})
rows = []
for n in names:
row = [n]
for s in slugs:
row.append(data[s].get(n, '-'))
rows.append(row)
print_table(['módulo'] + slugs, rows)
return 0
def cmd_uso(args, insts):
disk = shutil.disk_usage('/')
mem = {}
try:
with open('/proc/meminfo') as fh:
for line in fh:
k, v = line.split(':')
mem[k] = int(v.strip().split()[0]) * 1024
except OSError:
pass
load = os.getloadavg()
ncpu = os.cpu_count() or 1
procs = []
p = run(['ps', '-eo', 'pid,pcpu,pmem,rss,etime,args', '--sort=-rss'])
for line in p.stdout.splitlines()[1:]:
if 'odoo-bin' in line and 'grep' not in line:
pid, pcpu, pmem, rss, etime, rest = line.split(None, 5)
m = re.search(r'-c\s+(\S+)', rest)
procs.append({
'pid': int(pid), 'cpu': float(pcpu), 'mem': float(pmem),
'rss': int(rss) * 1024, 'uptime': etime,
'conf': m.group(1) if m else '?',
})
data = {
'host': os.uname().nodename,
'disk': {'total': disk.total, 'used': disk.used, 'free': disk.free,
'used_pct': round(disk.used * 100.0 / disk.total, 1)},
'mem': {'total': mem.get('MemTotal'), 'available': mem.get('MemAvailable'),
'used': (mem.get('MemTotal', 0) - mem.get('MemAvailable', 0))},
'load': {'1m': load[0], '5m': load[1], '15m': load[2], 'ncpu': ncpu},
'procesos_odoo': procs,
}
if args.json_global or args.json_sub:
print(json.dumps(data, indent=2))
return 0
print('Host: %s (CPU: %d núcleos)' % (data['host'], ncpu))
print('Disco /: %s usados de %s (%.1f%%), libres %s'
% (human(disk.used), human(disk.total), data['disk']['used_pct'], human(disk.free)))
print('RAM: %s usados de %s, disponibles %s'
% (human(data['mem']['used']), human(data['mem']['total']), human(data['mem']['available'])))
print('Carga: %.2f %.2f %.2f (1m/5m/15m)'
% (load[0], load[1], load[2]))
print('\nProcesos Odoo (por RSS):')
print_table(['pid', 'cpu%', 'mem%', 'rss', 'uptime', 'conf'],
[[p['pid'], '%.1f' % p['cpu'], '%.1f' % p['mem'],
human(p['rss']), p['uptime'], p['conf']] for p in procs])
return 0
def cmd_instalacion(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r' % args.slug, file=sys.stderr)
return 1
frontend_ok = os.path.isfile(os.path.join(d['frontend_dir'], 'index.html'))
brand_dirs = glob.glob(os.path.join(d['frontend_dir'], 'brand', '*')) + \
glob.glob(os.path.join(d['frontend_dir'], 'skeen-brand', '*'))
brand_param = psql(d['db'], "select value from ir_config_parameter where key='skeen.clinic.brand'") if d['db_exists'] else ''
checks = [
('DB existe', d['db_exists'], d['db']),
('Unit activa', d['service'] == 'active', '%s = %s' % (d['unit'], d['service'])),
('Conf Odoo', os.path.isfile(d['conf']), d['conf']),
('Vhost nginx', bool(d['vhost']), d['vhost'] or 'no encontrado'),
('Puerto escuchando', d['listening'], str(d['port'])),
('HTTP /web/login', d['http_code'] == 200, str(d['http_code'] or 'sin respuesta')),
('Frontend desplegado', frontend_ok, d['frontend_dir']),
('Marca (archivos)', bool(brand_dirs), '%d archivos' % len(brand_dirs)),
('Marca (param DB)', bool(brand_param), (brand_param[:60] + '...') if len(brand_param) > 60 else brand_param),
]
if args.json_global or args.json_sub:
print(json.dumps({'slug': d['slug'],
'checks': [{'check': c, 'ok': ok, 'detalle': det} for c, ok, det in checks],
'completa': all(ok for _, ok, _ in checks)}, indent=2))
return 0
print('Validación de instalación — %s\n' % d['slug'])
rows = [[('OK' if ok else 'FALTA'), c, det] for c, ok, det in checks]
print_table(['estado', 'check', 'detalle'], rows)
faltan = [c for c, ok, _ in checks if not ok]
print('\n%s' % ('Instalación COMPLETA.' if not faltan
else 'Falta: ' + ', '.join(faltan)))
return 0 if not faltan else 1
def cmd_logs(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r' % args.slug, file=sys.stderr)
return 1
n = args.n
def tail(path, label):
print('\n=== %s (%s) — últimas %d líneas ===' % (label, path, n))
if not os.path.isfile(path):
print(' (no existe)')
return []
p = run(['tail', '-n', str(n), path])
print(p.stdout.rstrip() or ' (vacío)')
return p.stdout.rstrip().splitlines()
if args.json_global or args.json_sub:
data = {}
for key, path in (('odoo', d['logfile']), ('backup', ENV.get('LOG_FILE'))):
data[key] = run(['tail', '-n', str(n), path]).stdout.rstrip().splitlines() \
if os.path.isfile(path) else []
print(json.dumps(data, indent=2))
return 0
tail(d['logfile'], 'Log Odoo')
tail(ENV.get('LOG_FILE'), 'Log de backups')
return 0
def cmd_credenciales(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r' % args.slug, file=sys.stderr)
return 1
pats = [os.path.join(ENV['BACKUP_ROOT'], '%s-credenciales-*.txt' % args.slug)]
files = sorted(f for p in pats for f in glob.glob(p))
if args.json_global or args.json_sub:
print(json.dumps({'slug': args.slug, 'archivos': files,
'contenido': [open(f).read() for f in files] if args.mostrar else None},
indent=2))
return 0
print('Ruta de credenciales de %s (generadas por el provisioning):' % args.slug)
if not files:
print(' %s/<slug>-credenciales-<fecha>.txt -> no encontrado' % ENV['BACKUP_ROOT'])
print(' El provisioning las guarda en %s con chmod 600.' % ENV['BACKUP_ROOT'])
print(' Para volver a generarlas: infra/provision/provision_clinica.py (o revisar el alta manual).')
return 0
for f in files:
print(' %s (%s, %s)' % (f, human(os.path.getsize(f)),
datetime.fromtimestamp(os.path.getmtime(f)).isoformat(timespec='seconds')))
if not args.mostrar:
print('\n(usa --mostrar para imprimir el contenido; nunca se muestran passwords sin ese flag)')
return 0
for f in files:
print('\n----- %s -----' % f)
print(open(f, encoding='utf-8', errors='replace').read().rstrip())
return 0
# ------------------------------------------------------------- acciones (mutan)
def lock_is_busy():
import fcntl
try:
fh = open(ENV['LOCK_FILE'], 'a+')
except OSError:
return False
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(fh, fcntl.LOCK_UN)
return False
except OSError:
return True
finally:
fh.close()
def cmd_backup(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r' % args.slug, file=sys.stderr)
return 1
if not args.yes:
print('Acción con CAMBIO DE ESTADO: se hará un backup de %s (db %s).' % (d['slug'], d['db']))
print('Repite con --yes para confirmar.')
return 2
if lock_is_busy():
print('ERROR: hay otro backup en curso (lock %s). Espera y reintenta.' % ENV['LOCK_FILE'],
file=sys.stderr)
return 3
cmd = [BACKUP_SCRIPT, '--instancia', d['slug']]
if args.verificar:
cmd.append('--verificar')
print('Ejecutando: %s' % ' '.join(cmd))
return subprocess.call(cmd)
def cmd_reiniciar(args, insts):
d = next((x for x in insts if x['slug'] == args.slug), None)
if not d:
print('ERROR: no existe la instancia %r' % args.slug, file=sys.stderr)
return 1
if not args.yes:
print('Acción con CAMBIO DE ESTADO: reiniciar %s (%s).' % (d['unit'], d['slug']))
print('ADVERTENCIA: reiniciar INTERRUMPE el servicio mientras arranca.')
print('Repite con --yes para confirmar.')
return 2
if lock_is_busy():
print('ERROR: hay un backup en curso (lock %s); no se reinicia.' % ENV['LOCK_FILE'],
file=sys.stderr)
return 3
print('ADVERTENCIA: reiniciar %s interrumpe el servicio de %s.' % (d['unit'], d['slug']))
print('Ejecutando: systemctl restart %s' % d['unit'])
return subprocess.call(['systemctl', 'restart', d['unit']])
# --------------------------------------------------------------------- main
def build_parser():
p = argparse.ArgumentParser(description='Panel/CLI multi-instancia SKEEN (read-only por defecto).')
p.add_argument('--json', action='store_true', dest='json_global', help='salida JSON')
sub = p.add_subparsers(dest='cmd')
def add(name, fn, **kw):
sp = sub.add_parser(name, **kw)
sp.add_argument('--json', action='store_true', dest='json_sub', help=argparse.SUPPRESS)
sp.set_defaults(func=fn)
return sp
add('listar', cmd_listar, help='tabla de instancias (default)')
sp = add('estado', cmd_estado, help='detalle de una instancia'); sp.add_argument('slug')
add('version', cmd_version, help='versiones de módulos skeen_*')
add('uso', cmd_uso, help='disco/RAM/CPU del host y procesos Odoo')
sp = add('instalacion', cmd_instalacion, help='valida que una instancia esté completa'); sp.add_argument('slug')
sp = add('logs', cmd_logs, help='tail de logs de una instancia'); sp.add_argument('slug')
sp.add_argument('-n', type=int, default=20, help='número de líneas (default 20)')
sp = add('credenciales', cmd_credenciales, help='ruta de credenciales')
sp.add_argument('slug'); sp.add_argument('--mostrar', action='store_true',
help='imprime el contenido (incluye passwords)')
sp = add('backup', cmd_backup, help='lanza un backup (requiere --yes)')
sp.add_argument('slug'); sp.add_argument('--verificar', action='store_true')
sp.add_argument('--yes', action='store_true')
sp = add('reiniciar', cmd_reiniciar, help='reinicia la instancia (requiere --yes)')
sp.add_argument('slug'); sp.add_argument('--yes', action='store_true')
p.set_defaults(func=cmd_listar)
return p
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
for attr in ('json_sub',):
if not hasattr(args, attr):
setattr(args, attr, False)
insts = discover()
if not insts:
print('ERROR: no se detectó ninguna instancia (unidades odoo-*.service).', file=sys.stderr)
return 1
insts = [gather(i) for i in insts]
return args.func(args, insts)
if __name__ == '__main__':
sys.exit(main())

42
infra/ops/backup.env Normal file
View File

@@ -0,0 +1,42 @@
# Configuración central de backups SKEEN.
# La lee backup_instancias.sh (con `source`) y admin_instancias.py (como clave=valor).
# Editar aquí y no en el script. Formato estricto: CLAVE=valor, sin espacios.
# --- Destinos ---
BACKUP_ROOT=/root/backups
LOG_FILE=/var/log/skeen-backup.log
LOCK_FILE=/var/lock/skeen-backup.lock
# --- Conexión PostgreSQL ---
# Por defecto se usa el rol compartido de Odoo sobre TCP (localhost).
DB_HOST=localhost
DB_PORT=5432
DB_USER=skeen
DB_PASSWORD=skeen_dev_2026
# Alternativa sin password: dejar DB_PASSWORD vacío y exportar PG_OS_USER=postgres
# (usa autenticación peer local: `su postgres -c pg_dump`).
PG_OS_USER=postgres
# --- Verificación (--verificar) ---
# Restaura el dump a una DB temporal y compara conteos. Requiere espacio libre
# igual a este factor x tamaño de la base en el filesystem de PostgreSQL.
VERIFY_MIN_FREE_FACTOR=2
# --- Retención (esquema abuelo-padre-hijo, buckets exclusivos) ---
# Se conserva el más reciente de cada mes (hasta RETAIN_MONTHLY),
# luego el más reciente de cada semana ISO (hasta RETAIN_WEEKLY),
# luego el más reciente de cada día (hasta RETAIN_DAILY).
# El backup más reciente NUNCA se borra (último bueno).
RETAIN_DAILY=14
RETAIN_WEEKLY=8
RETAIN_MONTHLY=6
# --- Log ---
# Además de logrotate, el script trunca el log si supera este tamaño (bytes).
LOG_MAX_BYTES=5242880
# --- Componentes opcionales (Docker, fuera del backup por instancia) ---
# Gestionados aparte; se documentan para el panel. 1 = respaldar (no implementado
# aquí), 0 = no. Supabase y WACRM self-hosted viven en Docker.
SUPABASE_BACKUP=0
WACRM_BACKUP=0

523
infra/ops/backup_instancias.sh Executable file
View File

@@ -0,0 +1,523 @@
#!/usr/bin/env bash
# -*- coding: utf-8 -*-
# backup_instancias.sh — Backups automáticos y verificados de instancias SKEEN.
#
# Descubre dinámicamente las instancias a partir de las unidades systemd
# `odoo-*.service`, lee de cada una su `-c <conf>` (ExecStart) y de ahí la
# base de datos, el puerto HTTP, el logfile y el data_dir. Para cada instancia:
#
# 1. pg_dump -Fc a /tmp y lo mueve a <BACKUP_ROOT>/<slug>/
# (<db>_<YYYYmmdd_HHMMSS>.dump, chmod 600 root:root).
# 2. Verifica integridad con `pg_restore --list`; si falla, borra y reporta.
# 3. Respalda la configuración del host en un tar.gz pequeño (odoo.conf, unit
# systemd, vhost nginx y directorios de marca del frontend).
# 4. (--verificar) Restaura a una DB temporal <db>_verify_<fecha>, compara
# conteos de tablas clave y la borra.
# 5. Rota según la política de retención (14 diarios / 8 semanales / 6
# mensuales), sin borrar el mensual vigente ni el último backup bueno.
#
# Uso:
# backup_instancias.sh [--instancia <slug>] [--solo-db] [--sin-config]
# [--verificar] [--dry-run] [--backup-root <dir>]
#
# Exit code: 0 si todo OK; 1 si alguna instancia falló; 3 si ya había otro
# backup en curso (lockfile).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SKEEN_BACKUP_ENV:-$SCRIPT_DIR/backup.env}"
# ------------------------------------------------------------- valores por defecto
BACKUP_ROOT=/root/backups
LOG_FILE=/var/log/skeen-backup.log
LOCK_FILE=/var/lock/skeen-backup.lock
DB_HOST=localhost
DB_PORT=5432
DB_USER=skeen
DB_PASSWORD=
PG_OS_USER=postgres
VERIFY_MIN_FREE_FACTOR=2
RETAIN_DAILY=14
RETAIN_WEEKLY=8
RETAIN_MONTHLY=6
LOG_MAX_BYTES=5242880
SUPABASE_BACKUP=0
WACRM_BACKUP=0
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
# ---------------------------------------------------------------- argumentos
INSTANCIA=""
SOLO_DB=0
SIN_CONFIG=0
VERIFICAR=0
DRY_RUN=0
usage() {
sed -n '3,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
while (($#)); do
case "$1" in
--instancia) INSTANCIA="${2:-}"; shift 2 ;;
--solo-db) SOLO_DB=1; SIN_CONFIG=1; shift ;;
--sin-config) SIN_CONFIG=1; shift ;;
--verificar) VERIFICAR=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--backup-root) BACKUP_ROOT="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Opción desconocida: $1" >&2; exit 2 ;;
esac
done
STAMP="$(date +%Y%m%d_%H%M%S)"
# ------------------------------------------------------------------- logging
_log_raw() { printf '%s\n' "$*" >>"$LOG_FILE"; }
log() { local m="[$(date '+%F %T')] $*"; _log_raw "$m"; printf '%s\n' "$m"; }
log_warn(){ local m="[$(date '+%F %T')] WARN $*"; _log_raw "$m"; printf '%s\n' "$m" >&2; }
log_err() { local m="[$(date '+%F %T')] ERROR $*"; _log_raw "$m"; printf '%s\n' "$m" >&2; }
log_ok() { local m="[$(date '+%F %T')] OK $*"; _log_raw "$m"; printf '%s\n' "$m"; }
log_truncate_if_needed() {
[[ -f "$LOG_FILE" ]] || return 0
local size
size=$(stat -c %s "$LOG_FILE" 2>/dev/null || echo 0)
if (( size > LOG_MAX_BYTES )); then
mv -f "$LOG_FILE" "${LOG_FILE}.1"
: >"$LOG_FILE"
log_warn "log truncado por tamaño (>${LOG_MAX_BYTES} bytes); anterior en ${LOG_FILE}.1"
fi
}
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
log_truncate_if_needed
MODE=""; (( DRY_RUN )) && MODE=", dry-run"
log "=== Inicio de backup (stamp=$STAMP${INSTANCIA:+, instancia=$INSTANCIA}$MODE) ==="
# --------------------------------------------------------------------- lock
if ! exec 9>"$LOCK_FILE"; then
log_err "no se pudo abrir el lock $LOCK_FILE"
exit 1
fi
if ! flock -n 9; then
log_err "ya hay otro backup en curso (lock $LOCK_FILE). Saliendo sin hacer nada."
exit 3
fi
log "lock adquirido ($LOCK_FILE)"
# ------------------------------------------------------------- utilidades
# Lee una clave de un odoo.conf (sección [options], formato `clave = valor`).
conf_get() {
local file="$1" key="$2"
[[ -f "$file" ]] || return 1
awk -v k="$key" '
/^[[:space:]]*\[/ { next }
/^[[:space:]]*[#;]/ { next }
{
line=$0
sub(/^[[:space:]]+/, "", line)
eq=index(line, "=")
if (eq>0) {
name=substr(line,1,eq-1); sub(/[[:space:]]+$/, "", name)
if (name==k) { v=substr(line, eq+1); sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit }
}
}' "$file"
}
human_bytes() {
numfmt --to=iec --suffix=B "${1:-0}" 2>/dev/null || echo "${1:-0}B"
}
# Lista las unidades odoo-*.service conocidas por systemd.
list_units() {
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null \
| awk '$1 ~ /^odoo-.*\.service$/ { print $1 }'
}
# Devuelve el vhost nginx de una instancia: por slug o por puerto.
find_vhost() {
local slug="$1" port="$2" f
for f in "/etc/nginx/sites-enabled/$slug" "/etc/nginx/sites-available/$slug"; do
[[ -f "$f" ]] && { echo "$f"; return 0; }
done
f=$(grep -rlE "127\.0\.0\.1:${port}\b" /etc/nginx/sites-enabled/ /etc/nginx/sites-available/ 2>/dev/null | head -1 || true)
[[ -n "$f" ]] && { echo "$f"; return 0; }
return 1
}
# ------------------------------------------------ estructura de una instancia
# Rellena INST_* para la unidad dada. Devuelve 1 si la unidad no es válida.
load_instance() {
local unit="$1"
local fragment exec_line
fragment=$(systemctl show "$unit" -p FragmentPath --value 2>/dev/null || true)
[[ -z "$fragment" || ! -f "$fragment" ]] && return 1
exec_line=$(systemctl cat "$unit" 2>/dev/null | grep -E '^[[:space:]]*ExecStart=' | tail -1 || true)
[[ -z "$exec_line" ]] && return 1
INST_SLUG="${unit#odoo-}"
INST_SLUG="${INST_SLUG%.service}"
INST_UNIT="$unit"
INST_UNIT_PATH="$fragment"
INST_CONF="$(printf '%s' "$exec_line" | sed -nE 's/.*[[:space:]]-c[[:space:]]+([^[:space:]]+).*/\1/p')"
[[ -z "$INST_CONF" ]] && return 1
[[ "$INST_CONF" != /* ]] && INST_CONF="/root/$INST_CONF"
INST_DB="$(conf_get "$INST_CONF" db_name || true)"
[[ -z "$INST_DB" ]] && return 1
INST_PORT="$(conf_get "$INST_CONF" http_port || true)"
INST_DATA_DIR="$(conf_get "$INST_CONF" data_dir || true)"
INST_DB_HOST="$(conf_get "$INST_CONF" db_host || true)"
INST_DB_PORT="$(conf_get "$INST_CONF" db_port || true)"
INST_DB_USER="$(conf_get "$INST_CONF" db_user || true)"
INST_LOG="$(printf '%s' "$exec_line" | sed -nE 's/.*--logfile=([^[:space:]]+).*/\1/p')"
if [[ -z "$INST_LOG" ]]; then
INST_LOG="$(conf_get "$INST_CONF" logfile || true)"
fi
[[ -z "$INST_LOG" ]] && INST_LOG="/tmp/odoo-${INST_SLUG}.log"
return 0
}
# ------------------------------------------------------------- pg_dump
dump_db() {
local db="$1" out="$2"
if [[ -n "$DB_PASSWORD" ]]; then
PGPASSWORD="$DB_PASSWORD" pg_dump -Fc -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$db" -f "$out"
else
su "$PG_OS_USER" -c "pg_dump -Fc -d $(printf %q "$db") -f $(printf %q "$out")"
fi
}
pg_restore_file() {
# Restaura por peer como superusuario local (el dump debe ser legible por
# el usuario PG_OS_USER). Usado por la prueba de restauración.
local db="$1" dump="$2"
su "$PG_OS_USER" -c "pg_restore --no-owner --no-privileges -d $(printf %q "$db") $(printf %q "$dump")"
}
# Ejecuta SQL (tuples only) contra postgres por peer (superusuario local).
psql_su() {
local db="$1" sql="$2"
su "$PG_OS_USER" -c "psql -d $(printf %q "$db") -Atc $(printf %q "$sql")" 2>/dev/null
}
# ------------------------------------------------- respaldo de configuración
backup_config() {
local slug="$1" unit="$2" conf="$3" vhost="$4" data_dir="$5" db="$6" port="$7" out="$8"
local stage
stage="$(mktemp -d /tmp/skeen-cfg.XXXXXX)"
local -a seen=()
saw_dir() { local d; for d in "${seen[@]:-}"; do [[ "$d" == "$1" ]] && return 0; done; return 1; }
mkdir -p "$stage/systemd" "$stage/nginx" "$stage/frontend"
[[ -f "$conf" ]] && cp -a "$conf" "$stage/odoo.conf"
[[ -f "$unit" ]] && cp -a "$unit" "$stage/systemd/"
[[ -n "$vhost" && -f "$vhost" ]] && cp -a "$vhost" "$stage/nginx/"
local d base
for d in "/var/www/${slug}-frontend/brand" \
"/var/www/${slug}-frontend/skeen-brand" \
"/var/www/skeen-frontend/brand" \
"/var/www/skeen-frontend/skeen-brand"; do
[[ -d "$d" ]] || continue
saw_dir "$d" && continue
seen+=("$d")
base="$(basename "$(dirname "$d")")-$(basename "$d")"
cp -a "$d" "$stage/frontend/$base"
done
{
echo "Instancia: $slug"
echo "Unidad: $unit"
echo "Conf: $conf"
echo "Base: $db"
echo "Puerto: $port"
echo "data_dir: $data_dir"
echo "Vhost: ${vhost:-<ninguno>}"
echo "Generado: $(date '+%F %T')"
echo "Host: $(hostname)"
echo "Contenido: odoo.conf, systemd/<unit>, nginx/<vhost>, frontend/<marca>"
} >"$stage/MANIFEST.txt"
if (( DRY_RUN )); then
log " [dry-run] escribiría config: $out"
rm -rf "$stage"
return 0
fi
tar czf "$out" -C "$stage" .
rm -rf "$stage"
chmod 600 "$out"
chown root:root "$out" 2>/dev/null || true
}
# --------------------------------------------------------------- rotación
# Plan de rotación GFS. Imprime las rutas a borrar (una por línea), sin borrar.
rotation_plan() {
local dir="$1"
local -a stamps=()
local f st
shopt -s nullglob
for f in "$dir"/*.dump; do
if [[ "$f" =~ _([0-9]{8}_[0-9]{6})\.dump$ ]]; then
stamps+=("${BASH_REMATCH[1]}")
fi
done
shopt -u nullglob
((${#stamps[@]})) || return 0
local sorted
sorted="$(printf '%s\n' "${stamps[@]}" | sort -r)"
declare -A keep=() month_rep=() week_rep=() day_rep=()
local mcount=0 wcount=0 dcount=0
local first=1 newest=""
for st in $sorted; do
local d="${st:0:8}" month="${st:0:6}" iso day
iso="$(date -d "${d:0:4}-${d:4:2}-${d:6:2}" +%G-W%V 2>/dev/null || echo "x")"
day="$d"
if (( first )); then newest="$st"; keep["$st"]="ultimo"; first=0; fi
if [[ -z "${month_rep[$month]:-}" && $mcount -lt $RETAIN_MONTHLY ]]; then
month_rep[$month]="$st"; keep["$st"]="mensual"; mcount=$((mcount+1)); continue
fi
if [[ -z "${week_rep[$iso]:-}" && $wcount -lt $RETAIN_WEEKLY ]]; then
week_rep[$iso]="$st"; keep["$st"]="semanal"; wcount=$((wcount+1)); continue
fi
if [[ -z "${day_rep[$day]:-}" && $dcount -lt $RETAIN_DAILY ]]; then
day_rep[$day]="$st"; keep["$st"]="diario"; dcount=$((dcount+1)); continue
fi
done
for st in $sorted; do
[[ -n "${keep[$st]:-}" ]] && continue
# borra el dump y su config tar gemelo (si existe)
local dfile
dfile=$(ls "$dir"/*_"$st".dump 2>/dev/null | head -1 || true)
[[ -n "$dfile" ]] && echo "$dfile"
local cfile
cfile=$(ls "$dir"/*_"$st".config.tar.gz 2>/dev/null | head -1 || true)
[[ -n "$cfile" ]] && echo "$cfile"
done
}
rotate() {
local dir="$1" slug="$2"
local -a to_delete=()
local line
while IFS= read -r line; do
[[ -n "$line" ]] && to_delete+=("$line")
done < <(rotation_plan "$dir")
if ((${#to_delete[@]} == 0)); then
log " rotación: nada que borrar en $dir"
return 0
fi
local f
for f in "${to_delete[@]}"; do
if (( DRY_RUN )); then
log " rotación [dry-run] borraría $(basename "$f")"
else
rm -f "$f"
log " rotación: borrado $(basename "$f")"
fi
done
local ndumps ncfgs
ndumps=$(ls "$dir"/*.dump 2>/dev/null | wc -l)
ncfgs=$(ls "$dir"/*.config.tar.gz 2>/dev/null | wc -l)
log " rotación: quedan $ndumps dumps y $ncfgs configs en $dir"
}
# --------------------------------------------------------- restaurar y verificar
verify_restore() {
local slug="$1" db="$2" dump="$3"
local tmpdb="${db}_verify_${STAMP}"
tmpdb="${tmpdb:0:63}"
# espacio libre en el filesystem de PostgreSQL
local pgdata free_kb dbsize_kb
pgdata="$(psql_su postgres 'show data_directory' | head -1)"
dbsize_kb="$(psql_su postgres "select pg_database_size('$db')/1024" | head -1)"
free_kb="$(df -Pk "${pgdata:-/var/lib/postgresql}" | awk 'NR==2{print $4}')"
if [[ -n "$dbsize_kb" && -n "$free_kb" ]] && (( free_kb < dbsize_kb * VERIFY_MIN_FREE_FACTOR )); then
log_warn " verificación OMITIDA: espacio libre insuficiente ($(human_bytes $((free_kb*1024))) < ${VERIFY_MIN_FREE_FACTOR}x base ($(human_bytes $((dbsize_kb*1024)))))"
return 0
fi
log " verificación: restaurando $dump a la DB temporal $tmpdb"
if (( DRY_RUN )); then
log " [dry-run] createdb $tmpdb; pg_restore; conteos; dropdb $tmpdb"
return 0
fi
if ! su "$PG_OS_USER" -c "createdb -T template0 $(printf %q "$tmpdb")" 2>>"$LOG_FILE"; then
log_err " verificación: no se pudo crear $tmpdb"
return 1
fi
# El dump es root:600. Se copia a un temporal legible por el superusuario
# postgres y se restaura por peer (evita passwords y problemas de permisos
# con extensiones que requieren superusuario).
local tmpd
tmpd="$(mktemp /tmp/skeen-vrf.XXXXXX)"
cp -a "$dump" "$tmpd"
chmod 640 "$tmpd"
chown "${PG_OS_USER}:${PG_OS_USER}" "$tmpd"
local restore_rc=0
pg_restore_file "$tmpdb" "$tmpd" >>"$LOG_FILE" 2>&1 || restore_rc=$?
rm -f "$tmpd"
# pg_restore puede devolver !=0 por avisos benignos (comentarios/acl); la
# prueba real es la comparación de conteos. Se registra pero no aborta.
(( restore_rc == 0 )) || log_warn " verificación: pg_restore devolvió $restore_rc (ver $LOG_FILE); se valida por conteos"
local tables=(res_partner skeen_cita skeen_venta)
local t orig rest status rc=0
log " verificación: comparación de conteos"
for t in "${tables[@]}"; do
if [[ -z "$(psql_su "$db" "select to_regclass('public.$t')")" ]]; then
log " $t: (no existe en $db) — omitida"
continue
fi
orig="$(psql_su "$db" "select count(*) from $t" | head -1)"
rest="$(psql_su "$tmpdb" "select count(*) from $t" | head -1)"
if [[ "$orig" == "$rest" ]]; then status="OK"; else status="DIF"; rc=1; fi
log " $t: original=$orig restaurado=$rest [$status]"
done
log " verificación: borrando DB temporal $tmpdb"
su "$PG_OS_USER" -c "dropdb --if-exists $(printf %q "$tmpdb")" >/dev/null 2>&1 || log_warn " no se pudo borrar $tmpdb"
return $rc
}
# --------------------------------------------------------------- instancia
process_instance() {
local unit="$1"
load_instance "$unit" || { log_warn "no se pudo interpretar la unidad $unit; omitida"; return 1; }
local slug="$INST_SLUG" db="$INST_DB" port="$INST_PORT"
local dir="$BACKUP_ROOT/$slug"
local dump_final="$dir/${db}_${STAMP}.dump"
local cfg_final="$dir/${db}_${STAMP}.config.tar.gz"
local dump_tmp="/tmp/${db}_${STAMP}.dump"
log "--- instancia $slug (unit=$unit, db=$db, puerto=$port, log=$INST_LOG) ---"
if (( DRY_RUN )); then
log " [dry-run] destino: $dir/"
log " [dry-run] pg_dump -Fc -> $dump_final"
else
mkdir -p "$dir"
chmod 700 "$dir" 2>/dev/null || true
fi
# 1-2. dump + verificación de integridad
local t0 t1 dur size
t0=$(date +%s)
local dump_ok=0
if (( DRY_RUN )); then
log " [dry-run] dump de $db a $dump_tmp (sin ejecutar)"
dump_ok=1
else
if dump_db "$db" "$dump_tmp" 2>>"$LOG_FILE"; then
if pg_restore --list "$dump_tmp" >/dev/null 2>>"$LOG_FILE"; then
mv -f "$dump_tmp" "$dump_final"
chmod 600 "$dump_final"; chown root:root "$dump_final" 2>/dev/null || true
dump_ok=1
else
log_err " dump CORRUPTO ($db): falló pg_restore --list; se borra"
rm -f "$dump_tmp"
fi
else
log_err " falló pg_dump de $db (ver $LOG_FILE)"
rm -f "$dump_tmp"
fi
fi
t1=$(date +%s); dur=$((t1 - t0))
if (( dump_ok )); then
if (( DRY_RUN )); then
log " [dry-run] dump simulado (no se ejecutó pg_dump)"
else
size=$(stat -c %s "$dump_final" 2>/dev/null || echo 0)
log_ok " dump OK: $dump_final ($(human_bytes "$size"), ${dur}s)"
fi
fi
# 3. configuración
local cfg_ok=1
if (( SIN_CONFIG )); then
log " config: omitida (--solo-db/--sin-config)"
else
local vhost
vhost="$(find_vhost "$slug" "$port" || true)"
if backup_config "$slug" "$INST_UNIT_PATH" "$INST_CONF" "$vhost" "$INST_DATA_DIR" "$db" "$port" "$cfg_final"; then
(( DRY_RUN )) || log_ok " config OK: $cfg_final ($(human_bytes "$(stat -c %s "$cfg_final" 2>/dev/null || echo 0)"))"
else
cfg_ok=0; log_err " falló el respaldo de configuración"
fi
fi
# 4. verificación con restauración
local verify_ok=1
if (( VERIFICAR )); then
if (( dump_ok )); then
verify_restore "$slug" "$db" "$dump_final" || verify_ok=0
else
verify_ok=0; log_err " verificación omitida: no hay dump válido"
fi
fi
# 5. rotación
if (( DRY_RUN )); then
rotate "$dir" "$slug"
else
rotate "$dir" "$slug"
fi
(( dump_ok && cfg_ok && verify_ok ))
}
# ------------------------------------------------------------------- main
log "retención efectiva: ${RETAIN_DAILY} diarios / ${RETAIN_WEEKLY} semanales / ${RETAIN_MONTHLY} mensuales"
declare -a RESULTS=()
TOTAL_FAIL=0
log "unidades detectadas: $(list_units | tr '\n' ' ')"
FOUND=0
while IFS= read -r unit; do
[[ -n "$unit" ]] || continue
if [[ -n "$INSTANCIA" ]]; then
[[ "$unit" == "odoo-${INSTANCIA}.service" ]] || continue
fi
FOUND=1
if process_instance "$unit"; then
RESULTS+=("OK $unit")
else
RESULTS+=("FALLO $unit"); TOTAL_FAIL=$((TOTAL_FAIL + 1))
fi
done < <(list_units)
if (( ! FOUND )); then
if [[ -n "$INSTANCIA" ]]; then
log_err "no se encontró la unidad odoo-${INSTANCIA}.service"
else
log_err "no se detectó ninguna instancia (unidades odoo-*.service)"
fi
TOTAL_FAIL=$((TOTAL_FAIL + 1))
fi
log "---------------- RESUMEN ----------------"
for r in "${RESULTS[@]:-}"; do [[ -n "$r" ]] && log " $r"; done
if (( TOTAL_FAIL == 0 )); then
log_ok "ESTADO GLOBAL: OK (${#RESULTS[@]} instancia(s)); backup $STAMP completado"
exit 0
else
log_err "ESTADO GLOBAL: FALLO ($TOTAL_FAIL instancia(s) con error); backup $STAMP"
exit 1
fi

View File

@@ -0,0 +1,12 @@
# Rotación del log de backups SKEEN. Instalado en /etc/logrotate.d/skeen-backup.
/var/log/skeen-backup.log {
su root root
weekly
rotate 8
missingok
notifempty
compress
delaycompress
copytruncate
create 0640 root root
}

View File

@@ -0,0 +1,17 @@
[Unit]
Description=SKEEN — backup de la instancia %i (unit odoo-%i.service)
Documentation=file:/root/skeen-proyecto/docs/producto/operacion-instancia.md
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=oneshot
User=root
# Plantilla opcional para programar una instancia concreta:
# systemctl enable --now backup-instancia@clinica2.timer
ExecStart=/root/skeen-proyecto/infra/ops/backup_instancias.sh --instancia %i
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,12 @@
[Unit]
Description=SKEEN — backup diario de una instancia (%i)
[Timer]
OnCalendar=*-*-* 03:40:00
RandomizedDelaySec=1800
AccuracySec=1min
Persistent=true
Unit=backup-instancia@%i.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,16 @@
[Unit]
Description=SKEEN — backup semanal CON prueba de restauración
Documentation=file:/root/skeen-proyecto/docs/producto/operacion-instancia.md
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=oneshot
User=root
# --verificar restaura a una DB temporal y compara conteos (requiere espacio).
ExecStart=/root/skeen-proyecto/infra/ops/backup_instancias.sh --verificar
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,13 @@
[Unit]
Description=SKEEN — verificación semanal de backups (restauración real)
Documentation=file:/root/skeen-proyecto/docs/producto/operacion-instancia.md
[Timer]
OnCalendar=Sun *-*-* 04:40:00
RandomizedDelaySec=1800
AccuracySec=1min
Persistent=true
Unit=skeen-backup-verify.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,17 @@
[Unit]
Description=SKEEN — backup automático de todas las instancias Odoo
Documentation=file:/root/skeen-proyecto/docs/producto/operacion-instancia.md
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=oneshot
User=root
# Descubre instancias por systemd (odoo-*.service); no hardcodea la lista.
ExecStart=/root/skeen-proyecto/infra/ops/backup_instancias.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
# El script ya registra en /var/log/skeen-backup.log; journal sólo como eco.
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,14 @@
[Unit]
Description=SKEEN — backup diario de instancias (~03:20)
Documentation=file:/root/skeen-proyecto/docs/producto/operacion-instancia.md
[Timer]
OnCalendar=*-*-* 03:20:00
RandomizedDelaySec=1800
AccuracySec=1min
# Si la VPS estuvo apagada a la hora prevista, corre al arrancar.
Persistent=true
Unit=skeen-backup.service
[Install]
WantedBy=timers.target