Skip to content

fix(kubevirt): concurrent virtual-media insert and boot override could drop the CD-ROM disk from the VM spec - #33

Merged
kubevirt-prow[bot] merged 1 commit into
kubevirt:mainfrom
team-redbull:fix-vm-write-race
Nov 12, 2026
Merged

kubevirt-prow[bot] merged 1 commit into
kubevirt:mainfrom
team-redbull:fix-vm-write-race

Conversation

@itayherzberg

@itayherzberg itayherzberg commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When Ironic (metal3) provisions a VM-backed BareMetalHost it sends InsertMedia, then the boot-device PATCH (BootSourceOverrideEnabled: Once, target Cd) about 35 ms later, then the power-on. Some cycles leave the VirtualMachine with the cdrom0 volume but without the cdrom0 disk entry:

disks:
- name: rootdisk            # cdrom0 disk entry gone
  bootOrder: 1
volumes:
- name: rootdisk
- name: cdrom0              # volume survived
  persistentVolumeClaim: {claimName: ...-bootiso-...}

KubeVirt attaches a volume that has no disk entry as a writable virtio disk. QEMU opens the ISO read-write, the file belongs to the import helper with mode 644, so the launcher fails with Could not open '.../cdrom0/disk.img': Permission denied and the VM goes CrashLoopBackOff, forever, because the half-state is persisted in the VM spec. Ironic sees the host powered off, inspection times out and retries, and every retry (eject, insert, boot override) rolls the dice again, about every 35 minutes per host. In one deployment 4 of 6 VMs were corrupted within a day. The only repair was a manual oc patch adding the disk entry back, and nothing stopped the next retry from corrupting the VM again.

Cause

Two writers change the same VM at the same time:

  • handleInsertVirtualMedia answers 202 and queues a job; a task-manager worker runs InsertVirtualMedia a little later.
  • handleBootUpdate runs RecordVMBootOptionsAsAnnotations and then SetBootOnce synchronously on the HTTP goroutine.

Every VM write in pkg/kubevirt/client.go was GET, DeepCopy, edit, computeVMPatch (a JSON merge patch of the difference), Patch. Two properties of that pattern combine into the bug:

  1. The patch never contained metadata.resourceVersion (both sides of the diff come from the same read), so the API server could not tell that the patch was computed from a stale object. The last writer wins.
  2. A JSON merge patch (RFC 7386) replaces arrays wholesale. modifyVmBootOrder stamps bootOrder on every disk, so SetBootOnce ships the complete disks array from its own read.

When the boot-once read happens before the insert's patch lands, and the boot-once patch lands after it, the disks array from before the insert overwrites the one that has cdrom0. That patch does not mention volumes, so the volume survives. The retry loop in InsertVirtualMedia that looked for "the object has been modified" could never trigger: no patch carried a version.

Reproduced on OpenShift 4.22 / OpenShift Virtualization 4.22.12 with the stock kubevirt-redfish-rhel9:v4.22 image, whose log shows the same write pattern and the same tell-tale lines as this code on main, three VMs and an Ironic-style driver sending eject, insert and boot override 35 to 150 ms apart: 36 cycles, 3 corrupted. Bridge log of one of them (race-vm-2); the boot-once read saw no CD-ROM (order: 1) and its patch landed last:

17:51:18.540Z Creating CD-ROM device cdrom0 in VM spec first          ← insert reads the VM
17:51:19.499Z Added new CD-ROM device cdrom0
17:51:20.295Z Set disk rootdisk as secondary boot device (order: 1)   ← boot-once read: 0 CD-ROMs (stale)
17:51:20.535Z Successfully created CD-ROM device cdrom0 in VM spec    ← insert patch lands
17:51:21.333Z Successfully set boot once to Cd for VM redfish-vms/race-vm-2   ← stale disks array lands on top

race-vm-2  disks=[rootdisk:1]  volumes=[rootdisk,cdrom0]   CORRUPTED (volume without disk)

Unit test added by this PR, run against current main (the mock dynamic client applies RFC 7386 merge patches and now checks resourceVersion the way the API server does):

--- FAIL: TestSetBootOnce_StaleRead_RetriesOnFreshCopy (0.00s)
    vm_update_test.go:296: VM vm: device cdrom0 has volume=true disk=false, must have both or neither
    vm_update_test.go:296: VM vm: volume cdrom0 has no disk entry; KubeVirt would attach it as a writable hard disk
    vm_update_test.go:297: disk cdrom0: boot order <no disk>, want 1
    vm_update_test.go:298: disk rootdisk: boot order 1, want 2
    vm_update_test.go:300: expected the rejected patch and one retry, got 1 patch attempts
--- FAIL: TestConcurrentVMWriters_ConvergeRegardlessOfOrder (0.01s)
    vm_update_test.go:503: VM vm-5: device cdrom0 has volume=true disk=false, must have both or neither
    vm_update_test.go:503: VM vm-7: device cdrom0 has volume=true disk=false, must have both or neither
    ...

The same race has a milder outcome when the boot-once patch lands first: the insert's patch then overwrites the boot-once's bootOrder stamps, or the boot-once never saw the CD-ROM at all. Either way the root disk stays first and the CD-ROM has no boot order (18 of the 36 unfixed cycles ended like that). An empty root disk boots the ISO anyway because there is nothing else to boot; a provisioned one does not.

Fix

All in pkg/kubevirt.

  • One path for every read-modify-write: updateVM (vm_update.go). It takes a per-VM lock, does a live GET, runs the caller's mutation on a copy, computes the merge patch and patches. The lock (vmlock.go, keyed by namespace/name, entries dropped as soon as nobody holds or waits for them) serializes the writers inside the process: HTTP handlers, task workers and the boot-once watcher. Different VMs never wait for each other, so the worker pool keeps its throughput. Only the spec change runs under the lock; PVC creation, the helper pod and the ISO download stay outside it.
  • The patch carries the resourceVersion it was computed from (computeVMPatch). The API server then answers a write based on a stale read with 409 Conflict instead of applying it, and updateVM re-runs the mutation on a fresh read (5 attempts, 50 to 500 ms backoff). This also covers writers outside the process: oc edit, a second instance, KubeVirt's own controllers. A mutation that changes nothing does not write at all.
  • Routed through it: RecordVMBootOptionsAsAnnotations, the spec step of InsertVirtualMedia, SetBootOrder, SetBootOnce, EjectVirtualMedia and the boot-order restore in the watcher (handleVMUpdate). Each mutation is a closure that takes every decision from the fresh copy; the insert generates the PVC name once, outside the retry, so a retried insert keeps the same claim. The dead "object has been modified" loop is gone.
  • Left alone on purpose: setImportingLabel, SetPowerAfterImportLabel, removeImportingLabel, the label removal in handlePowerAfterImport, and SetVMPowerState (RFC 6902 replace /spec/runStrategy). These are blind single-key patches that touch no array; the API server applies them atomically and they can neither clobber nor be clobbered. Also not done: patching single disk entries by index, because indices shift on insert and eject; a whole-array patch from a fresh, version-checked read is correct.
  • Both request orders give the same VM. An insert that finds a boot-once to CD already recorded (label and saved original boot order present) places the new CD-ROM first and adds it to the saved original order with no boot order of its own, so the restore after the one-time boot clears it instead of leaving two disks at position 1. It does not reorder when only the override annotations are present, the short window between the two writes of handleBootUpdate, because SetBootOnce would then save the reordered disks as the original.
  • Self-heal. The VM watcher already sees every VM event and sweeps labeled VMs once a minute. It now re-adds the missing disk entry for any inserted device (virtual-media.redfish/<device> annotation) whose volume is present, and applies the pending CD boot override. VMs corrupted by earlier builds are repaired on the next event or sweep, with no new goroutine. The opposite half-state (disk without volume) is only logged.
  • Mock dynamic client (mock_dynamic_client.go): patches are applied atomically under the store lock, every write bumps resourceVersion, a merge patch with a stale resourceVersion gets a Conflict, not-found is a typed NotFound; MutateVM and a BeforePatch hook let a test put another writer between a read and its write; IgnoreResourceVersion reproduces the old behaviour for the test that shows the bug.

Behaviour changes: a VM write that changes nothing no longer touches the API server; an insert reorders the boot devices when a boot-once to CD is already recorded; the watcher repairs a volume-without-disk half-state; after the retry limit a write fails with the Conflict error instead of silently overwriting.

Verification on the cluster

Same cluster and driver, this branch built from the tree and deployed next to the stock bridge:

  • 36 cycles, 0 corrupted. Every cycle ended with disks=[rootdisk:2,cdrom0:1], volume and disk paired, whichever request reached the VM first. In 21 of the 36 cycles the boot override took the VM lock first and its read saw no CD-ROM (order: 1), the ordering that used to corrupt or leave the CD-ROM unordered; in each of them the insert that followed placed the CD-ROM first. The rest went insert first and the boot-once saw the CD-ROM. The lock means two writers inside the bridge never work from a stale read any more; the resourceVersion check fired 3 times during the run, each time against a writer outside the bridge (KubeVirt's own controllers updating a VM right after it was created or ejected), and each write succeeded on the retry:
18:02:09.864Z VM redfish-vms/race-vm-3 changed while an update was being prepared, retrying on a fresh copy (attempt 1/5)
  • A cycle where the boot override reached the VM first, the order that used to leave the CD-ROM without a boot order:
18:01:11.884Z Set disk rootdisk as secondary boot device (order: 1)        ← boot-once, no CD-ROM yet
18:01:11.911Z Successfully set boot once to Cd for VM redfish-vms/race-vm-1
18:01:11.917Z Boot override to CD is pending for VM redfish-vms/race-vm-1, placing cdrom0 first in the boot order
18:01:11.917Z Set CD-ROM cdrom0 as primary boot device (order: 1)
18:01:12.424Z Successfully created CD-ROM device cdrom0 in VM spec

race-vm-1  disks=[rootdisk:2,cdrom0:1]  volumes=[rootdisk,cdrom0]   intact
  • Self-heal: a VM created directly in the corrupted state (volume, annotation and boot-once to CD present, no disk entry) was repaired by the watcher within 5 s: Repaired VM redfish-vms/race-vm-4: re-added the missing CD-ROM disk entry for virtual media device(s) [cdrom0]; result disks=[rootdisk:2,cdrom0:1], saved original order [{rootdisk,1},{cdrom0}]. The VM corrupted by the stock bridge in the first run was repaired the same way seconds after the fixed bridge started.
  • The precondition itself, checked on the API server before relying on it: oc patch vm --type=merge with a stale metadata.resourceVersion → Error from server (Conflict): ... the object has been modified; please apply your changes to the latest version and try again; with the current one → patched.

Tests

pkg/kubevirt/vm_update_test.go: the patch carries the resourceVersion and an unchanged VM yields an empty patch; the mock rejects a stale resourceVersion and accepts a blind patch; the production interleaving corrupts the VM without the precondition (IgnoreResourceVersion) and is retried on a fresh copy with it (fails on current main as shown above); updateVM gives up after vmUpdateMaxAttempts conflicts and never writes when nothing changed; a retried insert keeps its PVC name and another writer's annotation; an insert after a recorded boot-once puts the CD first and the later restore clears it; an insert between the override annotations and the boot-once state does not reorder; 24 VMs running insert, boot-option annotations and boot-once concurrently, then eject and boot-once, all converge, with no lock entries left behind; the watcher repairs a missing disk entry, leaves healthy and unmanaged VMs alone, and repairs then restores in one pass. pkg/kubevirt/vmlock_test.go: same key serializes, different keys do not block, entries are dropped.

go build ./..., go vet ./..., go test ./..., gofmt, go mod tidy (no change) pass; go test -race ./pkg/kubevirt/ passes. go test -race ./... reports data races in pkg/server and cmd that exist on main before this PR (88 failing tests there, 86 on this branch, none in pkg/kubevirt); not touched here.

Related

@kubevirt-prow kubevirt-prow Bot added the dco-signoff: no Indicates the PR's author has not DCO signed all their commits. label Oct 21, 2026
@kubevirt-prow
kubevirt-prow Bot requested review from MarSik and yanirq October 21, 2026 18:11
@kubevirt-prow

kubevirt-prow Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Hi @itayherzberg. Thanks for your PR.

PRs from untrusted users cannot be marked as trusted with /ok-to-test in this repo meaning untrusted PR authors can never trigger tests themselves. Collaborators can still trigger tests on the PR using /test all.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@kubevirt-prow kubevirt-prow Bot added dco-signoff: yes Indicates the PR's author has DCO signed all their commits. and removed dco-signoff: no Indicates the PR's author has not DCO signed all their commits. labels Oct 21, 2026
Comment thread pkg/kubevirt/client.go
}
logger.Error("Failed to update VM %s/%s: %v", namespace, name, err)
return fmt.Errorf("failed to update VM: %w", err)
// Ironic sends the boot override right after the insert request. When

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can keep this, but I wonder if Ironic should check the device was inserted first as well. Because that is what HTTP 202 means. It does not guarantee the operation was finished yet.

@itayherzberg itayherzberg Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that a client should treat 202 as "accepted, not done". Ironic can do that check since 2026-09 (openstack/ironic c537ed92, bug 2166702, release note, first in 39.0.0), but it is opt-in via [redfish] enable_vmedia_insert_polling, default off, and neither metal3-io/ironic-image nor openshift/ironic-image sets it. sushy itself posts with blocking=False, so it ignores the 202 and goes straight to the boot-device PATCH. So the Ironic that corrupted these VMs cannot be configured to wait, and other clients (curl, a second replica, oc edit, KubeVirt's own controllers) would not either. The lock and the resourceVersion precondition are the actual fix; this block only makes both request orders end in the same VM spec.

Two follow-ups outside this PR: the 202 responses carry no Location header pointing at the Task, which the spec expects; and if someone enables the Ironic polling, Inserted here only turns true once the PVC is Bound, so with WaitForFirstConsumer storage Ironic's 60 s default could time out before the import starts.

@MarSik

MarSik commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

This looks good to me and the reasoning makes sense, but I will have to test it with our local use cases first before approving.

@itayherzberg

Copy link
Copy Markdown
Contributor Author

@MarSik Gentle ping on this when you have a chance! Let me know if you need any additional context

…s spec changes

Every change to a VirtualMachine was a read, an edit of a copy and a JSON
merge patch of the difference. The patch carried no resourceVersion, so the
API server could not reject one computed from a stale read, and a merge
patch replaces the disks and volumes arrays wholesale. The insert of virtual
media (task worker) and the boot-once override (HTTP handler) run within
milliseconds of each other on the same VM; when the boot-once patch landed
last it carried the disks array from before the insert, deleting the cdrom0
disk entry while its volume survived. KubeVirt then attached the volume as a
writable hard disk, QEMU failed to open the read-only ISO and the VM
crash-looped.

Every read-modify-write now goes through one helper, updateVM, which
serializes writers of the same VM inside the process with a per-VM lock,
seeds the patch with the resourceVersion it was computed from so the API
server answers a stale write with 409 Conflict, and re-runs the mutation on
a fresh read when that happens. The lock covers only the spec change; PVC
creation and ISO downloads stay outside it. Blind single-key label patches
and the runStrategy JSON patch are unchanged: they cannot clobber anything.

An insert that finds a boot-once-to-CD already recorded places the new
CD-ROM first in the boot order and adds it to the saved original order, so
both request orders end with the same VM. The VM watcher re-adds a missing
disk entry for any inserted device whose volume survived, repairing VMs
corrupted before this fix on the next event or reconcile tick.

The mock dynamic client now applies patches atomically, bumps
resourceVersion on every write and rejects a stale resourceVersion with a
Conflict, so the tests reproduce the interleaving that corrupted the
production VMs and show it cannot happen any more.

Signed-off-by: itayherzberg <itayherzberg@gmail.com>
@MarSik

MarSik commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

/lgtm
/approve

@kubevirt-prow kubevirt-prow Bot added the lgtm Indicates that a PR is ready to be merged. label Nov 12, 2026
@kubevirt-prow

kubevirt-prow Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: MarSik

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubevirt-prow kubevirt-prow Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Nov 12, 2026
@kubevirt-prow
kubevirt-prow Bot merged commit 0007f32 into kubevirt:main Nov 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. dco-signoff: yes Indicates the PR's author has DCO signed all their commits. lgtm Indicates that a PR is ready to be merged. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants