Nix Pills, Chapter 7 (Kommentar)

Im siebten Kapitel der Nix Pills, Working Derivation, werden funktionsfähige Derivations definiert. Wir lernen, wie sie in .nix-Dateien eingefügt und über die Kommandozeile gebaut werden können.

Im siebten Kapitel der Nix Pills, Working Derivation, werden funktionsfähige Derivations definiert. Wir lernen, wie sie in .nix-Dateien eingefügt und über die Kommandozeile gebaut werden können.

Shell-Skripte als Builder

Wenn ein Programm von Hand gebaut wird, dann werden auf der Kommandozeile hintereinander die dazu notwendigen Befehlen ausgeführt. Um diesen Vorgang zu automatisieren, können sie in einem Shell-Skript zusammengefasst werden. Ein solches Skript kann einem Paket als Builder hinzugefügt werden.

Das Skript muss von einem bestimmten Interpreter ausgeführt werden. Wenn man (wie im folgenden Beispiel) Bash nutzen möchte, dann würde man auf der Kommandozeile bash builder.sh ausführen. Um eine Datei automatisch einem bestimmten Interpreter zur Ausführung zu übergeben, wird ins Skript für gewöhnlich eine Shebang-Zeile eingefügt.

In Nix ist die Situation komplizierter als man denken könnte. Wie alle Programme, so findet sich auch Bash (oder jede andere Shell) im Nix-Store. Der Pfad dorthin ist zum Zeitpunkt, an dem wir das Skript schreiben, nicht bekannt.

Unser Ziel ist es, den Input der derivation-Funktion so zu definieren, dass Bash als Builder genutzt und builder.sh als Argument übergeben wird. Dazu sind zwei Probleme zu lösen: Wie lässt sich Bash als Builder festlegen? Und wie können wir die builder.sh als Argument übergeben?

Import von Derivations

Um Bash verwenden zu können, muss eine Derivation davon in unser Paket eingeführt werden. Nixpkgs ist ein Repository, in dem sich sehr viele Derivations finden. Installierte Kanäle entscheiden dann hinter den Kulissen darüber, welche Version der angeforderten Pakete verwendet werden wird.

Wie im vorausgegangenen Kapitel gesehen, sind Derivations spezielle Attributmengen, die von der derivation-Funktion erzeugt werden. Bei den Paketdefinitionen, die sich im Nixpkgs-Repository finden, handelt es sich deshalb um Aufrufe dieser Funktion mit Paket-spezifischen Inputs. Die zurückgegebenen Derivations können wir in Variablen wie bash, hello etc. speichern.

Um Nixpkgs-Pakete im Nix-REPL verwenden zu können, kann die die Paket-Sammlung repräsentierende Datei mit :l <Dateiname> geladen werden.

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

Zu den damit eingeführten Variablen gehört bash, die eine bestimmte Bash-Derivation repräsentiert. Dabei handelt es sich vermutlich um die aktuellste auf dem Branch, die dem gesetzten Kanal entspricht. Wenn wir den Pfad zu Bash durch einen String repräsentieren, dann können wir den Store-Pfad darin durch Interpolation einfügen.1

derivation { ...; builder = "${bash}/bin/bash"; ... }

Für gewöhnlich werden Derivations in einer .nix-Datei, nicht im REPL definiert. In einem früheren Kapitel wurde erklärt, dass mit import Dateien geparst und ihr Inhalt ausgewertet werden kann. <nixpkgs> zeigt auf eine Datei, die einen Lambdaausdruck enthält. import <nixpkgs> evaluiert entsprechend zu einer Funktion. Mit <nixpkgs> {} wird dieser Funktion die leere Menge als Argument übergeben.2

let
  pkgs = import <nixpkgs> {};
in
  [...]

Es wird nicht im Detail erläutert, warum genau der Funktion die leere Menge übergeben wird. Es wird nur gesagt:

Calling import <nixpkgs> {} into a let-expression creates the local variable pkgs and brings it into scope. This has an effect similar to the :l <nixpkgs> we used in nix repl, in that it allows us to easily access derivations such as bash, gcc, and coreutils, but those derivations will have to be explicitly referred to as members of the pkgs set (e.g., pkgs.bash instead of just bash).

Damit können wir (eine bestimmte aber vorab unbekannte Version von) Bash als Builder festlegen:

let
  pkgs = import <nixpkgs> {};
in
  pkgs.stdenv.mkDerivation {
    [...]
    builder = "${pkgs.bash}/bin/bash";
    [...]
}

Somit ist das erste Problem gelöst: Bash ist nun als Builder verfügbar.

Argumente an einen Builder übergeben

Das zweite Problem besteht darin, unserem Builder das Build-Skript als Argument zu übergeben. Im vorausgegangen Kapitel wurde gesagt, dass für die Menge, die der derivation-Funktion als Argument übergeben wird, optionale Attribute definiert werden können. Dazu gehört args, mit dem Kommandozeilenargumente für den Builder bestimmt werden.

Im REPL:

derivation { [...] builder = "${bash}/bin/bash"; args = [ ./builder.sh ]; [...] }

Und in einer .nix-Datei:

let
  pkgs = import <nixpkgs> {};
in
  pkgs.stdenv.mkDerivation {
    [...]
    builder = "${pkgs.bash}/bin/bash";
    args = [ ./simple_builder.sh ];
    [...]
}

Bemerkenswert ist der Umstand, dass wir in beiden Fällen einen Pfad und keinen String nutzen (./builder.sh statt "./builder.sh"). Dazu wird gesagt:

This way, it is parsed as a path, and Nix performs some magic which we will cover later. Try using the string version and you will find that it cannot find builder.sh. This is because it tries to find it relative to the temporary build directory.

Die Build-Umgebung: ein REPL-Beispiel

Bisher wurde vom Inhalt des Skripts abstrahiert. Das Beispiel ist trivial: es erstellt im Nix-Store eine Datei, die das Wort foo enthält. Außerdem sollen während des Build-Vorgangs die Umgebungsvariablen ausgegeben werden. Das hat vermutlich didaktische Gründe; der Autor der Nix Pills möchte über die Werte einige dieser Variablen sprechen.

# builder.sh
declare -xp
echo foo > $out

declare ist ein in Bash eingebautes Kommando. Wenn es mit den gegebenen Flags ausgeführt wird, werden alle in der gegebenen Umgebung exportierten Variablen (mit ihren Werten) aufgelistet. Uns wird im Folgenden interessieren, woher das Skript die out-Variable kennt. Wenig überraschend wird sich herausstellen, dass sie von Nix kommt.

Die Derivation wird im REPL erzeugt (mit der derivation-Funktion) und mit :b <Derivation> gebaut.3

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

Der Build war erfolgreich. Das erkennen wir daran, dass sich im Anschluss im Store eine foo-Datei mit dem erwarteten Inhalt (foo) befindet. Im Beitrag findet sie sich unter /nix/store/w024zci0x1hh1wj6gjq0jagkc1sgrf5r-foo.

Die Ausführung hat im REPL keine Ausgabe. Das kann überraschen: Im Shell-Skript wurde ein Befehl eingefügt, der exportierte Umgebungsvariablen ausgibt. Um uns anzuzeigen, welche Ausgaben der Build-Vorgang hatte, kann bezüglich eines Build-Outputs eine Log-Datei verlangt werden:

$ 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"

Diese Variablen beschreiben die Build-Umgebung, es ist deshalb nicht unintressant, uns ihre Werte im Detail anzugucken.

Zunächst finden wir darin die Umgebungsvariable out. Diese ist es, die im Skript verwendet wird. Tatsächlich gehört sie zu einer Gruppe von Variablen, die zugleich und auf der gleichen Grundlage erstellt werden: “(…) $builder, $name, $out, and $system are variables set due to the .drv file’s contents.”

Der Fall von out ist untypisch, da dafür (anders als im Falle von name, system und builder) kein entsprechendes Attribut in der Input-Menge definiert wurde. Die offizielle Dokumentation erklärt:

For each output declared in outputs, the corresponding environment variable is set to point to the intended path in the Nix store for that output. Each output path is a concatenation of the cryptographic hash of all build inputs, the name attribute and the output name. (The output name is omitted if it’s out.)

out ist somit ein Default- bzw. Fallback-Wert, wenn das outputs-Attibut (wie in unserem Fall) nicht explizit definiert wurde.4

Im offiziellen Bedienungshandbuch erfahren wir, dass viele der Attribute der Input-Menge für derivation in Variablen der Build-Umgebung umgewandelt werden. Dort wird der Vorgang dabei im Detail dargestellt. Bis auf diese Derivation-Attribute wird die Umgebung dann geleert, sobald der Builder ausgeführt wird.

Für die Ausführung des Builder wird ein temporäres Verzeichnis erstellt. Der Pfad dorthin wird in einer Umgebungsvariable gesetzt, die wir ebenfalls in der obigen Liste aufgeführt finden: TMPDIR.5 Für die Zeit des Build wird in dieses Verzeichnis gewechselt.

Die Nix Pills und der offiziellen Dokumentation können wir weitere Erklärungen entnehmen:

Eigenes Paket erstellen und bauen: ein C-Beispiel

Für ein etwas realistischeres Beispiel, wird im Beitrag ein Paket für ein (sehr einfaches) C-Programm zusammengestellt. Das Programm selbst ist in simple.c beschrieben:

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

Die zweite Paketdatei ist ein eigenes Build-Skript (simple_builder.sh):

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

Hier finden sich neben out noch weitere Symbole, für die nicht ohne Weiteres auf der Hand liegt, wo sie eingeführt werden: gcc und src.

Wie im vorausgegangen Beispiel könnten wir nun im REPL eine Derivation erstellen und bauen:

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

Das Beispiel ist auch methodisch realistischer, da die Derivation (die das Paket repräsentiert) in einer Datei (simple.nix) definiert wird:

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;
}

Drei der verwendeten Variablen werden explizit in der Input-Menge definiert. Sie werden automatisch in Variablen umgewandelt, die in der Build-Umgebung zur Verfügung stehen. Bezüglich out gilt das gleiche, was bereits oben gesagt wurde.

Durch syntaktischen Zucker kann die Definition dieser Attribute (und mithin der daraus generierten Umgebungsvariablen) vereinfacht werden. Dazu dient das inherit-Schlüsselwort:

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;
}

Dabei handelt es sich um eine etwas elaboriertere Verwendung von inherit. Einführungen in die Nix Expression Language erklären zumeist nur, dass inherit foo äquivalent ist zu foo = foo, wobei letztere ihren Wert vom äußeren Gültigkeitsbereich erhält. inherit foo bar wäre äquivalent zu foo = foo; bar = bar. Und inherit (pkgs) gcc coreutils; schließlich ist äquivalent zu gcc = pkgs.gcc; coreutils = pkgs.coreutils;. Dieser etwas kryptische Ausdruck spart demnach lediglich einigen Schreibaufwand.

Um das Paket schließlich zu bauen, wird nix-build simple.nix verwendet. Der Build-Output wird im Store erstellt. Es handelt sich um eine ausführbare Datei, die Simple! ausgibt. Im Arbeitsverzeichnis wird der Symlnk result erstellt, der auf die ausführbare Datei im Store zeigt.

Wie in einem früheren Kapitel bereits vermutet wurde, umfasst nix-build zwei Operationen, die wir bereits kennengelernt haben:

Store Derivation

Wie zuvor wird bei der Instanziierung eine Store Derivation erstellt. Hier die .drv-Datei des obigen REPL-Beispiels:

$ 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"
    }
  }
}

Vom Beispiel im letzten Kapitel unterscheidet sie sich vor allem im Hinblick auf "args". Der Unterschied ergibt sich offensichtlich daraus, dass wir beim Input ein entsprechendes Attribut definiert haben.

Was wir hier dafür erhalten, ist ein Pfad zu einer Datei im Nix-Store. Der Hintergrund ist, dass Nix für den Build-Vorgang eines Pakets benötigte Dateien und Verzeichnisse automatisch in den Store kopiert. Dabei erhalten sie natürlich ebenfalls ein Hash-Präfix. Dadurch wird gewährleistet, dass sich die Datein während des Build-Vorgangs nicht ändern und Reproduzierbarkeit gefördert wird.

Damit fungiert das Skript als Input. Dieser Umstand spiegelt sich darin, dass die erstellte Kopie unter den InputSrcs auftaucht.

Für das C-Beispiel erhöht sich der Komplexitätsgrad weiter. Wie das Shell-Skript wird auch die Quelldatei in den Nix-Store kopiert. Die weiteren Änderungen werden folgendermaßen zusammengefasst:

(…) (P)retty print the .drv file. You’ll see simple_builder.sh and simple.c listed in the input derivations, along with bash, gcc and coreutils .drv files. The newly added environment variables described above will also appear.

Offene Fragen

Fußnoten

  1. Dieser Trick wird in Kapitel 6 der Nix Pills angesprochen. 

  2. Es wird betont, dass es sich beim Ausdruck um zwei Funktionsaufrufe handelt. Dies wird deutlicher, wenn man den Ausdruck mit Klammern paraphrasiert: (import <nixpkgs>) {}

  3. Wie bereits an früherer Stelle gesagt, handelt es sich dabei um eine Eigenheit vom REPL. In typischen Kontexten steht das Kommando nicht zur Verfügung. 

  4. Da Store-Outputs erzeugt werden, ergeben sich einige Besonderheiten bei der Verwendung von GNU Autools: “In terms of autotools, $out will be the --prefix path. Yes, not the make DESTDIR, but the --prefix. That’s the essence of stateless packaging. You don’t install the package in a global common path under /, you install it in a local isolated path under your nix store slot.” Im nächsten Kapitel wird dieser Aspekt nochmal eine Rolle spielen. 

  5. Es finden sich andere Temp-Verzeichnisse aufgelistet. Dazu wird gesagt: “Also, TMPDIR, TEMPDIR, TMP, TEMP are set to point to the temporary directory. This is to prevent the builder from accidentally writing temporary files anywhere else. Doing so might cause interference by other processes.” Da PWD und NIX_BUILD_TOP denselben Wert haben, fallen sie vermutlich in die gleiche Kategorie.