Les scripts shell robustes paraissent ennuyeux. C'est justement le but. Un script que l'on peut lire à 3 h du matin pendant un incident, qui échoue bruyamment au lieu de corrompre l'état, bat à chaque fois une commande astucieuse tenant sur une ligne.
Les trois premières lignes qui comptent
La plupart des scripts shell cassés ont un point commun : ils continuent après que quelque chose a déjà échoué. Le comportement par défaut est de hausser les épaules devant une erreur et de poursuivre : c'est ainsi qu'un script de sauvegarde « réussit » en écrivant zéro octet.
Commencez chaque script de la même façon :
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'Voici ce que cela apporte :
set -einterrompt à la première commande qui renvoie une valeur non nulle, de sorte qu'un échec s'arrête au lieu de se propager en dégâts.set -utraite les variables non définies comme des erreurs. Faites une faute de frappe dans un nom de variable et vous le voyez tout de suite, au lieu querm -rf "$PREFX/data"se développe enrm -rf "/data".set -o pipefailfait échouer un pipeline si n'importe quel étage échoue, pas seulement le dernier. Sans lui,curl ... | tar xsignale un succès même quandcurlest mort.- Régler
IFSsur le saut de ligne et la tabulation supprime toute une classe de bogues de découpage de mots autour des noms de fichiers contenant des espaces.
Ce n'est pas de la paranoïa. C'est la différence entre un script qui s'arrête et un script qui fait des dégâts à grande échelle.
Mettez tout entre guillemets
Le découpage en mots et l'expansion des motifs sont les deux pièges qui ruinent les scripts shell. Une variable sans guillemets est découpée sur les espaces et développée comme un motif, ce qui signifie qu'un fichier nommé my report.txt arrive sous la forme de deux arguments, et qu'une variable contenant * correspond à tout votre répertoire.
La règle est simple : si c'est une variable ou une substitution de commande, elle va entre guillemets doubles.
# faux : casse sur les espaces, développe les motifs glob
cp $src $dst
# correct
cp "$src" "$dst"Utilisez "$@" (entre guillemets) pour transmettre les arguments, jamais $* ni un $@ nu. Utilisez un tableau pour une liste d'éléments plutôt qu'une chaîne délimitée par des espaces :
files=("$dir"/*.log)
gzip "${files[@]}"Si vous vous surprenez à construire une commande dans une variable chaîne et à l'exécuter aveceval, arrêtez-vous. Vous voulez presque certainement un tableau.evalest l'endroit où les guillemets vont mourir.
Des fonctions, pas des scripts à plat
Un script de 300 lignes qui s'exécute de haut en bas est un script que personne ne peut tester. Découpez le travail en fonctions, nommez-les d'après ce qu'elles font et gardez un main en bas. Déclarez les variables locales avec local pour qu'une variable à l'intérieur d'une fonction ne puisse pas en écraser silencieusement une autre.
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
upload_artifact() {
local file="$1" bucket="$2"
[[ -f "$file" ]] || die "missing artifact: $file"
aws s3 cp "$file" "s3://$bucket/" || die "upload failed: $file"
}
main() {
upload_artifact "$1" "${BUCKET:?BUCKET must be set}"
}
main "$@"Trois choses ici valent d'être reprises. log écrit sur stderr, il ne pollue donc jamais les données que le script émet sur stdout. ${BUCKET:?BUCKET must be set} échoue bruyamment avec votre propre message quand la variable manque, au lieu de se développer silencieusement en rien. Et appeler main "$@" à la dernière ligne signifie que tout le fichier est analysé avant que quoi que ce soit s'exécute, de sorte qu'un script enregistré en pleine modification ne s'exécute pas à moitié.
Nettoyer avec trap
La partie difficile d'un script n'est pas le chemin nominal, c'est ce qui se passe quand il meurt au milieu. Fichiers temporaires, répertoires de verrou et sorties à moitié écrites fuient tous si la sortie n'est pas gérée explicitement.
trap exécute une fonction quand le script se termine pour une raison quelconque : succès, erreur ou Ctrl-C. Combinez-le avec mktemp et le nettoyage se déclenche réellement.
workdir="$(mktemp -d)"
cleanup() { rm -rf "$workdir"; }
trap cleanup EXIT
# faire le travail dans « $workdir ». Il disparaît quelle que soit la façon dont on sortUn seul trap sur EXIT couvre une sortie normale et la plupart des signaux. Si vous devez distinguer un plantage d'une exécution propre, interceptez ERR séparément et journalisez là. L'essentiel est que le nettoyage vive à un seul endroit, déclaré dès le départ, au lieu d'être copié-collé devant chaque exit.
Les pièges qui survivent à set -e
set -e n'est pas un champ de force. Il a des trous bien connus, et faire comme s'ils n'existaient pas est le meilleur moyen de se faire piéger par ce que l'on croyait couvert.
- Une commande qui échoue dans la condition d'un
ifne déclenche pas de sortie. C'est voulu, mais cela signifie queif grep -q foo file; thenne s'interrompra pas même sigrepéchoue pour une autre raison que « pas de correspondance ». - Une commande à l'intérieur d'une substitution
$(...)peut avaler son échec selon le contexte. Affectez d'abord, vérifiez ensuite. local x="$(cmd)"masque le code de sortie decmd, carlocallui-même réussit. Séparez la déclaration :local x; x="$(cmd)".- Une arithmétique comme
((count++))renvoie une valeur non nulle quand le résultat est0, ce qui, sousset -e, tue le script. Utilisez plutôtcount=$((count + 1)).
Rien de cela n'est un argument pour abandonner set -e. C'est un argument pour en connaître les angles morts. Un outil dont vous comprenez les limites vaut mieux qu'un outil auquel vous vous fiez aveuglément.
Savoir s'arrêter
Les scripts qui survivent sont ceux que l'on traite comme du vrai code : relus, analysés avec shellcheck et assez petits pour tenir dans la tête. Le niveau d'exigence d'un « simple script shell » est celui de tout ce qui tourne en production, car la production ne fait pas la différence.
Quand un script dépasse quelques centaines de lignes, ou commence à jongler avec de vraies structures de données, c'est le signal de passer à un langage avec des types et un lanceur de tests. Le shell sert de colle. Respectez ce pour quoi il est réellement bon, refusez de le pousser au-delà, et lancez shellcheck en CI pour que les pièges ci-dessus n'atteignent jamais la branche principale.