[Перевод] Как писать bash-скрипты надежно и безопасно: минимальный шаблон

Скрипты на Bash. Как много в этом слове. Любому разработчику рано или поздно приходится их писать. Почти никто не скажет «да, я люблю писать bash-скрипты», и поэтому почти все уделяют мало внимания этой теме.

Я не буду пытаться сделать из вас эксперта в Bash, а просто покажу минимальный шаблон, который поможет сделать ваши скрипты более надежными и безопасными.

Лучше всего суть Bash-скриптинга была выражена недавно в одном твите:

image-loader.svg

Но у Bash есть кое-что общее с другим многими любимым языком. Так же, как и JavaScript, он просто так не исчезнет. Хоть Bash (к счастью!) и не станет основным языком буквально для всего, он все равно всегда будет где-то рядом.

Bash можно найти почти в каждой Linux-системе, включая образы Docker. Так что если вам нужно создать скрипт для запуска серверного приложения, этапа CI/CD или запуска интеграционных тестов, Bash подойдет в самый раз.

Bash — самое простое и наиболее естественное решение, позволяющее склеить несколько команд вместе, передать вывод от одной команды к другой и запустить какой-нибудь исполняемый файл. Хотя есть смысл писать более крупные и сложные сценарии на других языках, вы не можете ожидать, что Python, Ruby, fish или любой другой интерпретатор, который, по вашему мнению, подойдет лучше, будет доступен везде. И вам, вероятно, следует подумать дважды, а затем еще раз, прежде чем добавлять его на какой-нибудь продакшн-сервер, в образ Docker или в CI.

Да, Bash очень далек от совершенства. Синтаксис просто кошмарный. Обработка ошибок сложна. Повсюду подводные камни. И нам придется с этим что-то делать.

Шаблон bash-скрипта

Давайте сразу к делу

вот он

#!/usr/bin/env bash

set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

usage() {
  cat <&2 -e "${1-}"
}

die() {
  local msg=$1
  local code=${2-1} # default exit status 1
  msg "$msg"
  exit "$code"
}

parse_params() {
  # default values of variables set from params
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

parse_params "$@"
setup_colors

# script logic here

msg "${RED}Read parameters:${NOFORMAT}"
msg "- flag: ${flag}"
msg "- param: ${param}"
msg "- arguments: ${args[*]-}"

Идея была в том, чтобы не делать его слишком длинным — мало кому хочется скроллить 500 строк чтобы увидеть основную логику скрипта. С другой стороны, хотелось иметь надежный фундамент для любых применений. Bash, к сожалению, никак не упрощает эту задачу из-за отсутствия какого-либо способа управления зависимостями.

Одним из решений было бы иметь отдельный скрипт со всеми стандартными и служебными функциями и запускать его в самом начале. Недостатком этого варианта будет необходимость всегда таскать этот второй файл повсюду, что уже все-таки далеко от идеи «простого Bash-скрипта». Поэтому я решил добавить в шаблон только то, что считаю минимально необходимым и сделать его максимально коротким.

А теперь давайте углубимся в детали.

Выбираем интерпретатор

#!/usr/bin/env bash

Скрипты обычно начинаются с шебанга. Для лучшей совместимости я использую /usr/bin/env вместо непосредствено /bin/bash. Хотя, если почитать комментарии на StackOverflow по ссылке выше, даже это может иногда не сработать.

Fail fast

set -Eeuo pipefail

Команда 'set' изменяет параметры выполнения скрипта. Например, обычно Bash вообще пофиг на ошибку какой-либо команды, которая завершилась с ненулевым кодом возврата — он просто беззаботно переходит к выполнению следущей команды. А теперь посмотрим вот на такой небольшой скрипт:

#!/usr/bin/env bash
cp important_file ./backups/
rm important_file

Что произойдет, если './backups/' не существует? Именно, вы получите сообщение об ошибке в консоли, но прежде чем вы успеете отреагировать, файл уже будет безвозвратно удален второй командой.

О том, как работает '-Eeuo pipefail' и от чего он защищает, можно прочитать вот в этой статье: https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/

Ну и еще нужно отметить, что существуют и аргументы против подобной практики.

Узнаем путь

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

Эта строка пытается определить директорию расположения скрипта.

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

Но если наша CI-система запустит скрипт вот так:

/opt/ci/project/script.sh

то наш скрипт сработает не в директории проекта, а в каком-то совершенно другом месте. Мы можем исправить это, перейдя по нужному пути перед выполнением скрипта:

cd /opt/ci/project && ./script.sh

Но гораздо лучше решить эту проблему на стороне скрипта. Итак, если скрипт читает какой-то файл или запускает другую программу из той же директории, где лежит он сам, он будет делать это так:

cat "$script_dir/my_file"

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

Прибираемся

trap cleanup SIGINT SIGTERM ERR EXIT

cleanup() {
  trap - SIGINT SIGTERM ERR EXIT
  # script cleanup here
}

Относитесь к 'trap' как к блоку finally для скрипта. В конце работы скрипта — при нормальном завершении, либо из-за ошибки или внешнего сигнала — будет выполнена функция cleanup (). Это место, где вы можете, например, попытаться удалить все временные файлы, созданные скриптом.

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

Отображаем справочную информацию

usage() {
  cat <

Располагая usage () где-то в начале скрипта, мы одним выстрелом убиваем двух зайцев:

  1. Отображаем справочную информацию для пользователя, который хочет узнать все возможные и необходимые параметры для его запуска и не хочет изучать весь скрипт чтобы разобраться в них;

  2. Имеем минимальную документацию на тот случай, когда кто-то будет вносить изменения в скрипт (например, даже вы сами две недели спустя, уже не помня, что и как было сделано).

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

Красивый вывод сообщений

setup_colors() {
  if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
    NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
  else
    NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
  fi
}

msg() {
  echo >&2 -e "${1-}"
}

Тут нужно отметить несколько вещей:

Во-первых, удалите функцию setup_colors (), если вы все равно не хотите использовать вывод разными цветами. Я оставил ее в скрипте просто потому, что лично я бы использовал цвета гораздо чаще, если бы не приходилось каждый раз гуглить их коды.

Во-вторых, эти цвета предназначены для использования только с моей функцией msg (), а не с командой echo.

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

Пример использования:

msg "This is a ${RED}very important${NOFORMAT} message, but not a script output value!"

Чтобы проверить, как он себя ведет, когда stderr не является интерактивным терминалом, добавьте в скрипт строку, подобную приведенной выше. Затем запустите его, перенаправив stderr на stdout и подключив его к cat. В результате операции конвейера вывод больше не отправляется непосредственно на терминал, а отправляется следующей команде, поэтому теперь вы не увидите цветов, но и не увидите никакого мусора:

image-loader.svg

Парсинг параметров

parse_params() {
  # default values of variables set from params
  flag=0
  param=''

  while :; do
    case "${1-}" in
    -h | --help) usage ;;
    -v | --verbose) set -x ;;
    --no-color) NO_COLOR=1 ;;
    -f | --flag) flag=1 ;; # example flag
    -p | --param) # example named parameter
      param="${2-}"
      shift
      ;;
    -?*) die "Unknown option: $1" ;;
    *) break ;;
    esac
    shift
  done

  args=("$@")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

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

Есть три основых типа параметров CLI: флаги, именнованные параметр и позиционные аргументы. Все они поддерживаются функцией parse_params ().

Единственный из распространенных видов параметров, который здесь не обрабатывается — это объединение нескольких однобуквенных флагов. Если вам нужно иметь возможность передавать два флага как -ab вместо -a -b, придется дописать немного кода.

Цикл while — это разбор параметров вручную. На любом другом языке вы бы использовали какой-нибудь встроенный парсер или библиотеку, но это Bash, поэтому как есть, так есть.

В шаблоне уже для примера объявлен флаг (-f) и именованный параметр (-p). Просто измените или скопируйте их, чтобы добавить другие параметры. И не забудьте после этого обновить usage () :)

Здесь есть важный момент, который обычно упускается, когда вы просто берете первый попавшийся результат запроса в гугл для парсинга аргументов Bash — это выдача ошибки при неизвестном параметре. Тот факт, что скрипт получил неизвестную опцию, означает, что пользователь либо ошибся, либо хотел, чтобы скрипт сделал что-то, что скрипт не может выполнить. Таким образом, ожидания пользователей и поведение скрипта могут быть совершенно разными. Лучше предотвратить это до того, как случится что-то плохое.

В Bash есть две альтернативы ручному парсингу параметров. Это 'getopt' и 'getopts'. Есть аргументы как за, так и против их использования. Я считаю эти функции не самым лучшим вариантом, поскольку по умолчанию 'getopt' в macOS ведет себя не так на других системах, а 'getopts' не поддерживает длинные параметры (например, --help).

Использование шаблона

Просто скопипасте его, как вы это делаете с большей частью кода, что вы находите в интернете.

После того, как вы его скопируете, вам нужно изменить только 4 вещи:

  1. текст usage () с описанием скрипта;

  2. содержимое cleanup ();

  3. параметры в parse_params () — оставьте --help и --no-color, но замените -f и -p (это примеры)

  4. основную логику скрипта :)

Переносимость

Я тестировал шаблон на MacOS (где по дефолту древний Bash 3.2) и нескольких образах Docker: Debian, Ubuntu, CentOS, Amazon Linux, Fedora. Всё работает.

Очевидно, что он не будет работать в средах без Bash, таких как Alpine Linux. Alpine, как минималистичная система, использует легковесный ash.

Вы можете спросить, не лучше ли использовать Bourne Shell-совместимый скрипт, который будет работать почти везде. Ответ, по крайней мере, в моем случае — нет. Bash безопаснее и мощнее (пусть не прост в использовании), поэтому я могу смириться с отсутствием поддержки нескольких дистрибутивов Linux, с которыми мне редко приходится иметь дело.

Внеклассное чтение

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

И в заключение

Я не первый и не последний, кто создал шаблон скрипта Bash. Хорошей альтернативой является этот проект, хоть он и слишком большой для моих повседневных нужд. В конце концов, я стараюсь делать bash-скрипты как можно компактнее.

При написании скриптов Bash используйте среду IDE, которая поддерживает линтер ShellCheck, например IDE JetBrains. Это не даст вам наворотить кучу вещей, которые могут привести к неприятным последствиям.

Мой шаблон Bash-скрипта также доступен как GitHub Gist (под лицензией MIT)

© Habrahabr.ru