# Может ли код завершения конвейера SSH скрыть ошибку команды?

Удаленная команда может завершиться ошибкой, форматтер может вывести убедительный результат, а агент все равно объявит об успехе. Это не загадка SSH. Так работает обычная семантика оболочки, когда она пересекает сетевую границу, не передавая достаточно сведений о результате.

Решение не сводится к тому, чтобы добавить `set -o pipefail` в каждый скрипт. `pipefail` меняет один итоговый результат. Агенту, который выполняет важную работу через SSH, нужны статусы всех этапов конвейера, четкое правило для ожидаемых ненулевых кодов и итоговый код удаленной команды, который нельзя принять за успех. Сразу сохраните вектор статусов, дайте ему имя и поручите обертке решить, что считать успехом.

## Успешная последняя команда может скрыть ошибку первой

По умолчанию оболочка сообщает в конвейере код завершения последней команды. Поэтому в задачах развертывания, миграции, резервного копирования и восстановления опасной может оказаться такая строка:

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

Предположим, `build_manifest` завершилась ошибкой, потому что не смогла прочитать обязательный файл. `sign_manifest` может получить непригодные входные данные и тоже завершиться ошибкой или создать пустой результат. `tee` все равно может создать файл, записать ноль байт и завершиться с кодом 0. Оболочка сообщит 0 для всего конвейера. Вызывающая программа, которая проверяет только `$?`, увидит успех.

В GNU Bash Reference Manual это сформулировано прямо: конвейер использует код завершения последней команды, если не включен `pipefail`. Bash дожидается всех команд в синхронном конвейере, но ожидание не означает сохранение их результатов.

Человек у интерактивного терминала иногда замечает отсутствие данных или сообщение об ошибке. У агента часто меньше контекста. Он может получить обрезанный протокол, отформатированное резюме или только итоговый результат команды. Если скрипт возвращает ноль, у агента есть основания сказать, что действие завершилось успешно, даже когда требуемое действие не выполнено.

Различие, которое команды часто размывают, простое:

- Код завершения конвейера это одно значение, принимающее решение.
- Статусы его команд это свидетельства, на которых основано решение.

Нужны оба. Значение решения определяет, вернет ли удаленная команда успех. Свидетельства показывают рецензенту, журналу или контролирующему агенту, где произошла ошибка.

Особенно важно это в случаях, когда первый этап меняет состояние системы. Представьте удаленный экспорт, который читает данные из production, сжимает их, шифрует и загружает. Клиент загрузки может завершиться с кодом 0 после отправки пустого потока. В протоколе могут появиться успокаивающие слова вроде «завершено», потому что последующая программа выполнила свою узкую задачу. Такой результат нельзя превращать в ложное утверждение, что экспорт прошел успешно.

## SSH возвращает то, что выбрала удаленная оболочка

OpenSSH не проверяет команды внутри удаленного конвейера. Он возвращает статус удаленной команды или 255, если ошибка произошла на стороне самого SSH.

Это правильное и полезное поведение. SSH не может знать, является ли переданный удаленный текст конвейером, функцией оболочки, скриптом или приложением, которое использует коды завершения по-своему:

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

Удаленная login shell разбирает эту команду. Если ее правила конвейера сообщают статус последнего `tee`, SSH передает этот статус локальной машине. После того как удаленная оболочка отбросила предыдущие результаты, локальный вызывающий процесс уже не может их восстановить.

Локальный `set -o pipefail` не исправляет конвейер, который выполняется удаленно. Эта команда меняет только правила статуса локального конвейера:

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

Конвейером `generate | transform | tee result.txt` по-прежнему управляет удаленная оболочка. Ей нужны собственная явная оболочка и собственная обработка ошибок.

Есть и вторая ловушка. Следующая локальная команда создает еще один конвейер после возврата SSH:

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

Теперь существуют два разных конвейера:

1. Внутри `remote command` у удаленной оболочки может быть свой конвейер.
2. У локальной оболочки есть `ssh | tee session.log`.

Успешный локальный `tee` может скрыть сбой транспорта SSH или ненулевой код удаленной обертки. Нужно проверить удаленный конвейер на хосте и локальный конвейер вокруг SSH. Если воспринимать всю строку как одну непрозрачную команду, ложные успешные результаты будут проходить проверку.

## Pipefail обнаруживает ошибку, но не объясняет ее

`set -o pipefail` меняет совокупный результат Bash. Когда эта настройка включена, Bash возвращает код крайней справа команды, завершившейся с ненулевым кодом, или 0, если все команды завершились успешно.

Для многих скриптов это заметное улучшение:

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

Если `produce_data` завершилась с кодом 17, а последующие команды вернули 0, конвейер вернет 17. Если `validate_data` вернула 4, а `publish_data` 0, конвейер вернет 4. Вызывающий процесс получит ошибку, а не ложный успех.

Но `pipefail` теряет детали, когда ошибку возвращают несколько этапов. Допустим, статусы равны `17 4 0`. Результатом конвейера будет 4, потому что именно 4 принадлежит крайней справа неисправной команде. Это говорит о наличии ошибки, но не показывает, вызвал ли валидатор сбой производителя, отреагировал на него или сломался независимо.

Поэтому `pipefail` это защитный барьер, а не формат отчета. Включайте его, когда хотите, чтобы конвейер завершался как единое целое. Используйте `PIPESTATUS`, когда позже нужно ответить на такие вопросы:

- Какой этап вернул ненулевой код?
- Запустился ли последующий этап и завершился ли успешно после сбоя предыдущего?
- Получил ли процесс сигнал вместо собственного кода ошибки?
- Ожидаем ли ненулевой код для этой конкретной команды?

Не замалчивайте проблему с помощью `|| true`:

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

Этот прием популярен, потому что позволяет скрипту продолжить работу. Одновременно он стирает единственный сигнал, который был у вызывающей программы. Если этап может законно вернуть ненулевой код, после сохранения фактического вектора явно укажите допустимый статус именно для этого этапа. Не отменяйте ошибку для всего конвейера.

## PIPESTATUS исчезает, если подождать хотя бы одну команду

Bash предоставляет код завершения каждого этапа в массиве `PIPESTATUS`. Массив намеренно хрупкий: он описывает последний выполненный конвейер переднего плана, а следующая команда может его заменить.

На вид следующий вариант разумен, но он неверен:

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

К моменту последнего присваивания Bash уже выполнил присваивание `pipeline_rc=$?` и `printf`. `PIPESTATUS` больше не описывает `source_data | normalize | upload`.

Сначала скопируйте массив, не выполняя ничего другого:

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

Затем изучите его, не полагаясь на совокупный код конвейера:

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

По этой же причине неосторожный `set -e` может усложнить диагностику. При активном `pipefail` неисправный конвейер может завершить Bash до того, как следующая строка скопирует `PIPESTATUS`. У обработки ошибок оболочкой много контекстных исключений, и скрипты, которые полагаются только на `set -e`, часто дают меньше сведений именно в момент сбоя.

Если статусы конвейера важны, на несколько строк, необходимых для его запуска и сохранения, отключите `errexit`. Затем явно примите решение. Кода получится больше, чем при использовании магической настройки оболочки, зато его можно прочитать во время инцидента.

## Запускайте удаленную программу под нужной оболочкой

`PIPESTATUS` это массив Bash. Это не переносимый синтаксис POSIX `sh`, а `pipefail` не входит в обязательные возможности POSIX shell. Команда, запущенная через SSH, может выполняться под login shell, которую вы не выбирали. На одном хосте это будет Bash, на другом `dash`, `zsh` или ограниченная оболочка.

Не отправляйте синтаксис Bash в неуказанную удаленную оболочку в надежде, что машина случайно вас поймет. Запускайте 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
```

Кавычки вокруг разделителя heredoc важны. `<<'REMOTE_SCRIPT'` не дает локальной оболочке подставлять переменные, выполнять подстановки команд и обрабатывать обратные косые черты до отправки скрипта. Удаленный процесс Bash получает написанный вами текст.

В macOS системный Bash старый, но он поддерживает индексированные массивы, `PIPESTATUS` и `set -o pipefail`. Это не означает, что `/bin/sh` является Bash. Скрипт с `#!/bin/bash` помогает только при прямом запуске файла. Если передать однострочную команду через `ssh host '...'`, ее все равно разбирает удаленная login shell, если вы явно не запустите Bash.

Для постоянно используемого пути автоматизации храните удаленную обертку в версионируемом скрипте и вызывайте ее по абсолютному пути. Для краткосрочной работы агента обычно проще проверять `bash -s` с heredoc в кавычках: вся удаленная программа видна в локальном запросе действия.

## Обертка должна называть этапы и возвращать честный результат

Хорошая удаленная обертка выполняет четыре задачи. Она запускает конвейер, сразу копирует вектор статусов, выводит машиночитаемую запись и завершает работу с ненулевым кодом, если обязательный этап завершился ошибкой.

В этом примере используются три этапа передачи данных. Замените команды, но сохраните структуру управления. Здесь намеренно нет зависимости от `set -e` при принятии решения о результате конвейера.

```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
```

Если сбор данных завершился ошибкой, а сжатие и отправка прошли успешно, вывод будет выглядеть так:

```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
```

Обертка завершится с кодом 23. SSH вернет 23 локальному процессу. Агент сможет сообщить, что экспорт завершился ошибкой на этапе `collect`, даже если `send_archive` вывела сообщение о завершении для пустого потока.

Точный возвращаемый код менее важен, чем сам принцип. В этой обертке побеждает первый ненулевой статус по порядку этапов. Bash `pipefail` выбирает крайний справа ненулевой статус. Подойдет любая политика, если вы ее явно описали и проверили. В операционных задачах я предпочитаю первый неисправный этап, потому что он обычно ближе к исходной причине. Сохраняйте весь вектор статусов в записи действия, чтобы никто не восстанавливал картину по одному числу.

Названия этапов нужны не для украшения. Запись `0=23,1=0,2=0` заставляет человека снова открывать скрипт. Запись `collect=23,compress=0,send=0` позволяет контролирующей системе направить ошибку, добавить контекст или решить, безопасно ли повторить операцию.

## Локальное журналирование может создать второй ложный успех

Операторам нужен локальный протокол. Агенту он тоже нужен. Наивный способ получить его выглядит так:

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

Если SSH возвращает 23, а локальный `tee` записывает протокол и возвращает 0, локальный конвейер по умолчанию вернет 0. Вы исправили ложь на удаленной стороне и создали локальную.

Сохраните локальные статусы тоже:

```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
```

Не включайте локальный `pipefail` и не останавливайтесь на этом. Он даст ненулевой совокупный результат, если завершился ошибкой `ssh` или `tee`, что лучше поведения по умолчанию. Но он не скажет агенту, что именно произошло: сбой удаленного действия, сетевого соединения или локального журналирования. Эти случаи требуют разных решений.

Код SSH 255 требует особого отношения. OpenSSH резервирует его для ошибки на стороне SSH-клиента, а не для результата удаленной команды. Обертка должна сообщать о нем как о сбое транспорта или выполнения SSH, а не утверждать, что именованный этап удаленного конвейера вернул 255.

Есть еще одна практическая причина разделять локальные и удаленные результаты. В протоколе могут быть записи нескольких удаленных конвейеров, предупреждения login shell и диагностика SSH. Если агент разбирает свободный текст и берет последнее встретившееся число, рано или поздно он выберет неправильное. Используйте узнаваемые записи и связывайте итог действия с фактическим кодом завершения процесса.

## Для SIGPIPE нужно отдельное правило, а не общее исключение

`pipefail` показывает ошибку, которую многие скрипты раньше игнорировали: SIGPIPE. В Bash процесс, завершенный сигналом с номером `N`, получает код `128 + N`; SIGPIPE обычно дает 141.

Классический намеренный случай:

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

`head` читает десять строк и успешно завершается. Генератор может продолжить запись, получить SIGPIPE, потому что читателя больше нет, и завершиться с кодом 141. При включенном `pipefail` конвейер может выглядеть неисправным, хотя требуемый образец из десяти строк был создан.

Но это не делает 141 безобидным в любом конвейере. Сетевой клиент, компрессор или производитель данных может получить SIGPIPE, потому что неожиданный следующий процесс завершился или отклонил входные данные. Если объявлять успешными все статусы 141, можно скрыть сломанную передачу.

Правило должно быть узким: разрешайте код, возникший из-за сигнала, только для этапа, раннее завершение которого входит в предусмотренный контракт команды. Размещайте это исключение рядом с этапом, а не в глобальной настройке оболочки.

Например, обертка для намеренного предварительного просмотра может принять `generate_many_lines=141` только при `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
```

Любой другой ненулевой результат остается ошибкой. Такая точность предотвращает распространенную чрезмерную реакцию: кто-то включает `pipefail`, однажды видит шумный 141, а затем отключает настройку во всей инфраструктуре автоматизации.

## Агенту нужны сведения отдельно от вывода команд

Агент не должен определять успех по тексту. Команды могут напечатать слова об успехе до сбоя, инструменты смешивают предупреждения с результатами, а удаленный скрипт может вывести последнюю строку после того, как один из этапов уже завершился ошибкой.

Определите контракт действия из двух уровней:

1. Код завершения процесса решает, выполнено ли требуемое действие.
2. Структурированные записи статусов объясняют каждый важный этап конвейера.

Сохраняйте обычный вывод команд для диагностики, но не заставляйте агента восстанавливать ход выполнения по этому тексту. В приведенной выше обертке записи с префиксами `agent_pipeline_status` и `agent_pipeline_result` передаются в stderr. Вызывающая программа может сохранить этот поток, разбирать только точные записи и при этом показывать остальное человеку.

Не доверяйте маркеру только потому, что он появился в недоверенном выводе команды. Если этап конвейера обрабатывает данные, переданные другим пользователем или системой, в этих данных может оказаться строка, похожая на вашу запись статуса. Надежнее поручить обертке собрать вывод этапов и самостоятельно вывести записи после завершения конвейера. Для более рискованных операций используйте отдельный файл результатов с ограниченными правами доступа, а затем пусть обертка прочитает и проверит его перед выводом одной итоговой записи.

В отчет агента должны входить код завершения удаленной команды, локальный код SSH и, если они доступны, статусы именованных удаленных этапов. Также нужно различать такие результаты:

- удаленное действие запустилось, и именованный этап завершился ошибкой;
- удаленная обертка не смогла создать полную запись статуса;
- SSH не смог установить или сохранить канал выполнения действия;
- локальная запись протокола завершилась ошибкой после завершения удаленного действия.

С точки зрения эксплуатации это разные факты. Повтор после потери сети может повторно выполнить уже завершившуюся удаленную операцию. Повтор после ошибки проверки может быть безопасным. Повтор после сбоя локального `tee` может быть бессмысленным, потому что удаленная работа уже выполнена.

Sallyport может не передавать SSH-учетные данные агенту во время выполнения SSH-действия, но удаленной команде все равно нужен честный контракт по коду завершения и доказательствам результата.

## Проверьте пути отказа до того, как до них доберется агент

Доверие к shell-обертке появляется только после контролируемых сбоев. Проверка успешного сценария показывает наименее интересную ветку.

Создайте одноразовые команды, которые возвращают нужные вам статусы:

```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]}"
```

Ожидаемый результат это `23,0,0`. Затем пропустите тот же шаблон через точную SSH-команду, которую использует ваш агент. Не ограничивайтесь локальной проверкой: выбор удаленной оболочки, кавычки heredoc, локальный конвейер журналирования и поведение обертки при завершении находятся за пределами первой проверки.

Проверьте как минимум такие случаи:

- все этапы завершаются успешно, а обертка возвращает ноль;
- ранний этап завершается ошибкой, а последующие возвращают ноль;
- промежуточный этап завершается ошибкой после обработки части входных данных;
- SSH не может подключиться или пройти аутентификацию;
- локальный `tee` не может записать протокол;
- намеренный конвейер с `head` запускает предусмотренное правило SIGPIPE.

Для каждого случая запишите ожидаемый код завершения и ожидаемые записи этапов. Если тест сообщает об успехе после ненулевого результата раннего этапа, обертка не выполнила свою задачу.

Соблазнительный короткий путь состоит в том, чтобы после каждого действия просить агента проверить протокол и решить, «похож» ли вывод на правильный. Такой подход ломается под нагрузкой, после изменения формулировок инструментов и при обрезанном выводе. Коды завершения это канал управления. Записи этапов это канал доказательств. Разделяйте их, сохраняйте оба через SSH и не позволяйте последнему `tee` решать, произошло ли удаленное действие.
