#!/usr/bin/env bash # {{ backup_source_description }} backup — managed by Ansible (roles/backup_source) # # Dumps to stdout, encrypts with age, writes {{ backup_source_dir }}. # The host holds only the age PUBLIC key, so it cannot read its own backups. set -euo pipefail umask 077 BACKUP_DIR="{{ backup_source_dir }}" RETENTION_DAYS={{ backup_source_retention_days }} RECIPIENT="{{ backup_source_recipient }}" SUFFIX="{{ backup_source_artifact_suffix }}" NAME="{{ backup_source_name }}" {% if backup_source_stop_service or backup_source_stop_command %} STOP_CMD={{ (backup_source_stop_command or ('systemctl stop ' ~ backup_source_stop_service)) | quote }} START_CMD={{ (backup_source_start_command or ('systemctl start ' ~ backup_source_stop_service)) | quote }} SERVICE="{{ backup_source_stop_service or backup_source_description }}" # label for the log only {% endif %} TIMESTAMP=$(date +%Y%m%d_%H%M%S) ARTIFACT="${BACKUP_DIR}/${NAME}_${TIMESTAMP}.${SUFFIX}" die() { echo "FATAL: $*" >&2; exit 1; } log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*"; } # --- Pre-flight --- [[ -n "$RECIPIENT" ]] || die "no age recipient configured" command -v age >/dev/null || die "age is not installed" # Mode must agree with what the role sets, or each undoes the other every run. mkdir -p "$BACKUP_DIR" {% if backup_source_pull_user %} chown root:{{ backup_source_pull_user }} "$BACKUP_DIR" chmod 750 "$BACKUP_DIR" {% else %} chmod 700 "$BACKUP_DIR" {% endif %} # A run that died mid-dump leaves a .partial. It is not a backup, and the prune # glob below cannot match it (it ends .partial, not .${SUFFIX}), so clear them # here or they accumulate forever. rm -f "${BACKUP_DIR}/${NAME}_"*.partial {% if backup_source_stop_service or backup_source_stop_command %} # --- Stop the service, and guarantee it comes back --- # The trap is the point: without it a failed dump leaves the service down until # the next timer fires. Every hand-written script this replaced had that bug. log "Stopping ${SERVICE}..." eval "$STOP_CMD" trap 'log "Restarting ${SERVICE}..."; eval "$START_CMD" || true' EXIT {% endif %} # --- Dump straight into age; plaintext never touches the disk --- log "Writing ${ARTIFACT}..." {{ backup_source_dump_command }} | age -r "$RECIPIENT" -o "${ARTIFACT}.partial" mv "${ARTIFACT}.partial" "$ARTIFACT" {% if backup_source_pull_user %} # Readable by the pull account and nobody else. The contents are age-encrypted # regardless, so this is depth rather than the actual protection. chown root:{{ backup_source_pull_user }} "$ARTIFACT" chmod 640 "$ARTIFACT" {% else %} chmod 600 "$ARTIFACT" {% endif %} log "Wrote ${ARTIFACT} ($(du -h "$ARTIFACT" | cut -f1))" # --- Prune --- log "Pruning local artefacts older than ${RETENTION_DAYS} days..." find "$BACKUP_DIR" -maxdepth 1 -type f -name "${NAME}_*.${SUFFIX}" -mtime +"${RETENTION_DAYS}" -delete log "Done."