Move all of NixOS to nixos/ in preparation of the repository merge

This commit is contained in:
Eelco Dolstra
2013-10-10 13:28:20 +02:00
parent 6070bc016b
commit 5c1f8cbc70
481 changed files with 0 additions and 0 deletions
+87
View File
@@ -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_;
}
+6
View File
@@ -0,0 +1,6 @@
{ system ? builtins.currentSystem }:
{ pkgs =
(import nixpkgs/default.nix { inherit system; })
// { recurseForDerivations = true; };
}
+73
View File
@@ -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;
}
+4
View File
@@ -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
+60
View File
@@ -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;
}
+91
View File
@@ -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
+30
View File
@@ -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
'';
}
+38
View File
@@ -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;
}
+58
View File
@@ -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
+10
View File
@@ -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}"
];
}
+70
View File
@@ -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;
+568
View File
@@ -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;
+135
View File
@@ -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 its 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>
+129
View File
@@ -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;
}
+178
View File
@@ -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);
+30
View File
@@ -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("+");
});
});
});
+244
View File
@@ -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;
}
+10
View File
@@ -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);
}