Du terminal au script
Pourquoi
En reprenant sig-outils, vous trouvez dans /etc/cron.d/signalements-mairie une ligne laissée par Camille :
45 5 * * * signalements sh /opt/signalements/bin/publier-export.sh > /dev/null 2>&1Chaque matin à 5 h 45, ce script dépose l'export de la veille dans le bucket de la mairie. Il « marche » : la mairie reçoit ses fichiers. Mais en l'ouvrant, vous découvrez qu'il ne commence par aucune ligne #!, qu'il utilise une syntaxe que sh ne connaît pas, que sa vérification de réussite est donc cassée depuis le premier jour, et que personne ne l'a jamais su, parce que toute sa sortie part dans /dev/null. Camille le testait à la main avec bash publier-export.sh, où tout allait bien.
Ce n'est pas un défaut de compétence de Camille : c'est le résultat d'une frontière mal comprise entre taper des commandes et écrire un programme. Dans un terminal, vous voyez chaque message, vous corrigez au fil de l'eau, votre shell est configuré par votre .bashrc. Un script, lui, sera lancé par un autre programme (cron, systemd, un pipeline de CI, sudo, un collègue), avec un autre interpréteur parfois, un autre environnement toujours, et personne pour lire ce qu'il affiche.
Cette leçon traite tout ce qui se passe avant la première ligne utile d'un script : faut-il l'écrire en Bash, ou l'écrire tout court ; quelle ligne #! mettre ; qui l'exécute selon la façon de le lancer ; comment vérifier qu'il est au moins syntaxiquement correct ; où l'installer et comment le livrer sans casser une exécution en cours. C'est le socle de tout le cours : un script parfaitement écrit mais exécuté par le mauvais interpréteur est un script faux.
Les concepts
Script, interpréteur, shell
Un script est un fichier texte contenant des instructions qu'un autre programme, l'interpréteur, lit et exécute au fur et à mesure. Un programme compilé (comme ls ou gzip) contient directement des instructions pour le processeur ; un script ne contient que du texte, et il faut toujours un interpréteur pour lui donner vie : Bash pour un script Bash, Python pour un script Python.
Le shell est l'interpréteur que vous utilisez déjà en tapant des commandes. Un script shell n'est rien d'autre qu'une suite de lignes que vous auriez pu taper, enregistrée dans un fichier. Le manuel de Bash le dit sobrement : un script shell est un fichier texte contenant des commandes shell. Tout ce que vous tapez peut aller dans un script, et réciproquement ; mais un shell qui lit un fichier est un shell non interactif, qui ne lit pas votre .bashrc, n'a pas vos alias, et ne s'arrête pas pour vous demander quoi que ce soit.
Il existe plusieurs shells, et ils ne parlent pas exactement la même langue :
| Shell | Où on le trouve | Langage |
|---|---|---|
| Bash 5.2 | shell de connexion de Debian et d'Ubuntu, /bin/bash | POSIX plus de nombreuses extensions : [[ ]], tableaux, {1..5}, $'...', pipefail... |
| Dash 0.5.12 | /bin/sh de Debian et d'Ubuntu | POSIX presque strict, plus quelques ajouts (local, echo -n) |
| BusyBox ash | /bin/sh des images Alpine, des systèmes embarqués | POSIX, quelques extensions |
| Bash 3.2 | /bin/bash de macOS (version de 2007) | ancien : pas de tableaux associatifs, pas de mapfile |
| Zsh | shell interactif de macOS | proche de Bash, mais différent sur des points essentiels (découpage des mots) |
La norme POSIX (Portable Operating System Interface, publiée par l'IEEE et l'Open Group) définit un langage de shell commun, le Shell Command Language. Un script qui s'y limite tourne avec n'importe quel sh conforme. Tout ce que Bash ajoute à cette norme s'appelle, dans le jargon, un bashisme (bashism) : une construction qui marche dans Bash et casse ailleurs.
Pourquoi /bin/sh n'est pas Bash sur Debian et Ubuntu
Ubuntu a fait de Dash son /bin/sh en 2006 (version 6.10), Debian l'a suivie avec sa version 6.0 (Squeeze), en 2011. La page DashAsBinSh du wiki d'Ubuntu en donne la raison principale : l'efficacité. Le démarrage de la machine lançait à l'époque des milliers de petits scripts sh, et Dash, beaucoup plus léger que Bash, les exécutait nettement plus vite. Le shell de connexion des comptes, lui, est resté Bash.
La conséquence est une règle écrite dans la politique de Debian (Debian Policy, section 10.4) : un script qui déclare /bin/sh ne doit utiliser que les fonctionnalités POSIX, plus une courte liste d'ajouts tolérés (echo -n, local, test -a et -o, les noms de signaux de kill et de trap) ; s'il a besoin d'autre chose, il doit nommer explicitement son shell sur sa première ligne, par exemple #!/bin/bash. Et, ajoute la politique, dans le doute, utilisez /bin/bash.
Sur Red Hat, AlmaLinux ou Rocky Linux, au contraire, /bin/sh est Bash lui-même. Lancé sous ce nom, Bash passe en mode POSIX, mais ce mode ne désactive pas ses extensions : [[ ]] et les tableaux y fonctionnent toujours. Un script plein de bashismes mais lancé par sh marche donc sur RHEL, et casse sur Debian. C'est l'origine d'innombrables « ça marchait sur l'autre serveur ».
La ligne shebang
La première ligne d'un script peut désigner son interpréteur :
#!/bin/bashLes deux caractères #! se lisent shebang (contraction de sharp, #, et bang, !), et l'on appelle ligne shebang la ligne entière. Ce n'est pas le shell qui la lit, mais le noyau : quand on lui demande d'exécuter un fichier dont les deux premiers octets sont #!, il lance à la place le programme nommé sur cette ligne, en lui passant le chemin du script. Pour le shell, en revanche, une ligne qui commence par # est un commentaire : la ligne shebang est ignorée une fois l'interpréteur lancé.
Trois propriétés à retenir, tirées de la page execve(2) et du code du noyau :
- Elle doit être la toute première ligne, et commencer au tout premier octet. Une ligne vide, un commentaire de licence ou un caractère invisible avant
#!la transforme en simple commentaire. - Le chemin de l'interpréteur doit être absolu. Le noyau ne cherche pas dans
PATH:#!bashdésigne un fichierbashdans le répertoire courant de l'appelant, et échoue presque partout (ShellCheck le signale par SC2239). - Un seul argument optionnel. Sous Linux, tout ce qui suit le chemin de l'interpréteur est passé comme un seul argument, espaces compris.
#!/bin/bash -e -une transmet pas deux options mais la chaîne-e -u.
Deux formes coexistent en pratique :
| Forme | Avantage | Inconvénient |
|---|---|---|
#!/bin/bash | Chemin fixe : on sait exactement quel binaire s'exécute, quel que soit le PATH | Ne convient pas aux systèmes où Bash est ailleurs (macOS avec un Bash récent installé par Homebrew, FreeBSD, NixOS) |
#!/usr/bin/env bash | env cherche bash dans le PATH : prend le Bash « de l'utilisateur », où qu'il soit | Le résultat dépend du PATH de l'appelant ; on ne peut plus passer d'option à Bash (sauf env -S) |
Le guide de style de Google impose #!/bin/bash ; le guide de Greg Wooledge (BashGuide) recommande #!/usr/bin/env bash. Les deux positions se défendent. Pour signalements-outils, l'équipe retient #!/usr/bin/env bash : les scripts sont développés et testés sur des postes, dont des Mac qui ont un Bash 5 hors de /bin, avant d'être déployés sur les serveurs. Sur sig-outils, env trouvera /usr/bin/bash dans tous les PATH réalistes ; la section Sécurité revient sur le cas où ce ne serait pas vrai.
Note
Depuis la fusion de /usr (merged /usr), Debian 13 et Ubuntu 24.04 font de /bin un lien vers /usr/bin : /bin/bash et /usr/bin/bash sont le même fichier. ls -ld /bin affiche /bin -> usr/bin.
Les quatre façons de lancer un script
C'est la notion la plus importante de la leçon. Un même fichier peut être lancé de quatre façons, et ce n'est pas le même interpréteur qui le lit :
| Commande | Qui lit le script | Ligne #! | Droit x nécessaire | Nouveau processus |
|---|---|---|---|---|
./publier-export (ou par PATH) | l'interpréteur de la ligne #! | utilisée | oui | oui |
bash publier-export | Bash | ignorée (commentaire) | non, lecture seule | oui |
sh publier-export | /bin/sh, donc Dash sur Debian et Ubuntu | ignorée | non | oui |
source publier-export (ou . publier-export) | le shell courant | ignorée | non | non |
./script: seule forme où le fichier décide lui-même de son interpréteur. C'est celle de cron quand la ligne cite le chemin du script, de systemd (ExecStart=), desudo, d'un pipeline de CI qui appellebin/publier-export.bash scriptetsh script: vous choisissez l'interpréteur, et la ligne#!n'a aucun effet. Lancer parshun script écrit pour Bash le fait lire par Dash.source script: le script s'exécute dans le shell courant, comme si vous tapiez ses lignes. La leçon 7 de Premiers pas l'a montré : sescdet ses variables restent après lui, et unexitferme votre shell. On réservesourceaux fichiers conçus pour être chargés : bibliothèques de fonctions (leçon 6), fichiers d'environnement.
Quand écrire un script Bash, et quand ne pas le faire
Bash est un excellent langage de colle : il lance des programmes, branche leurs entrées et leurs sorties, réagit à leurs codes de sortie. Il est mauvais dès qu'il faut manipuler des données : il ne connaît que des chaînes et des entiers, pas de nombres décimaux, pas de structures imbriquées, et chaque erreur de guillemets est un bogue silencieux. Le guide de style de Google en tire une règle volontairement brutale : au-delà d'une centaine de lignes, ou dès que la logique de contrôle n'est plus simple, réécrivez dans un langage plus structuré, maintenant.
| La tâche... | Bash convient | Préférez autre chose |
|---|---|---|
enchaîne des commandes existantes (gzip, aws, rsync, systemctl) | oui, c'est son domaine | |
| sert de point d'entrée d'un conteneur, d'étape de CI, d'enveloppe autour d'un outil | oui | |
| lit et produit du JSON imbriqué, appelle une API HTTP avec authentification | quelques champs avec jq | Python (bibliothèque standard), Go |
| calcule sur des décimaux, des dates complexes, des fuseaux | non | Python |
| traite des millions de lignes de texte | avec awk, sort, grep en tube, pas en boucle Bash | awk, Python |
| configure l'état d'un parc de machines | non : un script n'est pas idempotent par défaut | Ansible (cours Ansible : les fondamentaux) |
| doit tourner à heure fixe, être relancé, limité en ressources | le script fait le travail, pas la planification | un minuteur systemd (leçon 10 du cours d'administration) |
| reçoit des entrées non fiables (formulaire web, fichier déposé par un tiers) | à éviter | un langage où les données ne deviennent jamais du code par accident |
| doit tourner sous Windows | non | PowerShell, Python |
Il y a aussi une troisième réponse, souvent la meilleure : ne rien écrire. Avant de coder une rotation de fichiers, une sauvegarde ou une relance automatique, vérifiez que logrotate, restic ou une option de systemd (Restart=, RuntimeMaxSec=) ne le font pas déjà, testés par des milliers d'utilisateurs.
Pour Signalements, le partage est net : l'export lui-même, qui interroge la base et écrit un CSV, est en Python ; publier-export, qui compresse, calcule une empreinte et appelle aws, est de la colle, donc du Bash. rapport-journaux, qui compte des codes HTTP, est à la frontière : les leçons 5 et 7 montreront jusqu'où Bash tient, et ce que awk fait mieux.
En pratique
Les essais se font sur une machine où vous avez un compte ordinaire (Bash 5.2, shellcheck installé par sudo apt install shellcheck), dans un répertoire de travail, par exemple ~/signalements-outils. Les messages en anglais sont ceux d'une locale C.UTF-8 ; les numéros de processus varient d'une machine à l'autre.
Examiner le script de Camille
Avant de modifier quoi que ce soit, on regarde ce que l'on a. Voici /opt/signalements/bin/publier-export.sh :
# Export mairie (Camille, mars 2024)
cd /srv/donnees/exports
F=signalements-`date +%F`.csv
gzip -kf $F
sha256sum $F.gz > $F.gz.sha256
aws s3 cp $F.gz s3://sig-exports-mairie/`date +%Y/%m`/ --endpoint-url https://s3.fr-par.scw.cloud
aws s3 cp $F.gz.sha256 s3://sig-exports-mairie/`date +%Y/%m`/ --endpoint-url https://s3.fr-par.scw.cloud
if [[ $? == 0 ]]; then
echo "export publié"
else
echo "ECHEC du dépôt" | mail -s "export mairie" camille
fiTrois commandes donnent l'essentiel sans rien exécuter :
$ ls -l /opt/signalements/bin/publier-export.sh
-rw-r--r-- 1 camille camille 452 Mar 12 2024 /opt/signalements/bin/publier-export.sh
$ file /opt/signalements/bin/publier-export.sh
/opt/signalements/bin/publier-export.sh: Unicode text, UTF-8 text
$ head -n 1 /opt/signalements/bin/publier-export.sh | cat -A
# Export mairie (Camille, mars 2024)$
- Pas de droit d'exécution, pas de ligne
#!: le script ne peut être lancé que parsh fichieroubash fichier, et c'est l'appelant qui choisit l'interpréteur. La ligne cron a choisish. - Propriétaire
camille: le compte d'une personne partie possède un fichier exécuté chaque nuit. Nous y reviendrons dans la section Sécurité. fileditUnicode text, UTF-8 text(les accents de « publié » et « dépôt ») et pasBourne-Again shell script: sans ligne#!, rien n'indique le langage. Avec une ligne#!/usr/bin/env bashet le droit d'exécution,filerépondBourne-Again shell script, Unicode text, UTF-8 text executable.cat -Aconfirme qu'il n'y a pas de retour chariot Windows en fin de ligne ($seul, pas^M$: voir la leçon 5 de Premiers pas).
Reproduire le défaut caché
Pour comprendre ce que cron vit chaque matin, on isole la seule construction douteuse, le [[ ]], dans un petit script d'essai quel-shell :
#!/usr/bin/env bash
echo "\$0=$0 PID=$$ parent=$PPID"
if [[ -n ${BASH_VERSION-} ]]; then
echo "lu par bash $BASH_VERSION"
else
echo "lu par un autre shell"
fi$0 contient le nom sous lequel le script a été lancé, $$ le numéro du processus qui l'exécute, $PPID celui de son parent. BASH_VERSION n'est définie que dans Bash. Lançons-le des quatre façons :
$ chmod 755 quel-shell
$ echo "shell courant : PID=$$"
shell courant : PID=4127
$ ./quel-shell
$0=./quel-shell PID=4180 parent=4127
lu par bash 5.2.21(1)-release
$ bash quel-shell
$0=quel-shell PID=4183 parent=4127
lu par bash 5.2.21(1)-release
$ sh quel-shell
$0=quel-shell PID=4187 parent=4127
quel-shell: 3: [[: not found
lu par un autre shell
$ echo $?
0
$ . ./quel-shell
$0=-bash PID=4127 parent=4126
lu par bash 5.2.21(1)-release
Tout le tableau des concepts est là :
./quel-shelletbash quel-shellcréent un nouveau processus (PID différent, dont le parent est votre shell) et sont lus par Bash.sh quel-shellest lu par Dash, qui ne connaît pas[[: il le cherche comme une commande, ne la trouve pas ([[: not found, le3est le numéro de ligne dans le format de Dash), considère la condition comme fausse, et continue. Le script se termine avec le code 0. Aucune alarme.. ./quel-shells'exécute dans votre shell : même PID, et$0est le nom de votre shell. Le tiret de-bashindique un shell de connexion, ouvert par SSH.
Transposé au script de Camille sous cron : Dash exécute les commandes ordinaires sans problème (cd, gzip, aws ne sont pas des bashismes), le dépôt réussit, puis le [[ $? == 0 ]] échoue comme commande introuvable. La condition est donc toujours fausse : chaque matin, le script part dans la branche else et tente d'envoyer « ECHEC du dépôt », même quand tout s'est bien passé. Il n'y a pas d'outil mail sur sig-outils, donc cette commande échoue à son tour, et la redirection > /dev/null 2>&1 de la ligne cron efface tous ces messages. Le jour où un dépôt échouera vraiment, rien de plus ne se produira. La vérification n'a jamais existé.
Warning
Un bashisme sous Dash ne fait pas toujours échouer le script. Selon la construction, Dash s'arrête sur une erreur de syntaxe (tableaux, function), ou exécute autre chose que prévu sans erreur visible ([[ traité comme une commande, echo -e qui affiche -e, {1..3} laissé tel quel). Le second cas est le plus dangereux.
Ce qui casse sous Dash
Pour savoir si un fragment est un bashisme, on le soumet à Dash directement. Quelques constructions courantes, essayées avec dash -c '...' sur Ubuntu 24.04 :
| Fragment | Sous Dash 0.5.12 | Équivalent POSIX |
|---|---|---|
[[ $a == b ]] | [[: not found | [ "$a" = b ] |
[ a == a ] | [: a: unexpected operator | [ a = a ] |
a=(1 2) | Syntax error: "(" unexpected | pas de tableau en POSIX : set -- 1 2 |
function f { ...; } | Syntax error: "}" unexpected | f() { ...; } |
echo {1..3} | affiche {1..3} | seq 1 3 |
echo -e "a\tb" | affiche -e a puis une tabulation et b | printf 'a\tb\n' |
source fichier | source: not found | . fichier |
${v//b/X} | Bad substitution | sed, ou une boucle |
cat <<< texte | Syntax error: redirection unexpected | printf '%s\n' texte | cat |
set -o pipefail | set: Illegal option -o pipefail | aucun dans Dash 0.5.12 |
echo $RANDOM | ligne vide | od -An -N2 -tu2 /dev/urandom |
Le dernier exemple de pipefail mérite une remarque : la norme POSIX de 2024 a intégré pipefail, mais Dash 0.5.12, celui de Debian 13 comme d'Ubuntu 24.04, ne l'a pas encore. Ce cours écrit du Bash, assumé comme tel : tableaux, [[ ]], pipefail sont trop utiles pour s'en priver sur des serveurs où Bash est toujours installé. La seule exigence est que le script soit toujours lancé par Bash, ce que garantit la ligne #! à condition de lancer le script par son chemin.
Écrire le shebang et donner le droit d'exécution
Dans le dépôt signalements-outils, le script perd son extension (on lance une commande, pas un fichier .sh : c'est aussi la règle de la politique de Debian pour les programmes installés dans le PATH) et gagne sa première ligne :
$ mkdir -p ~/signalements-outils/bin && cd ~/signalements-outils
$ cp /opt/signalements/bin/publier-export.sh bin/publier-export
$ sed -i '1i #!/usr/bin/env bash' bin/publier-export
$ chmod 755 bin/publier-export
$ head -n 2 bin/publier-export
#!/usr/bin/env bash
# Export mairie (Camille, mars 2024)
$ ls -l bin/publier-export
-rwxr-xr-x 1 vous vous 472 Oct 8 09:12 bin/publier-export
sed -i '1i texte'insère une ligne avant la première (commandeide GNU sed), sur place (-i).chmod 755: lecture, écriture et exécution pour le propriétaire, lecture et exécution pour les autres. Un script n'a pas besoin d'être secret ; il a besoin de n'être modifiable que par qui doit le modifier (leçon 9 de Premiers pas).chmod +xajouterait l'exécution sans toucher au reste, mais755dit explicitement l'état voulu.
Il faut le droit de lecture en plus de l'exécution : l'interpréteur ouvre le fichier pour le lire. Un script en 711 est exécutable par le noyau, mais Bash, lancé sous votre identité, ne pourra pas l'ouvrir.
Trois erreurs de lancement, trois causes
Les messages de lancement sont courts, et se ressemblent assez pour induire en erreur. Les voici, produits par un Bash 5.2 interactif :
$ ./publier-export
bash: ./publier-export: Permission denied
$ echo $?
126
Code 126 : le fichier existe mais ne peut pas être exécuté. Deux causes possibles : il manque le droit x (vérifiez avec ls -l), ou le fichier est sur un système de fichiers monté avec l'option noexec. Ce second cas surprend : sur sig-outils, un collègue avait copié le script dans /srv/donnees/outils/ pour l'essayer, lui avait donné le droit x, et obtenait toujours ce message. /srv/donnees est monté nodev,nosuid,noexec (la leçon 6 du cours d'administration l'a décidé ainsi) : le noyau refuse toute exécution directe de fichier sur ce volume, et la page execve(2) range ce cas parmi les erreurs EACCES, la même que pour un droit manquant. findmnt -T /srv/donnees/outils -o TARGET,OPTIONS lève le doute.
$ ./publier-export
bash: ./publier-export: cannot execute: required file not found
$ echo $?
127
Code 127 et required file not found (c'est la formulation de Bash 5.2 ; les versions antérieures disaient bad interpreter: No such file or directory) : le script existe, c'est son interpréteur qui est introuvable. Le noyau a lu la ligne #!, n'a pas trouvé le programme désigné, et a renvoyé ENOENT. Causes typiques : un chemin faux (#!/usr/local/bin/bash sur une machine où Bash est dans /usr/bin), une image de conteneur Alpine sans Bash, ou un fichier enregistré sous Windows dont la première ligne se termine par un retour chariot invisible, ce qui fait chercher un programme nommé bash\r. head -n 1 fichier | cat -A montre #!/usr/bin/env bash^M$ dans ce dernier cas.
$ ./publier-export
/usr/bin/env: ‘bash -e’: No such file or directory
/usr/bin/env: use -[v]S to pass options in shebang lines
$ echo $?
127
Quelqu'un a écrit #!/usr/bin/env bash -e pour activer une option. Le noyau a passé bash -e comme un seul argument à env, qui a cherché un programme nommé littéralement bash -e. Le coreutils de Debian et d'Ubuntu détecte ce cas et suggère -S, l'option qui demande à env de découper lui-même la chaîne : #!/usr/bin/env -S bash -e fonctionne. Mais la bonne correction est ailleurs : les options d'un script se règlent dans le script, par set (leçon 9), pour qu'elles s'appliquent aussi quand on le lance par bash publier-export. Le guide de style de Google le demande explicitement.
L'en-tête et le squelette
Un script de production sera lu bien plus souvent qu'il ne sera écrit, la plupart du temps par quelqu'un qui cherche, sous pression, pourquoi il a échoué. Ses premières lignes doivent répondre aux questions de cette personne : à quoi sert-il, qui le lance, de quoi dépend-il, où est sa source. Voici bin/publier-export à la fin de cette leçon :
#!/usr/bin/env bash
#
# publier-export : dépose l'export quotidien de Signalements pour la mairie
# dans le bucket sig-exports-mairie (Object Storage Scaleway, région fr-par).
#
# Usage : publier-export
# Lancé par : le minuteur signalements-publication.timer sur sig-outils, compte
# signalements, après l'export Python de 5 h (signalements-export.timer).
# Dépend de : bash 5, gzip, sha256sum, aws (CLI v2, profil dans ~/.aws du compte).
# Source : dépôt Git signalements-outils. Ne pas modifier sur le serveur.
# Réglages
repertoire_exports=/srv/donnees/exports
bucket=s3://sig-exports-mairie
point_acces=https://s3.fr-par.scw.cloud
# Le fichier du jour et sa destination
jour=$(date +%F)
fichier="signalements-$jour.csv"
destination="$bucket/$(date +%Y/%m)/"
cd "$repertoire_exports" || exit 1
gzip --keep --force -- "$fichier" || exit 1
sha256sum -- "$fichier.gz" > "$fichier.gz.sha256" || exit 1
aws s3 cp "$fichier.gz" "$destination" --endpoint-url "$point_acces" || exit 1
aws s3 cp "$fichier.gz.sha256" "$destination" --endpoint-url "$point_acces" || exit 1
echo "export publié : $destination$fichier.gz"Ce n'est pas encore le script final, loin de là : il ne vérifie pas que le CSV est valide (leçon 4), ne prend aucune option (leçon 8), ne gère pas les erreurs de façon systématique (leçon 9) et laisse des fichiers à moitié écrits si on l'interrompt (leçon 10). Mais il corrige déjà ce qui tient à cette leçon :
- la ligne
#!en première ligne, sans rien avant ; - l'en-tête : rôle, usage, déclencheur, dépendances, source de vérité. « Ne pas modifier sur le serveur » est une consigne pour la prochaine personne qui ouvrira le fichier avec
vimun soir d'incident ; - les réglages en tête, nommés, au lieu de chemins répétés dans le corps ;
$(...)au lieu des accents graves, plus lisible et imbriquable (leçon 6 de Premiers pas) ;- des guillemets autour de chaque variable : la leçon 2 explique pourquoi c'est vital, et le
--qui marque la fin des options ; || exit 1après chaque étape qui compte : sicdéchoue, on ne lance pasgzipdans un répertoire inconnu ; si le premierawséchoue, on ne dépose pas l'empreinte seule, et surtout le script se termine avec un code non nul, que le minuteur systemd verra. C'est une forme rudimentaire de ce que la leçon 9 systématisera avecset -euo pipefail;- le test
[[ $? == 0 ]]a disparu : il était inutile, puisque chaque commande est vérifiée au moment où elle s'exécute. ShellCheck le signale d'ailleurs (SC2181, plus bas).
Les noms de variables sont en minuscules et en français, cohérents avec le reste du projet. Les variables en MAJUSCULES sont, par convention, les variables d'environnement (PATH, HOME) et les variables propres au shell (BASH_VERSION) : en les évitant pour vos propres variables, vous ne risquez pas d'écraser l'une d'elles par accident. Écrire PATH=/srv/donnees/exports au lieu de repertoire=... rendrait toutes les commandes suivantes introuvables.
Vérifier la syntaxe sans exécuter : bash -n
bash -n lit le script et en vérifie la syntaxe sans rien exécuter (option noexec de Bash, à ne pas confondre avec l'option de montage). Oublions un fi et un guillemet dans deux fichiers d'essai :
$ printf 'if true; then\n echo a\n' > essai-fi
$ bash -n essai-fi
essai-fi: line 3: syntax error: unexpected end of file
$ echo $?
2
$ printf 'echo "a\n' > essai-guillemet
$ bash -n essai-guillemet
essai-guillemet: line 1: unexpected EOF while looking for matching `"'
$ bash -n bin/publier-export && echo "syntaxe correcte"
syntaxe correcte
Bash signale l'erreur là où il s'en aperçoit, c'est-à-dire souvent à la fin du fichier, pas là où se trouve l'oubli : unexpected end of file veut dire « il manque une fermeture quelque part ». bash -n ne voit ni les commandes introuvables, ni les variables mal orthographiées, ni les fautes de logique : il dit seulement que le texte est du Bash grammaticalement correct. C'est le minimum avant de déployer, et un filet utile dans un crochet Git ou une CI, mais il faut un outil plus fin.
Analyser avec ShellCheck
ShellCheck est un outil d'analyse statique pour les scripts shell : il lit le code sans l'exécuter et signale les constructions dangereuses ou incorrectes, chacune avec un code SCxxxx documenté sur son wiki. C'est le meilleur investissement de ce cours ; la leçon 12 l'installe dans la CI. Pour l'instant, on l'applique à l'original de Camille :
$ shellcheck /opt/signalements/bin/publier-export.sh
Le format de sortie est le suivant, une ligne de code citée puis une flèche sous l'endroit visé, le code, la sévérité et le message :
In /opt/signalements/bin/publier-export.sh line 1:
# Export mairie (Camille, mars 2024)
^-- SC2148 (error): Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.Sans ligne #!, ShellCheck ne sait pas s'il doit juger du Bash ou du POSIX : c'est une erreur. Il poursuit pourtant l'analyse en supposant du Bash (c'est le repli prévu par son code pour un fichier en .sh), et c'est là le piège : jugé comme du Bash, le [[ ]] est parfaitement valide, et le défaut caché n'apparaît pas. Il faut lui dire quel shell lit vraiment le script. Analysons-le comme le lit cron, c'est-à-dire avec Dash, grâce à l'option -s (shell) :
$ shellcheck -s dash /opt/signalements/bin/publier-export.sh
Les constats, résumés (chaque code a sa page sur https://www.shellcheck.net/wiki/SCxxxx) :
| Ligne | Code | Sévérité | Message | Ce que cela veut dire ici |
|---|---|---|---|---|
| 2 | SC2164 | warning | Use 'cd ... || exit' or 'cd ... || return' in case cd fails. | si le répertoire manque, la suite s'exécute ailleurs |
| 3, 6, 7 | SC2006 | style | Use $(...) notation instead of legacy backticks `...`. | syntaxe ancienne |
| 4 à 7 | SC2086 | info | Double quote to prevent globbing and word splitting. | $F non protégé (leçon 2) |
| 8 | SC3010 | error | In dash, [[ ]] is not supported. | le défaut caché |
| 8 | SC2181 | style | Check exit code directly with e.g. 'if mycmd;', not indirectly with $?. | $? testé de loin |
Avec -s sh au lieu de -s dash, SC3010 devient un avertissement formulé In POSIX sh, [[ ]] is undefined. : ShellCheck distingue ce que la norme ne garantit pas de ce que Dash refuse vraiment. Notre nouvelle version déclare Bash, protège ses variables, vérifie son cd et teste chaque commande directement : elle ne contient plus aucune des constructions que vise le tableau. ShellCheck renvoie alors le code 0 et n'affiche rien, ce qui s'utilise tel quel dans une CI :
$ shellcheck bin/publier-export && echo "aucun constat"
Une minute de ShellCheck aurait évité deux ans de vérification fantôme.
Versionner le script
Le dépôt signalements-outils est la seule source de vérité : on ne modifie plus jamais un script directement sur un serveur. Deux réglages évitent les mauvaises surprises :
$ git init -q && git add bin/publier-export
$ git ls-files --stage bin/publier-export
100755 18c0d6cecaa47f915ac86562536c9bea3b806bf9 0 bin/publier-export
Git ne retient des permissions que le bit d'exécution : le mode 100755 le montre. Si un collègue ajoute un script sans chmod, ou depuis un système qui ne connaît pas ce bit, git update-index --chmod=+x bin/purger-pieces-jointes le positionne dans l'index. (L'empreinte est celle du contenu exact du fichier, ici la version complète de la section précédente : elle change à chaque modification.)
Ensuite, un fichier .gitattributes à la racine interdit à Git de convertir les fins de ligne des scripts sur les postes Windows :
# Les scripts restent en fins de ligne Unix, quel que soit le poste
bin/** text eol=lf
lib/** text eol=lf
*.bats text eol=lfSans lui, un réglage core.autocrlf=true sur un poste Windows peut produire des fichiers en CRLF, et l'erreur required file not found vue plus haut.
Installer le script
Où le mettre sur le serveur ? La norme de hiérarchie des fichiers (FHS) offre deux réponses :
/usr/local/bin: programmes installés localement par l'administrateur, hors du gestionnaire de paquets. Il est dans lePATHde tous les comptes et desudo(secure_path)./opt/<application>/bin: logiciel additionnel livré comme un tout. C'est le choix historique de Signalements, cohérent avec/opt/signalementsqui contient déjà l'application.
L'équipe garde /opt/signalements/bin, et y installe les scripts depuis le dépôt avec install plutôt qu'avec cp :
$ sudo install -o root -g root -m 0755 bin/publier-export /opt/signalements/bin/publier-export
install copie, fixe propriétaire, groupe et droits en une seule commande, et surtout remplace le fichier au lieu de le réécrire : la section suivante montre pourquoi c'est important. Le script appartient à root et n'est modifiable que par lui, même s'il est exécuté par le compte signalements. Une fois le minuteur systemd en place (comme pour l'export dans la leçon 10 du cours d'administration, avec ExecStart=/opt/signalements/bin/publier-export), la ligne de /etc/cron.d/signalements-mairie est supprimée. Plus tard, ce déploiement manuel sera remplacé par un paquet ou par Ansible.
Sous le capot
Ce que le noyau fait de la ligne #!
Quand un programme (votre shell, cron, systemd) veut lancer /opt/signalements/bin/publier-export, il appelle execve(2) avec ce chemin et la liste des arguments. Le noyau lit les premiers octets du fichier et essaie, l'un après l'autre, les formats d'exécutables qu'il connaît : ELF pour les programmes compilés, et binfmt_script pour les scripts. Le code de ce dernier, dans fs/binfmt_script.c, tient en une centaine de lignes et se lit très bien :
- Si les deux premiers octets ne sont pas
#et!, ce n'est pas un script :-ENOEXEC, « format non reconnu ». - Le noyau cherche la fin de la ligne dans un tampon de taille fixe : 256 octets depuis Linux 5.1 (128 auparavant). Il saute les espaces et tabulations après
#!, puis lit le chemin de l'interpréteur jusqu'au prochain blanc. Si la ligne dépasse le tampon et que le chemin lui-même risque d'être tronqué, il refuse (-ENOEXEC) plutôt que d'exécuter un chemin coupé ; un argument tronqué, lui, est accepté. - Tout ce qui suit le premier blanc, jusqu'à la fin de la ligne, devient un seul argument optionnel.
- Il réécrit la liste des arguments :
argv[0]devient le chemin de l'interpréteur, puis vient l'argument optionnel s'il existe, puis le chemin du script tel qu'il a été demandé, puis les arguments d'origine (sauf l'ancienargv[0]). - Il recommence l'exécution avec l'interpréteur. Celui-ci peut être lui-même un script, jusqu'à quatre niveaux d'imbrication.
On peut voir le résultat de la réécriture : Linux expose la ligne de commande de chaque processus dans /proc/<pid>/cmdline, arguments séparés par des octets nuls. Un script d'essai voir-argv :
#!/bin/bash -u
tr '\0' '|' < /proc/$$/cmdline; echo$ ./voir-argv un 'deux mots'
/bin/bash|-u|./voir-argv|un|deux mots|
Le noyau a bien lancé /bin/bash avec l'argument optionnel -u, puis le chemin du script, puis vos deux arguments, intacts. Avec #!/bin/bash -e -u, l'argument optionnel serait la chaîne -e -u, que Bash essaie de lire comme une suite d'options collées, et rejette :
$ ./deux-options
/bin/bash: - : invalid option
C'est pour cela que ShellCheck signale (SC2096) les lignes #! à plusieurs paramètres : Linux, comme la plupart des systèmes, n'en transmet qu'un. macOS fait exception et découpe la ligne : un script qui fonctionne sur un Mac peut échouer sur le serveur.
Avec #!/usr/bin/env bash, l'interpréteur est env, et son argument bash. C'est ensuite env qui cherche bash dans le PATH et l'exécute à son tour. Une trace des appels système le montre, avec un PATH volontairement court :
$ env -i PATH=/usr/local/bin:/usr/bin:/bin strace -f -e trace=execve ./quel-shell > /dev/null
execve("./quel-shell", ["./quel-shell"], 0x7ffd... /* 1 var */) = 0
execve("/usr/local/bin/bash", ["bash", "./quel-shell"], 0x7ffd... /* 1 var */) = -1 ENOENT (No such file or directory)
execve("/usr/bin/bash", ["bash", "./quel-shell"], 0x7ffd... /* 1 var */) = 0
(Les numéros de processus et la suite de la trace sont omis.) Le premier execve est le vôtre, et il réussit : le noyau a remplacé le script par env, ce que strace n'affiche pas comme un appel séparé. Puis env essaie chaque répertoire du PATH dans l'ordre jusqu'à trouver bash. Le PATH de l'appelant décide donc quel Bash s'exécute.
Le repli du shell quand il n'y a pas de #!
Que se passe-t-il quand on exécute un fichier texte sans ligne #!, comme le script de Camille s'il avait eu le droit x ? Le noyau répond ENOEXEC. La suite dépend entièrement de qui a demandé l'exécution.
La norme POSIX (section 2.9.1.6) impose aux shells un repli : si l'exécution échoue avec ENOEXEC, le shell doit exécuter le fichier comme un script shell, en lançant un nouveau shell sur ce fichier. Il peut s'en dispenser s'il constate que le fichier ne peut pas être un script (la norme cite l'exemple d'un octet nul avant le premier saut de ligne, signe d'un binaire), et l'échec vaut alors le code 126. La norme précise aussi, dès sa section 2.1, que le comportement d'un fichier dont la première ligne commence par #! est non spécifié : POSIX ne normalise pas le shebang, c'est une convention des noyaux.
Et chaque shell se replie sur lui-même. Faites l'essai avec un fichier sans-shebang exécutable contenant [[ 1 == 1 ]] && echo double-crochets-ok :
$ ./sans-shebang
double-crochets-ok
$ dash -c ./sans-shebang
./sans-shebang: 1: [[: not found
$ env ./sans-shebang
./sans-shebang: 1: [[: not found
$ python3 -c 'import subprocess; subprocess.run(["./sans-shebang"])'
...
OSError: [Errno 8] Exec format error: './sans-shebang'
- Lancé depuis Bash, le fichier est lu par Bash : le manuel décrit ce cas, Bash crée une nouvelle instance de lui-même qui se réinitialise comme pour un nouveau script.
- Lancé depuis Dash, il est lu par Dash. Or cron exécute chaque ligne de crontab avec
/bin/sh -c, donc Dash : même un script sans#!lancé par cron sans le préfixeshserait lu par Dash. - Lancé par
env, il est lu par/bin/sh: la fonctionexecvpde la bibliothèque C, qu'utiliseenv, applique le même repli avec/bin/sh. - Lancé par un programme qui appelle
execvedirectement, sans repli, c'est un échec : Python lèveExec format error, et systemd aussi. Un service dontExecStart=pointe vers un script sans#!écrit dans le journalFailed to execute /opt/...: Exec format error, et se termine avec le statut203/EXEC, le code que systemd réserve aux échecs de l'appelexecvelui-même.
Le même fichier est donc lu par Bash, par Dash, ou refusé, selon le programme qui le lance. La seule façon d'obtenir un comportement unique est la ligne #!.
Comment Bash lit un script
Bash ne charge pas le script en mémoire avant de l'exécuter : il le lit au fur et à mesure, commande par commande. Une trace des appels de lecture (version abrégée, sur un script de quatre lignes qui fait un echo, un sleep 2 et un autre echo) montre la mécanique :
dup2(3, 255) = 255
fcntl(255, F_SETFD, FD_CLOEXEC) = 0
read(255, "#!/bin/bash\necho \"verifier-sante"..., 87) = 87
lseek(255, -32, SEEK_CUR) = 55
read(255, "echo \"verifier-sante 1.0 : fin\"\n", 87) = 32
read(255, "", 87) = 0Bash ouvre le script, déplace son descripteur sur le numéro 255 (pour laisser libres les petits numéros que le script pourrait utiliser dans ses redirections), et le marque close-on-exec pour que les commandes lancées n'en héritent pas. Il lit un bloc, analyse la première commande, puis, avant de lancer une commande externe (ici sleep), il recule la position de lecture (lseek) juste après la commande qu'il vient d'analyser : position 55. Après le sleep, il relit le fichier à partir de cette position.
Conséquence directe : si le fichier change pendant que le script tourne, Bash lit la suite dans le nouveau contenu, à l'ancienne position en octets. Une démonstration avec une version 1.0 de verifier-sante en cours d'exécution, et une version 1.1 copiée par-dessus avec cp pendant son sleep :
$ bin/verifier-sante & sleep 1; cp v1.1/verifier-sante bin/verifier-sante; wait
verifier-sante 1.0 : début
bin/verifier-sante: line 4: les: command not found
verifier-sante 1.1 : début
verifier-sante 1.1 : fin
(Les messages de suivi des tâches sont omis.) La version 1.1 commence par un commentaire, # verifier-sante 1.1 : interroge /sante sur les deux machines. La position 55 tombait au milieu de ce commentaire : Bash a lu les deux machines comme une commande, puis exécuté toute la nouvelle version. Ici, rien de grave. Avec une ligne rm -rf "$repertoire_temp/"* coupée au mauvais endroit, le résultat aurait pu l'être.
cp sur un fichier existant le réécrit en place : même inode, contenu tronqué puis remplacé, et Bash, qui a toujours le fichier ouvert, voit le changement. install, lui, supprime d'abord la destination puis crée un nouveau fichier (le code de GNU coreutils active unlink_dest_before_opening), comme mv d'un fichier temporaire : l'ancien inode reste intact tant que Bash le garde ouvert, et l'exécution en cours se termine avec l'ancienne version. Refaite avec install -m 0755, la même démonstration affiche les deux lignes de la version 1.0, sans rien d'autre. C'est aussi la raison pour laquelle on n'édite jamais un script en production avec un éditeur qui écrit en place pendant qu'une exécution tourne.
Pièges courants
Le script testé avec bash, lancé avec sh. Le cas de Camille. Ligne cron sh script, CMD ["sh", "script"] dans un Dockerfile, sh -c dans un outil : la ligne #! est ignorée. Lancez par le chemin (/opt/signalements/bin/publier-export), et cherchez les sh devant vos scripts lors d'un état des lieux : grep -rn 'sh /opt' /etc/cron* /etc/systemd.
Un bashisme qui ne fait pas d'erreur. [[ sous Dash, echo -e, {1..10} : le script continue avec un résultat faux et un code 0. shellcheck -s dash les trouve, l'outil checkbashisms du paquet devscripts aussi.
La ligne #! qui n'est pas la première. Une ligne vide ou un commentaire de licence au-dessus, et la ligne n'est plus qu'un commentaire (ShellCheck : SC1128, The shebang must be on the first line). Variante invisible : un éditeur Windows qui enregistre en UTF-8 avec BOM place trois octets EF BB BF avant le #!. Le noyau ne reconnaît plus le script ; lancé depuis Bash, le repli le fait lire par Bash, qui affiche ./script: line 1: #!/bin/bash: No such file or directory (le BOM, invisible, précède le #), puis continue ; lancé par env ou cron, il est lu par Dash. head -c 3 script | od -c le révèle.
Les fins de ligne CRLF. cannot execute: required file not found alors que l'interpréteur existe : cat -A montre ^M$. Corrigez le fichier et ajoutez .gitattributes.
#!/usr/bin/env bash -e. Une seule option passe, et pas avec env : set -e dans le corps du script.
source d'un script qui fait exit. Le exit termine votre shell, et ferme votre session SSH. Ne chargez par source que des fichiers prévus pour.
Le script dans un répertoire monté noexec. Permission denied malgré le droit x. Installez les scripts dans /opt ou /usr/local/bin, jamais dans un volume de données.
Un script nommé comme une commande existante. Un script d'essai appelé test ou time est masqué par la commande interne ou le mot-clé de Bash du même nom quand on le lance sans chemin (type test répond test is a shell builtin), et un script install par /usr/bin/install, placé avant votre répertoire dans le PATH. Choisissez des noms explicites (publier-export), et lancez vos essais par ./.
Le chemin mémorisé. Bash retient l'emplacement des commandes déjà trouvées dans le PATH (table de hachage, commande hash). Si vous déplacez un script de /opt/signalements/bin vers /usr/local/bin, un shell déjà ouvert répond bash: /opt/signalements/bin/publier-export: No such file or directory au lieu de chercher ailleurs. hash -r vide la table ; un nouveau shell n'a pas le problème.
Modifier un script pendant qu'il tourne. Voir Sous le capot : install ou mv, jamais cp ni un éditeur sur le fichier en cours d'exécution.
Un script Bash dans une image Alpine. L'image n'a pas de Bash : le lancement échoue avec un message du type exec /usr/local/bin/entrypoint.sh: no such file or directory, qui parle du script alors que c'est l'interpréteur qui manque. Installez bash dans l'image, ou écrivez le point d'entrée en POSIX avec #!/bin/sh et vérifiez-le avec shellcheck -s sh.
Sécurité
- Qui peut modifier le script peut agir sous l'identité qui l'exécute. Le script de Camille appartenait à
camilleet tournait soussignalements: n'importe qui ayant pris le contrôle du comptecamillepouvait y ajouter une ligne exécutée chaque matin avec les droits du compte de service, et donc lire les données personnelles de l'export. Un script exécuté par un service appartient àroot, en0755, dans un répertoire lui-même non modifiable par d'autres (/opt/signalements/binen0755 root:root). Vérifiez toute la chaîne :namei -l /opt/signalements/bin/publier-exportaffiche les droits de chaque répertoire du chemin. - Le bit setuid est ignoré sur les scripts. Linux, comme les autres Unix modernes, ignore les bits set-user-ID et set-group-ID d'un script (page
execve(2)) : la ligne#!ouvrait trop de failles (substitution du fichier entre la lecture par le noyau et la lecture par l'interpréteur, variables d'environnement). Le guide de Google l'interdit de toute façon. Pour donner à un compte le droit de lancer un script enroot, écrivez une règlesudoqui désigne ce script par son chemin absolu, et assurez-vous que ni le script ni ses répertoires ne sont modifiables par ce compte : sinon, la règlesudodonnerootà quiconque peut éditer le fichier. #!/usr/bin/env bashfait confiance auPATH. Si un répertoire modifiable par un autre compte figure dans lePATHavant/usr/bin, y déposer un fauxbashsuffit à faire exécuter n'importe quoi à la place de votre script. Soussudo,secure_pathneutralise ce risque ; sous systemd, lePATHdes services est fixe (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin) ; sous cron, il vaut/usr/bin:/bin. Le risque concerne surtout les scripts lancés enrootdepuis un shell dont lePATHa été enrichi (~/bin,.). Pour un script qui ne tournera jamais que sur des serveurs enroot,#!/bin/bashreste le choix le plus sûr.noexecn'est pas une protection contre les scripts.bash /srv/donnees/outils/xs'exécute sans difficulté : c'est l'interpréteur qui est exécuté, depuis/usr/bin. L'option arrête les maladresses et les attaques peu élaborées, rien de plus.sourceexécute. Charger un fichier parsource, c'est exécuter tout ce qu'il contient avec vos droits. Ne chargez jamais ainsi un fichier modifiable par un autre compte, même s'il « ne contient que des variables » :NOM=valeur; curl ... | shest aussi une affectation suivie d'une commande. La leçon 8 montre comment lire un fichier de configuration sans l'exécuter.- Aucun secret dans un script. Le script est versionné, copié, affiché par
psdans ses arguments, lu par les collègues. Les identifiants restent dans des fichiers à droits restreints (/etc/signalements/enven0640 root:signalements) ou dans un gestionnaire de secrets, et le script les reçoit par l'environnement que lui donne systemd.
En production
- Un dépôt, une revue, une CI. Les scripts d'exploitation sont du code : ils vivent dans Git, passent en revue de code, et une CI lance au minimum
bash -netshellchecksur chaque modification. Chez Lyneko, les workflows du cours GitHub Actions s'en chargent ; la leçon 12 l'écrit. - Un parc, des Bash différents. Debian 13 a Bash 5.2.37, Ubuntu 24.04 Bash 5.2.21, un ancien serveur Ubuntu 20.04 Bash 5.0, macOS Bash 3.2 (ou Zsh), une image Alpine pas de Bash du tout. Fixez une version minimale (ici Bash 5), écrivez-la dans l'en-tête, et vérifiez-la au lancement : le tableau
BASH_VERSINFOcontient la version majeure dans${BASH_VERSINFO[0]}. - Le script ne se planifie pas lui-même. La planification, la relance, les limites de ressources, l'isolation et la notification d'échec relèvent de systemd (leçons 10 et 14 du cours d'administration). Le script fait une chose, la fait bien, et renvoie un code de sortie honnête.
- Le déploiement est atomique.
installou un renommage, jamais une copie en place ; à terme, un paquet Debian ou un rôle Ansible, qui permettent aussi le retour arrière. Le jour d'un incident,dpkg -S /opt/signalements/bin/publier-exportou l'historique Git doivent dire quelle version tourne. - Savoir s'arrêter. Un script qui grossit au-delà de quelques centaines de lignes, qui manipule du JSON en profondeur ou qui doit être testé finement gagne à être réécrit en Python. La leçon 12 donne des critères concrets ; la bonne question n'est pas « est-ce faisable en Bash ? » (tout l'est) mais « la prochaine personne pourra-t-elle le modifier sans casser quelque chose ? ».
Exercices
1. Qui lit le script ? (niveau 100) Un fichier bin/verifier-sante commence par #!/usr/bin/env bash, a les droits 0644 et contient un [[ ]]. Pour chaque lancement, dites quel interpréteur le lit, ou quel message s'affiche : (a) ./bin/verifier-sante ; (b) bash bin/verifier-sante ; (c) sh bin/verifier-sante ; (d) une ligne de crontab */5 * * * * signalements /opt/signalements/bin/verifier-sante après install -m 0755 ; (e) . bin/verifier-sante.
Solution
(a) bash: ./bin/verifier-sante: Permission denied, code 126 : pas de droit d'exécution. (b) Bash : la ligne #! est ignorée, le droit x inutile, la lecture suffit. (c) Dash, qui affiche [[: not found et continue. (d) cron lance /bin/sh -c '/opt/signalements/bin/verifier-sante' ; Dash exécute le chemin, le noyau lit la ligne #! : le script est lu par Bash (trouvé par env dans le PATH de cron, /usr/bin:/bin). (e) Le shell courant, Bash si c'est votre shell de connexion ; un éventuel exit dans le script fermerait votre session.
2. Trois messages (niveau 100). Associez chaque message à sa cause et à sa correction : (a) cannot execute: required file not found, alors que /usr/bin/bash existe ; (b) Permission denied après un chmod 755 dans /srv/donnees/outils/ ; (c) /usr/bin/env: ‘bash -u’: No such file or directory.
Solution
(a) Fins de ligne CRLF : le noyau cherche bash\r. Vérification : head -n 1 script | cat -A montre ^M$. Correction : sed -i 's/\r$//' script (ou dos2unix), puis .gitattributes avec eol=lf pour que cela ne revienne pas. (b) Montage noexec : findmnt -T /srv/donnees/outils -o TARGET,OPTIONS montre noexec. Correction : installer le script dans /opt/signalements/bin ou /usr/local/bin, pas contourner le montage. (c) Ligne #!/usr/bin/env bash -u : le noyau transmet bash -u en un seul argument. Correction : #!/usr/bin/env bash et set -u dans le corps du script.
3. Bash ou pas ? (niveau 100). Pour chacune de ces tâches, proposez Bash, un autre outil, ou aucun script, en une phrase de justification : (a) toutes les nuits, archiver /etc/signalements et l'envoyer dans un bucket ; (b) interroger l'API de la mairie, qui renvoie du JSON paginé avec jeton OAuth, pour récupérer la liste des quartiers ; (c) relancer l'API si elle plante ; (d) au démarrage du conteneur de l'API, attendre que sig-db réponde, puis lancer python3 app.py ; (e) calculer la durée moyenne de traitement des signalements, en heures décimales, sur un an de données.
Solution
(a) Pas de script maison : restic le fait, avec chiffrement, déduplication et vérification (leçon 16 du cours d'administration) ; au plus une enveloppe Bash de quelques lignes. (b) Python : pagination, authentification, JSON, gestion des erreurs HTTP ; en Bash avec curl et jq, cela devient vite illisible et fragile. (c) Aucun script : Restart=on-failure dans l'unité systemd. (d) Bash convient : quelques lignes de colle dans le point d'entrée, terminées par exec python3 app.py pour que l'application remplace le shell et reçoive les signaux (cours Docker). (e) Pas Bash, qui ne calcule qu'en entiers : une requête SQL directement dans sig-db, ou Python.
4. Réparer un héritage (niveau 100). Sur sig-outils, /opt/signalements/bin/purger.sh appartient à camille:camille en 0664, n'a pas de ligne #!, contient des tableaux Bash, et est lancé par la ligne /etc/cron.d/signalements-purge : 15 3 * * * signalements sh /opt/signalements/bin/purger.sh. Listez les problèmes, puis les commandes pour remettre le script en état dans le dépôt et sur le serveur.
Solution
Problèmes : (1) sans #! et lancé par sh, le script est lu par Dash, qui s'arrête sur Syntax error: "(" unexpected dès le premier tableau, donc la purge ne tourne plus du tout (et l'erreur part dans un courrier sans MTA) ; (2) le fichier appartient à une personne partie et est modifiable par le groupe camille : n'importe quel membre peut y injecter du code exécuté sous signalements ; (3) il n'est pas versionné. Remise en état : copier le script dans bin/purger-pieces-jointes du dépôt, ajouter #!/usr/bin/env bash en première ligne, chmod 755, bash -n et shellcheck, commit ; sur le serveur, sudo install -o root -g root -m 0755 bin/purger-pieces-jointes /opt/signalements/bin/, remplacer la ligne cron par un minuteur systemd dont ExecStart= désigne le script par son chemin, puis sudo rm /opt/signalements/bin/purger.sh /etc/cron.d/signalements-purge. Vérifier avec systemctl list-timers et, le lendemain, journalctl -u du service.
5. Observer le noyau (niveau 100, pour aller plus loin). Écrivez un script voir-argv dont la ligne #! est #!/bin/bash -x, qui affiche son /proc/$$/cmdline. Lancez-le par ./voir-argv a b, puis par bash voir-argv a b. Expliquez la différence de sortie.
Solution
Lancé par ./, la ligne de commande est /bin/bash|-x|./voir-argv|a|b| : le noyau a inséré l'interpréteur et son argument. L'option -x est active : chaque commande est affichée, préfixée de +, sur la sortie d'erreur avant d'être exécutée (la leçon 12 l'exploite pour déboguer). Lancé par bash voir-argv a b, la ligne est bash|voir-argv|a|b| : la ligne #! n'a pas été lue par le noyau, et pour Bash ce n'est qu'un commentaire, donc pas de trace. C'est exactement pourquoi les options doivent être posées par set dans le corps du script.
Récapitulatif
- Un script est un texte lu par un interpréteur. Bash est un excellent langage de colle, mauvais pour manipuler des données : au-delà d'une centaine de lignes ou de structures complexes, passez à Python ; et vérifiez d'abord qu'aucun outil ni aucune option de systemd ne fait déjà le travail.
- Sur Debian et Ubuntu,
/bin/shest Dash, pas Bash. Un bashisme lu par Dash échoue, ou pire, s'exécute de travers avec un code 0. Cron lance ses lignes avec/bin/sh -c. - La ligne
#!, au premier octet, désigne l'interpréteur ; c'est le noyau qui la lit. Chemin absolu (le noyau ne cherche pas dans lePATH), un seul argument optionnel. Ce cours utilise#!/usr/bin/env bash. - Quatre lancements :
./scriptrespecte la ligne#!;bash scriptetsh scriptl'ignorent ;source scripts'exécute dans le shell courant. - Messages : 126
Permission denied(droitxou montagenoexec) ; 127required file not found(interpréteur introuvable, CRLF) ;Exec format erroret203/EXECsous systemd (pas de#!). - Un en-tête dit rôle, usage, déclencheur, dépendances et source ; les réglages viennent en tête ; les variables du script sont en minuscules.
bash -nvérifie la syntaxe, ShellCheck trouve les vrais défauts, dont les bashismes (-s dash).- On versionne (bit
xdans Git,.gitattributeseneol=lf) et l'on installe avecinstall, qui crée un nouveau fichier : Bash lit son script au fur et à mesure, et une copie en place corrompt l'exécution en cours. - Un script exécuté par un service appartient à root et n'est modifiable que par lui ; le bit setuid est ignoré sur les scripts.
Pour aller plus loin
- Le manuel de Bash, sections Shell Scripts et Invoking Bash, puis Bash POSIX Mode, qui liste ce que change le mode POSIX (et ce qu'il ne change pas).
- La page
execve(2)du projet Linux man-pages, section Interpreter scripts, et le fichierfs/binfmt_script.cdu noyau, court et abondamment commenté. - La section 10.4 de la politique de Debian, pour les règles des scripts
shd'un système Debian, et la page DashAsBinSh du wiki d'Ubuntu, qui liste les bashismes les plus fréquents et leur équivalent POSIX. - Le Google Shell Style Guide, à lire en entier : court, opinionné, et la plupart de ses règles sont reprises dans ce cours.
- Le wiki de ShellCheck : chaque page
SCxxxxexplique le problème, le code fautif, le code correct et les exceptions. Lire celles que l'outil vous signale est une excellente façon d'apprendre Bash. - La leçon suivante, Guillemets et expansions, qui explique enfin ce que Bash fait d'une ligne avant de l'exécuter, et pourquoi les guillemets de notre script ne sont pas décoratifs.
Sources
- GNU Bash Reference Manual, 3.8 Shell Scripts et 6.1 Invoking Bash
- POSIX.1-2024, Shell Command Language, 2.1 Shell Introduction et 2.9.1.6 Non-built-in Utility Execution
- Linux man-pages, execve(2), section Interpreter scripts
- Noyau Linux, code source de fs/binfmt_script.c
- Debian Policy Manual, 10.4 Scripts
- Ubuntu Wiki, DashAsBinSh
- Greg's Wiki, BashGuide/Practices
- Google Shell Style Guide
- ShellCheck, wiki : SC2148, SC1128, SC2096, SC3010, SC2086, SC2164
- ShellCheck 0.10.0, code source (Formatter/TTY.hs, Analytics.hs, Checks/ShellSupport.hs)
- GNU coreutils 9.4, code source de install (src/install.c)
- systemd 257, code source : src/core/exec-invoke.c et src/shared/exit-status.c
- Filesystem Hierarchy Standard 3.0, /usr/local et /opt