Le shell est l’un des outils les plus puissants d’un système Unix ou Linux.
Une commande permet de :
copier un fichier
Une boucle permet d’en copier :
10 000
Et une variable mal citée permet parfois d’en supprimer :
beaucoup plus que prévu
Bienvenue dans le monde des scripts Bash.
Bash permet de transformer une succession de commandes en véritable programme d’automatisation capable de :
- traiter des fichiers ;
- effectuer des sauvegardes ;
- administrer des serveurs ;
- contrôler des services ;
- interroger le réseau ;
- analyser des journaux ;
- enchaîner des commandes ;
- prendre des décisions ;
- répéter des opérations ;
- et éventuellement répéter très rapidement une erreur qui aurait été beaucoup moins grave à la main.
Un script shell ne rend pas une opération plus sûre. Il la rend surtout reproductible. Si l’opération est mauvaise, elle devient reproductiblement mauvaise.
Qu’est-ce que Bash ?
Bash signifie :
Bourne-Again SHell
C’est un interpréteur de commandes compatible avec de nombreux concepts du shell Bourne traditionnel et enrichi de nombreuses fonctionnalités.
Bash peut être utilisé :
- interactivement dans un terminal ;
- pour exécuter une commande ;
- pour lire un fichier contenant des commandes ;
- comme langage de script.
Par exemple :
echo "Bonjour"
peut être tapé directement dans un terminal.
Mais ces mêmes instructions peuvent être enregistrées dans :
bonjour.sh
puis exécutées automatiquement.
Shell, Bash et sh : attention aux termes
Un :
shell
est une catégorie d’interpréteurs de commandes.
Parmi eux :
- Bash ;
- Dash ;
- Zsh ;
- Ksh ;
- Fish.
Un script écrit pour :
#!/bin/sh
ne doit pas automatiquement utiliser toutes les fonctionnalités propres à Bash.
Inversement, un script qui utilise :
[[ ... ]];- les tableaux Bash ;
mapfile;- certaines expansions avancées ;
sourcesous sa forme Bash ;
doit clairement annoncer qu’il nécessite Bash.
Qu’est-ce qu’un script Bash ?
Un script Bash est simplement un fichier texte contenant des commandes comprises par Bash.
Par exemple :
printf '%s\n' "Bonjour, humain."
date
whoami
Bash lit les instructions, effectue les expansions nécessaires, applique les redirections puis exécute les commandes.
Un script peut contenir :
- variables ;
- conditions ;
- boucles ;
- fonctions ;
- tableaux ;
- pipelines ;
- redirections ;
- gestion des erreurs ;
- appels à des programmes externes.
C’est donc un véritable langage de programmation, même s’il est principalement spécialisé dans l’orchestration de commandes et l’administration système.
Le shebang
Une première ligne comme :
#!/bin/bash
s’appelle le :
shebang
Elle indique quel interpréteur utiliser lorsque le fichier est exécuté directement.
Par exemple :
#!/bin/bash
printf '%s\n' "Bonjour"
date
Le shebang doit être la première ligne
Évitez :
# Mon super script
#!/bin/bash
Le :
#!
doit commencer le fichier pour remplir son rôle d’interpréteur lors de l’exécution directe.
/bin/bash ou /usr/bin/env bash ?
Deux formes sont courantes.
Chemin explicite
#!/bin/bash
Avantage :
vous désignez précisément :
/bin/bash
C’est parfaitement adapté à un système dont vous connaissez l’organisation, notamment de nombreux systèmes Linux.
Recherche via env
#!/usr/bin/env bash
Ici :
env
cherche :
bash
dans le :
PATH
de l’environnement.
Cela peut être pratique pour des scripts utilisés sur plusieurs plateformes ou dans des environnements où Bash n’est pas forcément installé dans :
/bin/bash
En contrepartie, l’interpréteur sélectionné dépend de :
PATH
ce qui peut être indésirable pour certains scripts système ou environnements particulièrement contrôlés.
Le shebang n’est pas obligatoire dans tous les modes d’exécution
Si vous faites :
bash monscript.sh
vous avez déjà explicitement demandé à Bash de lire :
monscript.sh
Le shebang n’est donc pas utilisé pour choisir l’interpréteur.
En revanche :
./monscript.sh
demande au système d’exécuter directement le fichier.
Dans ce scénario, le shebang devient important.
Créer un premier script
Créons :
nano monscript.sh
avec :
#!/bin/bash
printf '%s\n' "Bonjour, humain."
printf 'Utilisateur : %s\n' "$USER"
date
Vérifier le contenu
cat monscript.sh
Exécuter avec Bash
Même sans permission d’exécution :
bash monscript.sh
fonctionne si le fichier est lisible.
Rendre le fichier directement exécutable
chmod +x monscript.sh
Puis :
./monscript.sh
Pourquoi ./ ?
Lorsque vous tapez :
monscript.sh
le shell cherche une commande appelée :
monscript.sh
dans les répertoires contenus dans :
$PATH
Le répertoire courant :
.
n’est généralement pas inclus dans ce PATH pour des raisons notamment de sécurité et de comportement prévisible.
En écrivant :
./monscript.sh
vous dites explicitement :
« Exécute le fichier monscript.sh qui se trouve dans le répertoire courant. »
Afficher PATH
printf '%s\n' "$PATH"
Vous verrez quelque chose ressemblant à :
/usr/local/bin:/usr/bin:/bin
Installer un script comme commande
Pour un script personnel, on peut utiliser :
~/.local/bin
si ce répertoire se trouve dans le PATH.
Par exemple :
mkdir -p "$HOME/.local/bin"
install -m 0755 monscript.sh "$HOME/.local/bin/monscript"
Ensuite :
monscript
peut être appelé comme une commande classique si :
$HOME/.local/bin
est présent dans le PATH de la session.
Pour un outil administrateur
On utilise fréquemment :
/usr/local/sbin
ou :
/usr/local/bin
pour des scripts installés localement par l’administrateur.
Évitez de déposer manuellement vos scripts dans :
/usr/bin
au milieu des fichiers gérés par le gestionnaire de paquets.
Attention aux fins de ligne Windows
Un script créé ou modifié sous Windows peut utiliser :
CRLF
au lieu de :
LF
Vous pouvez alors rencontrer :
/bin/bash^M: bad interpreter
Le caractère :
^M
correspond au retour chariot supplémentaire.
On peut diagnostiquer avec :
file monscript.sh
et convertir, par exemple avec :
dos2unix monscript.sh
si l’outil est installé.
Les commentaires
Tout ce qui suit :
#
dans le contexte approprié peut servir de commentaire.
#!/bin/bash
# Affiche l'utilisateur actuel
whoami
Les commentaires doivent expliquer :
- pourquoi quelque chose est fait ;
- une hypothèse importante ;
- un comportement non évident.
Un commentaire comme :
# On affiche la date
date
n’apporte pas énormément au patrimoine documentaire mondial.
Les variables
Une variable se définit sans espaces autour de :
=
Correct :
nom="Alice"
Incorrect :
nom = "Alice"
Dans ce second exemple, Bash essaie notamment d’interpréter :
nom
comme une commande.
Lire une variable
nom="Alice"
printf 'Bonjour %s\n' "$nom"
Les accolades peuvent améliorer la lisibilité :
fichier="${nom}_rapport.txt"
Sans accolades :
$nom_rapport
serait interprété comme une autre variable appelée :
nom_rapport
Les guillemets : probablement la règle la plus importante
Comparez :
rm $fichier
avec :
rm -- "$fichier"
La seconde forme est généralement préférable.
Pourquoi ?
Parce qu’une expansion non citée peut subir notamment :
- word splitting ;
- pathname expansion ou globbing.
Exemple avec un espace
fichier="rapport annuel.txt"
Avec :
cat $fichier
Bash peut transmettre deux arguments :
rapport
annuel.txt
Avec :
cat -- "$fichier"
il transmet un seul argument :
rapport annuel.txt
Règle pratique
Lorsqu’une expansion représente une chaîne ou un nom de fichier, mettez-la entre guillemets sauf si vous voulez explicitement le word splitting ou le globbing.
Par exemple :
cp -- "$source" "$destination"
printf '%s\n' "$message"
cd -- "$repertoire"
rm -- "$fichier"
Pourquoi — ?
Imaginez un fichier appelé :
-rf
Avec :
rm "$fichier"
certaines commandes pourraient interpréter un nom commençant par :
-
comme une option.
De nombreuses commandes acceptent :
--
pour indiquer :
« Fin des options ; ce qui suit est un argument normal. »
Ainsi :
rm -- "$fichier"
est un excellent réflexe lorsque la commande le supporte.
Guillemets simples et doubles
Guillemets doubles
nom="Alice"
printf '%s\n' "Bonjour $nom"
Résultat :
Bonjour Alice
Guillemets simples
printf '%s\n' 'Bonjour $nom'
Résultat :
Bonjour $nom
Les guillemets simples empêchent la plupart des expansions.
printf plutôt que echo pour les sorties structurées
echo convient à de nombreux cas simples :
echo "Bonjour"
Mais son comportement historique varie davantage pour certaines options et séquences d’échappement.
Pour un script robuste, :
printf
est souvent plus prévisible.
Exemple :
printf 'Utilisateur : %s\n' "$USER"
Entrée utilisateur avec read
Exemple :
read -r -p "Quel est votre nom ? " nom
printf 'Bonjour, %s !\n' "$nom"
L’option :
-r
empêche :
read
d’interpréter les antislashs comme caractères d’échappement.
C’est généralement la forme souhaitée lorsqu’on lit une chaîne arbitraire.
Lire sans afficher la saisie
Pour un secret interactif :
read -r -s -p "Mot de passe : " motdepasse
printf '\n'
L’option :
-s
désactive l’écho de la saisie.
Mais attention :
le fait de ne pas afficher un mot de passe ne signifie pas qu’il est ensuite sûr de le conserver dans une variable, un journal ou une ligne de commande.
Les paramètres d’un script
Exécutez :
./monscript.sh alice fichier.txt
Le script reçoit notamment :
$0 → nom du script
$1 → alice
$2 → fichier.txt
Nombre de paramètres
$#
contient le nombre de paramètres positionnels.
Par exemple :
printf 'Nombre d’arguments : %s\n' "$#"
Tous les arguments : « $@ »
Pour transmettre correctement tous les arguments :
ma_fonction "$@"
"$@" conserve chaque argument comme élément séparé.
Exemple :
./script.sh "rapport annuel.txt" "Jean Dupont"
Avec :
"$@"
les deux arguments restent :
rapport annuel.txt
Jean Dupont
sans être découpés sur leurs espaces.
« $@ » et « $* » ne sont pas équivalents
"$@" développe chaque paramètre comme un argument distinct.
"$*" les rassemble en une seule chaîne séparée selon le premier caractère de :
IFS
Dans presque tous les scénarios où vous voulez retransmettre les arguments :
"$@"
est donc la bonne forme.
Vérifier qu’un argument existe
if [[ $# -lt 1 ]]; then
printf 'Usage : %s fichier\n' "$0" >&2
exit 2
fi
Valeur par défaut
Cette expansion :
${variable:-valeur}
permet de fournir une valeur de remplacement si la variable est vide ou absente.
Par exemple :
repertoire="${1:-$HOME}"
Exiger une variable
On peut également écrire :
: "${BACKUP_DIR:?BACKUP_DIR doit être définie}"
Si :
BACKUP_DIR
est absente ou vide, Bash produit une erreur.
Les codes de retour
Sous Unix, une commande retourne un :
exit status
Un statut :
0
signifie normalement :
succès
Un statut non nul signifie :
échec ou autre résultat particulier
selon la commande.
$? : statut de la dernière commande
cp source.txt destination.txt
printf 'Code : %s\n' "$?"
Mais attention :
$? change après chaque commande.
Dans :
cp source.txt destination.txt
printf '%s\n' "Copie terminée"
printf '%s\n' "$?"
vous examinez maintenant le statut de :
printf
et non celui de :
cp
Préférez tester directement la commande
Au lieu de :
ping -c 3 "$cible" >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
printf 'OK\n'
else
printf 'Échec\n'
fi
écrivez :
if ping -c 3 "$cible" >/dev/null 2>&1; then
printf 'La cible %s est joignable.\n' "$cible"
else
printf 'Échec vers %s.\n' "$cible"
fi
C’est :
- plus lisible ;
- plus direct ;
- moins fragile.
exit
Un script peut terminer explicitement :
exit 0
pour indiquer un succès.
Ou :
exit 1
pour signaler un échec générique.
Pour un script sérieux, documenter plusieurs codes peut être utile.
Exemple :
0 → succès
1 → erreur générale
2 → usage incorrect
3 → fichier source absent
Conditions avec if
Syntaxe :
if commande; then
commandes
elif autre_commande; then
commandes
else
commandes
fi
Le mot important est :
commande
Bash prend ses décisions à partir des codes de retour.
Test Bash avec [[ ]]
Lorsqu’un script nécessite explicitement Bash, :
[[ ... ]]
offre de nombreux avantages pour les expressions conditionnelles.
Exemple :
if [[ "$nom" == "Alice" ]]; then
printf 'Bonjour Alice.\n'
fi
Tester une chaîne vide
if [[ -z "$nom" ]]; then
printf 'Le nom est vide.\n'
fi
Tester une chaîne non vide
if [[ -n "$nom" ]]; then
printf 'Nom : %s\n' "$nom"
fi
Tests sur les fichiers
Quelques opérateurs utiles :
| Test | Signification |
|---|---|
-e |
L’objet existe |
-f |
Fichier régulier |
-d |
Répertoire |
-L |
Lien symbolique |
-r |
Lisible |
-w |
Inscriptible |
-x |
Exécutable |
-s |
Existe et taille supérieure à zéro |
Exemple
fichier="/etc/hosts"
if [[ -f "$fichier" ]]; then
printf '%s est un fichier régulier.\n' "$fichier"
else
printf '%s est absent ou n’est pas un fichier régulier.\n' "$fichier"
fi
Tests numériques
Avec :
[[ ... ]]
on rencontre :
| Opérateur | Signification |
|---|---|
-eq |
Égal |
-ne |
Différent |
-lt |
Inférieur |
-le |
Inférieur ou égal |
-gt |
Supérieur |
-ge |
Supérieur ou égal |
Exemple :
age=21
if [[ "$age" -ge 18 ]]; then
printf 'Accès autorisé.\n'
fi
Contexte arithmétique (( ))
Bash fournit également :
(( ... ))
pour les expressions arithmétiques.
Exemple :
compteur=5
((compteur++))
printf 'Compteur : %d\n' "$compteur"
Dans une condition :
if (( compteur >= 5 )); then
printf 'Le compteur vaut au moins 5.\n'
fi
Calculs avec $(( ))
a=10
b=4
resultat=$((a + b))
printf '%d\n' "$resultat"
Bash réalise principalement de l’arithmétique entière.
Pour des calculs décimaux avancés, utilisez un outil adapté comme :
awk;bc;- Python ;
- un autre langage approprié.
AND et OR
Dans une condition Bash :
if [[ -f "$fichier" && -r "$fichier" ]]; then
printf 'Fichier présent et lisible.\n'
fi
Pour une alternative :
if [[ "$reponse" == "o" || "$reponse" == "oui" ]]; then
printf 'Confirmation reçue.\n'
fi
&& et || entre commandes
On peut écrire :
mkdir -p -- "$destination" && printf 'Répertoire prêt.\n'
La seconde commande n’est exécutée que si la première réussit.
Avec :
commande || printf 'Échec.\n' >&2
la seconde s’exécute si la première échoue.
Attention aux chaînes complexes de && et ||
Ce genre de construction :
commande1 && commande2 || commande3
n’est pas toujours équivalent à un :
if / else
car :
commande3
peut aussi être exécutée si :
commande2
échoue.
Pour une logique importante :
écrivez un vrai :
if
Cinq lignes lisibles coûtent généralement moins cher qu’un incident mystérieux.
La boucle for
Exemple original :
for i in {1..5}; do
printf 'Ligne %d\n' "$i"
done
Résultat :
Ligne 1
Ligne 2
Ligne 3
Ligne 4
Ligne 5
Parcourir une liste
for service in ssh cron systemd-journald; do
printf 'Service : %s\n' "$service"
done
Parcourir les arguments
for argument in "$@"; do
printf 'Argument : %s\n' "$argument"
done
Parcourir des fichiers avec un glob
for fichier in "$HOME/Documents"/*.txt; do
printf '%s\n' "$fichier"
done
Mais attention :
par défaut, si aucun fichier ne correspond, le motif peut rester littéral.
On peut activer temporairement :
shopt -s nullglob
pour que les motifs sans correspondance produisent zéro élément.
Ne faites pas for f in $(ls)
Évitez :
for fichier in $(ls *.txt); do
...
done
Cette construction casse facilement avec :
- espaces ;
- tabulations ;
- retours à la ligne ;
- autres caractères inhabituels dans les noms.
Le shell sait déjà développer :
*.txt
Vous n’avez pas besoin de demander à :
ls
de fabriquer une représentation textuelle des noms pour ensuite essayer de la retransformer en noms de fichiers.
Les noms de fichiers ne sont pas des lignes de texte
Sous Unix, un nom de fichier peut notamment contenir :
- des espaces ;
- des tabulations ;
- des retours à la ligne.
Les deux caractères qui ne peuvent pas apparaître dans un composant de nom Unix sont essentiellement :
NUL
et
/
C’est pourquoi le traitement robuste de collections de fichiers demande parfois un séparateur :
NUL
find avec -print0
Exemple robuste :
while IFS= read -r -d '' fichier; do
printf 'Fichier : %s\n' "$fichier"
done < <(find "$HOME/Documents" -type f -name '*.txt' -print0)
Cette construction est spécifique à Bash à cause notamment de :
<( ... )
qui utilise la substitution de processus.
La boucle while
compteur=1
while (( compteur <= 5 )); do
printf 'Tour %d\n' "$compteur"
((compteur++))
done
Lire un fichier ligne par ligne
La forme classique robuste est :
while IFS= read -r ligne; do
printf '%s\n' "$ligne"
done < fichier.txt
Le :
IFS=
empêche notamment la suppression involontaire de certains espaces en début ou fin de ligne.
Le :
-r
préserve les antislashs.
case : excellent pour les menus et options simples
read -r -p "Choisissez une option : " choix
case "$choix" in
1)
date
;;
2)
whoami
;;
q|Q)
printf 'Au revoir.\n'
;;
*)
printf 'Option inconnue.\n' >&2
;;
esac
Script de menu complet
#!/bin/bash
printf '%s\n' "1) Afficher l'heure"
printf '%s\n' "2) Afficher l'utilisateur"
printf '%s\n' "3) Afficher l'espace disque"
read -r -p "Choisissez une option : " choix
case "$choix" in
1)
date
;;
2)
whoami
;;
3)
df -h
;;
*)
printf 'Option inconnue : %s\n' "$choix" >&2
exit 1
;;
esac
Les tableaux Bash
Bash possède des tableaux indexés.
serveurs=(
"web01"
"web02"
"db01"
)
Afficher un élément :
printf '%s\n' "${serveurs[0]}"
Afficher tous les éléments :
printf '%s\n' "${serveurs[@]}"
Parcourir un tableau
for serveur in "${serveurs[@]}"; do
printf 'Serveur : %s\n' "$serveur"
done
Les guillemets sont ici particulièrement importants :
"${serveurs[@]}"
préserve chaque élément comme argument distinct.
Nombre d’éléments
printf '%d\n' "${#serveurs[@]}"
Ajouter un élément
serveurs+=("backup01")
Tableaux associatifs
Bash possède également des tableaux associatifs :
declare -A ports=(
[ssh]=22
[http]=80
[https]=443
)
printf '%s\n' "${ports[https]}"
Ils sont propres à des shells comme Bash et ne font pas partie du langage shell POSIX traditionnel.
Les fonctions
Une fonction permet de regrouper une opération :
saluer() {
printf 'Bonjour, %s !\n' "$1"
}
saluer "Toto"
Variables locales dans une fonction
Sans :
local
une variable créée dans une fonction peut modifier une variable du script.
Préférez :
saluer() {
local nom="$1"
printf 'Bonjour, %s !\n' "$nom"
}
Retour d’une fonction
Le :
return
d’une fonction renvoie un code de statut.
Par exemple :
est_fichier() {
local chemin="$1"
[[ -f "$chemin" ]]
}
Utilisation :
if est_fichier "/etc/hosts"; then
printf 'Fichier trouvé.\n'
fi
Ne retournez pas une chaîne avec return
Évitez d’imaginer :
return "bonjour"
comme dans certains langages.
return concerne le statut numérique.
Pour produire une chaîne, vous pouvez utiliser :
printf
et éventuellement récupérer sa sortie.
Substitution de commande
La syntaxe moderne :
$(commande)
permet de récupérer la sortie standard d’une commande.
Exemple :
maintenant="$(date '+%Y-%m-%d_%H-%M-%S')"
printf '%s\n' "$maintenant"
Préférez $() aux anciens backticks
Ancienne forme :
date=`date`
Forme moderne :
date_actuelle="$(date)"
$() est notamment :
- plus lisible ;
- plus simple à imbriquer ;
- moins pénible avec les échappements.
Attention au statut dans une substitution de commande
Ce code :
resultat="$(commande)"
doit être conçu en tenant compte du fait que :
- la commande peut échouer ;
- la sortie peut être vide ;
- des comportements particuliers apparaissent avec
set -e.
Pour une opération critique :
if ! resultat="$(commande)"; then
printf 'La commande a échoué.\n' >&2
exit 1
fi
Redirections
La sortie standard est :
stdout
fd 1
La sortie d’erreur :
stderr
fd 2
L’entrée standard :
stdin
fd 0
Rediriger stdout
date > date.txt
Écrase le fichier.
Ajouter à un fichier
date >> journal.txt
Rediriger stderr
commande 2> erreurs.log
Rediriger stdout et stderr
Forme Bash :
commande >& journal.log
Une forme extrêmement courante et plus explicitement portable entre shells est :
commande > journal.log 2>&1
Ignorer une sortie
commande >/dev/null 2>&1
Le :
/dev/null
est essentiellement le trou noir du système.
Les données y entrent.
Elles n’envoient pas de carte postale.
Les pipelines
Exemple :
journalctl | grep ssh
La sortie standard de :
journalctl
devient l’entrée standard de :
grep
Le piège du statut d’une pipeline
Par défaut, dans Bash, le statut d’une pipeline est normalement celui de sa dernière commande.
Imaginons :
commande_qui_echoue | commande_qui_reussit
La pipeline peut donc retourner :
0
si la dernière commande réussit, même si la première a échoué.
pipefail
Avec :
set -o pipefail
le statut de la pipeline reflète le dernier échec non nul de la chaîne si une commande échoue.
Exemple :
set -o pipefail
generer_donnees | compresser | envoyer
Si :
generer_donnees
échoue mais :
envoyer
termine proprement son entrée vide, l’échec n’est plus aussi facilement masqué.
PIPESTATUS
Bash conserve également les codes des commandes de la dernière pipeline dans :
PIPESTATUS
Par exemple :
commande1 | commande2 | commande3
printf '%s\n' "${PIPESTATUS[@]}"
Attention :
consultez :
PIPESTATUS
immédiatement, car l’exécution d’une autre commande le modifie.
set -e : utile, mais pas magique
On rencontre fréquemment :
set -e
ou :
set -o errexit
On l’explique souvent comme :
« Arrête le script lorsqu’une commande échoue. »
C’est une approximation.
Les exceptions de set -e
Bash n’arrête notamment pas automatiquement le script dans plusieurs contextes où un statut non nul fait partie de la logique, par exemple :
- le test d’un
if; - certaines conditions de boucle ;
- certaines listes
&&et||; - des pipelines selon leur position et
pipefail; - des commandes dont le statut est inversé avec
!.
Par exemple :
if grep -q "root" /etc/passwd; then
printf 'Trouvé.\n'
fi
Le :
grep
peut légitimement retourner non zéro parce que le motif n’existe pas.
Bash ne doit évidemment pas tuer le script avant que :
if
puisse examiner le résultat.
set -u
set -u
ou :
set -o nounset
traite l’utilisation de nombreuses variables non définies comme une erreur.
Exemple :
printf '%s\n' "$VARIABLE_QUI_N_EXISTE_PAS"
peut alors arrêter le script.
Les valeurs par défaut restent utiles avec set -u
Cette construction :
${variable:-}
permet notamment de traiter volontairement une variable éventuellement absente.
Exemple :
if [[ -n "${DEBUG:-}" ]]; then
printf 'Debug actif.\n'
fi
set -x
set -x
active le :
xtrace
Bash affiche les commandes après expansion au fur et à mesure de leur exécution.
C’est extrêmement utile pour déboguer.
Attention : set -x peut révéler des secrets
Par exemple :
motdepasse="TresSecret"
curl -u "alice:$motdepasse" ...
avec :
set -x
peut faire apparaître le secret dans :
- le terminal ;
- les logs CI/CD ;
- les journaux d’administration.
Vous pouvez temporairement désactiver le tracing :
set +x
mais le mieux reste d’éviter de manipuler inutilement les secrets dans des commandes exposables.
set -euo pipefail
Un en-tête très courant est :
#!/bin/bash
set -euo pipefail
Ou :
#!/bin/bash
set -Eeuo pipefail
avec :
-E
pour étendre notamment l’héritage du trap :
ERR
dans davantage de contextes Bash.
Ce n’est pas le « mode strict officiel » de Bash
Bash ne possède pas un bouton nommé :
strict mode
garantissant qu’un script devient automatiquement correct.
set -Eeuo pipefail modifie plusieurs comportements.
Il faut comprendre ces comportements et écrire le script en conséquence.
Ajoutez ces options parce que votre script est conçu pour elles, pas parce qu’un billet de blog les a présentées comme un talisman.
trap : exécuter quelque chose lors d’un événement
trap permet de définir une action lors :
- de la sortie du script ;
- de certains signaux ;
- d’une erreur dans certains contextes.
Exemple :
cleanup() {
printf 'Nettoyage...\n'
}
trap cleanup EXIT
La fonction :
cleanup
sera exécutée lorsque le shell quittera le script.
Fichiers temporaires : utilisez mktemp
Évitez :
temp="/tmp/mon-script.tmp"
echo "données" > "$temp"
Un autre processus pourrait avoir créé ce chemin avant votre script.
Utilisez :
temp="$(mktemp)"
ou pour un répertoire :
tmpdir="$(mktemp -d)"
Nettoyage sécurisé d’un répertoire temporaire
#!/bin/bash
set -Eeuo pipefail
tmpdir="$(mktemp -d)"
cleanup() {
if [[ -n "${tmpdir:-}" && -d "$tmpdir" ]]; then
rm -rf -- "$tmpdir"
fi
}
trap cleanup EXIT
printf 'Répertoire temporaire : %s\n' "$tmpdir"
# Travail ici...
Le répertoire est créé par :
mktemp
et supprimé lorsque le script termine normalement ou quitte à la suite de nombreuses erreurs.
Ne faites pas mktemp -u pour créer ensuite le fichier
L’option :
mktemp -u
génère seulement un nom qui n’existe pas au moment de la vérification.
Entre :
générer le nom
et :
créer le fichier
un autre processus peut occuper le chemin.
Pour un temporaire réel :
mktemp
doit créer directement l’objet.
Script de suppression conditionnelle amélioré
L’exemple original peut être rendu plus robuste :
#!/bin/bash
set -u
fichier="test.txt"
if [[ ! -e "$fichier" ]]; then
printf 'Le fichier %s n’existe pas.\n' "$fichier"
exit 0
fi
read -r -p "Supprimer $fichier ? [o/N] " reponse
case "$reponse" in
o|O|oui|OUI)
if rm -- "$fichier"; then
printf 'Fichier supprimé.\n'
else
printf 'Échec de suppression.\n' >&2
exit 1
fi
;;
*)
printf 'Opération annulée.\n'
;;
esac
Pourquoi [o/N] ?
Cette notation indique :
o → oui explicite
N → choix par défaut
Si l’utilisateur appuie simplement sur :
Entrée
l’opération destructive n’est pas exécutée.
Une bonne interface rend les erreurs dangereuses légèrement plus difficiles que les décisions prudentes.
Script de ping amélioré
#!/bin/bash
read -r -p "Adresse IP ou nom DNS : " cible
if [[ -z "$cible" ]]; then
printf 'Aucune cible fournie.\n' >&2
exit 2
fi
if ping -c 3 "$cible" >/dev/null 2>&1; then
printf '%s est joignable par ICMP.\n' "$cible"
else
printf 'Aucune réponse ICMP de %s.\n' "$cible"
fi
Ping ne prouve pas que le service fonctionne
Une machine peut ne pas répondre à ICMP mais fournir parfaitement :
HTTPS
SSH
SMTP
Inversement, un ping réussi ne garantit pas que :
port 443
fonctionne.
Pour tester un service TCP, utilisez un outil approprié :
nc
curl
openssl
ss
nmap
selon le contexte.
Exemple : vérifier un service HTTP
if curl -fsS --max-time 5 "https://example.com/" >/dev/null; then
printf 'Le site répond correctement.\n'
else
printf 'Le test HTTP a échoué.\n' >&2
fi
Script de copie locale amélioré
L’exemple original utilisait :
cp -r
Nous pouvons le rendre plus prudent :
#!/bin/bash
set -Eeuo pipefail
source="$HOME/documents"
racine_backup="$HOME/sauvegardes"
timestamp="$(date '+%Y-%m-%d_%H-%M-%S')"
destination="$racine_backup/$timestamp"
if [[ ! -d "$source" ]]; then
printf 'Source absente : %s\n' "$source" >&2
exit 1
fi
mkdir -p -- "$destination"
cp -a -- "$source" "$destination/"
printf 'Copie terminée dans : %s\n' "$destination"
cp -a
Sur GNU/Linux :
cp -a
cherche à préserver de nombreux attributs et à copier récursivement de manière adaptée à l’archivage.
Mais :
une copie dans un autre dossier du même disque n’est pas une véritable stratégie de sauvegarde contre la perte du disque.
Le script automatise une copie.
La stratégie de sauvegarde reste un sujet séparé.
Rsync pour les synchronisations
Lorsque :
rsync
est disponible, il est particulièrement pratique :
rsync -a -- "$source/" "$destination/"
Il peut notamment :
- ne transférer que les changements ;
- préserver de nombreuses métadonnées ;
- fonctionner à distance ;
- produire des statistiques ;
- proposer un mode de simulation.
Attention à –delete
Cette option :
rsync -a --delete source/ destination/
supprime dans la destination les éléments qui ne sont plus présents dans la source.
C’est extrêmement utile pour une synchronisation miroir.
C’est également extrêmement efficace lorsqu’on inverse accidentellement :
source
et
destination
Utilisez :
--dry-run
avant une commande destructive importante.
Créer son propre mode dry-run
Pour un script administratif, une option :
--dry-run
est excellente.
Conceptuellement :
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
fi
Puis :
if "$DRY_RUN"; then
printf 'Je supprimerais : %s\n' "$fichier"
else
rm -- "$fichier"
fi
true et false sont de vraies commandes shell
On peut utiliser :
DRY_RUN=true
puis :
if "$DRY_RUN"; then
...
fi
Bash exécute alors :
true
ou :
false
et teste leur statut.
Analyser les options avec getopts
Pour un script plus sérieux :
./backup.sh -s /srv/data -d /mnt/backup -v
Bash propose :
getopts
pour analyser les options courtes.
Exemple :
#!/bin/bash
source_dir=""
destination=""
verbose=false
while getopts ":s:d:v" option; do
case "$option" in
s)
source_dir="$OPTARG"
;;
d)
destination="$OPTARG"
;;
v)
verbose=true
;;
:)
printf 'Option -%s : argument manquant.\n' "$OPTARG" >&2
exit 2
;;
\?)
printf 'Option inconnue : -%s\n' "$OPTARG" >&2
exit 2
;;
esac
done
shift $((OPTIND - 1))
Pourquoi getopts ?
Parce que cette méthode :
if [[ "$1" == "-a" ]]; then ...
if [[ "$2" == "-b" ]]; then ...
if [[ "$3" == "-c" ]]; then ...
devient rapidement un musée de conditions impossibles à maintenir.
Les here-documents
Un :
here-document
permet de fournir plusieurs lignes à l’entrée d’une commande.
Exemple :
cat <<EOF
Bonjour
Ceci est un texte multiligne.
Utilisateur : $USER
EOF
Empêcher les expansions dans un here-document
En citant le délimiteur :
cat <<'EOF'
$USER ne sera pas développé.
$(date) non plus.
EOF
Très utile pour générer des fichiers de configuration contenant eux-mêmes des :
$VARIABLES
source et .
La commande Bash :
source fichier.conf
ou la forme :
. fichier.conf
exécute le fichier dans le shell courant.
Cela signifie que le fichier peut :
- définir des variables ;
- définir des fonctions ;
- modifier le répertoire courant ;
- changer des options ;
- exécuter des commandes.
Ne sourcez pas un fichier non fiable
Faire :
source configuration.txt
ne signifie pas :
« Lire gentiment quelques valeurs. »
Cela signifie :
« Exécuter ce fichier comme code shell dans mon processus actuel. »
Si le contenu provient d’un utilisateur ou d’une source non maîtrisée :
ne l’utilisez pas comme fichier sourcé.
N’utilisez pas eval sur des données non fiables
eval demande à Bash d’interpréter une chaîne comme nouveau code shell.
Exemple conceptuellement dangereux :
eval "$entree_utilisateur"
Si l’utilisateur saisit :
du code shell
vous lui avez précisément demandé de l’exécuter.
Dans la grande majorité des scripts d’administration ordinaires, si vous pensez avoir besoin d’eval, vérifiez d’abord s’il existe une solution qui n’en a pas besoin.
IFS
IFS signifie :
Internal Field Separator
Il intervient dans certaines opérations de découpage.
Vous verrez souvent :
IFS= read -r ligne
ou :
IFS=: read -r utilisateur motdepasse uid gid gecos home shell
pour analyser une ligne structurée.
Ne changez pas globalement IFS sans raison
Ce genre de chose :
IFS=,
# 300 lignes de script...
peut modifier des comportements loin de l’endroit où la variable a été changée.
Préférez limiter sa portée :
while IFS=, read -r colonne1 colonne2; do
...
done
Les globs
Bash développe des motifs comme :
*.txt
photo?.jpg
rapport[0-9].pdf
avant d’appeler la commande.
Par exemple :
rm -- *.tmp
peut devenir :
rm -- a.tmp b.tmp cache.tmp
Ne citez pas un glob que vous voulez développer
Ceci :
rm -- "*.tmp"
cherche littéralement un fichier appelé :
*.tmp
Les guillemets empêchent ici volontairement le globbing.
Mais citez les variables qui contiennent le répertoire
Correct :
for fichier in "$repertoire"/*.txt; do
...
done
La partie :
"$repertoire"
reste protégée.
Le :
*.txt
reste actif.
Attention à rm -rf
Ce n’est pas :
rm
qui est mauvais.
C’est l’utilisation d’une cible insuffisamment contrôlée.
Exemple inquiétant :
rm -rf "$destination"/*
Que vaut :
$destination
réellement ?
Avant une suppression massive, vérifiez :
- variable définie ;
- chemin attendu ;
- type de filesystem si pertinent ;
- répertoire exact ;
- dry-run ;
- absence de montage inattendu.
Créer une fonction de sécurité
Exemple :
require_directory() {
local dir="$1"
if [[ -z "$dir" || ! -d "$dir" ]]; then
printf 'Répertoire invalide : %s\n' "$dir" >&2
return 1
fi
}
Puis :
require_directory "$destination" || exit 1
Ne protégez pas rm -rf avec une simple variable non vide
Cette vérification :
[[ -n "$dir" ]]
ne suffit pas.
Une variable peut contenir :
/
tout en étant remarquablement non vide.
Vérifier un chemin critique
Pour un script spécialisé :
if [[ "$destination" != /srv/app/cache/* ]]; then
printf 'Destination refusée : %s\n' "$destination" >&2
exit 1
fi
peut constituer une couche de sécurité supplémentaire.
Elle doit cependant être pensée précisément, notamment en présence de :
- liens symboliques ;
..;- montages ;
- chemins non normalisés.
Résoudre un chemin
Selon l’objectif et le système :
realpath "$chemin"
ou :
readlink -f "$chemin"
peuvent aider à déterminer un chemin canonique.
Il faut néanmoins comprendre leur comportement avant de baser une décision destructive dessus.
Les permissions du script
Pour un script personnel :
chmod 750 script.sh
peut permettre :
- lecture/écriture/exécution au propriétaire ;
- lecture/exécution au groupe ;
- aucun accès aux autres.
Le classique :
chmod 777 script.sh
est rarement nécessaire.
Un fichier exécutable n’a pas besoin d’être mondialement modifiable.
Script exécuté comme root
Un script lancé avec :
sudo
possède potentiellement les capacités de root.
Une mauvaise expansion qui supprimait :
un fichier de l'utilisateur
peut soudainement supprimer :
un fichier du système
Le bug n’a pas changé.
Son habilitation, oui.
Appliquez le moindre privilège
Si une tâche peut fonctionner comme :
utilisateur backup
elle n’a peut-être aucune raison de tourner :
root
24 heures sur 24.
PATH dans un script privilégié
Un script exécuté avec des privilèges élevés doit faire attention à son environnement.
Si vous appelez :
commande_importante
Bash la cherche normalement dans :
PATH
Dans un script système, on peut utiliser un PATH connu :
PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
export PATH
ou utiliser des chemins absolus lorsque cela est réellement pertinent.
Vérifier une dépendance
Avec :
command -v
vous pouvez tester si une commande est disponible.
Par exemple :
if ! command -v rsync >/dev/null 2>&1; then
printf 'rsync est requis.\n' >&2
exit 1
fi
Ne testez pas avec which dans vos scripts
Pour ce besoin, le builtin :
command -v
est généralement plus approprié et évite de dépendre d’un programme externe dont le comportement varie entre environnements.
Fonction die
Une fonction simple facilite les erreurs :
die() {
printf 'ERREUR : %s\n' "$*" >&2
exit 1
}
Puis :
[[ -d "$source" ]] || die "Source absente : $source"
Fonction de journalisation
log() {
printf '[%s] %s\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" \
"$*"
}
Utilisation :
log "Début de la sauvegarde"
log "Copie de $source"
stdout et stderr dans les logs
Les messages informatifs peuvent aller vers :
stdout
et les erreurs vers :
stderr
Exemple :
printf 'Sauvegarde terminée.\n'
printf 'Erreur : disque absent.\n' >&2
Cela permet notamment à l’appelant de séparer :
sortie normale
et
diagnostics
Syslog et journalctl
Un script système peut utiliser :
logger
pour envoyer un message au système de journalisation :
logger -t mon-backup "Sauvegarde terminée"
Le message peut ensuite être consulté selon la configuration avec :
journalctl -t mon-backup
Empêcher plusieurs exécutions simultanées
Un script de sauvegarde lancé toutes les heures peut poser problème si l’exécution précédente prend :
90 minutes
Vous obtenez :
script 1
+
script 2
+
script 3
...
Une solution fréquente sous Linux est :
flock
Exemple avec flock
flock -n /run/lock/mon-backup.lock ./backup.sh
L’option :
-n
demande de ne pas attendre si le verrou est déjà détenu.
Verrou directement dans le script
Une construction possible :
exec 9>/run/lock/mon-script.lock
if ! flock -n 9; then
printf 'Une autre instance fonctionne déjà.\n' >&2
exit 1
fi
Le descripteur :
9
reste ouvert pendant la durée du script, conservant le verrou.
Les scripts non interactifs ont un environnement différent
Un script exécuté depuis votre terminal peut fonctionner.
Le même script exécuté depuis :
cron
systemd
SSH
CI/CD
peut échouer.
Pourquoi ?
Parce que son environnement peut différer :
- PATH ;
- HOME ;
- répertoire courant ;
- variables ;
- terminal absent ;
- credentials ;
- locale.
Ne supposez pas le répertoire courant
Si votre script contient :
cp config.ini backup/
il suppose que le répertoire courant contient :
config.ini
Ce n’est pas forcément vrai lorsque le script est lancé par un scheduler.
Trouver le répertoire du script
En Bash :
SCRIPT_DIR="$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1
pwd -P
)"
Vous pouvez ensuite utiliser :
"$SCRIPT_DIR/config.ini"
au lieu de dépendre du répertoire courant.
BASH_SOURCE
La variable :
BASH_SOURCE
est spécifique à Bash et contient des informations sur les fichiers source impliqués dans l’exécution.
Elle est particulièrement utile lorsqu’un script :
- est appelé depuis un autre répertoire ;
- source d’autres fichiers ;
- doit localiser ses propres ressources.
Un script interactif et un script automatisé ne devraient pas être les mêmes par accident
Cette commande :
read -r -p "Continuer ? " reponse
peut fonctionner merveilleusement dans un terminal.
Depuis cron :
personne ne répond.
Le script peut donc attendre ou échouer selon son entrée.
Décidez explicitement si le script est :
interactif
ou
non interactif
Tester si stdin est un terminal
if [[ -t 0 ]]; then
printf 'Entrée interactive.\n'
else
printf 'Pas de terminal sur stdin.\n'
fi
Idempotence
Un script est :
idempotent
lorsque le rejouer plusieurs fois conduit autant que possible au même état souhaité plutôt qu’à accumuler des effets indésirables.
Exemple non idempotent
echo "192.168.1.10 serveur" >> /etc/hosts
Chaque exécution ajoute :
une nouvelle ligne identique
Version plus contrôlée
ligne="192.168.1.10 serveur"
if ! grep -Fqx -- "$ligne" /etc/hosts; then
printf '%s\n' "$ligne" >> /etc/hosts
fi
Le script peut maintenant être rejoué sans empiler la même entrée.
mkdir -p est utile pour l’idempotence
mkdir -p -- "$destination"
réussit également si le répertoire existe déjà dans les conditions prévues.
Valider avant de modifier
Pour un fichier de configuration :
générer
↓
valider la syntaxe
↓
installer
↓
recharger le service
est souvent supérieur à :
écraser le fichier
↓
redémarrer le service
↓
découvrir qu'il ne démarre plus
Écriture atomique d’un fichier de configuration
Une stratégie :
tmp="$(mktemp)"
generer_configuration > "$tmp"
valider_configuration "$tmp"
install -m 0644 "$tmp" /etc/application/config.conf
puis laisser :
trap
supprimer le temporaire.
Tester la syntaxe Bash sans exécuter
Bash propose :
bash -n script.sh
L’option :
-n
lit les commandes sans les exécuter.
Elle peut détecter notamment :
fioublié ;- parenthèse non fermée ;
- guillemet incomplet ;
- certaines erreurs syntaxiques.
Mais elle ne peut pas déterminer que :
rm -rf /srv/important
était une mauvaise idée.
Syntaxiquement, cette commande est impeccable.
ShellCheck
ShellCheck est un analyseur statique spécialisé dans les scripts shell.
Sous Debian :
sudo apt update
sudo apt install shellcheck
Puis :
shellcheck monscript.sh
Il peut détecter de nombreux problèmes concernant :
- variables non citées ;
- mauvaise utilisation de tableaux ;
- tests douteux ;
- boucles fragiles ;
- mauvaises substitutions ;
- portabilité ;
- erreurs communes.
ShellCheck ne remplace pas les tests
Un script peut obtenir :
0 avertissement ShellCheck
et néanmoins :
supprimer la mauvaise sauvegarde
si votre logique métier est incorrecte.
ShellCheck vérifie beaucoup de choses.
Il ne connaît pas l’intention qui était dans votre tête.
Une bonne séquence de validation
bash -n script.sh
↓
shellcheck script.sh
↓
test avec données fictives
↓
test dry-run
↓
test réel limité
↓
production
Débogage avec bash -x
Au lieu d’insérer durablement :
set -x
vous pouvez lancer :
bash -x script.sh
Le tracing est alors activé pour cette exécution.
PS4 : améliorer le debug
La variable :
PS4
contrôle le préfixe du xtrace.
Exemple :
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
Les traces deviennent plus faciles à associer :
- au fichier ;
- à la ligne ;
- à la fonction.
Encore une fois :
ne faites pas cela dans un journal accessible si les commandes manipulent des secrets.
La variable LINENO
Bash fournit :
$LINENO
pour connaître le numéro de ligne courant.
Exemple :
printf 'Erreur ligne %d\n' "$LINENO" >&2
Trap ERR
On peut construire une aide au diagnostic :
trap 'printf "Erreur ligne %s, statut %s\n" "$LINENO" "$?" >&2' ERR
Mais :
ERR
possède des règles similaires aux subtilités de :
set -e
et ne doit pas être considéré comme une exception universelle attrapant absolument tous les échecs imaginables.
Ne confondez pas erreur et résultat négatif normal
Cette commande :
grep -q motif fichier
retourne notamment un statut différent lorsque le motif n’est pas trouvé.
Mais :
motif absent
peut être un résultat parfaitement normal dans votre programme.
Utilisez :
if grep -q ...; then
...
else
...
fi
au lieu de considérer chaque statut non nul comme :
catastrophe système
Validation des entrées
Si l’utilisateur fournit :
un nombre
vérifiez qu’il s’agit réellement d’un nombre.
Exemple Bash :
if [[ "$valeur" =~ ^[0-9]+$ ]]; then
printf 'Entier positif.\n'
else
printf 'Valeur invalide.\n' >&2
fi
Validation d’un nom simple
if [[ "$nom" =~ ^[A-Za-z0-9._-]+$ ]]; then
printf 'Nom accepté.\n'
fi
Mais ne créez pas une validation arbitrairement restrictive si votre application doit accepter :
- accents ;
- Unicode ;
- espaces ;
- d’autres caractères légitimes.
Validation n’est pas quoting
Vous devez généralement :
valider
ET
citer correctement
Une validation métier ne remplace pas les règles de syntaxe shell.
Ne construisez pas des commandes sous forme de chaîne
Évitez :
commande="cp $source $destination"
$commande
Cette méthode entraîne rapidement des problèmes de :
- quoting ;
- espaces ;
- options ;
- injection.
Utilisez un tableau pour construire une commande
En Bash :
commande=(
rsync
-a
--verbose
--
"$source/"
"$destination/"
)
"${commande[@]}"
Chaque argument reste clairement séparé.
Ajouter conditionnellement une option
commande=(rsync -a)
if [[ "${DRY_RUN:-false}" == "true" ]]; then
commande+=(--dry-run)
fi
commande+=(-- "$source/" "$destination/")
"${commande[@]}"
C’est énormément plus robuste que fabriquer une longue chaîne puis tenter de la faire réinterpréter.
Scripts et sudo
Évitez :
sudo commande1
sudo commande2
sudo commande3
dans un script conçu pour être entièrement administratif si tout le script requiert réellement root.
Vous pouvez vérifier :
if (( EUID != 0 )); then
printf 'Ce script doit être exécuté comme root.\n' >&2
exit 1
fi
Puis lancer :
sudo ./script.sh
Mais ne demandez les privilèges root que s’ils sont réellement nécessaires.
EUID
Bash fournit :
$EUID
qui correspond à l’identifiant effectif de l’utilisateur.
Pour root :
EUID = 0
Attention aux variables d’environnement avec sudo
sudo peut filtrer ou modifier :
- PATH ;
- HOME ;
- variables personnalisées.
Ne supposez pas que :
sudo ./script.sh
voit exactement le même environnement que votre shell utilisateur.
Manipulation sûre de listes de fichiers
Pour récupérer des résultats terminés par NUL :
mapfile -d '' -t fichiers < <(
find "$repertoire" -type f -print0
)
Puis :
for fichier in "${fichiers[@]}"; do
printf '%s\n' "$fichier"
done
mapfile est une fonctionnalité Bash.
Le piège de la substitution de commande pour des fichiers
Évitez :
fichiers=($(find "$repertoire" -type f))
Les résultats sont soumis au découpage et au globbing.
Les espaces et retours à la ligne dans les noms deviennent un problème.
Le mot-clé readonly
Pour une valeur qui ne doit plus changer :
readonly CONFIG_FILE="/etc/monoutil.conf"
Une tentative ultérieure de modification provoquera une erreur.
On peut aussi utiliser :
declare -r CONFIG_FILE="/etc/monoutil.conf"
Les variables d’environnement
Une variable Bash ordinaire :
MODE="production"
n’est pas automatiquement transmise aux processus enfants.
Pour l’exporter :
export MODE="production"
Puis :
commande_enfant
peut recevoir :
MODE
dans son environnement.
Variable pour une seule commande
MODE=production monprogramme
définit :
MODE
dans l’environnement de :
monprogramme
sans nécessairement modifier durablement l’environnement parent.
Les sous-shells
Les parenthèses :
(
cd /tmp
touch fichier-test
)
créent un environnement de sous-shell.
Le :
cd /tmp
n’affecte donc pas le répertoire du shell parent.
Les accolades
Avec :
{
commande1
commande2
}
les commandes sont groupées dans le shell courant.
La différence peut être importante lorsque les commandes modifient :
- variables ;
- répertoire courant ;
- options shell.
Une boucle dans une pipeline peut s’exécuter dans un sous-shell
Construction :
commande | while read -r ligne; do
compteur=$((compteur + 1))
done
Selon le contexte Bash, la boucle d’une pipeline peut fonctionner dans un sous-shell.
La valeur de :
compteur
peut donc ne pas être conservée comme vous l’espérez après la pipeline.
Préférez une redirection ou substitution de processus
Par exemple :
while IFS= read -r ligne; do
compteur=$((compteur + 1))
done < fichier.txt
ou, en Bash :
while IFS= read -r ligne; do
compteur=$((compteur + 1))
done < <(commande)
Le script complet : squelette robuste
#!/bin/bash
set -Eeuo pipefail
readonly PROGRAM="${0##*/}"
log() {
printf '[%s] %s\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" \
"$*"
}
die() {
printf '%s: erreur: %s\n' "$PROGRAM" "$*" >&2
exit 1
}
usage() {
printf 'Usage: %s SOURCE DESTINATION\n' "$PROGRAM"
}
cleanup() {
if [[ -n "${tmpdir:-}" && -d "$tmpdir" ]]; then
rm -rf -- "$tmpdir"
fi
}
trap cleanup EXIT
if [[ $# -ne 2 ]]; then
usage >&2
exit 2
fi
source_dir="$1"
destination="$2"
[[ -d "$source_dir" ]] ||
die "Source inexistante : $source_dir"
command -v rsync >/dev/null 2>&1 ||
die "rsync est requis"
tmpdir="$(mktemp -d)"
log "Début de l'opération"
log "Source : $source_dir"
log "Destination : $destination"
mkdir -p -- "$destination"
if rsync -a -- "$source_dir/" "$destination/"; then
log "Opération terminée"
else
die "Échec de rsync"
fi
Pourquoi ce squelette est meilleur qu’une suite brute de commandes
Il possède :
- un interpréteur explicitement défini ;
- une politique d’erreurs ;
- une vérification du nombre d’arguments ;
- des variables citées ;
- un contrôle du répertoire source ;
- un contrôle des dépendances ;
- un temporaire sécurisé ;
- un nettoyage automatique ;
- une journalisation ;
- des erreurs envoyées sur stderr.
Cela ne le rend pas parfait.
Cela le rend simplement beaucoup moins dépendant de :
« Normalement ça devrait aller. »
Script de supervision simple
#!/bin/bash
set -u
cibles=(
"192.168.1.1"
"192.168.1.10"
"example.com"
)
erreurs=0
for cible in "${cibles[@]}"; do
if ping -c 1 "$cible" >/dev/null 2>&1; then
printf '[OK] %s\n' "$cible"
else
printf '[ERREUR] %s\n' "$cible" >&2
((erreurs++))
fi
done
if (( erreurs > 0 )); then
exit 1
fi
Attention à set -e et (( compteur++ ))
Voilà un excellent exemple de subtilité Bash.
L’expression :
((compteur++))
retourne un statut dépendant de la valeur de l’expression.
Si :
compteur
vaut initialement :
0
l’expression peut retourner un statut non nul.
Avec :
set -e
cela peut produire un arrêt inattendu selon le contexte.
Une alternative dans ce scénario est :
((++compteur))
si vous savez que le résultat après incrément ne sera pas zéro, ou :
compteur=$((compteur + 1))
qui évite d’utiliser le statut arithmétique comme commande susceptible d’interagir avec errexit.
Voilà pourquoi « set -e rend tout sûr » est faux
Un script Bash possède plusieurs niveaux de sens :
valeur calculée
+
sortie
+
>statut de commande
+
contexte syntaxique
Le même code peut donc avoir des implications différentes selon l’endroit où il est placé.
Portabilité : Bash ou POSIX sh ?
Si votre script doit fonctionner sur beaucoup de systèmes :
Linux
BSD
Unix divers
environnements minimaux
il peut être intéressant de viser :
#!/bin/sh
et le langage POSIX correspondant.
Mais si vous avez besoin de :
- tableaux ;
[[ ... ]];- tableaux associatifs ;
mapfile;BASH_SOURCE;- substitution de processus ;
utiliser clairement :
#!/bin/bash
est souvent beaucoup plus propre que prétendre écrire du :
sh
tout en utilisant la moitié des extensions Bash.
Ne choisissez pas sh juste parce qu’il est plus court
Ce script :
#!/bin/sh
tableau=(un deux trois)
n’est pas un script shell POSIX valide.
Sur certains systèmes, :
/bin/sh
n’est même pas Bash.
ShellCheck aide justement sur ce point
Si votre fichier annonce :
#!/bin/sh
ShellCheck peut signaler certaines constructions propres à Bash.
Si vous annoncez :
#!/bin/bash
il analysera le code comme Bash.
Séparer configuration et logique
Pour un gros script, évitez :
500 lignes
avec
47 chemins codés en dur
Vous pouvez centraliser :
readonly SOURCE="/srv/data"
readonly DESTINATION="/backup"
readonly RETENTION_DAYS=30
en début de script.
Ou accepter ces valeurs comme :
- arguments ;
- variables d’environnement ;
- fichier de configuration lu avec un parseur approprié.
Ne transformez pas un fichier de configuration en script caché
Un fichier :
backup.conf
contenant :
SOURCE="/srv/data"
DEST="/backup"
puis chargé avec :
source backup.conf
est en réalité du code shell exécutable.
C’est acceptable si :
- le fichier est de confiance ;
- ses permissions sont contrôlées ;
- vous assumez explicitement cette conception.
Sinon, utilisez un véritable format de données et un parseur adapté.
Les scripts trop grands devraient parfois changer de langage
Bash est excellent pour :
- orchestrer des commandes ;
- administrer le système ;
- automatiser des procédures ;
- traiter des flux simples.
Il devient moins agréable lorsque votre programme contient :
- structures de données complexes ;
- JSON profondément imbriqué ;
- calculs avancés ;
- API complexes ;
- tests unitaires importants ;
- plusieurs milliers de lignes de logique métier.
À ce stade :
Python
Go
Perl
Ruby
autre langage
peut devenir beaucoup plus lisible et robuste.
La règle des « quelques lignes » n’existe pas
Il n’existe pas un nombre magique :
101 lignes
→ abandonner Bash
La vraie question est :
« Le problème consiste-t-il encore principalement à orchestrer des commandes et des fichiers, ou suis-je en train de construire une application générale dans un langage peu adapté ? »
Automatiser avec cron
Un script peut ensuite être lancé périodiquement.
Par exemple :
0 2 * * * /usr/local/sbin/backup.sh
Mais rappelez-vous :
- PATH peut être différent ;
- le répertoire courant peut être différent ;
- aucun terminal n’est présent ;
- les variables interactives ne sont pas forcément disponibles.
Automatiser avec systemd
Pour des tâches système modernes, un :
service
+
timer systemd
peut fournir :
- journalisation ;
- dépendances ;
- contrôle d’environnement ;
- timeouts ;
- politique de redémarrage ;
- planification.
Le script Bash reste alors :
le travail à accomplir
et systemd gère :
quand
comment
sous quel utilisateur
avec quelles dépendances
Les secrets dans les scripts
Évitez :
DB_PASSWORD="SuperSecret123!"
dans :
/usr/local/bin/backup.sh
surtout si le fichier est :
chmod 755
et donc lisible par de nombreux utilisateurs.
Évitez aussi les secrets dans les arguments
Une commande comme :
programme --password "secret"
peut exposer le secret dans :
- les listes de processus selon le système ;
- les logs ;
- l’historique ;
- le tracing.
Utilisez les mécanismes prévus par l’application :
- fichier de credentials protégé ;
- descripteur de fichier ;
- variable d’environnement si appropriée ;
- gestionnaire de secrets ;
- agent ;
- token de courte durée.
Ne mettez pas les secrets dans Git
Le classique :
git commit backup.sh
git push
avec :
PASSWORD="production-root-password"
transforme rapidement :
script d'automatisation
en :
incident de sécurité automatisé
Une checklist avant de lancer un script
- Quel utilisateur exécutera le script ?
- Quelles permissions possède-t-il ?
- Quels fichiers seront modifiés ?
- Les variables de chemin sont-elles citées ?
- Les entrées utilisateur sont-elles validées ?
- Les noms commençant par
-sont-ils correctement gérés ? - Les fichiers temporaires utilisent-ils
mktemp? - Les erreurs importantes sont-elles vérifiées ?
- Une opération destructive possède-t-elle un mode dry-run ?
- Le script a-t-il été testé avec des noms contenant des espaces ?
- Le script fonctionne-t-il sans terminal si nécessaire ?
bash -nest-il propre ?- ShellCheck est-il satisfait ou ses exceptions sont-elles comprises ?
Checklist pour une commande destructive
Avant :
rm
rsync --delete
find -delete
truncate
mkfs
dd
vérifiez au minimum :
1. La cible
2. Le contenu de la variable
3. Le chemin canonique si nécessaire
4. Le filesystem concerné
5. Le mode dry-run
6. La sauvegarde
7. L'utilisateur effectif
Testez avec des cas pénibles
Un script de fichiers devrait idéalement être testé avec des noms comme :
rapport annuel.txt
-fichier.txt
deux espaces.txt
école.txt
et, lorsque le niveau de robustesse l’exige, avec des noms contenant des caractères encore plus inhabituels.
Un script qui fonctionne uniquement avec :
test.txt
n’a pas encore rencontré le système de fichiers réel.
Les erreurs Bash les plus fréquentes
Oublier les guillemets
Fragile :
rm $fichier
Préférable :
rm -- "$fichier"
Utiliser for avec $(ls)
Fragile :
for f in $(ls); do
...
done
Préférez :
for f in ./*; do
...
done
ou des techniques NUL-safe lorsque nécessaire.
Tester $? trop tard
Fragile :
commande
echo "Commande exécutée"
if [[ $? -eq 0 ]]; then
...
fi
Vous testez maintenant :
echo
Préférez :
if commande; then
...
fi
Croire que set -e gère toutes les erreurs
Faux.
Comprenez ses exceptions.
Oublier pipefail
Une commande intermédiaire d’une pipeline peut échouer alors que la dernière réussit.
Activer set -x avec des secrets
Le debug devient alors une fonctionnalité d’exfiltration particulièrement documentée.
Utiliser rm -rf avec une variable non contrôlée
Examinez :
ce que contient réellement la variable
avant de donner à :
rm
le droit de l’interpréter.
Créer /tmp/fichier.$$ manuellement
Utilisez :
mktemp
pour les temporaires nécessitant une création sûre.
Utiliser eval sur des données utilisateur
Évitez.
Supposer que cron possède votre PATH
Définissez clairement l’environnement nécessaire.
Supposer que le script démarre dans son propre répertoire
Utilisez des chemins explicites.
Mélanger Bash et sh
Si vous utilisez des fonctionnalités Bash :
annoncez Bash
Utiliser chmod 777 pour résoudre un problème
Une permission d’exécution n’exige pas que tout le monde puisse modifier le script.
Lancer tout avec sudo
Si le script contient un bug, root lui donne simplement davantage d’endroits à explorer.
Tableau des constructions essentielles
| Construction | Rôle |
|---|---|
#!/bin/bash |
Choisir Bash lors de l’exécution directe |
"$variable" |
Préserver une expansion comme argument |
"$@" |
Transmettre correctement tous les arguments |
[[ ... ]] |
Expression conditionnelle Bash |
(( ... )) |
Expression arithmétique Bash |
$(commande) |
Substitution de commande |
if ...; then |
Condition basée sur un statut |
for |
Itération |
while |
Boucle conditionnelle |
case |
Choix parmi plusieurs motifs |
function() |
Regrouper une logique réutilisable |
local |
Limiter une variable à une fonction |
set -u |
Signaler de nombreuses variables absentes |
set -o pipefail |
Propager les échecs de pipeline |
trap |
Réagir à la sortie ou à certains signaux |
mktemp |
Créer un temporaire de manière sûre |
command -v |
Vérifier la disponibilité d’une commande |
Commandes de développement utiles
| Commande | Rôle |
|---|---|
bash script.sh |
Exécuter explicitement avec Bash |
./script.sh |
Exécuter directement le fichier |
chmod +x script.sh |
Ajouter le bit exécutable |
bash -n script.sh |
Vérifier la syntaxe sans exécuter |
bash -x script.sh |
Exécuter avec tracing |
shellcheck script.sh |
Analyse statique |
command -v commande |
Localiser/tester une commande |
type commande |
Indiquer comment Bash résout une commande |
help builtin |
Aide sur un builtin Bash |
man bash |
Documentation Bash |
Un exemple final : script de contrôle de service
#!/bin/bash
set -Eeuo pipefail
readonly PROGRAM="${0##*/}"
usage() {
printf 'Usage: %s SERVICE\n' "$PROGRAM"
}
die() {
printf '%s: %s\n' "$PROGRAM" "$*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
usage >&2
exit 2
fi
service="$1"
command -v systemctl >/dev/null 2>&1 ||
die "systemctl introuvable"
if systemctl is-active --quiet "$service"; then
printf '%s est actif.\n' "$service"
exit 0
fi
printf '%s est inactif.\n' "$service"
exit 1
Version avec plusieurs services
#!/bin/bash
set -u
services=(
"ssh"
"cron"
)
erreurs=0
for service in "${services[@]}"; do
if systemctl is-active --quiet "$service"; then
printf '[OK] %-20s actif\n' "$service"
else
printf '[ERREUR] %-20s inactif\n' "$service" >&2
erreurs=$((erreurs + 1))
fi
done
if (( erreurs > 0 )); then
exit 1
fi
exit 0
Un exemple final : supprimer des fichiers anciens avec confirmation
Supposons que nous voulions identifier des fichiers :
*.tmp
plus vieux que :
30 jours
dans :
/srv/cache
Première étape : afficher seulement.
find /srv/cache \
-type f \
-name '*.tmp' \
-mtime +30 \
-print
Ensuite seulement, après validation :
find /srv/cache \
-type f \
-name '*.tmp' \
-mtime +30 \
-delete
Le réflexe professionnel consiste à commencer par :
-print
et non directement :
-delete
Une automatisation doit être observable
Si un script tourne automatiquement depuis six mois, vous devez pouvoir répondre :
- a-t-il réellement tourné ?
- a-t-il réussi ?
- combien de temps a-t-il pris ?
- qu’a-t-il modifié ?
- où sont ses erreurs ?
- qui est alerté lorsqu’il échoue ?
Un script silencieux n’est pas nécessairement fiable.
Il est simplement silencieux.
Une automatisation doit échouer clairement
Évitez les scripts qui font :
copie impossible
↓
message ignoré
↓
suite du script
↓
"Backup completed successfully"
Un bon script doit :
détecter
↓
journaliser
↓
retourner un statut utile
↓
alerter si nécessaire
Une automatisation doit être testable
Un bon script offre idéalement :
- des fonctions courtes ;
- des paramètres ;
- des chemins configurables ;
- un mode dry-run pour les opérations dangereuses ;
- des codes de retour cohérents ;
- des logs compréhensibles.
Un script contenant :
SERVER_PRODUCTION="/srv/prod"
rm -rf "$SERVER_PRODUCTION/cache"/*
sans possibilité de changer facilement la cible n’est pas très agréable à tester ailleurs.
Une automatisation doit rester compréhensible
Ce code :
x=$(a|b|c|d|awk '{print $4}'|sed 's/x/y/'|cut -d: -f2)
est peut-être brillant.
Il peut aussi devenir parfaitement incompréhensible trois mois plus tard.
Lorsque la logique devient importante :
décomposez-la
nommez les variables
vérifiez les statuts
documentez l'intention
Votre futur vous possède généralement déjà suffisamment de problèmes.
Il n’a pas besoin de pratiquer l’archéologie sur votre one-liner.
Méthode recommandée pour écrire un script Bash
1. Définir précisément l'objectif
2. Écrire les commandes manuellement
3. Vérifier leurs effets
4. Transformer les valeurs variables en paramètres
5. Ajouter les guillemets correctement
6. Ajouter les contrôles d'entrée
7. Vérifier les codes de retour
8. Ajouter logs et gestion d'erreurs
9. Ajouter nettoyage et temporaires sûrs
10. Tester bash -n
11. Tester ShellCheck
12. Tester sur des données fictives
13. Tester les cas d'erreur
14. Ajouter un dry-run si nécessaire
15. Déployer progressivement
Les règles à retenir
- Choisissez explicitement Bash si vous utilisez ses fonctionnalités.
- Le shebang sert principalement lors de l’exécution directe.
chmod +xn’est pas nécessaire pourbash script.sh, mais l’est normalement pour./script.sh.- Utilisez
./lorsque le script se trouve dans le répertoire courant et n’est pas dans PATH. - Citez presque toujours les expansions qui représentent des chaînes ou chemins.
- Utilisez
"$@"pour transmettre des arguments. - Préférez tester directement les commandes plutôt que manipuler inutilement
$?. set -epossède des exceptions et ne remplace pas la gestion d’erreurs.pipefailest important lorsqu’une pipeline doit révéler ses échecs intermédiaires.set -xpeut exposer des secrets.- Utilisez
mktemppour les temporaires. - Utilisez
trappour le nettoyage. - Évitez
evalavec des données non fiables. - Ne parsez pas
lspour obtenir des noms de fichiers. - Traitez correctement les espaces et caractères particuliers dans les noms.
- Utilisez
command -vpour vérifier les dépendances. - Écrivez autant que possible des scripts idempotents.
- Utilisez le moindre privilège.
- Testez avec
bash -net ShellCheck. - Prévisualisez les opérations destructives.
Conclusion : automatiser, c’est multiplier son pouvoir
Un script Bash permet de transformer :
une commande manuelle
en :
une procédure répétable
puis éventuellement en :
une tâche automatique
exécutée chaque nuit
sans humain devant le clavier
C’est précisément ce qui rend Bash aussi utile.
Et précisément ce qui exige de la prudence.
Un bon script doit savoir :
quoi faire
sur quoi
avec quelles permissions
comment détecter un échec
comment nettoyer
et quand s'arrêter
La différence entre :
for fichier in ...
et :
200 fichiers supprimés
n’est que de quelques lignes.
Mais le véritable danger n’est pas la boucle.
C’est la boucle exécutée comme root, sans guillemets, sans contrôle, depuis cron, à 3 heures du matin, avec :
set -x
qui enregistre au passage le mot de passe de production.
Retenez donc une dernière règle :
avant d’automatiser une opération, assurez-vous d’abord de savoir exactement ce qu’elle fait manuellement.
Parce qu’un administrateur peut commettre une erreur en quelques secondes.
Bash, lui, peut la répéter plusieurs milliers de fois avant même que l’administrateur ait eu le temps de dire :
Ctrl+C
