Passer au contenu principal
Linux, OS

Scripts Bash : automatiser Linux proprement sans tout casser

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 ;
  • source sous 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 :

  • fi oublié ;
  • 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

  1. Quel utilisateur exécutera le script ?
  2. Quelles permissions possède-t-il ?
  3. Quels fichiers seront modifiés ?
  4. Les variables de chemin sont-elles citées ?
  5. Les entrées utilisateur sont-elles validées ?
  6. Les noms commençant par - sont-ils correctement gérés ?
  7. Les fichiers temporaires utilisent-ils mktemp ?
  8. Les erreurs importantes sont-elles vérifiées ?
  9. Une opération destructive possède-t-elle un mode dry-run ?
  10. Le script a-t-il été testé avec des noms contenant des espaces ?
  11. Le script fonctionne-t-il sans terminal si nécessaire ?
  12. bash -n est-il propre ?
  13. 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 +x n’est pas nécessaire pour bash 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 -e possède des exceptions et ne remplace pas la gestion d’erreurs.
  • pipefail est important lorsqu’une pipeline doit révéler ses échecs intermédiaires.
  • set -x peut exposer des secrets.
  • Utilisez mktemp pour les temporaires.
  • Utilisez trap pour le nettoyage.
  • Évitez eval avec des données non fiables.
  • Ne parsez pas ls pour obtenir des noms de fichiers.
  • Traitez correctement les espaces et caractères particuliers dans les noms.
  • Utilisez command -v pour vérifier les dépendances.
  • Écrivez autant que possible des scripts idempotents.
  • Utilisez le moindre privilège.
  • Testez avec bash -n et 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