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:
parent
60013006d6
commit
c40f4460e3
2 changed files with 47 additions and 2 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue