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
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;