0057. The Kubernetes upgrade applies the rendered configuration node by node; talosctl's upgrade-k8s is not reused whole

Status: Accepted · Date: 2026-10-26 · Design section: Upgrades Decided: Execute kind: Kubernetes as a per-node AdvanceMachineFunc that applies the freshly rendered machine configuration without a reboot (only the component image fields may differ), pre-pulls the images, verifies the node from the Kubernetes API, and syncs the bootstrap manifests once the last control plane runs the target; it refuses a skipped minor, an unsupported Talos pairing and an unfinished CNI hand-over before touching a node, and only warns about removed APIs in use

Context

The design's "Upgrades / Kubernetes" lists: one minor at a time, warn about removed APIs, pre-pull images, switch the control-plane components node by node through machine config, update kube-proxy (skipped when replaced), roll the kubelets, sync the bootstrap manifests, flag outstanding hand-overs first, and says reusing talosctl upgrade-k8s is the first thing to evaluate. ADR 0026 supplies the lock, the status-held state machine, pause on failure, maxUnavailable, hold annotations and ordering; ADR 0043 is the Talos-kind executor this follows.

Evaluating upgrade-k8s (Talos v1.14.3 source)

  • cmd/talosctl/cmd/talos/upgrade-k8s.go builds a kubernetes.UpgradeOptions and calls pkg/cluster/kubernetes.Upgrade (talos_managed.go). That package is public, but Upgrade is one blocking call over every node: it takes an UpgradeProvider (a talosctl cluster client with a kubeconfig), loops upgradeStaticPod over all control planes per component, then kube-proxy, then kubelets, then PerformManifestsSync, and keeps its progress in goroutine and watch state. It cannot resume after a crash from TalosOperation status, and it cannot be split into the backbone's per-machine steps.
  • It changes configuration with patchNodeConfig (patch.go): read the node's active config, patch an image field, ApplyConfiguration in NO_REBOOT mode. Ichor's controller renders each machine's whole configuration from the CRs and treats a node that differs as drift (design step 5), so a patch made behind its back would be contradicted by the next reconcile. Two findings from there carry over: every Kubernetes component image change applies with no reboot (the same call, NO_REBOOT, for apiserver, controller-manager, scheduler, kube-proxy and kubelet), and the pre-pull uses the ImagePull RPC in the CRI namespace for the static pods and the system namespace for the kubelet (prePullImages).
  • The removed-API pre-check (go-kubernetes/kubernetes/upgrade/checks.go, a different module, ADR 0022) keeps a per-minor table of removed flags, feature gates and API resources that someone has to extend for every Kubernetes release. Most of what it checks (flags and gates on the static pods) cannot be set through Ichor's typed configuration in the first place.
  • go-kubernetes's ssa.Manager and manifests are reusable as a library, but adding the module moves k8s.io/api, apimachinery, client-go, cli-runtime, component-base, kubectl from v0.36.0 to v0.36.2 (tried with go get and reverted) for every package in the repository, to save about forty lines.

So: not reused as a whole, and not as a dependency. What is reused is what it proves: the apply mode, the pre-pull RPC and namespaces, the order (control planes, then workers) and the bootstrap-manifest sync as the last cluster-wide step.

Decision

The per-node switch is "apply the rendered configuration". pkg/render already derives every component image from spec.kubernetesVersion; for a control plane the diff against the node is exactly KubeAPIServerConfig.image, KubeControllerManagerConfig.image, KubeSchedulerConfig.image, KubeProxyConfig.image and KubeletConfig.image (a worker: KubeletConfig.image), measured with ChangedPaths on a render at two versions. Nothing hand-writes a component switch.

The existing apply path cannot do it by itself, and that is useful. pkg/fieldmeta has no rule for these paths (it only knows the legacy cluster.apiServer.* prefixes, and cluster.proxy and the kubelet are deliberately absent), so TalosMachineReconciler classifies a version change as reboot-required and refuses it on every node (RebootRequired). That keeps the roll gated by the operation: no node changes until the executor's turn, one control plane at a time. The executor applies with NO_REBOOT itself, accepting a changed path only if it is one of the image paths above or fieldmeta.Assess says it is reboot-free and not install-time-only; any other pending change fails the step ("unrelated pending configuration change") instead of riding along. fieldmeta is not edited: a rule there would let the machine controller apply the roll on every node at once.

Per call, everything is re-derived (the ADR 0043 discipline). If the node's configuration hash differs from the render, the step has not been applied: pre-checks, dry run, pre-pull of the changed non-proxy images, apply, and the KubernetesUpgrade condition (a resume marker and the verification clock, not a source of truth). If they are equal it only verifies: the Node's kubelet version and Ready, and for a control plane its three static pods (mirror pods in kube-system, found by k8s-app label and node) on the target image tag and Ready. Not yet true is "still settling"; past 20 minutes from the marker the step errors and ADR 0026 pauses the operation.

Pre-checks (before any node is changed; failures pause with a message):

  1. upgrade.KubernetesSkew(node's kubelet version, target): first caller. Judged per node against what that node actually runs, so a skipped minor, a downgrade, or a node left behind is refused.
  2. upgrade.KubernetesPairing(target, the node's running Talos version).
  3. The node is Ready.
  4. CNI hand-over: block, as an error. The bootstrap-manifest sync re-applies the cluster's inline manifests; while Argo CD is taking the CNI over and Ichor still renders the inline entry (the CNIHandover condition is not True, ADR 0024), that rolls the CNI back, which can take the network down: not something to warn about and continue. Blocked: preset with a hand-over (cilium, via the same cniPresetFor/ArgoCDApplication decision the hand-over controller makes), handover automatic or manual, condition not True. Allowed: no CNI hand-over (flannel, custom), condition True, and handover: disabled (nothing ever takes over, so the inline manifest is the source of truth and re-applying it is the intended state). It is an error rather than a wait so a blocked operation does not hold the cluster lock indefinitely; the operator finishes the hand-over (or sets disabled on a cluster without Argo CD) and creates the operation again.
  5. Removed APIs: warn, not gate. The API server's own apiserver_requested_deprecated_apis metric reports, per requested API version, the release that removes it (pkg/upgrade.RemovedAPIsInUse), so there is no table to maintain. APIs removed by the target minor or earlier that something requested are recorded in the RemovedAPIsInUse condition on the TalosOperation; a failed check is recorded as Unknown. Limits: it sees only APIs requested since that API server started, and one API server answers the scrape.

Bootstrap manifests and kube-proxy run once, in the control plane whose verification finds every kube-apiserver pod on the target and Ready (observed state, no control-plane list, so countplanes_guard_test.go is untouched): KubernetesAdmin.SyncBootstrapManifests server-side-applies the node's k8s.Manifest resources under Talos's own field manager (talos) through an admin kubeconfig from the Talos API. The controller's own ServiceAccount therefore gets no write access to ClusterRoles, DaemonSets or CRDs; it gains get on DaemonSets, and get;list on Pods and Nodes it already had. When spec.cni.kubeProxy is replaced nothing about kube-proxy is checked (no DaemonSet is shipped); otherwise the step waits for the kube-proxy DaemonSet to carry the target and finish rolling out. This is before the workers, not after the kubelets as in upgrade-k8s; kube-proxy may be newer than a kubelet but not than the API server. ponytail: no pruning and no inventory, unlike ssa.Manager; an object a later Talos stops shipping stays until removed.

A rendering bug found on the way (fixed in pkg/render): spec.kubernetesVersion is vX.Y.Z (the CRD pattern) but generate.NewInput expects it without the v and adds its own, so every component image rendered as ...:vv1.36.0. The v is now trimmed before the call, with a regression test.

Consequences

  • TalosClient gains PullImage, Kubeconfig and BootstrapManifests; KubernetesAdmin is the seam for the two cluster-wide actions, with a fake in the tests. The real admin client (kubeconfig, SSA, /metrics) is covered by the repository's tests only through the pure parser; it has not been run against a cluster.
  • upgrade.KubernetesSkew has its first caller; KubernetesMinorOf and RemovedAPIsInUse are new.
  • pkg/fieldmeta still lacks reboot rules for the multi-document Kubernetes component kinds. Any machine whose rendered config changes one of those kinds outside an operation is reported RebootRequired; left as is on purpose (above).
  • Amended: every kubelet roll (the Kubelets stage, ADR 0087) now begins storage evacuation, cordons and drains the node with the config rollout's helpers, rolls the kubelet, waits for Ready, uncordons and ends the evacuation; a failure pauses and ends the evacuation too. The controller's own node goes last in the Kubelets stage (holdOwnNodeLast, as for config rollouts) and is drained and evacuated like the rest; its eviction restarts the controller, and the step resumes from the node's state.
  • Deferred: the Ichor kind is still unwired.

E2E observation (2026-10-26, through ichor upgrade kubernetes)

e2e harness, 3 control planes + 1 worker, Talos v1.14.3, Cilium 1.18.0 (handover: disabled), controller run in the cluster. No newer patch than v1.37.1 exists on registry.k8s.io, so the cluster booted at v1.36.4 and the target was v1.36.5. The cluster's configs were rendered by Ichor itself (all four machines Converged, i.e. zero drift) at versionContract v1.13: at v1.14 the typed patches cannot express pod/service subnets, the install disk or "no flannel" (those are separate documents, rejected when combined with the legacy Config fields).

  • The confirmation text appeared as designed: "Upgrade Kubernetes on cluster ... from v1.36.4 to v1.36.5 ... 4 machine(s) (3 control plane, 1 worker): control planes one at a time, then workers (at most 1 at once), in this order: ...". Answering n aborted with nothing written.

  • The vv fix holds: after the first control plane, kube-apiserver, kube-controller-manager and kube-scheduler ran registry.k8s.io/...:v1.36.5, the kubelet reported v1.36.5, the three pods were Ready.

  • Bug (not fixed here; internal/controller belongs to another change): the step fails with rpc error: code = DeadlineExceeded from ApplyNoReboot. begin runs the version query, the removed-API scrape, the dry run and the apply all under the one 15 s etcdDialTimeout context (dialCtx). Two runs, the first failed on the first control plane (nothing changed on the node), the second succeeded on it and failed on the second. Each leaves the operation Paused; a failed machine is not retried (ADR 0026), delete and start again. The apply of a control-plane image change blocks while Talos restarts the static pods, the same shape as the upgradeCallTimeout bug in ADR 0043. Needs its own bound for the apply. Stopped after the second failure; the cluster was finished with talosctl upgrade-k8s, which is outside the product.

  • Not observed: the bootstrap-manifest sync and the worker step (the operation never got past the second control plane).

  • Fixed: each Talos call in begin (and the kube-proxy switch and CA rotation applies) now has its own deadline, the apply bounded by upgradeCallTimeout.

  • Addendum: the executors write the machine condition through a re-read with retry on conflict (setMachineCondition), so the machine reconciler writing the same status during the long calls no longer pauses the first machine. Finding, not fixed: this executor applies the control-plane images and the kubelet of one node together, where upgrade-k8s upgrades every control plane first and the kubelets afterwards, so on a minor upgrade the first node's kubelet is newer than the apiservers it reaches through the endpoint (see the report on endpoint type node).

  • Addendum (ADR 0087): fixed. The executor now runs in four stages (apiservers, then controller-manager and scheduler, then kube-proxy and the bootstrap-manifest sync, then kubelets), so the manifest sync no longer waits for "the last control plane" and no kubelet is ever newer than an API server.

  • Amendment (ADR 0113): the target of a Kubernetes upgrade is the release set's Kubernetes, and other targets are refused before anything is written. The rollout runs this step after the Talos step, and after any configuration step the executor needs for an unrelated pending change. The executor's pairing and skew checks are unchanged.

Search the docs

Search pages and headings