kvm2: an unreadable address is not a missing one (H3)
address() tried the guest agent then the host's ARP table and skipped any
error that was not a context error, returning ("", nil) after both. An
active domain with no IP maps to StateProvisioning; the controller clears
status.IP on that, and the health engine prunes the proxy's counters and
calls ForgetProxy in one tick once EffectiveHost() goes empty.
The damaging trigger was not the guest. The libvirt connection is pooled
per host, so when another reconcile's cancel or budget overrun killed it,
call returned "connection is closed" -- carrying no context sentinel, so
the check did not recognise it -- and Get returned a *successful*
instance in Provisioning with an empty IP. An unrelated proxy's timeout
dropped a healthy proxy out of rotation.
The discriminator was already in the spec without the conclusion being
drawn: during a normal boot on beelink exactly one source fails. The
agent errors because it is not up yet, while ARP answers without error
and with nothing, since the host never sees the guest's traffic. So one
source failing stays "no address yet"; every source failing, or a
connection already dead, is ErrTransient.
The explicit provider.ErrTransient wrap is load-bearing, not decoration.
Without it classify picks the first libvirt code out of the joined error,
and ErrInvalidArg, ErrAccessDenied and ErrNoSupport are Permanent -- an
address read failing would latch a running proxy as Failed, a worse
version of the harm being fixed.
Half of the finding is deliberately still open, and it is the half that
fires more often: a guest agent restarting inside a running VM still
demotes it, because that is indistinguishable from a VM still booting.
The discriminator is the previous IP, which the provider does not hold by
design and the controller already has. Follow-up finding to file against
proxy_controller.go:208-211 -- on StateProvisioning, keep an existing IP
and Provisioned condition for a few polls instead of clearing on the
first. The kvm2-local alternative (use the mark's createdAt as a
provisioning window) was rejected: createdAt is creation, not boot, and
it invents a deadline that belongs to the controller.
TestGet_addressErrorIsProvisioning failed against the new rule, correctly
-- it fails both sources with ErrAgentUnresponsive, and code 86 is
agent-specific, so the ARP source cannot produce it. Re-pointed as
TestGet_agentFailsAndARPEmptyIsProvisioning, the beelink boot it was
reaching for, which nothing covered before.
TestGet_everyAddressSourceFailingIsTransient covers the other side, both
sources erroring and the pooled link dying under the read; against the
unfixed code both return a Provisioning instance instead of an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ Plan: [docs/plans/2026-09-17-1515-kvm2-review-fixes.md](../plans/2026-09-17-1515
|
||||
- [x] Step 0 — Save plan into the repo
|
||||
- [x] Step 1 — H1: a stalled write must not delay the teardown
|
||||
- [x] Step 2 — H2: a source error must not reach go-libvirt's stream sender
|
||||
- [ ] Step 3 — H3: an unreadable address must not look like a missing one
|
||||
- [x] Step 3 — H3: an unreadable address must not look like a missing one
|
||||
- [ ] Step 4 — H4: the base image cache must not be shared across provider entries
|
||||
|
||||
Remaining after those: the 48 mediums and lows in `review/REPORT.md`. Each gets its own
|
||||
@@ -210,3 +210,76 @@ atomic-publish work then lands exactly where this step put the discard.
|
||||
would mean a 150-250 line libvirt RPC server stub whose only assertion is about *upstream's*
|
||||
goroutines. Same call as H1's: the test would cost more than it protects. The two tests that
|
||||
did land pin the one property the seam actually needs.
|
||||
|
||||
## Step 3 — H3: an unreadable address must not look like a missing one
|
||||
|
||||
`address()` tried the guest agent then the host's ARP table and skipped any error that was
|
||||
not a context error, returning `("", nil)` after both. An active domain with no IP maps to
|
||||
`StateProvisioning`, the controller clears `status.IP` on that, and the health engine prunes
|
||||
the proxy's counters and calls `ForgetProxy` in one tick once `EffectiveHost()` is empty.
|
||||
|
||||
The damaging trigger was not the guest. The libvirt connection is pooled per host, so when
|
||||
another reconcile's cancel or budget overrun killed it, `call` returned
|
||||
`"connection is closed"` — carrying no context sentinel, so the check did not recognise it —
|
||||
and `Get` returned a *successful* instance in Provisioning with an empty IP. An unrelated
|
||||
proxy's timeout dropped a healthy proxy out of rotation.
|
||||
|
||||
The fix rests on a discriminator the spec had already recorded without drawing the
|
||||
conclusion: during a normal boot on beelink exactly one source fails. The agent errors
|
||||
because it is not up yet, while ARP answers *without* error and with nothing, since the host
|
||||
never sees the guest's traffic. So one source failing is "no address yet"; every source
|
||||
failing, or a connection already dead, is `ErrTransient`.
|
||||
|
||||
The explicit `provider.ErrTransient` wrap on the joined error is load-bearing rather than
|
||||
decoration. Without it `classify` picks the first libvirt code out of the join tree, and
|
||||
`ErrInvalidArg`, `ErrAccessDenied` and `ErrNoSupport` are Permanent — an address read failing
|
||||
would have latched a *running* proxy as Failed, a worse version of the harm being fixed.
|
||||
|
||||
`isDead(be)` is checked first in the error branch. It is not strictly needed, since a dead
|
||||
link fails both sources and the count rule catches it anyway, but it fails on the first
|
||||
source instead of the second, attributes the error better, and is the same predicate M15 will
|
||||
want for `ListByTag`.
|
||||
|
||||
### Tests
|
||||
|
||||
`TestGet_addressErrorIsProvisioning` failed against the new rule, which is correct: it fails
|
||||
*both* sources with `ErrAgentUnresponsive`, and code 86 is agent-specific — the ARP source
|
||||
cannot produce it. Re-pointed as `TestGet_agentFailsAndARPEmptyIsProvisioning`, the beelink
|
||||
boot it was really reaching for: agent errors, ARP returns no addresses and no error, result
|
||||
is Provisioning. Nothing covered that before, and it is the case the new rule must not break.
|
||||
|
||||
`TestGet_everyAddressSourceFailingIsTransient` covers the other side, two subtests: both
|
||||
sources erroring, and the pooled connection dying under the read. Against the unfixed code
|
||||
both return a Provisioning instance instead of an error, which is exactly the bug:
|
||||
|
||||
```bash
|
||||
# with isDead() and the every-source-failed arm removed from address():
|
||||
go test -count=1 -run TestGet_everyAddressSourceFailingIsTransient ./internal/provider/kvm2/
|
||||
# get_test.go:215: Get = &{... IP: State:Provisioning ...}, want no instance
|
||||
```
|
||||
|
||||
`TestGet_arpFallbackWhenAgentFails` passes untouched — one source of two.
|
||||
|
||||
### Worth noting
|
||||
|
||||
**Half of H3 is deliberately still open, and it is the half that fires more often.** A guest
|
||||
agent restarting inside a running VM still demotes it to Provisioning and clears its IP: the
|
||||
agent errors, ARP answers empty, and that is indistinguishable from a VM still booting. The
|
||||
discriminator is the *previous* IP, which the provider does not hold by design
|
||||
(`TestGet_readsUIDAndCreatedAtFromBackend` pins "read back, not remembered") and the
|
||||
controller already has. Follow-up finding, to be filed against
|
||||
`internal/controller/proxy_controller.go:208-211`: on `StateProvisioning`, keep an existing
|
||||
IP and `Provisioned` condition for a few polls rather than clearing on the first, or give the
|
||||
health engine a grace period before pruning.
|
||||
|
||||
The one kvm2-local alternative was considered and rejected: the mark carries `createdAt`, so
|
||||
`get` could treat "active, no address from any source, domain older than a provisioning
|
||||
window" as an error. It uses state the provider genuinely has, but `createdAt` is creation
|
||||
rather than boot, it fires on every genuinely stuck VM as a retry-forever error, and it
|
||||
invents a provisioning deadline that belongs to the controller. A design change wearing a bug
|
||||
fix's clothes.
|
||||
|
||||
**L6 not folded in.** A domain that vanishes between `DomainGetState` and the address read
|
||||
now returns Transient rather than a fake Provisioning — better, and self-correcting on the
|
||||
next poll. Returning NotFound one poll earlier is a two-line addition at the same spot, left
|
||||
for when L6 comes up so this commit stays one idea.
|
||||
|
||||
Reference in New Issue
Block a user