about summary refs log tree commit diff
path: root/nixos/lib
diff options
context:
space:
mode:
authorEelco Dolstra <eelco.dolstra@logicblox.com>2013-10-10 13:28:20 +0200
committerEelco Dolstra <eelco.dolstra@logicblox.com>2013-10-10 13:28:20 +0200
commit5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010 (patch)
treea6c0f605be6de3f372ae69905b331f9f75452da7 /nixos/lib
parent6070bc016bd2fd945b04347e25cfd3738622d2ac (diff)
downloadnixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar.gz
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar.bz2
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar.lz
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar.xz
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.tar.zst
nixlib-5c1f8cbc70cd5e6867ef6a2a06d27a40daa07010.zip
Move all of NixOS to nixos/ in preparation of the repository merge
Diffstat (limited to 'nixos/lib')
-rw-r--r--nixos/lib/build-vms.nix87
-rw-r--r--nixos/lib/channel-expr.nix6
-rw-r--r--nixos/lib/eval-config.nix73
-rw-r--r--nixos/lib/from-env.nix4
-rw-r--r--nixos/lib/make-iso9660-image.nix60
-rw-r--r--nixos/lib/make-iso9660-image.sh91
-rw-r--r--nixos/lib/make-squashfs.nix30
-rw-r--r--nixos/lib/make-system-tarball.nix38
-rw-r--r--nixos/lib/make-system-tarball.sh58
-rw-r--r--nixos/lib/qemu-flags.nix10
-rw-r--r--nixos/lib/test-driver/Logger.pm70
-rw-r--r--nixos/lib/test-driver/Machine.pm568
-rw-r--r--nixos/lib/test-driver/log2html.xsl135
-rw-r--r--nixos/lib/test-driver/logfile.css129
-rw-r--r--nixos/lib/test-driver/test-driver.pl178
-rw-r--r--nixos/lib/test-driver/treebits.js30
-rw-r--r--nixos/lib/testing.nix244
-rw-r--r--nixos/lib/utils.nix10
18 files changed, 1821 insertions, 0 deletions
diff --git a/nixos/lib/build-vms.nix b/nixos/lib/build-vms.nix
new file mode 100644
index 000000000000..59f05bfd1043
--- /dev/null
+++ b/nixos/lib/build-vms.nix
@@ -0,0 +1,87 @@
+{ system, minimal ? false }:
+
+let pkgs = import <nixpkgs> { config = {}; inherit system; }; in
+
+with pkgs.lib;
+with import ../lib/qemu-flags.nix;
+
+rec {
+
+  inherit pkgs;
+
+
+  # Build a virtual network from an attribute set `{ machine1 =
+  # config1; ... machineN = configN; }', where `machineX' is the
+  # hostname and `configX' is a NixOS system configuration.  Each
+  # machine is given an arbitrary IP address in the virtual network.
+  buildVirtualNetwork =
+    nodes: let nodesOut = mapAttrs (n: buildVM nodesOut) (assignIPAddresses nodes); in nodesOut;
+
+
+  buildVM =
+    nodes: configurations:
+
+    import ./eval-config.nix {
+      inherit system;
+      modules = configurations ++
+        [ ../modules/virtualisation/qemu-vm.nix
+          ../modules/testing/test-instrumentation.nix # !!! should only get added for automated test runs
+          { key = "no-manual"; services.nixosManual.enable = false; }
+        ] ++ optional minimal ../modules/testing/minimal-kernel.nix;
+      extraArgs = { inherit nodes; };
+    };
+
+
+  # Given an attribute set { machine1 = config1; ... machineN =
+  # configN; }, sequentially assign IP addresses in the 192.168.1.0/24
+  # range to each machine, and set the hostname to the attribute name.
+  assignIPAddresses = nodes:
+
+    let
+
+      machines = attrNames nodes;
+
+      machinesNumbered = zipTwoLists machines (range 1 254);
+
+      nodes_ = flip map machinesNumbered (m: nameValuePair m.first
+        [ ( { config, pkgs, nodes, ... }:
+            let
+              interfacesNumbered = zipTwoLists config.virtualisation.vlans (range 1 255);
+              interfaces = flip map interfacesNumbered ({ first, second }:
+                nameValuePair "eth${toString second}"
+                  { ipAddress = "192.168.${toString first}.${toString m.second}";
+                    subnetMask = "255.255.255.0";
+                  });
+            in
+            { key = "ip-address";
+              config =
+                { networking.hostName = m.first;
+
+                  networking.interfaces = listToAttrs interfaces;
+
+                  networking.primaryIPAddress =
+                    optionalString (interfaces != []) (head interfaces).value.ipAddress;
+
+                  # Put the IP addresses of all VMs in this machine's
+                  # /etc/hosts file.  If a machine has multiple
+                  # interfaces, use the IP address corresponding to
+                  # the first interface (i.e. the first network in its
+                  # virtualisation.vlans option).
+                  networking.extraHosts = flip concatMapStrings machines
+                    (m: let config = (getAttr m nodes).config; in
+                      optionalString (config.networking.primaryIPAddress != "")
+                        ("${config.networking.primaryIPAddress} " +
+                         "${config.networking.hostName}\n"));
+
+                  virtualisation.qemu.options =
+                    flip map interfacesNumbered
+                      ({ first, second }: qemuNICFlags second first m.second);
+                };
+            }
+          )
+          (getAttr m.first nodes)
+        ] );
+
+    in listToAttrs nodes_;
+
+}
diff --git a/nixos/lib/channel-expr.nix b/nixos/lib/channel-expr.nix
new file mode 100644
index 000000000000..453bdd506b88
--- /dev/null
+++ b/nixos/lib/channel-expr.nix
@@ -0,0 +1,6 @@
+{ system ? builtins.currentSystem }:
+
+{ pkgs =
+    (import nixpkgs/default.nix { inherit system; })
+    // { recurseForDerivations = true; };
+}
diff --git a/nixos/lib/eval-config.nix b/nixos/lib/eval-config.nix
new file mode 100644
index 000000000000..47e7d1a0eafa
--- /dev/null
+++ b/nixos/lib/eval-config.nix
@@ -0,0 +1,73 @@
+# From an end-user configuration file (`configuration'), build a NixOS
+# configuration object (`config') from which we can retrieve option
+# values.
+
+{ system ? builtins.currentSystem
+, pkgs ? null
+, baseModules ? import ../modules/module-list.nix
+, extraArgs ? {}
+, modules
+, nixpkgs ? <nixpkgs>
+}:
+
+let extraArgs_ = extraArgs; pkgs_ = pkgs; system_ = system; in
+
+rec {
+
+  # These are the NixOS modules that constitute the system configuration.
+  configComponents = modules ++ baseModules;
+
+  # Merge the option definitions in all modules, forming the full
+  # system configuration.  It's not checked for undeclared options.
+  systemModule =
+    pkgs.lib.fixMergeModules configComponents extraArgs;
+
+  optionDefinitions = systemModule.config;
+  optionDeclarations = systemModule.options;
+  inherit (systemModule) options;
+
+  # These are the extra arguments passed to every module.  In
+  # particular, Nixpkgs is passed through the "pkgs" argument.
+  extraArgs = extraArgs_ // {
+    inherit pkgs modules baseModules;
+    modulesPath = ../modules;
+    pkgs_i686 = import nixpkgs { system = "i686-linux"; };
+    utils = import ./utils.nix pkgs;
+  };
+
+  # Import Nixpkgs, allowing the NixOS option nixpkgs.config to
+  # specify the Nixpkgs configuration (e.g., to set package options
+  # such as firefox.enableGeckoMediaPlayer, or to apply global
+  # overrides such as changing GCC throughout the system), and the
+  # option nixpkgs.system to override the platform type.  This is
+  # tricky, because we have to prevent an infinite recursion: "pkgs"
+  # is passed as an argument to NixOS modules, but the value of "pkgs"
+  # depends on config.nixpkgs.config, which we get from the modules.
+  # So we call ourselves here with "pkgs" explicitly set to an
+  # instance that doesn't depend on nixpkgs.config.
+  pkgs =
+    if pkgs_ != null
+    then pkgs_
+    else import nixpkgs (
+      let
+        system = if nixpkgsOptions.system != "" then nixpkgsOptions.system else system_;
+        nixpkgsOptions = (import ./eval-config.nix {
+          inherit system extraArgs modules;
+          # For efficiency, leave out most NixOS modules; they don't
+          # define nixpkgs.config, so it's pointless to evaluate them.
+          baseModules = [ ../modules/misc/nixpkgs.nix ];
+          pkgs = import nixpkgs { system = system_; config = {}; };
+        }).optionDefinitions.nixpkgs;
+      in
+      {
+        inherit system;
+        inherit (nixpkgsOptions) config;
+      });
+
+  # Optionally check wether all config values have corresponding
+  # option declarations.
+  config =
+    let doCheck = optionDefinitions.environment.checkConfigurationOptions; in
+    assert doCheck -> pkgs.lib.checkModule "" systemModule;
+    systemModule.config;
+}
diff --git a/nixos/lib/from-env.nix b/nixos/lib/from-env.nix
new file mode 100644
index 000000000000..6bd71e40e9a1
--- /dev/null
+++ b/nixos/lib/from-env.nix
@@ -0,0 +1,4 @@
+# TODO: remove this file. There is lib.maybeEnv now
+name: default:
+let value = builtins.getEnv name; in
+if value == "" then default else value
diff --git a/nixos/lib/make-iso9660-image.nix b/nixos/lib/make-iso9660-image.nix
new file mode 100644
index 000000000000..5ad546e9534d
--- /dev/null
+++ b/nixos/lib/make-iso9660-image.nix
@@ -0,0 +1,60 @@
+{ stdenv, perl, cdrkit, pathsFromGraph
+
+, # The file name of the resulting ISO image.
+  isoName ? "cd.iso"
+
+, # The files and directories to be placed in the ISO file system.
+  # This is a list of attribute sets {source, target} where `source'
+  # is the file system object (regular file or directory) to be
+  # grafted in the file system at path `target'.
+  contents
+
+, # In addition to `contents', the closure of the store paths listed
+  # in `packages' are also placed in the Nix store of the CD.  This is
+  # a list of attribute sets {object, symlink} where `object' if a
+  # store path whose closure will be copied, and `symlink' is a
+  # symlink to `object' that will be added to the CD.
+  storeContents ? []
+
+, # Whether this should be an El-Torito bootable CD.
+  bootable ? false
+
+, # Whether this should be an efi-bootable El-Torito CD.
+  efiBootable ? false
+
+, # The path (in the ISO file system) of the boot image.
+  bootImage ? ""
+
+, # The path (in the ISO file system) of the efi boot image.
+  efiBootImage ? ""
+
+, # Whether to compress the resulting ISO image with bzip2.
+  compressImage ? false
+
+, # The volume ID.
+  volumeID ? ""
+
+}:
+
+assert bootable -> bootImage != "";
+assert efiBootable -> efiBootImage != "";
+
+stdenv.mkDerivation {
+  name = "iso9660-image";
+  builder = ./make-iso9660-image.sh;
+  buildInputs = [perl cdrkit];
+
+  inherit isoName bootable bootImage compressImage volumeID pathsFromGraph efiBootImage efiBootable;
+
+  # !!! should use XML.
+  sources = map (x: x.source) contents;
+  targets = map (x: x.target) contents;
+
+  # !!! should use XML.
+  objects = map (x: x.object) storeContents;
+  symlinks = map (x: x.symlink) storeContents;
+
+  # For obtaining the closure of `storeContents'.
+  exportReferencesGraph =
+    map (x: [("closure-" + baseNameOf x.object) x.object]) storeContents;
+}
diff --git a/nixos/lib/make-iso9660-image.sh b/nixos/lib/make-iso9660-image.sh
new file mode 100644
index 000000000000..89b681ed2cd5
--- /dev/null
+++ b/nixos/lib/make-iso9660-image.sh
@@ -0,0 +1,91 @@
+source $stdenv/setup
+
+sources_=($sources)
+targets_=($targets)
+
+objects=($objects)
+symlinks=($symlinks)
+
+
+# Remove the initial slash from a path, since genisofs likes it that way.
+stripSlash() {
+    res="$1"
+    if test "${res:0:1}" = /; then res=${res:1}; fi
+}
+
+stripSlash "$bootImage"; bootImage="$res"
+
+
+if test -n "$bootable"; then
+
+    # The -boot-info-table option modifies the $bootImage file, so
+    # find it in `contents' and make a copy of it (since the original
+    # is read-only in the Nix store...).
+    for ((i = 0; i < ${#targets_[@]}; i++)); do
+        stripSlash "${targets_[$i]}"
+        if test "$res" = "$bootImage"; then
+            echo "copying the boot image ${sources_[$i]}"
+            cp "${sources_[$i]}" boot.img
+            chmod u+w boot.img
+            sources_[$i]=boot.img
+        fi
+    done
+
+    bootFlags="-b $bootImage -c .boot.cat -no-emul-boot -boot-load-size 4 -boot-info-table"
+fi
+
+if test -n "$efiBootable"; then
+    bootFlags="$bootFlags -eltorito-alt-boot -e $efiBootImage -no-emul-boot"
+fi
+
+touch pathlist
+
+
+# Add the individual files.
+for ((i = 0; i < ${#targets_[@]}; i++)); do
+    stripSlash "${targets_[$i]}"
+    echo "$res=${sources_[$i]}" >> pathlist
+done
+
+
+# Add the closures of the top-level store objects.
+storePaths=$(perl $pathsFromGraph closure-*)
+for i in $storePaths; do
+    echo "${i:1}=$i" >> pathlist
+done
+
+
+# Also include a manifest of the closures in a format suitable for
+# nix-store --load-db.
+if [ -n "$object" ]; then
+    printRegistration=1 perl $pathsFromGraph closure-* > nix-path-registration
+    echo "nix-path-registration=nix-path-registration" >> pathlist
+fi
+
+
+# Add symlinks to the top-level store objects.
+for ((n = 0; n < ${#objects[*]}; n++)); do
+    object=${objects[$n]}
+    symlink=${symlinks[$n]}
+    if test "$symlink" != "none"; then
+        mkdir -p $(dirname ./$symlink)
+        ln -s $object ./$symlink
+        echo "$symlink=./$symlink" >> pathlist
+    fi
+done
+
+# !!! what does this do?
+cat pathlist | sed -e 's/=\(.*\)=\(.*\)=/\\=\1=\2\\=/' | tee pathlist.safer
+
+
+ensureDir $out/iso
+genCommand="genisoimage -iso-level 4 -r -J $bootFlags -hide-rr-moved -graft-points -path-list pathlist.safer ${volumeID:+-V $volumeID}"
+if test -z "$compressImage"; then
+    $genCommand -o $out/iso/$isoName
+else
+    $genCommand | bzip2 > $out/iso/$isoName.bz2
+fi
+
+
+ensureDir $out/nix-support
+echo $system > $out/nix-support/system
diff --git a/nixos/lib/make-squashfs.nix b/nixos/lib/make-squashfs.nix
new file mode 100644
index 000000000000..3b640334e17a
--- /dev/null
+++ b/nixos/lib/make-squashfs.nix
@@ -0,0 +1,30 @@
+{ stdenv, squashfsTools, perl, pathsFromGraph
+
+, # The root directory of the squashfs filesystem is filled with the
+  # closures of the Nix store paths listed here.
+  storeContents ? []
+}:
+
+stdenv.mkDerivation {
+  name = "squashfs.img";
+
+  buildInputs = [perl squashfsTools];
+
+  # For obtaining the closure of `storeContents'.
+  exportReferencesGraph =
+    map (x: [("closure-" + baseNameOf x) x]) storeContents;
+
+  buildCommand =
+    ''
+      # Add the closures of the top-level store objects.
+      storePaths=$(perl ${pathsFromGraph} closure-*)
+
+      # Also include a manifest of the closures in a format suitable
+      # for nix-store --load-db.
+      printRegistration=1 perl ${pathsFromGraph} closure-* > nix-path-registration
+
+      # Generate the squashfs image.
+      mksquashfs nix-path-registration $storePaths $out \
+        -keep-as-directory -all-root
+    '';
+}
diff --git a/nixos/lib/make-system-tarball.nix b/nixos/lib/make-system-tarball.nix
new file mode 100644
index 000000000000..8fed9a348827
--- /dev/null
+++ b/nixos/lib/make-system-tarball.nix
@@ -0,0 +1,38 @@
+{ stdenv, perl, xz, pathsFromGraph
+
+, # The file name of the resulting tarball
+  fileName ? "nixos-system-${stdenv.system}"
+
+, # The files and directories to be placed in the tarball.
+  # This is a list of attribute sets {source, target} where `source'
+  # is the file system object (regular file or directory) to be
+  # grafted in the file system at path `target'.
+  contents
+
+, # In addition to `contents', the closure of the store paths listed
+  # in `packages' are also placed in the Nix store of the tarball.  This is
+  # a list of attribute sets {object, symlink} where `object' if a
+  # store path whose closure will be copied, and `symlink' is a
+  # symlink to `object' that will be added to the tarball.
+  storeContents ? []
+}:
+
+stdenv.mkDerivation {
+  name = "tarball";
+  builder = ./make-system-tarball.sh;
+  buildInputs = [perl xz];
+
+  inherit fileName pathsFromGraph;
+
+  # !!! should use XML.
+  sources = map (x: x.source) contents;
+  targets = map (x: x.target) contents;
+
+  # !!! should use XML.
+  objects = map (x: x.object) storeContents;
+  symlinks = map (x: x.symlink) storeContents;
+
+  # For obtaining the closure of `storeContents'.
+  exportReferencesGraph =
+    map (x: [("closure-" + baseNameOf x.object) x.object]) storeContents;
+}
diff --git a/nixos/lib/make-system-tarball.sh b/nixos/lib/make-system-tarball.sh
new file mode 100644
index 000000000000..aadd0f6428c8
--- /dev/null
+++ b/nixos/lib/make-system-tarball.sh
@@ -0,0 +1,58 @@
+source $stdenv/setup
+set -x
+
+sources_=($sources)
+targets_=($targets)
+
+echo $objects
+objects=($objects)
+symlinks=($symlinks)
+
+
+# Remove the initial slash from a path, since genisofs likes it that way.
+stripSlash() {
+    res="$1"
+    if test "${res:0:1}" = /; then res=${res:1}; fi
+}
+
+touch pathlist
+
+# Add the individual files.
+for ((i = 0; i < ${#targets_[@]}; i++)); do
+    stripSlash "${targets_[$i]}"
+    mkdir -p "$(dirname "$res")"
+    cp -a "${sources_[$i]}" "$res"
+done
+
+
+# Add the closures of the top-level store objects.
+mkdir -p nix/store
+storePaths=$(perl $pathsFromGraph closure-*)
+for i in $storePaths; do
+    cp -a "$i" "${i:1}"
+done
+
+
+# TODO tar ruxo 
+# Also include a manifest of the closures in a format suitable for
+# nix-store --load-db.
+printRegistration=1 perl $pathsFromGraph closure-* > nix-path-registration
+
+# Add symlinks to the top-level store objects.
+for ((n = 0; n < ${#objects[*]}; n++)); do
+    object=${objects[$n]}
+    symlink=${symlinks[$n]}
+    if test "$symlink" != "none"; then
+        mkdir -p $(dirname ./$symlink)
+        ln -s $object ./$symlink
+    fi
+done
+
+ensureDir $out/tarball
+
+tar cvJf $out/tarball/$fileName.tar.xz *
+
+ensureDir $out/nix-support
+echo $system > $out/nix-support/system
+echo "file system-tarball $out/tarball/$fileName.tar.xz" > $out/nix-support/hydra-build-products
+
diff --git a/nixos/lib/qemu-flags.nix b/nixos/lib/qemu-flags.nix
new file mode 100644
index 000000000000..de355b08918c
--- /dev/null
+++ b/nixos/lib/qemu-flags.nix
@@ -0,0 +1,10 @@
+# QEMU flags shared between various Nix expressions.
+
+{
+
+  qemuNICFlags = nic: net: machine:
+    [ "-net nic,vlan=${toString nic},macaddr=52:54:00:12:${toString net}:${toString machine},model=virtio"
+      "-net vde,vlan=${toString nic},sock=$QEMU_VDE_SOCKET_${toString net}"
+    ];
+
+}
diff --git a/nixos/lib/test-driver/Logger.pm b/nixos/lib/test-driver/Logger.pm
new file mode 100644
index 000000000000..6e62fdfd7708
--- /dev/null
+++ b/nixos/lib/test-driver/Logger.pm
@@ -0,0 +1,70 @@
+package Logger;
+
+use strict;
+use Thread::Queue;
+use XML::Writer;
+
+sub new {
+    my ($class) = @_;
+    
+    my $logFile = defined $ENV{LOGFILE} ? "$ENV{LOGFILE}" : "/dev/null";
+    my $log = new XML::Writer(OUTPUT => new IO::File(">$logFile"));
+    
+    my $self = {
+        log => $log,
+        logQueue => Thread::Queue->new()
+    };
+    
+    $self->{log}->startTag("logfile");
+    
+    bless $self, $class;
+    return $self;
+}
+
+sub close {
+    my ($self) = @_;
+    $self->{log}->endTag("logfile");
+    $self->{log}->end;
+}
+
+sub drainLogQueue {
+    my ($self) = @_;
+    while (defined (my $item = $self->{logQueue}->dequeue_nb())) {
+        $self->{log}->dataElement("line", sanitise($item->{msg}), 'machine' => $item->{machine}, 'type' => 'serial');
+    }
+}
+
+sub maybePrefix {
+    my ($msg, $attrs) = @_;
+    $msg = $attrs->{machine} . ": " . $msg if defined $attrs->{machine};
+    return $msg;
+}
+
+sub nest {
+    my ($self, $msg, $coderef, $attrs) = @_;
+    print STDERR maybePrefix("$msg\n", $attrs);
+    $self->{log}->startTag("nest");
+    $self->{log}->dataElement("head", $msg, %{$attrs});
+    $self->drainLogQueue();
+    eval { &$coderef };
+    my $res = $@;
+    $self->drainLogQueue();
+    $self->{log}->endTag("nest");
+    die $@ if $@;
+}
+
+sub sanitise {
+    my ($s) = @_;
+    $s =~ s/[[:cntrl:]\xff]//g;
+    return $s;
+}
+
+sub log {
+    my ($self, $msg, $attrs) = @_;
+    chomp $msg;
+    print STDERR maybePrefix("$msg\n", $attrs);
+    $self->drainLogQueue();
+    $self->{log}->dataElement("line", $msg, %{$attrs});
+}
+
+1;
diff --git a/nixos/lib/test-driver/Machine.pm b/nixos/lib/test-driver/Machine.pm
new file mode 100644
index 000000000000..a28214ea934f
--- /dev/null
+++ b/nixos/lib/test-driver/Machine.pm
@@ -0,0 +1,568 @@
+package Machine;
+
+use strict;
+use threads;
+use Socket;
+use IO::Handle;
+use POSIX qw(dup2);
+use FileHandle;
+use Cwd;
+use File::Basename;
+use File::Path qw(make_path);
+
+
+my $showGraphics = defined $ENV{'DISPLAY'};
+
+my $sharedDir;
+
+
+sub new {
+    my ($class, $args) = @_;
+
+    my $startCommand = $args->{startCommand};
+    
+    my $name = $args->{name};
+    if (!$name) {
+        $startCommand =~ /run-(.*)-vm$/ if defined $startCommand;
+        $name = $1 || "machine";
+    }
+
+    if (!$startCommand) {
+        # !!! merge with qemu-vm.nix.
+        $startCommand =
+            "qemu-kvm -m 384 " .
+            "-net nic,model=virtio \$QEMU_OPTS ";
+        my $iface = $args->{hdaInterface} || "virtio";
+        $startCommand .= "-drive file=" . Cwd::abs_path($args->{hda}) . ",if=$iface,boot=on,werror=report "
+            if defined $args->{hda};
+        $startCommand .= "-cdrom $args->{cdrom} "
+            if defined $args->{cdrom};
+        $startCommand .= $args->{qemuFlags} || "";
+    } else {
+        $startCommand = Cwd::abs_path $startCommand;
+    }
+
+    my $tmpDir = $ENV{'TMPDIR'} || "/tmp";
+    unless (defined $sharedDir) {
+        $sharedDir = $tmpDir . "/xchg-shared";
+        make_path($sharedDir, { mode => 0700, owner => $< });
+    }
+
+    my $allowReboot = 0;
+    $allowReboot = $args->{allowReboot} if defined $args->{allowReboot};
+
+    my $self = {
+        startCommand => $startCommand,
+        name => $name,
+        allowReboot => $allowReboot,
+        booted => 0,
+        pid => 0,
+        connected => 0,
+        socket => undef,
+        stateDir => "$tmpDir/vm-state-$name",
+        monitor => undef,
+        log => $args->{log},
+        redirectSerial => $args->{redirectSerial} // 1,
+    };
+
+    mkdir $self->{stateDir}, 0700;
+
+    bless $self, $class;
+    return $self;
+}
+
+
+sub log {
+    my ($self, $msg) = @_;
+    $self->{log}->log($msg, { machine => $self->{name} });
+}
+
+
+sub nest {
+    my ($self, $msg, $coderef, $attrs) = @_;
+    $self->{log}->nest($msg, $coderef, { %{$attrs || {}}, machine => $self->{name} });
+}
+
+
+sub name {
+    my ($self) = @_;
+    return $self->{name};
+}
+
+
+sub stateDir {
+    my ($self) = @_;
+    return $self->{stateDir};
+}
+
+
+sub start {
+    my ($self) = @_;
+    return if $self->{booted};
+
+    $self->log("starting vm");
+
+    # Create a socket pair for the serial line input/output of the VM.
+    my ($serialP, $serialC);
+    socketpair($serialP, $serialC, PF_UNIX, SOCK_STREAM, 0) or die;
+
+    # Create a Unix domain socket to which QEMU's monitor will connect.
+    my $monitorPath = $self->{stateDir} . "/monitor";
+    unlink $monitorPath;
+    my $monitorS;
+    socket($monitorS, PF_UNIX, SOCK_STREAM, 0) or die;
+    bind($monitorS, sockaddr_un($monitorPath)) or die "cannot bind monitor socket: $!";
+    listen($monitorS, 1) or die;
+
+    # Create a Unix domain socket to which the root shell in the guest will connect.
+    my $shellPath = $self->{stateDir} . "/shell";
+    unlink $shellPath;
+    my $shellS;
+    socket($shellS, PF_UNIX, SOCK_STREAM, 0) or die;
+    bind($shellS, sockaddr_un($shellPath)) or die "cannot bind shell socket: $!";
+    listen($shellS, 1) or die;
+
+    # Start the VM.
+    my $pid = fork();
+    die if $pid == -1;
+
+    if ($pid == 0) {
+        close $serialP;
+        close $monitorS;
+        close $shellS;
+        if ($self->{redirectSerial}) {
+            open NUL, "</dev/null" or die;
+            dup2(fileno(NUL), fileno(STDIN));
+            dup2(fileno($serialC), fileno(STDOUT));
+            dup2(fileno($serialC), fileno(STDERR));
+        }
+        $ENV{TMPDIR} = $self->{stateDir};
+        $ENV{SHARED_DIR} = $sharedDir;
+        $ENV{USE_TMPDIR} = 1;
+        $ENV{QEMU_OPTS} =
+            ($self->{allowReboot} ? "" : "-no-reboot ") .
+            "-monitor unix:./monitor -chardev socket,id=shell,path=./shell " .
+            "-device virtio-serial -device virtconsole,chardev=shell " .
+            ($showGraphics ? "-serial stdio" : "-nographic") . " " . ($ENV{QEMU_OPTS} || "");
+        chdir $self->{stateDir} or die;
+        exec $self->{startCommand};
+        die "running VM script: $!";
+    }
+
+    # Process serial line output.
+    close $serialC;
+
+    threads->create(\&processSerialOutput, $self, $serialP)->detach;
+
+    sub processSerialOutput {
+        my ($self, $serialP) = @_;
+        while (<$serialP>) {
+            chomp;
+            s/\r$//;
+            print STDERR $self->{name}, "# $_\n";
+            $self->{log}->{logQueue}->enqueue({msg => $_, machine => $self->{name}}); # !!!
+        }
+    }
+
+    eval {
+        local $SIG{CHLD} = sub { die "QEMU died prematurely\n"; };
+        
+        # Wait until QEMU connects to the monitor.
+        accept($self->{monitor}, $monitorS) or die;
+
+        # Wait until QEMU connects to the root shell socket.  QEMU
+        # does so immediately; this doesn't mean that the root shell
+        # has connected yet inside the guest.
+        accept($self->{socket}, $shellS) or die;
+        $self->{socket}->autoflush(1);
+    };
+    die "$@" if $@;
+    
+    $self->waitForMonitorPrompt;
+
+    $self->log("QEMU running (pid $pid)");
+    
+    $self->{pid} = $pid;
+    $self->{booted} = 1;
+}
+
+
+# Send a command to the monitor and wait for it to finish.  TODO: QEMU
+# also has a JSON-based monitor interface now, but it doesn't support
+# all commands yet.  We should use it once it does.
+sub sendMonitorCommand {
+    my ($self, $command) = @_;
+    $self->log("sending monitor command: $command");
+    syswrite $self->{monitor}, "$command\n";
+    return $self->waitForMonitorPrompt;
+}
+
+
+# Wait until the monitor sends "(qemu) ".
+sub waitForMonitorPrompt {
+    my ($self) = @_;
+    my $res = "";
+    my $s;
+    while (sysread($self->{monitor}, $s, 1024)) {
+        $res .= $s;
+        last if $res =~ s/\(qemu\) $//;
+    }
+    return $res;
+}
+
+
+# Call the given code reference repeatedly, with 1 second intervals,
+# until it returns 1 or a timeout is reached.
+sub retry {
+    my ($coderef) = @_;
+    my $n;
+    for ($n = 0; $n < 900; $n++) {
+        return if &$coderef;
+        sleep 1;
+    }
+    die "action timed out after $n seconds";
+}
+
+
+sub connect {
+    my ($self) = @_;
+    return if $self->{connected};
+
+    $self->nest("waiting for the VM to finish booting", sub {
+
+        $self->start;
+
+        local $SIG{ALRM} = sub { die "timed out waiting for the VM to connect\n"; };
+        alarm 300;
+        readline $self->{socket} or die "the VM quit before connecting\n";
+        alarm 0;
+        
+        $self->log("connected to guest root shell");
+        $self->{connected} = 1;
+
+    });
+}
+
+
+sub waitForShutdown {
+    my ($self) = @_;
+    return unless $self->{booted};
+
+    $self->nest("waiting for the VM to power off", sub {
+        waitpid $self->{pid}, 0;
+        $self->{pid} = 0;
+        $self->{booted} = 0;
+        $self->{connected} = 0;
+    });
+}
+
+
+sub isUp {
+    my ($self) = @_;
+    return $self->{booted} && $self->{connected};
+}
+
+
+sub execute_ {
+    my ($self, $command) = @_;
+    
+    $self->connect;
+
+    print { $self->{socket} } ("( $command ); echo '|!=EOF' \$?\n");
+
+    my $out = "";
+
+    while (1) {
+        my $line = readline($self->{socket});
+        die "connection to VM lost unexpectedly" unless defined $line;
+        #$self->log("got line: $line");
+        if ($line =~ /^(.*)\|\!\=EOF\s+(\d+)$/) {
+            $out .= $1;
+            $self->log("exit status $2");
+            return ($2, $out);
+        }
+        $out .= $line;
+    }
+}
+
+
+sub execute {
+    my ($self, $command) = @_;
+    my @res;
+    $self->nest("running command: $command", sub {
+        @res = $self->execute_($command);
+    });
+    return @res;
+}
+
+
+sub succeed {
+    my ($self, @commands) = @_;
+
+    my $res;
+    foreach my $command (@commands) {
+        $self->nest("must succeed: $command", sub {
+            my ($status, $out) = $self->execute_($command);
+            if ($status != 0) {
+                $self->log("output: $out");
+                die "command `$command' did not succeed (exit code $status)\n";
+            }
+            $res .= $out;
+        });
+    }
+
+    return $res;
+}
+
+
+sub mustSucceed {
+    succeed @_;
+}
+
+
+sub waitUntilSucceeds {
+    my ($self, $command) = @_;
+    $self->nest("waiting for success: $command", sub {
+        retry sub {
+            my ($status, $out) = $self->execute($command);
+            return 1 if $status == 0;
+        };
+    });
+}
+
+
+sub waitUntilFails {
+    my ($self, $command) = @_;
+    $self->nest("waiting for failure: $command", sub {
+        retry sub {
+            my ($status, $out) = $self->execute($command);
+            return 1 if $status != 0;
+        };
+    });
+}
+
+
+sub fail {
+    my ($self, $command) = @_;
+    $self->nest("must fail: $command", sub {
+        my ($status, $out) = $self->execute_($command);
+        die "command `$command' unexpectedly succeeded"
+            if $status == 0;
+    });
+}
+
+
+sub mustFail {
+    fail @_;
+}
+
+
+sub getUnitInfo {
+    my ($self, $unit) = @_;
+    my ($status, $lines) = $self->execute("systemctl --no-pager show '$unit'");
+    return undef if $status != 0;
+    my $info = {};
+    foreach my $line (split '\n', $lines) {
+        $line =~ /^([^=]+)=(.*)$/ or next;
+        $info->{$1} = $2;
+    }
+    return $info;
+}
+
+
+# Wait for a systemd unit to reach the "active" state.
+sub waitForUnit {
+    my ($self, $unit) = @_;
+    $self->nest("waiting for unit ‘$unit’", sub {
+        retry sub {
+            my $info = $self->getUnitInfo($unit);
+            my $state = $info->{ActiveState};
+            die "unit ‘$unit’ reached state ‘$state’\n" if $state eq "failed";
+            return 1 if $state eq "active";
+        };
+    });
+}
+
+
+sub waitForJob {
+    my ($self, $jobName) = @_;
+    return $self->waitForUnit($jobName);
+}
+
+
+# Wait until the specified file exists.
+sub waitForFile {
+    my ($self, $fileName) = @_;
+    $self->nest("waiting for file ‘$fileName’", sub {
+        retry sub {
+            my ($status, $out) = $self->execute("test -e $fileName");
+            return 1 if $status == 0;
+        }
+    });
+}
+
+sub startJob {
+    my ($self, $jobName) = @_;
+    $self->execute("systemctl start $jobName");
+    # FIXME: check result
+}
+
+sub stopJob {
+    my ($self, $jobName) = @_;
+    $self->execute("systemctl stop $jobName");
+}
+
+
+# Wait until the machine is listening on the given TCP port.
+sub waitForOpenPort {
+    my ($self, $port) = @_;
+    $self->nest("waiting for TCP port $port", sub {
+        retry sub {
+            my ($status, $out) = $self->execute("nc -z localhost $port");
+            return 1 if $status == 0;
+        }
+    });
+}
+
+
+# Wait until the machine is not listening on the given TCP port.
+sub waitForClosedPort {
+    my ($self, $port) = @_;
+    retry sub {
+        my ($status, $out) = $self->execute("nc -z localhost $port");
+        return 1 if $status != 0;
+    }
+}
+
+
+sub shutdown {
+    my ($self) = @_;
+    return unless $self->{booted};
+
+    print { $self->{socket} } ("poweroff\n");
+
+    $self->waitForShutdown;
+}
+
+
+sub crash {
+    my ($self) = @_;
+    return unless $self->{booted};
+    
+    $self->log("forced crash");
+
+    $self->sendMonitorCommand("quit");
+
+    $self->waitForShutdown;
+}
+
+
+# Make the machine unreachable by shutting down eth1 (the multicast
+# interface used to talk to the other VMs).  We keep eth0 up so that
+# the test driver can continue to talk to the machine.
+sub block {
+    my ($self) = @_;
+    $self->sendMonitorCommand("set_link virtio-net-pci.1 off");
+}
+
+
+# Make the machine reachable.
+sub unblock {
+    my ($self) = @_;
+    $self->sendMonitorCommand("set_link virtio-net-pci.1 on");
+}
+
+
+# Take a screenshot of the X server on :0.0.
+sub screenshot {
+    my ($self, $filename) = @_;
+    my $dir = $ENV{'out'} || Cwd::abs_path(".");
+    $filename = "$dir/${filename}.png" if $filename =~ /^\w+$/;
+    my $tmp = "${filename}.ppm";
+    my $name = basename($filename);
+    $self->nest("making screenshot ‘$name’", sub {
+        $self->sendMonitorCommand("screendump $tmp");
+        system("convert $tmp ${filename}") == 0
+            or die "cannot convert screenshot";
+        unlink $tmp;
+    }, { image => $name } );
+}
+
+
+# Wait until it is possible to connect to the X server.  Note that
+# testing the existence of /tmp/.X11-unix/X0 is insufficient.
+sub waitForX {
+    my ($self, $regexp) = @_;
+    $self->nest("waiting for the X11 server", sub {
+        retry sub {
+            my ($status, $out) = $self->execute("xwininfo -root > /dev/null 2>&1");
+            return 1 if $status == 0;
+        }
+    });
+}
+
+
+sub getWindowNames {
+    my ($self) = @_;
+    my $res = $self->mustSucceed(
+        q{xwininfo -root -tree | sed 's/.*0x[0-9a-f]* \"\([^\"]*\)\".*/\1/; t; d'});
+    return split /\n/, $res;
+}
+
+
+sub waitForWindow {
+    my ($self, $regexp) = @_;
+    $self->nest("waiting for a window to appear", sub {
+        retry sub {
+            my @names = $self->getWindowNames;
+            foreach my $n (@names) {
+                return 1 if $n =~ /$regexp/;
+            }
+        }
+    });
+}
+
+
+sub copyFileFromHost {
+    my ($self, $from, $to) = @_;
+    my $s = `cat $from` or die;
+    $self->mustSucceed("echo '$s' > $to"); # !!! escaping
+}
+
+
+sub sendKeys {
+    my ($self, @keys) = @_;
+    foreach my $key (@keys) {
+        $key = "spc" if $key eq " ";
+        $key = "ret" if $key eq "\n";
+        $self->sendMonitorCommand("sendkey $key");
+    }
+}
+
+
+sub sendChars {
+    my ($self, $chars) = @_;
+    $self->nest("sending keys ‘$chars’", sub {
+        $self->sendKeys(split //, $chars);
+    });
+}
+
+
+# Sleep N seconds (in virtual guest time, not real time).
+sub sleep {
+    my ($self, $time) = @_;
+    $self->succeed("sleep $time");
+}
+
+
+# Forward a TCP port on the host to a TCP port on the guest.  Useful
+# during interactive testing.
+sub forwardPort {
+    my ($self, $hostPort, $guestPort) = @_;
+    $hostPort = 8080 unless defined $hostPort;
+    $guestPort = 80 unless defined $guestPort;
+    $self->sendMonitorCommand("hostfwd_add tcp::$hostPort-:$guestPort");
+}
+
+
+1;
diff --git a/nixos/lib/test-driver/log2html.xsl b/nixos/lib/test-driver/log2html.xsl
new file mode 100644
index 000000000000..8e907d85ffac
--- /dev/null
+++ b/nixos/lib/test-driver/log2html.xsl
@@ -0,0 +1,135 @@
+<?xml version="1.0"?>
+
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+
+  <xsl:output method='html' encoding="UTF-8"
+              doctype-public="-//W3C//DTD HTML 4.01//EN"
+              doctype-system="http://www.w3.org/TR/html4/strict.dtd" />
+
+  <xsl:template match="logfile">
+    <html>
+      <head>
+        <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
+        <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.10.3/jquery-ui.min.js"></script>
+        <script type="text/javascript" src="treebits.js" />
+        <link rel="stylesheet" href="logfile.css" type="text/css" />
+        <title>Log File</title>
+      </head>
+      <body>
+        <h1>VM build log</h1>
+        <p>
+          <a href="javascript:" class="logTreeExpandAll">Expand all</a> |
+          <a href="javascript:" class="logTreeCollapseAll">Collapse all</a>
+        </p>
+        <ul class='toplevel'>
+          <xsl:for-each select='line|nest'>
+            <li>
+              <xsl:apply-templates select='.'/>
+            </li>
+          </xsl:for-each>
+        </ul>
+
+        <xsl:if test=".//*[@image]">
+          <h1>Screenshots</h1>
+          <ul class="vmScreenshots">
+            <xsl:for-each select='.//*[@image]'>
+              <li><a href="{@image}"><xsl:value-of select="@image" /></a></li>
+            </xsl:for-each>
+          </ul>
+        </xsl:if>
+
+      </body>
+    </html>
+  </xsl:template>
+
+
+  <xsl:template match="nest">
+
+    <!-- The tree should be collapsed by default if all children are
+         unimportant or if the header is unimportant. -->
+    <xsl:variable name="collapsed" select="not(./head[@expanded]) and count(.//*[@error]) = 0"/>
+
+    <xsl:variable name="style"><xsl:if test="$collapsed">display: none;</xsl:if></xsl:variable>
+
+    <xsl:if test="line|nest">
+      <a href="javascript:" class="logTreeToggle">
+        <xsl:choose>
+          <xsl:when test="$collapsed"><xsl:text>+</xsl:text></xsl:when>
+          <xsl:otherwise><xsl:text>-</xsl:text></xsl:otherwise>
+        </xsl:choose>
+      </a>
+      <xsl:text> </xsl:text>
+    </xsl:if>
+
+    <xsl:apply-templates select='head'/>
+
+    <!-- Be careful to only generate <ul>s if there are <li>s, otherwise it’s malformed. -->
+    <xsl:if test="line|nest">
+
+      <ul class='nesting' style="{$style}">
+        <xsl:for-each select='line|nest'>
+
+          <!-- Is this the last line?  If so, mark it as such so that it
+               can be rendered differently. -->
+          <xsl:variable name="class"><xsl:choose><xsl:when test="position() != last()">line</xsl:when><xsl:otherwise>lastline</xsl:otherwise></xsl:choose></xsl:variable>
+
+          <li class='{$class}'>
+            <span class='lineconn' />
+            <span class='linebody'>
+              <xsl:apply-templates select='.'/>
+            </span>
+          </li>
+        </xsl:for-each>
+      </ul>
+    </xsl:if>
+
+  </xsl:template>
+
+
+  <xsl:template match="head|line">
+    <code>
+      <xsl:if test="@error">
+        <xsl:attribute name="class">errorLine</xsl:attribute>
+      </xsl:if>
+      <xsl:if test="@warning">
+        <xsl:attribute name="class">warningLine</xsl:attribute>
+      </xsl:if>
+      <xsl:if test="@priority = 3">
+        <xsl:attribute name="class">prio3</xsl:attribute>
+      </xsl:if>
+
+      <xsl:if test="@type = 'serial'">
+        <xsl:attribute name="class">serial</xsl:attribute>
+      </xsl:if>
+
+      <xsl:if test="@machine">
+        <xsl:choose>
+          <xsl:when test="@type = 'serial'">
+            <span class="machine"><xsl:value-of select="@machine"/># </span>
+          </xsl:when>
+          <xsl:otherwise>
+            <span class="machine"><xsl:value-of select="@machine"/>: </span>
+          </xsl:otherwise>
+        </xsl:choose>
+      </xsl:if>
+
+      <xsl:choose>
+        <xsl:when test="@image">
+          <a href="{@image}"><xsl:apply-templates/></a>
+        </xsl:when>
+        <xsl:otherwise>
+          <xsl:apply-templates/>
+        </xsl:otherwise>
+      </xsl:choose>
+    </code>
+  </xsl:template>
+
+
+  <xsl:template match="storeref">
+    <em class='storeref'>
+      <span class='popup'><xsl:apply-templates/></span>
+      <span class='elided'>/...</span><xsl:apply-templates select='name'/><xsl:apply-templates select='path'/>
+    </em>
+  </xsl:template>
+
+</xsl:stylesheet>
diff --git a/nixos/lib/test-driver/logfile.css b/nixos/lib/test-driver/logfile.css
new file mode 100644
index 000000000000..a54d8504a867
--- /dev/null
+++ b/nixos/lib/test-driver/logfile.css
@@ -0,0 +1,129 @@
+body {
+    font-family: sans-serif;
+    background: white;
+}
+
+h1
+{
+    color: #005aa0;
+    font-size: 180%;
+}
+
+a {
+    text-decoration: none;
+}
+
+
+ul.nesting, ul.toplevel {
+    padding: 0;
+    margin: 0;
+}
+
+ul.toplevel {
+    list-style-type: none;
+}
+
+.line, .head {
+    padding-top: 0em;
+}
+
+ul.nesting li.line, ul.nesting li.lastline {
+    position: relative;
+    list-style-type: none;
+}
+
+ul.nesting li.line {
+    padding-left: 2.0em;
+}
+
+ul.nesting li.lastline {
+    padding-left: 2.1em; /* for the 0.1em border-left in .lastline > .lineconn */
+}
+
+li.line {
+    border-left: 0.1em solid #6185a0;
+}
+
+li.line > span.lineconn, li.lastline > span.lineconn {
+    position: absolute;
+    height: 0.65em;
+    left: 0em;
+    width: 1.5em;
+    border-bottom: 0.1em solid #6185a0;
+}
+
+li.lastline > span.lineconn {
+    border-left: 0.1em solid #6185a0;
+}
+
+
+em.storeref {
+    color: #500000;
+    position: relative; 
+    width: 100%;
+}
+
+em.storeref:hover {
+    background-color: #eeeeee;
+}
+
+*.popup {
+    display: none;
+/*    background: url('http://losser.st-lab.cs.uu.nl/~mbravenb/menuback.png') repeat; */
+    background: #ffffcd;
+    border: solid #555555 1px;
+    position: absolute;
+    top: 0em;
+    left: 0em;
+    margin: 0;
+    padding: 0;
+    z-index: 100;
+}
+
+em.storeref:hover span.popup {
+    display: inline;
+    width: 40em;
+}
+
+
+.logTreeToggle {
+    text-decoration: none;
+    font-family: monospace;
+    font-size: larger;
+}
+
+.errorLine {
+    color: #ff0000;
+    font-weight: bold;
+}
+
+.warningLine {
+    color: darkorange;
+    font-weight: bold;
+}
+
+.prio3 {
+    font-style: italic;
+}
+
+code {
+    white-space: pre-wrap;
+}
+
+.serial {
+    color: #56115c;
+}
+
+.machine {
+    color: #002399;
+    font-style: italic;
+}
+
+ul.vmScreenshots {
+    padding-left: 1em;
+}
+
+ul.vmScreenshots li {
+    font-family: monospace;
+    list-style: square;
+}
diff --git a/nixos/lib/test-driver/test-driver.pl b/nixos/lib/test-driver/test-driver.pl
new file mode 100644
index 000000000000..c6a707cdf6b9
--- /dev/null
+++ b/nixos/lib/test-driver/test-driver.pl
@@ -0,0 +1,178 @@
+#! /somewhere/perl -w
+
+use strict;
+use Machine;
+use Term::ReadLine;
+use IO::File;
+use IO::Pty;
+use Logger;
+use Cwd;
+use POSIX qw(_exit dup2);
+
+$SIG{PIPE} = 'IGNORE'; # because Unix domain sockets may die unexpectedly
+
+STDERR->autoflush(1);
+
+my $log = new Logger;
+
+
+# Start vde_switch for each network required by the test.
+my %vlans;
+foreach my $vlan (split / /, $ENV{VLANS} || "") {
+    next if defined $vlans{$vlan};
+    # Start vde_switch as a child process.  We don't run it in daemon
+    # mode because we want the child process to be cleaned up when we
+    # die.  Since we have to make sure that the control socket is
+    # ready, we send a dummy command to vde_switch (via stdin) and
+    # wait for a reply.  Note that vde_switch requires stdin to be a
+    # TTY, so we create one.
+    $log->log("starting VDE switch for network $vlan");
+    my $socket = Cwd::abs_path "./vde$vlan.ctl";
+    my $pty = new IO::Pty;
+    my ($stdoutR, $stdoutW); pipe $stdoutR, $stdoutW;
+    my $pid = fork(); die "cannot fork" unless defined $pid;
+    if ($pid == 0) {
+        dup2(fileno($pty->slave), 0);
+        dup2(fileno($stdoutW), 1);
+        exec "vde_switch -s $socket" or _exit(1);
+    }
+    close $stdoutW;
+    print $pty "version\n";
+    readline $stdoutR or die "cannot start vde_switch";
+    $ENV{"QEMU_VDE_SOCKET_$vlan"} = $socket;
+    $vlans{$vlan} = $pty;
+    die unless -e "$socket/ctl";
+}
+
+
+my %vms;
+my $context = "";
+
+sub createMachine {
+    my ($args) = @_;
+    my $vm = Machine->new({%{$args}, log => $log, redirectSerial => ($ENV{USE_SERIAL} // "0") ne "1"});
+    $vms{$vm->name} = $vm;
+    return $vm;
+}
+
+foreach my $vmScript (@ARGV) {
+    my $vm = createMachine({startCommand => $vmScript});
+    $context .= "my \$" . $vm->name . " = \$vms{'" . $vm->name . "'}; ";
+}
+
+
+sub startAll {
+    $log->nest("starting all VMs", sub {
+        $_->start foreach values %vms;
+    });
+}
+
+
+# Wait until all VMs have terminated.
+sub joinAll {
+    $log->nest("waiting for all VMs to finish", sub {
+        $_->waitForShutdown foreach values %vms;
+    });
+}
+
+
+# In interactive tests, this allows the non-interactive test script to
+# be executed conveniently.
+sub testScript {
+    eval "$context $ENV{testScript};\n";
+    warn $@ if $@;
+}
+
+
+my $nrTests = 0;
+my $nrSucceeded = 0;
+
+
+sub subtest {
+    my ($name, $coderef) = @_;
+    $log->nest("subtest: $name", sub {
+        $nrTests++;
+        eval { &$coderef };
+        if ($@) {
+            $log->log("error: $@", { error => 1 });
+        } else {
+            $nrSucceeded++;
+        }
+    });
+}
+
+
+sub runTests {
+    if (defined $ENV{tests}) {
+        $log->nest("running the VM test script", sub {
+            eval "$context $ENV{tests}";
+            if ($@) {
+                $log->log("error: $@", { error => 1 });
+                die $@;
+            }
+        }, { expanded => 1 });
+    } else {
+        my $term = Term::ReadLine->new('nixos-vm-test');
+        $term->ReadHistory;
+        while (defined ($_ = $term->readline("> "))) {
+            eval "$context $_\n";
+            warn $@ if $@;
+        }
+        $term->WriteHistory;
+    }
+
+    # Copy the kernel coverage data for each machine, if the kernel
+    # has been compiled with coverage instrumentation.
+    $log->nest("collecting coverage data", sub {
+        foreach my $vm (values %vms) {
+            my $gcovDir = "/sys/kernel/debug/gcov";
+
+            next unless $vm->isUp();
+
+            my ($status, $out) = $vm->execute("test -e $gcovDir");
+            next if $status != 0;
+
+            # Figure out where to put the *.gcda files so that the
+            # report generator can find the corresponding kernel
+            # sources.
+            my $kernelDir = $vm->mustSucceed("echo \$(dirname \$(readlink -f /run/current-system/kernel))/.build/linux-*");
+            chomp $kernelDir;
+            my $coverageDir = "/tmp/xchg/coverage-data/$kernelDir";
+
+            # Copy all the *.gcda files.
+            $vm->execute("for d in $gcovDir/nix/store/*/.build/linux-*; do for i in \$(cd \$d && find -name '*.gcda'); do echo \$i; mkdir -p $coverageDir/\$(dirname \$i); cp -v \$d/\$i $coverageDir/\$i; done; done");
+        }
+    });
+
+    if ($nrTests != 0) {
+        $log->log("$nrSucceeded out of $nrTests tests succeeded",
+            ($nrSucceeded < $nrTests ? { error => 1 } : { }));
+    }
+}
+
+
+# Create an empty raw virtual disk with the given name and size (in
+# MiB).
+sub createDisk {
+    my ($name, $size) = @_;
+    system("qemu-img create -f raw $name ${size}M") == 0
+        or die "cannot create image of size $size";
+}
+
+
+END {
+    $log->nest("cleaning up", sub {
+        foreach my $vm (values %vms) {
+            if ($vm->{pid}) {
+                $log->log("killing " . $vm->{name} . " (pid " . $vm->{pid} . ")");
+                kill 9, $vm->{pid};
+            }
+        }
+    });
+    $log->close();
+}
+
+
+runTests;
+
+exit ($nrSucceeded < $nrTests ? 1 : 0);
diff --git a/nixos/lib/test-driver/treebits.js b/nixos/lib/test-driver/treebits.js
new file mode 100644
index 000000000000..9754093dfd07
--- /dev/null
+++ b/nixos/lib/test-driver/treebits.js
@@ -0,0 +1,30 @@
+$(document).ready(function() {
+
+    /* When a toggle is clicked, show or hide the subtree. */
+    $(".logTreeToggle").click(function() {
+        if ($(this).siblings("ul:hidden").length != 0) {
+            $(this).siblings("ul").show();
+            $(this).text("-");
+        } else {
+            $(this).siblings("ul").hide();
+            $(this).text("+");
+        }
+    });
+
+    /* Implementation of the expand all link. */
+    $(".logTreeExpandAll").click(function() {
+        $(".logTreeToggle", $(this).parent().siblings(".toplevel")).map(function() {
+            $(this).siblings("ul").show();
+            $(this).text("-");
+        });
+    });
+
+    /* Implementation of the collapse all link. */
+    $(".logTreeCollapseAll").click(function() {
+        $(".logTreeToggle", $(this).parent().siblings(".toplevel")).map(function() {
+            $(this).siblings("ul").hide();
+            $(this).text("+");
+        });
+    });
+
+});
diff --git a/nixos/lib/testing.nix b/nixos/lib/testing.nix
new file mode 100644
index 000000000000..7be0903ed3a8
--- /dev/null
+++ b/nixos/lib/testing.nix
@@ -0,0 +1,244 @@
+{ system, minimal ? false }:
+
+with import ./build-vms.nix { inherit system minimal; };
+with pkgs;
+
+rec {
+
+  inherit pkgs;
+
+
+  testDriver = stdenv.mkDerivation {
+    name = "nixos-test-driver";
+
+    buildInputs = [ makeWrapper perl ];
+
+    unpackPhase = "true";
+
+    installPhase =
+      ''
+        mkdir -p $out/bin
+        cp ${./test-driver/test-driver.pl} $out/bin/nixos-test-driver
+        chmod u+x $out/bin/nixos-test-driver
+
+        libDir=$out/lib/perl5/site_perl
+        mkdir -p $libDir
+        cp ${./test-driver/Machine.pm} $libDir/Machine.pm
+        cp ${./test-driver/Logger.pm} $libDir/Logger.pm
+
+        wrapProgram $out/bin/nixos-test-driver \
+          --prefix PATH : "${pkgs.qemu_kvm}/bin:${pkgs.vde2}/bin:${imagemagick}/bin:${coreutils}/bin" \
+          --prefix PERL5LIB : "${lib.makePerlPath [ perlPackages.TermReadLineGnu perlPackages.XMLWriter perlPackages.IOTty ]}:$out/lib/perl5/site_perl"
+      '';
+  };
+
+
+  # Run an automated test suite in the given virtual network.
+  # `driver' is the script that runs the network.
+  runTests = driver:
+    stdenv.mkDerivation {
+      name = "vm-test-run";
+
+      requiredSystemFeatures = [ "kvm" "nixos-test" ];
+
+      buildInputs = [ pkgs.libxslt ];
+
+      buildCommand =
+        ''
+          mkdir -p $out/nix-support
+
+          LOGFILE=$out/log.xml tests='eval $ENV{testScript}; die $@ if $@;' ${driver}/bin/nixos-test-driver || failed=1
+
+          # Generate a pretty-printed log.
+          xsltproc --output $out/log.html ${./test-driver/log2html.xsl} $out/log.xml
+          ln -s ${./test-driver/logfile.css} $out/logfile.css
+          ln -s ${./test-driver/treebits.js} $out/treebits.js
+
+          touch $out/nix-support/hydra-build-products
+          echo "report testlog $out log.html" >> $out/nix-support/hydra-build-products
+
+          for i in */xchg/coverage-data; do
+            mkdir -p $out/coverage-data
+            mv $i $out/coverage-data/$(dirname $(dirname $i))
+          done
+
+          [ -z "$failed" ] || touch $out/nix-support/failed
+        ''; # */
+    };
+
+
+  # Generate a coverage report from the coverage data produced by
+  # runTests.
+  makeReport = x: runCommand "report" { buildInputs = [rsync]; }
+    ''
+      mkdir -p $TMPDIR/gcov/
+
+      for d in ${x}/coverage-data/*; do
+          echo "doing $d"
+          [ -n "$(ls -A "$d")" ] || continue
+
+          for i in $(cd $d/nix/store && ls); do
+              if ! test -e $TMPDIR/gcov/nix/store/$i; then
+                  echo "copying $i"
+                  mkdir -p $TMPDIR/gcov/$(echo $i | cut -c34-)
+                  rsync -rv /nix/store/$i/.build/* $TMPDIR/gcov/
+              fi
+          done
+
+          chmod -R u+w $TMPDIR/gcov
+
+          find $TMPDIR/gcov -name "*.gcda" -exec rm {} \;
+
+          for i in $(cd $d/nix/store && ls); do
+              rsync -rv $d/nix/store/$i/.build/* $TMPDIR/gcov/
+          done
+
+          find $TMPDIR/gcov -name "*.gcda" -exec chmod 644 {} \;
+
+          echo "producing info..."
+          ${pkgs.lcov}/bin/geninfo --ignore-errors source,gcov $TMPDIR/gcov --output-file $TMPDIR/app.info
+          cat $TMPDIR/app.info >> $TMPDIR/full.info
+      done
+
+      echo "making report..."
+      mkdir -p $out/coverage
+      ${pkgs.lcov}/bin/genhtml --show-details $TMPDIR/full.info -o $out/coverage
+      cp $TMPDIR/full.info $out/coverage/
+
+      mkdir -p $out/nix-support
+      cat ${x}/nix-support/hydra-build-products >> $out/nix-support/hydra-build-products
+      echo "report coverage $out/coverage" >> $out/nix-support/hydra-build-products
+      [ ! -e ${x}/nix-support/failed ] || touch $out/nix-support/failed
+    ''; # */
+
+
+  makeTest = testFun: complete (call testFun);
+  makeTests = testsFun: lib.mapAttrs (name: complete) (call testsFun);
+
+  apply = makeTest; # compatibility
+  call = f: f { inherit pkgs system; };
+
+  complete = t: t // rec {
+    nodes = buildVirtualNetwork (
+      if t ? nodes then t.nodes else
+      if t ? machine then { machine = t.machine; }
+      else { } );
+
+    testScript =
+      # Call the test script with the computed nodes.
+      if builtins.isFunction t.testScript
+      then t.testScript { inherit nodes; }
+      else t.testScript;
+
+    vlans = map (m: m.config.virtualisation.vlans) (lib.attrValues nodes);
+
+    vms = map (m: m.config.system.build.vm) (lib.attrValues nodes);
+
+    # Generate onvenience wrappers for running the test driver
+    # interactively with the specified network, and for starting the
+    # VMs from the command line.
+    driver = runCommand "nixos-test-driver"
+      { buildInputs = [ makeWrapper];
+        inherit testScript;
+        preferLocalBuild = true;
+      }
+      ''
+        mkdir -p $out/bin
+        echo "$testScript" > $out/test-script
+        ln -s ${testDriver}/bin/nixos-test-driver $out/bin/
+        vms="$(for i in ${toString vms}; do echo $i/bin/run-*-vm; done)"
+        wrapProgram $out/bin/nixos-test-driver \
+          --add-flags "$vms" \
+          --run "testScript=\"\$(cat $out/test-script)\"" \
+          --set testScript '"$testScript"' \
+          --set VLANS '"${toString vlans}"'
+        ln -s ${testDriver}/bin/nixos-test-driver $out/bin/nixos-run-vms
+        wrapProgram $out/bin/nixos-run-vms \
+          --add-flags "$vms" \
+          --set tests '"startAll; joinAll;"' \
+          --set VLANS '"${toString vlans}"' \
+          ${lib.optionalString (builtins.length vms == 1) "--set USE_SERIAL 1"}
+      ''; # "
+
+    test = runTests driver;
+
+    report = makeReport test;
+  };
+
+
+  runInMachine =
+    { drv
+    , machine
+    , preBuild ? ""
+    , postBuild ? ""
+    , ... # ???
+    }:
+    let
+      vm = buildVM { }
+        [ machine
+          { key = "hostname"; networking.hostName = "client"; }
+        ];
+
+      buildrunner = writeText "vm-build" ''
+        source $1
+
+        ${coreutils}/bin/mkdir -p $TMPDIR
+        cd $TMPDIR
+
+        $origBuilder $origArgs
+
+        exit $?
+      '';
+
+      testscript = ''
+        startAll;
+        ${preBuild}
+        $client->succeed("env -i ${pkgs.bash}/bin/bash ${buildrunner} /tmp/xchg/saved-env >&2");
+        ${postBuild}
+      '';
+
+      vmRunCommand = writeText "vm-run" ''
+        ${coreutils}/bin/mkdir $out
+        ${coreutils}/bin/mkdir -p vm-state-client/xchg
+        export > vm-state-client/xchg/saved-env
+        export tests='${testscript}'
+        ${testDriver}/bin/nixos-test-driver ${vm.config.system.build.vm}/bin/run-*-vm
+      ''; # */
+
+    in
+      lib.overrideDerivation drv (attrs: {
+        requiredSystemFeatures = [ "kvm" ];
+        builder = "${bash}/bin/sh";
+        args = ["-e" vmRunCommand];
+        origArgs = attrs.args;
+        origBuilder = attrs.builder;
+      });
+
+
+  runInMachineWithX = { require ? [], ... } @ args:
+    let
+      client =
+        { config, pkgs, ... }:
+        {
+          inherit require;
+          virtualisation.memorySize = 1024;
+          services.xserver.enable = true;
+          services.xserver.displayManager.slim.enable = false;
+          services.xserver.displayManager.auto.enable = true;
+          services.xserver.windowManager.default = "icewm";
+          services.xserver.windowManager.icewm.enable = true;
+          services.xserver.desktopManager.default = "none";
+        };
+    in
+      runInMachine ({
+        machine = client;
+        preBuild =
+          ''
+            $client->waitForX;
+          '';
+      } // args);
+
+
+  simpleTest = as: (makeTest ({ ... }: as)).test;
+
+}
diff --git a/nixos/lib/utils.nix b/nixos/lib/utils.nix
new file mode 100644
index 000000000000..35c56e8c32bb
--- /dev/null
+++ b/nixos/lib/utils.nix
@@ -0,0 +1,10 @@
+pkgs: with pkgs.lib;
+
+rec {
+
+  # Escape a path according to the systemd rules, e.g. /dev/xyzzy
+  # becomes dev-xyzzy.  FIXME: slow.
+  escapeSystemdPath = s:
+   replaceChars ["/" "-" " "] ["-" "\\x2d" "\\x20"] (substring 1 (stringLength s) s);
+
+}