#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import sys import subprocess from shutil import which PROJECT_ROOT = os.path.abspath(os.getcwd()) # === Archivos Compose (entornos) === BASE_COMPOSE = os.path.join(PROJECT_ROOT, "compose.yaml") DEV_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dev.yaml") PROD_COMPOSE = os.path.join(PROJECT_ROOT, "compose.prod.yaml") # === Archivos Compose (globales) === NPM_COMPOSE = os.path.join(PROJECT_ROOT, "compose.npm.yaml") DBEAVER_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dbeaver.yaml") # Archivos .env ENV_FILES = { "development": ".env.development", "production": ".env.production", } # Nombres de proyecto para permitir DEV y PROD simultáneos def _base_project(): return os.path.basename(PROJECT_ROOT).lower() or "composeproj" PROJECT_NAMES = { "development": f"{_base_project()}_dev", "production": f"{_base_project()}_prod", } # Nombre de proyecto global (ambos yaml globales usan name: suitecoffee) GLOBAL_PROJECT_NAME = "suitecoffee" # ---------- Utilidades ---------- def check_prereqs(): if which("docker") is None: fail("No se encontró 'docker' en el PATH.") try: run(["docker", "compose", "version"], check=True, capture_output=True) except Exception: fail("No se pudo ejecutar 'docker compose'. Asegúrate de tener Docker Compose v2.") def run(cmd, check=False, capture_output=False): return subprocess.run(cmd, check=check, capture_output=capture_output, text=True) def compose_files_args(env_key): """ Devuelve los -f correctos según el entorno (dev/prod) + base. """ if not os.path.exists(BASE_COMPOSE): fail("No se encontró compose.yaml en la raíz del proyecto.") args = ["-f", BASE_COMPOSE] if env_key == "development": if not os.path.exists(DEV_COMPOSE): fail("No se encontró compose.dev.yaml.") args += ["-f", DEV_COMPOSE] elif env_key == "production": if not os.path.exists(PROD_COMPOSE): fail("No se encontró compose.prod.yaml.") args += ["-f", PROD_COMPOSE] else: fail(f"Entorno desconocido: {env_key}") return args def compose_files_args_global(kind): """ Devuelve los -f correctos para servicios globales (npm/dbeaver). """ if kind == "npm": if not os.path.exists(NPM_COMPOSE): fail("No se encontró compose.npm.yaml.") return ["-f", NPM_COMPOSE] elif kind == "dbeaver": if not os.path.exists(DBEAVER_COMPOSE): fail("No se encontró compose.dbeaver.yaml.") return ["-f", DBEAVER_COMPOSE] else: fail(f"Servicio global desconocido: {kind}") def env_file_path(env_key): fname = ENV_FILES.get(env_key) if not fname: return None path = os.path.join(PROJECT_ROOT, fname) return path if os.path.exists(path) else None def compose_cmd(base_args, env_key, env_file=None, project_name=None): """ Construye: docker compose -f base -f env --env-file ... -p [OPCIONES] (importante: --env-file y -p son opciones globales y van antes del subcomando) """ cmd = ["docker", "compose"] cmd += compose_files_args(env_key) if env_file: cmd += ["--env-file", env_file] if project_name: cmd += ["-p", project_name] cmd += base_args # ["up","-d","--force-recreate"] o ["ps","--status","running","-q"] return cmd def compose_cmd_global(base_args, kind, project_name=GLOBAL_PROJECT_NAME): """ Comandos para servicios globales (npm/dbeaver): docker compose -f compose..yaml -p suitecoffee ... """ cmd = ["docker", "compose"] cmd += compose_files_args_global(kind) if project_name: cmd += ["-p", project_name] cmd += base_args return cmd def yes_no(prompt, default="n"): default = default.lower() hint = "[Y/n]" if default == "y" else "[y/N]" while True: resp = input(f"{prompt} {hint} ").strip().lower() if not resp: return default == "y" if resp in ("y", "yes", "s", "si", "sí"): return True if resp in ("n", "no"): return False print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.") def print_header(title): print("\n" + "=" * 60) print(title) print("=" * 60 + "\n") def info(msg): print(f"• {msg}") def ok(msg): print(f"✓ {msg}") def warn(msg): print(f"! {msg}") def fail(msg): print(f"✗ {msg}") sys.exit(1) # ---------- Helpers globales (servicios por archivo y ps por labels) ---------- def list_services_from_compose_file(compose_path): """Obtiene la lista de servicios definidos en un archivo compose específico.""" cmd = ["docker", "compose", "-f", compose_path, "config", "--services"] proc = run(cmd, capture_output=True) if proc.returncode != 0: return [] return [s.strip() for s in proc.stdout.splitlines() if s.strip()] def list_services_global(kind): compose_path = NPM_COMPOSE if kind == "npm" else DBEAVER_COMPOSE return list_services_from_compose_file(compose_path) def docker_ps_by_labels(project, service=None, running_only=True): """Lista contenedores por labels de compose (project/service).""" cmd = ["docker", "ps"] if running_only: # por defecto docker ps ya lista solo running; se deja explícito por claridad pass cmd += ["--filter", f"label=com.docker.compose.project={project}"] if service: cmd += ["--filter", f"label=com.docker.compose.service={service}"] cmd += ["-q"] proc = run(cmd, capture_output=True) if proc.returncode != 0: return [] return [l.strip() for l in proc.stdout.splitlines() if l.strip()] # ---------- Acciones (entornos) ---------- def bring_up(env_key, force_recreate=False): env_path = env_file_path(env_key) if not env_path: warn(f"No se encontró archivo de entorno para '{env_key}'. Continuando sin --env-file.") pname = PROJECT_NAMES.get(env_key) base_args = ["up", "-d"] if force_recreate: base_args.append("--force-recreate") cmd = compose_cmd(base_args, env_key=env_key, env_file=env_path, project_name=pname) info("Ejecutando: " + " ".join(cmd)) proc = run(cmd) if proc.returncode == 0: ok(f"Entorno '{env_key}' levantado correctamente.") else: fail(f"Fallo al levantar entorno '{env_key}'. Código: {proc.returncode}") def bring_down(env_key): env_path = env_file_path(env_key) pname = PROJECT_NAMES.get(env_key) cmd = compose_cmd(["down"], env_key=env_key, env_file=env_path, project_name=pname) info("Ejecutando: " + " ".join(cmd)) proc = run(cmd) if proc.returncode == 0: ok(f"Entorno '{env_key}' detenido correctamente.") else: fail(f"Fallo al detener entorno '{env_key}'. Código: {proc.returncode}") def running_ids(env_key): env_path = env_file_path(env_key) pname = PROJECT_NAMES.get(env_key) cmd = compose_cmd(["ps", "--status", "running", "-q"], env_key=env_key, env_file=env_path, project_name=pname) proc = run(cmd, capture_output=True) if proc.returncode != 0: return [] return [l.strip() for l in proc.stdout.splitlines() if l.strip()] # ---------- Acciones (globales) ---------- def bring_up_global(kind, force_recreate=False): base_args = ["up", "-d"] if force_recreate: base_args.append("--force-recreate") cmd = compose_cmd_global(base_args, kind=kind) info("Ejecutando: " + " ".join(cmd)) proc = run(cmd) if proc.returncode == 0: ok(f"Servicio global '{kind}' levantado correctamente.") else: fail(f"Fallo al levantar servicio global '{kind}'. Código: {proc.returncode}") def bring_down_global(kind, remove=False): """ Apaga SOLO los servicios definidos en el compose global indicado. - Primero 'stop '. - Opcionalmente, 'rm -f ' si remove=True. """ services = list_services_global(kind) if not services: warn(f"No se encontraron servicios en compose.{kind}.yaml.") return # stop específico cmd = compose_cmd_global(["stop"] + services, kind=kind) info("Ejecutando: " + " ".join(cmd)) proc = run(cmd) if proc.returncode != 0: fail(f"Fallo al detener servicios de '{kind}'. Código: {proc.returncode}") if remove: cmd_rm = compose_cmd_global(["rm", "-f"] + services, kind=kind) info("Ejecutando: " + " ".join(cmd_rm)) proc_rm = run(cmd_rm) if proc_rm.returncode != 0: fail(f"Fallo al remover servicios de '{kind}'. Código: {proc_rm.returncode}") ok(f"Servicio(s) global(es) '{kind}' detenido(s).") def running_ids_global(kind): """ Detecta si el compose global está corriendo revisando contenedores por servicio, filtrando por labels (project+service). """ services = list_services_global(kind) ids = [] for svc in services: ids += docker_ps_by_labels(GLOBAL_PROJECT_NAME, service=svc, running_only=True) # eliminar duplicados return list(dict.fromkeys(ids)) # ---------- Estado y flujo ---------- def detect_status_summary(): dev_running = running_ids("development") prod_running = running_ids("production") npm_running = running_ids_global("npm") dbeaver_running = running_ids_global("dbeaver") print_header("Estado actual") info(f"DESARROLLO: {len(dev_running)} contenedor(es) en ejecución.") info(f"PRODUCCIÓN: {len(prod_running)} contenedor(es) en ejecución.") info(f"NPM (global): {len(npm_running)} contenedor(es) en ejecución.") info(f"DBEAVER (global): {len(dbeaver_running)} contenedor(es) en ejecución.\n") return bool(dev_running), bool(prod_running), bool(npm_running), bool(dbeaver_running) def detect_and_optionally_shutdown(): """Muestra estado y ofrece (opcional) apagar dev/prod. Los servicios globales se gestionan desde el menú principal (levantar/apagar). """ dev_on, prod_on, _npm_on, _dbeaver_on = detect_status_summary() options = [] if dev_on: options.append(("1", "Apagar entorno de DESARROLLO", "development")) if prod_on: options.append(("2", "Apagar entorno de PRODUCCIÓN", "production")) options.append(("3", "Continuar sin detener nada", None)) if len(options) == 1: return print("Selecciona una opción:") for opt in options: key, label = opt[0], opt[1] print(f" {key}) {label}") while True: choice = input("> ").strip() selected = next((opt for opt in options if opt[0] == choice), None) if not selected: print("Opción inválida.") continue if choice == "3" or selected[2] is None: ok("Continuamos sin detener nada.") return env_key = selected[2] bring_down(env_key) return def main_menu(): # Consultar estado de globales para decidir si mostrar opciones de "Levantar" o "Apagar" _dev_on, _prod_on, npm_on, dbeaver_on = detect_status_summary() print_header("Gestor de entornos Docker Compose") print("Selecciona una opción:") print(" 1) Levantar entorno de DESARROLLO") print(" 2) Levantar entorno de PRODUCCIÓN") dynamic_keys = {} next_key = 3 # NPM: opción según estado if not npm_on: print(f" {next_key}) Levantar NPM (compose.npm.yaml)") dynamic_keys[str(next_key)] = ("global_up", "npm") else: print(f" {next_key}) Apagar NPM (compose.npm.yaml)") dynamic_keys[str(next_key)] = ("global_down", "npm") next_key += 1 # DBEAVER: opción según estado if not dbeaver_on: print(f" {next_key}) Levantar DBEAVER (compose.dbeaver.yaml)") dynamic_keys[str(next_key)] = ("global_up", "dbeaver") else: print(f" {next_key}) Apagar DBEAVER (compose.dbeaver.yaml)") dynamic_keys[str(next_key)] = ("global_down", "dbeaver") next_key += 1 # Salir print(f" {next_key}) Salir") exit_key = str(next_key) while True: choice = input("> ").strip() if choice == "1": force = yes_no("¿Usar --force-recreate para DESARROLLO?", default="n") bring_up("development", force_recreate=force) return elif choice == "2": force = yes_no("¿Usar --force-recreate para PRODUCCIÓN?", default="n") bring_up("production", force_recreate=force) return elif choice in dynamic_keys: action, kind = dynamic_keys[choice] if action == "global_up": force = yes_no(f"¿Usar --force-recreate para {kind.upper()}?", default="n") bring_up_global(kind, force_recreate=force) return elif action == "global_down": remove = yes_no(f"¿También remover contenedores de {kind.upper()}? (rm -f)", default="n") bring_down_global(kind, remove=remove) return elif choice == exit_key: ok("Saliendo.") sys.exit(0) else: print("Opción inválida. Elige una de las opciones listadas.") def main(): try: check_prereqs() # Mostrar estado y permitir opcionalmente apagar dev/prod detect_and_optionally_shutdown() # Menú de gestión (incluye globales: levantar o apagar según estado) main_menu() except KeyboardInterrupt: print("\n") ok("Interrumpido por el usuario (Ctrl+C). Saliendo.") sys.exit(0) except Exception as e: fail(f"Ocurrió un error inesperado: {e}") if __name__ == "__main__": main()