616 lines
23 KiB
Python
Executable File
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())
|