Varios cambios realizados en cuando a la organización de los compose de docker.

Se adoptó la versión actualmente recomandada por el equipo de docker el formado "compose.yaml"

También se crearon nuevos scripts y actualizaron algunos para adaptarse a los nuevos archivos.
This commit is contained in:
Mateo Saldain 2025-08-19 18:26:21 +00:00
parent 0d1de7f7e2
commit c42814f963
19 changed files with 1671 additions and 535 deletions

3
.gitignore vendored
View File

@ -1,6 +1,9 @@
# Ignorar los directorios de dependencias
node_modules/
# Ignorar los volumenes respaldados
docker-volumes*
# Ignorar las carpetas de bases de datos
.db/

View File

@ -1,30 +1,5 @@
#!/usr/bin/env python3
"""
backup_compose_volumes.py
-------------------------
Export (compress) every Docker *volume* that belongs to a Docker Compose project.
DISCOVERY MODES
- By label (default): looks for label com.docker.compose.project=<project>
- By name prefix (fallback/optional): looks for volume names starting with <project>_ or <project>-
This helps when volumes were created with a Compose project like "suitecoffee_dev" or "suitecoffee_prod"
and you're passing "SuiteCoffee" (capitalized) or when some volumes lack labels.
Usage examples
--------------
python3 backup_compose_volumes.py -p suitecoffee_dev
python3 backup_compose_volumes.py -p suitecoffee_prod -o /backups/suitecoffee
python3 backup_compose_volumes.py -p SuiteCoffee --discovery auto
python3 backup_compose_volumes.py -p suitecoffee --discovery name # treat -p as a name prefix
python3 backup_compose_volumes.py --list-only # just list what would be backed up
Notes
-----
- You generally want to pass the EXACT Compose project name (e.g., "suitecoffee_dev").
- Docker Compose sets project names in lowercase; labels are case-sensitive.
- If zero volumes are found by label, this script tries lowercase and name-prefix fallback automatically.
"""
# -*- coding: utf-8 -*-
import argparse
import datetime
@ -34,7 +9,18 @@ import pathlib
import shlex
import subprocess
import sys
from typing import List, Dict
from typing import List, Dict, Tuple, Optional, Set
PROJECT_ROOT = pathlib.Path.cwd()
COMPOSE_BASE = PROJECT_ROOT / "compose.yaml"
COMPOSE_DEV = PROJECT_ROOT / "compose.dev.yaml"
COMPOSE_PROD = PROJECT_ROOT / "compose.prod.yaml"
COMPOSE_NPM = PROJECT_ROOT / "compose.npm.yaml"
COMPOSE_DBVR = PROJECT_ROOT / "compose.dbeaver.yaml"
GLOBAL_DEFAULT_PROJECT = "suitecoffee" # proyecto global (NPM/DBeaver)
# ---------- Shell utils ----------
def run(cmd: List[str], check=True, capture_output=True, text=True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
@ -43,42 +29,234 @@ def which(program: str) -> bool:
from shutil import which as _which
return _which(program) is not None
def detect_project_name(args_project: str) -> str:
if args_project:
return args_project
env_name = os.environ.get("COMPOSE_PROJECT_NAME")
if env_name:
return env_name
return pathlib.Path.cwd().name.replace(" ", "_")
# ---------- Docker volume discovery ----------
def docker_volume_ls_json(filters: List[str]) -> List[Dict[str, str]]:
"""
Devuelve objetos de 'docker volume ls' (formato json por entrada).
Soporta filtros como '--filter label=...'.
"""
cmd = ["docker", "volume", "ls", "--format", "{{json .}}"]
for f in filters:
cmd += ["--filter", f]
try:
cp = run(cmd)
except subprocess.CalledProcessError:
return []
out = []
for line in cp.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
def list_by_label(project: str) -> List[Dict[str, str]]:
def docker_volume_ls_names(filters: List[str]) -> List[str]:
"""Devuelve solo los nombres (Name) con filtros aplicados."""
rows = docker_volume_ls_json(filters)
names = []
for v in rows:
name = v.get("Name")
if name:
names.append(name)
return names
def list_by_label_project(project: str) -> List[Dict[str, str]]:
return docker_volume_ls_json([f"label=com.docker.compose.project={project}"])
def list_by_name_prefix(prefix: str) -> List[Dict[str, str]]:
# docker volume ls has no "name prefix" filter; we filter client-side.
vols = docker_volume_ls_json([])
keep = []
for v in vols:
name = v.get("Name") or v.get("Driver") # Name should be present
name = v.get("Name")
if not name:
continue
if name.startswith(prefix + "_") or name.startswith(prefix + "-") or name == prefix:
keep.append(v)
return keep
def normalize_project_name(p: str) -> str:
return (p or "").replace(" ", "_")
# ---------- Compose config parsing ----------
def compose_config_json(files: List[pathlib.Path]) -> Optional[dict]:
if not files or not all(p.exists() for p in files):
return None
cmd = ["docker", "compose"]
for f in files:
cmd += ["-f", str(f)]
cmd += ["config", "--format", "json"]
try:
cp = run(cmd)
return json.loads(cp.stdout or "{}")
except Exception:
return None
def extract_short_volume_names_from_config(cfg: dict) -> Set[str]:
"""
Extrae short names de volúmenes usados en services[].volumes (type: volume)
y las claves del toplevel 'volumes'.
"""
names: Set[str] = set()
if not cfg:
return names
# services[].volumes
services = cfg.get("services") or {}
for svc in services.values():
vols = svc.get("volumes") or []
for m in vols:
# en JSON canonical, cada mount es un dict con 'type', 'source', 'target', ...
if isinstance(m, dict) and m.get("type") == "volume":
src = m.get("source")
if isinstance(src, str) and src:
names.add(src)
# top-level volumes (claves)
top_vols = cfg.get("volumes") or {}
if isinstance(top_vols, dict):
for k in top_vols.keys():
if isinstance(k, str) and k:
names.add(k)
return names
def docker_compose_name_from(files: List[pathlib.Path]) -> Optional[str]:
cfg = compose_config_json(files)
if cfg and isinstance(cfg, dict):
name = cfg.get("name")
if name:
return name
return None
def read_compose_project_from_env(env_path: pathlib.Path) -> Optional[str]:
try:
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("COMPOSE_PROJECT_NAME="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return None
def base_folder_slug() -> str:
return PROJECT_ROOT.name.lower().replace(" ", "_")
def candidates_for_env(env: str) -> List[str]:
cand: List[str] = []
if env == "development":
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_DEV])
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.development")
n3 = f"{base_folder_slug()}_dev"
n4 = f"{base_folder_slug()}-dev"
cand.extend([n1, n2, n3, n4, base_folder_slug()])
elif env == "production":
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_PROD])
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.production")
n3 = f"{base_folder_slug()}_prod"
n4 = f"{base_folder_slug()}-prod"
cand.extend([n1, n2, n3, n4, base_folder_slug()])
# dedup preservando orden
seen = set(); ordered = []
for x in cand:
if x and x not in seen:
seen.add(x); ordered.append(x)
return ordered
def candidates_for_global() -> List[str]:
cand: List[str] = []
# nombres desde compose globales
if COMPOSE_NPM.exists():
n = docker_compose_name_from([COMPOSE_NPM])
if n: cand.append(n)
if COMPOSE_DBVR.exists():
n = docker_compose_name_from([COMPOSE_DBVR])
if n and n not in cand: cand.append(n)
# fallback esperados
if GLOBAL_DEFAULT_PROJECT not in cand: cand.append(GLOBAL_DEFAULT_PROJECT)
bf = base_folder_slug()
if bf not in cand: cand.append(bf)
return cand
# ---------- Nueva detección por grupo: COMPOSE + labels ----------
def detect_group_volumes_with_compose(filesets: List[List[pathlib.Path]],
project_candidates: List[str]) -> Tuple[Optional[str], str, List[str]]:
"""
filesets: lista de listas de archivos compose a considerar (dev=[base,dev], prod=[base,prod],
global=[[npm], [dbeaver]] -> dos sets para unir shortnames).
Devuelve (project_seleccionado, metodo, [nombres_de_volumen]).
"""
# 1) Unir shortnames de todos los filesets
shortnames: Set[str] = set()
for files in filesets:
cfg = compose_config_json(files)
shortnames |= extract_short_volume_names_from_config(cfg)
# 2) Si hay shortnames, probar a buscar por (project,label.volume)
if shortnames:
for proj in project_candidates:
# Buscar cada shortname con ambos labels
found: List[str] = []
for sn in sorted(shortnames):
names = docker_volume_ls_names([
f"label=com.docker.compose.project={proj}",
f"label=com.docker.compose.volume={sn}"
])
if names:
found.extend(names)
# dedup preservando orden
if found:
seen = set(); ordered = []
for n in found:
if n not in seen:
seen.add(n); ordered.append(n)
return proj, f"compose+labels:{proj}", ordered
# 3) Fallback: probar cualquier volumen del proyecto (label) o por prefijo
for proj in project_candidates:
method, rows = discover_volumes_for_project(proj)
if rows:
return proj, f"fallback:{method}", [r.get("Name") for r in rows if r.get("Name")]
# 4) Nada
first = project_candidates[0] if project_candidates else None
return first, "none", []
def discover_volumes_for_project(project_raw: str) -> Tuple[str, List[Dict[str, str]]]:
"""
Método previo de respaldo: por label de proyecto y prefijo (para CLI y fallback).
"""
project_norm = normalize_project_name(project_raw)
project_lower = project_norm.lower()
vols = list_by_label_project(project_norm)
if vols:
return f"label:{project_norm}", vols
vols2 = list_by_label_project(project_lower)
if vols2:
return f"label:{project_lower}", vols2
by_name = list_by_name_prefix(project_norm)
if by_name:
return f"name-prefix:{project_norm}", by_name
by_name2 = list_by_name_prefix(project_lower)
if by_name2:
return f"name-prefix:{project_lower}", by_name2
return "none", []
# ---------- Backup helpers ----------
def ensure_alpine_image():
try:
run(["docker", "image", "inspect", "alpine:latest"])
@ -86,13 +264,30 @@ def ensure_alpine_image():
print("Pulling alpine:latest ...")
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False)
def build_archive_name(project: str, volume_name: str, ts: str) -> str:
"""
Construye el nombre del .tar.gz evitando duplicar el prefijo del proyecto.
- Si volume_name ya empieza con '<project>_' o '<project>-', se usa tal cual.
- Si no, se antepone '<project>_'.
Resultado: <project>_<shortname>-<ts>.tar.gz
"""
proj_token = project.lower().replace(" ", "_")
v_lower = volume_name.lower()
if v_lower.startswith(proj_token + "_") or v_lower.startswith(proj_token + "-"):
base = volume_name
else:
base = f"{proj_token}_{volume_name}"
return f"{base}-{ts}.tar.gz"
def backup_volume(volume_name: str, out_dir: pathlib.Path, archive_name: str, dry_run: bool = False) -> int:
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / archive_name
out_dir_abs = out_dir.resolve()
out_path = out_dir_abs / archive_name
docker_cmd = [
"docker", "run", "--rm",
"-v", f"{volume_name}:/volume:ro",
"-v", f"{str(out_dir)}:/backup",
"-v", f"{str(out_dir_abs)}:/backup",
# "--user", f"{os.getuid()}:{os.getgid()}",
"alpine:latest",
"sh", "-lc",
f"tar czf /backup/{shlex.quote(out_path.name)} -C /volume ."
@ -103,10 +298,250 @@ def backup_volume(volume_name: str, out_dir: pathlib.Path, archive_name: str, dr
cp = subprocess.run(docker_cmd)
return cp.returncode
def main():
def backup_explicit(volume_names: List[str], ts: str, output_dir: Optional[str], dry_run: bool, prefix_project: Optional[str]) -> int:
"""
Respalda exactamente los volúmenes indicados.
- Directorio por defecto: ./docker-volumes-<ts>
- Nombre de archivo: build_archive_name(prefix_project, volume_name, ts)
"""
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
if not dry_run:
ensure_alpine_image()
failures = []
for vname in volume_names:
if not vname:
continue
archive = build_archive_name(prefix_project or "", vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
return 1
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
return 0
def backup_group(project_name: str, ts: str, output_dir: Optional[str] = None,
dry_run: bool = False, excludes: Optional[List[str]] = None) -> int:
"""
Fallback legacy (label/prefix). Mantiene coherencia con nombres y directorio por defecto.
"""
method, rows = discover_volumes_for_project(project_name)
print_header(f"Proyecto '{project_name}': {len(rows)} volumen(es) detectado(s) (método: {method})")
for v in rows:
print(" -", v.get("Name"))
if not rows:
warn("No hay volúmenes para respaldar.")
return 0
vols = [v.get("Name") for v in rows if v.get("Name")]
if excludes:
excl = set(excludes)
vols = [n for n in vols if n not in excl]
if not vols:
warn("Tras aplicar exclusiones, no quedó nada por respaldar.")
return 0
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
if not dry_run:
ensure_alpine_image()
failures = []
for vname in vols:
archive = build_archive_name(project_name, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
return 1
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
return 0
# ---------- UI helpers ----------
def yes_no(prompt: str, default="n") -> bool:
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",""):
return True
if resp in ("n","no"):
return False
print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.")
def print_header(title: str):
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)
# ---------- Menú interactivo ----------
def interactive_menu():
if not which("docker"):
fail("ERROR: 'docker' no está en el PATH.")
try:
run(["docker", "version"], check=True, capture_output=True)
except subprocess.CalledProcessError:
fail("ERROR: No se puede hablar con el daemon de Docker. ¿Está corriendo? ¿Tu usuario está en el grupo 'docker'?")
# DEV
dev_candidates = candidates_for_env("development") if COMPOSE_BASE.exists() and COMPOSE_DEV.exists() else []
dev_proj, dev_method, dev_names = detect_group_volumes_with_compose(
filesets=[[COMPOSE_BASE, COMPOSE_DEV]] if dev_candidates else [],
project_candidates=dev_candidates
)
# PROD
prod_candidates = candidates_for_env("production") if COMPOSE_BASE.exists() and COMPOSE_PROD.exists() else []
prod_proj, prod_method, prod_names = detect_group_volumes_with_compose(
filesets=[[COMPOSE_BASE, COMPOSE_PROD]] if prod_candidates else [],
project_candidates=prod_candidates
)
# GLOBAL = NPM + DBEAVER (unir shortnames de ambos)
global_candidates = candidates_for_global()
global_filesets = []
if COMPOSE_NPM.exists():
global_filesets.append([COMPOSE_NPM])
if COMPOSE_DBVR.exists():
global_filesets.append([COMPOSE_DBVR])
glob_proj, glob_method, glob_names = detect_group_volumes_with_compose(
filesets=global_filesets,
project_candidates=global_candidates
)
# Resumen
print_header("Resumen de volúmenes detectados")
if dev_proj:
info(f"DESARROLLO ({dev_proj}): {len(dev_names)} volumen(es) (método: {dev_method})")
else:
info("DESARROLLO: archivos compose no encontrados.")
if prod_proj:
info(f"PRODUCCIÓN ({prod_proj}): {len(prod_names)} volumen(es) (método: {prod_method})")
else:
info("PRODUCCIÓN: archivos compose no encontrados.")
if glob_proj:
info(f"GLOBALES ({glob_proj}): {len(glob_names)} volumen(es) (método: {glob_method})")
else:
info("GLOBALES: no se detectaron archivos compose globales.")
print()
# Menú
options = {}
key = 1
if dev_proj:
print(f" {key}) Respaldar volúmenes de DESARROLLO ({dev_proj})")
options[str(key)] = ("backup_explicit", dev_proj, dev_names); key += 1
if prod_proj:
print(f" {key}) Respaldar volúmenes de PRODUCCIÓN ({prod_proj})")
options[str(key)] = ("backup_explicit", prod_proj, prod_names); key += 1
if glob_proj:
print(f" {key}) Respaldar volúmenes GLOBALES ({glob_proj})")
options[str(key)] = ("backup_explicit", glob_proj, glob_names); key += 1
# TODOS: unión deduplicada por nombre (respalda 1 vez cada volumen)
groups = []
if dev_proj: groups.append( (dev_proj, dev_names) )
if prod_proj: groups.append( (prod_proj, prod_names) )
if glob_proj: groups.append( (glob_proj, glob_names) )
if len(groups) >= 2:
print(f" {key}) Respaldar TODOS los grupos detectados")
options[str(key)] = ("backup_all_explicit", groups); key += 1
print(f" {key}) Salir")
exit_key = str(key)
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
while True:
choice = input("> ").strip()
if choice == exit_key:
ok("Saliendo.")
sys.exit(0)
if choice not in options:
print("Opción inválida.")
continue
action = options[choice][0]
dry = yes_no("¿Dry-run (no escribir archivos)?", default="n")
outd = input(f"Directorio de salida (vacío = ./docker-volumes-{ts}): ").strip() or None
excl_input = input("Excluir volúmenes (nombres separados por coma, vacío = ninguno): ").strip()
excludes = set(e.strip() for e in excl_input.split(",") if e.strip()) if excl_input else set()
if action == "backup_explicit":
_, proj, names = options[choice]
names = [n for n in names if n not in excludes]
if not names:
warn("No hay volúmenes para respaldar.")
sys.exit(0)
rc = backup_explicit(names, ts, output_dir=outd, dry_run=dry, prefix_project=proj)
sys.exit(rc)
elif action == "backup_all_explicit":
_, groups_payload = options[choice]
vol_to_proj: Dict[str, str] = {}
for proj, names in groups_payload:
for n in names:
if n not in excludes and n not in vol_to_proj:
vol_to_proj[n] = proj
if not vol_to_proj:
warn("No hay volúmenes para respaldar.")
sys.exit(0)
if not dry:
ensure_alpine_image()
out_dir = pathlib.Path(outd) if outd else (PROJECT_ROOT / f"docker-volumes-{ts}")
failures = []
for vname, proj in vol_to_proj.items():
archive = build_archive_name(proj, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=dry)
if rc != 0:
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
failures.append(vname)
if failures:
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
sys.exit(1)
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
sys.exit(0)
# ---------- CLI legacy (se mantiene) ----------
def detect_project_name(args_project: Optional[str]) -> str:
if args_project:
return args_project
env_name = os.environ.get("COMPOSE_PROJECT_NAME")
if env_name:
return env_name
return PROJECT_ROOT.name.replace(" ", "_")
def cli_main():
parser = argparse.ArgumentParser(description="Export (compress) every Docker volume of a Docker Compose project.")
parser.add_argument("-p", "--project", help="Compose project or prefix (see --discovery).")
parser.add_argument("-o", "--output", help="Output directory (default: ./docker-volumes-backup-<timestamp>).")
parser.add_argument("-o", "--output", help="Output directory (default: ./docker-volumes-<timestamp>).")
parser.add_argument("--exclude", nargs="*", default=[], help="Volume names to exclude (space-separated).")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it.")
parser.add_argument("--timestamp", default=datetime.datetime.now().strftime("%Y%m%d-%H%M%S"),
@ -114,41 +549,40 @@ def main():
parser.add_argument("--discovery", choices=["auto","label","name"], default="auto",
help="How to discover volumes: 'label' (strict), 'name' (prefix), or 'auto' (default).")
parser.add_argument("--list-only", action="store_true", help="Only list volumes that would be backed up and exit.")
parser.add_argument("--menu", action="store_true", help="Launch interactive menu instead of CLI behavior.")
args = parser.parse_args()
if args.menu or not args.project:
interactive_menu()
return
if not which("docker"):
print("ERROR: 'docker' is not on PATH. Please install Docker and/or add it to PATH.", file=sys.stderr)
print("ERROR: 'docker' not on PATH.", file=sys.stderr)
sys.exit(2)
project_raw = detect_project_name(args.project)
project_norm = project_raw.replace(" ", "_")
project_norm = normalize_project_name(project_raw)
project_lower = project_norm.lower()
ts = args.timestamp
out_dir = pathlib.Path(args.output) if args.output else pathlib.Path(f"./docker-volumes-backup-{ts}")
out_dir = pathlib.Path(args.output) if args.output else (PROJECT_ROOT / f"docker-volumes-{ts}")
# Ensure daemon available
try:
run(["docker", "version"], check=True, capture_output=True)
except subprocess.CalledProcessError:
print("ERROR: Unable to talk to the Docker daemon. Are you in the 'docker' group? Is the daemon running?", file=sys.stderr)
print("ERROR: Docker daemon not reachable.", file=sys.stderr)
sys.exit(2)
# Descubrimiento legacy por label/prefijo (se mantiene para compatibilidad)
selected = []
method_used = None
# Discovery
if args.discovery in ("auto","label"):
vols = list_by_label(project_norm)
vols = list_by_label_project(project_norm)
if vols:
selected = vols; method_used = f"label:{project_norm}"
elif args.discovery == "auto":
vols2 = list_by_label(project_lower)
else:
vols2 = list_by_label_project(project_lower)
if vols2:
selected = vols2; method_used = f"label:{project_lower}"
if not selected and args.discovery in ("auto","name"):
# Treat project as a prefix
# Try exact, then lowercase
if not selected:
by_name = list_by_name_prefix(project_norm)
if by_name:
selected = by_name; method_used = f"name-prefix:{project_norm}"
@ -158,20 +592,16 @@ def main():
selected = by_name2; method_used = f"name-prefix:{project_lower}"
if not selected:
print(f"No volumes found for project/prefix '{project_raw}'. Tried methods:")
print(f" - label:{project_norm}")
print(f" - label:{project_lower}")
print(f" - name-prefix:{project_norm} (prefix_*, prefix-*)")
print(f" - name-prefix:{project_lower} (prefix_*, prefix-*)")
print(f"No volumes found for project/prefix '{project_raw}'.")
sys.exit(0)
exclude_set = set(args.exclude or [])
selected = [v for v in selected if v.get("Name") not in exclude_set]
names = [v.get("Name") for v in selected if v.get("Name") not in exclude_set]
print(f"Discovery method: {method_used}")
print(f"Volumes discovered: {len(selected)}")
for v in selected:
print(" -", v.get("Name"))
print(f"Volumes discovered: {len(names)}")
for n in names:
print(" -", n)
if args.list_only:
return
@ -180,13 +610,8 @@ def main():
ensure_alpine_image()
failures = []
for v in selected:
vname = v.get("Name")
if not vname:
continue
# Determine a 'project token' for filename: take the leading prefix before first '_' or '-'
prefix = project_lower
archive = f"{prefix}-{vname}-{ts}.tar.gz"
for vname in names:
archive = build_archive_name(project_lower, vname, ts)
print(f"Backing up volume: {vname} -> {archive}")
rc = backup_volume(vname, out_dir, archive, dry_run=args.dry_run)
if rc != 0:
@ -199,5 +624,10 @@ def main():
else:
print("\nAll done. Archives written to:", str(out_dir.resolve()))
# ---------- Entry point ----------
if __name__ == "__main__":
main()
if len(sys.argv) == 1:
interactive_menu()
else:
cli_main()

47
compose.dbeaver.yaml Normal file
View File

@ -0,0 +1,47 @@
# compose.dbeaver.yaml
name: suitecoffee
services:
dbeaver:
image: dbeaver/cloudbeaver:latest
ports:
- 8978:8978
environment:
TZ: America/Montevideo
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
suitecoffee_prod_net:
aliases:
- prod-auth
- prod-app
- prod-db
- prod-tenants
suitecoffee_dev_net:
aliases:
- dev-auth
- dev-app
- dev-db
- dev-tenants
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
# networks:
# - suitecoffee_dev_net
# - suitecoffee_prod_net
restart: unless-stopped
networks:
suitecoffee_dev_net:
external: true
suitecoffee_prod_net:
external: true
volumes:
dbeaver_logs:
dbeaver_workspace:

110
compose.dev.yaml Normal file
View File

@ -0,0 +1,110 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
services:
app:
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
image: node:20-bookworm
expose:
- ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
# healthcheck:
# # IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
# test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 20s
# restart: unless-stopped
networks:
net:
aliases: [dev-app]
command: npm run dev
auth:
image: node:20-bookworm
# depends_on:
# db:
# condition: service_healthy
expose:
- ${AUTH_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
# restart: unless-stopped
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
networks:
net:
aliases: [dev-auth]
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
# interval: 5s
# timeout: 3s
# retries: 20
# start_period: 10s
networks:
net:
aliases: [dev-db]
# restart: unless-stopped
tenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- tenants-db:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
# interval: 5s
# timeout: 3s
# retries: 20
# start_period: 10s
networks:
net:
aliases: [dev-tenants]
# restart: unless-stopped
volumes:
tenants-db:
suitecoffee-db:
networks:
net:
driver: bridge

46
compose.npm.yaml Normal file
View File

@ -0,0 +1,46 @@
# compose.npm.yaml
name: suitecoffee
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración
environment:
TZ: America/Montevideo
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
networks:
suitecoffee_prod_net:
aliases:
- prod-auth
- prod-app
- prod-db
- prod-tenants
suitecoffee_dev_net:
aliases:
- dev-auth
- dev-app
- dev-db
- dev-tenants
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
networks:
suitecoffee_dev_net:
external: true
suitecoffee_prod_net:
external: true
volumes:
npm_data:
npm_letsencrypt:

108
compose.prod.yaml Normal file
View File

@ -0,0 +1,108 @@
# compose.prod.yml
# Docker Comose para entorno de producción o production.
services:
app:
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
build:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- ${APP_LOCAL_PORT}
volumes:
- ./services/app:/app
env_file:
- ./services/app/.env.production
environment:
- NODE_ENV=${NODE_ENV}
# healthcheck:
# # IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
# test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 20s
# restart: unless-stopped
networks:
net:
aliases: [prod-app]
command: npm run start
auth:
# depends_on:
# db:
# condition: service_healthy
build:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- ${AUTH_LOCAL_PORT}
volumes:
- ./services/auth:/app
env_file:
- ./services/auth/.env.production
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
# restart: unless-stopped
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 15s
networks:
net:
aliases: [prod-auth]
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
# interval: 5s
# timeout: 3s
# retries: 20
# start_period: 10s
networks:
net:
aliases: [prod-db]
# restart: unless-stopped
tenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- tenants-db:/var/lib/postgresql/data
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
# interval: 5s
# timeout: 3s
# retries: 20
# start_period: 10s
networks:
net:
aliases: [prod-tenants]
# restart: unless-stopped
volumes:
tenants-db:
suitecoffee-db:
networks:
net:
driver: bridge

108
compose.yaml Normal file
View File

@ -0,0 +1,108 @@
# compose.yml
# Comose base
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
app:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
# build:
# context: ./services/app
# dockerfile: Dockerfile.production
# expose:
# - ${APP_LOCAL_PORT}
# volumes:
# - ./services/app:/app
# env_file:
# - ./services/app/.env.production
# environment:
# - NODE_ENV=${NODE_ENV}
# command: npm run start
healthcheck:
# IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
# networks:
# net:
# aliases: [prod-app]
restart: unless-stopped
auth:
depends_on:
db:
condition: service_healthy
# build:
# context: ./services/auth
# dockerfile: Dockerfile.production
# expose:
# - ${AUTH_LOCAL_PORT}
# volumes:
# - ./services/auth:/app
# env_file:
# - ./services/auth/.env.production
# environment:
# - NODE_ENV=${NODE_ENV}
# command: npm run start
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
# networks:
# net:
# aliases: [prod-auth]
restart: unless-stopped
db:
image: postgres:16
# environment:
# POSTGRES_DB: ${DB_NAME}
# POSTGRES_USER: ${DB_USER}
# POSTGRES_PASSWORD: ${DB_PASS}
# volumes:
# - suitecoffee-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
# networks:
# net:
# aliases: [prod-db]
restart: unless-stopped
tenants:
image: postgres:16
# environment:
# POSTGRES_DB: ${TENANTS_DB_NAME}
# POSTGRES_USER: ${TENANTS_DB_USER}
# POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
# volumes:
# - tenants-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
# networks:
# net:
# aliases: [prod-tenants]
restart: unless-stopped
# volumes:
# tenants-db:
# suitecoffee-db:
# networks:
# net:
# driver: bridge

View File

@ -1,148 +0,0 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
depends_on:
app:
condition: service_healthy
auth:
condition: service_healthy
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración NPM
- "443:443" # HTTPS público
volumes:
- npm_data:/data # config + DB (SQLite)
- npm_letsencrypt:/etc/letsencrypt
networks:
- suitecoffee-net
app:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
image: node:20-bookworm
ports:
- 3000:3000
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
healthcheck:
# IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
networks:
- suitecoffee-net
auth:
image: node:20-bookworm
depends_on:
db:
condition: service_healthy
ports:
- 4000:4000
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
networks:
- suitecoffee-net
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
ports:
- ${DB_LOCAL_PORT}:${DB_DOCKER_PORT}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
tenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- tenants-db:/var/lib/postgresql/data
ports:
- ${TENANTS_DB_LOCAL_PORT}:${TENANTS_DB_DOCKER_PORT}
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
dbeaver:
image: dbeaver/cloudbeaver:latest
# depends_on:
# tenants:
# condition: service_healthy
# db:
# condition: service_healthy
restart: unless-stopped
ports:
- 8978:8978
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
- suitecoffee-net
volumes:
tenants-db:
suitecoffee-db:
npm_data:
npm_letsencrypt:
dbeaver_logs:
dbeaver_workspace:
networks:
suitecoffee-net:
driver: bridge

View File

@ -1,138 +0,0 @@
# docker-compose.yml
# Docker Comose para entorno de producción o production.
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
npm:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
depends_on:
app:
condition: service_healthy
auth:
condition: service_healthy
ports:
- "80:80" # HTTP público
- "81:81" # UI de administración NPM
- "443:443" # HTTPS público
volumes:
- npm_data:/data # config + DB (SQLite)
- npm_letsencrypt:/etc/letsencrypt
networks:
- suitecoffee-net
app:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
build:
context: ./services/app
dockerfile: Dockerfile.production
volumes:
- ./services/app:/app
env_file:
- ./services/app/.env.production
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
healthcheck:
# IMPORTANTE: asegurate de tener curl instalado en la imagen de app (ver nota abajo)
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
networks:
- suitecoffee-net
auth:
depends_on:
db:
condition: service_healthy
build:
context: ./services/auth
dockerfile: Dockerfile.production
volumes:
- ./services/auth:/app
env_file:
- ./services/auth/.env.production
environment:
- NODE_ENV=${NODE_ENV}
command: npm run start
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
networks:
- suitecoffee-net
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- suitecoffee-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
tenants:
image: postgres:16
environment:
POSTGRES_DB: ${TENANTS_DB_NAME}
POSTGRES_USER: ${TENANTS_DB_USER}
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
volumes:
- tenants-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- suitecoffee-net
dbeaver:
image: dbeaver/cloudbeaver:latest
depends_on:
tenants:
condition: service_healthy
db:
condition: service_healthy
restart: unless-stopped
ports:
- "8978:8978"
volumes:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
- suitecoffee-net
volumes:
tenants-db:
suitecoffee-db:
npm_data:
npm_letsencrypt:
dbeaver_logs:
dbeaver_workspace:
networks:
suitecoffee-net:
driver: bridge

466
restore_compose_volumes.py Normal file
View File

@ -0,0 +1,466 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
restore_compose_volumes.py
--------------------------
Restaura volúmenes desde backups generados por backup_compose_volumes.py.
- Busca carpetas ./docker-volumes-<timestamp>
- Lee .tar.gz (nombres: <volume_name>-<YYYYMMDD-HHMMSS>.tar.gz)
- Dos modos:
1) Tradicional (sin labels)
2) Reconocido por Compose (aplica labels com.docker.compose.* para evitar el warning)
Además:
- Si un volumen existe y está en uso, ofrece detener y eliminar contenedores que lo usan
para poder recrearlo con labels correctos (solo en modo 2).
"""
import os
import re
import sys
import json
import shlex
import pathlib
import subprocess
from typing import List, Tuple, Dict, Optional
PROJECT_ROOT = pathlib.Path.cwd()
BACKUP_DIR_PATTERN = re.compile(r"^docker-volumes-\d{8}-\d{6}$")
ARCHIVE_PATTERN = re.compile(r"^(?P<basename>.+)-(?P<ts>\d{8}-\d{6})\.tar\.gz$")
# ---------- utils ----------
def run(cmd: List[str], check: bool = False, capture_output: bool = True, text: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
def which(prog: str) -> bool:
from shutil import which as _w
return _w(prog) is not None
def fail(msg: str):
print(f"{msg}")
sys.exit(1)
def ok(msg: str):
print(f"{msg}")
def info(msg: str):
print(f"{msg}")
def warn(msg: str):
print(f"! {msg}")
def yes_no(prompt: str, default: str = "n") -> bool:
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",""):
return True
if resp in ("n","no"):
return False
print("Respuesta no reconocida. Responde 'y' o 'n'.")
# ---------- docker helpers ----------
def ensure_alpine_image():
try:
run(["docker", "image", "inspect", "alpine:latest"], check=True)
except subprocess.CalledProcessError:
info("Descargando alpine:latest ...")
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False, text=True)
def volume_exists(name: str) -> bool:
try:
run(["docker", "volume", "inspect", name], check=True)
return True
except subprocess.CalledProcessError:
return False
def inspect_volume_labels(name: str) -> Dict[str, str]:
try:
cp = run(["docker", "volume", "inspect", name, "--format", "{{json .Labels}}"], check=True)
return json.loads(cp.stdout or "null") or {}
except subprocess.CalledProcessError:
return {}
def containers_using_volume(name: str) -> List[str]:
# docker ps soporta --filter volume=<name>
try:
cp = run(["docker", "ps", "-a", "--filter", f"volume={name}", "-q"], check=True)
return [l.strip() for l in cp.stdout.splitlines() if l.strip()]
except subprocess.CalledProcessError:
return []
def stop_containers(ids: List[str]) -> None:
if not ids:
return
info("Deteniendo contenedores que usan el volumen...")
run(["docker", "stop"] + ids, check=False, capture_output=False)
def remove_containers(ids: List[str]) -> None:
if not ids:
return
info("Eliminando contenedores detenidos que usan el volumen...")
run(["docker", "rm"] + ids, check=False, capture_output=False)
def remove_volume(name: str) -> bool:
try:
run(["docker", "volume", "rm", "-f", name], check=True, capture_output=False)
return True
except subprocess.CalledProcessError as e:
warn(f"No se pudo eliminar volumen '{name}': {e}")
return False
def create_volume(name: str, labels: Optional[Dict[str,str]] = None) -> bool:
cmd = ["docker", "volume", "create"]
if labels:
for k, v in labels.items():
cmd += ["--label", f"{k}={v}"]
cmd.append(name)
try:
run(cmd, check=True, capture_output=False)
return True
except subprocess.CalledProcessError as e:
warn(f"Fallo creando volumen '{name}': {e}")
return False
def restore_into_volume(volume_name: str, backup_dir: pathlib.Path, archive_file: pathlib.Path) -> int:
bdir_abs = backup_dir.resolve()
docker_cmd = [
"docker", "run", "--rm",
"-v", f"{volume_name}:/volume",
"-v", f"{str(bdir_abs)}:/backup",
"alpine:latest",
"sh", "-lc",
f"tar xzf /backup/{shlex.quote(archive_file.name)} -C /volume"
]
proc = subprocess.run(docker_cmd)
return proc.returncode
# ---------- parsing helpers ----------
def find_backup_dirs(root: pathlib.Path) -> List[pathlib.Path]:
dirs = [p for p in root.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return dirs
def find_archives(dirpath: pathlib.Path) -> List[pathlib.Path]:
files = [p for p in dirpath.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
files.sort(key=lambda p: p.name)
return files
def parse_archive_basename(archive_name: str) -> Optional[str]:
m = ARCHIVE_PATTERN.match(archive_name)
if not m:
return None
return m.group("basename")
# ---------- compose label helpers ----------
def derive_labels_auto(volume_name: str) -> Tuple[Optional[str], Optional[str]]:
"""
project = parte antes del primer '_' o '-'
short = resto luego del separador
"""
for sep in ("_", "-"):
if sep in volume_name:
idx = volume_name.find(sep)
return volume_name[:idx], volume_name[idx+1:]
return None, None
def derive_labels_with_fixed_project(volume_name: str, project: str) -> Tuple[str, str]:
"""
Usa project fijo. Si volume_name empieza con '<project>_' o '<project>-', recorta.
"""
p = project
if volume_name.startswith(p + "_"):
return p, volume_name[len(p) + 1:]
if volume_name.startswith(p + "-"):
return p, volume_name[len(p) + 1:]
return p, volume_name
def labels_match_compose(name: str, project: str, short: str) -> bool:
labels = inspect_volume_labels(name)
return (
labels.get("com.docker.compose.project") == project and
labels.get("com.docker.compose.volume") == short
)
# ---------- UI flows ----------
def pick_backup_dir(dirs: List[pathlib.Path]) -> Optional[pathlib.Path]:
if not dirs:
warn("No se encontraron carpetas de backup 'docker-volumes-<timestamp>'.")
return None
print("\nCarpetas de backup encontradas:")
for i, d in enumerate(dirs, 1):
print(f" {i}) {d.name}")
while True:
sel = input("> Elige una carpeta (número) o Enter para cancelar: ").strip()
if not sel:
return None
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
return dirs[int(sel) - 1]
print("Opción inválida.")
def pick_archives(files: List[pathlib.Path]) -> List[pathlib.Path]:
if not files:
warn("No hay archivos .tar.gz en esa carpeta.")
return []
print("\nBackups disponibles:")
for i, f in enumerate(files, 1):
base = parse_archive_basename(f.name) or f.name
print(f" {i}) {f.name} -> volumen: {base}")
print("\nOpciones:")
print(" a) Restaurar TODOS")
print(" s) Seleccionar algunos (ej: 1,3,5)")
while True:
sel = input("> Elige 'a' o 's': ").strip().lower()
if sel == "a":
return files
if sel == "s":
picks = input("> Números separados por coma: ").strip()
idxs = []
try:
for tok in picks.split(","):
tok = tok.strip()
if tok:
idx = int(tok)
idxs.append(idx - 1)
chosen = [files[i] for i in sorted(set(i for i in idxs if 0 <= i < len(files)))]
if chosen:
return chosen
except Exception:
pass
print("Selección inválida.")
else:
print("Opción inválida.")
def pick_restore_mode() -> str:
print("\nModo de restauración:")
print(" 1) Tradicional (sin labels)")
print(" 2) Reconocido por Compose (aplica labels para evitar el warning)")
while True:
sel = input("> Elige 1 o 2: ").strip()
if sel in ("1", "2"):
return sel
print("Opción inválida.")
def confirm_overwrite(volume_name: str) -> bool:
return yes_no(f"El volumen '{volume_name}' ya existe. ¿Sobrescribir (recrear)?", default="n")
# ---------- restore flows ----------
def restore_traditional(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
ensure_alpine_image()
print("\n=== Restauración TRADICIONAL ===\n")
for arch in archives:
vname = parse_archive_basename(arch.name)
if not vname:
warn(f"Nombre de backup no reconocible: {arch.name}, se omite.")
continue
info(f"Volumen: {vname}")
# Tradicional: no cambiamos labels; si existe, restauramos sobre volumen nuevo (recreándolo)
if volume_exists(vname):
# Intentar eliminar: si está en uso, ofrecer detener/remover contenedores
if not confirm_overwrite(vname):
info(" → Omitido (ya existe).")
continue
ids = containers_using_volume(vname)
if ids:
info(f"Contenedores que usan '{vname}': {', '.join(ids)}")
if yes_no("¿Detener y eliminar esos contenedores para continuar?", default="y"):
stop_containers(ids)
remove_containers(ids)
else:
warn(" → No se puede recrear el volumen en uso. Omitido.")
continue
if not remove_volume(vname):
warn(" → No se pudo eliminar el volumen. Omitido.")
continue
if not create_volume(vname):
warn(" → No se pudo crear el volumen, se omite.")
continue
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado.")
else:
warn(f" Falló la restauración (rc={rc}).")
def restore_with_compose_labels(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
"""
Restaura creando volúmenes con labels de Compose para que NO aparezca el warning:
"volume ... already exists but was not created by Docker Compose..."
"""
ensure_alpine_image()
print("\n=== Restauración RECONOCIDA POR COMPOSE (con labels) ===\n")
print("Estrategia de etiquetado:")
print(" 1) Auto (project = prefijo de <vol> antes de '_' o '-', short = resto)")
print(" 2) Fijar un 'project' para todos (p. ej. suitecoffee, suitecoffee_dev, suitecoffee_prod)")
mode = ""
while mode not in ("1", "2"):
mode = input("> Elige 1 o 2: ").strip()
fixed_project = None
if mode == "2":
fixed_project = input("> Indica el 'project' de Compose (exacto): ").strip()
if not fixed_project:
warn("Project vacío, cancelado.")
return
# Previsualización de etiquetas
preview = []
for arch in archives:
vname = parse_archive_basename(arch.name)
if not vname:
continue
if mode == "1":
proj, short = derive_labels_auto(vname)
else:
proj, short = derive_labels_with_fixed_project(vname, fixed_project)
preview.append((arch, vname, proj, short))
print("\nVista previa de etiquetas (project / volume):")
for _, vname, proj, short in preview:
if proj and short:
print(f" {vname} → project='{proj}', volume='{short}'")
else:
print(f" {vname} → (no derivado; se pedirá manualmente)")
if not yes_no("\n¿Confirmar restauración con estas etiquetas?", default="y"):
warn("Cancelado por el usuario.")
return
# Restaurar con labels
for arch, vname, proj, short in preview:
# completar manual si falta
if not proj or not short:
print(f"\nDefinir etiquetas para: {vname}")
proj = input(" project = ").strip()
short = input(" volume = ").strip()
if not proj or not short:
warn(" → Etiquetas incompletas; se omite.")
continue
info(f"\nVolumen: {vname} (labels: project='{proj}', volume='{short}')")
if volume_exists(vname):
# ¿ya tiene labels correctas? entonces solo restauramos datos sin recrear
if labels_match_compose(vname, proj, short):
info(" Volumen ya tiene labels de Compose correctas. Sobrescribiendo datos...")
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado (labels ya correctas).")
else:
warn(f" Falló la restauración (rc={rc}).")
continue
# Pedir permiso para detener/eliminar contenedores y recrear volumen con labels correctas
if not yes_no(" El volumen existe sin labels correctas. ¿Detener/eliminar contenedores y recrearlo con labels para evitar el warning?", default="y"):
warn(" → Omitido (mantiene warning de Compose).")
continue
ids = containers_using_volume(vname)
if ids:
info(f" Contenedores que usan '{vname}': {', '.join(ids)}")
stop_containers(ids)
remove_containers(ids)
if not remove_volume(vname):
warn(" → No se pudo eliminar el volumen. Omitido.")
continue
labels = {
"com.docker.compose.project": proj,
"com.docker.compose.volume": short,
}
if not create_volume(vname, labels=labels):
warn(" → No se pudo crear el volumen con labels. Omitido.")
continue
rc = restore_into_volume(vname, backup_dir, arch)
if rc == 0:
ok(" Restaurado con labels de Compose (warning resuelto).")
else:
warn(f" Falló la restauración (rc={rc}).")
# ---------- main ----------
def main():
if not which("docker"):
fail("No se encontró 'docker' en el PATH.")
try:
run(["docker", "version"], check=True)
except subprocess.CalledProcessError:
fail("No se puede comunicar con el daemon de Docker. ¿Está corriendo?")
# Elegir carpeta docker-volumes-<ts>
dirs = [p for p in PROJECT_ROOT.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not dirs:
warn("No hay carpetas de backup 'docker-volumes-<timestamp>'.")
return
print("\nCarpetas de backup encontradas:")
for i, d in enumerate(dirs, 1):
print(f" {i}) {d.name}")
chosen = None
while not chosen:
sel = input("> Elige una carpeta (número) o Enter para salir: ").strip()
if not sel:
warn("Cancelado."); return
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
chosen = dirs[int(sel)-1]
else:
print("Opción inválida.")
# Archivos en carpeta
archives = [p for p in chosen.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
archives.sort(key=lambda p: p.name)
if not archives:
warn("No hay .tar.gz en esa carpeta."); return
print("\nBackups disponibles:")
for i, f in enumerate(archives, 1):
base = parse_archive_basename(f.name) or f.name
print(f" {i}) {f.name} -> volumen: {base}")
print("\nOpciones de selección:")
print(" a) Restaurar TODOS")
print(" s) Elegir algunos (ej: 1,3,5)")
selected: List[pathlib.Path] = []
while not selected:
mode = input("> Elige 'a' o 's': ").strip().lower()
if mode == "a":
selected = archives
elif mode == "s":
picks = input("> Números separados por coma: ").strip()
try:
idxs = [int(x.strip())-1 for x in picks.split(",") if x.strip()]
selected = [archives[i] for i in sorted(set(i for i in idxs if 0 <= i < len(archives)))]
except Exception:
selected = []
else:
print("Opción inválida.")
# Modo de restauración
choice = pick_restore_mode()
if choice == "1":
restore_traditional(chosen, selected)
else:
restore_with_compose_labels(chosen, selected)
ok("\nProceso finalizado.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n✓ Interrumpido por el usuario (Ctrl+C).")

View File

@ -2,8 +2,8 @@
FROM node:22.18
# Definir variables de entorno con valores predeterminados
ARG NODE_ENV=production
ARG PORT=3000
# ARG NODE_ENV=production
# ARG PORT=3000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
@ -17,7 +17,4 @@ RUN npm i
# Copia el resto de la app
COPY . .
# Expone el puerto
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@ -2,8 +2,8 @@
FROM node:22.18
# Definir variables de entorno con valores predeterminados
ARG NODE_ENV=production
ARG PORT=4000
# ARG NODE_ENV=production
# ARG PORT=4000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
@ -17,7 +17,4 @@ RUN npm i
# Copia el resto de la app
COPY . .
# Expone el puerto
EXPOSE 4000
CMD ["npm", "run", "start"]

View File

@ -8,98 +8,81 @@ 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")
# === 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")
# Mapeo de entornos -> archivo .env
# === 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",
}
# ---------- Nuevas utilidades ----------
# Nombres de proyecto para permitir DEV y PROD simultáneos
def _base_project():
return os.path.basename(PROJECT_ROOT).lower() or "composeproj"
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)
PROJECT_NAMES = {
"development": f"{_base_project()}_dev",
"production": f"{_base_project()}_prod",
}
def list_project_containers(project_name, all_states=True):
"""
Lista contenedores del proyecto por etiqueta com.docker.compose.project=<name>.
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()
# 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.")
# 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
)
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]
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("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]
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:
@ -107,33 +90,33 @@ def env_file_path(env_key):
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):
def compose_cmd(base_args, env_key, env_file=None, project_name=None):
"""
Construye el comando docker compose con los -f adecuados
y opcionalmente --env-file si existe (antes del subcomando).
Construye: docker compose -f base -f env --env-file ... -p <name> <COMANDO> [OPCIONES]
(importante: --env-file y -p son opciones globales y van antes del subcomando)
"""
cmd = ["docker", "compose"]
cmd += compose_files_args(include_override=include_override)
cmd += compose_files_args(env_key)
if env_file:
cmd += ["--env-file", env_file] # opción global antes del subcomando
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.<kind>.yaml -p suitecoffee <COMANDO> ...
"""
cmd = ["docker", "compose"]
cmd += compose_files_args_global(kind)
if project_name:
cmd += ["-p", project_name]
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:
@ -158,13 +141,46 @@ def fail(msg):
print(f"{msg}")
sys.exit(1)
# ---------- Acciones ----------
# ---------- 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 bring_up(env_key, include_override=True):
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.")
cmd = compose_cmd(["up", "-d"], env_file=env_path, include_override=include_override)
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:
@ -172,74 +188,111 @@ def bring_up(env_key, include_override=True):
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)
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("Contenedores detenidos y red/volúmenes del proyecto desmontados (según corresponda).")
ok(f"Entorno '{env_key}' detenido correctamente.")
else:
fail(f"Fallo al detener el entorno. Código: {proc.returncode}")
fail(f"Fallo al detener entorno '{env_key}'. 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)
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()]
# Dev usa override; Prod no
dev_running = running_ids(dev_env, include_override=True)
prod_running = running_ids(prod_env, include_override=False)
# ---------- 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}")
any_running = bool(dev_running or prod_running)
if any_running:
print_header("Contenedores activos detectados")
def bring_down_global(kind, remove=False):
"""
Apaga SOLO los servicios definidos en el compose global indicado.
- Primero 'stop <servicios>'.
- Opcionalmente, 'rm -f <servicios>' 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.\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_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))
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 key, label, _ in options:
for opt in options:
key, label = opt[0], opt[1]
print(f" {key}) {label}")
while True:
@ -248,22 +301,79 @@ def detect_and_offer_shutdown():
if not selected:
print("Opción inválida.")
continue
if choice == "3":
ok("Se mantiene el estado actual.")
sys.exit(0)
if choice == "3" or selected[2] is None:
ok("Continuamos sin detener nada.")
return
env_key = selected[2]
bring_down(env_key)
return
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
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:
info("No hay contenedores activos del proyecto.")
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()
detect_and_offer_shutdown()
# 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")