#!/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" 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" cat "$local_file" | ssh "${SSH_OPTS[@]}" "$REMOTE" " set -euo pipefail cat > $(shell_quote "$remote_tmp") " >/dev/null 2>&1 || fail "échec de copie distante via SSH vers ${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_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" [[ -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 -n \"\$@\" || { echo 'sudo -n 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_REPO_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" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SETUP_CMD" \ || fail "échec de préparation système distante" 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_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 -n \"\$@\" || { echo 'sudo -n 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 non interactif minimal" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_SUDOERS_CMD" \ || fail "échec d'installation du sudoers non interactif" else log "Installation du sudoers non interactif désactivée." fi REMOTE_REPO_CMD=" set -euo pipefail if [[ ! -d $(shell_quote "${TARGET_REPO_DIR}/.git") ]]; then rm -rf $(shell_quote "$TARGET_REPO_DIR") git clone --branch $(shell_quote "$TARGET_REPO_BRANCH") --single-branch $(shell_quote "$TARGET_REPO_URL") $(shell_quote "$TARGET_REPO_DIR") else git -C $(shell_quote "$TARGET_REPO_DIR") fetch --prune origin git -C $(shell_quote "$TARGET_REPO_DIR") checkout -f $(shell_quote "$TARGET_REPO_BRANCH") git -C $(shell_quote "$TARGET_REPO_DIR") reset --hard origin/$(shell_quote "$TARGET_REPO_BRANCH") fi chmod 700 $(shell_quote "$TARGET_REPO_DIR/run-rebuild-bdd.sh") 2>/dev/null || true chmod 700 $(shell_quote "$TARGET_REPO_DIR/rebuild-bdd-core.sh") 2>/dev/null || true chmod 700 $(shell_quote "$TARGET_REPO_DIR/Checkup/check-postgresql.sh") 2>/dev/null || true chmod 700 $(shell_quote "$TARGET_REPO_DIR/Checkup/check-target-readiness.sh") 2>/dev/null || true " log "Clone / mise à jour du dépôt distant" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_REPO_CMD" \ || fail "échec de synchronisation du dépôt sur la cible" 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 -n true >/dev/null 2>&1 || { echo 'sudo -n indisponible' >&2 exit 1 } " log "Validation initiale de sudo -n" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_VALIDATE_SUDO_ROOT_CMD" \ || fail "sudo -n invalide sur la cible" REMOTE_RUN_CHECK_PG_CMD=" set -euo pipefail CHECK_SCRIPT=$(shell_quote "${TARGET_REPO_DIR}/Checkup/check-postgresql.sh") ENV_FILE=$(shell_quote "$TARGET_ENV_FILE_PATH") [[ -x \"\$CHECK_SCRIPT\" ]] || chmod 700 \"\$CHECK_SCRIPT\" \"\$CHECK_SCRIPT\" --env-file \"\$ENV_FILE\" --non-interactive " log "Préparation PostgreSQL via check-postgresql.sh" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_RUN_CHECK_PG_CMD" \ || fail "échec de préparation PostgreSQL pendant le bootstrap" REMOTE_VALIDATE_SUDO_POSTGRES_CMD=" set -euo pipefail sudo -n -u postgres true >/dev/null 2>&1 || { echo 'sudo -n -u postgres indisponible après préparation PostgreSQL' >&2 exit 1 } " log "Validation finale de sudo -n -u postgres" ssh "${SSH_OPTS[@]}" "$REMOTE" "$REMOTE_VALIDATE_SUDO_POSTGRES_CMD" \ || fail "sudo -n -u postgres invalide sur la cible" success "bootstrap initial terminé pour ${TARGET_NAME}"