Skip to content

Commit b2be3c8

Browse files
ipcache: add tests reproducing host-IP-as-world misclassification on restart
Add two test files that reproduce and document a bug where host IPs covered by a CiliumCIDRGroup subnet are misclassified as world identity during rolling Cilium agent restarts. Root cause (two cooperating code paths): 1. local_identity_restorer.go:128 - dumpOldIPCache() only restores identities with IdentityScopeLocal or ReservedIdentityIngress. ReservedIdentityHost (id=1, scope=global) is excluded. After ipcachemap.Recreate() wipes the BPF map, host IP entries are gone until syncHostIPs runs. 2. daemon.go startup ordering - K8sWatcher.InitK8sSubsystem() (line 202) starts CiliumCIDRGroup processing before syncHostIPs.StartAndWaitFirst() (line 249). During this window a host IP covered by a CiliumCIDRGroup receives only a cidrgroup label — no reserved:host. 3. metadata.go:798 resolveLabels() - without reserved:host, HasHostLabel()=false, isInCluster=false, AddWorldLabel() fires. The IP is assigned world identity. Impact: traffic from the misclassified host IP is denied by CNPs that use fromEntities:cluster, because world (id=2) is not in the cluster entity. pkg/ipcache/metadata_restart_test.go: - TestHostIPWorldFallbackDuringRestartWindow: inserts only a cidrgroup label for a host IP (no reserved:host), runs doInjectLabels, and asserts that world identity is assigned. Expected to fail once the root cause is fixed. - TestWorldFallbackDoesNotOccurWhenHostLabelPresentFirst: verifies correct steady-state behaviour when reserved:host arrives before cidrgroup labels. pkg/ipcache/restore/local_identity_restorer_test.go: - TestHostIdentityExcludedFromIPCacheRestoration: tests the exact filter predicate from dumpOldIPCache line 128 for all relevant identity types. - TestHostIdentityScopeIsGlobal: verifies that ReservedIdentityHost.Scope() returns IdentityScopeGlobal, the mechanical reason it is excluded. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 93ec997 commit b2be3c8

2 files changed

Lines changed: 82 additions & 89 deletions

File tree

pkg/ipcache/metadata_restart_test.go

Lines changed: 46 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,30 @@
33

44
package ipcache
55

6-
// TestHostIPWorldFallbackDuringRestartWindow and TestHostIdentityRestorationGap
7-
// reproduce a bug observed in production (us1.fed.dog, 2026-03-20) where node/host
8-
// IPs in a local-DC CIDR were misclassified as "world" identity during rolling
9-
// Cilium agent restarts, causing policy_denied drops against cluster-dns.
6+
// TestHostIPWorldFallbackDuringRestartWindow and related tests reproduce a bug
7+
// where node/host IPs that fall within a CiliumCIDRGroup CIDR are transiently
8+
// misclassified as "world" identity during rolling Cilium agent restarts.
109
//
1110
// Root cause (two code paths, both required):
1211
//
1312
// 1. pkg/ipcache/restore/local_identity_restorer.go:128
14-
// dumpOldIPCache() filters restored identities to IdentityScopeLocal and
15-
// ReservedIdentityIngress only. ReservedIdentityHost (scope=global, id=1) is
16-
// explicitly excluded. After ipcachemap.Recreate(), the new BPF map has no
17-
// entry for host IPs.
13+
// dumpOldIPCache() only restores IdentityScopeLocal and ReservedIdentityIngress
14+
// identities. ReservedIdentityHost (scope=global, id=1) is explicitly excluded.
15+
// After ipcachemap.Recreate(), the new BPF ipcache map has no entry for host IPs.
1816
//
1917
// 2. daemon/cmd/daemon.go startup ordering
2018
// K8sWatcher.InitK8sSubsystem() starts at line 202 (begins processing
21-
// CiliumCIDRGroups managed by fabric-k8s-controller). syncHostIPs.StartAndWaitFirst()
22-
// is not called until line 249. During this window, a host IP in the local-DC
23-
// CiliumCIDRGroup (e.g. 10.160.0.0/14) receives only a cidrgroup label.
19+
// CiliumCIDRGroups). syncHostIPs.StartAndWaitFirst() is not called until
20+
// line 249. During this window, a host IP covered by a CiliumCIDRGroup
21+
// receives only a cidrgroup label — no reserved:host.
2422
//
2523
// 3. pkg/ipcache/metadata.go:798 (resolveLabels)
2624
// Any IP without reserved:host, reserved:remote-node, reserved:health, or
27-
// reserved:ingress label has AddWorldLabel() called on it. A host IP with only
28-
// a cidrgroup label therefore becomes world — which is not covered by the
29-
// cluster-dns CNP's "fromEntities: cluster" ingress rule, causing drops.
25+
// reserved:ingress has AddWorldLabel() called on it. A host IP with only
26+
// a cidrgroup label is therefore assigned world identity.
27+
//
28+
// Impact: CNPs using "fromEntities: cluster" do not cover world (id=2). Traffic
29+
// from the misclassified host IP is denied with policy_denied.
3030

3131
import (
3232
"net/netip"
@@ -42,22 +42,23 @@ import (
4242
"github.com/cilium/cilium/pkg/source"
4343
)
4444

45-
// cidrGroupLabels returns a Labels set simulating what the CiliumCIDRGroup reconciler
46-
// (fabric-k8s-controller) injects via UpsertMetadata for an IP that matches a
47-
// CiliumCIDRGroup (e.g. the "local-dc" group covering 10.160.0.0/14).
45+
// cidrGroupLabels returns a Labels set simulating what a CiliumCIDRGroup
46+
// reconciler injects via UpsertMetadata for an IP that matches a
47+
// CiliumCIDRGroup (e.g. a group covering a node-local subnet).
4848
func cidrGroupLabels(groupName string) labels.Labels {
4949
return labels.Labels{
5050
groupName: labels.NewLabel(groupName, "", labels.LabelSourceCIDRGroup),
5151
}
5252
}
5353

5454
// TestHostIPWorldFallbackDuringRestartWindow reproduces the bug where a host IP
55-
// is assigned world identity because resolveLabels() runs with only cidrgroup labels
56-
// — before syncHostIPs has inserted the reserved:host label.
55+
// covered by a CiliumCIDRGroup is assigned world identity because resolveLabels()
56+
// runs with only cidrgroup labels — before syncHostIPs has inserted reserved:host.
5757
//
58-
// This test asserts the CURRENT BUGGY BEHAVIOR. It is expected to fail once the
59-
// bug is fixed (e.g. by ensuring host IPs are seeded into ipcache metadata before
60-
// CiliumCIDRGroup processing can trigger resolveLabels for those prefixes).
58+
// This test asserts the CURRENT BUGGY BEHAVIOUR. It is expected to fail once
59+
// the root cause is fixed (e.g. by ensuring host IPs are seeded into ipcache
60+
// metadata before CiliumCIDRGroup processing can trigger resolveLabels for
61+
// those prefixes, or by restoring host identity entries in dumpOldIPCache).
6162
func TestHostIPWorldFallbackDuringRestartWindow(t *testing.T) {
6263
s := setupIPCacheTestSuite(t)
6364
ctx := t.Context()
@@ -67,54 +68,53 @@ func TestHostIPWorldFallbackDuringRestartWindow(t *testing.T) {
6768
t.Cleanup(func() { option.Config.PolicyCIDRMatchMode = oldVal })
6869
option.Config.PolicyCIDRMatchMode = []string{}
6970

70-
// The host IP observed in production: 10.161.39.126 (in 10.160.0.0/14, localDc CIDR).
71-
// 8,258 drops were recorded against cluster-dns over 48h.
71+
// A host IP that falls within a CiliumCIDRGroup subnet.
7272
hostIPPrefix := cmtypes.NewLocalPrefixCluster(netip.MustParsePrefix("10.161.39.126/32"))
7373

7474
// ── Stage 1: Restart window ──────────────────────────────────────────────
75-
// K8sWatcher has processed the "local-dc" CiliumCIDRGroup. The ipcache BPF
76-
// map has been recreated empty (RestoreLocalIdentities skipped this IP since
77-
// ReservedIdentityHost is not locally-scoped). syncHostIPs has NOT run yet.
78-
//
79-
// Only the cidrgroup label is present — no reserved:host.
75+
// The K8s watcher has processed a CiliumCIDRGroup covering this IP's subnet.
76+
// The ipcache BPF map has been recreated empty (dumpOldIPCache skipped this
77+
// IP since ReservedIdentityHost is not locally-scoped). syncHostIPs has NOT
78+
// run yet — only the cidrgroup label is present.
8079
s.IPIdentityCache.metadata.upsertLocked(
8180
hostIPPrefix,
8281
source.Generated,
8382
"cidrgroup-resource-uid",
84-
cidrGroupLabels("local-dc"),
83+
cidrGroupLabels("example-local-subnet"),
8584
)
8685

8786
_, err := s.IPIdentityCache.doInjectLabels(ctx, []cmtypes.PrefixCluster{hostIPPrefix})
8887
require.NoError(t, err)
8988

9089
entry, ok := s.IPIdentityCache.ipToIdentityCache["10.161.39.126/32"]
91-
require.True(t, ok, "expected an identity entry for 10.161.39.126/32")
90+
require.True(t, ok, "expected an identity entry for the host IP")
9291

9392
assignedID := entry.ID
9493

9594
// Verify the assigned identity is NOT reserved:host (id=1).
9695
// This demonstrates the bug: the IP should be host but is not.
9796
assert.NotEqual(t, identity.ReservedIdentityHost, assignedID,
98-
"BUG REPRODUCED: host IP 10.161.39.126 was not assigned ReservedIdentityHost (id=1). "+
99-
"Got id=%d. This occurs because resolveLabels() ran with only cidrgroup labels "+
100-
"(no reserved:host) during the restart window before syncHostIPs executed.",
97+
"BUG REPRODUCED: host IP was not assigned ReservedIdentityHost (id=1). "+
98+
"Got id=%d. This occurs because resolveLabels() ran with only cidrgroup "+
99+
"labels (no reserved:host) during the restart window before syncHostIPs "+
100+
"executed.",
101101
assignedID)
102102

103-
// Verify the assigned identity has a world label — the world fallback fired.
103+
// Verify the assigned identity carries a world label — the world fallback fired.
104104
resolvedIdentity := s.Allocator.LookupIdentityByID(ctx, assignedID)
105105
require.NotNil(t, resolvedIdentity, "identity %d should be resolvable", assignedID)
106106
assert.True(t,
107107
resolvedIdentity.Labels.HasWorldLabel() || resolvedIdentity.Labels.HasWorldIPv4Label(),
108-
"BUG: host IP 10.161.39.126/32 was assigned world identity (id=%d, labels=%v). "+
108+
"BUG: host IP was assigned world identity (id=%d, labels=%v). "+
109109
"resolveLabels() called AddWorldLabel() because HasHostLabel()=false. "+
110-
"This causes policy_denied drops: the cluster-dns CNP allows 'fromEntities: cluster' "+
111-
"but world (id=2) is not in the cluster entity.",
110+
"Traffic from this IP will be denied by CNPs that use 'fromEntities: cluster' "+
111+
"because world (id=2) is not in the cluster entity.",
112112
assignedID, resolvedIdentity.Labels)
113113

114114
// ── Stage 2: syncHostIPs runs ────────────────────────────────────────────
115-
// After daemon initialization completes (daemon.go:249), syncHostIPs inserts
116-
// the reserved:host label for this IP. resolveLabels() now sees HasHostLabel()=true,
117-
// sets isInCluster=true, removes the cidrgroup label, and does NOT add world.
115+
// After daemon initialisation completes (daemon.go:249), syncHostIPs inserts
116+
// the reserved:host label. resolveLabels() now sees HasHostLabel()=true,
117+
// sets isInCluster=true, removes cidrgroup labels, and does NOT add world.
118118
s.IPIdentityCache.metadata.upsertLocked(
119119
hostIPPrefix,
120120
source.Local,
@@ -135,7 +135,7 @@ func TestHostIPWorldFallbackDuringRestartWindow(t *testing.T) {
135135
}
136136

137137
// TestWorldFallbackDoesNotOccurWhenHostLabelPresentFirst verifies the CORRECT
138-
// behaviour: when reserved:host is present before CIDRGroup labels are processed,
138+
// behaviour: when reserved:host is already present before CIDRGroup labels arrive,
139139
// resolveLabels() correctly identifies the IP as in-cluster and does not add
140140
// the world label.
141141
//
@@ -164,7 +164,7 @@ func TestWorldFallbackDoesNotOccurWhenHostLabelPresentFirst(t *testing.T) {
164164
hostIPPrefix,
165165
source.Generated,
166166
"cidrgroup-resource-uid",
167-
cidrGroupLabels("local-dc"),
167+
cidrGroupLabels("example-local-subnet"),
168168
)
169169

170170
_, err := s.IPIdentityCache.doInjectLabels(ctx, []cmtypes.PrefixCluster{hostIPPrefix})
@@ -173,10 +173,10 @@ func TestWorldFallbackDoesNotOccurWhenHostLabelPresentFirst(t *testing.T) {
173173
entry, ok := s.IPIdentityCache.ipToIdentityCache["10.161.39.126/32"]
174174
require.True(t, ok)
175175

176-
// When reserved:host is present, the identity must be ReservedIdentityHost.
176+
// When reserved:host is present first, identity must be ReservedIdentityHost.
177177
assert.Equal(t, identity.ReservedIdentityHost, entry.ID,
178-
"When reserved:host is already in ipcache metadata before CIDRGroup labels "+
179-
"arrive, the identity must be ReservedIdentityHost (id=1). Got id=%d.", entry.ID)
178+
"When reserved:host is in ipcache metadata before CIDRGroup labels arrive, "+
179+
"the identity must be ReservedIdentityHost (id=1). Got id=%d.", entry.ID)
180180

181181
resolvedIdentity := s.Allocator.LookupIdentityByID(ctx, entry.ID)
182182
require.NotNil(t, resolvedIdentity)

pkg/ipcache/restore/local_identity_restorer_test.go

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,28 @@
33

44
package restoration
55

6-
// TestHostIdentityExcludedFromIPCacheRestoration tests that the ipcache
7-
// restoration logic (dumpOldIPCache) explicitly excludes ReservedIdentityHost
8-
// entries from the set of identities that survive an agent restart.
6+
// TestHostIdentityExcludedFromIPCacheRestoration and related tests document the
7+
// restoration filter in dumpOldIPCache that contributes to the host-IP-as-world
8+
// misclassification bug (see pkg/ipcache/metadata_restart_test.go for the full
9+
// reproduction).
910
//
10-
// This is one of two root causes for the host-IP-as-world misclassification bug:
11+
// The filter at local_identity_restorer.go:128:
1112
//
12-
// dumpOldIPCache() at local_identity_restorer.go:128:
13+
// if nid.Scope() == identity.IdentityScopeLocal ||
14+
// nid == identity.ReservedIdentityIngress {
15+
// localPrefixes[k.Prefix()] = nid
16+
// }
1317
//
14-
// if nid.Scope() == identity.IdentityScopeLocal ||
15-
// nid == identity.ReservedIdentityIngress {
16-
// localPrefixes[k.Prefix()] = nid // host identity NEVER matches
17-
// }
18+
// ReservedIdentityHost (id=1) has IdentityScopeGlobal (scope bits = 0).
19+
// It does not satisfy either condition and is therefore never included in the
20+
// restored set. After ipcachemap.Recreate() (cell.go:118) wipes the ipcache BPF
21+
// map, host IP entries are absent until syncHostIPs.StartAndWaitFirst() runs
22+
// (daemon.go:249).
1823
//
19-
// Because ReservedIdentityHost (id=1) has IdentityScopeGlobal (scope bits = 0),
20-
// it does not pass the IdentityScopeLocal check. It is also not
21-
// ReservedIdentityIngress. The host IP entry from the OLD ipcache BPF map is
22-
// therefore NEVER written into localPrefixes and is NEVER restored into the new
23-
// ipcache metadata.
24-
//
25-
// Consequence: after ipcachemap.Recreate() wipes the BPF map (cell.go:118),
26-
// there is a window before syncHostIPs runs (daemon.go:249) during which host
27-
// IPs have no ipcache metadata entry. If a CiliumCIDRGroup covering the host IP
28-
// is processed during this window, resolveLabels() sees only the cidrgroup label,
29-
// calls AddWorldLabel(), and assigns world identity — causing policy_denied drops.
24+
// If a CiliumCIDRGroup covering the host IP is processed between those two
25+
// points, resolveLabels() in metadata.go receives only the cidrgroup label,
26+
// finds isInCluster=false, and calls AddWorldLabel() — assigning world identity
27+
// to what should be a host IP.
3028

3129
import (
3230
"testing"
@@ -38,32 +36,27 @@ import (
3836

3937
// TestHostIdentityExcludedFromIPCacheRestoration documents the restoration
4038
// filter condition that causes host IPs to lose their identity on restart.
41-
//
42-
// The filter in dumpOldIPCache (local_identity_restorer.go:128) is:
43-
//
44-
// nid.Scope() == identity.IdentityScopeLocal || nid == identity.ReservedIdentityIngress
45-
//
46-
// This test verifies the scope/identity values used by the filter and shows
47-
// which identities are retained vs dropped during restoration.
39+
// It tests the exact predicate used by dumpOldIPCache at
40+
// local_identity_restorer.go:128.
4841
func TestHostIdentityExcludedFromIPCacheRestoration(t *testing.T) {
4942
type testCase struct {
50-
name string
51-
id identity.NumericIdentity
52-
wantRestored bool
53-
explanation string
43+
name string
44+
id identity.NumericIdentity
45+
wantRestored bool
46+
explanation string
5447
}
5548

56-
// These cases mirror the exact filter condition at local_identity_restorer.go:128.
5749
cases := []testCase{
5850
{
5951
name: "ReservedIdentityHost is NOT restored",
6052
id: identity.ReservedIdentityHost, // id=1, scope=global
6153
wantRestored: false,
62-
explanation: "ReservedIdentityHost (id=1) has IdentityScopeGlobal (scope bits = 0). " +
63-
"It does not satisfy Scope()==IdentityScopeLocal and is not ReservedIdentityIngress. " +
64-
"BUG: after ipcachemap.Recreate(), host IP entries are missing from the new ipcache " +
65-
"until syncHostIPs runs. If CiliumCIDRGroup processing happens first, " +
66-
"resolveLabels() assigns world identity to the host IP.",
54+
explanation: "ReservedIdentityHost (id=1) has IdentityScopeGlobal (scope=0). " +
55+
"It does not satisfy Scope()==IdentityScopeLocal and is not " +
56+
"ReservedIdentityIngress. Host IP entries are therefore absent from " +
57+
"the new ipcache BPF map until syncHostIPs runs. If a CiliumCIDRGroup " +
58+
"covering the host IP is processed during this window, resolveLabels() " +
59+
"assigns world identity instead of host.",
6760
},
6861
{
6962
name: "ReservedIdentityWorld is NOT restored",
@@ -116,20 +109,20 @@ func TestHostIdentityExcludedFromIPCacheRestoration(t *testing.T) {
116109
}
117110

118111
// TestHostIdentityScopeIsGlobal explicitly verifies that ReservedIdentityHost
119-
// has global scope, which is why it is excluded from the restoration filter.
120-
// This is the direct mechanical reason for the misclassification bug.
112+
// has global scope — the direct mechanical reason it is excluded from the
113+
// dumpOldIPCache restoration filter and why the world fallback can fire.
121114
func TestHostIdentityScopeIsGlobal(t *testing.T) {
122115
hostScope := identity.ReservedIdentityHost.Scope()
123116

124117
assert.Equal(t, identity.IdentityScopeGlobal, hostScope,
125118
"ReservedIdentityHost must have IdentityScopeGlobal (scope=0). "+
126119
"This means it is excluded by the dumpOldIPCache filter "+
127120
"(local_identity_restorer.go:128) which only retains IdentityScopeLocal "+
128-
"and ReservedIdentityIngress. As a result, host IP entries are lost from "+
129-
"the ipcache BPF map after ipcachemap.Recreate() and are not re-inserted "+
130-
"until syncHostIPs.StartAndWaitFirst() completes (daemon.go:249).")
121+
"and ReservedIdentityIngress. Host IP entries are therefore absent from "+
122+
"the new ipcache BPF map after Recreate() until syncHostIPs completes.")
131123

132124
assert.NotEqual(t, identity.IdentityScopeLocal, hostScope,
133125
"If this assertion fails, the bug would be self-healing: "+
134-
"host identity would be restored and the world fallback would not occur.")
126+
"host identity would be restored from the old BPF map and the world fallback "+
127+
"would not occur during the CiliumCIDRGroup processing window.")
135128
}

0 commit comments

Comments
 (0)