Files
SKEEN-Proyecto/infra/ops/admin_instancias.py

616 lines
23 KiB
Python
Executable File

#!/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())