diff --git a/README.md b/README.md index 933c917..0f2fb40 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,127 @@ -# compose-backup +# 🐳 Compose Backup & Restore Tool + +A simple backup & restore tool for Docker Compose projects, including volume data. +Ideal for migrating projects or creating point-in-time snapshots of your stack. + +--- + +## πŸ“¦ Features + +- πŸ” Full backup of your Docker Compose project folder +- πŸ’Ύ Archives all named Docker volumes (excluding bind-mounts) +- 🧠 Automatic project detection and naming +- πŸ§™ Interactive restore menu with project/version selection +- ☁️ Stores backups in `~/.compose-backup` +- 🐍 Uses Python + Docker + Bash (no external services) + +--- + +## πŸ“ Project Structure + +``` + +compose-backup/ +β”œβ”€β”€ bin/ +β”‚ β”œβ”€β”€ compose-backup # Bash launcher (patched on install) +β”‚ └── compose-restore # Bash launcher (patched on install) +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ backup.py # Backup logic +β”‚ └── restore.py # Restore logic +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ install.sh # Install script (creates venv & installs commands) +└── README.md + +```` + +--- + +## πŸš€ Installation + +```bash +git clone https://your.repo/compose-backup.git +cd compose-backup +./install.sh +```` + +This will: + +* Set up a virtual environment in `.venv` +* Install required Python packages +* Patch & install `compose-backup` and `compose-restore` to `/usr/local/bin` + +> πŸ”’ You’ll be prompted for your sudo password to copy binaries system-wide. + +--- + +## πŸ” Usage + +### πŸ“€ Backup + +From inside a folder containing your `docker-compose.yml`: + +```bash +compose-backup +``` + +Creates a full backup (project files + named volumes) into: + +``` +~/.compose-backup/ +└── myproject_backup_20250711_183012.tar.gz +``` + +--- + +### πŸ“₯ Restore + +To restore a backup, anywhere on your system: + +```bash +compose-restore +``` + +* Step 1: Choose project +* Step 2: Choose version +* Step 3: Project files are restored to your current folder +* Step 4: Volumes are restored via temporary containers + +--- + +## 🧼 Uninstall + +To remove installed commands: + +```bash +sudo rm /usr/local/bin/compose-backup /usr/local/bin/compose-restore +``` + +(Optional) remove `.venv` and the repo folder. + +--- + +## πŸ›  Requirements + +* Docker +* Python 3.8+ +* Linux or macOS (tested on Ubuntu) + +--- + +## πŸ“Œ Notes + +* Only **named volumes** (declared under `volumes:` in your `docker-compose.yml`) are backed up. +* **Bind mounts** like `./data:/app/data` are not included. +* The tool does **not** stop running containers before backup β€” ensure consistency manually if needed. + +--- + +## 🀝 License + +MIT β€” feel free to fork and improve! + +--- + +## πŸ‘¨β€πŸ’» Author + +Built by gmarth β€” Contributions welcome! diff --git a/bin/compose-backup b/bin/compose-backup new file mode 100644 index 0000000..93953a8 --- /dev/null +++ b/bin/compose-backup @@ -0,0 +1,9 @@ +#!/bin/bash +# Absoluter Pfad zum Skriptverzeichnis +SCRIPT_DIR="$HOME/scripts/compose-backup/src" +# Aktuelles Arbeitsverzeichnis behalten (z.B. ~/docker/portainer) +WORKDIR="$(pwd)" +# Venv aktivieren +source "$SCRIPT_DIR/.venv/bin/activate" +# Python-Skript aufrufen – im aktuellen Arbeitsverzeichnis +python "$SCRIPT_DIR/src/backup.py" "$@" \ No newline at end of file diff --git a/bin/compose-restore b/bin/compose-restore new file mode 100644 index 0000000..ad9d5c2 --- /dev/null +++ b/bin/compose-restore @@ -0,0 +1,9 @@ +#!/bin/bash +# Absoluter Pfad zum Skriptverzeichnis +SCRIPT_DIR="$HOME/scripts/compose-backup/src" +# Aktuelles Arbeitsverzeichnis behalten (z.B. ~/docker/portainer) +WORKDIR="$(pwd)" +# Venv aktivieren +source "$SCRIPT_DIR/.venv/bin/activate" +# Python-Skript aufrufen – im aktuellen Arbeitsverzeichnis +python "$SCRIPT_DIR/src/restore.py" "$@" \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..be9a95d --- /dev/null +++ b/install.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e + +echo "[*] Installiere Compose-Backup Toolset..." + +# Absoluter Pfad zum aktuellen Verzeichnis +BASE_DIR="$(pwd)" +VENV_PATH="$BASE_DIR/.venv" + +echo "[*] Erstelle Virtual Environment unter $VENV_PATH..." +python3 -m venv "$VENV_PATH" + +echo "[*] Installiere Python-AbhΓ€ngigkeiten..." +source "$VENV_PATH/bin/activate" +pip install --upgrade pip +pip install -r requirements.txt + +echo "[*] Installiere nach $HOME/.local/bin..." +mkdir -p "$HOME/.local/bin" +sed "s|^SCRIPT_DIR=.*|SCRIPT_DIR=\"$BASE_DIR\"|" bin/compose-backup > "$HOME/.local/bin/compose-backup" +sed "s|^SCRIPT_DIR=.*|SCRIPT_DIR=\"$BASE_DIR\"|" bin/compose-restore > "$HOME/.local/bin/compose-restore" +chmod +x "$HOME/.local/bin/compose-backup" "$HOME/.local/bin/compose-restore" + +echo "[βœ”] Installation abgeschlossen!" +echo "β†’ Du kannst nun 'compose-backup' und 'compose-restore' global ausfΓΌhren." diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2fb7f46 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyaml==25.7.0 +PyYAML==6.0.2 diff --git a/src/backup.py b/src/backup.py new file mode 100644 index 0000000..467f37e --- /dev/null +++ b/src/backup.py @@ -0,0 +1,83 @@ +import os +import subprocess +import tarfile +import tempfile +import shutil +import yaml +from datetime import datetime +from pathlib import Path + +def run_cmd(cmd, capture_output=False): + result = subprocess.run(cmd, shell=True, text=True, capture_output=capture_output) + if result.returncode != 0: + print(f"[!] Fehler bei Befehl: {cmd}") + print(result.stderr) + raise RuntimeError("Befehl fehlgeschlagen") + return result.stdout if capture_output else None + +def get_project_name(): + return Path(os.getcwd()).name + +def get_volumes(): + print("[*] Ermittele Volumes mit `docker compose config`...") + output = run_cmd("docker compose config", capture_output=True) + output = yaml.safe_load(output) + volumes = [] + + for logical_name, attrs in output.get("volumes", {}).items(): + actual_name = attrs.get("name", logical_name) # fallback if "name" not set + volumes.append(actual_name) + + return volumes + +def archive_project(output_dir): + print("[*] Archiviere Projektordner...") + with tarfile.open(output_dir / "project_files.tar.gz", "w:gz") as tar: + for item in Path(".").iterdir(): + if item.name in [output_dir.name, ".git", "__pycache__"]: + continue + tar.add(item, arcname=item.name) + +def archive_volume(volume_name, backup_path): + print(f"[*] Archiviere Volume: {volume_name}") + archive_file = backup_path / f"{volume_name}.tar.gz" + cmd = ( + f"docker run --rm " + f"-v {volume_name}:/volume " + f"-v {backup_path.absolute()}:/backup " + f"alpine tar czf /backup/{volume_name}.tar.gz -C /volume ." + ) + run_cmd(cmd) + return archive_file + +def main(): + project_name = get_project_name() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + final_archive_name = f"{project_name}_backup_{timestamp}.tar.gz" + + # Step 1: Define and create backup target directory + backup_root = Path.home() / ".compose-backup" + backup_root.mkdir(parents=True, exist_ok=True) + + final_archive_path = backup_root / final_archive_name + + with tempfile.TemporaryDirectory() as tmpdir: + print("[*] Running docker compose up to create volumes...") + run_cmd("docker compose down") + + tmp_path = Path(tmpdir) + archive_project(tmp_path) + + volumes = get_volumes() + for vol in volumes: + archive_volume(vol, tmp_path) + + print(f"[*] Erstelle Gesamtarchiv: {final_archive_path}") + with tarfile.open(final_archive_path, "w:gz") as tar: + for file in tmp_path.iterdir(): + tar.add(file, arcname=file.name) + + print(f"[βœ”] Backup abgeschlossen: {final_archive_path}") + +if __name__ == "__main__": + main() diff --git a/src/restore.py b/src/restore.py new file mode 100644 index 0000000..b6598ae --- /dev/null +++ b/src/restore.py @@ -0,0 +1,86 @@ +import os +import tarfile +import tempfile +import subprocess +from pathlib import Path + +BACKUP_DIR = Path.home() / ".compose-backup" + +def run_cmd(cmd): + result = subprocess.run(cmd, shell=True) + if result.returncode != 0: + raise RuntimeError(f"Fehler beim Befehl: {cmd}") + +def list_projects(): + backups = BACKUP_DIR.glob("*.tar.gz") + projects = {} + + for archive in backups: + name = archive.name.split("_backup_")[0] + projects.setdefault(name, []).append(archive) + + return projects + +def select_from_list(prompt, items): + for i, item in enumerate(items, 1): + print(f"{i}) {item}") + while True: + choice = input(f"{prompt} [1-{len(items)}]: ") + if choice.isdigit() and 1 <= int(choice) <= len(items): + return items[int(choice) - 1] + +def restore_project_files(project, tempdir): + print("[*] Entpacke Projektdateien...") + with tarfile.open(tempdir / "project_files.tar.gz", "r:gz") as tar: + tar.extractall(f"./{project}/") # extract to current dir + +def restore_volume(volume_name, archive_path): + print(f"[*] Stelle Volume wieder her: {volume_name}") + cmd = ( + f"docker run --rm " + f"-v {volume_name}:/volume " + f"-v {archive_path.parent.absolute()}:/backup " + f"alpine sh -c \"cd /volume && tar xzf /backup/{archive_path.name}\"" + ) + run_cmd(cmd) + +def main(): + print("πŸ“¦ VerfΓΌgbare Projekte:") + projects = list_projects() + if not projects: + print("Keine Backups gefunden.") + return + + project = select_from_list("Projekt auswΓ€hlen", list(projects.keys())) + versions = sorted(projects[project], reverse=True) + backup_file = select_from_list("Version auswΓ€hlen", [p.name for p in versions]) + backup_path = BACKUP_DIR / backup_file + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + print(f"[*] Entpacke Backup: {backup_path}") + with tarfile.open(backup_path, "r:gz") as tar: + tar.extractall(tmp) + + restore_project_files(project, tmp) + + # change into project folder and run the compose project a single time + os.chdir(f"{Path.cwd()}/{project}") + print("[*] Running docker compose up to create volumes...") + run_cmd("docker compose up -d") + + print("[*] Stopping containers after volume creation...") + run_cmd("docker compose down") + + + for file in tmp.iterdir(): + if file.name == "project_files.tar.gz": + continue + if file.suffix == ".gz" and file.name.endswith(".tar.gz"): + volume_name = file.name.replace(".tar.gz", "") + restore_volume(volume_name, file) + + print("βœ… Wiederherstellung abgeschlossen.") + +if __name__ == "__main__": + main()