# Le code de sortie d'un pipeline SSH peut-il masquer une commande échouée ?

Une commande distante peut échouer, un formateur peut produire une sortie convaincante, et un agent peut malgré tout annoncer une réussite. Ce n'est pas un mystère SSH. Ce sont les règles habituelles du shell qui franchissent une frontière réseau sans informations suffisantes pour les accompagner.

La solution ne consiste pas simplement à ajouter `set -o pipefail` à tous les scripts. `pipefail` modifie un seul résultat global. Un agent qui exécute une opération SSH importante a besoin du statut de chaque étape du pipeline, d'une règle définie pour les codes différents de zéro attendus et d'un code de sortie distant final qui ne puisse pas être confondu avec une réussite. Capturez le vecteur immédiatement, donnez-lui un nom et laissez le wrapper décider de ce que signifie la réussite.

## Une dernière commande réussie peut masquer l'échec de la première

Par défaut, un shell renvoie le code de sortie de la dernière commande d'un pipeline. Cette ligne devient donc dangereuse pour les déploiements, migrations, sauvegardes et réparations :

```bash
build_manifest | sign_manifest | tee /var/tmp/manifest.json
```

Supposons que `build_manifest` échoue parce qu'il ne peut pas lire un fichier requis. `sign_manifest` peut alors recevoir une entrée inutilisable et échouer à son tour, ou produire un résultat vide. `tee` peut malgré tout créer un fichier, y écrire zéro octet et se terminer avec le code zéro. Le shell renvoie zéro pour l'ensemble du pipeline. Un appelant qui ne vérifie que `$?` voit une réussite.

Le GNU Bash Reference Manual l'indique clairement : un pipeline utilise le code de sortie de sa dernière commande, sauf si `pipefail` est activé. Bash attend la fin de toutes les commandes d'un pipeline synchrone, mais attendre ne signifie pas conserver leurs résultats.

À un terminal interactif, un humain remarque parfois les données manquantes ou le message d'erreur. Un agent a souvent une vue plus limitée. Il peut recevoir un transcript tronqué, un résumé formaté ou seulement le résultat final de la commande. Si le script renvoie zéro, l'agent peut légitimement annoncer que l'action a réussi, même si elle n'a pas produit le résultat demandé.

La distinction que les équipes confondent est simple :

- Le code de sortie d'un pipeline est une valeur de décision.
- Les statuts de ses commandes sont les éléments qui justifient cette décision.

Il faut les deux. La valeur de décision détermine si la commande distante renvoie une réussite. Les éléments de preuve indiquent à un réviseur, à un journal ou à un agent superviseur où l'erreur s'est produite.

Cela compte surtout lorsque la première étape modifie le monde extérieur. Imaginez un export distant qui lit des données de production, les compresse, les chiffre et les téléverse. Le client de téléversement peut se terminer avec le code zéro après avoir envoyé un flux vide. Le transcript peut contenir des mots rassurants comme « terminé », parce qu'un programme ultérieur a terminé sa tâche limitée. Ce résultat ne doit pas devenir une fausse affirmation selon laquelle l'export a réussi.

## SSH renvoie ce que le shell distant choisit de renvoyer

OpenSSH n'inspecte pas les commandes à l'intérieur d'un pipeline shell distant. Il renvoie le statut de la commande distante, ou 255 lorsque SSH rencontre lui-même une erreur.

Ce comportement est correct et utile. SSH ne peut pas savoir si ce texte distant est un pipeline, une fonction shell, un script ou une application qui utilise les codes de sortie à sa manière :

```bash
ssh deploy@host 'generate | transform | tee result.txt'
```

Le shell de connexion distant analyse cette commande. Si ses règles de pipeline renvoient le statut final de `tee`, SSH renvoie ce même statut à la machine locale. L'appelant local ne peut pas reconstituer les résultats précédents après que le shell distant les a oubliés.

Placer `set -o pipefail` dans le shell local ne corrige pas un pipeline qui s'exécute à distance. Cette commande modifie uniquement les règles de statut du pipeline local :

```bash
set -o pipefail
ssh deploy@host 'generate | transform | tee result.txt'
```

Le shell distant reste propriétaire de `generate | transform | tee result.txt`. Il lui faut son propre shell explicite et sa propre gestion des erreurs.

Il existe un second piège. Cette commande locale crée un autre pipeline après le retour de SSH :

```bash
ssh deploy@host 'remote command' 2>&1 | tee session.log
```

Deux pipelines différents existent alors :

1. Le shell distant peut contenir un pipeline dans `remote command`.
2. Le shell local contient `ssh | tee session.log`.

Un `tee` local qui réussit peut masquer une erreur de transport SSH ou un code différent de zéro renvoyé par le wrapper distant. Vous devez inspecter le pipeline distant sur l'hôte et le pipeline local autour de SSH. Traiter la ligne comme une commande opaque est la meilleure façon de laisser des résultats faussement positifs passer les contrôles.

## Pipefail détecte une erreur, mais ne l'explique pas

`set -o pipefail` modifie le résultat global de Bash. Lorsqu'il est activé, Bash renvoie le statut de la commande la plus à droite qui s'est terminée avec un code différent de zéro, ou zéro si toutes les commandes ont réussi.

Pour de nombreux scripts, c'est une vraie amélioration :

```bash
set -o pipefail
produce_data | validate_data | publish_data
printf 'pipeline status: %s\n' "$?"
```

Si `produce_data` se termine avec le code 17 et que les commandes suivantes renvoient zéro, le pipeline renvoie 17. Si `validate_data` renvoie 4 et `publish_data` zéro, le pipeline renvoie 4. Le processus appelant reçoit une erreur plutôt qu'un mensonge.

Mais `pipefail` perd des informations lorsque plusieurs étapes échouent. Supposons que les statuts soient `17 4 0`. Le résultat du pipeline est 4, car 4 provient de la dernière étape en échec. Cela indique qu'une erreur s'est produite, mais ne permet pas de savoir si le validateur a provoqué l'échec du producteur, s'il y a réagi ou s'il a échoué indépendamment.

C'est pourquoi `pipefail` est une barrière de sécurité, pas un format de rapport. Utilisez-le lorsque vous voulez qu'un pipeline échoue comme un tout. Utilisez `PIPESTATUS` lorsque vous devez répondre ensuite à ces questions :

- Quelle étape a renvoyé un code différent de zéro ?
- Une étape ultérieure a-t-elle continué et réussi après l'échec d'une étape précédente ?
- Le processus a-t-il reçu un signal au lieu de renvoyer sa propre erreur ?
- Un code différent de zéro est-il attendu pour cette commande précise ?

Ne masquez pas le problème avec `|| true` :

```bash
produce_data | validate_data | publish_data || true
```

Ce schéma est courant parce qu'il permet au script de continuer. Il efface aussi le seul signal dont disposait l'appelant. Si une étape peut légitimement renvoyer un code différent de zéro, encodez le statut autorisé pour cette étape après avoir capturé le vecteur réel. N'ignorez pas l'échec de tout le pipeline.

## PIPESTATUS disparaît si vous attendez une seule commande

Bash expose le code de sortie de chaque étape dans le tableau `PIPESTATUS`. Ce tableau est volontairement fragile : il décrit le dernier pipeline exécuté au premier plan, et la commande suivante peut le remplacer.

Cela semble raisonnable, mais c'est incorrect :

```bash
source_data | normalize | upload
pipeline_rc=$?
printf 'pipeline result: %s\n' "$pipeline_rc"
statuses=("${PIPESTATUS[@]}")
```

Lorsque Bash atteint la dernière affectation, l'affectation `pipeline_rc=$?` et `printf` ont déjà été exécutés. `PIPESTATUS` ne décrit plus `source_data | normalize | upload`.

Copiez le tableau en premier, avant toute autre opération :

```bash
source_data | normalize | upload
statuses=("${PIPESTATUS[@]}")
```

Inspectez-le ensuite sans dépendre du code global du pipeline :

```bash
printf 'source_data=%s normalize=%s upload=%s\n' \\
  "${statuses[0]}" "${statuses[1]}" "${statuses[2]}"
```

C'est aussi pourquoi un `set -e` utilisé machinalement peut compliquer le diagnostic. Avec `pipefail` actif, un pipeline défaillant peut entraîner la sortie de Bash avant que la ligne suivante ne copie `PIPESTATUS`. La gestion des erreurs du shell comporte plusieurs exceptions liées au contexte, et les scripts qui dépendent uniquement de `set -e` produisent souvent moins d'informations précisément lorsqu'une commande échoue.

Pour un pipeline dont les statuts comptent, désactivez `errexit` pendant les quelques lignes nécessaires à son exécution et à sa capture. Prenez ensuite une décision explicite. Cela demande plus de code qu'une option shell magique, mais ce code reste lisible pendant un incident.

## Exécutez le programme distant avec le shell requis

`PIPESTATUS` est un tableau Bash. Ce n'est pas une syntaxe POSIX `sh` portable, et POSIX n'impose pas `pipefail`. Une commande distante appelée via SSH peut s'exécuter dans un shell de connexion que vous n'avez pas choisi. Sur un hôte, ce sera Bash ; sur un autre, `dash`, `zsh` ou un shell restreint.

N'envoyez pas de syntaxe Bash à un shell distant non spécifié en espérant que la machine vous donnera raison. Démarrez explicitement Bash :

```bash
ssh deploy@host 'bash -s' <<'REMOTE_SCRIPT'
printf 'alpha\n' | grep 'beta' | tee /var/tmp/example.out
statuses=("${PIPESTATUS[@]}")
printf 'stages=%s,%s,%s\n' \\
  "${statuses[0]}" "${statuses[1]}" "${statuses[2]}" >&2
REMOTE_SCRIPT
```

Le délimiteur du heredoc entre guillemets est important. `<<'REMOTE_SCRIPT'` empêche le shell local d'étendre les variables, substitutions de commandes et antislashs avant l'envoi du script. Le processus Bash distant reçoit le texte que vous avez écrit.

Sur macOS, le Bash fourni par le système est ancien, mais il prend en charge les tableaux indexés, `PIPESTATUS` et `set -o pipefail`. Cela ne signifie pas que `/bin/sh` est Bash. Un script avec `#!/bin/bash` n'aide que lorsque vous exécutez directement ce fichier. Si vous transmettez une commande d'une ligne à `ssh host '...'`, le shell de connexion distant l'analyse encore, sauf si vous démarrez explicitement Bash.

Pour un parcours d'automatisation maintenu, placez le wrapper distant dans un script versionné et appelez-le avec son chemin absolu. Pour une tâche d'agent ponctuelle, `bash -s` avec un heredoc entre guillemets est souvent plus facile à auditer, car tout le programme distant apparaît dans la demande d'action locale.

## Un wrapper doit nommer les étapes et renvoyer un résultat honnête

Un wrapper distant utile effectue quatre opérations. Il exécute le pipeline, copie immédiatement le vecteur des statuts, émet un relevé lisible par une machine et se termine avec un code différent de zéro lorsqu'une étape requise a échoué.

Cet exemple utilise un transfert de données en trois étapes. Remplacez les commandes, mais conservez le flux de contrôle. Il ne dépend volontairement pas de `set -e` pour décider de la suite après le pipeline.

```bash
#!/usr/bin/env bash
set -uo pipefail

run_export() {
  local -a status
  local stage
  local -a names=("collect" "compress" "send")

  set +e
  collect_records | gzip -c | send_archive --destination daily
  status=("${PIPESTATUS[@]}")
  set -e

  if ((${#status[@]} != ${#names[@]})); then
    printf 'agent_pipeline_error pipeline=export reason=status_count expected=%s got=%s\n' \\
      "${#names[@]}" "${#status[@]}" >&2
    return 70
  fi

  for stage in "${!names[@]}"; do
    printf 'agent_pipeline_status pipeline=export stage=%s code=%s\n' \\
      "${names[$stage]}" "${status[$stage]}" >&2
  done

  for stage in "${!status[@]}"; do
    if (( status[stage] != 0 )); then
      printf 'agent_pipeline_result pipeline=export outcome=failed\n' >&2
      return "${status[$stage]}"
    fi
  done

  printf 'agent_pipeline_result pipeline=export outcome=ok\n' >&2
  return 0
}

run_export
```

Une collecte échouée avec un compresseur et un expéditeur réussis produit une sortie de cette forme :

```text
agent_pipeline_status pipeline=export stage=collect code=23
agent_pipeline_status pipeline=export stage=compress code=0
agent_pipeline_status pipeline=export stage=send code=0
agent_pipeline_result pipeline=export outcome=failed
```

Le wrapper se termine avec le code 23. SSH renvoie 23 au processus local. L'agent peut signaler que l'export a échoué à l'étape `collect`, même si `send_archive` a affiché un message indiquant la fin du transfert pour un flux vide.

Le code renvoyé importe moins que la discipline appliquée. Dans ce wrapper, le premier statut différent de zéro dans l'ordre du pipeline l'emporte. Bash `pipefail` choisit plutôt le statut différent de zéro le plus à droite. Les deux politiques peuvent fonctionner si vous les documentez et les testez. Pour les opérations, je préfère la première étape en échec, car elle indique généralement plus directement la cause initiale. Conservez le vecteur complet des statuts dans l'enregistrement de l'action afin que personne n'ait à déduire ce qui s'est passé à partir d'un seul nombre.

Les noms des étapes ne sont pas décoratifs. `0=23,1=0,2=0` oblige une personne à rouvrir le script. `collect=23,compress=0,send=0` permet à un superviseur d'orienter l'incident, d'ajouter du contexte ou de décider si une nouvelle tentative est sûre.

## La journalisation locale peut créer une seconde fausse réussite

Les opérateurs veulent un transcript local. Les agents aussi. La manière naïve de l'obtenir est la suivante :

```bash
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
```

Si SSH renvoie 23, mais que `tee` écrit le transcript local et renvoie zéro, le pipeline local renvoie zéro par défaut. Vous avez corrigé le mensonge distant et en avez introduit un autre au niveau local.

Capturez aussi les statuts locaux :

```bash
set +e
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
local_status=("${PIPESTATUS[@]}")
set -e

ssh_rc=${local_status[0]}
tee_rc=${local_status[1]}
printf 'ssh=%s tee=%s\n' "$ssh_rc" "$tee_rc" >&2

if (( ssh_rc != 0 )); then
  exit "$ssh_rc"
fi
if (( tee_rc != 0 )); then
  exit "$tee_rc"
fi
```

N'activez pas `pipefail` local et ne vous arrêtez pas là. Il donne un résultat global différent de zéro si `ssh` ou `tee` échoue, ce qui vaut mieux que le comportement par défaut. Il ne permet pas de dire à l'agent si l'action distante a échoué, si la connexion réseau a échoué ou si la journalisation locale a échoué. Ces cas entraînent des décisions différentes.

Le statut SSH 255 doit faire l'objet d'un traitement particulier. OpenSSH le réserve à une erreur du chemin client SSH, et non au résultat d'une commande distante. Un wrapper doit le signaler comme un échec du transport ou de l'exécution SSH, et non prétendre qu'une étape distante nommée a renvoyé 255.

Il existe une autre raison de séparer les résultats locaux et distants. Un transcript peut contenir plusieurs relevés de pipelines distants, des avertissements du shell de connexion et un diagnostic SSH. Si un agent cherche le dernier nombre dans le texte libre, il finira par choisir le mauvais. Utilisez des relevés reconnaissables, puis liez le résultat final de l'action au véritable code de sortie du processus.

## SIGPIPE exige une exception écrite, pas un pardon général

`pipefail` expose une erreur que de nombreux scripts ignoraient auparavant : SIGPIPE. Dans Bash, un processus terminé par le signal numéro `N` reçoit le statut `128 + N` ; SIGPIPE apparaît souvent sous la forme 141.

Un cas volontaire classique est le suivant :

```bash
generate_many_lines | head -n 10
```

`head` lit dix lignes et se termine correctement. Le générateur peut continuer à écrire, recevoir SIGPIPE parce qu'il n'y a plus de lecteur et se terminer avec le code 141. Avec `pipefail`, le pipeline peut sembler avoir échoué alors que l'échantillon de dix lignes demandé a bien été produit.

Cela ne rend pas 141 inoffensif dans tous les pipelines. Un client réseau, un compresseur ou un producteur de données peut recevoir SIGPIPE parce qu'un consommateur en aval inattendu s'est arrêté ou a rejeté l'entrée. Si vous considérez tous les statuts 141 comme des réussites, vous masquez un transfert interrompu.

La bonne règle est étroite : autorisez un statut dérivé d'un signal uniquement pour une étape dont l'arrêt anticipé fait partie du contrat prévu de la commande. Placez cette exception à côté de l'étape, et non dans un réglage shell global.

Par exemple, un wrapper autour d'un aperçu volontaire peut accepter `generate_many_lines=141` uniquement lorsque `head=0` :

```bash
if (( status[0] == 141 && status[1] == 0 )); then
  printf 'agent_pipeline_result pipeline=preview outcome=ok reason=expected_sigpipe\n' >&2
  return 0
fi
```

Tous les autres résultats différents de zéro restent des échecs. Cette précision évite une réaction excessive fréquente : après avoir activé `pipefail` et rencontré une fois un 141 bruyant, certaines équipes le désactivent dans toute leur infrastructure d'automatisation.

## Un agent a besoin de preuves séparées de la sortie des commandes

L'agent ne doit pas déterminer la réussite en lisant de la prose. Les commandes affichent des mots de réussite avant d'échouer, les outils mélangent avertissements et résultats, et un script distant peut émettre une dernière ligne après l'échec d'une étape.

Définissez un contrat d'action en deux niveaux :

1. Le code de sortie du processus décide si l'action demandée a réussi.
2. Des relevés structurés expliquent chaque étape importante du pipeline.

Conservez la sortie habituelle des commandes pour le diagnostic, mais ne demandez pas à l'agent d'en déduire le flux de contrôle. Dans le wrapper ci-dessus, stderr contient des relevés qui commencent par `agent_pipeline_status` et `agent_pipeline_result`. Un programme appelant peut conserver ce flux, n'analyser que ces relevés exacts et continuer à afficher le reste à un humain.

Ne faites pas confiance à un marqueur uniquement parce qu'il apparaît dans une sortie de commande non fiable. Si une étape traite des données fournies par un autre utilisateur ou système, ces données peuvent contenir une ligne ressemblant à votre relevé de statut. La méthode la plus sûre consiste à faire capturer la sortie de l'étape par le wrapper, puis à émettre lui-même les relevés une fois le pipeline terminé. Pour les opérations plus risquées, utilisez un fichier de résultat dédié avec des permissions restrictives, puis faites-le lire et valider par le wrapper avant l'émission d'un relevé final.

Le rapport de l'agent doit inclure le code de sortie distant, le code de sortie SSH local et, lorsqu'ils sont disponibles, les statuts des étapes distantes nommées. Il doit aussi distinguer les résultats suivants :

- l'action distante s'est exécutée et une étape nommée a échoué ;
- le wrapper distant n'a pas pu produire un relevé complet des statuts ;
- SSH n'a pas pu établir ou maintenir le canal d'action ;
- la capture du transcript local a échoué après la fin de l'action distante.

Ces faits ont des conséquences opérationnelles différentes. Une nouvelle tentative après une perte réseau peut dupliquer une modification distante déjà terminée. Une nouvelle tentative après l'échec d'une étape de validation peut être sûre. Une nouvelle tentative après l'échec du `tee` local peut être inutile, car le travail distant a déjà eu lieu.

Sallyport peut garder l'identifiant SSH hors de portée de l'agent pendant l'exécution de l'action SSH, mais la commande distante a toujours besoin de ce contrat honnête entre code de sortie et éléments de preuve.

## Testez les chemins d'échec avant qu'un agent ne les rencontre

Un wrapper shell ne mérite la confiance qu'après avoir échoué de manière contrôlée. Tester le chemin nominal permet de vérifier la branche la moins intéressante.

Créez des commandes jetables qui renvoient les statuts que vous voulez observer :

```bash
fail_23() { printf 'collector failed\n' >&2; return 23; }
pass_through() { cat; }
succeed() { cat >/dev/null; return 0; }

set +e
fail_23 | pass_through | succeed
status=("${PIPESTATUS[@]}")
set -e
printf 'observed=%s,%s,%s\n' "${status[0]}" "${status[1]}" "${status[2]}"
```

Le résultat attendu est `23,0,0`. Exécutez ensuite le même schéma avec l'invocation SSH exacte utilisée par votre agent. Ne vous arrêtez pas à un test dans le shell local, car le choix du shell distant, le quoting du heredoc, le pipeline de transcript local et le comportement du code de sortie du wrapper se situent tous en dehors de cette première vérification.

Testez au moins les cas suivants :

- toutes les étapes réussissent et le wrapper se termine avec le code zéro ;
- une première étape échoue tandis que les suivantes renvoient zéro ;
- une étape intermédiaire échoue après avoir consommé une partie de l'entrée ;
- SSH ne peut pas se connecter ou s'authentifier ;
- `tee` local ne peut pas écrire son transcript ;
- un pipeline `head` volontaire déclenche la règle SIGPIPE attendue.

Notez pour chacun le code de sortie attendu et les relevés d'étapes attendus. Si un test annonce une réussite après qu'une étape précédente a renvoyé un code différent de zéro, le wrapper ne remplit pas son rôle.

Le raccourci tentant consiste à demander à l'agent d'inspecter un transcript après chaque action et de décider si la sortie « semble correcte ». Cette méthode échoue sous charge, lorsque les outils changent leur formulation ou lorsque la sortie est tronquée. Les codes de sortie sont le canal de contrôle. Les relevés d'étapes sont le canal de preuve. Gardez-les séparés, préservez-les à travers SSH et ne laissez pas un `tee` final décider si une action distante a bien eu lieu.
