Skip to content

Commit a2898ad

Browse files
committed
[ipam/eni] wait for ENI interface before CNI ADD route setup
Signed-off-by: Jared Ledvina <jared.ledvina@datadoghq.com>
1 parent 67dfc5a commit a2898ad

6 files changed

Lines changed: 259 additions & 32 deletions

File tree

daemon/infraendpoints/infra_ip_allocation.go

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import (
2121
"github.com/cilium/statedb"
2222
"github.com/vishvananda/netlink"
2323
"go4.org/netipx"
24-
"golang.org/x/sys/unix"
25-
"k8s.io/apimachinery/pkg/util/wait"
2624

2725
"github.com/cilium/cilium/pkg/common"
2826
linuxrouting "github.com/cilium/cilium/pkg/datapath/linux/routing"
@@ -33,6 +31,7 @@ import (
3331
"github.com/cilium/cilium/pkg/ipam"
3432
ipamOption "github.com/cilium/cilium/pkg/ipam/option"
3533
"github.com/cilium/cilium/pkg/logging/logfields"
34+
"github.com/cilium/cilium/pkg/mac"
3635
"github.com/cilium/cilium/pkg/mtu"
3736
"github.com/cilium/cilium/pkg/node"
3837
"github.com/cilium/cilium/pkg/option"
@@ -221,32 +220,11 @@ func (r *infraIPAllocator) reallocateOldRouterIPs(fromK8s, fromFS net.IP) (resul
221220
}
222221

223222
func (r *infraIPAllocator) waitForENI(ctx context.Context, macAddr string) error {
224-
bo := wait.Backoff{
225-
Duration: 250 * time.Millisecond,
226-
Factor: 2,
227-
Jitter: 0.2,
228-
Steps: 5,
229-
}
230-
231-
findENIByMAC := func(ctx context.Context) (bool, error) {
232-
links, err := safenetlink.LinkList()
233-
if err != nil {
234-
return false, fmt.Errorf("unable to list interfaces: %w", err)
235-
}
236-
237-
for _, l := range links {
238-
// filter out slave devices
239-
if l.Attrs().RawFlags&unix.IFF_SLAVE != 0 {
240-
continue
241-
}
242-
if l.Attrs().HardwareAddr.String() == macAddr {
243-
return true, nil
244-
}
245-
}
246-
return false, nil
223+
parsedMAC, err := net.ParseMAC(macAddr)
224+
if err != nil {
225+
return fmt.Errorf("invalid MAC address %q: %w", macAddr, err)
247226
}
248-
249-
return wait.ExponentialBackoffWithContext(ctx, bo, findENIByMAC)
227+
return linuxrouting.WaitForENIInterface(ctx, mac.MAC(parsedMAC))
250228
}
251229

252230
func (r *infraIPAllocator) reallocateRouterIPs(ctx context.Context, family node.AddressingFamily, fromK8s, fromFS net.IP) (routerIP net.IP, err error) {
@@ -299,6 +277,7 @@ func (r *infraIPAllocator) reallocateRouterIPs(ctx context.Context, family node.
299277
if err := r.waitForENI(ctx, result.PrimaryMAC); err != nil {
300278
r.logger.Warn("unable to find ENI netlink interface, this will likely lead to an error in configuring the router routes and rules",
301279
logfields.MACAddr, result.PrimaryMAC,
280+
logfields.Error, err,
302281
)
303282
}
304283
}
@@ -425,7 +404,7 @@ func (r *infraIPAllocator) allocateHealthIPs(oldV4HealthIP netip.Addr, oldV6Heal
425404
return nil
426405
}
427406

428-
func (r *infraIPAllocator) allocateIngressIPs(oldV4IngressIP net.IP, oldV6IngressIP net.IP) error {
407+
func (r *infraIPAllocator) allocateIngressIPs(ctx context.Context, oldV4IngressIP net.IP, oldV6IngressIP net.IP) error {
429408
if !r.daemonConfig.EnableEnvoyConfig {
430409
return nil
431410
}
@@ -475,6 +454,17 @@ func (r *infraIPAllocator) allocateIngressIPs(oldV4IngressIP net.IP, oldV6Ingres
475454
if ingressRouting, err := r.parseRoutingInfo(result); err != nil {
476455
r.logger.Warn("Unable to allocate ingress information for ENI", logfields.Error, err)
477456
} else {
457+
// Mirror the router-interface guard above: wait for the ENI
458+
// before configuring its routes and rules.
459+
if r.daemonConfig.IPAM == ipamOption.IPAMENI {
460+
if err := r.waitForENI(ctx, result.PrimaryMAC); err != nil {
461+
r.logger.Warn("unable to find ENI netlink interface, this will likely lead to an error in configuring the ingress routes and rules",
462+
logfields.MACAddr, result.PrimaryMAC,
463+
logfields.Error, err,
464+
)
465+
}
466+
}
467+
478468
if err := ingressRouting.Configure(
479469
result.IP,
480470
r.mtuManager.GetDeviceMTU(),
@@ -553,7 +543,7 @@ func (r *infraIPAllocator) AllocateIPs(ctx context.Context) error {
553543
return fmt.Errorf("failed to allocate service loopback IPs: %w", err)
554544
}
555545

556-
if err := r.allocateIngressIPs(localNode.IPv4IngressIP, localNode.IPv6IngressIP); err != nil {
546+
if err := r.allocateIngressIPs(ctx, localNode.IPv4IngressIP, localNode.IPv6IngressIP); err != nil {
557547
return fmt.Errorf("failed to allocate ingress IPs: %w", err)
558548
}
559549

daemon/infraendpoints/infra_ip_allocation_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package infraendpoints
55

66
import (
7+
"context"
78
"fmt"
89
"net"
910
"net/netip"
@@ -323,3 +324,15 @@ func Test_getCiliumHostIPsFromFile(t *testing.T) {
323324
})
324325
}
325326
}
327+
328+
// TestWaitForENIInvalidMAC verifies that a malformed MAC fails fast instead of
329+
// delegating to the netlink poll and exhausting the backoff.
330+
func TestWaitForENIInvalidMAC(t *testing.T) {
331+
r := &infraIPAllocator{
332+
logger: hivetest.Logger(t),
333+
}
334+
335+
err := r.waitForENI(context.Background(), "not-a-valid-mac")
336+
require.Error(t, err)
337+
require.ErrorContains(t, err, "invalid MAC address")
338+
}

pkg/datapath/linux/routing/routing.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package linuxrouting
55

66
import (
7+
"context"
78
"errors"
89
"fmt"
910
"log/slog"
@@ -13,6 +14,7 @@ import (
1314
"github.com/vishvananda/netlink"
1415
"go4.org/netipx"
1516
"golang.org/x/sys/unix"
17+
"k8s.io/apimachinery/pkg/util/wait"
1618

1719
"github.com/cilium/statedb"
1820

@@ -24,8 +26,49 @@ import (
2426
"github.com/cilium/cilium/pkg/logging/logfields"
2527
"github.com/cilium/cilium/pkg/mac"
2628
"github.com/cilium/cilium/pkg/option"
29+
"github.com/cilium/cilium/pkg/time"
2730
)
2831

32+
// WaitForENIInterfaceBackoff bounds how long WaitForENIInterface polls before
33+
// giving up. ENI attachment is asynchronous: the operator can report an ENI as
34+
// attached (and cilium-agent can hand out an IP from it) before its netlink
35+
// device is visible, so resolving the ifindex by MAC can race. The original
36+
// router-only guard used 5 steps (~4.5s), which proved too short in practice;
37+
// this shared helper widens it to 7 (~19s worst case with jitter).
38+
var WaitForENIInterfaceBackoff = wait.Backoff{
39+
Duration: 250 * time.Millisecond,
40+
Factor: 2,
41+
Jitter: 0.2,
42+
Steps: 7,
43+
}
44+
45+
// WaitForENIInterface blocks until a non-slave netlink interface with the
46+
// given MAC is observed, or ctx is cancelled or WaitForENIInterfaceBackoff is
47+
// exhausted. Shared by the router, ingress, and CNI ADD paths, which all
48+
// resolve an ENI's ifindex by MAC shortly after it is reported as attached.
49+
func WaitForENIInterface(ctx context.Context, macAddr mac.MAC) error {
50+
findENIByMAC := func(ctx context.Context) (bool, error) {
51+
links, err := safenetlink.LinkList()
52+
if err != nil {
53+
return false, fmt.Errorf("unable to list interfaces: %w", err)
54+
}
55+
56+
for _, l := range links {
57+
// Linux slave devices have the same MAC address as their master
58+
// device, but we want the master device.
59+
if l.Attrs().RawFlags&unix.IFF_SLAVE != 0 {
60+
continue
61+
}
62+
if l.Attrs().HardwareAddr.String() == macAddr.String() {
63+
return true, nil
64+
}
65+
}
66+
return false, nil
67+
}
68+
69+
return wait.ExponentialBackoffWithContext(ctx, WaitForENIInterfaceBackoff, findENIByMAC)
70+
}
71+
2972
// useCompatEgressPriority determines whether to use the new or old style egress rule.
3073
// Old style rules are only used in Azure IPAM mode.
3174
func (info *RoutingInfo) useCompatEgressPriority() bool {

pkg/datapath/linux/routing/routing_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
package linuxrouting
55

66
import (
7+
"context"
78
"net"
89
"net/netip"
910
"testing"
11+
"time"
1012

1113
"github.com/cilium/hive/hivetest"
1214
"github.com/stretchr/testify/require"
1315
"github.com/vishvananda/netlink"
16+
"k8s.io/apimachinery/pkg/util/wait"
1417

1518
"github.com/cilium/cilium/pkg/datapath/linux/linux_defaults"
1619
"github.com/cilium/cilium/pkg/datapath/linux/route"
@@ -331,6 +334,145 @@ func getFakes(t *testing.T, ipamMode string, masquerade bool, withZeroCIDR bool)
331334
return netip.MustParseAddr("192.168.2.123"), *fakeRoutingInfo
332335
}
333336

337+
// withTestBackoff swaps WaitForENIInterfaceBackoff for a faster one so tests
338+
// need not wait out the production-sized backoff. It mutates a package-level
339+
// global, so callers must not use t.Parallel().
340+
func withTestBackoff(t *testing.T, bo wait.Backoff) {
341+
t.Helper()
342+
orig := WaitForENIInterfaceBackoff
343+
WaitForENIInterfaceBackoff = bo
344+
t.Cleanup(func() { WaitForENIInterfaceBackoff = orig })
345+
}
346+
347+
// TestPrivilegedWaitForENIInterfaceAlreadyPresent: returns immediately when
348+
// the interface already exists.
349+
func TestPrivilegedWaitForENIInterfaceAlreadyPresent(t *testing.T) {
350+
setupLinuxRoutingSuite(t)
351+
withTestBackoff(t, wait.Backoff{Duration: 10 * time.Millisecond, Factor: 2, Steps: 3})
352+
353+
ns := netns.NewNetNS(t)
354+
require.NoError(t, ns.Do(func() error {
355+
macAddr, err := mac.ParseMAC("00:11:22:33:44:66")
356+
require.NoError(t, err)
357+
358+
cleanup := createDummyDevice(t, macAddr)
359+
defer cleanup()
360+
361+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
362+
defer cancel()
363+
364+
require.NoError(t, WaitForENIInterface(ctx, macAddr))
365+
return nil
366+
}))
367+
}
368+
369+
// TestPrivilegedWaitForENIInterfaceAppearsLate: the interface is absent when
370+
// WaitForENIInterface is first called but shows up shortly after, so the
371+
// helper must poll until it appears rather than fail immediately.
372+
func TestPrivilegedWaitForENIInterfaceAppearsLate(t *testing.T) {
373+
setupLinuxRoutingSuite(t)
374+
withTestBackoff(t, wait.Backoff{Duration: 20 * time.Millisecond, Factor: 1.5, Jitter: 0.1, Steps: 10})
375+
376+
// Each goroutine enters the namespace via its own ns.Do call: ns.Do pins
377+
// the netns switch to the OS thread backing the goroutine it creates, so a
378+
// bare `go func(){...}()` inside an ns.Do closure would run in the host
379+
// netns, not ns.
380+
ns := netns.NewNetNS(t)
381+
382+
macAddr, err := mac.ParseMAC("00:11:22:33:44:77")
383+
require.NoError(t, err)
384+
385+
require.NoError(t, ns.Do(func() error {
386+
require.False(t, linkExistsWithMAC(t, macAddr), "interface must not exist yet")
387+
return nil
388+
}))
389+
390+
// Propagate the goroutine's failure back over a channel rather than
391+
// calling require/t.FailNow from it: those must run on the test's own
392+
// goroutine, so the assertions happen on the main goroutine below.
393+
type deviceResult struct {
394+
cleanup func()
395+
err error
396+
}
397+
resultCh := make(chan deviceResult, 1)
398+
go func() {
399+
// Simulate the ENI's netlink interface showing up asynchronously,
400+
// after WaitForENIInterface has already started polling.
401+
time.Sleep(60 * time.Millisecond)
402+
var res deviceResult
403+
res.err = ns.Do(func() error {
404+
dummy := &netlink.Dummy{
405+
LinkAttrs: netlink.LinkAttrs{
406+
Name: "linuxrout-test",
407+
HardwareAddr: net.HardwareAddr(macAddr),
408+
},
409+
}
410+
if err := netlink.LinkAdd(dummy); err != nil {
411+
return err
412+
}
413+
// Delete from inside the namespace: res.cleanup runs on the main
414+
// goroutine after the host netns has been restored, so a direct
415+
// LinkDel would target the host netns instead.
416+
res.cleanup = func() { _ = ns.Do(func() error { return netlink.LinkDel(dummy) }) }
417+
return nil
418+
})
419+
resultCh <- res
420+
}()
421+
422+
var waitErr error
423+
require.NoError(t, ns.Do(func() error {
424+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
425+
defer cancel()
426+
waitErr = WaitForENIInterface(ctx, macAddr)
427+
return nil
428+
}))
429+
430+
res := <-resultCh
431+
require.NoError(t, res.err, "failed to create dummy device asynchronously")
432+
require.NotNil(t, res.cleanup)
433+
defer res.cleanup()
434+
435+
require.NoError(t, waitErr, "WaitForENIInterface should succeed once the interface appears")
436+
}
437+
438+
// TestPrivilegedWaitForENIInterfaceTimeout verifies that WaitForENIInterface
439+
// gives up and returns an error if the interface never appears within the
440+
// configured backoff.
441+
func TestPrivilegedWaitForENIInterfaceTimeout(t *testing.T) {
442+
setupLinuxRoutingSuite(t)
443+
withTestBackoff(t, wait.Backoff{Duration: 10 * time.Millisecond, Factor: 1.5, Steps: 3})
444+
445+
ns := netns.NewNetNS(t)
446+
require.NoError(t, ns.Do(func() error {
447+
macAddr, err := mac.ParseMAC("00:11:22:33:44:88")
448+
require.NoError(t, err)
449+
450+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
451+
defer cancel()
452+
453+
err = WaitForENIInterface(ctx, macAddr)
454+
require.Error(t, err, "interface never appears, so WaitForENIInterface should give up and return an error")
455+
return nil
456+
}))
457+
}
458+
459+
// TestWaitForENIInterfaceContextCancelled: a cancelled context returns
460+
// promptly instead of exhausting the backoff. The backoff is deliberately
461+
// enormous, so the test hangs rather than passing spuriously if cancellation
462+
// is not honoured.
463+
func TestWaitForENIInterfaceContextCancelled(t *testing.T) {
464+
withTestBackoff(t, wait.Backoff{Duration: time.Hour, Factor: 1, Steps: 100})
465+
466+
macAddr, err := mac.ParseMAC("00:11:22:33:44:99")
467+
require.NoError(t, err)
468+
469+
ctx, cancel := context.WithCancel(context.Background())
470+
cancel()
471+
472+
err = WaitForENIInterface(ctx, macAddr)
473+
require.Error(t, err, "an already-cancelled context should cause WaitForENIInterface to return without exhausting the backoff")
474+
}
475+
334476
func linkExistsWithMAC(t *testing.T, macAddr mac.MAC) bool {
335477
links, err := safenetlink.LinkList()
336478
require.NoError(t, err)

0 commit comments

Comments
 (0)