diff --git a/nixos/vms/config.nix b/nixos/vms/config.nix index 4dc86a7..528671f 100644 --- a/nixos/vms/config.nix +++ b/nixos/vms/config.nix @@ -125,13 +125,34 @@ let if [ ! -f "$PERSIST_PATH" ]; then echo "Seeding persistent disk from store image..." mkdir -p "$(dirname "$PERSIST_PATH")" - cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH" + ${if vmCfg.disks.os.persistMode == "backing" + then ''qemu-img create -f qcow2 -F qcow2 -b "${toString storeImage}" "$PERSIST_PATH"'' + else ''cp --no-preserve=mode "${toString storeImage}" "$PERSIST_PATH"''} chmod 600 "$PERSIST_PATH" fi ''; persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript; + # A GC root pinning the OS overlay's ACTUAL backing store path, so + # nix-collect-garbage cannot delete the store image the disk reads through. + gcrootLink = "/nix/var/nix/gcroots/vmix-${vmCfg.name}-osbacking"; + gcrootScript = pkgs.writeShellScript "${vmCfg.name}-gcroot-vmix" '' + # Read the live overlay's backing (not the config's current image, which + # drifts to a new store path after a rebuild while the overlay keeps + # backing the old one). Pinning the top of the chain transitively keeps + # the whole chain -- qcow2 backing_file paths are registered nix refs. + BACK="" + if [ -f "${vmCfg.disks.os.persistPath}" ]; then + BACK=$(qemu-img info "${vmCfg.disks.os.persistPath}" 2>/dev/null | awk '/^backing file:/ {print $3; exit}') + fi + [ -z "$BACK" ] && BACK="${toString storeImage}" + if [ -n "$BACK" ]; then + mkdir -p /nix/var/nix/gcroots + ln -sfn "$BACK" "${gcrootLink}" + fi + ''; + # QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names. bootOrderQemu = let @@ -254,7 +275,8 @@ let "vm.vmix@${vmCfg.name}" = rec { bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service"; unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.service"; - after = bindsTo; + after = bindsTo ++ lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service"; + wants = lib.optional (hasOsDisk && vmCfg.disks.os.persist) "vm.vmix-gcroot@${vmCfg.name}.service"; path = with pkgs; [ iproute2 qemu gawk coreutils ]; serviceConfig = { ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ]; @@ -284,6 +306,18 @@ let ExecStop = deleteMacvTapsScript; }; }; + } + // lib.optionalAttrs (cfg.enable && hasOsDisk && vmCfg.disks.os.persist) { + "vm.vmix-gcroot@${vmCfg.name}" = { + before = [ "vm.vmix@${vmCfg.name}.service" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ qemu coreutils ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = gcrootScript; + }; + }; }; vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces; diff --git a/nixos/vms/submoduleOptions.nix b/nixos/vms/submoduleOptions.nix index c7cbd1a..45a55c4 100644 --- a/nixos/vms/submoduleOptions.nix +++ b/nixos/vms/submoduleOptions.nix @@ -173,6 +173,17 @@ with lib; default = ""; description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true."; }; + disks.os.persistMode = mkOption { + type = types.enum [ "copy" "backing" ]; + default = "copy"; + description = '' + How the persistent OS disk is seeded from the store image (persist = true). + copy: a full cp of the store image; the mutable disk holds everything. + backing: a thin qcow2 overlay backing onto the shared store image, so many + VMs share one base and each holds only its own deltas. The store image (and + its backing chain) must then survive GC -- vmix pins it via a per-VM gcroot. + ''; + }; disks.iso.file = mkOption { type = types.nullOr (types.either types.path types.str); description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";