commit c284a9f7cfa7c2277f3ac4d0d65406bdc526bab8 Author: monoadmin Date: Fri Apr 10 15:36:35 2026 -0700 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..806a4d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Docker backup configuration +# Copy to .env and fill in your values + +# Backup destination (local path or remote) +BACKUP_DEST=/backups + +# Optional: notification webhook (e.g. Slack, Discord) +NOTIFY_WEBHOOK= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..487dd4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Environment variables +.env +.env.* +!.env.example + +# Dependencies +node_modules/ +vendor/ + +# Build output +.next/ +dist/ +build/ +*.pyc +__pycache__/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + diff --git a/docker_backup.py b/docker_backup.py new file mode 100755 index 0000000..14f470a --- /dev/null +++ b/docker_backup.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" +Docker Backup/Restore Tool +Backup and restore containers (config + volumes) across multiple Docker hosts. +""" + +import argparse +import json +import os +import sys +import tarfile +import tempfile +import time +from datetime import datetime +from io import BytesIO +from pathlib import Path + +import docker +from docker.errors import DockerException, NotFound, APIError +from rich.console import Console +from rich.table import Table +from rich import print as rprint + +console = Console() + +# ── Host connection helpers ────────────────────────────────────────────────── + +def connect(host: str) -> docker.DockerClient: + """Connect to a Docker host. 'local' uses the default socket.""" + try: + if host in ("local", "unix:///var/run/docker.sock", ""): + client = docker.from_env() + elif host.startswith("ssh://"): + client = docker.DockerClient(base_url=host, use_ssh_client=True) + else: + # tcp:// or host:port shorthand + if not host.startswith("tcp://") and "://" not in host: + host = f"tcp://{host}" + client = docker.DockerClient(base_url=host) + client.ping() + return client + except DockerException as e: + console.print(f"[red]Cannot connect to {host}: {e}[/red]") + sys.exit(1) + + +# ── Backup ─────────────────────────────────────────────────────────────────── + +def backup_container(client: docker.DockerClient, container_name: str, + output_dir: Path, save_image: bool = False): + """ + Backup a single container to output_dir/_.tar + Contents: + config.json — full docker inspect output + volumes/ — one .tar per named/anonymous volume + image.tar — saved image (optional, can be large) + """ + try: + container = client.containers.get(container_name) + except NotFound: + console.print(f"[red]Container not found: {container_name}[/red]") + sys.exit(1) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = container.name.lstrip("/").replace("/", "_") + out_file = output_dir / f"{safe_name}_{ts}.tar" + + console.print(f"Backing up [cyan]{container.name}[/cyan] → [green]{out_file}[/green]") + + inspect = client.api.inspect_container(container.id) + + with tarfile.open(out_file, "w") as tar: + + # 1. config.json + config_bytes = json.dumps(inspect, indent=2).encode() + info = tarfile.TarInfo(name="config.json") + info.size = len(config_bytes) + info.mtime = int(time.time()) + tar.addfile(info, BytesIO(config_bytes)) + console.print(" [dim]✓ config.json[/dim]") + + # 2. Volumes + mounts = inspect.get("Mounts", []) + for mount in mounts: + mtype = mount.get("Type", "") + if mtype not in ("volume", "bind"): + continue + + if mtype == "volume": + vol_name = mount.get("Name", "") + dest = mount.get("Destination", "") + label = vol_name or dest.replace("/", "_") + else: + src = mount.get("Source", "") + label = src.replace("/", "_").lstrip("_") + dest = src + + archive_name = f"volumes/{label}.tar" + console.print(f" [dim]Archiving volume: {label} ({dest})[/dim]") + + try: + # Use docker cp stream to get volume data + stream, _ = client.api.get_archive(container.id, dest) + vol_data = b"".join(stream) + vol_info = tarfile.TarInfo(name=archive_name) + vol_info.size = len(vol_data) + vol_info.mtime = int(time.time()) + tar.addfile(vol_info, BytesIO(vol_data)) + console.print(f" [dim]✓ {archive_name} ({len(vol_data)//1024} KB)[/dim]") + except (APIError, Exception) as e: + console.print(f" [yellow]⚠ Could not archive {label}: {e}[/yellow]") + + # 3. Image (optional) + if save_image: + image_tag = inspect["Config"].get("Image", "") + console.print(f" [dim]Saving image: {image_tag} (may take a while)…[/dim]") + try: + image = client.images.get(image_tag) + img_data = b"".join(image.save()) + img_info = tarfile.TarInfo(name="image.tar") + img_info.size = len(img_data) + img_info.mtime = int(time.time()) + tar.addfile(img_info, BytesIO(img_data)) + console.print(f" [dim]✓ image.tar ({len(img_data)//1024//1024} MB)[/dim]") + except Exception as e: + console.print(f" [yellow]⚠ Could not save image: {e}[/yellow]") + + console.print(f"[green]✓ Backup complete:[/green] {out_file}") + return out_file + + +# ── Restore ────────────────────────────────────────────────────────────────── + +def restore_container(client: docker.DockerClient, backup_file: Path, + new_name: str | None = None, start: bool = False, + load_image: bool = False): + """ + Restore a container from a backup .tar produced by backup_container(). + """ + console.print(f"Restoring from [cyan]{backup_file}[/cyan]") + + with tarfile.open(backup_file, "r") as tar: + # 1. Read config + config_member = tar.getmember("config.json") + config = json.loads(tar.extractfile(config_member).read()) + + orig_name = config["Name"].lstrip("/") + container_name = new_name or orig_name + image_name = config["Config"]["Image"] + + console.print(f" Original name : [cyan]{orig_name}[/cyan]") + console.print(f" Restore as : [cyan]{container_name}[/cyan]") + console.print(f" Image : [cyan]{image_name}[/cyan]") + + # 2. Optionally load image from backup + if load_image: + with tarfile.open(backup_file, "r") as tar: + try: + img_member = tar.getmember("image.tar") + console.print(" [dim]Loading image from backup…[/dim]") + img_data = tar.extractfile(img_member).read() + client.images.load(img_data) + console.print(" [dim]✓ Image loaded[/dim]") + except KeyError: + console.print(" [yellow]⚠ No image.tar in backup, skipping load[/yellow]") + + # 3. Pull image if not present + try: + client.images.get(image_name) + console.print(f" [dim]✓ Image {image_name} already present[/dim]") + except NotFound: + console.print(f" [dim]Pulling {image_name}…[/dim]") + try: + client.images.pull(image_name) + except Exception as e: + console.print(f" [red]Cannot pull image: {e}[/red]") + sys.exit(1) + + # 4. Check for name collision + try: + existing = client.containers.get(container_name) + console.print(f" [yellow]Container {container_name} already exists (id={existing.short_id})[/yellow]") + answer = input(" Remove existing container? [y/N] ").strip().lower() + if answer == "y": + existing.remove(force=True) + else: + console.print("[red]Restore aborted.[/red]") + sys.exit(1) + except NotFound: + pass + + # 5. Recreate volumes and restore data + with tarfile.open(backup_file, "r") as tar: + volume_members = [m for m in tar.getmembers() + if m.name.startswith("volumes/") and m.name.endswith(".tar")] + + mounts = config.get("Mounts", []) + vol_map: dict[str, str] = {} # label -> actual volume name or bind path + + for mount in mounts: + mtype = mount.get("Type", "") + dest = mount.get("Destination", "") + if mtype == "volume": + vol_name = mount.get("Name", "") + label = vol_name or dest.replace("/", "_") + else: + src = mount.get("Source", "") + label = src.replace("/", "_").lstrip("_") + vol_map[label] = src + continue # bind mounts: path must exist on target host + + # Create volume if missing + try: + client.volumes.get(vol_name) + console.print(f" [dim]Volume {vol_name} already exists[/dim]") + except NotFound: + client.volumes.create(name=vol_name) + console.print(f" [dim]✓ Created volume {vol_name}[/dim]") + vol_map[label] = vol_name + + # Restore volume data via a temporary Alpine container + with tarfile.open(backup_file, "r") as tar: + for vmember in volume_members: + label = vmember.name[len("volumes/"):-len(".tar")] + if label not in vol_map: + console.print(f" [yellow]⚠ No matching mount for volume backup: {label}[/yellow]") + continue + + vol_name = vol_map[label] + dest_path = next( + (m["Destination"] for m in mounts + if (m.get("Name") == vol_name or + m.get("Source", "").replace("/", "_").lstrip("_") == label)), + None + ) + if not dest_path: + continue + + console.print(f" [dim]Restoring volume data: {vol_name} → {dest_path}[/dim]") + vol_data = tar.extractfile(vmember).read() + + # Spin up temp container with the volume mounted, restore via tar stream + if vol_map[label] == vol_name: + volumes_arg = {vol_name: {"bind": "/restore_target", "mode": "rw"}} + else: + volumes_arg = {vol_name: {"bind": "/restore_target", "mode": "rw"}} + + try: + restore_ctr = client.containers.run( + "alpine", + command="sh -c 'cd /restore_target && tar xf /tmp/vol.tar --strip-components=1'", + volumes=volumes_arg, + detach=True, + remove=False, + ) + # Upload the tar to the temp container + client.api.put_archive(restore_ctr.id, "/tmp", vol_data) + restore_ctr.wait() + restore_ctr.remove() + console.print(f" [dim]✓ Volume data restored: {vol_name}[/dim]") + except Exception as e: + console.print(f" [yellow]⚠ Could not restore volume data for {vol_name}: {e}[/yellow]") + + # 6. Rebuild container create kwargs from inspect + cfg = config["Config"] + host_cfg = config["HostConfig"] + + create_kwargs: dict = { + "image": image_name, + "name": container_name, + "command": cfg.get("Cmd"), + "entrypoint": cfg.get("Entrypoint"), + "environment": cfg.get("Env") or [], + "working_dir": cfg.get("WorkingDir") or None, + "user": cfg.get("User") or None, + "hostname": cfg.get("Hostname") or None, + "domainname": cfg.get("Domainname") or None, + "labels": cfg.get("Labels") or {}, + "tty": cfg.get("Tty", False), + "stdin_open": cfg.get("OpenStdin", False), + } + + # Ports + exposed = cfg.get("ExposedPorts") or {} + port_bindings = host_cfg.get("PortBindings") or {} + if exposed or port_bindings: + ports = {} + for port_proto in exposed: + bindings = port_bindings.get(port_proto) + if bindings: + host_ports = [b.get("HostPort") for b in bindings if b.get("HostPort")] + ports[port_proto] = host_ports or None + else: + ports[port_proto] = None + create_kwargs["ports"] = ports + + # Restart policy + restart = host_cfg.get("RestartPolicy", {}) + if restart.get("Name"): + create_kwargs["restart_policy"] = { + "Name": restart["Name"], + "MaximumRetryCount": restart.get("MaximumRetryCount", 0), + } + + # Network mode + net_mode = host_cfg.get("NetworkMode", "default") + if net_mode not in ("default", "bridge"): + create_kwargs["network_mode"] = net_mode + + # Volumes mounts + volume_binds = [] + for mount in mounts: + mtype = mount.get("Type", "") + dest = mount.get("Destination", "") + mode = "rw" if not mount.get("RW") is False else "ro" + if mtype == "volume": + volume_binds.append(f"{mount['Name']}:{dest}:{mode}") + elif mtype == "bind": + volume_binds.append(f"{mount['Source']}:{dest}:{mode}") + if volume_binds: + create_kwargs["volumes"] = volume_binds + + # Extra host config fields + if host_cfg.get("Privileged"): + create_kwargs["privileged"] = True + if host_cfg.get("NetworkMode") == "host": + create_kwargs["network_mode"] = "host" + cap_add = host_cfg.get("CapAdd") or [] + if cap_add: + create_kwargs["cap_add"] = cap_add + devices = host_cfg.get("Devices") or [] + if devices: + create_kwargs["devices"] = [ + f"{d['PathOnHost']}:{d['PathInContainer']}:{d['CgroupPermissions']}" + for d in devices + ] + + # 7. Create container + try: + ctr = client.containers.create(**{k: v for k, v in create_kwargs.items() if v is not None}) + console.print(f"[green]✓ Container created:[/green] {ctr.name} ({ctr.short_id})") + except Exception as e: + console.print(f"[red]Failed to create container: {e}[/red]") + sys.exit(1) + + if start: + ctr.start() + console.print(f"[green]✓ Container started[/green]") + + return ctr + + +# ── List ───────────────────────────────────────────────────────────────────── + +def list_containers(hosts: list[str], all_containers: bool = False): + table = Table(title="Docker Containers") + table.add_column("Host", style="cyan") + table.add_column("Name", style="white") + table.add_column("Image", style="dim") + table.add_column("Status", style="green") + table.add_column("Ports", style="dim") + + for host in hosts: + client = connect(host) + label = host + try: + containers = client.containers.list(all=all_containers) + for c in containers: + ports = ", ".join( + f"{v[0]['HostPort']}→{k}" if v else k + for k, v in (c.ports or {}).items() + ) or "-" + status = c.status + color = "green" if status == "running" else "yellow" + table.add_row(label, c.name, c.image.tags[0] if c.image.tags else c.image.short_id, + f"[{color}]{status}[/{color}]", ports) + label = "" # only show host label on first row + except Exception as e: + table.add_row(host, f"[red]ERROR: {e}[/red]", "", "", "") + + console.print(table) + + +# ── CLI ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Backup and restore Docker containers across multiple hosts.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List containers on multiple hosts + docker_backup.py list --host local --host tcp://192.168.1.10:2375 + + # Backup a container on a remote host + docker_backup.py backup myapp --host tcp://192.168.1.10:2375 --output /backups + + # Backup with image saved + docker_backup.py backup myapp --host local --save-image + + # Restore to a different host (pull image from registry) + docker_backup.py restore /backups/myapp_20240101_120000.tar --host tcp://192.168.1.20:2375 + + # Restore with a new name and auto-start + docker_backup.py restore /backups/myapp_20240101_120000.tar --name myapp2 --start + + # Restore using image from backup file (no registry needed) + docker_backup.py restore /backups/myapp_20240101_120000.tar --load-image --start + """ + ) + + sub = parser.add_subparsers(dest="command", required=True) + + # list + p_list = sub.add_parser("list", help="List containers across hosts") + p_list.add_argument("--host", action="append", default=[], dest="hosts", + metavar="HOST", help="Docker host (repeatable). Use 'local' for local socket.") + p_list.add_argument("-a", "--all", action="store_true", help="Show stopped containers too") + + # backup + p_back = sub.add_parser("backup", help="Backup a container") + p_back.add_argument("container", help="Container name or ID") + p_back.add_argument("--host", default="local", metavar="HOST", + help="Docker host (default: local)") + p_back.add_argument("--output", "-o", default=".", metavar="DIR", + help="Output directory (default: current dir)") + p_back.add_argument("--save-image", action="store_true", + help="Also save the container image into the backup (large!)") + + # restore + p_rest = sub.add_parser("restore", help="Restore a container from backup") + p_rest.add_argument("backup", help="Path to the backup .tar file") + p_rest.add_argument("--host", default="local", metavar="HOST", + help="Docker host to restore to (default: local)") + p_rest.add_argument("--name", metavar="NAME", + help="Override container name (default: original name)") + p_rest.add_argument("--start", action="store_true", + help="Start the container after restoring") + p_rest.add_argument("--load-image", action="store_true", + help="Load image from backup instead of pulling from registry") + + args = parser.parse_args() + + if args.command == "list": + hosts = args.hosts or ["local"] + list_containers(hosts, all_containers=args.all) + + elif args.command == "backup": + client = connect(args.host) + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + backup_container(client, args.container, output_dir, save_image=args.save_image) + + elif args.command == "restore": + client = connect(args.host) + restore_container(client, Path(args.backup), + new_name=args.name, + start=args.start, + load_image=args.load_image) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c837634 --- /dev/null +++ b/install.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -e + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEBAPP_DIR="$APP_DIR/webapp" +SERVICE_NAME="docker-backup" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +PYTHON="$(which python3)" +PORT="${PORT:-5999}" +BIND="${BIND:-0.0.0.0}" + +# ── Colours ─────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +die() { echo -e "${RED}✗${NC} $*"; exit 1; } +step() { echo -e "\n${YELLOW}▶${NC} $*"; } + +echo "" +echo " Docker Backup — Install" +echo " ───────────────────────" + +# ── Root check ──────────────────────────────────────────────────────────────── +[[ $EUID -ne 0 ]] && die "Run as root (sudo $0)" + +# ── Python deps ─────────────────────────────────────────────────────────────── +step "Checking Python dependencies…" +PIP="$(which pip3 2>/dev/null || which pip 2>/dev/null || echo 'python3 -m pip')" +MISSING=() +for pkg in docker flask rich apscheduler paramiko; do + python3 -c "import $pkg" 2>/dev/null && ok "$pkg" || MISSING+=("$pkg") +done + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo " Installing: ${MISSING[*]}" + $PIP install --quiet --break-system-packages "${MISSING[@]}" 2>/dev/null || \ + $PIP install --quiet "${MISSING[@]}" && ok "Packages installed" +fi + +# ── Backup dir ──────────────────────────────────────────────────────────────── +step "Creating backup directory…" +BACKUP_DIR="$WEBAPP_DIR/backups" +mkdir -p "$BACKUP_DIR" +ok "$BACKUP_DIR" + +# ── Systemd service ─────────────────────────────────────────────────────────── +step "Writing systemd service: $SERVICE_FILE" + +cat > "$SERVICE_FILE" < dict: + if CONFIG_FILE.exists(): + data = json.loads(CONFIG_FILE.read_text()) + # Back-fill missing keys + for k, v in DEFAULT_CONFIG.items(): + data.setdefault(k, v) + return data + return DEFAULT_CONFIG.copy() + + +def save_config(cfg: dict): + CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + + +# ── Auth ────────────────────────────────────────────────────────────────────── + +def auth_enabled() -> bool: + return bool(load_config().get("auth", {}).get("password_hash")) + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if auth_enabled() and not session.get("authenticated"): + if request.is_json or request.path.startswith("/api/"): + return jsonify({"error": "unauthorized"}), 401 + return jsonify({"error": "unauthorized"}), 401 + return f(*args, **kwargs) + return decorated + + +@app.route("/api/auth/status") +def api_auth_status(): + return jsonify({ + "enabled": auth_enabled(), + "authenticated": session.get("authenticated", False), + "username": load_config().get("auth", {}).get("username", ""), + }) + + +@app.route("/api/auth/login", methods=["POST"]) +def api_auth_login(): + data = request.json or {} + cfg = load_config() + auth = cfg.get("auth", {}) + if not auth.get("password_hash"): + return jsonify({"error": "Auth not configured"}), 400 + if data.get("username") != auth.get("username"): + return jsonify({"error": "Invalid credentials"}), 401 + if not check_password_hash(auth["password_hash"], data.get("password", "")): + return jsonify({"error": "Invalid credentials"}), 401 + session["authenticated"] = True + session.permanent = True + return jsonify({"ok": True}) + + +@app.route("/api/auth/logout", methods=["POST"]) +def api_auth_logout(): + session.clear() + return jsonify({"ok": True}) + + +@app.route("/api/auth/setup", methods=["POST"]) +def api_auth_setup(): + cfg = load_config() + auth = cfg.get("auth", {}) + data = request.json or {} + # If auth already configured, require current password + if auth.get("password_hash"): + if not session.get("authenticated"): + return jsonify({"error": "unauthorized"}), 401 + username = data.get("username", "").strip() + password = data.get("password", "") + if not username or not password: + return jsonify({"error": "username and password required"}), 400 + if len(password) < 6: + return jsonify({"error": "password must be at least 6 characters"}), 400 + cfg["auth"] = { + "username": username, + "password_hash": generate_password_hash(password), + } + save_config(cfg) + session["authenticated"] = True + return jsonify({"ok": True}) + + +@app.route("/api/auth/disable", methods=["POST"]) +@login_required +def api_auth_disable(): + cfg = load_config() + cfg["auth"] = {} + save_config(cfg) + session.clear() + return jsonify({"ok": True}) + + +# ── Job helpers ─────────────────────────────────────────────────────────────── + +def new_job(label: str) -> str: + jid = str(uuid.uuid4())[:8] + with jobs_lock: + jobs[jid] = {"id": jid, "label": label, "status": "running", + "logs": [], "created": datetime.now().isoformat()} + return jid + + +def job_log(jid: str, msg: str): + with jobs_lock: + if jid in jobs: + jobs[jid]["logs"].append(msg) + + +def job_done(jid: str, error: str | None = None): + with jobs_lock: + if jid in jobs: + jobs[jid]["status"] = "error" if error else "done" + if error: + jobs[jid]["logs"].append(f"ERROR: {error}") + jobs[jid]["logs"].append("[DONE]") + + +# ── Docker client helper ────────────────────────────────────────────────────── + +def make_client(host: str) -> docker.DockerClient: + if host in ("local", ""): + return docker.from_env() + url = host if "://" in host else f"tcp://{host}" + return docker.DockerClient(base_url=url, use_ssh_client=host.startswith("ssh://")) + + +# ── Retention ───────────────────────────────────────────────────────────────── + +def apply_retention(backup_dir: Path, container_name: str, + keep_count: int | None, keep_days: int | None) -> int: + safe = container_name.lstrip("/").replace("/", "_") + files = sorted(backup_dir.glob(f"{safe}_*.tar"), + key=lambda x: x.stat().st_mtime, reverse=True) + to_delete: set[Path] = set() + if keep_days and keep_days > 0: + cutoff = time.time() - keep_days * 86400 + to_delete.update(f for f in files if f.stat().st_mtime < cutoff) + if keep_count and keep_count > 0: + to_delete.update(files[keep_count:]) + for f in to_delete: + try: + f.unlink() + except Exception: + pass + return len(to_delete) + + +# ── Backup worker ───────────────────────────────────────────────────────────── + +def _run_backup(jid: str, host: str, container_name: str, + backup_dir: str, save_image: bool, pre_hook: str = "", + retention_count: int | None = None, retention_days: int | None = None): + from io import BytesIO + try: + client = make_client(host) + client.ping() + job_log(jid, f"Connected to {host}") + + try: + container = client.containers.get(container_name) + except NotFound: + job_done(jid, f"Container not found: {container_name}") + return + + # Pre-hook + if pre_hook and pre_hook.strip(): + job_log(jid, f"Running pre-hook: {pre_hook}") + try: + result = container.exec_run(["sh", "-c", pre_hook], + stdout=True, stderr=True) + output = result.output.decode(errors="replace").strip() + if result.exit_code != 0: + job_log(jid, f"⚠ Pre-hook exited {result.exit_code}: {output}") + else: + job_log(jid, f"✓ Pre-hook OK{': ' + output[:200] if output else ''}") + except Exception as e: + job_log(jid, f"⚠ Pre-hook failed: {e}") + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = container.name.lstrip("/").replace("/", "_") + out_file = Path(backup_dir) / f"{safe_name}_{ts}.tar" + Path(backup_dir).mkdir(parents=True, exist_ok=True) + + inspect = client.api.inspect_container(container.id) + job_log(jid, f"Backing up: {container.name}") + + with tarfile.open(out_file, "w") as tar: + config_bytes = json.dumps(inspect, indent=2).encode() + info = tarfile.TarInfo(name="config.json") + info.size = len(config_bytes) + info.mtime = int(time.time()) + tar.addfile(info, BytesIO(config_bytes)) + job_log(jid, "✓ config.json") + + for mount in inspect.get("Mounts", []): + mtype = mount.get("Type", "") + if mtype not in ("volume", "bind"): + continue + dest = mount.get("Destination", "") + if mtype == "volume": + label = mount.get("Name") or dest.replace("/", "_") + else: + label = mount.get("Source", "").replace("/", "_").lstrip("_") + + archive_name = f"volumes/{label}.tar" + job_log(jid, f"Archiving volume: {label} ({dest})") + try: + stream, _ = client.api.get_archive(container.id, dest) + vol_data = b"".join(stream) + vi = tarfile.TarInfo(name=archive_name) + vi.size = len(vol_data) + vi.mtime = int(time.time()) + tar.addfile(vi, BytesIO(vol_data)) + job_log(jid, f"✓ {archive_name} ({len(vol_data)//1024} KB)") + except Exception as e: + job_log(jid, f"⚠ Could not archive {label}: {e}") + + if save_image: + image_tag = inspect["Config"].get("Image", "") + job_log(jid, f"Saving image: {image_tag}…") + try: + img_data = b"".join(client.images.get(image_tag).save()) + ii = tarfile.TarInfo(name="image.tar") + ii.size = len(img_data) + ii.mtime = int(time.time()) + tar.addfile(ii, BytesIO(img_data)) + job_log(jid, f"✓ image.tar ({len(img_data)//1024//1024} MB)") + except Exception as e: + job_log(jid, f"⚠ Image save failed: {e}") + + job_log(jid, f"✓ Saved: {out_file.name}") + + # Retention + if retention_count or retention_days: + deleted = apply_retention(Path(backup_dir), container.name, + retention_count, retention_days) + if deleted: + job_log(jid, f"Retention: removed {deleted} old backup(s)") + + job_done(jid) + except Exception as e: + job_done(jid, str(e)) + + +# ── Restore worker ──────────────────────────────────────────────────────────── + +def _run_restore(jid: str, host: str, backup_path: str, + new_name: str | None, start: bool, load_image: bool): + from io import BytesIO + try: + client = make_client(host) + client.ping() + job_log(jid, f"Connected to {host}") + + bp = Path(backup_path) + if not bp.exists(): + job_done(jid, f"File not found: {backup_path}") + return + + with tarfile.open(bp, "r") as tar: + config = json.loads(tar.extractfile("config.json").read()) + + orig_name = config["Name"].lstrip("/") + container_name = new_name or orig_name + image_name = config["Config"]["Image"] + job_log(jid, f"Restoring '{orig_name}' → '{container_name}' (image: {image_name})") + + if load_image: + with tarfile.open(bp, "r") as tar: + try: + img_data = tar.extractfile("image.tar").read() + job_log(jid, "Loading image from backup…") + client.images.load(img_data) + job_log(jid, "✓ Image loaded") + except KeyError: + job_log(jid, "⚠ No image.tar in backup — will pull") + + try: + client.images.get(image_name) + job_log(jid, f"✓ Image {image_name} present") + except NotFound: + job_log(jid, f"Pulling {image_name}…") + client.images.pull(image_name) + job_log(jid, f"✓ Pulled {image_name}") + + try: + existing = client.containers.get(container_name) + job_log(jid, f"Removing existing container {container_name}") + existing.remove(force=True) + except NotFound: + pass + + mounts = config.get("Mounts", []) + vol_data_map: dict[str, bytes] = {} + with tarfile.open(bp, "r") as tar: + for m in tar.getmembers(): + if m.name.startswith("volumes/") and m.name.endswith(".tar"): + label = m.name[len("volumes/"):-len(".tar")] + vol_data_map[label] = tar.extractfile(m).read() + + for mount in mounts: + if mount.get("Type") != "volume": + continue + vol_name = mount.get("Name", "") + dest = mount.get("Destination", "") + label = vol_name or dest.replace("/", "_") + try: + client.volumes.get(vol_name) + job_log(jid, f"Volume {vol_name} exists") + except NotFound: + client.volumes.create(name=vol_name) + job_log(jid, f"✓ Created volume {vol_name}") + + if label in vol_data_map: + job_log(jid, f"Restoring volume data: {vol_name}") + try: + rc = client.containers.run( + "alpine", + command="sh -c 'cd /t && tar xf /tmp/vol.tar --strip-components=1'", + volumes={vol_name: {"bind": "/t", "mode": "rw"}}, + detach=True, remove=False, + ) + client.api.put_archive(rc.id, "/tmp", vol_data_map[label]) + rc.wait() + rc.remove() + job_log(jid, f"✓ Volume restored: {vol_name}") + except Exception as e: + job_log(jid, f"⚠ Volume restore failed {vol_name}: {e}") + + cfg_c = config["Config"] + hcfg = config["HostConfig"] + kwargs: dict = { + "image": image_name, "name": container_name, + "command": cfg_c.get("Cmd"), "entrypoint": cfg_c.get("Entrypoint"), + "environment": cfg_c.get("Env") or [], + "working_dir": cfg_c.get("WorkingDir") or None, + "user": cfg_c.get("User") or None, + "hostname": cfg_c.get("Hostname") or None, + "labels": cfg_c.get("Labels") or {}, + "tty": cfg_c.get("Tty", False), + "stdin_open": cfg_c.get("OpenStdin", False), + } + exposed = cfg_c.get("ExposedPorts") or {} + port_bindings = hcfg.get("PortBindings") or {} + if exposed or port_bindings: + ports = {} + for pp in exposed: + binds = port_bindings.get(pp) + ports[pp] = [b["HostPort"] for b in binds if b.get("HostPort")] if binds else None + kwargs["ports"] = ports + restart = hcfg.get("RestartPolicy", {}) + if restart.get("Name"): + kwargs["restart_policy"] = {"Name": restart["Name"], + "MaximumRetryCount": restart.get("MaximumRetryCount", 0)} + net_mode = hcfg.get("NetworkMode", "default") + if net_mode not in ("default", "bridge"): + kwargs["network_mode"] = net_mode + vol_binds = [] + for m in mounts: + dest = m.get("Destination", "") + mode = "rw" if m.get("RW") is not False else "ro" + if m.get("Type") == "volume": + vol_binds.append(f"{m['Name']}:{dest}:{mode}") + elif m.get("Type") == "bind": + vol_binds.append(f"{m['Source']}:{dest}:{mode}") + if vol_binds: + kwargs["volumes"] = vol_binds + if hcfg.get("Privileged"): + kwargs["privileged"] = True + if hcfg.get("CapAdd"): + kwargs["cap_add"] = hcfg["CapAdd"] + + ctr = client.containers.create(**{k: v for k, v in kwargs.items() if v is not None}) + job_log(jid, f"✓ Container created: {ctr.name} ({ctr.short_id})") + if start: + ctr.start() + job_log(jid, "✓ Container started") + job_done(jid) + except Exception as e: + job_done(jid, str(e)) + + +# ── Scheduler ───────────────────────────────────────────────────────────────── + +scheduler = BackgroundScheduler(timezone="UTC") +scheduler.start() + + +def run_scheduled_backup(schedule_id: str): + cfg = load_config() + sched = next((s for s in cfg.get("schedules", []) if s["id"] == schedule_id), None) + if not sched or not sched.get("enabled", True): + return + + backup_dir = cfg["backup_dir"] + host = sched["host"] + container = sched["container"] + save_image = sched.get("save_image", False) + pre_hook = sched.get("pre_hook", "") + r_count = sched.get("retention_count") or None + r_days = sched.get("retention_days") or None + + jid = new_job(f"[Scheduled] {container} on {host}") + + def worker(): + _run_backup(jid, host, container, backup_dir, save_image, + pre_hook=pre_hook, retention_count=r_count, retention_days=r_days) + status = jobs.get(jid, {}).get("status", "unknown") + cfg2 = load_config() + for s in cfg2.get("schedules", []): + if s["id"] == schedule_id: + s["last_run"] = datetime.now().isoformat() + s["last_status"] = status + break + save_config(cfg2) + + threading.Thread(target=worker, daemon=True).start() + return jid + + +def _cron_kwargs(expr: str) -> dict: + expr = CRON_ALIASES.get(expr.lower(), expr) + parts = expr.split() + if len(parts) != 5: + raise ValueError(f"Invalid cron: {expr}") + minute, hour, day, month, dow = parts + return dict(minute=minute, hour=hour, day=day, month=month, day_of_week=dow) + + +def _register_schedule(sched: dict): + try: + kwargs = _cron_kwargs(sched["cron"]) + scheduler.add_job(run_scheduled_backup, CronTrigger(**kwargs), + id=sched["id"], args=[sched["id"]], replace_existing=True) + except Exception as e: + print(f"Warning: schedule {sched['id']} not registered: {e}") + + +def _unregister_schedule(schedule_id: str): + try: + scheduler.remove_job(schedule_id) + except Exception: + pass + + +# Load existing schedules on startup +for _s in load_config().get("schedules", []): + if _s.get("enabled", True): + _register_schedule(_s) + + +# ── Routes: main page ───────────────────────────────────────────────────────── + +@app.route("/") +def index(): + return render_template("index.html") + + +# ── Routes: config ──────────────────────────────────────────────────────────── + +@app.route("/api/config", methods=["GET", "POST"]) +@login_required +def api_config(): + if request.method == "POST": + cfg = load_config() + data = request.json or {} + if "backup_dir" in data: + cfg["backup_dir"] = data["backup_dir"] + save_config(cfg) + return jsonify(cfg) + cfg = load_config() + cfg.pop("auth", None) # never send password hash to browser + return jsonify(cfg) + + +# ── Routes: hosts ───────────────────────────────────────────────────────────── + +@app.route("/api/hosts") +@login_required +def api_hosts_list(): + cfg = load_config() + result = [] + for host in cfg["hosts"]: + try: + c = make_client(host) + c.ping() + info = c.info() + result.append({"host": host, "ok": True, + "name": info.get("Name", ""), + "version": info.get("ServerVersion", ""), + "containers": info.get("Containers", 0)}) + except Exception as e: + result.append({"host": host, "ok": False, "error": str(e)}) + return jsonify(result) + + +@app.route("/api/hosts", methods=["POST"]) +@login_required +def api_hosts_add(): + host = (request.json or {}).get("host", "").strip() + if not host: + return jsonify({"error": "host required"}), 400 + cfg = load_config() + if host not in cfg["hosts"]: + cfg["hosts"].append(host) + save_config(cfg) + return jsonify(cfg) + + +@app.route("/api/hosts/", methods=["DELETE"]) +@login_required +def api_hosts_delete(host): + cfg = load_config() + cfg["hosts"] = [h for h in cfg["hosts"] if h != host] + save_config(cfg) + return jsonify(cfg) + + +# ── Routes: containers ──────────────────────────────────────────────────────── + +@app.route("/api/containers") +@login_required +def api_containers(): + cfg = load_config() + show_all = request.args.get("all", "false").lower() == "true" + host_filter = request.args.get("host") + hosts = [host_filter] if host_filter else cfg["hosts"] + result = [] + for host in hosts: + try: + client = make_client(host) + for c in client.containers.list(all=show_all): + ports = {k: v[0]["HostPort"] if v else None for k, v in (c.ports or {}).items()} + result.append({"host": host, "id": c.short_id, "name": c.name, + "image": c.image.tags[0] if c.image.tags else c.image.short_id, + "status": c.status, "ports": ports}) + except Exception as e: + result.append({"host": host, "id": None, "name": None, + "image": None, "status": "error", "error": str(e)}) + return jsonify(result) + + +# ── Routes: backups ─────────────────────────────────────────────────────────── + +@app.route("/api/backups") +@login_required +def api_backups(): + cfg = load_config() + backup_dir = Path(cfg["backup_dir"]) + if not backup_dir.exists(): + return jsonify([]) + result = [] + for f in sorted(backup_dir.glob("*.tar"), key=lambda x: x.stat().st_mtime, reverse=True): + stat = f.stat() + try: + with tarfile.open(f, "r") as tar: + members = tar.getmembers() + config = json.loads(tar.extractfile("config.json").read()) + orig_name = config["Name"].lstrip("/") + image = config["Config"]["Image"] + has_image = any(m.name == "image.tar" for m in members) + volumes = [m.name[len("volumes/"):-len(".tar")] + for m in members + if m.name.startswith("volumes/") and m.name.endswith(".tar")] + except Exception: + orig_name = f.stem + image = "unknown" + has_image = False + volumes = [] + result.append({ + "file": f.name, "path": str(f), + "size_mb": round(stat.st_size / 1024 / 1024, 1), + "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "container": orig_name, "image": image, + "has_image": has_image, "volumes": volumes, + }) + return jsonify(result) + + +@app.route("/api/backups/", methods=["DELETE"]) +@login_required +def api_backup_delete(filename): + cfg = load_config() + path = Path(cfg["backup_dir"]) / filename + if not path.exists(): + return jsonify({"error": "not found"}), 404 + path.unlink() + return jsonify({"ok": True}) + + +# ── Routes: backup / restore / bulk ────────────────────────────────────────── + +@app.route("/api/backup", methods=["POST"]) +@login_required +def api_backup_start(): + data = request.json or {} + host = data.get("host", "local") + container = data.get("container") + save_image = data.get("save_image", False) + pre_hook = data.get("pre_hook", "") + r_count = data.get("retention_count") or None + r_days = data.get("retention_days") or None + if not container: + return jsonify({"error": "container required"}), 400 + cfg = load_config() + jid = new_job(f"Backup {container} on {host}") + threading.Thread(target=_run_backup, + args=(jid, host, container, cfg["backup_dir"], + save_image, pre_hook, r_count, r_days), + daemon=True).start() + return jsonify({"job_id": jid}) + + +@app.route("/api/bulk-backup", methods=["POST"]) +@login_required +def api_bulk_backup(): + data = request.json or {} + host = data.get("host", "local") + save_image = data.get("save_image", False) + pre_hook = data.get("pre_hook", "") + try: + client = make_client(host) + client.ping() + containers = client.containers.list(all=False) # running only + except Exception as e: + return jsonify({"error": str(e)}), 500 + if not containers: + return jsonify({"error": "No running containers on host"}), 400 + cfg = load_config() + job_ids = [] + for c in containers: + jid = new_job(f"Bulk: {c.name} on {host}") + threading.Thread(target=_run_backup, + args=(jid, host, c.name, cfg["backup_dir"], + save_image, pre_hook), + daemon=True).start() + job_ids.append({"job_id": jid, "container": c.name}) + return jsonify({"jobs": job_ids}) + + +@app.route("/api/restore", methods=["POST"]) +@login_required +def api_restore_start(): + data = request.json or {} + host = data.get("host", "local") + backup_path = data.get("backup_path") + new_name = data.get("new_name") or None + start = data.get("start", False) + load_image = data.get("load_image", False) + if not backup_path: + return jsonify({"error": "backup_path required"}), 400 + jid = new_job(f"Restore {Path(backup_path).name} → {host}") + threading.Thread(target=_run_restore, + args=(jid, host, backup_path, new_name, start, load_image), + daemon=True).start() + return jsonify({"job_id": jid}) + + +# ── Routes: schedules ───────────────────────────────────────────────────────── + +@app.route("/api/schedules") +@login_required +def api_schedules_list(): + cfg = load_config() + schedules = cfg.get("schedules", []) + # Attach next_run from scheduler + for s in schedules: + job = scheduler.get_job(s["id"]) + s["next_run"] = job.next_run_time.isoformat() if (job and job.next_run_time) else None + return jsonify(schedules) + + +@app.route("/api/schedules", methods=["POST"]) +@login_required +def api_schedules_create(): + data = request.json or {} + required = ("host", "container", "cron") + if not all(data.get(k) for k in required): + return jsonify({"error": "host, container, cron required"}), 400 + # Validate cron + try: + _cron_kwargs(data["cron"]) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + sched = { + "id": str(uuid.uuid4())[:8], + "host": data["host"], + "container": data["container"], + "cron": data["cron"], + "pre_hook": data.get("pre_hook", ""), + "save_image": data.get("save_image", False), + "retention_count": data.get("retention_count") or None, + "retention_days": data.get("retention_days") or None, + "enabled": data.get("enabled", True), + "last_run": None, + "last_status": None, + } + cfg = load_config() + cfg.setdefault("schedules", []).append(sched) + save_config(cfg) + if sched["enabled"]: + _register_schedule(sched) + return jsonify(sched), 201 + + +@app.route("/api/schedules/", methods=["PUT"]) +@login_required +def api_schedules_update(sid): + data = request.json or {} + cfg = load_config() + schedules = cfg.get("schedules", []) + sched = next((s for s in schedules if s["id"] == sid), None) + if not sched: + return jsonify({"error": "not found"}), 404 + updatable = ("host", "container", "cron", "pre_hook", "save_image", + "retention_count", "retention_days", "enabled") + for k in updatable: + if k in data: + sched[k] = data[k] + if "cron" in data: + try: + _cron_kwargs(sched["cron"]) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + save_config(cfg) + _unregister_schedule(sid) + if sched.get("enabled", True): + _register_schedule(sched) + return jsonify(sched) + + +@app.route("/api/schedules/", methods=["DELETE"]) +@login_required +def api_schedules_delete(sid): + cfg = load_config() + cfg["schedules"] = [s for s in cfg.get("schedules", []) if s["id"] != sid] + save_config(cfg) + _unregister_schedule(sid) + return jsonify({"ok": True}) + + +@app.route("/api/schedules//run", methods=["POST"]) +@login_required +def api_schedules_run_now(sid): + jid = run_scheduled_backup(sid) + if not jid: + return jsonify({"error": "schedule not found or disabled"}), 404 + return jsonify({"job_id": jid}) + + +# ── Routes: jobs ────────────────────────────────────────────────────────────── + +@app.route("/api/jobs") +@login_required +def api_jobs(): + with jobs_lock: + return jsonify(list(jobs.values())) + + +@app.route("/api/jobs//stream") +def api_job_stream(jid): + # Auth check without decorator (SSE doesn't send JSON) + if auth_enabled() and not session.get("authenticated"): + return Response("data: unauthorized\n\n", mimetype="text/event-stream", status=401) + + def generate(): + last = 0 + while True: + with jobs_lock: + job = jobs.get(jid) + if not job: + yield f"data: Job {jid} not found\n\n" + break + logs = job["logs"] + while last < len(logs): + line = logs[last].replace("\n", " ") + yield f"data: {line}\n\n" + last += 1 + if line == "[DONE]": + return + if job["status"] in ("done", "error"): + break + time.sleep(0.15) + + return Response(stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +if __name__ == "__main__": + import argparse + p = argparse.ArgumentParser() + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int, default=5999) + p.add_argument("--debug", action="store_true") + args = p.parse_args() + print(f"Docker Backup UI → http://{args.host}:{args.port}") + app.run(host=args.host, port=args.port, debug=args.debug) diff --git a/webapp/start.sh b/webapp/start.sh new file mode 100755 index 0000000..094a92f --- /dev/null +++ b/webapp/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Start Docker Backup Web UI +cd "$(dirname "$0")" +exec python3 app.py "$@" diff --git a/webapp/templates/index.html b/webapp/templates/index.html new file mode 100644 index 0000000..63143fe --- /dev/null +++ b/webapp/templates/index.html @@ -0,0 +1,1109 @@ + + + + + + Docker Backup + + + + + + + +
+ +
+ +
+ + + + + +
+ + +
+
Dashboard
+
+
Hosts
+
Running
+
Backups
+
Total Size
+
+
+
+
+
Hosts
+
+
+
+
+
+
Recent Backups
+
+
+
+
+
+ + +
+
+
Containers
+
+
+ + +
+ +
+
+
+
+ + +
+
+
Backups
+ +
+
+
+ + + + + + +
ContainerImageDateSizeVolumes
+
+
+
+ + +
+
+
Schedules
+ +
+
+
+ + + + + + +
ContainerHostSchedulePre-hookRetentionLast RunNext Run
+
+
+
+ + +
+
Settings
+
+
+
+
Docker Hosts
+
+
+
+ + +
+
Use local for local socket.
+
+
+
+
+
+
Backup Storage
+
+ +
+ + +
+
+
+
+
+
+
Authentication
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+
Jobs
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + +