aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJan Tuomi <jan@jantuomi.fi>2026-03-03 17:27:35 +0200
committerJan Tuomi <jan@jantuomi.fi>2026-03-03 17:27:35 +0200
commit94f4fecc1ee69fadbae619683f62a41d97f93c1a (patch)
tree40eb00b143cbe60ae7f5dea91c3a3b09902cfc81
parentfda5165a100996d0184038541bae5e591e94660b (diff)
Exclude zfs datasets from backup based on com.pursotin:backup=false prop
-rw-r--r--templates/usr_local_bin_backup.sh.j273
1 files changed, 68 insertions, 5 deletions
diff --git a/templates/usr_local_bin_backup.sh.j2 b/templates/usr_local_bin_backup.sh.j2
index a79cdb7..f6a0e2c 100644
--- a/templates/usr_local_bin_backup.sh.j2
+++ b/templates/usr_local_bin_backup.sh.j2
@@ -7,6 +7,7 @@ KEEP_REMOTE=10
KEYFILE="/root/.ssh/backup"
HOST_DIR="backup"
DATASET="{% endraw %}{{ backup_zfs_dataset }}{% raw %}"
+BACKUP_EXCLUDE_PROP="{% endraw %}{{ backup_zfs_exclude_property | default('com.pursotin:backup') }}{% raw %}"
USER="{% endraw %}{{ backup_ssh_user }}{% raw %}"
HOST="{% endraw %}{{ backup_ssh_host }}{% raw %}"
EMAIL_TO=root
@@ -39,6 +40,29 @@ timestamp() {
date +%Y-%m-%d-%H-%M-%S%z | tr '+' '-'
}
+humanize_bytes() {
+ local bytes="$1"
+ if [[ "${bytes}" =~ ^[0-9]+$ ]]; then
+ awk -v b="${bytes}" '
+ BEGIN {
+ unit_count = split("B KiB MiB GiB TiB PiB EiB", units, " ")
+ i = 1
+ while (b >= 1024 && i < unit_count) {
+ b = b / 1024
+ i++
+ }
+ if (i == 1) {
+ printf "%.0f %s\n", b, units[i]
+ } else {
+ printf "%.2f %s\n", b, units[i]
+ }
+ }
+ '
+ else
+ echo "unknown"
+ fi
+}
+
latest_snapshot_for_dataset() {
# Latest snapshot on the TOP dataset only (newest first). Returns e.g. zroot@2025-09-30-...
zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \
@@ -51,6 +75,12 @@ list_top_snapshots_newest_first() {
| awk 'NR>1{print $1}'
}
+list_excluded_datasets() {
+ # List datasets (including inherited values) where backup property is explicitly false.
+ zfs get -r -H -o name,value "${BACKUP_EXCLUDE_PROP}" "${DATASET}" 2>/dev/null \
+ | awk 'tolower($2)=="false"{print $1}'
+}
+
list_remote_backups_sorted() {
# We store files under ${HOST_DIR}/<dataset>@<timestamp>.enc
echo "ls ${HOST_DIR}" \
@@ -116,9 +146,10 @@ cmd_snapshot() {
cmd_send_to_remote() {
echo ""
echo "[send-to-remote]"
- require_cmds zfs age sftp mktemp stat
+ require_cmds zfs age sftp mktemp stat awk sort
- local snap base target tmp size_bytes
+ local snap base target tmp size_bytes ds parent skip
+ local -a send_args excluded_raw excluded
snap="$(latest_snapshot_for_dataset)"
[[ -n "${snap}" ]] || die "No snapshot found to send. Run 'snapshot' first or ensure the dataset has snapshots."
@@ -127,9 +158,36 @@ cmd_send_to_remote() {
tmp="$(mktemp -t backup_send.XXXXXX)"
trap 'rm -f "${tmp}"' EXIT
+ send_args=(-Rvc)
+ mapfile -t excluded_raw < <(list_excluded_datasets | sort)
+ excluded=()
+ for ds in "${excluded_raw[@]}"; do
+ skip=0
+ [[ -n "${ds}" ]] || continue
+ for parent in "${excluded[@]}"; do
+ if [[ "${ds}" == "${parent}" || "${ds}" == "${parent}/"* ]]; then
+ skip=1
+ break
+ fi
+ done
+ [[ "${skip}" -eq 1 ]] || excluded+=("${ds}")
+ done
+
+ if [[ "${#excluded[@]}" -gt 0 ]]; then
+ echo "Excluding datasets where ${BACKUP_EXCLUDE_PROP}=false:"
+ for ds in "${excluded[@]}"; do
+ if [[ "${ds}" == "${DATASET}" ]]; then
+ die "Top dataset ${DATASET} is excluded via ${BACKUP_EXCLUDE_PROP}=false; refusing to create an empty backup stream."
+ fi
+ echo " - ${ds}"
+ send_args+=(-X "${ds}")
+ done
+ log_note "send-to-remote: excluded ${#excluded[@]} dataset(s) using ${BACKUP_EXCLUDE_PROP}=false"
+ fi
+
echo "Creating encrypted replication stream to temp file: ${tmp}"
# -R: recursive hierarchy, -v: progress to stderr, -c: send compressed (keeps on-disk compression)
- (set -x; zfs send -Rvc "${snap}" | age -e -i "${KEYFILE}" > "${tmp}")
+ (set -x; zfs send "${send_args[@]}" "${snap}" | age -e -i "${KEYFILE}" > "${tmp}")
size_bytes="$(stat -f %z "${tmp}" 2>/dev/null || stat -c %s "${tmp}" 2>/dev/null || echo "unknown")"
echo "Local stream size: ${size_bytes} bytes"
@@ -215,7 +273,8 @@ cmd_prune_local() {
cmd_notify() {
echo ""
echo "[notify]"
- require_cmds mail date
+ require_cmds mail date awk
+ local backup_size_human
FINISHED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')"
# Derive snapshot name if not set yet (best-effort)
@@ -229,6 +288,8 @@ cmd_notify() {
fi
fi
+ backup_size_human="$(humanize_bytes "${BACKUP_SIZE_BYTES:-unknown}")"
+
# Compose subject and body
local subject="Backup completed: ${BACKUP_NAME}"
local body=""
@@ -238,7 +299,7 @@ cmd_notify() {
body+="Dataset: ${DATASET}"$'\n'
body+="Remote: ${USER}@${HOST}:${HOST_DIR}"$'\n'
body+="Name: ${BACKUP_NAME}"$'\n'
- body+="Size: ${BACKUP_SIZE_BYTES:-unknown} bytes"$'\n'
+ body+="Size: ${backup_size_human} (${BACKUP_SIZE_BYTES:-unknown} bytes)"$'\n'
body+=$'\n'
body+="Steps:"$'\n'
body+="${MESSAGE_LOG:-<no steps recorded>}"$'\n'
@@ -263,6 +324,8 @@ Subcommands (executed in order):
Notes:
- Run 'init-remote' once before the first upload, or anytime after changing \$HOST_DIR.
- Local pruning destroys older snapshots **recursively** to maintain consistency across descendants.
+ - Set \${BACKUP_EXCLUDE_PROP}=false on a dataset to exclude it (and inherited descendants) from send-to-remote.
+ Example: zfs set com.pursotin:backup=false zroot/some/heavy-dataset
Examples:
$(basename "$0") init-remote snapshot send-to-remote prune-remote prune-local notify