Files
computer-playbook/roles/svc-bkp-nfs-2-local/files/shell/script.sh
kevinveenbirkenbachandClaude Opus 5.5 21eba1e88f fix(backup): carry hard links, ACLs and xattrs on every backup and recover hop
baudolo 7.1.x writes volume generations with `rsync -aHAX`, so the
producing host keeps `user.*` extended attributes (OpenCloud keeps its
storage metadata in `user.oc.*`). Every later copy dropped them again,
because none of the other rsync calls passed `-X` (nor `-A`/`-H`):

- NFS export snapshots (svc-bkp-nfs-2-local `script.sh`),
- the backup host's pull (svc-bkp-remote-2-local `pull_specific_host.py`),
- the device sync (svc-bkp-local-2-device `script.py`),
- every recover into a live target (`utils/recovery/base.py`, used by the
  volume, NFS and device recover scripts),
- the `recover full` and `recover remote` tree pulls.

A disaster recovery through the documented chain therefore restored
OpenCloud without `user.oc.*`, silently: the copies succeeded and the
dirval stamp does not cover attributes.

All of these now run `rsync -aHAX` (`-aHAXP` for the device sync). A
target filesystem without xattr support makes rsync exit non-zero as soon
as a source file carries attributes, so the hop fails loudly instead of
dropping them; there is no per-device opt-out. The single-image pull in
`recover remote` keeps `-a`. The provider's ssh-wrapper needs no change:
its glob accepts any server flags and its character guard allows them.

Two tests write a `user.oc.id` attribute and assert it survives the NFS
snapshot and the recover; with the old `-a` both fail with
`OSError: [Errno 61] No data available`.

Closes #1021

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 18:20:46 +02:00

66 lines
2.3 KiB
Bash

#!/bin/bash
# Stores a hard-linked differential snapshot of the NFS export in the
# local backup directory.
#
# Arguments:
# $1 SOURCE_DIR NFS export base to back up
# $2 BACKUPS_DIR local backup root
# $3 REPO_NAME repository directory name inside the machine hash dir
# $4 EXCLUDE_REL optional path relative to SOURCE_DIR to exclude
# (the shared backup root inside the export; without it
# every generation snapshots all previous backups)
#
# Environment:
# BKP_NFS_2_LOCAL_GENERATION optional generation name override
# (defaults to the current timestamp)
set -euo pipefail
SOURCE_DIR="${1:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}"
BACKUPS_DIR="${2:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}"
REPO_NAME="${3:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}"
EXCLUDE_REL="${4:-}"
if [[ ! -d "${SOURCE_DIR}" ]]; then
echo "ERROR: ${SOURCE_DIR} missing; this host is expected to serve the NFS export" >&2
exit 1
fi
MACHINE_HASH="$(sha256sum /etc/machine-id | cut -c1-64)"
REPO_DIR="${BACKUPS_DIR%/}/${MACHINE_HASH}/${REPO_NAME}"
GENERATION="${BKP_NFS_2_LOCAL_GENERATION:-$(date +%Y%m%d%H%M%S)}"
DEST_DIR="${REPO_DIR}/${GENERATION}/files"
PREVIOUS_FILES=""
if [[ -d "${REPO_DIR}" ]]; then
PREVIOUS_GENERATION="$(find "${REPO_DIR}" -mindepth 1 -maxdepth 1 -type d | sort | tail -n1)"
if [[ -n "${PREVIOUS_GENERATION}" && -d "${PREVIOUS_GENERATION}/files" ]]; then
PREVIOUS_FILES="${PREVIOUS_GENERATION}/files/"
fi
fi
cleanup_failed_generation() {
rm -rf "${REPO_DIR:?}/${GENERATION:?}"
}
trap cleanup_failed_generation ERR
mkdir -p "${DEST_DIR}"
rsync_args=(-aHAX --numeric-ids --delete)
if [[ -n "${EXCLUDE_REL}" ]]; then
rsync_args+=(--exclude "/${EXCLUDE_REL#/}")
fi
if [[ -n "${PREVIOUS_FILES}" ]]; then
rsync_args+=(--link-dest "${PREVIOUS_FILES}")
fi
rc=0
rsync "${rsync_args[@]}" "${SOURCE_DIR%/}/" "${DEST_DIR}/" || rc=$?
if [[ "${rc}" -eq 24 ]]; then
echo "WARN: rsync exit 24 (files vanished from the live export mid-transfer), snapshot kept"
elif [[ "${rc}" -ne 0 ]]; then
cleanup_failed_generation
echo "ERROR: rsync failed with exit ${rc}, incomplete generation removed" >&2
exit "${rc}"
fi
echo "OK: differential backup of ${SOURCE_DIR} stored in ${DEST_DIR}"