diff --git a/BackupVaultWarden/backup-vaultwarden.sh b/BackupVaultWarden/backup-vaultwarden.sh old mode 100644 new mode 100755 diff --git a/CheckStorage/check-storage.sh b/CheckStorage/check-storage.sh old mode 100644 new mode 100755 diff --git a/RebuildBdd/Checkup/check-postgresql.sh b/RebuildBdd/Checkup/check-postgresql.sh new file mode 100755 index 0000000..e28de4b --- /dev/null +++ b/RebuildBdd/Checkup/check-postgresql.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEFAULT_ENV_FILE="${REPO_DIR}/.env" + +ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" +CLI_REQUEST_ID="" +NON_INTERACTIVE="${NON_INTERACTIVE:-no}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --env-file" >&2; exit 1; } + ENV_FILE="$2" + shift 2 + ;; + --request-id) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --request-id" >&2; exit 1; } + CLI_REQUEST_ID="$2" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE="yes" + shift + ;; + *) + echo "Argument inconnu : $1" >&2 + exit 1 + ;; + esac +done + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +fail() { + log "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +postgres_server_ready() { + require_cmd postgres || return 1 + require_cmd pg_ctlcluster || return 1 + require_cmd pg_lsclusters || return 1 + return 0 +} + +ensure_postgres_cluster() { + if ! require_cmd pg_lsclusters || ! require_cmd pg_createcluster; then + return 0 + fi + + if pg_lsclusters --no-header 2>/dev/null | grep -q .; then + return 0 + fi + + local version="" + if [[ -d /etc/postgresql ]]; then + version="$(find /etc/postgresql -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | LC_ALL=C sort -V | tail -n 1)" + fi + + if [[ -z "$version" ]] && require_cmd psql; then + version="$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1)" + fi + + [[ -n "$version" ]] || return 1 + + log "Aucun cluster PostgreSQL détecté, création de ${version}/main..." + "$SUDO_BIN" pg_createcluster "$version" main --start >/dev/null 2>&1 || return 1 + return 0 +} + +collect_postgres_diagnostics() { + local diagnostics="" + + if "$SUDO_BIN" systemctl status "$POSTGRES_SERVICE_NAME" --no-pager >/dev/null 2>&1; then + diagnostics+="systemctl status ${POSTGRES_SERVICE_NAME}: OK; " + elif require_cmd systemctl; then + diagnostics+="systemctl status ${POSTGRES_SERVICE_NAME}: $( "$SUDO_BIN" systemctl status "$POSTGRES_SERVICE_NAME" --no-pager 2>/dev/null | tail -n 5 | tr '\n' ' ' ); " + fi + + if require_cmd pg_lsclusters; then + diagnostics+="pg_lsclusters: $(pg_lsclusters --no-header 2>/dev/null | tr '\n' ' '); " + fi + + if require_cmd journalctl; then + diagnostics+="journalctl: $( "$SUDO_BIN" journalctl -u "$POSTGRES_SERVICE_NAME" -n 10 --no-pager 2>/dev/null | tr '\n' ' ' ); " + fi + + printf '%s' "${diagnostics% }" +} + +start_postgres_service() { + if "$SUDO_BIN" systemctl start "$POSTGRES_SERVICE_NAME" >/dev/null 2>&1; then + return 0 + fi + + if require_cmd service && "$SUDO_BIN" service "$POSTGRES_SERVICE_NAME" start >/dev/null 2>&1; then + return 0 + fi + + if require_cmd pg_lsclusters && require_cmd pg_ctlcluster; then + local version cluster + while read -r version cluster _; do + [[ -n "$version" && -n "$cluster" ]] || continue + if "$SUDO_BIN" pg_ctlcluster "$version" "$cluster" start >/dev/null 2>&1; then + return 0 + fi + done < <(pg_lsclusters --no-header 2>/dev/null || true) + fi + + return 1 +} + +[[ -f "$ENV_FILE" ]] || fail "fichier .env introuvable : $ENV_FILE" + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${PGHOST:?Variable PGHOST manquante}" +: "${PGPORT:?Variable PGPORT manquante}" +: "${PGUSER:?Variable PGUSER manquante}" +: "${PGPASSWORD:?Variable PGPASSWORD manquante}" + +AUTO_INSTALL_POSTGRES="${AUTO_INSTALL_POSTGRES:-yes}" +AUTO_CREATE_PGUSER="${AUTO_CREATE_PGUSER:-yes}" +PGUSER_SUPERUSER="${PGUSER_SUPERUSER:-no}" +POSTGRES_PACKAGE_LIST="${POSTGRES_PACKAGE_LIST:-postgresql postgresql-client postgresql-contrib}" +POSTGRES_SERVICE_NAME="${POSTGRES_SERVICE_NAME:-postgresql}" +SUDO_BIN="${SUDO_BIN:-sudo}" + +export PGPASSWORD + +if ! require_cmd "$SUDO_BIN"; then + fail "sudo absent sur la cible" +fi + +if ! "$SUDO_BIN" /usr/bin/systemctl --version >/dev/null 2>&1; then + fail "sudo indisponible pour systemctl" +fi + +if [[ ! "$PGPORT" =~ ^[0-9]+$ ]]; then + fail "PGPORT invalide : $PGPORT" +fi + +POSTGRES_INSTALLED="no" + +if ! require_cmd psql || ! require_cmd pg_restore || ! require_cmd createdb || ! require_cmd dropdb || ! postgres_server_ready; then + [[ "${AUTO_INSTALL_POSTGRES,,}" == "yes" ]] || fail "PostgreSQL absent et AUTO_INSTALL_POSTGRES=no" + + log "PostgreSQL absent : installation en cours..." + "$SUDO_BIN" apt update >/dev/null 2>&1 || fail "échec de apt update" + "$SUDO_BIN" apt install -y $POSTGRES_PACKAGE_LIST >/dev/null 2>&1 || fail "échec de l'installation PostgreSQL" + POSTGRES_INSTALLED="yes" + log "Installation PostgreSQL terminée." +else + log "PostgreSQL déjà installé." +fi + +ensure_postgres_cluster || fail "aucun cluster PostgreSQL disponible et création automatique impossible" + +if ! "$SUDO_BIN" systemctl is-active --quiet "$POSTGRES_SERVICE_NAME"; then + log "Démarrage du service PostgreSQL..." + if ! start_postgres_service; then + fail "impossible de démarrer PostgreSQL. $(collect_postgres_diagnostics)" + fi +else + log "Service PostgreSQL déjà actif." +fi + +log "Vérification de la disponibilité de PostgreSQL..." +for _ in {1..20}; do + if "$SUDO_BIN" -u postgres psql -d postgres -c "SELECT 1;" >/dev/null 2>&1; then + log "PostgreSQL répond correctement." + break + fi + sleep 1 +done + +if ! "$SUDO_BIN" -u postgres psql -d postgres -c "SELECT 1;" >/dev/null 2>&1; then + fail "PostgreSQL ne répond pas correctement" +fi + +if [[ "${AUTO_CREATE_PGUSER,,}" == "yes" ]]; then + ROLE_EXISTS="$( + "$SUDO_BIN" -u postgres psql -d postgres -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname='${PGUSER//\'/\'\'}'" 2>/dev/null || true + )" + + if [[ "$ROLE_EXISTS" != "1" ]]; then + log "Création du rôle PostgreSQL ${PGUSER}..." + + ROLE_ATTRIBUTES="LOGIN CREATEDB CREATEROLE" + if [[ "${PGUSER_SUPERUSER,,}" == "yes" ]]; then + ROLE_ATTRIBUTES="LOGIN SUPERUSER CREATEDB CREATEROLE" + fi + + "$SUDO_BIN" -u postgres psql -d postgres -c \ + "CREATE ROLE \"${PGUSER}\" WITH ${ROLE_ATTRIBUTES} PASSWORD '${PGPASSWORD//\'/\'\'}';" \ + >/dev/null 2>&1 || fail "échec de création du rôle ${PGUSER}" + + log "Rôle PostgreSQL ${PGUSER} créé." + else + log "Rôle PostgreSQL ${PGUSER} déjà présent." + fi +fi + +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c "SELECT 1;" >/dev/null 2>&1; then + fail "connexion PostgreSQL locale impossible avec PGUSER=${PGUSER}" +fi + +log "Check PostgreSQL terminé avec succès." diff --git a/RebuildBdd/Checkup/check-target-readiness.sh b/RebuildBdd/Checkup/check-target-readiness.sh new file mode 100755 index 0000000..e44b378 --- /dev/null +++ b/RebuildBdd/Checkup/check-target-readiness.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# check-target-readiness.sh +# +# Prépare la machine cible pour permettre l'exécution non interactive du +# script de rebuild depuis une interface web. +############################################################################### + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEFAULT_ENV_FILE="${REPO_DIR}/.env" + +ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" +CLI_REQUEST_ID="" +NON_INTERACTIVE="${NON_INTERACTIVE:-no}" +JSON_ONLY="${JSON_ONLY:-no}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --env-file" >&2; exit 1; } + ENV_FILE="$2" + shift 2 + ;; + --request-id) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --request-id" >&2; exit 1; } + CLI_REQUEST_ID="$2" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE="yes" + shift + ;; + --json-only) + JSON_ONLY="yes" + shift + ;; + *) + echo "Argument inconnu : $1" >&2 + exit 1 + ;; + esac +done + +json_escape() { + python3 - <<'PY' "$1" +import json, sys +print(json.dumps(sys.argv[1])) +PY +} + +print_json_and_exit() { + local status="$1" + local message="$2" + local exit_code="$3" + + printf '{' + printf '"status":%s,' "$(json_escape "$status")" + printf '"message":%s,' "$(json_escape "$message")" + printf '"request_id":%s,' "$(json_escape "${REQUEST_ID:-}")" + printf '"environment":%s,' "$(json_escape "${ENV_NAME:-}")" + printf '"log_file":%s' "$(json_escape "${LOG_FILE:-}")" + printf '}\n' + exit "$exit_code" +} + +print_stdout() { + [[ "$JSON_ONLY" == "yes" ]] || echo "$*" +} + +log() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "$msg" >>"$LOG_FILE" + print_stdout "$msg" +} + +fail() { + log "ERROR: $*" + print_json_and_exit "error" "$*" 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "commande requise absente : $1" +} + +to_bool_yes_no() { + local v="${1:-}" + v="${v,,}" + case "$v" in + yes|y|oui|o|true|1) echo "yes" ;; + no|n|non|false|0|"") echo "no" ;; + *) return 1 ;; + esac +} + +require_env_vars() { + local missing=() + local var + + for var in \ + ENV_NAME PGHOST PGPORT PGUSER PGPASSWORD DBS \ + BACKUP_REMOTE_USER BACKUP_REMOTE_HOST BACKUP_REMOTE_DIR \ + SSH_KEY BACKUP_LOG_DIR + do + [[ -n "${!var:-}" ]] || missing+=("$var") + done + + if (( ${#missing[@]} > 0 )); then + fail "variables .env manquantes : ${missing[*]}" + fi +} + +validate_env_values() { + [[ "$PGPORT" =~ ^[0-9]+$ ]] || fail "PGPORT invalide" + [[ -n "$DBS" ]] || fail "DBS vide" + [[ "$PGUSER" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || fail "PGUSER invalide" + [[ -n "$BACKUP_REMOTE_HOST" ]] || fail "BACKUP_REMOTE_HOST vide" + [[ -n "$BACKUP_REMOTE_USER" ]] || fail "BACKUP_REMOTE_USER vide" + [[ -n "$BACKUP_REMOTE_DIR" ]] || fail "BACKUP_REMOTE_DIR vide" + BACKUP_REMOTE_SSH_PORT="${BACKUP_REMOTE_SSH_PORT:-22}" + [[ "$BACKUP_REMOTE_SSH_PORT" =~ ^[0-9]+$ ]] || fail "BACKUP_REMOTE_SSH_PORT invalide" +} + +prepare_log_file() { + mkdir -p "$BACKUP_LOG_DIR" || { + echo '{"status":"error","message":"impossible de créer le dossier de logs"}' + exit 1 + } + + local ts safe_request_id + ts="$(date '+%Y-%m-%d_%H-%M-%S')" + safe_request_id="${REQUEST_ID:-manual}" + safe_request_id="${safe_request_id//[^a-zA-Z0-9_.-]/_}" + + LOG_FILE="${BACKUP_LOG_DIR}/check_target_${ENV_NAME,,}_${safe_request_id}_${ts}.log" + touch "$LOG_FILE" || { + echo '{"status":"error","message":"impossible de créer le fichier de log"}' + exit 1 + } +} + +prepare_local_paths() { + local restore_base + restore_base="${LOCAL_RESTORE_BASE_DIR:-${REPO_DIR}/restore_tmp}" + + mkdir -p "$BACKUP_LOG_DIR" || fail "création BACKUP_LOG_DIR impossible" + [[ -w "$BACKUP_LOG_DIR" ]] || fail "BACKUP_LOG_DIR non inscriptible" + + mkdir -p "$restore_base" || fail "création LOCAL_RESTORE_BASE_DIR impossible" + [[ -w "$restore_base" ]] || fail "LOCAL_RESTORE_BASE_DIR non inscriptible" + + log "Dossiers locaux prêts." +} + +prepare_scripts_permissions() { + local core_script check_pg_script + core_script="${REPO_DIR}/rebuild-bdd-core.sh" + check_pg_script="${REPO_DIR}/Checkup/check-postgresql.sh" + + [[ -f "$core_script" ]] || fail "script core introuvable : $core_script" + [[ -f "$check_pg_script" ]] || fail "script PostgreSQL introuvable : $check_pg_script" + + chmod 700 "$core_script" || fail "chmod impossible sur $core_script" + chmod 700 "$check_pg_script" || fail "chmod impossible sur $check_pg_script" + + log "Permissions scripts corrigées." +} + +prepare_ssh_key() { + local key_dir + key_dir="$(dirname "$SSH_KEY")" + + mkdir -p "$key_dir" || fail "impossible de créer le dossier SSH : $key_dir" + chmod 700 "$key_dir" || fail "impossible de chmod 700 sur $key_dir" + + [[ -f "$SSH_KEY" ]] || fail "clé SSH absente : $SSH_KEY" + chmod 600 "$SSH_KEY" || fail "impossible de chmod 600 sur la clé privée" + + [[ -f "${SSH_KEY}.pub" ]] || log "clé publique absente : ${SSH_KEY}.pub" + [[ ! -f "${SSH_KEY}.pub" ]] || chmod 644 "${SSH_KEY}.pub" || fail "impossible de chmod 644 sur la clé publique" + + log "Clé SSH prête." +} + +prepare_known_hosts() { + local ssh_dir known_hosts + ssh_dir="$(dirname "$SSH_KEY")" + known_hosts="${ssh_dir}/known_hosts" + + touch "$known_hosts" || fail "impossible de créer known_hosts" + chmod 644 "$known_hosts" || fail "impossible de chmod 644 sur known_hosts" + + if ! ssh-keygen -F "$BACKUP_REMOTE_HOST" -f "$known_hosts" >/dev/null 2>&1; then + log "Ajout de ${BACKUP_REMOTE_HOST}:${BACKUP_REMOTE_SSH_PORT} à known_hosts" + ssh-keyscan -p "$BACKUP_REMOTE_SSH_PORT" -H "$BACKUP_REMOTE_HOST" >>"$known_hosts" 2>/dev/null || \ + fail "échec de récupération de la clé hôte pour ${BACKUP_REMOTE_HOST}" + else + log "Host déjà présent dans known_hosts." + fi +} + +test_backup_ssh() { + local ssh_timeout + ssh_timeout="${SSH_CONNECT_TIMEOUT:-8}" + + ssh \ + -i "$SSH_KEY" \ + -p "$BACKUP_REMOTE_SSH_PORT" \ + -o IdentitiesOnly=yes \ + -o BatchMode=yes \ + -o ConnectTimeout="$ssh_timeout" \ + -o StrictHostKeyChecking=yes \ + "${BACKUP_REMOTE_USER}@${BACKUP_REMOTE_HOST}" \ + "test -d '$BACKUP_REMOTE_DIR'" \ + >>"$LOG_FILE" 2>&1 || \ + fail "connexion SSH backup impossible, clé non autorisée, ou dossier distant absent" + + log "Connexion SSH backup validée." +} + +install_sudoers_if_allowed() { + local auto_configure sudoers_file tmp_file + auto_configure="$(to_bool_yes_no "${AUTO_CONFIGURE_SUDOERS:-no}")" || fail "AUTO_CONFIGURE_SUDOERS invalide" + + if [[ "$auto_configure" != "yes" ]]; then + log "Installation sudoers automatique désactivée." + return 0 + fi + + if ! sudo true >/dev/null 2>&1; then + fail "AUTO_CONFIGURE_SUDOERS=yes mais sudo n'est pas disponible ; configuration initiale manuelle requise" + fi + + require_cmd visudo + + sudoers_file="/etc/sudoers.d/rebuild-bdd-${USER}" + tmp_file="$(mktemp)" + + cat >"$tmp_file" </dev/null 2>&1 || { + rm -f "$tmp_file" + fail "fichier sudoers généré invalide" + } + + sudo install -m 440 "$tmp_file" "$sudoers_file" || { + rm -f "$tmp_file" + fail "impossible d'installer $sudoers_file" + } + + rm -f "$tmp_file" + log "Fichier sudoers installé : $sudoers_file" +} + +check_sudo_non_interactive() { + sudo /usr/bin/systemctl --version >/dev/null 2>&1 || \ + fail "sudo indisponible pour systemctl" + + log "sudo pour systemctl validé." + + if command -v apt >/dev/null 2>&1; then + sudo /usr/bin/apt --version >/dev/null 2>&1 || \ + fail "sudo indisponible pour apt" + log "sudo pour apt validé." + elif command -v apt-get >/dev/null 2>&1; then + sudo /usr/bin/apt-get --version >/dev/null 2>&1 || \ + fail "sudo indisponible pour apt-get" + log "sudo pour apt-get validé." + else + fail "ni apt ni apt-get disponibles sur la cible" + fi + + sudo -u postgres /usr/bin/psql -d postgres -c "SELECT 1;" >/dev/null 2>&1 || \ + fail "sudo -u postgres indisponible pour psql" + + log "sudo -u postgres pour psql validé." +} + +run_postgresql_check() { + local check_script + check_script="${REPO_DIR}/Checkup/check-postgresql.sh" + + [[ -x "$check_script" ]] || fail "script introuvable ou non exécutable : $check_script" + + "$check_script" \ + --env-file "$ENV_FILE" \ + --request-id "$REQUEST_ID" \ + --non-interactive \ + >>"$LOG_FILE" 2>&1 || fail "échec de préparation PostgreSQL" + + sudo -u postgres /usr/bin/psql -d postgres -c "SELECT 1;" >/dev/null 2>&1 || \ + fail "sudo -u postgres indisponible après préparation PostgreSQL" + log "Préparation PostgreSQL validée." +} + +[[ -f "$ENV_FILE" ]] || { + echo '{"status":"error","message":"fichier .env introuvable"}' + exit 1 +} + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +REQUEST_ID="${CLI_REQUEST_ID:-${REQUEST_ID:-}}" + +require_env_vars +validate_env_values +prepare_log_file + +require_cmd bash +require_cmd python3 +require_cmd ssh +require_cmd ssh-keygen +require_cmd ssh-keyscan +require_cmd sudo + +prepare_local_paths +prepare_scripts_permissions +prepare_ssh_key +prepare_known_hosts +test_backup_ssh +install_sudoers_if_allowed +check_sudo_non_interactive +run_postgresql_check + +log "Machine cible prête pour le rebuild." +print_json_and_exit "success" "machine cible prête" 0 diff --git a/RebuildBdd/Config/.env.exemple b/RebuildBdd/Config/.env.exemple new file mode 100644 index 0000000..ff66962 --- /dev/null +++ b/RebuildBdd/Config/.env.exemple @@ -0,0 +1,38 @@ +############################################################################### +# config/global.env.example +############################################################################### + +# Defaults d'exécution +ALLOW_OVERWRITE=no +RESTORE_ROLES=yes + +# Dépôt scripts +GLOBAL_REPO_URL=git@gitea.example.tld:team/RebuildBdd.git +GLOBAL_REPO_BRANCH=main + +# Backup central +GLOBAL_BACKUP_REMOTE_USER=backup +GLOBAL_BACKUP_REMOTE_HOST=192.168.1.60 +GLOBAL_BACKUP_REMOTE_PORT=22 +GLOBAL_BACKUP_REMOTE_BASE_DIR=/home/backup/backups + +# Clé SSH de lecture backup copiée sur les cibles +GLOBAL_BACKUP_SSH_PRIVATE_KEY=/home/matteo/.ssh/id_ed25519_backup_readonly +GLOBAL_BACKUP_SSH_PUBLIC_KEY=/home/matteo/.ssh/id_ed25519_backup_readonly.pub +GLOBAL_BACKUP_KNOWN_HOSTS_STRICT=yes + +# Defaults PostgreSQL +GLOBAL_PGHOST=127.0.0.1 +GLOBAL_PGPORT=5432 + +# Defaults scripts +GLOBAL_REMOTE_ROLES_DIR_NAME=user +GLOBAL_EXCLUDED_RESTORE_ROLES="postgres" + +# Defaults bootstrap / cible +GLOBAL_ENABLE_BOOTSTRAP=yes +GLOBAL_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO=yes +GLOBAL_AUTO_INSTALL_POSTGRES=yes +GLOBAL_AUTO_CREATE_PGUSER=yes +GLOBAL_PGUSER_SUPERUSER=no +GLOBAL_AUTO_CONFIGURE_SUDOERS=no \ No newline at end of file diff --git a/RebuildBdd/Config/Targets/prod.env.example b/RebuildBdd/Config/Targets/prod.env.example new file mode 100644 index 0000000..7070ae3 --- /dev/null +++ b/RebuildBdd/Config/Targets/prod.env.example @@ -0,0 +1,30 @@ + +############################################################################### +# CIBLE : prod +############################################################################### + +# TARGET_HOST_prod=10.0.0.20 +# TARGET_PORT_prod=22 +# TARGET_BOOTSTRAP_USER_prod=backup_liot +# TARGET_BOOTSTRAP_SSH_KEY_prod=/home/matteo/.ssh/id_ed25519_target_prod +# TARGET_RUNTIME_USER_prod=backup_liot +# TARGET_ENABLE_BOOTSTRAP_prod=yes +# TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO_prod=yes +# TARGET_REPO_DIR_prod=/home/backup_liot/RebuildBdd +# TARGET_ENV_FILE_prod=/home/backup_liot/RebuildBdd/.env +# TARGET_ENV_NAME_prod=PROD +# TARGET_PGHOST_prod=127.0.0.1 +# TARGET_PGPORT_prod=5432 +# TARGET_PGUSER_prod=backup_liot +# TARGET_PGPASSWORD_prod=change_me_prod_password +# TARGET_DBS_prod="sirh inventory ferme" +# TARGET_BACKUP_SUBDIR_prod=bdd-prod +# TARGET_BACKUP_LOG_DIR_prod=/home/backup_liot/logs/rebuild_bdd +# TARGET_LOCAL_RESTORE_BASE_DIR_prod=/home/backup_liot/RebuildBdd/restore_tmp +# TARGET_SSH_KEY_prod=/home/backup_liot/.ssh/id_ed25519_backup_readonly +# TARGET_REMOTE_ROLES_DIR_NAME_prod=user +# TARGET_EXCLUDED_RESTORE_ROLES_prod="postgres" +# TARGET_AUTO_INSTALL_POSTGRES_prod=yes +# TARGET_AUTO_CREATE_PGUSER_prod=yes +# TARGET_PGUSER_SUPERUSER_prod=no +# TARGET_AUTO_CONFIGURE_SUDOERS_prod=no \ No newline at end of file diff --git a/RebuildBdd/Config/Targets/test.env.example b/RebuildBdd/Config/Targets/test.env.example new file mode 100644 index 0000000..8244082 --- /dev/null +++ b/RebuildBdd/Config/Targets/test.env.example @@ -0,0 +1,42 @@ +############################################################################### +# config/targets/test.env.example +############################################################################### + +# SSH bootstrap cible +TARGET_HOST=192.168.1.50 +TARGET_PORT=22 +TARGET_BOOTSTRAP_USER=backup_liot +TARGET_BOOTSTRAP_SSH_KEY=/home/matteo/.ssh/id_ed25519_target_test +TARGET_RUNTIME_USER=backup_liot + +# Bootstrap +TARGET_ENABLE_BOOTSTRAP=yes +TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO=yes + +# Repo local cible +TARGET_REPO_DIR=/home/backup_liot/RebuildBdd +TARGET_ENV_FILE=/home/backup_liot/RebuildBdd/.env + +# PostgreSQL cible +TARGET_ENV_NAME=RECETTE +TARGET_PGHOST=127.0.0.1 +TARGET_PGPORT=5432 +TARGET_PGUSER=backup_liot +TARGET_PGPASSWORD=change_me_pg_password +TARGET_DBS="sirh inventory ferme" + +# Backup cible +TARGET_BACKUP_SUBDIR=bdd-recette + +# Logs / tmp / ssh cible +TARGET_BACKUP_LOG_DIR=/home/backup_liot/logs/rebuild_bdd +TARGET_LOCAL_RESTORE_BASE_DIR=/home/backup_liot/RebuildBdd/restore_tmp +TARGET_SSH_KEY=/home/backup_liot/.ssh/id_ed25519_backup_readonly + +# Options cible +TARGET_REMOTE_ROLES_DIR_NAME=user +TARGET_EXCLUDED_RESTORE_ROLES="postgres" +TARGET_AUTO_INSTALL_POSTGRES=yes +TARGET_AUTO_CREATE_PGUSER=yes +TARGET_PGUSER_SUPERUSER=no +TARGET_AUTO_CONFIGURE_SUDOERS=no \ No newline at end of file diff --git a/RebuildBdd/README.md b/RebuildBdd/README.md new file mode 100644 index 0000000..102cf3c --- /dev/null +++ b/RebuildBdd/README.md @@ -0,0 +1,566 @@ +# RebuildBdd + +Orchestration de reconstruction de bases PostgreSQL à partir de dumps distants, avec préparation automatique des machines cibles, exécution non interactive et intégration web. + +--- + +## Objectif + +Ce projet permet de : + +- préparer automatiquement une machine cible neuve ou partiellement configurée ; +- déployer et mettre à jour les scripts sur la cible ; +- préparer PostgreSQL localement sur la cible ; +- récupérer le dernier dump disponible depuis un serveur de backup ; +- restaurer une base PostgreSQL de manière non interactive ; +- exposer un flux exploitable depuis une interface web via des retours JSON. + +--- + +## Fonctionnement global + +Le flux standard est le suivant : + +1. **Création ou mise à jour de la configuration d’une cible** +2. **Bootstrap initial de la cible** +3. **Précheck de préparation** +4. **Rebuild de la base** + +En pratique : + +- `create-target-config.sh` crée un fichier de configuration cible ; +- `bootstrap-target-host.sh` prépare la machine cible ; +- `Checkup/check-target-readiness.sh` valide l’environnement ; +- `rebuild-bdd-core.sh` exécute la restauration ; +- `run-rebuild-bdd.sh` orchestre l’ensemble. + +--- + +## Architecture + +### Configuration + +Le projet utilise deux niveaux de configuration : + +#### 1. Configuration globale +Fichier : + +```bash +config/global.env +```` + +Contient les paramètres stables, par exemple : + +* dépôt Git des scripts ; +* serveur de backup ; +* clé SSH de lecture backup ; +* valeurs par défaut PostgreSQL ; +* options globales de bootstrap. + +#### 2. Configuration par cible + +Fichiers : + +```bash +config/targets/.env +``` + +Chaque fichier cible contient : + +* accès SSH bootstrap ; +* répertoires locaux de la cible ; +* paramètres PostgreSQL ; +* sous-répertoire backup associé ; +* options spécifiques à la cible. + +--- + +## Arborescence recommandée + +```bash +RebuildBdd/ +├── bootstrap-target-host.sh +├── create-target-config.sh +├── run-rebuild-bdd.sh +├── rebuild-bdd-core.sh +├── config/ +│ ├── global.env +│ └── targets/ +│ ├── test.env +│ └── prod.env +└── Checkup/ + ├── check-postgresql.sh + └── check-target-readiness.sh +``` + +--- + +## Scripts + +### `create-target-config.sh` + +Crée ou met à jour un fichier cible dans : + +```bash +config/targets/.env +``` + +Usage : + +```bash +./create-target-config.sh \ + --target test \ + --host 192.168.1.50 \ + --port 22 \ + --bootstrap-user backup_liot \ + --bootstrap-key /home/user/.ssh/id_ed25519_target_test \ + --runtime-user backup_liot \ + --repo-dir /home/backup_liot/RebuildBdd \ + --env-name RECETTE \ + --pguser backup_liot \ + --pgpassword secret \ + --dbs "sirh inventory ferme" \ + --backup-subdir bdd-recette +``` + +--- + +### `bootstrap-target-host.sh` + +Prépare une machine cible neuve ou quasi neuve : + +* connexion SSH bootstrap ; +* installation des paquets minimum ; +* création des dossiers ; +* génération du `.env` cible ; +* copie de la clé SSH backup ; +* préparation de `known_hosts` ; +* installation éventuelle d’un `sudoers.d` minimal ; +* synchronisation du dépôt ; +* exécution de `check-postgresql.sh`. + +Usage : + +```bash +./bootstrap-target-host.sh --target test +``` + +Mode JSON : + +```bash +./bootstrap-target-host.sh --target test --json-only +``` + +--- + +### `Checkup/check-postgresql.sh` + +Prépare PostgreSQL localement sur la cible : + +* installation si absent ; +* démarrage du service ; +* test de disponibilité ; +* création du rôle PostgreSQL cible si nécessaire. + +Ce script est prévu pour fonctionner en non interactif avec `sudo -n`. + +--- + +### `Checkup/check-target-readiness.sh` + +Valide la préparation complète de la cible : + +* lecture du `.env` cible ; +* vérification des chemins ; +* permissions locales ; +* permissions SSH ; +* `known_hosts` ; +* accès SSH au serveur de backup ; +* exécution de `check-postgresql.sh`. + +Mode JSON disponible pour usage web. + +--- + +### `rebuild-bdd-core.sh` + +Script métier de reconstruction : + +* validation des paramètres ; +* connexion au serveur de backup ; +* récupération du dernier dump ; +* récupération éventuelle du fichier des rôles ; +* suppression/recréation de la base si autorisé ; +* restauration des rôles ; +* restauration du dump PostgreSQL ; +* retour JSON final. + +--- + +### `run-rebuild-bdd.sh` + +Script orchestrateur principal. + +Il peut : + +* lancer le bootstrap si activé pour la cible ; +* synchroniser le dépôt distant ; +* lancer le précheck ; +* exécuter le rebuild. + +Usage : + +```bash +./run-rebuild-bdd.sh \ + --target test \ + --db sirh \ + --overwrite yes \ + --restore-roles yes \ + --request-id web_001 \ + --non-interactive +``` + +--- + +## Prérequis + +### Machine de lancement + +Doit disposer de : + +* `bash` +* `ssh` +* `scp` +* `git` +* `python3` + +### Machine cible + +Le bootstrap suppose : + +* accès SSH fonctionnel ; +* utilisateur bootstrap existant ; +* soit `root`, soit `sudo -n` déjà disponible pour le bootstrap initial. + +### Serveur de backup + +Doit : + +* être joignable en SSH depuis la cible ; +* accepter la clé de lecture backup ; +* contenir les dumps dans l’arborescence attendue. + +--- + +## Structure des backups attendue + +Exemple : + +```bash +/home/malio-b/backups/ +├── bdd-recette/ +│ ├── sirh/ +│ │ ├── sirh_2026-03-16_19-00-01.dump +│ ├── inventory/ +│ ├── ferme/ +│ └── user/ +│ ├── user_2026-03-16_19-00-01.sql +``` + +Le script recherche : + +* le dernier dump dans : + +```bash +//_*.dump +``` + +* le dernier fichier rôles dans : + +```bash +//user_*.sql +``` + +--- + +## Configuration + +### 1. Créer la configuration globale + +Copier : + +```bash +config/global.env.example +``` + +vers : + +```bash +config/global.env +``` + +Renseigner ensuite : + +* dépôt Git ; +* serveur de backup ; +* clé SSH backup ; +* defaults globaux. + +--- + +### 2. Créer une cible + +Deux possibilités. + +#### A. À la main + +Créer un fichier : + +```bash +config/targets/test.env +``` + +à partir de : + +```bash +config/targets/test.env.example +``` + +#### B. Via script + +Utiliser : + +```bash +./create-target-config.sh ... +``` + +--- + +## Exécution locale + +### Bootstrap seul + +```bash +./bootstrap-target-host.sh --target test +``` + +### Rebuild complet + +```bash +./run-rebuild-bdd.sh \ + --target test \ + --db sirh \ + --overwrite yes \ + --restore-roles yes \ + --non-interactive +``` + +--- + +## Intégration web + +L’interface web ne doit envoyer que les paramètres métier de l’exécution : + +```json +{ + "target": "test", + "db": "sirh", + "overwrite": "yes", + "restore_roles": "yes", + "request_id": "web_20260317_001" +} +``` + +Le backend transforme cela en commande : + +```bash +./run-rebuild-bdd.sh \ + --target test \ + --db sirh \ + --overwrite yes \ + --restore-roles yes \ + --request-id web_20260317_001 \ + --non-interactive +``` + +### Important + +Le web ne doit pas transmettre directement : + +* les clés SSH ; +* les mots de passe PostgreSQL ; +* les paramètres bas niveau de la cible ; +* les chemins système sensibles. + +Ces informations doivent être stockées dans la configuration serveur. + +--- + +## Ajouter une nouvelle machine depuis le web + +Le flux recommandé est : + +1. créer ou mettre à jour `config/targets/.env` +2. lancer `bootstrap-target-host.sh --target ` +3. lancer ensuite `run-rebuild-bdd.sh --target ...` + +Le bouton web **“Ajouter une machine”** doit donc : + +* créer la configuration cible ; +* déclencher le bootstrap ; +* vérifier le retour ; +* rendre ensuite la cible disponible pour les rebuilds. + +--- + +## Sorties JSON + +### Succès + +Exemple : + +```json +{ + "status": "success", + "message": "restauration terminée avec succès", + "request_id": "web_001", + "environment": "RECETTE", + "database": "sirh", + "dump_file": "/home/backup/backups/bdd-recette/sirh/sirh_2026-03-16_19-00-01.dump", + "log_file": "/home/backup_liot/logs/rebuild_bdd/restore_recette_web_001_2026-03-17_09-10-00.log" +} +``` + +### Erreur + +Exemple : + +```json +{ + "status": "error", + "message": "la base existe déjà et overwrite n'est pas autorisé", + "request_id": "web_001", + "environment": "RECETTE", + "database": "sirh", + "dump_file": "/home/backup/backups/bdd-recette/sirh/sirh_2026-03-16_19-00-01.dump", + "log_file": "/home/backup_liot/logs/rebuild_bdd/restore_recette_web_001_2026-03-17_09-10-00.log" +} +``` + +--- + +## Sécurité + +### Recommandations minimales + +* utiliser des clés SSH dédiées ; +* limiter la clé backup à la lecture seule ; +* restreindre les permissions des fichiers de config ; +* exécuter les scripts avec un utilisateur dédié ; +* ne pas exposer les secrets dans l’interface web ; +* valider strictement toutes les entrées côté backend. + +### `sudoers` + +Le bootstrap peut installer un `sudoers.d` minimal pour l’utilisateur runtime : + +```sudoers + ALL=(root) NOPASSWD: /usr/bin/apt, /usr/bin/apt-get, /usr/bin/systemctl + ALL=(postgres) NOPASSWD: /usr/bin/psql +``` + +Adapter si d’autres commandes doivent être autorisées. + +--- + +## Logs + +Les logs de rebuild sont stockés dans : + +```bash +TARGET_BACKUP_LOG_DIR +``` + +Exemple : + +```bash +/home/backup_liot/logs/rebuild_bdd/ +``` + +Le chemin du log est renvoyé dans le JSON final. + +--- + +## Limites connues + +* le bootstrap initial nécessite un accès SSH bootstrap valide ; +* le bootstrap ne remplace pas une mauvaise architecture réseau ; +* les secrets doivent être gérés proprement par la couche web/backend ; +* des verrous d’exécution peuvent être ajoutés si plusieurs rebuilds concurrents sont prévus. + +--- + +## Recommandations de validation + +Avant mise en production, tester au minimum : + +1. bootstrap d’une machine neuve ; +2. rebuild complet d’une base ; +3. refus si la base existe et `overwrite=no` ; +4. relance complète une seconde fois sur la même cible ; +5. accès backup invalide ; +6. PostgreSQL absent au départ ; +7. `sudo -n` indisponible. + +--- + +## Commandes utiles + +### Créer une cible + +```bash +./create-target-config.sh \ + --target test \ + --host 192.168.1.50 \ + --port 22 \ + --bootstrap-user backup_liot \ + --bootstrap-key /home/matteo/.ssh/id_ed25519_target_test \ + --runtime-user backup_liot \ + --repo-dir /home/backup_liot/RebuildBdd \ + --env-name RECETTE \ + --pguser backup_liot \ + --pgpassword secret \ + --dbs "sirh inventory ferme" \ + --backup-subdir bdd-recette +``` + +### Bootstrap + +```bash +./bootstrap-target-host.sh --target test +``` + +### Rebuild + +```bash +./run-rebuild-bdd.sh \ + --target test \ + --db sirh \ + --overwrite yes \ + --restore-roles yes \ + --non-interactive +``` + +--- + +## État du projet + +Le projet permet désormais une utilisation : + +* locale ; +* automatisée ; +* intégrée au web ; + +avec préparation des cibles, exécution non interactive et retour JSON. + +``` diff --git a/RebuildBdd/bootstrap-target-host.sh b/RebuildBdd/bootstrap-target-host.sh new file mode 100755 index 0000000..0504b18 --- /dev/null +++ b/RebuildBdd/bootstrap-target-host.sh @@ -0,0 +1,579 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_DIR="${SCRIPT_DIR}/Config" +GLOBAL_ENV_FILE_DEFAULT="${CONFIG_DIR}/global.env" +TARGETS_DIR_DEFAULT="${CONFIG_DIR}/Targets" +GIT_TOPLEVEL="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" +LOCAL_REPO_SUBDIR_DEFAULT="" + +if [[ -n "$GIT_TOPLEVEL" && "$SCRIPT_DIR" == "$GIT_TOPLEVEL"/* ]]; then + LOCAL_REPO_SUBDIR_DEFAULT="${SCRIPT_DIR#"$GIT_TOPLEVEL"/}" +fi + +GLOBAL_ENV_FILE="${GLOBAL_ENV_FILE:-$GLOBAL_ENV_FILE_DEFAULT}" +TARGETS_DIR="${TARGETS_DIR:-$TARGETS_DIR_DEFAULT}" + +TARGET_NAME="${TARGET_NAME:-}" +CLI_TARGET="" +JSON_ONLY="${JSON_ONLY:-no}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --global-env-file) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --global-env-file" >&2; exit 1; } + GLOBAL_ENV_FILE="$2" + shift 2 + ;; + --targets-dir) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --targets-dir" >&2; exit 1; } + TARGETS_DIR="$2" + shift 2 + ;; + --target) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --target" >&2; exit 1; } + CLI_TARGET="$2" + shift 2 + ;; + --json-only) + JSON_ONLY="yes" + shift + ;; + *) + echo "Argument inconnu : $1" >&2 + exit 1 + ;; + esac +done + +json_escape() { + python3 - <<'PY' "$1" +import json, sys +print(json.dumps(sys.argv[1])) +PY +} + +print_stdout() { + [[ "$JSON_ONLY" == "yes" ]] || echo "$*" +} + +log() { + print_stdout "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +fail() { + local msg="$1" + if [[ "$JSON_ONLY" == "yes" ]]; then + printf '{"status":%s,"message":%s}\n' \ + "$(json_escape "error")" \ + "$(json_escape "$msg")" + else + echo "ERROR: $msg" >&2 + fi + exit 1 +} + +success() { + local msg="$1" + if [[ "$JSON_ONLY" == "yes" ]]; then + printf '{"status":%s,"message":%s}\n' \ + "$(json_escape "success")" \ + "$(json_escape "$msg")" + else + log "$msg" + fi +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "commande requise absente : $1" +} + +to_bool_yes_no() { + local v="${1:-}" + v="${v,,}" + case "$v" in + yes|y|oui|o|true|1) echo "yes" ;; + no|n|non|false|0|"") echo "no" ;; + *) return 1 ;; + esac +} + +shell_quote() { + printf "%q" "$1" +} + +cleanup() { + rm -f "${TMP_ENV_FILE:-}" +} +trap cleanup EXIT + +copy_file_to_remote_via_ssh() { + local local_file="$1" + local remote_final_path="$2" + local remote_mode="$3" + local remote_parent + local remote_tmp + + [[ -f "$local_file" ]] || fail "fichier source introuvable : $local_file" + [[ -r "$local_file" ]] || fail "fichier source non lisible : $local_file" + + remote_parent="$(dirname "$remote_final_path")" + remote_tmp="/tmp/bootstrap_copy.$$.$RANDOM.tmp" + + ssh "${SSH_OPTS[@]}" "$REMOTE" " + set -euo pipefail + mkdir -p $(shell_quote "$remote_parent") + test -d $(shell_quote "$remote_parent") + test -w $(shell_quote "$remote_parent") + " >/dev/null 2>&1 || fail "dossier distant absent ou non inscriptible : $remote_parent" + + ssh "${SSH_OPTS[@]}" "$REMOTE" " + set -euo pipefail + cat > $(shell_quote "$remote_tmp") + " < "$local_file" >/dev/null 2>&1 || fail "échec d'écriture temporaire distante : $remote_tmp" + + ssh "${SSH_OPTS[@]}" "$REMOTE" " + set -euo pipefail + install -m $(shell_quote "$remote_mode") $(shell_quote "$remote_tmp") $(shell_quote "$remote_final_path") + rm -f $(shell_quote "$remote_tmp") + " >/dev/null 2>&1 || fail "échec d'installation distante : $remote_final_path" +} + +TARGET_NAME="${CLI_TARGET:-${TARGET_NAME:-}}" +[[ -n "$TARGET_NAME" ]] || fail "target manquante" + +TARGET_ENV_SOURCE="${TARGETS_DIR}/${TARGET_NAME}.env" + +[[ -f "$GLOBAL_ENV_FILE" ]] || fail "fichier global introuvable : $GLOBAL_ENV_FILE" +[[ -f "$TARGET_ENV_SOURCE" ]] || fail "fichier cible introuvable : $TARGET_ENV_SOURCE" + +set -a +# shellcheck disable=SC1090 +source "$GLOBAL_ENV_FILE" +# shellcheck disable=SC1090 +source "$TARGET_ENV_SOURCE" +set +a + +BOOTSTRAP_HOST="${TARGET_HOST:-}" +BOOTSTRAP_PORT="${TARGET_PORT:-22}" +BOOTSTRAP_USER="${TARGET_BOOTSTRAP_USER:-}" +BOOTSTRAP_SSH_KEY="${TARGET_BOOTSTRAP_SSH_KEY:-}" + +TARGET_REPO_URL="${TARGET_REPO_URL:-${GLOBAL_REPO_URL:-}}" +TARGET_REPO_BRANCH="${TARGET_REPO_BRANCH:-${GLOBAL_REPO_BRANCH:-}}" +TARGET_REPO_DIR="${TARGET_REPO_DIR:-}" +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR:-$LOCAL_REPO_SUBDIR_DEFAULT}" +TARGET_ENV_FILE_PATH="${TARGET_ENV_FILE:-}" + +TARGET_ENV_NAME_VALUE="${TARGET_ENV_NAME:-}" +TARGET_PGHOST_VALUE="${TARGET_PGHOST:-${GLOBAL_PGHOST:-}}" +TARGET_PGPORT_VALUE="${TARGET_PGPORT:-${GLOBAL_PGPORT:-}}" +TARGET_PGUSER_VALUE="${TARGET_PGUSER:-}" +TARGET_PGPASSWORD_VALUE="${TARGET_PGPASSWORD:-}" +TARGET_DBS_VALUE="${TARGET_DBS:-}" + +TARGET_BACKUP_REMOTE_USER_VALUE="${TARGET_BACKUP_REMOTE_USER:-${GLOBAL_BACKUP_REMOTE_USER:-}}" +TARGET_BACKUP_REMOTE_HOST_VALUE="${TARGET_BACKUP_REMOTE_HOST:-${GLOBAL_BACKUP_REMOTE_HOST:-}}" +TARGET_BACKUP_REMOTE_SSH_PORT_VALUE="${TARGET_BACKUP_REMOTE_SSH_PORT:-${GLOBAL_BACKUP_REMOTE_PORT:-22}}" +GLOBAL_BACKUP_REMOTE_BASE_DIR_VALUE="${GLOBAL_BACKUP_REMOTE_BASE_DIR:-}" +TARGET_BACKUP_SUBDIR_VALUE="${TARGET_BACKUP_SUBDIR:-}" +TARGET_BACKUP_LOG_DIR_VALUE="${TARGET_BACKUP_LOG_DIR:-}" + +TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE="${TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY:-${GLOBAL_BACKUP_SSH_PRIVATE_KEY:-}}" +TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE="${TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY:-${GLOBAL_BACKUP_SSH_PUBLIC_KEY:-}}" +TARGET_BACKUP_KNOWN_HOSTS_STRICT_VALUE="${TARGET_BACKUP_KNOWN_HOSTS_STRICT:-${GLOBAL_BACKUP_KNOWN_HOSTS_STRICT:-yes}}" + +TARGET_LOCAL_RESTORE_BASE_DIR_VALUE="${TARGET_LOCAL_RESTORE_BASE_DIR:-${TARGET_REPO_DIR}/restore_tmp}" +TARGET_REMOTE_ROLES_DIR_NAME_VALUE="${TARGET_REMOTE_ROLES_DIR_NAME:-${GLOBAL_REMOTE_ROLES_DIR_NAME:-user}}" +TARGET_SSH_KEY_VALUE="${TARGET_SSH_KEY:-/home/${BOOTSTRAP_USER}/.ssh/id_ed25519_backup_readonly}" +TARGET_AUTO_INSTALL_POSTGRES_VALUE="${TARGET_AUTO_INSTALL_POSTGRES:-${GLOBAL_AUTO_INSTALL_POSTGRES:-yes}}" +TARGET_AUTO_CREATE_PGUSER_VALUE="${TARGET_AUTO_CREATE_PGUSER:-${GLOBAL_AUTO_CREATE_PGUSER:-yes}}" +TARGET_PGUSER_SUPERUSER_VALUE="${TARGET_PGUSER_SUPERUSER:-${GLOBAL_PGUSER_SUPERUSER:-no}}" +TARGET_AUTO_CONFIGURE_SUDOERS_VALUE="${TARGET_AUTO_CONFIGURE_SUDOERS:-${GLOBAL_AUTO_CONFIGURE_SUDOERS:-no}}" +TARGET_EXCLUDED_RESTORE_ROLES_VALUE="${TARGET_EXCLUDED_RESTORE_ROLES:-${GLOBAL_EXCLUDED_RESTORE_ROLES:-postgres}}" + +TARGET_RUNTIME_USER_VALUE="${TARGET_RUNTIME_USER:-$BOOTSTRAP_USER}" +TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO_VALUE="${TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO:-${GLOBAL_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO:-yes}}" + +[[ -n "$BOOTSTRAP_HOST" ]] || fail "TARGET_HOST manquante" +[[ "$BOOTSTRAP_PORT" =~ ^[0-9]+$ ]] || fail "TARGET_PORT invalide" +[[ -n "$BOOTSTRAP_USER" ]] || fail "TARGET_BOOTSTRAP_USER manquante" +[[ -n "$BOOTSTRAP_SSH_KEY" ]] || fail "TARGET_BOOTSTRAP_SSH_KEY manquante" +[[ -f "$BOOTSTRAP_SSH_KEY" ]] || fail "clé bootstrap introuvable : $BOOTSTRAP_SSH_KEY" +[[ -r "$BOOTSTRAP_SSH_KEY" ]] || fail "clé bootstrap non lisible : $BOOTSTRAP_SSH_KEY" + +[[ -n "$TARGET_REPO_URL" ]] || fail "GLOBAL_REPO_URL/TARGET_REPO_URL manquant" +[[ -n "$TARGET_REPO_BRANCH" ]] || fail "GLOBAL_REPO_BRANCH/TARGET_REPO_BRANCH manquant" +[[ -n "$TARGET_REPO_DIR" ]] || fail "TARGET_REPO_DIR manquante" +[[ -n "$TARGET_ENV_FILE_PATH" ]] || fail "TARGET_ENV_FILE manquante" + +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR#/}" +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR%/}" + +TARGET_CLONE_DIR="$TARGET_REPO_DIR" +TARGET_SCRIPT_DIR="$TARGET_REPO_DIR" +if [[ -n "$TARGET_REPO_SUBDIR" ]]; then + if [[ "$TARGET_REPO_DIR" == */"$TARGET_REPO_SUBDIR" ]]; then + TARGET_CLONE_DIR="$(dirname "$TARGET_REPO_DIR")" + else + TARGET_SCRIPT_DIR="${TARGET_REPO_DIR}/${TARGET_REPO_SUBDIR}" + fi +fi + +[[ -n "$TARGET_ENV_NAME_VALUE" ]] || fail "TARGET_ENV_NAME manquante" +[[ -n "$TARGET_PGHOST_VALUE" ]] || fail "TARGET_PGHOST/GLOBAL_PGHOST manquant" +[[ -n "$TARGET_PGPORT_VALUE" ]] || fail "TARGET_PGPORT/GLOBAL_PGPORT manquant" +[[ -n "$TARGET_PGUSER_VALUE" ]] || fail "TARGET_PGUSER manquante" +[[ -n "$TARGET_PGPASSWORD_VALUE" ]] || fail "TARGET_PGPASSWORD manquante" +[[ -n "$TARGET_DBS_VALUE" ]] || fail "TARGET_DBS manquante" + +[[ -n "$TARGET_BACKUP_REMOTE_USER_VALUE" ]] || fail "GLOBAL_BACKUP_REMOTE_USER/TARGET_BACKUP_REMOTE_USER manquant" +[[ -n "$TARGET_BACKUP_REMOTE_HOST_VALUE" ]] || fail "GLOBAL_BACKUP_REMOTE_HOST/TARGET_BACKUP_REMOTE_HOST manquant" +[[ -n "$GLOBAL_BACKUP_REMOTE_BASE_DIR_VALUE" ]] || fail "GLOBAL_BACKUP_REMOTE_BASE_DIR manquant" +[[ -n "$TARGET_BACKUP_SUBDIR_VALUE" ]] || fail "TARGET_BACKUP_SUBDIR manquante" +[[ -n "$TARGET_BACKUP_LOG_DIR_VALUE" ]] || fail "TARGET_BACKUP_LOG_DIR manquante" +TARGET_BACKUP_REMOTE_DIR_VALUE="${GLOBAL_BACKUP_REMOTE_BASE_DIR_VALUE%/}/${TARGET_BACKUP_SUBDIR_VALUE}" + +[[ -n "$TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE" ]] || fail "GLOBAL_BACKUP_SSH_PRIVATE_KEY/TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY manquant" +[[ -f "$TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE" ]] || fail "clé privée backup introuvable : $TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE" +[[ -r "$TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE" ]] || fail "clé privée backup non lisible : $TARGET_BACKUP_SOURCE_SSH_PRIVATE_KEY_VALUE" + +if [[ -n "$TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE" ]]; then + [[ -f "$TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE" ]] || fail "clé publique backup introuvable : $TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE" + [[ -r "$TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE" ]] || fail "clé publique backup non lisible : $TARGET_BACKUP_SOURCE_SSH_PUBLIC_KEY_VALUE" +fi + +[[ "$TARGET_BACKUP_REMOTE_SSH_PORT_VALUE" =~ ^[0-9]+$ ]] || fail "port backup invalide" +to_bool_yes_no "$TARGET_BACKUP_KNOWN_HOSTS_STRICT_VALUE" >/dev/null || fail "TARGET_BACKUP_KNOWN_HOSTS_STRICT invalide" +to_bool_yes_no "$TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO_VALUE" >/dev/null || fail "TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO invalide" + +ALLOW_PASSWORDLESS_SUDO="$(to_bool_yes_no "$TARGET_BOOTSTRAP_ALLOW_PASSWORDLESS_SUDO_VALUE")" + +require_cmd ssh +require_cmd python3 + +SSH_OPTS=( + -i "$BOOTSTRAP_SSH_KEY" + -p "$BOOTSTRAP_PORT" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o StrictHostKeyChecking=accept-new + -o ConnectTimeout=8 +) + +REMOTE="${BOOTSTRAP_USER}@${BOOTSTRAP_HOST}" + +log "Test de connexion SSH bootstrap vers ${REMOTE}:${BOOTSTRAP_PORT}" +ssh "${SSH_OPTS[@]}" "$REMOTE" "exit 0" >/dev/null 2>&1 \ + || fail "connexion SSH bootstrap impossible vers ${REMOTE}" + +REMOTE_SETUP_CMD=" +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +run_root() { + if [ \"\$(id -u)\" -eq 0 ]; then + \"\$@\" + return 0 + fi + + if command -v sudo >/dev/null 2>&1; then + sudo \"\$@\" || { + echo 'sudo indisponible pour le bootstrap' >&2 + exit 1 + } + return 0 + fi + + echo 'ni root ni sudo disponible pour le bootstrap' >&2 + exit 1 +} + +if ! command -v apt-get >/dev/null 2>&1; then + echo 'apt-get absent sur la cible' >&2 + exit 1 +fi + +run_root apt-get update +run_root apt-get install -y bash git python3 sudo curl openssh-client ca-certificates postgresql-client + +mkdir -p $(shell_quote "$(dirname "$TARGET_CLONE_DIR")") +mkdir -p $(shell_quote "$(dirname "$TARGET_SCRIPT_DIR")") +mkdir -p $(shell_quote "$(dirname "$TARGET_ENV_FILE_PATH")") +mkdir -p $(shell_quote "$TARGET_BACKUP_LOG_DIR_VALUE") +mkdir -p $(shell_quote "$TARGET_LOCAL_RESTORE_BASE_DIR_VALUE") +mkdir -p $(shell_quote "$(dirname "$TARGET_SSH_KEY_VALUE")") + +chmod 700 $(shell_quote "$(dirname "$TARGET_SSH_KEY_VALUE")") || true +touch $(shell_quote "$(dirname "$TARGET_SSH_KEY_VALUE")/known_hosts") +chmod 644 $(shell_quote "$(dirname "$TARGET_SSH_KEY_VALUE")/known_hosts") || true +" + +log "Installation du socle minimal sur la cible" +if [[ "$JSON_ONLY" == "yes" ]]; then + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SETUP_CMD" >/dev/null \ + || fail "échec de préparation système distante" +else + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SETUP_CMD" \ + || fail "échec de préparation système distante" +fi + +TMP_ENV_FILE="$(mktemp)" + +cat >"$TMP_ENV_FILE" </dev/null 2>&1; then + ssh-keyscan -p $(shell_quote "$TARGET_BACKUP_REMOTE_SSH_PORT_VALUE") -H $(shell_quote "$TARGET_BACKUP_REMOTE_HOST_VALUE") >> $(shell_quote "$REMOTE_KNOWN_HOSTS") 2>/dev/null +fi +" + +log "Ajout du serveur de backup dans known_hosts côté cible" +ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_KNOWN_HOSTS_CMD" \ + || fail "échec de préparation known_hosts sur la cible" + +STRICT_OPTION="yes" +case "${TARGET_BACKUP_KNOWN_HOSTS_STRICT_VALUE,,}" in + yes|y|oui|o|true|1) STRICT_OPTION="yes" ;; + no|n|non|false|0) STRICT_OPTION="no" ;; + *) fail "TARGET_BACKUP_KNOWN_HOSTS_STRICT invalide" ;; +esac + +REMOTE_BACKUP_TEST_CMD=" +set -euo pipefail + +ssh \ + -i $(shell_quote "$TARGET_SSH_KEY_VALUE") \ + -p $(shell_quote "$TARGET_BACKUP_REMOTE_SSH_PORT_VALUE") \ + -o IdentitiesOnly=yes \ + -o BatchMode=yes \ + -o ConnectTimeout=8 \ + -o StrictHostKeyChecking=$(shell_quote "$STRICT_OPTION") \ + $(shell_quote "${TARGET_BACKUP_REMOTE_USER_VALUE}@${TARGET_BACKUP_REMOTE_HOST_VALUE}") \ + test -d $(shell_quote "$TARGET_BACKUP_REMOTE_DIR_VALUE") +" + +log "Test de la connexion SSH cible -> backup" +ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_BACKUP_TEST_CMD" \ + || fail "la cible ne peut pas accéder au serveur de backup avec la clé fournie" + +if [[ "$ALLOW_PASSWORDLESS_SUDO" == "yes" ]]; then + REMOTE_SUDOERS_PRECHECK_CMD=" +set -euo pipefail + +if [ \"\$(id -u)\" -eq 0 ]; then + exit 0 +fi + +command -v sudo >/dev/null 2>&1 || exit 1 +sudo true /dev/null 2>&1 +" + + REMOTE_SUDOERS_CMD=" +set -euo pipefail + +run_root() { + if [ \"\$(id -u)\" -eq 0 ]; then + \"\$@\" + return 0 + fi + + if command -v sudo >/dev/null 2>&1; then + sudo \"\$@\" || { + echo 'sudo indisponible pour installer sudoers' >&2 + exit 1 + } + return 0 + fi + + echo 'ni root ni sudo disponible pour sudoers' >&2 + exit 1 +} + +if ! command -v visudo >/dev/null 2>&1; then + run_root apt-get update + run_root apt-get install -y sudo +fi + +TMP_SUDOERS_FILE=\$(mktemp) +cat >\"\$TMP_SUDOERS_FILE\" </dev/null 2>&1 || { + rm -f \"\$TMP_SUDOERS_FILE\" + echo 'fichier sudoers généré invalide' >&2 + exit 1 +} + +run_root install -m 440 \"\$TMP_SUDOERS_FILE\" /etc/sudoers.d/rebuild-bdd-${TARGET_RUNTIME_USER_VALUE} +rm -f \"\$TMP_SUDOERS_FILE\" + " + + log "Installation du sudoers minimal" + if ! ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SUDOERS_PRECHECK_CMD" >/dev/null 2>&1; then + log "Installation du sudoers ignorée : élévation de privilèges indisponible sans interaction." + elif ! ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SUDOERS_CMD" >/dev/null 2>&1; then + log "Installation du sudoers ignorée : privilèges root/sudo insuffisants pour cette étape." + fi +else + log "Installation du sudoers minimal désactivée." +fi + +REMOTE_REPO_CMD=" +set -euo pipefail + +if [[ ! -d $(shell_quote "${TARGET_CLONE_DIR}/.git") ]]; then + rm -rf $(shell_quote "$TARGET_CLONE_DIR") + git clone --branch $(shell_quote "$TARGET_REPO_BRANCH") --single-branch $(shell_quote "$TARGET_REPO_URL") $(shell_quote "$TARGET_CLONE_DIR") +else + git -C $(shell_quote "$TARGET_CLONE_DIR") fetch --prune origin + git -C $(shell_quote "$TARGET_CLONE_DIR") checkout -f $(shell_quote "$TARGET_REPO_BRANCH") + git -C $(shell_quote "$TARGET_CLONE_DIR") reset --hard origin/$(shell_quote "$TARGET_REPO_BRANCH") +fi + +chmod 700 $(shell_quote "$TARGET_SCRIPT_DIR/run-rebuild-bdd.sh") 2>/dev/null || true +chmod 700 $(shell_quote "$TARGET_SCRIPT_DIR/rebuild-bdd-core.sh") 2>/dev/null || true +chmod 700 $(shell_quote "$TARGET_SCRIPT_DIR/Checkup/check-postgresql.sh") 2>/dev/null || true +chmod 700 $(shell_quote "$TARGET_SCRIPT_DIR/Checkup/check-target-readiness.sh") 2>/dev/null || true + +for required_file in \ + $(shell_quote "$TARGET_SCRIPT_DIR/run-rebuild-bdd.sh") \ + $(shell_quote "$TARGET_SCRIPT_DIR/rebuild-bdd-core.sh") \ + $(shell_quote "$TARGET_SCRIPT_DIR/Checkup/check-postgresql.sh") \ + $(shell_quote "$TARGET_SCRIPT_DIR/Checkup/check-target-readiness.sh"); do + if [[ ! -f \"\$required_file\" ]]; then + echo \"fichier requis absent après synchronisation du dépôt : \$required_file\" >&2 + echo \"vérifier TARGET_REPO_DIR=$(shell_quote "$TARGET_REPO_DIR"), TARGET_REPO_SUBDIR=$(shell_quote "$TARGET_REPO_SUBDIR"), TARGET_REPO_URL=$(shell_quote "$TARGET_REPO_URL"), TARGET_REPO_BRANCH=$(shell_quote "$TARGET_REPO_BRANCH")\" >&2 + exit 1 + fi +done +" + +log "Clone / mise à jour du dépôt distant" +if [[ "$JSON_ONLY" == "yes" ]]; then + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_REPO_CMD" >/dev/null \ + || fail "échec de synchronisation du dépôt sur la cible" +else + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_REPO_CMD" \ + || fail "échec de synchronisation du dépôt sur la cible" +fi + +REMOTE_VALIDATE_SUDO_ROOT_CMD=" +set -euo pipefail +command -v sudo >/dev/null 2>&1 || { + echo 'sudo absent sur la cible' >&2 + exit 1 +} +sudo /usr/bin/systemctl --version >/dev/null 2>&1 || { + echo 'sudo indisponible pour systemctl' >&2 + exit 1 +} +" + +log "Validation initiale de sudo" +ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_VALIDATE_SUDO_ROOT_CMD" \ + || fail "sudo invalide sur la cible" + +REMOTE_RUN_CHECK_PG_CMD=" +set -euo pipefail + +CHECK_SCRIPT=$(shell_quote "${TARGET_SCRIPT_DIR}/Checkup/check-postgresql.sh") +ENV_FILE=$(shell_quote "$TARGET_ENV_FILE_PATH") + +[[ -f \"\$CHECK_SCRIPT\" ]] || { + echo \"script PostgreSQL introuvable : \$CHECK_SCRIPT\" >&2 + echo \"vérifier TARGET_REPO_DIR=$(shell_quote "$TARGET_REPO_DIR") et TARGET_REPO_SUBDIR=$(shell_quote "$TARGET_REPO_SUBDIR")\" >&2 + exit 1 +} +[[ -x \"\$CHECK_SCRIPT\" ]] || chmod 700 \"\$CHECK_SCRIPT\" + +\"\$CHECK_SCRIPT\" --env-file \"\$ENV_FILE\" --non-interactive +" + +log "Préparation PostgreSQL via check-postgresql.sh" +if [[ "$JSON_ONLY" == "yes" ]]; then + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_RUN_CHECK_PG_CMD" >/dev/null \ + || fail "échec de préparation PostgreSQL pendant le bootstrap" +else + ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_RUN_CHECK_PG_CMD" \ + || fail "échec de préparation PostgreSQL pendant le bootstrap" +fi + +REMOTE_VALIDATE_SUDO_POSTGRES_CMD=" +set -euo pipefail +sudo -u postgres /usr/bin/psql -d postgres -c 'SELECT 1;' >/dev/null 2>&1 || { + echo 'sudo -u postgres indisponible après préparation PostgreSQL' >&2 + exit 1 +} +" + +log "Validation finale de sudo -u postgres" +ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_VALIDATE_SUDO_POSTGRES_CMD" \ + || fail "sudo -u postgres invalide sur la cible" + +success "bootstrap initial terminé pour ${TARGET_NAME}" diff --git a/RebuildBdd/create-target-config.sh b/RebuildBdd/create-target-config.sh new file mode 100644 index 0000000..fe60867 --- /dev/null +++ b/RebuildBdd/create-target-config.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_DIR="${SCRIPT_DIR}/Config" +TARGETS_DIR_DEFAULT="${CONFIG_DIR}/Targets" + +TARGETS_DIR="${TARGETS_DIR:-$TARGETS_DIR_DEFAULT}" + +TARGET="" +HOST="" +PORT="22" +BOOTSTRAP_USER="" +BOOTSTRAP_SSH_KEY="" +RUNTIME_USER="" +REPO_DIR="" +ENV_FILE="" +ENV_NAME="" +PGHOST="" +PGPORT="" +PGUSER="" +PGPASSWORD="" +DBS="" +BACKUP_SUBDIR="" +BACKUP_LOG_DIR="" +LOCAL_RESTORE_BASE_DIR="" +SSH_KEY_TARGET_PATH="" +ENABLE_BOOTSTRAP="yes" +ALLOW_PASSWORDLESS_SUDO="yes" +AUTO_INSTALL_POSTGRES="yes" +AUTO_CREATE_PGUSER="yes" +PGUSER_SUPERUSER="no" +AUTO_CONFIGURE_SUDOERS="no" +REMOTE_ROLES_DIR_NAME="user" +EXCLUDED_RESTORE_ROLES="postgres" +FORCE="no" + +while [[ $# -gt 0 ]]; do + case "$1" in + --targets-dir) TARGETS_DIR="$2"; shift 2 ;; + --target) TARGET="$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --bootstrap-user) BOOTSTRAP_USER="$2"; shift 2 ;; + --bootstrap-key) BOOTSTRAP_SSH_KEY="$2"; shift 2 ;; + --runtime-user) RUNTIME_USER="$2"; shift 2 ;; + --repo-dir) REPO_DIR="$2"; shift 2 ;; + --env-file) ENV_FILE="$2"; shift 2 ;; + --env-name) ENV_NAME="$2"; shift 2 ;; + --pghost) PGHOST="$2"; shift 2 ;; + --pgport) PGPORT="$2"; shift 2 ;; + --pguser) PGUSER="$2"; shift 2 ;; + --pgpassword) PGPASSWORD="$2"; shift 2 ;; + --dbs) DBS="$2"; shift 2 ;; + --backup-subdir) BACKUP_SUBDIR="$2"; shift 2 ;; + --backup-log-dir) BACKUP_LOG_DIR="$2"; shift 2 ;; + --local-restore-base-dir) LOCAL_RESTORE_BASE_DIR="$2"; shift 2 ;; + --ssh-key-target-path) SSH_KEY_TARGET_PATH="$2"; shift 2 ;; + --enable-bootstrap) ENABLE_BOOTSTRAP="$2"; shift 2 ;; + --allow-passwordless-sudo) ALLOW_PASSWORDLESS_SUDO="$2"; shift 2 ;; + --auto-install-postgres) AUTO_INSTALL_POSTGRES="$2"; shift 2 ;; + --auto-create-pguser) AUTO_CREATE_PGUSER="$2"; shift 2 ;; + --pguser-superuser) PGUSER_SUPERUSER="$2"; shift 2 ;; + --auto-configure-sudoers) AUTO_CONFIGURE_SUDOERS="$2"; shift 2 ;; + --remote-roles-dir-name) REMOTE_ROLES_DIR_NAME="$2"; shift 2 ;; + --excluded-restore-roles) EXCLUDED_RESTORE_ROLES="$2"; shift 2 ;; + --force) FORCE="yes"; shift ;; + *) echo "Argument inconnu : $1" >&2; exit 1 ;; + esac +done + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +to_bool_yes_no() { + local v="${1:-}" + v="${v,,}" + case "$v" in + yes|y|oui|o|true|1) echo "yes" ;; + no|n|non|false|0|"") echo "no" ;; + *) return 1 ;; + esac +} + +[[ -n "$TARGET" ]] || fail "--target manquant" +[[ "$TARGET" =~ ^[a-zA-Z0-9_-]+$ ]] || fail "target invalide" + +[[ -n "$HOST" ]] || fail "--host manquant" +[[ -n "$BOOTSTRAP_USER" ]] || fail "--bootstrap-user manquant" +[[ -n "$BOOTSTRAP_SSH_KEY" ]] || fail "--bootstrap-key manquant" +[[ -n "$REPO_DIR" ]] || fail "--repo-dir manquant" +[[ -n "$ENV_NAME" ]] || fail "--env-name manquant" +[[ -n "$PGUSER" ]] || fail "--pguser manquant" +[[ -n "$PGPASSWORD" ]] || fail "--pgpassword manquant" +[[ -n "$DBS" ]] || fail "--dbs manquant" +[[ -n "$BACKUP_SUBDIR" ]] || fail "--backup-subdir manquant" +[[ "$PORT" =~ ^[0-9]+$ ]] || fail "--port invalide" + +[[ -n "$RUNTIME_USER" ]] || RUNTIME_USER="$BOOTSTRAP_USER" +[[ -n "$ENV_FILE" ]] || ENV_FILE="${REPO_DIR}/.env" +[[ -n "$PGHOST" ]] || PGHOST="127.0.0.1" +[[ -n "$PGPORT" ]] || PGPORT="5432" +[[ "$PGPORT" =~ ^[0-9]+$ ]] || fail "--pgport invalide" +[[ -n "$BACKUP_LOG_DIR" ]] || BACKUP_LOG_DIR="/home/${RUNTIME_USER}/logs/rebuild_bdd" +[[ -n "$LOCAL_RESTORE_BASE_DIR" ]] || LOCAL_RESTORE_BASE_DIR="${REPO_DIR}/restore_tmp" +[[ -n "$SSH_KEY_TARGET_PATH" ]] || SSH_KEY_TARGET_PATH="/home/${RUNTIME_USER}/.ssh/id_ed25519_backup_readonly" + +ENABLE_BOOTSTRAP="$(to_bool_yes_no "$ENABLE_BOOTSTRAP")" || fail "--enable-bootstrap invalide" +ALLOW_PASSWORDLESS_SUDO="$(to_bool_yes_no "$ALLOW_PASSWORDLESS_SUDO")" || fail "--allow-passwordless-sudo invalide" +AUTO_INSTALL_POSTGRES="$(to_bool_yes_no "$AUTO_INSTALL_POSTGRES")" || fail "--auto-install-postgres invalide" +AUTO_CREATE_PGUSER="$(to_bool_yes_no "$AUTO_CREATE_PGUSER")" || fail "--auto-create-pguser invalide" +PGUSER_SUPERUSER="$(to_bool_yes_no "$PGUSER_SUPERUSER")" || fail "--pguser-superuser invalide" +AUTO_CONFIGURE_SUDOERS="$(to_bool_yes_no "$AUTO_CONFIGURE_SUDOERS")" || fail "--auto-configure-sudoers invalide" + +mkdir -p "$TARGETS_DIR" || fail "impossible de créer $TARGETS_DIR" + +TARGET_FILE="${TARGETS_DIR}/${TARGET}.env" +if [[ -f "$TARGET_FILE" && "$FORCE" != "yes" ]]; then + fail "fichier déjà existant : $TARGET_FILE (utiliser --force pour écraser)" +fi + +cat >"$TARGET_FILE" <&2; exit 1; } + ENV_FILE="$2" + shift 2 + ;; + --db) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --db" >&2; exit 1; } + CLI_DB="$2" + shift 2 + ;; + --overwrite) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --overwrite" >&2; exit 1; } + CLI_OVERWRITE="$2" + shift 2 + ;; + --restore-roles) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --restore-roles" >&2; exit 1; } + CLI_RESTORE_ROLES="$2" + shift 2 + ;; + --request-id) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --request-id" >&2; exit 1; } + CLI_REQUEST_ID="$2" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE="yes" + shift + ;; + --json-only) + JSON_ONLY="yes" + shift + ;; + *) + echo "Argument inconnu : $1" >&2 + exit 1 + ;; + esac +done + +json_escape() { + python3 - <<'PY' "$1" +import json, sys +print(json.dumps(sys.argv[1])) +PY +} + +print_json_and_exit() { + local status="$1" + local message="$2" + local exit_code="$3" + + printf '{' + printf '"status":%s,' "$(json_escape "$status")" + printf '"message":%s,' "$(json_escape "$message")" + printf '"request_id":%s,' "$(json_escape "${REQUEST_ID:-}")" + printf '"environment":%s,' "$(json_escape "${ENV_NAME:-}")" + printf '"database":%s,' "$(json_escape "${DB:-}")" + printf '"dump_file":%s,' "$(json_escape "${LAST_REMOTE_DB_DUMP:-}")" + printf '"log_file":%s' "$(json_escape "${LOG_FILE:-}")" + printf '}\n' + exit "$exit_code" +} + +print_stdout() { + [[ "$JSON_ONLY" == "yes" ]] || echo "$*" +} + +log() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "$msg" >>"$LOG_FILE" + print_stdout "$msg" +} + +fail() { + log "ERROR: $*" + print_json_and_exit "error" "$*" 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +download_remote_file() { + local remote_path="$1" + local local_path="$2" + local local_dir + + local_dir="$(dirname "$local_path")" + mkdir -p "$local_dir" || fail "impossible de créer le dossier local de restauration : $local_dir" + + if scp "${SCP_OPTS[@]}" "${REMOTE_SSH}:${remote_path}" "$local_path" >>"$LOG_FILE" 2>&1; then + return 0 + fi + + log "Téléchargement scp standard échoué, tentative avec scp -O" + scp -O "${SCP_OPTS[@]}" "${REMOTE_SSH}:${remote_path}" "$local_path" >>"$LOG_FILE" 2>&1 +} + +to_bool_yes_no() { + local v="${1:-}" + v="${v,,}" + case "$v" in + yes|y|oui|o|true|1) echo "yes" ;; + no|n|non|false|0|"") echo "no" ;; + *) return 1 ;; + esac +} + +is_tty() { + [[ -t 0 && -t 1 ]] +} + +sql_escape_literal() { + local s="${1:-}" + s="${s//\'/\'\'}" + printf "%s" "$s" +} + +build_excluded_roles_regex() { + local roles_string="${1:-}" + local role + local -a escaped_roles=() + + read -r -a roles_array <<< "$roles_string" + + for role in "${roles_array[@]}"; do + [[ -n "$role" ]] || continue + [[ "$role" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || continue + escaped_roles+=("$role") + done + + if [[ "${#escaped_roles[@]}" -eq 0 ]]; then + return 1 + fi + + local joined="" + local first="yes" + for role in "${escaped_roles[@]}"; do + if [[ "$first" == "yes" ]]; then + joined="$role" + first="no" + else + joined+="|$role" + fi + done + + printf '%s' "$joined" +} + +cleanup() { + rm -f \ + "${LOCAL_DB_DUMP_FILE:-}" \ + "${LOCAL_ROLES_FILE:-}" \ + "${FILTERED_ROLES_FILE:-}" \ + "${ROLES_CREATE_LIST:-}" \ + "${ROLES_APPLY_FILE:-}" +} +trap cleanup EXIT + +[[ -f "$ENV_FILE" ]] || { + echo '{"status":"error","message":"fichier .env cible introuvable"}' + exit 1 +} + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${ENV_NAME:?Variable ENV_NAME manquante}" +: "${PGHOST:?Variable PGHOST manquante}" +: "${PGPORT:?Variable PGPORT manquante}" +: "${PGUSER:?Variable PGUSER manquante}" +: "${PGPASSWORD:?Variable PGPASSWORD manquante}" +: "${DBS:?Variable DBS manquante}" +: "${BACKUP_REMOTE_USER:?Variable BACKUP_REMOTE_USER manquante}" +: "${BACKUP_REMOTE_HOST:?Variable BACKUP_REMOTE_HOST manquante}" +: "${BACKUP_REMOTE_DIR:?Variable BACKUP_REMOTE_DIR manquante}" +: "${SSH_KEY:?Variable SSH_KEY manquante}" +: "${BACKUP_LOG_DIR:?Variable BACKUP_LOG_DIR manquante}" + +LOCAL_RESTORE_BASE_DIR="${LOCAL_RESTORE_BASE_DIR:-${SCRIPT_DIR}/restore_tmp}" +REMOTE_ROLES_DIR_NAME="${REMOTE_ROLES_DIR_NAME:-user}" +SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-8}" +BACKUP_REMOTE_SSH_PORT="${BACKUP_REMOTE_SSH_PORT:-22}" +DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" +EXCLUDED_RESTORE_ROLES="${EXCLUDED_RESTORE_ROLES:-postgres}" + +REQUEST_ID="${CLI_REQUEST_ID:-${REQUEST_ID:-}}" +REQUESTED_DB="${CLI_DB:-${REQUESTED_DB:-}}" +ALLOW_OVERWRITE_RAW="${CLI_OVERWRITE:-${ALLOW_OVERWRITE:-no}}" +RESTORE_ROLES_RAW="${CLI_RESTORE_ROLES:-${RESTORE_ROLES:-yes}}" + +ALLOW_OVERWRITE="$(to_bool_yes_no "$ALLOW_OVERWRITE_RAW")" || { + echo '{"status":"error","message":"ALLOW_OVERWRITE invalide"}' + exit 1 +} + +RESTORE_ROLES="$(to_bool_yes_no "$RESTORE_ROLES_RAW")" || { + echo '{"status":"error","message":"RESTORE_ROLES invalide"}' + exit 1 +} + +[[ "$PGPORT" =~ ^[0-9]+$ ]] || fail "PGPORT invalide" +[[ "$BACKUP_REMOTE_SSH_PORT" =~ ^[0-9]+$ ]] || fail "BACKUP_REMOTE_SSH_PORT invalide" + +mkdir -p "$BACKUP_LOG_DIR" || { + echo '{"status":"error","message":"impossible de créer le dossier de logs"}' + exit 1 +} + +TIMESTAMP="$(date '+%Y-%m-%d_%H-%M-%S')" +SAFE_REQUEST_ID="${REQUEST_ID:-manual}" +SAFE_REQUEST_ID="${SAFE_REQUEST_ID//[^a-zA-Z0-9_.-]/_}" + +LOG_FILE="${BACKUP_LOG_DIR}/restore_${ENV_NAME,,}_${SAFE_REQUEST_ID}_${TIMESTAMP}.log" +touch "$LOG_FILE" || { + echo '{"status":"error","message":"impossible de créer le log"}' + exit 1 +} + +LOCAL_RESTORE_DIR="${LOCAL_RESTORE_BASE_DIR}/${SAFE_REQUEST_ID}_${TIMESTAMP}" +mkdir -p "$LOCAL_RESTORE_DIR" || fail "impossible de créer le dossier temporaire local" + +EXCLUDED_ROLES_REGEX="" +if EXCLUDED_ROLES_REGEX="$(build_excluded_roles_regex "$EXCLUDED_RESTORE_ROLES")"; then + log "Rôles exclus de la restauration : $EXCLUDED_RESTORE_ROLES" +else + log "Aucun rôle exclu de la restauration." +fi + +for cmd in ssh scp psql pg_restore createdb dropdb python3 grep sed find basename curl; do + require_cmd "$cmd" || fail "commande requise absente : $cmd" +done + +CHECK_SCRIPT="${SCRIPT_DIR}/Checkup/check-postgresql.sh" +if [[ -x "$CHECK_SCRIPT" ]]; then + log "Précheck PostgreSQL déjà effectué par check-target-readiness.sh" +else + fail "script introuvable ou non exécutable : $CHECK_SCRIPT" +fi + +[[ -f "$SSH_KEY" ]] || fail "clé SSH source backup introuvable : $SSH_KEY" +[[ -r "$SSH_KEY" ]] || fail "clé SSH source backup non lisible : $SSH_KEY" + +export PGPASSWORD + +SSH_OPTS=( + -i "$SSH_KEY" + -p "$BACKUP_REMOTE_SSH_PORT" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o ConnectTimeout="$SSH_CONNECT_TIMEOUT" + -o StrictHostKeyChecking=yes +) + +SCP_OPTS=( + -i "$SSH_KEY" + -P "$BACKUP_REMOTE_SSH_PORT" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o ConnectTimeout="$SSH_CONNECT_TIMEOUT" + -o StrictHostKeyChecking=yes +) + +REMOTE_SSH="${BACKUP_REMOTE_USER}@${BACKUP_REMOTE_HOST}" + +read -r -a DBS_ARRAY <<< "$DBS" +[[ "${#DBS_ARRAY[@]}" -gt 0 ]] || fail "aucune base définie dans DBS" + +if [[ -z "$REQUESTED_DB" ]]; then + if [[ "$NON_INTERACTIVE" == "yes" ]]; then + fail "REQUESTED_DB manquante en mode non interactif" + fi + + if is_tty; then + print_stdout "Bases disponibles :" + for i in "${!DBS_ARRAY[@]}"; do + print_stdout " $((i + 1))) ${DBS_ARRAY[$i]}" + done + echo + read -r -p "Sélectionnez le numéro de la base à restaurer : " DB_INDEX + [[ "$DB_INDEX" =~ ^[0-9]+$ ]] || fail "numéro de base invalide" + (( DB_INDEX >= 1 && DB_INDEX <= ${#DBS_ARRAY[@]} )) || fail "numéro hors plage" + REQUESTED_DB="${DBS_ARRAY[$((DB_INDEX - 1))]}" + else + fail "REQUESTED_DB manquante et aucune interaction terminal disponible" + fi +fi + +DB="" +for candidate in "${DBS_ARRAY[@]}"; do + if [[ "$candidate" == "$REQUESTED_DB" ]]; then + DB="$candidate" + break + fi +done + +[[ -n "$DB" ]] || fail "base refusée : non présente dans DBS" +[[ "$DB" =~ ^[a-zA-Z0-9_]+$ ]] || fail "nom de base invalide" + +log "Environnement : $ENV_NAME" +log "Base cible : $DB" +log "Request ID : ${REQUEST_ID:-N/A}" +log "Overwrite : $ALLOW_OVERWRITE" +log "Restore roles : $RESTORE_ROLES" + +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c "SELECT 1;" \ + >>"$LOG_FILE" 2>&1; then + fail "connexion PostgreSQL locale impossible avec PGUSER=${PGUSER}" +fi + +log "Test SSH vers ${REMOTE_SSH}" +if ! ssh "${SSH_OPTS[@]}" "$REMOTE_SSH" "exit 0" >>"$LOG_FILE" 2>&1; then + fail "connexion SSH impossible vers ${REMOTE_SSH}" +fi + +REMOTE_DB_DIR="${BACKUP_REMOTE_DIR}/${DB}" +REMOTE_ROLES_DIR="${BACKUP_REMOTE_DIR}/${REMOTE_ROLES_DIR_NAME}" + +LAST_REMOTE_DB_DUMP="$( + ssh "${SSH_OPTS[@]}" "$REMOTE_SSH" \ + "find '${REMOTE_DB_DIR}' -maxdepth 1 -type f -name '${DB}_*.dump' | LC_ALL=C sort | tail -n 1" +)" + +[[ -n "$LAST_REMOTE_DB_DUMP" ]] || fail "aucun dump trouvé pour ${DB} dans ${REMOTE_DB_DIR}" +log "Dernier dump sélectionné : ${LAST_REMOTE_DB_DUMP}" + +LAST_REMOTE_ROLES_FILE="" +if [[ "$RESTORE_ROLES" == "yes" ]]; then + LAST_REMOTE_ROLES_FILE="$( + ssh "${SSH_OPTS[@]}" "$REMOTE_SSH" \ + "find '${REMOTE_ROLES_DIR}' -maxdepth 1 -type f -name 'user_*.sql' | LC_ALL=C sort | tail -n 1" + )" + if [[ -n "$LAST_REMOTE_ROLES_FILE" ]]; then + log "Dernier fichier rôles sélectionné : ${LAST_REMOTE_ROLES_FILE}" + else + log "Aucun fichier rôles trouvé ; la restauration des rôles sera ignorée." + fi +else + log "Restauration des rôles désactivée." +fi + +LOCAL_DB_DUMP_FILE="${LOCAL_RESTORE_DIR}/$(basename "$LAST_REMOTE_DB_DUMP")" +LOCAL_ROLES_FILE="" + +log "Téléchargement du dump principal" +download_remote_file "$LAST_REMOTE_DB_DUMP" "$LOCAL_DB_DUMP_FILE" \ + || fail "échec téléchargement du dump principal" + +if [[ -n "$LAST_REMOTE_ROLES_FILE" ]]; then + LOCAL_ROLES_FILE="${LOCAL_RESTORE_DIR}/$(basename "$LAST_REMOTE_ROLES_FILE")" + log "Téléchargement du fichier des rôles" + download_remote_file "$LAST_REMOTE_ROLES_FILE" "$LOCAL_ROLES_FILE" \ + || fail "échec téléchargement du fichier des rôles" +fi + +DB_EXISTS="$( + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='$(sql_escape_literal "$DB")'" \ + 2>>"$LOG_FILE" || true +)" + +if [[ "$DB_EXISTS" == "1" ]]; then + if [[ "$ALLOW_OVERWRITE" != "yes" ]]; then + if [[ "$NON_INTERACTIVE" == "yes" || ! -t 0 ]]; then + fail "la base existe déjà et overwrite n'est pas autorisé" + fi + + read -r -p "La base '${DB}' existe déjà. Voulez-vous l'écraser ? (oui/non) : " CONFIRM_OVERWRITE + CONFIRM_OVERWRITE="$(to_bool_yes_no "$CONFIRM_OVERWRITE")" || fail "réponse overwrite invalide" + [[ "$CONFIRM_OVERWRITE" == "yes" ]] || fail "restauration annulée par l'utilisateur" + fi + + log "Suppression de la base existante : ${DB}" + dropdb -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" --if-exists "$DB" \ + >>"$LOG_FILE" 2>&1 || fail "échec suppression base ${DB}" +fi + +if [[ -n "$LOCAL_ROLES_FILE" ]]; then + log "Restauration des rôles depuis : ${LOCAL_ROLES_FILE}" + + FILTERED_ROLES_FILE="${LOCAL_RESTORE_DIR}/filtered_$(basename "$LOCAL_ROLES_FILE")" + ROLES_CREATE_LIST="${LOCAL_RESTORE_DIR}/roles_to_create_$(basename "$LOCAL_ROLES_FILE")" + ROLES_APPLY_FILE="${LOCAL_RESTORE_DIR}/roles_apply_$(basename "$LOCAL_ROLES_FILE")" + + if [[ -n "$EXCLUDED_ROLES_REGEX" ]]; then + grep -viE "^(CREATE ROLE|ALTER ROLE) (${EXCLUDED_ROLES_REGEX})\\b" "$LOCAL_ROLES_FILE" \ + > "$FILTERED_ROLES_FILE" || true + else + cp "$LOCAL_ROLES_FILE" "$FILTERED_ROLES_FILE" + fi + + # Une exécution sous un rôle non superuser ne peut pas restaurer l'attribut + # SUPERUSER ; on ignore donc ces lignes pour laisser passer le reste. + sed -i -E '/^ALTER ROLE .* (NO)?SUPERUSER\b/d' "$FILTERED_ROLES_FILE" + + log "Fichier des rôles filtré généré : ${FILTERED_ROLES_FILE}" + + sed -nE 's/^CREATE ROLE "?([^" ;]+)"?;$/\1/p' "$FILTERED_ROLES_FILE" \ + > "$ROLES_CREATE_LIST" || true + + if [[ -s "$ROLES_CREATE_LIST" ]]; then + while IFS= read -r role_name; do + [[ -n "$role_name" ]] || continue + [[ "$role_name" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || { + log "Rôle ignoré car non conforme : ${role_name}" + continue + } + + ROLE_EXISTS="$( + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname='$(sql_escape_literal "$role_name")'" \ + 2>>"$LOG_FILE" || true + )" + + if [[ "$ROLE_EXISTS" != "1" ]]; then + log "Création du rôle manquant : ${role_name}" + psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres \ + -c "CREATE ROLE \"${role_name}\";" \ + >>"$LOG_FILE" 2>&1 || fail "échec création rôle ${role_name}" + else + log "Rôle déjà présent : ${role_name}" + fi + done < "$ROLES_CREATE_LIST" + fi + + grep -viE '^CREATE ROLE ' "$FILTERED_ROLES_FILE" > "$ROLES_APPLY_FILE" || true + + log "Application ALTER ROLE / privilèges / memberships" + psql -v ON_ERROR_STOP=1 \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + -d postgres \ + -f "$ROLES_APPLY_FILE" \ + >>"$LOG_FILE" 2>&1 || fail "échec restauration rôles" +else + log "Aucune restauration des rôles effectuée." +fi + +log "Création de la base : ${DB}" +createdb -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" "$DB" \ + >>"$LOG_FILE" 2>&1 || fail "échec création base ${DB}" + +log "Restauration de la base ${DB}" +pg_restore \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + -d "$DB" \ + --clean \ + --if-exists \ + --no-owner \ + --no-privileges \ + "$LOCAL_DB_DUMP_FILE" \ + >>"$LOG_FILE" 2>&1 || fail "échec restauration base ${DB}" + +send_discord_message() { + local message="$1" + local payload="" + + [[ -n "$DISCORD_WEBHOOK_URL" ]] || return 0 + + payload="$(python3 -c 'import json,sys; print(json.dumps({"content": sys.argv[1]}))' "$message")" || return 0 + + curl -sS -X POST "$DISCORD_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + >/dev/null || true +} + +SUCCESS_MESSAGE="✅ REBUILD BDD ${ENV_NAME} +Base restaurée : ${DB} +Hôte PostgreSQL : ${PGHOST}:${PGPORT} +Dump utilisé : $(basename "$LAST_REMOTE_DB_DUMP") +Log : ${LOG_FILE}" + +send_discord_message "$SUCCESS_MESSAGE" + +log "Restauration terminée avec succès pour ${DB}" +print_json_and_exit "success" "restauration terminée avec succès" 0 diff --git a/RebuildBdd/run-rebuild-bdd.sh b/RebuildBdd/run-rebuild-bdd.sh new file mode 100755 index 0000000..b22687b --- /dev/null +++ b/RebuildBdd/run-rebuild-bdd.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_DIR="${SCRIPT_DIR}/Config" +GLOBAL_ENV_FILE_DEFAULT="${CONFIG_DIR}/global.env" +TARGETS_DIR_DEFAULT="${CONFIG_DIR}/Targets" +GIT_TOPLEVEL="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" +LOCAL_REPO_SUBDIR_DEFAULT="" + +if [[ -n "$GIT_TOPLEVEL" && "$SCRIPT_DIR" == "$GIT_TOPLEVEL"/* ]]; then + LOCAL_REPO_SUBDIR_DEFAULT="${SCRIPT_DIR#"$GIT_TOPLEVEL"/}" +fi + +GLOBAL_ENV_FILE="${GLOBAL_ENV_FILE:-$GLOBAL_ENV_FILE_DEFAULT}" +TARGETS_DIR="${TARGETS_DIR:-$TARGETS_DIR_DEFAULT}" + +CLI_TARGET="" +CLI_DB="" +CLI_OVERWRITE="" +CLI_RESTORE_ROLES="" +CLI_REQUEST_ID="" +NON_INTERACTIVE="${NON_INTERACTIVE:-no}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --global-env-file) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --global-env-file" >&2; exit 1; } + GLOBAL_ENV_FILE="$2" + shift 2 + ;; + --targets-dir) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --targets-dir" >&2; exit 1; } + TARGETS_DIR="$2" + shift 2 + ;; + --target) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --target" >&2; exit 1; } + CLI_TARGET="$2" + shift 2 + ;; + --db) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --db" >&2; exit 1; } + CLI_DB="$2" + shift 2 + ;; + --overwrite) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --overwrite" >&2; exit 1; } + CLI_OVERWRITE="$2" + shift 2 + ;; + --restore-roles) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --restore-roles" >&2; exit 1; } + CLI_RESTORE_ROLES="$2" + shift 2 + ;; + --request-id) + [[ $# -ge 2 ]] || { echo "Argument manquant pour --request-id" >&2; exit 1; } + CLI_REQUEST_ID="$2" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE="yes" + shift + ;; + *) + echo "Argument inconnu : $1" >&2 + exit 1 + ;; + esac +done + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +fail() { + log "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "commande requise absente : $1" +} + +to_bool_yes_no() { + local v="${1:-}" + v="${v,,}" + case "$v" in + yes|y|oui|o|true|1) echo "yes" ;; + no|n|non|false|0|"") echo "no" ;; + *) return 1 ;; + esac +} + +is_tty() { + [[ -t 0 && -t 1 ]] +} + +shell_quote() { + printf "%q" "$1" +} + +cleanup() { + rm -f "${BOOTSTRAP_JSON:-}" "${REMOTE_RESULT_JSON:-}" +} +trap cleanup EXIT + +[[ -f "$GLOBAL_ENV_FILE" ]] || fail "fichier global introuvable : $GLOBAL_ENV_FILE" +[[ -d "$TARGETS_DIR" ]] || fail "dossier targets introuvable : $TARGETS_DIR" + +set -a +# shellcheck disable=SC1090 +source "$GLOBAL_ENV_FILE" +set +a + +require_cmd ssh +require_cmd git +require_cmd python3 + +TARGET="${CLI_TARGET:-${TARGET:-}}" +REQUESTED_DB="${CLI_DB:-${REQUESTED_DB:-}}" +ALLOW_OVERWRITE_RAW="${CLI_OVERWRITE:-${ALLOW_OVERWRITE:-no}}" +RESTORE_ROLES_RAW="${CLI_RESTORE_ROLES:-${RESTORE_ROLES:-yes}}" +REQUEST_ID="${CLI_REQUEST_ID:-${REQUEST_ID:-$(date '+%Y%m%d%H%M%S')_$RANDOM}}" + +ALLOW_OVERWRITE="$(to_bool_yes_no "$ALLOW_OVERWRITE_RAW")" || fail "ALLOW_OVERWRITE invalide" +RESTORE_ROLES="$(to_bool_yes_no "$RESTORE_ROLES_RAW")" || fail "RESTORE_ROLES invalide" + +if [[ -z "$TARGET" ]]; then + if [[ "$NON_INTERACTIVE" == "yes" ]]; then + fail "TARGET manquante en mode non interactif" + fi + + mapfile -t TARGET_LIST < <(find "$TARGETS_DIR" -maxdepth 1 -type f -name '*.env' -printf '%f\n' | sed 's/\.env$//' | LC_ALL=C sort) + + [[ "${#TARGET_LIST[@]}" -gt 0 ]] || fail "aucune cible définie dans ${TARGETS_DIR}" + + if is_tty; then + echo "Cibles disponibles :" + for i in "${!TARGET_LIST[@]}"; do + echo " $((i + 1))) ${TARGET_LIST[$i]}" + done + echo + read -r -p "Sélectionnez le numéro de la cible : " TARGET_INDEX + [[ "$TARGET_INDEX" =~ ^[0-9]+$ ]] || fail "numéro de cible invalide" + (( TARGET_INDEX >= 1 && TARGET_INDEX <= ${#TARGET_LIST[@]} )) || fail "numéro hors plage" + TARGET="${TARGET_LIST[$((TARGET_INDEX - 1))]}" + else + fail "TARGET manquante et aucune interaction terminal disponible" + fi +fi + +TARGET_ENV_SOURCE="${TARGETS_DIR}/${TARGET}.env" +[[ -f "$TARGET_ENV_SOURCE" ]] || fail "fichier cible introuvable : $TARGET_ENV_SOURCE" + +set -a +# shellcheck disable=SC1090 +source "$TARGET_ENV_SOURCE" +set +a + +TARGET_HOST="${TARGET_HOST:-}" +TARGET_PORT="${TARGET_PORT:-22}" +TARGET_USER="${TARGET_BOOTSTRAP_USER:-}" +TARGET_SSH_KEY="${TARGET_BOOTSTRAP_SSH_KEY:-}" +TARGET_REPO_URL="${TARGET_REPO_URL:-${GLOBAL_REPO_URL:-}}" +TARGET_REPO_BRANCH="${TARGET_REPO_BRANCH:-${GLOBAL_REPO_BRANCH:-main}}" +TARGET_REPO_DIR="${TARGET_REPO_DIR:-}" +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR:-$LOCAL_REPO_SUBDIR_DEFAULT}" +TARGET_ENV_FILE="${TARGET_ENV_FILE:-}" +TARGET_ENABLE_BOOTSTRAP="${TARGET_ENABLE_BOOTSTRAP:-${GLOBAL_ENABLE_BOOTSTRAP:-yes}}" + +[[ -n "$TARGET_HOST" ]] || fail "TARGET_HOST manquante" +[[ "$TARGET_PORT" =~ ^[0-9]+$ ]] || fail "TARGET_PORT invalide" +[[ -n "$TARGET_USER" ]] || fail "TARGET_BOOTSTRAP_USER manquante" +[[ -n "$TARGET_SSH_KEY" ]] || fail "TARGET_BOOTSTRAP_SSH_KEY manquante" +[[ -f "$TARGET_SSH_KEY" ]] || fail "clé SSH cible introuvable : $TARGET_SSH_KEY" +[[ -r "$TARGET_SSH_KEY" ]] || fail "clé SSH cible non lisible : $TARGET_SSH_KEY" + +[[ -n "$TARGET_REPO_URL" ]] || fail "GLOBAL_REPO_URL/TARGET_REPO_URL manquant" +[[ -n "$TARGET_REPO_BRANCH" ]] || fail "GLOBAL_REPO_BRANCH/TARGET_REPO_BRANCH manquant" +[[ -n "$TARGET_REPO_DIR" ]] || fail "TARGET_REPO_DIR manquante" +[[ -n "$TARGET_ENV_FILE" ]] || fail "TARGET_ENV_FILE manquante" + +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR#/}" +TARGET_REPO_SUBDIR="${TARGET_REPO_SUBDIR%/}" + +TARGET_CLONE_DIR="$TARGET_REPO_DIR" +TARGET_SCRIPT_DIR="$TARGET_REPO_DIR" +if [[ -n "$TARGET_REPO_SUBDIR" ]]; then + if [[ "$TARGET_REPO_DIR" == */"$TARGET_REPO_SUBDIR" ]]; then + TARGET_CLONE_DIR="$(dirname "$TARGET_REPO_DIR")" + else + TARGET_SCRIPT_DIR="${TARGET_REPO_DIR}/${TARGET_REPO_SUBDIR}" + fi +fi + +TARGET_ENABLE_BOOTSTRAP="$(to_bool_yes_no "$TARGET_ENABLE_BOOTSTRAP")" || fail "TARGET_ENABLE_BOOTSTRAP invalide" + +BOOTSTRAP_SCRIPT_LOCAL="${SCRIPT_DIR}/bootstrap-target-host.sh" +[[ -f "$BOOTSTRAP_SCRIPT_LOCAL" ]] || fail "script bootstrap introuvable : $BOOTSTRAP_SCRIPT_LOCAL" +[[ -x "$BOOTSTRAP_SCRIPT_LOCAL" ]] || chmod 700 "$BOOTSTRAP_SCRIPT_LOCAL" || fail "chmod impossible sur $BOOTSTRAP_SCRIPT_LOCAL" + +if [[ -z "$REQUESTED_DB" ]]; then + DBS_FOR_TARGET="${TARGET_DBS:-}" + if [[ "$NON_INTERACTIVE" == "yes" ]]; then + fail "REQUESTED_DB manquante en mode non interactif" + fi + + read -r -a DBS_ARRAY <<< "$DBS_FOR_TARGET" + [[ "${#DBS_ARRAY[@]}" -gt 0 ]] || fail "TARGET_DBS vide" + + if is_tty; then + echo "Bases disponibles :" + for i in "${!DBS_ARRAY[@]}"; do + echo " $((i + 1))) ${DBS_ARRAY[$i]}" + done + echo + read -r -p "Nom exact de la base à restaurer : " REQUESTED_DB + else + fail "REQUESTED_DB manquante et aucune interaction terminal disponible" + fi +fi + +[[ "$REQUESTED_DB" =~ ^[a-zA-Z0-9_]+$ ]] || fail "nom de base invalide" + +if [[ "$TARGET_ENABLE_BOOTSTRAP" == "yes" ]]; then + log "Bootstrap initial activé pour la cible ${TARGET}" + BOOTSTRAP_JSON="/tmp/bootstrap_target_${REQUEST_ID}.json" + + "$BOOTSTRAP_SCRIPT_LOCAL" \ + --global-env-file "$GLOBAL_ENV_FILE" \ + --targets-dir "$TARGETS_DIR" \ + --target "$TARGET" \ + --json-only >"$BOOTSTRAP_JSON" || { + cat "$BOOTSTRAP_JSON" 2>/dev/null || true + fail "échec du bootstrap initial de la cible ${TARGET}" + } + + BOOTSTRAP_STATUS="$( + python3 - <<'PY' "$BOOTSTRAP_JSON" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get("status", "error")) +PY + )" + + if [[ "$BOOTSTRAP_STATUS" != "success" ]]; then + cat "$BOOTSTRAP_JSON" + fail "bootstrap initial échoué pour la cible ${TARGET}" + fi + + log "Bootstrap initial terminé pour ${TARGET}" +else + log "Bootstrap initial désactivé pour ${TARGET}" +fi + +SSH_OPTS=( + -i "$TARGET_SSH_KEY" + -p "$TARGET_PORT" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o StrictHostKeyChecking=accept-new + -o ConnectTimeout=8 +) + +ssh "${SSH_OPTS[@]}" "${TARGET_USER}@${TARGET_HOST}" "exit 0" >/dev/null 2>&1 \ + || fail "connexion SSH impossible vers la cible ${TARGET_USER}@${TARGET_HOST}" + +TARGET_CORE_SCRIPT="${TARGET_SCRIPT_DIR}/rebuild-bdd-core.sh" +REMOTE_RESULT_JSON="/tmp/run_rebuild_bdd_${REQUEST_ID}.json" + +REMOTE_BOOTSTRAP_CMD=" +set -euo pipefail + +CLONE_DIR=$(shell_quote "$TARGET_CLONE_DIR") +REPO_DIR=$(shell_quote "$TARGET_SCRIPT_DIR") +REPO_URL=$(shell_quote "$TARGET_REPO_URL") +REPO_BRANCH=$(shell_quote "$TARGET_REPO_BRANCH") +CORE_SCRIPT=$(shell_quote "$TARGET_CORE_SCRIPT") +PRECHECK_SCRIPT=$(shell_quote "${TARGET_SCRIPT_DIR}/Checkup/check-target-readiness.sh") +TARGET_ENV_FILE=$(shell_quote "$TARGET_ENV_FILE") +REQUESTED_DB=$(shell_quote "$REQUESTED_DB") +ALLOW_OVERWRITE=$(shell_quote "$ALLOW_OVERWRITE") +RESTORE_ROLES=$(shell_quote "$RESTORE_ROLES") +REQUEST_ID=$(shell_quote "$REQUEST_ID") + +command -v git >/dev/null 2>&1 || { echo '{\"status\":\"error\",\"message\":\"git absent sur la cible\"}'; exit 1; } +command -v bash >/dev/null 2>&1 || { echo '{\"status\":\"error\",\"message\":\"bash absent sur la cible\"}'; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo '{\"status\":\"error\",\"message\":\"python3 absent sur la cible\"}'; exit 1; } + +mkdir -p \"\$(dirname \"\$CLONE_DIR\")\" +mkdir -p \"\$(dirname \"\$REPO_DIR\")\" + +if [[ ! -d \"\$CLONE_DIR/.git\" ]]; then + rm -rf \"\$CLONE_DIR\" + git clone --branch \"\$REPO_BRANCH\" --single-branch \"\$REPO_URL\" \"\$CLONE_DIR\" >/dev/null 2>&1 +else + git -C \"\$CLONE_DIR\" fetch --prune origin >/dev/null 2>&1 + git -C \"\$CLONE_DIR\" checkout -f \"\$REPO_BRANCH\" >/dev/null 2>&1 + git -C \"\$CLONE_DIR\" reset --hard \"origin/\$REPO_BRANCH\" >/dev/null 2>&1 +fi + +[[ -f \"\$CORE_SCRIPT\" ]] || { echo '{\"status\":\"error\",\"message\":\"script core introuvable sur la cible\"}'; exit 1; } +[[ -f \"\$PRECHECK_SCRIPT\" ]] || { echo '{\"status\":\"error\",\"message\":\"script précheck introuvable sur la cible\"}'; exit 1; } + +chmod 700 \"\$CORE_SCRIPT\" +chmod 700 \"\$PRECHECK_SCRIPT\" + +PRECHECK_JSON=\"/tmp/check_target_\${REQUEST_ID}.json\" +PRECHECK_STDERR=\"/tmp/check_target_\${REQUEST_ID}.stderr\" +CORE_JSON=\"/tmp/rebuild_target_\${REQUEST_ID}.json\" +CORE_STDERR=\"/tmp/rebuild_target_\${REQUEST_ID}.stderr\" + +\"\$PRECHECK_SCRIPT\" \ + --env-file \"\$TARGET_ENV_FILE\" \ + --request-id \"\$REQUEST_ID\" \ + --non-interactive \ + --json-only >\"\$PRECHECK_JSON\" 2>\"\$PRECHECK_STDERR\" || { + cat \"\$PRECHECK_STDERR\" >&2 2>/dev/null || true + cat \"\$PRECHECK_JSON\" 2>/dev/null || true + rm -f \"\$PRECHECK_JSON\" \"\$PRECHECK_STDERR\" \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 + } + +PRECHECK_STATUS=\"\$(python3 - <<'PY' \"\$PRECHECK_JSON\" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get('status', 'error')) +PY +)\" || { + cat \"\$PRECHECK_STDERR\" >&2 2>/dev/null || true + cat \"\$PRECHECK_JSON\" 2>/dev/null || true + rm -f \"\$PRECHECK_JSON\" \"\$PRECHECK_STDERR\" \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 +} + +if [[ \"\$PRECHECK_STATUS\" != \"success\" ]]; then + cat \"\$PRECHECK_STDERR\" >&2 2>/dev/null || true + cat \"\$PRECHECK_JSON\" + rm -f \"\$PRECHECK_JSON\" \"\$PRECHECK_STDERR\" \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 +fi + +rm -f \"\$PRECHECK_JSON\" \"\$PRECHECK_STDERR\" + +\"\$CORE_SCRIPT\" \ + --env-file \"\$TARGET_ENV_FILE\" \ + --db \"\$REQUESTED_DB\" \ + --overwrite \"\$ALLOW_OVERWRITE\" \ + --restore-roles \"\$RESTORE_ROLES\" \ + --request-id \"\$REQUEST_ID\" \ + --non-interactive \ + --json-only >\"\$CORE_JSON\" 2>\"\$CORE_STDERR\" || { + cat \"\$CORE_STDERR\" >&2 2>/dev/null || true + cat \"\$CORE_JSON\" 2>/dev/null || true + rm -f \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 + } + +CORE_STATUS=\"\$(python3 - <<'PY' \"\$CORE_JSON\" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get('status', 'error')) +PY +)\" || { + cat \"\$CORE_STDERR\" >&2 2>/dev/null || true + cat \"\$CORE_JSON\" 2>/dev/null || true + rm -f \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 +} + +if [[ \"\$CORE_STATUS\" != \"success\" ]]; then + cat \"\$CORE_STDERR\" >&2 2>/dev/null || true + cat \"\$CORE_JSON\" + rm -f \"\$CORE_JSON\" \"\$CORE_STDERR\" + exit 1 +fi + +cat \"\$CORE_JSON\" +rm -f \"\$CORE_JSON\" \"\$CORE_STDERR\" +" + +ssh "${SSH_OPTS[@]}" "${TARGET_USER}@${TARGET_HOST}" "$REMOTE_BOOTSTRAP_CMD" >"$REMOTE_RESULT_JSON" 2>&1 \ + || { + cat "$REMOTE_RESULT_JSON" 2>/dev/null || true + fail "échec d'exécution distante sur la cible ${TARGET}" + } + +REMOTE_STATUS="$( + python3 - <<'PY' "$REMOTE_RESULT_JSON" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get("status", "error")) +PY +)" || { + cat "$REMOTE_RESULT_JSON" 2>/dev/null || true + fail "réponse JSON invalide renvoyée par la cible ${TARGET}" +} + +if [[ "$REMOTE_STATUS" != "success" ]]; then + cat "$REMOTE_RESULT_JSON" + fail "restauration distante échouée pour la cible ${TARGET}" +fi + +python3 - <<'PY' "$REMOTE_RESULT_JSON" +import json, sys + +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) + +message = data.get("message", "restauration terminée") +environment = data.get("environment") or "N/A" +database = data.get("database") or "N/A" +request_id = data.get("request_id") or "N/A" +dump_file = data.get("dump_file") or "N/A" +log_file = data.get("log_file") or "N/A" + +print(f"[{request_id}] {message}") +print(f"Environnement : {environment}") +print(f"Base : {database}") +print(f"Dump : {dump_file}") +print(f"Log : {log_file}") +PY diff --git a/RecetteScripts/README.md b/RecetteScripts/README.md index 5d8c69d..a211530 100644 --- a/RecetteScripts/README.md +++ b/RecetteScripts/README.md @@ -1,4 +1,4 @@ -# RecetteScripts +# RecetteScripts Scripts Bash permettant d’automatiser la gestion d’un environnement **PostgreSQL de recette**. diff --git a/RecetteScripts/backup-bdd-recette.sh b/RecetteScripts/backup-bdd-recette.sh old mode 100644 new mode 100755 diff --git a/RecetteScripts/check-statut-recette.sh b/RecetteScripts/check-statut-recette.sh old mode 100644 new mode 100755