SuiteCoffee/restore_compose_volumes.py
msaldain c42814f963 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.
2025-08-19 18:26:21 +00:00

467 lines
16 KiB
Python

#!/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).")