fix(hetzner): unbreak bare-metal re-provisioning on Ubuntu 26.04 - #213
Open
nihaldivyam wants to merge 4 commits into
Open
fix(hetzner): unbreak bare-metal re-provisioning on Ubuntu 26.04#213nihaldivyam wants to merge 4 commits into
nihaldivyam wants to merge 4 commits into
Conversation
… NIC cloud-init's runcmd can start before networking is up. `ip route get 8.8.8.8` then fails with "Network is unreachable", NETWORK_INTERFACE ends up empty, and the netplan file written from it gets a bare `link:`. netplan apply rejects the whole config with vswitch.4000: interface '' is not defined so the vSwitch VLAN is never created, the node has no address on the private network, and kubeadm join times out against the private API endpoint. runcmd fails, cloud-init reports status=error, and CAPH parks the host in "ensure-provisioned" indefinitely. Ubuntu 24.04 generally won this race; 26.04 brings networking up later and loses it every time, so every bare-metal node re-imaged to 26.04 fails to join. Hit on qa-htz1-kilroy-eu worker 2836489. Replace the three copies of the one-liner with a shared helper that polls for the default route (60 x 2s) and aborts loudly if it never appears, rather than writing an invalid netplan. The kubeaid-storagectl block already carries a `sleep 10s` for this same race, but it runs after the code that needs it.
CAPH builds its kubelet-serving CSR allow-list from HetznerBareMetalMachine.status.addresses, which is derived from the hardwareDetails NIC snapshot taken in the Hetzner rescue system — before cloud-init creates the vSwitch VLAN. A worker's kubelet advertises its vSwitch address (--node-ip=10.0.1.x), so the CSR carries an IP CAPH can never know and is denied: Denied: CSRValidationFailed - Validation by cluster-api-provider-hetzner failed: the IP address "10.0.1.2" is not allowed kubelet is then left without a serving certificate, and every apiserver-initiated call to that node — logs, exec, port-forward, metrics — fails with "remote error: tls: internal error". Observed on qa-htz1-kilroy-eu worker 2836489 after a re-provision: 84 consecutive denied CSRs. Nodes provisioned earlier are unaffected only because they already hold issued certificates. KubeAid already ships kubelet-csr-approver, which validates the same CSRs against providerIpPrefixes (10.0.0.0/16 by default, covering both the HCloud network and the vSwitch subnet) plus node-name and expiry checks. CAPH's approver is therefore redundant, and its narrower allow-list makes bare-metal vSwitch nodes unusable after every re-provision. REFER: syself/cluster-api-provider-hetzner#2095
nihaldivyam
changed the base branch from
fix/hetzner-network-interface-detection
to
master
August 17, 2026 09:35
Node-by-node runbook for taking a mode: hybrid cluster (HCloud control
plane + bare-metal workers on a Robot vSwitch) through a Kubernetes and/or
Ubuntu upgrade, written from a qa-htz1-kilroy-eu run to 32.4.0 / v1.35.0 /
Ubuntu 26.04.
Covers the parts that are not obvious from the charts:
- which values field drives which resource, and which of those are
immutable in CAPI (so a plain ArgoCD sync reports partial failure by
design)
- recreating HCloudMachineTemplate and forcing a control-plane roll —
including that CAPI v1beta2 renamed the field to spec.rollout.after,
and patching the old spec.rolloutAfter silently no-ops
- bare-metal workers are standalone Machines with no MachineDeployment,
so nothing rolls them; they upgrade only by delete + re-provision,
strictly one at a time because each holds a Ceph mon
- pre-drain checks that avoid multi-hour stalls: orphaned
VolumeAttachments, pods in deleted namespaces, zero-disruption CNPG
PDBs, and workloads that tolerate every taint
Plus a troubleshooting section for the failures actually hit: stuck
deletions, cloud-init recovery (including that status.json is persistent,
not tmpfs), CAPH's ~16 minute backoff, both flavours of denied kubelet
serving CSR, and ArgoCD apps silently serving cached manifests.
MD060 — table delimiter row needs padded pipes for the compact style. MD040 — six fenced blocks holding captured command output had no language; tag them as text.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent bugs make bare-metal nodes unusable after re-provisioning. We hit it while upgrading to KubeAid 32.4.0 / Kubernetes v1.35 / Ubuntu 26.04, and both are fixed here. (Supersedes #210, which carried the first commit alone.)
1. Node never joins — empty
link:in the vSwitch netplancloud-init'sruncmdcan start before networking is up, so the interface-detection one-liner fails:NETWORK_INTERFACEends up empty, the generated netplan gets a barelink:, andnetplan applyrejects the whole file:No vSwitch VLAN → no address on the private network →
kubeadm jointimes out against the private API endpoint →runcmdfails →cloud-init status=error→ CAPH parks the host inensure-provisionedforever.Ubuntu 24.04 generally won this race; 26.04 brings networking up later and loses it consistently.
Fix: replace the three copies of the one-liner with a shared
hetzner.detectNetworkInterfaceScripthelper that polls for the default route (60 × 2s) and aborts loudly rather than writing an invalid netplan. Call sites:KubeadmConfig.yaml(workers),KubeadmControlPlane.yaml×2 (bare-metal CP vSwitch VLAN, HCloud floating IPs).Note the
kubeaid-storagectlblock already carried asleep 10sfor this same race — the mitigation just sat after the code that needed it.2. Node joins but kubelet has no serving cert — CAPH denies the vSwitch IP
With
serverTLSBootstrap: true, kubelet requests a serving cert whose SANs include its--node-ipvSwitch address. CAPH denies it:CAPH's allow-list (
pkg/csr/csr.go) is built fromHetznerBareMetalMachine.status.addresses, derived fromHetznerBareMetalHost.spec.status.hardwareDetails.nics— a NIC snapshot taken in the Hetzner rescue system, before cloud-init creates the vSwitch VLAN. The private IP structurally cannot be there. We saw 84 consecutive denials on one node.Result: no serving cert, so every apiserver-initiated call —
logs,exec,port-forward, metrics — fails withremote error: tls: internal error, on a node that is otherwiseReady.Upstream: syself/cluster-api-provider-hetzner#2095 (open; the maintainer rejected the proposed fix as circular, so nothing is coming soon). The validation code is unchanged between CAPH v1.1.7 and v1.1.8 — not a provider regression; it simply only bites on re-provision.
Fix: pass
--disable-csr-approvaland let kubelet-csr-approver — which KubeAid already ships — handle these CSRs. It validates the same requests againstproviderIpPrefixes, plus node-name and expiry checks, so security posture is preserved.Worth knowing: CAPH's controller skips a CSR only
if len(Status.Conditions) > 0, i.e. once someone has decided it. With both controllers running it is a pure race, and CAPH won 84/84 on our cluster — so the approver alone is not sufficient, and leaving both enabled would at best give intermittent denials.Rejected alternatives:
serverTLSBootstrap: false(drops to unverified self-signed kubelet certs) and hand-patchinghardwareDetails(CAPH v1.1.8 removed theif HardwareDetails == nilguard, so it is overwritten on every re-registration).--disable-csr-approvalis global to the management cluster, which is fine for KubeAid: each cluster is self-managed, and KubeAid ships the replacement approver as a standard component.Testing
shclean (sh -n) — cloud-init runsruncmdunder/bin/sh.2836489(before these fixes) needed manual SSH rescue to join and never got a serving cert. Worker2836490, re-provisioned with this branch, wentimage-installing → provisioned → Ready on Ubuntu 26.04 / v1.35.0in ~19 minutes fully unattended, with both CSRsApproved,Issuedautomatically. Ceph returned to 3 mons and 6/6 OSDs with all PGsactive+clean.Documentation
Adds
docs/operations/upgrade-hetzner-hybrid-cluster.md— a node-by-node runbook for upgrading amode: hybridcluster (HCloud control plane + bare-metal workers on a Robot vSwitch), written from this run.It documents the parts that are not discoverable from the charts: which values field drives which resource and which of those are immutable (so a plain sync reports partial failure by design); recreating
HCloudMachineTemplateand forcing a control-plane roll — including that CAPI v1beta2 renamed the field tospec.rollout.afterand patching the oldspec.rolloutAftersilently no-ops; and that bare-metal workers are standaloneMachines with noMachineDeployment, so nothing rolls them and they upgrade only by delete + re-provision, strictly one at a time because each holds a Ceph mon.It also carries a troubleshooting section for the failures actually hit during this upgrade — stuck deletions (orphaned VolumeAttachments, pods in deleted namespaces), cloud-init recovery including that
status.jsonis persistent rather than tmpfs, CAPH's ~16 minute reconcile backoff, both flavours of denied kubelet serving CSR, and ArgoCD apps silently serving cached manifests.