diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5578d94..423b3bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,17 +11,31 @@ on: workflow_dispatch: jobs: - build: - runs-on: macos-13 + # ── Fast: lint + stub-based unit tests (no daemon required) ────────────── + unit: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Update brew - run: brew update - - - name: Install shellcheck - run: brew install shellcheck + - name: Install dependencies + run: sudo apt-get install -y shellcheck bats jq - name: Run shellcheck - run: shellcheck *.sh backup/**.sh restore/*.sh -e SC2154 -e SC1091 + run: > + shellcheck *.sh backup/**.sh restore/*.sh + test/integration/*.sh + -e SC2154 -e SC1091 -e SC2317 + + - name: Run unit tests (stub-based, no daemon) + run: bats test/*.bats + + # ── Real: full backup → wipe → restore → verify inside dind ────────────── + integration: + runs-on: ubuntu-latest # ubuntu runners support privileged containers + + steps: + - uses: actions/checkout@v3 + + - name: Run integration tests (Docker-in-Docker) + run: bash test/integration/run.sh diff --git a/Readme.md b/Readme.md index 3c408b4..af46db3 100644 --- a/Readme.md +++ b/Readme.md @@ -2,56 +2,151 @@ ## Description -A bunch of Bash scripts to make a backup of all your running containers -dynamically. +A set of Bash scripts to back up and fully restore all your running Docker +containers: images, named volumes, bind-mounted host paths, and container +configuration. -This will create a backup of docker images, volumes, upload to dropbox and -remove the backup files after to save space +## Requirements -## Setup and Usage +- Docker +- `jq` — required for restore operations and bind-mount backup (`apt install jq` / `brew install jq`) +- Sufficient permissions to read/write bind-mount host paths (may require `sudo`) -This script use flags for configuration. The flags are: +## Flags -- **-m** to set the mode (backup or restore) -- **-b** to set the backup path -- **-t** to set the tar options -- **-u** to upload to dropbox -- **-f** to force -- **-s** to run in non-interactive mode +| Flag | Description | +|------|-------------| +| `-m` | **Required.** Mode: `backup` or `restore` | +| `-p` | Backup directory path (default: `/home/core/backups`) | +| `-t` | Extra options passed to `tar` when compressing the backup dir (e.g. `--exclude=/some/path`) | +| `-B` | Enable bind-mount backup/restore (off by default — see note below) | +| `-u` | Upload backup to Dropbox after completing | +| `-f` | Force: overwrite non-empty backup dir on backup; replace existing volumes/containers/paths on restore | +| `-s` | Non-interactive mode — skips all prompts, enables all steps (use in cron jobs) | -Also, on dropbox you must create an App to store this backups, refer to -https://www.dropbox.com/developers to get your **Generated access token** -before running it and placed inside the **config/dropbox-uploader.conf** file +## What gets backed up -Give permissions to all sh files in the folder +| Artifact | Location in backup | +|----------|--------------------| +| Container images | `/-image.tar` (via `docker save`) | +| Named volumes | `volumes/.tar.gz` — one file per volume, shared volumes deduplicated | +| Container inspect data | `/-data.txt` — JSON used to recreate containers on restore | +| Bind-mount host paths *(opt-in with `-B`)* | `/binds/bind-N.tar.gz` + `manifest.json` | + +## What gets restored (in order) + +1. **Images** — `docker load` from each image tar +2. **Volumes** — extracted back into named Docker volumes +3. **Bind mounts** *(only if `-B` was used during backup)* — host paths extracted to their original locations +4. **Containers** — recreated from inspect data (best-effort: covers name, image, env, ports, volumes, restart policy, network mode) + +> **Container recreation is best-effort.** Common configuration is reconstructed +> from the saved `docker inspect` output. Unusual settings (custom capabilities, +> device mappings, secrets, etc.) may need to be applied manually. + +> **Bind-mount host paths** (`-v /host/path:/container/path`) require the `-B` +> flag. Without it, only the container config (the `-v` flag reference) is +> restored, but the actual host-side files are not touched. These paths often +> require root-level read/write access. + +## Setup ```bash cd docker-backup-scripts -chmod +x *.sh +chmod +x *.sh backup/*.sh restore/*.sh ``` -Run the backup +## Usage + +### Run a backup ```bash ./backup-manager.sh -p -m backup ``` -Run the restoration +Include bind-mounted host paths: + +```bash +./backup-manager.sh -p -m backup -B +``` + +### Run a restore ```bash ./backup-manager.sh -p -m restore ``` -## Extra +Restore including bind-mounted host paths: -Create a cron if you want to run it often (use the -s flag to run it in non-interactive mode) +```bash +./backup-manager.sh -p -m restore -B +``` + +Force-replace existing containers and volumes: ```bash -crontab -e +./backup-manager.sh -p -m restore -f +``` + +### Backup + upload to Dropbox + +```bash +./backup-manager.sh -p -m backup -u +``` + +You must create a Dropbox App and place your generated access token in +`config/dropbox_uploader.conf` before using `-u`. See +https://www.dropbox.com/developers for details. + +### Non-interactive / cron mode + +```bash +./backup-manager.sh -p -m backup -s ``` +All steps are enabled automatically in `-s` mode (bind mounts only if `-B` is +also passed). + ```bash +crontab -e +# Daily backup at midnight: 0 0 * * * /path/to/backup-manager.sh -p -m backup -s ``` -For CoreOS, I supply a timer to allow run it daily with an installation script +## CoreOS timer + +A systemd timer for CoreOS is provided in `backup/coreos-timer/`. Run the +installation script there to set up a daily backup service. + +## Testing + +Two test tiers are provided. + +### Unit tests — fast, no Docker daemon needed + +Uses a `docker` stub to verify script logic in isolation. Requires +[bats-core](https://github.com/bats-core/bats-core) and `jq`. + +```bash +bats test/*.bats +``` + +Covers: volume backup layout and deduplication (issue #14), dotted volume-name +parsing regression, full restore pipeline ordering (issue #18), container +reconstruction from inspect JSON, bind-mount backup/restore skip/force logic. + +### Integration tests — real backup → wipe → restore → verify + +Runs the full cycle inside an isolated **Docker-in-Docker** daemon so your host +containers and volumes are never touched. Requires Docker with privileged +container support. + +```bash +bash test/integration/run.sh +``` + +Verifies end-to-end: +- Named volume data survives backup and restore +- Bind-mount host paths are backed up with `-B` and restored in place +- Container images are re-loaded after being deleted +- Restored container is running with correct env, restart policy, and port mapping diff --git a/backup-manager.sh b/backup-manager.sh index 5ef9083..9748139 100755 --- a/backup-manager.sh +++ b/backup-manager.sh @@ -21,12 +21,13 @@ cd "${BASH_SOURCE%/*}" || exit non_interactive=false backup_path="/home/core/backups" -tar_opts="--exclude='/var/run/*'" +tar_opts="--exclude=/var/run/*" docker_upload_enable=false force=false mode="" +bind_mounts_enable=false -while getopts "sp:t:ufm:" opt; do +while getopts "sp:t:ufm:B" opt; do case $opt in s) non_interactive=true @@ -46,6 +47,9 @@ while getopts "sp:t:ufm:" opt; do m) mode=$OPTARG ;; + B) + bind_mounts_enable=true + ;; \?) echo "Invalid option: -$OPTARG" >&2 ;; diff --git a/backup/backup-all.sh b/backup/backup-all.sh index 49d1f3c..d462dd1 100755 --- a/backup/backup-all.sh +++ b/backup/backup-all.sh @@ -50,12 +50,16 @@ then echo "Backup volumes ? (y/n)" read -r backup_volumes + echo "Backup bind-mounted host paths ? (y/n) [requires read access to those paths]" + read -r backup_bind_mounts + echo "Should I compress the backup directory ? (y/n)" read -r compress_backup else backup_container_data="y" backup_container_images="y" backup_volumes="y" + backup_bind_mounts=$([ "$bind_mounts_enable" = true ] && echo "y" || echo "n") compress_backup="n" fi @@ -74,10 +78,16 @@ then source backup/backup-volumes.sh fi +if [ "$backup_bind_mounts" = "y" ] +then + source backup/backup-bind-mounts.sh +fi + if [ "$compress_backup" = "y" ] then echo -n "Compressing backup directory - " - tar -czf "$backup_path.tar.gz" "$backup_path" >/dev/null 2>&1 + # shellcheck disable=SC2086 + tar $tar_opts -czf "$backup_path.tar.gz" "$backup_path" >/dev/null 2>&1 echo "OK" echo -n "Removing backup directory - " diff --git a/backup/backup-bind-mounts.sh b/backup/backup-bind-mounts.sh new file mode 100644 index 0000000..14ea9de --- /dev/null +++ b/backup/backup-bind-mounts.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Path: backup-bind-mounts.sh +# Backup host filesystem paths that are bind-mounted into containers. +# +# Each container's bind mounts are archived into: +# //binds/bind-N.tar.gz +# with a manifest.json that maps each archive back to its original host path. +# +# Note: this script must run with sufficient permissions to read the bind-mount +# source paths (often requires root). + +echo "Backing up bind mounts" +echo "----------------------" + +found_any=false + +for container_name in $(docker ps -q | xargs docker inspect --format='{{.Name}}' | cut -f2 -d/) +do + # Extract bind-mount source paths using a Go template (no jq needed here) + bind_sources=$(docker inspect \ + --format='{{range .Mounts}}{{if eq .Type "bind"}}{{println .Source}}{{end}}{{end}}' \ + "$container_name") + + [ -z "$bind_sources" ] && continue + + found_any=true + echo "$container_name:" + + binds_dir="$backup_path/$container_name/binds" + mkdir -p "$binds_dir" + + manifest="$binds_dir/manifest.json" + printf '[\n' > "$manifest" + first=true + i=0 + + while IFS= read -r host_path + do + [ -z "$host_path" ] && continue + + archive_name="bind-$i.tar.gz" + archive_path="$binds_dir/$archive_name" + + echo -n " $host_path - " + + if [ ! -e "$host_path" ]; then + echo "SKIPPED (path does not exist)" + else + # Archive with absolute paths (-P) so restore can put files back exactly + tar -P -czf "$archive_path" "$host_path" 2>/dev/null + echo "OK" + fi + + # Append manifest entry (comma-separate after the first) + if [ "$first" = true ]; then + first=false + else + printf ',\n' >> "$manifest" + fi + printf ' {"archive":"%s","path":"%s"}' "$archive_name" "$host_path" >> "$manifest" + + i=$((i + 1)) + done <<< "$bind_sources" + + printf '\n]\n' >> "$manifest" +done + +if [ "$found_any" = false ]; then + echo "No bind mounts found on running containers" +fi + +echo "" diff --git a/backup/sync-dropbox.sh b/backup/sync-dropbox.sh index 0a98887..62fa793 100644 --- a/backup/sync-dropbox.sh +++ b/backup/sync-dropbox.sh @@ -1,43 +1,67 @@ #!/bin/bash -docker_containers=$(docker ps -q) -for i in $(docker inspect --format='{{.Name}}' "$docker_containers" | cut -f2 -d/) - do container_name=$i - - # Creating container folder - docker run --rm --user="$(id -u)":"$(id -g)" \ - -v "$PWD"/config:/config \ - -v "$backup_path":/workdir \ - peez/dropbox-uploader \ - mkdir "$container_name" - - # Uploading image - echo "$container_name - image to Dropbox " - # TODO check if the file exists - docker run --rm --user="$(id -u)":"$(id -g)" \ - --name "dropbox-$container_name-image-backup" \ - -v "$PWD"/config:/config \ - -v "$backup_path":/workdir \ - peez/dropbox-uploader \ - upload "$container_name"/"$container_name-image.tar" \ - "$container_name"/"$container_name-image.tar" && \ - - # remove local image, TODO creating a condition to know if the file - # uploaded well before deleting - rm -f "$backup_path"/"$container_name"/"$container_name-image.tar" - - # Uploading volume - echo "$container_name - volume to Dropbox " - # TODO check if the file exists - docker run --rm --user="$(id -u)":"$(id -g)" \ - --name "dropbox-$container_name-volume-backup" \ - -v "$PWD"/config:/config \ - -v "$backup_path":/workdir \ - peez/dropbox-uploader \ - upload "$container_name"/"$container_name-volume.tar.xz" \ - "$container_name"/"$container_name-volume.tar.xz" && \ - - # remove local volume, TODO creating a condition to know if the file - # uploaded well before deleting - rm -f "$backup_path"/"$container_name"/"$container_name-volume.tar.xz" +# Path: sync-dropbox.sh +# Upload the backup directory to Dropbox and remove local files after a +# successful upload. Requires a valid config/dropbox_uploader.conf. + +echo "Syncing to Dropbox" +echo "------------------" + +_dropbox_mkdir() { + docker run --rm --user="$(id -u)":"$(id -g)" \ + -v "$PWD/config":/config \ + -v "$backup_path":/workdir \ + peez/dropbox-uploader \ + mkdir "$1" 2>/dev/null || true +} + +_dropbox_upload() { + local src="$1" # path relative to $backup_path + local dest="$2" # path in Dropbox + docker run --rm --user="$(id -u)":"$(id -g)" \ + --name "dropbox-upload-$(echo "$src" | tr '/' '-')" \ + -v "$PWD/config":/config \ + -v "$backup_path":/workdir \ + peez/dropbox-uploader \ + upload "$src" "$dest" +} + +# Upload per-volume tar archives +if [ -d "$backup_path/volumes" ]; then + _dropbox_mkdir "volumes" + + for vol_file in "$backup_path/volumes/"*.tar.gz; do + [ -e "$vol_file" ] || continue + filename=$(basename "$vol_file") + echo -n "volumes/$filename - " + if _dropbox_upload "volumes/$filename" "volumes/$filename"; then + rm -f "$vol_file" + echo "OK" + else + echo "FAILED" + fi + done +fi + +# Upload per-container image and inspect-data files +for container_dir in "$backup_path"/*/; do + [ -d "$container_dir" ] || continue + container_name=$(basename "$container_dir") + [ "$container_name" = "volumes" ] && continue + + _dropbox_mkdir "$container_name" + + for file in "$container_dir"*; do + [ -e "$file" ] || continue + filename=$(basename "$file") + echo -n "$container_name/$filename - " + if _dropbox_upload "$container_name/$filename" "$container_name/$filename"; then + rm -f "$file" + echo "OK" + else + echo "FAILED" + fi + done done + +echo "" diff --git a/restore/restore-all.sh b/restore/restore-all.sh index d571fb2..1088ca0 100755 --- a/restore/restore-all.sh +++ b/restore/restore-all.sh @@ -13,10 +13,27 @@ fi if [ "$non_interactive" = false ] then + echo "Restore container images ? (y/n)" + read -r restore_images + echo "Restore volumes ? (y/n)" read -r restore_volumes + + echo "Restore bind-mounted host paths ? (y/n) [requires write access to those paths]" + read -r restore_bind_mounts + + echo "Recreate containers from saved inspect data ? (y/n)" + read -r restore_containers else + restore_images="y" restore_volumes="y" + restore_bind_mounts=$([ "$bind_mounts_enable" = true ] && echo "y" || echo "n") + restore_containers="y" +fi + +if [ "$restore_images" = "y" ] +then + source restore/restore-images.sh fi if [ "$restore_volumes" = "y" ] @@ -24,7 +41,17 @@ then source restore/restore-volumes.sh fi +if [ "$restore_bind_mounts" = "y" ] +then + source restore/restore-bind-mounts.sh +fi + +if [ "$restore_containers" = "y" ] +then + source restore/restore-containers.sh +fi + echo "" -echo "Restoration finished" \ No newline at end of file +echo "Restoration finished" diff --git a/restore/restore-bind-mounts.sh b/restore/restore-bind-mounts.sh new file mode 100644 index 0000000..2b6f801 --- /dev/null +++ b/restore/restore-bind-mounts.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Path: restore-bind-mounts.sh +# Restore host filesystem paths that were bind-mounted into containers. +# +# Reads the manifest.json written by backup-bind-mounts.sh and extracts each +# archive back to its original host path. +# +# Note: this script must run with sufficient permissions to write to the +# restored host paths (often requires root). + +echo "Restoring bind mounts" +echo "---------------------" + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required to restore bind mounts." + echo " Install it with your package manager (e.g. apt install jq / brew install jq)." + return 1 2>/dev/null || exit 1 +fi + +found_any=false + +for container_dir in "$backup_path"/*/ +do + [ -d "$container_dir" ] || continue + container_name=$(basename "$container_dir") + [ "$container_name" = "volumes" ] && continue + + manifest="$container_dir/binds/manifest.json" + [ -f "$manifest" ] || continue + + found_any=true + echo "$container_name:" + + while IFS= read -r entry + do + archive=$(printf '%s' "$entry" | jq -r '.archive') + host_path=$(printf '%s' "$entry" | jq -r '.path') + archive_file="$container_dir/binds/$archive" + + echo -n " $host_path - " + + if [ ! -f "$archive_file" ]; then + echo "SKIPPED (archive not found: $archive)" + continue + fi + + if [ -e "$host_path" ] && [ "$force" != true ]; then + echo "SKIPPED (already exists; use -f to overwrite)" + continue + fi + + mkdir -p "$(dirname "$host_path")" + if tar -P -xzf "$archive_file" 2>/dev/null; then + echo "OK" + else + echo "FAILED (extraction error — check permissions)" + fi + done < <(jq -c '.[]' "$manifest") +done + +if [ "$found_any" = false ]; then + echo "No bind mount backups found in $backup_path" +fi + +echo "" diff --git a/restore/restore-containers.sh b/restore/restore-containers.sh new file mode 100644 index 0000000..67c9cef --- /dev/null +++ b/restore/restore-containers.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +# Path: restore-containers.sh +# Recreate containers from the saved docker inspect data (best-effort). +# +# Note: Container recreation from inspect data covers the most common +# configuration fields (name, image, env, ports, volumes, restart policy, +# network). Unusual configurations (custom capabilities, device mappings, +# secrets, etc.) may need to be applied manually after restoration. +# +# Requires: jq + +echo "Recreating containers" +echo "---------------------" + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required for container restoration." + echo " Install it with your package manager (e.g. apt install jq / brew install jq)." + return 1 2>/dev/null || exit 1 +fi + +# build_run_cmd +# Reads the first container entry from a docker-inspect JSON file and prints +# the equivalent "docker run" command to stdout. +build_run_cmd() { + local json_file="$1" + local data + data=$(cat "$json_file") + + local name restart image + name=$(printf '%s' "$data" | jq -r '.[0].Name | ltrimstr("/")') + restart=$(printf '%s' "$data" | jq -r '.[0].HostConfig.RestartPolicy.Name // empty') + image=$(printf '%s' "$data" | jq -r '.[0].Config.Image') + + local cmd="docker run -d" + cmd="$cmd --name \"$name\"" + + if [ -n "$restart" ] && [ "$restart" != "no" ]; then + cmd="$cmd --restart $restart" + fi + + # Environment variables + while IFS= read -r env_var; do + [ -n "$env_var" ] && cmd="$cmd -e \"$env_var\"" + done < <(printf '%s' "$data" | jq -r '.[0].Config.Env[]? // empty') + + # Port bindings: HostConfig.PortBindings maps "containerPort/proto" to + # [{ "HostIp": "", "HostPort": "hostPort" }] + while IFS= read -r port_mapping; do + [ -n "$port_mapping" ] && cmd="$cmd -p $port_mapping" + done < <(printf '%s' "$data" | jq -r ' + .[0].HostConfig.PortBindings // {} | + to_entries[] | + .key as $cport | + .value[]? | + (if .HostIp != "" and .HostIp != null then .HostIp + ":" else "" end) + + .HostPort + ":" + ($cport | split("/")[0]) + ') + + # Bind mounts (host-path:container-path[:options]) + while IFS= read -r bind; do + [ -n "$bind" ] && cmd="$cmd -v \"$bind\"" + done < <(printf '%s' "$data" | jq -r '.[0].HostConfig.Binds[]? // empty') + + # Named volumes (Type == "volume" in Mounts) + while IFS= read -r vol_mount; do + [ -n "$vol_mount" ] && cmd="$cmd -v $vol_mount" + done < <(printf '%s' "$data" | jq -r ' + .[0].Mounts[]? | + select(.Type == "volume") | + "\(.Name):\(.Destination)" + ') + + # Network mode (skip default/bridge — those are Docker defaults) + local network_mode + network_mode=$(printf '%s' "$data" | jq -r '.[0].HostConfig.NetworkMode // empty') + if [ -n "$network_mode" ] && [ "$network_mode" != "default" ] && [ "$network_mode" != "bridge" ]; then + cmd="$cmd --network $network_mode" + fi + + cmd="$cmd $image" + + # Override CMD only if explicitly set in the inspect data + local container_cmd + container_cmd=$(printf '%s' "$data" | jq -r '.[0].Config.Cmd // [] | join(" ")') + if [ -n "$container_cmd" ]; then + cmd="$cmd $container_cmd" + fi + + printf '%s\n' "$cmd" +} + +data_files=( "$backup_path"/*/*-data.txt ) +if [ ! -e "${data_files[0]}" ]; then + echo "No container data backups found in $backup_path" + echo "" + return 0 2>/dev/null || exit 0 +fi + +for data_file in "${data_files[@]}" +do + container_name=$(basename "$(dirname "$data_file")") + echo -n "$container_name - " + + if docker inspect "$container_name" >/dev/null 2>&1; then + if [ "$force" = true ]; then + docker rm -f "$container_name" >/dev/null 2>&1 + else + echo "SKIPPED (already exists; use -f to replace)" + continue + fi + fi + + run_cmd=$(build_run_cmd "$data_file") + eval "$run_cmd" >/dev/null 2>&1 + echo "OK" +done + +echo "" diff --git a/restore/restore-images.sh b/restore/restore-images.sh new file mode 100644 index 0000000..b3041ff --- /dev/null +++ b/restore/restore-images.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Path: restore-images.sh +# Restore all container images from the backup. +# Each container directory inside the backup path contains a *-image.tar file +# produced by "docker save". + +echo "Restoring container images" +echo "--------------------------" + +image_files=( "$backup_path"/*/*-image.tar ) +if [ ! -e "${image_files[0]}" ]; then + echo "No image backups found in $backup_path" + echo "" + return 0 2>/dev/null || exit 0 +fi + +for image_file in "${image_files[@]}" +do + image_name=$(basename "$image_file" "-image.tar") + echo -n "$image_name - " + docker load -i "$image_file" >/dev/null 2>&1 + echo "OK" +done + +echo "" diff --git a/restore/restore-volumes.sh b/restore/restore-volumes.sh index 3462156..6bd8a5b 100755 --- a/restore/restore-volumes.sh +++ b/restore/restore-volumes.sh @@ -1,20 +1,43 @@ #!/bin/bash # Path: restore-volumes.sh -# Restore all volumes from the backup -# Inside the backup directory, there is a volumes directory, which contains all the volumes. +# Restore all volumes from the backup. +# The backup directory contains a volumes/ subdirectory with one .tar.gz file +# per volume, named after the volume (e.g. myvolume.tar.gz). -echo "Volumes restaurations" +echo "Volumes restoration" echo "------------------" -volumes=$(ls "$backup_path/volumes") +if [ ! -d "$backup_path/volumes" ]; then + echo "No volumes backup found in $backup_path/volumes" + echo "" + return 0 2>/dev/null || exit 0 +fi -for volume in $volumes +found=false +for file in "$backup_path/volumes/"*.tar.gz do - # Get volume name from the file name - volume=$(echo "$volume" | cut -f1 -d.) + [ -e "$file" ] || continue + found=true - echo -n "$volume - " - docker run --rm -v "$volume":/volume -v "$backup_path"/volumes:/backup busybox sh -c "cd /volume && tar -xvf /backup/$volume.tar.gz --strip 1" >/dev/null 2>&1 - echo "OK" -done \ No newline at end of file + # Derive the volume name by stripping the .tar.gz suffix. + # Using parameter expansion instead of cut so dotted names work correctly + # (e.g. "my.vol.1.tar.gz" → "my.vol.1", not "my"). + filename=$(basename "$file") + volume="${filename%.tar.gz}" + + echo -n "$volume - " + docker volume create "$volume" >/dev/null 2>&1 + docker run --rm \ + --userns=host \ + -v "$volume":/volume \ + -v "$backup_path/volumes":/backup \ + busybox sh -c "cd /volume && tar -xvf /backup/$filename --strip 1" >/dev/null 2>&1 + echo "OK" +done + +if [ "$found" = false ]; then + echo "No volume backups found in $backup_path/volumes" +fi + +echo "" \ No newline at end of file diff --git a/test/backup-bind-mounts.bats b/test/backup-bind-mounts.bats new file mode 100644 index 0000000..ba3c5fe --- /dev/null +++ b/test/backup-bind-mounts.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# Tests for backup/backup-bind-mounts.sh + +setup() { + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + mkdir -p "$backup_path" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export STUB_CONTAINERS="test-container" + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" + + # Create real host paths to act as bind-mount sources + export FAKE_CONFIG_DIR="$TEST_TMPDIR/host/config" + export FAKE_DATA_DIR="$TEST_TMPDIR/host/data" + mkdir -p "$FAKE_CONFIG_DIR" "$FAKE_DATA_DIR" + echo "config-value" > "$FAKE_CONFIG_DIR/app.conf" + echo "data-value" > "$FAKE_DATA_DIR/data.txt" + + # Tell the stub which bind sources to return for this container + export STUB_BIND_SOURCES="$FAKE_CONFIG_DIR +$FAKE_DATA_DIR" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "creates binds/ directory per container" { + source backup/backup-bind-mounts.sh + + [ -d "$backup_path/test-container/binds" ] +} + +@test "creates one archive per bind-mount source" { + source backup/backup-bind-mounts.sh + + [ -f "$backup_path/test-container/binds/bind-0.tar.gz" ] + [ -f "$backup_path/test-container/binds/bind-1.tar.gz" ] +} + +@test "archives contain the correct files" { + source backup/backup-bind-mounts.sh + + # bind-0 should contain the config file + run tar -tzf "$backup_path/test-container/binds/bind-0.tar.gz" + [[ "$output" == *"app.conf"* ]] + + # bind-1 should contain the data file + run tar -tzf "$backup_path/test-container/binds/bind-1.tar.gz" + [[ "$output" == *"data.txt"* ]] +} + +@test "creates a valid manifest.json" { + source backup/backup-bind-mounts.sh + + manifest="$backup_path/test-container/binds/manifest.json" + [ -f "$manifest" ] + + # Must be valid JSON + run jq '.' "$manifest" + [ "$status" -eq 0 ] +} + +@test "manifest maps archive names to original host paths" { + source backup/backup-bind-mounts.sh + + manifest="$backup_path/test-container/binds/manifest.json" + + run jq -r '.[0].archive' "$manifest" + [ "$output" = "bind-0.tar.gz" ] + + run jq -r '.[0].path' "$manifest" + [ "$output" = "$FAKE_CONFIG_DIR" ] + + run jq -r '.[1].path' "$manifest" + [ "$output" = "$FAKE_DATA_DIR" ] +} + +@test "skips non-existent bind-mount source paths gracefully" { + # Override bind sources with a non-existent path in the outer scope so the + # subshell inherits the already-correct PATH (with docker stub prepended). + export STUB_BIND_SOURCES="/nonexistent/path" + + run bash -c " + cd '$BATS_TEST_DIRNAME/..' + export backup_path='$backup_path' + export STUB_CONTAINERS='$STUB_CONTAINERS' + export DOCKER_LOG='$DOCKER_LOG' + source backup/backup-bind-mounts.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED"* ]] +} + +@test "handles containers with no bind mounts gracefully" { + export STUB_BIND_SOURCES="" + + source backup/backup-bind-mounts.sh + + # No binds directory should be created for this container + [ ! -d "$backup_path/test-container/binds" ] +} diff --git a/test/backup-volumes.bats b/test/backup-volumes.bats new file mode 100644 index 0000000..0886dce --- /dev/null +++ b/test/backup-volumes.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# Tests for backup/backup-volumes.sh +# +# Confirms issue #14: volumes are backed up one-per-file using the volume name, +# and shared volumes are not duplicated. + +setup() { + # Run from repo root so relative `source` paths work + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + mkdir -p "$backup_path/volumes" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export STUB_VOLUME_MOUNT="$TEST_TMPDIR/mnt" + + # Prepend stub dir to PATH so our fake `docker` is used + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "creates one tar.gz per volume named after the volume (issue #14)" { + # Default stub provides: vol1, vol2, my.dotted.vol + source backup/backup-volumes.sh + + run ls "$backup_path/volumes/" + [ "$status" -eq 0 ] + + # Stub docker run (busybox tar) just records the call; create stub tar files + # to simulate what the real command would produce so assertions are realistic + touch "$backup_path/volumes/vol1.tar.gz" + touch "$backup_path/volumes/vol2.tar.gz" + touch "$backup_path/volumes/my.dotted.vol.tar.gz" + + run ls "$backup_path/volumes/" + echo "volumes dir: $output" + + # Three separate tar archives, one per volume + run bash -c "grep -c 'tar -cvzf' \"$DOCKER_LOG\"" + [ "$output" -eq 3 ] +} + +@test "volume filenames match volume names exactly" { + source backup/backup-volumes.sh + + run grep 'tar -cvzf /backup/vol1\.tar\.gz' "$DOCKER_LOG" + [ "$status" -eq 0 ] + + run grep 'tar -cvzf /backup/vol2\.tar\.gz' "$DOCKER_LOG" + [ "$status" -eq 0 ] + + # Dotted name must not be truncated + run grep 'tar -cvzf /backup/my\.dotted\.vol\.tar\.gz' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "does not back up any volume more than once (deduplication, issue #14)" { + # docker volume ls returns each volume once; verify each tar call appears once + source backup/backup-volumes.sh + + vol1_count=$(grep -c 'tar -cvzf /backup/vol1\.tar\.gz' "$DOCKER_LOG" || true) + [ "$vol1_count" -eq 1 ] + + vol2_count=$(grep -c 'tar -cvzf /backup/vol2\.tar\.gz' "$DOCKER_LOG" || true) + [ "$vol2_count" -eq 1 ] +} + +@test "all volume archives are placed inside the volumes/ subdirectory" { + source backup/backup-volumes.sh + + # Every tar -cvzf call should write to /backup/ (which maps to backup_path/volumes) + run grep 'tar -cvzf /backup/' "$DOCKER_LOG" + [ "$status" -eq 0 ] + + # None should write outside /backup/ + run grep 'tar -cvzf' "$DOCKER_LOG" + while IFS= read -r line; do + [[ "$line" == *"/backup/"* ]] + done <<< "$output" +} diff --git a/test/fixtures/container_inspect.json b/test/fixtures/container_inspect.json new file mode 100644 index 0000000..2db8adb --- /dev/null +++ b/test/fixtures/container_inspect.json @@ -0,0 +1,56 @@ +[ + { + "Id": "abc123def456", + "Name": "/test-container", + "Config": { + "Image": "nginx:latest", + "Env": [ + "FOO=bar", + "BAZ=qux" + ], + "Cmd": null + }, + "HostConfig": { + "Binds": [ + "/host/data:/container/data:ro" + ], + "PortBindings": { + "80/tcp": [ + { + "HostIp": "", + "HostPort": "8080" + } + ], + "443/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "8443" + } + ] + }, + "NetworkMode": "mynetwork", + "RestartPolicy": { + "Name": "always", + "MaximumRetryCount": 0 + } + }, + "Mounts": [ + { + "Type": "volume", + "Name": "myvolume", + "Source": "/var/lib/docker/volumes/myvolume/_data", + "Destination": "/data", + "Driver": "local", + "Mode": "", + "RW": true + } + ], + "NetworkSettings": { + "Networks": { + "mynetwork": { + "IPAddress": "172.18.0.2" + } + } + } + } +] diff --git a/test/fixtures/container_inspect_with_binds.json b/test/fixtures/container_inspect_with_binds.json new file mode 100644 index 0000000..9c66c38 --- /dev/null +++ b/test/fixtures/container_inspect_with_binds.json @@ -0,0 +1,46 @@ +[ + { + "Id": "abc123def456", + "Name": "/test-container", + "Config": { + "Image": "nginx:latest", + "Env": ["FOO=bar"], + "Cmd": null + }, + "HostConfig": { + "Binds": [ + "/host/config:/app/config:ro", + "/host/data:/app/data" + ], + "PortBindings": {}, + "NetworkMode": "bridge", + "RestartPolicy": { + "Name": "unless-stopped", + "MaximumRetryCount": 0 + } + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/host/config", + "Destination": "/app/config", + "Mode": "ro", + "RW": false + }, + { + "Type": "bind", + "Source": "/host/data", + "Destination": "/app/data", + "Mode": "", + "RW": true + } + ], + "NetworkSettings": { + "Networks": { + "bridge": { + "IPAddress": "172.17.0.2" + } + } + } + } +] diff --git a/test/helpers/docker b/test/helpers/docker new file mode 100755 index 0000000..b14b3df --- /dev/null +++ b/test/helpers/docker @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Docker stub for bats tests. +# Records every invocation to $DOCKER_LOG (if set). +# Returns canned responses based on the subcommand. +# +# Tuneable env vars: +# DOCKER_LOG – file to append "docker " lines to +# STUB_VOLUME_MOUNT – fake mountpoint prefix for volumes (default /mnt/volumes) +# STUB_VOLUMES – space-separated list of fake volume names +# (default: vol1 vol2 my.dotted.vol) +# STUB_CONTAINERS – space-separated list of fake running container names +# STUB_CONTAINER_EXISTS – set to "true" to make `docker inspect ` succeed +# STUB_INSPECT_FILE – path to a JSON file returned by `docker inspect ` +# STUB_BIND_SOURCES – newline-separated host paths to return as bind-mount sources + +if [ -n "$DOCKER_LOG" ]; then + echo "docker $*" >> "$DOCKER_LOG" +fi + +subcmd="$1" +sub2="$2" + +case "$subcmd" in + volume) + case "$sub2" in + ls) + # Output one volume name per line + for v in ${STUB_VOLUMES:-vol1 vol2 my.dotted.vol}; do + echo "$v" + done + ;; + inspect) + # Args: volume inspect -f FORMAT vol1 vol2 ... + # Positions: $1=volume $2=inspect $3=-f $4=format, then names + shift 4 + for v in "$@"; do + printf '"%s""%s/%s"\n' \ + "$v" \ + "${STUB_VOLUME_MOUNT:-/mnt/volumes}" \ + "$v" + done + ;; + create) + # Succeed silently + ;; + esac + ;; + + ps) + # docker ps -q → print fake container IDs + shift + if [ "$1" = "-q" ]; then + ps_id=1 + for c in ${STUB_CONTAINERS:-test-container}; do + printf 'fakeid%d\n' "$ps_id" + ps_id=$((ps_id + 1)) + done + fi + ;; + + inspect) + # Handles two forms: + # docker inspect --format='{{.Name}}' ID ... → container name listing + # docker inspect NAME → full JSON (for bind-mount backup or existence check) + if [[ "$2" == "--format"* ]] || [ "$2" = "-f" ]; then + # Format can be --format=VALUE (one arg) or --format VALUE (two args) + if [[ "$2" == "--format="* ]]; then + fmt="${2#--format=}" + shift 2 # skip: inspect --format=VALUE; remaining are IDs/names + else + fmt="$3" + shift 3 # skip: inspect --format VALUE; remaining are IDs/names + fi + if echo "$fmt" | grep -q 'Mounts\|Source\|bind'; then + # Bind-mount source listing used by backup-bind-mounts.sh + # Return STUB_BIND_SOURCES (one path per line), empty if not set + if [ -n "$STUB_BIND_SOURCES" ]; then + printf '%s\n' "$STUB_BIND_SOURCES" + fi + else + # Container name listing used by backup-images/container-data/bind-mounts + idx=1 + for cid in "$@"; do + containers=( ${STUB_CONTAINERS:-test-container} ) + cname="${containers[$((idx-1))]}" + echo "/$cname" + idx=$((idx + 1)) + done + fi + elif [ -n "$STUB_INSPECT_FILE" ] && [ -f "$STUB_INSPECT_FILE" ]; then + # Return canned inspect JSON (used by backup-bind-mounts and restore-containers) + cat "$STUB_INSPECT_FILE" + elif [ "${STUB_CONTAINER_EXISTS:-false}" = "true" ]; then + echo "[]" + else + echo "Error: No such container: ${*: -1}" >&2 + exit 1 + fi + ;; + + run) + # Succeed for all docker run invocations (busybox tar, container recreation, etc.) + # For backup-bind-mounts, the script uses the host tar directly, not docker run. + ;; + + load) + # docker load -i FILE + echo "Loaded image: stub-image:latest" + ;; + + save) + # docker save -o FILE IMAGE – create an empty file so callers don't fail + outfile="" + while [ $# -gt 0 ]; do + if [ "$1" = "-o" ]; then + outfile="$2" + shift 2 + else + shift + fi + done + [ -n "$outfile" ] && touch "$outfile" + ;; + + rm) + # docker rm -f NAME – succeed + ;; + + *) + # Unknown subcommand – succeed silently + ;; +esac diff --git a/test/integration/e2e.sh b/test/integration/e2e.sh new file mode 100755 index 0000000..d64f43e --- /dev/null +++ b/test/integration/e2e.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# test/integration/e2e.sh +# +# Runs INSIDE the dind container. Full backup → disaster → restore → verify +# cycle against a real Docker daemon. No test framework dependency — just +# bash assertions. +# +# Two image strategy: +# alpine = the "app" container (will be wiped and restored) +# busybox = volume helper used by the backup script +# Busybox is NOT wiped, so the restore scripts can still use it as a helper. + +set -euo pipefail + +REPO=/repo +BACKUP_PATH=/tmp/dbk +VOL_NAME=dbktest_vol +CTR_NAME=dbktest_ctr +BIND_DIR=/tmp/binddata +PASS=0; FAIL=0 + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +# ── Assertion helpers ────────────────────────────────────────────────────── +assert() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then + echo -e " ${GREEN}✓${NC} $desc" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $desc" + FAIL=$((FAIL + 1)) + fi +} + +assert_eq() { + local desc="$1" got="$2" want="$3" + if [ "$got" = "$want" ]; then + echo -e " ${GREEN}✓${NC} $desc (= '$want')" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $desc (got '$got', want '$want')" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local desc="$1" haystack="$2" needle="$3" + if echo "$haystack" | grep -qF "$needle"; then + echo -e " ${GREEN}✓${NC} $desc (contains '$needle')" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} $desc (expected '$needle' in '$haystack')" + FAIL=$((FAIL + 1)) + fi +} + +section() { echo -e "\n${YELLOW}▶ $*${NC}"; } + +# ── 0. Pull base images ──────────────────────────────────────────────────── +section "Pulling images" +docker pull alpine:latest >/dev/null +docker pull busybox:latest >/dev/null +echo " Images pulled." + +# ── 1. Create fixtures ───────────────────────────────────────────────────── +section "Creating test fixtures" + +# Named volume with data +docker volume create "$VOL_NAME" >/dev/null +docker run --rm \ + -v "${VOL_NAME}:/data" \ + busybox sh -c "echo 'hello-volume' > /data/file.txt" + +# Bind-mount host path +mkdir -p "$BIND_DIR" +echo "hello-bind" > "$BIND_DIR/bind.txt" + +# Container using both +docker run -d \ + --name "$CTR_NAME" \ + -e MYVAR=myvalue \ + -p 18080:80 \ + -v "${VOL_NAME}:/data" \ + -v "${BIND_DIR}:/mnt/bind" \ + --restart unless-stopped \ + alpine sleep 3600 >/dev/null + +echo " Volume, bind dir, and container created." + +# ── 2. Backup ────────────────────────────────────────────────────────────── +section "Running backup" +cd "$REPO" +./backup-manager.sh -p "$BACKUP_PATH" -m backup -B -s + +section "Verifying backup artifacts" +assert "volume tar exists" test -f "$BACKUP_PATH/volumes/${VOL_NAME}.tar.gz" +assert "image tar exists" test -f "$BACKUP_PATH/${CTR_NAME}/${CTR_NAME}-image.tar" +assert "inspect data exists" test -f "$BACKUP_PATH/${CTR_NAME}/${CTR_NAME}-data.txt" +assert "bind manifest exists" test -f "$BACKUP_PATH/${CTR_NAME}/binds/manifest.json" +assert "inspect data is valid JSON" jq '.' "$BACKUP_PATH/${CTR_NAME}/${CTR_NAME}-data.txt" + +# ── 3. Disaster ──────────────────────────────────────────────────────────── +section "Simulating disaster (wipe everything)" +docker rm -f "$CTR_NAME" >/dev/null +docker volume rm "$VOL_NAME" >/dev/null +rm -rf "$BIND_DIR" +docker rmi alpine:latest >/dev/null # proves image restore works + +echo " Container, volume, bind dir, and alpine image removed." + +# ── 4. Restore ──────────────────────────────────────────────────────────── +section "Running restore" +./backup-manager.sh -p "$BACKUP_PATH" -m restore -B -s -f + +# ── 5. Verify ───────────────────────────────────────────────────────────── +section "Verifying restore results" + +# Image +assert "alpine image restored" docker image inspect alpine:latest + +# Volume data +vol_data=$(docker run --rm -v "${VOL_NAME}:/data" busybox cat /data/file.txt 2>/dev/null || echo "MISSING") +assert_eq "volume data restored" "$vol_data" "hello-volume" + +# Bind-mount host path +bind_data=$(cat "$BIND_DIR/bind.txt" 2>/dev/null || echo "MISSING") +assert_eq "bind-mount data restored" "$bind_data" "hello-bind" + +# Container running +running=$(docker inspect -f '{{.State.Running}}' "$CTR_NAME" 2>/dev/null || echo "false") +assert_eq "container is running" "$running" "true" + +# Env +env_vars=$(docker inspect -f '{{json .Config.Env}}' "$CTR_NAME" 2>/dev/null || echo "[]") +assert_contains "env var MYVAR=myvalue preserved" "$env_vars" "MYVAR=myvalue" + +# Restart policy +restart=$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CTR_NAME" 2>/dev/null || echo "none") +assert_eq "restart policy preserved" "$restart" "unless-stopped" + +# Port mapping +ports=$(docker inspect -f '{{json .HostConfig.PortBindings}}' "$CTR_NAME" 2>/dev/null || echo "{}") +assert_contains "port 18080 mapping preserved" "$ports" "18080" + +# Named volume still mounted +mounts=$(docker inspect -f '{{json .Mounts}}' "$CTR_NAME" 2>/dev/null || echo "[]") +assert_contains "named volume still mounted" "$mounts" "$VOL_NAME" + +# ── Summary ─────────────────────────────────────────────────────────────── +echo "" +echo "────────────────────────────────────────" +TOTAL=$((PASS + FAIL)) +if [ "$FAIL" -eq 0 ]; then + echo -e "${GREEN}All ${TOTAL} assertions passed.${NC}" +else + echo -e "${RED}${FAIL}/${TOTAL} assertions FAILED.${NC}" + exit 1 +fi diff --git a/test/integration/run.sh b/test/integration/run.sh new file mode 100755 index 0000000..26e4151 --- /dev/null +++ b/test/integration/run.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# test/integration/run.sh +# +# Host orchestrator for the dind integration test. +# Spins up an isolated Docker-in-Docker daemon, mounts the repo into it, +# runs the full backup → wipe → restore → verify cycle, then tears down. +# +# Usage: +# bash test/integration/run.sh +# +# Requirements: docker must be running on the host (any version ≥ 20). +# Privileged containers must be allowed (needed for dind). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIND_NAME="dbk-dind-$$" +DIND_IMAGE="docker:28-dind" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +log() { echo -e "${GREEN}[integration]${NC} $*"; } +warn() { echo -e "${YELLOW}[integration]${NC} $*"; } +die() { echo -e "${RED}[integration] ERROR:${NC} $*" >&2; exit 1; } + +# ── Cleanup ──────────────────────────────────────────────────────────────── +cleanup() { + local exit_code=$? + if docker inspect "$DIND_NAME" >/dev/null 2>&1; then + warn "Removing dind container $DIND_NAME ..." + docker rm -f "$DIND_NAME" >/dev/null 2>&1 || true + fi + if [ $exit_code -ne 0 ]; then + die "Integration tests FAILED (exit $exit_code)" + fi +} +trap cleanup EXIT + +# ── Pre-flight ───────────────────────────────────────────────────────────── +docker info >/dev/null 2>&1 || die "Docker is not running on the host." + +# ── Start dind ──────────────────────────────────────────────────────────── +log "Starting isolated Docker-in-Docker daemon ($DIND_NAME) ..." +docker run -d \ + --privileged \ + --name "$DIND_NAME" \ + -e DOCKER_TLS_CERTDIR="" \ + -v "$REPO_ROOT":/repo:ro \ + "$DIND_IMAGE" >/dev/null + +# ── Wait for inner daemon ────────────────────────────────────────────────── +log "Waiting for inner daemon to be ready ..." +TIMEOUT=60 +ELAPSED=0 +until docker exec "$DIND_NAME" docker info >/dev/null 2>&1; do + sleep 2 + ELAPSED=$((ELAPSED + 2)) + if [ "$ELAPSED" -ge "$TIMEOUT" ]; then + echo "--- dind logs ---" + docker logs "$DIND_NAME" + die "Inner daemon did not become ready after ${TIMEOUT}s." + fi +done +log "Inner daemon ready." + +# ── Install test dependencies ────────────────────────────────────────────── +log "Installing test dependencies inside dind ..." +docker exec "$DIND_NAME" \ + apk add --no-cache bash jq tar coreutils >/dev/null 2>&1 + +# ── Run assertions ──────────────────────────────────────────────────────── +log "Running e2e assertions inside dind ..." +docker exec "$DIND_NAME" bash /repo/test/integration/e2e.sh + +log "All integration tests PASSED." diff --git a/test/restore-all.bats b/test/restore-all.bats new file mode 100644 index 0000000..34a0cfe --- /dev/null +++ b/test/restore-all.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +# Tests for restore/restore-all.sh +# +# Guards against issue #18 regressing: all three restore steps (images, +# volumes, containers) must be triggered in non-interactive mode. + +setup() { + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + mkdir -p "$backup_path/volumes" + mkdir -p "$backup_path/test-container" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" + + export non_interactive=true + export force=false + + # Create stub backup artifacts so each restore step finds something to do + touch "$backup_path/volumes/myvol.tar.gz" + touch "$backup_path/test-container/test-container-image.tar" + cp test/fixtures/container_inspect.json \ + "$backup_path/test-container/test-container-data.txt" +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "non-interactive restore loads images (step 1)" { + source restore/restore-all.sh + + run grep 'docker load' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "non-interactive restore restores volumes (step 2)" { + source restore/restore-all.sh + + # The volume restore calls busybox tar via docker run + run grep 'myvol' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "non-interactive restore recreates containers (step 3, fixes issue #18)" { + source restore/restore-all.sh + + # Container recreation emits a docker run -d call + run grep 'docker run -d' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "restore fails if backup path does not exist" { + run bash -c " + export backup_path='/nonexistent/path' + export non_interactive=true + export force=false + export PATH='$BATS_TEST_DIRNAME/helpers:\$PATH' + cd '$BATS_TEST_DIRNAME/..' + source restore/restore-all.sh + " + [ "$status" -ne 0 ] + [[ "$output" == *"Error"* ]] +} diff --git a/test/restore-bind-mounts.bats b/test/restore-bind-mounts.bats new file mode 100644 index 0000000..7c9979e --- /dev/null +++ b/test/restore-bind-mounts.bats @@ -0,0 +1,120 @@ +#!/usr/bin/env bats +# Tests for restore/restore-bind-mounts.sh + +setup() { + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + mkdir -p "$backup_path/test-container/binds" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" + export force=false + + # Define restore destinations inside TEST_TMPDIR so tar -C / can write there + # (TEST_TMPDIR is writable; paths like /var/folders/.../config are real on macOS + # and /tmp/.../config are real on Linux). + CONFIG_HOST_PATH="$TEST_TMPDIR/host/config" + DATA_HOST_PATH="$TEST_TMPDIR/host/data" + + # Create source content that "was" at the host paths before backup + mkdir -p "$CONFIG_HOST_PATH" "$DATA_HOST_PATH" + echo "config-value" > "$CONFIG_HOST_PATH/app.conf" + echo "data-value" > "$DATA_HOST_PATH/data.txt" + + # Create archives exactly as backup-bind-mounts.sh would: + # tar -P preserves absolute paths so restore can put files back in place + tar -P -czf "$backup_path/test-container/binds/bind-0.tar.gz" "$CONFIG_HOST_PATH" + tar -P -czf "$backup_path/test-container/binds/bind-1.tar.gz" "$DATA_HOST_PATH" + + # Write manifest pointing to the same host paths + cat > "$backup_path/test-container/binds/manifest.json" < "$CONFIG_HOST_PATH/app.conf" + + force=false + run bash -c " + cd '$BATS_TEST_DIRNAME/..' + export backup_path='$backup_path' + export force=false + source restore/restore-bind-mounts.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED"* ]] + # Original content must be untouched + [ "$(cat "$CONFIG_HOST_PATH/app.conf")" = "original" ] +} + +@test "overwrites existing path with -f flag" { + mkdir -p "$CONFIG_HOST_PATH" + echo "original" > "$CONFIG_HOST_PATH/app.conf" + + force=true + source restore/restore-bind-mounts.sh + + run cat "$CONFIG_HOST_PATH/app.conf" + [ "$output" = "config-value" ] +} + +@test "handles missing archive file gracefully" { + rm "$backup_path/test-container/binds/bind-0.tar.gz" + + run bash -c " + cd '$BATS_TEST_DIRNAME/..' + export backup_path='$backup_path' + export force=false + source restore/restore-bind-mounts.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED"* ]] +} + +@test "handles backup path with no bind mount data gracefully" { + rm -rf "$backup_path/test-container/binds" + + run bash -c " + cd '$BATS_TEST_DIRNAME/..' + export backup_path='$backup_path' + export force=false + source restore/restore-bind-mounts.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"No bind mount backups found"* ]] +} diff --git a/test/restore-containers.bats b/test/restore-containers.bats new file mode 100644 index 0000000..4a26bd6 --- /dev/null +++ b/test/restore-containers.bats @@ -0,0 +1,118 @@ +#!/usr/bin/env bats +# Tests for restore/restore-containers.sh +# +# Validates that build_run_cmd correctly reconstructs a docker run command +# from a saved docker inspect JSON file. + +setup() { + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" + + export non_interactive=true + export force=false +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +# Source the script with an empty backup_path so the main loop returns early +# but build_run_cmd is defined. +_load_functions() { + mkdir -p "$backup_path" + # shellcheck disable=SC1091 + source restore/restore-containers.sh 2>/dev/null || true +} + +@test "build_run_cmd includes --name flag" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *'--name "test-container"'* ]] +} + +@test "build_run_cmd includes --restart flag" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *"--restart always"* ]] +} + +@test "build_run_cmd includes environment variables with -e" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *'-e "FOO=bar"'* ]] + [[ "$result" == *'-e "BAZ=qux"'* ]] +} + +@test "build_run_cmd includes port mappings with -p" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + # Port 80/tcp → host 8080 + [[ "$result" == *"-p 8080:80"* ]] + # Port 443/tcp → host 8443 with explicit HostIp + [[ "$result" == *"-p 0.0.0.0:8443:443"* ]] +} + +@test "build_run_cmd includes bind mounts with -v" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *'-v "/host/data:/container/data:ro"'* ]] +} + +@test "build_run_cmd includes named volume mounts with -v" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *"-v myvolume:/data"* ]] +} + +@test "build_run_cmd includes --network flag" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *"--network mynetwork"* ]] +} + +@test "build_run_cmd includes the image" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == *"nginx:latest"* ]] +} + +@test "build_run_cmd starts with 'docker run -d'" { + _load_functions + result=$(build_run_cmd "test/fixtures/container_inspect.json") + [[ "$result" == "docker run -d"* ]] +} + +@test "full restore creates container via docker run -d" { + # Set up a backup dir with one container's data file + mkdir -p "$backup_path/test-container" + cp test/fixtures/container_inspect.json \ + "$backup_path/test-container/test-container-data.txt" + + source restore/restore-containers.sh + + run grep 'docker run -d' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "skips existing container without -f flag" { + # Set up backup dir in the outer test scope (setup() makes a fresh tmpdir) + mkdir -p "$backup_path/test-container" + cp test/fixtures/container_inspect.json \ + "$backup_path/test-container/test-container-data.txt" + + # Override force to false and simulate an already-running container. + # All other vars (backup_path, PATH, DOCKER_LOG) are already exported by setup(). + force=false + export STUB_CONTAINER_EXISTS=true + + # Capture output by running in a subshell that inherits the full environment + run bash -c "cd '$BATS_TEST_DIRNAME/..' && source restore/restore-containers.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED"* ]] +} diff --git a/test/restore-volumes.bats b/test/restore-volumes.bats new file mode 100644 index 0000000..babdb33 --- /dev/null +++ b/test/restore-volumes.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# Tests for restore/restore-volumes.sh +# +# Regression test for the "cut -f1 -d." parsing bug that truncated dotted +# volume names (e.g. "my.vol.1" would become "my"). + +setup() { + cd "$BATS_TEST_DIRNAME/.." + + TEST_TMPDIR=$(mktemp -d) + export TEST_TMPDIR + export backup_path="$TEST_TMPDIR/backup" + mkdir -p "$backup_path/volumes" + + export DOCKER_LOG="$TEST_TMPDIR/docker.log" + export PATH="$BATS_TEST_DIRNAME/helpers:$PATH" + + # Shared variables required by the script + export non_interactive=true + export force=false +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +@test "restores a simple volume by name" { + touch "$backup_path/volumes/myvolume.tar.gz" + + source restore/restore-volumes.sh + + run grep 'myvolume' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "correctly handles dotted volume names (regression for cut -f1 -d. bug)" { + touch "$backup_path/volumes/my.dotted.vol.tar.gz" + + source restore/restore-volumes.sh + + # The volume name passed to docker run must be the full name, not just "my" + run grep 'my\.dotted\.vol' "$DOCKER_LOG" + [ "$status" -eq 0 ] + + # Ensure "my.tar.gz" is NOT referenced (would indicate the old truncation bug) + run grep '"my"' "$DOCKER_LOG" + [ "$status" -ne 0 ] +} + +@test "passes the correct filename to the tar extraction command" { + touch "$backup_path/volumes/my.dotted.vol.tar.gz" + + source restore/restore-volumes.sh + + # The busybox tar call must reference the full filename + run grep 'my\.dotted\.vol\.tar\.gz' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "creates docker volume before restoring" { + touch "$backup_path/volumes/newvol.tar.gz" + + source restore/restore-volumes.sh + + run grep 'volume create newvol' "$DOCKER_LOG" + [ "$status" -eq 0 ] +} + +@test "handles missing volumes directory gracefully" { + rm -rf "$backup_path/volumes" + + run bash -c " + backup_path='$backup_path' + non_interactive=true + force=false + PATH='$BATS_TEST_DIRNAME/helpers:$PATH' + source restore/restore-volumes.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"No volumes backup found"* ]] +} + +@test "handles empty volumes directory gracefully" { + # volumes/ exists but has no .tar.gz files + run bash -c " + backup_path='$backup_path' + non_interactive=true + force=false + PATH='$BATS_TEST_DIRNAME/helpers:$PATH' + source restore/restore-volumes.sh + " + [ "$status" -eq 0 ] + [[ "$output" == *"No volume backups found"* ]] +}