about summary refs log tree commit diff
path: root/nixos/modules
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules')
-rw-r--r--nixos/modules/config/users-groups.nix10
-rw-r--r--nixos/modules/module-list.nix1
-rw-r--r--nixos/modules/profiles/perlless.nix31
-rw-r--r--nixos/modules/services/system/dbus.nix1
-rw-r--r--nixos/modules/system/boot/systemd/sysusers.nix169
-rw-r--r--nixos/modules/system/etc/build-composefs-dump.py209
-rwxr-xr-xnixos/modules/system/etc/check-build-composefs-dump.sh8
-rw-r--r--nixos/modules/system/etc/etc-activation.nix98
-rw-r--r--nixos/modules/system/etc/etc.nix116
-rw-r--r--nixos/modules/testing/test-instrumentation.nix5
10 files changed, 631 insertions, 17 deletions
diff --git a/nixos/modules/config/users-groups.nix b/nixos/modules/config/users-groups.nix
index 2aed620eb154..967ad0846d75 100644
--- a/nixos/modules/config/users-groups.nix
+++ b/nixos/modules/config/users-groups.nix
@@ -685,7 +685,7 @@ in {
       shadow.gid = ids.gids.shadow;
     };
 
-    system.activationScripts.users = {
+    system.activationScripts.users = if !config.systemd.sysusers.enable then {
       supportsDryActivation = true;
       text = ''
         install -m 0700 -d /root
@@ -694,7 +694,7 @@ in {
         ${pkgs.perl.withPackages (p: [ p.FileSlurp p.JSON ])}/bin/perl \
         -w ${./update-users-groups.pl} ${spec}
       '';
-    };
+    } else ""; # keep around for backwards compatibility
 
     system.activationScripts.update-lingering = let
       lingerDir = "/var/lib/systemd/linger";
@@ -711,7 +711,9 @@ in {
     '';
 
     # Warn about user accounts with deprecated password hashing schemes
-    system.activationScripts.hashes = {
+    # This does not work when the users and groups are created by
+    # systemd-sysusers because the users are created too late then.
+    system.activationScripts.hashes = if !config.systemd.sysusers.enable then {
       deps = [ "users" ];
       text = ''
         users=()
@@ -729,7 +731,7 @@ in {
           printf ' - %s\n' "''${users[@]}"
         fi
       '';
-    };
+    } else ""; # keep around for backwards compatibility
 
     # for backwards compatibility
     system.activationScripts.groups = stringAfter [ "users" ] "";
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 00e6240f531d..b31154b9624e 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1488,6 +1488,7 @@
   ./system/boot/systemd/repart.nix
   ./system/boot/systemd/shutdown.nix
   ./system/boot/systemd/sysupdate.nix
+  ./system/boot/systemd/sysusers.nix
   ./system/boot/systemd/tmpfiles.nix
   ./system/boot/systemd/user.nix
   ./system/boot/systemd/userdbd.nix
diff --git a/nixos/modules/profiles/perlless.nix b/nixos/modules/profiles/perlless.nix
new file mode 100644
index 000000000000..90abd14f077e
--- /dev/null
+++ b/nixos/modules/profiles/perlless.nix
@@ -0,0 +1,31 @@
+# WARNING: If you enable this profile, you will NOT be able to switch to a new
+# configuration and thus you will not be able to rebuild your system with
+# nixos-rebuild!
+
+{ lib, ... }:
+
+{
+
+  # Disable switching to a new configuration. This is not a necessary
+  # limitation of a perlless system but just a current one. In the future,
+  # perlless switching might be possible.
+  system.switch.enable = lib.mkDefault false;
+
+  # Remove perl from activation
+  boot.initrd.systemd.enable = lib.mkDefault true;
+  system.etc.overlay.enable = lib.mkDefault true;
+  systemd.sysusers.enable = lib.mkDefault true;
+
+  # Random perl remnants
+  system.disableInstallerTools = lib.mkDefault true;
+  programs.less.lessopen = lib.mkDefault null;
+  programs.command-not-found.enable = lib.mkDefault false;
+  boot.enableContainers = lib.mkDefault false;
+  environment.defaultPackages = lib.mkDefault [ ];
+  documentation.info.enable = lib.mkDefault false;
+
+  # Check that the system does not contain a Nix store path that contains the
+  # string "perl".
+  system.forbiddenDependenciesRegex = "perl";
+
+}
diff --git a/nixos/modules/services/system/dbus.nix b/nixos/modules/services/system/dbus.nix
index b47ebc92f93a..e8f8b48d0337 100644
--- a/nixos/modules/services/system/dbus.nix
+++ b/nixos/modules/services/system/dbus.nix
@@ -95,6 +95,7 @@ in
         uid = config.ids.uids.messagebus;
         description = "D-Bus system message bus daemon user";
         home = homeDir;
+        homeMode = "0755";
         group = "messagebus";
       };
 
diff --git a/nixos/modules/system/boot/systemd/sysusers.nix b/nixos/modules/system/boot/systemd/sysusers.nix
new file mode 100644
index 000000000000..c619c2d91eb0
--- /dev/null
+++ b/nixos/modules/system/boot/systemd/sysusers.nix
@@ -0,0 +1,169 @@
+{ config, lib, pkgs, utils, ... }:
+
+let
+
+  cfg = config.systemd.sysusers;
+  userCfg = config.users;
+
+  sysusersConfig = pkgs.writeTextDir "00-nixos.conf" ''
+    # Type Name ID GECOS Home directory Shell
+
+    # Users
+    ${lib.concatLines (lib.mapAttrsToList
+      (username: opts:
+        let
+          uid = if opts.uid == null then "-" else toString opts.uid;
+        in
+          ''u ${username} ${uid}:${opts.group} "${opts.description}" ${opts.home} ${utils.toShellPath opts.shell}''
+      )
+      userCfg.users)
+    }
+
+    # Groups
+    ${lib.concatLines (lib.mapAttrsToList
+      (groupname: opts: ''g ${groupname} ${if opts.gid == null then "-" else toString opts.gid}'') userCfg.groups)
+    }
+
+    # Group membership
+    ${lib.concatStrings (lib.mapAttrsToList
+      (groupname: opts: (lib.concatMapStrings (username: "m ${username} ${groupname}\n")) opts.members ) userCfg.groups)
+    }
+  '';
+
+  staticSysusersCredentials = pkgs.runCommand "static-sysusers-credentials" { } ''
+    mkdir $out; cd $out
+    ${lib.concatLines (
+      (lib.mapAttrsToList
+        (username: opts: "echo -n '${opts.initialHashedPassword}' > 'passwd.hashed-password.${username}'")
+        (lib.filterAttrs (_username: opts: opts.initialHashedPassword != null) userCfg.users))
+        ++
+      (lib.mapAttrsToList
+        (username: opts: "echo -n '${opts.initialPassword}' > 'passwd.plaintext-password.${username}'")
+        (lib.filterAttrs (_username: opts: opts.initialPassword != null) userCfg.users))
+        ++
+      (lib.mapAttrsToList
+        (username: opts: "cat '${opts.hashedPasswordFile}' > 'passwd.hashed-password.${username}'")
+        (lib.filterAttrs (_username: opts: opts.hashedPasswordFile != null) userCfg.users))
+      )
+    }
+  '';
+
+  staticSysusers = pkgs.runCommand "static-sysusers"
+    {
+      nativeBuildInputs = [ pkgs.systemd ];
+    } ''
+    mkdir $out
+    export CREDENTIALS_DIRECTORY=${staticSysusersCredentials}
+    systemd-sysusers --root $out ${sysusersConfig}/00-nixos.conf
+  '';
+
+in
+
+{
+
+  options = {
+
+    # This module doesn't set it's own user options but reuses the ones from
+    # users-groups.nix
+
+    systemd.sysusers = {
+      enable = lib.mkEnableOption (lib.mdDoc "systemd-sysusers") // {
+        description = lib.mdDoc ''
+          If enabled, users are created with systemd-sysusers instead of with
+          the custom `update-users-groups.pl` script.
+
+          Note: This is experimental.
+        '';
+      };
+    };
+
+  };
+
+  config = lib.mkIf cfg.enable {
+
+    assertions = [
+      {
+        assertion = config.system.activationScripts.users == "";
+        message = "system.activationScripts.users has to be empty to use systemd-sysusers";
+      }
+      {
+        assertion = config.users.mutableUsers -> config.system.etc.overlay.enable;
+        message = "config.users.mutableUsers requires config.system.etc.overlay.enable.";
+      }
+    ];
+
+    systemd = lib.mkMerge [
+      ({
+
+        # Create home directories, do not create /var/empty even if that's a user's
+        # home.
+        tmpfiles.settings.home-directories = lib.mapAttrs'
+          (username: opts: lib.nameValuePair opts.home {
+            d = {
+              mode = opts.homeMode;
+              user = username;
+              group = opts.group;
+            };
+          })
+          (lib.filterAttrs (_username: opts: opts.home != "/var/empty") userCfg.users);
+      })
+
+      (lib.mkIf config.users.mutableUsers {
+        additionalUpstreamSystemUnits = [
+          "systemd-sysusers.service"
+        ];
+
+        services.systemd-sysusers = {
+          # Enable switch-to-configuration to restart the service.
+          unitConfig.ConditionNeedsUpdate = [ "" ];
+          requiredBy = [ "sysinit-reactivation.target" ];
+          before = [ "sysinit-reactivation.target" ];
+          restartTriggers = [ "${config.environment.etc."sysusers.d".source}" ];
+
+          serviceConfig = {
+            LoadCredential = lib.mapAttrsToList
+              (username: opts: "passwd.hashed-password.${username}:${opts.hashedPasswordFile}")
+              (lib.filterAttrs (_username: opts: opts.hashedPasswordFile != null) userCfg.users);
+            SetCredential = (lib.mapAttrsToList
+              (username: opts: "passwd.hashed-password.${username}:${opts.initialHashedPassword}")
+              (lib.filterAttrs (_username: opts: opts.initialHashedPassword != null) userCfg.users))
+            ++
+            (lib.mapAttrsToList
+              (username: opts: "passwd.plaintext-password.${username}:${opts.initialPassword}")
+              (lib.filterAttrs (_username: opts: opts.initialPassword != null) userCfg.users))
+            ;
+          };
+        };
+      })
+    ];
+
+    environment.etc = lib.mkMerge [
+      (lib.mkIf (!userCfg.mutableUsers) {
+        "passwd" = {
+          source = "${staticSysusers}/etc/passwd";
+          mode = "0644";
+        };
+        "group" = {
+          source = "${staticSysusers}/etc/group";
+          mode = "0644";
+        };
+        "shadow" = {
+          source = "${staticSysusers}/etc/shadow";
+          mode = "0000";
+        };
+        "gshadow" = {
+          source = "${staticSysusers}/etc/gshadow";
+          mode = "0000";
+        };
+      })
+
+      (lib.mkIf userCfg.mutableUsers {
+        "sysusers.d".source = sysusersConfig;
+      })
+    ];
+
+  };
+
+  meta.maintainers = with lib.maintainers; [ nikstur ];
+
+}
diff --git a/nixos/modules/system/etc/build-composefs-dump.py b/nixos/modules/system/etc/build-composefs-dump.py
new file mode 100644
index 000000000000..923d40008b63
--- /dev/null
+++ b/nixos/modules/system/etc/build-composefs-dump.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+
+"""Build a composefs dump from a Json config
+
+See the man page of composefs-dump for details about the format:
+https://github.com/containers/composefs/blob/main/man/composefs-dump.md
+
+Ensure to check the file with the check script when you make changes to it:
+
+./check-build-composefs-dump.sh ./build-composefs_dump.py
+"""
+
+import glob
+import json
+import os
+import sys
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+Attrs = dict[str, Any]
+
+
+class FileType(Enum):
+    """The filetype as defined by the `st_mode` stat field in octal
+
+    You can check the st_mode stat field of a path in Python with
+    `oct(os.stat("/path/").st_mode)`
+    """
+
+    directory = "4"
+    file = "10"
+    symlink = "12"
+
+
+class ComposefsPath:
+    path: str
+    size: int
+    filetype: FileType
+    mode: str
+    uid: str
+    gid: str
+    payload: str
+    rdev: str = "0"
+    nlink: int = 1
+    mtime: str = "1.0"
+    content: str = "-"
+    digest: str = "-"
+
+    def __init__(
+        self,
+        attrs: Attrs,
+        size: int,
+        filetype: FileType,
+        mode: str,
+        payload: str,
+        path: str | None = None,
+    ):
+        if path is None:
+            path = attrs["target"]
+        self.path = "/" + path
+        self.size = size
+        self.filetype = filetype
+        self.mode = mode
+        self.uid = attrs["uid"]
+        self.gid = attrs["gid"]
+        self.payload = payload
+
+    def write_line(self) -> str:
+        line_list = [
+            str(self.path),
+            str(self.size),
+            f"{self.filetype.value}{self.mode}",
+            str(self.nlink),
+            str(self.uid),
+            str(self.gid),
+            str(self.rdev),
+            str(self.mtime),
+            str(self.payload),
+            str(self.content),
+            str(self.digest),
+        ]
+        return " ".join(line_list)
+
+
+def eprint(*args, **kwargs) -> None:
+    print(args, **kwargs, file=sys.stderr)
+
+
+def leading_directories(path: str) -> list[str]:
+    """Return the leading directories of path
+
+    Given the path "alsa/conf.d/50-pipewire.conf", for example, this function
+    returns `[ "alsa", "alsa/conf.d" ]`.
+    """
+    parents = list(Path(path).parents)
+    parents.reverse()
+    # remove the implicit `.` from the start of a relative path or `/` from an
+    # absolute path
+    del parents[0]
+    return [str(i) for i in parents]
+
+
+def add_leading_directories(
+    target: str, attrs: Attrs, paths: dict[str, ComposefsPath]
+) -> None:
+    """Add the leading directories of a target path to the composefs paths
+
+    mkcomposefs expects that all leading directories are explicitly listed in
+    the dump file. Given the path "alsa/conf.d/50-pipewire.conf", for example,
+    this function adds "alsa" and "alsa/conf.d" to the composefs paths.
+    """
+    path_components = leading_directories(target)
+    for component in path_components:
+        composefs_path = ComposefsPath(
+            attrs,
+            path=component,
+            size=4096,
+            filetype=FileType.directory,
+            mode="0755",
+            payload="-",
+        )
+        paths[component] = composefs_path
+
+
+def main() -> None:
+    """Build a composefs dump from a Json config
+
+    This config describes the files that the final composefs image is supposed
+    to contain.
+    """
+    config_file = sys.argv[1]
+    if not config_file:
+        eprint("No config file was supplied.")
+        sys.exit(1)
+
+    with open(config_file, "rb") as f:
+        config = json.load(f)
+
+    if not config:
+        eprint("Config is empty.")
+        sys.exit(1)
+
+    eprint("Building composefs dump...")
+
+    paths: dict[str, ComposefsPath] = {}
+    for attrs in config:
+        target = attrs["target"]
+        source = attrs["source"]
+        mode = attrs["mode"]
+
+        if "*" in source:  # Path with globbing
+            glob_sources = glob.glob(source)
+            for glob_source in glob_sources:
+                basename = os.path.basename(glob_source)
+                glob_target = f"{target}/{basename}"
+
+                composefs_path = ComposefsPath(
+                    attrs,
+                    path=glob_target,
+                    size=100,
+                    filetype=FileType.symlink,
+                    mode="0777",
+                    payload=glob_source,
+                )
+
+                paths[glob_target] = composefs_path
+                add_leading_directories(glob_target, attrs, paths)
+        else:  # Without globbing
+            if mode == "symlink":
+                composefs_path = ComposefsPath(
+                    attrs,
+                    # A high approximation of the size of a symlink
+                    size=100,
+                    filetype=FileType.symlink,
+                    mode="0777",
+                    payload=source,
+                )
+            else:
+                if os.path.isdir(source):
+                    composefs_path = ComposefsPath(
+                        attrs,
+                        size=4096,
+                        filetype=FileType.directory,
+                        mode=mode,
+                        payload=source,
+                    )
+                else:
+                    composefs_path = ComposefsPath(
+                        attrs,
+                        size=os.stat(source).st_size,
+                        filetype=FileType.file,
+                        mode=mode,
+                        payload=target,
+                    )
+            paths[target] = composefs_path
+            add_leading_directories(target, attrs, paths)
+
+    composefs_dump = ["/ 4096 40755 1 0 0 0 0.0 - - -"]  # Root directory
+    for key in sorted(paths):
+        composefs_path = paths[key]
+        eprint(composefs_path.path)
+        composefs_dump.append(composefs_path.write_line())
+
+    print("\n".join(composefs_dump))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/nixos/modules/system/etc/check-build-composefs-dump.sh b/nixos/modules/system/etc/check-build-composefs-dump.sh
new file mode 100755
index 000000000000..da61651d1a5d
--- /dev/null
+++ b/nixos/modules/system/etc/check-build-composefs-dump.sh
@@ -0,0 +1,8 @@
+#! /usr/bin/env nix-shell
+#! nix-shell -i bash -p black ruff mypy
+
+file=$1
+
+black --check --diff $file
+ruff --line-length 88 $file
+mypy --strict $file
diff --git a/nixos/modules/system/etc/etc-activation.nix b/nixos/modules/system/etc/etc-activation.nix
index 780104950186..f47fd771c659 100644
--- a/nixos/modules/system/etc/etc-activation.nix
+++ b/nixos/modules/system/etc/etc-activation.nix
@@ -1,12 +1,96 @@
 { config, lib, ... }:
-let
-  inherit (lib) stringAfter;
-in {
+
+{
 
   imports = [ ./etc.nix ];
 
-  config = {
-    system.activationScripts.etc =
-      stringAfter [ "users" "groups" ] config.system.build.etcActivationCommands;
-  };
+  config = lib.mkMerge [
+
+    {
+      system.activationScripts.etc =
+        lib.stringAfter [ "users" "groups" ] config.system.build.etcActivationCommands;
+    }
+
+    (lib.mkIf config.system.etc.overlay.enable {
+
+      assertions = [
+        {
+          assertion = config.boot.initrd.systemd.enable;
+          message = "`system.etc.overlay.enable` requires `boot.initrd.systemd.enable`";
+        }
+        {
+          assertion = (!config.system.etc.overlay.mutable) -> config.systemd.sysusers.enable;
+          message = "`system.etc.overlay.mutable = false` requires `systemd.sysusers.enable`";
+        }
+        {
+          assertion = lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.6";
+          message = "`system.etc.overlay.enable requires a newer kernel, at least version 6.6";
+        }
+        {
+          assertion = config.systemd.sysusers.enable -> (config.users.mutableUsers == config.system.etc.overlay.mutable);
+          message = ''
+            When using systemd-sysusers and mounting `/etc` via an overlay, users
+            can only be mutable when `/etc` is mutable and vice versa.
+          '';
+        }
+      ];
+
+      boot.initrd.availableKernelModules = [ "loop" "erofs" "overlay" ];
+
+      boot.initrd.systemd = {
+        mounts = [
+          {
+            where = "/run/etc-metadata";
+            what = "/sysroot${config.system.build.etcMetadataImage}";
+            type = "erofs";
+            options = "loop";
+            unitConfig.RequiresMountsFor = [
+              "/sysroot/nix/store"
+            ];
+          }
+          {
+            where = "/sysroot/etc";
+            what = "overlay";
+            type = "overlay";
+            options = lib.concatStringsSep "," ([
+              "relatime"
+              "redirect_dir=on"
+              "metacopy=on"
+              "lowerdir=/run/etc-metadata::/sysroot${config.system.build.etcBasedir}"
+            ] ++ lib.optionals config.system.etc.overlay.mutable [
+              "rw"
+              "upperdir=/sysroot/.rw-etc/upper"
+              "workdir=/sysroot/.rw-etc/work"
+            ] ++ lib.optionals (!config.system.etc.overlay.mutable) [
+              "ro"
+            ]);
+            wantedBy = [ "initrd-fs.target" ];
+            before = [ "initrd-fs.target" ];
+            requires = lib.mkIf config.system.etc.overlay.mutable [ "rw-etc.service" ];
+            after = lib.mkIf config.system.etc.overlay.mutable [ "rw-etc.service" ];
+            unitConfig.RequiresMountsFor = [
+              "/sysroot/nix/store"
+              "/run/etc-metadata"
+            ];
+          }
+        ];
+        services = lib.mkIf config.system.etc.overlay.mutable {
+          rw-etc = {
+            unitConfig = {
+              DefaultDependencies = false;
+              RequiresMountsFor = "/sysroot";
+            };
+            serviceConfig = {
+              Type = "oneshot";
+              ExecStart = ''
+                /bin/mkdir -p -m 0755 /sysroot/.rw-etc/upper /sysroot/.rw-etc/work
+              '';
+            };
+          };
+        };
+      };
+
+    })
+
+  ];
 }
diff --git a/nixos/modules/system/etc/etc.nix b/nixos/modules/system/etc/etc.nix
index ea61e7384e60..baf37ba6def3 100644
--- a/nixos/modules/system/etc/etc.nix
+++ b/nixos/modules/system/etc/etc.nix
@@ -62,6 +62,16 @@ let
     ]) etc'}
   '';
 
+  etcHardlinks = filter (f: f.mode != "symlink") etc';
+
+  build-composefs-dump = pkgs.runCommand "build-composefs-dump.py"
+    {
+      buildInputs = [ pkgs.python3 ];
+    } ''
+    install ${./build-composefs-dump.py} $out
+    patchShebangs --host $out
+  '';
+
 in
 
 {
@@ -72,6 +82,30 @@ in
 
   options = {
 
+    system.etc.overlay = {
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Mount `/etc` as an overlayfs instead of generating it via a perl script.
+
+          Note: This is currently experimental. Only enable this option if you're
+          confident that you can recover your system if it breaks.
+        '';
+      };
+
+      mutable = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc ''
+          Whether to mount `/etc` mutably (i.e. read-write) or immutably (i.e. read-only).
+
+          If this is false, only the immutable lowerdir is mounted. If it is
+          true, a writable upperdir is mounted on top.
+        '';
+      };
+    };
+
     environment.etc = mkOption {
       default = {};
       example = literalExpression ''
@@ -190,12 +224,84 @@ in
   config = {
 
     system.build.etc = etc;
-    system.build.etcActivationCommands =
-      ''
-        # Set up the statically computed bits of /etc.
-        echo "setting up /etc..."
-        ${pkgs.perl.withPackages (p: [ p.FileSlurp ])}/bin/perl ${./setup-etc.pl} ${etc}/etc
+    system.build.etcActivationCommands = let
+      etcOverlayOptions = lib.concatStringsSep "," ([
+        "relatime"
+        "redirect_dir=on"
+        "metacopy=on"
+      ] ++ lib.optionals config.system.etc.overlay.mutable [
+        "upperdir=/.rw-etc/upper"
+        "workdir=/.rw-etc/work"
+      ]);
+    in if config.system.etc.overlay.enable then ''
+      # This script atomically remounts /etc when switching configuration. On a (re-)boot
+      # this should not run because /etc is mounted via a systemd mount unit
+      # instead. To a large extent this mimics what composefs does. Because
+      # it's relatively simple, however, we avoid the composefs dependency.
+      if [[ ! $IN_NIXOS_SYSTEMD_STAGE1 ]]; then
+        echo "remounting /etc..."
+
+        tmpMetadataMount=$(mktemp --directory)
+        mount --type erofs ${config.system.build.etcMetadataImage} $tmpMetadataMount
+
+        # Mount the new /etc overlay to a temporary private mount.
+        # This needs the indirection via a private bind mount because you
+        # cannot move shared mounts.
+        tmpEtcMount=$(mktemp --directory)
+        mount --bind --make-private $tmpEtcMount $tmpEtcMount
+        mount --type overlay overlay \
+          --options lowerdir=$tmpMetadataMount::${config.system.build.etcBasedir},${etcOverlayOptions} \
+          $tmpEtcMount
+
+        # Move the new temporary /etc mount underneath the current /etc mount.
+        #
+        # This should eventually use util-linux to perform this move beneath,
+        # however, this functionality is not yet in util-linux. See this
+        # tracking issue: https://github.com/util-linux/util-linux/issues/2604
+        ${pkgs.move-mount-beneath}/bin/move-mount --move --beneath $tmpEtcMount /etc
+
+        # Unmount the top /etc mount to atomically reveal the new mount.
+        umount /etc
+
+      fi
+    '' else ''
+      # Set up the statically computed bits of /etc.
+      echo "setting up /etc..."
+      ${pkgs.perl.withPackages (p: [ p.FileSlurp ])}/bin/perl ${./setup-etc.pl} ${etc}/etc
+    '';
+
+    system.build.etcBasedir = pkgs.runCommandLocal "etc-lowerdir" { } ''
+      set -euo pipefail
+
+      makeEtcEntry() {
+        src="$1"
+        target="$2"
+
+        mkdir -p "$out/$(dirname "$target")"
+        cp "$src" "$out/$target"
+      }
+
+      mkdir -p "$out"
+      ${concatMapStringsSep "\n" (etcEntry: escapeShellArgs [
+        "makeEtcEntry"
+        # Force local source paths to be added to the store
+        "${etcEntry.source}"
+        etcEntry.target
+      ]) etcHardlinks}
+    '';
+
+    system.build.etcMetadataImage =
+      let
+        etcJson = pkgs.writeText "etc-json" (builtins.toJSON etc');
+        etcDump = pkgs.runCommand "etc-dump" { } "${build-composefs-dump} ${etcJson} > $out";
+      in
+      pkgs.runCommand "etc-metadata.erofs" {
+        nativeBuildInputs = [ pkgs.composefs pkgs.erofs-utils ];
+      } ''
+        mkcomposefs --from-file ${etcDump} $out
+        fsck.erofs $out
       '';
+
   };
 
 }
diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix
index 9ee77cd79a9b..6aa718c1975d 100644
--- a/nixos/modules/testing/test-instrumentation.nix
+++ b/nixos/modules/testing/test-instrumentation.nix
@@ -207,7 +207,10 @@ in
     networking.usePredictableInterfaceNames = false;
 
     # Make it easy to log in as root when running the test interactively.
-    users.users.root.initialHashedPassword = mkOverride 150 "";
+    # This needs to be a file because of a quirk in systemd credentials,
+    # where you cannot specify an empty string as a value. systemd-sysusers
+    # uses credentials to set passwords on users.
+    users.users.root.hashedPasswordFile = mkOverride 150 "${pkgs.writeText "hashed-password.root" ""}";
 
     services.xserver.displayManager.job.logToJournal = true;