Skip to content

VM Templates, Export & Resource Controls — design spec

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/superpowers/specs/vm-templates-export-resource-controls.md. Edit at the source, not here.

VM Templates, Export & Resource Controls — design spec

Section titled “VM Templates, Export & Resource Controls — design spec”

Date: 2026-06-27 Status: proposed — awaiting owner (Tony) review BEFORE any build Scope: Task 4 — VM lifecycle remainder: VM→template + template library + deploy-from-template, OVA/OVF export, raw-disk import from URL, per-VM resource controls (CPU/mem limit/reservation/shares) Risk class: moderate (data-plane writes; reversible per feature). Spec first; phase the build.


SynVirt can create a VM from a profile, snapshot it, clone it (same host, cross-pool, and cross-host), import it from ten external formats, and — as of the NIC attach/detach work in the companion deliverable — hot-edit its hardware. The lifecycle gaps that remain are the ones an operator hits right after “I have a VM I like”:

  1. Templates — capture a configured VM as a reusable golden image, keep a library of them, and deploy fresh VMs from one in a couple of clicks. Today the only reuse path is ad-hoc clone-of-a-VM.
  2. Export — produce a portable OVA/OVF a customer can take to another system. SynVirt has a rich import framework but no exit.
  3. Raw-disk import from URL — pull a cloud image / raw / qcow2 from a URL straight into a new VM. Today imports are from local archives; only ISOs can be fetched from a URL.
  4. Per-VM resource controls — CPU/memory limit, reservation, and shares per VM. The host has a global reservation knob, but a VM cannot be capped or guaranteed individually.

These are the “table-stakes” lifecycle features that make the difference between a lab tool and something an admin runs a fleet on.

Note: NIC attach/detach (POST/DELETE /api/v1/vms/{name}/nics) is already implemented in the companion deliverable and is out of scope here.


2.1 Snapshots + clone (the template engine’s foundation)

Section titled “2.1 Snapshots + clone (the template engine’s foundation)”

crates/daemon/src/api/snapshots.rs is a full snapshot stack: list / create / delete / restore / clone / space / get_policy / put_policy under /api/v1/vms/{name}/snapshots*. clone() (snapshots.rs:593) already does the three hard cases:

  • same-pool: zfs clone <snapshot> <dst_dataset> (:695),
  • cross-pool: zfs send | zfs recv (zfs_send_recv, :1710),
  • cross-host: clone_to_remote_host streams to a federated peer (:814).

Child disk zvols are cloned recursively under one snapshot label (:708). Helpers: resolve_vm_layout (:1329), list_child_volume_datasets (:1892, zfs list -r -d 1 -t volume), list_snapshots (:1442). Deploy-from-template is “clone a template’s snapshot into a new VM dataset” — the primitive already exists.

2.2 VM create + libvirt renderer (the deploy path)

Section titled “2.2 VM create + libvirt renderer (the deploy path)”

POST /api/v1/vms/from-profilecrates/daemon/src/libvirt_renderer/api.rs::define_from_profile. It resolves a ResolvedProfile (+ security/networking overlays), builds a VmContext (vcpu, memory, networks, disks), runs 14 element renderers in libvirt_renderer/elements/ to emit the <domain> XML, virsh defines it, allocates disk zvols via vm_layout::ensure_zvol, and emits DaemonEvent::VmCreated. Deploy-from-template reuses this exact composition with the disks pointed at cloned template zvols.

Resource-control elements do not exist yet: grep confirms no <cputune>, <memtune>, or <blkiotune> renderer (only <memballoon><stats period='5'/>). They are a clean new renderer to add.

2.3 Migrator import framework (the export to reverse)

Section titled “2.3 Migrator import framework (the export to reverse)”

crates/migrator/ defines two traits:

  • OfflineImporter (importer.rs): kind / can_handle / inspect → VmManifest / read_disk → BlockStream / run_full_import(probe, sink).
  • DestinationSink (sink.rs): prepare_disk / write_chunk / flush / rollback_disk / snapshot / destroy_job.

Nine importers in importers/mod.rs (vmx-folder, ovf-folder, vmcx-export, vbox-folder, ova-archive, xva-archive, vhdx, qcow2, vmdk) land on the default ZfsZvolSink (zfs_sink.rs, thin zfs create -s -V). There is no exporter and no URL importer. An exporter is the mirror image: a source that reads a SynVirt VM’s zvols and an OVA/OVF writer sink. The OVF/OVA reader logic (ova_archive, ovf_folder) already encodes the OVF envelope/manifest shape to reverse.

crates/daemon/src/api/iso_download.rs fetches a file from a URL with a job model: DownloadStatus { Downloading, Completed, Failed, Cancelled }, run_download(state, id, url, root, …) async loop polling a cancel flag, emitting DaemonEvent::{IsoDownloadStarted, IsoDownloadProgress, IsoDownloadCompleted, IsoDownloadFailed}. Raw-disk-import-from-URL reuses this job pattern, swapping the destination from the ISO library to a VM zvol via the migrator sink.

crates/core/src/storage/vm_layout.rs: VmLayout { pool, dataset, mountpoint } over the <pool>/vms/<slug>/ convention; pure layout_for(pool, name); I/O ensure_dataset / ensure_zvol / destroy_zvol / detect_disk_runtime; ProvisioningMode { Thin, Thick }; VmLayoutError. Pool LABEL→zpool resolution via crates/daemon/src/api/storage_client.rs::resolve_zpool. A template library mirrors this as <pool>/templates/<id>/ — same helpers, different parent path.

synvirt-resources::ResourceService (get / put, ReservationMode { Auto, Manual }, config in /etc/synvirt/synvirt.toml [resources.reserved], audited) reserves CPU+RAM headroom for the host. synvirt-host-tune applies runtime cgroup-v2 / sysfs tunings on every daemon start (apply_all, idempotent TuningResults). These are the host-side pattern per-VM controls extend: libvirt <cputune> / <memtune> translate into cgroup-v2 limits on the per-VM scope libvirt already creates.

ApiError (api/error.rs, E_*, trace_id), task_helper::start / settle (api/task_helper.rs) for long-running ops, DaemonEvent + emit (api/events.rs) → observability bus → SSE + activity log.


  • Templates + deploy-from-template: daemon submodule crates/daemon/src/api/templates.rs (+ a small template_layout helper in synvirt-core mirroring vm_layout). It is glue over existing primitives (snapshots/clone + define_from_profile), not a new domain — keep it in the daemon, like snapshots.rs.
  • Export + URL/raw-disk import: extend crates/migrator/. Add an exporters/ module with an OvaOvfExporter and a VmZvolSource (the read-side mirror of ZfsZvolSink), and a UrlImageImporter reusing the iso_download job mechanics. This keeps the one-crate-per-domain invariant: the migrator owns format ingress/egress.
  • Per-VM resource controls: a new <cputune>/<memtune> renderer in libvirt_renderer/elements/ + a thin crates/daemon/src/api/vm_resources.rs editor that hot-applies via the same virsh/hotplug path the hardware editor uses (schedinfo/memtune live; XML for persistent).

A template is a frozen, deploy-only capture of a VM: its domain XML (hardware shape, minus identity/MAC) plus its disks as a base ZFS snapshot under a dedicated dataset.

/// Library entry. Disks live as a recursive snapshot of the source VM's
/// dataset under <pool>/templates/<id>/; deploy = clone that snapshot.
pub struct Template {
pub id: String, // slug
pub name: String,
pub description: String,
pub source_vm: Option<String>, // provenance
pub os_hint: Option<String>, // from the source profile / iso_detection
pub domain_template: String, // sanitized <domain> XML (no uuid/mac/nvram identity)
pub disks: Vec<TemplateDisk>, // dataset + base snapshot per disk
pub pool: String,
pub created_at: i64,
pub size_bytes: u64, // referenced, from zfs
}

Capture (VM → template): snapshot the source VM’s dataset (reuse snapshots::create recursive logic), zfs send | zfs recv (or zfs clone + promote) the snapshot into <pool>/templates/<id>/ so the template is independent of the source VM’s lifecycle, sanitize the domain XML (strip UUID, MAC, NVRAM identity, host-specific paths) into domain_template, write the Template record to sled (/var/lib/synvirt/templates.db/). Source VM should be powered off for a crash-consistent capture (soft-warn if running, offer a snapshot-while-running path later).

Deploy (template → VM): clone each template disk snapshot into a new <pool>/vms/<slug>/ dataset (reuse the snapshots::clone machinery), rehydrate identity (new UUID/MAC/NVRAM) into the domain XML, then take the same virsh define + start path define_from_profile uses. Deploy accepts overrides (name, vcpu, memory, target pool, networks) so one template fans out to many sized instances.

Library: CRUD over the sled store; the dashboard renders it as a gallery with OS icon, size, and a “Deploy” CTA (FRONTEND_PRINCIPLES empty-state-with-CTA rule).

Mirror the import framework:

/// The read-side mirror of DestinationSink: streams a SynVirt VM's
/// disks out as blocks for an exporter to package.
pub trait DiskSource {
async fn list_disks(&self) -> Result<Vec<SourceDisk>>;
fn read_disk<'a>(&'a self, d: &'a SourceDisk) -> BlockStream<'a>;
async fn manifest(&self) -> Result<VmManifest>; // reuse migrator's VmManifest
}
/// Packages a VmManifest + disk streams into a portable archive.
pub trait Exporter {
fn kind(&self) -> ExportKind; // Ova | OvfFolder
async fn export(&self, src: Arc<dyn DiskSource>, dst: ExportTarget) -> Result<()>;
}

VmZvolSource reads the VM’s zvols (/dev/zvol/...); OvaOvfExporter emits an OVF descriptor (reverse the envelope the ovf_folder / ova_archive importers already parse), streams each disk to a VMDK or sparse raw inside the OVA tar, and writes the manifest + SHA-256s. Run it as a task_helper job with DaemonEvent progress (mirror iso_download). Disk-format conversion (raw zvol → stream-optimized VMDK) is the main new work; phase it (start with raw/qcow2 disk in an OVF folder, add VMDK stream-optimized after).

A new migrator ingress: UrlImageImporter fetches a URL (reuse iso_download::run_download’s fetch + cancel + progress) to a staging area, sniffs the magic bytes (the existing qcow2/vhdx/vmdk importers already do this), and runs the standard run_full_import onto ZfsZvolSink. For a bare raw image with no manifest, synthesize a minimal VmManifest (operator supplies vcpu/memory/firmware in the request, like define_from_profile). Surfaced at POST /api/v1/migrator/import-url.

A new vm_resources editor + a <cputune>/<memtune> renderer element:

pub struct VmResourceControls {
pub cpu: CpuControls, // shares, limit (quota/period), reservation (cpu pinning/headroom)
pub memory: MemControls, // hard_limit, soft_limit, guarantee (min_guarantee)
}
// → libvirt <cputune><shares/><period/><quota/></cputune>
// libvirt <memtune><hard_limit/><soft_limit/><min_guarantee/></memtune>
  • Shares<cputune><shares> (cgroup-v2 cpu.weight), proportional under contention.
  • Limit<cputune><period>/<quota> (cgroup-v2 cpu.max), a hard ceiling.
  • Memory limit/guarantee<memtune> (cgroup-v2 memory.max / memory.low).

Hot-apply on a running VM via virsh schedinfo --set / virsh memtune (live) and write the XML (persistent), reusing the hardware editor’s live-vs-config AffectFlags pattern. Validate against the host’s synvirt-resources reservation so a per-VM guarantee can’t oversubscribe the host headroom — soft-warn on oversubscription (no-hardcoded- restriction), hard-block only physically impossible values (e.g. quota < 0). Surfaced at GET/PUT /api/v1/vms/{name}/resources.

# Templates
GET /api/v1/templates
POST /api/v1/templates capture from { source_vm }
GET /api/v1/templates/{id}
PATCH /api/v1/templates/{id} name/description
DELETE /api/v1/templates/{id} (+ destroy the templates dataset)
POST /api/v1/templates/{id}/deploy { name, vcpu?, memory_mb?, pool?, networks? } → new VM
# Export
POST /api/v1/vms/{name}/export { kind: ova|ovf } → job id (task + SSE progress)
GET /api/v1/exports/{id}/download
# Import from URL
POST /api/v1/migrator/import-url { url, name, vcpu?, memory_mb?, pool? } → job id
# Per-VM resource controls
GET /api/v1/vms/{name}/resources
PUT /api/v1/vms/{name}/resources

Error codes: E_TEMPLATE_EXISTS, E_TEMPLATE_NOT_FOUND, E_TEMPLATE_SOURCE_RUNNING (warning), E_TEMPLATE_DATASET, E_EXPORT_FAILED, E_IMPORT_URL_UNREACHABLE, E_IMPORT_FORMAT_UNKNOWN, E_RESOURCE_OVERSUBSCRIBED (warning), E_RESOURCE_INVALID.


  • Phase 0 — NIC attach/detach. ✅ Done in the companion deliverable (POST/DELETE /api/v1/vms/{name}/nics). Listed here only to close the lifecycle picture.
  • Phase 1 — Templates (safe-ish, reversible). Capture, library CRUD, deploy-from-template. Pure composition over the existing snapshot/clone
    • define_from_profile primitives; destroying a template is a dataset destroy. Recommended first build.
  • Phase 2 — Per-VM resource controls. New <cputune>/<memtune> renderer + the editor with live + persistent apply and the host- reservation soft-guard.
  • Phase 3 — Import from URL. UrlImageImporter reusing the iso_download job + the existing magic-byte sniffers onto ZfsZvolSink.
  • Phase 4 — OVA/OVF export. VmZvolSource + OvaOvfExporter; start with raw/qcow2-in-OVF-folder, add stream-optimized VMDK packaging after.

  1. Template independence vs space. zfs clone keeps a template cheap but tethers it to the source snapshot (can’t destroy the source without promotion); zfs send|recv makes it independent but uses full space. Recommend send/recv (or clone+promote) so a template outlives its source VM — confirm the space trade-off with Tony.
  2. Domain XML sanitization. Stripping UUID/MAC/NVRAM/host-paths from the captured XML is fiddly; a missed host-specific path makes a template undeployable on another host. Reuse the renderer’s element model rather than regexing raw XML.
  3. Export disk conversion. Raw zvol → stream-optimized VMDK is the real work and a common interop pain point. Phase it; ship OVF-folder with raw/qcow2 first so something works end-to-end before the VMDK converter.
  4. URL import trust. Fetching arbitrary URLs into a privileged host is a security surface: enforce size caps (soft-warn, like the ISO path), checksum verification when the operator supplies one, and a staging-area quota. Reuse iso_download’s existing guards.
  5. Resource oversubscription semantics. “Reservation/guarantee” can conflict with the global synvirt-resources headroom and with other VMs’ guarantees. Decide whether guarantees are admission-controlled (refuse to start if unsatisfiable) or best-effort (soft-warn). The no-hardcoded-restriction philosophy favors soft-warn + observability.
  6. Cluster scope. Is the template library host-local or cluster-shared? Host-local for v1 (deploy cross-host via the existing clone_to_remote_host path); a replicated library is a later phase.
  7. Long-running jobs surviving restarts. Export/import jobs are long; if they must survive a daemon restart they belong in synvirt- migratord (sled StateStore) rather than an in-daemon task — the migrator already has that durability, so route URL-import/export through it, not a bare tokio::spawn.

Phase 1 — Templates. It is the highest-value, lowest-risk piece: every primitive it needs already ships and is proven (recursive snapshots, zfs clone/send|recv, define_from_profile, vm_layout/resolve_zpool, task_helper, DaemonEvent). It writes only into a new <pool>/templates/<id>/ namespace and a new sled DB, so it cannot disturb existing VMs, and every operation is reversible by destroying a dataset. It also unblocks the most-requested workflow (“golden image → many VMs”) without touching the riskier surfaces (per-VM cgroup limits that can starve a guest, URL fetches into a privileged host, or disk-format conversion). Build templates first, prove the capture/deploy loop, then layer resource controls, URL import, and export on top.