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.
1. Problem statement
Section titled “1. Problem statement”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”:
- 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.
- Export — produce a portable OVA/OVF a customer can take to another system. SynVirt has a rich import framework but no exit.
- 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.
- 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. What exists today (cited)
Section titled “2. What exists today (cited)”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_hoststreams 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-profile →
crates/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.
2.4 URL-download precedent
Section titled “2.4 URL-download precedent”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.
2.5 VM dataset layout
Section titled “2.5 VM dataset layout”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.
2.6 Resource-controls host pattern
Section titled “2.6 Resource-controls host pattern”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.
2.7 Conventions
Section titled “2.7 Conventions”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.
3. Proposed design
Section titled “3. Proposed design”3.1 Crate placement
Section titled “3.1 Crate placement”- Templates + deploy-from-template: daemon submodule
crates/daemon/src/api/templates.rs(+ a smalltemplate_layouthelper insynvirt-coremirroringvm_layout). It is glue over existing primitives (snapshots/clone +define_from_profile), not a new domain — keep it in the daemon, likesnapshots.rs. - Export + URL/raw-disk import: extend
crates/migrator/. Add anexporters/module with anOvaOvfExporterand aVmZvolSource(the read-side mirror ofZfsZvolSink), and aUrlImageImporterreusing theiso_downloadjob mechanics. This keeps the one-crate-per-domain invariant: the migrator owns format ingress/egress. - Per-VM resource controls: a new
<cputune>/<memtune>renderer inlibvirt_renderer/elements/+ a thincrates/daemon/src/api/vm_resources.rseditor that hot-applies via the samevirsh/hotplug path the hardware editor uses (schedinfo/memtunelive; XML for persistent).
3.2 Templates
Section titled “3.2 Templates”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).
3.3 OVA/OVF export
Section titled “3.3 OVA/OVF export”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).
3.4 Raw-disk / image import from URL
Section titled “3.4 Raw-disk / image import from URL”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.
3.5 Per-VM resource controls
Section titled “3.5 Per-VM resource controls”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-v2cpu.weight), proportional under contention. - Limit →
<cputune><period>/<quota>(cgroup-v2cpu.max), a hard ceiling. - Memory limit/guarantee →
<memtune>(cgroup-v2memory.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.
3.6 API surface
Section titled “3.6 API surface”# TemplatesGET /api/v1/templatesPOST /api/v1/templates capture from { source_vm }GET /api/v1/templates/{id}PATCH /api/v1/templates/{id} name/descriptionDELETE /api/v1/templates/{id} (+ destroy the templates dataset)POST /api/v1/templates/{id}/deploy { name, vcpu?, memory_mb?, pool?, networks? } → new VM
# ExportPOST /api/v1/vms/{name}/export { kind: ova|ovf } → job id (task + SSE progress)GET /api/v1/exports/{id}/download
# Import from URLPOST /api/v1/migrator/import-url { url, name, vcpu?, memory_mb?, pool? } → job id
# Per-VM resource controlsGET /api/v1/vms/{name}/resourcesPUT /api/v1/vms/{name}/resourcesError 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.
4. Phased rollout
Section titled “4. Phased rollout”- 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_profileprimitives; 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.
UrlImageImporterreusing theiso_downloadjob + the existing magic-byte sniffers ontoZfsZvolSink. - Phase 4 — OVA/OVF export.
VmZvolSource+OvaOvfExporter; start with raw/qcow2-in-OVF-folder, add stream-optimized VMDK packaging after.
5. Risks & open questions
Section titled “5. Risks & open questions”- Template independence vs space.
zfs clonekeeps a template cheap but tethers it to the source snapshot (can’t destroy the source without promotion);zfs send|recvmakes 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. - 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.
- 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.
- 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. - Resource oversubscription semantics. “Reservation/guarantee” can
conflict with the global
synvirt-resourcesheadroom 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. - Cluster scope. Is the template library host-local or
cluster-shared? Host-local for v1 (deploy cross-host via the existing
clone_to_remote_hostpath); a replicated library is a later phase. - Long-running jobs surviving restarts. Export/import jobs are long;
if they must survive a daemon restart they belong in
synvirt- migratord(sledStateStore) rather than an in-daemon task — the migrator already has that durability, so route URL-import/export through it, not a baretokio::spawn.
6. What’s safe to build first
Section titled “6. What’s safe to build first”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.