diff --git a/docs/producto/operacion-instancia.md b/docs/producto/operacion-instancia.md new file mode 100644 index 0000000..e87b1f1 --- /dev/null +++ b/docs/producto/operacion-instancia.md @@ -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 ` (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//`: + +- **Base de datos**: `pg_dump -Fc` → `_.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**: `_.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/-frontend/brand`, + `/var/www/-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 ` | 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 ` | 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@.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 `_verify_`, 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:/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 ` | 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 ` | Valida que la instancia esté completa: DB, unit activa, conf, vhost, puerto, HTTP, frontend desplegado y marca configurada. Lista lo que falta. | +| `logs [-n N]` | Tail del log de Odoo de esa instancia y del log de backups. | +| `credenciales [--mostrar]` | Ruta del archivo de credenciales del provisioning. **Nunca** imprime passwords sin `--mostrar`. | +| `backup [--verificar] --yes` | Lanza el script de backup de esa instancia. | +| `reiniciar --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//`, 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 `; `journalctl -u odoo-.service -n 100` | Reiniciar con `./admin_instancias.py reiniciar --yes` (o `systemctl restart odoo-`). Si la DB quedó corrupta, restaurar (sección 3). | +| **Instancia incompleta** | `./admin_instancias.py instalacion ` | 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-.service` y que `-c ` 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//` (`_.dump`, `_.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`). diff --git a/infra/ops/README.md b/infra/ops/README.md new file mode 100644 index 0000000..f2e81e2 --- /dev/null +++ b/infra/ops/README.md @@ -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 `). 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`. | diff --git a/infra/ops/admin_instancias.py b/infra/ops/admin_instancias.py new file mode 100755 index 0000000..5235dd1 --- /dev/null +++ b/infra/ops/admin_instancias.py @@ -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 ` 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 + ./admin_instancias.py version + ./admin_instancias.py uso + ./admin_instancias.py instalacion + ./admin_instancias.py logs [-n N] + ./admin_instancias.py credenciales [--mostrar] + ./admin_instancias.py backup [--verificar] --yes + ./admin_instancias.py reiniciar --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/-credenciales-.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()) diff --git a/infra/ops/backup.env b/infra/ops/backup.env new file mode 100644 index 0000000..aa1d862 --- /dev/null +++ b/infra/ops/backup.env @@ -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 diff --git a/infra/ops/backup_instancias.sh b/infra/ops/backup_instancias.sh new file mode 100755 index 0000000..26b0a9d --- /dev/null +++ b/infra/ops/backup_instancias.sh @@ -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 ` (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 // +# (_.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 _verify_, 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 ] [--solo-db] [--sin-config] +# [--verificar] [--dry-run] [--backup-root ] +# +# 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:-}" + echo "Generado: $(date '+%F %T')" + echo "Host: $(hostname)" + echo "Contenido: odoo.conf, systemd/, nginx/, frontend/" + } >"$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 diff --git a/infra/ops/logrotate/skeen-backup b/infra/ops/logrotate/skeen-backup new file mode 100644 index 0000000..4f6aadb --- /dev/null +++ b/infra/ops/logrotate/skeen-backup @@ -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 +} diff --git a/infra/ops/systemd/backup-instancia@.service b/infra/ops/systemd/backup-instancia@.service new file mode 100644 index 0000000..9f4279c --- /dev/null +++ b/infra/ops/systemd/backup-instancia@.service @@ -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 diff --git a/infra/ops/systemd/backup-instancia@.timer b/infra/ops/systemd/backup-instancia@.timer new file mode 100644 index 0000000..173242a --- /dev/null +++ b/infra/ops/systemd/backup-instancia@.timer @@ -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 diff --git a/infra/ops/systemd/skeen-backup-verify.service b/infra/ops/systemd/skeen-backup-verify.service new file mode 100644 index 0000000..36d37f6 --- /dev/null +++ b/infra/ops/systemd/skeen-backup-verify.service @@ -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 diff --git a/infra/ops/systemd/skeen-backup-verify.timer b/infra/ops/systemd/skeen-backup-verify.timer new file mode 100644 index 0000000..923cf07 --- /dev/null +++ b/infra/ops/systemd/skeen-backup-verify.timer @@ -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 diff --git a/infra/ops/systemd/skeen-backup.service b/infra/ops/systemd/skeen-backup.service new file mode 100644 index 0000000..4fab07d --- /dev/null +++ b/infra/ops/systemd/skeen-backup.service @@ -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 diff --git a/infra/ops/systemd/skeen-backup.timer b/infra/ops/systemd/skeen-backup.timer new file mode 100644 index 0000000..2d67a00 --- /dev/null +++ b/infra/ops/systemd/skeen-backup.timer @@ -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