Unverified Commit a566d084 authored by Silvan Mosberger's avatar Silvan Mosberger Committed by GitHub
Browse files

Merge pull request #172736 from infinisil/docker-nix-shell

parents 50e0b801 a1cf2493
Loading
Loading
Loading
Loading
+139 −0
Original line number Diff line number Diff line
@@ -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 <nixpkgs> {};
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!
+53 −0
Original line number Diff line number Diff line
@@ -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"
        )
  '';
})
+187 −1
Original line number Diff line number Diff line
@@ -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";
}
+115 −1
Original line number Diff line number Diff line
@@ -7,7 +7,7 @@
#  $ nix-build '<nixpkgs>' -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
    '';
  };

}
+3 −3
Original line number Diff line number Diff line
@@ -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