Skip to content

kubevirt: keep domain bookkeeping until Cleanup - #6271

Open
eriknordmark wants to merge 8 commits into
lf-edge:masterfrom
eriknordmark:kubevirt-graceful-stop
Open

kubevirt: keep domain bookkeeping until Cleanup#6271
eriknordmark wants to merge 8 commits into
lf-edge:masterfrom
eriknordmark:kubevirt-graceful-stop

Conversation

@eriknordmark

@eriknordmark eriknordmark commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes to the KubeVirt hypervisor backend, found while investigating why an app
restart on EVE-k behaves differently from the same operation on EVE-kvm.

This branch is now based on #6257 — see "PR dependencies" below. Its first two
commits are Andrew Durbin's from that PR, included verbatim (authorship preserved)
because #6257 rewrites the same Info/StopReplicaVMI code this PR touches, and the
two cannot be reviewed or tested independently of each other. Review the four
kubevirt: commits here.

1. kubevirt: keep domain bookkeeping until Cleanup

domainmgr asks the hypervisor to shut a domain down and then polls Info()
until it reports the domain gone, before creating any replacement.
kubevirtContext.Stop() dropped the vmiList entry that Info() resolves a
domain through, so every poll after Stop failed. waitForDomainGone treats a
failed poll as "the domain is gone", so it returned on its first one-second tick
and cleared DomainId.

The effect is that domainmgr requests deletion of a VMIRS and creates the
replacement roughly a second later, while KubeVirt is still shutting the old VMI
down — virt-handler signals a graceful shutdown and only kills the VMI once the
grace period expires (30 s by default). On a lab device two virt-launcher pods
for the same app were observed running at once as a result. Clearing DomainId
also made the forced-shutdown escalation in doInactivate unreachable, since it
is guarded on DomainId being set.

The entry now stays in place in Stop and is removed in Cleanup, the
unconditional tail of doInactivate. Delete cannot own the removal: it only
runs while DomainId is still set, which is no longer true once the domain goes
away during the graceful wait, and Cleanup's IsMetaReplicaVMI path did not
remove it either — so dropping the Stop removal on its own would leak an entry
on every successful graceful shutdown. The removal is deferred, since Cleanup
can fail after the domain is already on its way out.

For contrast, the same sequence on EVE-kvm polls real domain state, backs off
1/2/4/8/16/32 s, and escalates before anything replaces the domain, because the
containerd-backed Stop it inherits keeps no such state.

2. kubevirt: name the replicaset in the delete error

A failed VMIRS delete logged only the error, so an operator could not tell which
app's replicaset failed to go away — and this path fires during every teardown that
races the Kubernetes API. The name is now included, and the trailing newline logrus
adds itself is dropped.

An earlier version of this PR also carried kubevirt: log a successful VMIRS delete correctly, fixing errors.IsNotFound(nil) being false so a successful delete
logged Stop VMI Replicaset error <nil>. That commit is gone: #6257 fixes the same
bug
with a three-way switch that additionally logs the success case, so keeping
both would have double-applied it.

3. kubevirt: don't ignore the scheduling lookup error on Delete

Delete asked scheduledOnMe() which node the domain is placed on and then
discarded its error. A failed lookup reports onMe=false, which is indistinguishable
from the legitimate "scheduled on another node" case tested on the next line, so
Delete returned nil — reporting a successful teardown while deleting nothing —
whenever the Kubernetes API was briefly unavailable. Stop already checks the same
error. (Not every failure path is (false, false): replicaPodScheduledOnMe's
"Unhandled scheduling state" returns scheduledOnNone=true alongside its error, so the
check has to key on the error rather than on scheduledOnNone.)

All five Delete call sites in domainmgr log the error and continue (three of
them are behind !ctx.hvTypeKube and unreachable on EVE-k), so no caller's
control flow changes. Note this also surfaces an already-deleted ReplicaSet as a
Delete error rather than a silent success, since the lookup's Get reports
NotFound through the same path.

4. kubevirt: treat a mid-call NotFound as gone

Info confirms the replicaset exists and then fetches it again to read the guest's
phase. A workload deleted between those two calls made the second lookup fail with
NotFound, which Info reported as an error alongside a still-scheduling state —
and waitForDomainGone returns true on any Info error before it inspects the
state, so the graceful wait ended early. That is the same premature "domain is
gone" commit 1 exists to prevent, reached through a one-poll race rather than on
every shutdown. NotFound now reports absence identically wherever it is observed,
which also drops a redundant third Get of an object already known to be gone.

Raised by @naiming-zededa in review: the concern was that keeping the vmiList
entry past Stop would let a deleted replicaset reach scheduledOnMe and be
reported BROKEN. #6257's Info rework removed that specific outcome, but the
error path it named was still wrong for the reason above.

Known gaps (deliberately not addressed here)

  • Stop's force parameter is still ignored. This matches KvmContext.Stop,
    which also discards it (_ bool); in both backends the actual forcing is done
    by the subsequent Delete. Now that the escalation is reachable, a stuck
    domain can spend an extra maxDelay in force-true before Delete runs.
    The KVM-analogous fix would be to make kubevirt's Delete the forcing step
    (delete with GracePeriodSeconds: 0), not to act on force in Stop.
  • An error returned from Stop still skips the graceful wait entirely, because
    doInactivate only calls waitForDomainGone on the success branch.

PR dependencies

Depends on #6257 (eve-k-purge-lost-delete, draft). Its two commits —
evetest: add purge tests for the kube path and pillar: keep purges from leaving the old app generation behind — are the base of this branch, included verbatim at
be7740c59 (d36df5bbe and be7740c59) with Andrew Durbin's authorship and sign-off
intact. They are not proposed as new work here.

That base builds: go build -tags k ./... and the hypervisor and zedmanager tests are
clean on it, and on this branch on top of it. #6257 has force-pushed repeatedly, and one
of its earlier tips did not compile, so this PR pins a snapshot that does rather than
tracking its head continuously. Expect a rebase when #6257 settles.

Why stacked rather than independent: #6257 rewrites Info() to derive DomainId from a
live VMIRS Get instead of from vmiList, and fixes the StopReplicaVMI logging this
PR originally fixed. The two overlap in the same functions, so they conflict textually
and cannot be tested apart. When #6257 merges, this branch should be rebased and its two
commits will drop out. #6257 force-pushes frequently, so expect re-bases.

How to test and validate this PR

Automated: go build -tags k ./hypervisor/, go vet -tags k ./hypervisor/ and
go test -tags k ./hypervisor/ under pkg/pillar. Note the k build tag is
required — hypervisor/kubevirt.go is behind //go:build k, so a build without
it does not compile this file at all. TestCreateReplicaPodConfig fails in that
bare host run, on master too — it mocks the kubeconfig at the real
/run/.kube/k3s/k3s.yaml, which a non-root user cannot create; CI runs in a
container as root and is unaffected (#6290).

On an EVE-k device, restart or deactivate an app instance and watch
waitForDomainGone in the device log:

  • Before: a single waiting for 1s followed by error info domain <name>, then
    a replacement VMIRS created within about a second.
  • After: state still RUNNING waited … with the delay backing off, until the
    VMI is actually gone (or the budget expires and the forced path runs), and no
    replacement until then.

Cross-check that kubectl get pods -n eve-kube-app never shows two
virt-launcher pods for the same app during a restart.

Result on hardware (amd64 EVE-k)

Run on a Supermicro SYS-E300-8D EVE-k node with the patched image, using the
preceding build without these commits as the control. The exercise is an
integration test that writes ~128 KB inside a VM app's guest and then restarts
the app instance; each test run does one cycle where the restart is requested
90–240 s after the write and one where it is requested in the same millisecond.

The graceful wait now happens. Time from the restart request to the app being
back online:

image range cycles
control 34.4 – 100.2 s 4
patched 113.5 – 167.3 s 6

The ranges do not overlap. That is the intended effect: waitForDomainGone polls
real domain state instead of accepting the first failed Info as "gone", so the
replacement is no longer created about a second after the delete request.

Data survival is improved but not fixed. Only the same-millisecond cycle ever
loses the guest's write:

cycle shape control patched
restart +90…240 s kept 2/2 kept 3/3
restart +0.00 s lost 2/2 lost 1/3

Why, from the device log. The wait this change enables cannot currently
succeed, because Stop deletes the VMIRS and the wait then polls that same
VMIRS by name:

13:45:08.446  DomainShutdown force-false 27d4050e….6.1        <- Stop deletes the VMIRS
13:45:08.706  waitForDomainGone …: waiting for 1s
13:45:09.761  waitForDomainGone … error Failed to determine scheduled node:
              virtualmachineinstancereplicasets.kubevirt.io "ztest-…-27d40-1" not found
13:45:09.762  waitForVMI … waiting for 1s     (then 2s, 4s, 8s, 16s, 32s)
13:46:13.278  waitForVMI … giving up at state:Unknown
13:46:13.278  VMI still available
13:46:13.307  doActivate({27d4050e… 7})       <- replacement created regardless

Every tick returns NotFound, so the state never resolves, the full 1+2+4+8+16+32
= 63 s backoff runs out, and the replacement is created with the old VMI still
present — by the code's own VMI still available on the preceding line. The
identical sequence appears in a cycle that kept its data (98ea9e82…, VMIRS
ztest-…-98ea9-1, give-up at 04:38:42.782, doActivate 20 ms later), so the
overlap is now the steady state of every EVE-k app restart on this build, and
whether the guest's last write survives is a race inside that window.

So this change converts a 1-second false "domain is gone" into a 63-second wait
that ends in the same conclusion. It removes the misleading bookkeeping and the
misleading log, and it is a prerequisite for a correct wait, but on its own it
does not stop the replacement from overlapping the old VMI. A follow-up should
make the wait poll the VMI object (which outlives its ReplicaSet) rather than the
ReplicaSet that Stop has just removed.

Also visible in both cycles, and probably worth its own fix:
kubeapi.DetachOldWorkload: a node name is required — the old workload's volume
detach does not happen.

That analysis was derived against master's Info, which returned an error whenever
vmiList had no entry for the domain. #6257, now the base of this branch, changes
exactly that: Info resolves existence through a live VMIRS Get and reports HALTED
only when the object is confirmed absent. How the graceful wait behaves on the stacked
branch therefore needs re-deriving, and I have not re-measured it on hardware since the
rebase — the numbers above are from the standalone form of these commits.

The rest of the suite was unaffected: 46 passes, plus failures this node also
produces without the change (it has no assignable audio/COM hardware).

Result in the kvm→k conversion matrix (with #5971)

Separately from the hardware run above, the two substantive commits were exercised in the
7-leg EVE-kvm→EVE-k boot-disk conversion matrix, on integration image
0.0.0-newgo-allprs2-f40d7876. That build carries #5971 (the Go kube-init daemon)
and rucoder#3 on top of the conversion chain, plus #6257, #6197, #6240, #6259 and
#6190 — so it is the combination this PR will actually ship into, not a standalone build.

7/7. Six legs green in the batch (ext4-shrink, twodisk-ext4, twodisk-zfs,
zfs-grow, ext4-toofull, zfs-notail); ext4-grow lost its emulator to a QEMU AHCI
abort (prdt_warnings=4 aborts=1) while another test shared the host, and passed on
re-run with prdt_warnings=0 aborts=0. Every leg came in faster than on the two previous
7/7 images of that branch line, including twodisk-zfs, which is the historically
fragile leg.

Scope of that evidence, precisely:

  • It covers keep domain bookkeeping until Cleanup and don't ignore the scheduling lookup error on Delete — content-identical in that image to the commits here
    (patch-ids 7d1df4a12b16 and c21b35662d3f).
  • It does not cover name the replicaset in the delete error or treat a mid-call NotFound as gone, both committed after that image was built.
  • It demonstrates no regression in the conversion path with pkg/kube: port cluster-init.sh to Go daemon #5971's bring-up. It is
    not a targeted test of the graceful-stop behaviour itself; that evidence is the
    hardware section above.

Changelog notes

On EVE-k, an app restart or deactivate no longer creates the replacement while
the previous instance is still shutting down.

PR Backports

  • 17.0-stable: To be decided by the maintainers — the change is small, but the
    behaviour it alters only matters for EVE-k.
  • 16.0-stable: Same.
  • 14.5-stable: Same.
  • 13.4-stable: Same.

Checklist

  • I've provided a proper description
  • I've added the proper documentation
  • I've tested my PR on amd64 device
  • I've tested my PR on arm64 device
  • I've written the test verification instructions
  • I've set the proper labels to this PR

And the last but not least:

  • I've checked the boxes above, or I've provided a good reason why I didn't
    check them.

Reasons for the unchecked boxes: no documentation change — this alters
bookkeeping ownership inside one hypervisor backend, with no new knob or
user-visible surface. The patched image has been run on an amd64 EVE-k device
(see the hardware result above); arm64 is untested.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 46.94960% with 200 lines in your changes missing coverage. Please review.
✅ Project coverage is 24.96%. Comparing base (6647569) to head (d66956b).

Files with missing lines Patch % Lines
pkg/pillar/hypervisor/kubevirt.go 36.45% 169 Missing and 14 partials ⚠️
pkg/pillar/cmd/volumemgr/initialvolumestatus.go 57.69% 9 Missing and 2 partials ⚠️
pkg/pillar/cmd/volumemgr/volumemgr.go 0.00% 2 Missing ⚠️
pkg/pillar/hypervisor/kubevirt_identity.go 80.00% 1 Missing and 1 partial ⚠️
pkg/pillar/hypervisor/kubevirt_staleness.go 95.45% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6271      +/-   ##
==========================================
+ Coverage   24.27%   24.96%   +0.68%     
==========================================
  Files         512      524      +12     
  Lines       93831    95836    +2005     
==========================================
+ Hits        22782    23930    +1148     
- Misses      69246    69873     +627     
- Partials     1803     2033     +230     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@eriknordmark
eriknordmark marked this pull request as ready for review August 5, 2026 05:24
@eriknordmark

Copy link
Copy Markdown
Contributor Author

Correcting the hardware section I posted earlier today: it claimed the guest data
loss across an app restart was gone. A third run of the same test reproduced it,
and the device log shows this change does not stop the VMI overlap it was meant
to prevent. The description now reflects that; summary of what the run actually
shows:

What the change does do. Restart-request to app-online went from 34–100 s
(4 cycles, without these commits) to 113–167 s (6 cycles, with them), ranges not
overlapping. The graceful wait now happens instead of being skipped.

Why that is not enough. Stop deletes the VMIRS, and waitForDomainGone
waitForVMI then resolves the domain through that same ReplicaSet name, so every
poll returns NotFound:

13:45:08.446  DomainShutdown force-false 27d4050e….6.1
13:45:09.761  waitForDomainGone … error Failed to determine scheduled node:
              virtualmachineinstancereplicasets.kubevirt.io "ztest-…-27d40-1" not found
13:45:09.762  waitForVMI … waiting for 1s   (2s, 4s, 8s, 16s, 32s)
13:46:13.278  waitForVMI … giving up at state:Unknown
13:46:13.278  VMI still available
13:46:13.307  doActivate({27d4050e… 7})

The state never resolves, the 63 s backoff expires, and the replacement is created
with the old VMI still present. The same sequence appears in a cycle that kept its
data, so the overlap is the steady state of every EVE-k app restart on this build
rather than the explanation for the one failure.

Data survival. Only the cycle where the restart is issued immediately after
the guest's write can lose it: 2 of 2 lost without these commits, 1 of 3 with
them. Too few samples to claim a real reduction.

Net: the three commits are still correct in themselves — the bookkeeping and the
two error paths — and a correct wait is impossible without the first one. But the
wait needs to poll the VMI object, which outlives its ReplicaSet, not the
ReplicaSet Stop has just deleted. Happy to take that on in this PR or a
follow-up, whichever reviewers prefer.

Separately, both cycles log kubeapi.DetachOldWorkload: a node name is required,
so the old workload's volume detach is not happening either.

Comment thread pkg/pillar/hypervisor/kubevirt.go Outdated

// The vmiList entry must outlive Stop: Info() resolves a domain through
// it, and domainmgr polls Info() to learn when the guest actually
// stopped. Delete() and Cleanup() own the removal.

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.

in the Info(), this will allow the function to find the 'vmis' struct, and move on, but later, in the scheduledOnMe():

	onMe, _, err := ctx.scheduledOnMe(vmis.mtype, vmis.name)
	if err != nil {
		if isK3sUnreachable(err) {
			return 0, types.UNKNOWN, nil
		}
		return 0, types.BROKEN, logError("Failed to determine scheduled node: %s", err)
	}

if it is deleted in the kuberntes cluster, it is going to return here the types.BROKEN. which is also a problem.

Mayin in the 'vmis' struct, we can add a 'pendingOnDelete' bool, and set it here. Over in the Info(), checks on this bool on error, and return Unknown still.

@eriknordmark eriknordmark Aug 7, 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.

types.BROKEN is gone — #6257 rewrote Info() to confirm existence with its own Get first, so a deleted VMIRS returns HALTED/zero before scheduledOnMe runs. That is also why I didn't add pendingOnDelete: the live Get answers it better than a flag, which would be wrong if the delete itself failed.

The case underneath your comment is still real though. Info now Gets the same VMIRS up to three times, and a delete landing after the first is caught by none of them — the second's NotFound is dropped by the fall-through, the third ends at return id, types.SCHEDULING, err. waitForDomainGone returns true on any Info error before it looks at the state, so the race ends the graceful wait early.

Fixed in 5fafb53: errors.IsNotFound -> 0, HALTED, nil at both lookups, with a test that fails without it. Does that cover what you meant?

@naiming-zededa naiming-zededa Aug 8, 2026

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.

I think this code added is fine. but I think there is still a problem on the ScheduledOnMe() in the deletion case:
the function checks the VMIRS first, if this is gone, it returns 'not found', I think the problem is that, even the VMIRS can be removed quickly, the VMI itself may still stay around fairly longer. So, this deletion case, I think we need the same check as confirmVMIRSGone() to verify.

@eriknordmark
eriknordmark force-pushed the kubevirt-graceful-stop branch from 431cc4d to 260c5a0 Compare August 7, 2026 15:57
@eriknordmark

Copy link
Copy Markdown
Contributor Author

Heads-up on a force-push that changes what this PR contains.

This branch is now based on #6257. Its two commits are the base here, included
verbatim at df8ae7c12 with @andrewd-zededa's authorship and sign-off intact — they are
not proposed as new work. #6257 rewrites Info() to derive DomainId from a live VMIRS
Get rather than from vmiList, which is the same code this PR touches, so the two
conflict textually and cannot be reviewed or tested apart.

Consequences worth flagging rather than leaving to be discovered in the diff:

On testing: the two substantive commits have since been exercised in the 7-leg
EVE-kvm→EVE-k conversion matrix (7/7) on an integration image that also carries #5971,
rucoder#3 and #6257 — details in the description. That is a no-regression result for
the conversion path, not a targeted test of the graceful-stop behaviour.

When #6257 merges I will rebase and its commits will drop out. It force-pushes often, so
expect further re-bases here.

@eriknordmark

Copy link
Copy Markdown
Contributor Author

Correction to my comment above: it said the included #6257 commits are "at df8ae7c12".
That is wrong — df8ae7c12 is not an ancestor of this branch. The commits actually
included are e9887bb1c and 73111d49f, an earlier #6257 tip. I refreshed a
separate integration branch to df8ae7c12 and then quoted that SHA here by mistake. The
description now states the correct base.

On why the base is not #6257's current tip: #6257's latest pillar commit
(74a5b1b0d) does not compile. replicaVmiScheduledOnMe was split into a
vmiScheduledOnMeFromVmirs helper whose body still references vmirsName, which is not
one of its parameters:

$ go build -tags k ./hypervisor/
hypervisor/kubevirt.go:1178:6: undefined: vmirsName
hypervisor/kubevirt.go:1183:5: undefined: vmirsName
hypervisor/kubevirt.go:1189:32: undefined: vmirsName
hypervisor/kubevirt.go:1201:75: undefined: vmirsName

Reproducible on 74a5b1b0d with none of this PR's commits present; its parent
4262a6bed builds cleanly, so the break is in that pillar commit. I did rebase this
branch onto it, resolved the one conflict in StopReplicaVMI, and then reverted — a
rebase would only turn this PR's CI red for a reason that is not this PR's. The rebase is
kept locally and will be redone once #6257 settles and builds.

@andrewd-zededa flagging the build break on your side.

Nothing else about this PR changed: the branch head is still 260c5a059, and
go build/vet/test -tags k ./hypervisor/ is clean on it.

eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 7, 2026
Replaces the three lf-edge#6271 kubevirt commits with lf-edge#6257 at df8ae7c plus the two
of them that survive on top of it, since lf-edge#6257 fixes the VMIRS-delete logging
the same way and rewrites Info to derive DomainId from a live Get rather than
vmiList. Adds the proposed volumemgr readiness-publish follow-up to lf-edge#6240.

Also records that lf-edge#5971 and rucoder#3 are held at the tips the 7/7 image used
rather than caught up, so the bring-up baseline is not a variable in this run.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 7, 2026
A failed VMIRS delete logged only the error, so an operator reading the log
could not tell which app's replicaset failed to go away -- and this path fires
during every teardown that races the Kubernetes API. Include the replicaset
name, and drop the trailing newline that logrus adds itself.

Also records lf-edge#6271's presence in the branch composition table, including which
of its commits lf-edge#6257 supersedes, and corrects the lf-edge#6190 row: the fault-injection
gates are compiled in whenever the build arg is non-empty, not only when it is y.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eriknordmark
eriknordmark force-pushed the kubevirt-graceful-stop branch from 260c5a0 to fc0f374 Compare August 7, 2026 18:49
@eriknordmark

Copy link
Copy Markdown
Contributor Author

Rebased onto #6257's be7740c59, which fixes the undefined: vmirsName break I reported
above — go build -tags k ./... and the hypervisor and zedmanager tests are clean both
on that tip and on this branch over it. Branch head is now fc0f37411.

The three kubevirt: commits are unchanged in substance. The only conflict was in
StopReplicaVMI, resolved in favour of #6257's structure: it now names the replicaset in
the success log, so this PR's commit adds the name to the error log only and drops the
stray newline.

Re-derived after the rebase, since #6257 reworked Info() again: a vmiList miss still
returns (id, SCHEDULING, nil), so without the entry Info can never report the guest's
real phase. That is what keep domain bookkeeping until Cleanup still buys, and its
description is unchanged.

The description now names be7740c59 as the pinned base. #6257 has force-pushed several
times and has already moved past this snapshot; this PR pins one that builds rather than
tracking its head, so expect one more rebase when #6257 settles.

A purge must delete the previous generation of an app. In the kube path
it can leave that generation in the cluster. No test found this, because
no test looked at the cluster after a purge.

These tests purge a container app. eve-k runs a container app in a shim
VM, so the app has a VMIRS.

- purge_baseline_test: purge a running app. Make sure that only the new
  VMIRS and one PVC stay.
- purge_after_power_cycle_test: purge the app while the device is off,
  then power the device on. This is the failure that customers saw.
- purge_during_failover_test: purge the app while it runs on a different
  node than the node that gets the purge.

The power-cycle test runs two times, one time for each hypervisor. The
HYPERVISOR parameter selects it, and the default is kubevirt. The kvm run
is the control. A kvm domain is a local qemu process, and no cluster
object stays alive after the node stops, so the duplicate generation
cannot occur there. The kvm run must therefore pass, and a pass keeps the
duplicate-generation defect on the kube path.

The kvm run still tests the parts of a purge that are the same for each
hypervisor: the purge must complete and must not stop for ever in
zedmanager, and the old generation's disk must go away. It also shows
that the kube fixes do not make kvm worse.

The two runs use one test body, because only the last assertions are
different: VMIRS and PVC objects for kubevirt, files on disk and the qemu
domain state directory for kvm.

The helpers go in files named for the state that they observe, and not for
the test that needed them first. A catch-all helpers file gives a
contributor no clue where to put the next helper, and it becomes large:

- deviceaccess_test: the only file that runs a command on a device.
- appstate_test: pillar's own view of the app, from pubsub and from
  persisted state, by app UUID.
- appworkload_test: where the app runs, as the hypervisor sees it. VMIRS
  objects for kubevirt, qemu domain state directories for kvm.
- appvolumes_test: the app's disk in each of its three forms, and the
  storage rules. These rules stay true after a delete or a restart, so
  they must not go in a purge file.
- purge_assertions_test: the assertions that have no meaning if there is
  no purge.
- fixtures_test: device requirements and app configuration.

testsuite_test holds the file map and the three questions that tell you
where a new helper goes. README.md points to this suite as the example.

The disk assertions depend on a property of the framework, so a note in
appvolumes_test records it. PurgeApplication increases the generationCount
of each volume that the app uses, together with the app purge counter, the
same as a controller does. Only that increase makes zedmanager replace the
volume: it finds the volumes to replace with the key of the volume
reference, which holds the volume UUID, the generation counters and the app
UUID. An app purge counter alone changes none of them, so the reference
still matches and nothing is removed or added. If the framework loses that
increase, the assertions on the volume generation key and on the old
artifact stop being possible to satisfy, not merely weaker.

EdgeCluster.FindDeviceHostingApp could return a device that no longer runs
the app. It reads the cluster info that each device published last, and
that data can be older than the event that the caller waits for. The fault
is clearest when a device is off: its own old data still names it as the
host, and the device sends nothing more.

The method now accepts excludeDevNames. It does not read cluster info from
an excluded device, and it never returns one. The parameter is variadic, so
the calls that exist do not change. The method also subscribes before it
reads the cached data, because the old order could lose an update that
arrived between the two steps. WaitUntilNodesAreReady already used the
correct order.

The power-cycle test flushes the device caches before it removes the power.
EVE unpacks the container image layers without an fsync, and containerd
marks the snapshots as committed. If the power stops in the writeback
window, the layers keep the directory tree but each file becomes empty:
approximately 150 MB of layers became 3 MB in a test. Nothing extracts
them again, each later volume from that image is empty, and the app then
starts and stops in a loop while EVE reports it as RUNNING. This is a
durability fault in EVE. The sync keeps it out of a test that has a
different subject.

Each purge test has two phases, because the purge and the reclamation of
the old disk use different clocks:

- The purge end state: the counter increased by one, the purge phase is
  NotInprogress, the app is RUNNING, there is one volume with a new
  generation, and there is one VMIRS or one qemu domain. This settles in
  approximately one minute.
- The removal of the old generation's storage: the old artifact is gone and
  no artifact stays that no VolumeStatus names, or, for kubevirt, no PVC
  stays that no VolumeStatus names. volumemgr does this on its own
  schedule, approximately five minutes after the purge in a test. This is
  much sooner than the one hour VdiskGCTime, but much later than the purge.

A check for the second in the window of the first fails on time and not on
substance. This was true for the PVC check also.

On kubevirt each test waits for the node to become Ready before it waits for
the app, and again after the power cycle, because the reboot stops k3s. Each
other app test in this package does the first of these. Without the wait, the
app stays in INITIAL while k3s and Longhorn start, which used six of the ten
minutes of the app limit in a test, and the test then failed on time and not
on substance.

fixtures_test holds each timeout in one block with the reason for its
value. Eventually stops as soon as the condition is true, so a large limit
costs nothing when the system operates correctly, and it only delays a
true failure. Each limit comes from a measured time with headroom. If a
limit starts to expire, find what changed. Do not increase the number.

If the app is not RUNNING, the failure message now includes BootFailed,
TriedCount and the error from DomainStatus. The state alone cannot tell you
that a guest started and then stopped itself.

The failover test had a local helper to work around that fault. The helper
is deleted, and findAppNodeName stays private, so this commit adds no new
public API to the framework. EdgeDevice gets one method, Name, which the
failover test needs to report which node now runs the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrew Durbin <andrewd@zededa.com>
andrewd-zededa and others added 3 commits August 7, 2026 15:55
A purge must delete the previous generation of an app before it creates
the next one. In the kube path three defects stop this. The old VMIRS
then keeps running, and EVE reports the purge as complete.

1. Info() says a domain is gone when it only cannot see it.

   Info() returns domainID 0 if it finds no replica on this node.
   domainmgr copies that 0 into DomainStatus. zedmanager reads 0 as
   "there is no domain", so it skips the teardown and releases the
   purge. Info() now gets identity and existence from the cluster:

   - domainID is workloadID(), a hash of the VMIRS metadata.uid. It was
     rand.Uint32(), which has no relation to the cluster.
   - Task() keeps the DomainStatus. The kube name now comes from the
     status and not from the vmiList cache, which goes stale.
   - Info() returns 0 only when the VMIRS is absent. If it cannot
     confirm absence, it returns the last known id.

2. Nothing deletes an old generation after a reboot.

   /run is tmpfs, so a reboot deletes the purge state. The VMIRS stays
   in etcd. zedmanager finds no domain to halt and advances the counter,
   so the new generation starts beside the old one. Start() now sweeps
   first: it finds each older generation of the app in the cluster,
   deletes it, and waits until the object and its pods are gone. The
   sweep reads the cluster and does not need the old counter, so it
   survives the reboot. If it cannot confirm that a generation is gone,
   Start() fails. A failed start is safer than two generations that
   share MACs, veth names and one RWO disk.

3. zedmanager waits for ever for a volume-ref removal.

   On a purge, doInstall parks the old VolumeRefStatus and waits for
   volumemgr to confirm the delete. If volumemgr has no live status for
   that reference in this boot, the confirmation never comes. The app
   stays in DownloadAndVerify and never asks for the new volume.
   zedmanager now looks at volumemgr's live status first, and drops the
   reference if it is already absent.

Each Kubernetes request that this change adds now has a deadline of
kubeapi.KubeAPITimeout, with the apiCtx helper. domainmgr calls Info on a
timer of 9 to 30 seconds and calls Start directly, and the watchdog of
zedbox restarts the device if that handler stops to report. A request
without a deadline on those paths can therefore stop the node. With a
deadline it fails, and Info reports UNKNOWN and keeps the id of the caller,
which is the contract on types.DomainStatus.DomainId. getVmirs already used
this same limit, so the new calls now match it.

Also in this change:

- StopReplicaVMI had an inverted IsNotFound test, so each successful
  delete logged "Stop VMI Replicaset error <nil>" at error level. This
  made a good teardown look like a bad one in the field. The return
  values do not change.
- The kubevirt and k8s client constructors are now package-level vars.
  The tests can then inject a fake client.
- Info() and sweepStaleGenerations() now call getConfig() themselves.
  The receiver is a value, so a getConfig() call in a method they call
  fills in a copy and leaves kubeConfig nil. A nil kubeConfig made
  newKubevirtClient panic, which stopped zedbox and rebooted the node.
  The client stubs in the tests now reject a nil config.
- docs/zedkube.md records what domainID means, what the sweep does, and
  two gaps that stay open: the sweep does not delete the stale
  generation's PVC, and it cannot run if a purge stops before Start().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrew Durbin <andrewd@zededa.com>
sweepStaleGenerations deletes a stale purge generation's VMIRS and pod,
but not its PVC. The old disk stays in the cluster with nothing
pointing at it.

volumemgr already garbage-collects unused files and ZFS volumes on a
timer. Add the same pass for PVCs: gcPVCs lists every PVC, and the
existing gcVolumes deletes any with no live VolumeStatus.

A node keeps a live VolumeStatus for a volume it does not run, when
that volume's app can fail over to it. Checked on a live cluster: the
non-hosting node publishes IsReplicated=true and RefCount>0 for such a
volume, so gcVolumes already skips it.

Split gcVolumes into two parts: volumesToReap decides which candidates
are unused, gcVolumes destroys them. The decision is now unit tested;
the Kubernetes delete call itself is not, and is unchanged.

Also re-enables assertNoOrphanedPVCs in
purge_after_power_cycle_test.go, disabled earlier in this branch
because this fix did not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A PVC can stay Pending forever. Its ProvisioningFailed events
alternate "volume not found" and "volume already exists" - the
provisioner created the Longhorn backend volume once, lost track of
that success in its own cache, and keeps retrying a name it no longer
recognizes. This has hit TestVMAppPurgeDuringFailover at first-ever
PVC creation, timing it out before it ever reaches the failover step
it tests.

Confirmed live, no EVE or pillar change involved: deleting the
csi-provisioner pod forces a fresh leader election and cache, and the
next retry succeeds.

waitForAppRunningMitigatingPVCStall wraps WaitUntilAppIsRunning with a
background watcher. It restarts csi-provisioner, once, only after a
PVC has shown the failure signature continuously for two minutes. A
PVC that clears on its own before then - a normal, if slow, retry -
passes through untouched. It never changes the wait's own timeout or
failure behavior.

Wired into TestVMAppPurgeDuringFailover only, the one test this has
actually been observed to fail. Restarting a cluster-wide Longhorn pod
is not something to do from every test that creates a volume.

This is marked REMOVE ME in longhorn_provisioner_workaround_test.go.
It works around infra, not anything this suite tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
…rkers

The lf-edge#6257 replay left conflict markers in four hypervisor files. Take
the lf-edge#6257 side, matching how the same overlap was resolved on the
newgo line.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The lf-edge#6257 replay resolved kubevirt_info_test.go to the lf-edge#6257 side,
dropping this lf-edge#6271-only test. The newgo line kept it; restore it
here so both lines cover the same behavior.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: eriknordmark <erik@zededa.com>
eriknordmark and others added 4 commits August 8, 2026 22:14
domainmgr asks the hypervisor to shut a domain down and then polls Info()
until it reports the domain gone, before creating any replacement. Stop()
dropped the vmiList entry Info() needs in order to read the guest's phase,
so after Stop every poll could only answer SCHEDULING -- never the HALTED
that ends the wait. domainmgr therefore polls out the entire
graceful-shutdown budget and escalates to a forced shutdown even when
KubeVirt stopped the guest promptly.

Leave the entry in place in Stop and remove it in Cleanup, the unconditional
tail of doInactivate. Delete cannot own the removal: it only runs while
DomainId is still set, which is no longer true once the domain goes away
during the graceful wait, and Cleanup's IsMetaReplicaVMI path did not remove
it either. The removal is deferred so a Cleanup that fails partway still
releases the entry.

On EVE-kvm the same sequence polls real domain state throughout, because the
containerd-backed Stop it inherits keeps no such bookkeeping.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delete asked scheduledOnMe() which node the domain is placed on and then
discarded its error. A failed lookup -- a failed config load, a missing node
name, an unreachable client, or a ReplicaSet Get that did not answer --
reports onMe=false, which is indistinguishable from the legitimate
"scheduled on another node" case tested on the next line. Delete therefore
returned nil, reporting a successful teardown to domainmgr while deleting
nothing, whenever the Kubernetes API was briefly unavailable.

Check the error and return it, as Stop already does with the same call.
domainmgr logs a Delete failure and waits for the domain regardless, so the
retry behaviour is unchanged.

Note this also surfaces an already-deleted ReplicaSet as a Delete error
rather than a silent success, since the lookup's Get reports NotFound through
the same path. That is a louder log on a teardown that had nothing left to
do; softening it would need the lookup to distinguish NotFound from a genuine
API failure, which it currently does not.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed VMIRS delete logged only the error, so an operator reading the log
could not tell which app's replicaset failed to go away -- and this path fires
during every teardown that races the Kubernetes API. Include the replicaset
name, and drop the trailing newline that logrus adds itself.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Info confirms the replicaset exists and then fetches it again to read the
guest's phase. A workload deleted between those two calls made the second
lookup fail with NotFound, which Info reported as an error alongside a
still-scheduling state; domainmgr's wait for the domain to disappear
treats any such error as "already gone" and stops waiting. That is the
same early return the graceful wait exists to prevent, reached through a
narrower window. Absence is now reported identically wherever it is
observed, which also drops a redundant lookup of an object already known
to be gone.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eriknordmark
eriknordmark force-pushed the kubevirt-graceful-stop branch from 5fafb53 to d66956b Compare August 8, 2026 20:22
@github-actions
github-actions Bot requested a review from europaul August 8, 2026 20:22
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The branch carried lf-edge#6271's pinned snapshot of these tests. lf-edge#6271 is now
based on lf-edge#6257 rather than pinning a copy of it, so the tests move to the
version lf-edge#6257 actually ships. Test-only; no image content changes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The branch carried lf-edge#6271's pinned snapshot of these tests. lf-edge#6271 is now
based on lf-edge#6257 rather than pinning a copy of it, so the tests move to the
version lf-edge#6257 actually ships. Test-only; no image content changes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The branch carried lf-edge#6271's pinned snapshot of these tests. lf-edge#6271 is now
based on lf-edge#6257 rather than pinning a copy of it, so the tests move to the
version lf-edge#6257 actually ships. Test-only; no image content changes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The branch carried lf-edge#6271's pinned snapshot of these tests. lf-edge#6271 is now
based on lf-edge#6257 rather than pinning a copy of it, so the tests move to the
version lf-edge#6257 actually ships. Test-only; no image content changes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The branch carried lf-edge#6271's pinned snapshot of these tests. lf-edge#6271 is now
based on lf-edge#6257 rather than pinning a copy of it, so the tests move to the
version lf-edge#6257 actually ships. Test-only; no image content changes.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
lf-edge#6271 has moved to d66956b and is now stacked on lf-edge#6257, carrying two commits
the branch already had under its own SHAs. Records that tip, refreshes the head
and commit count, and states that all seven source PRs were checked by content
against the working tree rather than by commit overlap.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eriknordmark added a commit to eriknordmark/eve that referenced this pull request Aug 8, 2026
The conflict repair that brought TestInfoVmirsDeletedMidCall back onto this
branch kept the test body but dropped the comment explaining why absence seen
at the second Get has to answer HALTED rather than fall through to
SCHEDULING-with-an-error, and why that error matters to waitForDomainGone.
Takes the file from lf-edge#6271's current head, which also puts the test back at its
original position, so the two are now byte-identical.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants