From b69d2d43184dabfa94aac101fa2cd70228e37da7 Mon Sep 17 00:00:00 2001 From: Casey Davenport Date: Fri, 18 Sep 2026 12:05:47 -0400 Subject: [PATCH] Derive CNI address families from workload-capable IP pools An IPv6 pool restricted to other uses was enough to turn on assign_ipv6 for every pod, which fails on a single-stack IPv4 cluster. --- pkg/render/node.go | 32 +++++++++++-- pkg/render/node_test.go | 104 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/pkg/render/node.go b/pkg/render/node.go index 59decc6a1a..e08e65a295 100644 --- a/pkg/render/node.go +++ b/pkg/render/node.go @@ -659,7 +659,7 @@ func (c *nodeComponent) createCalicoPluginConfig() map[string]any { ipam := c.getCalicoIPAM() if c.cfg.Installation.CNI.IPAM.Type == operatorv1.IPAMPluginHostLocal { - ipam = buildHostLocalIPAM(c.cfg.IPPools) + ipam = buildHostLocalIPAM(WorkloadIPPools(c.cfg.IPPools)) } apiRoot := c.cfg.K8sServiceEp.CNIAPIRoot() @@ -825,14 +825,15 @@ func (c *nodeComponent) nodeCNIConfigMap() *corev1.ConfigMap { func (c *nodeComponent) getCalicoIPAM() map[string]any { // Determine what address families to enable. + pools := WorkloadIPPools(c.cfg.IPPools) var assign_ipv4 string var assign_ipv6 string - if HasIPv4Pool(c.cfg.IPPools) { + if HasIPv4Pool(pools) { assign_ipv4 = "true" } else { assign_ipv4 = "false" } - if HasIPv6Pool(c.cfg.IPPools) { + if HasIPv6Pool(pools) { assign_ipv6 = "true" } else { assign_ipv6 = "false" @@ -1818,6 +1819,31 @@ func HasIPv6Pool(pools []operatorv1.IPPool) bool { return false } +// WorkloadIPPools returns the pools that can hand out addresses to ordinary workloads. +func WorkloadIPPools(pools []operatorv1.IPPool) []operatorv1.IPPool { + filtered := []operatorv1.IPPool{} + for _, pool := range pools { + if allowsWorkloads(pool) { + filtered = append(filtered, pool) + } + } + return filtered +} + +func allowsWorkloads(pool operatorv1.IPPool) bool { + if len(pool.AllowedUses) == 0 { + // An unset allowedUses means Workload and Tunnel, for back-compatibility. + return true + } + + for _, use := range pool.AllowedUses { + if use == operatorv1.IPPoolAllowedUseWorkload { + return true + } + } + return false +} + // bgpEnabled returns true if the given Installation enables BGP, false otherwise. func bgpEnabled(instance *operatorv1.InstallationSpec) bool { return instance.CalicoNetwork != nil && diff --git a/pkg/render/node_test.go b/pkg/render/node_test.go index 73290edbc4..d7a3ff2d5e 100644 --- a/pkg/render/node_test.go +++ b/pkg/render/node_test.go @@ -15,6 +15,7 @@ package render_test import ( + "encoding/json" "fmt" "strings" @@ -3334,3 +3335,106 @@ func verifyInitContainers(ds *appsv1.DaemonSet, instance *operatorv1.Installatio Expect(flexvolContainer).To(BeNil()) } } + +var _ = Describe("Node CNI address family selection", func() { + const l2Workload = operatorv1.IPPoolAllowedUse("L2Workload") + + var cfg render.NodeConfiguration + var instance *operatorv1.InstallationSpec + + // ipamConfig renders the CNI config map and returns the ipam block of the calico plugin. + ipamConfig := func() map[string]any { + component := render.Node(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + cm, ok := rtest.GetResource(resources, "cni-config", "calico-system", "", "v1", "ConfigMap").(*corev1.ConfigMap) + Expect(ok).To(BeTrue()) + + var parsed cniNetworkConfig + Expect(json.Unmarshal([]byte(cm.Data["config"]), &parsed)).NotTo(HaveOccurred()) + Expect(parsed.Plugins).NotTo(BeEmpty()) + return parsed.Plugins[0].IPAM + } + + BeforeEach(func() { + ff := true + instance = &operatorv1.InstallationSpec{ + CNI: &operatorv1.CNISpec{ + Type: operatorv1.PluginCalico, + IPAM: &operatorv1.IPAMSpec{Type: operatorv1.IPAMPluginCalico}, + }, + CalicoNetwork: &operatorv1.CalicoNetworkSpec{ + BGP: &bgpEnabled, + IPPools: []operatorv1.IPPool{{CIDR: "192.168.0.0/16"}}, + NodeAddressAutodetectionV4: &operatorv1.NodeAddressAutodetection{FirstFound: &ff}, + }, + } + defaultCNIConfDir, defaultCNIBinDir := render.DefaultCNIDirectories(instance.KubernetesProvider) + instance.CNI.ConfDir, instance.CNI.BinDir = &defaultCNIConfDir, &defaultCNIBinDir + + scheme := runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).NotTo(HaveOccurred()) + cli := ctrlrfake.DefaultFakeClientBuilder(scheme).Build() + certificateManager, err := certificatemanager.Create(cli, nil, clusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + + cfg = render.NodeConfiguration{ + K8sServiceEp: k8sapi.ServiceEndpoint{}, + Installation: instance, + TLS: getTyphaNodeTLS(cli, certificateManager), + ClusterDomain: clusterDomain, + FelixConfiguration: &v3.FelixConfiguration{Spec: v3.FelixConfigurationSpec{HealthPort: ptr.To(9099)}}, + IPPools: instance.CalicoNetwork.IPPools, + } + }) + + It("should not enable IPv6 assignment for a pool that doesn't allow workloads", func() { + cfg.IPPools = append(cfg.IPPools, operatorv1.IPPool{ + CIDR: "2001:db8:1::/122", + AllowedUses: []operatorv1.IPPoolAllowedUse{l2Workload}, + }) + ipam := ipamConfig() + Expect(ipam).To(HaveKeyWithValue("assign_ipv4", "true")) + Expect(ipam).To(HaveKeyWithValue("assign_ipv6", "false")) + }) + + It("should enable IPv6 assignment for a pool that allows workloads", func() { + cfg.IPPools = append(cfg.IPPools, operatorv1.IPPool{ + CIDR: "2001:db8:1::/122", + AllowedUses: []operatorv1.IPPoolAllowedUse{l2Workload, operatorv1.IPPoolAllowedUseWorkload}, + }) + Expect(ipamConfig()).To(HaveKeyWithValue("assign_ipv6", "true")) + }) + + It("should enable IPv6 assignment for a pool with no allowed uses set", func() { + cfg.IPPools = append(cfg.IPPools, operatorv1.IPPool{CIDR: "2001:db8:1::/122"}) + Expect(ipamConfig()).To(HaveKeyWithValue("assign_ipv6", "true")) + }) + + It("should disable IPv4 assignment when the only IPv4 pool is tunnel-only", func() { + cfg.IPPools = []operatorv1.IPPool{{ + CIDR: "192.168.0.0/16", + AllowedUses: []operatorv1.IPPoolAllowedUse{operatorv1.IPPoolAllowedUseTunnel}, + }} + Expect(ipamConfig()).To(HaveKeyWithValue("assign_ipv4", "false")) + }) + + It("should render host-local as single-stack when the IPv6 pool doesn't allow workloads", func() { + instance.CNI.IPAM.Type = operatorv1.IPAMPluginHostLocal + cfg.IPPools = append(cfg.IPPools, operatorv1.IPPool{ + CIDR: "2001:db8:1::/122", + AllowedUses: []operatorv1.IPPoolAllowedUse{l2Workload}, + }) + Expect(ipamConfig()).To(Equal(map[string]any{"type": "host-local", "subnet": "usePodCidr"})) + }) +}) + +// cniNetworkConfig mirrors the parts of the rendered CNI network config the tests assert on. +type cniNetworkConfig struct { + Plugins []cniPluginConfig `json:"plugins"` +} + +type cniPluginConfig struct { + IPAM map[string]any `json:"ipam"` +}