Repository navigation
fix(kubevirt): concurrent virtual-media insert and boot override could drop the CD-ROM disk from the VM spec - #33
Conversation
|
Hi @itayherzberg. Thanks for your PR. PRs from untrusted users cannot be marked as trusted with I understand the commands that are listed here. DetailsInstructions 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. |
70bcf1a to
5f649bd
Compare
| } | ||
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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. |
|
@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>
5f649bd to
c74205f
Compare
|
/lgtm |
|
[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 DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Problem
When Ironic (metal3) provisions a VM-backed BareMetalHost it sends
InsertMedia, then the boot-devicePATCH(BootSourceOverrideEnabled: Once, targetCd) about 35 ms later, then the power-on. Some cycles leave the VirtualMachine with thecdrom0volume but without thecdrom0disk entry: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 deniedand the VM goesCrashLoopBackOff, 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 manualoc patchadding 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:
handleInsertVirtualMediaanswers202and queues a job; a task-manager worker runsInsertVirtualMediaa little later.handleBootUpdaterunsRecordVMBootOptionsAsAnnotationsand thenSetBootOncesynchronously on the HTTP goroutine.Every VM write in
pkg/kubevirt/client.gowas GET,DeepCopy, edit,computeVMPatch(a JSON merge patch of the difference),Patch. Two properties of that pattern combine into the bug: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.modifyVmBootOrderstampsbootOrderon every disk, soSetBootOnceships the completedisksarray 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
disksarray from before the insert overwrites the one that hascdrom0. That patch does not mentionvolumes, so the volume survives. The retry loop inInsertVirtualMediathat 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.22image, whose log shows the same write pattern and the same tell-tale lines as this code onmain, 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:Unit test added by this PR, run against current
main(the mock dynamic client applies RFC 7386 merge patches and now checksresourceVersionthe way the API server does):The same race has a milder outcome when the boot-once patch lands first: the insert's patch then overwrites the boot-once's
bootOrderstamps, 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.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.resourceVersionit was computed from (computeVMPatch). The API server then answers a write based on a stale read with409 Conflictinstead of applying it, andupdateVMre-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.RecordVMBootOptionsAsAnnotations, the spec step ofInsertVirtualMedia,SetBootOrder,SetBootOnce,EjectVirtualMediaand 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.setImportingLabel,SetPowerAfterImportLabel,removeImportingLabel, the label removal inhandlePowerAfterImport, andSetVMPowerState(RFC 6902replace /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.handleBootUpdate, becauseSetBootOncewould then save the reordered disks as the original.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.go): patches are applied atomically under the store lock, every write bumpsresourceVersion, a merge patch with a staleresourceVersiongets aConflict, not-found is a typedNotFound;MutateVMand aBeforePatchhook let a test put another writer between a read and its write;IgnoreResourceVersionreproduces 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:
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:Repaired VM redfish-vms/race-vm-4: re-added the missing CD-ROM disk entry for virtual media device(s) [cdrom0]; resultdisks=[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.oc patch vm --type=mergewith a stalemetadata.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 currentmainas shown above);updateVMgives up aftervmUpdateMaxAttemptsconflicts 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 inpkg/serverandcmdthat exist onmainbefore this PR (88 failing tests there, 86 on this branch, none inpkg/kubevirt); not touched here.Related
Clientstruct, in a different place, so the two merge cleanly in either order. feat(kubevirt): serve read-only VM/VMI lookups from an informer cache #32 keepsGetVMlive and moves only read-only lookups to its cache, which is exactly whatupdateVMneeds, so neither PR changes the other.EjectVirtualMedia, which this PR rewrites aroundupdateVM; the line moves into the closure, a one-line rebase for whichever lands second.