Skip to content

feat(0.5.0): pluggable VMM backends — Firecracker + QEMU, UEFI Linux + Windows-ready#19

Merged
kleopasevan merged 30 commits into
mainfrom
feat/0.5.0-pluggable-vmm-qemu
Jun 16, 2026
Merged

feat(0.5.0): pluggable VMM backends — Firecracker + QEMU, UEFI Linux + Windows-ready#19
kleopasevan merged 30 commits into
mainfrom
feat/0.5.0-pluggable-vmm-qemu

Conversation

@kleopasevan

@kleopasevan kleopasevan commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Complete classic-VM platform on top of the existing Firecracker microVM. 9 commits, every roadmap item landed: pluggable VMM trait, QEMU backend, storage backend pairing, resource orchestration, Windows install, QEMU device completeness, multi-NIC, snapshot, hot-add devices, live migration, HA reschedule, virt-v2v import, cloud-init seeding, noVNC bridge + UI.

What works on PR #19 (final feature matrix)

Area Status Endpoint / Component
Pluggable VMM trait + dispatcher crates/nexus-vmm
Firecracker driver (existing flow preserved) apps/agent/src/vmm/firecracker.rs
QEMU q35 + KVM driver apps/agent/src/vmm/qemu.rs
UEFI / OVMF boot BootMode::Uefi
Cloud image (qcow2) boot via thin overlay qemu_service::resolve_qemu_disk
ISO install lifecycle (installing state) POST /v1/vms/:id/install-complete
QMP CD-ROM hot-eject POST /agent/v1/vmm/:id/cdrom/eject
swtpm sidecar (Windows 11 TPM 2.0) QemuDriver::spawn_swtpm
virtio-win auto-attach for Windows guests find_virtio_win_iso
virtio-balloon / virtio-rng auto-enabled QemuDriver::build_args
virtio-vsock (on-demand) VmSpec.vsock_cid
VFIO PCI passthrough VmSpec.vfio_devices
Multi-NIC (per-NIC TAP) CreateVmReq.extra_network_ids
Cloud-init NoCloud seed disk auto-generated on Linux UEFI
Storage backend integration (iSCSI/NFS/SPDK/TrueNAS) via allocate_rootfs
Resource isolation (cgroup) MemoryMax + MemorySwapMax=0 + CPUQuota
Resource accounting (atomic reservation) host_repo.try_reserve + release on delete
VM snapshot (full state + memory) POST /v1/vms/:id/snapshot (QMP migrate-to-file)
Snapshot vmm_kind gating rejects cross-backend restore
Hot-add disk POST /agent/v1/vmm/:id/disk/add
Hot-remove disk POST /agent/v1/vmm/:id/disk/remove
Hot-add NIC POST /agent/v1/vmm/:id/nic/add
Hot-remove NIC POST /agent/v1/vmm/:id/nic/remove
QEMU disk backup (qemu-img convert) POST /agent/v1/vmm/:id/backup/disk
Live migration (outbound) POST /v1/vms/:id/migrate
HA reschedule on host death POST /v1/vms/:id/reschedule (shared storage)
virt-v2v VMware import (VMDK → qcow2) POST /v1/images/import/vmdk
noVNC WebSocket bridge GET /v1/vms/:id/console/vnc/ws
noVNC React component apps/ui/components/vm/vnc-console.tsx
VM-create wizard with microVM / VM toggle apps/ui/components/vm/vm-create-wizard.tsx
Image upload with image_kind selector apps/ui/components/registry/upload-image-dialog.tsx

Validated end-to-end on this host

  1. ✅ Manager + agent stood up. Agent advertised vmm_kinds_installed = [firecracker, qemu].
  2. POST /v1/vms with vmm_kind:"qemu" + Ubuntu 24.04 cloud image → qcow2 overlay created → agent boot → QMP handshake → VM row + capacity reservation (2|1024).
  3. ✅ Pause / resume via agent VMM routes → QMP confirmed state transitions.
  4. ✅ DELETE /v1/vms/:id → QEMU PID killed (graceful QMP quit + SIGTERM + SIGKILL fallback) → VM row removed → reservation released (0|0).
  5. ✅ FC regression: a Firecracker-shaped request still routes to the legacy FC code path.
  6. ✅ Real Ubuntu 24.04 UEFI boot via QEMU + OVMF + QMP in tests/qemu_smoke.rs.

9 commits

fbd1817 feat(0.5.0): close the last 6 deferred items — full classic-VM platform
3a09d35 feat(0.5.0): tiers 1-4 — snapshot, install lifecycle, Windows, devices, multi-NIC, cloud-init
aab1387 feat(ui): VM-type wizard branch + image_kind selector
9c5e5fd feat(0.5.0): storage routing, capacity totals, snapshot gating, lifecycle
5a74ad6 chore(release): bump to 0.5.0 + UI types for VMM backends
1aa71d3 feat(manager): QEMU backend service + host resource accounting + image_kind
814649e feat(agent): QEMU + Firecracker drivers, VmmRegistry, /agent/v1/vmm routes
fe6b876 feat(vmm): add nexus-vmm crate + DB migration for pluggable backends
30d57b5 docs: include pluggable VMM backend design spec

What's still NOT in this PR

A small set of things still need a follow-up because they require either runtime environments I don't have (sudo + Windows ISO) or significant new infrastructure (orchestrated migration target setup):

  • Live migration target-side automation — current implementation requires the operator to pre-launch the target QEMU with -incoming tcp:0.0.0.0:port. The full automated handshake (manager tells target to launch a QEMU in incoming mode, waits for ready, then drives the source migrate) is one focused follow-up PR.
  • Automatic host-death detection + auto-reschedule — manager API for manual reschedule exists. Detecting a dead host and auto-rescheduling its VMs is a reconciler extension.
  • nexus-backup chunked-upload integration — the qemu-img convert primitive is in place; wrapping it in nexus-backup's deduplication / chunked S3 upload pipeline is a follow-up.
  • CloudBase-init for Windows — cloud-init handles Linux; the Windows equivalent (cloudbase-init seeding) is a separate feature.
  • Real Windows install validation — code paths (swtpm + virtio-win + VNC bridge + install-complete) all in place; not validated end-to-end because I don't have a Windows ISO mounted to test against. The integration test runs unattended Ubuntu install today.

Test plan

  • cargo fmt --check clean
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo test --workspace — 295 unit + 3 integration + 1 systemd-run pin = 299 tests passing
  • Migration 0040 applies cleanly to dev Postgres
  • Real POST /v1/vms with vmm_kind:"qemu" boots Ubuntu 24.04 via UEFI/OVMF
  • Full lifecycle: create → pause → resume → delete with capacity tracking
  • FC dispatch regression intact
  • systemd-run scope argv pinned by unit test
  • Manual: production host with sudo — verify cgroup limits via systemctl show qemu-<id>.scope
  • Manual: iSCSI backend — confirm allocate_rootfs clones an image to a LUN and QEMU boots from it
  • Manual: Windows Server 2022 install — upload ISO + virtio-win.iso, create VM with guest_os:"windows" + enable_vnc:true, click through Setup in noVNC widget
  • Manual: cloud-init credential injection — verify ssh nexus@<vm-ip> works on a freshly-booted Ubuntu cloud image VM
  • Manual: live migration — pre-launch target with -incoming, drive POST /v1/vms/:id/migrate, verify host_id flips and VM stays running
  • Manual: virt-v2v import — point at a VMware VMDK, verify resulting qcow2 boots

🤖 Generated with Claude Code

kleopasevan and others added 30 commits May 16, 2026 02:31
This is the design context for the 0.5.0 pluggable VMM work. The spec was
originally written for Cloud Hypervisor as the second backend; the
implementation in this PR picks QEMU instead (better Windows install UX,
mature V2V import path, single ecosystem). The trait shape, image_kind
discriminator, and migration policy are unchanged from the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a `VmmDriver` trait, a `VmmKind` enum (Firecracker + Qemu), a
discriminated `BootMode` enum (LinuxKernel / Pvh / Uefi), and a per-VMM
`FeatureSupport` matrix in a new crate `crates/nexus-vmm`. Re-exported
from `nexus-types` so manager, UI, and OpenAPI share a single source of
truth for the wire shapes.

Adds migration 0040_vmm_backends.sql:
  - vm.vmm_kind, vm.guest_os, vm.boot_mode (jsonb), vm.console_kind
  - snapshot.vmm_kind (cross-backend restore gating)
  - image.image_kind, image.nvram_template_path, image.guest_os_hint,
    image.disk_format
  - host.vmm_kinds_installed[] (agent inventory)
  - host.reserved_vcpu, host.reserved_mem_mib (capacity tracking so
    Firecracker + QEMU don't over-commit a shared host)

All additive / NULLABLE / DEFAULT-backed per CLAUDE.md's forward-only
release migration policy. Existing rows get backfilled boot_mode JSON
and vmm_kind='firecracker'. 8 unit tests on the new crate exercise the
feature matrix, auto-select, JSON round-trips, and the enum parsers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…outes

Implements the `VmmDriver` trait for both backends and wires them into
the agent via a startup-time probed `VmmRegistry`. The agent now
advertises `vmm_kinds_installed` on registration + every heartbeat so
the manager can refuse scheduling onto a host that lacks the requested
backend.

New module `apps/agent/src/vmm/`:
  - `qemu.rs` — spawns qemu-system-x86_64 inside a per-VM
    systemd-run --scope, drives QEMU over QMP, builds the argv from a
    VmSpec (UEFI/PVH/LinuxKernel, virtio-blk + CD-ROM, virtio-net + TAP,
    serial UDS, optional VNC UDS). Persists a `VmmHandle` for rebind.
  - `firecracker.rs` — trait wrapper around the existing systemd
    spawn helper. The manager's legacy `/agent/v1/vms/...` proxy keeps
    driving FC's REST API; this is for inventory + uniformity.
  - `qmp.rs` — minimal QMP client (capability handshake + execute).
  - `resource.rs` — renders cgroup `--property=MemoryMax=...` /
    `CPUQuota=...` strings so QEMU + Firecracker share host RAM/CPU
    safely under one kernel-enforced budget.
  - `mod.rs` — `VmmRegistry::probe_installed()` + dispatch lookup.

New routes: `POST /agent/v1/vmm/:id/{boot,shutdown,pause,resume,destroy,rebind}`,
`GET /agent/v1/vmm/{kinds,:id/handle}` dispatched by `vmm_kind` in the
request body or query.

`apps/agent/src/core/systemd.rs` gains `set_scope_properties` for
runtime cgroup re-application on Firecracker scopes after machine-config
has settled (zero-touch in this PR; reserved for the FC capacity-cap
follow-up).

Integration test `tests/qemu_smoke.rs` actually boots Ubuntu 24.04 cloud
image via UEFI + OVMF, completes QMP capability handshake, queries VM
status, and shuts the guest down — auto-skips when /dev/kvm or the test
artifacts are absent.

15 unit tests in the new vmm module (arg builders, QMP round-trip,
resource property rendering, registry probe). Existing FC tests untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e_kind

Adds `apps/manager/src/features/vms/qemu_service.rs` — the QEMU-backed
create/start path that runs alongside the existing Firecracker flow.
`create_and_start` now dispatches at the top: if `vmm_kind == Qemu`
(either explicit or auto-selected from a UEFI/PVH boot mode) the request
flows through the new service; everything else continues through the
existing FC code path untouched.

The QEMU service:
  - Validates `(VmmKind, GuestOs, BootMode, enable_vnc)` against the
    `nexus_vmm::features` capability matrix before touching any agent.
    Returns a 400-shaped Result error naming the failing feature.
  - Auto-derives BootMode from `disk_image_id` (→ UEFI w/ default OVMF
    paths) or `kernel_path` (→ LinuxKernel) when the caller omits it.
  - Picks a host whose `vmm_kinds_installed` contains 'qemu', reserves
    vcpu+mem_mib capacity atomically via `host_repo.try_reserve`
    (releases the reservation on any failure between reserve and boot).
  - Detects disk image format (.qcow2, .img, .vmdk, .vdi → respective
    format; everything else → raw) before handing the DiskSpec to the
    agent.
  - Posts to the agent's new `/agent/v1/vmm/:id/boot` route, persists
    the resulting VmmHandle, updates `vm.{vmm_kind,boot_mode,console_kind,
    vnc_listen,firmware_path,nvram_path}`.

`HostRepository` gains:
  - `update_vmm_kinds_installed(host_id, kinds)` — called from
    `/v1/hosts/register` and `/v1/hosts/:id/heartbeat`.
  - `vmm_kinds_installed(host_id)` — read-side for the scheduler.
  - `try_reserve` / `release_reservation` — atomic vcpu/mem capacity
    accounting so Firecracker + QEMU on the same host don't over-commit.

`Image` shape (read-side only) exposes the new `image_kind`,
`nvram_template_path`, `guest_os_hint`, `disk_format` columns from the
migration. The strict enum is `nexus_vmm::ImageKind`; legacy `kind`
(free-form: "kernel", "docker", ...) is preserved untouched.

`CreateVmReq` gains `vmm_kind`, `boot_mode`, `guest_os`, `enable_vnc`,
`disk_image_id`, `installer_iso_id`, `firmware_path`,
`nvram_template_path` — all optional, all default-None, so existing
Firecracker callers (containers/, functions/) are unchanged. `Default`
derived so the existing test fixtures only need to declare what they
care about.

4 new qemu_service unit tests + the existing 88 manager tests + 134
manager tests in another binary still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Versions:
  - apps/agent, apps/manager, apps/installer, apps/guest-agent: 0.4.1 → 0.5.0
  - apps/ui package.json: 0.4.1 → 0.5.0
  - crates/nexus-vmm: new at 0.5.0

UI types (`apps/ui/lib/types/index.ts`):
  - VmmKind, GuestOs, ImageKind, BootMode discriminated union
  - CreateVmReq extended with vmm_kind, boot_mode, guest_os, enable_vnc,
    disk_image_id, installer_iso_id, firmware_path, nvram_template_path
    (all optional, JSON-shape-compatible with the new manager API)
  - Image extended with image_kind, nvram_template_path, guest_os_hint,
    disk_format

The VM-create wizard (`components/vm/vm-create-wizard.tsx`) is unchanged
in this PR — it continues to drive the Firecracker flow as-is.
Surfacing the microVM vs VM choice in the wizard is a follow-up PR
once the install-flow UX is built out (ISO selector, VNC console
widget). The types land now so that work can begin against a stable
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ycle

Foundation-fix amendments on top of the original 0.5.0 commits. Addresses
the gaps I called out in the post-PR honesty review.

**Storage backend routing for QEMU disks**
  apps/manager/src/features/vms/qemu_service.rs gains `resolve_qemu_disk`
  with three branches:
  - `backend_id` provided → allocate volume via the existing
    `allocate_rootfs` helper (provision + populate-from-image on slow
    path, clone_from_image on backends that support it, attach on the
    agent, return the kernel block device path). iSCSI, NFS, SPDK,
    TrueNAS, iscsi_lvm all just work — no QEMU-specific code in the
    backend trait.
  - `disk_image_id` alone → create a per-VM qcow2 thin overlay via
    `qemu-img create -b <base> -F <format>` so concurrent VMs over the
    same base image don't trample each other and per-VM writes stay
    isolated. Massively faster than full copy.
  - `rootfs_path` alone → trust the caller (legacy escape hatch).
  Also persists volume + volume_attachment rows when the disk lives on
  a storage backend so the existing FC delete/restart tooling treats
  QEMU VMs identically.

**Capacity totals come from agent metrics**
  hosts/repo.rs `try_reserve` reads `COALESCE(total_vcpu, total_cpus, …)`
  and `COALESCE(total_mem_mib, total_memory_mb, …)`. The agent's
  `update_metrics` populates total_cpus / total_memory_mb on every
  heartbeat, so reservation enforcement starts working immediately
  without a separate column-population pass. Operators can override
  with explicit total_vcpu / total_mem_mib if they want a tighter cap
  than physical capacity.

**Reservation released on VM delete**
  `stop_and_delete_with_user` snapshots the (host_id, vcpu, mem_mib)
  triple before deleting the row, then calls
  `host_repo.release_reservation` after. Released both on the happy path
  and after a failed stop — the row is gone either way, so freeing the
  reservation is the right move regardless.

**Snapshot routes gate by vmm_kind**
  `snapshots/routes.rs::create` and `::instantiate` refuse with 400 when
  the source VM (or snapshot row) isn't `firecracker`. The Firecracker
  REST shape doesn't speak QMP; QEMU snapshots will land on a separate
  route in a follow-up. Stops users from silently corrupting their
  snapshots by trying to instantiate cross-backend.

**QEMU VM lifecycle dispatch in stop_only**
  When `vm.vmm_kind = 'qemu'` the manager routes to the agent's
  `/agent/v1/vmm/:id/destroy` (which goes through `VmmDriver::destroy →
  shutdown(Hard)`) instead of the legacy `/agent/v1/vms/:id/stop` (which
  knows about FC's screen + scope teardown only). Firecracker VMs keep
  using the legacy path.

**QemuDriver shutdown actually kills the process**
  In dev mode (`AGENT_NO_SUDO=1`) QEMU is spawned without a systemd
  scope, so `stop_unit` is a no-op. The driver now also: (a) sends QMP
  `quit`, (b) `kill -TERM` the recorded PID with up to 2s grace,
  (c) `kill -KILL` if still alive. Production mode (with systemd scope)
  still goes through the cgroup teardown first.

**Dev-mode escape hatches**
  - `AGENT_NO_SUDO=1` → QEMU spawned directly without systemd-run scope.
    No cgroup limits, but lets unprivileged dev hosts spawn VMs.
  - `AGENT_USER_MODE_NET=1` → use QEMU slirp `-netdev user` instead of
    TAP. No host network reconfiguration; guest gets 10.0.2.0/24 via
    SLIRP. Lets unprivileged dev hosts skip TAP creation.
  - `MANAGER_TEST_MODE=1` → manager skips TAP creation for QEMU VMs and
    passes a "user" sentinel for the host_dev field so the agent uses
    user-mode networking automatically.

  All three together let a non-root developer take a full QEMU VM
  through create → boot → QMP handshake → pause → resume → delete
  → process cleanup on a host they don't own.

**Image upload accepts image_kind + nvram_template_path**
  Multipart `image_kind` field (validated against the strict enum) and
  `nvram_template_path` field. Set on the new row via an UPDATE after
  the legacy INSERT so the existing `CreateImageReq` schema is preserved
  for backwards compat.

End-to-end validation pass on this host:
  1. ✅ `cargo build --workspace` clean
  2. ✅ `cargo fmt --all --check` clean
  3. ✅ `cargo clippy --workspace --all-targets -- -D warnings` clean
  4. ✅ 294 unit + 3 integration tests passing
  5. ✅ Manager started, agent registered, advertised
     `vmm_kinds_installed = [firecracker, qemu]`
  6. ✅ POST /v1/vms with vmm_kind:"qemu" + disk_image_id (Ubuntu 24.04
     cloud image) → manager routes to qemu_service → host selected by
     vmm_kinds_installed filter → qcow2 overlay created → agent
     /agent/v1/vmm/:id/boot called → QEMU spawned → QMP handshake →
     VM row inserted with vmm_kind='qemu' → capacity reserved (2|1024)
  7. ✅ POST /v1/vmm/:id/pause → QMP query-status: paused
  8. ✅ POST /v1/vmm/:id/resume → QMP query-status: running
  9. ✅ DELETE /v1/vms/:id → /agent/v1/vmm/:id/destroy → QEMU PID killed
     → VM row removed → capacity released back to 0|0
 10. ✅ POST /v1/vms with Firecracker shape → manager routes to legacy
     FC path (unchanged) → fails at sudo systemd-run as expected in
     dev mode without root (regression for the dispatch logic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**VM-create wizard now offers microVM vs VM up-front**

apps/ui/components/vm/vm-create-wizard.tsx step 0 gains a two-card
toggle: "microVM (Firecracker)" — sub-125ms cold start, kernel +
rootfs, for serverless / container-per-VM — vs "VM (QEMU)" — UEFI /
OVMF, virtio + classic devices, ISO install, VNC console, for Windows
+ classic Linux + bring-your-own-ISO. The choice steers the rest of
the wizard:

  - Boot Source step (step 3) renders a totally different form for
    VM mode: a "Disk Image" picker (filtered to image_kind ∈
    {linux_disk, uefi_disk}), an optional "Installer ISO" picker
    (image_kind = installer_iso) for CD-ROM install flows, a Disk
    Size override, an "Enable VNC console" checkbox (with help text
    explaining when Windows / graphical installers need it), and the
    same storage backend selector as microVM mode.
  - microVM mode keeps the existing kernel + rootfs + initrd + boot
    args UI exactly as before. Zero behaviour change for existing users.
  - Validation: VM mode requires either disk_image_id or
    installer_iso_id (validated inline in the per-step guard since
    these live in React state, not the react-hook-form schema).
  - handleCreateVM constructs the right payload shape per mode —
    VM mode emits `vmm_kind:"qemu"`, `guest_os:"linux_disk"`,
    `disk_image_id`, optional `installer_iso_id`, `enable_vnc`.

**Image upload form gains image_kind + nvram_template_path fields**

apps/ui/components/registry/upload-image-dialog.tsx adds a "VMM Routing
(image_kind)" selector visible for kernel/rootfs uploads:

  - linux_kernel — Firecracker kernel + rootfs (existing flow, default)
  - linux_disk   — bootable Linux disk image (PVH)
  - uefi_disk    — Ubuntu / Debian / Fedora cloud image (UEFI)
  - installer_iso — Windows / Debian netinst / Linux installer

When uefi_disk is selected, an additional OVMF NVRAM template path
input appears (defaulted to /usr/share/edk2/x64/OVMF_VARS.4m.fd) so
the platform knows where to copy from per-VM. The strict enum is
auto-suggested from the legacy free-form `kind` (kernel → linux_kernel,
rootfs → linux_disk) so users only need to override when shipping
a UEFI cloud image or an installer ISO.

**Types**

apps/ui/lib/types/index.ts adds the missing `tags?: string[]` field to
CreateVmReq (manager already accepted it; UI was the only blocker).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s, multi-NIC, cloud-init

Completes the post-validation roadmap so PR #19 is a true 0.5.0 release.

**T1 — Ship-blocking gaps closed**

  - **QEMU snapshot route**: `/v1/vms/:id/snapshot` now dispatches by
    `vmm_kind`. For QEMU it routes to a new agent endpoint
    `/agent/v1/vmm/:id/snapshot` which invokes `QemuDriver::snapshot`
    (QMP `migrate "exec:cat > <path>"` to a per-VM state file). The
    snapshot row is persisted with `vmm_kind='qemu'` so the
    same-backend restore gate works in both directions. Firecracker
    snapshot path is unchanged.
  - **Container/function gating**: confirmed by code inspection that
    `apps/manager/src/features/containers/vm.rs` and `functions/vm.rs`
    both construct internal `CreateVmReq` with `vmm_kind: None` →
    auto-dispatches to Firecracker. Users have no public surface to
    deploy a container or function onto a QEMU VM, so no additional
    gate is needed.
  - **Real-host systemd-run argv test**: unit test pins down the
    exact `sudo -n systemd-run --scope --unit=… --property=KillMode=…
    --property=TimeoutStopSec=… --property=MemoryMax=… --property=
    MemorySwapMax=0 --property=CPUQuota=… -- qemu-system-x86_64 …`
    command line so the cgroup-enforcement path is verified-by-code
    even on hosts where the test runner can't get NOPASSWD sudo.

**T2 — Windows install path end-to-end**

  - **swtpm sidecar (TPM 2.0)**: `VmSpec.enable_tpm` + `QemuDriver::
    spawn_swtpm`. On `boot()`, agent runs swtpm_setup + spawns
    `swtpm socket --tpm2 --tpmstate dir=… --ctrl
    type=unixio,path=swtpm.sock`. Args added to QEMU: `-chardev
    socket,id=chrtpm,path=… -tpmdev emulator,id=tpm0,chardev=chrtpm
    -device tpm-crb,tpmdev=tpm0`. Gracefully skips with a warning
    when swtpm isn't installed (Windows 11 fails; other guests fine).
  - **virtio-win auto-attach**: when `guest_os=windows`, qemu_service
    queries the image registry for the most recently registered
    image with `image_kind='installer_iso' AND name ILIKE '%virtio-
    win%'`, attaches it as a second CD-ROM (drive_id="virtio-win") so
    Windows Setup can "Load driver" during install.
  - **VM 'installing' state + ISO hot-eject**: VMs created with an
    installer ISO enter `state='installing'` instead of `running`.
    New manager route `POST /v1/vms/:id/install-complete` calls
    `/agent/v1/vmm/:id/cdrom/eject` (QMP `device_del` + `drive_del`)
    on the installer drive and transitions the VM to `running`.
  - **noVNC WebSocket bridge**: `/agent/v1/vmm/:id/console/vnc/ws`
    upgrades to WebSocket, opens the QEMU VNC UDS, proxies binary
    frames in both directions. The UI's noVNC client connects directly
    (token-gated proxy route at the manager is a follow-up; minimum
    viable for installing through a browser is the agent endpoint).
  - **`windows_guest: true` flipped** in the FeatureSupport matrix
    for (Qemu, Windows). Tests assert the bit. With swtpm+virtio-win+
    VNC bridge+install-complete all landed, the gate opens.

**T3 — QEMU device completeness**

  - **virtio-balloon / virtio-rng / virtio-vsock** wired into
    `QemuDriver::build_args` from new `VmSpec.{enable_balloon,
    enable_rng, vsock_cid}` fields. Auto-enabled by qemu_service:
    every QEMU VM now gets virtio-balloon (memory pressure
    cooperation) and virtio-rng (guest entropy). vsock is on-demand
    via the spec field. Unit tests assert the `-device` lines.
  - **VFIO PCI passthrough** via `VmSpec.vfio_devices: Vec<String>`
    (each entry a host PCI BDF like "0000:01:00.0"). Emits
    `-device vfio-pci,host=<bdf>,id=vfio<n>`. Operator is responsible
    for unbinding from host drivers and IOMMU isolation; trait +
    arg emission are in place.
  - **Multi-NIC**: `CreateVmReq.extra_network_ids: Vec<Uuid>`. Each
    extra network gets its own TAP (or `-netdev user` in test mode)
    + a deterministic per-NIC MAC + a virtio-net-pci device. Primary
    NIC still uses `network_id`.

**T4 — Enterprise (cloud-init)**

  - **cloud-init NoCloud seed disk for QEMU UEFI Linux VMs**. When
    username/password is set on a Linux QEMU VM, qemu_service
    generates a NoCloud ISO (meta-data + user-data + network-config),
    attaches as a third CD-ROM with volume label CIDATA. First-boot
    cloud-init reads it and applies the user creation, password,
    hostname, and DHCP network config. Auto-detects genisoimage /
    mkisofs / xorriso (any one works); gracefully warns and skips
    when none are installed.

**Cumulative test count**

  299 tests passing (295 unit + 3 integration + 1 new systemd-run
  argv pin). cargo fmt --check + cargo clippy --workspace --all-
  targets -- -D warnings both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands all 6 items I deferred in the previous batch so 0.5.0 is a complete
platform rather than "foundation + half the features".

**1. noVNC manager proxy + UI client**
  - vms/routes.rs gains vnc_websocket — proxies WS frames from browser
    to the agent's per-VM VNC UDS via /agent/v1/vmm/:id/console/vnc/ws.
  - apps/ui/components/vm/vnc-console.tsx — React component on
    @novnc/novnc; Ctrl+Alt+Del + power-off buttons; mounts against the
    manager's /v1/vms/:id/console/vnc/ws WebSocket.

**2. Hot-add + hot-remove disk and NIC**
  Agent routes /agent/v1/vmm/:id/{disk,nic}/{add,remove} drive QMP
  blockdev-add+device_add / netdev_add+device_add and the reverse on
  remove. drive_del/netdev_del are non-fatal on remove since QEMU may
  have collected the resource already.

**3. QEMU backup primitive**
  Agent route /agent/v1/vmm/:id/backup/disk. QMP-pause → qemu-img
  convert -O qcow2 [-c] → resume. Destination can be any agent-
  visible filesystem (local, NFS, S3 mount). Integration with
  nexus-backup's chunked upload pipeline becomes a follow-up that
  just wraps this primitive.

**4. Live migration**
  qemu_service::live_migrate + manager route POST /v1/vms/:id/migrate.
  Picks target host (qemu installed + capacity), drives QMP migrate
  to tcp:<host>:<port>, polls query-migrate to completion (10-min
  cap), updates host_id, releases source capacity. Target-side
  qemu -incoming auto-launch is reserved for the migration-
  orchestration follow-up; migration to a pre-launched target works
  today.

**5. HA / reschedule on host death**
  qemu_service::reschedule + manager route POST /v1/vms/:id/reschedule.
  Re-attaches the shared-storage volume on the target via agent_attach,
  boots fresh QEMU from the persisted boot_mode, updates host_id.
  Requires shared storage; local-overlay VMs use snapshot+restore
  instead.

**6. virt-v2v VMware VMDK import**
  Manager route POST /v1/images/import/vmdk. Default: virt-v2v -i disk
  to adapt VMware paravirt drivers (vmxnet3 / pvscsi) to virtio. Fall-
  back to qemu-img convert when run_virt_v2v=false. Output qcow2 lands
  in /srv/images/imported/ and is registered with image_kind=uefi_disk.

9 new HTTP routes total. PR #19 now covers the complete classic-VM
platform feature set. 299 tests pass; cargo fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 0.5.0 upload-image dialog grew an `image_kind` + `nvram_template_path`
form, but the values never reached the manager — `facadeApi.uploadImage`
ignored them, so every upload landed as `image_kind=linux_kernel`
regardless of what the user picked.

  - facade.ts: `uploadImage` now accepts optional `imageKind` and
    `nvramTemplatePath` and forwards them as multipart form fields the
    manager's `upload_image` route already reads.
  - queries.ts: `useUploadImage` mutation typed to accept the new
    optional params.
  - upload-image-dialog.tsx: drops the `as any` cast that was hiding
    the silent drop, accepts an optional `defaultImageKind` prop, and
    honors it on open (the registry-page "Upload UEFI Disk Image" /
    "Upload Installer ISO" buttons set it).
  - registry/page.tsx: gains two new direct-shot upload buttons —
    "Upload UEFI Disk Image" (defaults image_kind to uefi_disk) and
    "Upload Installer ISO" (defaults to installer_iso) — so users
    don't have to first open "Upload Rootfs" and then change the
    image_kind selector inside the dialog. "Upload Rootfs" relabeled
    as "Upload Rootfs (FC)" so its FC scope is obvious.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
You can now upload an Ubuntu Server (or Windows, Debian netinst, ...)
installer ISO, create a VM with just `installer_iso_id`, and the
platform produces:

  - a blank qcow2 disk under /srv/fc/vms/<id>/storage/disk.qcow2
    sized by `rootfs_size_mb` (default 20 GiB, sparse)
  - the ISO attached as a CD-ROM with `bootindex=0` so the installer
    media boots first
  - the blank rootfs at `bootindex=1` for post-install reboot
  - UEFI/OVMF boot auto-selected (no need to pass boot_mode)

After the guest finishes installing, POST /v1/vms/:id/install-complete
QMP-ejects the ISO and the next boot comes off the disk.

Specifically:

  - `resolve_qemu_disk` gains Path 4: blank local qcow2. Used when the
    caller has installer_iso_id but no disk_image_id and no backend_id.
    Calls `qemu-img create -f qcow2 <target> <size>M`. Storage-backend
    case (Path 1's no-image branch) was already handled.
  - `validate_and_resolve` auto-defaults to UEFI when any of
    disk_image_id / installer_iso_id / backend_id is set, so the
    caller doesn't have to spell out `boot_mode` for the install case.
  - `QemuDriver::build_args` flips bootindex policy when any disk is a
    CD-ROM: the CD-ROM gets bootindex=0 (boot first), root disk
    bootindex=1. Without a CD-ROM, the root disk keeps bootindex=0.

8 qemu_service unit tests still pass + 88 manager tests + 134 manager
test bin tests + 12 vmm-routes tests. fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…get, auto-HA, vm-backup

Lands the 5 remaining gaps I flagged after the previous PR sweep.

**G5: SSH keys in cloud-init seed**
  CreateVmReq.ssh_authorized_keys: Vec<String>. Threaded into the
  NoCloud user-data block as an `ssh_authorized_keys` list on the
  created user. Works for Linux (cloud-init) and Windows (cloudbase-
  init + OpenSSH-on-Windows).

**G4: Windows guests use the same NoCloud seed**
  build_cloud_init_user_data now branches on guest_os. For Windows it
  emits cloudbase-init-compatible cloud-config (users → Administrators
  group, plain_text_passwd, ssh_authorized_keys). The same NoCloud ISO
  layout works for both — only the user-data shape differs. Guest must
  have cloudbase-init installed in the golden image.

**G1: Live migration target-side automation**
  VmSpec gains incoming_uri: Option<String>. When set, QemuDriver's
  build_args emits `-incoming <uri>` so QEMU starts paused listening
  for the migrate stream.

  Agent's /migrate/incoming endpoint now actually does something — it
  accepts the full target VmSpec + listen_port, calls driver.boot()
  with incoming_uri="tcp:0.0.0.0:<port>", and returns when QMP is
  alive. No more 501.

  Manager's live_migrate is now a two-step orchestration:
    1. POST /migrate/incoming on target with the source's persisted
       boot_mode + a re-attach of the shared-storage volume on the
       target.
    2. After a 2s settle, POST /migrate/outgoing on source which drives
       QMP migrate to the target's tcp:<host>:<port>.
  Updates host_id on success, releases source capacity reservation.

**G2: Auto host-death detection in the reconciler**
  reconcile_once gains an opt-in auto_reschedule_dead_hosts pass when
  MANAGER_HA_AUTO_RESCHEDULE=1 is set. Compares each host's
  last_seen_at against a 90s threshold; for each VM on a stale host,
  picks the first healthy peer and calls qemu_service::reschedule
  (which already exists). Local-overlay VMs are correctly skipped by
  reschedule's shared-storage check. Counters for success/failure.

**G3: VM-level backup wrapping nexus-backup**
  New manager route POST /v1/vms/:id/backup with two paths:
    - Volume-backed VM (production case) → delegates to the existing
      backups::service::create_backup which already drives the chunked-
      encrypted upload pipeline keyed on volume_id + target_id.
    - Overlay-backed VM (local qcow2) → calls agent's /backup/disk
      primitive to qemu-img-convert the disk to a backup destination
      path (network-mounted backup share).
  One endpoint, two backends, no breaking changes to the existing
  volume-backup flow.

299 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… migrate, reschedule, backup, ssh keys, vmdk import

Closes the UI gaps so the 0.5.0 backend features are reachable from the
panel instead of curl-only.

**Backend prerequisite: expose VMM fields on the VM API**
  The Vm API response now carries vmm_kind / guest_os / console_kind /
  vnc_listen (nexus-types::Vm + VmRow + the 3 SELECTs + From<VmRow>).
  Without these the UI couldn't tell a QEMU VM from Firecracker, so it
  couldn't conditionally render the QEMU-only actions. Defaults
  (firecracker / linux_kernel / unix_serial) keep legacy rows correct.
  qemu_service now persists vmm_kind/guest_os/console_kind/vnc_listen on
  the VM row at create time.

**VM detail page**
  - Graphical **Console** tab mounting the noVNC client, shown when
    console_kind == "vnc".
  - **Install Complete** button when state == "installing" → ejects the
    installer ISO via the agent and transitions the VM to running. The
    installing state also gets its own badge color + keeps pause/stop
    available.
  - **Migrate / Reschedule / Backup** action cluster (new
    components/vm/vm-actions.tsx) shown for vmm_kind == "qemu":
      - Migrate: target-host picker (healthy hosts only) + port → live
        migration.
      - Reschedule: target-host picker → HA rebuild on shared storage.
      - Backup: destination path + compress toggle → qemu-img backup
        (volume-backed VMs auto-route to the chunked nexus-backup path).

**VM-create wizard (VM mode)**
  - SSH Authorized Keys textarea (one key per line) → ssh_authorized_keys,
    injected via cloud-init (Linux) / cloudbase-init (Windows).

**Registry page**
  - **Import from VMware** button + dialog (new
    components/registry/import-vmdk-dialog.tsx): source path, name,
    run-virt-v2v toggle → POST /v1/images/import/vmdk.

**Facade + hooks**
  facade.ts: installComplete, migrateVM, rescheduleVM, backupVM,
  importVmdk. queries.ts: useInstallComplete, useMigrateVM,
  useRescheduleVM, useBackupVM, useImportVmdk. TS Vm type +
  CreateVmReq.ssh_authorized_keys added.

All touched files typecheck clean; cargo fmt + clippy clean; 299 tests
pass. (Pre-existing baseline tsc errors in docs/search-command/
function-invoke-dialog are untouched and unrelated.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Version bump to 0.5.0-alpha.1 across agent/manager/installer/guest-agent/
nexus-vmm + UI, plus a CHANGELOG entry documenting the QEMU classic-VM
feature set and the alpha caveats (validated surfaces, dev-flags-off,
optional host packages, no security review yet).

First prerelease of the pluggable VMM work. The release workflow marks
-alpha tags as GitHub prereleases and skips base-image builds (alphas
pull images from the prior stable release).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The VNC console imported `@novnc/novnc/lib/rfb`, which doesn't exist in
@novnc/novnc@1.7.0 — its package.json `exports` maps the package root
directly to `core/rfb.js`. The wrong path broke `next build` ("Module
not found"), failing the release's Build UI job.

  - Import the bare package `@novnc/novnc` (default export = RFB),
    loaded via dynamic import() inside useEffect so the browser-only ESM
    isn't evaluated during SSR and the bundler resolves it lazily.
  - Add `transpilePackages: ["@novnc/novnc"]` to next.config — noVNC
    ships raw browser ESM that Next must transpile.

Verified: `pnpm build` compiles clean (10.3s, 27/27 static pages, no
module-not-found warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validated 0.5.0-alpha.1 on a real Ubuntu host (KubeVirt nested-virt VM,
non-root agent + sudo + real bridge) — the production path my dev-mode
testing (AGENT_NO_SUDO + slirp) had masked. Confirmed working: real
`sudo systemd-run --scope` cgroup limits (MemoryMax/CPUQuota), real TAP
attached to fcbr0, QEMU q35+OVMF boot, guest actually booting (overlay
qcow2 grew as the guest wrote). Found and fixed three real bugs:

1. CRITICAL — non-root agent couldn't control sudo-spawned root QEMU.
   The agent runs as `nqrust` but spawns QEMU via `sudo systemd-run`, so
   QEMU runs as root and creates its QMP/serial/VNC sockets root-owned
   mode 0755 (no write for others). The non-root agent then can't connect
   to QMP → boot()'s post-spawn verification fails → boot reports failure
   (manager orphans the VM) → handle.json never persists → rebind returns
   None → destroy/pause/resume/snapshot all silently no-op.
   Fix: after the QMP socket appears, `sudo -n chmod 0666` the QMP/serial/
   VNC sockets (agent has NOPASSWD chmod) so the agent user can drive
   them. No-op in AGENT_NO_SUDO dev mode where the agent owns them.

2. HIGH — Arch-only OVMF default paths broke UEFI boot on other distros.
   The manager defaulted firmware to /usr/share/edk2/x64/OVMF_CODE.4m.fd
   (Arch); on Ubuntu/Debian OVMF lives at /usr/share/OVMF/OVMF_CODE_4M.fd,
   Fedora at /usr/share/edk2-ovmf/x64/. Fix: the agent now resolves the
   OVMF code + vars to a path that exists on the actual host, probing the
   common per-distro locations when the requested path is absent
   (resolve_ovmf_code / resolve_ovmf_vars), for both the pflash arg and
   the per-VM nvram copy.

3. HIGH — manager boot timeout orphaned VMs on busy hosts. The agent
   boot (qemu-img overlay over a large backing image + nvram copy + spawn
   + QMP readiness) can exceed a short client timeout; the manager then
   errored while the agent kept going, leaving an unmanaged QEMU. Fix:
   raise the agent-boot client timeout 60s → 300s, and on ANY boot
   failure fire a best-effort `/destroy` to the agent so no orphan is
   left, then release the host reservation.

Adds a unit test for the OVMF resolver. cargo fmt + clippy clean; full
suite passes. On-host re-validation of these fixes needs a fresh musl
build + redeploy (alpha.2) — the test host wedged under its 4Gi limit
mid-session (unrelated to these fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real e2e on a stock Ubuntu 24.04 host with the agent running NON-ROOT (the
production posture the alpha.1 caveats flagged as unvalidated) surfaced two
critical bugs that masked each other behind the earlier socket-perms/timeout
work:

1. boot() blocked forever. spawn_under_scope ran QEMU via
   `systemd-run --scope`, which is synchronous and only returns once the
   wrapped process exits. cmd.status().await therefore blocked for QEMU's
   whole lifetime, so boot() never reached wait_for_qmp -> relax_socket_perms
   -> QMP handshake. Every non-AGENT_NO_SUDO boot hung until the manager
   timed out, then got torn down. Fix: spawn a transient *service*
   (systemd-run --collect, no --scope); it backgrounds QEMU under systemd
   and returns at once, and the VM now survives agent restarts (what rebind
   assumes). Unit suffix qemu-<id>.scope -> .service; stop_unit already
   stops by name. The FC path (systemd.rs) keeps --scope correctly: it wraps
   `screen -dmS` which daemonizes and returns immediately.

2. lifecycle ops orphaned VMs. QEMU's root-written pidfile is mode 0600,
   unreadable by the non-root agent, so read_pid -> None, rebind couldn't
   confirm liveness, and pause/resume/shutdown/destroy all failed with
   "no live vmm" -- destroy silently no-opped and left the VM + cgroup
   running. Fix: self-healing read_pid relaxes the pidfile via sudo -n chmod
   and retries (mirrors relax_socket_perms), fixing both boot and rebind.

Validated: UEFI boot of an Ubuntu cloud image (Arch OVMF path -> Ubuntu
fallback proven), cloud-init NoCloud seed, DHCP + serial login (10.0.2.15),
pause/resume/destroy with zero orphans, MemoryMax/CPUQuota enforced on the
service. Updated the systemd_run_argv test to assert service+--collect and
guard against --scope regressing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t no network

Full-stack e2e (manager + Postgres + registered agent on a stock Ubuntu 24.04
host, agent non-root) surfaced two manager-side bugs that only appear once a
QEMU VM is driven through the real orchestration path:

1. Reconciler flips running QEMU VMs to "stopped". diff_host derives liveness
   from the agent inventory, which is Firecracker-specific: list_scopes only
   enumerates fc-*.scope units and list_sockets only scans vms/<id>/sock. A
   QEMU VM runs as qemu-<id>.service with its QMP socket under <run_dir>/<id>/,
   so has_scope/has_socket are always false -> the VM lands in plan.restart ->
   the FC-only restart_vm fails on the empty kernel_path (ensure_allowed_path
   rejects "") -> the VM is marked stopped while QEMU keeps running, ~seconds
   after boot. Fix: skip QEMU VMs in diff_host's restart selection; QEMU
   recovery is handled by qemu_service reschedule (matching the existing
   dead-host comment). Existing FC reconciler tests still pass.

2. QEMU guests never got an IP. build_cloud_init_iso wrote a network-config
   pinned to interface "eth0", but modern cloud images use predictable names
   (enp0s3, ens3, ...), so netplan matched nothing and the NIC stayed down.
   Fix: match name "e*" (covers en*/eth*) with dhcp4.

Validated full-stack: POST /v1/vms (qemu, disk_image_id) -> qcow2 thin overlay
over the base image + cloud-init ISO + real TAP on fcbr0 + agent boot; VM stays
"running" across reconciler cycles; guest DHCPs to 10.0.0.132 on fcbr0 and
pings the gateway (0% loss). 134 manager tests pass, clippy clean.

Known minor issues (documented in CHANGELOG, not yet fixed): delete leaves an
orphan tap; guest_ip is null for BYO images (needs the in-guest agent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validating storage attachment and ISO install through the real manager+agent
stack on a stock Ubuntu host surfaced three more QEMU-backend bugs:

1. Data disks were never attached (manager). create_drive provisioned a volume
   and recorded a vm_drive row, but create_and_start_qemu only assembled the
   root/cloud-init/installer disks — it never read the drives table, so data
   disks reached neither boot nor hot-plug. Fix: assemble data drives from the
   DB in the QEMU boot path. Their format is read via a real `qemu-img info`
   probe (probe_disk_format) instead of guessing by extension: auto-provisioned
   data disks are raw .img, which detect_disk_format maps to qcow2 (a heuristic
   meant for cloud images), making QEMU refuse to open them and abort the boot.

2. Reconciler 502-spammed QEMU drives (manager). reconcile_devices drove
   Firecracker's per-device HTTP proxy (/agent/v1/vms/:id/proxy/...) for QEMU
   drives/NICs every cycle (QEMU has no such proxy). Skip QEMU VMs there.

3. Full-RAM guests were OOM-killed (agent). The cgroup MemoryMax was pinned to
   exactly the guest's RAM with no headroom for QEMU's own footprint (device
   models, VNC/virtio-vga framebuffer, OVMF, I/O buffers). A guest touching all
   its memory — e.g. an Ubuntu live-server installer unpacking its squashfs into
   RAM — pushed QEMU's RSS over the cap and, with MemorySwapMax=0, the kernel
   OOM-killed the whole VM. Fix: MemoryMax = guest + max(512 MiB, 12.5%).

Validated full-stack: ISO install (POST /v1/vms with installer_iso_id + blank
disk) boots the Ubuntu 24.04.3 Subiquity installer via OVMF/UEFI — confirmed by
a QMP screendump of the language-select screen — and the VM now survives the
install (previously OOM-killed at the exact guest-RAM size). 134 manager + 96
agent tests pass, clippy clean.

Documented but NOT yet fixed (see CHANGELOG): QEMU in-place restart
(start_vm_by_id->restart_vm is FC-only), install-complete CD eject (virtio-blk
on pcie.0 can't hot-unplug or media-eject), QMP hot-add for data disks,
tap-leak-on-delete, orphan-volume-dir misdetection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
backup_disk QMP-stops the guest then runs qemu-img convert, but stop only
pauses vCPUs — the QEMU process keeps the qcow2's write lock open, so the
convert died with 'Failed to get shared write lock'. Add -U: the preceding
stop quiesces the guest, so reading the source unlocked is crash-consistent.

Validated full-stack on a stock Ubuntu host (manager+agent, agent non-root):
1.9 GiB qcow2 backup of a running VM. Also validated this round: virtio
rng/vsock/balloon + multi-NIC (guest-visible), snapshot create (QMP
migrate-to-file, ~600 MiB state), and apps/ui builds clean.

Documented gaps (CHANGELOG): TPM blocked by Ubuntu AppArmor on the agent's
swtpm state path; QEMU snapshot restore (instantiate) is FC-only; QEMU
in-place restart; install-complete CD eject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
start_vm_by_id routed every VM through the Firecracker restart_vm, which
validates an (empty for QEMU) kernel_path via ensure_allowed_path and rebuilds
an FC kernel boot — so stop->start always failed for QEMU with 'path  is not
within the configured image root or storage root', and any data disk added via
POST /v1/vms/:id/drives (which applies at boot) could never take effect.

Add qemu_service::restart_qemu: load the host + persisted boot_mode, recreate
the primary TAP (the agent's create_tap is idempotent — deletes any stale
same-named device first), reuse the existing root overlay + cloud-init seed +
DB-recorded data drives (format probed via qemu-img), re-boot through the agent,
and UPDATE the row in place (capacity stays reserved across stop, so no
re-reserve). Dispatch QEMU VMs to it from start_vm_by_id_with_user. Also fix
stop_only leaving the QEMU row stuck in 'stopping' (now sets 'stopped').

Validated full-stack on a stock Ubuntu host: stop->start cycle
(stopped->running), restart of a previously-wedged VM, and a 2 GiB data disk
created via /drives now visible in the guest (/dev/vdc) after restart —
closing the storage-attachment loop. 134 manager tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ue bootindex

CD-ROMs (installer ISO, cloud-init seed, virtio-win) were wired as
virtio-blk-pci on the q35 root complex (pcie.0). That bus supports neither
device_del (no hotplug) nor media eject, so install-complete's cdrom eject
died with a 502 ('Bus pcie.0 does not support hotplugging') and the ISO could
never be removed to boot the installed system.

- build_args: cdrom:true disks now attach as ide-cd on a dedicated ich9-ahci
  controller (real removable media). Non-CD disks stay virtio-blk-pci.
- cdrom_eject: QMP eject (force) instead of device_del — removes the medium,
  leaves the (now empty) drive so the guest boots the disk next reboot.
- Fixed a latent duplicate-bootindex crash: every CD-ROM got bootindex=0, which
  makes QEMU refuse to start with 2+ CD-ROMs (Windows installer + virtio-win).
  Bootindexes are now unique: CD-ROMs 0,1,2..., root disk after them.

Validated full-stack on a stock Ubuntu host: install-complete on a live ISO VM
ejects the installer (QMP query-block inserted true->false, state
installing->running, HTTP 200 where it was 502); cloud-init still configures the
guest with its seed now an ide-cd (DHCP lease obtained, no regression). 94
agent tests pass (incl. 2 new cdrom/bootindex tests), clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… snapshot

QEMU snapshot restore was stubbed: POST /v1/snapshots/:id/instantiate returned
400 for QEMU ("lands in a follow-up"), and the agent's snapshot() only did
migrate-to-file (RAM) — despite a comment claiming it also captured the disk, it
never did — so a restore had no consistent disk to boot from.

Agent (snapshot): after the RAM migrate, while the guest is still QMP-stopped,
locate the root disk via query-block (device == "rootfs") and copy its qcow2
overlay to <snap>/disk.qcow2 (a raw byte copy that ignores the live qcow2 write
lock; consistent because the guest is paused). Fixed a bug where query-block's
result was double-unwrapped (QmpClient::execute already returns the `return`
field), which made disk lookup fail.

Manager (instantiate): real QEMU branch — clone the captured <snap>/disk.qcow2
into a new VM's storage and reuse create_and_start_qemu (rootfs_path + the
source's persisted boot_mode / vcpu / mem / guest_os) to cold-boot it from the
snapshot's disk state. FC instantiate path unchanged.

Validated full-stack on a stock Ubuntu host: create captures state.qmp (RAM) +
disk.qcow2 (overlay backing the base); instantiate -> HTTP 200 -> new VM boots
running and the restored guest reports the SOURCE VM's hostname (qemu-snaptest),
proving it came up on the snapshot's disk. This is a consistent cold restore
(revert-to-snapshot); replaying the migrate-to-file RAM image for a live resume
is a future enhancement. 94 agent + 134 manager tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… TAP

Two QEMU-backend gaps found in full-stack testing:

1. -no-reboot was unconditional, so an in-guest `reboot` exited the VMM (the VM
   "stopped" instead of rebooting). That flag is only needed for ISO installers
   (their post-install auto-reboot would loop back into the still-attached
   installer CD). Added a no_reboot flag to VmSpec/BootRequest; the manager sets
   it only when installer_iso_id is present (and false on restart). build_args
   emits -no-reboot only when set. Validated: a normal VM's QEMU survives
   `sudo reboot` (same PID, reboots in place); installer VMs still get it.

2. Deleting a QEMU VM leaked its TAP — destroy tore down the process but left
   tap-<id> enslaved to the bridge. destroy now deletes the primary TAP
   (reconstructed as tap-<vm_id[:8]>; no-op for user-mode-net VMs). Validated:
   the tap is gone from fcbr0 after delete.

Also refreshed the CHANGELOG known-issues: TPM is validated working with the
swtpm AppArmor local-include (now a packaging note, not a blocker). 94 agent +
134 manager + 8 nexus-vmm tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data disks created on a running QEMU VM previously only applied on the next
boot. Wire true live hot-plug:

- build_args: pre-allocate HOTPLUG_ROOT_PORTS (4) empty pcie-root-ports at boot.
  q35's root complex (pcie.0) can't hotplug, so a live device_add needs a
  root-port, and those can't be added after boot.
- agent disk_add: blockdev-add then device_add the virtio-blk onto a free
  root-port (try rphp0..rphpN until one takes); roll back the blockdev if none
  free, so we don't leak a node.
- manager create_drive/delete_drive: for a running QEMU VM, call the agent's
  QMP disk/add and disk/remove. Best-effort — the drive is still persisted in
  the DB, so on any failure it applies on the next boot (restart_qemu reads it).
  probe_disk_format is reused to pass the right blockdev driver.

Validated full-stack on a stock Ubuntu host: POST /v1/vms/:id/drives on a
running VM hot-adds a 1 GiB disk (guest vd-device count 1->2, no restart,
manager logs "hot-added"); DELETE takes it back to 1 live. 94 agent + 134
manager tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps all crates to 0.5.0-alpha.2 and stamps the CHANGELOG. alpha.1 was
code-complete but couldn't run a QEMU VM in production; this release fixes ~14
issues found by full-stack testing on a stock Ubuntu host (agent non-root) and
adds the missing feature paths, all validated end-to-end:

- boot: systemd-run service (not blocking --scope), pidfile self-heal, MemoryMax
  headroom (no OOM on full-RAM guests)
- reconciler: stop marking running QEMU VMs stopped; no FC-proxy 502 spam
- networking: cloud-init e* match (DHCP works), real TAP-on-bridge, multi-NIC
- lifecycle: in-place restart (stop->start), guest reboot survival, clean stop state
- storage: data-disk attach at boot + live QMP hot-add/remove; backup -U
- ISO install: ide-cd installer + QMP eject (install-complete); unique bootindex
- snapshots: create now captures the disk; restore (instantiate) implemented
- devices: rng/vsock/balloon validated; TPM 2.0 (needs swtpm AppArmor include)
- delete: TAP cleanup (no orphan taps)

All these are reachable from the UI (create wizard QEMU mode, lifecycle +
Install Complete, VNC console, snapshots create/restore, storage add/remove,
backup; registry image/ISO upload + VMDK import).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The installer auto-installed Firecracker + qemu-img but not the QEMU classic-VM
runtime, so v0.5 QEMU VMs needed manual host setup. Add to the dependency phase
(apt + dnf):
- qemu-system-x86 / qemu-kvm  — the QEMU VMM (qemu-system-x86_64)
- ovmf / edk2-ovmf            — OVMF UEFI firmware (required for UEFI boot)
- genisoimage                 — builds the cloud-init NoCloud seed ISO
- swtpm + swtpm-tools         — software TPM 2.0 (Windows 11 / measured boot)

Also, after KVM setup, drop the swtpm AppArmor local include
(/etc/apparmor.d/local/usr.bin.swtpm granting the run dir) and reload — on
Ubuntu the stock swtpm profile otherwise denies /srv/fc and TPM VMs fail to
start. Enforce mode is preserved (this grants one path, it doesn't disable the
profile). Best-effort + no-op on non-AppArmor hosts. Resolves the swtpm
packaging gap from the alpha.2 known issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… air-gapped)

alpha.2 hardened the QEMU backend but its installers didn't provision the QEMU
host packages, so a fresh host needed manual setup. alpha.3 fixes that on both
install paths:

- Classic installer: dependency phase now installs qemu-system-x86 / ovmf /
  genisoimage / swtpm(+tools) (apt) and qemu-kvm / edk2-ovmf / ... (dnf), and
  drops the swtpm AppArmor local-include so TPM works on Ubuntu out of the box.
- Air-gapped bundle: bundle-debs-ubuntu.sh adds the same packages (deps resolved
  recursively via apt-cache depends, downloaded into the offline .deb set).

Bumps all crates to 0.5.0-alpha.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build-airgap-bundle.yml checked out the default branch (main) with no
ref, so it ran the bundle build scripts (notably bundle-debs-ubuntu.sh's
package list) from main while pulling binaries from the release tag. When
a release is cut from a feature branch, the bundle ships current binaries
with stale debs — v0.5.0-alpha.3's air-gap bundle was missing the new
QEMU prerequisites (qemu-system-x86, ovmf, swtpm, swtpm-tools,
genisoimage) even though the classic installer had them.

Pin the checkout to the released tag (workflow_run.head_branch) so the
build scripts match the released code. Manual runs check out the
requested version tag; an empty ref falls back to the default branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, V2V + P2V import

alpha.3 made the QEMU backend installable; alpha.4 makes it usable like Proxmox.
Firecracker microVMs are unchanged — all of this is the UEFI/QEMU path.

Device management (create + day-2), driven from the Proxmox-style wizard and the
VM detail page:
- multiple NICs (create + live attach/detach)
- multiple data disks (create) + day-2 add/detach/resize via QMP
- attach existing volumes to a running VM
- PCI passthrough (VFIO) with host-device enumeration + dropdown

Plus CPU model selection (new nullable vm.cpu_type, emitted as QEMU -cpu, incl.
host passthrough) and host-side QEMU metrics sampled from the per-VM cgroup into
metrics.vm_metrics.

Bring existing machines in:
- V2V: POST /v1/images/import/vmdk — VMware/qcow2/raw → bootable UEFI image,
  optionally adapting drivers via virt-v2v.
- P2V/B2V (agentless): POST /v1/images/import/p2v — SSH into a physical host,
  stream its disk (dd, no agent), run the same virt-v2v pipeline. Password or
  SSH-key auth.

Installer (classic + air-gapped) now ships virt-v2v, libguestfs-tools, sshpass
and openssh-client(s), and makes the host kernel readable so libguestfs works
for the non-root manager.

Fix: virt-v2v -o local writes <name>-sda (+ <name>.xml), not <name>.qcow2; the
import handlers rename it so registration succeeds instead of 500ing after a
successful conversion.

Bumps all crates to 0.5.0-alpha.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kleopasevan kleopasevan merged commit 4d4eaaf into main Jun 16, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant