diff --git a/doc/builders/images/dockertools.section.md b/doc/builders/images/dockertools.section.md index db1a2a214d1e..581bffd1a5a3 100644 --- a/doc/builders/images/dockertools.section.md +++ b/doc/builders/images/dockertools.section.md @@ -394,3 +394,142 @@ buildImage { }; } ``` + +## buildNixShellImage {#ssec-pkgs-dockerTools-buildNixShellImage} + +Create a Docker image that sets up an environment similar to that of running `nix-shell` on a derivation. +When run in Docker, this environment somewhat resembles the Nix sandbox typically used by `nix-build`, with a major difference being that access to the internet is allowed. +It additionally also behaves like an interactive `nix-shell`, running things like `shellHook` and setting an interactive prompt. +If the derivation is fully buildable (i.e. `nix-build` can be used on it), running `buildDerivation` inside such a Docker image will build the derivation, with all its outputs being available in the correct `/nix/store` paths, pointed to by the respective environment variables like `$out`, etc. + +::: {.warning} +The behavior doesn't match `nix-shell` or `nix-build` exactly and this function is known not to work correctly for e.g. fixed-output derivations, content-addressed derivations, impure derivations and other special types of derivations. +::: + +### Arguments + +`drv` + +: The derivation on which to base the Docker image. + + Adding packages to the Docker image is possible by e.g. extending the list of `nativeBuildInputs` of this derivation like + + ```nix + buildNixShellImage { + drv = someDrv.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs or [] ++ [ + somethingExtra + ]; + }); + # ... + } + ``` + + Similarly, you can extend the image initialization script by extending `shellHook` + +`name` _optional_ + +: The name of the resulting image. + + *Default:* `drv.name + "-env"` + +`tag` _optional_ + +: Tag of the generated image. + + *Default:* the resulting image derivation output path's hash + +`uid`/`gid` _optional_ + +: The user/group ID to run the container as. This is like a `nixbld` build user. + + *Default:* 1000/1000 + +`homeDirectory` _optional_ + +: The home directory of the user the container is running as + + *Default:* `/build` + +`shell` _optional_ + +: The path to the `bash` binary to use as the shell. This shell is started when running the image. + + *Default:* `pkgs.bashInteractive + "/bin/bash"` + +`command` _optional_ + +: Run this command in the environment of the derivation, in an interactive shell. See the `--command` option in the [`nix-shell` documentation](https://nixos.org/manual/nix/stable/command-ref/nix-shell.html?highlight=nix-shell#options). + + *Default:* (none) + +`run` _optional_ + +: Same as `command`, but runs the command in a non-interactive shell instead. See the `--run` option in the [`nix-shell` documentation](https://nixos.org/manual/nix/stable/command-ref/nix-shell.html?highlight=nix-shell#options). + + *Default:* (none) + +### Example + +The following shows how to build the `pkgs.hello` package inside a Docker container built with `buildNixShellImage`. + +```nix +with import {}; +dockerTools.buildNixShellImage { + drv = hello; +} +``` + +Build the derivation: + +```console +nix-build hello.nix +``` + + these 8 derivations will be built: + /nix/store/xmw3a5ln29rdalavcxk1w3m4zb2n7kk6-nix-shell-rc.drv + ... + Creating layer 56 from paths: ['/nix/store/crpnj8ssz0va2q0p5ibv9i6k6n52gcya-stdenv-linux'] + Creating layer 57 with customisation... + Adding manifests... + Done. + /nix/store/cpyn1lc897ghx0rhr2xy49jvyn52bazv-hello-2.12-env.tar.gz + +Load the image: + +```console +docker load -i result +``` + + 0d9f4c4cd109: Loading layer [==================================================>] 2.56MB/2.56MB + ... + ab1d897c0697: Loading layer [==================================================>] 10.24kB/10.24kB + Loaded image: hello-2.12-env:pgj9h98nal555415faa43vsydg161bdz + +Run the container: + +```console +docker run -it hello-2.12-env:pgj9h98nal555415faa43vsydg161bdz +``` + + [nix-shell:/build]$ + +In the running container, run the build: + +```console +buildDerivation +``` + + unpacking sources + unpacking source archive /nix/store/8nqv6kshb3vs5q5bs2k600xpj5bkavkc-hello-2.12.tar.gz + ... + patching script interpreter paths in /nix/store/z5wwy5nagzy15gag42vv61c2agdpz2f2-hello-2.12 + checking for references to /build/ in /nix/store/z5wwy5nagzy15gag42vv61c2agdpz2f2-hello-2.12... + +Check the build result: + +```console +$out/bin/hello +``` + + Hello, world! diff --git a/nixos/tests/docker-tools.nix b/nixos/tests/docker-tools.nix index 21a727dbd97c..e76a46131929 100644 --- a/nixos/tests/docker-tools.nix +++ b/nixos/tests/docker-tools.nix @@ -431,5 +431,58 @@ import ./make-test-python.nix ({ pkgs, ... }: { docker.succeed("docker run --rm image-with-certs:latest test -r /etc/pki/tls/certs/ca-bundle.crt") docker.succeed("docker image rm image-with-certs:latest") + with subtest("buildNixShellImage: Can build a basic derivation"): + docker.succeed( + "${examples.nix-shell-basic} | docker load", + "docker run --rm nix-shell-basic bash -c 'buildDerivation && $out/bin/hello' | grep '^Hello, world!$'" + ) + + with subtest("buildNixShellImage: Runs the shell hook"): + docker.succeed( + "${examples.nix-shell-hook} | docker load", + "docker run --rm -it nix-shell-hook | grep 'This is the shell hook!'" + ) + + with subtest("buildNixShellImage: Sources stdenv, making build inputs available"): + docker.succeed( + "${examples.nix-shell-inputs} | docker load", + "docker run --rm -it nix-shell-inputs | grep 'Hello, world!'" + ) + + with subtest("buildNixShellImage: passAsFile works"): + docker.succeed( + "${examples.nix-shell-pass-as-file} | docker load", + "docker run --rm -it nix-shell-pass-as-file | grep 'this is a string'" + ) + + with subtest("buildNixShellImage: run argument works"): + docker.succeed( + "${examples.nix-shell-run} | docker load", + "docker run --rm -it nix-shell-run | grep 'This shell is not interactive'" + ) + + with subtest("buildNixShellImage: command argument works"): + docker.succeed( + "${examples.nix-shell-command} | docker load", + "docker run --rm -it nix-shell-command | grep 'This shell is interactive'" + ) + + with subtest("buildNixShellImage: home directory is writable by default"): + docker.succeed( + "${examples.nix-shell-writable-home} | docker load", + "docker run --rm -it nix-shell-writable-home" + ) + + with subtest("buildNixShellImage: home directory can be made non-existent"): + docker.succeed( + "${examples.nix-shell-nonexistent-home} | docker load", + "docker run --rm -it nix-shell-nonexistent-home" + ) + + with subtest("buildNixShellImage: can build derivations"): + docker.succeed( + "${examples.nix-shell-build-derivation} | docker load", + "docker run --rm -it nix-shell-build-derivation" + ) ''; }) diff --git a/pkgs/build-support/docker/default.nix b/pkgs/build-support/docker/default.nix index 6b07865928e6..9a0d01acaae8 100644 --- a/pkgs/build-support/docker/default.nix +++ b/pkgs/build-support/docker/default.nix @@ -19,6 +19,7 @@ , pigz , rsync , runCommand +, runCommandNoCC , runtimeShell , shadow , skopeo @@ -30,6 +31,7 @@ , vmTools , writeReferencesToFile , writeScript +, writeShellScriptBin , writeText , writeTextDir , writePython3 @@ -78,7 +80,7 @@ let in rec { examples = callPackage ./examples.nix { - inherit buildImage buildLayeredImage fakeNss pullImage shadowSetup buildImageWithNixDb; + inherit buildImage buildLayeredImage fakeNss pullImage shadowSetup buildImageWithNixDb streamNixShellImage; }; tests = { @@ -1034,4 +1036,188 @@ rec { ''; in result; + + # This function streams a docker image that behaves like a nix-shell for a derivation + streamNixShellImage = + { # The derivation whose environment this docker image should be based on + drv + , # Image Name + name ? drv.name + "-env" + , # Image tag, the Nix's output hash will be used if null + tag ? null + , # User id to run the container as. Defaults to 1000, because many + # binaries don't like to be run as root + uid ? 1000 + , # Group id to run the container as, see also uid + gid ? 1000 + , # The home directory of the user + homeDirectory ? "/build" + , # The path to the bash binary to use as the shell. See `NIX_BUILD_SHELL` in `man nix-shell` + shell ? bashInteractive + "/bin/bash" + , # Run this command in the environment of the derivation, in an interactive shell. See `--command` in `man nix-shell` + command ? null + , # Same as `command`, but runs the command in a non-interactive shell instead. See `--run` in `man nix-shell` + run ? null + }: + assert lib.assertMsg (! (drv.drvAttrs.__structuredAttrs or false)) + "streamNixShellImage: Does not work with the derivation ${drv.name} because it uses __structuredAttrs"; + assert lib.assertMsg (command == null || run == null) + "streamNixShellImage: Can't specify both command and run"; + let + + # A binary that calls the command to build the derivation + builder = writeShellScriptBin "buildDerivation" '' + exec ${lib.escapeShellArg (stringValue drv.drvAttrs.builder)} ${lib.escapeShellArgs (map stringValue drv.drvAttrs.args)} + ''; + + staticPath = "${dirOf shell}:${lib.makeBinPath [ builder ]}"; + + # https://github.com/NixOS/nix/blob/2.8.0/src/nix-build/nix-build.cc#L493-L526 + rcfile = writeText "nix-shell-rc" '' + unset PATH + dontAddDisableDepTrack=1 + # TODO: https://github.com/NixOS/nix/blob/2.8.0/src/nix-build/nix-build.cc#L506 + [ -e $stdenv/setup ] && source $stdenv/setup + PATH=${staticPath}:"$PATH" + SHELL=${lib.escapeShellArg shell} + BASH=${lib.escapeShellArg shell} + set +e + [ -n "$PS1" -a -z "$NIX_SHELL_PRESERVE_PROMPT" ] && PS1='\n\[\033[1;32m\][nix-shell:\w]\$\[\033[0m\] ' + if [ "$(type -t runHook)" = function ]; then + runHook shellHook + fi + unset NIX_ENFORCE_PURITY + shopt -u nullglob + shopt -s execfail + ${optionalString (command != null || run != null) '' + ${optionalString (command != null) command} + ${optionalString (run != null) run} + exit + ''} + ''; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/globals.hh#L464-L465 + sandboxBuildDir = "/build"; + + # This function closely mirrors what this Nix code does: + # https://github.com/NixOS/nix/blob/2.8.0/src/libexpr/primops.cc#L1102 + # https://github.com/NixOS/nix/blob/2.8.0/src/libexpr/eval.cc#L1981-L2036 + stringValue = value: + # We can't just use `toString` on all derivation attributes because that + # would not put path literals in the closure. So we explicitly copy + # those into the store here + if builtins.typeOf value == "path" then "${value}" + else if builtins.typeOf value == "list" then toString (map stringValue value) + else toString value; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L992-L1004 + drvEnv = lib.mapAttrs' (name: value: + let str = stringValue value; + in if lib.elem name (drv.drvAttrs.passAsFile or []) + then lib.nameValuePair "${name}Path" (writeText "pass-as-text-${name}" str) + else lib.nameValuePair name str + ) drv.drvAttrs // + # A mapping from output name to the nix store path where they should end up + # https://github.com/NixOS/nix/blob/2.8.0/src/libexpr/primops.cc#L1253 + lib.genAttrs drv.outputs (output: builtins.unsafeDiscardStringContext drv.${output}.outPath); + + # Environment variables set in the image + envVars = { + + # Root certificates for internet access + SSL_CERT_FILE = "${cacert}/etc/ssl/certs/ca-bundle.crt"; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1027-L1030 + # PATH = "/path-not-set"; + # Allows calling bash and `buildDerivation` as the Cmd + PATH = staticPath; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1032-L1038 + HOME = homeDirectory; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1040-L1044 + NIX_STORE = storeDir; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1046-L1047 + # TODO: Make configurable? + NIX_BUILD_CORES = "1"; + + } // drvEnv // { + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1008-L1010 + NIX_BUILD_TOP = sandboxBuildDir; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1012-L1013 + TMPDIR = sandboxBuildDir; + TEMPDIR = sandboxBuildDir; + TMP = sandboxBuildDir; + TEMP = sandboxBuildDir; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1015-L1019 + PWD = sandboxBuildDir; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1071-L1074 + # We don't set it here because the output here isn't handled in any special way + # NIX_LOG_FD = "2"; + + # https://github.com/NixOS/nix/blob/2.8.0/src/libstore/build/local-derivation-goal.cc#L1076-L1077 + TERM = "xterm-256color"; + }; + + + in streamLayeredImage { + inherit name tag; + contents = [ + binSh + usrBinEnv + (fakeNss.override { + # Allows programs to look up the build user's home directory + # https://github.com/NixOS/nix/blob/ffe155abd36366a870482625543f9bf924a58281/src/libstore/build/local-derivation-goal.cc#L906-L910 + # Slightly differs however: We use the passed-in homeDirectory instead of sandboxBuildDir. + # We're doing this because it's arguably a bug in Nix that sandboxBuildDir is used here: https://github.com/NixOS/nix/issues/6379 + extraPasswdLines = [ + "nixbld:x:${toString uid}:${toString gid}:Build user:${homeDirectory}:/noshell" + ]; + extraGroupLines = [ + "nixbld:!:${toString gid}:" + ]; + }) + ]; + + fakeRootCommands = '' + # Effectively a single-user installation of Nix, giving the user full + # control over the Nix store. Needed for building the derivation this + # shell is for, but also in case one wants to use Nix inside the + # image + mkdir -p ./nix/{store,var/nix} ./etc/nix + chown -R ${toString uid}:${toString gid} ./nix ./etc/nix + + # Gives the user control over the build directory + mkdir -p .${sandboxBuildDir} + chown -R ${toString uid}:${toString gid} .${sandboxBuildDir} + ''; + + # Run this image as the given uid/gid + config.User = "${toString uid}:${toString gid}"; + config.Cmd = + # https://github.com/NixOS/nix/blob/2.8.0/src/nix-build/nix-build.cc#L185-L186 + # https://github.com/NixOS/nix/blob/2.8.0/src/nix-build/nix-build.cc#L534-L536 + if run == null + then [ shell "--rcfile" rcfile ] + else [ shell rcfile ]; + config.WorkingDir = sandboxBuildDir; + config.Env = lib.mapAttrsToList (name: value: "${name}=${value}") envVars; + }; + + # Wrapper around streamNixShellImage to build an image from the result + buildNixShellImage = { drv, ... }@args: + let + stream = streamNixShellImage args; + in + runCommand "${drv.name}-env.tar.gz" + { + inherit (stream) imageName; + passthru = { inherit (stream) imageTag; }; + nativeBuildInputs = [ pigz ]; + } "${stream} | pigz -nT > $out"; } diff --git a/pkgs/build-support/docker/examples.nix b/pkgs/build-support/docker/examples.nix index 1e9f07045e37..802b2f79f0fc 100644 --- a/pkgs/build-support/docker/examples.nix +++ b/pkgs/build-support/docker/examples.nix @@ -7,7 +7,7 @@ # $ nix-build '' -A dockerTools.examples.redis # $ docker load < result -{ pkgs, buildImage, buildLayeredImage, fakeNss, pullImage, shadowSetup, buildImageWithNixDb, pkgsCross }: +{ pkgs, buildImage, buildLayeredImage, fakeNss, pullImage, shadowSetup, buildImageWithNixDb, pkgsCross, streamNixShellImage }: let nixosLib = import ../../../nixos/lib { @@ -715,4 +715,118 @@ rec { config = { }; }; + + nix-shell-basic = streamNixShellImage { + name = "nix-shell-basic"; + tag = "latest"; + drv = pkgs.hello; + }; + + nix-shell-hook = streamNixShellImage { + name = "nix-shell-hook"; + tag = "latest"; + drv = pkgs.mkShell { + shellHook = '' + echo "This is the shell hook!" + exit + ''; + }; + }; + + nix-shell-inputs = streamNixShellImage { + name = "nix-shell-inputs"; + tag = "latest"; + drv = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.hello + ]; + }; + command = '' + hello + ''; + }; + + nix-shell-pass-as-file = streamNixShellImage { + name = "nix-shell-pass-as-file"; + tag = "latest"; + drv = pkgs.mkShell { + str = "this is a string"; + passAsFile = [ "str" ]; + }; + command = '' + cat "$strPath" + ''; + }; + + nix-shell-run = streamNixShellImage { + name = "nix-shell-run"; + tag = "latest"; + drv = pkgs.mkShell {}; + run = '' + case "$-" in + *i*) echo This shell is interactive ;; + *) echo This shell is not interactive ;; + esac + ''; + }; + + nix-shell-command = streamNixShellImage { + name = "nix-shell-command"; + tag = "latest"; + drv = pkgs.mkShell {}; + command = '' + case "$-" in + *i*) echo This shell is interactive ;; + *) echo This shell is not interactive ;; + esac + ''; + }; + + nix-shell-writable-home = streamNixShellImage { + name = "nix-shell-writable-home"; + tag = "latest"; + drv = pkgs.mkShell {}; + run = '' + if [[ "$HOME" != "$(eval "echo ~$(whoami)")" ]]; then + echo "\$HOME ($HOME) is not the same as ~\$(whoami) ($(eval "echo ~$(whoami)"))" + exit 1 + fi + + if ! touch $HOME/test-file; then + echo "home directory is not writable" + exit 1 + fi + echo "home directory is writable" + ''; + }; + + nix-shell-nonexistent-home = streamNixShellImage { + name = "nix-shell-nonexistent-home"; + tag = "latest"; + drv = pkgs.mkShell {}; + homeDirectory = "/homeless-shelter"; + run = '' + if [[ "$HOME" != "$(eval "echo ~$(whoami)")" ]]; then + echo "\$HOME ($HOME) is not the same as ~\$(whoami) ($(eval "echo ~$(whoami)"))" + exit 1 + fi + + if -e $HOME; then + echo "home directory exists" + exit 1 + fi + echo "home directory doesn't exist" + ''; + }; + + nix-shell-build-derivation = streamNixShellImage { + name = "nix-shell-build-derivation"; + tag = "latest"; + drv = pkgs.hello; + run = '' + buildDerivation + $out/bin/hello + ''; + }; + } diff --git a/pkgs/build-support/fake-nss/default.nix b/pkgs/build-support/fake-nss/default.nix index 9e0b60133e00..7d85ec5fc0a5 100644 --- a/pkgs/build-support/fake-nss/default.nix +++ b/pkgs/build-support/fake-nss/default.nix @@ -2,17 +2,17 @@ # Useful when packaging binaries that insist on using nss to look up # username/groups (like nginx). # /bin/sh is fine to not exist, and provided by another shim. -{ symlinkJoin, writeTextDir, runCommand }: +{ lib, symlinkJoin, writeTextDir, runCommand, extraPasswdLines ? [], extraGroupLines ? [] }: symlinkJoin { name = "fake-nss"; paths = [ (writeTextDir "etc/passwd" '' root:x:0:0:root user:/var/empty:/bin/sh - nobody:x:65534:65534:nobody:/var/empty:/bin/sh + ${lib.concatStrings (map (line: line + "\n") extraPasswdLines)}nobody:x:65534:65534:nobody:/var/empty:/bin/sh '') (writeTextDir "etc/group" '' root:x:0: - nobody:x:65534: + ${lib.concatStrings (map (line: line + "\n") extraGroupLines)}nobody:x:65534: '') (writeTextDir "etc/nsswitch.conf" '' hosts: files dns