Initial commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -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=
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -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
|
||||
|
||||
463
docker_backup.py
Executable file
463
docker_backup.py
Executable file
@@ -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/<container_name>_<timestamp>.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()
|
||||
100
install.sh
Executable file
100
install.sh
Executable file
@@ -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" <<EOF
|
||||
[Unit]
|
||||
Description=Docker Backup Web UI
|
||||
After=network.target docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${PYTHON} ${WEBAPP_DIR}/app.py --host ${BIND} --port ${PORT}
|
||||
WorkingDirectory=${WEBAPP_DIR}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=${SERVICE_NAME}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
ok "Service file written"
|
||||
|
||||
# ── Enable & start ────────────────────────────────────────────────────────────
|
||||
step "Enabling and starting service…"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
# Wait a moment then check status
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
ok "Service is running"
|
||||
else
|
||||
warn "Service may have failed — check: journalctl -u $SERVICE_NAME -n 30"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
HOST_IP=$(hostname -I | awk '{print $1}')
|
||||
echo ""
|
||||
echo " ┌──────────────────────────────────────────────┐"
|
||||
echo " │ Docker Backup UI is running │"
|
||||
echo " │ │"
|
||||
printf " │ Local: http://localhost:%-19s│\n" "${PORT}"
|
||||
printf " │ Network: http://%-27s│\n" "${HOST_IP}:${PORT}"
|
||||
echo " │ │"
|
||||
echo " │ Manage service: │"
|
||||
echo " │ systemctl status $SERVICE_NAME │"
|
||||
echo " │ systemctl stop $SERVICE_NAME │"
|
||||
echo " │ systemctl restart $SERVICE_NAME │"
|
||||
echo " │ journalctl -u $SERVICE_NAME -f │"
|
||||
echo " └──────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
1
webapp/.secret_key
Normal file
1
webapp/.secret_key
Normal file
@@ -0,0 +1 @@
|
||||
ba0e6899ed5beaa803976a8e1068df03270fc67aae10560be81f937e0adc0664
|
||||
859
webapp/app.py
Normal file
859
webapp/app.py
Normal file
@@ -0,0 +1,859 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Docker Backup Web UI"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from docker.errors import NotFound, APIError
|
||||
from flask import Flask, Response, jsonify, render_template, request, session, stream_with_context
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── Secret key (stable across restarts) ──────────────────────────────────────
|
||||
_KEY_FILE = Path(__file__).parent / ".secret_key"
|
||||
if _KEY_FILE.exists():
|
||||
app.secret_key = _KEY_FILE.read_text().strip()
|
||||
else:
|
||||
_key = secrets.token_hex(32)
|
||||
_KEY_FILE.write_text(_key)
|
||||
app.secret_key = _key
|
||||
|
||||
CONFIG_FILE = Path(__file__).parent / "config.json"
|
||||
DEFAULT_CONFIG = {
|
||||
"hosts": ["local"],
|
||||
"backup_dir": str(Path(__file__).parent / "backups"),
|
||||
"auth": {},
|
||||
"schedules": [],
|
||||
}
|
||||
|
||||
jobs: dict[str, dict] = {}
|
||||
jobs_lock = threading.Lock()
|
||||
|
||||
CRON_ALIASES = {
|
||||
"hourly": "0 * * * *",
|
||||
"daily": "0 2 * * *",
|
||||
"weekly": "0 2 * * 0",
|
||||
"monthly": "0 2 1 * *",
|
||||
}
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> 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/<path:host>", 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/<path:filename>", 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/<sid>", 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/<sid>", 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/<sid>/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/<jid>/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)
|
||||
4
webapp/start.sh
Executable file
4
webapp/start.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Start Docker Backup Web UI
|
||||
cd "$(dirname "$0")"
|
||||
exec python3 app.py "$@"
|
||||
1109
webapp/templates/index.html
Normal file
1109
webapp/templates/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user