Предисловие

В 2014 и 2015 годах Люка Бруно (Luca Bruno aka Lethalman) опубликовал серию постов про пакетный менеджер Nix, операционную систему NixOS и хранилище Nixpkgs.

Люка назвал свои посты пилюлями (англ. pill — таблетка, пилюля).

Берясь за перевод, я пытался выяснить, нет ли у выражения in pills устойчивого смысла. Оказалось, что скрытый смысл есть у слова Nix. Это одна из торговых марок перметрина — средства против клещей, которое доступно только в виде мази. Иными словами, медицинского Никса ни в пилюлях, ни в таблетках не бывает.

С момента публикации, Nix в пилюлях считается классическим введением в Nix. В 2017 году Грэм Кристиансен (Graham Christensen aka grahamc/gchristensen) инициировал работу по переводу серии статей в формат электронной книги.

Актуальную оригинальную версию книги вы найдёте по адресу https://nixos.org/guides/nix-pills/. Там же доступен вариант в формате EPUB.

В 2024-25 годах Марк Шевченко перевёл книгу на русский язык. Актуальная версия доступна по адресу https://nix-pills-ru.github.io.

ℹ️ В примерах, команды, которые начинаются с символа “решётка” (#), должны быть запущены с правами пользователя root.

Почему вам стоит попробовать Nix

Введение

Добро пожаловать на первую пилюлю из цикла «Nix в пилюлях». Nix — это чистый функциональный пакетный менеджер и система развёртывания для POSIX-совместимых ОС.

Есть немало материалов, посвящённых Nix, NixOS и связанным проектам. И, возможно, вы даже не стали бы их читать, так что цель этой статьи — убедить вас попробовать Nix. Установка NixOS не потребуется, но иногда я буду ссылаться на NixOS, как на реальный пример операционной системы, построенной на базе Nix.

Почему появился этот цикл?

Руководства по Nix, Nixpkgs и NixOS вместе с вики — великолепные ресурсы, объясняющие как устроены Nix/NixOS, как их использовать, и насколько крутые штуки можно делать с их помощью.

Цель этих статей — дополнить существующие документы чуть менее формальными объяснениями.

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

Когда пакетный менеджер — не чистый функциональный

Большинство, если не все, популярные пакетные менеджеры (dpkg, rpm, …) изменяют глобальное состояние системы. Установив пакет foo-1.0 в каталог /usr/bin/foo, вы не сможете установить туда же новую версию foo-1.1, т.к. имя исполняемого файла будет одно и то же. Впрочем, его изменение может ввести в заблуждение пользователей.

Есть разные способы избежать этой проблемы. Скажем, Debian частично решает её с помощью системы альтернатив.

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

Скажем, вам нужен сервис nginx и — кроме него — сервис nginx-openresty. Вы должны создать новый пакет, и поменять в нём все пути, например, добавив к ним суффикс -openresty.

Или, представим, вам надо запустить разные версии mysql: 5.2 и 5.5. Возникнет та же свистопляска с путями, что и в предыдущем случае. Кроме того, вам надо будет убедиться, что разные версии библиотеки mysqlclient не конфликтуют друг с другом.

Такая ситуация может возникнуть, и в этом случае справиться с ней будет очень непросто. А если вы захотите установить два различных программных стека, скажем, GNOME 3.10 и GNOME 3.13, вам можно только посочувствовать.

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

Разработчики скажут: вы можете использовать virtualenv для python, или jhbuild для gnome, или что-то подобное для других языков. Но как вы смешаете разные стеки? Как вам избежать перекомпиляции исходников, если их надо сделать общими для нескольких проектов? А ещё вам придётся настроить инструменты разработки, чтобы они загружали библиотеки из правильных каталогов. И, наконец, всегда остаётся риск, что часть софта некорректно работает с системными библиотеками.

В общем, проблем много. Nix решает эти проблемы на уровне пакетов, и решает их хорошо. Один инструмент — чтобы управлять всеми пакетами1.

Когда пакетный менеджер — чистый функциональный

Nix не делает никаких предположений о глобальном состоянии системы. У такого подхода есть много преимуществ, но, конечно, есть и недостатки. Сердцем системы Nix является хранилище, обычно расположенное в /nix/store, а также кое-какие инструменты для работы с ним. В Nix вместо понятия пакет существует понятие деривация2 (derivation). Для новичков различия между ними кажутся слишком тонкими, поэтому я буду использовать эти слова, как синонимы.

Деривации/пакеты находятся в хранилище Nix в подкаталогах, чьи имена соответствуют формату /nix/store/hash-name, где хэш (hash) — уникальный идентификатор деривации (с некоторыми оговорками, на которые мы не будет отвлекаться), а имя (name) — её имя.

Например, взглянем, на деривацию bash: /nix/store/s4zia7hhqkin1di0f187b79sa2srhv6k-bash-4.2-p45/. Это каталог в хранилище Nix, где находится утилита bin/bash.

Фактически это значит, что в системе нет никакой глобальной оболочки, а есть только эта конкретная версия в одном из каталогов хранилища. То же касается и других утилит, да и вообще всего. Чтобы утилиты можно было вызывать из командной строки, Nix следит за тем, чтобы в переменной PATH были правильные пути.

В итоге у нас есть хранилище всех пакетов (разные версии пакетов хранятся в разных каталогах), и всё, что там есть — менять нельзя.

В системе нет даже кэша ldconfig, так что вы вправе спросить: как в таком случае bash находит libc?

$ ldd  `which bash`
libc.so.6 => /nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19/lib/libc.so.6 (0x00007f0248cce000)

Оказывается, когда bash был собран, он был собран с конкретной версией glibc из хранилища Nix, и при запуске он загружает именно эту версию glibc.

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

Для чего все эти сложности? Благодаря им, теперь можно запускать mysql 5.2 с glibc-2.18 и mysql 5.5 с glibc-2.19. Можно использовать модуль c python 2.7, собранным gcc 4.6 и тот же самый модуль — с python 3, собранным gcc 4.8, в одной и той же системе.

Никакого больше геморроя с зависимостями и даже никакого алгоритма разрешения зависимостей. Прямые зависимости дериваций от других дериваций.

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

Разработчики скажут: если вы хотите разрабатывать webkit и с llvm 3.4, и с llvm 3.3, это можно сделать безболезненно.

Изменяемое против неизменного

При обновлении библиотеки большинство пакетных менеджеров просто перезаписывают файл в каталоге. Потом все приложения просто запускаются с новой версией. Ненадёжно, но кого это волнует? В конце концов, все приложения динамически ссылаются на libc6.so.

Поскольку деривации Nix неизменны (иммутабельны), обновление библиотеки наподобие glibc требует перекомпиляции всех приложений, потому что путь к glibc в хранилище Nix зависит от версии.

Как же нам быть с обновлениями безопасности? В Nix есть несколько трюков (всё ещё чистых), чтобы справиться с этой проблемой, но к ним мы вернёмся позже.

Другая проблема заключается в том, что софт, который рассчитывает на глобальные пути, не так то и просто заставить работать в Nix.

Для примера возьмём Firefox. В большинстве системы вы устанавливаете flash, и он просто начинает работать, потому что Firefox ищет плагины по глобальному пути.

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

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

Если изменился формат данных, то миграция на новый формат — отвественность автора программы.

Заключение

Nix позволяет гибко управлять сборкой программ, делая их настолько воспроизводимыми (идентичными), насколько это вообще возможно. Кроме того, из-за природы Nix, разворачивать приложения в облаке настолько просто, что в мире Nix все инструменты контейнеризации и оркестрации безнадёжно устарели по сравнению с NixOps.

Тем не менее, Nix пока не справляется с динамической компоновкой при работе программ, или с заменой низкоуровневых библиотек, из-за того, что всё это требует перекомпиляции.

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

Взглянув на Nixpkgs (ссылка на github) — репозиторий всего существующего софта, построенный с нуля, с непривычным подходом, с небольшим количеством основных разработчиков, но с растущим год от года вкладом сообщества, мы должны признать, что он вышел из стадии эксперимента и находится в прекрасной рабочей форме. Он стоит потраченного на него времени.

В следующей пилюле

…мы установим Nix в вашу систему (предположительно GNU/Linux, но подойдёт и OSX), и начнём его изучать.

1

Отсылка на «Властелина колец».

2

Сначала я решил, что перевод должен быть переводом. Слово деривация уже заимствовано в русском языке, но широко не используется и большинству читателей неизвестно. Так что я выбрал слово порождение, которое передаёт смысл, присутствующий в оригинальном английском термине. Однако, волна возмущения в telegram-чате NixOS RU оказалась настолько высокой, что я вынужден был отказаться от своей идеи. Derivation — ключевое для Nix понятие, которое встречается в названии функций, в сообщениях об ошибках, при поиске в Google — короче, почти везде. Оно похоже на термин file, который гораздо практичнее оказалось просто заимствовать. Мне пришлось переписывать первые восемь пилюль, которые я уже успел перевести, но зато теперь везде в тексте использован термин деривация.

Установка в вашей системе

Добро пожаловать на вторую пилюлю Nix. В первой пилюле мы кратко рассказали про Nix.

Теперь установим Nix в нашу систему и разберёмся, что изменилось после установки.

Если вы используте NixOS, Nix у вас уже установлен, так что вы сразу можете переходить к следующей пилюле.

За инструкциями по установке, пожалуйста, обратитесь к разделу Установка Nix Справочного руководства Nix.

Установка

Эти статьи — не руководство по использованию Nix. Здесь мы будем знакомиться с Nix, не погружаясь в формальности, просто чтобы разобраться, как он устроен.

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

ℹ️ При многопользовательской установке (такая как раз применяется в NixOS), хранилищем владеет root, а многочисленные пользователи могут устанавливать или собирать софт при помощи демона Nix. Больше о многопользовательской установке вы можете прочитать здесь: https://nixos.org/manual/nix/stable/installation/installing-binary.html#multi-user-installation.

С чего начинается хранилище в Nix

Вот что печатает команда установки Nix во время установки:

    copying Nix to /nix/store..........................

Речь идёт о каталоге /nix/store, который мы обсуждали в первой статье. Туда копируется всё, что необходимо для запуска системы Nix. Вы можете заметить bash, утилиты ядра, компилятор C, библиотеки, Perl, sqlite и сам Nix с его утилитами и libnix.

Обратите внимание, что в /nix/store лежат не только каталоги, но и файлы. У них такой же формат имени /hash-name.

База данных Nix

Сразу после наполнения хранилища, процесс установки инициализирует базу данных:

    initialising Nix database...

Да, в Nix есть база данных. Она находится в каталоге /nix/var/nix/db. Это база данных sqlite, которая хранит зависимости между деривациями.

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

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

Можно исследовать эту базу, установив sqlite (nix-env -iA sqlite -f '<nixpkgs>') и выполнив команду sqlite3 /nix/var/nix/db/db.sqlite.

ℹ️ Сразу после установки Nix не забудьте закрыть и заново открыть терминалы, чтобы обновить настройки командной строки.

📢 Изменяйте /nix/store вручную, только если вы на самом деле знаете, что делаете, или хранилище больше не будет синхронизировано в базой данных sqlite.

Первый профиль

Завершив установку, познакомимся с профилем:

creating /home/nix/.nix-profile
installing 'nix-2.1.3'
building path(s) `/nix/store/a7p1w3z2h8pl00ywvw6icr3g5l9vm5r7-user-environment'
created 7 symlinks in user environment

Профиль в Nix — это удобная концепция, чтобы откатывать изменения. Профили используются для объединения компонентов, разбросанных по разным путям, в одно целое. Более того, у профилей есть версии или «поколения». Когда вы изменяете профиль, рядом со старой версией появляется новая.

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

Итак, внимательно посмотрим на наш профиль:

$ ls -l ~/.nix-profile/
bin -> /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/bin
[...]
manifest.nix -> /nix/store/q8b5238akq07lj9gfb3qb5ycq4dxxiwm-env-manifest.nix
[...]
share -> /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/share

Деривация nix-2.1.3 в хранилище Nix — это сам Nix вместе с исполняемыми файлами и библиотеками. Процесс «установки» деривации в профиль, в сущности, воспроизводит дерево nix-2.1.3 из хранилища с помощью символических ссылок.

В настоящий момент в профиль установлена только одна программа (сам Nix), поэтому каталог bin ссылается на каталог bin из nix-2.1.3.

Но ~/.nix-profile — это не реальный каталог, а символическая ссылка на последнее поколение нашего профиля, /nix/var/nix/profiles/default. Которая, в свою очередь, тоже является ссылкой на соседний каталог default-1-link. Так что текущим профилем может быть люоое из поколений, но сейчас он указывает на первое.

В конечном итоге, default-1-link — символическая ссылка на деривацию user-environment, которая печаталась на экране в процесс установки.

О файле manifest.nix мы подробнее поговорим в следующей статье.

Выражения Nixpkgs

Ещё немного вывода от программы установки:

downloading Nix expressions from `http://releases.nixos.org/nixpkgs/nixpkgs-14.10pre46060.a1a2851/nixexprs.tar.xz'...
unpacking channels...
created 2 symlinks in user environment
modifying /home/nix/.profile...

Выражения Nix написаны на языке Nix, они описывают пакеты и процесс их сборки. Nixpkgs — это репозиторий, содержащий все выражения: https://github.com/NixOS/nixpkgs.

Установщик скачал описания пакетов, начиная с коммита a1a2851.

Второй профиль, который есть в системе — это профиль каналов. ~/.nix-defexpr/channels ссылается на /nix/var/nix/profiles/per-user/nix/channels, который в свою очередь ссылается на channels-1-link, который ссылается на каталог в хранилище со скачанными выражениями Nix.

Каналы — это набор пакетов и выражений, доступных для скачивания. Подобно стабильным и нестабильным репозиториям в Debian, в Nix есть есть стабильные и нестабильные каналы.

Позже мы вернёмся к выражениям Nix, а пока закончим с профилями.

В конечном итоге установщик изменил ~/.profile так, чтобы вы попадали в окружение Nix автоматически. Что делает скрипт ~/.nix-profile/etc/profile.d/nix.sh на самом деле, так это добавляет ~/.nix-profile/bin в PATH и ~/.nix-defexpr/channels/nixpkgs в NIX_PATH. Переменную NIX_PATH мы обсудим позже.

Попробуйте в разобраться в скрипте nix.sh, он не очень большой.

Вопросы и ответы: можно ли заменить /nix на что-то другое?

Да, можно, но есть веская причина использовать именно каталог /nix вместо любого другого. Все деривации зависят от других дериваций, используя при этом абсолютные пути. В первой статье мы видели, что bash ссылается на glibc по конкретному абсолютному пути внутри /nix/store.

Убедитесь сами, и не волнуйтесь, если увидите множество дериваций bash:

$ ldd /nix/store/*bash*/bin/bash
[...]

Размещая хранилище в /nix, мы можем напрямую использовать бинарные образы с nixos.org (точно также, как и пакеты с зеркал Debian).

Если же разместить хранилище в каталоге, скажем, /foo/store, то:

  • glibc будет установлен в /foo/store
  • После этого bash будет указывать на glibc в /foo/store вместо /nix/store
  • В результате мы не сможем использовать бинарный образ, так как нам нужен другой bash, и мы вынуждены перекомпилировать вообще всё.

Помимо прочего, /nix — это осмысленное место для хранилища.

Заключение

Мы установили Nix в нашу систему. Он полностью изолирован и принадлежит пользователю nix, а мы продолжим изучать особенности новой системы.

В этой статье мы познакомились с новыми концепциями, такими как профили и каналы. В частности, с помощью профилей мы научились управлять поколениями, а с помощью каналов — загружать бинарные образы с nixos.org.

Программа установки помещает всё в каталог /nix, создавая несколько символических ссылок в домашнем каталоге пользователя Nix.

Я надеюсь, что объяснил все детали, и вы теперь не думаете, что вся система построена на волшебстве. В конечном итоге всё сводится к тому, что компоненты лежат в хранилище и ссылаются друг на друга через символические ссылки.

В следующей пилюле

…мы погрузимся в окружение Nix и научимся взаимодействовать с хранилищем.

Погружаемся в среду

Добро пожаловать на третью Nix-пилюлю. Во второй пилюле мы установили Nix в свою систему. Сейчас мы, наконец, устроим пару экспериментов. Эта статья будет полезной, даже если вы используете NixOS, а не просто Nix.

Начинаем погружение

Если вы используете NixOS, то можете пропустить следующий шаг.

В прошлый раз мы создали пользователя Nix. Давайте переключимся на него с помощью команды su - nix. Если ваш ~/.profile уже создан, вам должны быть доступны команды наподобие nix-env и nix-store.

Если нет, запустите:

$ source ~/.nix-profile/etc/profile.d/nix.sh

Ранее мы выяснили, что ~/.nix-profile/etc указывает на деривацию nix-2.1.3. В данный момент мы находимся в профиле пользователя Nix.

Устанавливаем что-нибудь

И вот она, практика! Установка в окружение Nix — интересный процесс. Начнём с hello — простой командной утилиты, которая печатает Hello world и часто используется для проверки компиляторов и пакетных менеджеров.

Итак, установка:

$ nix-env -i hello
installing 'hello-2.10'
[...]
building '/nix/store/0vqw0ssmh6y5zj48yg34gc6macr883xk-user-environment.drv'...
created 36 symlinks in user environment

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

  • Мы установили программу с правами пользователя Nix, и только для пользователя Nix.
  • Из-за этого появилось новое окружение пользователя (иногда его называют средой пользователя). Это — новое поколение профиля нашего пользователя Nix.
  • Утилита nix-env управляет окружениями, профилями и их поколенями.
  • Мы установили hello, используя только имя, без указания версии. Повторяю: мы указали только имя деривации (без версии) для установки.

Мы можем вывести список поколений без блужданий по каталогу /nix:

$ nix-env --list-generations
   1   2014-07-24 09:23:30
   2   2014-07-25 08:45:01   (current)

Вывести установленные деривации:

$ nix-env -q
nix-2.1.3
hello-2.10

Куда мы установили hello на самом деле? which hello говорит нам ~/.nix-profile/bin/hello, который указывает на хранилище. Мы также можем вывести путь деривации с помощью команды nix-env -q --out-path. Этот путь деривации называется выходом сборки.

Слияние путей

Сейчас вы, возможно, хотите запустить man чтобы получить кое-какую информацию. Даже если у вас уже есть man в основной системе, вы можете установить её в окружение Nix с помощью команды nix-env -i man-db.

Исследуем профиль:

$ ls -l ~/.nix-profile/
dr-xr-xr-x 2 nix nix 4096 Jan  1  1970 bin
lrwxrwxrwx 1 nix nix   55 Jan  1  1970 etc -> /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/etc
[...]

Мы видим кое-что интересное. Когда у нас была установлена только одна деривация nix-2.1.3, bin был символической ссылкой на nix-2.1.3. Теперь, когда мы дополнительно установили несколько программ (man, hello), bin стал реальным каталогом, не симлинком.

$ ls -l ~/.nix-profile/bin/
[...]
man -> /nix/store/83cn9ing5sc6644h50dqzzfxcs07r2jn-man-1.6g/bin/man
[...]
nix-env -> /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/bin/nix-env
[...]
hello -> /nix/store/58r35bqb4f3lxbnbabq718svq9i2pda3-hello-2.10/bin/hello
[...]

Так, кое-что стало проясняться. nix-env слил пути из установленных дериваций. which man указывает на профиль Nix, вместо того, чтобы указывать на системный man, потому что ~/.nix-profile/bin находится в начале $PATH1.

Откат и переключение поколений

Последняя установленная команда — это man. Сейчас мы находимся в поколении профиля номер 3, если вы ничего не меняли на предыдущих шагах. Мы можем откатиться к предыдущему поколению:

$ nix-env --rollback
switching from generation 3 to 2

Теперь nix-env -q не показывет man. Команда ls -l \which man`` должна вывести содержимое каталога основной системы.

Разобравшись с откатом, вернёмся обратно в поколение 3:

$ nix-env -G 3
switching from generation 2 to 3

Самое время познакомиться со справкой на команду nix-env. Для запуска nix-env нужно указывать операцию с опциями. Часть из них общие, а часть — специфичные для отдельных операций.

Конечно, вы можете и удалять, и обновлять пакеты.

Запросы в хранилище

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

Для этого есть команда nix-store. Она предоставляет широкие возможности, но пока мы познакомимся только с несколькими видами запросов.

Чтобы вывести непосредственные зависимости программы hello:

$ nix-store -q --references `which hello`
/nix/store/fg4yq8i8wd08xg3fy58l6q73cjy8hjr2-glibc-2.27
/nix/store/58r35bqb4f3lxbnbabq718svq9i2pda3-hello-2.10

Аргумент nix-store может иметь любой тип, если он находится в хранилище Nix. Утилита следует симлинкам.

Сейчас это может показаться неважным, но давайте просмотрим деривации, зависящие от hello:

$ nix-store -q --referrers `which hello`
/nix/store/58r35bqb4f3lxbnbabq718svq9i2pda3-hello-2.10
/nix/store/fhvy2550cpmjgcjcx5rzz328i0kfv3z3-env-manifest.nix
/nix/store/yzdk0xvr0b8dcwhi2nns6d75k2ha5208-env-manifest.nix
/nix/store/mp987abm20c70pl8p31ljw1r5by4xwfw-user-environment
/nix/store/ppr3qbq7fk2m2pa49i2z3i32cvfhsv7p-user-environment

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

Мы видим в списке два окружения, относящиеся к поколениям 2 и 3, так как именно они содержат установленную программу hello.

Файл manifest.nix содержит метаданные, относящиеся к окружению, например, список установленных дериваций. Команда nix-env может выводить, обновлять и удалять их. И снова — текущий manifest.nix находится по пути ~/.nix-profile/manifest.nix.

Замыкания

Замыкание деривации — это дерево всех её зависимостей, которое включает абсолютно всё, что нужно для использования данной деривации.

$ nix-store -qR `which man`
[...]

Копирование всех этих дериваций в хранилище на другой машине позволит запустить на ней утилиту man, ничего больше не настраивая. Это — основа развёртывания с помощю Nix. Думаю, вы уже догадываетесь, насколько это мощный и удобный инструмент для развёртывания приложений в облаках (подсказка: nix-copy-closures и nix-store --export).

Просмотр замыкания в виде дерева зависимостей:

$ nix-store -q --tree `which man`
[...]

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

То же самое относится и к окружениям. В качестве упражнения запустите nix-store -q --tree ~/.nix-profile и убедитесь, что прямыми наследниками окружения пользователя являются установленные деривации и файл manifest.nix.

Разрешение зависимостей

В Nix нет ничего похожего на apt, которая решает задачу выполнимости булевых формул (SAT problem), чтобы подобрать зависимости с учётом верхних и нижних границ версий. Вся эта сложность не нужна, потому что все зависимости статичны: если деривация X зависит от деривации Y, она зависит от неё всегда. Деривация X, которая зависит от Z, должна быть другой деривацией.

Ручное восстановление

$ nix-env -e '*'
uninstalling 'hello-2.10'
uninstalling 'nix-2.1.3'
[...]

Ой, команда удалила все деривации из окружения, включая сам Nix! Это значит, что теперь мы не можем запустить даже nix-env. Что делать?

Раньше мы получали доступ к nix-env из окружения. Окружения удобны для пользователей, но Nix всё ещё находится в хранилище!

Сначала выберем одну из дериваций nix-2.1.3: ls /nix/store/*nix-2.1.3, пусть это будет /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3.

Первый способ всё починить — откатить изменения:

$ /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/bin/nix-env --rollback

Второй способ — заново установить nix-env, тем самым создав новое поколение:

$ /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/bin/nix-env -i /nix/store/ig31y9gfpp8pf3szdd7d4sf29zr7igbr-nix-2.1.3/bin/nix-env

Каналы

Откуда берутся пакеты? Мы уже говорили об этом во второй статье. Есть список каналов, откуда мы получаем пакеты, хотя обычно мы используем только один. И есть утилита для управления каналами — nix-channel.

$ nix-channel --list
nixpkgs http://nixos.org/channels/nixpkgs-unstable

Если вы используете NixOS, вы можете не увидеть вывода этой команды (если используете настройки по умолчанию), либо вы можете увидеть канал, чьё имя начинается с “nixos-” вместо “nixpkgs”.

По сути, это содержимое файла ~/.nix-channels.

ℹ️ ~/.nix-channels — это не символическая ссылка на хранилище!

Для обновления канала запустите nix-channel --update. Эта команда скачает новые выражения Nix (описания пакетов), создаст новое поколнение профиля каналов и распакует его в ~/.nix-defexpr/channels.

Она немного похожа на apt-get update. (См. таблицу, где в первом приближении сравниваются средства управления пакетами Ubuntu и NixOS).

Заключение

Мы узнали, как исследовать окружение пользователя и изменять его, устанавливая и удаляя программы. Обновлять программы тоже не сложно, подробности см. в руководстве (вкратце: nix-env -u обновит все пакеты в окружении).

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

Далее мы выяснили, как работать с хранилищем. Мы изучили прямые и обратные зависимости путей в нём.

Мы увидели, как симлинки используются для слияния путей из хранилища Nix — полезный трюк.

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

В следующей пилюле

…мы изучим основы языка Nix. Язык Nix используется для описания того, как строить деривации. Он является основой всего, включая NixOS. Поэтому очень важно понимать как синтаксис, так и семантику языка.

1

Это значит, что путь к системному man находится в списке путей позже и программа which его не видит — примечание переводчика.

Основы языка

Добро пожаловать на четвёртую пилюлю Nix. В предыдущей статье мы познакомились с окружениями Nix. Мы устанавливали программы, управляли профилем, переключались между поколениями и писали запросы к хранилищу Nix. Всё это — основы системного администрирования с помощью Nix.

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

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

Этот синтаксис прекрасно подходит для описания пакетов, так что изучение языка окупится при написании пакетных выражений.

📢 В Nix всё является выражением, там нет операторов. Обычное дело в функциональных языках.

📢 Значения в Nix неизменяемые (иммутабельные).

Типы значений

В Nix 2.0 есть команда nix repl. Это простая утилита, которая позволяет экспериментировать с языком Nix. Напомню, что Nix — это не только набор утилит для работы с деривациями, но и чистый ленивый функциональный язык. Синтаксис nix repl немного отличается от синтаксиса Nix, когда речь заходит о присваивании переменных (ведь в функциональных языках не бывает присваиваний). Просто помните об этом, и вы не запутаетесь. Эксперименты с nix repl помогут нам быстрее вкатиться в язык.

Запустите nix repl. Прежде всего, Nix поддерживает основные арифметические операции: +, -, * и /. (Чтобы выйти из nix repl, введите команду :q. Команда :? выводит справку.)

nix-repl> 1+3
4

nix-repl> 7-4
3

nix-repl> 3*2
6

Попытка выполнить деление в Nix может вас удивить.

nix-repl> 6/3
/home/nix/6/3

Что произошло? Вспомним, что Nix не является языком общего назначения, это предметно-ориентированный язык для написания пакетов. Деление чисел — не самая нужная операция при написании пакетных выражений. Для Nix 6/3 — это путь, построенный относительно текущего каталога. Чтобы заставить Nix выполнить деление, добавьте пробел после /, либо вызовите встроенную функцию builtins.div.

nix-repl> 6/ 3
2

nix-repl> builtins.div 6 3
2

Другие операторы — это ||, && и | для булевых значений и операторы сравнения, такие как !=, ==, <, >, <=, >=. В Nix <, >, <= and >= используются нечасто. Есть и другие операторы, с которыми мы познакомимся в этом цикле статей.

В Nix есть простые типы: целые числа, числа с плавающей запятой, строки, пути, булевы значения и null. Кроме того, есть списки, множества и функции. Этих типов хватает, чтобы собрать целую операционную систему.

Nix является сильно типизированным, но не статически типизированным языком. То есть, вы не можете смешивать строки и целые числа без предварительного преобразования типов.

Мы выяснили, что выражения считаются путями, если после символа деления нет пробела. Поэтому, чтобы указать текущий каталог, пишите ./. Кроме того, Nix умеет распознавать url’ы.

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

Идентификаторы

Идентификаторы в Nix такие же, как в других языках, за исключением того, что позволяют писать дефис (-). Удобно, имея дело с пакетами, писать дефис в имени. Пример:

nix-repl> a-b
error: undefined variable `a-b' at (string):1:1
nix-repl> a - b
error: undefined variable `a' at (string):1:1

Как видите, a-b распознаётся как идентификатор, а не как вычитание.

Строки

Строки заключаются в двойные кавычки (") или в пару одинарных кавычек ('').

nix-repl> "foo"
"foo"

nix-repl> ''foo''
"foo"

В других языках, например, в Python, можно заключать строки в одиночные кавычки ('foo'), но не в Nix.

Можно интерполировать выражения Nix внутри строк с помощью синтаксиса ${...}. Если вы писали на других языках, то можете по привычке написать $foo или {$foo}, но этот синтаксис работать не будет.

nix-repl> foo = "strval"
nix-repl> "$foo"
"$foo"
nix-repl> "${foo}"
"strval"
nix-repl> "${2+3}"
error: cannot coerce an integer to a string, at (string):1:2

Помните, что присваивание foo = "strval" — это специальный синтаксис, доступный только в nix repl и недоступный в обычном языке.

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

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

nix-repl> ''test " test''
"test \" test"
nix-repl> ''${foo}''
"strval"

Экранирование ${...} в строках с двойными кавычками делается с помощью обратной косой линии (бекслеша), а в строках с парой одиночных кавычек — с помощью '':

nix-repl> "\${foo}"
"${foo}"
nix-repl> ''test ''${foo} test''
"test ${foo} test"

Списки

Списки — это последовательность выражений, разделённая пробелами (не запятыми):

nix-repl> [ 2 "foo" true (2+3) ]
[ 2 "foo" true 5 ]

Списки, как и всё в Nix, неизменяемы (иммутабельны). Добавление или удаление элементов в списке возможно, но возвращает новый список.

Наборы атрибутов

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

nix-repl> s = { foo = "bar"; a-b = "baz"; "123" = "num"; }
nix-repl> s
{ "123" = "num"; a-b = "baz"; foo = "bar"; }

Набор атрибутов можно перепутать с набором аргументов при вызове функций, но это разные вещи.

Чтобы обратиться к элементу в наборе атрибутов:

nix-repl> s.a-b
"baz"
nix-repl> s."123"
"num"

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

Внутри набора нельзя ссылаться на другие элементы или на сам набор:

nix-repl> { a = 3; b = a+4; }
error: undefined variable `a' at (string):1:10

Это можно делать с помощью рекурсивных наборов:

nix-repl> rec { a = 3; b = a+4; }
{ a = 3; b = 7; }

Такая возможность полезна при описании пакетов, которые часто имеют рекурсивную природу.

Выражения ‘if’

Это всё ещё выражения, не операторы.

nix-repl> a = 3
nix-repl> b = 4
nix-repl> if a > b then "yes" else "no"
"no"

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

Выражения ‘let’

Выражения ‘let’ используются, чтобы определить локальные переменные для других (внутренних) выражений.

nix-repl> let a = "foo"; in a
"foo"

Синтаксис такой: сначала определяем переменные, затем пишем ключевое слово in, затем выражение, в котором можно ссылаться на определённые переменные. Значением всего выражения let будет значение выражения после in.

nix-repl> let a = 3; b = 4; in a + b
7

Попробуем записать два выражения let, одно внутри другого:

nix-repl> let a = 3; in let b = 4; in a + b
7

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

nix-repl> let a = 3; a = 8; in a
error: attribute `a' at (string):1:12 already defined at (string):1:5
nix-repl> let a = 3; in let a = 8; in a
8

Нельзя ссылаться на переменные в выражении let снаружи:

nix-repl> let a = (let c = 3; in c); in c
error: undefined variable `c' at (string):1:31

Можно ссылаться на переменные в выражении let, определяя другие перменные, как в рекурсивных наборах.

nix-repl> let a = 4; b = a + 5; in b
9

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

Выражения ‘with’

Это непривычный тип выражений — его нечасто можно встретить в других языках. Можно считать его расширенной версией оператора using из C++, или from module import* из Python. Конструкция with включает атрибуты набора в область видимости.

nix-repl> longName = { a = 3; b = 4; }
nix-repl> longName.a + longName.b
7
nix-repl> with longName; a + b
7

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

nix-repl> let a = 10; in with longName; a + b
14
nix-repl> let a = 10; in with longName; longName.a + b
7

Ленивые вычисления

Nix вычисляет выражения только тогда, когда ему нужен результат. Эта особенность языка активно используется при описании пакетов.

nix-repl> let a = builtins.div 4 0; b = 6; in b
6

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

В следующей пилюле

…поговорим о функциях и импорте. В этой пилюле я старался избегать функций, иначе пост стал бы слишком большим.

1

Оригинальный термин set обычно переводится на русский, как множество. Но в нашем случае термин множество вводит в заблуждение, поскольку set содержит не отдельные элементы, а пары ключ-значение. Такую стркутуру обычно называют называют словарём или ассоциативным массивом. В конечном итоге я остановился на слове набор, которое всё-таки не совсем множество.

Функции и импорт

Добро пожаловать в пятую пилюлю Nix. В предыдущей четвёртой пилюле мы начали изучение языка программирования Nix: рассказали про основные типы и значения языка, и про базовые выражения, такие как if, with и let. Чтобы закрепить материал, запустите сессию REPL и поэкспериментируйте с выражениями Nix.

Функции часто используются, чтобы строить повторно используемые компоненты в больших хранилищах, скажем, в nixpkgs. В руководстве по языку Nix есть великолепное объяснение функций, так что я буду часто на него ссылаться.

Напоминаю, как запустить среду Nix: source ~/.nix-profile/etc/profile.d/nix.sh

Безымянные с единственным параметром

В Nix функции всегда анонимы (то есть являются лямбдами), и у них всегда один параметр. Синтаксис экстремально прост: пишите имя параметра, затем “:” и тело функции.

nix-repl> x: x*2
«lambda»

Здесь мы определили функцию, которая принимает параметр x и возвращает x*2. Проблема в том, что мы не можем её вызвать, потому что у неё нет имени… шутка!

Мы можем дать функции имя, связав её с переменной.

nix-repl> double = x: x*2
nix-repl> double
«lambda»
nix-repl> double 3
6

Как я писал ранее, присваивание существует только в nix repl, в обычном языке Nix его нет. Итак, мы определили функцию x: x*2, которая принимает один параметр x и возвращает x*2. Затем она присваивается переменной double. После чего её можно вызвать: double 3.

Важное примечание: в большинстве языков программирования параметры нужно заключать в скобки: double(3). В Nix скобки не нужны: double 3.

Итого: чтобы вызывать функцию, напишите её имя, затем пробел, затем аргумент. Настолько всё просто.

Когда параметров больше одного

Как записать функцию, которая принимает больше одного параметра? Тем, кто не сталкивался с функциональным программированием, потребуется немного времени, чтобы разобраться. Исследуем тему по шагам.

nix-repl> mul = a: (b: a*b)
nix-repl> mul
«lambda»
nix-repl> mul 3
«lambda»
nix-repl> (mul 3) 4
12

Сначала мы определили функцию, которая принимает параметр a и возвращает другую функцию. Эта другая функция принимает параметр b и возвращает a*b. Вызывая mul 3 мы получаем в результате функцию b: 3*b. Вызывая её с параметром 4, мы получаем искомый результат.

В этом коде можно вообще отказаться от скобок, поскольку в Nix есть приоритеты операторов:

nix-repl> mul = a: b: a*b
nix-repl> mul
«lambda»
nix-repl> mul 3
«lambda»
nix-repl> mul 3 4
12
nix-repl> mul (6+7) (8+9)
221

Всё выглядит так, как будто у функции mul два параметра. Из-за того, что аргументы разделяются пробелом, вам придётся ставить скобки, чтобы передавать более сложные выражения. В других языках вы бы написали mul(6+7, 8+9).

Поскольку функции имеют только один параметр, несложно использовать частичное применение:

nix-repl> foo = mul 3
nix-repl> foo 4
12
nix-repl> foo 5
15

Мы сохранили функцию, которую вернула mul 3 в переменную foo, и затем вызывали.

Набор аргументов

Одна из самых мощных возможностей Nix — сопоставление с образцом параметра, который имеет тип набор атрибутов. Напишем альтернативную версию mul = a: b: a*b сначала используя набор аргументов, а затем — сопоставление с образцом.

nix-repl> mul = s: s.a*s.b
nix-repl> mul { a = 3; b = 4; }
12
nix-repl> mul = { a, b }: a*b
nix-repl> mul { a = 3; b = 4; }
12

В первом случае мы определили функцию, которая принимает один параметр-набор. Затем мы взяли атрибуты a и b из этого набора. Заметьте, как элегантно выглядит запись вызова без скобок. В других языках нам пришлось бы написать mul({ a=3; b=4; }).

Во втором случае мы определили набор аргументов. Это похоже на определение набора атрибутов, только без значений. Мы требуем, чтобы переданный набор содержал ключи a и b. Затем мы можем использовать эти a и b непосредственно в теле функции.

nix-repl> mul = { a, b }: a*b
nix-repl> mul { a = 3; b = 4; c = 6; }
error: anonymous function at (string):1:2 called with unexpected argument `c', at (string):1:1
nix-repl> mul { a = 3; }
error: anonymous function at (string):1:2 called without required argument `b', at (string):1:1

Функция принимает набор ровно с теми атрибутами, которые были указаны при её определении.

Атрибуты по умолчанию и вариативные атрибуты

В наборе аргументов можно указывать значения атрибутов умолчанию:

nix-repl> mul = { a, b ? 2 }: a*b
nix-repl> mul { a = 3; }
6
nix-repl> mul { a = 3; b = 4; }
12

Функция может принимать больше атрибутов, чем ей нужно. Такие атрибуты называются вариативными:

nix-repl> mul = { a, b, ... }: a*b
nix-repl> mul { a = 3; b = 4; c = 2; }

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

nix-repl> mul = s@{ a, b, ... }: a*b*s.c
nix-repl> mul { a = 3; b = 4; c = 2; }
24

Написав name@ перед образцом, вы даёте имя name всему набору атрибутов.

Преимущества использования наборов аргументов:

  • Из-за того, что аргументы именованы, вы не должны запоминать их порядок.
  • В качестве аргументов можно передать набор, что создаёт совершенно новый уровень гибкости и удобства.

Недостатки:

  • Частичное применение не работает с набором аргументов. Вы должны определить набор атрибутов целиком, нельзя определить только его часть.

Наборы атрибутов похожи на **kwargs из языка Python.

Импорт

Встроенная в язык функция import позволяет включать в текст программы другие файлы .nix. Такой подход весьма распространён в программировании: мы определяем каждый компонент в отдельном файле .nix, а затем соединяем компоненты, импортируя эти файлы в один модуль.

Начнём с простейшего примера.

a.nix:

3

b.nix:

4

mul.nix:

a: b: a*b
nix-repl> a = import ./a.nix
nix-repl> b = import ./b.nix
nix-repl> mul = import ./mul.nix
nix-repl> mul a b
12

Да, всё действительно настолько просто. Вы импортируете файл, он компилируется в выражение. Важный момент: в импортируемом файле нет доступа к переменным из импортирующего файла.

test.nix:

x
nix-repl> let x = 5; in import ./test.nix
error: undefined variable `x' at /home/lethal/test.nix:1:1

Чтобы передать информацию в импортируемый модуль, нужно использовать функции. Пример посложнее:

test.nix:

{ a, b ? 3, trueMsg ? "yes", falseMsg ? "no" }:
if a > b
  then builtins.trace trueMsg true
  else builtins.trace falseMsg false
nix-repl> import ./test.nix { a = 5; trueMsg = "ok"; }
trace: ok
true

Объяснение:

  • В test.nix мы возвращаем функцию. Она принимет набор, где у атрибутов b, trueMsg и falseMsg есть значения по умолчанию.
  • builtins.trace — встроенная функция, которая принимает два аргумента. Первый — это сообщение для печати, второй — возвращаемое значение. Обычно она используется для отладки.
  • В конце мы импортируем test.nix и вызываем функцию с набором { a = 5; trueMsg = "ok"; }.

Когда сообщение будет напечатано? Тогда, когда вычисления доберутся до соответствующей ветви кода.

В следующей пилюле

…мы, наконец, напишем свою первую деривацию.

Наша первая деривация

Добро пожаловать на шестую пилюлю. В предыдущей пятой пилюле мы познакомились с функциями и импортом. Функции и импорт — очень простые концепции, которые позволяют строить сложные абстракции и композицию модулей, чтобы собрать гибкую систему Nix.

В этом посте мы, наконец, добрались до написания деривации. Деривации, с точки зрения файловой системы — это строительные блоки системы Nix. Для описания дериваций используется язык Nix.

Напоминаю, как входить в окружение Nix: source ~/.nix-profile/etc/profile.d/nix.sh

Функция “derivation”

Для создания дериваций используется встроенная функция `derivation``. Прежде, чем двигаться дальше, пройдите по ссылке и познакомьтесь с тем, что написано в официальном руководстве. С точки зрения языка Nix, деривация — всего лишь набор с несколькими атрибутами, так что вы можете хранить её в переменной и передавать в другие функции, как любое другое значение.

Здесь-то и появляется реальная мощь.

Функция derivation в качестве первого агрумента принимает набор. Он требует по меньшей мере следующих трёх атрибутов:

  • name: название деривации. В хранилище Nix деривации хранятся в формате /hash-name, и часть name берётся из этого атрибута.
  • system: название системы, в которой деривация может быть собрана. Например, x86_64-linux.
  • builder: программа, которая собирает деривацию — бинарный образ или скрипт.

Прежде всего надо выяснить, какое, с точки зрения Nix, название у нашей системы?

nix-repl> builtins.currentSystem
"x86_64-linux"

Что будет, если использовать несуществующее название системы?

nix-repl> d = derivation { name = "myname"; builder = "mybuilder"; system = "mysystem"; }
nix-repl> d
«derivation /nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv»

Так, так, что это? Скрипт собрал деривацию? Нет, не собрал, он всего лишь создал файл .drv. nix repl не собирает деривации, пока вы явно не попросите об этом.

Отсутпление про файлы .drv

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

Чтобы разобраться, можно провести несколько аналогий с языком C:

  • Файлы .nix похожи на файлы .c.
  • Файлы .drv являются промежуточными файлами наподобие файлов .o. Файл .drv описывает, как собрать деривацию. Здесь самый минимум информации.
  • В конечном итоге результатом сборки является выходной путь.

Как вы видите из примера, и выходной путь, и путь к файлу .drv ведут в хранилище.

Так что внутри файла .drv? Его можно прочитать, но лучше распечатать его содержимое в понятном виде:

ℹ️ Если в вашей версия Nix нет команды nix derivation show, используйте вместо неё nix show-derivation.

$ nix derivation show /nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv
{
  "/nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname"
      }
    },
    "inputSrcs": [],
    "inputDrvs": {},
    "platform": "mysystem",
    "builder": "mybuilder",
    "args": [],
    "env": {
      "builder": "mybuilder",
      "name": "myname",
      "out": "/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname",
      "system": "mysystem"
    }
  }
}

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

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

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

В файле .drv заполнены не все поля, так что я вкратце опишу их назначение:

  1. Выходные пути (их может быть несколько). По умолчанию Nix создаёт один выходной путь, называемый “out”.
  2. Список входных дериваций. Он пуст, поскольку мы не ссылались на другие деривации. В противном случае, здесь был бы список других файлов .drv.
  3. Название системы (system) и путь к сборщику (да, сейчас это ненастоящая программа).
  4. Наконец, список переменных окружения, передаваемых в программу сборки.

Это минимум информации, нужной для сборки деривации.

Важное замечание: переменные окружения, передаваемые в сборщик — не только те, которые вы видите в файле .drv, но и некоторые другие, относящиеся к конфигурации Nix (количество ядер, временный каталог и др.). Сборщик не наследут никаких переменных из вашей оболочки, иначе сборки бы страдали от не-детерминизма.

Но вернёмся к нашей учебной деривации.

Попробуем её собрать, не смотря на то, что она почти ненастоящая.

nix-repl> d = derivation { name = "myname"; builder = "mybuilder"; system = "mysystem"; }
nix-repl> :b d
[...]
these derivations will be built:
  /nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv
building path(s) `/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname'
error: a `mysystem' is required to build `/nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv', but I am a `x86_64-linux'

Команда :b доступна только в nix repl, она используется для сборки деривации. Вы можете получить помощь по командам, введя :?. На экране мы видим путь к файлу .drv, в котором написано, как собирать деривацию. Далее там написано, какой путь будет нашим выходным путём. Наконец, мы видим ошибку, которую и должны были увидеть: деривация не может быть собрана в нашей системе.

Мы выполняем построение с помощью nix repl, но это не единственный способ. Вы можете реализовать файл .drv с помощью команды:

\$ nix-store -r /nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv

На экране будет напечатано то же самое.

Давайте исправим атрибут `system``:

nix-repl> d = derivation { name = "myname"; builder = "mybuilder"; system = builtins.currentSystem; }
nix-repl> :b d
[...]
build error: invalid file name `mybuilder'

В качестве программы сборки указана mybuilder, которой в действительности не существует. К чему приведёт запуск такого файла .drv?

Что есть в наборе деривации?

Для начала давайте взглянем на результат функции derivation. Это простой набор:

nix-repl> d = derivation { name = "myname"; builder = "mybuilder"; system = "mysystem"; }
nix-repl> builtins.isAttrs d
true
nix-repl> builtins.attrNames d
[ "all" "builder" "drvAttrs" "drvPath" "name" "out" "outPath" "outputName" "system" "type" ]

Вы можете догадаться, что делает builtins.isAttrs: она возвращает true, если аргумент является набором. Вторая функция, builtins.attrNames, возвращает список ключей из заданного набора. Можно сказать, что это своего рода рефлексия.

Начнём с drvAttrs:

nix-repl> d.drvAttrs
{ builder = "mybuilder"; name = "myname"; system = "mysystem"; }

По сути, это входные данные, которые мы передали функции derivation. Конкретно d.name, d.system и d.builder — те атрибуты, которые мы указали при вызове.

nix-repl> (d == d.out)
true

А здесь мы видим, что значение атрибута out — это и есть деривация, что кажется странным. Причина в том, что у деривации может быть только один выходной путь. По этой же причине d.all — это синглтон. Позже мы узнаем, как сделать несколько выходных путей у одной деривации.

d.drvPath — это пусть к файлу .drv: /nix/store/z3hhlxbckx4g3n9sw91nnvlkjvyw754p-myname.drv.

Кое-что интересное об атрибует type. Его значение "derivation". Nix добавляет небольшую магию при работе с наборами типа derivation, но действительно небольшую. Чтобы разобраться, создайте набор с типом “derivation”:

nix-repl> { type = "derivation"; }
«derivation ???»

Конечно, в нём нет никакой информации, так что Nix не знает, что печатать. Как понимаете, type = "derivation" — всего лишь соглашение для Nix и для нас, чтобы мы понимали, что набор — это деривация.

При создании пакетов, нас интересует конечный результат — файлы, записанные на диск. Все остальные метаданные нужны Nix чтобы знать, как определить путь к файлу .drv и выходной путь out.

Атрибут outPath — это выходной путь в хранилище Nix: /nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname.

Ссылки на другие деривации

Другие пакетные менеджеры позволяют пакетам ссылаться друг на друга. А как сослаться на другие деривации в Nix, какие указывать пути? Для этого используется атрибут outPath, который содержит путь к файлам нужной деривации. Для удобства Nix умеет конвертировать набор деривации в строку.

nix-repl> d.outPath
"/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname"
nix-repl> builtins.toString d
"/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname"

Nix выполняет “конверсию набора в строку”, если в наборе есть атрибут outPath (это что-то вроде метода toString в других языках):

nix-repl> builtins.toString { outPath = "foo"; }
"foo"
nix-repl> builtins.toString { a = "b"; }
error: cannot coerce a set to a string, at (string):1:1

Скажем, мы хотим использовать бинарные программы из coreutils:

nix-repl> :l <nixpkgs>
Added 3950 variables.
nix-repl> coreutils
«derivation /nix/store/1zcs1y4n27lqs0gw4v038i303pb89rw6-coreutils-8.21.drv»
nix-repl> builtins.toString coreutils
"/nix/store/8w4cbiy7wqvaqsnsnb3zvabq1cp2zhyz-coreutils-8.21"

Безотносительно nixpkgs, представьте, будто мы добавили в область видимости переменные и одна из них это coreutils. Это деривация пакета coreutils, который может быть знаком вам по другим дистрибутивам Linux — он содержит основные программы для систем GNU/Linux. В вашем хранилище может быть несколько дериваций coreutils.

$ ls /nix/store/*coreutils*/bin
[...]

Напоминаю, что внутри строк можно интерполировать выражения Nix с помощью ${...}:

nix-repl> "${d}"
"/nix/store/40s0qmrfb45vlh6610rk29ym318dswdr-myname"
nix-repl> "${coreutils}"
"/nix/store/8w4cbiy7wqvaqsnsnb3zvabq1cp2zhyz-coreutils-8.21"

Это очень удобно, потому что мы можем ссылаться, скажем, на бинарник bin/true вот так:

nix-repl> "${coreutils}/bin/true"
"/nix/store/8w4cbiy7wqvaqsnsnb3zvabq1cp2zhyz-coreutils-8.21/bin/true"

Почти работающая деривация

В предыдущем примере мы использовали ненастоящую программу сборки, mybuilder, которой, очевидно, не существует. Для экспериментов мы можем вызвать программу bin/true, которая всегда завершается с кодом 0 (успех).

nix-repl> :l <nixpkgs>
nix-repl> d = derivation { name = "myname"; builder = "${coreutils}/bin/true"; system = builtins.currentSystem; }
nix-repl> :b d
[...]
builder for `/nix/store/qyfrcd53wmc0v22ymhhd5r6sz5xmdc8a-myname.drv' failed to produce output path `/nix/store/ly2k1vswbfmswr33hw0kf0ccilrpisnk-myname'

Как мы видим, выполняется программа сборки bin/true, которая ничего не создаёт ни файла, ни каталога по выходному пути, она просто завершается с кодом 0.

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

Давайте проверим новый файл .drv, после того как мы сослались на другую деривацию:

$ nix derivation show /nix/store/qyfrcd53wmc0v22ymhhd5r6sz5xmdc8a-myname.drv
{
  "/nix/store/qyfrcd53wmc0v22ymhhd5r6sz5xmdc8a-myname.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/ly2k1vswbfmswr33hw0kf0ccilrpisnk-myname"
      }
    },
    "inputSrcs": [],
    "inputDrvs": {
      "/nix/store/hixdnzz2wp75x1jy65cysq06yl74vx7q-coreutils-8.29.drv": [
        "out"
      ]
    },
    "platform": "x86_64-linux",
    "builder": "/nix/store/qrxs7sabhqcr3j9ai0j0cp58zfnny0jz-coreutils-8.29/bin/true",
    "args": [],
    "env": {
      "builder": "/nix/store/qrxs7sabhqcr3j9ai0j0cp58zfnny0jz-coreutils-8.29/bin/true",
      "name": "myname",
      "out": "/nix/store/ly2k1vswbfmswr33hw0kf0ccilrpisnk-myname",
      "system": "x86_64-linux"
    }
  }
}

Ага! Nix добавил зависимость в наш myname.drv, это coreutils.drv. Перед тем, как собрать нашу деривацию, Nix должен построить coreutils.drv. Но, поскольку coreutils уже лежит в нашем хранилище, не нужно ничего строить, так что мы просто обращаемся по пути /nix/store/qrxs7sabhqcr3j9ai0j0cp58zfnny0jz-coreutils-8.29.

Когда деривация собирается на самом деле

Nix не строит дериваций в процессе вычисления выражений Nix. В частности, именно поэтому мы должны вызывать “:b drv” в nix repl или использовать nix-store -r.

В Nix существует важное разделение:

  • Время Инстанцирования/Вычисления: выражения Nix анализируются, интерпретируются и превращаются в набор деривации. При вычислении, вы можете ссылаться на другие деривации, потому что Nix создаст файлы .drv, для которых нам будут известны их выходные пути. Для этого мы используем команду nix-instantiate. – Время Реализации/Построения: деривация собирается из файла .drv, предварительно собрав деривации из файлов .drv, от которых она зависит. Для этого мы используем команду nix-store -r.

Процесс похож на компиляцию и компоновку в проектах C/C++. Сначала вы компилируете все исходные файлы в объектные файлы. А затем компонуете объектные файлы в один исполняемый файл.

В Nix, сначала выражение (обычно из файла .nix) компилируется в .drv, а затем каждый .drv собирается и записывается в хранилище по выходным путям.

Заключение

Сложно ли создать пакет в Nix? Нет, не сложно.

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

С помощью функции derivation мы описываем построение пакета, и принимаем готовый файл .drv в качестве результата. Nix конвертирует набор в строку, когда в наборе есть атрибут outPath. Благодаря этой удобной возможности, легко выстраивать записимости между деривациями.

Когда Nix собирает деривацию, он сначала из выражения создаёт файл .drv, а затем использует этот файл для сборки выходных файлов. Он делает это рекурсивно для всех зависимостей и “выполняет” файлы .drv друг за другом. Не так уж много магии, если подумать.

В следующей пилюле

…мы в конце концов напишем нашу первую работающую деривацию. Да, этот пост тоже про “наше первую деривацию”, но я никогда не утверждал, что она будет работать.

Работающая деривация

Введение

Добро пожаловать на седьмую пилюлю Nix. В предыдущей шестой пилюле мы начали разбираться с деривациями в языке Nix, выяснили, как определить пустую деривацию и (попытаться) её собрать.

В этом посте мы продолжим двигаться тем же курсом, создав деривацию, которая действительно что-то собирает. Затем мы попытаемся создать пакет с реальной программой: скомпилируем простой файл .c и создадим деривацию, используя классический инструментарий.

Напоминаю, как запускать окружение Nix: source ~/.nix-profile/etc/profile.d/nix.sh

Использование скрипта для сборки

Каков простейший способ выполнить цепочку команд, чтобы что-нибудь собрать? Скрипт. Напишем свой скрипт builder.sh, и запустим его при сборке деривации: bash builder.sh.

Мы не можем использовать шебанг1 в builder.sh, поскольку во время написания мы не знаем путь к bash в хранилище Nix. Не смотря на то, что bash конечно же есть в хранилище, ведь там есть всё!

Мы даже не можем сослаться на /usr/bin/env, потому что тогда мы потеряем такое крутое свойство Nix, как отсутствие состояния. Это не говоря о том, что PATH очищается во время сборки, так что bash всё равно не будет найден.

В итоге, чтобы собрать деривацию с помощью bash, мы должны вызывать его, передав аргумент builder.sh. Оказывается, функция derivation принимает опциональный атрибут args, который используется для передачи аргументов исполняемому файлу сборки.

Для начала, запишем наш builder.sh в текущий каталог:

declare -xp
echo foo > $out

Команда declare -xp выводит список эскпортированных переменных (declare — встроенная функция bash). В предыдущей пилюле мы выяснили, что Nix вычисляет выходной путь деривации на основании её атрибутов. Получившийся файл .drv содержит список переменных окружения, передаваемых в скрипт сборки. И одна из этих переменных — $out.

Что мы должны сделать, так это создать что-то по пути $out — файл или каталог. В нашем примере мы создаём файл.

Дополнительно, мы выводим переменные среды в процессе построения. Мы не можем использовать env для этого, потому что env является частью coreutils, и этой зависимости у нас пока нет. Сейчас у нас есть только bash.

Как и в случае с coreutils из предыдущей пилюли, мы без усилий получаем доступ к bash, благодаря нашей волшебной “груде всего”, которая называется nixpkgs.

nix-repl> :l <nixpkgs>
Added 3950 variables.
nix-repl> "${bash}"
"/nix/store/ihmkc7z2wqk3bbipfnlh0yjrlfkkgnv6-bash-4.2-p45"

Так что с помощью небольшого трюка, мы можем сослаться на bin/bash и создать нашу деривацию:

nix-repl> d = derivation { name = "foo"; builder = "${bash}/bin/bash"; args = [ ./builder.sh ]; system = builtins.currentSystem; }
nix-repl> :b d
[1 built, 0.0 MiB DL]

this derivation produced the following outputs:
  out -> /nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo

У нас получилось! Содержимым файла /nix/store/w024zci0x1hh1wj6gjq0jagkc1sgrf5r-foo является как раз строка “foo”. Мы собрали нашу первую деривацию.

Обратите внимание, что мы использовали ./builder.sh, а не "./builder.sh". Благодаря этому Nix понимает, что речь идёт о пути, и может выполнить кое-какую магию, которую мы обсудим позже. Попробуйте строковую версию и вы увидите, что Nix не сможет найти builder.sh. Это потому, что он пытается найти скрипт по относительному пути от временного каталога, где идёт сборка.

Окружение скрипта сборки

Мы можем использовать nix-store --read-log, чтобы посмотреть, какие логи записал наш скрипт:

$ nix-store --read-log /nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo
declare -x HOME="/homeless-shelter"
declare -x NIX_BUILD_CORES="4"
declare -x NIX_BUILD_TOP="/tmp/nix-build-foo.drv-0"
declare -x NIX_LOG_FD="2"
declare -x NIX_STORE="/nix/store"
declare -x OLDPWD
declare -x PATH="/path-not-set"
declare -x PWD="/tmp/nix-build-foo.drv-0"
declare -x SHLVL="1"
declare -x TEMP="/tmp/nix-build-foo.drv-0"
declare -x TEMPDIR="/tmp/nix-build-foo.drv-0"
declare -x TMP="/tmp/nix-build-foo.drv-0"
declare -x TMPDIR="/tmp/nix-build-foo.drv-0"
declare -x builder="/nix/store/q1g0rl8zfmz7r371fp5p42p4acmv297d-bash-4.4-p19/bin/bash"
declare -x name="foo"
declare -x out="/nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo"
declare -x system="x86_64-linux"

Давайте исследуем эти переменные среды, напечатаннные в процессе сборки.

  • $HOME — это не ваш домашний каталог, и /homeless-shelter вообще не существует. Мы заставляем пакеты не зависеть от $HOME в процессе построения.
  • $PATH также, как и $HOME не содержит реальных путей.
  • $NIX_BUILD_CORES и $NIX_STORE — это конфигурационные опции Nix.
  • $PWD и $TMP указывают на временный каталог, который Nix создал для сборки.
  • $builder, $name, $out и $system — переменные, получившие значения из файла .drv.

Переменная $out содержит путь к деривации, куда нам надо что-нибудь сохранить. Выглядит так, будто Nix зарезервировал для нас слот в хранилище, который мы должны заполнить.

В терминах autotools, $out — то же самое, что и путь --prefix. Да, не переменная DESTDIR из make, а именно --prefix. Вот суть создания пакетов без состояния. Вы не устанавливаете пакет по глобальному пути относительно /, вы устанавливаете его по локальному изолированному пути в слот вашего хранилища Nix.

Содержимое файлов .drv

В примере выше мы добавили в деривацию атрибут args. Как это изменило файл .drv по сравнению с примером из предыдущей пилюли?

$ nix derivation show /nix/store/i76pr1cz0za3i9r6xq518bqqvd2raspw-foo.drv
{
  "/nix/store/i76pr1cz0za3i9r6xq518bqqvd2raspw-foo.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo"
      }
    },
    "inputSrcs": [
      "/nix/store/lb0n38r2b20r8rl1k45a7s4pj6ny22f7-builder.sh"
    ],
    "inputDrvs": {
      "/nix/store/hcgwbx42mcxr7ksnv0i1fg7kw6jvxshb-bash-4.4-p19.drv": [
        "out"
      ]
    },
    "platform": "x86_64-linux",
    "builder": "/nix/store/q1g0rl8zfmz7r371fp5p42p4acmv297d-bash-4.4-p19/bin/bash",
    "args": [
      "/nix/store/lb0n38r2b20r8rl1k45a7s4pj6ny22f7-builder.sh"
    ],
    "env": {
      "builder": "/nix/store/q1g0rl8zfmz7r371fp5p42p4acmv297d-bash-4.4-p19/bin/bash",
      "name": "foo",
      "out": "/nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo",
      "system": "x86_64-linux"
    }
  }
}

Похоже на старый добрый файл .drv, за исключением списка аргументов, который передаётся в bash. Это спосок на самом деле содержит один скрипт builder.sh, который каким-то образом оказался в хранилище Nix. Дело в том, что Nix автоматически копирует в хранилище файлы и каталоги, нужные для сборки. Это гарантирует, что они не изменятся в процессе сборки, и что при развёртывании не может быть никаких состояний или зависимостей от текущей машины.

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

Создаём пакет из простой программы на C

Напишем простую программу на C, поместив её в файл simple.c:

void main() {
  puts("Simple!");
}

А вот скрипт сборки simple_builder.sh:

export PATH="$coreutils/bin:$gcc/bin"
mkdir $out
gcc -o $out/simple $src

Не бескойтесь о том, откуда возьмутся эти переменные; давайте пока опишем деривацию и соберём её:

nix-repl> :l <nixpkgs>
nix-repl> simple = derivation { name = "simple"; builder = "${bash}/bin/bash"; args = [ ./simple_builder.sh ]; gcc = gcc; coreutils = coreutils; src = ./simple.c; system = builtins.currentSystem; }
nix-repl> :b simple
this derivation produced the following outputs:

  out -> /nix/store/ni66p4jfqksbmsl616llx3fbs1d232d4-simple

Теперь можно запустить /nix/store/ni66p4jfqksbmsl616llx3fbs1d232d4-simple/simple в вашем bash.

Объяснение

Мы добавили два новых атрибута к вызову derivation: gcc и coreutils. В gcc = gcc;, слева находится имя в наборе деривации, а справа — ссылка на деривацию gcc из nixpkgs. То же касается и coreutils.

Мы также добавили атрибут src. Здесь ничего магического — атрибуту присвоен путь ./simple.c. Так же, как и simple-builder.sh, simple.c будет добавлен в хранилище.

Трюк: каждый атрибут в наборе, переданный в derivation будет сконвертирован в строку и передан в скрипт сборки как переменная окружения. Именно так скрипт получает доступ к coreutils и gcc: при конвертации в строку, деривации превращаются в их выходные пути, а добавление к ним /bin ведёт нас к их исполняемым файлам.

То же касается и переменной src. $src — это путь к simple.c в хранилище Nix. В качестве упражнение выведите .drv в читаемом виде. Вы увидите среди входных дериваций simple_builder.sh и simple.c, наряду с файлами .drv, относящимися к bash, gcc и coreutils. Кроме того, вы увидите новые добавленные переменные окружения, описанные выше.

В simple_builder.sh мы установили PATH для исполняемых файлов gcc и coreutils, так что скрипт сборки может найти нужные утилиты вроде mkdir и gcc.

Затем мы создали $out как каталог и разместили бинарные файлы внутри него. Обратите внимание, что gcc найден через переменную окружения PATH, но на него точно также можно было бы сослаться явно, используя $gcc/bin/gcc.

Избавляемся от nix repl

Попробуем повторить те же шаги, избавившись от nix repl, и написав выражение Nix в файле simple.nix:

let
  pkgs = import <nixpkgs> { };
in
pkgs.stdenv.mkDerivation {
  name = "simple";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./simple_builder.sh ];
  gcc = pkgs.gcc;
  coreutils = pkgs.coreutils;
  src = ./simple.c;
  system = builtins.currentSystem;
}

Собрать деривацию можно с помощью команды nix-build simple.nix. Она создаст в текущем каталоге символическую ссылку result, указывающую на выходной путь деривации.

nix-buils выполняет две задачи:

  • nix-instantiate: разбирает и выполняет simple.nix и возвращает файл .drv, относящийся к разобранному набору деривации
  • nix-store -r: исполняет файл .drv, что в действительности строит деривацию.

В конце концов он создаёт символическую ссылку.

Во второй строке simple.nix у нас есть вызов функкции import. Вспомните, что import принимает один аргумент — файл .nix для загрузки. Содержимое файла выполняется, как будто это функция.

Мы вызываем эту функцию с пустым набором. Подобный вызов мы видили в пятой пилюле. Ещё раз обратите внимание: конструкция import <nixpkgs> { } вызывает две функции, не одну. Чтобы стало яснее, прочитайте выражение как (import <nixpkgs>) { }.

Значение, возвращаемое функцией nixpkgs это набор; более точно, это набор дериваций. Вызов import<nixpkgs> { } в выражении let создаёт локальную переменную pkgs и вводит её в область видимости. Тот же эффект воникает и при выполнении инструкции :l <nixpkgs>, который мы использовали в nix repl. Набор pkgs, позволяет нам легко обращаться к таким деривациям, как bash, gcc и coreutils. На эти деривации надо ссылаться, как на атрибуты набора pkgs, т.е. писать pkgs.bash вместо bash.

Ниже представлена исправленная версия simple.nix, использующая ключевое слово inherit:

let
  pkgs = import <nixpkgs> { };
in
pkgs.stdenv.mkDerivation {
  name = "simple";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./simple_builder.sh ];
  inherit (pkgs) gcc coreutils;
  src = ./simple.c;
  system = builtins.currentSystem;
}

Для чего используется ключевое слово inherit? inherit foo; является эквивалентом foo = foo;. Точно также inherit gcc coretutils; — эквивалент для gcc = gcc; coreutils = coreutils;. Наконец, inherit (pkgs) gcc coreutils; — эквивалент для gcc = pkgs.gcc; coreutils = pkgs.coreutils;.

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

В следующей пилюле

Мы напишем универсальный скрипт сборки. Наверное вы заметили, что мы в этом посте написали два разных скрипта builder.sh. Было бы лучше, если бы у нас был универсальный скрипт, особенно, учитывая, что каждый скрипт сохраняется в хранилище Nix, а это затратно.

Это и правда так трудно — делать пакеты в Nix? Нет, мы просто изучаем основы.

1

Шебанг ровно так и записан в русской википедии. Shebang или hash-bang, как в оригинале у Люка — символы “#!”, которые идут в начале любого скрипта и указывают пусть к интепретатору.

Универсальные скрипты сборки

Добро пожаловать на восьмую пилюлю Nix. В предыдущей седьмой пилюле мы успешно собрали деривацию. Мы написали скрипт сборки, который скомпилировал программу на языке C и установил бинарный образ в хранилище Nix.

В этом посте мы обобщим скрипт сборки, напишем выражение Nix для GNU hello world и создадим обёртку над встроенной функцией derivation.

Упаковываем GNU hello world

В предыдущей пилюле мы упаковали простой файл .c, который был скомпилирован с помощью обычного вызова gcc. Это не самый удачный пример проекта. Многие используют autotools и, поскольку мы хотим обобщить наш скрипт, лучше ориентироваться на самую популярную систему сборки.

GNU hello world не смотря на своё название, это всё ещё простой проект, собираемый при помощи autotools. Загрузите последний архив отсюда: https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz.

Создадим скрипт сборки для GNU hello world, назовём его hello_builder.sh:

export PATH="$gnutar/bin:$gcc/bin:$gnumake/bin:$coreutils/bin:$gawk/bin:$gzip/bin:$gnugrep/bin:$gnused/bin:$bintools/bin"
tar -xzf $src
cd hello-2.12.1
./configure --prefix=$out
make
make install

Деривация hello.nix:

let
  pkgs = import <nixpkgs> { };
in
derivation {
  name = "hello";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./hello_builder.sh ];
  inherit (pkgs)
    gnutar
    gzip
    gnumake
    gcc
    coreutils
    gawk
    gnused
    gnugrep
    ;
  bintools = pkgs.binutils.bintools;
  src = ./hello-2.12.1.tar.gz;
  system = builtins.currentSystem;
}

Nix в Darwin

Сборка в Darwin (т.е. macOS) в качестве компилятора C традиционно использует clang вместо gcc. Чтобы адаптировать наш пример под Darwin, напишем такую модифицированную версию hello.nix:

let
  pkgs = import <nixpkgs> { };
in
derivation {
  name = "hello";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./hello_builder.sh ];
  inherit (pkgs)
    gnutar
    gzip
    gnumake
    coreutils
    gawk
    gnused
    gnugrep
    ;
  gcc = pkgs.clang;
  bintools = pkgs.clang.bintools.bintools_bin;
  src = ./hello-2.12.1.tar.gz;
  system = builtins.currentSystem;
}

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

Соберём программу, запустив nix build hello.nix. Теперь можно выполнить result/bin/hello. Всё довольно просто, но надо ли писать builder.sh для каждого пакета? Надо ли всегда передавать зависимости в функцию derivation?

Пожалуйста, обратите внимание на параметр --prefix=$out, который мы обсуждали в предыдущей пилюле.

Универсальный скрипт

Обобщим builder.sh на все проекты autotools:

set -e
unset PATH
for p in $buildInputs; do
    export PATH=$p/bin${PATH:+:}$PATH
done

tar -xf $src

for d in *; do
    if [ -d "$d" ]; then
        cd "$d"
        break
    fi
done

./configure --prefix=$out
make
make install

Что мы делаем?

  1. С помощью set -e просим оболочку прерывать выполнение скрипта в случае любой ошибки.
  2. Вначале очищаем PATH`` (unset PATH`), потому что в этом месте переменная содержит несуществующие пути.
  3. В конец каждого пути из $buildInputs дописываем bin и всё вместе добавляем к PATH. Подробности обсуждим чуть позже.
  4. Распоковываем исходники.
  5. Ищем каталог, куда были распакованы исходники и переходим в него, выполнив команду cd.
  6. Наконец, конфигурируем, компилируем и устанавливаем проект.

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

Теперь перепишем hello.nix:

let
  pkgs = import <nixpkgs> { };
in
derivation {
  name = "hello";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./builder.sh ];
  buildInputs = with pkgs; [
    gnutar
    gzip
    gnumake
    gcc
    coreutils
    gawk
    gnused
    gnugrep
    binutils.bintools
  ];
  src = ./hello-2.12.1.tar.gz;
  system = builtins.currentSystem;
}

Тут всё ясно, за исключением, может быть, buildInputs. Но и в buildInputs нет никакой чёрной магии.

Nix умеет конвертировать списки в строку. Сначала он конвертирует в строки каждый отдельный элемент, а затем склеивает их, разделяя пробелом:

nix-repl> builtins.toString 123
"123"

nix-repl> builtins.toString [ 123 456 ]
"123 456"

Вспомним, что и деривации можно конвертировать в строку, поэтому:

nix-repl> :l <nixpkgs>
Added 3950 variables.

nix-repl> builtins.toString gnugrep
"/nix/store/g5gdylclfh6d224kqh9sja290pk186xd-gnugrep-2.14"

nix-repl> builtins.toString [ gnugrep gnused ]
"/nix/store/g5gdylclfh6d224kqh9sja290pk186xd-gnugrep-2.14 /nix/store/krgdc4sknzpw8iyk9p20lhqfd52kjmg0-gnused-4.2.2"

Вот так всё просто! Переменная buildInputs в конечном итогде будет содержать нужные нам пути, разделённые пробелом. Нет ничего лучше для использования в цикле for интерпретатора bash.

Удобная версия функции derivation

Нам удалось написать скрипт, который можно использовать для разных проектов autotools. Но в выражении hello.nix мы определяем все программы, которые могут потребоваться, включая те, которые не нужны для сборки конкретного проекта.

Мы можем написать функцию, которая также, как и derivation, принимает набор атрибутов, и сливает его с другим набором атрибутов, общим для всех проектов.

autotools.nix:

pkgs: attrs:
let
  defaultAttrs = {
    builder = "${pkgs.bash}/bin/bash";
    args = [ ./builder.sh ];
    baseInputs = with pkgs; [
      gnutar
      gzip
      gnumake
      gcc
      coreutils
      gawk
      gnused
      gnugrep
      binutils.bintools
    ];
    buildInputs = [ ];
    system = builtins.currentSystem;
  };
in
derivation (defaultAttrs // attrs)

Чтобы разобраться, как работает этот код, вспоминм кое-что о фукнциях Nix. Всё выржаение Nix из файла autotools.nix превращается в функцию. Эта функция принимает параметр pkgs и возвращает функцию, которая принимает параметр attrs.

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

  1. Сначала добавляем в обласить видимость магический набор атрибутов pkgs.
  2. С помощью выражения let определяем вспомогательную переменную defaultAttrs, куда складываем несколько атрибутов, нужных для деривации.
  3. В конце создаём вызываем derivation, передавая в качестве параметра странное выражение (defaultAttrs // attrs).

Оператор // — принимает на вход два набора. Результатом является их объединение. В случае конфликта имён атрибутов, используется значение из правого набора.

Так что мы используем defaultAttrs как основу, и добавляем (переопределяем) туда атрибуты из attrs.

Пара примеров прояснит работу оператора:

nix-repl> { a = "b"; } // { c = "d"; }
{ a = "b"; c = "d"; }

nix-repl> { a = "b"; } // { a = "c"; }
{ a = "c"; }

Упражнение: Завершите новый скрипт builder.sh добавив $baseInputs в цикл for вместе с $buildInputs.

Результат оператора // мы передаём в функцию derivation. Атрибут buildInputs пустой, поэтому он будет иметь точно то значение, которое указано в наборе attrs.

Перепишем hello.nix:

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "hello";
  src = ./hello-2.12.1.tar.gz;
}

Финал! Мы получили простейшее описание пакета! Несколько комментариев, которые помогут вам лучше разораться в языке Nix.

  • Мы помещаем в переменную pkgs импорт, который в предыдущих выражениях помещали в оператор “with”. Это обычная практика, не стоит её опасться.
  • Переменная mkDerivation — прекрасный пример частичного применения. На неё можно смотреть, как как на ‘(import ./autotools.nix) pkgs’.
  • Вначале мы импортируем выражение, затем применяем его к параметру pkgs1.
  • Это даёт нам функцию, которая принимает набор атрибутов attrs.
  • Мы создаём деривацию, указывая только атрибуты name и src. Если проекту нужны другие знависимости в PATH, их можно добавить в buildInputs, но в примере с hello.nix нам это было не нужно.

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

Заключение

Nix даёт нам базовые инструменты для создания дериваций, подготовки окружения для сборки и сохранения результата в хранилище Nix.

В этой пилюле мы написали универсальный скрипт сборки проектов autotools и функцию mkDerivation. Последняя объединяет основные компоненты, используемые в проектах autotools с настройками по умолчанию, и избавляет нас от дублирования кода в разных проектах.

Мы познакомились с тем, как расширять систему Nix: мы пишем и объединяем новые деривации.

Аналогия: в C вы создаёте объекты, которые находятся в куче, и затем на их основе создаёте новые объекты. Для ссылки на другие объекты используются указатели.

В Nix вы создаёте деривации, которые находятся в хранилище Nix, и затем на их основе создаёте новые деривации. Для ссылки на другие деривации используются выходные пути.

В следующей пилюле

…мы поговорим про зависимости от среды выполнения. Является ли пакет GNU hello world автономным? Каковы зависимости его среды выполнения? Пока что мы определили зависимости для сборки, посредством использования других дериваций в деривации “hello”.

1

В функциональных языках вызов функции с параметром часто называют применением функции к параметру.

Автоматические зависимости времени выполнения

Добро пожаловать на девятую пилюлю Nix. В предыдущей восьмой пилюле мы разработали универсальный скрипт сборки для проектов autotools. Мы загрузили зависимости и исходники, и получили в качестве результата деривацию Nix.

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

Зависимости сборки

Давайте начнём анализ зависимостей сборки для пакета GNU hello:

$ nix-instantiate hello.nix
/nix/store/z77vn965a59irqnrrjvbspiyl2rph0jp-hello.drv
$ nix-store -q --references /nix/store/z77vn965a59irqnrrjvbspiyl2rph0jp-hello.drv
/nix/store/0q6pfasdma4as22kyaknk4kwx4h58480-hello-2.10.tar.gz
/nix/store/1zcs1y4n27lqs0gw4v038i303pb89rw6-coreutils-8.21.drv
/nix/store/2h4b30hlfw4fhqx10wwi71mpim4wr877-gnused-4.2.2.drv
/nix/store/39bgdjissw9gyi4y5j9wanf4dbjpbl07-gnutar-1.27.1.drv
/nix/store/7qa70nay0if4x291rsjr7h9lfl6pl7b1-builder.sh
/nix/store/g6a0shr58qvx2vi6815acgp9lnfh9yy8-gnugrep-2.14.drv
/nix/store/jdggv3q1sb15140qdx0apvyrps41m4lr-bash-4.2-p45.drv
/nix/store/pglhiyp1zdbmax4cglkpz98nspfgbnwr-gnumake-3.82.drv
/nix/store/q9l257jn9lndbi3r9ksnvf4dr8cwxzk7-gawk-4.1.0.drv
/nix/store/rgyrqxz1ilv90r01zxl0sq5nq0cq7v3v-binutils-2.23.1.drv
/nix/store/qzxhby795niy6wlagfpbja27dgsz43xk-gcc-wrapper-4.8.3.drv
/nix/store/sk590g7fv53m3zp0ycnxsc41snc2kdhp-gzip-1.6.drv

Учитывая, что наша универсальная функция mkDerivation всегда извлекает такие зависимости (сравните с пакетом build-essential из Debian), они попадут в хранилище Nix ещё до того, как потребуются какому-нибудь пакету для сборки.

Почему мы смотрим на файлы .drv? Потому что hello.drv представляет собой действие, которое собирает программу hello по выходному пути. Как таковой, он содержит входные деривации, нужные для сборки hello.

Немного о файлах NAR

Формат NAR означает “Nix ARchive”. Его разработали, потому что существующие форматы архивов, такие как tar, не удовлетворяют некоторым важным требованиям. Для Nix нужны детерминированные средства сборки, но обычные архиваторы не детерминированы: они выравнивают данные, они не сортируют файлы они добавляют метки времени и так далее. В результате директории, содержащие побитово-идентичные файлы превращаются в побитово-неидентичные архивы, что приводит к различным хэшам.

В отличие от tar, NAR был разработан как простой детерминированный формат архива. Ниже мы увидим, что он широко используется в Nix.

За подробным обоснованием и деталями реализации NAR обращайтесь к докторской диссертации Долстры.

Чтобы создавать архивы NAR из путей хранилища, мы можем использовать утилиты nix-store --dump и nix-store --restore.

Зависимости времени выполнения

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

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

  1. Создаёт архив NAR из деривации. Обратите внимание, что этот шаг сериализует вывод деривации — он хорошо работает и тогда, когда деривация — это один файл, и тогда, когда это целый каталог.
  2. Для каждого файла .drv, от которого зависит сборка и относящегося к нему выходного пути, ищет по этому пути архив NAR.
  3. Если архив найден, то путь является зависимостью времени выполнения.

Приведённый фрагмент показывает зависимости hello.

$ nix-instantiate hello.nix
/nix/store/z77vn965a59irqnrrjvbspiyl2rph0jp-hello.drv
$ nix-store -r /nix/store/z77vn965a59irqnrrjvbspiyl2rph0jp-hello.drv
/nix/store/a42k52zwv6idmf50r9lps1nzwq9khvpf-hello
$ nix-store -q --references /nix/store/a42k52zwv6idmf50r9lps1nzwq9khvpf-hello
/nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19
/nix/store/8jm0wksask7cpf85miyakihyfch1y21q-gcc-4.8.3
/nix/store/a42k52zwv6idmf50r9lps1nzwq9khvpf-hello

Мы видим, что glibc и gcc являются зависимостями времени выполнения. Интуитивно, здесь не должно быть gcc! Но вывод на экран строк из двоичного файла hello показывает, что gcc действительно там встречается:

$ strings result/bin/hello|grep gcc
/nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19/lib:/nix/store/8jm0wksask7cpf85miyakihyfch1y21q-gcc-4.8.3/lib64

Вот почему Nix добавил gcc. Но откуда этот путь вообще появился? Дело в том, что он есть в ld rpath: списке каталогов с библиотеками времени выполнения. В других дистрибутивах этим, обычно, не злоупотребляют. Но в Nix нам приходится ссылаться на определённые версии библиотек, поэтому rpath играет важную роль.

Процесс сборки добавляет путь к библиотекам gcc, полагая, что он потребуется среде выполнения. Впрочем, это не обязательно так. Для решения этой проблемы, Nix предоставляет инструмент под названием patchelf, который сводит rpath к путям, которые действительно нужны при выполнении программы.

Даже после сокращения rpath, исполняемый файл hello по прежнему зависит от gcc из-за отладочной информации. В следующем разделе мы исследуем, как с помощью strip полностью избавиться от этой зависимости.

Ещё одна фаза сборки

Добавим ещё одну фазу в скрипт сборки autotools. В настоящий момент сборщик имеет шесть фаз:

  1. Фаза “настройки окружения”
  2. Фаза “распаковки”: мы распаковываем исходники в текущий каталог (помните, что Nix запускает сборку во временном каталоге)
  3. Фаза “смены каталога”: временный каталог становится корнем дерева исходников
  4. Фаза “конфигурирования”: ./configure
  5. Фаза “сборки”: make
  6. Фаза “установки”: make install

Добавим новую фазу сразу после “установки”, это будет фаза “исправления”. Допишем в конец builder.sh:

find $out -type f -exec patchelf --shrink-rpath '{}' \; -exec strip '{}' \; 2>/dev/null

То есть для каждого файла мы выполняем patchelf --shrink-rpath и strip. Обратите внимание, что, поскольку мы использовали две новые команды find и patchelf, нам надо добавить их в деривацию.

Упражнение: Добавьте findutils и patchelf к baseInputs скрипта autotools.nix.

Теперь снова соберём hello.nix

$ nix-build hello.nix
[...]
$ nix-store -q --references result
/nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19
/nix/store/md4a3zv0ipqzsybhjb8ndjhhga1dj88x-hello

и увидим, что в списке зависимостей времени выполнения осталась только библиотека glibc. Именно этого мы и добивались.

Мы сделали автономный (самодостаточный) пакет. Это значит, что мы можем скопировать его на другую машину, где он соберёт точно такую же программу hello. Обратите внимание, что для её запуска требуются некоторые компоненты из /nix/store, так что нужно будет запустить nix. Исполняемый файл hello запускается именно с той версией библиотеки glibc и интерпретатора1, которые указаны в нём, а не с системными версиями.

$ ldd result/bin/hello
 linux-vdso.so.1 (0x00007fff11294000)
 libc.so.6 => /nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19/lib/libc.so.6 (0x00007f7ab7362000)
 /nix/store/94n64qy99ja0vgbkf675nyk39g9b978n-glibc-2.19/lib/ld-linux-x86-64.so.2 (0x00007f7ab770f000)

Конечно, исполняемый файл будет прекрасно запускаться, пока все нужные пакеты лежат в каталоге /nix/store.

Заключение

Мы познакомились с несколькими инструментами Nix и с их возможностями. В частности, мы узнали, как Nix распознаёт зависимости времени выполнения. И речь идёт не только о разделяемых библиотеках, но и об исполняемых файлах, скриптах, библиотеках Python и так далее.

Такой подход к сборке позволяет делать пакеты самодостаточными, гарантируя, что копирования пакета на другую машину достаточно для запуска программы. Благодаря этому, мы можем запускать программы без установки, используя nix-shell и использовать Nix для надёжного развёртывания в облаке.

В следующей пилюле

Следующая пилюля расскажет про nix-shell. Ранее мы строили деривации с нуля с помощью nix-build: распаковывали исходные коды, конфигурировали, собирали и устанавливали. Развёртывание больших пакетов может занимать много времени. Мы могли бы вносить небольшие изменения и использовать инкрементальную компиляцию, в то же время опираясь на преимущества самодостаточного окружения, как в nix-build. Для этого нам и потребуется nix-shell.

1 Речь про загрузчик ELF, который иногда называют интерпретатором.

Разработка с помощью nix-shell

Добро пожаловать на десятую пилюлю Nix. В предыдущей девятой пилюле мы познакомились с одной из мощных возможностей Nix: автоматическим обнаружения зависимостей времени выполнения. Заодно мы завершили разработку пакета GNU hello.

В этой пилюле мы познакомимся с утилитой nix-shell и попробуем с её помощью взломать программу hello. Мы узнаем, что nix-shell создаёт для нас изолированную среду с возможностью редактирования исходных файлов проекта, точно также, как nix-build создаёт изолированную среду во время сборки деривации.

В конце концов мы сделаем наш скрипт сборки более эргономичным, ориентируясь на возможности nix-shell

Что такое nix-shell?

Утилита nix-shell помещает нас в командную оболочку с настроенными переменными окружения, необходимыми для сборки деривации. Она не запускает сборку; она готовит плацдарм для пошаговой сборки проекта.

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

Мы можем передать в nix-shell любое выражение Nix, возвращающее деривацию, но в переменной PATH, которая в конечном итоге попадёт в bash, не будет нужных нам утилит.

$ nix-shell hello.nix
[nix-shell]$ make
bash: make: command not found
[nix-shell]$ echo $baseInputs
/nix/store/jff4a6zqi0yrladx3kwy4v6844s3swpc-gnutar-1.27.1 [...]

Такая оболочка в лучшем случае бесполезна. Было бы разумно ожидать, что программы, описанные в $buildInputs, попадают в PATH (в том числе и программа GNU make), но в нашем случае это не так.

Однако, у нас есть переменные окружения, которые мы установили в деривации, в частности $baseInputs, $buildInputs, $src и др.

Это значит, что мы можем запустить source с параметром builder.sh и она построит деривацию. На этапе установки у вас может возникнуть ошибка, потому что ваш пользователь не имеет прав записи в /nix/store:

[nix-shell]$ source builder.sh
...

Деривация не установилась, но она была построена. Обратите внимание вот на что:

  • Мы запустили builder.sh и он выполнил все шаги сборки, включая настройку PATH.
  • Рабочий каталог — больше не временный каталог, созданный nix-build, а каталог, в котором мы запустили оболочку.
  • Таким образом, hello-2.10 был распакован в текущий каталог.

Мы можем войти в каталог hello-2.10 и запустить make, поскольку make теперь доступен.

Это подтверждает, что nix-shell помещает нас в оболочку с тем же (или очень похожим) окружением, что и во время сборки.

Сборщик для nix-shell

Предыдущие шаги требуют ручного запуска команд и не оптимизированы для работы с nix-shell. Сейчас мы сделаем наш сборщик более дружественным по отношению к nix-shell.

Вот несколько пунктов, которые нам надо изменить.

Во-первых, когда мы загружаем builder.sh, мы загружаем его в текущий каталог. Что мы действительно хотим, так это поместить builder.sh в хранилище Nix, поскольку утилита nix-build использует именно этот файл. Корректный способ в том, чтобы передать в деривацию правильную переменную окружения. (Обратите внимание, что переменная $builder уже определена, но она указывает на исполняемый файл bash вместо builder.sh. Наш builder.sh передаётся в bash как аргумент.)

Во-вторых, мы не хотим запускать сборку полностью, мы собираемся всего лишь настроить окружение, нужное для ручной сборки проекта. Так что мы можем разбить builder.sh на два файла: setup.sh для настройки окружения и настоящий builder.sh, который отправится в nix-build.

В процессе рефакторинга мы завернём этапы сборки в функции, чтобы придать больше структуры нашему дизайну. Дополнительно, мы перенесём set -e из файла настройки в файл сборки. Команда set -e в nix-shell раздражает, так как она завершает работу оболочки при возникновении ошибки.

Вот наш исправленный autotools.nix. Примечательным является атрибут setup = ./setup.sh в деривации, который добавляет setup.sh в хранилище Nix и соответственно инициализирует переменную окружения $setup в сборщике.

pkgs: attrs:
let
  defaultAttrs = {
    builder = "${pkgs.bash}/bin/bash";
    args = [ ./builder.sh ];
    setup = ./setup.sh;
    baseInputs = with pkgs; [
      gnutar
      gzip
      gnumake
      gcc
      coreutils
      gawk
      gnused
      gnugrep
      binutils.bintools
      patchelf
      findutils
    ];
    buildInputs = [ ];
    system = builtins.currentSystem;
  };
in
derivation (defaultAttrs // attrs)

Благодаря этому мы можем разделить builder.sh на setup.sh и builder.sh. Задача builder.sh заключается в том, чтобы загрузить $setup и вызвать функцию genericBuild. Всё остальное — небольшие изменения в скрипте bash.

Вот исправленная версия builder.sh:

set -e
source $setup
genericBuild

Вот новая добавленная версия setup.sh:

unset PATH
for p in $baseInputs $buildInputs; do
    export PATH=$p/bin${PATH:+:}$PATH
done

function unpackPhase() {
    tar -xzf $src

    for d in *; do
    if [ -d "$d" ]; then
        cd "$d"
        break
    fi
    done
}

function configurePhase() {
    ./configure --prefix=$out
}

function buildPhase() {
    make
}

function installPhase() {
    make install
}

function fixupPhase() {
    find $out -type f -exec patchelf --shrink-rpath '{}' \; -exec strip '{}' \; 2>/dev/null
}

function genericBuild() {
    unpackPhase
    configurePhase
    buildPhase
    installPhase
    fixupPhase
}

Наконец, вот hello.nix:

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "hello";
  src = ./hello-2.12.1.tar.gz;
}

Возвращаемся в nix-shell:

$ nix-shell hello.nix
[nix-shell]$ source $setup
[nix-shell]$

Теперь, скажем, вы можете запустить unpackPhase, которая распакует $src и зайдёт в каталог. И вы можете запускать такие команды, как ./configure, make и т.д. вручную, или запускать фазы с помощью соответствующих функций.

Всё правильно: процесс настолько прост, насколько вам кажется. nix-shell собирает файл .drv и все его входные зависимости, и затем запускает командную оболочку с настроенными переменными окружения, необходимыми для сборки .drv. В частности, переменные окружения в оболочке совпадают с теми, которые передаются в функцию derivation.

Заключение

С помощью nix-shell мы можем запустить изолированное окружение, подходящее для разработки проекта. Это окружение предоставляет необходимые зависимости для оболочки разработчика, подобно тому, как nix-build предоставляет необходимые зависимости сборщику. Дополнительно, мы можем собирать и отлаживать проект вручную, выполняя его пошагово, как мы делали бы в любой другой среде разработки. Заметьте, что мы никогда не устанавливаем такие инструменты, как gcc или make в систему; эти инструменты и библиотеки изолированы и доступны попроектно.

В следующей пилюле

В следующей пилюле мы займёмся чисткой хранилища Nix. Мы написали и построили деривации, которые затем добавили в хранилище Nix, но до сего момента мы не беспокоились о том, чтобы удалять неиспользуемые деривации из хранилища.

Сборщик мусора

Добро пожаловать на одиннадцатую пилюлю Nix. В предыдущей десятой пилюле, мы провели параллель между изолированной средой сборки, предоставляемой nix-build и изолированной оболочкой разработки, предоставляемой nix-shell.

С этого момента мы больше не будем концентрироваться на создании пакетов и вместо этого исследующим критический важный компонент Nix: сборщик мусора. Используя инструменты Nix, мы часто строим деривации, включая файлы .drv и выходные пути. Эти артефакты попадают в хранилище Nix и занимают место на нашем носителе так что мы можем захотеть освободить немного места, удалив деривации, которые нам больше не нужны. На решении этого вопроса мы сфокусируемся в одиннадцатой пилюле. По умолчанию, Nix придерживается консервативной стратегии, решая, какие из дериваций являются «нужными». В этой пилюле мы познакомимся с техникой выполнения более «опасных» операций обновления и удаления.

Как работает сборщик мусора?

Языки программирования со сборщиком мусора (garbage collector) используют концепцию множества корневых элементов (корней сборщика мусора или GC-корней) для отслеживания «живых» объектов. GC-корень — это объект, который всегда считается «живым» (пока не будет явным образом удалён из списка GC-корней). Процесс сборки мусора начинается с GC-корней, и рекурсивно помечает объекты, до которых может добраться, как «живые» Все остальные объекты могут быть собраны и удалены.

Вместо объектов, сборщик мусора Nix оперирует путями в хранилище, причём GC-корни также являются путями в хранилище. Этот подход гораздо более принципиален, чем у традиционных менеджеров пакетов, таких как dpkg или rpm, которые могут оставлять неиспользуемые пакеты или висящие файлы.

Реализация очень проста и прозрачна для пользователя. Первичные GC-корни хранятся в /nix/var/nix/gcroots. Если существует символическая ссылка на путь в хранилище, то связанный путь в хранилище является корнем GC.

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

Подводя итог, можно сказать, что Nix ведёт список корней СМ. Эти корни могут быть использованы для вычисления списка всех живых путей в хранилище. Любой другой путь считается мёртвым. Удаление такие путей — простая операция. Сначала Nix переносит мёртвые пути в каталог /nix/store/trash, что является атомарной операцией. После этого корзина чистится.

Экспериментируем со сборщиком мусора

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

$ nix-collect-garbage
finding garbage collector roots...
[...]
deleting unused links...
note: currently hard linking saves -0.00 MiB
1169 store paths deleted, 228.43 MiB freed

Если мы запустим сборщик мусора снова, он, как и ожидается, не найдёт объектов для удаления. После завершения сборки мусора, хранилище Nix содержит только пути с ссылками из корней GC.

Теперь установим новую программу, bsd-games, исследуем её путь в хранилище, и убедимся, что он является GC-корнем. Команда nix-store -q --roots нужна, чтобы запросить корни GC, которые ссылаются на заданную деривацию. В нашем случае, окружение текущего пользователя ссылается на bsd-games:

$ nix-collect-garbage
finding garbage collector roots...
[...]
deleting unused links...
note: currently hard linking saves -0.00 MiB
1169 store paths deleted, 228.43 MiB freed

Теперь удалим его, запустим сборщик мусора и убедимся,что bsd-games всё ещё находится в хранилище Nix.

$ rm /nix/var/nix/profiles/default-9-link
$ nix-env --list-generations
[...]
   8   2014-07-28 10:23:24
  10   2014-08-20 12:47:16   (current)
$ nix-collect-garbage
[...]
$ ls /nix/store/b3lxx3d3ggxcggvjw5n0m1ya1gcrmbyn-bsd-games-2.17
ls: cannot access /nix/store/b3lxx3d3ggxcggvjw5n0m1ya1gcrmbyn-bsd-games-2.17: No such file or directory

Старое поколение всё ещё в хранилище Nix, потому что оно является корнем GC. Как вы увидим ниже, все профили и их поколения — это корни GC.

Удаление корня СМ — простая операция. В нашем случае мы удаляем поколение, которое ссылается на bsd-games, запускаем сборщик мусора и убеждаемся, что bsd-games больше недоступен в хранилище Nix:

ℹ️ Замечание: nix-env --list-generations не опирается на какие-либо метаданные. Он получает поколения, основываясь исключительно на именах файлов в каталоге профилей.

Обратите внимание, что мы удалили ссылку из каталога /nix/var/nix/profiles, не из /nix/var/nix/gcroots. Помимо прочего, Nix трактует /nix/var/nix/profiles как корень GC. Это полезно, поскольку это значит, что любой профиль и его поколения являются корнями GC. Другие пути также считаются корнями GC, например, /run/booted-system в NixOS. Команда nix-store --gc --print-roots печатает все пути, которые считаются корнями GC при запуске сборщика мусора.

Косвенные корни

Вспомните, что построение пакета GNU hello с помощью nix-build создаёт в текущем каталоге символическую ссылку result. Несмотря на выполненную выше сборку мусора, программа hello всё ещё работает. Следовательно, она не была собрана. Так как не существует других дериваций, которые зависят от пакета GNU hello, значит, он и является корнем GC.

Фактически, nix-build автоматически делает символическую ссылку result корнем GC. Обратите внимание, что речь идёт не о собранной деривации, а только о символической ссылке. Эти корни GC добавляются в каталог /nix/var/nix/gcroots/auto.

$ ls -l /nix/var/nix/gcroots/auto/
total 8
drwxr-xr-x 2 nix nix 4096 Aug 20 10:24 ./
drwxr-xr-x 3 nix nix 4096 Jul 24 10:38 ../
lrwxrwxrwx 1 nix nix   16 Jul 31 10:51 xlgz5x2ppa0m72z5qfc78b8wlciwvgiz -> /home/nix/result/

Нам пока не важно, какое имя носит символическая ссылка, ставшая корнем GC. Значение имеет то, что она существует и указывает на /home/nix/result. Это называется косвенным корнем GC. Корень GC считается косвенным, если он определён где-то вне каталога /nix/var/nix/gcroots. В нашем случае это значит, что цель символической ссылки result не будет удаляться сборщиком мусора.

Есть два способа удалить деривацию, считающуюся «живой» из-за косвенного корня GC:

  • Удалить косвенный корень GC из /nix/var/nix/gcroots/auto.
  • Удалить символическую ссылку result.

В первом случае деривация будет удалена из хранилища Nix в течение сборки мусора и result станет висячей символической ссылкой. Во втором случае деривация удаляется вместе с косвенным корнем в /nix/var/nix/gcroots/auto.

Запуск nix-collect-garbage после удаления корня GC или косвенного корня GC удалит деривацию из хранилища.

Чистим всё

Главный источник дублирования программ в хранилище Nix возникает из-за корней GC, связанных с nix-build и поколениями профиля. Запуск nix-build приводит к созданию корня GC для сборки, который ссылается на определённые версии определённых библиотек, например, glibc. После обновления, мы должны удалить предыдущую сборку, если мы хотим, чтобы сборщик мусора удалил соответствующие деривации, а так же, если мы хотим очистить старые зависимости.

То же касается и профилей. Манипулирование профилем nix-env создаёт дополнительные поколения. Старые поколения ссылаются на старые программы, увеличивая таким образом дублирование после обновления в хранилище Nix.

Другие системы обычно «забывают» всё о своём предыдущем состоянии сразу после обновления. В Nix мы тоже можем выполнить такой тип обновления (заставив Nix удалить старые деривации, включая старые поколения), но это надо делать вручную. Потребуются четыре шага:

  • Во-первых, мы скачиваем новую версию канала nixpkgs, который содержит описание всех программ. Это делается с помощью nix-channel --update.
  • Затем мы обновляем установленные пакеты, запустив nix-env -u. Так у нас появится новое поколение с обновлёнными программами.
  • Затем мы удаляем все косвенные корни, созданные nix-build: будьте осторожны, потому что это приводит к появлением висячих системных ссылок. Более умной стратегией будет одновременное удаление целей этих символических ссылок.
  • Наконец, с помощью опции -d при запуске nix-collect-garbage удаляем старые поколения всех профилей с последующей сборкой мусора. После этого к предыдущим поколениям вернуться нельзя. Прежде, чем запускать эту команду, важно убедиться, что новое поколение корректно работает.

Вот эти четыре команды:

nix-channel --update
nix-env -u --always
rm /nix/var/nix/gcroots/auto/*
nix-collect-garbage -d

Заключение

Сборка мусора в Nix — мощный механизм для чистки вашей системы. Команды nix-store позволяют нам узнать, почему определённая деривация присутствует в хранилище Nix, независимо от того, подлежит ли она сборке, или нет. Также мы выяснили, как выполнить более деструктивные операции удаления и обновления.

В следующей пилюле

В следующей пилюле мы запакуем другой проект и объясним паттерн проектирования «Входы». До сих пор мы работали только с одной деривацией; теперь нам предстоит организовать маленький репозиторий для программ. Паттерн «Входные параметры» широко используется в nixpkgs; он позволяет нам разделять деривации из одного репозитория и обеспечивает тонкую настройку.

Репозитории пакетов и паттерн Inputs (Входные параметры)

Добро пожаловать на двенадцатую пилюлю Nix. В предыдущей одиннадцатой пилюле мы приостановили разговор о пакетах и очистили систему с помощью сборщика мусора.

Сейчас мы вернёмся к пакетам и кое-что в них улучшим. Также мы покажем, как создать репозиторий для множества пакетов.

Репозитории в Nix

Репозитории пакетов в Nix нужны, чтобы организовывать пакеты. Язык Nix не требует определённой структуры каталогов или политики пакетирования. Будучи полноценным функциональным языком программирования, он достаточно мощен, чтобы поддерживать различные виды репозиториев.

Структура основного репозитория nixpkgs сложилась с течением времени. Она отражает историю Nix, как и паттерны проектирования, популярные среди пользователей, поскольку доказали свою полезность при построении и организации пакетов. Далее мы познакомимся с некоторыми из этих паттернов.

Паттерн single repository (единый репозиторий)

Различные дистрибутивы по разному подходят к организации репозиториев. Debian, распределяет пакеты по нескольким маленьким репозиториям, что затрудняет отслеживание взаимозависимых изменений и мешает контрибуции. В то же время Gentoo держит описания всех пакетов в Едином репозитории.

Nix следует паттерну “единый репозиторий”, размещая описания всех пакетов в nixpkgs. Этот подход воспринимается как естественный и привлекательный при внесении правок в пакеты.

Остаток этой пилюли мы посвятим разбору паттерна единый репозиторий. Реализация, естественная для Nix — создать выражение верхнего уровня, за которым разместить по одному выражению на каждый пакет. Выражение верхнего уровня импортирует и объединяет все выражения пакетов в набор атрибутов, отображая имена в пакеты.

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

Упаковываем graphviz

Мы уже создали пакет для GNU hello. Теперь создадим пакет программы рисования графиков под названием graphviz, чтобы сделать репозиторий, содержащий несколько пакетов. Пакет graphviz был выбран потому, что он использует стандартную систему сборки autotools и не требует никаких патчей. В нём также есть опциональные зависимости, которые позволяют нам проиллюстрировать технику конфигурирования сборок.

Вначале мы загружаем graphviz из gitlab. Выражение graphviz.nix достаточно простое:

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "graphviz";
  src = ./graphviz-2.49.3.tar.gz;
}

Собрав проект командой nix-build graphviz.nix, мы получим готовые исполняемые файлы в каталоге result/bin. Обратите внимание, что мы повторно используем скрипт autotools.nix, созданный нами при написании hello.nix.

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

$ echo 'graph test { a -- b }'|result/bin/dot -Tpng -o test.png
Format: "png" not recognized. Use one of: canon cmap [...]

Если мы хотим сохранять файлы png с помощью graphviz, мы должны добавить поддержку формата в деривацию. Это можно сделать в autotools.nix, где мы описали переменную buildInputs, которая затем объединяется с baseInputs. Эта переменная для того и нужна, чтобы автор пакета мог добавить входные деривации из пакетных выражений.

В graphviz версии 2.49 есть несколько плагинов для работы с png. Для простоты будем использовать libgd.

Передаём информацию о библиотеках в pkg-config через переменные окружения

Конфигурационный скрипт graphviz использует pkg-config для того, чтобы определить, какие флаги должны быть переданы компилятору. Поскольку не существует глобального каталога, где собраны библиотеки, мы должны сказать pkg-config где искать файлы описания, которые подскажут конфигурационному скрипту, откуда брать заголовки и библиотеки.

В классических POSIX системах, pkg-config просто ищет файлы .pc для всех установленных библиотек в системном каталоге наподобие /usr/lib/pkgconfig. Однако, в изолированных окружениях Nix такой подход просто не будет работать.

В качестве альтернативы мы можем информировать pkg-config о местоположении библиотек через переменную окружения PKG_CONFIG_PATH. Мы можем определить эту переменную, используя тот же трюк, что и для переменной PATH — автоматически заполнив пути из buildInputs. Вот соответствующий фрагмент setup.sh:

for p in $baseInputs $buildInputs; do
    if [ -d $p/bin ]; then
        export PATH="$p/bin${PATH:+:}$PATH"
    fi
    if [ -d $p/lib/pkgconfig ]; then
        export PKG_CONFIG_PATH="$p/lib/pkgconfig${PKG_CONFIG_PATH:+:}$PKG_CONFIG_PATH"
    fi
done

Теперь, если мы добавим деривации к buildInputs, их подкаталоги lib/pkgconfig и bin автоматически добавятся к переменным PKG_CONFIG_PATH и PATH.

Завершаем graphviz с помощью gd

Ниже мы завершаем выражение для graphviz, включив в него поддержку gd. Обратите внимание, что использование выражения with c buildInputs позволяет избежать дублирования pkgs:

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "graphviz";
  src = ./graphviz-2.49.3.tar.gz;
  buildInputs = with pkgs; [
    pkg-config
    (pkgs.lib.getLib gd)
    (pkgs.lib.getDev gd)
  ];
}

Мы добавляем к деривации pkg-config чтобы сделать эту утилиту доступной для конфигурационного скрипта. Поскольку gd — пакет с несколькими выходными путями, надо добавить оба пути — lib и dev.

После сборки graphviz может создавать png файлы.

Выражение репозитория

Теперь, когда у нас есть два пакета, мы хотим объединить их в один репозиторий. Чтобы это сделать, будем подражать nixpkgs: создадим один набор атрибутов, содержащий деривации. Впоследствии этот набор атрибутов можно будет импортировать, и доступ к деривациям может быть получен через набор атрибутов верхнего уровня.

Используя эти технику, мы можем абстрагироваться от имён файлов. Эта техника позволяет вместо ссылки на пакет REPO/some/sub/dir/package.nix обращаться к деривации через importedRepo.package (или pkgs.package в нашем примере).

Для начала в текущем каталоге создадим default.nix:

{
  hello = import ./hello.nix;
  graphviz = import ./graphviz.nix;
}

Этот файл можно использовать из nix repl:

$ nix repl
nix-repl> :l default.nix
Added 2 variables.
nix-repl> hello
«derivation /nix/store/dkib02g54fpdqgpskswgp6m7bd7mgx89-hello.drv»
nix-repl> graphviz
«derivation /nix/store/zqv520v9mk13is0w980c91z7q1vkhhil-graphviz.drv»

Для nix-build мы должны передать параметр -A чтобы получить доступ к атрибуту из набора нужного выражения .nix:

$ nix-build default.nix -A hello
[...]
$ result/bin/hello
Hello, world!

Файл default.nix — особенный. Если каталог содержит default.nix, он используется как неявное выражение Nix для этого каталога. Благодаря этому мы, например, можем запустить nix-build -A hello, без явного указания default.nix.

Теперь nix-env можно использовать для установки пакета в пользовательское окружение:

$ nix-env -f . -iA graphviz
[...]
$ dot -V

Разберёмся, как работает эта команда:

  • Параметр -f ссылается на выражение. В нашем случае это выражение из ./default.nix текущего каталога.
  • Параметр -i запускает “установку” (“installation”).
  • Параметр -A имеет тот же смысл, что и в nix-build.

Мы воспроизвели самое базовое поведение nixpkgs: объединили несколько дериваций в один набор атрибутов верхнего уровня.

Паттерн inputs (входные параметры)

У подхода, который мы рассмотрели, есть несколько проблем:

  • Во-первых, hello.nix и graphviz.nix зависят от nigpkgs, который они импортируют напрямую. Лучшим подходом была бы передача nixpkgs в качестве аргумента, как в autotools.nix.
  • Во-вторых, у нас нет простого способа компилировать различные варианты одной и той же программы, скажем, graphviz с поддержкой и без поддержки libgd.
  • В-третьих, у нас нет возможности протестировать graphviz с определённой версией libgd.

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

Когда мы говорим о “входных параметрах выражения”, мы имеем в виду деривации, нужные для сборки выражения. В нашем случае это:

  • mkDerivation из autotools. Напомним, что mkDerivation имеет неявную зависимость от инструментария.
  • libgd и её зависимости.

Каталог ./src также передаётся через параметр, но мы не станем менять исходный код в скрипте сборки. В nixpkgs при повышении версии предпочитают написать ещё одно выражение (в том числе из-за патчей или отличающихся входных параметров).

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

{ mkDerivation, lib, gdSupport ? true, gd, pkg-config }:

mkDerivation {
  name = "graphviz";
  src = ./graphviz-2.49.3.tar.gz;
  buildInputs =
    if gdSupport
      then [
        pkg-config
        (lib.getLib gd)
        (lib.getDev gd)
      ]
      else [];
}

Напомню, что {...}: ... — это синтаксис определения функции, принимающей набор атрибутов в качестве аргумента, так что этот пример просто определяет функцию.

Мы сделали gd и её зависимости опциональными. Если параметр gdSupport равен true (по умолчанию это именно так), мы заполняем buildInputs и graphviz будет собран с поддержкой gd. В противном случае, если набор атрибутов передаётся с gdSupport = false;, пакет будет собран без поддержки gd.

Вернёмся к default.nix и модифицируем выражение, чтобы применить шаблон входные параметры.

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
with pkgs;
{
  hello = import ./hello.nix { inherit mkDerivation; };
  graphviz = import ./graphviz.nix {
    inherit
      mkDerivation
      lib
      gd
      pkg-config
      ;
  };
  graphvizCore = import ./graphviz.nix {
    inherit
      mkDerivation
      lib
      gd
      pkg-config
      ;
    gdSupport = false;
  };
}

Мы разделили импорт nixpkgs и mkDerivation, и также добавили вариант сборки graphviz без поддержки gd. Теперь и hello.nix (оставленный читателю в качестве упражнения), и graphviz.nixб во-первых, не зависят от репозитория, а во вторых, настраиваются с помощью входных параметров.

Если мы захотим собрать graphviz с нужной версией gd, достаточно будет передать gd = ...;

Если мы захотим изменить инструмент сборки, мы передадим другую реализацию mkDerivation.

Давайте взглянем на этот фрагмент внимательней, и разберём, как он работает:

  • Выражение default.nix возвращает набор атрибутов с ключами hello, graphviz и graphvizCore.
  • С помощью let мы определяем несколько локальных переменных.
  • Мы включаем pkgs в область видимости, определяя набор пакетов. Это избавляет нас от необходимости многократно набирать pkgs.
  • Мы импортируем hello.nix и graphviz.nix, каждый из которых возвращает функцию. Мы вызываем функции с набором входных параметров, чтобы получить деривации.
  • Синтаксис inherit x эквивалентен x = x. Строка inherit gd скомбинированная с with pkgs; эквивалентна gd = pkgs.gd.

Весь репозиторий, посвящённый пилюле 12, можно найти в этом GitHub Gist.1

Заключение

Паттерн “inputs” позволяет настраивать выражения с помощью набора аргументов. Эти аргументы могут быть флагами, деривациями или любыми другими настройками, доступными в языке Nix. Пакетные выражения — всего лишь функции, здесь нет никакой скрытой магии.

Также паттерн “inputs” позволяет создавать независимые от репозитория выражения. И, поскольку нужные данные передаются через аргументы, выражения можно использовать в других контекстах.

В следующей пилюле

В следующей пилюле мы поговорим про паттерн проектирования “callPackage”. Он избавляет от необходимости дублировать имена входных параметров: и в default.nix, и в пакетном выражении. Благодаря “callPackage” мы можем неявно передать входные параметры из выражения верхнего уровня.

1 У термина gist нет устоявшегося перевода на русский язык. В целом, gist — это штука, которая позволяет ссылаться не на весь код в репозитории, а на его фрагменты.

Паттерн проектирования callPackage

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

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

Удобство callPackage

В предыдущей пилюле мы продемонстрировали, как паттерн входные параметры позволяет отвязать пакеты от репозитория. Это позволило нам явным образом передавать аргументы в деривацию: деривация объявляет параметры, а вызывающая сторона передаёт аргументы.

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

{ input1, input2, ... }:
...

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

rec {
  lib1 = import package1.nix { inherit input1 input2; };
  program2 = import package2.nix { inherit inputX inputY lib1; };
}

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

Вместо того, чтобы дважды передавать входные параметры, мы можем автоматически передавать их из репозитория, переопределяя их в случае необходимости.

Для этого нам потребуется такая функция callPackage, чтобы её можно было вызывать вот так:

{
  lib1 = callPackage package1.nix { };
  program2 = callPackage package2.nix { someoverride = overriddenDerivation; };
}

Надо, чтобы callPackage была функцией с двумя аргументами, которая делает следующее:

  • Импортирует выражение, заданное в файле, который передаётся в первом аргументе, и возвращает функцию. Полученная функция возвращает деривацию пакета, при этом деривация использует паттерн Входные параметры.
  • Определяет имена аргументов функции, то есть имена входных параметров деривации.
  • Передаёт значения аргументов из набора репозитория — мы назовём их значениями по умолчанию. В то же время она позволяет переопределить эти значения, если мы хотим поменять настройки деривации.

Реализуем callPackage

В этом разделе мы реализуем паттерн callPackage с нуля. Для начала разберёмся, как получать имена аргументов функции во время выполнения. В нашем случае речь идёт о функции, которая создаёт деривацию. Зная имена, мы сможем передавать аргументы автоматически.

Для этого Nix предоставляет встроенную функцию:

nix-repl> add = { a ? 3, b }: a+b
nix-repl> builtins.functionArgs add
{ a = true; b = false; }

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

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

Для этого нам нужны две вещи:

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

Чтобы достичь первой цели, достаточно в качестве имён аргументов использовать имена пакетов репозитория, например, nixpkgs. Вторая цель достигается благодаря ещё одной встроенной функции Nix:

nix-repl> values = { a = 3; b = 5; c = 10; }
nix-repl> builtins.intersectAttrs values (builtins.functionArgs add)
{ a = true; b = false; }
nix-repl> builtins.intersectAttrs (builtins.functionArgs add) values
{ a = 3; b = 5; }

intersectAttrs возвращает набор атрибутов, имена которых — это пересечение имён атрибутов обоих аргументов, а значения получены из второго аргумента.

Это всё, что нам нужно: мы получили имена аргументов из функции и заполнили их значениями из существующего набора атрибутов. Вот простая учебная реализация callPackage:

nix-repl> callPackage = set: f: f (builtins.intersectAttrs (builtins.functionArgs f) set)
nix-repl> callPackage values add
8
nix-repl> with values; add { inherit a b; }
8

Проанализируем этот фрагмент кода:

  • Мы определяем функцию callPackage.
  • Первый параметр функции callPackage — это набор пар имя-значение. Часть из них может совпадать с набором аргументов вызываемой фукнции.
  • Второй параметр — вызываемая функция.
  • Мы получаем имена аргументов функции и находим их пересечение с набором всех значений.
  • Наконец, мы вызываем функцию f с полученным пересечением.

Фрагмент выше демонстрирует, что вызов callPackage эквивалентен прямому вызову add a b.

У нас получилось почти всё, что мы хотели: мы вызываем функции автоматически, передавая им набор аргументов. Если аргумент в наборе не найден, мы получаем ошибку (если только у функции не переменное число аргументов, объявленных через ..., как мы объясняли в пятой таблетке).

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

nix-repl> callPackage = set: f: overrides: f ((builtins.intersectAttrs (builtins.functionArgs f) set) // overrides)
nix-repl> callPackage values add { }
8
nix-repl> callPackage values add { b = 12; }
15

Код довольно ясный, не смотря на выросшее количество скобок. Здесь мы всего-навсего объединяем набор аргументов по умолчанию с набором переопределений.

Используем callPackage, чтобы упростить код репозитория

Имея на руках функцию callPackage, мы можем упростить выражение репозитория в default.nix:

let
  nixpkgs = import <nixpkgs> { };
  allPkgs = nixpkgs // pkgs;
  callPackage =
    path: overrides:
    let
      f = import path;
    in
    f ((builtins.intersectAttrs (builtins.functionArgs f) allPkgs) // overrides);
  pkgs = with nixpkgs; {
    mkDerivation = import ./autotools.nix nixpkgs;
    hello = callPackage ./hello.nix { };
    graphviz = callPackage ./graphviz.nix { };
    graphvizCore = callPackage ./graphviz.nix { gdSupport = false; };
  };
in
pkgs

Разберёмся, как это работает:

  • Выражение выше определяет наш собственный репозиторий пакетов, который мы называем pkgs. Он содержит пакет hello и два варианта пакета graphviz.
  • В выражении let мы импортируем nixpkgs. Обратите внимание, что раньше мы ссылались на это значение с помощью переменной pkgs, но теперь это имя зарезервировано за репозиторием, который мы создаём.
  • Нам нужен способ как-то передать pkgs в callPackage. Вместо того, чтобы напрямую вернуть набор пакетов из default.nix, мы сначала присваиваем его переменной в выражении let, что позволяет передать его в callPackage.
  • В целях упрощения, мы передаём в callPackage не функцию, а имя файла, из которого callPackage эту функцию импортирует. Иначе бы нам пришлось вручную импортировать каждый пакет.
  • Поскольку наши выражения используют пакеты из nixpkgs, в callPackage мы описываем переменную allPkgs, в которую помещаем пакеты из nixpkgs и наши пакеты.
  • Мы переместили функцию mkDerivation в pkgs, чтобы она передавалась в параметрах автоматически.

Обратите внимание, как легко мы переопределили аргументы для создания graphviz без gd. Кроме того, обратите внимание, как легко слить два репозитория — nixpkgs и наш pkgs!

Читатель должен заметить магическую вещь, которая здесь происходит. Чтобы определить pkgs, мы используем callPackage, а при определении callPackage мы используем pkgs. Эта магия работает благодаря ленивым вычислениям: builtins.intersectAttrs не должен знать все значения из allPkgs, чтобы найти пересечение, а только ключи, ни один из которых не требует вычисления callPackage.

Заключение

Паттерн "callPackage" значительно упростил код репозитория. Мы смогли импортировать пакеты с именованными аргументами и вызывать их автоматически, при этом пользуясь пакетами из nixpkgs.

Также мы познакомились с полезными встроенными функциями, которые позволили нам получить информацию о функциях Nix и манипулировать атрибутами. Эти встроенные функции обычно используются не для установки пакета, а, скорее, как инструменты при установке. Они описаны в Руководстве по Nix.

Создание репозитория в Nix — это эволюция разработки удобных функций для комбинирования пакетов. Эта пилюля демонстрирует, как Nix может быть универсальным инструментом для сборки и развёртывания программ и как он подходит для создания репозиториев, которые следуют нашим собственным соглашениями.

В следующей пилюле

В следующей пилюле мы поговорим о паттерне проектирования “переопределение”. Пакет graphvizCore кажется простым. Он использует скрипт graphviz.nix и собирает пакет без поддержки gd. В следующей пилюле мы посмотрим на этот процесс с другой точки зрения: может быть стоит начать с функции pkgs.graphviz и отключать gd при её вызове?

Паттерн проектирования override (переопределение)

Добро пожаловать на четырнадцатую пилюлю Nix. В предыдущей тринадцатой пилюле мы рассказали о паттерне callPackage и показали, как с его помощью объединять пакеты в репозиторий.

Следующий паттерн проектирования не настолько полезен, впрочем, его довольно часто используют и, кроме того, он позволяет глубже разобраться, как устроен Nix.

О композируемости

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

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

Скажем, у нас есть начальная деривация drv и мы хотим преобразовать её в drv с отладочной информацией и пользовательскими патчами:

debugVersion (applyPatches [ ./patch1.patch ./patch2.patch ] drv)

Конечным результатом должна стать оригинальная деривация с какими-то изменениями. Этот подход кажется интересным, в то же время он сильно отличается от других подходов к созданию пакетов, что является следствием использования функционального языка.

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

Паттерн override (переопределение)

В пилюле 12 мы рассказали о паттерне проектирования входные параметры. Мы не возвращаем деривацию, выбирая зависимости непосредственно из репозитория; вместо этого мы объявляем входные параметры и позволяем вызывающим функциям передавать необходимые аргументы.

В нашем репозитории есть набор атрибутов, которые импортируют выражения пакетов и передают им аргументы, получая в ответ деривацию. Для примера рассмотрим атрибут graphviz:

graphviz = import ./graphviz.nix { inherit mkDerivation gd fontconfig libjpeg bzip2; };

Если бы мы хотели создать деривацию graphviz с отличающейся версией gd, нам бы пришлось повторить большую часть приведённого кода, плюс указать альтернативу gd:

{
  mygraphviz = import ./graphviz.nix {
    inherit
      mkDerivation
      fontconfig
      libjpeg
      bzip2
      ;
    gd = customgd;
  };
}

Такой код трудно поддерживать. Использовать callPackage уже проще:

mygraphviz = callPackage ./graphviz.nix { gd = customgd; };

Но мы всё ещё наследуем оригинальный пакет graphviz из репозитория.

Нам бы хотелось избавиться от повторного указания выражения Nix. Вместо этого было бы здорово повторно использоваль оригинальный атрибут graphviz из репозитория и добавить к нему наши переопределения:

mygraphviz = graphviz.override { gd = customgd; };

Отличия, так же, как и преимущества такого подхода, кажется, очевидны.

Обратите внимание, что .override не является “методом” в смысле ООП, как вам могло бы показаться. Nix — функциональный язык. Так что .override — это всего лишь атрибут набора.

Реализация паттерна override

Вспомним, что атрибут graphviz — это деривация, возвращаемая функцией, импортируемой из graphviz.nix. Нам бы хотелось добавить атрибут override к возвращаемому набору.

Давайте начнём с создания функции “makeOverridable”. Эта функция получает два аргумента: функцию (которая должна возвращать набор) и набор исходных аргументов, которые надо передать этой функции.

Поместим эту функцию в lib.nix:

{
  makeOverridable =
    f: origArgs:
    let
      origRes = f origArgs;
    in
    origRes // { override = newArgs: f (origArgs // newArgs); };
}

makeOverridable принимает функцию и набор исходных аргументов. Она возвращает исходный набор плюс новый атрибут override.

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

Посмотрим, как это работает в nix repl:

$ nix repl
:l lib.nix
Added 1 variables.
f = { a, b }: { result = a+b; }
f { a = 3; b = 5; }
{ result = 8; }
res = makeOverridable f { a = 3; b = 5; }
res
{ override = «lambda»; result = 8; }
res.override { a = 10; }
{ result = 15; }

Обратите внимание, что, функция f возвращает не сумму двух аргументов. Она возвращает набор, в котором сумма привязана к атрибуту result.

Переменная res содержит результат выполнения функции без переопределения. Это видно из кода makeOverridable. Кроме того, появился новый атрибут override, значением которого является функция.

Вызов res.override, как и ожидалось, запустит исходную функцию с переопределёнными атрибутами.

Хорошее начало, но мы пока не можем повторно переопределить атрибуты! Всё потому, что возвращаемый набор (с result = 15) не имеет собственного атрибута override. Это плохо, так как мешает композиции функций.

Решение здесь простое: функция .override должна вернуть переопределяемый результат:

rec {
  makeOverridable =
    f: origArgs:
    let
      origRes = f origArgs;
    in
    origRes // { override = newArgs: makeOverridable f (origArgs // newArgs); };
}

Обратите внимание на ключевое слово rec. Оно необходимо, чтобы мы могли вызвать makeOverridable из самой makeOverridable.

Теперь попробуем двойное переопределение:

nix-repl> :l lib.nix
Added 1 variables.
nix-repl> f = { a, b }: { result = a+b; }
nix-repl> res = makeOverridable f { a = 3; b = 5; }
nix-repl> res2 = res.override { a = 10; }
nix-repl> res2
{ override = «lambda»; result = 15; }
nix-repl> res2.override { b = 20; }
{ override = «lambda»; result = 30; }

Получилось! Результат (как и ожидалось) равен 30, потому что a в первом переопределении получила значение 10, а b во втором — 20.

Теперь было бы неплохо, если бы функция callPackage делала наши деривации переопределяемыми. Пусть это останется упражнением для читателя.

Заключение

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

Мы можем помечтать об отдельном изолированном окружении nix-shell для тестирования graphviz с альтернативной версией gd:

debugVersion (graphviz.override { gd = customgd; })

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

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

В следующей пилюле

В следующей пилюле мы поговорим о путях поиска Nix. Под “путём поиска” мы подразумеваем место в файловой системе, где Nix ищет выражения. Это позволить нам узнать, откуда берётся <nixpkgs>.

Поисковые пути Nix

Добро пожаловать на пятнадцатую пилюлю Nix. В предыдущей четырнадцатой пилюле мы познакомились с паттерном “переопределение”, позволяющим создавать разные варианты дериваций с помощью параметров.

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

NIX_PATH

Переменная окружения NIX_PATH — одна из самых важных. Она очень похожа на переменную окружения PATH. Тот же синтаксис — несколько путей, разделённых символом :. Nix что-то ищет в этих каталогах, перебирая их слева направо.

Где нужен NIX_PATH? В выражениях Nix! Да, NIX_PATH нужен не столько для инструментария Nix, как такового, сколько для написания выражений Nix.

Например, в командной строке, когда вы запускаете ping, оболочка ищет программу в каталогах, перечисленных в PATH. Запущен будет первый найденный файл.

В Nix всё точно также, при небольшом отличии синтаксиса. Вместо того, чтобы ввести ping, вы вводите <ping>. Да, я знаю… вы уже подумали про <nixpkgs>. Что же, читайте дальше, скоро во всём разберёмся.

Для чего нужна переменная NIX_PATH? Выражения Nix могут ссылаться на “абстрактный” путь, такой как <nixpkgs>, и этот путь можно переопределить из командной строки.

Запуск nix-instantiate --eval поможет нам провести пару тестов, чтобы разобраться, как всё работает. Напоминаю, что nix-instantiate используется для выполнения выражений и генерации файлов .drv. Сейчас нам не надо собирать деривации, так что обычного выполнения будет достаточно. Программу можно использовать для запуска одноразовых выражений.

Небольшая подделка

То, что мы сейчас сделаем, бессмысленно с точки зрения Nix, но поможет нам разобраться. Возьмём переменную PATH вместо NIX_PATH и попытаемся найти ping (или любую другую утилиту, если этой у вас нет).

$ nix-instantiate --eval -E '<ping>'
error: file `ping' was not found in the Nix search path (add it using $NIX_PATH or -I)
$ NIX_PATH=$PATH nix-instantiate --eval -E '<ping>'
/bin/ping
$ nix-instantiate -I /bin --eval -E '<ping>'
/bin/ping

Великолепно! При первой попытке Nix однозначно заявил, что не смог найти программу ни в одном из поисковых путей. Обратите внимание, что опция -I принимает в качестве параметра один каталог. Пути, добавленные через -I имеют приоритет на путями из NIX_PATH.

Помимо путей, в NIX_PATH может встретиться синтаксис “somename=somepath”, которого нет в PATH. Он позволяет указать точное место нахождения программы и избавиться от поиска.

$ NIX_PATH="ping=/bin/ping" nix-instantiate --eval -E '<ping>'
/bin/ping
$ NIX_PATH="ping=/bin/foo" nix-instantiate --eval -E '<ping>'
error: file `ping' was not found in the Nix search path (add it using $NIX_PATH or -I)

Обратите внимание, что во втором случае Nix проверяет существование пути.

Путь к репозиторию

Вам ведь любопытно, да?

$ nix-instantiate --eval -E '<nixpkgs>'
/home/nix/.nix-defexpr/channels/nixpkgs
$ echo $NIX_PATH
nixpkgs=/home/nix/.nix-defexpr/channels/nixpkgs

У вас может быть другой путь, в зависимости от того, как вы добавляли каналы, и т. д. В этом, в общем-то, и суть. Переменная <nixpkgs> ссылается на каталог в файловой системе, определённый в переменной NIX_PATH.

Вы можете просмотреть этот каталог и убедиться, что там находится один из коммитов репозитория nixpkgs (подсказка: .version-suffix).

Переменная NIX_PATH экспортируется скриптом nix.sh. Именно поэтому я просил вас выполнить команду source nix.sh в предыдущих постах.

Возможно, вы задаётесь вопросом: могу ли я указать другой путь к nixpkgs, выполнив git checkout из репозитория nixpkgs? Да, вы можете, более того, я рекомендую это сделать. Займёмся этим в следующей пилюле.

Теперь попробуем определить путь к нашему репозиторию! Пусть все пакеты default.nix, graphviz.nix и прочие будут в каталоге /home/nix/mypkgs:

$ export NIX_PATH=mypkgs=/home/nix/mypkgs:$NIX_PATH
$ nix-instantiate --eval '<mypkgs>'
{ graphviz = <code>; graphvizCore = <code>; hello = <code>; mkDerivation = <code>; }

Да, и nix-build принимает пути в угловых скобках. Сначала мы выполняем выражение для всего репозитория (из файла default.nix), а затем можем выбрать атрибут, в данном случае graphviz.

Несколько слов о nix-env

Команда nix-env отличается от nix-instantiate и nix-build. В то время, как nix-instantiate и nix-build требуют вычисления выражения Nix, nix-env — не требует.

Эта концепция может сбить с толку. Вам может показаться, что nix-env использует NIX_PATH, чтобы найти репозиторий nixpkgs. Но нет.

Команда nix-env использует ~/.nix-defexpr, который по умолчанию также является частью NIX_PATH, что, на самом деле, всего лишь совпадение. Если вы очистите NIX_PATH, nix-env всё равно сможет искать деривации из-за ~/.nix-defexpr.

Так что если вы запустите nix-env -i graphviz в каталоге вашего репозитория, утилита установит пакет из nixpkgs. То же самое случится, если NIX_PATH будет указывать на ваш репозиторий.

Чтобы выбрать альтернативный путь вместо ~/.nix-defexpr, используйте опцию -f:

$ nix-env -f '<mypkgs>' -i graphviz
warning: there are multiple derivations named `graphviz'; using the first one
replacing old `graphviz'
installing `graphviz'

Почему утилита вывела сообщение о другой деривации с именем graphviz? Потому что у обоих атрибутов graphviz и graphvizCore из вашего репозитория, имя деривации — “graphviz”:

$ nix-env -f '<mypkgs>' -qaP
graphviz      graphviz
graphvizCore  graphviz
hello         hello

Обычно nix-env разбирает все деривации и использует имена дериваций, чтобы интерпретировать командную строку. Так что здесь “graphviz” соответствует двум деривациям. Как и в случае с nix-build, вы можете использовать -A, чтобы программа искала имена атрибутов вместо имён дериваций:

$ nix-env -f '<mypkgs>' -i -A graphviz
replacing old `graphviz'
installing `graphviz'

Эта форма, не только точнее, но и быстрее, поскольку nix-env не нужно проверять все деривации.

Для полноты картины: вы должны устанавливать graphvizCore с опцией -A, поскольку без неё выбор деривации неоднозначен.

Подведём итог. Возможна ситуация, когда когда nix-env выберет деривацию, отличную от той, которую выберет nix-build. Даже если вы указали правильный путь в NIX_PATH, nix-env ищет деривации в ~/.nix-defexpr.

Почему nix-evn ведёт себя иначе? Я, на самом деле, не знаю. Думаю, что верен один из этих ответов:

  • nix-env пытается быть универсальной утилитой, поэтому не зависит от NIX_PATH, а ищет деривации в ~/.nix-defexpr.
  • nix-env позволяет слить несколько репозиторией в один файл ~/.nix-defexpr, благодаря чему можно получить доступ ко всем деривации на вашей машине.

Впрочем, дело может быть и в том, что разработчики nix-env постарались сделать утилиту дружелюбнее при обычных настройках пользователя. Речь об ошибке нельзя сопоставить имя деривации при установке (you cannot match a derivation name when installing), возникающей из-за неоднозначного имени деривации, как было описано выше.

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

Заключение

Переменная NIX_PATH содержит поисковые пути. Утилиты Nix используют их, когда встречают имя пакета в угловых скобках. Это позволяет использовать “абстрактные” пути в выражениях Nix и опредлять “конкретный” путь с помощью NIX_PATH или флага -I.

Утилита nix-env работает не так, как другие. Для поиска пакетов она использует не переменную NIX_PATH, а файл ~/.nix-defexpr. Будьте внимательны!

В целом, постарайтесь не злоупотреблять NIX_PATH. При написании собственных выражений Nix по возможности используйте относительные пути. Впрочем, при ссылке на <nixpkgs> из собственного репозитория использовать NIX_PATH совершенно нормально. А вот внутри репозитория используйте относительные пути, например, ./hello.nix.

В следующей пилюле

…мы, наконец, погрузимся в nixpkgs. Нам пригодятся все техники, с которыми мы познакомились в этом цикле. Это и mkDerivation, и callPackage, и override, и остальные, но, конечно, более проработанные. Сообщество постоянно улучшает эти утилиты, добавляя новые функции, которые помогают обрабатывать больше сценариев более общим способом.

Параметры nixpkgs

Добро пожаловать на шестнадцатую пилюлю Nix. В предыдущей пятнадцатой пилюле мы выяснили, как Nix обрабатывает выражения в угловых скобках. Теперь мы знаем, где находится <nixpkgs>.

Теперь мы готовы к погружению в репозиторий nixpkgs сквозь всё многообразие инструментов и паттернов проектирования. Хочу обратить ваше внимание на то, что у nixpkgs есть собственное руководство. Как мы говорили выше, Nix не накладывает ограничений на то, каким должен быть репозиторий, так что nixpkgs — всего лишь один из возможных вариантов. Разные руководства подчёркивают тот факт, что связь между Nix и nixpkgs не такая сильная, как вы, возможно, думали.

Выражение default.nix

Вместо того, чтобы с головой погрузиться в изучение пакетов, давайте для начала разберёмся со структурой nixpkgs в целом.

Разрабатывая собственный репозиторий, мы создали для него файл default.nix, куда поместили выражения для нескольких пакетов.

Точно также и у nixpkgs есть собственный default.nix, который загружается, когда кто-то ссылается на <nixpkgs>. Он проверяет, что версия Nix не меньше, чем 1.7 (на момент написания оригинального поста — прим. переводчика), а затем импортирует pkgs/top-level/all-packages.nix. Начиная с этого момента, этот набор пакетов мы будем называть pkgs.

Настоящий файл, который объединяет все пакеты — это all-packages.nix. Обратите внимание, что пакеты хранятся в подкаталоге pkgs/, в то время, как сама NixOS — в подкаталоге nixos/.

Содержимое all-packages.nix весьма интересно. Это функция, которая принимает пару интересных параметров:

  • system: значение по-умолчанию — текущая система
  • config: значение по-умолчанию null
  • прочее…

Параметр system, если верить комментарию в выражении — это система, для которой собираются пакеты. Он, например, позволяет собирать пакеты i686 на машине amd64.

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

Параметр system

Вы часто встретите этот параметр в различных выражениях .nix, например, в выражениях выпусков (releases). Причина в том, что pkgs принимает параметр system, так что, импортируя pkgs, вы можете передать в него нужное значение.

myrelease.nix:

{ system ? builtins.currentSystem }:

let pkgs = import <nixpkgs> { inherit system; };
...

Для чего это может понадобиться? Например, для того, чтобы без серьёзных ухищрений выбрать набор пакетов конкретной системы:

nix-build -A psmisc --argstr system i686-linux

Этот вызов соберёт деривацию psmisc для i686-linux вместо x86_67-linux. Концепция очень похожа на мультиархивы Debian.

Настройка кросс-компиляции есть и в nixpkgs, но мы не будем погружаться в изучение этого вопроса, поскольку я в нём пока не разбирался (речь про автора оригинального цикла — Люка Бруно; впрочем, переводчик тоже в вопросе не разбирался — прим. переводчика).

Параметр config

Я уверен, вы читали в wiki или других руководствах про ~/.config/nixpkgs/config.nix (ранее — ~/.nixpkgs/config.nix), и я уверен, вы задавались вопросом, почему этот путь «вшит» в Nix. На самом деле, он вшит не в Nix, а в nixpkgs.

Выражение all-packages.nix принимает параметр config. Если там содержится null, функция ищет переменную окружения NIXPKGS_CONFIG. Если переменная не найдена, nixpkgs обращается к $HOME/.config/nixpkgs/config.nix.

При обнаружении config.nix, он будет импортирован как выражение Nix, которое и станет значением config (конечно, в том случае, если параметр не был явно передан при импорте <nixpkgs>).

Значение config доступно после импорта репозитория:

$ nix repl
nix-repl> pkgs = import <nixpkgs> {}
nix-repl> pkgs.config
{ }
nix-repl> pkgs = import <nixpkgs> { config = { foo = "bar"; }; }
nix-repl> pkgs.config
{ foo = "bar"; }

Что указывать в config — вопрос удобства и соглашений.

Например, config.allowUnfree — атрибут, запрещающий сборку пакетов, чья лицензия по умолчанию не является свободной. Настройка config.pulseaudio подсказывает, собирать ли пакеты с поддержкой PulseAudio, если это возможно и если деривация это умеет.

О функциях .nix

Файл .nix содержит выражение Nix, которое может быть функцией. Напомню, что nix-build обрабатывает только те выражения, результатом которых является деривация. Так что вполне естественно возвращать деривацию напрямую из файла .nix. Кроме того, вполне естественно передавать в файл .nix параметры, помогающие настроить эту деривацию.

Для таких случае Nix использует трюк:

  • Если выражение является деривацией, собрать её.
  • Если выражение является функцией, вызвать её и собрать деривацию, которую она вернула.

Например, с помощью nix-build можно собрать такой файл:

{ pkgs ? import <nixpkgs> {} }:

pkgs.psmisc

Nix может вызвать функцию, поскольку у параметра pkgs есть значение по умолчанию. Вы можете передать другое значение pkgs, используя опцию –arg.

Останется ли схема работей, если у вас будет функция, которая возвращает функцию, которая возвращает деривацию? Нет, Nix вызывает найденную функцию только один раз.

Заключение

Мы разобрались с тем, что из себя представляет репозиторий <nixpkgs>. Это функция, которая принимает несколько параметров и возвращает набор всех пакетов. Из-за ленивых вычислений, собраны будут только те деривации, к которым вы обратитесь.

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

В последнее время я немного занят выпуском NixOS 14.11 и другими вещами, а также думаю о переходе с Blogger на другую блог-платформу, больше ориентированную на программистов. Поэтому прошу прощения, что пишу редко и мало (снова речь идёт об авторе оригинального цикла Люка Бруно — прим. переводчика).

В следующей пилюле

…мы поговорим о переопределении пакетов в репозитории nixpkgs. Что, если бы вам захотелось изменить несколько параметров вашей библиотеки? Смогут ли пакеты, настроив параметры, подключить новую библиотеку вместо старой? Одна из возможностей, как мы узнали в этой главе — использование параметра config. Однако у этого подхода есть ограничения. А другой способ — это переопределение дериваций.

Переопределение пакетов nixpkgs

Добро пожаловать на семнадцатую пилюлю Nix. В предыдущей шестнадцатой пилюле мы начали погружение в репозиторий nixpkgs. Теперь мы знаем, что ’nixpkgs — это функция и мы разобрались, для чего ей параметры systemиconfig`.

Сегодня мы поговорим о специальном атрибуте — config.packageOverrides. Переопределение пакетов в наборе с неподвижной точкой можно рассматривать как ещё один паттерн проектирования в nixpkgs.

Переопределение пакета

Вспомните паттерн проектирования переопределение из пилюли 14, где мы сделали косвенный вызов функции с параметром, вместо того, чтобы вызывать её напрямую.

Мы поместили функцию override в набор атрибутов, возвращаемый оригинальной функцией.

Возьмём, например, пакет graphviz. У него есть входящий параметр xorg. Если он равен null, graphviz будет собран без поддержки X Window System.

$ nix repl
nix-repl> :l <nixpkgs>
Added 4360 variables.
nix-repl> :b graphviz.override { withXorg = false; }

Этот вызов соберёт graphviz без поддержки X, всё настолько просто.

Теперь пусть, скажем, пакет P зависит от graphviz, как сделать, чтобы P зависел от новой версии graphviz без поддержки X?

В императивном мире…

…вы могли бы сделать что-то такое:

pkgs = import <nixpkgs> {};
pkgs.graphviz = pkgs.graphviz.override { withXorg = false; };
build(pkgs.P)

Учитывая, что pkgs.P зависит от pkgs.graphviz, легко собрать P с изменённым graphviz. В чистом функциональном языке это не так просто, поскольку ваши переменные иммутабельны и вы не можете присваивать им новые значения.

Неподвижная точка

(Комментарий переводчика: автор цикла с места в карьер использует термин неподвижная точка, который неизвестен разработчикам, не сталкивавшимся с функциональным программированием. Это интересная, но не самая простая концепция — трудно объяснить её «на пальцах». Не вдаваясь в подробности, скажу, что неподвижная точка — остроумное решение парадоксальной задачи. В функциональных языках часто используют лямбда-функции, они же анонимные функции, то есть не имеющие имени. Функцию, у которой есть имя, можно сделать рекурсивной — она может вызвать сама себя по имени. А как сделать рекурсивной анонимную функцию? Ответ — с помощью второй функции специального вида, которая и носит название неподвижной точки. Так что, встретив в тексте «неподвижную точку», вы можете мысленно подставлять вместо вместо неё слова «штука для рекурсии». Этого хватит, чтобы прочитать статью, но я бы посоветовал изучить вопрос глубже. Просто для удовольствия.)

Неподвижная точка с ленивым вычислением — не самая удобная, но необходимая часть языка Nix. С помощью неё можно решить нашу задачу с пакетами P и graphviz.

Вот определение неподвижной точки в nixpkgs:

{
  # Взять функцию и запустить её с результатом, который она вернула.
  fix =
    f:
    let
      result = f result;
    in
    result;
}

Это функция, которая принимает функцию f и вызывает её с параметром result, а результат вызова снова передаёт функции f, и так далее. Иными словами, это f(f(f(...

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

nix-repl> fix = f: let result = f result; in result
nix-repl> pkgs = self: { a = 3; b = 4; c = self.a+self.b; }
nix-repl> fix pkgs
{ a = 3; b = 4; c = 7; }

У нас получилось сослаться на a и b в том же самом наборе без ключевого слова rec.

  • Сначала вызывается pkgs с пока ещё не вычисленными параметрами (pkgs(pkgs(...)
  • Чтобы установить значение c, вычисляются self.a и self.b.
  • Функция pkgs вызывается снова, чтобы получить значения a и b.

Трюк заключается в том, что значение c не нужно во внутреннем вызове, поэтому мы не входим в бесконечный цикл.

Не стану вдаваться здесь в дальнейшие объяснения. Хорошее введение в неподвижную точку можно найти здесь (англ.).

Переопределение набора с помощью неподвижной точки

Учитывая, что self.a и self.b ссылаются на переданный набор, а не на константы в определении функции pkgs, мы можем переопределить a и b, и получить новое значение для c:

nix-repl> overrides = { a = 1; b = 2; }
nix-repl> let newpkgs = pkgs (newpkgs // overrides); in newpkgs
{ a = 3; b = 4; c = 3; }
nix-repl> let newpkgs = pkgs (newpkgs // overrides); in newpkgs // overrides
{ a = 1; b = 2; c = 3; }

В первом случае мы вычислили pkgs с переопределённым набором, а во втором, помимо этого, мы включили переопределённые атрибуты в результат.

Переопределение пакетов nixpkgs

Мы разобрались, как переопределять атрибуты в наборе, чтобы зависящие от них атрибуты имели доступ к новым значениям. Тот же подход можно использовать и для дериваций. В конце концов, nixpkgs — это гигантский набор атрибутов, которые зависят друг от друга.

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

Создайте файл config.nix следующего вида:

{
    packageOverrides = pkgs: {
    graphviz = pkgs.graphviz.override {
      # запретить поддержку xorg
      withXorg = false;
    };
  };
}

Теперь мы можем собрать, скажем, asciidoc-full который использует переопределённый graphviz:

nix-repl> pkgs = import <nixpkgs> { config = import ./config.nix; }
nix-repl> :b pkgs.asciidoc-full

Обратите внимание, как мы передаём config с атрибутом packageOverrides, когда импортируем nixpkgs. Здесь pkgs.asciidoc-full — это деривация с входящим параметром graphviz (при этом pkgs.asciidoc — это облегчённая версия, которая в принципе не использует graphviz).

Поскольку в кэше исполняемых файлов нет версии asciidoc с graphviz без поддержки X Window System, Nix перекомпилирует нужные вам компоненты.

Файл ~/.config/nixpkgs/config.nix

Мы уже обсуждали этот файл в предыдущей пилюле. Обычно там находится что-то похожее на config.nix, который мы только что написали.

Файл позволяет избавиться от явной передачи параметров всякий раз, когда мы импортируем nixpkgs, поскольку загружается в nixpkgs атоматически.

Заключение

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

Императивные пакетные менеджеры устанавливают новые версии библиотек поверх старых. В Nix не всё так просто и прямолинейно. Но более аккуратно.

Приложения Nix зависят от определённых версий библиотек, поэтому нам приходится перекомпилировать asciidoc, чтобы использовать новую библиотеку graphviz.

Новая версия asciidoc будет зависеть от новой версии graphviz, а старая версия asciidoc без помех продолжит работать со старой версию graphviz.

В следующей пилюле

…мы на время оставим изучение nixpkgs и поговорим о путях хранения. Как Nix генерирует путь в хранилище, где будет размещена сборка? И как добавить в хранилище файлы, для которых предусмотрена проверка целостности?

Пути хранения Nix

Добро пожаловать на восемнадцатую пилюлю Nix. В предыдущей семнадцатой пилюле мы разобрались, как «в целом» устроен репозиторий nixpkgs. Это набор пакетов и мы можем переопределять эти пакеты так, что все остальные пакеты могут их использовать.

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

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

Исходные пути

Давайте начнём с простого. Вы знаете, что Nix позволяет использовать относительные пути. Так как файл или каталог находятся в хранилище Nix, то какой-нибудь ./myfile лежит где-то в /nix/store/........ Мы хотим разобраться, как именно генерируется путь хранения такого файла:

$ echo mycontent > myfile

Напомню, что у простейшей деривации, которую вы можете сделать, должны быть указаны имя (name), сборщик (buidler) и система (system):

$ nix repl
nix-repl> derivation { system = "x86_64-linux"; builder = ./myfile; name = "foo"; }
«derivation /nix/store/y4h73bmrc9ii5bxg6i7ck6hsf5gqv8ck-foo.drv»

Теперь загляните внутрь файла .drv, чтобы узнать, где хранится ./myfile:

$ nix derivation show /nix/store/y4h73bmrc9ii5bxg6i7ck6hsf5gqv8ck-foo.drv
{
  "/nix/store/y4h73bmrc9ii5bxg6i7ck6hsf5gqv8ck-foo.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/hs0yi5n5nw6micqhy8l1igkbhqdkzqa1-foo"
      }
    },
    "inputSrcs": [
      "/nix/store/xv2iccirbrvklck36f1g7vldn5v58vck-myfile"
    ],
    "inputDrvs": {},
    "platform": "x86_64-linux",
    "builder": "/nix/store/xv2iccirbrvklck36f1g7vldn5v58vck-myfile",
    "args": [],
    "env": {
      "builder": "/nix/store/xv2iccirbrvklck36f1g7vldn5v58vck-myfile",
      "name": "foo",
      "out": "/nix/store/hs0yi5n5nw6micqhy8l1igkbhqdkzqa1-foo",
      "system": "x86_64-linux"
    }
  }
}

Хороший вопрос: почему Nix решил выбрать имя xv2iccirbrvklck36f1g7vldn5v58vck? Посмотрим, что там написано в исходном коде Nix.

Обратите внимание: запуск nix-store --add myfile сохранит файл по тому же пути хранения.

Шаг 1: вычисляем хеш файла

Комментарии подсказывают нам, что сначала вычисляется хеш sha256 — таким же образом, как и при сериализации файла в архив NAR. Вручную мы можем это сделать двумя способами:

$ nix-hash --type sha256 myfile
2bfef67de873c54551d884fdab3055d84d573e654efa79db3c0d7b98883f9ee3

или:

$ nix-store --dump myfile|sha256sum
2bfef67de873c54551d884fdab3055d84d573e654efa79db3c0d7b98883f9ee3

В целом, Nix понимает содержимое двух видов: плоское для обычных файлов и рекурсивное для архивов NAR, где может находиться что угодно.

Шаг 2: строим строковое описание

Затем Nix использует строку специального вида, которая включает хеш, тип пути и имя файла. Сохраним её в отдельный файл:

$ echo -n "source:sha256:2bfef67de873c54551d884fdab3055d84d573e654efa79db3c0d7b98883f9ee3:/nix/store:myfile" > myfile.str

Шаг 3: вычисляем окончательный хеш

Наконец, комментарии подсказывают, что надо вычислить хеш sha256 от строки, полученной на предыдущем шаге, усечь результат до 160 бит и перевести их в представление Base32:

$ nix-hash --type sha256 --truncate --base32 --flat myfile.str
xv2iccirbrvklck36f1g7vldn5v58vck

Выходные пути

Обычно выходные пути генерируется для дериваций. Простой пример с файлом нужен был для того, чтобы показать, как всё работает. Что касается дериваций, то Nix знает, что выходной путь — это hs0yi5n5nw6micqhy8l1igkbhqdkzqa1, даже если мы не запустили сборку. Причина в том, что выходной путь зависит только от входных параметров.

Он вычисляется практически также, как и пути хранения, за исключением того, что хешируется файл .drv и тип деривации — это output:out. В случае нескольких выходных путей, у нас может быть несколько output:<id>.

В тот момент, когда Nix вычисляет выходной путь, в файле .div вместо каждого выходного пути хранится пустая строка. Так что мы берём наш .drv и заменяем выходные пути пустыми строками:

$ cp -f /nix/store/y4h73bmrc9ii5bxg6i7ck6hsf5gqv8ck-foo.drv myout.drv
$ sed -i 's,/nix/store/hs0yi5n5nw6micqhy8l1igkbhqdkzqa1-foo,,g' myout.drv

Файл myout.drv — это состояние .drv перед тем, как Nix вычислит выходной путь для нашей деривации:

$ sha256sum myout.drv
1bdc41b9649a0d59f270a92d69ce6b5af0bc82b46cb9d9441ebc6620665f40b5  myout.drv
$ echo -n "output:out:sha256:1bdc41b9649a0d59f270a92d69ce6b5af0bc82b46cb9d9441ebc6620665f40b5:/nix/store:foo" > myout.str
$ nix-hash --type sha256 --truncate --base32 --flat myout.str
hs0yi5n5nw6micqhy8l1igkbhqdkzqa1

Теперь Nix записывает выходной путь в файл .drv и на этом процесс завершается.

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

Иными словами, вы получается окончательный файл .drv, где все другие файлы .drv заменены их хешами.

Фиксированные выходящие пути

Существует ещё один вид пути — когда нам известен хеш целостности файла. Обычное дело для tar-архивов.

У деривации может быть три специальных атрибута — outputHashMode, outputHash и outputHashAlgo. Они хорошо документированы в руководстве Nix.

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

Допустим, наш скрипт должен создать файл, который содержит строку mycontent:

$ echo mycontent > myfile
$ sha256sum myfile
f3f3c4763037e059b4d834eaf68595bbc02ba19f6d2a500dce06d124e2cd99bb  myfile
nix-repl> derivation { name = "bar"; system = "x86_64-linux"; builder = "none"; outputHashMode = "flat"; outputHashAlgo = "sha256"; outputHash = "f3f3c4763037e059b4d834eaf68595bbc02ba19f6d2a500dce06d124e2cd99bb"; }
«derivation /nix/store/ymsf5zcqr9wlkkqdjwhqllgwa97rff5i-bar.drv»

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

$ nix derivation show /nix/store/ymsf5zcqr9wlkkqdjwhqllgwa97rff5i-bar.drv
{
  "/nix/store/ymsf5zcqr9wlkkqdjwhqllgwa97rff5i-bar.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/a00d5f71k0vp5a6klkls0mvr1f7sx6ch-bar",
        "hashAlgo": "sha256",
        "hash": "f3f3c4763037e059b4d834eaf68595bbc02ba19f6d2a500dce06d124e2cd99bb"
      }
    },
[...]
}

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

Nix создаёт промежуточное строковое представление для содержимого с фиксированным выходным путём:

$ echo -n "fixed:out:sha256:f3f3c4763037e059b4d834eaf68595bbc02ba19f6d2a500dce06d124e2cd99bb:" > mycontent.str
$ sha256sum mycontent.str
423e6fdef56d53251c5939359c375bf21ea07aaa8d89ca5798fb374dbcfd7639  myfile.str

Затем обрабатывает его так же, как и любой другой выходной путь деривации:

$ echo -n "output:out:sha256:423e6fdef56d53251c5939359c375bf21ea07aaa8d89ca5798fb374dbcfd7639:/nix/store:bar" > myfile.str
$ nix-hash --type sha256 --truncate --base32 --flat myfile.str
a00d5f71k0vp5a6klkls0mvr1f7sx6ch

Как видим, путь хранения зависит только от фиксированного выходного хеша.

Заключение

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

Кроме того, мы выяснили что Nix’у известен выходной путь деривации до её сборки, поскольку он зависит только от входных параметров. Наконец, мы узнали о деривациях с фиксированным выходным путём, которые nixpkgs использует, например, для загрузки и проверки tar-архивов с исходниками.

В следующей пилюле

…мы расскажем про stdenv. В предыдущих пилюлях мы разработали собственную удобную функцию mkDerivation для создания дериваций. Теперь мы узнаем, какие ещё удобные функции можно использовать для компиляции проектов autotools и других систем сборки.

Основы stdenv

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

Настало время исследовать nixpkgs и, конкретно, одну из его основных дериваций — stdenv.

Деривация stdenv не считается какой-то особенной, но очень важна для репозитория nixpkgs. Она служит базой для создания пакетов, поскольку подгружает зависимости для инструментария GCC, GNU make, утилит ядра, утилит patch и diff и т. д. Благодаря ей, нам доступны основные утилиты, необходимые для сборки огромной кучи программ, присутствующих в nixpkgs в данный момент.

Что такое stdenv?

Прежде всего, stdenv — это очень простая деривация:

$ nix-build '<nixpkgs>' -A stdenv
/nix/store/k4jklkcag4zq4xkqhkpy156mgfm34ipn-stdenv
$ ls -R result/
result/:
nix-support/  setup

result/nix-support:
propagated-user-env-packages

В ней всего лишь два файла: /setup и /nix-support/propagated-user-env-packages. Последний можно не принимать во внимание, поскольку, по факту, он пустой. Действительно важный файл — это /setup.

Как такая простая деривация может включать в себя весь набор инструментов и базовых утилит для сборки пакетов? Взглянем на зависимости времени выполнения:

$ nix-store -q --references result
/nix/store/3a45nb37s0ndljp68228snsqr3qsyp96-bzip2-1.0.6
/nix/store/a457ywa1haa0sgr9g7a1pgldrg3s798d-coreutils-8.24
/nix/store/zmd4jk4db5lgxb8l93mhkvr3x92g2sx2-bash-4.3-p39
/nix/store/47sfpm2qclpqvrzijizimk4md1739b1b-gcc-wrapper-4.9.3
...

Как такое может быть? Пакет должен как-то ссылаться на другие пакеты. Оказывается, зависимости прописаны в файле /setup:

$ head result/setup
export SHELL=/nix/store/zmd4jk4db5lgxb8l93mhkvr3x92g2sx2-bash-4.3-p39/bin/bash
initialPath="/nix/store/a457ywa1haa0sgr9g7a1pgldrg3s798d-coreutils-8.24 ..."
defaultNativeBuildInputs="/nix/store/sgwq15xg00xnm435gjicspm048rqg9y6-patchelf-0.8 ..."

Файл /setup

Помните наш обобщённый скрипт builder.sh из восьмой пилюли? Он инициализировал переменную PATH, распаковывал исходники и запускал для нас команды autotools.

Файл /setup из stdenv — точно такой же. Он инициализирует несколько переменных окружения, таких как PATH и создаёт несколько вспомогательных функций bash для сборки пакетов. Давайте его прочитаем.

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

Сборка в stdenv разбита на фазы: unpackPhase, configurePhase, buildPhase, checkPhase, installPhase, fixupPhase. Список фаз по умолчанию находится в функции genericBuild.

Функция запускает эти фазы одну за другой. Все фазы — это, на самом деле, функции bash, так что вы легко можете их прочитать.

У каждой фазы есть хуки для запуска команд до или после фазы. Фазы можно переопределить, переставить местами, да и в принципе сделать с ними что угодно, так как это просто код на bash.

Как использовать этот файл? Как наш старый скрипт сборки. Чтобы проверить его, сделаем фиктивную пустую деривацию, запустим setup из stdenv с помощью source, распакуем исходники hello и скомпилируем их:

$ nix-shell -E 'derivation { name = "fake"; builder = "fake"; system = "x86_64-linux"; }'
nix-shell$ unset PATH
nix-shell$ source /nix/store/k4jklkcag4zq4xkqhkpy156mgfm34ipn-stdenv/setup
nix-shell$ tar -xf hello-2.10.tar.gz
nix-shell$ cd hello-2.10
nix-shell$ configurePhase
...
nix-shell$ buildPhase
...

Я очистил PATH, чтобы ещё раз показать, что в stdenv есть всё, что нужно для сборки пакетов autotools, не имеющих других зависимостей.

Мы запустили функции configurePhase и buildPhase, и они отработали. Эти функции bash имеют «говорящие» имена, их код вы можете найти в файле setup.

Из чего состоит setup

До сих пор мы работали с обычными скриптами bash. А что насчёт Nix? В репозитории nixpkgs есть полезная функция, похожая на ту, которую мы написали в нашем старом скрипте. Она вызывает функцию деривации, подтягивая stdenv и запуская genericBuild. Это stdenv.mkDerivation.

Обратите внимание, что stdenv — это не только деривация, но и набор атрибутов, содержащий, в том числе, mkDerivation. Это сделано для удобства.

Давайте напишем выражение hello.nix, используя stdenv:

with import <nixpkgs> { };
stdenv.mkDerivation {
  name = "hello";
  src = ./hello-2.10.tar.gz;
}

Не пугайтесь выражения with. Оно подгружает репозиторий nixpkgs в область видимости, чтобы мы могли напрямую использовать stdenv. Это очень похоже на выражение hello из пилюли 8.

Программа отлично собирается и работает:

$ nix-build hello.nix
...
/nix/store/6y0mzdarm5qxfafvn2zm9nr01d1j0a72-hello
$ result/bin/hello
Hello, world!

Сборщик stdenv.mkDerivation

Взглянем на скрипт сборки, используемый mkDerivation. Вы можете найти код здесь в nixpkgs:

{
  # ...
  builder = attrs.realBuilder or shell;
  args =
    attrs.args or [
      "-e"
      (attrs.builder or ./default-builder.sh)
    ];
  stdenv = result;
  # ...
}

Сравним его с нашей старой обёрткой над деривациями из предыдущих пилюль. Здесь пакет собирается с помощью bash (переменная shell), аргументом которого является default-builder.sh. В набор атрибутов, уже присутствующему в деривации stdenv, мы добавляем переменную окружения $stdenv.

Вы можете открыть default-builder.sh и посмотреть, что он делает:

source $stdenv/setup
genericBuild

То же самое мы делали в десятой пилюлей чтобы из nix-shell было удобно работать с деривациями. При входе в оболочку, файл setup настраивает окружение, но не запускает сборку. А при запуске nix-build он действительно запускает процесс сборки.

Чтобы получить ясное представление о переменных окружения, загляните в файл hello.drv:

$ nix derivation show $(nix-instantiate hello.nix)
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
{
  "/nix/store/abwj50lycl0m515yblnrvwyydlhhqvj2-hello.drv": {
    "outputs": {
      "out": {
        "path": "/nix/store/6y0mzdarm5qxfafvn2zm9nr01d1j0a72-hello"
      }
    },
    "inputSrcs": [
      "/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh",
      "/nix/store/svc70mmzrlgq42m9acs0prsmci7ksh6h-hello-2.10.tar.gz"
    ],
    "inputDrvs": {
      "/nix/store/hcgwbx42mcxr7ksnv0i1fg7kw6jvxshb-bash-4.4-p19.drv": [
        "out"
      ],
      "/nix/store/sfxh3ybqh97cgl4s59nrpi78kgcc8f3d-stdenv-linux.drv": [
        "out"
      ]
    },
    "platform": "x86_64-linux",
    "builder": "/nix/store/q1g0rl8zfmz7r371fp5p42p4acmv297d-bash-4.4-p19/bin/bash",
    "args": [
      "-e",
      "/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh"
    ],
    "env": {
      "buildInputs": "",
      "builder": "/nix/store/q1g0rl8zfmz7r371fp5p42p4acmv297d-bash-4.4-p19/bin/bash",
      "configureFlags": "",
      "depsBuildBuild": "",
      "depsBuildBuildPropagated": "",
      "depsBuildTarget": "",
      "depsBuildTargetPropagated": "",
      "depsHostBuild": "",
      "depsHostBuildPropagated": "",
      "depsTargetTarget": "",
      "depsTargetTargetPropagated": "",
      "name": "hello",
      "nativeBuildInputs": "",
      "out": "/nix/store/6y0mzdarm5qxfafvn2zm9nr01d1j0a72-hello",
      "propagatedBuildInputs": "",
      "propagatedNativeBuildInputs": "",
      "src": "/nix/store/svc70mmzrlgq42m9acs0prsmci7ksh6h-hello-2.10.tar.gz",
      "stdenv": "/nix/store/6kz2vbh98s2r1pfshidkzhiy2s2qdw0a-stdenv-linux",
      "system": "x86_64-linux"
    }
  }
}

Он настолько короткий, что я решил вставить его полностью. Программа сборки — это bash с аргументами -d default-builder.sh. В файле вы видите переменные окружения src и stdenv.

Последняя фаза, с которой мы пока не сталкивались — это unpackPhase. В setup она используется для распаковки исходных кодов и перехода в каталог. Здесь снова всё точно также, как в наших старых скриптах сборки.

Заключение

Деривация stdenv — это ядро репозитория nixpkgs. Все пакеты используют обёртку stdenv.mkDerivation вместо прямого вызова дериваций. Она выполняет разные операции и создаёт удобное окружение для сборки.

Процесс в целом прост:

  • nix-build
  • bash -e default-builder.sh
  • source $stdenv/setup
  • genericBuild

И это всё. То, что вам надо знать о фазах stdenv есть в файле setup.

Серьёзно, найдите время его прочитать. И не забывайте, что в руководстве по nixpkgs тоже есть важные документы.

В следующей пилюле

Мы поговорим о том, как добавить зависимости в наши пакеты с помощью buildInputs и propagatedBuildInputs, и о том, как влиять на зависимые сборки с помощью хуков настройки и окружения. Эти концепции очень важны для понимания, как nixpkgs собирает пакеты.

Основные зависимости и хуки

Добро пожаловать на двадцатую пилюлю Nix. В предыдущей девятнадцатой пилюле мы познакомились с деривацией stdenv, где встретили скрипт setup.sh, вспомогательный скрипт default-builder.sh и функцию сборки stdenv.mkDerivation. Разбирались в том, как stdenv сводит всё это вместе, как он используется и немного — на фазах genericBuild.

Сегодня исследуем взаимодействие процесса сборки пакетов с stdevn.mkDerivation. Естественно, что пакеты зависят друг от друга. Мы можем описать зависимости с помощью атрибутов buildInputs и propagatedBuildInputs. Иногда входные пакеты должны влиять на зависимые пакеты образом, который невозможно предсказать заранее. Чтобы с этим справиться, у нас есть хуки установки и хуки окружения. Вместе эти 4 концепции обеспечивает практически любое взаимодействие при сборке пакета.

ℹ️ С течение времени, в основном, для поддержки кросс-компиляции, сложность инфраструктуры зависимостей и хуков выросла. Изучив основные концепции, вы сможете перейти к более сложным темам. Начать изучение можно вот с коммита 6675f0a5 в nixpkgs. Это последняя версия stdenv без поддержки кросс-компиляции.

Атрибут buildInputs

В простейшем случае, когда одному пакету нужен другой пакет, мы используем атрибут buildInputs. Это именно тот паттерн, который мы применяли в нашем скрипте сборки в Пилюле 8. Для демонстрации давайте соберём пакет GNU Hello, а затем другой пакет, в котором будет скрипт, запускающий программу hello.

let

  nixpkgs = import <nixpkgs> { };

  inherit (nixpkgs) stdenv fetchurl which;

  actualHello = stdenv.mkDerivation {
    name = "hello-2.3";

    src = fetchurl {
      url = "mirror://gnu/hello/hello-2.3.tar.bz2";
      sha256 = "0c7vijq8y68bpr7g6dh1gny0bff8qq81vnp4ch8pjzvg56wb3js1";
    };
  };

  wrappedHello = stdenv.mkDerivation {
    name = "hello-wrapper";

    buildInputs = [
      actualHello
      which
    ];

    unpackPhase = "true";

    installPhase = ''
      mkdir -p "$out/bin"
      echo "#! ${stdenv.shell}" >> "$out/bin/hello"
      echo "exec $(which hello)" >> "$out/bin/hello"
      chmod 0755 "$out/bin/hello"
    '';
  };
in
wrappedHello

Обратите внимание, что деривация wrappedHello находит программу hello через переменную PATH. Это работает, поскольку stdenv содержит такие строки:

pkgs=""
for i in $buildInputs; do
    findInputs $i
done

где findInputs определена, как:

findInputs() {
    local pkg=$1

    ## Не повторяем для уже обработанных пакетов
    case $pkgs in
        *\ $pkg\ *)
            return 0
            ;;
    esac

    pkgs="$pkgs $pkg "

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать
}

после этого выполняется:

for i in $pkgs; do
    addToEnv $i
done

где addToEnv определена как:

addToEnv() {
    local pkg=$1

    if test -d $1/bin; then
        addToSearchPath _PATH $1/bin
    fi

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать
}

Вызов addToSearchPath добавляет $1/bin к _PATH, если такой путь существует (код здесь). Как только все пакеты из buildInputs обработаны, содержимое _PATH добавляется в PATH:

PATH="${_PATH-}${_PATH:+${PATH:+:}}$PATH"

Если путь к hello прописан в PATH, фаза installPhase должна завершиться успешно.

Атрибут propagatedBuildInputs

Атрибут buildInputs покрывает прямые зависимости, но как быть с косвенными зависимостями, когда одному пакету нужен другой пакет, которому нужен третий? Nix и сам прекрасно с этим справляется, умея обрабатывать различные замыкания зависимостей, возникших при сборке предыдущих пакетов. Впрочем, buildInputs всё ещё удобнее, поскольку собирает каталоги pkg/bin в переменную окружения pkgs с последующим включением их в PATH. Для зависимых пакетов в stdenv для тех же целей используют атрибут propagatedBuildInputs:

let

  nixpkgs = import <nixpkgs> { };

  inherit (nixpkgs) stdenv fetchurl which;

  actualHello = stdenv.mkDerivation {
    name = "hello-2.3";

    src = fetchurl {
      url = "mirror://gnu/hello/hello-2.3.tar.bz2";
      sha256 = "0c7vijq8y68bpr7g6dh1gny0bff8qq81vnp4ch8pjzvg56wb3js1";
    };
  };

  intermediary = stdenv.mkDerivation {
    name = "middle-man";

    propagatedBuildInputs = [ actualHello ];

    unpackPhase = "true";

    installPhase = ''
      mkdir -p "$out"
    '';
  };

  wrappedHello = stdenv.mkDerivation {
    name = "hello-wrapper";

    buildInputs = [
      intermediary
      which
    ];

    unpackPhase = "true";

    installPhase = ''
      mkdir -p "$out/bin"
      echo "#! ${stdenv.shell}" >> "$out/bin/hello"
      echo "exec $(which hello)" >> "$out/bin/hello"
      chmod 0755 "$out/bin/hello"
    '';
  };
in
wrappedHello

Обратите внимание, что в пакете intermediary зависимость описана в propagatedBuildInputs, в то время, как wrappedHello получает зависимость через buildInputs.

Как это работает? Вы можете решить, что подобную штуку проворачивает Nix, но на самом деле она происходит не при выполнении кода, а во время сборки программы в bash. Давайте взглянем на фрагмент fixupPhase из stdenv:

fixupPhase() {

    ## Опущено

    if test -n "$propagatedBuildInputs"; then
        mkdir -p "$out/nix-support"
        echo "$propagatedBuildInputs" > "$out/nix-support/propagated-build-inputs"
    fi

    ## Опущено

}

Этот код сохраняет сборки, перечисленные в propagatedBuildInputs в одноимённом файле в каталоге $out/nix-support. Вернёмся к findInputs и исследуем строки, которые мы ранее пропустили:

findInputs() {
    local pkg=$1

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать

    if test -f $pkg/nix-support/propagated-build-inputs; then
        for i in $(cat $pkg/nix-support/propagated-build-inputs); do
            findInputs $i
        done
    fi
}

Функция findInputs на самом деле рекурсивна — она исследует propagatedBuildInputs каждой зависимости, затем propagatedBuildInputs этих зависимостей и т. д.

На самом деле мы упростили вызов findInputs в прошлых примерах: в действительности propagatedBuildInputs тоже зациклен:

pkgs=""
for i in $buildInputs $propagatedBuildInputs; do
    findInputs $i
done

Этот код демонстрирует важный момент. Для *текущего“ пакета неважно, является ли зависимость косвенной или нет. Все они будут обработаны одним и тем же способом: вызваны через findInputs и переданы в addToEnv. (Пакеты, найденные функций findInputs, собранные в pkgs и переданные в addToEnv, в обоих случаях будут одни и те же.) Однако, в $out/nix-support/propagated-build-inputs помещаются только явные косвенные зависимости.

Хуки установки

Выше мы уже писали, что зависимости иногда должны влиять на пакеты не просто фактом своего существования1. В качестве примера можно рассмотреть сам параметр propagatedBuildInputs: пакеты, которые его используют, «проталкивают» зависимости в buildInputs зависимых пакетов. Однако, хотелось бы, чтобы зависимости могли оказывать на зависящие пакеты произвольное влияние. Произвольное здесь — ключевое слово. Можно научить setup.sh конкретным вещам, чему-то вроде pkg/nix-support/propagated-build-inputs, но не произвольному взаимодействию.

Хуки установки — основные строительные блоки, которые для этого применяются. В nixpkgs «хуки» — это, по сути, функции обратного вызова в bash, и хуки установки не является исключением. Взглянем на последнюю часть findInputs, которую мы пока игнорировали:

findInputs() {
    local pkg=$1

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать

    if test -f $pkg/nix-support/setup-hook; then
        source $pkg/nix-support/setup-hook
    fi

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать

}

Если в пакете есть скрипт с именем pkg/nix-support/setup-hook, он будет запущен с помощью команды source любым пакетом, основанным на stdenv и включающим исходный пакет, как зависимость.

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

Хуки окружения

Чтобы сделать написание скриптов сборки ещё удобнее, можно использовать хуки окружения. Вспомните, как в Пилюле 12 мы собирали пути в NIX_CFLAGS_COMPILE для флага -I, и в NIX_LDFLAGS для флага -L так же, как до этого собирали их в PATH. Однако, это слишком специализированное решение для универсального скрипта сборки. Имеет смысл обрабатывать PATH особым образом, поскольку PATH используется оболочкой, а универсальный скрипт неразрывно связан с оболочкой. Но флаги -I и -L относятся только к компилятору C. Пакет stdenv не обязан как-то по особенному относиться к компилятору С (хотя, по факту, относится), ведь существуют другие компиляторы, у которых могут быть совершенно другие флаги.

В качестве первого шага мы можем переместить эту логику в хук установки для компилятора C; на самом деле именно это и сделано в обёртке над CC2. Но этот паттерн встречается достаточно часто, так что кто-то решил добавить несколько вспомогательных функций, чтобы сократить объём кода.

Вторая половина addToEnv выглядит так:

addToEnv() {
    local pkg=$1

    ## Здесь на самом деле есть кое-что ещё, что мы можем пока игнорировать

    # Запускаем специфичные для пакета хуки, установленные в скриптах setup-hook
    for i in "${envHooks[@]}"; do
        $i $pkg
    done
}

Функции, перечисленные в envHooks, применяются к каждому пакету, переданному в addToEnv. Можно написать такой хук установки:

anEnvHook() {
    local pkg=$1

    echo "I'm depending on \"$pkg\""
}

envHooks+=(anEnvHook)

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

В следующей пилюле

…я не уверен! Опираясь на знание о том, как работает stdenv, мы могли бы поговорить о других типах зависимостей и хуках, которые нужны при кросс-компиляции. Мы могли бы поговорить о том, как происходит загрузка nixpkgs. Или, мы могли бы поговорить о том, как localSystem и crossSystem превращаются в buildPlatform, hostPlatform и targetPlatform, у каждой из которых есть свой этап загрузки. Дайте мне знать, что вам интересно!

1

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

2

В версии nixpkgs, актуальной на момент написания пилюли, он назывался Обёрткой над GCC; Поддержка компиляторов Darwin и Clang не стала достаточным основанием, чтобы его переименовать.