diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 7df9df0..b25873f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -160,12 +160,12 @@ services: # - suitecoffee-net volumes: - dev-tenants-data: - dev-suitecoffee-data: - dev-npm_data: - dev-npm_letsencrypt: - dev-dbeaver_logs: - dev-dbeaver_workspace: + tenants-data: + suitecoffee-data: + npm_data: + npm_letsencrypt: + dbeaver_logs: + dbeaver_workspace: networks: suitecoffee-net: diff --git a/docker-compose.yml b/docker-compose.yml index 2c791f9..bdaaafb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ # docker-compose.yml -# Docker Comose para entorno deproducción o production. +# Docker Comose para entorno de producción o production. +name: ${COMPOSE_PROJECT_NAME:-suitecoffee} services: diff --git a/suitecoffee.py b/suitecoffee.py index e69de29..d661ae9 100644 --- a/suitecoffee.py +++ b/suitecoffee.py @@ -0,0 +1,276 @@ +#!/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()