Most organisations that leave a single proprietary hypervisor do not land on a single replacement. Long-lived server VMs want a classic virtualization cluster: shared storage, live migration, fencing, a console. Teams that already run Kubernetes want their VMs next to their pods, in the same namespaces, under the same GitOps and RBAC. Both are reasonable, and forcing everything onto one engine usually means one group gives up something it needs.

Victor’s answer is to manage both from one place. KVM virtual machines run on clustered hosts through upstream Incus; KubeVirt virtual machines run as Kubernetes resources. Victor is the control plane and console above them. This post explains what that unifies, what it deliberately leaves visible, and how to decide which engine a workload belongs on.

How each engine works#

KVM on clustered hosts#

The classic runtime is QEMU/KVM, managed through Incus (7.0 LTS, which is Victor’s target, and the latest stable release). An Incus cluster is a set of Linux hosts that share a database of instances, storage pools and networks. A VM is a QEMU process on one member; its disks live on a storage pool (a local directory, LVM thin, ZFS, btrfs, Ceph RBD, or NFS through a clustered LVM volume group); its NICs attach to a bridge, an OVN network or macvlan. Live migration moves the QEMU process between members. Victor’s own Victor Host image packages this stack, but it does not fork it: the packages carry only upstream backports.

This is the model anyone coming from vSphere already knows: hosts, clusters, datastores, port groups, and an HA function that restarts VMs when a host fails.

KubeVirt on Kubernetes#

KubeVirt (Victor supports 1.8 and 1.9, the current and previous minor releases, with CDI) adds a VirtualMachine custom resource to Kubernetes. Each running VM is a virt-launcher pod containing a QEMU process, scheduled by the Kubernetes scheduler like any other pod. Disks are PersistentVolumeClaims, usually populated by CDI (the Containerized Data Importer). Networking starts with the pod network and adds secondary networks through Multus. Snapshots, clones and exports are further custom resources (VirtualMachineSnapshot, VirtualMachineClone, VirtualMachineExport), each behind a feature gate.

The model here is Kubernetes: nodes, namespaces, storage classes, NetworkPolicy, and controllers that reconcile desired state. Failure handling belongs to Kubernetes and its node remediation operators, not to a hypervisor HA agent.

What Victor unifies#

Victor puts both engines into one inventory and one operating model:

  • One inventory tree. Datacenters, clusters, hosts, datastores, networks, templates and VMs, in a vSphere-like layout. A Kubernetes node pool stands in for a cluster and a namespace acts as the resource pool, so a KubeVirt VM has a place in the same tree as a KVM VM. Tags, folders and notes apply to VMs; permissions apply to every object.
  • One task model. Every change is a task with a state, steps and an owner, retried idempotently with a request ID. Recent tasks are docked at the bottom of the Classic view and behind a Tasks button in the Victor view.
  • One permission model. Roles and privileges are checked by Victor before anything reaches an engine, on either runtime.
  • One placement layer. VM/host rules, DRS and HA admission control are configured the same way on both (with differences covered below).
  • One audit trail. Every action, refusal and console session is an audited event, regardless of engine.
  • One API. The UI, victorctl, the Terraform provider and MCP all use the same public API. The runtime is a field on the request:
# KVM VM in a classic cluster
victorctl vm create web-01 --cluster cl-a --template debian/12 --cpu 2 --mem 4 --disk 40 \
  --datastore ceph-ssd --network vlan-204-web --folder Production/Web --on --wait

# KubeVirt VM in a namespace (the template is a CDI golden image)
victorctl vm create api-01 --runtime kubernetes --cluster NAMESPACE_ID --template datasource:os-images/rhel9 \
  --cpu 4 --mem 8 --disk 60 --on --wait

--runtime defaults to classic; with kubernetes, --cluster takes a namespace ID instead of a cluster ID. In the UI, the runtimes are labelled Classic and Kubernetes and keep their own colours in both console views.

Both engines are part of Victor Community (Apache-2.0). Choosing KubeVirt, KVM or both is not an Enterprise unlock.

What Victor deliberately does not hide#

A unified console is tempting to design as a lowest common denominator, or worse, as a facade that pretends every engine can do everything. Victor does neither. Each engine reports its capabilities per cluster, and an action an engine cannot perform is refused before anything runs, with the reason. The UI greys out the same control with the same reason. A KubeVirt cluster that lacks the Snapshot feature gate still registers; its snapshot feature is disabled with “enable KubeVirt’s Snapshot feature gate”.

The table below summarises the differences that matter in practice, from Victor’s engine documentation.

Capability KVM (Classic) KubeVirt (Kubernetes)
Consoles Serial and graphical (SPICE, in the browser) Serial and graphical (VNC)
Firmware default UEFI with Secure Boot (off for a template whose boot loader isn’t signed) BIOS; UEFI variables are not persisted (Victor doesn’t set efi.persistent)
Snapshots Disk snapshots, quiesced through the guest agent, and stateful snapshots with memory Disk-only VirtualMachineSnapshot (needs the Snapshot gate); snapshots with memory are not available
Networks Bridge, OVN and macvlan networks; IP pools; built-in DHCP and DNS on bridge/OVN Multus NetworkAttachmentDefinitions; the pod network is Kubernetes’ own and Victor doesn’t change it; DHCP and built-in DNS refused
Network ACLs (per-VM firewall) On bridge and OVN networks (security.acls) Refused: KubeVirt traffic is filtered by Kubernetes NetworkPolicy, which Victor doesn’t manage
Datastores Incus storage pools: dir, LVM thin, ZFS, btrfs, Ceph RBD, NFS (clustered LVM on the export) Storage classes: local-path, CSI drivers, Ceph RBD through ceph-csi
Live storage move Root disk moves live in a cluster while the VM live-migrates; standalone servers need a stop Live with KubeVirt volume migration (1.5+, LiveUpdate rollout, two nodes); otherwise a stop and a CDI clone
HA and fencing Victor fences the failed host and restarts VMs on shared storage, in priority order KubeVirt restarts VMs after a Node Health Check fence agent remediates the node; Victor observes. “Confirm host down” is refused
CPU and memory hot-add vCPUs by count (up to 64), memory up to the hot-plug maximum; Victor onlines them in Linux guests through the agent Whole sockets only, memory through virtio-mem; needs the LiveUpdate rollout strategy and a live migration, so a second node
VM Generation ID Present (volatile.uuid.generation), and preserved by migration None
Test boot (migration, Enterprise) Supported Refused
Instant recovery (Victor Backup, Enterprise) Supported Not built; restores are staged

A few of these deserve a sentence of context.

Firmware. Incus regenerates a VM’s UEFI variable store when Secure Boot or CSM changes, so boot entries written by the guest are lost; the firmware then boots each disk’s removable-media path, which cloud images, installers and Windows provide. Victor refuses firmware changes on a running VM on both engines so a VM never runs with settings other than the ones it shows.

Hot-add. KubeVirt’s CPU hotplug works by changing spec.domain.cpu.sockets, and the upstream implementation requires the VM to live-migrate into a new pod to apply it. On a one-node cluster that means no hot-add. Victor reads each engine’s limits into the VM (Vm.hotplug) and refuses a value past them in Edit settings, in the API and at Terraform plan time.

Generation ID. Windows domain controllers and some databases use the VM Generation ID to detect being rolled back or cloned. It exists on the KVM side and is kept when VMs are migrated from VMware; KubeVirt has no equivalent. That alone can decide where a domain controller lives.

Placement, DRS and HA by engine#

The same placement settings appear on both runtimes, but enforcement works differently because the scheduler is different.

VM/host rules. Victor checks rules (separate, keep together, must/should run on, must/should not run on) before every create, migrate and drain on both engines. On KVM clusters, Victor places the VM itself. On KubeVirt, Victor also writes the rules into each governed VM’s affinity (SEPARATE becomes a required podAntiAffinity on the host name; MUST_RUN_ON a required nodeAffinity), so the Kubernetes scheduler keeps them at start, in every live migration and after a node failure. Because Kubernetes enforces required affinity itself, an override (--override-rules, audited) can only break preferences there, not required rules.

victorctl placement host-group save rack-a --cluster cluster-a --host hv-a01 --host hv-a02
victorctl placement rule save sql-on-rack-a --cluster cluster-a --kind must-run-on --vm-group sql --host-group rack-a

DRS. Available on classic clusters and Kubernetes pools. On KubeVirt, DRS moves are live migrations to a chosen node; the Kubernetes scheduler still places what Victor doesn’t, such as VMs started outside Victor or KubeVirt’s own evictions. Demand is CPU and memory only.

HA admission control. Off by default. When turned on (host failures, percentage or dedicated failover hosts), it is enforced on power-on, create, migrate, reconfigure and DRS moves on both engines, against aggregate capacity. On KubeVirt, reservations map to the VM’s resource requests.

HA restarts. This is the sharpest difference. On KVM clusters where Victor runs HA, Victor fences a failed host (BMC power-off, Ceph blocklisting, NFS export withdrawal or watchdog self-fence) before restarting its VMs, honours restart priorities and start-after ordering, and only restarts VMs on shared storage. On KubeVirt, KubeVirt restarts VMs once a Node Health Check with a fence agent remediates the node; Victor opens an “HA failover” task that watches and reports, but restart priorities show without applying, and “Confirm host down” is refused because the engine decides when to restart. If you run KubeVirt, node remediation is part of your HA design, not an optional extra.

Templates: image aliases and CDI DataSources#

A template means different things on each engine, and Victor shows both in one library (/<datacenter>/template/<alias>).

  • KVM: a template is an image in Victor’s library, referenced by alias (debian/12). You can upload a qcow2 cloud image or publish a stopped VM as a template. A template has no datastore of its own; a linked clone can go on any copy-on-write pool (ZFS, btrfs, Ceph RBD, LVM thin).
  • KubeVirt: a template is a CDI DataSource (a golden image that each VM clones into its own DataVolume) or a container disk image. A DataSource lives on one storage class, so a linked clone from it lands on that class, and a request for another class is refused. Container-disk templates can’t be linked clones because they have no disk on a datastore.

In Terraform, the same victor_template data source covers both:

data "victor_template" "rhel" { id = "datasource:os-images/rhel9" } # or name = "rhel9"

The experimental runtime move#

Victor contains a zero-copy move of a VM between runtimes: the VM’s Ceph RBD disks are reused in place by the other engine rather than copied. Treat it as experimental. It is off by default (victord’s --runtime-move flag, VICTOR_RUNTIME_MOVE=true), it has not been validated on real Ceph hardware, and there is no victorctl command for it; it runs through the API’s move_runtime action.

The checks spell out its limits:

  • every disk must be on a Ceph pool; a disk on local storage is refused;
  • the VM must be stopped, have no snapshots and no passthrough devices;
  • moving into KubeVirt refuses a vTPM (its sealed keys would be lost), UEFI variables and more than one NIC (the moved VM gets one, on the pod network), and needs a ceph-csi storage class that maps the pool;
  • moving to or from Incus needs the instance_protection_start API extension, which Incus 7.5.1 has and 7.0.1 LTS does not.

Victor’s known issues also record that it does not yet take the RBD exclusive lock, so a VM started outside Victor during a move isn’t fenced. Do not plan production changes around this feature.

Choosing an engine per workload#

Choose per workload, not per organisation. A practical guide:

If the workload… Prefer
Is a traditional server VM migrated from VMware, especially Windows KVM
Needs Victor-driven HA with fencing, restart priorities and ordering KVM
Relies on the VM Generation ID (domain controllers, some databases) KVM
Needs per-VM network ACLs managed in Victor, VLAN bridges or OVN KVM
Needs a migration test boot or instant recovery (Enterprise) KVM
Needs stateful snapshots with memory KVM
Is owned by a team that already deploys to Kubernetes with GitOps KubeVirt
Sits beside containers it talks to, under NetworkPolicy KubeVirt
Uses Kubernetes-native backup (Velero) and storage classes KubeVirt
Should scale with the platform’s node pools and namespaces KubeVirt

Most estates end up with KVM for the bulk of migrated servers and KubeVirt for VMs that belong to application platforms. Victor’s job is to make that split an operational detail rather than two separate tools, two audit trails and two permission models.

A short evaluation exercise#

Feature lists are easy to compare and tell you little. Instead, follow a VM through both engines and check that every refusal is explained. With one KVM cluster and one KubeVirt namespace registered:

  1. Create one VM on each runtime with victorctl vm create (above), and confirm both appear in one tree with their runtime shown.
  2. Take a snapshot with memory on each: victorctl vm snapshot create VM snap1 --memory --wait. The KVM VM should succeed; the KubeVirt VM should be refused with a reason.
  3. Apply a network ACL to each: victorctl acl create db --rule 'in allow tcp 5432 from 10.0.210.0/24' --rule 'in reject any' --wait, then victorctl vm firewall set VM db --wait. Expect the KVM VM to take it and the KubeVirt VM to be refused with a reason (network ACLs are a KVM-cluster feature).
  4. Raise CPU on each running VM with victorctl vm reconfigure VM --cpu 3. On KubeVirt, a vCPU count that isn’t whole sockets is refused, and a single-node cluster cannot hot-add.
  5. Create a must-run-on rule for the KubeVirt VM and inspect its virt-launcher pod’s affinity with kubectl.
  6. Read the event history for both VMs (victorctl events --vm VM) and check that every action and refusal is there with a user and a reason.

If the refusals are clear and the audit trail is complete, the control plane is doing its job. If an action silently did something different from what you asked, that is the finding to take seriously.

Where Victor fits#

Victor is a control plane and console, not a new hypervisor: KVM through upstream Incus and KubeVirt on Kubernetes do the real work, unmodified. It gives both one inventory, task model, permission model and audit trail, and reports each engine’s limits instead of papering over them. Both engines are in Victor Community; Enterprise adds multi-site federation, advanced migration and backup, and compliance features on top.

Explore the platform