vms: disks.os.persistMode (copy|backing) + a GC-root for the backing chain

persistMode=backing seeds the persistent OS disk as a thin qcow2 overlay
(`qemu-img create -b <storeImage>`) instead of a full cp, so many VMs share one
base image and each holds only its deltas. Because the overlay reads through a
store path that nix does not otherwise pin (the disk lives outside the store),
and because even a cp'd Windows disk backs onto the store chain, a per-VM
oneshot `vm.vmix-gcroot@<name>` reads the overlay's ACTUAL backing_file at boot
(not the config's current image, which drifts after a rebuild) and symlinks it
under /nix/var/nix/gcroots. It runs before the VM service and outside its
ProtectSystem sandbox. Fires whenever the OS disk is persistent, covering copy
too. Default stays copy, so existing VMs are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
This commit is contained in:
Git Sagar 2026-09-16 12:23:45 -03:00
parent 60013006d6
commit c40f4460e3
2 changed files with 47 additions and 2 deletions

View file

@ -125,13 +125,34 @@ let
if [ ! -f "$PERSIST_PATH" ]; then if [ ! -f "$PERSIST_PATH" ]; then
echo "Seeding persistent disk from store image..." echo "Seeding persistent disk from store image..."
mkdir -p "$(dirname "$PERSIST_PATH")" 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" chmod 600 "$PERSIST_PATH"
fi fi
''; '';
persistExecStartPre = lib.optional (hasOsDisk && vmCfg.disks.os.persist) seedPersistentDiskScript; 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. # QEMU expects single-letter boot codes (e.g. c,d,n), while vmix uses readable names.
bootOrderQemu = bootOrderQemu =
let let
@ -254,7 +275,8 @@ let
"vm.vmix@${vmCfg.name}" = rec { "vm.vmix@${vmCfg.name}" = rec {
bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service"; bindsTo = [ "net.vmix@${spaceName}.target" ] ++ lib.optional (allMacvtaps != []) "macvtaps.vm.vmix@${vmCfg.name}.service";
unitConfig.JoinsNamespaceOf = "ns.net.vmix@${spaceName}.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 ]; path = with pkgs; [ iproute2 qemu gawk coreutils ];
serviceConfig = { serviceConfig = {
ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ]; ExecStartPre = persistExecStartPre ++ [ createTapsforLansScript ];
@ -284,6 +306,18 @@ let
ExecStop = deleteMacvTapsScript; 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; vmServices = concatMapAttrs (spaceName: namespaceCfg: (concatMapAttrs (mkServices4aVMInNamespace spaceName) namespaceCfg.vms)) vmixCfg.namespaces;

View file

@ -173,6 +173,17 @@ with lib;
default = ""; default = "";
description = "Mutable path for the persistent OS disk (e.g. /storage/vms/myvm/os.qcow2). Required when persist = true."; 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 { disks.iso.file = mkOption {
type = types.nullOr (types.either types.path types.str); 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."; description = "Path to the ISO file. Can be a Nix store path or a string path to a local file.";