583 lines
24 KiB
Python
Executable File
583 lines
24 KiB
Python
Executable File
#!/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 <slug>_odoo)')
|
|
ap.add_argument('--db-role',
|
|
help='rol PostgreSQL (default <slug>: 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/<slug>-credenciales-<fecha>.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://<ip-de-la-vps>:%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://<ip-vps>:%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)
|