#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Provisiona una instancia white-label nueva de SKEEN en la VPS. Levanta una clínica/spa completa en un comando sin tocar las instancias que ya existen: base de datos y rol PostgreSQL propios, secretos propios, conf y unit systemd, inicialización de los módulos skeen, post-instalación (marca, empresa, admin, glosario/sucursal/settings si existen), renombrado del menú raíz, vhost nginx y frontend. Uso típico: ./provision_clinica.py --slug spa-bella --nombre "Spa Bella" \\ --dominio spa.consultoria-as.com --puerto 8071 --db spa_bella_odoo \\ --pack spa --demo --dry-run Con --dry-run NO se ejecuta nada: solo se validan colisiones (lecturas) y se imprimen los pasos, el contenido de los archivos y los comandos que correrían. """ import argparse import datetime import json import os import re import secrets import shlex import string import subprocess import sys import tempfile from string import Template # ------------------------------------------------------------------ rutas fijas BASE = os.path.dirname(os.path.abspath(__file__)) TEMPLATES = os.path.join(BASE, 'templates') ODOO_ROOT = '/root/skeen-odoo' ODOO_BIN = os.path.join(ODOO_ROOT, 'odoo-bin') ODOO_PYTHON = '/root/odoo-venv/bin/python' ADDONS_PATH = '/root/skeen-odoo/addons,/root/skeen-odoo/odoo/addons' FRONTEND_SRC = '/root/skeen-frontend' BRAND_SRC_CANDIDATES = [ '/var/www/clinica2-frontend/brand', '/root/skeen-proyecto/frontend/public/brand', ] BACKUPS_DIR = '/root/backups' NGINX_AVAILABLE = '/etc/nginx/sites-available' NGINX_ENABLED = '/etc/nginx/sites-enabled' SYSTEMD_DIR = '/etc/systemd/system' REFERENCE_DB = 'skeen_odoo' # de aquí se toma la lista de módulos skeen DEFAULT_PACK = 'clinica' SLUG_RE = re.compile(r'^[a-z0-9-]+$') DB_RE = re.compile(r'^[a-z0-9_]+$') # ------------------------------------------------------------------ utilidades def gen_secret(n, alphabet=None): alphabet = alphabet or (string.ascii_letters + string.digits) return ''.join(secrets.choice(alphabet) for _ in range(n)) def read_template(name): with open(os.path.join(TEMPLATES, name), encoding='utf-8') as fh: return fh.read() def render(name, ctx): return Template(read_template(name)).substitute(**ctx) def sh(cmd, check=False, input_text=None): """Ejecuta SIEMPRE (solo para lecturas y para el modo real).""" p = subprocess.run(cmd, capture_output=True, text=True, input=input_text, cwd=ODOO_ROOT) if check and p.returncode != 0: raise RuntimeError('comando falló (%d): %s\n%s' % (p.returncode, ' '.join(map(str, cmd)), p.stderr)) return p def psql(db, sql, tuples_only=True): flag = '-Atc' if tuples_only else '-c' return sh(['su', 'postgres', '-c', 'psql -d %s %s %s' % (shlex.quote(db), flag, shlex.quote(sql))]) class Ctx: """Ejecutor con modo dry-run y pista de deshacer.""" def __init__(self, dry, force): self.dry = dry self.force = force self.n = 0 self.actions = [] # (descripcion, [comando de limpieza]) self.files = [] def step(self, title): self.n += 1 print('\n%s\n[%d] %s\n%s' % ('=' * 74, self.n, title, '=' * 74)) def info(self, msg=''): print(' ' + msg) def warn(self, msg): print(' !! ' + msg) def run(self, cmd, check=True, undo=None, quiet=False, cwd=None): pretty = ' '.join(shlex.quote(str(c)) for c in cmd) if self.dry: prefix = ('(cd %s && ' % cwd) if cwd else '' suf = ')' if cwd else '' print(' [dry-run] $ %s%s%s' % (prefix, pretty, suf)) return subprocess.CompletedProcess(cmd, 0, '', '') if not quiet: print(' $ %s%s' % (('(cd %s) ' % cwd) if cwd else '', pretty)) p = subprocess.run(cmd, cwd=cwd or ODOO_ROOT) if check and p.returncode != 0: raise RuntimeError('comando falló (%d): %s' % (p.returncode, pretty)) if undo: self.actions.append((undo[0], undo[1])) return p def write(self, path, content, mode=None, undo=True): if self.dry: print(' [dry-run] escribir %s%s' % (path, (' (chmod %o)' % mode) if mode else '')) print(' ' + '-' * 66) for line in content.splitlines(): print(' | ' + line) print(' ' + '-' * 66) else: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w', encoding='utf-8') as fh: fh.write(content) if mode: os.chmod(path, mode) print(' escrito %s' % path) self.files.append(path) if undo: self.actions.append(('archivo %s' % path, ['rm', '-f', path])) # --------------------------------------------------------------------- args def parse_args(argv=None): ap = argparse.ArgumentParser( description='Provisiona una instancia white-label de SKEEN.', formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('--slug', required=True, help='identificador corto (^[a-z0-9-]+$), p.ej. spa-bella') ap.add_argument('--nombre', required=True, help='nombre comercial') ap.add_argument('--dominio', required=True, help='dominio público, p.ej. spa.consultoria-as.com') ap.add_argument('--puerto', required=True, type=int, help='puerto HTTP de Odoo; longpolling usa puerto+3') ap.add_argument('--db', help='nombre de la base (default _odoo)') ap.add_argument('--db-role', help='rol PostgreSQL (default : rol propio con ' 'password nueva; usar "skeen" para el rol compartido)') ap.add_argument('--pack', default=DEFAULT_PACK, choices=['clinica', 'spa', 'estetica', 'dental', 'custom'], help='giro para glosario y datos demo') ap.add_argument('--demo', action='store_true', help='sembrar datos demo') ap.add_argument('--demo-limpia', action='store_true', help='borrar datos demo al final (usa demo_limpiar.py)') ap.add_argument('--dominio-lan', type=int, help='puerto LAN extra en el vhost nginx (p.ej. 8881)') ap.add_argument('--admin-login', default='admin') ap.add_argument('--pie', help='pie de página de la marca (default genérico)') ap.add_argument('--salida', help='archivo de credenciales (default en ' '/root/backups/-credenciales-.txt)') ap.add_argument('--con-wacrm', action='store_true', help='NO bloquear WACRM (por defecto se bloquea)') ap.add_argument('--sin-wacrm', action='store_true', help='bloquear WACRM (comportamiento por defecto)') ap.add_argument('--skip-build', action='store_true', help='no recompilar el frontend; reusar /root/skeen-frontend/dist') ap.add_argument('--force', action='store_true', help='continuar aunque existan colisiones') ap.add_argument('--dry-run', action='store_true', help='imprimir todo sin ejecutar nada') return ap.parse_args(argv) # ----------------------------------------------------------- lecturas de entorno def used_ports(): p = sh(['ss', '-ltnH']) return set(int(m) for m in re.findall(r':(\d+)\b', p.stdout)) def existing_dbs(): p = psql('postgres', 'SELECT datname FROM pg_database') return set(l.strip() for l in p.stdout.splitlines() if l.strip()) def existing_roles(): p = psql('postgres', 'SELECT rolname FROM pg_roles') return set(l.strip() for l in p.stdout.splitlines() if l.strip()) def reference_modules(): """Lista de módulos skeen instalados en la instancia de referencia, ordenada topológicamente (dependencias primero).""" names = psql(REFERENCE_DB, "SELECT name FROM ir_module_module " "WHERE state='installed' AND name LIKE 'skeen%'").stdout mods = [l.strip() for l in names.splitlines() if l.strip()] if not mods: return mods deps_raw = psql(REFERENCE_DB, "SELECT m.name || ' ' || d.name FROM ir_module_module m " "JOIN ir_module_module_dependency d ON d.module_id=m.id " "WHERE m.name LIKE 'skeen%'").stdout deps = {m: set() for m in mods} for line in deps_raw.splitlines(): parts = line.split() if len(parts) == 2 and parts[0] in deps and parts[1] in deps: deps[parts[0]].add(parts[1]) ordered, seen = [], set() def visit(m): if m in seen: return seen.add(m) for d in sorted(deps.get(m, ())): visit(d) ordered.append(m) for m in sorted(mods): visit(m) return ordered def psql_file(ctx, db, sql, kind): """Ejecuta SQL como postgres. En dry-run solo lo imprime. El SQL va por stdin (`-f -`) en vez de un archivo temporal: `su postgres` pierde privilegios y no podría leer un archivo root-only 0600.""" if ctx.dry: print(' [dry-run] SQL sobre %s (%s):' % (db, kind)) for line in sql.strip().splitlines(): print(' | ' + line) return p = subprocess.run(['su', 'postgres', '-c', 'psql -d %s -v ON_ERROR_STOP=1 -f -' % shlex.quote(db)], input=sql, capture_output=True, text=True) if p.returncode != 0: raise RuntimeError('SQL falló:\n%s' % (p.stdout + p.stderr)) print(' SQL (%s) ok' % kind) def sql_literal(value): return "'" + str(value).replace("'", "''") + "'" ctx = None # --------------------------------------------------------------------- main def main(argv=None): global ctx args = parse_args(argv) ctx = Ctx(args.dry_run, args.force) # ---------------------------------------------------------- derivados slug = args.slug db = args.db or (slug.replace('-', '_') + '_odoo') role = args.db_role or slug.replace('-', '_') http_port = args.puerto longpolling_port = http_port + 3 conf_path = '/root/%s-odoo.conf' % slug unit_name = 'odoo-%s.service' % slug unit_path = os.path.join(SYSTEMD_DIR, unit_name) data_dir = '/var/lib/odoo-%s' % slug log_path = '/tmp/odoo-%s.log' % slug screenshots_dir = '/tmp/odoo_screenshots_%s' % slug frontend_dir = '/var/www/%s-frontend' % slug vhost_avail = os.path.join(NGINX_AVAILABLE, slug) vhost_enabled = os.path.join(NGINX_ENABLED, slug) fecha = datetime.datetime.now().strftime('%Y%m%d-%H%M%S') salida = args.salida or os.path.join( BACKUPS_DIR, '%s-credenciales-%s.txt' % (slug, fecha)) pie = args.pie or 'Sistema de gestión' print('=' * 74) print('PROVISIONAMIENTO SKEEN — instancia "%s" (%s)' % (args.nombre, slug)) print('Modo: %s' % ('DRY-RUN (no se ejecuta nada)' if args.dry_run else 'REAL')) print('=' * 74) # ------------------------------------------------------- 1. validaciones ctx.step('Validaciones previas') errors = [] if not SLUG_RE.match(slug): errors.append('slug inválido %r: debe cumplir ^[a-z0-9-]+$' % slug) if not DB_RE.match(db): errors.append('nombre de DB inválido %r: debe cumplir ^[a-z0-9_]+$' % db) if not DB_RE.match(role): errors.append('rol inválido %r' % role) ports = used_ports() dbs = existing_dbs() roles = existing_roles() collisions = [] if http_port in ports: collisions.append('puerto %d ya está en uso' % http_port) if longpolling_port in ports: collisions.append('puerto longpolling %d ya está en uso' % longpolling_port) if db in dbs: collisions.append('la base de datos %r ya existe' % db) if os.path.exists(unit_path): collisions.append('el unit %s ya existe' % unit_path) if os.path.exists(vhost_enabled) or os.path.exists(vhost_avail): collisions.append('el vhost nginx %s ya existe' % slug) if os.path.exists(frontend_dir): collisions.append('el directorio frontend %s ya existe' % frontend_dir) if os.path.exists(conf_path): collisions.append('el archivo de conf %s ya existe' % conf_path) if os.path.exists(data_dir): collisions.append('el data_dir %s ya existe' % data_dir) for e in errors: ctx.warn(e) for c in collisions: ctx.warn('COLISIÓN: ' + c) if role in roles and role != 'skeen': ctx.warn('el rol PostgreSQL %r ya existe; no se cambiará su password' % role) if errors or (collisions and not args.force): print('\nABORTADO. Corrige los errores/colisiones o usa --force.') return 2 if collisions and args.force: ctx.warn('--force activo: se continúa pese a las colisiones anteriores.') ctx.info('slug=%s db=%s rol=%s puerto=%d/%d dominio=%s' % (slug, db, role, http_port, longpolling_port, args.dominio)) ctx.info('pack=%s demo=%s wacrm=%s' % (args.pack, args.demo, 'NO' if args.con_wacrm else 'bloqueado')) # ----------------------------------------------------------- 2. secretos ctx.step('Secretos propios de la instancia') admin_passwd = gen_secret(24) db_password = gen_secret(28) admin_password = gen_secret(16) ctx.info('admin_passwd (master Odoo): %s' % admin_passwd) ctx.info('db_password (rol %s): %s' % (role, db_password)) ctx.info('admin frontend %s: %s' % (args.admin_login, admin_password)) ctx.info('(se guardarán en %s, chmod 600)' % salida) # ------------------------------------------------------------- 3. DB ctx.step('Base de datos y rol PostgreSQL') if role == 'skeen': ctx.warn('Usando el rol COMPARTIDO "skeen". El db_password generado no ' 'coincidirá con la password real del rol; ajusta ' 'db_password a mano en %s o usa un rol propio (default).' % conf_path) elif role in roles: ctx.warn('El rol %s ya existe: se reutiliza y NO se le cambia la ' 'password. Verifica que db_password en la conf coincida.' % role) else: create_role_sql = ("CREATE ROLE %s WITH LOGIN PASSWORD %s;" % (role, sql_literal(db_password))) psql_file(ctx, 'postgres', create_role_sql, 'CREATE ROLE %s' % role) ctx.actions.append(('rol %s' % role, ['su', 'postgres', '-c', 'dropuser %s' % role])) createdb = ['su', 'postgres', '-c', 'createdb -O %s %s' % (role, db)] ctx.run(createdb, undo=('base %s' % db, ['su', 'postgres', '-c', 'dropdb %s' % db])) ctx.run(['mkdir', '-p', data_dir], undo=('data_dir %s' % data_dir, ['rm', '-rf', data_dir])) # --------------------------------------------------------- 4. odoo.conf ctx.step('Configuración de Odoo (%s)' % conf_path) conf = render('odoo.conf.tmpl', { 'ADDONS_PATH': ADDONS_PATH, 'ADMIN_PASSWD': admin_passwd, 'DATA_DIR': data_dir, 'DB_NAME': db, 'DB_PASSWORD': db_password, 'DB_USER': role, 'HTTP_PORT': http_port, 'LONGPOLLING_PORT': longpolling_port, 'LOG_PATH': log_path, 'SCREENSHOTS_DIR': screenshots_dir, }) ctx.write(conf_path, conf, mode=0o600) # ------------------------------------------------------ 5. unit systemd ctx.step('Unit systemd (%s)' % unit_name) service = render('service.tmpl', { 'NOMBRE': args.nombre, 'SLUG': slug, 'ODOO_PYTHON': ODOO_PYTHON, 'ODOO_BIN': ODOO_BIN, 'CONF_PATH': conf_path, }) ctx.write(unit_path, service) ctx.run(['systemctl', 'daemon-reload']) ctx.run(['systemctl', 'enable', unit_name], undo=('unit %s' % unit_name, ['systemctl', 'disable', '--now', unit_name])) # -------------------------------------------------- 6. init de módulos ctx.step('Inicialización de módulos skeen') modules = reference_modules() if not modules: ctx.warn('No se pudieron leer los módulos de %s; usando la lista del ' 'procedimiento manual.' % REFERENCE_DB) modules = ['skeen_pacientes', 'skeen_citas', 'skeen_inventario', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp', 'skeen_chat', 'skeen_visitas'] ctx.info('Módulos (orden topológico, tomados de %s): %s' % (REFERENCE_DB, ','.join(modules))) init_cmd = [ODOO_PYTHON, ODOO_BIN, '-c', conf_path, '-d', db, '--without-demo=all', '--stop-after-init', '--no-http', '-i', ','.join(modules), '--log-level=warn'] ctx.run(init_cmd) ctx.info('(la service no se arranca hasta después del post-install)') # ------------------------------------------------- 7. post-instalación ctx.step('Post-instalación (marca, empresa, admin, config v2)%s' % (', demo' if args.demo else '')) report_path = os.path.join(tempfile.gettempdir(), 'postinstall-%s.json' % slug) cfg_post = { 'conf': conf_path, 'db': db, 'nombre': args.nombre, 'dominio': args.dominio, 'pack': args.pack, 'admin_login': args.admin_login, 'admin_password': admin_password, 'con_wacrm': args.con_wacrm, 'demo': args.demo, 'pie': pie, 'report': report_path, } cfg_path = os.path.join(tempfile.gettempdir(), 'postinstall-%s.cfg.json' % slug) ctx.write(cfg_path, json.dumps(cfg_post, ensure_ascii=False, indent=2), mode=0o600) helper = os.path.join(BASE, 'postinstall_clinica.py') ctx.run([ODOO_PYTHON, helper, cfg_path]) if not ctx.dry and os.path.exists(report_path): try: with open(report_path, encoding='utf-8') as fh: rep = json.load(fh) for k in ('seeded', 'skipped', 'warnings', 'todo'): for item in rep.get(k, []): ctx.info('%-9s %s' % (k + ':', item)) except Exception as e: ctx.warn('no se pudo leer el reporte de post-install: %s' % e) else: ctx.info('(el post-install reporta qué sembró y deja TODO para lo que ' 'el modelo del nuevo config aún no exista)') # --------------------------------------------- 8. renombrar menú raíz ctx.step('Renombrar el menú raíz (%s)' % args.nombre) menu_sql = ( "UPDATE ir_ui_menu SET name = jsonb_build_object('en_US', %s)\n" "WHERE id = (SELECT res_id FROM ir_model_data\n" " WHERE module='skeen_pacientes' AND name='menu_skeen_root'\n" " AND model='ir.ui.menu');" % sql_literal(args.nombre)) psql_file(ctx, db, menu_sql, 'menu_skeen_root -> %s' % args.nombre) # ---------------------------------------------------- 9. vhost nginx ctx.step('Vhost nginx (%s)' % slug) lan_listen = ' listen %d;\n' % args.dominio_lan if args.dominio_lan else '' lan_comment = ('# Acceso LAN: http://:%d' % args.dominio_lan if args.dominio_lan else '') wacrm_block = '' if args.con_wacrm else ( "\n # Instancia sin WhatsApp: se corta la ruta WACRM aunque el\n" " # backend tenga fallback. Quitar este bloque al activar WhatsApp.\n" " location /api/odoo/skeen/frontend/v1/wacrm/ {\n" " return 403;\n" " }") vhost = render('nginx-vhost.tmpl', { 'NOMBRE': args.nombre, 'SLUG': slug, 'DOMINIO': args.dominio, 'HTTP_PORT': http_port, 'FRONTEND_DIR': frontend_dir, 'LAN_LISTEN': lan_listen, 'LAN_COMMENT': lan_comment, 'LAN_PORT_OR_EMPTY': args.dominio_lan or '', 'WACRM_BLOCK': wacrm_block, }) ctx.write(vhost_avail, vhost) if ctx.dry: print(' [dry-run] $ ln -s %s %s' % (vhost_avail, vhost_enabled)) else: if os.path.lexists(vhost_enabled): os.unlink(vhost_enabled) os.symlink(vhost_avail, vhost_enabled) print(' symlink %s -> %s' % (vhost_enabled, vhost_avail)) ctx.actions.append(('vhost %s' % slug, ['rm', '-f', vhost_enabled, vhost_avail])) ctx.run(['nginx', '-t']) ctx.run(['systemctl', 'reload', 'nginx']) # ------------------------------------------------------- 10. frontend ctx.step('Frontend React (%s)' % frontend_dir) if args.skip_build: ctx.info('--skip-build: se reutiliza %s/dist' % FRONTEND_SRC) else: ctx.info('Build compartido (un solo bundle sirve a todas las instancias; ' 'la marca sale del API /brand). Se compila una vez desde %s.' % FRONTEND_SRC) ctx.run(['npm', 'run', 'build'], cwd=FRONTEND_SRC) ctx.run(['mkdir', '-p', frontend_dir], undo=('frontend %s' % frontend_dir, ['rm', '-rf', frontend_dir])) ctx.run(['rsync', '-a', '--delete', FRONTEND_SRC + '/dist/', frontend_dir + '/']) brand_src = next((p for p in BRAND_SRC_CANDIDATES if os.path.isdir(p)), BRAND_SRC_CANDIDATES[-1]) ctx.info('Logos genéricos desde %s' % brand_src) ctx.run(['mkdir', '-p', os.path.join(frontend_dir, 'brand')]) ctx.run(['cp', '-f'] + [os.path.join(brand_src, f) for f in ('logo-blanco.svg', 'logo-negro.svg', 'favicon.svg')] + [os.path.join(frontend_dir, 'brand') + '/']) ctx.run(['chown', '-R', 'www-data:www-data', frontend_dir]) # ------------------------------------------------- 11. arrancar service ctx.step('Arranque de la instancia') ctx.run(['systemctl', 'start', unit_name], undo=('service %s' % unit_name, ['systemctl', 'stop', unit_name])) ctx.run(['systemctl', 'is-active', unit_name], check=False) # ------------------------------------------------- demo-limpia opcional if args.demo_limpia: ctx.step('Limpieza de datos demo') ctx.run([ODOO_PYTHON, os.path.join(BASE, 'demo_limpiar.py'), '--conf', conf_path, '--db', db]) # ------------------------------------------------------- 12. credenciales ctx.step('Credenciales y resumen') creds = ( 'Instancia: %s (%s)\n' 'Generado: %s\n' 'Dominio: https://%s\n' 'Odoo HTTP: http://127.0.0.1:%d (longpolling %d)\n' 'Base de datos: %s\n' 'Rol PostgreSQL: %s\n' 'Conf: %s\n' 'Unit: %s\n' 'Frontend: %s\n' '\n' 'Odoo master password (admin_passwd): %s\n' 'Frontend admin: %s / %s (cambio obligatorio al primer login)\n' % (args.nombre, slug, datetime.datetime.now().isoformat(timespec='seconds'), args.dominio, http_port, longpolling_port, db, role, conf_path, unit_name, frontend_dir, admin_passwd, args.admin_login, admin_password) ) if ctx.dry: print(' [dry-run] escribir %s (chmod 600):' % salida) for line in creds.splitlines(): print(' | ' + line) else: os.makedirs(BACKUPS_DIR, exist_ok=True) with open(salida, 'w', encoding='utf-8') as fh: fh.write(creds) os.chmod(salida, 0o600) print(' credenciales guardadas en %s (chmod 600)' % salida) print('\n%s' % ('-' * 74)) print('RESUMEN — %s' % ('dry-run: nada se ejecutó' if ctx.dry else 'OK')) print('-' * 74) print(' Logs: tail -f %s' % log_path) print(' Estado: systemctl status %s' % unit_name) print(' Health: curl http://127.0.0.1:%d/web/health' % http_port) print(' LAN: http://:%s' % (args.dominio_lan or '-')) print(' PÚBLICO: agregar ingress en el dashboard de cloudflared:') print(' hostname %s -> service http://localhost:80' % args.dominio) if args.demo and not args.demo_limpia: print(' Demo: borrar con %s/demo_limpiar.py --conf %s --db %s' % (BASE, conf_path, db)) print('-' * 74) return 0 if __name__ == '__main__': try: sys.exit(main()) except KeyboardInterrupt: print('\nInterrumpido.') sys.exit(130) except Exception as e: print('\n' + '!' * 74) print('FALLÓ: %s' % e) print('!' * 74) print('Estado parcial (limpia a mano si lo necesitas):') if ctx is not None: for desc, undo in ctx.actions: print(' - %s -> %s' % (desc, ' '.join(map(str, undo)))) else: print(' - aún no se ejecutó ningún paso') sys.exit(1)