Aller au contenu

Lab : fiabiliser les scripts de Signalements

200 Compagnon ⏱ 8 h bashlinuxubuntudebianshellcheck

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) bats 1.10.0 (Ubuntu), 1.11.1 (Debian) bats-assert 2.1.0 bats-support 0.3.0 shellcheck 0.9.0 (Ubuntu), 0.10.0 (Debian) , vérifié le 8 octobre 2026

Ce lab rassemble le cours Bash pour l'automatisation. Vous reconstruisez, à votre façon, le dépôt signalements-outils que l'équipe a tiré des scripts de Camille : la bibliothèque commune, publier-export et purger-pieces-jointes, leur configuration d'analyse et leurs tests. Puis un script, verifier-lab, lance vos outils de l'extérieur dans des situations préparées (export vide, dépôt en échec, verrou occupé, signal pendant un dépôt, noms de fichiers piégés) et vérifie leurs codes de sortie et leurs effets. Il valide le niveau 200 (Compagnon) de la compétence « Scripts Bash ».

Comptez une journée. Toutes les pièces sont dans les leçons, et la leçon 12 montre l'état final de publier-export : essayez d'écrire chaque partie avant de la relire, et de comprendre chaque ligne que vous reprenez.

Note

Tout se passe en local, dans un dossier jetable : aucun compte de cloud, aucune donnée réelle. L'Object Storage de Scaleway est remplacé par une doublure de la commande aws, qui copie les fichiers dans un répertoire. Le script de vérification a été exécuté avec Bash 5.2.21, ShellCheck 0.10.0 et Bats 1.11.1 contre une implémentation de référence écrite en suivant le cours, puis contre des versions volontairement cassées de celle-ci (retour chariot non retiré, dépôt lancé au premier plan, piège EXIT absent, configuration chargée par source, verrou supprimé, rm sans guillemets) : chaque défaut fait échouer au moins un contrôle.

Ce qu'il faut

  • Une machine Linux avec Bash 5.2 : votre poste sous Ubuntu 24.04 ou Debian 13, ou une machine virtuelle jetable. Sous macOS, le Bash livré date de 2007 et flock n'existe pas : passez par une machine virtuelle.
  • Les outils d'analyse et de test, ainsi que ceux qu'utilisent les scripts :
$ sudo apt install shellcheck bats bats-support bats-assert make gzip util-linux procps git

bats-support et bats-assert s'installent dans /usr/lib/bats/, que bats_load_library consulte par défaut. Si vous les avez clonés ailleurs, indiquez le répertoire parent dans BATS_LIB_PATH.

  • Un dépôt Git vide, signalements-outils, où vous rangerez votre travail. Faites un commit à chaque étape franchie : en cas de régression, git diff dira ce qui a changé.

Le bac à sable

Le script suivant fabrique des données d'essai : six exports (un normal de 120 signalements, un petit, un réduit à l'en-tête, un vide, un aux fins de ligne CRLF, un au séparateur ;) et des pièces jointes anciennes aux noms hostiles (espaces, *, tiret initial, saut de ligne, espace en tête), à côté de fichiers récents qu'aucune purge ne doit toucher. Enregistrez-le hors de votre dépôt, sous preparer-bac-a-sable, rendez-le exécutable et lancez-le sur un répertoire neuf :

#!/usr/bin/env bash
# preparer-bac-a-sable : crée des exports et des pièces jointes d'essai pour le lab Bash.
# Usage : preparer-bac-a-sable RÉPERTOIRE   (créé s'il n'existe pas ; refuse s'il n'est pas vide)

set -Eeuo pipefail
export LC_ALL=C.UTF-8

readonly EN_TETE='id,type,commune,date'

(( $# == 1 )) || { printf 'Usage : %s RÉPERTOIRE\n' "${0##*/}" >&2; exit 2; }
bac=$1
mkdir -p -- "$bac"
bac=$(readlink -f -- "$bac")
if [[ -n $(find "$bac" -mindepth 1 -maxdepth 1 -print -quit) ]]; then
  printf '%s : %s n'"'"'est pas vide, choisissez un répertoire neuf\n' "${0##*/}" "$bac" >&2
  exit 1
fi
mkdir -p -- "$bac/exports/publies" "$bac/pj/2025 archives" "$bac/pj/2026" "$bac/bucket"

# exporter JOUR NOMBRE : un export valide de NOMBRE signalements, fins de ligne Unix.
exporter() {
  local jour=$1 n=$2 i
  local -a types=(nid-de-poule lampadaire tag encombrant)
  {
    printf '%s\n' "$EN_TETE"
    for (( i = 1; i <= n; i++ )); do
      printf '%d,%s,Exempleville,%s\n' "$i" "${types[i % ${#types[@]}]}" "$jour"
    done
  } > "$bac/exports/signalements-$jour.csv"
}

exporter 2026-10-07 120
exporter 2026-10-05 3
printf '%s\n' "$EN_TETE" > "$bac/exports/signalements-2026-10-06.csv"         # en-tête seul
: > "$bac/exports/signalements-2026-10-04.csv"                                 # vide
printf '%s\r\n' "$EN_TETE" "1,tag,Exempleville,2026-10-03" \
  > "$bac/exports/signalements-2026-10-03.csv"                                 # CRLF, valide
printf '%s\n' "id;type;commune;date" "1;tag;Exempleville;2026-10-02" \
  > "$bac/exports/signalements-2026-10-02.csv"                                 # séparateur ;

# Pièces jointes : anciennes (à purger) aux noms hostiles, récentes (à garder).
pj=$bac/pj
touch -d '400 days ago' -- "$pj/2025 archives/photo du 12 mars.jpg" \
  "$pj/2025 archives/-rf.jpg" "$pj/2025 archives/photo*.jpg" \
  "$pj/2025 archives/"$'deux\nlignes.jpg' "$pj/2025 archives/ espace-devant.jpg"
touch -d '40 days ago' -- "$pj/2026/moyenne.jpg"
touch -d '10 days ago' -- "$pj/2026/recente.jpg" "$pj/2025 archives/photo1.jpg"

cat > "$bac/essai.conf" <<'FIN'
# Configuration d'essai de publier-export : clé=valeur, sans guillemets.
DESTINATION=s3://sig-exports-recette
FIN

printf 'Bac à sable prêt dans %s :\n' "$bac"
printf '  export PUBLIER_EXPORT_REPERTOIRE=%q\n' "$bac/exports"
printf '  export PUBLIER_EXPORT_VERROU=%q\n' "$bac/verrou"
printf '  export PUBLIER_EXPORT_CONFIG=%q\n' "$bac/essai.conf"
printf '  export PURGER_REPERTOIRE=%q\n' "$bac/pj"
$ ./preparer-bac-a-sable ~/bac-signalements
Bac à sable prêt dans /home/vous/bac-signalements :
  export PUBLIER_EXPORT_REPERTOIRE=/home/vous/bac-signalements/exports
  export PUBLIER_EXPORT_VERROU=/home/vous/bac-signalements/verrou
  export PUBLIER_EXPORT_CONFIG=/home/vous/bac-signalements/essai.conf
  export PURGER_REPERTOIRE=/home/vous/bac-signalements/pj

Copiez les quatre lignes export dans votre shell, ajoutez export HORODATER=0 pour des messages sans horodatage, et placez votre doublure en tête du PATH (PATH=$PWD/tests/doublures:$PATH, depuis la racine du dépôt) : vos scripts s'essaient alors sans jamais toucher à /srv ni à /etc, ni à un vrai bucket. Le fichier essai.conf désigne un bucket de recette, sig-exports-recette, pour vérifier que la configuration est bien lue.

Les exigences

ExigenceLeçon
Le dépôt contient bin/publier-export et bin/purger-pieces-jointes (exécutables, #!/usr/bin/env bash), lib/commun.sh, tests/ avec une doublure tests/doublures/aws, .shellcheckrc, Makefile et un .gitattributes qui impose eol=lf1, 6, 12
Les deux scripts chargent lib/commun.sh par un chemin calculé depuis leur propre emplacement (REP_OUTILS), avec la directive # shellcheck source= ; la bibliothèque fournit journaliser, deboguer, avertir, mourir [-c CODE] et exiger, et tous les messages vont sur la sortie d'erreur6, 8
Les scripts sont structurés en fonctions avec un main, appelé seulement si le fichier est exécuté et non chargé par source ; ils tournent en mode strict (set -Eeuo pipefail, inherit_errexit) et signalent toute erreur imprévue avec sa ligne6, 9
publier-export accepte -d/--date AAAA-MM-JJ (défaut : aujourd'hui), --destination s3://..., -n/--dry-run, -v/--verbeux, -h/--help (aide commençant par Usage) et --version (publier-export X.Y.Z) ; les formes --date=... et -- sont reconnues8
Réglages par ordre de priorité croissante : valeurs par défaut, fichier de configuration (/etc/signalements/publier-export.conf, changé par PUBLIER_EXPORT_CONFIG, lu ligne à ligne sans être exécuté), environnement (PUBLIER_EXPORT_DESTINATION), options. PUBLIER_EXPORT_REPERTOIRE et PUBLIER_EXPORT_VERROU remplacent le répertoire des exports et le fichier de verrou5, 8
Code 2 pour une option inconnue, une option sans sa valeur, une date mal formée ou qui n'existe pas au calendrier3, 4, 8
Code 3, sans rien déposer, pour un export absent, vide, réduit à l'en-tête ou dont l'en-tête n'est pas id,type,commune,date ; un export aux fins de ligne CRLF est accepté4, 9
L'export est compressé, son empreinte SHA-256 calculée (fichier .sha256 contenant le seul nom de l'archive, vérifiable par sha256sum --check), puis l'archive et l'empreinte sont déposées, dans cet ordre, sous DESTINATION/AAAA/MM/ ; chaque appel à aws porte --endpoint-url https://s3.fr-par.scw.cloud3, 7, 9
Un échec de aws donne le code 4 ; une copie locale de l'archive et de son empreinte n'apparaît dans publies/ qu'après un dépôt réussi, et les copies de plus de 30 jours sont purgées9, 10
En fonctionnement normal, rien sur la sortie standard8
Une seule exécution à la fois : verrou flock sur PUBLIER_EXPORT_VERROU, code 5 s'il est occupé10
Le travail se fait dans un répertoire créé par mktemp à côté de publies/ et supprimé par un piège EXIT, y compris en cas d'échec ; un SIGTERM pendant un dépôt arrête aws sans attendre la fin du transfert, nettoie, rend le verrou et termine avec le code 14310, 11
--dry-run n'appelle jamais aws, ne dépose rien et ne laisse aucun fichier8, 10
purger-pieces-jointes supprime sous PURGER_REPERTOIRE (défaut /srv/donnees/pieces-jointes) les fichiers modifiés il y a plus de -j/--jours N jours (365 par défaut, de 30 à 3650, sinon code 2), y compris aux noms hostiles, sans suivre les liens symboliques ni quitter le système de fichiers ; -n/--dry-run ne supprime rien ; sans -y/--oui, il demande confirmation devant un terminal et refuse avec le code 2 sinon2, 5, 8
ShellCheck ne signale rien sur bin/, lib/, tests/*.bats et les doublures ; .shellcheckrc contient external-sources=true et source-path=SCRIPTDIR, sans désactivation globale ; toute directive locale est justifiée en commentaire12
Au moins dix tests Bats pour publier-export et quatre pour purger-pieces-jointes, qui passent sans réseau et sans appeler le vrai aws, et ne dépendent ni de la date du jour ni de la configuration du poste12
make verifier lance ShellCheck puis Bats ; un workflow d'intégration continue fait de même à chaque pull request12

Pistes

  • Avancez dans l'ordre des leçons, et faites tourner vos tests dès qu'ils existent : la bibliothèque (leçon 6), l'interface (leçon 8), le mode strict et les vérifications explicites (leçon 9), puis le verrou et les pièges (leçon 10). Écrivez le test d'un comportement avant de le coder quand vous le pouvez : un test qui n'a jamais échoué ne prouve rien.
  • La doublure de aws de la leçon 12 suffit pour vos tests. Le script de vérification apporte la sienne, indépendante, et n'utilise pas vos variables d'environnement de test : votre script doit seulement appeler aws par le PATH, jamais par un chemin absolu.
  • Prenez le verrou avant de lire l'export : deux exécutions simultanées ne doivent même pas commencer à préparer une archive.
  • Pour qu'un SIGTERM soit traité pendant le dépôt, aws doit tourner en arrière-plan, attendu par wait, et le piège doit l'arrêter avant de nettoyer (leçon 10). Lancé au premier plan, le dépôt bloque le piège jusqu'à la fin du transfert : c'est précisément ce que le contrôle mesure. Pour finir avec le bon code, le piège retire ses gestionnaires et se renvoie le signal.
  • Les variables qu'utilisent les pièges (répertoire de travail, PID de l'enfant) sont globales : un piège EXIT s'exécute après le retour de main, quand ses variables locales ont disparu.
  • Retirez le \r final de la ligne d'en-tête avant de la comparer : le module csv de Python, qui produit les vrais exports, termine ses lignes par CRLF.
  • Pour la purge, find ... -print0 lu par mapfile -d '' ou read -r -d '' ; rm -f -- "$chemin". Les fichiers du bac à sable vous diront vite si un nom se perd en route, et --dry-run doit les afficher sans ambiguïté (printf '%q').
  • Une condition [[ ... ]] && (( ... )) || erreur_usage ... déclenche SC2015 : écrivez un if explicite.
  • Sous Bats, l'entrée standard peut être le terminal : redirigez-la depuis /dev/null (run -2 "$SCRIPT" < /dev/null) pour tester le refus de confirmation, sinon la question bloquera la suite.

Vérifier

Enregistrez le script suivant hors de votre dépôt, sous verifier-lab, rendez-le exécutable et lancez-le en lui donnant le chemin du dépôt : ./verifier-lab ~/signalements-outils. Il n'a besoin d'aucun droit particulier et ne touche à rien en dehors d'un répertoire temporaire, supprimé à la fin. Comptez une dizaine de secondes.

#!/usr/bin/env bash
# verifier-lab : vérifie de l'extérieur le dépôt signalements-outils du lab Bash.
# Usage : verifier-lab [CHEMIN_DU_DEPOT]   (par défaut : le répertoire courant)
# Chaque contrôle affiche OK ou ÉCHEC ; le code de sortie vaut 0 si tout passe, 1 sinon.
# Ne touche à rien hors d'un répertoire de travail temporaire, supprimé à la sortie.

set -Eeuo pipefail
shopt -s inherit_errexit
export LC_ALL=C.UTF-8

readonly JOUR=2026-10-07
readonly MOIS=2026/10
readonly EN_TETE='id,type,commune,date'
readonly MARQUE_ATTENTE=lab-aws-attente

depot=""
travail=""
echecs=0
total=0

# --- Infrastructure -------------------------------------------------------

nettoyer() {
  # Une doublure en attente qui aurait survécu à un contrôle raté.
  pkill -u "$(id -u)" -f "^$MARQUE_ATTENTE " 2> /dev/null || true
  if [[ -n $travail ]]; then
    rm -rf -- "$travail"
  fi
}
trap nettoyer EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

mourir() {
  printf 'verifier-lab : %s\n' "$*" >&2
  exit 1
}

# controle LIBELLÉ COMMANDE...
#   Attention (leçon 9) : la commande s'exécute dans la condition d'un if, où
#   set -e est suspendu. Les fonctions de contrôle enchaînent donc leurs tests
#   par && ou se terminent par un test explicite, sans compter sur set -e.
controle() {
  local libelle=$1
  shift
  total=$((total + 1))
  if "$@"; then
    printf 'OK     %s\n' "$libelle"
  else
    printf 'ÉCHEC  %s\n' "$libelle"
    echecs=$((echecs + 1))
  fi
}

# Environnement minimal et maîtrisé pour lancer publier-export : aucune variable
# héritée du poste ne doit changer son comportement. Les variables d'essai
# (CONFIG_ESSAI, DESTINATION_ESSAI, LAB_AWS_CODE, LAB_AWS_ATTENTE) s'ajoutent si
# elles sont définies. Remplit le tableau global commande_publier.
commande_publier=()
preparer_publier() {
  commande_publier=(env -i HOME="$travail/maison" PATH="$travail/doublures:$PATH"
    LC_ALL=C.UTF-8 HORODATER=0
    PUBLIER_EXPORT_REPERTOIRE="$travail/exports"
    PUBLIER_EXPORT_VERROU="$travail/verrou"
    PUBLIER_EXPORT_CONFIG="${CONFIG_ESSAI:-$travail/absente.conf}"
    LAB_AWS_JOURNAL="$travail/aws.log" LAB_BUCKET="$travail/bucket")
  [[ -z ${DESTINATION_ESSAI:-} ]] || commande_publier+=(PUBLIER_EXPORT_DESTINATION="$DESTINATION_ESSAI")
  [[ -z ${LAB_AWS_CODE:-} ]] || commande_publier+=(LAB_AWS_CODE="$LAB_AWS_CODE")
  [[ -z ${LAB_AWS_ATTENTE:-} ]] || commande_publier+=(LAB_AWS_ATTENTE="$LAB_AWS_ATTENTE")
  commande_publier+=("$depot/bin/publier-export")
}

# lancer_publier [ARGUMENTS...] : remplit code, et les fichiers sortie et erreurs.
code=0
lancer_publier() {
  code=0
  preparer_publier
  "${commande_publier[@]}" "$@" > "$travail/sortie" 2> "$travail/erreurs" < /dev/null || code=$?
}

lancer_purger() {
  code=0
  env -i HOME="$travail/maison" PATH="$PATH" LC_ALL=C.UTF-8 HORODATER=0 \
    PURGER_REPERTOIRE="$travail/pj" \
    "$depot/bin/purger-pieces-jointes" "$@" \
    > "$travail/sortie" 2> "$travail/erreurs" < /dev/null || code=$?
}

# Remet à zéro les exports, le seau simulé et le journal de la doublure.
reinitialiser() {
  rm -rf -- "$travail/exports" "$travail/bucket" "$travail/verrou"
  mkdir -p -- "$travail/exports/publies" "$travail/bucket"
  : > "$travail/aws.log"
}

# creer_export CONTENU : écrit l'export du jour testé, tel quel (printf %b).
creer_export() {
  printf '%b' "$1" > "$travail/exports/signalements-$JOUR.csv"
}

export_valide() {
  creer_export "$EN_TETE\n1,nid-de-poule,Exempleville,$JOUR\n2,lampadaire,Exempleville,$JOUR\n"
}

aws_non_appele() { [[ ! -s $travail/aws.log ]]; }
sortie_vide() { [[ ! -s $travail/sortie ]]; }
erreurs_non_vides() { [[ -s $travail/erreurs ]]; }
aucun_travail_restant() { ! compgen -G "$travail/exports/publies/.travail.*" > /dev/null; }

# La doublure de aws du vérificateur, indépendante de celle du lecteur.
ecrire_doublure() {
  mkdir -p -- "$travail/doublures"
  cat > "$travail/doublures/aws" <<'FIN'
#!/usr/bin/env bash
# Doublure de aws : note l'appel, puis simule s3 cp et s3api head-object.
printf '%s\n' "$*" >> "${LAB_AWS_JOURNAL:?}"
if [[ -n ${LAB_AWS_ATTENTE:-} ]]; then
  # Un seul processus, renommé pour être retrouvé : voir la leçon 10.
  exec -a lab-aws-attente sleep "$LAB_AWS_ATTENTE"
fi
if (( ${LAB_AWS_CODE:-0} != 0 )); then
  echo "doublure aws : échec simulé" >&2
  exit "$LAB_AWS_CODE"
fi
case "$1 $2" in
  "s3 cp")
    source=${*: -2:1}
    cible=${*: -1}
    mkdir -p -- "${LAB_BUCKET:?}/$(dirname -- "${cible#s3://}")"
    cp -- "$source" "$LAB_BUCKET/${cible#s3://}"
    ;;
  "s3api head-object")
    while (( $# > 0 )); do
      case $1 in
        --bucket) seau=$2; shift ;;
        --key) cle=$2; shift ;;
      esac
      shift
    done
    stat -c %s -- "${LAB_BUCKET:?}/$seau/$cle"
    ;;
  *)
    echo "doublure aws : appel non prévu : $*" >&2
    exit 99
    ;;
esac
FIN
  chmod 0755 "$travail/doublures/aws"
}

# --- Contrôles du dépôt ----------------------------------------------------

structure_presente() {
  [[ -x $depot/bin/publier-export && -x $depot/bin/purger-pieces-jointes \
    && -f $depot/lib/commun.sh && -f $depot/Makefile ]]
}

shellcheckrc_correct() {
  local f=$depot/.shellcheckrc
  [[ -f $f ]] && grep -qx 'external-sources=true' "$f" \
    && grep -qx 'source-path=SCRIPTDIR' "$f" && ! grep -q '^disable=' "$f"
}

shellcheck_propre() {
  local -a fichiers=("$depot"/bin/* "$depot/lib/commun.sh" "$depot"/tests/*.bats)
  [[ -d $depot/tests/doublures ]] && fichiers+=("$depot"/tests/doublures/*)
  ( cd -- "$depot" && shellcheck "${fichiers[@]#"$depot"/}" ) > "$travail/shellcheck.txt" 2>&1
}

nombre_de_tests() {   # nombre_de_tests FICHIER MINIMUM
  local n
  [[ -f $1 ]] || return 1
  n=$(bats --count "$1") || return 1
  (( n >= $2 ))
}

# Les tests passent, et aucun n'atteint un vrai aws : un piège en fin de PATH
# laisse une trace si la doublure du lecteur n'a pas pris sa place.
bats_passe() {
  mkdir -p -- "$travail/piege"
  printf '#!/bin/sh\ntouch "%s"\nexit 97\n' "$travail/vrai-aws-appele" > "$travail/piege/aws"
  chmod 0755 "$travail/piege/aws"
  ( cd -- "$depot" && PATH="$travail/piege:$PATH" bats tests/ ) > "$travail/bats.txt" 2>&1 \
    && [[ ! -e $travail/vrai-aws-appele ]]
}

make_verifier_existe() { make -C "$depot" -n verifier > /dev/null 2>&1; }

# --- Contrôles de publier-export --------------------------------------------

aide_et_version() {
  lancer_publier --help
  if (( code != 0 )) || ! grep -q '^Usage' "$travail/sortie"; then
    return 1
  fi
  lancer_publier --version
  (( code == 0 )) && grep -Eq '^publier-export [0-9]+\.[0-9]+\.[0-9]+$' "$travail/sortie"
}

publication_nominale() {
  local seau=$travail/bucket/sig-exports-mairie/$MOIS
  local nom=signalements-$JOUR.csv.gz
  reinitialiser
  export_valide
  lancer_publier --date "$JOUR"
  (( code == 0 )) && sortie_vide \
    && [[ -f $seau/$nom && -f $seau/$nom.sha256 ]] \
    && ( cd -- "$seau" && sha256sum --check --quiet "$nom.sha256" ) \
    && cmp -s <(zcat -- "$seau/$nom") "$travail/exports/signalements-$JOUR.csv" \
    && [[ -f $travail/exports/publies/$nom && -f $travail/exports/publies/$nom.sha256 ]] \
    && aucun_travail_restant
}

point_acces_fr_par() {
  # Chaque appel enregistré vise l'Object Storage de Scaleway à Paris.
  [[ -s $travail/aws.log ]] \
    && ! grep -v -- '--endpoint-url https://s3.fr-par.scw.cloud' "$travail/aws.log" | grep -q .
}

crlf_accepte() {
  reinitialiser
  creer_export "$EN_TETE\r\n1,nid-de-poule,Exempleville,$JOUR\r\n"
  lancer_publier -d "$JOUR"
  (( code == 0 ))
}

# refus_export CONTENU|absent : code 3, message d'erreur, rien de déposé.
refus_export() {
  reinitialiser
  [[ $1 == absent ]] || creer_export "$1"
  lancer_publier -d "$JOUR"
  (( code == 3 )) && erreurs_non_vides && sortie_vide && aws_non_appele && aucun_travail_restant
}

usage_refuse() {
  reinitialiser
  export_valide
  local ligne
  local -a arguments
  for ligne in "-d 07/10/2026" "-d 2026-02-30" "--option-inconnue" "-d"; do
    read -r -a arguments <<< "$ligne"
    lancer_publier "${arguments[@]}"
    if (( code != 2 )) || ! erreurs_non_vides || ! aws_non_appele; then
      return 1
    fi
  done
}

depot_en_echec() {
  reinitialiser
  export_valide
  LAB_AWS_CODE=1 lancer_publier -d "$JOUR"
  (( code == 4 )) && erreurs_non_vides && aucun_travail_restant \
    && [[ ! -e $travail/exports/publies/signalements-$JOUR.csv.gz ]]
}

verrou_respecte() {
  local fd
  reinitialiser
  export_valide
  exec {fd}>> "$travail/verrou"
  flock -n "$fd"
  lancer_publier -d "$JOUR"
  exec {fd}>&-
  (( code == 5 )) && aws_non_appele
}

simulation_sans_aws() {
  reinitialiser
  export_valide
  lancer_publier --dry-run -d "$JOUR"
  (( code == 0 )) && aws_non_appele && aucun_travail_restant \
    && [[ -z $(find "$travail/bucket" -type f) ]] \
    && [[ ! -e $travail/exports/publies/signalements-$JOUR.csv.gz ]]
}

destination_option_et_environnement() {
  reinitialiser
  export_valide
  lancer_publier -d "$JOUR" --destination s3://sig-exports-recette/essai
  if (( code != 0 )) || [[ ! -f $travail/bucket/sig-exports-recette/essai/$MOIS/signalements-$JOUR.csv.gz ]]; then
    return 1
  fi
  reinitialiser
  export_valide
  DESTINATION_ESSAI=s3://sig-exports-env lancer_publier -d "$JOUR"
  (( code == 0 )) && [[ -f $travail/bucket/sig-exports-env/$MOIS/signalements-$JOUR.csv.gz ]]
}

configuration_lue_sans_execution() {
  local config=$travail/essai.conf
  reinitialiser
  export_valide
  printf '%s\n' '# essai' 'DESTINATION=s3://sig-exports-conf' \
    "X=\$(touch $travail/pirate)" > "$config"
  CONFIG_ESSAI=$config lancer_publier -d "$JOUR"
  (( code == 0 )) && [[ ! -e $travail/pirate ]] \
    && [[ -f $travail/bucket/sig-exports-conf/$MOIS/signalements-$JOUR.csv.gz ]]
}

# SIGTERM pendant le dépôt : code 143, aucun répertoire de travail, aucune
# doublure orpheline, et le verrou est rendu (une nouvelle exécution réussit).
sigterm_nettoie() {
  local pid i
  reinitialiser
  export_valide
  LAB_AWS_ATTENTE=30 preparer_publier
  # env remplace son processus par le script : $! est bien celui de publier-export.
  "${commande_publier[@]}" -d "$JOUR" > "$travail/sortie" 2> "$travail/erreurs" < /dev/null &
  pid=$!
  for (( i = 0; i < 100; i++ )); do
    [[ -s $travail/aws.log ]] && break
    sleep 0.1
  done
  if [[ ! -s $travail/aws.log ]]; then
    kill "$pid" 2> /dev/null || true
    wait "$pid" || true
    return 1
  fi
  # Le script doit réagir tout de suite, pas à la fin du dépôt en cours (leçon 10).
  kill -TERM "$pid"
  for (( i = 0; i < 30; i++ )); do
    kill -0 "$pid" 2> /dev/null || break
    sleep 0.1
  done
  if kill -0 "$pid" 2> /dev/null; then
    kill -KILL "$pid" 2> /dev/null || true
    wait "$pid" 2> /dev/null || true
    return 1
  fi
  code=0
  wait "$pid" || code=$?
  (( code == 143 )) || return 1
  for (( i = 0; i < 20; i++ )); do
    pgrep -u "$(id -u)" -f "^$MARQUE_ATTENTE " > /dev/null || break
    sleep 0.1
  done
  if pgrep -u "$(id -u)" -f "^$MARQUE_ATTENTE " > /dev/null || ! aucun_travail_restant; then
    return 1
  fi
  : > "$travail/aws.log"
  lancer_publier -d "$JOUR"
  (( code == 0 ))
}

# --- Contrôles de purger-pieces-jointes ------------------------------------

preparer_pj() {
  local pj=$travail/pj
  rm -rf -- "$pj" "$travail/hors-pj"
  mkdir -p -- "$pj/2025 archives" "$pj/2026" "$travail/hors-pj"
  touch -d '400 days ago' -- "$pj/2025 archives/photo du 12 mars.jpg" \
    "$pj/2025 archives/-rf.jpg" "$pj/2025 archives/photo*.jpg" \
    "$pj/2025 archives/"$'deux\nlignes.jpg' "$travail/hors-pj/temoin.jpg"
  touch -d '40 days ago' -- "$pj/2026/moyenne.jpg"
  touch -d '10 days ago' -- "$pj/2026/recente.jpg" "$pj/2025 archives/photo1.jpg"
  ln -s -- "$travail/hors-pj/temoin.jpg" "$pj/2026/lien-vers-temoin.jpg"
}

anciens_presents() {
  local pj=$travail/pj
  [[ -e "$pj/2025 archives/photo du 12 mars.jpg" && -e "$pj/2025 archives/-rf.jpg" \
    && -e "$pj/2025 archives/photo*.jpg" && -e "$pj/2025 archives/"$'deux\nlignes.jpg' ]]
}

anciens_absents() {
  local pj=$travail/pj
  [[ ! -e "$pj/2025 archives/photo du 12 mars.jpg" && ! -e "$pj/2025 archives/-rf.jpg" \
    && ! -e "$pj/2025 archives/photo*.jpg" && ! -e "$pj/2025 archives/"$'deux\nlignes.jpg' ]]
}

recents_intacts() {
  local pj=$travail/pj
  [[ -e $pj/2026/moyenne.jpg && -e $pj/2026/recente.jpg \
    && -e "$pj/2025 archives/photo1.jpg" && -e $travail/hors-pj/temoin.jpg ]]
}

purge_simulation() {
  preparer_pj
  lancer_purger --dry-run
  (( code == 0 )) && anciens_presents && recents_intacts
}

purge_sans_oui_refusee() {
  preparer_pj
  lancer_purger
  (( code == 2 )) && erreurs_non_vides && anciens_presents
}

purge_seuils_invalides() {
  local valeur
  preparer_pj
  for valeur in 3 zéro 08x 99999; do
    lancer_purger --jours "$valeur" --oui
    (( code == 2 )) || return 1
  done
  lancer_purger --option-inconnue
  (( code == 2 )) && anciens_presents && recents_intacts
}

purge_noms_hostiles() {
  preparer_pj
  lancer_purger --oui
  (( code == 0 )) && anciens_absents && recents_intacts
}

purge_seuil_30() {
  preparer_pj
  lancer_purger --jours 30 --oui
  (( code == 0 )) && [[ ! -e $travail/pj/2026/moyenne.jpg && -e $travail/pj/2026/recente.jpg ]] \
    && [[ -e $travail/hors-pj/temoin.jpg ]]
}

# --- Programme principal ----------------------------------------------------

main() {
  local outil
  depot=$(readlink -f -- "${1:-.}")
  [[ -d $depot ]] || mourir "dépôt introuvable : ${1:-.}"
  for outil in shellcheck bats make flock gzip zcat sha256sum pgrep; do
    command -v "$outil" > /dev/null || mourir "commande requise introuvable : $outil"
  done
  travail=$(mktemp -d -t verifier-lab.XXXXXX)
  mkdir -p -- "$travail/maison"
  ecrire_doublure
  printf 'Dépôt : %s\n\n' "$depot"

  controle "structure : bin/publier-export et bin/purger-pieces-jointes exécutables, lib/commun.sh, Makefile" structure_presente
  if (( echecs > 0 )); then
    mourir "structure incomplète, inutile d'aller plus loin"
  fi
  controle ".shellcheckrc : external-sources, source-path=SCRIPTDIR, aucune désactivation globale" shellcheckrc_correct
  controle "ShellCheck : aucun constat (bin, lib, tests)" shellcheck_propre
  controle "au moins 10 tests pour publier-export" nombre_de_tests "$depot/tests/publier-export.bats" 10
  controle "au moins 4 tests pour purger-pieces-jointes" nombre_de_tests "$depot/tests/purger-pieces-jointes.bats" 4
  controle "bats : tous les tests passent, sans appeler le vrai aws" bats_passe
  controle "make verifier existe" make_verifier_existe

  controle "publier-export : --help et --version" aide_et_version
  controle "publier-export : dépôt nominal, empreinte vérifiable, copie locale, sortie standard vide (code 0)" publication_nominale
  controle "publier-export : chaque appel à aws vise https://s3.fr-par.scw.cloud" point_acces_fr_par
  controle "publier-export : export aux fins de ligne CRLF accepté (code 0)" crlf_accepte
  controle "publier-export : export absent (code 3, rien déposé)" refus_export absent
  controle "publier-export : export vide (code 3, rien déposé)" refus_export ''
  controle "publier-export : en-tête seul (code 3, rien déposé)" refus_export "$EN_TETE\n"
  controle "publier-export : en-tête inattendu (code 3, rien déposé)" refus_export "id;type;commune;date\n1;x;y;$JOUR\n"
  controle "publier-export : dates et options invalides (code 2)" usage_refuse
  controle "publier-export : dépôt en échec (code 4, nettoyage fait)" depot_en_echec
  controle "publier-export : verrou détenu par une autre exécution (code 5)" verrou_respecte
  controle "publier-export : --dry-run n'appelle pas aws et ne laisse rien" simulation_sans_aws
  controle "publier-export : --destination et PUBLIER_EXPORT_DESTINATION respectés" destination_option_et_environnement
  controle "publier-export : configuration lue sans être exécutée" configuration_lue_sans_execution
  controle "publier-export : SIGTERM pendant le dépôt, nettoyage et verrou rendu" sigterm_nettoie

  controle "purger-pieces-jointes : --dry-run ne supprime rien (code 0)" purge_simulation
  controle "purger-pieces-jointes : sans --oui ni terminal, refus (code 2)" purge_sans_oui_refusee
  controle "purger-pieces-jointes : seuils et options invalides (code 2)" purge_seuils_invalides
  controle "purger-pieces-jointes : noms hostiles supprimés, récents et cibles de liens épargnés" purge_noms_hostiles
  controle "purger-pieces-jointes : --jours 30 abaisse le seuil" purge_seuil_30

  printf '\n%d contrôle(s), %d échec(s)\n' "$total" "$echecs"
  if (( echecs > 0 )); then
    printf 'Détails : relancez les scénarios à la main, ou lisez la sortie de shellcheck et bats.\n'
    return 1
  fi
}

main "$@"

La sortie attendue, une fois tout en place :

Dépôt : /home/vous/signalements-outils

OK     structure : bin/publier-export et bin/purger-pieces-jointes exécutables, lib/commun.sh, Makefile
OK     .shellcheckrc : external-sources, source-path=SCRIPTDIR, aucune désactivation globale
...
OK     purger-pieces-jointes : --jours 30 abaisse le seuil

27 contrôle(s), 0 échec(s)

Quelques choix du script méritent une explication, parce qu'ils appliquent le cours :

  • Un environnement vide. env -i lance publier-export avec les seules variables que le contrôle fixe : un PUBLIER_EXPORT_DESTINATION ou un VERBEUX oublié dans votre shell ne fausse pas le résultat. C'est aussi pour cela que les variables d'essai (LAB_AWS_CODE, DESTINATION_ESSAI...) sont ajoutées au tableau commande_publier une à une, seulement si elles sont définies (leçon 7).
  • Les effets plutôt que les messages. Hormis l'aide et la version, le script ne compare aucun texte : il vérifie des codes de sortie, la présence ou l'absence de fichiers, le journal de la doublure et l'empreinte déposée. Vos messages restent libres.
  • set -e suspendu dans les contrôles. Chaque contrôle est appelé dans la condition d'un if : set -e y est inopérant, jusque dans les fonctions appelées (leçon 9). Les fonctions de contrôle enchaînent donc leurs tests par &&, ou par des if explicites, sans compter sur l'arrêt automatique.
  • Un piège pour le vrai aws. Pendant bats, un faux aws placé dans le PATH laisse une trace s'il est appelé : si vos tests oublient leur doublure, le contrôle échoue au lieu de laisser partir un appel réel.
  • Le signal. Le contrôle lance publier-export en arrière-plan, attend que la doublure ait été appelée, envoie SIGTERM et exige que le script se termine en moins de trois secondes avec le code 143 (128 + 15). La doublure attend sous le nom lab-aws-attente (exec -a), ce qui permet de vérifier qu'aucune n'a survécu ; une nouvelle exécution doit ensuite réussir, preuve que le verrou a été rendu.
  • Le verrou est pris par le script de vérification lui-même, sur son propre descripteur, avant de lancer le vôtre : pas de course entre deux processus de test.
  • La purge est essayée sur des fichiers vieillis par touch -d, avec un témoin hors du répertoire visé par un lien symbolique, et un photo1.jpg récent voisin de photo*.jpg ancien : un rm sans guillemets les emporterait tous les deux.

Le workflow d'intégration continue et la qualité de vos tests ne se vérifient pas automatiquement : faites relire votre dépôt par un ou une collègue, avec les critères de revue de la leçon 12.

Démonter

Supprimez le bac à sable (rm -rf ~/bac-signalements) ; le script de vérification nettoie ses propres fichiers temporaires. Gardez le dépôt signalements-outils : c'est une bonne base pour vos propres scripts, et le quiz du cours vous attend.

Plan du cours

    +200 XP 200 Compagnon

    Validation sur l'honneur, enregistrée dans ce navigateur. Elle valide le niveau 200 de Scripts Bash et fait monter en rareté l'équipement de votre cosmonaute.