Skip to content

Commit 0acdba8

Browse files
authored
Add IPv6 public backends for NodeBalancers (#545)
What: - add the `service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends` annotation and the `--enable-ipv6-for-nodebalancer-backends` controller flag, including Helm values and docs - resolve the backend address family per service so the service annotation overrides the global default when needed - program NodeBalancer backends from `node.k8s.linode.com/public-ipv6` when IPv6 backends are enabled, format IPv6 backend addresses correctly, and omit IPv4 subnet IDs from IPv6 backend nodes - avoid applying VPC backend configuration during IPv6 backend creation while preserving the existing IPv4/VPC path for non-IPv6 services - fall back to the Service's current status when a fresh LoadBalancer status lookup fails during reconcile - add unit coverage for backend IP selection, annotation-vs-flag resolution, IPv6 address formatting, and the VPC/subnet behavior around IPv6 backends - add a dedicated Chainsaw test and CAPL workflow support for validating IPv6 public backends end to end in a dual-stack cluster - centralize CAPL manifest patching for image overrides, `LINODE_API_VERSION=v4beta`, VPC/subnet mutations, and subnet-specific test setup - document the feature, its dual-stack requirement, and its current limitation to node public IPv6 backends rather than VPC IPv6 backend addresses Why: - support NodeBalancer services that need IPv6 backend targets instead of the existing IPv4-only backend programming - make the backend selection logic explicit so IPv6 public backends do not inherit IPv4/VPC assumptions such as subnet IDs - validate the feature in CI and local CAPL flows using a dual-stack workload cluster that can actually serve IPv6 NodePort traffic - keep the rollout understandable for operators by documenting the opt-in controls, migration behavior, and failure mode when nodes do not expose public IPv6 addresses How: - factor backend-family handling into helpers for backend-state resolution, subnet lookup, backend node construction, backend address formatting, and node backend IP selection - read IPv6 backend addresses from the node public IPv6 annotation, return a clear reconcile error when a selected node lacks one, and continue using the existing private/VPC address path when IPv6 backends are disabled - create a dedicated IPv6 CAPL cluster path and `ipv6-backends` Chainsaw selector, run that test separately from the rest of e2e, and use a dual-stack service definition to exercise the public IPv6 backend datapath - patch generated CAPL manifests through `hack/patch-capl-manifest.sh` so the CCM image repo/tag, pull policy, beta API env var, VPC template values, and optional subnet override are applied consistently across regular, IPv6, and subnet test flows - route `clusterctl` usage through repo-managed tooling/devbox configuration and ignore generated local manifest and kubeconfig artifacts from dev workflows
1 parent 0ae25f7 commit 0acdba8

17 files changed

Lines changed: 956 additions & 97 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ coverage.txt
3939
junit.xml
4040

4141
.DS_Store
42+
43+
# Local cluster artifacts
44+
*-manifests.yaml
45+
*-kubeconfig.yaml
46+
.opencode/

Makefile

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ LOCALBIN ?= $(CACHE_BIN)
99
DEVBOX_BIN ?= $(DEVBOX_PACKAGES_DIR)/bin
1010
HELM ?= $(LOCALBIN)/helm
1111
HELM_VERSION ?= v3.16.3
12+
CLUSTERCTL ?= $(CACHE_BIN)/clusterctl
13+
CLUSTERCTL_VERSION ?= v1.8.5
1214

1315
GOLANGCI_LINT ?= $(LOCALBIN)/golangci-lint
1416
GOLANGCI_LINT_NILAWAY ?= $(CACHE_BIN)/golangci-lint-nilaway
@@ -21,6 +23,8 @@ SUBNET_CLUSTER_NAME ?= subnet-testing-$(shell git rev-parse --short HEAD)
2123
VPC_NAME ?= $(CLUSTER_NAME)
2224
MANIFEST_NAME ?= capl-cluster-manifests
2325
SUBNET_MANIFEST_NAME ?= subnet-testing-manifests
26+
IPV6_CLUSTER_NAME ?= ipv6-$(shell git rev-parse --short HEAD)
27+
IPV6_MANIFEST_NAME ?= ipv6-manifests
2428

2529
# renovate: datasource=github-tags depName=kubernetes/kubernetes
2630
K8S_VERSION ?= "v1.31.2"
@@ -46,6 +50,7 @@ LINODE_URL ?= https://api.linode.com
4650
KUBECONFIG_PATH ?= $(CURDIR)/test-cluster-kubeconfig.yaml
4751
SUBNET_KUBECONFIG_PATH ?= $(CURDIR)/subnet-testing-kubeconfig.yaml
4852
MGMT_KUBECONFIG_PATH ?= $(CURDIR)/mgmt-cluster-kubeconfig.yaml
53+
IPV6_KUBECONFIG_PATH ?= $(CURDIR)/ipv6-kubeconfig.yaml
4954

5055
# if the $DEVBOX_PACKAGES_DIR env variable exists that means we are within a devbox shell and can safely
5156
# use devbox's bin for our tools
@@ -54,8 +59,9 @@ ifdef DEVBOX_PACKAGES_DIR
5459
endif
5560

5661
export PATH := $(CACHE_BIN):$(PATH)
57-
$(LOCALBIN):
58-
mkdir -p $(LOCALBIN)
62+
TOOL_DIRS := $(sort $(LOCALBIN) $(CACHE_BIN))
63+
$(TOOL_DIRS):
64+
mkdir -p $@
5965

6066
export GO111MODULE=on
6167

@@ -135,8 +141,8 @@ docker-build: build-linux
135141
docker-push:
136142
docker push ${IMG}
137143

138-
.PHONY: docker-setup
139-
docker-setup: docker-build docker-push
144+
.PHONY: build-and-push
145+
build-and-push: docker-build docker-push
140146

141147
.PHONY: run
142148
# run the ccm locally, really only makes sense on linux anyway
@@ -160,42 +166,58 @@ run-debug: build
160166
#####################################################################
161167

162168
.PHONY: mgmt-and-capl-cluster
163-
mgmt-and-capl-cluster: docker-setup mgmt-cluster capl-cluster
169+
mgmt-and-capl-cluster: build-and-push mgmt-cluster
170+
$(MAKE) -j2 capl-ipv6-cluster capl-cluster
164171

165172
.PHONY: capl-cluster
166-
capl-cluster: generate-capl-cluster-manifests create-capl-cluster patch-linode-ccm
173+
capl-cluster: generate-capl-cluster-manifests
174+
MANIFEST_NAME=$(MANIFEST_NAME) CLUSTER_NAME=$(CLUSTER_NAME) KUBECONFIG_PATH=$(KUBECONFIG_PATH) \
175+
$(MAKE) create-capl-cluster
176+
177+
.PHONY: capl-ipv6-cluster
178+
capl-ipv6-cluster: generate-capl-ipv6-cluster-manifests
179+
MANIFEST_NAME=$(IPV6_MANIFEST_NAME) CLUSTER_NAME=$(IPV6_CLUSTER_NAME) KUBECONFIG_PATH=$(IPV6_KUBECONFIG_PATH) \
180+
$(MAKE) create-capl-cluster
167181

168182
.PHONY: generate-capl-cluster-manifests
169-
generate-capl-cluster-manifests:
183+
generate-capl-cluster-manifests: clusterctl
170184
# Create the CAPL cluster manifests without any CSI driver stuff
171-
LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) clusterctl generate cluster $(CLUSTER_NAME) \
185+
LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(VPC_NAME) $(CLUSTERCTL) generate cluster $(CLUSTER_NAME) \
172186
--kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \
173187
--control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) > $(MANIFEST_NAME).yaml
174-
yq -i e 'select(.kind == "LinodeVPC").spec.subnets = [{"ipv4": "10.0.0.0/8", "label": "default"}, {"ipv4": "172.16.0.0/16", "label": "testing"}]' $(MANIFEST_NAME).yaml
188+
IMG=$(IMG) SUBNET_NAME=$(SUBNET_NAME) ./hack/patch-capl-manifest.sh $(MANIFEST_NAME).yaml
189+
190+
.PHONY: generate-capl-ipv6-cluster-manifests
191+
generate-capl-ipv6-cluster-manifests: clusterctl
192+
LINODE_FIREWALL_ENABLED=$(LINODE_FIREWALL_ENABLED) LINODE_OS=$(LINODE_OS) VPC_NAME=$(IPV6_CLUSTER_NAME) $(CLUSTERCTL) generate cluster $(IPV6_CLUSTER_NAME) \
193+
--kubernetes-version $(K8S_VERSION) --infrastructure linode-linode:$(CAPL_VERSION) \
194+
--control-plane-machine-count $(CONTROLPLANE_NODES) --worker-machine-count $(WORKER_NODES) --flavor kubeadm-dual-stack > $(IPV6_MANIFEST_NAME).yaml
195+
IMG=$(IMG) ./hack/patch-capl-manifest.sh $(IPV6_MANIFEST_NAME).yaml
175196

176197
.PHONY: create-capl-cluster
177-
create-capl-cluster:
198+
create-capl-cluster: clusterctl
178199
# Create a CAPL cluster with updated CCM and wait for it to be ready
179200
kubectl apply -f $(MANIFEST_NAME).yaml
180201
kubectl wait --for=condition=ControlPlaneReady cluster/$(CLUSTER_NAME) --timeout=600s || (kubectl get cluster -o yaml; kubectl get linodecluster -o yaml; kubectl get linodemachines -o yaml; kubectl logs -n capl-system deployments/capl-controller-manager --tail=50)
181202
kubectl wait --for=condition=NodeHealthy=true machines -l cluster.x-k8s.io/cluster-name=$(CLUSTER_NAME) --timeout=900s
182-
clusterctl get kubeconfig $(CLUSTER_NAME) > $(KUBECONFIG_PATH)
203+
$(CLUSTERCTL) get kubeconfig $(CLUSTER_NAME) > $(KUBECONFIG_PATH)
183204
KUBECONFIG=$(KUBECONFIG_PATH) kubectl wait --for=condition=Ready nodes --all --timeout=600s
184205
# Remove all taints from control plane node so that pods scheduled on it by tests can run (without this, some tests fail)
185206
KUBECONFIG=$(KUBECONFIG_PATH) kubectl taint nodes -l node-role.kubernetes.io/control-plane node-role.kubernetes.io/control-plane-
186207

187208
.PHONY: patch-linode-ccm
188209
patch-linode-ccm:
189210
KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p="[{'op': 'replace', 'path': '/spec/template/spec/containers/0/image', 'value': '${IMG}'}]"
211+
KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/imagePullPolicy", "value": "Always"}]'
190212
KUBECONFIG=$(KUBECONFIG_PATH) kubectl patch -n kube-system daemonset ccm-linode --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/env/-", "value": {"name": "LINODE_API_VERSION", "value": "v4beta"}}]'
191213
KUBECONFIG=$(KUBECONFIG_PATH) kubectl rollout status -n kube-system daemonset/ccm-linode --timeout=600s
192214
KUBECONFIG=$(KUBECONFIG_PATH) kubectl -n kube-system get daemonset/ccm-linode -o yaml
193215

194216
.PHONY: mgmt-cluster
195-
mgmt-cluster:
217+
mgmt-cluster: clusterctl
196218
# Create a mgmt cluster
197219
ctlptl apply -f e2e/setup/ctlptl-config.yaml
198-
clusterctl init \
220+
$(CLUSTERCTL) init \
199221
--wait-providers \
200222
--wait-provider-timeout 600 \
201223
--core cluster-api:$(CAPI_VERSION) \
@@ -214,13 +236,25 @@ cleanup-cluster:
214236

215237
.PHONY: e2e-test
216238
e2e-test:
239+
# Run ipv6 tests first and then the rest
240+
$(MAKE) e2e-test-ipv6-backends
217241
CLUSTER_NAME=$(CLUSTER_NAME) \
218242
MGMT_KUBECONFIG=$(MGMT_KUBECONFIG_PATH) \
219243
KUBECONFIG=$(KUBECONFIG_PATH) \
220244
REGION=$(LINODE_REGION) \
221245
LINODE_TOKEN=$(LINODE_TOKEN) \
222246
LINODE_URL=$(LINODE_URL) \
223-
chainsaw test e2e/test --parallel 2 $(E2E_FLAGS)
247+
chainsaw test e2e/test --parallel 2 --selector all $(E2E_FLAGS)
248+
249+
.PHONY: e2e-test-ipv6-backends
250+
e2e-test-ipv6-backends:
251+
CLUSTER_NAME=$(IPV6_CLUSTER_NAME) \
252+
MGMT_KUBECONFIG=$(MGMT_KUBECONFIG_PATH) \
253+
KUBECONFIG=$(IPV6_KUBECONFIG_PATH) \
254+
REGION=$(LINODE_REGION) \
255+
LINODE_TOKEN=$(LINODE_TOKEN) \
256+
LINODE_URL=$(LINODE_URL) \
257+
chainsaw test e2e/test --selector ipv6-backends $(E2E_FLAGS)
224258

225259
.PHONY: e2e-test-bgp
226260
e2e-test-bgp:
@@ -239,16 +273,9 @@ e2e-test-subnet:
239273
# Generate cluster manifests for second cluster
240274
SUBNET_NAME=testing CLUSTER_NAME=$(SUBNET_CLUSTER_NAME) MANIFEST_NAME=$(SUBNET_MANIFEST_NAME) VPC_NAME=$(CLUSTER_NAME) \
241275
VPC_NETWORK_CIDR=172.16.0.0/16 K8S_CLUSTER_CIDR=172.16.64.0/18 make generate-capl-cluster-manifests
242-
# Add subnetNames to HelmChartProxy
243-
yq e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate' $(SUBNET_MANIFEST_NAME).yaml > tmp.yaml
244-
yq -i e '.routeController += {"subnetNames": "testing"}' tmp.yaml
245-
yq -i e '.routeController.vpcNames = "{{.InfraCluster.spec.vpcRef.name}}"' tmp.yaml
246-
yq -i e 'select(.kind == "HelmChartProxy" and .spec.chartName == "ccm-linode").spec.valuesTemplate = load_str("tmp.yaml")' $(SUBNET_MANIFEST_NAME).yaml
247-
rm tmp.yaml
248276
# Create the second cluster
249277
MANIFEST_NAME=$(SUBNET_MANIFEST_NAME) CLUSTER_NAME=$(SUBNET_CLUSTER_NAME) KUBECONFIG_PATH=$(SUBNET_KUBECONFIG_PATH) \
250278
make create-capl-cluster
251-
KUBECONFIG_PATH=$(SUBNET_KUBECONFIG_PATH) make patch-linode-ccm
252279
# Run chainsaw test
253280
LINODE_TOKEN=$(LINODE_TOKEN) \
254281
LINODE_URL=$(LINODE_URL) \
@@ -295,13 +322,13 @@ helm-template: helm
295322
.PHONY: kubectl
296323
kubectl: $(KUBECTL) ## Download kubectl locally if necessary.
297324
$(KUBECTL): $(LOCALBIN)
298-
curl -fsSL https://dl.k8s.io/release/$(KUBECTL_VERSION)/bin/$(OS)/$(ARCH_SHORT)/kubectl -o $(KUBECTL)
325+
curl -fsSL https://dl.k8s.io/release/$(KUBECTL_VERSION)/bin/$(HOSTOS)/$(ARCH_SHORT)/kubectl -o $(KUBECTL)
299326
chmod +x $(KUBECTL)
300327

301328
.PHONY: clusterctl
302329
clusterctl: $(CLUSTERCTL) ## Download clusterctl locally if necessary.
303-
$(CLUSTERCTL): $(LOCALBIN)
304-
curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(OS)-$(ARCH_SHORT) -o $(CLUSTERCTL)
330+
$(CLUSTERCTL): $(CACHE_BIN)
331+
curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(HOSTOS)-$(ARCH_SHORT) -o $(CLUSTERCTL)
305332
chmod +x $(CLUSTERCTL)
306333

307334
.phony: golangci-lint-nilaway

cloud/annotations/annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const (
4141
// AnnLinodeEnableIPv6Ingress is the annotation used to specify that a service should include both IPv4 and IPv6
4242
// addresses for its LoadBalancer ingress. When set to "true", both addresses will be included in the status.
4343
AnnLinodeEnableIPv6Ingress = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-ingress"
44+
// AnnLinodeEnableIPv6Backends controls whether a NodeBalancer service should use public IPv6 backend nodes.
45+
AnnLinodeEnableIPv6Backends = "service.beta.kubernetes.io/linode-loadbalancer-enable-ipv6-backends"
4446

4547
AnnLinodeNodePrivateIP = "node.k8s.linode.com/private-ip"
4648
AnnLinodeHostUUID = "node.k8s.linode.com/host-uuid"

0 commit comments

Comments
 (0)