#!/bin/bash # Géré par Set-OPS (rôle client_backup). Ne pas éditer à la main. # Sauvegarde applicative du nœud -> dépôt restic hors-nœud (chiffré, dédupliqué). set -euo pipefail export RESTIC_REPOSITORY="{{ client_backup_repo }}" export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass" # UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le # controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent # partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans # attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun # instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure. restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; } # L'INSTANTANE D'AVANT RASAGE (2026-10-07). `--avant-raser` (unite # `setops-sauvegarde-avant-raser`, lancee par `make sauvegarder-maintenant`) etiquette le # depot « {{ client_backup_etiquette_avant_raser }} », et la retention garde cette etiquette. # Sans elle, le premier depot de la machine reconstruite, le meme jour, chassait l'instantane # d'avant par `--keep-daily` : apres la reconstruction de Technolibre (M4), il ne restait # rien a quoi comparer l'etat remis. Un seul porte l'etiquette, le dernier : il est garde # jusqu'a la reconstruction suivante, qui la lui retire. AVANT_RASER=0 if [[ "${1:-}" == "--avant-raser" ]]; then AVANT_RASER=1; fi ETIQUETTES=(--tag setops) if (( AVANT_RASER )); then ETIQUETTES+=(--tag "{{ client_backup_etiquette_avant_raser }}"); fi # 0. UN ETAT D'AVANT ATTEND-IL ? (2026-09-30) # Sur une machine reconstruite, tant que l'etat de l'incarnation precedente n'a ete ni # remis ni ecarte, deposer serait NUISIBLE : `restic forget --keep-daily` garde un # instantane par jour, et celui de l'etat neuf chasserait celui d'avant, s'ils tombent le # meme jour. On refuse donc, bruyamment (code 3). Voir `setops-restaurer garde`. /usr/local/sbin/setops-restaurer garde # 1. Dumps applicatifs (pg_dump, slapcat, forgejo dump...) vers le staging. {% for job in client_backup_jobs %} {% if job.commande is defined %} install -d -m 0700 "{{ client_backup_staging }}/{{ job.nom }}" {{ job.commande }} {% endif %} {% endfor %} # 2. Initialiser le dépôt s'il n'existe pas encore. restic snapshots >/dev/null 2>&1 || restic init # 3. Snapshot de tous les chemins déclarés. CHEMINS=( {% for job in client_backup_jobs %} {% for c in job.chemins %} "{{ c }}" {% endfor %} {% endfor %} ) # Un chemin DECLARE peut ne pas exister encore : `/srv/web` avant la premiere webapp, # le stockage de Nextcloud avant son installation. Restic refuse alors TOUT le lot # (« all source directories/files do not exist ») et la sauvegarde entiere echoue — # constate le 2026-08-13 sur trois hotes d'une flotte neuve. # On ne sauvegarde donc que ce qui existe, et on le DIT quand il n'y a rien : c'est a la # supervision de signaler un depot vide, pas au script de mourir. # NOTE : ne JAMAIS ecrire la longueur d'un tableau bash dans ce gabarit — la sequence # accolade-diese y ouvre un commentaire Jinja et casse le rendu. On compte a la main. # (Ce commentaire a lui-meme casse le gabarit une fois, en citant la sequence.) PRESENTS=() N_PRESENTS=0 for c in "${CHEMINS[@]}"; do if [[ -e "$c" ]]; then PRESENTS+=("$c") N_PRESENTS=$((N_PRESENTS + 1)) else echo "absent, ignore : $c" >&2 fi done if (( N_PRESENTS == 0 )); then echo "aucun chemin declare n'existe encore sur cet hote — rien a sauvegarder." >&2 exit 0 fi restic backup --host "{{ inventory_hostname }}" "${ETIQUETTES[@]}" "${PRESENTS[@]}" # 3 bis. L'etiquette passe au dernier depot d'avant rasage : les precedents rentrent dans # la retention ordinaire. APRES le depot, jamais avant : s'il echoue, l'ancien reste garde. if (( AVANT_RASER )); then ANCIENS=$(restic snapshots --host "{{ inventory_hostname }}" --tag "{{ client_backup_etiquette_avant_raser }}" --json \ | python3 -c 'import json, sys import datetime, re def t(x): s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"]) return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp() s = sorted(json.load(sys.stdin) or [], key=t) print(" ".join(x["id"] for x in s[:-1]))') if [[ -n "${ANCIENS}" ]]; then read -r -a ANCIENS_IDS <<< "${ANCIENS}" restic tag --remove "{{ client_backup_etiquette_avant_raser }}" "${ANCIENS_IDS[@]}" fi fi # 4. Rétention. restic forget {{ client_backup_retention }} --keep-tag "{{ client_backup_etiquette_avant_raser }}" --prune