diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..9a41b00 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,105 @@ +name: Tests et Vérifications + +on: + push: + branches: [ main, 2.1 ] + pull_request: + branches: [ main ] + +jobs: + tests: + name: Tests unitaires BATS + runs-on: self-hosted + + steps: + - name: Checkout du code + uses: actions/checkout@v3 + + - name: Installation de BATS + run: | + sudo apt-get update + sudo apt-get install -y bats + + - name: Afficher version BATS + run: bats --version + + - name: Exécuter tests unitaires + run: | + cd tests + bats *.bats + + - name: Upload résultats des tests + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: tests/*.log + retention-days: 7 + + shellcheck: + name: Vérification ShellCheck + runs-on: self-hosted + + steps: + - name: Checkout du code + uses: actions/checkout@v3 + + - name: Installation de ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Afficher version ShellCheck + run: shellcheck --version + + - name: Vérifier le script principal + run: | + shellcheck -x zfs-nfs-replica.sh || true + + - name: Vérifier les scripts de test + run: | + shellcheck -x tests/*.bash || true + + syntax: + name: Vérification syntaxe Bash + runs-on: self-hosted + + steps: + - name: Checkout du code + uses: actions/checkout@v3 + + - name: Vérifier syntaxe du script principal + run: | + bash -n zfs-nfs-replica.sh + + - name: Vérifier syntaxe des helpers de test + run: | + bash -n tests/test_helper.bash + + summary: + name: Résumé des tests + runs-on: self-hosted + needs: [tests, shellcheck, syntax] + if: always() + + steps: + - name: Afficher résumé + run: | + echo "==========================================" + echo "RÉSUMÉ DES TESTS" + echo "==========================================" + echo "" + echo "Tests unitaires: ${{ needs.tests.result }}" + echo "ShellCheck: ${{ needs.shellcheck.result }}" + echo "Syntaxe Bash: ${{ needs.syntax.result }}" + echo "" + + if [[ "${{ needs.tests.result }}" == "success" ]] && \ + [[ "${{ needs.shellcheck.result }}" == "success" ]] && \ + [[ "${{ needs.syntax.result }}" == "success" ]]; then + echo "✓ Tous les tests sont passés avec succès" + exit 0 + else + echo "✗ Certains tests ont échoué" + exit 1 + fi diff --git a/tests/fixtures/zfs_list_snapshots.txt b/tests/fixtures/zfs_list_snapshots.txt new file mode 100644 index 0000000..74ac9a4 --- /dev/null +++ b/tests/fixtures/zfs_list_snapshots.txt @@ -0,0 +1,9 @@ +NAME USED AVAIL REFER MOUNTPOINT +zpool1 5.12T 2.55T 192K /zpool1 +zpool1/data-nfs-share 4.89T 2.55T 4.89T /zpool1/data-nfs-share +zpool1/data-nfs-share@autosnap_2024-12-29_14:00:00 128M - 4.89T - +zpool1/data-nfs-share@autosnap_2024-12-29_14:15:00 256M - 4.89T - +zpool1/data-nfs-share@autosnap_2024-12-29_14:30:00 64M - 4.89T - +zpool1/pbs-backups 230G 2.55T 230G /zpool1/pbs-backups +zpool1/pbs-backups@autosnap_2024-12-29_14:00:00 10M - 230G - +zpool1/pbs-backups@autosnap_2024-12-29_14:15:00 15M - 230G - diff --git a/tests/fixtures/zpool_status_degraded.txt b/tests/fixtures/zpool_status_degraded.txt new file mode 100644 index 0000000..163dd6d --- /dev/null +++ b/tests/fixtures/zpool_status_degraded.txt @@ -0,0 +1,15 @@ + pool: zpool1 + state: DEGRADED +status: One or more devices has been removed by the administrator. + Sufficient replicas exist for the pool to continue functioning in a + degraded state. +action: Online the device using 'zpool online' or replace the device with + 'zpool replace'. + scan: scrub repaired 0B in 0 days 02:15:32 with 0 errors on Sun Dec 15 02:39:32 2024 +config: + + NAME STATE READ WRITE CKSUM + zpool1 DEGRADED 0 0 0 + /dev/disk/by-id/wwn-0x5000cca2dfe2e414 UNAVAIL 0 0 0 + +errors: No known data errors diff --git a/tests/fixtures/zpool_status_healthy.txt b/tests/fixtures/zpool_status_healthy.txt new file mode 100644 index 0000000..0a30738 --- /dev/null +++ b/tests/fixtures/zpool_status_healthy.txt @@ -0,0 +1,10 @@ + pool: zpool1 + state: ONLINE + scan: scrub repaired 0B in 0 days 02:15:32 with 0 errors on Sun Dec 15 02:39:32 2024 +config: + + NAME STATE READ WRITE CKSUM + zpool1 ONLINE 0 0 0 + /dev/disk/by-id/wwn-0x5000cca2dfe2e414 ONLINE 0 0 0 + +errors: No known data errors diff --git a/tests/test_health_checks.bats b/tests/test_health_checks.bats new file mode 100644 index 0000000..21d0f3f --- /dev/null +++ b/tests/test_health_checks.bats @@ -0,0 +1,300 @@ +#!/usr/bin/env bats +# +# Tests unitaires pour les fonctions de health check +# Test des vérifications de santé des disques et pools ZFS +# + +load test_helper + +# Charger uniquement les fonctions du script (pas le code principal) +setup() { + # Setup environnement + setup_script_env + + # Sourcer le script en mode test (le BATS_TEST_MODE évite l'exécution du main) + export BATS_TEST_MODE=true + source "${BATS_TEST_DIRNAME}/../zfs-nfs-replica.sh" +} + +teardown() { + cleanup_script_env +} + +# ============================================================================ +# Tests: get_pool_disk_uuids() +# ============================================================================ + +@test "get_pool_disk_uuids: retourne des UUIDs pour un pool sain" { + run get_pool_disk_uuids "zpool1" + + [ "$status" -eq 0 ] + [[ "$output" =~ "wwn-0x5000cca2dfe2e414" ]] +} + +@test "get_pool_disk_uuids: retourne vide pour pool inexistant" { + # Mock zpool pour retourner une erreur + zpool() { + if [[ "$1" == "status" ]]; then + echo "cannot open 'fakerpool': no such pool" >&2 + return 1 + fi + } + export -f zpool + + run get_pool_disk_uuids "fakerpool" + + # La fonction doit gérer l'erreur gracieusement + [ "$status" -ne 0 ] || [ -z "$output" ] +} + +# ============================================================================ +# Tests: init_disk_tracking() +# ============================================================================ + +@test "init_disk_tracking: crée le fichier d'état avec UUIDs" { + run init_disk_tracking "zpool1" + + [ "$status" -eq 0 ] + [ -f "${STATE_DIR}/disk-uuids-zpool1.txt" ] + + # Vérifier le contenu + grep -q "initialized=true" "${STATE_DIR}/disk-uuids-zpool1.txt" + grep -q "pool=zpool1" "${STATE_DIR}/disk-uuids-zpool1.txt" + grep -q "wwn-0x" "${STATE_DIR}/disk-uuids-zpool1.txt" +} + +@test "init_disk_tracking: ne réinitialise pas si déjà initialisé" { + # Créer un fichier déjà initialisé + create_disk_uuid_file "zpool1" + + # Modifier le timestamp pour vérifier qu'il ne change pas + original_content=$(cat "${STATE_DIR}/disk-uuids-zpool1.txt") + + run init_disk_tracking "zpool1" + + [ "$status" -eq 0 ] + + # Le fichier ne doit pas avoir changé + new_content=$(cat "${STATE_DIR}/disk-uuids-zpool1.txt") + [ "$original_content" == "$new_content" ] +} + +# ============================================================================ +# Tests: verify_disk_presence() +# ============================================================================ + +@test "verify_disk_presence: succès si tous les disques présents" { + create_disk_uuid_file "zpool1" "wwn-0x5000cca2dfe2e414" + export TEST_DISK_PRESENT=true + + run verify_disk_presence "zpool1" + + [ "$status" -eq 0 ] +} + +@test "verify_disk_presence: échec si disque manquant" { + # Créer un fichier avec UUID fictif + create_disk_uuid_file "zpool1" "wwn-0xFAKE_MISSING_DISK" + export TEST_DISK_PRESENT=false + + run verify_disk_presence "zpool1" + + [ "$status" -eq 1 ] + [[ "$output" =~ "manquant" ]] || [[ "$output" =~ "MISSING" ]] +} + +@test "verify_disk_presence: retourne erreur si fichier d'état absent" { + # Pas de fichier disk-uuids + rm -f "${STATE_DIR}/disk-uuids-zpool1.txt" + + run verify_disk_presence "zpool1" + + [ "$status" -eq 1 ] +} + +# ============================================================================ +# Tests: check_pool_health_status() +# ============================================================================ + +@test "check_pool_health_status: succès pour pool ONLINE avec espace libre" { + export TEST_POOL_STATE="ONLINE" + export TEST_POOL_CAPACITY=67 + + run check_pool_health_status "zpool1" + + [ "$status" -eq 0 ] +} + +@test "check_pool_health_status: échec pour pool DEGRADED" { + export TEST_POOL_STATE="DEGRADED" + export TEST_POOL_CAPACITY=67 + + run check_pool_health_status "zpool1" + + [ "$status" -eq 1 ] +} + +@test "check_pool_health_status: échec si espace disque critique (>95%)" { + export TEST_POOL_STATE="ONLINE" + export TEST_POOL_CAPACITY=96 + + run check_pool_health_status "zpool1" + + [ "$status" -eq 1 ] + [[ "$output" =~ "espace libre" ]] || [[ "$output" =~ "capacity" ]] +} + +@test "check_pool_health_status: succès avec exactement 95% (limite)" { + export TEST_POOL_STATE="ONLINE" + export TEST_POOL_CAPACITY=95 + + run check_pool_health_status "zpool1" + + # 95% = 5% libre, c'est la limite, doit passer + [ "$status" -eq 0 ] +} + +# ============================================================================ +# Tests: triple_health_check() +# ============================================================================ + +@test "triple_health_check: succès si 3/3 tentatives réussissent" { + create_disk_uuid_file "zpool1" + export TEST_POOL_STATE="ONLINE" + export TEST_POOL_CAPACITY=67 + export TEST_DISK_PRESENT=true + export CHECK_DELAY=0 # Pas de délai dans tests + + run triple_health_check "zpool1" + + [ "$status" -eq 0 ] +} + +@test "triple_health_check: échec si les 3 tentatives échouent" { + create_disk_uuid_file "zpool1" "wwn-0xFAKE_MISSING" + export TEST_DISK_PRESENT=false + export CHECK_DELAY=0 + + run triple_health_check "zpool1" + + [ "$status" -eq 1 ] +} + +@test "triple_health_check: fait vraiment 3 tentatives (pas d'early return)" { + create_disk_uuid_file "zpool1" + export TEST_POOL_STATE="DEGRADED" + export TEST_DISK_PRESENT=true + export CHECK_DELAY=0 + + run triple_health_check "zpool1" + + [ "$status" -eq 1 ] + + # Vérifier qu'il y a bien 3 lignes d'erreur (3 tentatives) + attempt_count=$(echo "$output" | grep -c "Vérification santé #" || echo "0") + [ "$attempt_count" -eq 3 ] +} + +# ============================================================================ +# Tests: check_recent_critical_error() +# ============================================================================ + +@test "check_recent_critical_error: retourne 0 si erreur récente (<1h)" { + # Erreur il y a 30 minutes (1800 secondes) + local current_epoch=1735481400 + local error_epoch=$((current_epoch - 1800)) + + export TEST_CURRENT_EPOCH=$current_epoch + create_critical_error_file "zpool1" "$error_epoch" + + run check_recent_critical_error "zpool1" + + [ "$status" -eq 0 ] +} + +@test "check_recent_critical_error: retourne 1 si erreur ancienne (>1h)" { + # Erreur il y a 2 heures (7200 secondes) + local current_epoch=1735481400 + local error_epoch=$((current_epoch - 7200)) + + export TEST_CURRENT_EPOCH=$current_epoch + create_critical_error_file "zpool1" "$error_epoch" + + run check_recent_critical_error "zpool1" + + [ "$status" -eq 1 ] +} + +@test "check_recent_critical_error: retourne 1 si pas de fichier d'erreur" { + rm -f "${STATE_DIR}/critical-errors-zpool1.txt" + + run check_recent_critical_error "zpool1" + + [ "$status" -eq 1 ] +} + +# ============================================================================ +# Tests: record_critical_error() +# ============================================================================ + +@test "record_critical_error: crée fichier avec toutes les infos" { + run record_critical_error "zpool1" "Test failure reason" "lxc_migrated" + + [ "$status" -eq 0 ] + [ -f "${STATE_DIR}/critical-errors-zpool1.txt" ] + + grep -q "reason=Test failure reason" "${STATE_DIR}/critical-errors-zpool1.txt" + grep -q "action=lxc_migrated" "${STATE_DIR}/critical-errors-zpool1.txt" + grep -q "epoch=" "${STATE_DIR}/critical-errors-zpool1.txt" +} + +@test "record_critical_error: écrase le fichier précédent" { + # Créer une première erreur + create_critical_error_file "zpool1" "1735400000" + + # Enregistrer une nouvelle erreur + run record_critical_error "zpool1" "New error" "lxc_stopped" + + [ "$status" -eq 0 ] + + # Vérifier que c'est la nouvelle erreur + grep -q "reason=New error" "${STATE_DIR}/critical-errors-zpool1.txt" + grep -q "action=lxc_stopped" "${STATE_DIR}/critical-errors-zpool1.txt" +} + +# ============================================================================ +# Tests: handle_health_failure() +# ============================================================================ + +@test "handle_health_failure: migre le LXC si première erreur" { + # Pas d'erreur récente + rm -f "${STATE_DIR}/critical-errors-zpool1.txt" + + export REMOTE_NODE_NAME="acemagician" + + run handle_health_failure "zpool1" "Disk failure" + + [ "$status" -eq 0 ] + [[ "$output" =~ "MIGRATION" ]] || [[ "$output" =~ "migrate" ]] + + # Vérifier que l'erreur a été enregistrée + [ -f "${STATE_DIR}/critical-errors-zpool1.txt" ] + grep -q "action=lxc_migrated" "${STATE_DIR}/critical-errors-zpool1.txt" +} + +@test "handle_health_failure: arrête le LXC si erreur récente (<1h)" { + # Erreur récente (30 min) + local current_epoch=1735481400 + local error_epoch=$((current_epoch - 1800)) + + export TEST_CURRENT_EPOCH=$current_epoch + create_critical_error_file "zpool1" "$error_epoch" + + run handle_health_failure "zpool1" "Another disk failure" + + [ "$status" -eq 0 ] + [[ "$output" =~ "ARRÊT" ]] || [[ "$output" =~ "stop" ]] || [[ "$output" =~ "ping-pong" ]] + + # Vérifier que l'erreur a été mise à jour + grep -q "action=lxc_stopped" "${STATE_DIR}/critical-errors-zpool1.txt" +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..db5103d --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,261 @@ +#!/bin/bash +# +# Helpers et mocks pour les tests BATS +# Ce fichier fournit des simulations de toutes les commandes système +# utilisées par zfs-nfs-replica.sh, permettant de tester sans ZFS réel +# + +# Variables globales pour les tests +export TEST_FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures" +export TEST_POOL_STATE="${TEST_POOL_STATE:-ONLINE}" +export TEST_POOL_CAPACITY="${TEST_POOL_CAPACITY:-67}" +export TEST_LXC_STATUS="${TEST_LXC_STATUS:-running}" +export TEST_DISK_PRESENT="${TEST_DISK_PRESENT:-true}" + +# Mock: zpool - Simuler les commandes ZFS pool +zpool() { + case "$1" in + status) + if [[ "$2" == "-P" ]]; then + # Format détaillé avec chemins physiques + if [[ "$TEST_POOL_STATE" == "ONLINE" ]]; then + cat "${TEST_FIXTURES_DIR}/zpool_status_healthy.txt" + else + cat "${TEST_FIXTURES_DIR}/zpool_status_degraded.txt" + fi + else + # Format simple + echo "zpool1 ${TEST_POOL_STATE} - - -" + fi + ;; + list) + local format="${4:-name,health}" + if [[ "$3" == "-o" ]]; then + case "$4" in + health) + echo "${TEST_POOL_STATE}" + ;; + capacity) + echo "${TEST_POOL_CAPACITY}%" + ;; + *) + echo "zpool1" + ;; + esac + else + echo "zpool1 7.67T 5.12T 2.55T ${TEST_POOL_CAPACITY}% ${TEST_POOL_STATE} -" + fi + ;; + import) + echo "pool zpool1 imported" + return 0 + ;; + *) + echo "Mock zpool: commande non supportée: $*" >&2 + return 1 + ;; + esac +} + +# Mock: zfs - Simuler les commandes ZFS dataset +zfs() { + case "$1" in + list) + if [[ "$2" == "-t" && "$3" == "snapshot" ]]; then + cat "${TEST_FIXTURES_DIR}/zfs_list_snapshots.txt" + else + cat "${TEST_FIXTURES_DIR}/zfs_list_snapshots.txt" + fi + ;; + get) + echo "5120000000000" # 5.12TB en bytes + ;; + *) + echo "Mock zfs: commande non supportée: $*" >&2 + return 0 + ;; + esac +} + +# Mock: pct - Simuler les commandes Proxmox LXC +pct() { + case "$1" in + status) + echo "status: ${TEST_LXC_STATUS}" + ;; + exec) + # Simuler une exécution réussie dans le container + return 0 + ;; + stop) + echo "Stopping CT ${2}" + TEST_LXC_STATUS="stopped" + return 0 + ;; + start) + echo "Starting CT ${2}" + TEST_LXC_STATUS="running" + return 0 + ;; + *) + echo "Mock pct: commande non supportée: $*" >&2 + return 1 + ;; + esac +} + +# Mock: ha-manager - Simuler Proxmox HA manager +ha-manager() { + case "$1" in + migrate) + echo "Migrating ${2} to ${3}" + return 0 + ;; + status) + echo "ct:103 started elitedesk" + return 0 + ;; + *) + echo "Mock ha-manager: commande non supportée: $*" >&2 + return 1 + ;; + esac +} + +# Mock: ssh - Simuler les connexions SSH +ssh() { + # Ignorer les options SSH (-i, -o, etc.) + local cmd="" + for arg in "$@"; do + if [[ ! "$arg" =~ ^- ]] && [[ "$arg" != *"@"* ]] && [[ "$arg" != "root" ]]; then + cmd="$arg" + break + fi + done + + if [[ "$cmd" == "echo OK" ]] || [[ "$cmd" == *"echo"* ]]; then + echo "OK" + return 0 + fi + + # Simuler les commandes distantes + eval "$cmd" +} + +# Mock: syncoid - Simuler la réplication Syncoid +syncoid() { + echo "Sending incremental zpool1@autosnap_2024-12-29_14:30:00" + echo "2.15GB 0:00:45 [48.9MB/s]" + return 0 +} + +# Mock: logger - Simuler syslog +logger() { + # Silencieux pour les tests, sauf si DEBUG + if [[ "${BATS_TEST_DEBUG:-}" == "true" ]]; then + echo "[SYSLOG] $*" >&2 + fi +} + +# Mock: hostname - Retourner un hostname de test +hostname() { + echo "${TEST_HOSTNAME:-elitedesk}" +} + +# Mock: readlink - Simuler la résolution de symlinks +readlink() { + if [[ "$1" == "-f" ]]; then + # Retourner un chemin /dev/sdX simulé + echo "/dev/sda1" + else + echo "/dev/disk/by-id/wwn-0x5000cca2dfe2e414" + fi +} + +# Mock: ls pour /dev/disk/by-id/ +ls() { + if [[ "$*" =~ /dev/disk/by-id ]]; then + if [[ "$TEST_DISK_PRESENT" == "true" ]]; then + echo "lrwxrwxrwx 1 root root 9 Dec 29 14:00 wwn-0x5000cca2dfe2e414 -> ../../sda1" + fi + return 0 + fi + + # Appeler le vrai ls pour autres cas + command ls "$@" +} + +# Mock: date - Contrôler le temps dans les tests +date() { + if [[ "$1" == "+%s" ]]; then + echo "${TEST_CURRENT_EPOCH:-1735481400}" + elif [[ "$1" == "+%Y-%m-%d_%H:%M:%S" ]]; then + echo "2024-12-29_14:30:00" + else + command date "$@" + fi +} + +# Helper: Setup des variables d'environnement pour le script +setup_script_env() { + export ZPOOLS=("zpool1") + export CTID=103 + export CONTAINER_NAME="nfs-server" + export STATE_DIR="${BATS_TMPDIR}/zfs-nfs-replica" + export LOG_DIR="${BATS_TMPDIR}/logs" + export HEALTH_CHECK_MIN_FREE_SPACE=5 + export HEALTH_CHECK_ERROR_COOLDOWN=3600 + export NOTIFICATION_ENABLED=false + export AUTO_UPDATE_ENABLED=false + export CHECK_DELAY=0 # Pas de délai dans les tests + + # Cluster nodes + declare -gA CLUSTER_NODES=( + ["acemagician"]="192.168.100.10" + ["elitedesk"]="192.168.100.20" + ) + + # Créer les répertoires nécessaires + mkdir -p "$STATE_DIR" + mkdir -p "$LOG_DIR" +} + +# Helper: Nettoyer l'environnement de test +cleanup_script_env() { + rm -rf "${BATS_TMPDIR}/zfs-nfs-replica" + rm -rf "${BATS_TMPDIR}/logs" +} + +# Helper: Créer un fichier d'état disk-uuids +create_disk_uuid_file() { + local pool="$1" + local uuid="${2:-wwn-0x5000cca2dfe2e414}" + + cat > "${STATE_DIR}/disk-uuids-${pool}.txt" < "${STATE_DIR}/critical-errors-${pool}.txt" </dev/null # Vérifier que c'est un nombre +} + +@test "Variables de config: CONTAINER_NAME est défini" { + [ -n "$CONTAINER_NAME" ] +} + +@test "Variables de config: HEALTH_CHECK_MIN_FREE_SPACE valide" { + [ "$HEALTH_CHECK_MIN_FREE_SPACE" -ge 0 ] + [ "$HEALTH_CHECK_MIN_FREE_SPACE" -le 100 ] +} + +@test "Variables de config: HEALTH_CHECK_ERROR_COOLDOWN valide" { + [ "$HEALTH_CHECK_ERROR_COOLDOWN" -gt 0 ] +} diff --git a/zfs-nfs-replica.sh b/zfs-nfs-replica.sh index be9e589..07a9b91 100644 --- a/zfs-nfs-replica.sh +++ b/zfs-nfs-replica.sh @@ -1,31 +1,36 @@ #!/bin/bash # # Script de réplication ZFS automatique pour NFS HA (Multi-pools) -# À déployer sur acemagician et elitedesk +# À déployer sur tous les nœuds de production du cluster Proxmox # -# Ce script version 2.0 : +# Ce script version 2.1 : # - Supporte la réplication de plusieurs pools ZFS simultanément # - Vérifie 3 fois que le LXC nfs-server est actif localement +# - Vérifie l'état de santé des disques et pools ZFS avant réplication +# - Détecte les disques manquants, pools dégradés, erreurs I/O +# - Migration automatique du LXC en cas de défaillance matérielle +# - Protection anti-ping-pong (arrêt du LXC si erreur < 1h) # - Détermine le nœud distant automatiquement # - Réplique chaque pool ZFS vers le nœud passif avec isolation des erreurs # - Utilise un verrou par pool pour éviter les réplications concurrentes # - Gère l'activation/désactivation de Sanoid selon le nœud actif # - Logs avec rotation automatique (2 semaines de rétention) -# - Fichiers d'état séparés par pool +# - Fichiers d'état séparés par pool (tailles, UUIDs disques, erreurs critiques) # # Auteur : BENE Maël -# Version : 2.0.1 +# Version : 2.1.0 # set -euo pipefail # Configuration -SCRIPT_VERSION="2.0.1" +SCRIPT_VERSION="2.1.0" REPO_URL="https://forgejo.tellserv.fr/Tellsanguis/zfs-sync-nfs-ha" SCRIPT_URL="${REPO_URL}/raw/branch/main/zfs-nfs-replica.sh" SCRIPT_PATH="${BASH_SOURCE[0]}" AUTO_UPDATE_ENABLED=true # Mettre à false pour désactiver l'auto-update +# Configuration du container LXC CTID=103 CONTAINER_NAME="nfs-server" @@ -33,6 +38,14 @@ CONTAINER_NAME="nfs-server" # Ajouter ou retirer des pools selon vos besoins ZPOOLS=("zpool1" "zpool2") +# Configuration des nœuds du cluster +# Format: NODE_NAME:IP_ADDRESS +# Ajouter tous les nœuds de production du cluster +declare -A CLUSTER_NODES=( + ["acemagician"]="192.168.100.10" + ["elitedesk"]="192.168.100.20" +) + CHECK_DELAY=2 # Délai entre chaque vérification (secondes) LOG_FACILITY="local0" SSH_KEY="/root/.ssh/id_ed25519_zfs_replication" @@ -44,6 +57,33 @@ MIN_REMOTE_RATIO=50 # Le distant doit avoir au moins 50% de la taille du local LOG_DIR="/var/log/zfs-nfs-replica" LOG_RETENTION_DAYS=14 +# Configuration de vérification de santé des pools +HEALTH_CHECK_MIN_FREE_SPACE=5 # Pourcentage minimum d'espace libre +HEALTH_CHECK_ERROR_COOLDOWN=3600 # Anti-ping-pong: 1 heure en secondes + +# Configuration des notifications via Apprise +NOTIFICATION_ENABLED=true # Activer/désactiver les notifications +NOTIFICATION_MODE="INFO" # "INFO" (toutes les notifs) ou "ERROR" (erreurs uniquement) + +# URLs Apprise (séparées par des espaces) - Exemples: +# Discord: discord://webhook_id/webhook_token +# Telegram: tgram://bot_token/chat_id +# Gotify: gotify://hostname/token +# Email: mailto://user:pass@smtp.domain.com +# Ntfy: ntfy://topic ou ntfy://hostname/topic +# Slack: slack://TokenA/TokenB/TokenC +# Voir https://github.com/caronc/apprise pour plus de services +APPRISE_URLS="" # Configurer vos URLs ici + +# Exemples d'utilisation: +# APPRISE_URLS="discord://webhook_id/token" +# APPRISE_URLS="discord://id/token gotify://server/token" +# APPRISE_URLS="mailto://user:pass@gmail.com tgram://bot_token/chat_id" + +# Configuration environnement Python pour Apprise +APPRISE_VENV_DIR="${STATE_DIR}/venv" # Répertoire du virtualenv Python +APPRISE_BIN="${APPRISE_VENV_DIR}/bin/apprise" # Binaire Apprise dans le venv + # Initialiser le répertoire de logs init_logging() { mkdir -p "$LOG_DIR" @@ -87,6 +127,123 @@ log() { fi } +# Initialisation de l'environnement Python pour Apprise +# Note: Le venv est persistant dans /var/lib/zfs-nfs-replica/venv +setup_apprise_venv() { + # Créer le répertoire d'état si nécessaire + mkdir -p "$STATE_DIR" + + # Vérifier si le venv existe déjà + if [[ ! -d "$APPRISE_VENV_DIR" ]]; then + log "info" "Création de l'environnement Python virtuel pour Apprise..." + + # Créer le virtualenv (python3 et venv sont préinstallés sur Proxmox) + if ! python3 -m venv "$APPRISE_VENV_DIR" 2>/dev/null; then + log "error" "Échec de la création du virtualenv" + return 1 + fi + + log "info" "✓ Virtualenv créé: ${APPRISE_VENV_DIR}" + + # Installer pip dans le venv (pas installé par défaut sur Proxmox) + log "info" "Installation de pip dans le virtualenv..." + if ! "${APPRISE_VENV_DIR}/bin/python" -m ensurepip --upgrade >/dev/null 2>&1; then + log "error" "Échec de l'installation de pip" + return 1 + fi + + log "info" "✓ Pip installé dans le virtualenv" + fi + + # Vérifier si Apprise est installé dans le venv + if [[ ! -f "$APPRISE_BIN" ]]; then + log "info" "Installation d'Apprise dans le virtualenv..." + + # Installer Apprise via pip du venv + if "${APPRISE_VENV_DIR}/bin/pip" install --quiet apprise 2>/dev/null; then + log "info" "✓ Apprise installé avec succès" + else + log "error" "Échec de l'installation d'Apprise" + log "error" "Essayer manuellement: ${APPRISE_VENV_DIR}/bin/pip install apprise" + return 1 + fi + fi + + # Vérifier que Apprise fonctionne + if [[ -x "$APPRISE_BIN" ]]; then + local apprise_version + apprise_version=$("$APPRISE_BIN" --version 2>/dev/null | head -1) + log "info" "✓ Apprise prêt: ${apprise_version}" + return 0 + else + log "error" "Apprise installé mais non exécutable" + return 1 + fi +} + +# Fonction d'envoi de notifications via Apprise +send_notification() { + local severity="$1" # "info" ou "error" + local title="$2" + local message="$3" + + # Vérifier si les notifications sont activées + if [[ "${NOTIFICATION_ENABLED}" != "true" ]]; then + return 0 + fi + + # Vérifier si des URLs Apprise sont configurées + if [[ -z "${APPRISE_URLS}" ]]; then + return 0 + fi + + # Filtrer selon le mode de notification + if [[ "${NOTIFICATION_MODE}" == "ERROR" ]] && [[ "$severity" != "error" ]]; then + # Mode ERROR: ignorer les notifications info + return 0 + fi + + # Vérifier si Apprise est installé dans le venv + if [[ ! -x "$APPRISE_BIN" ]]; then + log "warning" "Apprise non disponible - notifications désactivées" + log "warning" "Le virtualenv n'a pas été correctement initialisé" + return 1 + fi + + # Préparer le corps du message + local hostname + hostname=$(hostname) + local timestamp + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + local full_message="[${hostname}] [${timestamp}] +${message} + +Script: zfs-nfs-replica v${SCRIPT_VERSION} +Nœud: ${hostname}" + + # Déterminer le type de notification Apprise selon la sévérité + local notification_type="info" + if [[ "$severity" == "error" ]]; then + notification_type="warning" # Apprise utilise "warning" pour les erreurs critiques + fi + + # Envoyer la notification à tous les services configurés + # apprise supporte plusieurs URLs séparées par des espaces + if "$APPRISE_BIN" \ + --notification-type="$notification_type" \ + --title="ZFS NFS HA: ${title}" \ + --body="$full_message" \ + ${APPRISE_URLS} \ + >/dev/null 2>&1; then + log "info" "Notification envoyée avec succès" + return 0 + else + log "warning" "Échec d'envoi de la notification via Apprise" + return 1 + fi +} + # Fonction d'auto-update auto_update() { # Vérifier si l'auto-update est activé @@ -318,6 +475,259 @@ check_common_snapshots() { fi } +# Extraction des UUIDs des disques physiques d'un pool +get_pool_disk_uuids() { + local pool="$1" + + # Obtenir la configuration du pool avec chemins physiques + local pool_config + pool_config=$(zpool status -P "$pool" 2>/dev/null) + + if [[ -z "$pool_config" ]]; then + return 1 + fi + + # Extraire les devices physiques (lignes contenant /dev/) + # Format typique: " /dev/sdb1 ONLINE 0 0 0" + local devices + devices=$(echo "$pool_config" | grep -E '^\s+/dev/' | awk '{print $1}') + + if [[ -z "$devices" ]]; then + # Pool virtuel ou pas de disques physiques + return 0 + fi + + # Pour chaque device, résoudre vers /dev/disk/by-id/ (méthode optimisée) + local uuids=() + while read -r device; do + if [[ -z "$device" ]]; then + continue + fi + + # Résoudre le device réel + local device_real + device_real=$(readlink -f "$device" 2>/dev/null) + + if [[ -z "$device_real" ]]; then + continue + fi + + # Chercher les liens dans /dev/disk/by-id/ pointant vers ce device + # Méthode optimisée: ls -l au lieu de find + local found_uuids + found_uuids=$(ls -l /dev/disk/by-id/ 2>/dev/null | \ + awk -v target="$(basename "$device_real")" '$NF == target {print $(NF-2)}' | \ + grep -E '^(wwn-|ata-|scsi-|nvme-)' || true) + + if [[ -n "$found_uuids" ]]; then + while read -r uuid_name; do + uuids+=("$uuid_name") + done <<< "$found_uuids" + fi + done <<< "$devices" + + # Retourner les UUIDs triés et uniques + if [[ ${#uuids[@]} -gt 0 ]]; then + printf '%s\n' "${uuids[@]}" | sort -u + return 0 + else + # Aucun UUID trouvé (pool virtuel possible) + return 0 + fi +} + +# Initialisation du tracking des disques pour un pool +init_disk_tracking() { + local pool="$1" + local uuids_file="${STATE_DIR}/disk-uuids-${pool}.txt" + + # Vérifier si déjà initialisé + if [[ -f "$uuids_file" ]] && grep -q "^initialized=true" "$uuids_file" 2>/dev/null; then + log "info" "Tracking des disques déjà initialisé pour ${pool}" + return 0 + fi + + log "info" "Initialisation du tracking des disques pour ${pool}" + + # Créer le répertoire d'état si nécessaire + mkdir -p "$STATE_DIR" + + # Obtenir les UUIDs des disques du pool + local uuids + uuids=$(get_pool_disk_uuids "$pool") + + if [[ -z "$uuids" ]]; then + log "warning" "Aucun disque physique détecté pour ${pool} (pool virtuel?)" + # Créer quand même un fichier pour marquer comme initialisé + cat > "$uuids_file" </dev/null + return 0 + fi + + # Créer le fichier de tracking + cat > "$uuids_file" <> "$uuids_file" + log "info" "Disque détecté pour ${pool}: ${uuid}" + done <<< "$uuids" + + # Définir les permissions + chmod 600 "$uuids_file" + chown root:root "$uuids_file" 2>/dev/null + + local disk_count + disk_count=$(echo "$uuids" | wc -l) + log "info" "✓ Tracking des disques initialisé pour ${pool}: ${disk_count} disque(s) enregistré(s)" + + return 0 +} + +# Vérification de la présence des disques trackés +verify_disk_presence() { + local pool="$1" + local uuids_file="${STATE_DIR}/disk-uuids-${pool}.txt" + + if [[ ! -f "$uuids_file" ]]; then + log "error" "Fichier de tracking des disques non trouvé: ${uuids_file}" + return 1 + fi + + # Lire les UUIDs du fichier (ignorer les lignes de commentaires et metadata) + local tracked_uuids + tracked_uuids=$(grep -E '^(wwn-|ata-|scsi-|nvme-)' "$uuids_file" 2>/dev/null) + + if [[ -z "$tracked_uuids" ]]; then + # Pool virtuel, pas de vérification nécessaire + log "info" "Pool ${pool}: aucun disque physique à vérifier (pool virtuel)" + return 0 + fi + + # Vérifier chaque UUID + local missing_disks=0 + while read -r uuid; do + local disk_path="/dev/disk/by-id/${uuid}" + + if [[ ! -L "$disk_path" ]]; then + log "error" "Disque manquant pour ${pool}: ${uuid}" + log "error" " Emplacement attendu: ${disk_path}" + missing_disks=$((missing_disks + 1)) + elif [[ ! -e "$disk_path" ]]; then + log "error" "Symlink dangling pour ${pool}: ${uuid}" + log "error" " Le lien existe mais pointe vers un device inexistant" + missing_disks=$((missing_disks + 1)) + fi + done <<< "$tracked_uuids" + + if [[ $missing_disks -gt 0 ]]; then + log "error" "✗ ${missing_disks} disque(s) manquant(s) pour ${pool}" + send_notification "error" "Disque(s) manquant(s) - ${pool}" \ + "${missing_disks} disque(s) manquant(s) détecté(s) pour le pool ${pool}. + +Vérifier les connexions USB/SATA et l'état des disques. +Une migration automatique du LXC peut être déclenchée." + return 1 + else + log "info" "✓ Tous les disques présents pour ${pool}" + return 0 + fi +} + +# Vérification de l'état de santé du pool ZFS +check_pool_health_status() { + local pool="$1" + local health_issues=0 + + # Check 1: Status du pool (ONLINE/DEGRADED/FAULTED) + local pool_health + pool_health=$(zpool list -H -o health "$pool" 2>/dev/null) + + if [[ -z "$pool_health" ]]; then + log "error" "Impossible de récupérer le status du pool ${pool}" + return 1 + fi + + if [[ "$pool_health" != "ONLINE" ]]; then + log "error" "Pool ${pool} en état dégradé: ${pool_health}" + send_notification "error" "Pool ZFS ${pool_health} - ${pool}" \ + "Le pool ZFS ${pool} est en état ${pool_health}. + +Vérifier l'état des disques avec: zpool status ${pool} +Une migration automatique du LXC peut être déclenchée." + health_issues=$((health_issues + 1)) + else + log "info" "✓ Pool ${pool} status: ONLINE" + fi + + # Check 2: Espace libre (doit être > HEALTH_CHECK_MIN_FREE_SPACE%) + local capacity + capacity=$(zpool list -H -o capacity "$pool" 2>/dev/null | sed 's/%//') + + if [[ -n "$capacity" ]]; then + local min_capacity=$((100 - HEALTH_CHECK_MIN_FREE_SPACE)) + + if [[ $capacity -ge $min_capacity ]]; then + log "error" "Espace disque critique pour ${pool}: ${capacity}% utilisé (seuil: ${min_capacity}%)" + send_notification "error" "Espace disque critique - ${pool}" \ + "Le pool ${pool} est presque plein: ${capacity}% utilisé. + +Seuil critique: ${min_capacity}% +Espace libre restant: $((100 - capacity))% + +ACTION REQUISE: Libérer de l'espace ou agrandir le pool." + health_issues=$((health_issues + 1)) + else + local free_percent=$((100 - capacity)) + log "info" "✓ Espace libre pour ${pool}: ${free_percent}% (seuil minimum: ${HEALTH_CHECK_MIN_FREE_SPACE}%)" + fi + fi + + # Check 3: Scrub/resilver en cours (non bloquant, juste informatif) + if zpool status "$pool" 2>/dev/null | grep -qi "scrub in progress"; then + log "warning" "Scrub en cours sur ${pool} (non bloquant)" + fi + + if zpool status "$pool" 2>/dev/null | grep -qi "resilver in progress"; then + log "warning" "Resilver en cours sur ${pool} (non bloquant)" + fi + + # Check 4: Erreurs I/O (READ/WRITE/CKSUM) + local error_lines + error_lines=$(zpool status "$pool" 2>/dev/null | grep -E "errors:" | grep -v "No known data errors") + + if [[ -n "$error_lines" ]]; then + log "error" "Erreurs détectées sur le pool ${pool}:" + zpool status "$pool" 2>/dev/null | grep -E "(READ|WRITE|CKSUM)" | while read -r line; do + log "error" " ${line}" + done + health_issues=$((health_issues + 1)) + else + log "info" "✓ Aucune erreur I/O détectée sur ${pool}" + fi + + if [[ $health_issues -eq 0 ]]; then + return 0 + else + log "error" "✗ ${health_issues} problème(s) de santé détecté(s) sur ${pool}" + return 1 + fi +} + # Récupération des tailles de datasets get_dataset_sizes() { local target="$1" # "local" ou "remote:IP" @@ -478,6 +888,224 @@ check_size_safety() { return 0 } +# Triple vérification de santé (mirroring verify_lxc_is_active pattern) +triple_health_check() { + local pool="$1" + local success_count=0 + + for i in 1 2 3; do + log "info" "Vérification santé #${i}/3 pour ${pool}" + + # Vérifier à la fois la présence des disques ET l'état du pool + if verify_disk_presence "$pool" && check_pool_health_status "$pool"; then + success_count=$((success_count + 1)) + log "info" "Vérification santé #${i}/3 réussie pour ${pool}" + else + log "error" "Vérification santé #${i}/3 échouée pour ${pool}" + fi + + # Délai entre les vérifications (sauf après la dernière) + if [[ $i -lt 3 ]]; then + sleep "$CHECK_DELAY" + fi + done + + if [[ $success_count -eq 3 ]]; then + log "info" "✓ Triple vérification santé réussie pour ${pool} (${success_count}/3)" + return 0 + else + log "error" "✗ Triple vérification santé échouée pour ${pool}: ${success_count}/3 vérifications réussies" + return 1 + fi +} + +# Vérification de l'existence d'une erreur critique récente (anti-ping-pong) +check_recent_critical_error() { + local pool="$1" + local error_file="${STATE_DIR}/critical-errors-${pool}.txt" + + if [[ ! -f "$error_file" ]]; then + # Pas d'erreur précédente + return 1 + fi + + # Lire le timestamp epoch de la dernière erreur + local last_error_epoch + last_error_epoch=$(grep "^epoch=" "$error_file" | cut -d= -f2) + + if [[ -z "$last_error_epoch" ]]; then + # Fichier corrompu ou format invalide + return 1 + fi + + # Calculer le temps écoulé depuis la dernière erreur + local current_epoch + current_epoch=$(date +%s) + local time_diff=$((current_epoch - last_error_epoch)) + + if [[ $time_diff -lt $HEALTH_CHECK_ERROR_COOLDOWN ]]; then + # Erreur récente (< 1 heure par défaut) + log "warning" "Erreur critique récente détectée: il y a ${time_diff}s (seuil: ${HEALTH_CHECK_ERROR_COOLDOWN}s)" + return 0 + else + # Erreur ancienne (> 1 heure) + log "info" "Dernière erreur critique: il y a ${time_diff}s (> seuil de ${HEALTH_CHECK_ERROR_COOLDOWN}s)" + return 1 + fi +} + +# Enregistrement d'une erreur critique +record_critical_error() { + local pool="$1" + local reason="$2" + local action="$3" # "lxc_migrated" ou "lxc_stopped" ou "lxc_stopped_failsafe" + local error_file="${STATE_DIR}/critical-errors-${pool}.txt" + + # Créer le répertoire d'état si nécessaire + mkdir -p "$STATE_DIR" + + # Créer ou écraser le fichier d'erreur + cat > "$error_file" </dev/null + + log "error" "Erreur critique enregistrée pour ${pool}: ${reason} → ${action}" +} + +# Gestion de l'échec de santé - Migration ou arrêt du LXC +handle_health_failure() { + local pool="$1" + local failure_reason="$2" + + # Log box pour visibilité maximale + log "error" "╔════════════════════════════════════════════════════════════╗" + log "error" "║ ÉCHEC CRITIQUE DE SANTÉ POUR ${pool}" + log "error" "║ Raison: ${failure_reason}" + log "error" "╚════════════════════════════════════════════════════════════╝" + + # Vérifier s'il y a eu une erreur récente (mécanisme anti-ping-pong) + if check_recent_critical_error "$pool"; then + local last_error_time + last_error_time=$(grep "^timestamp=" "${STATE_DIR}/critical-errors-${pool}.txt" | cut -d= -f2) + + log "error" "Erreur critique récente détectée (${last_error_time})" + log "error" "Action: ARRÊT du LXC ${CTID} pour éviter ping-pong" + + # Arrêter le LXC + if pct stop "$CTID" >/dev/null 2>&1; then + log "error" "✓ LXC ${CTID} arrêté avec succès" + send_notification "error" "Arrêt LXC (anti-ping-pong)" \ + "Le LXC ${CTID} a été arrêté pour éviter un ping-pong. + +Pool: ${pool} +Raison: ${failure_reason} +Erreur précédente: ${last_error_time} + +ACTION REQUISE: Vérifier l'état des disques et pools sur les deux nœuds avant de redémarrer le LXC." + record_critical_error "$pool" "$failure_reason" "lxc_stopped" + return 0 + else + log "error" "✗ Échec de l'arrêt du LXC ${CTID}" + record_critical_error "$pool" "$failure_reason" "lxc_stop_failed" + return 1 + fi + else + # Première erreur ou erreur ancienne (> 1 heure) + log "warning" "Première erreur ou erreur ancienne (> ${HEALTH_CHECK_ERROR_COOLDOWN}s)" + log "warning" "Action: MIGRATION du LXC ${CTID} vers ${REMOTE_NODE_NAME}" + + # Tenter la migration via HA + if ha-manager migrate "ct:${CTID}" "$REMOTE_NODE_NAME" >/dev/null 2>&1; then + log "warning" "✓ Migration HA initiée vers ${REMOTE_NODE_NAME}" + send_notification "error" "Migration LXC vers ${REMOTE_NODE_NAME}" \ + "Le LXC ${CTID} a été migré vers ${REMOTE_NODE_NAME} suite à un problème de santé. + +Pool: ${pool} +Raison: ${failure_reason} +Nœud source: $(hostname) +Nœud destination: ${REMOTE_NODE_NAME} + +Le service NFS devrait continuer à fonctionner sur le nœud distant." + record_critical_error "$pool" "$failure_reason" "lxc_migrated" + return 0 + else + log "error" "✗ Échec de la migration HA vers ${REMOTE_NODE_NAME}" + log "error" "Tentative d'arrêt du LXC en dernier recours" + + # Fallback: arrêter le LXC si la migration échoue + if pct stop "$CTID" >/dev/null 2>&1; then + log "error" "✓ LXC ${CTID} arrêté en dernier recours" + record_critical_error "$pool" "$failure_reason" "lxc_stopped_failsafe" + return 0 + else + log "error" "✗ Échec critique: impossible de migrer ou d'arrêter le LXC ${CTID}" + record_critical_error "$pool" "$failure_reason" "all_actions_failed" + return 1 + fi + fi + fi +} + +# Orchestrateur principal de vérification de santé d'un pool +verify_pool_health() { + local pool="$1" + + export CURRENT_POOL="$pool" + log "info" "==========================================" + log "info" "Vérification de santé du pool: ${pool}" + log "info" "==========================================" + + # Étape 1: Initialiser le tracking des disques si nécessaire + local uuids_file="${STATE_DIR}/disk-uuids-${pool}.txt" + if [[ ! -f "$uuids_file" ]] || ! grep -q "^initialized=true" "$uuids_file" 2>/dev/null; then + log "info" "Première exécution: initialisation du suivi des disques pour ${pool}" + + if ! init_disk_tracking "$pool"; then + log "warning" "Impossible d'initialiser le suivi des disques pour ${pool}" + log "info" "Continuation sans vérification des disques (pool virtuel possible)" + # Ne pas échouer pour les pools virtuels + return 0 + fi + fi + + # Étape 2: Triple vérification de santé + if ! triple_health_check "$pool"; then + log "error" "Triple vérification de santé échouée pour ${pool}" + + # Déterminer la raison de l'échec pour un message d'erreur précis + local failure_reason="Vérification de santé échouée" + + # Tenter de déterminer la cause spécifique + if ! verify_disk_presence "$pool"; then + failure_reason="Disque(s) manquant(s) détecté(s)" + elif ! check_pool_health_status "$pool"; then + # Obtenir plus de détails sur le problème de pool + local pool_health + pool_health=$(zpool list -H -o health "$pool" 2>/dev/null) + if [[ "$pool_health" != "ONLINE" ]]; then + failure_reason="Pool en état ${pool_health}" + else + failure_reason="État du pool non nominal (espace disque ou erreurs I/O)" + fi + fi + + # Gérer l'échec (migration ou arrêt du LXC) + handle_health_failure "$pool" "$failure_reason" + return 1 + fi + + log "info" "✓ Vérification de santé réussie pour ${pool}" + return 0 +} + # Fonction de réplication d'un pool replicate_pool() { local pool="$1" @@ -600,6 +1228,11 @@ replicate_pool() { # SCRIPT PRINCIPAL ################################################################################ +# Ne pas exécuter le script principal si on est en mode test BATS +if [[ "${BATS_TEST_MODE:-false}" == "true" ]]; then + return 0 2>/dev/null || exit 0 +fi + # Initialiser le système de logs init_logging @@ -614,21 +1247,49 @@ log "info" "==========================================" # Vérifier les mises à jour (avant toute opération) auto_update "$@" +# Vérifier et initialiser la configuration des notifications +if [[ "${NOTIFICATION_ENABLED}" == "true" ]] && [[ -n "${APPRISE_URLS}" ]]; then + log "info" "Initialisation du système de notifications..." + + # Initialiser l'environnement Python et installer Apprise si nécessaire + if setup_apprise_venv; then + local url_count + url_count=$(echo "${APPRISE_URLS}" | wc -w) + log "info" "✓ Notifications activées: ${url_count} service(s) configuré(s)" + log "info" " Mode: ${NOTIFICATION_MODE}" + else + log "warning" "Échec de l'initialisation d'Apprise - notifications désactivées" + NOTIFICATION_ENABLED=false + fi +elif [[ "${NOTIFICATION_ENABLED}" == "true" ]] && [[ -z "${APPRISE_URLS}" ]]; then + log "info" "Notifications activées mais aucune URL Apprise configurée (APPRISE_URLS vide)" + log "info" "Configurer APPRISE_URLS dans le script pour recevoir des notifications" +fi + # Déterminer le nœud distant et son IP -case "$LOCAL_NODE" in - "acemagician") - REMOTE_NODE_NAME="elitedesk" - REMOTE_NODE_IP="192.168.100.20" - ;; - "elitedesk") - REMOTE_NODE_NAME="acemagician" - REMOTE_NODE_IP="192.168.100.10" - ;; - *) - log "error" "Nœud inconnu: ${LOCAL_NODE}. Ce script doit s'exécuter sur acemagician ou elitedesk." - exit 1 - ;; -esac +# Vérifier que le nœud local est dans la configuration +if [[ ! -v "CLUSTER_NODES[$LOCAL_NODE]" ]]; then + local valid_nodes="${!CLUSTER_NODES[@]}" + log "error" "Nœud inconnu: ${LOCAL_NODE}. Nœuds valides: ${valid_nodes}" + exit 1 +fi + +# Trouver le nœud distant (le premier nœud différent du local) +REMOTE_NODE_NAME="" +REMOTE_NODE_IP="" +for node in "${!CLUSTER_NODES[@]}"; do + if [[ "$node" != "$LOCAL_NODE" ]]; then + REMOTE_NODE_NAME="$node" + REMOTE_NODE_IP="${CLUSTER_NODES[$node]}" + break + fi +done + +# Vérifier qu'un nœud distant a été trouvé +if [[ -z "$REMOTE_NODE_NAME" ]]; then + log "error" "Aucun nœud distant trouvé dans CLUSTER_NODES. Vérifier la configuration." + exit 1 +fi log "info" "Nœud distant configuré: ${REMOTE_NODE_NAME} (${REMOTE_NODE_IP})" log "info" "Pools configurés: ${ZPOOLS[*]}" @@ -652,6 +1313,29 @@ fi log "info" "Connexion SSH vers ${REMOTE_NODE_NAME} (${REMOTE_NODE_IP}) vérifiée" +# Vérification de santé des pools +log "info" "==========================================" +log "info" "Vérification de santé des pools" +log "info" "==========================================" + +HEALTH_CHECK_FAILED=false +for pool in "${ZPOOLS[@]}"; do + if ! verify_pool_health "$pool"; then + log "error" "Vérification de santé échouée pour ${pool}" + HEALTH_CHECK_FAILED=true + fi +done + +# Arrêt si échec de santé détecté +if [[ "$HEALTH_CHECK_FAILED" == "true" ]]; then + export CURRENT_POOL="global" + log "error" "Arrêt du script suite à échec(s) de vérification de santé" + log "error" "Le LXC a été migré ou arrêté pour protection" + exit 1 +fi + +log "info" "✓ Tous les pools sont en bonne santé" + # Compteurs globaux POOLS_TOTAL=${#ZPOOLS[@]} POOLS_SUCCESS=0 @@ -683,8 +1367,23 @@ log "info" "==========================================" if [[ $POOLS_FAILED -eq 0 ]]; then log "info" "✓ Toutes les réplications ont réussi" + send_notification "info" "Réplication ZFS réussie" \ + "Toutes les réplications ZFS ont réussi. + +Pools répliqués: ${POOLS_TOTAL} +Succès: ${POOLS_SUCCESS} +Nœud actif: $(hostname) +Nœud distant: ${REMOTE_NODE_NAME}" exit 0 else log "error" "✗ ${POOLS_FAILED} pool(s) ont échoué" + send_notification "error" "Échec réplication ZFS" \ + "${POOLS_FAILED} pool(s) ont échoué lors de la réplication. + +Total: ${POOLS_TOTAL} +Succès: ${POOLS_SUCCESS} +Échecs: ${POOLS_FAILED} + +Vérifier les logs: /var/log/zfs-nfs-replica/" exit 1 fi