diff --git a/.claude/deploiement.md b/.claude/deploiement.md index 51989dc1..95415350 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -624,6 +624,19 @@ cat .deployed-sha # SHA actuellement déployé tail -20 var/log/deploy-nightly.log # dernière tentative ``` +### Garde-fous : `composer` en CLI, vérification de `vendor/`, rollback (#570) + +Incident du 2026-10-02 : le déploiement nocturne a lancé composer avec le PHP **CGI** du PATH cron. Composer a affiché son aide et **sorti en code 0** sans rien installer ; le code avait déjà été mis à jour par `git checkout`, `vendor/` est resté ancien (3 paquets manquants : `monolog/monolog`, `symfony/monolog-bridge`, `symfony/monolog-bundle`) et 6 instances sont passées en HTTP 500. + +Ce qui est en place depuis, **commun aux trois scripts** (`bin/lib/deploy-common.sh`, sourcé par `deploy-nightly.sh`, `deploy-all.sh` et `deploy.sh`) : + +- **composer via le PHP CLI explicite** : `$HC_PHP -d memory_limit=512M /usr/local/bin/composer install …`. `$HC_COMPOSER_BIN` vaut plusieurs mots : toujours l'utiliser **non quoté** (entre guillemets, bash cherche un exécutable nommé « php composer » et sort en 127). Plus aucun `composer` nu dans `bin/` (test `test_aucun_composer_nu_dans_les_scripts_de_deploiement`). +- **Vérification de `vendor/`** après `composer install` : `composer install --dry-run --no-dev` doit afficher « Nothing to install, update or remove ». Sinon l'étape « vérification de vendor/ » échoue (validé avec le vrai composer : message présent sur ronan, absent sur une instance à `vendor/` incomplet). +- **Rollback automatique si l'échec précède les migrations** : `git checkout --force `, `composer install`, vérification de `vendor/`, `cache:clear`. Le rapport indique `→ code restauré ()` ou `→ ROLLBACK ÉCHOUÉ …, instance probablement hors service` (alors : intervention manuelle). **Pas de rollback à partir des migrations** : l'état de la base est incertain (migration partielle), un ancien code sur un schéma à moitié migré serait pire. Pas de rollback non plus si `HEAD` est déjà la cible. +- La cible du rollback est le `HEAD` réellement en place avant l'opération, **pas** `.deployed-sha` (un déploiement interrompu laisse `HEAD` sur le nouveau code alors que `.deployed-sha` reste ancien). +- Un correctif du script nocturne n'est actif sur une instance qu'**à la nuit suivant celle où elle l'a récupéré** (le script du cron est celui du disque au lancement). Après un correctif de script, passer par `bash bin/deploy-all.sh` (exécuté depuis le poste) plutôt que d'attendre le cron. +- Tests : `bash tests/bash/run.sh` (28 tests). Ils ne tournent pas dans la CI. + ### Crons cPanel — créés et actifs depuis le 2026-09-12 > Vérifié en SSH le 2026-09-12 22h : les 8 crons ci-dessous sont bien diff --git a/.github/avancement.md b/.github/avancement.md index 151009c7..7e0b526f 100644 --- a/.github/avancement.md +++ b/.github/avancement.md @@ -6,6 +6,18 @@ --- +## 🚧 Déploiement : composer en CLI, vérification de vendor/, rollback (2026-10-02, #570, branche `fix/570-deploy-composer-rollback`) + +- Cause de l'incident #569 (6 instances en 500) : composer lancé en PHP CGI par le cron affichait son aide et sortait en 0 ; code déjà mis à jour par `git checkout`, `vendor/` ancien, aucun rollback. +- Le correctif `61ba614` était lui-même cassé : `"$COMPOSER_BIN"` (deux mots) entre guillemets → exit 127 « commande introuvable ». 7 tests bash échouaient déjà sur `main` sans que personne le voie (les tests bash ne tournent pas en CI). +- `bin/lib/deploy-common.sh` : définition unique de composer (PHP CLI explicite, `memory_limit`) et de la vérification de `vendor/`, sourcée par `deploy-nightly.sh`, `deploy-all.sh` et `deploy.sh`. Plus de `composer` nu dans `bin/`. +- Vérification de `vendor/` (`composer install --dry-run` → « Nothing to install ») validée avec le vrai composer : présent sur ronan, absent sur yannick (3 paquets manquants listés). +- Rollback si l'échec précède les migrations (jamais après : état de la base incertain), cible = `HEAD` réellement en place ; rapport `→ code restauré ()` / `→ ROLLBACK ÉCHOUÉ`. +- Tests bash : 28/28 (dont 11 nouveaux, 7 réparés). Suite PHP non concernée. +- Reste : revue, `gh pr create` (label + `Closes #570` + assignee + board), CI verte, merge. Piste proposée : exécuter `tests/bash/run.sh` en CI. + +--- + ## ✅ Mises à jour composer — Symfony 8.0 → 8.1 (2026-10-02, #538, PR #562 mergée) - Contraintes `symfony/*` et `extra.symfony.require` passées de `8.0.*` à `8.1.*` (un `composer update` seul n'aurait rien monté). Symfony en 8.1.8, Doctrine ORM 3.7.3, DoctrineBundle 3.3.2, phpunit 13.4.0, twig 3.30, monolog 3.12.1. diff --git a/bin/deploy-all.sh b/bin/deploy-all.sh index c261d565..921c9d9e 100755 --- a/bin/deploy-all.sh +++ b/bin/deploy-all.sh @@ -79,12 +79,14 @@ SSH_HOST="lenouvel.me" SSH_PORT=22 GIT_REPO="https://github.com/ronan-develop/home-cloud" GIT_BRANCH="main" -# -d memory_limit=512M : sur le mutualisé o2switch (LVE CloudLinux), le -# memory_limit par défaut du php.ini fait tuer cache:clear --env=prod même -# isolé dans son propre process SSH (vécu 2026-09-27) — la valeur par défaut -# est trop juste pour la compilation du container Symfony en prod. -PHP_BIN="/usr/local/bin/php -d memory_limit=512M" -COMPOSER_BIN="composer" +# PHP/composer (chemins absolus, composer via PHP CLI explicite, memory_limit) +# et vérification de vendor/ : une seule définition, partagée avec +# deploy-nightly.sh (#570). Valeurs de plusieurs mots : utilisées dans des +# chaînes de commande distantes, jamais comme un seul argument. +# shellcheck source=lib/deploy-common.sh +source "${SCRIPT_DIR}/lib/deploy-common.sh" || exit 1 +PHP_BIN="$HC_PHP_BIN" +COMPOSER_BIN="$HC_COMPOSER_BIN" SSH_KEY_OPTS="" if [[ -n "${SSH_KEY_PATH:-}" && -f "${SSH_KEY_PATH}" ]]; then @@ -157,6 +159,23 @@ run_step() { return 0 } +# ── Restauration du code précédent (#570) ──────────────────────────────────── +# Appelée seulement si l'échec précède les migrations : après, l'état de la +# base est incertain (migration partielle) et un ancien code sur un schéma à +# moitié migré serait pire que le nouveau code. +rollback_remote() { + local sha="$1" + warn "${SUBDOMAIN} — restauration du code précédent (${sha:0:7})…" + if run_step "rollback git checkout" "git checkout --force ${sha}" \ + && run_step "rollback composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \ + && run_step "rollback vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \ + && run_step "rollback cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod"; then + success "${SUBDOMAIN} — code restauré (${sha:0:7})" + else + error "${SUBDOMAIN} — ROLLBACK ÉCHOUÉ, instance probablement hors service : intervention manuelle requise" + fi +} + # ── Build Tailwind une seule fois, hors boucle (#421) ───────────────────────── # Le CSS est identique pour les 7 instances — le reconstruire à chaque # itération était un gaspillage pur, déjà vrai avant #421. @@ -230,7 +249,8 @@ ENVEOF continue fi - if run_step "composer install" "${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts" \ + if run_step "composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \ + && run_step "vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \ && run_step "install-ffmpeg" "bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" \ && run_step "cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod" \ && run_step "assets:install" "${PHP_BIN} bin/console assets:install public --env=prod" \ @@ -271,12 +291,20 @@ ENVEOF # app.built.css vient d'être écrasé par le scp ci-dessus : annuler cette # modification locale avant le pull, sinon git refuse de merger # ("Your local changes ... would be overwritten by merge"). + # Code réellement en place avant le pull : cible du rollback (#570). + PREV_SHA=$(ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "cd ${DEPLOY_PATH} && git rev-parse HEAD" 2>/dev/null || echo "") + CODE_READY=false if run_step "git pull" "git checkout -- var/tailwind/app.built.css 2>/dev/null; mkdir -p var/log && git pull origin ${GIT_BRANCH}" \ - && run_step "composer install" "${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts" \ + && run_step "composer install" "${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS}" \ + && run_step "vérification de vendor/" "${HC_VERIFY_VENDOR_SNIPPET}" \ && run_step "install-ffmpeg" "bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" \ && run_step "cache:clear" "${PHP_BIN} bin/console cache:clear --env=prod" \ && run_step "assets:install" "${PHP_BIN} bin/console assets:install public --env=prod" \ - && run_step "importmap:install" "${PHP_BIN} bin/console importmap:install --env=prod" \ + && run_step "importmap:install" "${PHP_BIN} bin/console importmap:install --env=prod"; then + CODE_READY=true + fi + + if [[ "$CODE_READY" == true ]] \ && run_step "migrations" "${PHP_BIN} bin/console doctrine:migrations:migrate --no-interaction --env=prod" \ && run_step "asset-map:compile" "${PHP_BIN} bin/console asset-map:compile" \ && run_step "deploy-info" "echo '${DEPLOY_INFO_LINE}' > templates/deploy-info.html.twig" \ @@ -287,6 +315,9 @@ ENVEOF success "${SUBDOMAIN} — mise à jour OK" RESULTS_OK+=("$SUBDOMAIN") else + if [[ "$CODE_READY" != true && -n "$PREV_SHA" ]]; then + rollback_remote "$PREV_SHA" + fi RESULTS_FAIL+=("$SUBDOMAIN") fi fi diff --git a/bin/deploy-nightly.sh b/bin/deploy-nightly.sh index 5a1a3fbc..ac5acd12 100755 --- a/bin/deploy-nightly.sh +++ b/bin/deploy-nightly.sh @@ -19,26 +19,14 @@ PRENOM="${1:?Usage: deploy-nightly.sh /dev/null || echo "") + +# Restaure le code précédent et un vendor/ cohérent avec son composer.lock. +# Appelée seulement quand l'échec survient AVANT les migrations : après, l'état +# de la base est incertain (migration partielle) et un ancien code sur un schéma +# à moitié migré serait pire que le nouveau code. +ROLLBACK_NOTE="" +rollback_code() { + local sha="$1" + local original_failed_step="$FAILED_STEP" + echo "↩ ${PRENOM} — restauration du code précédent (${sha:0:7})" >&2 + if run_step "rollback git checkout" git checkout --force "$sha" \ + && run_step "rollback composer install" $COMPOSER_BIN $HC_COMPOSER_INSTALL_ARGS \ + && run_step "rollback vérification de vendor/" hc_verify_vendor \ + && run_step "rollback cache:clear" $PHP_BIN bin/console cache:clear --env=prod; then + ROLLBACK_NOTE=" → code restauré (${sha:0:7})" + else + ROLLBACK_NOTE=" → ROLLBACK ÉCHOUÉ à « ${FAILED_STEP} », instance probablement hors service" + fi + FAILED_STEP="$original_failed_step" +} + +CODE_READY=false if run_step "git checkout" git checkout --force "$REMOTE_SHA" \ -&& run_step "composer install" "$COMPOSER_BIN" install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts \ +&& run_step "composer install" $COMPOSER_BIN $HC_COMPOSER_INSTALL_ARGS \ +&& run_step "vérification de vendor/" hc_verify_vendor \ && run_step "install-ffmpeg" bash bin/install-ffmpeg.sh \ && run_step "cache:clear" $PHP_BIN bin/console cache:clear --env=prod \ && run_step "assets:install" bash -c "umask 022 && $PHP_BIN bin/console assets:install public --env=prod" \ -&& run_step "importmap:install" bash -c "umask 022 && $PHP_BIN bin/console importmap:install --env=prod" \ +&& run_step "importmap:install" bash -c "umask 022 && $PHP_BIN bin/console importmap:install --env=prod"; then + CODE_READY=true +fi + +if [[ "$CODE_READY" == true ]] \ && run_step "migrations" $PHP_BIN bin/console doctrine:migrations:migrate --no-interaction --env=prod \ && run_step "asset-map:compile" bash -c "umask 022 && $PHP_BIN bin/console asset-map:compile"; then rm -f "$IMMINENT_FILE" @@ -156,12 +174,18 @@ if run_step "git checkout" git checkout --force "$REMOTE_SHA" \ exit 0 else rm -f "$IMMINENT_FILE" - echo "${PRENOM} : échec du déploiement, .deployed-sha inchangé." >&2 + # Rollback seulement si l'échec précède les migrations ET qu'il y a un + # code différent à restaurer (si HEAD est déjà la cible, un second + # checkout n'améliorerait rien). + if [[ "$CODE_READY" != true && -n "$PREVIOUS_HEAD" && "$PREVIOUS_HEAD" != "$REMOTE_SHA" ]]; then + rollback_code "$PREVIOUS_HEAD" + fi + echo "${PRENOM} : échec du déploiement${ROLLBACK_NOTE}, .deployed-sha inchangé." >&2 if [[ "$IS_CRITICAL" == true ]]; then - report_line "critical" "${FAILED_STEP:-inconnue}" + report_line "critical" "${FAILED_STEP:-inconnue}${ROLLBACK_NOTE}" open_critical_ticket "${FAILED_STEP:-inconnue}" else - report_line "failed" "${FAILED_STEP:-inconnue}" + report_line "failed" "${FAILED_STEP:-inconnue}${ROLLBACK_NOTE}" fi exit 1 fi diff --git a/bin/deploy.sh b/bin/deploy.sh index 5a26cd7d..10befeba 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -56,8 +56,12 @@ if [[ -n "${SSH_KEY_PATH:-}" && -f "$SSH_KEY_PATH" ]]; then SSH_KEY_OPTS="-i ${SSH_KEY_PATH}" fi GIT_BRANCH="main" -PHP_BIN="/usr/local/bin/php" -COMPOSER_BIN="composer" +# PHP/composer en chemin absolu, composer via PHP CLI explicite : définis une +# seule fois dans bin/lib/deploy-common.sh (#570). +# shellcheck source=lib/deploy-common.sh +source "${SCRIPT_DIR}/lib/deploy-common.sh" || exit 1 +PHP_BIN="$HC_PHP_BIN" +COMPOSER_BIN="$HC_COMPOSER_BIN" # ── Questionnaire ───────────────────────────────────────────────────────────── title "═══════════════════════════════════════" @@ -189,7 +193,8 @@ if [[ "$UPDATE_MODE" == true ]]; then ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" \ "cd ${DEPLOY_PATH} && \ git pull origin main && \ - ${COMPOSER_BIN} install --no-interaction --prefer-dist --no-progress --no-dev && \ + ${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS} && \ + ( ${HC_VERIFY_VENDOR_SNIPPET} ) && \ ${PHP_BIN} bin/console cache:clear --env=prod && \ rm -rf var/cache/prod/* && \ rm -rf public/assets/* && \ @@ -200,7 +205,7 @@ if [[ "$UPDATE_MODE" == true ]]; then { error "❌ Erreur lors du déploiement."; exit 1; } else info "Mode primo déploiement : clonage repo + setup" - ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "mkdir -p ${DEPLOY_PATH} && cd ${DEPLOY_PATH} && git clone ${GIT_REPO} . && composer install --no-interaction --prefer-dist --no-progress && bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" && \ + ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "mkdir -p ${DEPLOY_PATH} && cd ${DEPLOY_PATH} && git clone ${GIT_REPO} . && ${COMPOSER_BIN} ${HC_COMPOSER_INSTALL_ARGS} && ( ${HC_VERIFY_VENDOR_SNIPPET} ) && bash bin/install-ffmpeg.sh || echo '⚠ ffmpeg non installé — vignettes vidéo indisponibles'" && \ success "✅ Déploiement réussi !" || \ { error "❌ Erreur lors du déploiement."; exit 1; } fi diff --git a/bin/lib/deploy-common.sh b/bin/lib/deploy-common.sh new file mode 100644 index 00000000..40c61e43 --- /dev/null +++ b/bin/lib/deploy-common.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# ============================================================================= +# Définitions communes aux scripts de déploiement (#570) — SOURCÉ par +# deploy-nightly.sh, deploy-all.sh et deploy.sh, jamais exécuté directement. +# +# Une seule définition de la commande composer et de la vérification de +# vendor/, pour qu'un correctif ne puisse plus être appliqué à un script et +# oublié dans les deux autres (le correctif 61ba614 n'avait touché que +# deploy-nightly.sh). +# ============================================================================= + +# Chemins absolus obligatoires : un cron cPanel s'exécute avec un PATH minimal +# (pas celui du profil shell interactif) — "composer"/"php" seuls ne résolvent +# à rien et font échouer le déploiement en silence (#421, échec réel constaté +# la nuit du 2026-09-12 : « composer : commande introuvable »). +HC_PHP="${DEPLOY_NIGHTLY_PHP_BIN:-/usr/local/bin/php}" +# -d memory_limit=512M : sur le mutualisé o2switch (LVE CloudLinux), le +# memory_limit par défaut du php.ini fait tuer cache:clear --env=prod même +# isolé dans son propre process SSH (vécu 2026-09-27) — la valeur par défaut +# est trop juste pour la compilation du container Symfony en prod. +HC_PHP_BIN="${HC_PHP} -d memory_limit=512M" + +# composer est un script `#!/usr/bin/env php` : sous le PATH minimal du cron, +# `env` résout "php" vers le CGI, pas le CLI. Composer affiche alors son aide +# et SORT EN 0 sans rien installer (constaté le 2026-10-02 : vendor/ resté +# sans symfony/monolog-bundle, 6 instances en HTTP 500). Invoquer composer.phar +# via le PHP CLI explicite court-circuite ce shebang. Valeur de plusieurs mots : +# toujours l'utiliser NON quoté ($HC_COMPOSER_BIN), comme $HC_PHP_BIN. +HC_COMPOSER_BIN="${HC_PHP_BIN} ${DEPLOY_NIGHTLY_COMPOSER_PHAR:-/usr/local/bin/composer}" +HC_COMPOSER_INSTALL_ARGS="install --no-interaction --prefer-dist --no-progress --no-dev --no-scripts" + +# Vérifie que vendor/ correspond exactement à composer.lock : après un +# install réussi, un dry-run ne doit plus rien avoir à faire. Un composer qui +# a affiché son aide (CGI) ou s'est arrêté en route ne produit pas ce message. +# Snippet de shell (et non une fonction seule) pour pouvoir aussi être envoyé +# tel quel dans une commande SSH distante (deploy-all.sh). +HC_VERIFY_VENDOR_SNIPPET='out=$('"${HC_COMPOSER_BIN}"' install --dry-run --no-dev --no-scripts --no-interaction 2>&1); case "$out" in *"Nothing to install, update or remove"*) ;; *) echo "vendor/ ne correspond pas à composer.lock (composer non exécuté en CLI ?) :" >&2; printf "%s\n" "$out" | head -8 >&2; exit 1 ;; esac' + +# À appeler dans un sous-shell (run_step) : sort en 1 si vendor/ est incomplet. +hc_verify_vendor() { + eval "$HC_VERIFY_VENDOR_SNIPPET" +} diff --git a/tests/bash/deploy-all-routing-test.sh b/tests/bash/deploy-all-routing-test.sh index c3096621..4f738d3f 100644 --- a/tests/bash/deploy-all-routing-test.sh +++ b/tests/bash/deploy-all-routing-test.sh @@ -50,10 +50,18 @@ if [[ "$*" == *"echo OK"* ]]; then echo "OK" elif [[ "$*" == *'echo $HOME'* ]]; then echo "/home9/ron2cuba" +elif [[ "$*" == *"git rev-parse HEAD"* && "$*" != *".deployed-sha"* ]]; then + # HEAD distant avant la mise à jour (sert au rollback, #570) + echo "prev1234567" +fi +# Échec simulé UNE seule fois sur la première commande contenant le motif +if [[ -n "${SSH_FAIL_ONCE_PATTERN:-}" && "$*" == *"${SSH_FAIL_ONCE_PATTERN}"* && ! -f "__MARKER__" ]]; then + touch "__MARKER__" + exit 1 fi exit 0 EOF - sed -i "s|__CALL_LOG__|${CALL_LOG}|" "${STUB_BIN}/ssh" + sed -i "s|__CALL_LOG__|${CALL_LOG}|; s|__MARKER__|${FIXTURE_DIR}/ssh-failed-once|g" "${STUB_BIN}/ssh" chmod +x "${STUB_BIN}/ssh" cat > "${STUB_BIN}/scp" < /dev/null 2>&1 + + assert_contains "$(cat "$CALL_LOG")" "Nothing to install, update or remove" + assert_contains "$(cat "$CALL_LOG")" "composer install --no-interaction" + + _teardown_routing_fixture +} + +test_echec_avant_migrations_restaure_le_code_precedent() { + _setup_routing_fixture + _write_routing_stubs + + local output exit_code + output=$(SSH_FAIL_ONCE_PATTERN="cache:clear" bash "${PROJECT_ROOT}/bin/deploy-all.sh" 2>&1) + exit_code=$? + + assert_equals "1" "$exit_code" "un échec de déploiement doit faire sortir en erreur" + assert_contains "$(cat "$CALL_LOG")" "git checkout --force prev1234567" + assert_contains "$output" "code restauré (prev123)" + + _teardown_routing_fixture +} + +test_echec_des_migrations_ne_restaure_pas_le_code() { + _setup_routing_fixture + _write_routing_stubs + + local output exit_code + output=$(SSH_FAIL_ONCE_PATTERN="doctrine:migrations:migrate" bash "${PROJECT_ROOT}/bin/deploy-all.sh" 2>&1) + exit_code=$? + + assert_equals "1" "$exit_code" + if grep -q "git checkout --force prev1234567" "$CALL_LOG"; then + fail "pas de restauration du code après un échec de migration (état de la base incertain)" + fi + + _teardown_routing_fixture +} diff --git a/tests/bash/deploy-nightly-test.sh b/tests/bash/deploy-nightly-test.sh index 6d32e74c..784d1c22 100644 --- a/tests/bash/deploy-nightly-test.sh +++ b/tests/bash/deploy-nightly-test.sh @@ -25,12 +25,13 @@ _setup_fixture() { # doivent donc pointer explicitement vers les stubs au lieu de compter # sur $PATH pour les intercepter. export DEPLOY_NIGHTLY_PHP_BIN="${STUB_BIN}/php" - export DEPLOY_NIGHTLY_COMPOSER_BIN="${STUB_BIN}/composer" + export DEPLOY_NIGHTLY_COMPOSER_PHAR="${STUB_BIN}/composer.phar" } _teardown_fixture() { rm -rf "$FIXTURE_DIR" - unset DEPLOY_NIGHTLY_PHP_BIN DEPLOY_NIGHTLY_COMPOSER_BIN + unset DEPLOY_NIGHTLY_PHP_BIN DEPLOY_NIGHTLY_COMPOSER_PHAR + unset STUB_HEAD_SHA STUB_VENDOR_INCOMPLETE STUB_COMPOSER_INSTALL_EXIT STUB_CACHE_CLEAR_EXIT STUB_CACHE_CLEAR_FAIL_ONCE STUB_MIGRATIONS_EXIT hash -r } @@ -43,7 +44,9 @@ echo "git \$*" >> "${CALL_LOG}" case "\$1" in fetch) exit 0 ;; rev-parse) - echo "${remote_sha}" + # HEAD = code réellement en place (peut différer de .deployed-sha) ; + # tout le reste (origin/main) = SHA distant. + if [[ "\$2" == "HEAD" ]]; then echo "\${STUB_HEAD_SHA:-head0000}"; else echo "${remote_sha}"; fi exit 0 ;; checkout) exit \${GIT_CHECKOUT_EXIT:-0} ;; @@ -66,12 +69,42 @@ EOF chmod +x "${STUB_BIN}/${name}" } +# Stub php : sert à la fois pour composer.phar (via PHP CLI explicite, #570) et +# pour bin/console. Réglable par variables d'environnement exportées par le test : +# STUB_VENDOR_INCOMPLETE=1|once composer --dry-run affiche son aide (simule le CGI, exit 0) — toujours / au 1er appel seulement +# STUB_COMPOSER_INSTALL_EXIT code de sortie de "composer install" +# STUB_CACHE_CLEAR_EXIT / STUB_CACHE_CLEAR_FAIL_ONCE=1 échec de cache:clear (toujours / au 1er appel) +# STUB_MIGRATIONS_EXIT code de sortie des migrations +_write_php_stub() { + cat > "${STUB_BIN}/php" <> "${CALL_LOG}" +args="\$*" +if [[ "\$args" == *"--dry-run"* ]]; then + if [[ "\${STUB_VENDOR_INCOMPLETE:-0}" == "1" || ( "\${STUB_VENDOR_INCOMPLETE:-0}" == "once" && \$(grep -c -e "--dry-run" "${CALL_LOG}") -eq 1 ) ]]; then + echo "Warning: Composer should be invoked via the CLI version of PHP, not the cgi-fcgi SAPI" + echo "Usage: composer [options] command [arguments]" + else + echo "Nothing to install, update or remove" + fi + exit 0 +fi +if [[ "\$args" == *"composer.phar install"* ]]; then exit \${STUB_COMPOSER_INSTALL_EXIT:-0}; fi +if [[ "\$args" == *"cache:clear"* ]]; then + if [[ "\${STUB_CACHE_CLEAR_FAIL_ONCE:-0}" == "1" && \$(grep -c "cache:clear" "${CALL_LOG}") -eq 1 ]]; then exit 1; fi + exit \${STUB_CACHE_CLEAR_EXIT:-0} +fi +if [[ "\$args" == *"doctrine:migrations:migrate"* ]]; then exit \${STUB_MIGRATIONS_EXIT:-0}; fi +exit 0 +STUBEOF + chmod +x "${STUB_BIN}/php" +} + _write_all_success_stubs() { local remote_sha="$1" _write_git_stub "$remote_sha" - _write_passthrough_stub composer _write_passthrough_stub bash - _write_passthrough_stub php + _write_php_stub } # Délai de préavis à 0 par défaut dans les tests (sinon chaque test attendrait @@ -90,7 +123,7 @@ test_instance_deja_a_jour_ne_deploie_rien() { assert_equals "0" "$exit_code" "sortie attendue à 0 sur instance déjà à jour" assert_file_not_exists "${FIXTURE_DIR}/composer_called" local composer_calls - composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} + composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si rien à déployer" assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|skipped" @@ -123,7 +156,7 @@ test_sha_different_execute_les_etapes_dans_lordre() { calls="$(cat "$CALL_LOG")" local checkout_line composer_line cache_line migrations_line checkout_line=$(grep -n "git checkout" <<< "$calls" | head -1 | cut -d: -f1) - composer_line=$(grep -n "^composer install" <<< "$calls" | head -1 | cut -d: -f1) + composer_line=$(grep -n "composer.phar install" <<< "$calls" | head -1 | cut -d: -f1) if [[ -z "$checkout_line" || -z "$composer_line" ]]; then fail "étapes attendues absentes de l'historique d'appels" @@ -138,9 +171,9 @@ test_echec_conserve_le_sha_precedent_et_stoppe_la_chaine() { _setup_fixture echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" _write_git_stub "new1111" - _write_passthrough_stub composer 1 # composer install échoue _write_passthrough_stub bash - _write_passthrough_stub php + _write_php_stub + export STUB_COMPOSER_INSTALL_EXIT=1 # composer install échoue /usr/bin/bash "$DEPLOY_NIGHTLY_SCRIPT" yannick "${FIXTURE_DIR}/instance" "${FIXTURE_DIR}/report.log" > /dev/null 2>&1 local exit_code=$? @@ -150,9 +183,9 @@ test_echec_conserve_le_sha_precedent_et_stoppe_la_chaine() { assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|failed|composer install" # Aucune étape après composer install ne doit avoir été appelée - local php_calls - php_calls=$(grep -c "^php " "$CALL_LOG" 2>/dev/null); php_calls=${php_calls:-0} - assert_equals "0" "$php_calls" "les étapes après l'échec ne doivent pas s'exécuter" + local console_calls + console_calls=$(grep -c "bin/console" "$CALL_LOG" 2>/dev/null); console_calls=${console_calls:-0} + assert_equals "0" "$console_calls" "les étapes après l'échec ne doivent pas s'exécuter" _teardown_fixture } @@ -183,7 +216,7 @@ test_activite_recente_reporte_le_deploiement() { assert_equals "0" "$exit_code" "sortie attendue à 0 sur report d'activité" assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|postponed" local composer_calls - composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} + composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si activité récente détectée" assert_equals "" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha" 2>/dev/null)" ".deployed-sha ne doit pas changer sur un report" @@ -293,8 +326,131 @@ EOF assert_contains "$(cat "${FIXTURE_DIR}/report.log")" "yannick|postponed" local composer_calls - composer_calls=$(grep -c "^composer " "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} + composer_calls=$(grep -c "composer.phar install" "$CALL_LOG" 2>/dev/null); composer_calls=${composer_calls:-0} assert_equals "0" "$composer_calls" "composer ne doit pas être appelé si reconnexion pendant le préavis" _teardown_fixture } + +# ── Composer via PHP CLI explicite, jamais « nu » (#570) ──────────────────── +# Incident du 2026-10-02 : composer lancé via le PHP CGI du PATH cron affichait +# son aide et sortait en 0 ; vendor/ restait incomplet → 6 instances en 500. + +_report() { cat "${FIXTURE_DIR}/report.log" 2>/dev/null; } +_count_calls() { local n; n=$(grep -c -e "$1" "$CALL_LOG" 2>/dev/null); echo "${n:-0}"; } +_run_nightly() { + /usr/bin/bash "$DEPLOY_NIGHTLY_SCRIPT" yannick "${FIXTURE_DIR}/instance" "${FIXTURE_DIR}/report.log" > /dev/null 2>&1 +} + +test_composer_est_lance_via_php_cli_explicite() { + _setup_fixture + _write_all_success_stubs "new1111" + + _run_nightly + + assert_contains "$(cat "$CALL_LOG")" "composer.phar install --no-interaction" + assert_equals "0" "$(_count_calls '^composer ')" "aucun appel direct à « composer »" + + _teardown_fixture +} + +test_vendor_incomplet_apres_composer_fait_echouer_et_restaure() { + _setup_fixture + echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" + _write_all_success_stubs "new1111" + export STUB_HEAD_SHA="old0000" STUB_VENDOR_INCOMPLETE="once" + + _run_nightly + local exit_code=$? + + assert_equals "1" "$exit_code" "composer qui n'installe rien ne doit pas passer pour un succès" + assert_contains "$(_report)" "yannick|failed|vérification de vendor/" + assert_contains "$(_report)" "code restauré (old0000)" + assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")" + # Seul le cache:clear de la restauration a tourné : celui du déploiement + # n'a jamais été atteint. + assert_equals "1" "$(_count_calls 'cache:clear')" "cache:clear du déploiement ne doit pas être atteint" + + _teardown_fixture +} + +# ── Rollback (#570) ───────────────────────────────────────────────────────── + +test_echec_avant_migrations_restaure_le_code_precedent() { + _setup_fixture + echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" + _write_all_success_stubs "new1111" + export STUB_HEAD_SHA="old0000" STUB_CACHE_CLEAR_FAIL_ONCE=1 + + _run_nightly + local exit_code=$? + + assert_equals "1" "$exit_code" + local calls checkout_new checkout_old + calls="$(cat "$CALL_LOG")" + checkout_new=$(grep -n "git checkout --force new1111" <<< "$calls" | head -1 | cut -d: -f1) + checkout_old=$(grep -n "git checkout --force old0000" <<< "$calls" | head -1 | cut -d: -f1) + if [[ -z "$checkout_new" || -z "$checkout_old" || "$checkout_old" -le "$checkout_new" ]]; then + fail "le code précédent doit être restauré après le checkout du nouveau (new=${checkout_new}, old=${checkout_old})" + fi + assert_equals "2" "$(_count_calls 'composer.phar install --no-interaction')" "vendor/ réinstallé pour l'ancien lock" + assert_equals "2" "$(_count_calls 'cache:clear')" "cache vidé à nouveau après restauration" + assert_contains "$(_report)" "yannick|failed|cache:clear" + assert_contains "$(_report)" "code restauré (old0000)" + assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")" + + _teardown_fixture +} + +test_rollback_en_echec_est_signale_sans_ambiguite() { + _setup_fixture + echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" + _write_all_success_stubs "new1111" + export STUB_HEAD_SHA="old0000" STUB_CACHE_CLEAR_EXIT=1 + + _run_nightly + local exit_code=$? + + assert_equals "1" "$exit_code" + assert_contains "$(_report)" "ROLLBACK ÉCHOUÉ" + assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")" + + _teardown_fixture +} + +# Migrations en échec : l'état de la base est incertain (migration partielle), +# restaurer l'ancien code sur un schéma à moitié migré serait pire. +test_echec_des_migrations_ne_restaure_pas_le_code() { + _setup_fixture + echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" + _write_all_success_stubs "new1111" + export STUB_HEAD_SHA="old0000" STUB_MIGRATIONS_EXIT=1 + + _run_nightly + local exit_code=$? + + assert_equals "1" "$exit_code" + assert_equals "1" "$(_count_calls 'git checkout --force')" "pas de restauration du code après un échec de migration" + assert_contains "$(_report)" "yannick|failed|migrations" + assert_equals "old0000" "$(cat "${FIXTURE_DIR}/instance/.deployed-sha")" + + _teardown_fixture +} + +# HEAD déjà sur la cible (cas de l'incident : code nouveau, vendor ancien, lors +# d'une nouvelle tentative) : rien à restaurer, un second checkout n'aiderait pas. +test_pas_de_rollback_si_le_code_en_place_est_deja_la_cible() { + _setup_fixture + echo "old0000" > "${FIXTURE_DIR}/instance/.deployed-sha" + _write_all_success_stubs "new1111" + export STUB_HEAD_SHA="new1111" STUB_CACHE_CLEAR_EXIT=1 + + _run_nightly + local exit_code=$? + + assert_equals "1" "$exit_code" + assert_equals "1" "$(_count_calls 'git checkout --force')" + assert_equals "0" "$(grep -c 'ROLLBACK\|restauré' "${FIXTURE_DIR}/report.log")" "aucune mention de rollback" + + _teardown_fixture +} diff --git a/tests/bash/deploy-scripts-test.sh b/tests/bash/deploy-scripts-test.sh new file mode 100644 index 00000000..e8bca2ef --- /dev/null +++ b/tests/bash/deploy-scripts-test.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# ============================================================================= +# Garde-fou transversal aux scripts de déploiement (#570) : composer ne doit +# jamais être appelé « nu ». Incident du 2026-10-02 : lancé via le PHP CGI du +# PATH cron, composer affichait son aide et sortait en 0 sans rien installer. +# Le correctif de la veille n'avait été appliqué qu'à un des trois scripts. +# ============================================================================= + +test_aucun_composer_nu_dans_les_scripts_de_deploiement() { + local bare + bare=$(grep -nE 'COMPOSER_BIN="composer"|(&&|;)[[:space:]]*composer[[:space:]]+install' \ + "${PROJECT_ROOT}"/bin/*.sh "${PROJECT_ROOT}"/bin/lib/*.sh 2>/dev/null | grep -v '^[^:]*:[0-9]*:[[:space:]]*#') + assert_equals "" "$bare" "composer ne doit jamais être appelé sans PHP CLI explicite" +} + +test_les_trois_scripts_partagent_la_definition_commune() { + local script + for script in deploy-nightly.sh deploy-all.sh deploy.sh; do + assert_contains "$(cat "${PROJECT_ROOT}/bin/${script}")" "lib/deploy-common.sh" + done +}