Export a NotFound-classifying helper from internal/libvirt (generaliz... - #152
Merged
colonelpanik merged 1 commit intoAug 9, 2026
Conversation
colonelpanik
marked this pull request as ready for review
August 9, 2026 19:18
colonelpanik
force-pushed
the
overseer/export-a-notfound-classifying-helper-from-internal-libvirt-g
branch
from
August 9, 2026 19:19
3950e07 to
28126b5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Export a NotFound-classifying helper from internal/libvirt (generalizing checkpoint.go's existing unexported isNotFound, which already matches typed golibvirt.Error codes ErrNoDomainCheckpoint/ErrNoDomain/ErrNoDomainSnapshot before falling back to substring matching) and use it in internal/grpcapi/snapshot.go:395 instead of the current strings.Contains(delErr.Error(), "not found") / "no domain snapshot" checks.
Plan
Plan: export a NotFound-classifying helper from
internal/libvirtGoal
Promote
internal/libvirt/checkpoint.go's unexportedisNotFoundto an exportedlibvirt.IsNotFound, and use it atinternal/grpcapi/snapshot.go:395in place of thehand-rolled
so the "libvirt snapshot metadata is already gone — still clean up the corrosion record"
path is decided by one shared classifier that checks typed go-libvirt error codes
first and only then falls back to substring matching.
What exists today (read this before judging the diff)
1. The helper —
internal/libvirt/checkpoint.go:160-178One call site:
DeleteCheckpointatcheckpoint.go:91, where a not-found lookup returnsnil(idempotent delete).golibvirt.Erroris a value type (Code uint32,Message string) whoseError()returns
Messageverbatim — go-libvirt's owncheckErroruses the sameerr.(Error)assertion shape, so the direct type assertion here matches upstream idiom.
2. The call site —
internal/grpcapi/snapshot.go:381-400DeleteSnapshotcallsFlattenSnapshot(running VM, last snapshot) orDeleteSnapshot, falling back from the former to the latter. If the resultingdelErrlooks like "already gone", it logs and continues so the corrosion record + vmstate file +
firmware sidecar are still cleaned up; otherwise it returns
codes.Internal. The commentat
:391-393is explicit that a prior revert consumes the libvirt snapshot metadata, sothis is a normal, expected state — not an error path.
3. Three facts that decide whether this change is a regression
(a) The helper's substring list does not contain
"no domain snapshot".This is the single most important detail. A naive swap to
isNotFoundwould drop theno domain snapshotarm that grpcapi added, which is exactly libvirt's message for thecase the
:391-393comment describes. The exported helper must gain"no domain snapshot"in its fallback list. Without it this change is a silentregression on its own motivating case.
(b) Today's grpcapi check usually matches on litevirt's own wrapper text, not
libvirt's.
internal/libvirt/snapshot.go:293wraps the snapshot lookup asfmt.Errorf("snapshot %q not found: %w", snapshotName, err)— so everyDomainSnapshotLookupByNamefailure, whatever its cause, already contains"not found"and is already classified as "already gone" today. The exported helper does not change
that (its
"not found"arm matches the same string). Reviewers evaluating "is the newclassifier too permissive?" should note the existing one is permissive in precisely this
way; nothing here widens that.
(c) The typed path is genuinely reachable at this call site.
internal/libvirt/snapshot.go:296returnsc.virt.DomainSnapshotDelete(...)'s errorunwrapped — a raw
golibvirt.Error. If a snapshot vanishes between lookup and delete(a revert racing a delete), the error arrives with
Code == ErrNoDomainSnapshotand anarbitrary message. Today's substring check would classify that as a hard failure and
return
Internal, leaking the corrosion record. The exported helper fixes it. This is theone real behavioural win of the change, and the test below pins it.
Files to change
1.
internal/libvirt/errors.go— new fileHolds the exported helper. A shared, cross-package classifier does not belong in
checkpoint.go(whose package doc is about dirty-bitmap backup); a dedicated file makesit discoverable.
The body is
isNotFoundverbatim plus the one new"no domain snapshot"arm.2.
internal/libvirt/checkpoint.go— deleteisNotFound, update its caller160-178.:91becomesif IsNotFound(err) {."strings"from the import block.stringsis used only insideisNotFoundin this file (verified:grep -n 'strings\.' internal/libvirt/checkpoint.gohits 174-177 and nothing else).
golibvirtstays — it is still used at:96and:98.Missing this is a build break.
3.
internal/grpcapi/snapshot.go— use the helper:395becomes:lv(internal/libvirt, used at:413forlv.SnapshotFirmwareBundlePath). No new import."strings"from the import block. Line 395 is the file's only use ofstrings(verified). Missing this is a build break.:391-393as-is; it still describes exactly why thebranch exists.
Exact behavioural delta
checkpoint.go:91(DeleteCheckpoint)not found/no domain checkpoint/cannot findno domain snapshotErrNoDomainSnapshotis already in the typed list, so this is internally consistent;DomainCheckpointLookupByNamecannot realistically produce that text anyway.grpcapi/snapshot.go:395not found/no domain snapshotnot found/no domain snapshot/no domain checkpoint/cannot findcannot find,no domain checkpoint, and case-insensitivity.The one risk worth naming. At the grpcapi site the accepted set grows by
"cannot find". A genuineDomainSnapshotDeletefailure whose message happened tocontain that phrase would now be swallowed: the corrosion record is tombstoned while the
libvirt snapshot metadata survives, leaving orphaned metadata (no data loss — the disk
overlay and backing chain are untouched, and
virsh snapshot-deleteclears it).Assessment: acceptable. libvirt's
cannot findmessages are emulator/chardev/statisticslookups, not snapshot-delete failures; and per fact (b) the pre-existing check already
classifies every lookup failure as not-found via litevirt's own wrapper, so this is not a
new class of over-broad classification. The alternative — a second, narrower classifier —
defeats the point of having one shared helper.
Deliberately not changed
err.(golibvirt.Error)stays a direct type assertion, noterrors.As.errors.Aswould be strictly broader (it would also catch a typed error wrapped by
fmt.Errorf)and would not regress the fallback, so it is safe — but it is a behaviour change,
whereas everything else here except the
no domain snapshotarm is a pure move. Keepingit lets a reviewer verify "same logic, new home" by inspection. Worth a follow-up commit;
not worth entangling with this one. (It is also not needed for correctness today: the
raw typed error at
internal/libvirt/snapshot.go:296is unwrapped, and the wrappedlookup error at
:293is caught by the"not found"arm.)internal/libvirt/snapshot.go:194, 540, 558— the"lock"/"already"retryheuristics. These are QEMU/virtlockd write-lock retry predicates with no corresponding
typed go-libvirt code; classifying "should I retry this start/restore/resume?" is a
different question from "does this object exist?". Out of scope by constraint, and the
unit test below pins that
IsNotFound("Failed to get write lock") == falseso a futurerefactor cannot quietly merge the two.
internal/storage/iscsi.go:37,internal/lb/demote.go:430,internal/grpcapi/containers.go— none of these classify a libvirt error(iscsiadm stdout, load-balancer state, container runtime). Not candidates.
Tests
A.
internal/libvirt/errors_test.go— new,package libvirtThere is currently no unit test for
isNotFoundanywhere. Table test forIsNotFound:nilgolibvirt.Error{Code: uint32(golibvirt.ErrNoDomain), Message: "opaque"}golibvirt.Error{Code: uint32(golibvirt.ErrNoDomainSnapshot), Message: "opaque"}golibvirt.Error{Code: uint32(golibvirt.ErrNoDomainCheckpoint), Message: "opaque"}golibvirt.Error{Code: uint32(golibvirt.ErrInternalError), Message: "opaque"}errors.New("no domain snapshot with matching name 'snap1'")fmt.Errorf("snapshot %q not found: %w", "snap1", errors.New("boom"))internal/libvirt/snapshot.go:293)errors.New("Domain Not Found")errors.New("internal error: qemu unexpectedly closed the monitor")errors.New("Failed to get write lock")Message-only cases must use messages that do not also satisfy the typed branch, and
typed cases must use messages that do not also satisfy the substring branch, so each
row exercises exactly one arm.
B.
internal/grpcapi/snapshot_test.go— extend the existing fileThis file already exists (179 lines, 9 tests, including
TestDeleteSnapshot_VMNotFoundand
TestDeleteSnapshot_WrongHost). Append the stub and the three tests below; do notrewrite the file. The existing imports need
errors,fmt,libvirtfake, andgolibvirtadded;codes/status/corrosion/pbare already there.Test doubles:
s.virtis an unexportedLibvirtBackendfield, settable directly from asame-package test (
internal/grpcapi/vtpm_lifecycle_test.go:29doess.virt = fake), andthe repo already has the embed-and-override stub idiom (
recordingVirtindualrun_test.go:19). Add:Setup for each case:
s := testServer(t);s.dataDir = t.TempDir()ctx := adminCtx()— and pass it to every handler call.DeleteSnapshotopens withrequirePermPrecheck(ctx, "operator")(snapshot.go:340), which returnscodes.Unauthenticatedoutright whencallerUsername(ctx)is empty(
internal/grpcapi/auth.go:711-714), and thenRequirePerm(…, "snapshot.delete", "operator")at:347. A barecontext.Background()would fail at the first line andthe fake backend — and therefore the classifier under test — would never be reached,
making all three tests vacuous.
testServer(t)leavesauthEnginenil, so theprecheck falls through to
RequireRole(ctx, "operator")andadminCtx()'s admin rolesatisfies it; this is the same setup
vtpm_lifecycle_test.go:117already uses to callDeleteSnapshotsuccessfully."stopped"(soflattenatsnapshot.go:379is false andonly
DeleteSnapshotis called — one code path, no flatten fallback to reason about)corrosion.SnapshotRecords.virt = &deleteErrVirt{Fake: libvirtfake.New(), delErr: …}Because an auth failure and a "record survived" assertion can look alike, each test should
assert the specific expected code (
codes.Internalfor case 3) rather than justerr != nil— anUnauthenticatedregression must not be able to pass as a failure case.TestDeleteSnapshot_TypedNotFoundTreatedAsAlreadyGone— the headline test.delErr = golibvirt.Error{Code: uint32(golibvirt.ErrNoDomainSnapshot), Message: "gone"}.Assert the RPC returns no error and
corrosion.GetSnapshotreturns nil (recordtombstoned).
"gone"contains neither"not found"nor"no domain snapshot", sothis test fails on
mainwithcodes.Internal— it is the one that proves thechange did something. (It costs the grpcapi test binary a direct
go-libvirtimport;the package already depends on it transitively through
internal/libvirt, so thisdoes not contradict the
LibvirtBackenddoc comment's intent — no libvirt socketclient is constructed.)
TestDeleteSnapshot_MessageNotFoundTreatedAsAlreadyGone— sub-tests overerrors.New("no domain snapshot with matching name 'snap1'")andfmt.Errorf("snapshot %q not found: %w", "snap1", errors.New("boom")). Sameassertions. These pass before and after; they are the no-regression guard for the two
arms the old check covered.
TestDeleteSnapshot_RealFailureStillPropagates—delErr = errors.New("internal error: qemu unexpectedly closed the monitor").Assert
status.Code(err) == codes.Internaland that the corrosion record is stillpresent. The second assertion is what makes this test non-vacuous: without it, a
classifier that returns
truefor everything still passes.Mutation verification (required by
CLAUDE.md— run each, confirm red, restore)switchfromIsNotFoundTestDeleteSnapshot_TypedNotFoundTreatedAsAlreadyGone"no domain snapshot"armTestDeleteSnapshot_MessageNotFoundTreatedAsAlreadyGoneIsNotFoundreturnstrueunconditionallyTestDeleteSnapshot_RealFailureStillPropagates; libvirt negative rowsgrpcapi/snapshot.go:395to the oldstrings.ContainspairTestDeleteSnapshot_TypedNotFoundTreatedAsAlreadyGone— proves the call site really routes through the helperNo fleet-tier test is warranted: this is package-local error classification with no
multi-node failure mode, and
tests/fleet/has no snapshot-delete coverage to extend.internal/libvirt/checkpoint_integration_test.gois behind thelibvirt_integrationbuild tag and never runs in CI — do not put anything load-bearing there.
Verification
make ci-guardsshould be a clean no-op for this change: no schema change(
check-schema-bump.sh), no SQL builder (stmtshapecheck), no state write(
writecheck). The docs triangulation guard(
cmd/litevirt/docs_triangulation_test.go) is also unaffected —IsNotFoundis aninternal Go symbol, not a command, config key, or
litevirt_*identifier, so nodocumentation is owed.
Suggested commit:
refactor(libvirt): export IsNotFound and use it for snapshot delete.Reviewer checklist
"strings"removed from bothinternal/libvirt/checkpoint.goandinternal/grpcapi/snapshot.go— each file's only use of it is being deleted."no domain snapshot"is in the exported helper's fallback list. Without it thechange regresses the exact case
grpcapi/snapshot.go:391-393exists for.is still reached when the assertion fails.
internal/libvirt/snapshot.go:194, 540, 558are untouched.TestDeleteSnapshot_TypedNotFoundTreatedAsAlreadyGonefails when checked outagainst
main'ssnapshot.go:395— if it passes on both, it is not testing thechange.
TestDeleteSnapshot_RealFailureStillPropagatesasserts the corrosion recordsurvives, not just the gRPC code.
adminCtx(). Acontext.Background()call returnsUnauthenticatedbefores.virtis touched — the test would pass for the wrongreason (or fail as if the classifier were broken).
internal/grpcapi/snapshot_test.go; the ninepre-existing tests in it are still present.
Deviations from this plan, as implemented
The plan was accurate on every concrete claim — line numbers,
golibvirt.Errorbeing avalue type, the
snapshot %q not found: %wwrapper atinternal/libvirt/snapshot.go:293,the unwrapped
DomainSnapshotDeletereturn at:296,stringsbeing each file's onlyremaining use, and
testServer(t)+adminCtx()reaching the backend. Two cosmeticdepartures:
deleteSnapshotServer(t, delErr)helper ratherthan repeating the plan's five setup bullets three times. It does exactly what the plan
listed (temp
dataDir,adminCtx(), a stoppeddvmon the local host, a matchingSnapshotRecord,deleteErrVirtass.virt). Because it returns the context, thetest file's import list gained
contexton top of theerrors/fmt/libvirtfake/golibvirtthe plan enumerated.// NEW — see (a) abovemarker on the"no domain snapshot"arm was notshipped. It refers to this document, which the committed code cannot; the arm reads
as a peer of the other three.
All four mutations were run and each went red as predicted — including
TestDeleteSnapshot_TypedNotFoundTreatedAsAlreadyGonefailing under mutation 4 (call sitereverted to the
strings.Containspair with the helper left intact), which is the one thatproves the call site routes through the helper. Full
go build ./...,go vet ./...,go test ./...andmake ci-guardsare clean;ci-guardswas a no-op as predicted.Final Codex review
No blocking findings remained.
Opened by overseer. The plan and the code each converged to zero blocking findings from Codex before this pull request was created.