commit ba428d591c112dd9d46709173d1c9bf70182e709 Author: Jeremy McClure Date: Sun Jul 12 00:24:37 2026 -0400 added docker-stack-backup diff --git a/docker/docker-stack-backup/README.md b/docker/docker-stack-backup/README.md new file mode 100644 index 0000000..32715b7 --- /dev/null +++ b/docker/docker-stack-backup/README.md @@ -0,0 +1,394 @@ +# Docker Compose Stack Backup & Restore + +A bash script for backing up and restoring Docker Compose stacks with full permission preservation, container lifecycle management, and running-stack discovery. + +By default, `--all` discovers stacks relative to the **script's location** (so drop the script in your stacks directory or add it to PATH and `cd` to your stacks). Backups go into `$PWD/backups/`. External stacks anywhere on the filesystem are discoverable via `--running` or explicit `--stack /path`. + +## How It Works + +A **stack** is any directory containing a compose file (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`). + +### Backup Flow + +``` +1. Resolve target stacks (--all, --running, or --stack) +2. For each stack: + a. Elevate to root via sudo (if not already) + b. Load x-backup config from compose.yaml + c. Run pre-backup hook (if configured) + d. If x-backup.stop != false → `docker compose down` + e. Dump named Docker volumes into _volumes/ (unless opted out) + f. Create a timestamped tar.gz archive: + tar -czf /_.tar.gz \ + --same-permissions --preserve-permissions \ + [--exclude=...] \ + -C + g. Write .meta file (path, sha256, size, volumes flag) + h. Run post-backup hook (if configured) + i. Apply retention (prune old backups if configured) + j. If was stopped → `docker compose up -d` +``` + +The archive contains the **entire stack directory** — compose file, `.env` file, and all data subdirectories (bind mounts). Permissions and ownership are preserved via `tar --same-permissions --preserve-permissions`, which is critical for bind mounts that run as non-root UIDs inside containers. Named Docker volumes are also dumped by default (see `backup-volumes`). + +### Restore Flow + +``` +1. Resolve target stacks (same logic as backup) +2. For each stack: + a. Find the most recent backup matching _*.tar.gz + b. Remove any stale pre-restore directories from previous failed restores + c. Load x-backup config from the backup archive + d. `docker compose down` (always — we're about to replace the directory) + e. Run pre-restore hook (if configured) + f. If stack directory exists → rename to .pre-restore- + g. Extract backup: + tar -xzf \ + --same-permissions --preserve-permissions \ + -C + h. Remove the pre-restore copy (backup archive is the authoritative source) + i. Restore named Docker volumes from _volumes/ in the archive + j. Run post-restore hook (if configured) + k. `docker compose up -d` (only if the stack was running before restore) +``` + +## Discovery + +The script finds stacks in three ways: + +| Mode | What it finds | +|------|---------------| +| `--all` | Every subdirectory of the **script's directory** containing a compose file | +| `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` | +| `--stack ` | A specific stack by name (relative to the script's directory) or by absolute path | + +### External Stacks + +When `--running` discovers a compose project living outside the stacks directory, the script uses its absolute path directly. During backup, it archives from the correct parent directory so the stack's directory structure is preserved. Restore writes back to the same absolute path. + +You can also explicitly target external stacks by passing an absolute path to `--stack`: + +```bash +./docker-stack-backup.sh backup --stack /srv/docker/nextcloud +``` + +This is useful for ad-hoc backups of stacks that aren't under your main stacks directory. + +## Usage + +### Backup + +```bash +# Interactive menu — shows all available stacks with running status +./docker-stack-backup.sh backup + +# All stacks found in the script's directory (drop it in your stacks folder) +./docker-stack-backup.sh backup --all + +# Only stacks currently running in Docker (wherever they live) +./docker-stack-backup.sh backup --running + +# Specific stacks (by name or absolute path) +./docker-stack-backup.sh backup --stack random_stack +./docker-stack-backup.sh backup --stack /srv/docker/nextcloud +./docker-stack-backup.sh backup -s stack_a -s stack_b +./docker-stack-backup.sh backup --stack stack_a,stack_b + +# With a custom backup destination +./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups + +# Dry run to see what would happen +./docker-stack-backup.sh backup --all --dry-run --verbose +``` + +Running `backup` without flags opens an interactive menu: + +``` +Available stacks: +----------------- + 1) external_stack (running, /tmp/external_stack) + 2) random_stack (running) + all) Back up all stacks + q) Cancel + +Select stacks to back up (numbers, 'all', or 'q'): +``` + +After selecting stacks, the script re-executes via `sudo` with your selections as `--stack` flags so root elevation happens only when needed. Local stacks and running stacks discovered elsewhere are listed together. Select by index, ranges (`1-3`), or `all`. + +### Restore + +```bash +# Interactive menu — shows all available backups with readable dates +./docker-stack-backup.sh restore + +# Restore a specific stack from its latest backup +./docker-stack-backup.sh restore --stack random_stack + +# Restore an external stack by path +./docker-stack-backup.sh restore --stack /srv/docker/nextcloud + +# Restore from a specific backup directory +./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups +``` + +Running `restore` without `--stack` opens an interactive menu: + +``` +Available backups: +------------------ + 1) external_stack 2026-07-11 19:49:49 + 2) random_stack 2026-07-11 19:49:48 + all) Restore all stacks + q) Cancel + +Select stacks to restore (numbers, 'all', or 'q'): +``` + +After selecting backups, the script re-executes via `sudo` with the resolved stack paths so the actual restore runs as root. You can enter space-separated numbers, ranges like `1-3`, or `all`. Stacks that were originally external (backed up from an absolute path) use a stored `.meta` file to restore to the correct location automatically. + +When restoring over an existing stack directory, the current directory is briefly renamed to `.pre-restore-` as a safety net during extraction. Once the restore completes successfully, the pre-restore copy is removed — the backup archive is the authoritative restore point. Any stale pre-restore directories from previous failed restores are cleaned up automatically. + +### Verify + +```bash +# Check integrity of all backups +./docker-stack-backup.sh verify + +# Check a specific stack's backups +./docker-stack-backup.sh verify --stack random_stack +``` + +Each backup has a SHA256 hash stored in its `.meta` file. The `verify` command recalculates it and reports any corruption: + +``` + OK random_stack_20260711_204621.tar.gz (4.0K) + FAIL badstack_20260711_120000.tar.gz (sha256 mismatch) +``` + +Does not require root — read-only operation. + +### List Stacks & Backups + +```bash +./docker-stack-backup.sh list +``` + +Shows all discovered stacks — local ones and running ones found elsewhere — with their x-backup config tags. Backups display a truncated SHA256 hash, original path for external stacks, and a `[volumes]` tag if named volumes were included. + +``` +=== Stacks === + - random_stack (running) [x-backup: pre-hook post-hook exclude retention=7] + - nextcloud (/srv/docker/nextcloud) (running) + +=== Backups === + random_stack_20260711_204621.tar.gz (4.0K) -> random_stack sha256:e3b0c44298fc.. + nextcloud_20260711_193245.tar.gz (128M) -> nextcloud sha256:1a2b3c4d5e6f.. [volumes] +``` + +External stacks show their full path in parentheses. + +## x-backup Extensions + +You can control per-stack backup behavior by adding an `x-backup` top-level key to your `compose.yaml`. This follows Docker Compose's [extension mechanism](https://docs.docker.com/compose/compose-file/10-extension/). + +```yaml +services: + app: + image: nginx + db: + image: postgres:16 + +x-backup: + stop: false + exclude: + - app_data/cache + - db_data/wal_archive + pre-hook: pg_dumpall -U postgres > /tmp/db_dump.sql + post-hook: rm -f /tmp/db_dump.sql + pre-restore-hook: echo "About to restore..." + post-restore-hook: echo "Restore complete" + retention: 14 +``` + +### Configuration Options + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `stop` | boolean | `true` | Stop containers during backup. Set to `false` for zero-downtime backups of databases that handle snapshots internally. | +| `exclude` | list | — | Paths to exclude from the archive, relative to the stack directory. Useful for large cache directories, temp files, or generated data. | +| `pre-hook` | string | — | Shell command run **before** the backup archive is created (after container stop). | +| `post-hook` | string | — | Shell command run **after** the backup archive is created (before container start). | +| `pre-restore-hook` | string | — | Shell command run **before** a restore (after container stop). | +| `post-restore-hook` | string | — | Shell command run **after** a restore (before container start). | +| `retention` | integer | — | Maximum number of backups to keep. Older backups are pruned after each successful backup. | +| `backup-volumes` | boolean | `true` | Dump named Docker volumes alongside the file backup. Set to `false` to skip. Volumes are archived into a `_volumes/` directory inside the archive and restored automatically. | + +### Hook Examples + +**Database-safe backup** — dump the database before tarring, then clean up: + +```yaml +x-backup: + pre-hook: docker exec -t $(docker ps --filter name=db -q) pg_dumpall -U postgres > /tmp/db.sql + post-hook: rm -f /tmp/db.sql +``` + +**Notification on completion**: + +```yaml +x-backup: + post-hook: curl -fsS -m 10 --retry 3 -o /dev/null "https://hc-ping.com/my-uuid" +``` + +### Named Docker Volumes (`backup-volumes`) + +Compose files can define **named volumes** — data stored inside Docker's storage area rather than on the host filesystem. By default the script detects them from your compose file and dumps them into `_volumes/` inside the archive. + +Variable substitution in volume paths (`${VAR:-/default}:/container/path`) is handled correctly — parameterised bind mounts are not mistaken for named volumes. + +To skip named volumes (e.g. they're ephemeral or backed up separately), set `x-backup.backup-volumes: false`: + +```yaml +services: + app: + image: nginx + volumes: + - ./app_data:/app/data # bind mount — always backed up + - app_cache:/app/cache # named volume — skipped if opted out + +volumes: + app_cache: + +x-backup: + backup-volumes: false +``` + +When opted out, a warning is shown listing the skipped volumes: + +``` + [!] Named volumes detected: + - app_cache + - pgdata + These store data inside Docker's storage area, not on the host filesystem. + Set 'x-backup.backup-volumes: false' to skip them. +``` + +When enabled (default), each named volume is dumped via `docker run ... alpine tar` into a `_volumes/` directory inside the backup archive: + +``` +random_stack_20260711_204621.tar.gz + random_stack/ + compose.yaml + ... + _volumes/ + app_cache.tar.gz + pgdata.tar.gz +``` + +During `restore`, the volumes are recreated and their data is extracted back. The `_volumes/` directory is automatically removed after restore. + +## Metadata Files + +Each backup archive has an accompanying `.meta` file with key-value metadata for identification and verification: + +``` +stack=random_stack +path=/home/jeremy/Projects/docker/random_stack +date=20260711_204621 +compose=compose.yaml +volumes=true +sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +size=4096 +``` + +| Field | Description | +|-------|-------------| +| `stack` | Stack name (directory basename) | +| `path` | Original full path — used by interactive restore to place files correctly | +| `date` | Backup timestamp (`YYYYMMDD_HHMMSS`) | +| `compose` | Compose filename found in the stack | +| `volumes` | Whether named Docker volumes were included | +| `sha256` | SHA256 hex digest of the archive file | +| `size` | Archive size in bytes | + +The `verify` command reads `sha256` and `size` to check integrity. Legacy `.meta` files (just a bare path string) are still supported. + +### Detecting x-backup Config in `list` + +Running `list` shows active x-backup settings for each stack: + +``` +=== Stacks === + - random_stack (running) [x-backup: no-stop pre-hook post-hook exclude retention=7] +``` + +Non-default settings (anything other than `stop: true`) are displayed as tags. + +## Directory Layout + +Stack discovery (`--all`) looks relative to the **script's location**. Backups default to `$PWD/backups/` so archives land where you ran the script, not where the script lives. Use `--backup-dir` to override. + +``` +/data/stacks/ # put the script here, or cd here and use it from PATH +├── random_stack/ # A compose project +│ ├── compose.yaml +│ ├── .env +│ ├── app_data/ # Bind-mounted volume dir +│ └── db_data/ # Bind-mounted volume dir +├── nextcloud/ # Another compose project +│ ├── compose.yaml +│ └── ... +└── backups/ # Created automatically in $PWD + ├── random_stack_20260711_191731.tar.gz + └── nextcloud_20260711_193245.tar.gz + +/srv/docker/ +└── grafana/ # External running stack (discovered via --running + ├── compose.yaml # or targeted with --stack /srv/docker/grafana) + ├── .env + └── data/ +``` + +## Running in a Container (Cron) + +The script is designed to be trivially containerized. Run the container as root (or use `user: root`) to preserve file ownership. + +Example Docker Compose entry: + +```yaml +services: + stack-backup: + image: alpine:latest + user: root + entrypoint: | + sh -c " + apk add --no-cache docker-cli tar && + /data/docker-stack-backup.sh backup --running --backup-dir /backups + " + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /path/to/stacks:/data:ro # stacks dir for discovery + - /path/to/backups:/backups + # Mount any external stack directories that --running may discover: + - /srv/docker:/srv/docker:ro + restart: no +``` + +Or as a cron job on the host (in root's crontab, or use `sudo`): + +```cron +0 3 * * * /path/to/stacks/docker-stack-backup.sh backup --running >> /var/log/stack-backup.log 2>&1 +``` + +## Requirements + +- **Root access** — backup and restore require root because `tar --same-permissions --preserve-permissions` only preserves UID/GID ownership when running as root. The script automatically re-executes via `sudo` when needed. Read-only commands (`list`, `verify`, and the interactive menus) run without elevation. + + When run via `sudo`, the backup archives (`.tar.gz` and `.meta` files) are automatically `chown`ed back to the original user so you can list, copy, or delete them without root. + +- **bash** (no bashisms beyond `set -euo pipefail` and arrays) +- **tar** (for archive creation/extraction) +- **docker** (for compose lifecycle — `docker compose`) + +If Docker is not available or not running, the script will still back up and restore files; it simply skips the container stop/start steps with a warning. diff --git a/docker/docker-stack-backup/docker-stack-backup.sh b/docker/docker-stack-backup/docker-stack-backup.sh new file mode 100755 index 0000000..6c39ef5 --- /dev/null +++ b/docker/docker-stack-backup/docker-stack-backup.sh @@ -0,0 +1,1317 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --- Configuration ----------------------------------------------------------- +# NOTE: INITIAL_DIR is re-set inside main() after exec sudo, since sudo may +# change the working directory. The top-level value here is a fallback for +# the usage text. +_DEFAULT_BACKUP_DIR="${PWD}/backups" +VERBOSE=false +DRY_RUN=false +ACTION="" +TARGET_STACKS=() +MODE="all" # all|running +HAS_FLAGS=false +ORIG_ARGS=() + +# --- Helper Functions -------------------------------------------------------- + +log() { if $VERBOSE; then echo "[INFO] $*"; fi; } +warn() { echo "[WARN] $*" >&2; } +err() { echo "[ERROR] $*" >&2; exit 1; } +dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; } + +# When run via sudo, chown files back to the original user +fix_owner() { + [ -n "${SUDO_USER:-}" ] && chown "$SUDO_USER:" "$@" 2>/dev/null || true +} + +# --- Stack Spec Resolution ---------------------------------------------------- +# A stack spec can be: +# name – resolved as / +# /absolute/path – used directly, name = basename + +stack_spec_to_path() { + local stacks_dir="$1" + local spec="$2" + if [[ "$spec" == /* ]]; then + echo "$spec" + else + echo "$stacks_dir/$spec" + fi +} + +stack_spec_to_name() { + local spec="$1" + basename "$spec" +} + +usage() { + cat < [options] + +Commands: + backup Backup one or more docker compose stacks. + With no flags, shows an interactive menu. + restore Restore one or more stacks from a backup. + With no --stack flag, shows an interactive menu. + list List available stacks and backups + verify Verify backup integrity (checks sha256 hash) + +Options: + -s, --stack Target a specific stack (repeatable, or comma-separated). + Can be a name (relative to stacks dir) or absolute path. + -a, --all Target all stacks found in stacks directory + -r, --running Target only currently running compose projects wherever + they live (discovers compose file paths automatically) + -d, --backup-dir Backup directory (default: ${_DEFAULT_BACKUP_DIR}) + -n, --dry-run Show what would be done without making changes + -v, --verbose Verbose output + -h, --help Show this help message + +Must be run as root (preserves file ownership in backups and restores). + +Compose x-backup extensions (add to compose.yaml): + x-backup: + stop: false # Skip container stop/start + exclude: + - path/to/exclude # Paths to exclude from the archive + pre-hook: "command" # Run before backup + post-hook: "command" # Run after backup + pre-restore-hook: "cmd" # Run before restore + post-restore-hook: "cmd" # Run after restore + retention: 7 # Keep only N latest backups + backup-volumes: false # Skip named Docker volume dumps + +Examples: + $(basename "$0") backup --all + $(basename "$0") backup --running + $(basename "$0") backup --stack random_stack --stack another_stack + $(basename "$0") backup -s random_stack -v + $(basename "$0") restore --stack random_stack -d /path/to/backups + $(basename "$0") list +EOF + exit 0 +} + +# --- Dependency Checks ------------------------------------------------------- + +check_deps() { + for cmd in docker tar; do + command -v "$cmd" >/dev/null 2>&1 || err "Required command '$cmd' is not installed" + done +} + +require_root() { + if [ "$(id -u)" -eq 0 ]; then + return + fi + echo "Elevating to root via sudo..." + exec sudo "$0" "${ORIG_ARGS[@]}" +} + +# --- Stack Discovery --------------------------------------------------------- + +# Get all directories in stacks dir that contain a compose file +discover_all_stacks() { + local stacks_dir="$1" + local stack + local result=() + for dir in "$stacks_dir"/*/; do + stack="$(basename "$dir")" + if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then + result+=("$stack") + fi + done + echo "${result[@]}" +} + +_RUNNING_CACHE="" + +# Get currently running compose projects — returns absolute directory paths +# Results are cached for the lifetime of the process since running compose +# projects don't change during a single backup/restore/list invocation. +discover_running_stacks() { + [ -n "$_RUNNING_CACHE" ] && echo "$_RUNNING_CACHE" && return + local result + if result="$(docker compose ls --format json 2>/dev/null | python3 -c " +import sys, json, os + +raw = sys.stdin.read().strip() +if not raw: + sys.exit(0) + +try: + data = json.loads(raw) +except json.JSONDecodeError: + sys.exit(0) + +if isinstance(data, list): + items = data +else: + items = [data] + +for obj in items: + if not isinstance(obj, dict): + continue + name = obj.get('Name', '') or '' + config_files = obj.get('ConfigFiles', '') or '' + if config_files: + path = config_files.split(',')[0].strip() + path = os.path.dirname(path) + print(path) + else: + wd = obj.get('WorkingDirectory', '') or '' + if wd: + print(os.path.join(wd, name)) + else: + print(name) +" 2>/dev/null)"; then + _RUNNING_CACHE="$result" + else + result="$(docker compose ls 2>/dev/null | tail -n +2 | awk '{print $1}' || true)" + _RUNNING_CACHE="$result" + fi + echo "$_RUNNING_CACHE" +} + +resolve_target_stacks() { + local stacks_dir="$1" + local mode="$2" + shift 2 + local explicit=("$@") + + if [ ${#explicit[@]} -gt 0 ]; then + for spec in "${explicit[@]}"; do + stack_spec_to_path "$stacks_dir" "$spec" + done + return + fi + + case "$mode" in + running) + discover_running_stacks + ;; + all|*) + discover_all_stacks "$stacks_dir" + ;; + esac +} + +# --- Compose File Helpers ---------------------------------------------------- + +# Resolve the compose file path for a stack +resolve_compose_file() { + local stack_path="$1" + for name in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do + [ -f "$stack_path/$name" ] && echo "$stack_path/$name" && return + done + echo "" +} + +# Extract the x-backup block (lines between "x-backup:" and the next top-level key) +extract_x_backup_block() { + local compose_file="$1" + [ -z "$compose_file" ] && return 1 + awk ' + /^x-backup:/ { found=1; next } + found && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { found=0; next } + found { print } + ' "$compose_file" 2>/dev/null || true +} + +# Get a boolean x-backup setting (default: "true") +x_backup_bool() { + local block="$1" + local key="$2" + local default="${3:-true}" + local val + val="$(echo "$block" | grep -E "^\s+${key}:" | sed "s/.*${key}:[[:space:]]*//" | head -1 | tr -d ' \t' | tr '[:upper:]' '[:lower:]')" + # Strip inline YAML comments + val="${val%%#*}" + [ -z "$val" ] && echo "$default" && return + [ "$val" = "false" ] || [ "$val" = "0" ] || [ "$val" = "no" ] && echo "false" && return + echo "true" +} + +# Get a string x-backup setting +x_backup_str() { + local block="$1" + local key="$2" + local default="${3:-}" + local val + val="$(echo "$block" | grep -E "^\s+${key}:" | sed "s/.*${key}:[[:space:]]*//" | head -1)" + # Strip inline YAML comments and trailing whitespace + val="${val%%#*}" + val="${val%% }" + # Strip YAML quotes only when the entire value is wrapped + if [[ "$val" =~ ^\"(.*)\"$ ]] || [[ "$val" =~ ^\'(.*)\'$ ]]; then + val="${BASH_REMATCH[1]}" + fi + echo "${val:-$default}" +} + +# Get a list x-backup setting (items starting with "- ") +x_backup_list() { + local block="$1" + local key="$2" + local indent + indent="$(echo "$block" | grep -n "^\s\+${key}:" | head -1 | sed 's/\([0-9]*\):.*/\1/')" + if [ -n "$indent" ]; then + echo "$block" | awk -v start="$indent" 'NR > start && /^\s+- / { gsub(/^\s+-[[:space:]]*/, ""); gsub(/"/, ""); gsub(/#.*/, ""); gsub(/[[:space:]]+$/, ""); print }' + fi +} + +# Load x-backup config for a stack into global vars +load_x_backup_config() { + local stack_path="$1" + local compose_file + compose_file="$(resolve_compose_file "$stack_path")" + + XB_STOP=true + XB_EXCLUDE="" + XB_PRE_HOOK="" + XB_POST_HOOK="" + XB_PRE_RESTORE_HOOK="" + XB_POST_RESTORE_HOOK="" + XB_RETENTION="" + XB_BACKUP_VOLUMES="true" + + [ -z "$compose_file" ] && return + + local block + block="$(extract_x_backup_block "$compose_file")" + [ -z "$block" ] && return + + XB_STOP="$(x_backup_bool "$block" "stop" "true")" + XB_PRE_HOOK="$(x_backup_str "$block" "pre-hook")" + XB_POST_HOOK="$(x_backup_str "$block" "post-hook")" + XB_PRE_RESTORE_HOOK="$(x_backup_str "$block" "pre-restore-hook")" + XB_POST_RESTORE_HOOK="$(x_backup_str "$block" "post-restore-hook")" + XB_RETENTION="$(x_backup_str "$block" "retention")" + + local excludes + excludes="$(x_backup_list "$block" "exclude")" + if [ -n "$excludes" ]; then + XB_EXCLUDE="" + while IFS= read -r item; do + [ -n "$item" ] && XB_EXCLUDE="$XB_EXCLUDE --exclude=$(basename "$stack_path")/$item" + done <<< "$excludes" + fi + + XB_BACKUP_VOLUMES="$(x_backup_bool "$block" "backup-volumes" "true")" +} + +# Build the x-backup config tag string for list display (reads global XB_ vars) +format_xb_tags() { + local tags="" + [ "$XB_STOP" = "false" ] && tags="$tags no-stop" + [ -n "$XB_PRE_HOOK" ] && tags="$tags pre-hook" + [ -n "$XB_POST_HOOK" ] && tags="$tags post-hook" + [ -n "$XB_EXCLUDE" ] && tags="$tags exclude" + [ -n "$XB_RETENTION" ] && tags="$tags retention=$XB_RETENTION" + [ -n "$tags" ] && tags=" [x-backup:$tags]" + echo "$tags" +} + +# Apply retention: keep only the N most recent backups +apply_retention() { + local backup_dir="$1" + local stack_name="$2" + local retention="$3" + + [ -z "$retention" ] && return + [ "$retention" -le 0 ] 2>/dev/null && return + + local count + count="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | wc -l)" + if [ "$count" -gt "$retention" ]; then + local to_delete + to_delete="$((count - retention))" + log "Retention=$retention, pruning $to_delete old backup(s) for '$stack_name'" + find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | head -n "$to_delete" | while IFS= read -r f; do + log " Pruning: $(basename "$f")" + dry rm -f "$f" "${f}.meta" + done + fi +} + +# --- Named Volume Detection & Backup ----------------------------------------- + +# Parse the compose file and return named (non-bind-mount) volume names. +# These are listed under a top-level "volumes:" key or referenced under services. +detect_named_volumes() { + local compose_file="$1" + [ -z "$compose_file" ] && return + + python3 -c ' +import sys, re + +with open(sys.argv[1]) as f: + content = f.read() + +service_vols = set() +in_vol_list = False +for line in content.split("\n"): + s = line.strip() + if re.match(r"^volumes:", s): + in_vol_list = True + continue + if in_vol_list: + if not s or not line[0].isspace(): + in_vol_list = False + continue + # Short syntax: - source:target + if s.startswith("- ") and ":" in s: + parts = s.split(":", 1) + src = parts[0].replace("- ", "", 1).strip() + # Skip long-syntax keys (type, source, target, content) + if src in ("type", "source", "target", "content", "consistency"): + continue + if src and not src.startswith("/") and not src.startswith("./") and not src.startswith("../") and not src.startswith("${"): + service_vols.add(src) + continue + # Long syntax: source: value + m = re.search(r"source:\s*(\S+)", s) + if m: + src = m.group(1) + if src and not src.startswith("/") and not src.startswith("./") and not src.startswith("../") and not src.startswith("${"): + service_vols.add(src) + +for v in sorted(service_vols): + print(v) +' "$compose_file" 2>/dev/null || true +} + +# Warn about named volumes that won't be included in the file backup +warn_named_volumes() { + local compose_file="$1" + local stack_name="$2" + + [ -z "$compose_file" ] && return + + local vols + vols="$(detect_named_volumes "$compose_file")" + [ -z "$vols" ] && return + + echo " [!] Named volumes detected (not backed up by default):" + for v in $vols; do + echo " - $v" + done + echo " These store data inside Docker's storage area, not on the host filesystem." + echo " Set 'x-backup.backup-volumes: false' to skip them." +} + +# Resolve the actual Docker volume name for a compose volume reference. +# Compose v2 names volumes as _ where project = stack dir name. +resolve_docker_volume() { + local stack_name="$1" + local vol_name="$2" + echo "${stack_name}_${vol_name}" +} + +# Back up named Docker volumes into a temporary directory. +# Each volume is dumped as /_volumes/.tar.gz +backup_named_volumes() { + local stack_path="$1" + local stack_name="$2" + local compose_file="$3" + + [ -z "$compose_file" ] && return + + local vols + vols="$(detect_named_volumes "$compose_file")" + [ -z "$vols" ] && return + + local volumes_dir="$stack_path/_volumes" + mkdir -p "$volumes_dir" + + # shellcheck disable=SC2066 + while IFS= read -r vol; do + [ -z "$vol" ] && continue + # Skip anything that looks like a shell variable — it's a parametrised + # bind-mount path, not a named Docker volume. + [[ "$vol" == *'$'* ]] && continue + local docker_vol + docker_vol="$(resolve_docker_volume "$stack_name" "$vol")" + + if docker volume inspect "$docker_vol" >/dev/null 2>&1; then + log "Dumping named volume '$docker_vol'..." + if $DRY_RUN; then + echo "[DRY-RUN] docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz" + else + docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \ + warn "Failed to dump named volume '$docker_vol'" + fi + else + warn "Named volume '$docker_vol' not found, skipping" + fi + done <<< "$vols" +} + +# Restore named Docker volumes from a backup archive (the _volumes/ dir) +restore_named_volumes() { + local stack_path="$1" + local stack_name="$2" + + local volumes_dir="$stack_path/_volumes" + [ ! -d "$volumes_dir" ] && return + + local any_restored=false + for dump in "$volumes_dir"/*.tar.gz; do + [ -f "$dump" ] || continue + local vol_name + vol_name="$(basename "$dump" .tar.gz)" + local docker_vol + docker_vol="$(resolve_docker_volume "$stack_name" "$vol_name")" + + log "Restoring named volume '$docker_vol'..." + if $DRY_RUN; then + echo "[DRY-RUN] docker volume create $docker_vol" + echo "[DRY-RUN] cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst" + else + docker volume rm "$docker_vol" 2>/dev/null || true + docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; } + # Pipe via cat + -i flag to ensure stdin works inside the container + cat "$dump" | docker run -i --rm -v "${docker_vol}:/_dst" alpine tar -xzf - -C /_dst 2>/dev/null || \ + warn "Failed to restore named volume '$docker_vol'" + fi + any_restored=true + done + + # Clean up the extracted volume dumps + rm -rf "$volumes_dir" + + if $any_restored; then + echo " -> Named volumes restored" + fi +} + +run_hook() { + local stage="$1" + local stack_name="$2" + local command="$3" + + [ -z "$command" ] && return + log "Running $stage hook for '$stack_name': $command" + if $DRY_RUN; then + echo "[DRY-RUN] $command" + else + eval "$command" || warn "Hook '$stage' for '$stack_name' exited with status $?" + fi +} + +# --- Docker Operations ------------------------------------------------------- + +stack_is_running() { + local stack_path="$1" + local running + running="$(discover_running_stacks)" + # Match against either the full path or the basename (name-only fallback) + echo "$running" | grep -qx "$stack_path" || echo "$running" | grep -qx "$(basename "$stack_path")" +} + +stop_stack() { + local stack_path="$1" + local stack_name + stack_name="$(basename "$stack_path")" + + # Find compose file in the stack directory + local compose_file + compose_file="$(resolve_compose_file "$stack_path")" + + if stack_is_running "$stack_path"; then + log "Stopping stack '$stack_name' ($stack_path)..." + if [ -n "$compose_file" ]; then + dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \ + dry docker compose -p "$stack_name" down 2>/dev/null || \ + warn "Failed to stop stack '$stack_name'" + else + dry docker compose -p "$stack_name" down 2>/dev/null || \ + warn "Failed to stop stack '$stack_name'" + fi + else + log "Stack '$stack_name' is not running, skipping stop" + fi +} + +start_stack() { + local stack_path="$1" + local stack_name + stack_name="$(basename "$stack_path")" + + local compose_file + compose_file="$(resolve_compose_file "$stack_path")" + + if [ -n "$compose_file" ]; then + log "Starting stack '$stack_name' ($stack_path)..." + dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \ + warn "Failed to start stack '$stack_name'" + fi +} + +# --- Backup ------------------------------------------------------------------ + +backup_stack() { + require_root + local TIMESTAMP + TIMESTAMP="$(date +%Y%m%d_%H%M%S)" + local stacks_dir="$1" + local backup_dir="$2" + local stack_path="$3" + local stack_name + stack_name="$(stack_spec_to_name "$stack_path")" + local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz" + local archive_root + archive_root="$(dirname "$stack_path")" + local archive_basename + archive_basename="$(basename "$stack_path")" + + echo "Backing up stack '$stack_name' ($stack_path) -> $backup_file" + + # Load x-backup config + load_x_backup_config "$stack_path" + + if [ "$XB_STOP" = "false" ]; then + log "x-backup.stop=false, skipping container stop" + else + stop_stack "$stack_path" + fi + + # Dump named Docker volumes (opt-out via x-backup.backup-volumes: false) + local compose_file + compose_file="$(resolve_compose_file "$stack_path")" + local volumes_dumped=false + if [ "$XB_BACKUP_VOLUMES" = "false" ]; then + warn_named_volumes "$compose_file" "$stack_name" + else + log "Dumping named volumes..." + backup_named_volumes "$stack_path" "$stack_name" "$compose_file" + volumes_dumped=true + fi + + # Pre-backup hook + run_hook "pre-backup" "$stack_name" "$XB_PRE_HOOK" + + # Create the backup: tar from the parent directory so the stack dir + # appears as itself in the archive, preserving relative exclude paths. + # If named volumes were dumped, they live under _volumes/ inside stack_path + # and are included in the archive automatically. + dry mkdir -p "$backup_dir" + fix_owner "$backup_dir" 2>/dev/null || true + if $DRY_RUN; then + echo "[DRY-RUN] tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename" + else + # shellcheck disable=SC2086 + tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename" + fix_owner "$backup_file" + echo " -> Backup complete: $(du -h "$backup_file" | cut -f1)" + fi + + # Clean up temporary volume dumps + if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then + dry rm -rf "$stack_path/_volumes" + fi + + # Write metadata — used by the interactive restore menu and for + # identifying backups without extracting the archive. + if ! $DRY_RUN; then + local has_volumes="false" + [ -d "$stack_path/_volumes" ] && has_volumes="true" + local archive_sha256 archive_size + archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)" + archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")" + cat > "${backup_file}.meta" < "$(basename "${stack_latest[$sname]}")" ]; then + stack_latest["$sname"]="$f" + stack_date["$sname"]="$(format_backup_date "$basename_f")" + local sname_meta="${f}.meta" + if [ -f "$sname_meta" ]; then + local sv + sv="$(grep '^volumes=' "$sname_meta" | head -1 | sed 's/^volumes=//')" + [ "$sv" = "true" ] && stack_date["$sname"]="${stack_date[$sname]} [volumes]" || true + fi + fi + done + + local names=() + for key in "${!stack_latest[@]}"; do + names+=("$key") + done + + if [ ${#names[@]} -eq 0 ]; then + echo "No backups found in $backup_dir" + exit 0 + fi + + # Sort alphabetically + IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS + + echo "" + echo "Available backups:" + echo "------------------" + local i + for i in "${!names[@]}"; do + local idx=$((i + 1)) + printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}" + done + echo " all) Restore all stacks" + echo " q) Cancel" + echo "" + + read -r -p "Select stacks to restore (numbers, 'all', or 'q'): " selection + + [ -z "$selection" ] && echo "Cancelled." && exit 0 + + # Normalise: handle "all" + if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then + selection="" + for i in "${!names[@]}"; do + selection="$selection $((i + 1))" + done + fi + + if [ "$selection" = "q" ]; then + echo "Cancelled." + exit 0 + fi + + # Convert space-separated indices to stack names + local selected_stacks=() + local seen_idx + for seen_idx in $selection; do + seen_idx="$(echo "$seen_idx" | xargs)" # trim + # Allow ranges like 1-3 + if [[ "$seen_idx" =~ ^([0-9]+)-([0-9]+)$ ]]; then + for ((j = ${BASH_REMATCH[1]}; j <= ${BASH_REMATCH[2]}; j++)); do + if [ "$j" -ge 1 ] && [ "$j" -le "${#names[@]}" ]; then + selected_stacks+=("${names[$((j - 1))]}") + fi + done + elif [[ "$seen_idx" =~ ^[0-9]+$ ]]; then + if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then + selected_stacks+=("${names[$((seen_idx - 1))]}") + else + echo "Invalid selection: $seen_idx (out of range)" + fi + fi + done + + if [ ${#selected_stacks[@]} -eq 0 ]; then + echo "No valid stacks selected." + exit 0 + fi + + # Resolve paths and re-exec via sudo with explicit --stack flags + local -a stack_flags=() + for s in "${selected_stacks[@]}"; do + local spath + # Read original path from .meta file if available + local meta_file + meta_file="$(find "$backup_dir" -maxdepth 1 -name "${s}_*.tar.gz.meta" -type f 2>/dev/null | sort | tail -1)" + if [ -n "$meta_file" ]; then + spath="$(grep '^path=' "$meta_file" | head -1 | sed 's/^path=//')" + [ -z "$spath" ] && spath="$(cat "$meta_file" | head -1)" # legacy format + elif [ -d "$stacks_dir/$s" ]; then + spath="$stacks_dir/$s" + else + warn "Cannot determine where to restore '$s': no metadata file and no directory under '$stacks_dir'. Use '--stack /original/path'." + continue + fi + stack_flags+=("--stack" "$spath") + done + + if [ ${#stack_flags[@]} -eq 0 ]; then + echo "No valid stacks selected." + exit 0 + fi + + # Elevate and re-exec with the selected stacks + if [ "$(id -u)" -ne 0 ]; then + echo "Elevating to root via sudo..." + exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}" + fi + + echo "" + for ((i = 0; i < ${#stack_flags[@]}; i += 2)); do + local spath="${stack_flags[$((i + 1))]}" + restore_stack "$stacks_dir" "$backup_dir" "$spath" + done +} + +# --- Verify ------------------------------------------------------------------- + +verify_backup() { + local backup_dir="$1" + local stack_name="$2" + + if [ -n "$stack_name" ]; then + local files + files="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort)" + else + local files + files="$(find "$backup_dir" -maxdepth 1 -name '*.tar.gz' -type f 2>/dev/null | sort)" + fi + + if [ -z "$files" ]; then + echo "No backups found." + return + fi + + local exit_code=0 + for f in $files; do + local basename_f + basename_f="$(basename "$f")" + local meta_file="${f}.meta" + + if [ ! -f "$meta_file" ]; then + echo " SKIP $basename_f (no .meta file)" + continue + fi + + local stored_hash stored_size + stored_hash="$(grep '^sha256=' "$meta_file" | head -1 | sed 's/^sha256=//')" + stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')" + + if [ -z "$stored_hash" ]; then + echo " SKIP $basename_f (no sha256 in .meta — legacy backup)" + continue + fi + + # Check file still exists + if [ ! -f "$f" ]; then + echo " FAIL $basename_f (file missing)" + exit_code=1 + continue + fi + + # Check size + local actual_size + actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")" + if [ "$actual_size" != "$stored_size" ]; then + echo " FAIL $basename_f (size mismatch: $actual_size vs $stored_size)" + exit_code=1 + continue + fi + + # Check hash + local actual_hash + actual_hash="$(sha256sum "$f" | cut -d' ' -f1)" + if [ "$actual_hash" != "$stored_hash" ]; then + echo " FAIL $basename_f (sha256 mismatch)" + exit_code=1 + else + local human_size + human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")" + echo " OK $basename_f ($human_size)" + fi + done + + return $exit_code +} + +# --- Interactive Backup Menu ------------------------------------------------ + +interactive_backup_menu() { + local stacks_dir="$1" + local backup_dir="$2" + + # Collect stacks: local + running, deduped by canonical path + local -A seen + local -a names + local -a paths + local -a labels + + # Running stacks (absolute paths) + local running + running="$(discover_running_stacks 2>/dev/null || true)" + if [ -n "$running" ]; then + while IFS= read -r run_path; do + [ -z "$run_path" ] && continue + [[ "$run_path" != /* ]] && continue + local canon + canon="$(cd "$run_path" 2>/dev/null && pwd || echo "$run_path")" + [ -n "${seen[$canon]:-}" ] && continue + seen["$canon"]=1 + local run_name + run_name="$(basename "$run_path")" + names+=("$run_name") + if [ "$canon" != "$stacks_dir/$run_name" ]; then + paths+=("$canon") + labels+=("(running, $canon)") + else + paths+=("$canon") + labels+=("(running)") + fi + done <<< "$running" + fi + + # Local stacks (not already seen) + for dir in "$stacks_dir"/*/; do + [ -d "$dir" ] || continue + if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then + local canon + canon="$(cd "$dir" 2>/dev/null && pwd || echo "$dir")" + [ -n "${seen[$canon]:-}" ] && continue + seen["$canon"]=1 + local stack + stack="$(basename "$dir")" + names+=("$stack") + paths+=("$canon") + local running_label="" + stack_is_running "$canon" 2>/dev/null && running_label=" (running)" + labels+=("$running_label") + fi + done + + if [ ${#names[@]} -eq 0 ]; then + echo "No stacks found." + exit 0 + fi + + echo "" + echo "Available stacks:" + echo "-----------------" + local i + for i in "${!names[@]}"; do + local idx=$((i + 1)) + printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${labels[$i]}" + done + echo " all) Back up all stacks" + echo " q) Cancel" + echo "" + + read -r -p "Select stacks to back up (numbers, 'all', or 'q'): " selection + + [ -z "$selection" ] && echo "Cancelled." && exit 0 + + if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then + selection="" + for i in "${!names[@]}"; do + selection="$selection $((i + 1))" + done + fi + + if [ "$selection" = "q" ]; then + echo "Cancelled." + exit 0 + fi + + local selected_paths=() + local seen_idx + for seen_idx in $selection; do + seen_idx="$(echo "$seen_idx" | xargs)" + if [[ "$seen_idx" =~ ^([0-9]+)-([0-9]+)$ ]]; then + for ((j = ${BASH_REMATCH[1]}; j <= ${BASH_REMATCH[2]}; j++)); do + if [ "$j" -ge 1 ] && [ "$j" -le "${#names[@]}" ]; then + selected_paths+=("${paths[$((j - 1))]}") + fi + done + elif [[ "$seen_idx" =~ ^[0-9]+$ ]]; then + if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then + selected_paths+=("${paths[$((seen_idx - 1))]}") + else + echo "Invalid selection: $seen_idx (out of range)" + fi + fi + done + + if [ ${#selected_paths[@]} -eq 0 ]; then + echo "No valid stacks selected." + exit 0 + fi + + # Elevate and re-exec with the selected stacks + if [ "$(id -u)" -ne 0 ]; then + local -a stack_flags=() + for spath in "${selected_paths[@]}"; do + stack_flags+=("--stack" "$spath") + done + echo "Elevating to root via sudo..." + exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}" + fi + + echo "" + for spath in "${selected_paths[@]}"; do + backup_stack "$stacks_dir" "$backup_dir" "$spath" + done +} + +# --- Restore ----------------------------------------------------------------- + +restore_stack() { + require_root + local TIMESTAMP + TIMESTAMP="$(date +%Y%m%d_%H%M%S)" + local stacks_dir="$1" + local backup_dir="$2" + local stack_path="$3" + local stack_name + stack_name="$(stack_spec_to_name "$stack_path")" + local archive_root + archive_root="$(dirname "$stack_path")" + local archive_basename + archive_basename="$(basename "$stack_path")" + + # Find the latest backup for this stack + local backup_file + backup_file="$(find "$backup_dir" -maxdepth 1 -name "${stack_name}_*.tar.gz" -type f 2>/dev/null | sort | tail -1)" + + if [ -z "$backup_file" ]; then + err "No backup found for stack '$stack_name' in $backup_dir" + fi + + echo "Restoring stack '$stack_name' ($stack_path) from $backup_file" + + # Get list of files in backup to confirm it's the right one + if $VERBOSE; then + tar -tzf "$backup_file" | head -20 + fi + + # Load x-backup config from within the backup (extract to temp) + local temp_dir + temp_dir="$(mktemp -d)" + local parsed_compose="" + tar -xzf "$backup_file" -C "$temp_dir" --wildcards "*/compose.y*" "*/compose.yml*" "*/docker-compose.y*" "*/docker-compose.yml*" 2>/dev/null || true + local stack_dir_in_backup + stack_dir_in_backup="$(ls "$temp_dir" 2>/dev/null | head -1)" + [ -n "$stack_dir_in_backup" ] && parsed_compose="$temp_dir/$stack_dir_in_backup" + + if [ -n "$parsed_compose" ]; then + load_x_backup_config "$parsed_compose" + else + # Fallback: load from the live dir if it still exists + load_x_backup_config "$stack_path" + fi + + # Track whether the stack was running before we stop it, so we can + # restart it afterwards only if it was originally running. + local was_running=false + stack_is_running "$stack_path" && was_running=true + + # Always stop during restore — we're about to rename/move the entire + # stack directory out from under any running containers. The + # x-backup.stop flag only applies to backup, where data is read-only. + stop_stack "$stack_path" + + # Pre-restore hook + run_hook "pre-restore" "$stack_name" "$XB_PRE_RESTORE_HOOK" + + # Clean up any stale pre-restore dirs from previous failed restores + for old in "$stack_path".pre-restore-*; do + [ -d "$old" ] || continue + log "Removing stale pre-restore backup: $(basename "$old")" + dry rm -rf "$old" + done + + # Restore + if $DRY_RUN; then + echo "[DRY-RUN] tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root" + else + if [ -d "$stack_path" ]; then + local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}" + log "Moving existing '$stack_name' to $existing_backup" + mv "$stack_path" "$existing_backup" + fi + tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root" + # Restore succeeded — remove the pre-restore safety copy since the + # backup archive is the authoritative restore point. + if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then + dry rm -rf "$existing_backup" + fi + echo " -> Restore complete" + # Restore named Docker volumes from the _volumes/ dir inside the archive + restore_named_volumes "$stack_path" "$stack_name" + fi + + # Post-restore hook + run_hook "post-restore" "$stack_name" "$XB_POST_RESTORE_HOOK" + + # Cleanup temp + rm -rf "$temp_dir" + + # Only restart if it was running before we stopped it + if $was_running; then + start_stack "$stack_path" + else + log "Stack was not running before restore, skipping start" + fi +} + +# --- List -------------------------------------------------------------------- + +list_stacks_and_backups() { + local stacks_dir="$1" + local backup_dir="$2" + + echo "=== Stacks ===" + # Collect all seen stack dirs to avoid duplicates + local -A seen + + # 1) Running stacks (discovered wherever they live) + local running + running="$(discover_running_stacks 2>/dev/null || true)" + local has_running=false + if [ -n "$running" ]; then + while IFS= read -r run_path; do + [ -z "$run_path" ] && continue + # skip bare names (fallback from no-Docker mode — they'll be in local) + [[ "$run_path" != /* ]] && continue + # canonical key: realpath or full path + local canon + canon="$(cd "$run_path" 2>/dev/null && pwd || echo "$run_path")" + seen["$canon"]=1 + local run_name + run_name="$(basename "$run_path")" + local label="(running)" + load_x_backup_config "$run_path" + local config_tags + config_tags="$(format_xb_tags)" + if [ "$canon" != "$stacks_dir/$run_name" ]; then + echo " - $run_name ($run_path) $label$config_tags" + else + echo " - $run_name $label$config_tags" + fi + has_running=true + done <<< "$running" + fi + + # 2) Local stacks (not already listed as running) + local has_local=false + for dir in "$stacks_dir"/*/; do + [ -d "$dir" ] || continue + local stack + stack="$(basename "$dir")" + if [ -f "$dir/compose.yaml" ] || [ -f "$dir/compose.yml" ] || [ -f "$dir/docker-compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; then + local canon + canon="$(cd "$dir" 2>/dev/null && pwd || echo "$dir")" + if [ -z "${seen["$canon"]:-}" ]; then + seen["$canon"]=1 + local label="" + stack_is_running "$dir" 2>/dev/null && label=" (running)" + load_x_backup_config "$dir" + local config_tags + config_tags="$(format_xb_tags)" + echo " - $stack$label$config_tags" + has_local=true + fi + fi + done + + if ! $has_running && ! $has_local; then + echo " No stacks found." + fi + + echo "" + echo "=== Backups ===" + if [ -d "$backup_dir" ]; then + local backup_count=0 + for f in "$backup_dir"/*.tar.gz; do + [ -f "$f" ] || continue + local sname + sname="$(basename "$f" | sed 's/_[0-9]\{8\}_[0-9]\{6\}\.tar\.gz$//')" + local meta_info="" + if [ -f "${f}.meta" ]; then + local mpath mvols mhash + mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')" + mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')" + mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')" + [ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.." + [ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)" + [ "$mvols" = "true" ] && meta_info="$meta_info [volumes]" + fi + echo " $(basename "$f") ($(du -h "$f" | cut -f1)) -> $sname$meta_info" + backup_count=$((backup_count + 1)) + done + if [ "$backup_count" -eq 0 ]; then + echo " No backups found in $backup_dir" + fi + else + echo " No backups directory found at $backup_dir" + fi +} + +# --- Main -------------------------------------------------------------------- + +main() { + check_deps + ORIG_ARGS=("$@") + + local SCRIPT_DIR + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + local INITIAL_DIR + INITIAL_DIR="$(pwd -P)" + local stacks_dir="$SCRIPT_DIR" + local backup_dir="${INITIAL_DIR}/backups" + + # Parse arguments + while [ $# -gt 0 ]; do + case "$1" in + backup|restore|list|verify) + ACTION="$1" + shift + ;; + -s|--stack) + if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi + IFS=',' read -ra ADDR <<< "$2" + for s in "${ADDR[@]}"; do + TARGET_STACKS+=("$(echo "$s" | xargs)") + done + HAS_FLAGS=true + shift 2 + ;; + -a|--all) + MODE="all" + HAS_FLAGS=true + shift + ;; + -r|--running) + MODE="running" + HAS_FLAGS=true + shift + ;; + -d|--backup-dir) + if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi + backup_dir="$2" + shift 2 + ;; + -n|--dry-run) + DRY_RUN=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + -h|--help) + usage + ;; + *) + err "Unknown option: $1. Use -h for help." + ;; + esac + done + + if [ -z "$ACTION" ]; then + usage + fi + + if [ ! -d "$stacks_dir" ]; then + err "Stacks directory not found: $stacks_dir" + fi + + case "$ACTION" in + list) + list_stacks_and_backups "$stacks_dir" "$backup_dir" + ;; + backup) + mkdir -p "$backup_dir" + if ! $HAS_FLAGS; then + # No flags at all — show interactive menu (display doesn't need root) + interactive_backup_menu "$stacks_dir" "$backup_dir" + exit 0 + fi + local stacks + stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")" + if [ -z "$stacks" ]; then + echo "No stacks found in '$stacks_dir'. Either cd there, pass --stack /path, or use -d to set the backup directory." + exit 0 + fi + local stack + for stack in $stacks; do + local spath + spath="$(stack_spec_to_path "$stacks_dir" "$stack")" + backup_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Backup failed for '$spath'" + done + ;; + restore) + if [ ! -d "$backup_dir" ]; then + err "Backup directory not found: $backup_dir" + fi + if [ ${#TARGET_STACKS[@]} -eq 0 ]; then + # No --stack flag — show interactive menu (display doesn't need root) + interactive_restore_menu "$stacks_dir" "$backup_dir" + exit 0 + fi + for spec in "${TARGET_STACKS[@]}"; do + local spath + spath="$(stack_spec_to_path "$stacks_dir" "$spec")" + restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'" + done + ;; + verify) + if [ ! -d "$backup_dir" ]; then + err "Backup directory not found: $backup_dir" + fi + local vtarget="" + [ ${#TARGET_STACKS[@]} -gt 0 ] && vtarget="${TARGET_STACKS[0]}" + verify_backup "$backup_dir" "$vtarget" + ;; + esac +} + +main "$@"