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:
257
docs/producto/operacion-instancia.md
Normal file
257
docs/producto/operacion-instancia.md
Normal 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
64
infra/ops/README.md
Normal 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
615
infra/ops/admin_instancias.py
Executable 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
42
infra/ops/backup.env
Normal 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
523
infra/ops/backup_instancias.sh
Executable 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
|
||||
12
infra/ops/logrotate/skeen-backup
Normal file
12
infra/ops/logrotate/skeen-backup
Normal 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
|
||||
}
|
||||
17
infra/ops/systemd/backup-instancia@.service
Normal file
17
infra/ops/systemd/backup-instancia@.service
Normal 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
|
||||
12
infra/ops/systemd/backup-instancia@.timer
Normal file
12
infra/ops/systemd/backup-instancia@.timer
Normal 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
|
||||
16
infra/ops/systemd/skeen-backup-verify.service
Normal file
16
infra/ops/systemd/skeen-backup-verify.service
Normal 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
|
||||
13
infra/ops/systemd/skeen-backup-verify.timer
Normal file
13
infra/ops/systemd/skeen-backup-verify.timer
Normal 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
|
||||
17
infra/ops/systemd/skeen-backup.service
Normal file
17
infra/ops/systemd/skeen-backup.service
Normal 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
|
||||
14
infra/ops/systemd/skeen-backup.timer
Normal file
14
infra/ops/systemd/skeen-backup.timer
Normal 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
|
||||
Reference in New Issue
Block a user