personal_infra/ansible/roles/backup_source/templates/backup.sh.j2
2026-09-12 16:02:00 +02:00

69 lines
2.5 KiB
Django/Jinja

#!/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 %}
SERVICE="{{ backup_source_stop_service }}"
{% 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 %}
# --- 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}..."
systemctl stop "$SERVICE"
trap 'log "Restarting ${SERVICE}..."; systemctl start "${SERVICE}" || 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."