postgres ne lit pas le repertoire jetable de root ; trouve en repetition sur data-sql-01 de Technolibre. Le test l'exige desormais. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
13 KiB
Django/Jinja
317 lines
13 KiB
Django/Jinja
#!/bin/bash
|
|
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
|
|
#
|
|
# setops-restaurer — REMETTRE EN SERVICE l'etat d'une incarnation precedente de CE noeud.
|
|
#
|
|
# POURQUOI IL EXISTE (2026-09-30). Technolibre a ete rasee et reconstruite par ses
|
|
# runners : 0 echec, `make valider` vert, « restauration verifiee ». Et le mot de passe de
|
|
# `sysadmin` etait revenu a celui de l'amorcage. RIEN n'etait revenu : ni l'annuaire, ni
|
|
# la racine de l'AC, ni les bases, ni les fichiers de Nextcloud. « Restauration verifiee »
|
|
# voulait dire : restauree dans un repertoire temporaire, lue, jetee. Chaque
|
|
# reconstruction de Chezlepro etait repartie a vide de la meme facon.
|
|
#
|
|
# QUELLE INCARNATION. La cle d'hote SSH nait au clonage (`machine-id`, lui, vient du
|
|
# gabarit : tous les noeuds portent la meme date). L'instantane a remettre est donc le
|
|
# DERNIER PRIS AVANT LA NAISSANCE de cette cle — celui de la machine qu'on a rasee.
|
|
# `--instantane ID` impose un autre choix.
|
|
#
|
|
# LE MARQUEUR. Un jeu restaure, abandonne, ou trouve deja en place laisse une trace dans
|
|
# {{ client_backup_restauration_marques }}/<jeu>. La sauvegarde s'y fie (`garde`) : tant
|
|
# qu'un etat anterieur n'a ete ni remis ni ecarte, ce noeud REFUSE de deposer — sans quoi
|
|
# `restic forget --keep-daily` chasserait l'instantane d'avant au profit de l'etat neuf du
|
|
# meme jour.
|
|
#
|
|
# RIEN N'EST ECRASE SANS ETRE MIS DE COTE, dans {{ client_backup_restauration_mise_de_cote }}.
|
|
#
|
|
# Sous-commandes :
|
|
# etat ce qui serait restaure, et les marqueurs
|
|
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
|
|
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
|
|
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
|
|
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
|
|
# --essai (slapadd -u a blanc, rien n'est touche)
|
|
# base [opts] NOM rejouer une base depuis le pg_dumpall
|
|
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
|
|
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
|
|
# garde (pour la sauvegarde) refuser si un etat attend
|
|
# Options communes : --instantane ID.
|
|
set -euo pipefail
|
|
|
|
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
|
|
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
|
|
MOI="{{ inventory_hostname }}"
|
|
MARQUES="{{ client_backup_restauration_marques }}"
|
|
MISE_DE_COTE="{{ client_backup_restauration_mise_de_cote }}"
|
|
CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
|
|
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
|
|
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
|
|
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
|
|
|
|
dire() { printf '%s\n' "$*" >&2; }
|
|
mourir() { dire "setops-restaurer: $*"; exit 1; }
|
|
|
|
naissance() {
|
|
local b
|
|
b=$(stat -c %W "${CLE_HOTE}")
|
|
if [[ "${b}" -le 0 ]]; then b=$(stat -c %Y "${CLE_HOTE}"); fi
|
|
printf '%s\n' "${b}"
|
|
}
|
|
|
|
# Imprime « ID HEURE » du candidat, ou rien. Code 2 : depot illisible — et c'est une
|
|
# ERREUR, pas une absence : conclure « rien a restaurer » parce que le dépôt ne repond pas
|
|
# ferait marquer « neuf » une machine dont l'etat attend chez l'hebergeur.
|
|
candidat() {
|
|
local impose="${1:-}" json rc
|
|
set +e
|
|
json=$(restic snapshots --host "${MOI}" --json 2>/dev/null)
|
|
rc=$?
|
|
set -e
|
|
# restic 0.17+ : 10 = le depot n'existe pas. Premiere vie de l'ecosysteme.
|
|
if (( rc == 10 )); then return 0; fi
|
|
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
|
|
printf '%s' "${json}" | python3 -c '
|
|
import datetime, json, re, sys
|
|
naissance, impose = int(sys.argv[1]), sys.argv[2]
|
|
def t(s):
|
|
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
|
|
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
|
|
snaps = json.load(sys.stdin) or []
|
|
if impose:
|
|
c = [x for x in snaps if impose in (x["short_id"], x["id"])]
|
|
if not c:
|
|
sys.exit("instantane impose introuvable pour cet hote : " + impose)
|
|
else:
|
|
c = [x for x in snaps if t(x["time"]) < naissance]
|
|
c.sort(key=lambda x: t(x["time"]))
|
|
if c:
|
|
print(c[-1]["short_id"], c[-1]["time"][:19])
|
|
' "$(naissance)" "${impose}"
|
|
}
|
|
|
|
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
|
|
INSTANTANE=""
|
|
resoudre_instantane() {
|
|
local c
|
|
c=$(candidat "${INSTANTANE}") || exit 2
|
|
INSTANTANE="${c%% *}"
|
|
}
|
|
|
|
# Le repertoire jetable ou l'instantane est extrait. Detruit a la sortie, quoi qu'il arrive.
|
|
TMP=""
|
|
trap 'if [[ -n "${TMP}" ]]; then rm -rf "${TMP}"; fi' EXIT
|
|
|
|
extraire() { # remplit TMP avec les chemins demandes de l'instantane
|
|
local inc=()
|
|
TMP=$(mktemp -d /var/tmp/setops-restauration.XXXXXX)
|
|
for c in "$@"; do inc+=(--include "${c}"); done
|
|
restic restore "${INSTANTANE}" --target "${TMP}" "${inc[@]}" >/dev/null \
|
|
|| mourir "restic restore ${INSTANTANE} a echoue"
|
|
}
|
|
|
|
MIS_DE_COTE=""
|
|
mettre_de_cote() { # $1 = chemin vivant ; MIS_DE_COTE = ou il est parti
|
|
MIS_DE_COTE="${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)$1"
|
|
mkdir -p "$(dirname "${MIS_DE_COTE}")"
|
|
cp -a "$1" "${MIS_DE_COTE}"
|
|
dire "etat en place mis de cote : ${MIS_DE_COTE}"
|
|
}
|
|
|
|
vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
|
|
[[ ! -e "$1" ]] || { [[ -d "$1" ]] && [[ -z "$(find "$1" -mindepth 1 -print -quit)" ]]; }
|
|
}
|
|
|
|
cmd_fichiers() {
|
|
local proprio="" remplacer=0 vers="" chemins=() src dest n
|
|
while (( $# )); do
|
|
case "$1" in
|
|
--proprietaire) proprio="$2"; shift 2 ;;
|
|
--remplacer) remplacer=1; shift ;;
|
|
--vers) vers="$2"; shift 2 ;;
|
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
|
*) chemins+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
|
|
resoudre_instantane
|
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
|
extraire "${chemins[@]}"
|
|
for c in "${chemins[@]}"; do
|
|
src="${TMP}${c}"
|
|
dest="${vers}${c}"
|
|
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
|
|
if ! vide "${dest}"; then
|
|
if [[ -z "${vers}" ]] && (( ! remplacer )); then
|
|
mourir "${dest} n'est pas vide : --remplacer pour l'ecraser (il sera mis de cote)"
|
|
fi
|
|
mettre_de_cote "${dest}"
|
|
fi
|
|
mkdir -p "$(dirname "${dest}")"
|
|
if [[ -d "${src}" ]]; then
|
|
mkdir -p "${dest}"
|
|
rsync -a --delete "${src}/" "${dest}/"
|
|
else
|
|
cp -a "${src}" "${dest}"
|
|
fi
|
|
if [[ -n "${proprio}" ]]; then chown -R "${proprio}" "${dest}"; fi
|
|
n=$(find "${dest}" -type f | wc -l)
|
|
echo "RESTAURE ${INSTANTANE} : ${c} -> ${dest} (${n} fichier(s))"
|
|
done
|
|
}
|
|
|
|
cmd_annuaire() {
|
|
local essai=0 base="" ldif dir n
|
|
while (( $# )); do
|
|
case "$1" in
|
|
--essai) essai=1; shift ;;
|
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
|
*) base="$1"; shift ;;
|
|
esac
|
|
done
|
|
[[ -n "${base}" ]] || mourir "annuaire : BASE_DN requis"
|
|
resoudre_instantane
|
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
|
extraire "$(dirname "${LDIF}")"
|
|
ldif="${TMP}${LDIF}"
|
|
[[ -s "${ldif}" ]] || mourir "${INSTANTANE} n'emporte pas d'annuaire (${LDIF})"
|
|
n=$(grep -c '^dn:' "${ldif}")
|
|
# A BLANC D'ABORD : un LDIF que le schema EN PLACE refuse (overlay pas encore charge,
|
|
# attribut inconnu) doit echouer ici, pas apres qu'on a deplace la base vivante.
|
|
slapadd -u -q -b "${base}" -l "${ldif}" || mourir "slapadd a blanc refuse ${INSTANTANE}"
|
|
if (( essai )); then echo "ESSAI OK : ${n} entree(s) rejouables depuis ${INSTANTANE}."; return 0; fi
|
|
dir=$(slapcat -n 0 -a "(olcSuffix=${base})" | awk '/^olcDbDirectory:/{print $2}')
|
|
[[ -d "${dir}" ]] || mourir "repertoire de la base introuvable pour ${base}"
|
|
systemctl stop slapd
|
|
mettre_de_cote "${dir}"
|
|
find "${dir}" -mindepth 1 -delete
|
|
if ! slapadd -q -b "${base}" -l "${ldif}"; then
|
|
dire "slapadd a echoue : remise de la base d'avant"
|
|
find "${dir}" -mindepth 1 -delete
|
|
cp -a "${MIS_DE_COTE}/." "${dir}/"
|
|
chown -R openldap:openldap "${dir}"
|
|
systemctl start slapd
|
|
exit 1
|
|
fi
|
|
chown -R openldap:openldap "${dir}"
|
|
systemctl start slapd
|
|
echo "RESTAURE ${INSTANTANE} : annuaire ${base}, ${n} entree(s)."
|
|
}
|
|
|
|
tables() { # nombre de tables hors schemas systeme ; -1 si la base n'existe pas
|
|
runuser -u postgres -- psql -tAX -d "$1" -c \
|
|
"select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')" \
|
|
2>/dev/null || echo -1
|
|
}
|
|
|
|
cmd_base() {
|
|
local remplacer=0 vers="" nom="" cible dump section creation proprio n
|
|
while (( $# )); do
|
|
case "$1" in
|
|
--remplacer) remplacer=1; shift ;;
|
|
--vers) vers="$2"; shift 2 ;;
|
|
--instantane) INSTANTANE="$2"; shift 2 ;;
|
|
*) nom="$1"; shift ;;
|
|
esac
|
|
done
|
|
[[ -n "${nom}" ]] || mourir "base : NOM requis"
|
|
cible="${vers:-${nom}}"
|
|
resoudre_instantane
|
|
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
|
|
extraire "$(dirname "${DUMP_PG}")"
|
|
dump="${TMP}${DUMP_PG}"
|
|
[[ -s "${dump}" ]] || mourir "${INSTANTANE} n'emporte pas de dump PostgreSQL"
|
|
creation=$(grep -m1 -E "^CREATE DATABASE ${nom} " "${dump}" || true)
|
|
if [[ -z "${creation}" ]]; then echo "ABSENTE de ${INSTANTANE} : base ${nom}"; return 0; fi
|
|
proprio=$(grep -m1 -oP "^ALTER DATABASE ${nom} OWNER TO \K[^;]+" "${dump}" || echo postgres)
|
|
# LE PIEGE DE pg_dumpall (runbook §5) : un `CREATE DATABASE <suivante>` et, avec
|
|
# `--clean`, un `DROP DATABASE postgres;` precedent le `\connect` suivant. On coupe sur
|
|
# les trois, puis on REFUSE toute section qui viserait encore une autre base.
|
|
section="${TMP}/section.sql"
|
|
awk -v db="${nom}" '$0 == "\\connect " db {f=1; next}
|
|
f && (/^\\connect / || /^CREATE DATABASE / || /^DROP DATABASE /) {exit} f' "${dump}" > "${section}"
|
|
if grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' "${section}"; then
|
|
mourir "section ${nom} hors perimetre : refus"
|
|
fi
|
|
if [[ -n "${vers}" ]]; then sed -i -E "/ ON DATABASE ${nom} /d" "${section}"; fi
|
|
n=$(tables "${cible}")
|
|
if (( n > 0 )) && (( ! remplacer )); then
|
|
mourir "la base ${cible} contient ${n} table(s) : --remplacer pour l'ecraser (elle sera mise de cote)"
|
|
fi
|
|
if (( n >= 0 )); then
|
|
mkdir -p "${MISE_DE_COTE}"
|
|
runuser -u postgres -- pg_dump -Fc "${cible}" > "${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)-${cible}.dump"
|
|
runuser -u postgres -- dropdb --force "${cible}"
|
|
fi
|
|
runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres \
|
|
-c "${creation/CREATE DATABASE ${nom} /CREATE DATABASE ${cible} }" \
|
|
-c "ALTER DATABASE ${cible} OWNER TO ${proprio};"
|
|
# UNE SEULE TRANSACTION : une erreur au milieu laisse une base VIDE, que la prochaine
|
|
# passe retentera — pas une base a moitie chargee qu'on prendrait pour la bonne.
|
|
# Par l'ENTREE STANDARD, pas `-f` : psql tourne en `postgres`, qui ne peut pas lire le
|
|
# repertoire jetable de root (0700). Trouve en repetition sur data-sql-01, 2026-09-30.
|
|
runuser -u postgres -- psql -X -q --single-transaction -v ON_ERROR_STOP=1 \
|
|
-d "${cible}" < "${section}" >/dev/null
|
|
echo "RESTAURE ${INSTANTANE} : base ${nom} -> ${cible}, $(tables "${cible}") table(s)."
|
|
}
|
|
|
|
cmd_acter() {
|
|
local jeu="${1:?jeu}" etat="${2:?etat}" id="${3:-}"
|
|
install -d -m 0700 "${MARQUES}"
|
|
printf 'etat=%s\ninstantane=%s\ndate=%s\n' "${etat}" "${id}" "$(date -Is)" > "${MARQUES}/${jeu}"
|
|
echo "ACTE ${jeu} : ${etat}${id:+ (${id})}"
|
|
}
|
|
|
|
cmd_choisir() {
|
|
[[ "${1:-}" == "--instantane" ]] && INSTANTANE="${2:-}"
|
|
local c
|
|
c=$(candidat "${INSTANTANE}") || exit 2
|
|
python3 -c 'import json,sys; print(json.dumps({"instantane": sys.argv[1], "heure": sys.argv[2], "naissance": int(sys.argv[3])}))' \
|
|
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
|
|
}
|
|
|
|
cmd_etat() {
|
|
local c
|
|
echo "hote : ${MOI}"
|
|
echo "naissance : $(date -d "@$(naissance)" -Is)"
|
|
c=$(candidat "") || exit 2
|
|
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
|
|
# guillemet ouvrant, et tout le script devient illisible.)
|
|
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
|
|
echo "candidat : ${c}"
|
|
for j in "${JEUX[@]}"; do
|
|
if [[ -f "${MARQUES}/${j}" ]]; then
|
|
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")"
|
|
else
|
|
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
|
|
fi
|
|
done
|
|
}
|
|
|
|
cmd_garde() {
|
|
local attente=() c
|
|
for j in "${JEUX[@]}"; do
|
|
[[ -f "${MARQUES}/${j}" ]] || attente+=("${j}")
|
|
done
|
|
if [[ -z "${attente[*]:-}" ]]; then return 0; fi
|
|
c=$(candidat "") || exit 2
|
|
if [[ -z "${c}" ]]; then
|
|
for j in "${attente[@]}"; do cmd_acter "${j}" neuf >/dev/null; done
|
|
return 0
|
|
fi
|
|
dire "REFUS : l'etat d'une incarnation precedente (${c}) attend dans le depot, non remis"
|
|
dire "pour : ${attente[*]}. Deposer maintenant pousserait l'instantane d'avant hors de"
|
|
dire "la retention. Le remettre (redeployer le role proprietaire), ou l'ecarter :"
|
|
dire " make restauration-renoncer HOTE=${MOI} JEU=<jeu> CONFIRMER=true"
|
|
exit 3
|
|
}
|
|
|
|
sous="${1:-etat}"; shift || true
|
|
case "${sous}" in
|
|
etat) cmd_etat ;;
|
|
choisir) cmd_choisir "$@" ;;
|
|
fichiers) cmd_fichiers "$@" ;;
|
|
annuaire) cmd_annuaire "$@" ;;
|
|
base) cmd_base "$@" ;;
|
|
acter) cmd_acter "$@" ;;
|
|
garde) cmd_garde ;;
|
|
*) mourir "sous-commande inconnue : ${sous}" ;;
|
|
esac
|