Débutant 6 min de lecture · 1 211 mots

Mon premier script bash propre : le squelette que je recopie à chaque fois

Un script bash qui marche, c'est facile. Un script qui échoue proprement, nettoie derrière lui et se relit dans six mois, ça demande cinq habitudes. Voici le squelette complet.

Estimated reading time: 6 minutes

Un script bash, ça commence toujours pareil : on tape trois commandes dans un fichier, on le rend exécutable, et ça marche. Puis un jour, une des commandes échoue au milieu, le script continue quand même comme si de rien n’était, et il efface le mauvais dossier.

Ce n’est pas de la malchance, c’est le comportement par défaut de bash : il continue. Cet article présente le petit squelette que je recopie au début de chaque nouveau script, et surtout pourquoi chaque ligne est là.

Le squelette complet

Voici le fichier entier. On le décortique juste après.

#!/usr/bin/env bash
set -euo pipefail

NOM_SCRIPT="$(basename "$0")"
readonly NOM_SCRIPT
TMP_DIR="$(mktemp -d)"

log() {
  printf '%s [%s] %sn' "$(date '+%F %T')" "$NOM_SCRIPT" "$*"
}

die() {
  log "ERREUR : $*" >&2
  exit 1
}

nettoyer() {
  rm -rf "$TMP_DIR"
}
trap nettoyer EXIT

main() {
  log "Démarrage"
  command -v curl >/dev/null || die "curl n'est pas installé"

  log "Travail dans $TMP_DIR"
  # ... ici, le vrai travail du script ...

  log "Terminé"
}

main "$@"

Copie-le dans un fichier exemple.sh, fais chmod +x exemple.sh, lance-le : il affiche deux lignes horodatées et nettoie son dossier temporaire.

Ligne 1 : le shebang

#!/usr/bin/env bash

Cette ligne dit au système quel interpréteur utiliser. Avec env bash, on laisse le système retrouver bash où qu’il soit installé, ce qui évite de se tromper de chemin (/bin/bash, /usr/bin/bash…). Sans shebang, le script est lu par le shell de l’utilisateur qui le lance, et le comportement peut changer d’une machine à l’autre.

Ligne 3 : le mode « strict »

set -euo pipefail

C’est la ligne la plus importante. Trois options en une :

  • -e : le script s’arrête dès qu’une commande échoue. Sans ça, bash continue joyeusement.
  • -u : utiliser une variable non définie est une erreur. Sans ça, rm -rf "$DOSSIER/" avec $DOSSIER vide devient rm -rf /… (bash a quelques garde-fous, mais ne comptons pas dessus).
  • -o pipefail : dans commande1 | commande2, l’échec de commande1 compte. Sans ça, seul le code de retour de la dernière commande est pris en compte.

Ce mode n’est pas magique. Quelques pièges à connaître :

# -e ne s'applique pas dans une condition : ceci ne stoppe pas le script
if grep -q "motif" fichier.txt; then
  echo "trouvé"
fi

# Pour tolérer volontairement un échec, on le dit explicitement
grep "motif" fichier.txt || true

# Avec -u, une variable optionnelle se déclare avec une valeur par défaut
nom="${1:-inconnu}"

Les fonctions log et die

log() { printf '%s [%s] %sn' "$(date '+%F %T')" "$NOM_SCRIPT" "$*"; }
die() { log "ERREUR : $*" >&2; exit 1; }

Deux petites fonctions qui rendent les scripts lisibles :

  • log affiche un message horodaté. Quand un script tourne la nuit via cron, ces dates sont précieuses pour comprendre ce qui s’est passé et quand.
  • die affiche l’erreur sur la sortie d’erreur (>&2) et s’arrête avec un code de retour non nul. Le code de retour est ce qui permet à cron, à systemd ou à un autre script de savoir que ça s’est mal passé.

J’utilise printf plutôt que echo : son comportement est le même partout, alors que celui d’echo varie selon les shells quand le texte contient des antislashs ou commence par un tiret.

trap : nettoyer quoi qu’il arrive

TMP_DIR="$(mktemp -d)"
nettoyer() { rm -rf "$TMP_DIR"; }
trap nettoyer EXIT

mktemp -d crée un dossier temporaire au nom unique (pas de collision si deux instances du script tournent en même temps). trap nettoyer EXIT demande à bash d’exécuter la fonction nettoyer à la sortie du script, qu’elle soit normale, causée par une erreur (set -e), ou par un Ctrl+C.

Résultat : plus de fichiers temporaires qui traînent dans /tmp après un plantage.

Tout dans main

Mettre le travail dans une fonction main appelée tout en bas (main "$@") a un avantage discret : bash lit le script entier avant de l’exécuter. Si tu modifies le fichier pendant qu’une instance tourne (ça arrive avec cron), le comportement reste cohérent. Et le fichier se lit de haut en bas comme un petit programme : les outils d’abord, la logique à la fin.

Les vérifications au démarrage

command -v curl >/dev/null || die "curl n'est pas installé"

command -v teste si une commande existe. Cinq lignes de vérification au début d’un script valent mieux qu’un plantage obscur à la ligne 80, après que le script a déjà fait la moitié du travail.

Même idée pour les fichiers et les dossiers dont dépend le script :

[[ -d "$SOURCE" ]] || die "dossier introuvable : $SOURCE"
[[ -r "$CONFIG" ]] || die "fichier de configuration illisible : $CONFIG"

Les guillemets : la règle qui évite 80 % des bugs

Mets toujours tes variables entre guillemets doubles : "$variable", pas $variable. Sans guillemets, bash découpe le contenu aux espaces. Un fichier nommé mes photos.jpg devient deux arguments (mes et photos.jpg), et la commande ne fait plus du tout ce que tu veux. J’y consacre un article complet un peu plus loin dans cette série.

Tester avant de lancer pour de vrai

Deux réflexes avant d’exécuter un nouveau script :

bash -n exemple.sh        # vérifie seulement la syntaxe, n'exécute rien
shellcheck exemple.sh     # relit le script et signale les pièges classiques

Et pour comprendre ce qu’un script fait pas à pas :

bash -x exemple.sh        # affiche chaque commande avant de l'exécuter

Mon petit rituel pour un nouveau script

  1. Je copie le squelette.
  2. Je remplis le commentaire de la ligne 2 : si je ne sais pas écrire en une phrase ce que fait le script, c’est qu’il fait trop de choses.
  3. J’écris le travail dans main, en le testant d’abord dans un dossier de test.
  4. Je passe shellcheck.
  5. Je le range dans un dossier scripts/ sous git. Un script qui n’existe que sur une machine est un script qu’on perdra.

À retenir

  • #!/usr/bin/env bash + set -euo pipefail : le strict minimum.
  • log et die : des messages datés, et un vrai code de retour.
  • trap … EXIT : on nettoie toujours derrière soi.
  • Un main en bas du fichier, des vérifications en haut.
  • Des guillemets partout, et shellcheck avant de lancer.

Dans le prochain article, on rend le script configurable avec des arguments et des options, comme une vraie commande.

Une remarque, un retour ?

Cet article est vivant - corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisis celui qui te convient.

Laisser un commentaire