#!/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" ;; # Valeur vide traitée comme "no" pour conserver le comportement historique. 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}}" LOG_DIR="${RUN_REBUILD_BDD_LOG_DIR:-${SCRIPT_DIR}/logs}" mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/run_rebuild_bdd_${REQUEST_ID}.log" exec > >(tee -a "$LOG_FILE") 2>&1 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 for critical_dir in "$TARGET_CLONE_DIR" "$TARGET_SCRIPT_DIR" "$TARGET_REPO_DIR"; do [[ -n "$critical_dir" ]] || fail "répertoire critique vide" [[ "$critical_dir" != "/" ]] || fail "répertoire critique dangereux refusé : $critical_dir" [[ "$critical_dir" != "/root" ]] || fail "répertoire critique dangereux refusé : $critical_dir" [[ "$critical_dir" != "/home" ]] || fail "répertoire critique dangereux refusé : $critical_dir" [[ ! "$critical_dir" =~ ^/home/[^/]+$ ]] || fail "répertoire critique dangereux refusé : $critical_dir" done 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=yes -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 if [[ \"\$CLONE_DIR\" == / || \"\$CLONE_DIR\" == /root || \"\$CLONE_DIR\" == /home || \"\$CLONE_DIR\" =~ ^/home/[^/]+$ ]]; then echo '{\"status\":\"error\",\"message\":\"TARGET_CLONE_DIR dangereux refusé\"}' exit 1 fi 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