#!/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 comunes BASE_COMPOSE = os.path.join(PROJECT_ROOT, "docker-compose.yml") OVERRIDE_COMPOSE = os.path.join(PROJECT_ROOT, "docker-compose.override.yml") # Mapeo de entornos -> archivo .env ENV_FILES = { "development": ".env.development", "production": ".env.production", } # ---------- Nuevas utilidades ---------- def resolve_project_name(env_file=None, include_override=True): """ Obtiene el 'project name' que usará docker compose para esta combinación de archivos/env, consultando a 'docker compose config --format json'. Si falla, usa el nombre de la carpeta. """ cmd = ["docker", "compose"] + compose_files_args(include_override=include_override) if env_file: cmd += ["--env-file", env_file] cmd += ["config", "--format", "json"] proc = run(cmd, capture_output=True) if proc.returncode == 0: try: data = json.loads(proc.stdout) # Compose v2 devuelve 'name' en el JSON; si no, fallback return data.get("name") or os.path.basename(PROJECT_ROOT) except Exception: return os.path.basename(PROJECT_ROOT) return os.path.basename(PROJECT_ROOT) def list_project_containers(project_name, all_states=True): """ Lista contenedores del proyecto por etiqueta com.docker.compose.project=. Si all_states=False, solo los running. Devuelve lista de dicts con {id, name, status, image}. """ base = ["docker", "ps"] if all_states: base.append("-a") base += ["--filter", f"label=com.docker.compose.project={project_name}", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"] proc = run(base, capture_output=True) if proc.returncode != 0: return [] rows = [] for line in proc.stdout.splitlines(): parts = line.strip().split("\t") if len(parts) >= 4: rows.append({"id": parts[0], "name": parts[1], "status": parts[2], "image": parts[3]}) return rows def print_containers_table(title, rows): print_header(title) if not rows: print("(ninguno)\n") return # ancho simple, sin dependencias print(f"{'ID':<12} {'NAME':<40} {'STATUS':<20} IMAGE") for r in rows: print(f"{r['id']:<12} {r['name']:<40} {r['status']:<20} {r['image']}") print() # ---------- Utilidades ---------- def check_prereqs(): if which("docker") is None: fail("No se encontró 'docker' en el PATH.") # Verificar que docker compose esté disponible (subcomando integrado) 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(include_override=True): args = [] if os.path.exists(BASE_COMPOSE): args += ["-f", BASE_COMPOSE] else: fail("No se encontró docker-compose.yml en la raíz del proyecto.") if include_override and os.path.exists(OVERRIDE_COMPOSE): args += ["-f", OVERRIDE_COMPOSE] return args 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_file=None, include_override=True): """ Construye el comando docker compose con los -f adecuados y opcionalmente --env-file si existe (antes del subcomando). """ cmd = ["docker", "compose"] cmd += compose_files_args(include_override=include_override) if env_file: cmd += ["--env-file", env_file] # opción global antes del subcomando cmd += base_args return cmd def get_running_containers(): """ Devuelve lista de container IDs en estado 'running' para este proyecto. """ cmd = compose_cmd(["ps", "--status", "running", "-q"]) proc = run(cmd, capture_output=True) if proc.returncode != 0: return [] lines = [l.strip() for l in proc.stdout.splitlines() if l.strip()] return lines def yes_no(prompt, default="n"): """ Pregunta si/no. default: 'y' o '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) # ---------- Acciones ---------- def bring_up(env_key, include_override=True): 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.") cmd = compose_cmd(["up", "-d"], env_file=env_path, include_override=include_override) 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=None): """ Intenta apagar usando el env proporcionado si existe el .env. Si no se pasa env_key o no existe el .env, hace un down genérico. """ env_path = env_file_path(env_key) if env_key else None cmd = compose_cmd(["down"], env_file=env_path) info("Ejecutando: " + " ".join(cmd)) proc = run(cmd) if proc.returncode == 0: ok("Contenedores detenidos y red/volúmenes del proyecto desmontados (según corresponda).") else: fail(f"Fallo al detener el entorno. Código: {proc.returncode}") def main_menu(): 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") print(" 3) Salir") while True: choice = input("> ").strip() if choice == "1": bring_up("development") # incluye override return elif choice == "2": bring_up("production", include_override=False) # sin override return elif choice == "3": ok("Saliendo.") sys.exit(0) else: print("Opción inválida. Elige 1, 2 o 3.") def detect_and_offer_shutdown(): # Paths de env (si existen) dev_env = env_file_path("development") prod_env = env_file_path("production") # Helper: obtiene IDs running para una combinación de files/env def running_ids(env_path, include_override): cmd = compose_cmd(["ps", "--status", "running", "-q"], env_file=env_path, include_override=include_override) proc = run(cmd, capture_output=True) if proc.returncode != 0: return [] return [l.strip() for l in proc.stdout.splitlines() if l.strip()] # Dev usa override; Prod no dev_running = running_ids(dev_env, include_override=True) prod_running = running_ids(prod_env, include_override=False) any_running = bool(dev_running or prod_running) if any_running: print_header("Contenedores activos detectados") info(f"DESARROLLO: {len(dev_running)} contenedor(es) en ejecución.") info(f"PRODUCCIÓN: {len(prod_running)} contenedor(es) en ejecución.\n") options = [] if dev_running: options.append(("1", "Apagar entorno de DESARROLLO", ("development", True))) if prod_running: options.append(("2", "Apagar entorno de PRODUCCIÓN", ("production", False))) options.append(("3", "Mantener todo como está y salir", None)) print("Selecciona una opción:") for key, label, _ in options: 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": ok("Se mantiene el estado actual.") sys.exit(0) env_key, _include_override = selected[2] info(f"Intentando apagar entorno de {env_key.upper()}…") bring_down(env_key) # ya respeta --env-file y el include_override de prod no usa override ok("Listo.") break else: info("No hay contenedores activos del proyecto.") def main(): try: check_prereqs() detect_and_offer_shutdown() 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()