Compare commits
5
Commits
63609830af
...
e54d77893a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54d77893a
|
||
|
|
8617450eb9
|
||
|
|
0afc798ee6
|
||
|
|
84e7dfb2b7
|
||
|
|
f2b17647f4
|
@@ -60,7 +60,9 @@ The script finds stacks in three ways:
|
|||||||
|------|---------------|
|
|------|---------------|
|
||||||
| `--all` | Every subdirectory of the **script's directory** containing a compose file |
|
| `--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` |
|
| `--running` | All running compose projects **anywhere on the filesystem** by reading `ConfigFiles` from `docker compose ls --format json` |
|
||||||
| `--stack <spec>` | A specific stack by name (relative to the script's directory) or by absolute path |
|
| `--stack <spec>` | A specific stack by name (relative to the **current directory**) or by absolute path |
|
||||||
|
|
||||||
|
Add `--dir <path>` to any command to override the default scan directory (script dir for backup/restore, current dir for init).
|
||||||
|
|
||||||
### External Stacks
|
### External Stacks
|
||||||
|
|
||||||
@@ -76,6 +78,23 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### Init
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inject default x-backup config into all stacks in the current directory
|
||||||
|
./docker-stack-backup.sh init
|
||||||
|
|
||||||
|
# Or specify a directory to scan (no cd needed)
|
||||||
|
./docker-stack-backup.sh init --dir /mnt/data/stacks
|
||||||
|
|
||||||
|
# Or target specific stacks
|
||||||
|
./docker-stack-backup.sh init --running
|
||||||
|
./docker-stack-backup.sh init --stack random_stack
|
||||||
|
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
|
||||||
|
./docker-stack-backup.sh init -s stack_a -s stack_b
|
||||||
|
./docker-stack-backup.sh init --dry-run --verbose # preview changes
|
||||||
|
```
|
||||||
|
|
||||||
### Backup
|
### Backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -94,6 +113,9 @@ This is useful for ad-hoc backups of stacks that aren't under your main stacks d
|
|||||||
./docker-stack-backup.sh backup -s stack_a -s stack_b
|
./docker-stack-backup.sh backup -s stack_a -s stack_b
|
||||||
./docker-stack-backup.sh backup --stack stack_a,stack_b
|
./docker-stack-backup.sh backup --stack stack_a,stack_b
|
||||||
|
|
||||||
|
# Scan a specific directory for stacks instead of the script's directory
|
||||||
|
./docker-stack-backup.sh backup --dir /srv/docker --all
|
||||||
|
|
||||||
# With a custom backup destination
|
# With a custom backup destination
|
||||||
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
|
./docker-stack-backup.sh backup --all --backup-dir /mnt/nfs/backups
|
||||||
|
|
||||||
@@ -128,6 +150,9 @@ After selecting stacks, the script re-executes via `sudo` with your selections a
|
|||||||
# Restore an external stack by path
|
# Restore an external stack by path
|
||||||
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
|
./docker-stack-backup.sh restore --stack /srv/docker/nextcloud
|
||||||
|
|
||||||
|
# Restore to a different location (overrides the original path)
|
||||||
|
./docker-stack-backup.sh restore --stack random_stack --to /new/location/random_stack
|
||||||
|
|
||||||
# Restore from a specific backup directory
|
# Restore from a specific backup directory
|
||||||
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
|
./docker-stack-backup.sh restore --stack random_stack --backup-dir /mnt/nfs/backups
|
||||||
```
|
```
|
||||||
@@ -149,6 +174,38 @@ After selecting backups, the script re-executes via `sudo` with the resolved sta
|
|||||||
|
|
||||||
When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` 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.
|
When restoring over an existing stack directory, the current directory is briefly renamed to `<stack>.pre-restore-<timestamp>` 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.
|
||||||
|
|
||||||
|
### Init (Inject Default Config)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inject default x-backup config into all stacks in the current directory
|
||||||
|
./docker-stack-backup.sh init
|
||||||
|
|
||||||
|
# Or target running/external stacks
|
||||||
|
./docker-stack-backup.sh init --running
|
||||||
|
./docker-stack-backup.sh init --stack /srv/docker/nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Scans each compose file (relative to the **current directory** by default, or use `--dir` to point elsewhere) and injects a default `x-backup` block at the top:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
x-backup:
|
||||||
|
stop: true
|
||||||
|
backup-volumes: true
|
||||||
|
# pre-hook: ""
|
||||||
|
# post-hook: ""
|
||||||
|
# pre-restore-hook: ""
|
||||||
|
# post-restore-hook: ""
|
||||||
|
# exclude:
|
||||||
|
# - path/to/exclude
|
||||||
|
# retention: 7
|
||||||
|
```
|
||||||
|
|
||||||
|
Active defaults (`stop: true`, `backup-volumes: true`) are uncommented. Optional fields are commented out as a reference — uncomment and adjust as needed.
|
||||||
|
|
||||||
|
If the compose file already has an `x-backup:` block, it is removed first so the result is always a clean standard config. This makes `init` idempotent and safe to re-run at any time.
|
||||||
|
|
||||||
|
Does not require root by default — reads and writes compose files as the current user. If a compose file is owned by another user (e.g. root), the script automatically escalates with `sudo` for the write.
|
||||||
|
|
||||||
### Verify
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -383,7 +440,7 @@ Or as a cron job on the host (in root's crontab, or use `sudo`):
|
|||||||
|
|
||||||
## Requirements
|
## 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.
|
- **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. The `init` command also runs without root by default, but auto-escalates with `sudo` if the compose file isn't writable by the current user.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,29 @@ TARGET_STACKS=()
|
|||||||
MODE="all" # all|running
|
MODE="all" # all|running
|
||||||
HAS_FLAGS=false
|
HAS_FLAGS=false
|
||||||
ORIG_ARGS=()
|
ORIG_ARGS=()
|
||||||
|
TARGET_DIR=""
|
||||||
|
RESTORE_TARGET=""
|
||||||
|
|
||||||
# --- Helper Functions --------------------------------------------------------
|
# --- Output Formatting --------------------------------------------------------
|
||||||
|
|
||||||
log() { if $VERBOSE; then echo "[INFO] $*"; fi; }
|
# Terminal colors (auto-disabled when not a TTY or tput unavailable)
|
||||||
warn() { echo "[WARN] $*" >&2; }
|
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
|
||||||
err() { echo "[ERROR] $*" >&2; exit 1; }
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
dry() { if $DRY_RUN; then echo "[DRY-RUN] $*"; else "$@"; fi; }
|
RED=$(tput setaf 1 2>/dev/null || true)
|
||||||
|
GREEN=$(tput setaf 2 2>/dev/null || true)
|
||||||
|
YELLOW=$(tput setaf 3 2>/dev/null || true)
|
||||||
|
BLUE=$(tput setaf 4 2>/dev/null || true)
|
||||||
|
MAGENTA=$(tput setaf 5 2>/dev/null || true)
|
||||||
|
CYAN=$(tput setaf 6 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { $VERBOSE && echo " ${BLUE}ℹ${RESET} $*" || true; }
|
||||||
|
warn() { echo " ${YELLOW}⚠${RESET} $*" >&2; }
|
||||||
|
err() { echo " ${RED}✗${RESET} $*" >&2; exit 1; }
|
||||||
|
dry() { if $DRY_RUN; then echo " ${MAGENTA}◇${RESET} $*"; else "$@"; fi; }
|
||||||
|
success() { echo " ${GREEN}✓${RESET} $*"; }
|
||||||
|
header() { echo ""; echo " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
|
||||||
|
|
||||||
# When run via sudo, chown files back to the original user
|
# When run via sudo, chown files back to the original user
|
||||||
fix_owner() {
|
fix_owner() {
|
||||||
@@ -55,21 +71,25 @@ Commands:
|
|||||||
With no flags, shows an interactive menu.
|
With no flags, shows an interactive menu.
|
||||||
restore Restore one or more stacks from a backup.
|
restore Restore one or more stacks from a backup.
|
||||||
With no --stack flag, shows an interactive menu.
|
With no --stack flag, shows an interactive menu.
|
||||||
|
init Inject default x-backup config into compose files
|
||||||
|
that don't already have one.
|
||||||
list List available stacks and backups
|
list List available stacks and backups
|
||||||
verify Verify backup integrity (checks sha256 hash)
|
verify Verify backup integrity (checks sha256 hash)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-s, --stack <name> Target a specific stack (repeatable, or comma-separated).
|
-s, --stack <name> Target a specific stack (repeatable, or comma-separated).
|
||||||
Can be a name (relative to stacks dir) or absolute path.
|
Can be a name (relative to current directory) or absolute path.
|
||||||
-a, --all Target all stacks found in stacks directory
|
-a, --all Target all stacks found in stacks directory
|
||||||
-r, --running Target only currently running compose projects wherever
|
-r, --running Target only currently running compose projects wherever
|
||||||
they live (discovers compose file paths automatically)
|
they live (discovers compose file paths automatically)
|
||||||
-d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR})
|
-d, --backup-dir <dir> Backup directory (default: ${_DEFAULT_BACKUP_DIR})
|
||||||
|
--dir <dir> Scan this directory for stacks (overrides default discovery dir)
|
||||||
|
--to <path> Override restore target path (use with restore --stack)
|
||||||
-n, --dry-run Show what would be done without making changes
|
-n, --dry-run Show what would be done without making changes
|
||||||
-v, --verbose Verbose output
|
-v, --verbose Verbose output
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Must be run as root (preserves file ownership in backups and restores).
|
Must be run as root for backup/restore (preserves file ownership). The init and list commands do not require root.
|
||||||
|
|
||||||
Compose x-backup extensions (add to compose.yaml):
|
Compose x-backup extensions (add to compose.yaml):
|
||||||
x-backup:
|
x-backup:
|
||||||
@@ -89,6 +109,9 @@ Examples:
|
|||||||
$(basename "$0") backup --stack random_stack --stack another_stack
|
$(basename "$0") backup --stack random_stack --stack another_stack
|
||||||
$(basename "$0") backup -s random_stack -v
|
$(basename "$0") backup -s random_stack -v
|
||||||
$(basename "$0") restore --stack random_stack -d /path/to/backups
|
$(basename "$0") restore --stack random_stack -d /path/to/backups
|
||||||
|
$(basename "$0") restore --stack random_stack --to /new/location/random_stack
|
||||||
|
$(basename "$0") init
|
||||||
|
$(basename "$0") init --running
|
||||||
$(basename "$0") list
|
$(basename "$0") list
|
||||||
EOF
|
EOF
|
||||||
exit 0
|
exit 0
|
||||||
@@ -106,7 +129,7 @@ require_root() {
|
|||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
echo "Elevating to root via sudo..."
|
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||||
exec sudo "$0" "${ORIG_ARGS[@]}"
|
exec sudo "$0" "${ORIG_ARGS[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +206,7 @@ resolve_target_stacks() {
|
|||||||
|
|
||||||
if [ ${#explicit[@]} -gt 0 ]; then
|
if [ ${#explicit[@]} -gt 0 ]; then
|
||||||
for spec in "${explicit[@]}"; do
|
for spec in "${explicit[@]}"; do
|
||||||
stack_spec_to_path "$stacks_dir" "$spec"
|
stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec"
|
||||||
done
|
done
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -328,9 +351,9 @@ apply_retention() {
|
|||||||
if [ "$count" -gt "$retention" ]; then
|
if [ "$count" -gt "$retention" ]; then
|
||||||
local to_delete
|
local to_delete
|
||||||
to_delete="$((count - retention))"
|
to_delete="$((count - retention))"
|
||||||
log "Retention=$retention, pruning $to_delete old backup(s) for '$stack_name'"
|
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
|
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")"
|
echo " ${MAGENTA}◇${RESET} Pruning: $(basename "$f")"
|
||||||
dry rm -f "$f" "${f}.meta"
|
dry rm -f "$f" "${f}.meta"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -394,12 +417,12 @@ warn_named_volumes() {
|
|||||||
vols="$(detect_named_volumes "$compose_file")"
|
vols="$(detect_named_volumes "$compose_file")"
|
||||||
[ -z "$vols" ] && return
|
[ -z "$vols" ] && return
|
||||||
|
|
||||||
echo " [!] Named volumes detected (not backed up by default):"
|
echo " ${YELLOW}┃${RESET} Named volumes detected but ${BOLD}backup-volumes is false${RESET}, skipping:"
|
||||||
for v in $vols; do
|
for v in $vols; do
|
||||||
echo " - $v"
|
echo " ${YELLOW}┃${RESET} ${CYAN}●${RESET} $v"
|
||||||
done
|
done
|
||||||
echo " These store data inside Docker's storage area, not on the host filesystem."
|
echo " ${YELLOW}┃${RESET} These store data inside Docker's storage area, not on the host filesystem."
|
||||||
echo " Set 'x-backup.backup-volumes: false' to skip them."
|
echo " ${YELLOW}┃${RESET} Set ${BOLD}x-backup.backup-volumes: true${RESET} to include them."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve the actual Docker volume name for a compose volume reference.
|
# Resolve the actual Docker volume name for a compose volume reference.
|
||||||
@@ -407,7 +430,11 @@ warn_named_volumes() {
|
|||||||
resolve_docker_volume() {
|
resolve_docker_volume() {
|
||||||
local stack_name="$1"
|
local stack_name="$1"
|
||||||
local vol_name="$2"
|
local vol_name="$2"
|
||||||
|
if [[ "$vol_name" == "${stack_name}_"* ]]; then
|
||||||
|
echo "$vol_name"
|
||||||
|
else
|
||||||
echo "${stack_name}_${vol_name}"
|
echo "${stack_name}_${vol_name}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Back up named Docker volumes into a temporary directory.
|
# Back up named Docker volumes into a temporary directory.
|
||||||
@@ -438,7 +465,7 @@ backup_named_volumes() {
|
|||||||
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
|
if docker volume inspect "$docker_vol" >/dev/null 2>&1; then
|
||||||
log "Dumping named volume '$docker_vol'..."
|
log "Dumping named volume '$docker_vol'..."
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
echo "[DRY-RUN] docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
|
echo " ${MAGENTA}◇${RESET} docker run --rm -v ${docker_vol}:/_src alpine tar -czf - -C /_src . > $volumes_dir/${vol}.tar.gz"
|
||||||
else
|
else
|
||||||
docker run --rm -v "${docker_vol}:/_src" alpine tar -czf - -C /_src . > "$volumes_dir/${vol}.tar.gz" 2>/dev/null || \
|
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'"
|
warn "Failed to dump named volume '$docker_vol'"
|
||||||
@@ -467,8 +494,8 @@ restore_named_volumes() {
|
|||||||
|
|
||||||
log "Restoring named volume '$docker_vol'..."
|
log "Restoring named volume '$docker_vol'..."
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
echo "[DRY-RUN] docker volume create $docker_vol"
|
echo " ${MAGENTA}◇${RESET} docker volume create $docker_vol"
|
||||||
echo "[DRY-RUN] cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
|
echo " ${MAGENTA}◇${RESET} cat $dump | docker run -i --rm -v ${docker_vol}:/_dst alpine tar -xzf - -C /_dst"
|
||||||
else
|
else
|
||||||
docker volume rm "$docker_vol" 2>/dev/null || true
|
docker volume rm "$docker_vol" 2>/dev/null || true
|
||||||
docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; }
|
docker volume create "$docker_vol" >/dev/null || { warn "Failed to create volume '$docker_vol'"; continue; }
|
||||||
@@ -483,7 +510,7 @@ restore_named_volumes() {
|
|||||||
rm -rf "$volumes_dir"
|
rm -rf "$volumes_dir"
|
||||||
|
|
||||||
if $any_restored; then
|
if $any_restored; then
|
||||||
echo " -> Named volumes restored"
|
success "Named volumes restored"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +548,7 @@ stop_stack() {
|
|||||||
compose_file="$(resolve_compose_file "$stack_path")"
|
compose_file="$(resolve_compose_file "$stack_path")"
|
||||||
|
|
||||||
if stack_is_running "$stack_path"; then
|
if stack_is_running "$stack_path"; then
|
||||||
log "Stopping stack '$stack_name' ($stack_path)..."
|
echo " ${CYAN}●${RESET} Stopping containers..."
|
||||||
if [ -n "$compose_file" ]; then
|
if [ -n "$compose_file" ]; then
|
||||||
dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \
|
dry docker compose -f "$compose_file" --project-directory "$stack_path" down 2>/dev/null || \
|
||||||
dry docker compose -p "$stack_name" down 2>/dev/null || \
|
dry docker compose -p "$stack_name" down 2>/dev/null || \
|
||||||
@@ -544,7 +571,7 @@ start_stack() {
|
|||||||
compose_file="$(resolve_compose_file "$stack_path")"
|
compose_file="$(resolve_compose_file "$stack_path")"
|
||||||
|
|
||||||
if [ -n "$compose_file" ]; then
|
if [ -n "$compose_file" ]; then
|
||||||
log "Starting stack '$stack_name' ($stack_path)..."
|
echo " ${CYAN}●${RESET} Starting containers..."
|
||||||
dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \
|
dry docker compose -f "$compose_file" --project-directory "$stack_path" up -d 2>/dev/null || \
|
||||||
warn "Failed to start stack '$stack_name'"
|
warn "Failed to start stack '$stack_name'"
|
||||||
fi
|
fi
|
||||||
@@ -552,6 +579,12 @@ start_stack() {
|
|||||||
|
|
||||||
# --- Backup ------------------------------------------------------------------
|
# --- Backup ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Resolve the real path if the directory involves symlinks
|
||||||
|
_resolve_dir() {
|
||||||
|
local dir="$1"
|
||||||
|
(cd -P "$dir" 2>/dev/null && pwd) || echo "$dir"
|
||||||
|
}
|
||||||
|
|
||||||
backup_stack() {
|
backup_stack() {
|
||||||
require_root
|
require_root
|
||||||
local TIMESTAMP
|
local TIMESTAMP
|
||||||
@@ -564,10 +597,16 @@ backup_stack() {
|
|||||||
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
|
local backup_file="$backup_dir/${stack_name}_${TIMESTAMP}.tar.gz"
|
||||||
local archive_root
|
local archive_root
|
||||||
archive_root="$(dirname "$stack_path")"
|
archive_root="$(dirname "$stack_path")"
|
||||||
|
archive_root="$(_resolve_dir "$archive_root")"
|
||||||
local archive_basename
|
local archive_basename
|
||||||
archive_basename="$(basename "$stack_path")"
|
archive_basename="$(basename "$stack_path")"
|
||||||
|
local _backup_size=""
|
||||||
|
|
||||||
echo "Backing up stack '$stack_name' ($stack_path) -> $backup_file"
|
header "Backup: ${stack_name}"
|
||||||
|
|
||||||
|
echo " ${CYAN}●${RESET} Source: ${BOLD}${stack_path}${RESET}"
|
||||||
|
echo " ${CYAN}●${RESET} Archive: ${backup_file##*/}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Load x-backup config
|
# Load x-backup config
|
||||||
load_x_backup_config "$stack_path"
|
load_x_backup_config "$stack_path"
|
||||||
@@ -581,13 +620,11 @@ backup_stack() {
|
|||||||
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
|
# Dump named Docker volumes (opt-out via x-backup.backup-volumes: false)
|
||||||
local compose_file
|
local compose_file
|
||||||
compose_file="$(resolve_compose_file "$stack_path")"
|
compose_file="$(resolve_compose_file "$stack_path")"
|
||||||
local volumes_dumped=false
|
|
||||||
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
|
if [ "$XB_BACKUP_VOLUMES" = "false" ]; then
|
||||||
warn_named_volumes "$compose_file" "$stack_name"
|
warn_named_volumes "$compose_file" "$stack_name"
|
||||||
else
|
else
|
||||||
log "Dumping named volumes..."
|
echo " ${CYAN}●${RESET} Dumping named volumes..."
|
||||||
backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
|
backup_named_volumes "$stack_path" "$stack_name" "$compose_file"
|
||||||
volumes_dumped=true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pre-backup hook
|
# Pre-backup hook
|
||||||
@@ -600,30 +637,46 @@ backup_stack() {
|
|||||||
dry mkdir -p "$backup_dir"
|
dry mkdir -p "$backup_dir"
|
||||||
fix_owner "$backup_dir" 2>/dev/null || true
|
fix_owner "$backup_dir" 2>/dev/null || true
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
echo "[DRY-RUN] tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
|
echo " ${MAGENTA}◇${RESET} tar -czf $backup_file --same-permissions --preserve-permissions $XB_EXCLUDE -C $archive_root $archive_basename"
|
||||||
else
|
else
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
|
if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
|
||||||
|
local cr=$'\r'
|
||||||
|
local clr=$'\033[K'
|
||||||
|
echo -n " ${CYAN}●${RESET} Compressing..."
|
||||||
|
tar -czf "$backup_file" \
|
||||||
|
--checkpoint=500 \
|
||||||
|
--checkpoint-action="ttyout=${cr} ${CYAN}●${RESET} %T${clr}" \
|
||||||
|
--same-permissions --preserve-permissions \
|
||||||
|
$XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
||||||
|
echo -ne "\r\033[K\n" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " ${CYAN}●${RESET} Compressing..."
|
||||||
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
tar -czf "$backup_file" --same-permissions --preserve-permissions $XB_EXCLUDE -C "$archive_root" "$archive_basename"
|
||||||
|
fi
|
||||||
fix_owner "$backup_file"
|
fix_owner "$backup_file"
|
||||||
echo " -> Backup complete: $(du -h "$backup_file" | cut -f1)"
|
_backup_size="$(du -h "$backup_file" | cut -f1)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Capture volume state before cleanup
|
||||||
|
local has_volumes="false"
|
||||||
|
[ -d "$stack_path/_volumes" ] && has_volumes="true"
|
||||||
|
|
||||||
# Clean up temporary volume dumps
|
# Clean up temporary volume dumps
|
||||||
if $volumes_dumped && [ -d "$stack_path/_volumes" ]; then
|
if [ "$has_volumes" = "true" ]; then
|
||||||
dry rm -rf "$stack_path/_volumes"
|
dry rm -rf "$stack_path/_volumes"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write metadata — used by the interactive restore menu and for
|
# Write metadata — used by the interactive restore menu and for
|
||||||
# identifying backups without extracting the archive.
|
# identifying backups without extracting the archive.
|
||||||
if ! $DRY_RUN; then
|
if ! $DRY_RUN; then
|
||||||
local has_volumes="false"
|
|
||||||
[ -d "$stack_path/_volumes" ] && has_volumes="true"
|
|
||||||
local archive_sha256 archive_size
|
local archive_sha256 archive_size
|
||||||
archive_sha256="$(sha256sum "$backup_file" | cut -d' ' -f1)"
|
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")"
|
archive_size="$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")"
|
||||||
|
local resolved_path="${archive_root}/${archive_basename}"
|
||||||
cat > "${backup_file}.meta" <<EOF
|
cat > "${backup_file}.meta" <<EOF
|
||||||
stack=${stack_name}
|
stack=${stack_name}
|
||||||
path=${stack_path}
|
path=${resolved_path}
|
||||||
date=${TIMESTAMP}
|
date=${TIMESTAMP}
|
||||||
compose=$(basename "${compose_file:-compose.yaml}")
|
compose=$(basename "${compose_file:-compose.yaml}")
|
||||||
volumes=${has_volumes}
|
volumes=${has_volumes}
|
||||||
@@ -645,6 +698,10 @@ EOF
|
|||||||
else
|
else
|
||||||
start_stack "$stack_path"
|
start_stack "$stack_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! $DRY_RUN && [ -n "$_backup_size" ]; then
|
||||||
|
success "Backup complete: ${BOLD}${_backup_size}${RESET}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Interactive Restore Menu ------------------------------------------------
|
# --- Interactive Restore Menu ------------------------------------------------
|
||||||
@@ -704,7 +761,7 @@ interactive_restore_menu() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ ${#names[@]} -eq 0 ]; then
|
if [ ${#names[@]} -eq 0 ]; then
|
||||||
echo "No backups found in $backup_dir"
|
echo " ${YELLOW}┃${RESET} No backups found in ${backup_dir}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -712,20 +769,19 @@ interactive_restore_menu() {
|
|||||||
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
|
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Available backups:"
|
echo " ${BOLD}${CYAN}━━━ Available Backups ━━━${RESET}"
|
||||||
echo "------------------"
|
|
||||||
local i
|
local i
|
||||||
for i in "${!names[@]}"; do
|
for i in "${!names[@]}"; do
|
||||||
local idx=$((i + 1))
|
local idx=$((i + 1))
|
||||||
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
|
printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${stack_date[${names[$i]}]:-}"
|
||||||
done
|
done
|
||||||
echo " all) Restore all stacks"
|
echo " ${CYAN}all)${RESET} Restore all stacks"
|
||||||
echo " q) Cancel"
|
echo " ${YELLOW}q)${RESET} Cancel"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -r -p "Select stacks to restore (numbers, 'all', or 'q'): " selection
|
read -r -p " ${CYAN}›${RESET} Select stacks to restore (numbers, 'all', or 'q'): " selection
|
||||||
|
|
||||||
[ -z "$selection" ] && echo "Cancelled." && exit 0
|
[ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0
|
||||||
|
|
||||||
# Normalise: handle "all"
|
# Normalise: handle "all"
|
||||||
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
||||||
@@ -736,7 +792,7 @@ interactive_restore_menu() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$selection" = "q" ]; then
|
if [ "$selection" = "q" ]; then
|
||||||
echo "Cancelled."
|
echo " ${YELLOW}┃${RESET} Cancelled."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -756,13 +812,13 @@ interactive_restore_menu() {
|
|||||||
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
||||||
selected_stacks+=("${names[$((seen_idx - 1))]}")
|
selected_stacks+=("${names[$((seen_idx - 1))]}")
|
||||||
else
|
else
|
||||||
echo "Invalid selection: $seen_idx (out of range)"
|
echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ ${#selected_stacks[@]} -eq 0 ]; then
|
if [ ${#selected_stacks[@]} -eq 0 ]; then
|
||||||
echo "No valid stacks selected."
|
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -786,13 +842,13 @@ interactive_restore_menu() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ ${#stack_flags[@]} -eq 0 ]; then
|
if [ ${#stack_flags[@]} -eq 0 ]; then
|
||||||
echo "No valid stacks selected."
|
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Elevate and re-exec with the selected stacks
|
# Elevate and re-exec with the selected stacks
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "Elevating to root via sudo..."
|
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||||
exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}"
|
exec sudo "$0" restore --backup-dir "$backup_dir" "${stack_flags[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -818,7 +874,7 @@ verify_backup() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$files" ]; then
|
if [ -z "$files" ]; then
|
||||||
echo "No backups found."
|
echo " ${YELLOW}┃${RESET} No backups found."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -829,7 +885,7 @@ verify_backup() {
|
|||||||
local meta_file="${f}.meta"
|
local meta_file="${f}.meta"
|
||||||
|
|
||||||
if [ ! -f "$meta_file" ]; then
|
if [ ! -f "$meta_file" ]; then
|
||||||
echo " SKIP $basename_f (no .meta file)"
|
echo " ${YELLOW}⚠${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no .meta file)"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -838,13 +894,13 @@ verify_backup() {
|
|||||||
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
|
stored_size="$(grep '^size=' "$meta_file" | head -1 | sed 's/^size=//')"
|
||||||
|
|
||||||
if [ -z "$stored_hash" ]; then
|
if [ -z "$stored_hash" ]; then
|
||||||
echo " SKIP $basename_f (no sha256 in .meta — legacy backup)"
|
echo " ${YELLOW}⚠${RESET} ${YELLOW}SKIP${RESET} ${basename_f} (no sha256 in .meta — legacy backup)"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check file still exists
|
# Check file still exists
|
||||||
if [ ! -f "$f" ]; then
|
if [ ! -f "$f" ]; then
|
||||||
echo " FAIL $basename_f (file missing)"
|
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (file missing)"
|
||||||
exit_code=1
|
exit_code=1
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -853,7 +909,7 @@ verify_backup() {
|
|||||||
local actual_size
|
local actual_size
|
||||||
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
|
actual_size="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "0")"
|
||||||
if [ "$actual_size" != "$stored_size" ]; then
|
if [ "$actual_size" != "$stored_size" ]; then
|
||||||
echo " FAIL $basename_f (size mismatch: $actual_size vs $stored_size)"
|
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (size mismatch: ${actual_size} vs ${stored_size})"
|
||||||
exit_code=1
|
exit_code=1
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -862,12 +918,12 @@ verify_backup() {
|
|||||||
local actual_hash
|
local actual_hash
|
||||||
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
|
actual_hash="$(sha256sum "$f" | cut -d' ' -f1)"
|
||||||
if [ "$actual_hash" != "$stored_hash" ]; then
|
if [ "$actual_hash" != "$stored_hash" ]; then
|
||||||
echo " FAIL $basename_f (sha256 mismatch)"
|
echo " ${RED}✗${RESET} ${RED}FAIL${RESET} ${basename_f} (sha256 mismatch)"
|
||||||
exit_code=1
|
exit_code=1
|
||||||
else
|
else
|
||||||
local human_size
|
local human_size
|
||||||
human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")"
|
human_size="$(echo "$stored_size" | numfmt --to=iec 2>/dev/null || echo "${stored_size}B")"
|
||||||
echo " OK $basename_f ($human_size)"
|
echo " ${GREEN}✓${RESET} ${GREEN}OK${RESET} ${basename_f} (${human_size})"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -929,25 +985,24 @@ interactive_backup_menu() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ ${#names[@]} -eq 0 ]; then
|
if [ ${#names[@]} -eq 0 ]; then
|
||||||
echo "No stacks found."
|
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Available stacks:"
|
echo " ${BOLD}${CYAN}━━━ Available Stacks ━━━${RESET}"
|
||||||
echo "-----------------"
|
|
||||||
local i
|
local i
|
||||||
for i in "${!names[@]}"; do
|
for i in "${!names[@]}"; do
|
||||||
local idx=$((i + 1))
|
local idx=$((i + 1))
|
||||||
printf " %2d) %-20s %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
|
printf " ${CYAN}%2d)${RESET} ${BOLD}%-20s${RESET} %s\n" "$idx" "${names[$i]}" "${labels[$i]}"
|
||||||
done
|
done
|
||||||
echo " all) Back up all stacks"
|
echo " ${CYAN}all)${RESET} Back up all stacks"
|
||||||
echo " q) Cancel"
|
echo " ${YELLOW}q)${RESET} Cancel"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -r -p "Select stacks to back up (numbers, 'all', or 'q'): " selection
|
read -r -p " ${CYAN}›${RESET} Select stacks to back up (numbers, 'all', or 'q'): " selection
|
||||||
|
|
||||||
[ -z "$selection" ] && echo "Cancelled." && exit 0
|
[ -z "$selection" ] && echo " ${YELLOW}┃${RESET} Cancelled." && exit 0
|
||||||
|
|
||||||
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
if [ "$selection" = "all" ] || [ "$selection" = "a" ]; then
|
||||||
selection=""
|
selection=""
|
||||||
@@ -957,7 +1012,7 @@ interactive_backup_menu() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$selection" = "q" ]; then
|
if [ "$selection" = "q" ]; then
|
||||||
echo "Cancelled."
|
echo " ${YELLOW}┃${RESET} Cancelled."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -975,13 +1030,13 @@ interactive_backup_menu() {
|
|||||||
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
if [ "$seen_idx" -ge 1 ] && [ "$seen_idx" -le "${#names[@]}" ]; then
|
||||||
selected_paths+=("${paths[$((seen_idx - 1))]}")
|
selected_paths+=("${paths[$((seen_idx - 1))]}")
|
||||||
else
|
else
|
||||||
echo "Invalid selection: $seen_idx (out of range)"
|
echo " ${YELLOW}┃${RESET} Invalid selection: ${seen_idx} (out of range)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ ${#selected_paths[@]} -eq 0 ]; then
|
if [ ${#selected_paths[@]} -eq 0 ]; then
|
||||||
echo "No valid stacks selected."
|
echo " ${YELLOW}┃${RESET} No valid stacks selected."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -991,7 +1046,7 @@ interactive_backup_menu() {
|
|||||||
for spath in "${selected_paths[@]}"; do
|
for spath in "${selected_paths[@]}"; do
|
||||||
stack_flags+=("--stack" "$spath")
|
stack_flags+=("--stack" "$spath")
|
||||||
done
|
done
|
||||||
echo "Elevating to root via sudo..."
|
echo " ${MAGENTA}◇${RESET} Elevating to root via ${BOLD}sudo${RESET}..."
|
||||||
exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}"
|
exec sudo "$0" backup --backup-dir "$backup_dir" "${stack_flags[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1010,12 +1065,14 @@ restore_stack() {
|
|||||||
local stacks_dir="$1"
|
local stacks_dir="$1"
|
||||||
local backup_dir="$2"
|
local backup_dir="$2"
|
||||||
local stack_path="$3"
|
local stack_path="$3"
|
||||||
|
local restore_path="${4:-$stack_path}"
|
||||||
local stack_name
|
local stack_name
|
||||||
stack_name="$(stack_spec_to_name "$stack_path")"
|
stack_name="$(stack_spec_to_name "$stack_path")"
|
||||||
local archive_root
|
local archive_root
|
||||||
archive_root="$(dirname "$stack_path")"
|
archive_root="$(dirname "$restore_path")"
|
||||||
|
archive_root="$(_resolve_dir "$archive_root")"
|
||||||
local archive_basename
|
local archive_basename
|
||||||
archive_basename="$(basename "$stack_path")"
|
archive_basename="$(basename "$restore_path")"
|
||||||
|
|
||||||
# Find the latest backup for this stack
|
# Find the latest backup for this stack
|
||||||
local backup_file
|
local backup_file
|
||||||
@@ -1025,7 +1082,11 @@ restore_stack() {
|
|||||||
err "No backup found for stack '$stack_name' in $backup_dir"
|
err "No backup found for stack '$stack_name' in $backup_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Restoring stack '$stack_name' ($stack_path) from $backup_file"
|
header "Restore: ${stack_name}"
|
||||||
|
|
||||||
|
echo " ${CYAN}●${RESET} Target: ${BOLD}${restore_path}${RESET}"
|
||||||
|
echo " ${CYAN}●${RESET} Backup: ${backup_file##*/}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Get list of files in backup to confirm it's the right one
|
# Get list of files in backup to confirm it's the right one
|
||||||
if $VERBOSE; then
|
if $VERBOSE; then
|
||||||
@@ -1070,20 +1131,32 @@ restore_stack() {
|
|||||||
|
|
||||||
# Restore
|
# Restore
|
||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
echo "[DRY-RUN] tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
|
echo " ${MAGENTA}◇${RESET} tar -xzf $backup_file --same-permissions --preserve-permissions -C $archive_root"
|
||||||
else
|
else
|
||||||
if [ -d "$stack_path" ]; then
|
if [ -d "$stack_path" ]; then
|
||||||
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
|
local existing_backup="${stack_path}.pre-restore-${TIMESTAMP}"
|
||||||
log "Moving existing '$stack_name' to $existing_backup"
|
log "Moving existing '$stack_name' to $existing_backup"
|
||||||
mv "$stack_path" "$existing_backup"
|
mv "$stack_path" "$existing_backup"
|
||||||
fi
|
fi
|
||||||
|
if [ -t 1 ] && tar --help 2>/dev/null | grep -q -- --checkpoint; then
|
||||||
|
local cr=$'\r'
|
||||||
|
local clr=$'\033[K'
|
||||||
|
echo -n " ${CYAN}●${RESET} Extracting..."
|
||||||
|
tar -xzf "$backup_file" \
|
||||||
|
--checkpoint=500 \
|
||||||
|
--checkpoint-action="ttyout=${cr} ${CYAN}●${RESET} %T${clr}" \
|
||||||
|
--same-permissions --preserve-permissions -C "$archive_root"
|
||||||
|
echo -ne "\r\033[K\n" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " ${CYAN}●${RESET} Extracting..."
|
||||||
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
|
tar -xzf "$backup_file" --same-permissions --preserve-permissions -C "$archive_root"
|
||||||
|
fi
|
||||||
# Restore succeeded — remove the pre-restore safety copy since the
|
# Restore succeeded — remove the pre-restore safety copy since the
|
||||||
# backup archive is the authoritative restore point.
|
# backup archive is the authoritative restore point.
|
||||||
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
|
if [ -n "${existing_backup:-}" ] && [ -d "$existing_backup" ]; then
|
||||||
dry rm -rf "$existing_backup"
|
dry rm -rf "$existing_backup"
|
||||||
fi
|
fi
|
||||||
echo " -> Restore complete"
|
success "Restore complete"
|
||||||
# Restore named Docker volumes from the _volumes/ dir inside the archive
|
# Restore named Docker volumes from the _volumes/ dir inside the archive
|
||||||
restore_named_volumes "$stack_path" "$stack_name"
|
restore_named_volumes "$stack_path" "$stack_name"
|
||||||
fi
|
fi
|
||||||
@@ -1102,14 +1175,88 @@ restore_stack() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Init (inject default x-backup config) -----------------------------------
|
||||||
|
|
||||||
|
_remove_x_backup() {
|
||||||
|
awk '
|
||||||
|
/^x-backup:/ { skip=1; next }
|
||||||
|
skip && /^[a-zA-Z_-][a-zA-Z0-9_-]*:/ { skip=0 }
|
||||||
|
skip { next }
|
||||||
|
{ print }
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
_init_inject_header() {
|
||||||
|
local header="$1"
|
||||||
|
local file="$2"
|
||||||
|
local cp_cmd="${3:-cp}"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
if grep -q '^---' "$file" 2>/dev/null; then
|
||||||
|
awk -v h="$header" '!done && /^---/ { print; print ""; print h; done=1; next } 1' "$file" > "$tmp"
|
||||||
|
else
|
||||||
|
{ echo "$header"; echo ""; cat "$file"; } > "$tmp"
|
||||||
|
fi
|
||||||
|
$cp_cmd "$tmp" "$file" && rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_stack() {
|
||||||
|
local stack_path="$1"
|
||||||
|
local compose_file
|
||||||
|
compose_file="$(resolve_compose_file "$stack_path")"
|
||||||
|
local stack_name
|
||||||
|
stack_name="$(basename "$stack_path")"
|
||||||
|
|
||||||
|
[ -z "$compose_file" ] && { warn "No compose file in '$stack_name'"; return; }
|
||||||
|
|
||||||
|
local has_xb=false
|
||||||
|
grep -q '^x-backup:' "$compose_file" 2>/dev/null && has_xb=true
|
||||||
|
|
||||||
|
local sudo_write=""
|
||||||
|
[ -f "$compose_file" ] && [ ! -w "$compose_file" ] && sudo_write="sudo"
|
||||||
|
local cp_cmd="${sudo_write:+sudo }cp"
|
||||||
|
|
||||||
|
local header
|
||||||
|
header='x-backup:
|
||||||
|
stop: true
|
||||||
|
backup-volumes: true
|
||||||
|
# pre-hook: ""
|
||||||
|
# post-hook: ""
|
||||||
|
# pre-restore-hook: ""
|
||||||
|
# post-restore-hook: ""
|
||||||
|
# exclude:
|
||||||
|
# - path/to/exclude
|
||||||
|
# retention: 7'
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
local verb="inject"
|
||||||
|
$has_xb && verb="replace"
|
||||||
|
local maybe_sudo=""
|
||||||
|
[ -n "$sudo_write" ] && maybe_sudo=" (with sudo)"
|
||||||
|
echo " ${MAGENTA}◇${RESET} Init: would ${verb} x-backup config in ${BOLD}${compose_file}${RESET}${maybe_sudo}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $has_xb; then
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
_remove_x_backup "$compose_file" > "$tmp"
|
||||||
|
_init_inject_header "$header" "$tmp"
|
||||||
|
$cp_cmd "$tmp" "$compose_file" && rm -f "$tmp"
|
||||||
|
echo " ${GREEN}✓${RESET} ${BOLD}${stack_name}${RESET} x-backup config replaced"
|
||||||
|
else
|
||||||
|
_init_inject_header "$header" "$compose_file" "$cp_cmd"
|
||||||
|
echo " ${GREEN}✓${RESET} ${BOLD}${stack_name}${RESET} x-backup config injected"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --- List --------------------------------------------------------------------
|
# --- List --------------------------------------------------------------------
|
||||||
|
|
||||||
list_stacks_and_backups() {
|
list_stacks_and_backups() {
|
||||||
local stacks_dir="$1"
|
local stacks_dir="$1"
|
||||||
local backup_dir="$2"
|
local backup_dir="$2"
|
||||||
|
|
||||||
echo "=== Stacks ==="
|
echo " ${BOLD}${CYAN}━━━ Stacks ━━━${RESET}"
|
||||||
# Collect all seen stack dirs to avoid duplicates
|
|
||||||
local -A seen
|
local -A seen
|
||||||
|
|
||||||
# 1) Running stacks (discovered wherever they live)
|
# 1) Running stacks (discovered wherever they live)
|
||||||
@@ -1132,9 +1279,9 @@ list_stacks_and_backups() {
|
|||||||
local config_tags
|
local config_tags
|
||||||
config_tags="$(format_xb_tags)"
|
config_tags="$(format_xb_tags)"
|
||||||
if [ "$canon" != "$stacks_dir/$run_name" ]; then
|
if [ "$canon" != "$stacks_dir/$run_name" ]; then
|
||||||
echo " - $run_name ($run_path) $label$config_tags"
|
echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${BLUE}(${run_path})${RESET} ${label}${config_tags}"
|
||||||
else
|
else
|
||||||
echo " - $run_name $label$config_tags"
|
echo " ${CYAN}●${RESET} ${BOLD}${run_name}${RESET} ${label}${config_tags}"
|
||||||
fi
|
fi
|
||||||
has_running=true
|
has_running=true
|
||||||
done <<< "$running"
|
done <<< "$running"
|
||||||
@@ -1156,18 +1303,18 @@ list_stacks_and_backups() {
|
|||||||
load_x_backup_config "$dir"
|
load_x_backup_config "$dir"
|
||||||
local config_tags
|
local config_tags
|
||||||
config_tags="$(format_xb_tags)"
|
config_tags="$(format_xb_tags)"
|
||||||
echo " - $stack$label$config_tags"
|
echo " ${CYAN}●${RESET} ${BOLD}${stack}${RESET}${label}${config_tags}"
|
||||||
has_local=true
|
has_local=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if ! $has_running && ! $has_local; then
|
if ! $has_running && ! $has_local; then
|
||||||
echo " No stacks found."
|
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Backups ==="
|
echo " ${BOLD}${CYAN}━━━ Backups ━━━${RESET}"
|
||||||
if [ -d "$backup_dir" ]; then
|
if [ -d "$backup_dir" ]; then
|
||||||
local backup_count=0
|
local backup_count=0
|
||||||
for f in "$backup_dir"/*.tar.gz; do
|
for f in "$backup_dir"/*.tar.gz; do
|
||||||
@@ -1180,18 +1327,20 @@ list_stacks_and_backups() {
|
|||||||
mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')"
|
mpath="$(grep '^path=' "${f}.meta" | head -1 | sed 's/^path=//')"
|
||||||
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
|
mvols="$(grep '^volumes=' "${f}.meta" | head -1 | sed 's/^volumes=//')"
|
||||||
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
|
mhash="$(grep '^sha256=' "${f}.meta" | head -1 | sed 's/^sha256=//')"
|
||||||
[ -n "$mhash" ] && meta_info=" sha256:${mhash:0:12}.."
|
[ -n "$mhash" ] && meta_info="${meta_info} ${BLUE}sha256:${mhash:0:12}..${RESET}"
|
||||||
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="$meta_info ($mpath)"
|
[ -n "$mpath" ] && [ "$mpath" != "$(dirname "$f")/$sname" ] && meta_info="${meta_info} ${CYAN}(${mpath})${RESET}"
|
||||||
[ "$mvols" = "true" ] && meta_info="$meta_info [volumes]"
|
[ "$mvols" = "true" ] && meta_info="${meta_info} ${GREEN}[volumes]${RESET}"
|
||||||
fi
|
fi
|
||||||
echo " $(basename "$f") ($(du -h "$f" | cut -f1)) -> $sname$meta_info"
|
local fsize
|
||||||
|
fsize="$(du -h "$f" | cut -f1)"
|
||||||
|
echo " ${CYAN}●${RESET} ${BOLD}$(basename "$f")${RESET} ${BLUE}${fsize}${RESET} → ${sname}${meta_info}"
|
||||||
backup_count=$((backup_count + 1))
|
backup_count=$((backup_count + 1))
|
||||||
done
|
done
|
||||||
if [ "$backup_count" -eq 0 ]; then
|
if [ "$backup_count" -eq 0 ]; then
|
||||||
echo " No backups found in $backup_dir"
|
echo " ${YELLOW}┃${RESET} No backups found in ${backup_dir}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " No backups directory found at $backup_dir"
|
echo " ${YELLOW}┃${RESET} No backups directory found at ${backup_dir}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,7 +1360,7 @@ main() {
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
backup|restore|list|verify)
|
backup|restore|init|list|verify)
|
||||||
ACTION="$1"
|
ACTION="$1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
@@ -1234,6 +1383,18 @@ main() {
|
|||||||
HAS_FLAGS=true
|
HAS_FLAGS=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--dir)
|
||||||
|
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||||
|
TARGET_DIR="$2"
|
||||||
|
HAS_FLAGS=true
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--to)
|
||||||
|
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||||
|
RESTORE_TARGET="$2"
|
||||||
|
HAS_FLAGS=true
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-d|--backup-dir)
|
-d|--backup-dir)
|
||||||
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
if [ $# -lt 2 ]; then err "Option $1 requires an argument"; fi
|
||||||
backup_dir="$2"
|
backup_dir="$2"
|
||||||
@@ -1260,11 +1421,32 @@ main() {
|
|||||||
usage
|
usage
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TARGET_DIR" ]; then
|
||||||
|
stacks_dir="$TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -d "$stacks_dir" ]; then
|
if [ ! -d "$stacks_dir" ]; then
|
||||||
err "Stacks directory not found: $stacks_dir"
|
err "Stacks directory not found: $stacks_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
|
init)
|
||||||
|
local scan_dir="${TARGET_DIR:-$INITIAL_DIR}"
|
||||||
|
if ! $HAS_FLAGS; then
|
||||||
|
MODE="all"
|
||||||
|
fi
|
||||||
|
local stacks
|
||||||
|
stacks="$(resolve_target_stacks "$scan_dir" "$MODE" "${TARGET_STACKS[@]}")"
|
||||||
|
if [ -z "$stacks" ]; then
|
||||||
|
echo " ${YELLOW}┃${RESET} No stacks found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
for stack in $stacks; do
|
||||||
|
local spath
|
||||||
|
spath="$(stack_spec_to_path "$scan_dir" "$stack")"
|
||||||
|
init_stack "$spath"
|
||||||
|
done
|
||||||
|
;;
|
||||||
list)
|
list)
|
||||||
list_stacks_and_backups "$stacks_dir" "$backup_dir"
|
list_stacks_and_backups "$stacks_dir" "$backup_dir"
|
||||||
;;
|
;;
|
||||||
@@ -1278,7 +1460,8 @@ main() {
|
|||||||
local stacks
|
local stacks
|
||||||
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
|
stacks="$(resolve_target_stacks "$stacks_dir" "$MODE" "${TARGET_STACKS[@]}")"
|
||||||
if [ -z "$stacks" ]; then
|
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."
|
echo " ${YELLOW}┃${RESET} No stacks found in ${BOLD}${stacks_dir}${RESET}."
|
||||||
|
echo " ${CYAN}→${RESET} Either cd there, pass ${BOLD}--stack /path${RESET}, or use ${BOLD}-d${RESET} to set the backup directory."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
local stack
|
local stack
|
||||||
@@ -1299,8 +1482,9 @@ main() {
|
|||||||
fi
|
fi
|
||||||
for spec in "${TARGET_STACKS[@]}"; do
|
for spec in "${TARGET_STACKS[@]}"; do
|
||||||
local spath
|
local spath
|
||||||
spath="$(stack_spec_to_path "$stacks_dir" "$spec")"
|
spath="$(stack_spec_to_path "${INITIAL_DIR:-$stacks_dir}" "$spec")"
|
||||||
restore_stack "$stacks_dir" "$backup_dir" "$spath" || warn "Restore failed for '$spath'"
|
local restore_path="${RESTORE_TARGET:-$spath}"
|
||||||
|
restore_stack "$stacks_dir" "$backup_dir" "$spath" "$restore_path" || warn "Restore failed for '$spath'"
|
||||||
done
|
done
|
||||||
;;
|
;;
|
||||||
verify)
|
verify)
|
||||||
|
|||||||
@@ -9,15 +9,21 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── Terminal colours (disabled when not on a terminal) ──────────────
|
# ── Terminal colours ──────────────────────────────────────────────
|
||||||
if [[ -t 1 ]]; then
|
BOLD=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; RESET=""
|
||||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
|
||||||
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
BOLD=$(tput bold 2>/dev/null || true)
|
||||||
else
|
RED=$(tput setaf 1 2>/dev/null || true)
|
||||||
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC=''
|
GREEN=$(tput setaf 2 2>/dev/null || true)
|
||||||
|
YELLOW=$(tput setaf 3 2>/dev/null || true)
|
||||||
|
CYAN=$(tput setaf 6 2>/dev/null || true)
|
||||||
|
RESET=$(tput sgr0 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── State ───────────────────────────────────────────────────────────
|
# ── State ───────────────────────────────────────────────────────────
|
||||||
|
TARGET_STACKS=() # user-specified stacks (empty = all)
|
||||||
|
LIST_ONLY=false # --list flag
|
||||||
|
all_names=() # pre-filter stack names (for error messages)
|
||||||
updated_names=() # stack names that were updated
|
updated_names=() # stack names that were updated
|
||||||
updated_detail=() # parallel: "stack: service1, service2"
|
updated_detail=() # parallel: "stack: service1, service2"
|
||||||
unchanged_names=() # stack names already up to date
|
unchanged_names=() # stack names already up to date
|
||||||
@@ -25,24 +31,58 @@ orphaned_names=() # stacks whose compose files are gone – removed
|
|||||||
failed_names=() # stack names that errored
|
failed_names=() # stack names that errored
|
||||||
|
|
||||||
# ── Helpers ─────────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────────
|
||||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
log() { echo -e " ${CYAN}ℹ${RESET} $*"; }
|
||||||
ok() { echo -e " ${GREEN}✓${NC} $*"; }
|
ok() { echo -e " ${GREEN}✓${RESET} $*"; }
|
||||||
warn() { echo -e " ${YELLOW}~${NC} $*"; }
|
warn() { echo -e " ${YELLOW}⚠${RESET} $*"; }
|
||||||
fail() { echo -e " ${RED}✗${NC} $*"; }
|
fail() { echo -e " ${RED}✗${RESET} $*"; }
|
||||||
|
header() { echo ""; echo -e " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
|
||||||
|
|
||||||
|
# ── Usage ─────────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [options]
|
||||||
|
|
||||||
|
Pull latest images and restart all running Docker Compose stacks
|
||||||
|
that have updates.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Show this help message
|
||||||
|
-l, --list List running stacks and exit
|
||||||
|
-s, --stack <name> Target a specific stack (repeatable)
|
||||||
|
|
||||||
|
No options = update all running stacks.
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Parse arguments ──────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage ;;
|
||||||
|
-l|--list) LIST_ONLY=true; shift ;;
|
||||||
|
-s|--stack)
|
||||||
|
shift
|
||||||
|
[[ $# -eq 0 ]] && { echo -e " ${RED}✗${RESET} Option --stack requires a name." >&2; exit 1; }
|
||||||
|
TARGET_STACKS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo -e " ${RED}✗${RESET} Unknown option: $1" >&2; usage ;;
|
||||||
|
*)
|
||||||
|
echo -e " ${RED}✗${RESET} Unexpected argument: $1" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# ── Pre-flight ──────────────────────────────────────────────────────
|
# ── Pre-flight ──────────────────────────────────────────────────────
|
||||||
if ! command -v docker &>/dev/null; then
|
if ! command -v docker &>/dev/null; then
|
||||||
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2
|
echo -e "${RED}✗${RESET} Error: 'docker' not found in PATH." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BOLD}Docker Compose Stack Updater${NC}"
|
header "Docker Compose Stack Updater"
|
||||||
echo -e "${BOLD}────────────────────────────${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# ── Discover stacks ─────────────────────────────────────────────────
|
# ── Discover stacks ─────────────────────────────────────────────────
|
||||||
info "Scanning for running Docker Compose stacks ..."
|
log "Scanning for running Docker Compose stacks ..."
|
||||||
echo
|
|
||||||
|
|
||||||
stack_json=$(docker compose ls --format json 2>/dev/null || true)
|
stack_json=$(docker compose ls --format json 2>/dev/null || true)
|
||||||
|
|
||||||
@@ -53,7 +93,7 @@ fi
|
|||||||
|
|
||||||
# Parse JSON with jq (required).
|
# Parse JSON with jq (required).
|
||||||
if ! command -v jq &>/dev/null; then
|
if ! command -v jq &>/dev/null; then
|
||||||
echo -e "${RED}Error: 'jq' is required. Install it first.${NC}" >&2
|
echo -e "${RED}✗${RESET} Error: 'jq' is required. Install it first." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -61,16 +101,73 @@ readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
|
|||||||
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
|
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
|
||||||
|
|
||||||
total=${#names[@]}
|
total=${#names[@]}
|
||||||
echo " Found ${total} running stack(s):"
|
echo " ${CYAN}●${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
|
||||||
printf " • %s\n" "${names[@]}"
|
for name in "${names[@]}"; do
|
||||||
echo
|
echo " ${CYAN}·${RESET} ${name}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if $LIST_ONLY; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Spinner for long operations ─────────────────────────────────────
|
||||||
|
_spin_kill=""
|
||||||
|
|
||||||
|
_spin_start() {
|
||||||
|
local msg="$1"
|
||||||
|
local -a ch=('-' '\' '|' '/')
|
||||||
|
local i=0
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
echo -ne "\r ${CYAN}${ch[i]}${RESET} ${msg} "
|
||||||
|
i=$(( (i+1) % 4 ))
|
||||||
|
sleep 0.15
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
_spin_kill=$!
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_spin_stop() {
|
||||||
|
[[ -z "$_spin_kill" ]] && return
|
||||||
|
kill "$_spin_kill" 2>/dev/null || true
|
||||||
|
wait "$_spin_kill" 2>/dev/null || true
|
||||||
|
_spin_kill=""
|
||||||
|
echo -ne "\r\033[K"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Filter stacks if --stack was given ───────────────────────────────
|
||||||
|
if [[ ${#TARGET_STACKS[@]} -gt 0 ]]; then
|
||||||
|
all_names=("${names[@]}")
|
||||||
|
filtered_names=()
|
||||||
|
filtered_configs=()
|
||||||
|
for i in "${!names[@]}"; do
|
||||||
|
for t in "${TARGET_STACKS[@]}"; do
|
||||||
|
if [[ "${names[$i]}" == "$t" ]]; then
|
||||||
|
filtered_names+=("${names[$i]}")
|
||||||
|
filtered_configs+=("${configs[$i]}")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
names=("${filtered_names[@]}")
|
||||||
|
configs=("${filtered_configs[@]}")
|
||||||
|
if [[ ${#names[@]} -eq 0 ]]; then
|
||||||
|
warn "No running stacks match the requested name(s)."
|
||||||
|
echo " ${CYAN}→${RESET} Running stacks: ${all_names[*]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " ${CYAN}→${RESET} Targeting ${BOLD}${#names[@]}${RESET} of ${BOLD}${total}${RESET} running stack(s)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Process each stack ──────────────────────────────────────────────
|
# ── Process each stack ──────────────────────────────────────────────
|
||||||
for i in "${!names[@]}"; do
|
for i in "${!names[@]}"; do
|
||||||
stack="${names[$i]}"
|
stack="${names[$i]}"
|
||||||
cfg_str="${configs[$i]}"
|
cfg_str="${configs[$i]}"
|
||||||
|
|
||||||
echo -e "${BOLD}── ${stack}${NC}"
|
echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
|
||||||
|
|
||||||
# Support multi-file projects (ConfigFiles is comma-separated).
|
# Support multi-file projects (ConfigFiles is comma-separated).
|
||||||
IFS=',' read -ra cfg_files <<< "$cfg_str"
|
IFS=',' read -ra cfg_files <<< "$cfg_str"
|
||||||
@@ -97,12 +194,13 @@ for i in "${!names[@]}"; do
|
|||||||
for m in "${missing_files[@]}"; do
|
for m in "${missing_files[@]}"; do
|
||||||
echo " ${m}"
|
echo " ${m}"
|
||||||
done
|
done
|
||||||
info "Tearing down orphaned stack ..."
|
_spin_start "Tearing down orphaned stack..."
|
||||||
if docker compose -p "$stack" down 2>/dev/null; then
|
if docker compose -p "$stack" down 2>/dev/null; then
|
||||||
ok "${stack} — orphaned stack removed"
|
_spin_stop; ok "${stack} — orphaned stack removed"
|
||||||
else
|
else
|
||||||
|
_spin_stop
|
||||||
# Fall back to direct container / network cleanup by project label.
|
# Fall back to direct container / network cleanup by project label.
|
||||||
info "Fallback: removing containers by project label …"
|
log "Fallback: removing containers by project label..."
|
||||||
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
|
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
|
||||||
if [[ -n "$containers" ]]; then
|
if [[ -n "$containers" ]]; then
|
||||||
docker container rm -f $containers 2>/dev/null || true
|
docker container rm -f $containers 2>/dev/null || true
|
||||||
@@ -114,49 +212,43 @@ for i in "${!names[@]}"; do
|
|||||||
ok "${stack} — orphaned containers removed"
|
ok "${stack} — orphaned containers removed"
|
||||||
fi
|
fi
|
||||||
orphaned_names+=("$stack")
|
orphaned_names+=("$stack")
|
||||||
echo; continue
|
echo ""; continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Pulling latest images ..."
|
# Pull with spinner (capture output for parsing later).
|
||||||
|
_spin_start "Pulling latest images..."
|
||||||
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
|
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
|
||||||
|
_spin_stop
|
||||||
fail "Image pull failed for ${stack}"
|
fail "Image pull failed for ${stack}"
|
||||||
echo "$pull_out" | head -5
|
echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
|
||||||
failed_names+=("$stack")
|
failed_names+=("$stack")
|
||||||
echo; continue
|
echo ""; continue
|
||||||
}
|
}
|
||||||
|
_spin_stop
|
||||||
|
|
||||||
# Always run up -d after a successful pull (idempotent when nothing
|
# Recreate containers with spinner.
|
||||||
# changed).
|
_spin_start "Recreating containers..."
|
||||||
info "Recreating containers …"
|
|
||||||
docker compose "${compose_args[@]}" up -d 2>&1 || {
|
docker compose "${compose_args[@]}" up -d 2>&1 || {
|
||||||
|
_spin_stop
|
||||||
fail "Container recreation failed for ${stack}"
|
fail "Container recreation failed for ${stack}"
|
||||||
failed_names+=("$stack"); echo; continue
|
failed_names+=("$stack"); echo ""; continue
|
||||||
}
|
}
|
||||||
|
_spin_stop
|
||||||
|
|
||||||
# Determine which services (if any) received new images by parsing the
|
# Determine which services (if any) received new images by parsing the
|
||||||
# pull output. Only the pull output is authoritative — up -d restarts
|
# pull output. Only the pull output is authoritative — up -d restarts
|
||||||
# are idempotent and tell us nothing about whether images changed.
|
# are idempotent and tell us nothing about whether images changed.
|
||||||
#
|
#
|
||||||
# Patterns matched (TTY and non-TTY alike):
|
# The only reliable indicator of an actual image update is the
|
||||||
# " ✔ svc Pulled" / "svc Pulled"
|
# "Downloaded newer image for <service>" string. The "Pulled" status
|
||||||
# "Downloaded newer image for svc"
|
# appears even when the image is already up to date (Docker shows
|
||||||
|
# "Pulled" for the manifest check), so we ignore it.
|
||||||
updated_svc=()
|
updated_svc=()
|
||||||
|
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
# "Downloaded newer image for svc" — unambiguous.
|
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
|
||||||
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then
|
updated_svc+=("${BASH_REMATCH[1]}")
|
||||||
updated_svc+=("${BASH_REMATCH[1]}"); continue
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# "Pulled" not "Already up to date"
|
|
||||||
[[ "$line" =~ Already\ up\ to\ date ]] && continue
|
|
||||||
[[ ! "$line" =~ Pulled ]] && continue
|
|
||||||
|
|
||||||
# Extract the word before "Pulled". Strip leading " ✔ " if
|
|
||||||
# present, then read the first field.
|
|
||||||
cleaned="${line#*✔ }"
|
|
||||||
read -r svc _ <<< "$cleaned"
|
|
||||||
[[ -n "$svc" && "$svc" != "Pulled" ]] && updated_svc+=("$svc")
|
|
||||||
done <<< "$pull_out"
|
done <<< "$pull_out"
|
||||||
|
|
||||||
# Deduplicate while preserving order.
|
# Deduplicate while preserving order.
|
||||||
@@ -170,49 +262,52 @@ for i in "${!names[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ ${#deduped[@]} -gt 0 ]]; then
|
if [[ ${#deduped[@]} -gt 0 ]]; then
|
||||||
ok "${stack} — updated: ${deduped[*]}"
|
ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
|
||||||
updated_names+=("$stack")
|
updated_names+=("$stack")
|
||||||
updated_detail+=("${stack}: ${deduped[*]}")
|
updated_detail+=("${stack}: ${deduped[*]}")
|
||||||
else
|
else
|
||||||
info "${stack} — already up to date"
|
log "${stack} — already up to date"
|
||||||
unchanged_names+=("$stack")
|
unchanged_names+=("$stack")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Summary ─────────────────────────────────────────────────────────
|
# ── Summary ─────────────────────────────────────────────────────────
|
||||||
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
|
header "Summary"
|
||||||
echo -e "${BOLD} Summary${NC}"
|
|
||||||
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
|
|
||||||
echo
|
|
||||||
|
|
||||||
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
|
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
|
||||||
echo " Total stacks found: ${total_found}"
|
echo " ${CYAN}●${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
|
||||||
echo
|
echo ""
|
||||||
|
|
||||||
if [[ ${#updated_names[@]} -gt 0 ]]; then
|
if [[ ${#updated_names[@]} -gt 0 ]]; then
|
||||||
echo -e " ${GREEN}✓${NC} ${BOLD}Updated (${#updated_names[@]})${NC}"
|
echo -e " ${GREEN}✓${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
|
||||||
for d in "${updated_detail[@]}"; do
|
for d in "${updated_detail[@]}"; do
|
||||||
echo " ${d}"
|
echo " ${d}"
|
||||||
done
|
done
|
||||||
echo
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${#unchanged_names[@]} -gt 0 ]]; then
|
if [[ ${#unchanged_names[@]} -gt 0 ]]; then
|
||||||
echo -e " ${YELLOW}−${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}"
|
echo -e " ${CYAN}−${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
|
||||||
printf " %s\n" "${unchanged_names[@]}"
|
for s in "${unchanged_names[@]}"; do
|
||||||
echo
|
echo " ${CYAN}·${RESET} ${s}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${#orphaned_names[@]} -gt 0 ]]; then
|
if [[ ${#orphaned_names[@]} -gt 0 ]]; then
|
||||||
echo -e " ${RED}✗${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}"
|
echo -e " ${YELLOW}⚠${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
|
||||||
printf " %s\n" "${orphaned_names[@]}"
|
for s in "${orphaned_names[@]}"; do
|
||||||
echo
|
echo " ${YELLOW}·${RESET} ${s}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${#failed_names[@]} -gt 0 ]]; then
|
if [[ ${#failed_names[@]} -gt 0 ]]; then
|
||||||
echo -e " ${RED}✗${NC} ${BOLD}Failed (${#failed_names[@]})${NC}"
|
echo -e " ${RED}✗${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
|
||||||
printf " %s\n" "${failed_names[@]}"
|
for s in "${failed_names[@]}"; do
|
||||||
echo
|
echo " ${RED}·${RESET} ${s}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user