networkmanager: repair pooled TAP drift instead of failing - #32
Conversation
Unclean daemon shutdowns and foreign tooling can leave pooled TAPs with stale state: a host MAC that never got (or lost) its deterministic value, or a bridge attach lost when a predecessor's bridge was deleted. Both variants used to abort allocation or daemon recovery outright, which surfaced as 'pooled TAP ... host MAC is <random>, want 02:fd:...' on consecutive restores and as fatal startup on leftover endpoints. The endpoint identity is fully determined by its name, so re-stamping the deterministic host MAC and re-attaching the bridge can only converge a pooled TAP back to the expected state. Do that (with a warning) and keep hard errors for real identity violations (wrong type/name) and lease-record corruption. The kernel ifindex is bookkeeping, not identity: refresh the lease record on drift instead of failing.
There was a problem hiding this comment.
Pull request overview
This PR changes pooled TAP validation in networkmanager to repair host-side drift (missing bridge attachment, randomized host MAC, and ifindex drift) during allocation and daemon startup recovery, rather than failing hard when the TAP’s identity-by-name is still valid.
Changes:
- Re-attach pooled TAPs to
sandbox0when the master index drifted, logging a warning instead of aborting. - Re-stamp the deterministic host MAC (
02:fd:<ip4>) when the kernel-assigned/random MAC is observed, logging a warning instead of aborting. - Treat ifindex drift as non-fatal and attempt to “refresh” the lease record (warn + update in-memory struct).
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| logrus.Warnf( | ||
| "networkmanager: pooled TAP %s index drifted (%d, lease records %d); refreshing lease", | ||
| expectedName, link.Attrs().Index, resource.Interface.Index, | ||
| ) | ||
| resource.Interface.Index = link.Attrs().Index |
There was a problem hiding this comment.
Fixed in f7eae7f (durable key swap on recovery)
There was a problem hiding this comment.
Superseded by f1c3f7c: active-lease ifindex drift is now rejected outright at recovery (see tianyuzhou95's thread), so recovery never rewrites the durable key anymore. The remaining refresh paths (markUsing re-handout, Recycle) persist the refreshed serialization through the returned/queued string, which is what the durable key and the handed-out annotation both become.
| if err := netlink.LinkSetHardwareAddr(link, expectedHostMAC); err != nil { | ||
| return fmt.Errorf( | ||
| "restore pooled TAP %s host MAC to %s: %w", expectedName, expectedHostMAC, err, | ||
| ) | ||
| } |
There was a problem hiding this comment.
False positive: net.HardwareAddr implements Stringer; fmt %s invokes it (verified output is colon-separated), and the pre-PR code used the same pattern.
Review follow-up: setTapState may repair lease bookkeeping in memory (the ifindex refresh), but the recovery path kept the original usingInterfaces key, so the repair was never stored and the drift warning would repeat on every restart. Swap the durable key when the serialized lease changed so the repaired one is what gets persisted and recycled.
| // ifindex refresh after the device was recreated). Swap the | ||
| // durable key so the repaired lease is what gets stored and | ||
| // handed back on recycle, instead of re-warning every restart. | ||
| if refreshed := stored.ToString(); refreshed != activeID { |
There was a problem hiding this comment.
Do not repair active TAP replacement by rewriting the lease key
An active TAP should not be recreated by sandboxd: createTapDevice refuses to replace an existing device, MAC and bridge repairs do not change the ifindex, and a TAP left in the LinkAdd crash window has not yet become a durable active lease. An ifindex mismatch on an active lease therefore means that the kernel device was replaced externally, which is an identity violation rather than ordinary bookkeeping drift. Please keep this case as a hard error instead of accepting the replacement.
Rewriting the key here is also not sufficient to migrate ownership. The original serialized NetResource remains in the sandbox OCI annotations and is later passed to Deactivate and Release. Those operations look up the old string, do not find it after this swap, and return success without deactivating or recycling the TAP, leaving the refreshed lease active after sandbox deletion.
The MAC and bridge self-repair can remain independent of this check. A regression test should verify that active ifindex replacement is rejected while idle orphan MAC and bridge drift are still repaired.
There was a problem hiding this comment.
Addressed in f1c3f7c — agreed on all three points.
- Active-lease ifindex mismatch is a hard error again, checked in
load()before any repair:active pooled TAP … was replaced externally (ifindex N, durable lease records M). The durable lease is left untouched for the operator and the replacement is never adopted (noLinkSetUpon it). - The durable-key swap from f7eae7f is reverted: with active-lease replacement rejected, recovery no longer mutates the stored serialization, so no rename can ever diverge from the sandbox OCI annotations — your second concern is gone at the root rather than patched over.
resolveLeaseKey(f8f789f) stays as defense in depth for release-path lookups. - MAC re-stamp / bridge re-attach repairs are unchanged, and the ifindex refresh now only runs on consumer-less paths (
markUsingre-handout,Recycle), where the refreshed serialization is exactly the string the caller persists and later releases. - Regression tests added per your ask:
TestRecoveryRejectsExternallyReplacedActiveTap(replacement rejected) andTestRecoveryRepairsIdleOrphanMacAndBridgeDrift(idle orphan with randomized MAC + detached bridge still adopted with both repairs). Full gates green (gofmt/go vet/go test ./...).
Review follow-up (P1): recovery renames the durable lease key when setTapState repairs ifindex drift, but the serialized NetResource in the sandbox OCI annotations still names the pre-repair string. Deactivate and Release missed on that stale key and silently returned success, so the refreshed lease stayed active forever, leaking its IP and pool slot after sandbox deletion. Release-path lookups (Recycle, Deactivate, releaseEphemeral, Discard) now resolve misses onto the stored key by immutable identity (endpoint type, interface name, IP) via resolveLeaseKey. Recycle also queues the serialization refreshed by setTapState instead of the caller's copy, so idle leases carry current bookkeeping. setTapState now routes its host mutations through the injectable linkOperations seam: the package-level netlink wrappers are inline candidates that gomonkey cannot patch, which made recovery untestable under plain 'go test'. Adds the requested regression test: active ifindex drift, restart (key rename), then sandbox deletion driving Deactivate and Release with the pre-restart annotation string — red before the resolution, green after.
Review follow-up (revised comment): sandboxd never recreates a leased TAP — createTapDevice refuses to replace an existing device, MAC/bridge repairs do not change the ifindex, and a createTapDevice crash-window orphan has no durable lease yet. An ifindex mismatch on an ACTIVE lease therefore means the kernel device was replaced externally, and the owning sandbox's networking is already broken (the guest still holds the old device). Recovery now refuses to adopt the replacement instead of refreshing the lease record over a dead endpoint; the durable lease is left untouched for the operator. This also reverts the durable-key swap from f7eae7f: with active-lease replacement rejected, recovery no longer mutates the stored serialization, so no rename can diverge from the sandbox OCI annotations. resolveLeaseKey (f8f789f) stays as defense in depth for any future divergence between externally held strings and stored keys. The ifindex refresh remains on the consumer-less paths (markUsing re-handout, Recycle), where the refreshed serialization becomes the string the caller persists and later releases. MAC re-stamp and bridge re-attach repairs are unchanged (they restore function rather than relabel a broken state). Adds the reviewer-requested regression tests: active ifindex replacement is rejected while idle orphan MAC and bridge drift are still repaired, plus the markUsing consistency path.
Design note: how the TAP pool is accounted, and what recovery may and may not healPosting the full reasoning behind this change (and the review follow-ups) in one place, since it answers several questions raised in review. 1. Pool bookkeepingThe manager keeps three ledgers:
Only active leases are persisted: idle state is reconstructable from the host, but a live sandbox's claim must outlive the daemon that granted it. The VM processes hold their TAP fds independently, so a daemon restart never interrupts a sandbox. Device ownership is expressed by three deterministic conventions:
2. When recovery self-heals and when it refusesThe criterion is "does the repair restore function, and is it a convergence toward the deterministic expectation?" Relabeling a broken state as healthy is not healing.
3. The recovery algorithm (
|
Remove fallback resolution by endpoint type, name, and IP now that active TAP replacement is rejected during recovery. Exact serialized lease keys preserve generation boundaries and avoid allowing stale cleanup requests to act on a later sandbox reusing the same endpoint identity. Document the reserved TAP namespace that permits sandboxd to adopt and repair unleased TAP devices during startup recovery. Signed-off-by: Tianyu Zhou <albert.zty@antgroup.com>
|
Follow-up pushed in This trims the recovery change back to the intended ownership contract:
Validation on the remote test node completed successfully: |
|
ACK on Since Verified at Ready to merge from our side. |
|
Please resolv the conflict and I will merge this pr. |
|
There is one related TAP crash-consistency gap worth fixing in this PR. Ordinary pooled Could this PR persist the active pooled lease synchronously before |
Resolve README.md conflict: keep the TAP namespace reservation note from this branch and adopt main's network ACL schema v2 wording.
Ordinary pooled Allocate() returned as soon as markUsing() updated the in-memory active set; keepStoring() only flushed that state on its five-second tick. A daemon exiting after the TAP was handed to a runtime but before the flush left the durable active set without the lease, so restart recovery classified the TAP as idle and could hand it to a second sandbox while the first runtime still held the device. Allocate() now persists the active set synchronously before returning — the same write-before-handoff boundary AllocateEphemeral() already uses. A StoreRaw failure fails the allocation: the TAP is rolled back to a proven-safe idle state (setTapState converges it down, refreshed serialization re-queued), or stays leased and quarantined when rollback cannot be completed safely. The pending storeMark is deliberately left set on success so a concurrent Recycle's removal can never be dropped from the next flush. Regression tests inject the persistence failure and verify that no lease reaches the caller, that the endpoint returns to the idle pool only in the converged state, and that an unrollbackable endpoint stays quarantined. Signed-off-by: hky1999 <hukeyang.hky@antgroup.com>
|
Follow-up pushed in The TAP crash-consistency gap is fixed at the requested boundary:
The drift-repair and recovery semantics this PR is about are untouched ( CI is 13/13 green on |
Summary
Pooled TAP endpoints whose host-side state drifted — host MAC randomized,
or the bridge attach lost when a predecessor daemon's
sandbox0was torndown — used to abort allocation and even make daemon startup fatal:
The endpoint identity is fully determined by its name (IP-encoded), so
re-stamping the deterministic host MAC and re-attaching the bridge can only
converge a pooled TAP back to its expected state. This change repairs such
drift in place with a warning, instead of failing.
What changed
pkg/networkmanager/tap.go—setTapStateonly (+35/−12):LinkSetMasterre-attach + warn02:fd:<ip>LinkSetHardwareAddrre-stamp + warnNo API or behavior change for healthy pools.
Why repair is safe
02:fd:<ip4>host MAC,sandbox0master)are pure functions of the lease IP; applying them cannot make a correct
endpoint wrong.
markUsingrepairs freshly-popped idle endpoints, whileload()atdaemon startup repairs both adopted idle taps and taps leased to
still-running sandboxes (the VMs outlive the daemon; that is the
point of the durable leases). Re-attaching a bridge port or re-stamping
the host-side MAC on a TAP a guest still holds open only converges the
host side toward the expected state.
device types) still fail loudly.
Root cause (why drift exists at all)
createTapDeviceis the only TAP creation site and always sets thedeterministic MAC — but creation is not atomic:
LinkAdd(kernel assignsa random MAC) →
SetHardwareAddr→ bridge attach → set down. netlinktuntap creation cannot carry a MAC in the create call (kernel API
limitation, iproute2 is equally two-step), so a daemon dying inside that
window structurally leaves an orphan
tap.*with a random MAC and nobridge. The next daemon's recovery adopts host
tap.*devices into thepool — and then dies on them.
This was verified end-to-end by deterministically reenacting the crash
window: a 20-line program issues the exact same netlink calls and exits
right after
LinkAdd, leavingtap.0a5b00c8with MAC2a:21:c6:f4:97:e0, unattached. Against that single orphan:recover idle pooled TAP tap.0a5b00c8: … is not attached to sandbox0(the production signature)2a:21:c6:f4:97:e0 → 02:fd:0a:5b:00:c8), and a sandbox restorethrough the adopted endpoint succeeds.
Since the origin (crash window) cannot be eliminated at the kernel-API
level, adoption-time repair is the correct layer to fix it.
Verification
Root cause was reproduced by injecting drift into a pooled TAP on the old
binary — the first allocation failed with the exact production error
signature; clean sequential/concurrent runs never reproduce (rules out a
race). With the fix (full matrix in the linked check doc):
Gates:
gofmt -lempty,go vet ./...,go test ./...green.Notes for reviewers
fatal after unclean termination when a stale sandbox cgroup dir is missing
pids.max; deserves the same drop-stale/repair treatment.FC fork @2c6936f6a + runsc release-20260817.0-akernel.1(pinned).
Review follow-up 1 —
f7eae7f(Copilot: persist the refreshed lease key)The ifindex repair only mutated the in-memory struct, so recovery kept the
original
usingInterfaceskey and re-warned on every restart. Recovery nowswaps the durable key when the serialization changed.
Review follow-up 2 —
f8f789f(P1: keep external lease references working)The durable-key swap above broke the sandbox's externally held copy: the
serialized
NetResourceis written into the sandbox OCI annotations once atstart and later passed back verbatim to
Deactivate/Release. After therename, both lookups missed and silently returned success, so the refreshed
lease stayed active forever — leaking its IP and pool slot after sandbox
deletion.
Release-path lookups (
Recycle,Deactivate,releaseEphemeral,Discard)now resolve misses onto the stored key by immutable identity (endpoint type,
interface name — which encodes the IP — and the IP) via
resolveLeaseKey,so the durable key can stay fresh without orphaning external references.
setTapStatealso routes its host mutations through the injectablelinkOperationsseam (the package-level netlink wrappers are inlinecandidates that gomonkey cannot patch, which made recovery untestable under
plain
go test). Regression testTestRecoveryIfindexDriftKeepsExternalLeaseReferenceWorkingcovers therequested scenario: active ifindex drift → restart (key rename) → sandbox
deletion with the pre-restart annotation string — red before the fix, green
after.
End-to-end verification on a live host (real netlink devices + real durable
store; annotate → external delete/recreate of the TAP → daemon recovery →
Deactivate/Release with the stale annotation string):
Deactivate(stale)/Release(stale)main(PR base)pooled TAP … index is 60, lease records 59— daemon cannot startf7eae7fnilwithout doing anythingf8f789fGates re-run at
f8f789f:gofmt -lempty,go vet ./...,go test ./...green.How startup handles a dirty tap (the full matrix)
Daemon startup (
load()) classifies each hosttap.*device by lease stateand repairs what has a deterministic answer:
LinkSetUp, lease kept; ifindex refresh also renames the durable key (external references stay resolvable per follow-up 2)LinkSetDown, queued as idleAt allocation,
markUsingruns the same validation on freshly-popped idletaps; an unrepairable one is quarantined (kept in the using set, counted,
never requeued) rather than handed to a sandbox.
Reproducing locally
The P1 chain (ifindex drift → restart → deletion with the stale annotation
string) is covered by the regression test added in
f8f789f:For an end-to-end check against real netlink devices and the real durable
store (needs root):
Observed matrix across the three commits (
main/f7eae7f/f8f789f),also embedded in the table under follow-up 2. The MAC re-stamp and bridge
re-attach repair branches can be exercised the same way by randomizing the
MAC or detaching the bridge instead of recreating the device.
Review follow-up 3 —
f1c3f7c(revised P1 comment: reject replaced active TAPs)The reviewer sharpened the boundary and it is the better call: sandboxd never
recreates a leased TAP, so an ifindex mismatch on an ACTIVE lease means the
kernel device was replaced externally — and the owning sandbox's networking
is already broken (the guest still holds the old device). Refreshing the
lease record would relabel a broken state as healthy instead of restoring
function.
load()before any repair; the durable lease is left untouched and thereplacement is never adopted.
mutates the stored serialization, so no rename can diverge from the sandbox
OCI annotations.
resolveLeaseKey(follow-up 2) stays as defense in depth.runs on consumer-less paths (
markUsingre-handout,Recycle), where therefreshed serialization is what the caller persists and later releases.
load()fails, noLinkSetUp); idle orphan with randomized MAC + detached bridge stilladopted with both repairs;
markUsingkeeps the stored key and handed-outstring identical. Gates green.