From 814e18cf12ad81e08ab592174e5298c21b80f517 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 27 Sep 2026 19:17:25 +0100 Subject: [PATCH] Clean up --- README.md | 8 ++++++-- src/backup.sh | 33 +++++++++++---------------------- src/borg-common.sh | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7333d5e..cd139ee 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ To adhere to the [3-2-1 backup rule](https://en.wikipedia.org/wiki/Backup) witho The [`docker-compose.yml`](docker-compose.yml) file demonstrates how to set up multiple backup targets using environment variables such as `BORG_REPO_0`, `BORG_REPO_1`, `BORG_PASSPHRASE_0`, `BORG_PASSPHRASE_1`, and so forth. The backup script sequentially handles each repository defined by the environment variables, ensuring your source volume is backed up across all specified targets. +Missing repositories are initialized automatically. An existing local directory, including the `/local-backup` bind mount, is initialized only after confirming that it is empty (including hidden files). Invalid or inaccessible repositories are rejected. + The backup script first takes `BORG_REPO_0` and the corresponding env vars and sets up the [`BORG_REPO`](https://borgbackup.readthedocs.io/en/stable/usage/general.html#repository-urls), `BORG_REMOTE_PATH`, and `BORG_PASSPHRASE` environment variables for `borg`. Once the backup finished (successfully or otherwise), the script checks whether `BORG_REPO_1` exists, if so, it sets `BORG_REPO` and the other env vars to their expected values and backs up again. The script keeps going to `BORG_REPO_2`, `BORG_REPO_3` and so on as long as these are set. It then checks each repository with the corresponding environment before starting the next backup cycle. Thus, the following sets of environment variables are valid for multi-target backups: @@ -88,9 +90,11 @@ Borg resumes partial checks from the last segment checked. These checks inspect Mount a Btrfs subvolume at `/btrfs-root`, and a writable directory on the same Btrfs filesystem at `/snapshot`. The backup assembles snapshots of the root and every nested subvolume, including Btrfs subvolumes mounted below the source. Child snapshots are taken sequentially: each is consistent individually, but there is no single atomic snapshot across subvolumes. Applications spanning subvolumes may need to be paused to obtain a consistent backup. -Failure to snapshot a child fails the backup. A mounted child on a different Btrfs filesystem cannot be snapshotted into the same destination; configure a separate backup container for it. Non-Btrfs mounts are outside snapshot coverage. A mount point hiding nonempty underlying directories is rejected rather than discarding that hidden data. The temporary snapshot itself is omitted, and cleanup deletes child snapshots before their parents. +Failure to snapshot a child fails the backup. A mounted child on a different Btrfs filesystem cannot be snapshotted into the same destination; configure a separate backup container for it. Non-Btrfs mounts and bind mounts of ordinary directories or files are outside snapshot coverage: the snapshot retains the underlying source directory, without the mounted contents. Ordinary Btrfs bind mounts are reported in the backup log. To include that data, back up the subvolume containing it. A mounted subvolume hiding nonempty underlying directories is rejected rather than discarding that hidden data. Recursive subvolume mounts are rejected. The temporary snapshot itself is omitted, and cleanup deletes child snapshots before their parents. -Set `BACKUP_RELATIVE_PATH=/path/within/source` to select a directory within the assembled snapshot. The default exclusions match `.env`, `.dev.env`, `node_modules`, `.venv`, and `__pycache__` at both the backup root and deeper levels. Edit [config/exclude.conf](config/exclude.conf) and rebuild the image to change these exclusions. +Directory access and modification times are preserved when assembling child snapshots and removing scratch placeholders. Discovery and cleanup use batched directory traversal without launching a process per directory. + +Set `BACKUP_RELATIVE_PATH=/path/within/source` to select a directory within the assembled snapshot. It must resolve inside that snapshot; paths that escape through `..` or symlinks fail the backup. The default exclusions match `.env`, `.dev.env`, `node_modules`, `.venv`, and `__pycache__` at both the backup root and deeper levels. These settings apply to Borg after snapshot assembly, so excluded directories are still traversed during snapshot discovery. Edit [config/exclude.conf](config/exclude.conf) and rebuild the image to change these exclusions. ### Healthcheck diff --git a/src/backup.sh b/src/backup.sh index 7799382..21b2120 100755 --- a/src/backup.sh +++ b/src/backup.sh @@ -16,27 +16,7 @@ source /src/borg-common.sh archive_prefix=$(borg_literal_prefix archive "$ARCHIVE_PREFIX") archive_glob=$(borg_literal_prefix glob "$ARCHIVE_PREFIX") -# Only a missing repository (modern exit code 13) triggers initialization. -info_status=0 -borg_wait info || info_status=$? -case "$info_status" in - 0) ;; - 13) - echo "Repository does not exist. Initializing Borg..." - init_status=0 - borg_wait init --encryption=repokey || init_status=$? - case "$init_status" in - 0) ;; - # Another client may have initialized the repository in between. - 10) borg_wait info ;; - *) exit "$init_status" ;; - esac - ;; - *) - echo "Cannot access repository (Borg exit status $info_status). Skipping backup." >&2 - exit "$info_status" - ;; -esac +borg_ensure_repository cleanup() { local status=$? @@ -54,7 +34,16 @@ fi /src/snapshot.sh create /btrfs-root /snapshot/btrfs-root -cd "/snapshot/btrfs-root${BACKUP_RELATIVE_PATH:-}" +case ${BACKUP_RELATIVE_PATH:-} in + ''|/*) ;; + *) echo 'BACKUP_RELATIVE_PATH must start with /.' >&2; exit 1 ;; +esac +IFS= read -r -d '' backup_directory < <(realpath -ez -- "/snapshot/btrfs-root${BACKUP_RELATIVE_PATH:-}") +if [[ $backup_directory != /snapshot/btrfs-root && $backup_directory != /snapshot/btrfs-root/* ]]; then + echo 'BACKUP_RELATIVE_PATH must resolve to a directory within the snapshot.' >&2 + exit 1 +fi +cd -- "$backup_directory" borg_wait create --stats \ --list \ diff --git a/src/borg-common.sh b/src/borg-common.sh index faa7238..f8b6fd6 100644 --- a/src/borg-common.sh +++ b/src/borg-common.sh @@ -2,6 +2,26 @@ export BORG_RSH='ssh -oBatchMode=yes' +borg_ensure_repository() { + local status=0 + borg_wait info || status=$? + (( status != 0 )) || return 0 + if (( status == 13 )) || (( status == 15 )); then + echo 'Repository is missing or confirmed empty. Initializing Borg...' + status=0 + borg_wait init --encryption=repokey || status=$? + # Another client may have initialized it after the initial check. + if (( status == 10 )); then + borg_wait info + else + return "$status" + fi + else + echo "Cannot access repository (Borg exit status $status). Skipping backup." >&2 + return "$status" + fi +} + # Borg expands placeholders in both names and archive filters. Only the filter # needs glob escaping; user-supplied prefixes are literal in both places. borg_literal_prefix() {