Представьте, что вам надо поднять какую-нибудь continuous integration систему. Распространённые решения (BuildBot, Jenkins, TravisCI, ...) — относительно монструозные сложные системы, заточенные под запуск недоверенного кода в изолированном окружении. Зайти на slave и поотлаживать скрипт запуска — не дадут. Кроме того, даже те же современные версии BuildBot интерфейса уже являются web-приложением, а не HTML страницами, что серьёзное неудобство.
А можно что-нибудь попроще и полегче? И чтобы было кроссплатформенным: GNU/Linux далеко не единственная платформа на которой, к сожалению, приходится проверять работоспособность различного софта.
BASS: Build Automation Steady System — Simple as bass guitar with only a few strings, yet as powerful!
Что нам необходимо? Некий master сервер, центральная точка, раздающая задачи. Slave-ы, забирающие задачи, выполняющие и публикующие результаты работы. Задачей (task), в общем случае, является shell-скрипт, описывающий что надо проделать (slave input). Результат выполнения задачи (job) — директория со всякими журналами, итогами тестов и артефактами сборки.
Для всего этого не нужно никаких баз данных, сетевого API/RPC протокола, отдельного менеджера очередей/блокировок или чего-либо подобного. Достаточно сетевой файловой системы NFS. А если запуск предполагается на локальном компьютере (для отладки), то никто не мешает и без неё обойтись, а только nullfs с изолированными Jail-ами например.
Соответственно, на master узле у нас должен быть некий task-maker: создатель task-ов. Task директория содержит:
Сжатие архивов желательно для того, чтобы по сети передавать меньше данных, экономя время на запуск всего этого. Конечно же, целесообразен Zstandard, обладающий безумной скоростью декомпрессии.
Имя task директории: NUM:PROJ:VERSION:ARCH[:HOST]. Указываются порядковый номер задачи, проект, версия, архитектура (процессор/ОС), Опционально имя хоста для целенаправленного запуска на нём.
$TASKS директория содержит:
Job директория содержит:
Как без СУБД или какого-то другого арбитра, slave может единолично взять задачу на обработку? На POSIX совместимых файловых системах, в том числе и на NFS, есть атомарная операция: mkdir. Только один slave сможет создать $JOBS/$CTR:task. Аналогично можно атомарно инкрементировать значение счётчика задач на master сервере: пытаться сделать mkdir $CTR+1, пока у нас это не выйдет. Всё это пока позволяет совершенно обходиться без чего-либо кроме shell-скриптов.
Благодаря возможности создания нескольких параллельных соединений к серверу NFS, можно утилизировать 10+GbE каналы связи, не упираясь в одно ядро процессора или TCP соединение.
Давайте сделаем task-maker для некоего проекта, например goredo.
Подготовим директории на NFS сервере. Кстати, NFS сервер не обязательно должен быть master-ом: скрипты task-maker могут исполняться где угодно.
Создадим Git post-receive hook, создающий файлы с хэшом коммита запушенным:
Затем пишем сам task-maker, специфичный для каждого проекта, так как правила сборки/тестирования могут существенно отличаться.
Скрипт ожидает ряд выставленных переменных окружения с названием проекта, архитектур и прочего. Почему в нём не делается просто «cp -a» для копирования задачи для каждой архитектуры? Потому что это займёт много места. clone-with-ctr создаёт жёсткие ссылки, конечно же с атомарно инкрементирующимся (на основе mkdir $CTR+1) значением счётчика. Кроме того, он делает fsync, гарантируя, что если задача окажется в $TASKS/cur/, то значит она там точно оказалась, а не осела в буферах файловой системы.
А если вам надо создавать задачи сборки/тестирования не по push в Git, а например каждую ночь? Вызывайте task-maker по cron, как заблагорассудится!
Шаги сборки steps.tar могут быть очень простыми:
На slave выполняется task-taker скрипт. Он периодически проверяет подмонтированную по NFS директорию $TASKS и атомарно (mkdir) берёт задачи под стать своей архитектуре. Хочется на мощном slave запустить несколько задач параллельно? Запускаем несколько task-taker!
Этот скрипт, в свою очередь, для взятой задачи запускает job-starter.
Наличие tmux-а очень удобно: можно удалённо подключиться к slave и воочию посмотреть что же там в конкретной задаче происходит. Если задача не выполнилась успешно (какой-то шаг (step) упал), то tmux остаётся висеть в фоне в течении какого-то времени — к нему можно подключиться и заняться отладкой буквально прямо в том же самом окружении, где всё и запускалось. Иначе он уничтожается вместе с временной директорией для очистки ресурсов.
Для просмотра результатов тестов и вообще происходящих процессов, имеется reporter скрипт на Z shell (уж очень удобно на нём с файлами работать). Это web-сервер, отображающий dashboard похожий на BuildBot вывод: кто запущен, где, когда, жив/мёртв, какие шаги выполнены/выполняются, сколько занимают по времени, ссылки на их stdout/stderr, ссылка для подключения к tmux-у. Вся эта информация генерируется на основе plaintext файлов и их mtime из NFS директории.
notify-non-started и notify-non-taken shell-скрипты на пару десятков строчек кода, позволяют оповещать о не взятых или не запускаемых задачах.
Таким образом, нам нужно иметь постоянно запущенные демоны: task-maker, task-taker, reporter, опционально notify-non-started, notify-non-taken. Для их запуска нужны среди кросс-платформенных решений выделяется простотой daemontools, а также схожие (а то и совместимые): runit, s6. Использовать daemontools не обязательно (любители современных GNU/Linux могут хоть systemd применять), но их «run» скрипты будут, скорее всего, идентичны и на BSD системах и на GNU/Linux.
Раз мы заводим разговор о кросс-платформенности, то рождается вопрос: а как собирать окружение для тестирования проектов на Python, Go, и т.д.? Далеко не всё может быть в родном системной пакетном менеджере ОС, как и возможность установки разных конфликтующих версий.
Необходим кросс-платформенный пакетный менеджер, система сборки, которая могла бы существовать в своей изолированной от основной ОС директории. Единственный кросс-платформенный пакетный менеджер мне известный: NetBSD's pkgsrc. Тот же Nix уже давно перестал работать на FreeBSD. Но, судя по документации, pkgsrc не тривиально заставить работать в пределах директории, что является проблемой.
В чём заключается процесс сборки программы? Как-то добыть исходный код. Распаковать его во временную директорию, выполнить конфигурацию, сборку, установку, «опакечивание».
Как можно «установить» программу в несколько разных директорий? В преобладающем большинстве случаев — с помощью символических ссылок. GNU Stow менеджер символических ссылок превосходно помогает в этом.
Много софта вшивает установочные пути во время сборки. Поэтому, как это делается и в Nix, мы вынуждены устанавливать софт в перманентные пути.
Например, мы установим Perl в: /perm/perl-5.32.1-zP3IpCa_XY7pGHCNYQxp_1KjQQNCyUl84LqSrWLErjA где хэш после имени это криптографический хэш от всякой метаинформации конкретной сборки. Создадим /tmp/tmp.whatever и попросим GNU Stow установить нам Perl и GNU Make. Будут сделаны следующие ссылки:
Достаточно добавить local/bin директорию в $PATH, local/lib в $LD_LIBRARY_PATH и в преобладающем большинстве случаев этого будет достаточно. В tmp.whatever environment-е будут как-бы локально установленные версии Perl и Make.
Скрипт описывающий процесс сборки программы называется «skel»-ом (сокращение от skeleton). Бинарный результат работы skel-а это установленный софт, называемый skelbin.
skel-ы, когда BASS только писался, были действительно просто pure POSIX shell скриптами. Однако, когда между ними начали появляться зависимости, то как-то необходимо было управлять lock-ами между процессами сборки и понимать что у нас собрано, а что нет. Всё это идеальная задача для такой системы как redo. Перейдя на его использование, полностью решаются все эти проблемы, плюс появляется возможность распараллеливать процесс сборки. Поэтому теперь skel это redo цель.
Необходим баланс между простотой/минимальностью и порогом вхождения, когнитивной нагрузкой. Баланс между simplicity и easiness of usage. Например, процесс сборки многих программ на Си сводится к процедуре:
и можно было бы написать вспомогательную команду/функцию типа
Но я осознанно отказываюсь от этого. Как только кто-либо, незнакомый с системой, видит очередную неизвестную прежде команду, то вынужден идти в документацию/код и выяснять что же это за магия тут выполняется. Когда я вижу вызов штатных всем известных команд и простейший процесс установки, как я бы его выполнил вручную — то нагрузка почти отсутствует, всё ясно и понятно, пускай и будет много copy-paste между разными skel-ами. Необходим только разумный минимум.
Как выглядит простейший skel?
Первые три строчки это copy-paste идентичный для всех skel-ов, выставляющий немногочисленные переменные окружения. Далее создаётся директория для установки, находящаяся в перманентном $SKELBINS. Туда копируется hw.pl утилита. А дальше вызывается вспомогательный скрипт создания бинарного пакета.
skelbin может содержать тысячи файлов, иметь особенности при работе через NFS, плюс нужны сопутствующие метаданные. Если какой-то skel хочет зависеть от другого, то хотелось бы указывать человекочитаемое имя, а не весь $name-$hash, при смене которого бы пришлось обновлять абсолютно все зависимые skel.
По аналогии с Arch Linux и Gentoo, создан формат пакета: skelpkg. Это POSIX pax архив с файлами:
Так как это tar архив, то у него нет индекса с файлами, а значит критично иметь самый тяжёлый bin файл в конце архива. Вся метаинформация может быть найдена в начале архива. Поэтому сам skelpkg, в отличии от входящего в него bin, не сжат. bin же по умолчанию сжимается Zstandard-ом.
.meta4 это Metalink4 XML, где содержатся контрольные суммы. Для удовлетворения сертифицирующих органов РФ, наряду с быстрыми BLAKE*/Skein, есть и Стрибог.
Чтобы сборка была воспроизводимой (reproducible), кроме корректных флагов сборки, нам также необходим и воспроизводимо создаваемый архив. В общем случае, ни GNU tar, ни libarchive-based BSD tar не позволяют этого в полной мере сделать, разве что только для ustar формата. Поэтому написана build/contrib/detpax утилита на Go, для того, чтобы отсортированно, с фиксированным timestamp, без указания владельцев, создать pax архив. В ней есть возможность указания приоритета сортировки файлов/директорий, что позволяет skelpkg поддиректорию с hook-ами разместить в начале архива, относительно дёшево узнавая какие у него есть зависимости и особые действия при установке/удалении.
Сборка одного и того же skel может происходить для разных архитектур. Для этого мы делаем pkg/$ARCH директорию для их дифференциации, в которую жёсткими ссылками помещаем наши skel-ы.
В данном примере, parallel-20240122 будет собранным skelpkg.
В общем случае, установка пакета заключается просто в его распаковке в $SKELBINS. Но могут потребоваться и дополнительные шаги. Поэтому есть возможность запуска «pre install» (preinst), «post install» (postinst), «pre remove» (prerm), «post remove» (postrm) hook-ов. Hook это директория, содержащая хотя бы один исполняемый файл. Имена директорий лексикографически отсортированы. Размещаются внутри skelpkg в $NAME-$hsh/skelpkg/$NAME-$hsh/hooks/$hook. Одним из самых часто используемых hook-ов является вызов команды установки зависимых пакетов:
Установка пакетов, а точнее создание символических ссылок до распакованных skelbin-ов, предполагается проводить в отдельной директории, называемой skelenv (environment). В ней есть, как минимум, local/ поддиректория, куда и помещаются ссылки. Зачастую также есть и «rc» файл, меняющий переменные окружения для «включения» skelenv-а.
Например, установка пакета pkgconf может добавить в этот skelenv/rc файл дополнительные переменные окружения:
Даже изначальное создание skelenv/rc файла делается установкой rc-paths пакета, не содержащего ничего, кроме hook:
Конкретно для данного пакета применяется не Zstandard сжатие, а gzip. К сожалению, до сих пор существуют дистрибутивы не поддерживающие из коробки zstd. Его можно установить через skelpkg, но для этого его надо сперва чем-то распаковать.
pkg-inst команда устанавливает пакет:
При этом, в skelenv/skelpkgs/$PKG директории сохраняются:
Наконец, есть вспомогательная команда mk-skelenv:
Сборка ОБЯЗАНА не зависеть от доступности Интернета. Исходный код должен в полной мере присутствовать заранее скачанным. В преобладающем большинстве случаев это сводится к скачиванию tarball, а также возможно сопутствующих криптографических подписей. Обязательна проверка целостности скачанных данных. Поэтому, по умолчанию, цель для скачивания tarball-ов ожидает наличия соответствующего Metalink4 файла, в котором будут ссылки (в том числе на зеркала) для скачивания и контрольные суммы. Выполняя цель redo-ifchange "$DISTFILES"/parallel-20240122.do.tar.bz2 мы ищем parallel-20240122.do.tar.bz2.meta4 файл, а дальше запускаем либо aria2c, либо wget, либо meta4ra-check утилиты для скачивания.
meta4ra утилиты позволяют создавать, скачивать и проверять .meta4.
А если надо сделать tarball из коммита Git-репозитория?
Скачать исходный код для всех пакетов?
Для того, чтобы передать на другую машину (изолированную от Интернета или вообще сетей) всё скачанное, удобно бы было оформить архив, без ненужных склонированных репозиториев:
Сборочная система требует:
Для удобства сборки некоторых из этих зависимостей, включая Go, имеется contrib/prepare-deps скрипт, скачивающий их из Интернета и собирающий в local/ директории. Достаточно лишь будет добавить local/bin в $PATH. А contrib/go-debash содержат скрипты для избавления Go от bash зависимости.
Проект BASS, как инструмент для создания воспроизводимых сборок у нас с коллегами активно применяется больше года, собирая массу ПО, включая Python зависимости для virtualenv. Целевыми системами были Astra Linux и FreeBSD — с минимумом if-ов в skel-ах.
Как таковых release tarball-ов проекта нет — всё лежит в Git репозитории. В наличии более двухсот skel. Конечно же, всё это свободное программное обеспечение.
А можно что-нибудь попроще и полегче? И чтобы было кроссплатформенным: GNU/Linux далеко не единственная платформа на которой, к сожалению, приходится проверять работоспособность различного софта.
BASS
BASS: Build Automation Steady System — Simple as bass guitar with only a few strings, yet as powerful!
Что нам необходимо? Некий master сервер, центральная точка, раздающая задачи. Slave-ы, забирающие задачи, выполняющие и публикующие результаты работы. Задачей (task), в общем случае, является shell-скрипт, описывающий что надо проделать (slave input). Результат выполнения задачи (job) — директория со всякими журналами, итогами тестов и артефактами сборки.
Для всего этого не нужно никаких баз данных, сетевого API/RPC протокола, отдельного менеджера очередей/блокировок или чего-либо подобного. Достаточно сетевой файловой системы NFS. А если запуск предполагается на локальном компьютере (для отладки), то никто не мешает и без неё обойтись, а только nullfs с изолированными Jail-ами например.
┌───┐ ┌──────────┐ ┌───┐ ┌──────────┐ ┌───────────┐ │git│ │task-maker│ │NFS│ │task-taker│ │job-starter│ └─┬─┘ └────┬─────┘ └─┬─┘ └────┬─────┘ └─────┬─────┘ │ revs/$COMMIT │ │ │ │───────────────────────────────────>│ │ │ │ │ │ │ │ │ │ revs/$COMMIT │ │ │ │ │ <────────────────│ │ │ │ │ │ │ │ │ │ mkdir $CTR/+1 │ │ │ │ │ ────────────────>│ │ │ │ │ │ │ │ │ │ rc=0 │ │ │ │ │ <────────────────│ │ │ │ │ │ │ │ │ │ $TASKS/$CTR:task │ │ │ │ │ ────────────────>│ │ │ │ │ │ │ │ │ │ │ $TASKS/$CTR:task │ │ │ │ │─────────────────────> │ │ │ │ │ │ │ │ │mkdir $JOBS/$CTR:task│ │ │ │ │<───────────────────── │ │ │ │ │ │ │ │ │ rc=0 │ │ │ │ │─────────────────────> │ │ │ │ │ │ │ │ │ │ $JOBS/$CTR:task │ │ │ │ │ ────────────────────>│ │ │ │ │ │ │ │ │ touch $JOBS/$CTR:task/alive │ │ │ │<───────────────────────────────────────────│ │ │ │ │ │ │ │ │ │ │────┐ │ │ │ │ │ │ "steps-runner" │ │ │ │ │<───┘ │ │ │ │ │ │ │ │ touch $JOBS/$CTR:task/finished │ │ │ │<───────────────────────────────────────────│ │ │ │ │ │ │ │ │ │ │
Соответственно, на master узле у нас должен быть некий task-maker: создатель task-ов. Task директория содержит:
- code.tar, code-revision.txt, code-version.txt файлы — сжатый tarball с кодом, который вы хотите протестировать. Зачастую, он будет сделан каким-нибудь «git archive».
- steps.tar, steps-revision.txt, steps-version.txt — архив со скриптами тестирования.
Сжатие архивов желательно для того, чтобы по сети передавать меньше данных, экономя время на запуск всего этого. Конечно же, целесообразен Zstandard, обладающий безумной скоростью декомпрессии.
Имя task директории: NUM:PROJ:VERSION:ARCH[:HOST]. Указываются порядковый номер задачи, проект, версия, архитектура (процессор/ОС), Опционально имя хоста для целенаправленного запуска на нём.
$TASKS директория содержит:
- ctr/ — атомарно инкрементирующийся счётчик задач.
- tmp/ — временная директория для создаваемых task, позже перенося в cur/.
- cur/ — готовые для взятия slave-ами задачи.
- old/ — архивные задачи, не взятые, завершённые.
Job директория содержит:
- alive — файл с постоянно обновляемым значением mtime (touch alive), чтобы понимать, что задача «жива».
- host.txt — имя slave, взявшего задачу.
- tmp-path.txt — путь к временной директории на slave, где задача выполняется.
- pkg.txt — список установленных пакетов.
- env.txt — список выставленных переменных окружения.
- steps/ — содержит поддиректории названные по имени step из steps.tar. Каждая такая поддиректория содержит:
- started — пустой файл с mtime-ом равным времени запуска шага.
- stdout.txt, stderr.txt — stdout/stderr шага.
- exitcode.txt — создаётся после завершения шага. Содержит или ASCII decimal значение кода возврата или «timeout» строчку, если шаг выполнялся слишком долго.
Как без СУБД или какого-то другого арбитра, slave может единолично взять задачу на обработку? На POSIX совместимых файловых системах, в том числе и на NFS, есть атомарная операция: mkdir. Только один slave сможет создать $JOBS/$CTR:task. Аналогично можно атомарно инкрементировать значение счётчика задач на master сервере: пытаться сделать mkdir $CTR+1, пока у нас это не выйдет. Всё это пока позволяет совершенно обходиться без чего-либо кроме shell-скриптов.
Благодаря возможности создания нескольких параллельных соединений к серверу NFS, можно утилизировать 10+GbE каналы связи, не упираясь в одно ядро процессора или TCP соединение.
task-maker
Давайте сделаем task-maker для некоего проекта, например goredo.
Подготовим директории на NFS сервере. Кстати, NFS сервер не обязательно должен быть master-ом: скрипты task-maker могут исполняться где угодно.
$ mkdir -p /nfs/revs/goredo $ mkdir -p /nfs/tasks/ctr/0 $ mkdir -p /nfs/tasks/{cur,old,tmp} $ mkdir /nfs/jobs
Создадим Git post-receive hook, создающий файлы с хэшом коммита запушенным:
$ cat >goredo.git/hooks/post-receive <<EOF #!/bin/sh -e REVS=/nfs/revs/goredo ZERO="0000000000000000000000000000000000000000" read prev curr ref [ "$curr" != $ZERO ] || exit 0 [ "$prev" != $ZERO ] || prev=$curr^ git rev-list $prev..$curr | while read rev ; do mkdir -p $REVS/$ref echo BASSing $ref/$rev... >&2 touch $REVS/$ref/$rev done EOF
Затем пишем сам task-maker, специфичный для каждого проекта, так как правила сборки/тестирования могут существенно отличаться.
#!/bin/sh -e sname="$0" . $BASS_ROOT/lib/rc [ -n "$REVS" ] [ -n "$PROJ" ] [ -n "$STEPS" ] [ -n "$ARCHS" ] cd $REVS rev=$(find . -type f | sed -n 1p) [ -n "$rev" ] rev_path=$(realpath $rev) rev=$(basename $rev) task_proj=goredo task_version=$(cd $PROJ ; $BASS_ROOT/master/bin/version-for-git $rev) [ -n "$task_version" ] task=":$task_proj:$task_version:" mkdir $TASKS/tmp/$task trap "rm -fr $TASKS/tmp/${task}*" HUP PIPE INT QUIT TERM EXIT cd $STEPS $BASS_ROOT/master/bin/version-for-git >$TASKS/tmp/$task/steps-version.txt git rev-parse >$TASKS/tmp/$task/steps-revision.txt # $TAR cf - --posix * | $COMPRESSOR >$TASKS/tmp/$task/steps.tar git archive | $COMPRESSOR >$TASKS/tmp/$task/steps.tar cd $PROJ echo $task_version >$TASKS/tmp/$task/code-version.txt git show --no-patch --pretty=fuller $rev >>$TASKS/tmp/$task/code-version.txt echo $rev >$TASKS/tmp/$task/code-revision.txt git archive $rev | $COMPRESSOR >$TASKS/tmp/$task/code.tar tasks=$($BASS_ROOT/master/bin/clone-with-ctr $task $(for arch in $ARCH ; do echo ${task}${arch} ; done)) [ -n "$tasks" ] for t in $tasks ; do echo $t mv $t ../cur done rm $rev_path
Скрипт ожидает ряд выставленных переменных окружения с названием проекта, архитектур и прочего. Почему в нём не делается просто «cp -a» для копирования задачи для каждой архитектуры? Потому что это займёт много места. clone-with-ctr создаёт жёсткие ссылки, конечно же с атомарно инкрементирующимся (на основе mkdir $CTR+1) значением счётчика. Кроме того, он делает fsync, гарантируя, что если задача окажется в $TASKS/cur/, то значит она там точно оказалась, а не осела в буферах файловой системы.
А если вам надо создавать задачи сборки/тестирования не по push в Git, а например каждую ночь? Вызывайте task-maker по cron, как заблагорассудится!
Шаги сборки steps.tar могут быть очень простыми:
-- steps/00prerequisites -- #!/bin/sh -ex cd .. $BASS_ROOT/build/bin/pkg-inst go-stringer-0.18.0 sharness-1.2.0 go1.22.6 perl-5.32.1 -- steps/01stringer -- #!/bin/sh -ex go generate -- steps/02build -- #!/bin/sh -ex go build ./goredo -symlinks -- steps/03t -- #!/bin/sh -ex PATH="$(realpath .):$PATH" export SHARNESS_TEST_SRCDIR="$(realpath ../local/share/sharness)" cd t prove .
task-taker
На slave выполняется task-taker скрипт. Он периодически проверяет подмонтированную по NFS директорию $TASKS и атомарно (mkdir) берёт задачи под стать своей архитектуре. Хочется на мощном slave запустить несколько задач параллельно? Запускаем несколько task-taker!
Этот скрипт, в свою очередь, для взятой задачи запускает job-starter.
- code.tar и steps.tar распаковываются в $JOBS/cur/$task.
- В фоне запускается heartbeat процесс, делающий ежесекундный «touch $job/alive».
- Порождается tmux, внутри которого выполняются steps.
- Для каждого шага запускается фоновый процесс проверки наличия прогресса в журналах. Если нет никакого вывода в течении длительного времени, то процесс убивается с пометкой timeout в его $job/steps/$step/exitcode.txt.
- stdout/stderr вывод сохраняется не просто так, а прогоняя через tai64n утилиту, добавляющую TAI64 временной штамп к каждой строчке вывода.
Наличие tmux-а очень удобно: можно удалённо подключиться к slave и воочию посмотреть что же там в конкретной задаче происходит. Если задача не выполнилась успешно (какой-то шаг (step) упал), то tmux остаётся висеть в фоне в течении какого-то времени — к нему можно подключиться и заняться отладкой буквально прямо в том же самом окружении, где всё и запускалось. Иначе он уничтожается вместе с временной директорией для очистки ресурсов.
Демоны
Для просмотра результатов тестов и вообще происходящих процессов, имеется reporter скрипт на Z shell (уж очень удобно на нём с файлами работать). Это web-сервер, отображающий dashboard похожий на BuildBot вывод: кто запущен, где, когда, жив/мёртв, какие шаги выполнены/выполняются, сколько занимают по времени, ссылки на их stdout/stderr, ссылка для подключения к tmux-у. Вся эта информация генерируется на основе plaintext файлов и их mtime из NFS директории.
notify-non-started и notify-non-taken shell-скрипты на пару десятков строчек кода, позволяют оповещать о не взятых или не запускаемых задачах.
Таким образом, нам нужно иметь постоянно запущенные демоны: task-maker, task-taker, reporter, опционально notify-non-started, notify-non-taken. Для их запуска нужны среди кросс-платформенных решений выделяется простотой daemontools, а также схожие (а то и совместимые): runit, s6. Использовать daemontools не обязательно (любители современных GNU/Linux могут хоть systemd применять), но их «run» скрипты будут, скорее всего, идентичны и на BSD системах и на GNU/Linux.
Сборка зависимостей
Раз мы заводим разговор о кросс-платформенности, то рождается вопрос: а как собирать окружение для тестирования проектов на Python, Go, и т.д.? Далеко не всё может быть в родном системной пакетном менеджере ОС, как и возможность установки разных конфликтующих версий.
Необходим кросс-платформенный пакетный менеджер, система сборки, которая могла бы существовать в своей изолированной от основной ОС директории. Единственный кросс-платформенный пакетный менеджер мне известный: NetBSD's pkgsrc. Тот же Nix уже давно перестал работать на FreeBSD. Но, судя по документации, pkgsrc не тривиально заставить работать в пределах директории, что является проблемой.
В чём заключается процесс сборки программы? Как-то добыть исходный код. Распаковать его во временную директорию, выполнить конфигурацию, сборку, установку, «опакечивание».
Как можно «установить» программу в несколько разных директорий? В преобладающем большинстве случаев — с помощью символических ссылок. GNU Stow менеджер символических ссылок превосходно помогает в этом.
Много софта вшивает установочные пути во время сборки. Поэтому, как это делается и в Nix, мы вынуждены устанавливать софт в перманентные пути.
Например, мы установим Perl в: /perm/perl-5.32.1-zP3IpCa_XY7pGHCNYQxp_1KjQQNCyUl84LqSrWLErjA где хэш после имени это криптографический хэш от всякой метаинформации конкретной сборки. Создадим /tmp/tmp.whatever и попросим GNU Stow установить нам Perl и GNU Make. Будут сделаны следующие ссылки:
/tmp/tmp.whatever/local/bin/gmake -> /perm/gmake-4.4-$hsh1/bin/gmake /tmp/tmp.whatever/local/bin/perl5 -> /perm/perl5-$hsh0/bin/perl5 /tmp/tmp.whatever/local/lib/site_perl -> /perm/perl5-$hsh0/lib/site_perl /tmp/tmp.whatever/local/share/info -> /perm/gmake-4.4-$hsh1/share/info
Достаточно добавить local/bin директорию в $PATH, local/lib в $LD_LIBRARY_PATH и в преобладающем большинстве случаев этого будет достаточно. В tmp.whatever environment-е будут как-бы локально установленные версии Perl и Make.
skel и skelbin
Скрипт описывающий процесс сборки программы называется «skel»-ом (сокращение от skeleton). Бинарный результат работы skel-а это установленный софт, называемый skelbin.
skel-ы, когда BASS только писался, были действительно просто pure POSIX shell скриптами. Однако, когда между ними начали появляться зависимости, то как-то необходимо было управлять lock-ами между процессами сборки и понимать что у нас собрано, а что нет. Всё это идеальная задача для такой системы как redo. Перейдя на его использование, полностью решаются все эти проблемы, плюс появляется возможность распараллеливать процесс сборки. Поэтому теперь skel это redo цель.
Необходим баланс между простотой/минимальностью и порогом вхождения, когнитивной нагрузкой. Баланс между simplicity и easiness of usage. Например, процесс сборки многих программ на Си сводится к процедуре:
$ ./configure --prefix=$SKELBINS/... && make && make install
и можно было бы написать вспомогательную команду/функцию типа
$ do-standard-configure-make-make-install-procedure
Но я осознанно отказываюсь от этого. Как только кто-либо, незнакомый с системой, видит очередную неизвестную прежде команду, то вынужден идти в документацию/код и выяснять что же это за магия тут выполняется. Когда я вижу вызов штатных всем известных команд и простейший процесс установки, как я бы его выполнил вручную — то нагрузка почти отсутствует, всё ясно и понятно, пускай и будет много copy-paste между разными skel-ами. Необходим только разумный минимум.
Как выглядит простейший skel?
[ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../../.. sname=$1.do . "$BASS_ROOT"/lib/rc . "$BASS_ROOT"/build/skel/common.rc mkdir -p "$SKELBINS"/$ARCH/$NAME/bin cd "$SKELBINS"/$ARCH cp ~/src/misc/hw/hw.pl $NAME/bin "$BASS_ROOT"/build/lib/mk-pkg $NAME
Первые три строчки это copy-paste идентичный для всех skel-ов, выставляющий немногочисленные переменные окружения. Далее создаётся директория для установки, находящаяся в перманентном $SKELBINS. Туда копируется hw.pl утилита. А дальше вызывается вспомогательный скрипт создания бинарного пакета.
skelpkg
skelbin может содержать тысячи файлов, иметь особенности при работе через NFS, плюс нужны сопутствующие метаданные. Если какой-то skel хочет зависеть от другого, то хотелось бы указывать человекочитаемое имя, а не весь $name-$hash, при смене которого бы пришлось обновлять абсолютно все зависимые skel.
По аналогии с Arch Linux и Gentoo, создан формат пакета: skelpkg. Это POSIX pax архив с файлами:
- name, name.meta4 — полное имя ($name-$hash) директории пакета после распаковки
- buildinfo, buildinfo.meta4 — различная текстовая информации о сборке
- bin.meta4, bin — ещё один PAX архив, в котором и находится $name-$hash директория с содержимым skelbin
Так как это tar архив, то у него нет индекса с файлами, а значит критично иметь самый тяжёлый bin файл в конце архива. Вся метаинформация может быть найдена в начале архива. Поэтому сам skelpkg, в отличии от входящего в него bin, не сжат. bin же по умолчанию сжимается Zstandard-ом.
.meta4 это Metalink4 XML, где содержатся контрольные суммы. Для удовлетворения сертифицирующих органов РФ, наряду с быстрыми BLAKE*/Skein, есть и Стрибог.
Чтобы сборка была воспроизводимой (reproducible), кроме корректных флагов сборки, нам также необходим и воспроизводимо создаваемый архив. В общем случае, ни GNU tar, ни libarchive-based BSD tar не позволяют этого в полной мере сделать, разве что только для ustar формата. Поэтому написана build/contrib/detpax утилита на Go, для того, чтобы отсортированно, с фиксированным timestamp, без указания владельцев, создать pax архив. В ней есть возможность указания приоритета сортировки файлов/директорий, что позволяет skelpkg поддиректорию с hook-ами разместить в начале архива, относительно дёшево узнавая какие у него есть зависимости и особые действия при установке/удалении.
Сборка одного и того же skel может происходить для разных архитектур. Для этого мы делаем pkg/$ARCH директорию для их дифференциации, в которую жёсткими ссылками помещаем наши skel-ы.
$ cd build $ pkg/mk-arch $ARCH0 $ pkg/mk-arch $ARCH1 $ redo pkg/FreeBSD-whatever/sysutils/parallel-20240122 $ redo pkg/GNU_Linux-whatever/sysutils/parallel-20240122
В данном примере, parallel-20240122 будет собранным skelpkg.
hooks
В общем случае, установка пакета заключается просто в его распаковке в $SKELBINS. Но могут потребоваться и дополнительные шаги. Поэтому есть возможность запуска «pre install» (preinst), «post install» (postinst), «pre remove» (prerm), «post remove» (postrm) hook-ов. Hook это директория, содержащая хотя бы один исполняемый файл. Имена директорий лексикографически отсортированы. Размещаются внутри skelpkg в $NAME-$hsh/skelpkg/$NAME-$hsh/hooks/$hook. Одним из самых часто используемых hook-ов является вызов команды установки зависимых пакетов:
$ tar xfO $SKELPKGS/$ARCH/curl-8.6.0 name | read namenhash $ tar xfO $SKELPKGS/$ARCH/curl-8.6.0 bin | tar tf - $namenhash/skelpkg/$namenhash/hooks/preinst $namenhash/skelpkg/$namenhash/hooks/preinst/010-rdeps $ tar xfO $SKELPKGS/$ARCH/curl-8.6.0 bin | tar xfO - $namenhash/skelpkg/$namenhash/hooks/preinst/010-rdeps #!/bin/sh -e exec "$BASS_ROOT"/build/bin/pkg-inst openssl-1.1.1w
skelenv
Установка пакетов, а точнее создание символических ссылок до распакованных skelbin-ов, предполагается проводить в отдельной директории, называемой skelenv (environment). В ней есть, как минимум, local/ поддиректория, куда и помещаются ссылки. Зачастую также есть и «rc» файл, меняющий переменные окружения для «включения» skelenv-а.
Например, установка пакета pkgconf может добавить в этот skelenv/rc файл дополнительные переменные окружения:
$ tar xfO $SKELPKGS/$ARCH/pkgconf-2.1.1 name | read namenhash $ tar xfO $SKELPKGS/$ARCH/pkgconf-2.1.1 bin | tar xfO - $namenhash/skelpkg/$namenhash/hooks/postinst/01rc-add #!/bin/sh -e _localpath="$(realpath local)" cat >>rc <<EOF PKG_CONFIG_PATH="$_localpath/lib/pkgconfig:\$PKG_CONFIG_PATH" PKG_CONFIG_PATH="$_localpath/libdata/pkgconfig:\$PKG_CONFIG_PATH" export PKG_CONFIG_PATH EOF
Даже изначальное создание skelenv/rc файла делается установкой rc-paths пакета, не содержащего ничего, кроме hook:
$ cat skel/rc-paths.do [ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../../.. sname=$1.do . "$BASS_ROOT"/lib/rc . "$BASS_ROOT"/build/skel/common.rc hsh=$("$BASS_ROOT"/build/bin/cksum $BASS_REV $SPATH) "$BASS_ROOT"/bin/rm-r "$SKELBINS"/$ARCH/$NAME-$hsh mkdir -p "$SKELBINS"/$ARCH/$NAME-$hsh cd "$SKELBINS"/$ARCH/$NAME-$hsh mkdir -p skelpkg/$NAME-$hsh/hooks/postinst cat >skelpkg/$NAME-$hsh/hooks/postinst/rc <<EOF _localpath="\$(realpath local)" PATH="\$_localpath/bin:\$_localpath/sbin:\$PATH" export MANPATH="\$_localpath/share/man:\$MANPATH" export INFOPATH="\$_localpath/share/info:\$INFOPATH" export LD_LIBRARY_PATH="\$_localpath/lib:\$LD_LIBRARY_PATH" export CFLAGS="-I\$_localpath/include \$CFLAGS" export CXXFLAGS="\$CFLAGS \$CXXFLAGS" export LDFLAGS="-L\$_localpath/lib \$LDFLAGS" EOF cat >skelpkg/$NAME-$hsh/hooks/postinst/01rc-inst <<EOF #!/bin/sh -e cp "\$SKELBINS"/\$ARCH/\$NAMENHASH/skelpkg/\$NAMENHASH/hooks/postinst/rc . chmod +w rc EOF chmod +x skelpkg/$NAME-$hsh/hooks/postinst/01rc-inst cd .. COMPRESSOR=gzip "$BASS_ROOT"/build/lib/mk-pkg $NAME-$hsh
Конкретно для данного пакета применяется не Zstandard сжатие, а gzip. К сожалению, до сих пор существуют дистрибутивы не поддерживающие из коробки zstd. Его можно установить через skelpkg, но для этого его надо сперва чем-то распаковать.
pkg-inst команда устанавливает пакет:
- проверяет есть ли нужный skelbin в $SKELBINS
- если нет, то распаковывает его из skelpkg
- запускает preinst hook
- запускает stow, для создания ссылок
- запускает postinst
При этом, в skelenv/skelpkgs/$PKG директории сохраняются:
- «lock» файл
- namenhash — точное значение $NAME-$hsh пакета
- $hook.done файлы, для понимания какие hook-и выполнились
Наконец, есть вспомогательная команда mk-skelenv:
% cat =mk-skelenv #!/bin/sh -e [ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../.. sname="$0" . "$BASS_ROOT"/lib/rc mkdir local mkdir local/service "$BASS_ROOT"/build/bin/pkg-inst rc-paths stow
Более сложный skel
$ cat skel/parallel-20240122.do [ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../../../.. sname=$1.do . "$BASS_ROOT"/lib/rc . "$BASS_ROOT"/build/skel/common.rc bdeps="rc-paths stow archivers/zstd devel/gmake-4.4.1" rdeps=lang/perl-5.32.1 redo-ifchange $bdeps "$DISTFILES"/$name.tar.bz2 $rdeps hsh=$("$BASS_ROOT"/build/bin/cksum $BASS_REV $spath) . "$BASS_ROOT"/build/lib/create-tmp-for-build.rc "$BASS_ROOT"/build/bin/pkg-inst $bdeps $rdeps . ./rc $TAR xf "$DISTFILES"/$name.tar.bz2 "$BASS_ROOT"/bin/rm-r "$SKELBINS"/$ARCH/$NAME-$hsh cd $NAME ./configure --prefix="$SKELBINS"/$ARCH/$NAME-$hsh --disable-documentation >&2 perl -i -ne 'print unless /^\s+citation_notice..;$/' src/parallel gmake -j$MAKE_JOBS >&2 gmake install >&2 cd "$SKELBINS"/$ARCH "$LIB"/prepare-preinst-010-rdeps $NAME-$hsh $rdeps mkdir -p $NAME-$hsh/skelpkg/$NAME-$hsh/hooks/postinst cat >$NAME-$hsh/skelpkg/$NAME-$hsh/hooks/postinst/01will-cite <<EOF #!/bin/sh echo yeah, yeah, will cite >&2 EOF chmod +x $NAME-$hsh/skelpkg/$NAME-$hsh/hooks/postinst/01will-cite "$BASS_ROOT"/build/lib/mk-pkg $NAME-$hsh
- Убеждаемся, выполняя redo цель, что зависимые пакеты (rc-paths, stow, zstd, gmake, perl) собраны
- Убеждаемся, что distfile для GNU Parallel существует, скачан
- Создаём и переходим во временную директорию для сборки
- create-tmp-for-build.rc автоматом выполняет mk-skelenv
- Устанавливаем $bdeps (build dependencies)
- Активируем skelenv
- Распаковываем исходный код
- Удаляем предыдущую сборку, которая могла остаться по какой-то нештатной ситуации. Используется rm-r скрипт, а не просто «rm -r», потому что в skelbin убираются права на запись, что мешает удалению
- Выполняем ./configure
- Можем и патчи применить, что-то подправить
- Собираем и устанавливаем в "$SKELBINS"/$ARCH/$NAME-$hsh
- Запускаем вспомогательную функцию создания hook-а для установки $rdeps (runtime dependencies) зависимых пакетов. Уж больно частое это действие
- Вручную создаём hook, который после установки сообщит о том, как этого просит автор GNU Parallel, что мы будем упоминать его ПО
- Создаём skelpkg пакет. В skelbin буду изменены права и сделан fsync
distfiles
Сборка ОБЯЗАНА не зависеть от доступности Интернета. Исходный код должен в полной мере присутствовать заранее скачанным. В преобладающем большинстве случаев это сводится к скачиванию tarball, а также возможно сопутствующих криптографических подписей. Обязательна проверка целостности скачанных данных. Поэтому, по умолчанию, цель для скачивания tarball-ов ожидает наличия соответствующего Metalink4 файла, в котором будут ссылки (в том числе на зеркала) для скачивания и контрольные суммы. Выполняя цель redo-ifchange "$DISTFILES"/parallel-20240122.do.tar.bz2 мы ищем parallel-20240122.do.tar.bz2.meta4 файл, а дальше запускаем либо aria2c, либо wget, либо meta4ra-check утилиты для скачивания.
meta4ra утилиты позволяют создавать, скачивать и проверять .meta4.
$ wget https://ftpmirror.gnu.org/parallel/parallel-20240122.tar.bz2 $ wget https://ftpmirror.gnu.org/parallel/parallel-20240122.tar.bz2.sig $ gpg --verify parallel-20240122.tar.bz2.sig # its .sig file contains non-signature related commentary, that we strip off: $ perl -i -ne 'print if /BEGIN/../END/' parallel-20240122.tar.bz2.sig $ meta4ra-create \ -fn parallel-20240122.tar.bz2 \ -sig-pgp parallel-20240122.tar.bz2.sig \ https://ftpmirror.gnu.org/parallel/parallel-20240122.tar.bz2 \ <parallel-20240122.tar.bz2 >parallel-20240122.tar.bz2.meta4
А если надо сделать tarball из коммита Git-репозитория?
% cat distfiles/vim-v9.1.1267.tar.zst.do [ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../.. sname=$1.do . "$BASS_ROOT"/lib/rc [ -d vim.git ] || git clone --depth 1 --bare https://github.com/vim/vim.git >&2 cd vim.git commit=de8f8f732ac1bcf69899df6ffd27dca9a4e66f3c git fetch origin $commit >&2 git archive --prefix=${1%.tar.zst}/ $commit | $COMPRESSOR
Скачать исходный код для всех пакетов?
$ cat build/distfiles/all.do ./list | xargs redo-ifchange $ cat build/distfiles/list #!/bin/sh -e cd "$(dirname "$(realpath -- "$0")")" sed -n "/[^\/]$/p" <.gitignore | sed "s#^/##"
Для того, чтобы передать на другую машину (изолированную от Интернета или вообще сетей) всё скачанное, удобно бы было оформить архив, без ненужных склонированных репозиториев:
% cat distfiles/pack #!/bin/sh -e [ -n "$BASS_ROOT" ] || BASS_ROOT="$(dirname "$(realpath -- "$0")")"/../.. sname="$0" . "$BASS_ROOT"/lib/rc cd "$DISTFILES" { ./list find . -type f -name "*.meta4" } | $TAR cfT - - $ distfiles/pack | ssh remote "tar xfC - /path/to/distfiles"
prepare-deps
Сборочная система требует:
- bsdtar (libarchive-based), так как GNU tar не умеет прозрачно разжимать на лету архивы полученные через stdin
- meta4ra, написанные на Go
- Perl, куда без него, единственного портабельного интерпретируемого языка, как правило всегда имеющегося на любой Unix-like системе
- redo. Рекомендуем goredo, написанный на Go
- setlock, lockf, flock — хотя бы одно из этого
- detpax, включённый в код BASS, написанный на Go
Для удобства сборки некоторых из этих зависимостей, включая Go, имеется contrib/prepare-deps скрипт, скачивающий их из Интернета и собирающий в local/ директории. Достаточно лишь будет добавить local/bin в $PATH. А contrib/go-debash содержат скрипты для избавления Go от bash зависимости.
Итог
Проект BASS, как инструмент для создания воспроизводимых сборок у нас с коллегами активно применяется больше года, собирая массу ПО, включая Python зависимости для virtualenv. Целевыми системами были Astra Linux и FreeBSD — с минимумом if-ов в skel-ах.
Как таковых release tarball-ов проекта нет — всё лежит в Git репозитории. В наличии более двухсот skel. Конечно же, всё это свободное программное обеспечение.
dyadyaSerezha
Не убедил) Не слишком это проще (да проще ли вообще?), чем Jenkins и его собратья. А кто будет следить и чистить артефакты? А убивать зависшие сборки/тесты? А делать разные типы зависимостей между шагами? Кстати, а почему нельзя тем же tmux-ом зайти в ноду Jenkins и все посмотреть и попробовать руками? В TeamCity можно.
В общем, не убедил)