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