Nix-Tutorium vom Nationalen Forschungsinstitut für Informatik und Automatisierung (INRIA): Teil 2

Nach einer Woche Pause geht es weiter mit Teil 2, Hacking Your First Package. Was es dieses Mal zu lernen gibt:

Pakete und Derivations

Relativ zu Beginn des Beitrags wird gesagt, dass von Derivations statt von Paketen (packages) gesprochen werden wird.Könnte man deshalb sagen, dass der Paketbegriff auf verschiedene Weisen konzeptualisiert werden kann und dass Derivations die Konzeption der Nix-Paketverwaltung repräsentieren?

Für mich stellt sich nun die Frage: Gibt es im Nix-Ökosystem eine Idee von Paketen, die nicht als Derivations zu deuten sind? Ich bin mir ziemlich sicher, ich habe in einigen anderen Artikeln und Quellen gelesen, dass sich Pakete und Derivations im Nix-Store finden. Vielleicht sind Pakete etwas Abstraktes, das durch Derivations in Nix (also in der Nix Expression Language) beschrieben werden? In diesem Sinne können sie gebaut und installiert werden. Vielleicht sind installierte Pakete gemeint, wenn gesagt wird, dass sich neben Derivations noch “Pakete” im Store finden?1

Der so wichtige Grundbegriff wird nur relativ vage definiert: “A derivation is a function that describes a build process.” Funktionsausdrücke haben in Nix die folgende syntaktische Form: <Parameter>: <Wert>. Derivations werden nach dem Vorausgesagten durch Ausdrücke der gleichen Form denotiert. Nicht ohne Weiteres klar ist es, wie solche Ausdrücke (oder die damit bezeichneten Derivations) Build-Vorgänge beschreiben. Das im Artikel unmittelbar darauf folgende Beispiel vermittelt aber zumindest ein gutes Grundverständnis. Dazu sofort mehr.

Für eine präzise Definition wird auf das offizielle Bedienungshandbuch verwiesen. Dort wird eine Funktion dargestellt, derivation. Doch auch wenn es sich bei Derivations um Funktionen handelt - eine Charakterisierung die ich zumindest noch mit einem Fragezeichen versehen würde - ist nicht unmittelbar klar, welcher Zusammenhang zwischen Derivationen-als-Funktionen und der derivation-Funktion besteht.

Das folgende Beispiel nutzt im Funktionskörper eine Funktion namens mkDerivation. Es ist davon auszugehen, dass zwischen derivation und mkDerivation ein Zusammenhang besteht. Jedenfalls scheinen sie bei der Definition von Derivationen-als-Funktionen eine zentrale Rolle zu spielen.

Nix-Paketdefinitionen: Ein Beispiel

Exemplarisch verpackt wird ein Projekt, das in C++ implementiert wurde und SimGrid sowie die C++-Bibliothek Boost nutzt. Scheinbar wird ein Chord-verteilter Hash-Algorithmus simuliert. Ich habe keine Ahnung, was diese Dinge bedeuten. Spielt für die eigentliche Paketdefinition hoffentlich keine allzu große Rolle.

Hier das Beispiel:

{
  pkgs ? import (fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/4fe8d07066f6ea82cda2b0c9ae7aee59b2d241b3.tar.gz";
    sha256 = "sha256:06jzngg5jm1f81sc4xfskvvgjy5bblz51xpl788mnps1wrkykfhp";
  }) {}
}:
pkgs.stdenv.mkDerivation rec {
  pname = "chord";
  version = "0.1.0";

  src = pkgs.fetchgit {
    url = "https://gitlab.inria.fr/nix-tutorial/chord-tuto-nix-2022";
    rev = "069d2a5bfa4c4024063c25551d5201aeaf921cb3";
    sha256 = "sha256-MlqJOoMSRuYeG+jl8DFgcNnpEyeRgDCK2JlN9pOqBWA=";
  };

  buildInputs = [
    pkgs.simgrid
    pkgs.boost
    pkgs.cmake
  ];

  configurePhase = ''
    cmake .
  '';

  buildPhase = ''
    make
  '';

  installPhase = ''
    mkdir -p $out/bin
    mv chord $out/bin
  '';
}

Der Input: pkgs

Derivations wurden als Funktionen charakterisiert und Funktionsausdrücke (Lambdas) haben die Form <Parameter>: <Rückgabewert>. Weniger abstrakt betrachtet haben sie die Form <Attributmenge>: pkgs.stdenv.mkDerivation rec {...}.

Um zu gewährleisten, dass pkgs zur Verfügung steht, wird der notwendige Funktionsinput vorausgesetzt. pkgs repräsentiert das das Nixpkgs-Repository. Wir lernen: “Nixpkgs (…) contains functions to help in building packages and a (big) set of packages.” pkgs.stdenv.mkDerivation dürfte eine solcher Helper-Funktion sein?

Wahrscheinlich enthält das Repository genau genommen keine Pakete, sondern Definitionen von Paketen (oder Derivations). Also Ausdrücke, die strukturell dem obigen Beispiel ähneln. Richtiger ist es wohl auch zu sagen, dass pkgs zu einem gegebenen Zeitpunkt (zum Zeitpunkt der Ausdrucksauswertung?) einen bestimmten Snapshot von Nixpkgs repräsentiert.

Das Beispiel zeigt, dass über Commits auf solche Zeitpunkte verwiesen werden kann. Dies wird genutzt, um einen Repo- oder Branch-Snapshot als Fallback zu bestimmen. Dazu wird mit pkgs ? ... ein Defaultwert für das pkgs-Attribut festgelegt. Der Snapshot vom unstabilen Nixpkgs-Branch findet sich hier. Es wird betont, dass diese Praxis reproduzierbarer ist als die Verwendung von Kanälen für den Zugriff auf das Nixpkgs-Repo.

Die Details der Input-Definition sind mir nicht völlig klar. Wenn ich das richtig lese, dann ist der Defaultwert für pkgs der Rückgabewert der import-Funktion. Wie das Bedienungshandbuch erklärt wird import ein Pfad zu einer Nix-Datei übergeben, die genau einen Ausdruck enthält. Der Rückgabewert der Funktion ist der Wert des Ausdrucks in der Datei.

Wir brauchen demnach einen Pfad. Das Handbuch erklärt bezüglich fetchTarball: “Download the specified URL, unpack it and return the path of the unpacked tree.” Ich weiß nicht, was der “path of the unpacked tree” genau ist. Verzeichnisse und Repos sind (wie alle Verzeichnisstrukturen) baumformig aufgebaut. Und im Tutorium heißt es auch: “(…) pkgs is the top-level tree of Nixpkgs (…).” Aber ich verstehe dennoch nicht völlig, was hier gesagt wird. Wie dem auch sei, zumindest dürfte der Rückgabewert von fetchTarball ein passendes Argument für import zu sein.

Das Handbuch nennt zwei Weisen, wie fetchTarball verwendet werden kann. Beim einfachen Gebrauch wird lediglich eine URL übergeben:

with import (fetchTarball https://github.com/NixOS/nixpkgs/archive/nixos-14.12.tar.gz) {};

Interessant sind die leeren geschweiften Klammern am Ende des Funktionsaufrufs. Diese finden sich auch im obigen Beispiel. Leider wird nicht darauf eingegangen, was sie bedeuten.

Das Beispiel verwendet eine komplexere Form des Funktionsaufrufs. Über einen Hash-Wert wird geprüft, dass die heruntergeladenen Daten wirklich diejenigen sind, die erwartet wurden:

pkgs ? import (fetchTarball {
  url = "https://github.com/NixOS/nixpkgs/archive/4fe8d07066f6ea82cda2b0c9ae7aee59b2d241b3.tar.gz";
  sha256 = "sha256:06jzngg5jm1f81sc4xfskvvgjy5bblz51xpl788mnps1wrkykfhp";
}) {}

Das sei nicht die “gewöhnliche Methode” um Archivinhalte zu prüfen. “The usual method is to pass lib.fakeSha256 (…) to fetchTarball and try to build the package. Nix will then return an error indicating the actual sha256 of the tarball.” Das wirkt alles ziemlich hacky. Vielleicht erstmal auch nicht so wichtig.

Das wichtigste nochmal zusammengefasst:

Der Output

Der Funktionskörper enthält nur einen Ausdruck, und zwar einen Funktionsauf von mkDerivation. Der Rückgabewert der Funktion ist demnach zugleich der Rückgabewert der Derivation. Oder nicht? Leider lernen wir zu dieser zentralen Funktion nur sehr wenig: “mkDerivation takes a set as input and expects many attributes within it.” Der Beitrag geht daraufhin nur auf die vielen Attribute ein. Über den Rückgabewert der Funktion erfahren wir nichts. Einleitend wurde zitiert, dass Derivations Pakete repräsentieren und Build-Vorgänge beschreiben. Vor diesem Hintergrund sind die vorgestellten Attribute zumindest im Allgemeinen nachvollziehbar.

An dieser Stelle ist es vielleicht angebracht, nochmal darüber zu sprechen, was Derivations sind. Ich habe definitiv Stellen im Netz gefunden, nach denen die Attributmengen (die Argumente für mkDerivation) Derivations genannt werden. Im Grunde vielleicht nur Nomenklatur ohne substanzielle Bedeutung. Dennoch würde mich interessieren, was die richtige Verwendung vom Wort “Derivations” wäre.

Das sind die erläuterten Attribute:

Bauen von Paketen

Derivations können gebaut werden. Dazu kann nix-build genutzt werden. Wenn der obige Ausdruck in der Datei chord_example.nix gespeichert wurde, dann resultiert nix-build chord_example.nix in einem neuen Verzeichnis im Nix-Store mit den gebauten Dateien. Im Build-Verzeichnis wird ein Link angelegt, der auf das Verzeichnis im Store zeigt (./result).

In der Build-Umgebung sind nur die Build-Dependencies (spezifiziert als buildInputs) verfügbar. Mit nix-shell kann eine Shell betreten werden, in der (nur) die buildInputs einer angegebenen Derivation verfügbar sind. Um die Shell wieder zu verlassen, kann STRG+D gedrückt oder exit ausgeführt werden. Mit --command kann ein Befehl in der Shell ausgeführt werden, ohne sie zu betreten:

nix-shell chord_example.nix --command 'mkdir build && cd build && cmake .. && make'

Im Defaultfall werden die gegenwärtig gesetzten Umgebungsvariablen für die neue Shell übernommen. Will man dieses Verhalten unterbinden, kann das --pure-Flag gesetzt werden.

nix-shell --pure chord_example.nix

Davon unabhängig werden einige Umgebungsvariablen automatisch gesetzt. Dabei handelt es sich um Variablen, die für die buildInputs wichtig sind. CMake beispielsweise erwartet $CMAKE_LIBRARY_PATH.

Es wird gesagt, dass nix-shell zunächst dafür eingeführt wurde, die Build-Umgebung eines Pakets zu betreten (primär für Debugging-Zwecke). Für Entwickler dürfte ein anderer Verwendungszweck noch interessanter sein. Mit mkShell kann eine eigene Umgebung für generelle Zwecke definiert werden. Dafür wird für gewöhnlich eine shell.nix angelegt. Hier ein Beispiel:

# This shell defines a development environment for the Chord project.
{
  pkgs ? import (fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/4fe8d07066f6ea82cda2b0c9ae7aee59b2d241b3.tar.gz";
    sha256 = "sha256:06jzngg5jm1f81sc4xfskvvgjy5bblz51xpl788mnps1wrkykfhp";
  }) {}
}:
pkgs.mkShell rec {
   buildInputs = with pkgs; [
    cmake
    boost
    simgrid

    # debugging tools
    gdb
    valgrind
   ];
}

Wie das Beispiel zeigt, fungiert mkShell auf analoge Weise zu mkDerivation. So würde man wohl auch seinen Editor, Linter, Autoformatter, die GNU Core Utils, und all die anderen Dinge festlegen, die man für eine Entwicklung an dem Projekt benötigt?

Was mich daran nur irritiert ist die Rede von buildInputs. Debugging Tools sind keine Build-Inputs, zumindest nicht im obigen Sinne von Build-Dependencies. Oder etwa doch? Ich meine mich zu erinnern, dass Ian Henry in seiner Nix-Serie feststellt, dass Nix nicht strikt zwischen Runtime- und Build-Dependencies unterscheidet.2 Aber ich weiß nicht recht, ob dieser Punkt hier wirklich eine Rolle spielt.

Offene Fragen

Fußnoten

  1. Tatsächlich findet sich im Beitrag selbst ein Satz, der das nahelegt: “The resulting package resides in the nix store.” Also das Paket, das daraus resultiert, dass eine entsprechende Derivation gebaut wird. 

  2. Siehe “Does Nix not explicitly distinguish between runtime and build-time dependencies?” in Beitrag 40 seiner “How to Learn Nix”-Serie.