{ callPackage, coreutils, docker, e2fsprogs, findutils, go, jshon, jq, lib, pkgs, pigz, nixUnstable, perl, runCommand, rsync, shadow, stdenv, storeDir ? builtins.storeDir, utillinux, vmTools, writeReferencesToFile, writeScript, writeText, }: # WARNING: this API is unstable and may be subject to backwards-incompatible changes in the future. rec { examples = import ./examples.nix { inherit pkgs buildImage pullImage shadowSetup buildImageWithNixDb; }; pullImage = let nameReplace = name: builtins.replaceStrings ["/" ":"] ["-" "-"] name; in # For simplicity we only support sha256. { imageName, imageTag ? "latest", imageId ? "${imageName}:${imageTag}" , sha256, name ? (nameReplace "docker-image-${imageName}-${imageTag}.tar") }: runCommand name { impureEnvVars=pkgs.stdenv.lib.fetchers.proxyImpureEnvVars; outputHashMode="flat"; outputHashAlgo="sha256"; outputHash=sha256; } "${pkgs.skopeo}/bin/skopeo copy docker://${imageId} docker-archive://$out:${imageId}"; # We need to sum layer.tar, not a directory, hence tarsum instead of nix-hash. # And we cannot untar it, because then we cannot preserve permissions ecc. tarsum = runCommand "tarsum" { buildInputs = [ go ]; } '' mkdir tarsum cd tarsum cp ${./tarsum.go} tarsum.go export GOPATH=$(pwd) mkdir src ln -sT ${docker.src}/components/engine/pkg/tarsum src/tarsum go build cp tarsum $out ''; # buildEnv creates symlinks to dirs, which is hard to edit inside the overlay VM mergeDrvs = { derivations, onlyDeps ? false }: runCommand "merge-drvs" { inherit derivations onlyDeps; } '' if [[ -n "$onlyDeps" ]]; then echo $derivations > $out exit 0 fi mkdir $out for derivation in $derivations; do echo "Merging $derivation..." if [[ -d "$derivation" ]]; then # If it's a directory, copy all of its contents into $out. cp -drf --preserve=mode -f $derivation/* $out/ else # Otherwise treat the derivation as a tarball and extract it # into $out. tar -C $out -xpf $drv || true fi done ''; # Helper for setting up the base files for managing users and # groups, only if such files don't exist already. It is suitable for # being used in a runAsRoot script. shadowSetup = '' export PATH=${shadow}/bin:$PATH mkdir -p /etc/pam.d if [[ ! -f /etc/passwd ]]; then echo "root:x:0:0::/root:${stdenv.shell}" > /etc/passwd echo "root:!x:::::::" > /etc/shadow fi if [[ ! -f /etc/group ]]; then echo "root:x:0:" > /etc/group echo "root:x::" > /etc/gshadow fi if [[ ! -f /etc/pam.d/other ]]; then cat > /etc/pam.d/other < $out/db.dump perl ${pkgs.pathsFromGraph} closure-* > $out/storePaths ''; # Create a "layer" (set of files). mkPureLayer = { # Name of the layer name, # JSON containing configuration and metadata for this layer. baseJson, # Files to add to the layer. contents ? null, # Additional commands to run on the layer before it is tar'd up. extraCommands ? "", uid ? 0, gid ? 0 }: runCommand "docker-layer-${name}" { inherit baseJson contents extraCommands; buildInputs = [ jshon rsync ]; } '' mkdir layer if [[ -n "$contents" ]]; then echo "Adding contents..." for item in $contents; do echo "Adding $item" rsync -ak --chown=0:0 $item/ layer/ done else echo "No contents to add to layer." fi chmod ug+w layer if [[ -n $extraCommands ]]; then (cd layer; eval "$extraCommands") fi # Tar up the layer and throw it into 'layer.tar'. echo "Packing layer..." mkdir $out tar -C layer --mtime="@$SOURCE_DATE_EPOCH" --owner=${toString uid} --group=${toString gid} -cf $out/layer.tar . # Compute a checksum of the tarball. echo "Computing layer checksum..." tarsum=$(${tarsum} < $out/layer.tar) # Add a 'checksum' field to the JSON, with the value set to the # checksum of the tarball. cat ${baseJson} | jshon -s "$tarsum" -i checksum > $out/json # Indicate to docker that we're using schema version 1.0. echo -n "1.0" > $out/VERSION echo "Finished building layer '${name}'" ''; # Make a "root" layer; required if we need to execute commands as a # privileged user on the image. The commands themselves will be # performed in a virtual machine sandbox. mkRootLayer = { # Name of the image. name, # Script to run as root. Bash. runAsRoot, # Files to add to the layer. If null, an empty layer will be created. contents ? null, # JSON containing configuration and metadata for this layer. baseJson, # Existing image onto which to append the new layer. fromImage ? null, # Name of the image we're appending onto. fromImageName ? null, # Tag of the image we're appending onto. fromImageTag ? null, # How much disk to allocate for the temporary virtual machine. diskSize ? 1024, # Commands (bash) to run on the layer; these do not require sudo. extraCommands ? "" }: # Generate an executable script from the `runAsRoot` text. let runAsRootScript = shellScript "run-as-root.sh" runAsRoot; in runWithOverlay { name = "docker-layer-${name}"; inherit fromImage fromImageName fromImageTag diskSize; preMount = lib.optionalString (contents != null && contents != []) '' echo "Adding contents..." for item in ${toString contents}; do echo "Adding $item..." rsync -ak --chown=0:0 $item/ layer/ done chmod ug+w layer ''; postMount = '' mkdir -p mnt/{dev,proc,sys} mnt${storeDir} # Mount /dev, /sys and the nix store as shared folders. mount --rbind /dev mnt/dev mount --rbind /sys mnt/sys mount --rbind ${storeDir} mnt${storeDir} # Execute the run as root script. See 'man unshare' for # details on what's going on here; basically this command # means that the runAsRootScript will be executed in a nearly # completely isolated environment. unshare -imnpuf --mount-proc chroot mnt ${runAsRootScript} # Unmount directories and remove them. umount -R mnt/dev mnt/sys mnt${storeDir} rmdir --ignore-fail-on-non-empty \ mnt/dev mnt/proc mnt/sys mnt${storeDir} \ mnt$(dirname ${storeDir}) ''; postUmount = '' (cd layer; eval "${extraCommands}") echo "Packing layer..." mkdir $out tar -C layer --mtime="@$SOURCE_DATE_EPOCH" -cf $out/layer.tar . # Compute the tar checksum and add it to the output json. echo "Computing checksum..." ts=$(${tarsum} < $out/layer.tar) cat ${baseJson} | jshon -s "$ts" -i checksum > $out/json # Indicate to docker that we're using schema version 1.0. echo -n "1.0" > $out/VERSION echo "Finished building layer '${name}'" ''; }; # 1. extract the base image # 2. create the layer # 3. add layer deps to the layer itself, diffing with the base image # 4. compute the layer id # 5. put the layer in the image # 6. repack the image buildImage = args@{ # Image name. name, # Image tag. tag ? "latest", # Parent image, to append to. fromImage ? null, # Name of the parent image; will be read from the image otherwise. fromImageName ? null, # Tag of the parent image; will be read from the image otherwise. fromImageTag ? null, # Files to put on the image (a nix store path or list of paths). contents ? null, # Docker config; e.g. what command to run on the container. config ? null, # Optional bash script to run on the files prior to fixturizing the layer. extraCommands ? "", uid ? 0, gid ? 0, # Optional bash script to run as root on the image when provisioning. runAsRoot ? null, # Size of the virtual machine disk to provision when building the image. diskSize ? 1024, # Time of creation of the image. created ? "1970-01-01T00:00:01Z", }: let baseName = baseNameOf name; # Create a JSON blob of the configuration. Set the date to unix zero. baseJson = writeText "${baseName}-config.json" (builtins.toJSON { inherit created config; architecture = "amd64"; os = "linux"; }); layer = if runAsRoot == null then mkPureLayer { name = baseName; inherit baseJson contents extraCommands uid gid; } else mkRootLayer { name = baseName; inherit baseJson fromImage fromImageName fromImageTag contents runAsRoot diskSize extraCommands; }; result = runCommand "docker-image-${baseName}.tar.gz" { buildInputs = [ jshon pigz coreutils findutils jq ]; # Image name and tag must be lowercase imageName = lib.toLower name; imageTag = lib.toLower tag; inherit fromImage baseJson; layerClosure = writeReferencesToFile layer; passthru.buildArgs = args; passthru.layer = layer; } '' # Print tar contents: # 1: Interpreted as relative to the root directory # 2: With no trailing slashes on directories # This is useful for ensuring that the output matches the # values generated by the "find" command ls_tar() { for f in $(tar -tf $1 | xargs realpath -ms --relative-to=.); do if [[ "$f" != "." ]]; then echo "/$f" fi done } mkdir image touch baseFiles layers="" if [[ -n "$fromImage" ]]; then echo "Unpacking base image..." tar -C image -xpf "$fromImage" config=$(jq -r '.[0].Config' image/manifest.json) layers=$(jq -r '.[0].Layers | join(" ")' image/manifest.json) for l in $layers; do ls_tar image/$l >> baseFiles done chmod u+w image image/$config rm image/$config fi chmod -R ug+rw image mkdir temp cp ${layer}/* temp/ chmod ug+w temp/* echo "$(dirname ${storeDir})" >> layerFiles echo '${storeDir}' >> layerFiles for dep in $(cat $layerClosure); do find $dep >> layerFiles done echo "Adding layer..." # Record the contents of the tarball with ls_tar. ls_tar temp/layer.tar >> baseFiles # Get the files in the new layer which were *not* present in # the old layer, and record them as newFiles. comm <(sort -n baseFiles|uniq) \ <(sort -n layerFiles|uniq|grep -v ${layer}) -1 -3 > newFiles # Append the new files to the layer. tar -rpf temp/layer.tar --mtime="@$SOURCE_DATE_EPOCH" \ --owner=0 --group=0 --no-recursion --files-from newFiles gzip temp/layer.tar layerID="sha256:$(sha256sum temp/layer.tar.gz | cut -d ' ' -f 1)" mv temp/layer.tar.gz image/$layerID echo "Generating image configuration and manifest..." imageJson=$(cat ${baseJson} | jq ". + {\"rootfs\": {\"diff_ids\": [], \"type\": \"layers\"}}") manifestJson=$(jq -n "[{\"RepoTags\":[\"$imageName:$imageTag\"]}]") # The layer list is ordered starting from the base image layers=$(echo $layers $layerID) for i in $(echo $layers); do imageJson=$(echo "$imageJson" | jq ".history |= [{\"created\": \"${created}\"}] + .") diffId=$(gzip -dc image/$i | sha256sum | cut -d" " -f1) imageJson=$(echo "$imageJson" | jq ".rootfs.diff_ids |= [\"sha256:$diffId\"] + .") manifestJson=$(echo "$manifestJson" | jq ".[0].Layers |= [\"$i\"] + .") done imageJsonChecksum=$(echo "$imageJson" | sha256sum | cut -d ' ' -f1) echo "$imageJson" > "image/sha256:$imageJsonChecksum" manifestJson=$(echo "$manifestJson" | jq ".[0].Config = \"sha256:$imageJsonChecksum\"") echo "$manifestJson" > image/manifest.json # Make the image read-only. chmod -R a-w image echo "Cooking the image..." tar -C image --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --xform s:'./':: -c . | pigz -nT > $out echo "Finished." ''; in result; # Build an image and populate its nix database with the provided # contents. The main purpose is to be able to use nix commands in # the container. # Be careful since this doesn't work well with multilayer. buildImageWithNixDb = args@{ contents ? null, extraCommands ? "", ... }: buildImage (args // { extraCommands = '' echo "Generating the nix database..." echo "Warning: only the database of the deepest Nix layer is loaded." echo " If you want to use nix commands in the container, it would" echo " be better to only have one layer that contains a nix store." # This requires Nix 1.12 or higher export NIX_REMOTE=local?root=$PWD ${nixUnstable}/bin/nix-store --load-db < ${nixRegistration contents}/db.dump # We fill the store in order to run the 'verify' command that # generates hash and size of output paths. # Note when Nix 1.12 is be the stable one, the database dump # generated by the exportReferencesGraph function will # contains sha and size. See # https://github.com/NixOS/nix/commit/c2b0d8749f7e77afc1c4b3e8dd36b7ee9720af4a storePaths=$(cat ${nixRegistration contents}/storePaths) echo "Copying everything to /nix/store (will take a while)..." cp -prd $storePaths nix/store/ ${nixUnstable}/bin/nix-store --verify --check-contents mkdir -p nix/var/nix/gcroots/docker/ for i in ${lib.concatStringsSep " " contents}; do ln -s $i nix/var/nix/gcroots/docker/$(basename $i) done; '' + extraCommands; }); }