nixpkgs/nixos/modules/profiles/nix-builder-vm.nix
Winter 747c55e702 nixos/profiles/nix-builder-vm: allow the system derivation to be substituted
Consider a user wanting to set up the Linux builder for the first time,
but with a slightly more generous allocation of resources compared to
the default. They'll do something like this:

```
{
  virtualisation.cores = 3;
  virtualisation.darwin-builder = {
    diskSize = 40 * 1024;
    memorySize = 4 * 1024;
  };
}
```

This will fail with an error like this:

```
error: a 'aarch64-linux' with features {} is required to build '/nix/store/3acpgmwqwnk8g2gc5r05ar2lvmn01b8a-builder.pl.drv', but I am a 'aarch64-darwin' with features {apple-virt, benchmark, big-parallel, nixos-test}
```

But why would they have to rebuild the NixOS system?! All they did was
change the arguments passed to QEMU, and nothing those options control
would affect the NixOS configuration itself... right?

`config.system.build.toplevel` is defined with `allowSubstitutes` set to
`false` by default, which makes it so that the toplevel can't be
substituted if Nix is trying to use it "directly." So because the above
example would have to rebuild the VM runner, which references toplevel
directly, Nix refuses to substitute it, unless `always-allow-substitutes
= true` is set as a Nix option. (In the case where the QEMU options
aren't changed at all, Nix just substitutes the runner, which sidesteps
this issue as the runner itself doesn't use toplevel as an input.)
2025-05-16 13:00:15 -04:00

310 lines
9.2 KiB
Nix

/*
This profile uses NixOS to create a remote builder VM to build Linux packages,
which can be used to build packages for Linux on other operating systems;
primarily macOS.
It contains both the relevant guest settings as well as an installer script
that manages it as a QEMU virtual machine on the host.
*/
{
config,
lib,
options,
...
}:
let
keysDirectory = "/var/keys";
user = "builder";
keyType = "ed25519";
cfg = config.virtualisation.darwin-builder;
in
{
imports = [
../virtualisation/qemu-vm.nix
# Avoid a dependency on stateVersion
{
disabledModules = [
../virtualisation/nixos-containers.nix
../services/x11/desktop-managers/xterm.nix
];
# swraid's default depends on stateVersion
config.boot.swraid.enable = false;
options.boot.isContainer = lib.mkOption {
default = false;
internal = true;
};
}
];
options.virtualisation.darwin-builder = with lib; {
diskSize = mkOption {
default = 20 * 1024;
type = types.int;
example = 30720;
description = "The maximum disk space allocated to the runner in MB";
};
memorySize = mkOption {
default = 3 * 1024;
type = types.int;
example = 8192;
description = "The runner's memory in MB";
};
min-free = mkOption {
default = 1024 * 1024 * 1024;
type = types.int;
example = 1073741824;
description = ''
The threshold (in bytes) of free disk space left at which to
start garbage collection on the runner
'';
};
max-free = mkOption {
default = 3 * 1024 * 1024 * 1024;
type = types.int;
example = 3221225472;
description = ''
The threshold (in bytes) of free disk space left at which to
stop garbage collection on the runner
'';
};
workingDirectory = mkOption {
default = ".";
type = types.str;
example = "/var/lib/darwin-builder";
description = ''
The working directory to use to run the script. When running
as part of a flake will need to be set to a non read-only filesystem.
'';
};
hostPort = mkOption {
default = 31022;
type = types.int;
example = 22;
description = ''
The localhost host port to forward TCP to the guest port.
'';
};
};
config = {
# The builder is not intended to be used interactively
documentation.enable = false;
environment.etc = {
"ssh/ssh_host_ed25519_key" = {
mode = "0600";
source = ./keys/ssh_host_ed25519_key;
};
"ssh/ssh_host_ed25519_key.pub" = {
mode = "0644";
source = ./keys/ssh_host_ed25519_key.pub;
};
};
# DNS fails for QEMU user networking (SLiRP) on macOS. See:
#
# https://github.com/utmapp/UTM/issues/2353
#
# This works around that by using a public DNS server other than the DNS
# server that QEMU provides (normally 10.0.2.3)
networking.nameservers = [ "8.8.8.8" ];
# The linux builder is a lightweight VM for remote building; not evaluation.
nix.channel.enable = false;
# Deployment is by image.
# TODO system.switch.enable = false;?
system.disableInstallerTools = true;
# Allow the system derivation to be substituted, so that
# users are less likely to run into a state where they need
# the builder running to build the builder if they just want
# to make a tweak that only affects the macOS side of things,
# like changing the QEMU args.
#
# TODO(winter): Move to qemu-vm? Trying it here for now as a
# low impact change that'll probably improve people's experience.
#
# (I have no clue what is going on in https://github.com/nix-darwin/nix-darwin/issues/1081
# though, as this fix would only apply to one person in that thread... hopefully someone
# comes across with a reproducer if this doesn't do it.)
system.systemBuilderArgs.allowSubstitutes = true;
nix.settings = {
min-free = cfg.min-free;
max-free = cfg.max-free;
trusted-users = [ user ];
};
services = {
getty.autologinUser = user;
openssh = {
enable = true;
authorizedKeysFiles = [ "${keysDirectory}/%u_${keyType}.pub" ];
};
};
system.build.macos-builder-installer =
let
privateKey = "/etc/nix/${user}_${keyType}";
publicKey = "${privateKey}.pub";
# This installCredentials script is written so that it's as easy as
# possible for a user to audit before confirming the `sudo`
installCredentials = hostPkgs.writeShellScript "install-credentials" ''
set -euo pipefail
KEYS="''${1}"
INSTALL=${hostPkgs.coreutils}/bin/install
"''${INSTALL}" -g nixbld -m 600 "''${KEYS}/${user}_${keyType}" ${privateKey}
"''${INSTALL}" -g nixbld -m 644 "''${KEYS}/${user}_${keyType}.pub" ${publicKey}
'';
hostPkgs = config.virtualisation.host.pkgs;
add-keys = hostPkgs.writeShellScriptBin "add-keys" (
''
set -euo pipefail
''
+
# When running as non-interactively as part of a DarwinConfiguration the working directory
# must be set to a writeable directory.
(
if cfg.workingDirectory != "." then
''
${hostPkgs.coreutils}/bin/mkdir --parent "${cfg.workingDirectory}"
cd "${cfg.workingDirectory}"
''
else
""
)
+ ''
KEYS="''${KEYS:-./keys}"
${hostPkgs.coreutils}/bin/mkdir --parent "''${KEYS}"
PRIVATE_KEY="''${KEYS}/${user}_${keyType}"
PUBLIC_KEY="''${PRIVATE_KEY}.pub"
if [ ! -e "''${PRIVATE_KEY}" ] || [ ! -e "''${PUBLIC_KEY}" ]; then
${hostPkgs.coreutils}/bin/rm --force -- "''${PRIVATE_KEY}" "''${PUBLIC_KEY}"
${hostPkgs.openssh}/bin/ssh-keygen -q -f "''${PRIVATE_KEY}" -t ${keyType} -N "" -C 'builder@localhost'
fi
if ! ${hostPkgs.diffutils}/bin/cmp "''${PUBLIC_KEY}" ${publicKey}; then
(set -x; sudo --reset-timestamp ${installCredentials} "''${KEYS}")
fi
''
);
run-builder = hostPkgs.writeShellScriptBin "run-builder" (''
set -euo pipefail
KEYS="''${KEYS:-./keys}"
KEYS="$(${hostPkgs.nix}/bin/nix-store --add "$KEYS")" ${lib.getExe config.system.build.vm}
'');
script = hostPkgs.writeShellScriptBin "create-builder" (''
set -euo pipefail
export KEYS="''${KEYS:-./keys}"
${lib.getExe add-keys}
${lib.getExe run-builder}
'');
in
script.overrideAttrs (old: {
pos = __curPos; # sets meta.position to point here; see script binding above for package definition
meta = (old.meta or { }) // {
platforms = lib.platforms.darwin;
};
passthru = (old.passthru or { }) // {
# Let users in the repl inspect the config
nixosConfig = config;
nixosOptions = options;
inherit add-keys run-builder;
};
});
system = {
# To prevent gratuitous rebuilds on each change to Nixpkgs
nixos.revision = null;
# to be updated by module maintainers, see nixpkgs#325610
stateVersion = "24.05";
};
users.users."${user}" = {
isNormalUser = true;
};
security.polkit.enable = true;
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if (action.id === "org.freedesktop.login1.power-off" && subject.user === "${user}") {
return "yes";
} else {
return "no";
}
})
'';
virtualisation = {
diskSize = cfg.diskSize;
memorySize = cfg.memorySize;
forwardPorts = [
{
from = "host";
guest.port = 22;
host.port = cfg.hostPort;
}
];
# Disable graphics for the builder since users will likely want to run it
# non-interactively in the background.
graphics = false;
sharedDirectories.keys = {
source = "\"$KEYS\"";
target = keysDirectory;
};
# If we don't enable this option then the host will fail to delegate builds
# to the guest, because:
#
# - The host will lock the path to build
# - The host will delegate the build to the guest
# - The guest will attempt to lock the same path and fail because
# the lockfile on the host is visible on the guest
#
# Snapshotting the host's /nix/store as an image isolates the guest VM's
# /nix/store from the host's /nix/store, preventing this problem.
useNixStoreImage = true;
# Obviously the /nix/store needs to be writable on the guest in order for it
# to perform builds.
writableStore = true;
# This ensures that anything built on the guest isn't lost when the guest is
# restarted.
writableStoreUseTmpfs = false;
# Pass certificates from host to the guest otherwise when custom CA certificates
# are required we can't use the cached builder.
useHostCerts = true;
};
};
}