Talos upgrades¶
A TalosUpgrade declares a target Talos version for a set of nodes. tuppr
health-checks the cluster, then rolls the version out node-by-node (or in
parallel batches), draining and rebooting each node and verifying it returns
Ready on the new version before moving on.
The only required field is the target version:
apiVersion: tuppr.home-operations.com/v1alpha1
kind: TalosUpgrade
metadata:
name: cluster
spec:
talos:
# renovate: datasource=docker depName=ghcr.io/siderolabs/talos
version: v1.14.0
Everything below is optional and layers onto that. The full set of fields and defaults lives in the CRD schema; this page covers the ones you'll reach for.
Health checks¶
Health checks are CEL expressions evaluated against live
cluster objects before an upgrade proceeds. They run concurrently, and (for a
TalosUpgrade) before each node or batch.
spec:
healthChecks:
# All nodes Ready
- apiVersion: v1
kind: Node
expr: status.conditions.filter(c, c.type == "Ready").all(c, c.status == "True")
timeout: 10m
# A named Deployment fully rolled out
- apiVersion: apps/v1
kind: Deployment
name: critical-app
namespace: production
expr: status.readyReplicas == status.replicas
# Objects selected by labels
- apiVersion: apps/v1
kind: Deployment
namespace: production
labelSelector:
matchLabels:
app.kubernetes.io/part-of: critical-platform
expr: status.readyReplicas == status.replicas
# A custom resource
- apiVersion: ceph.rook.io/v1
kind: CephCluster
name: rook-ceph
namespace: rook-ceph
expr: status.ceph.health in ["HEALTH_OK"]
Each check attempt is emitted as Events on the resource (HealthChecksStarted,
then HealthChecksPassed or a HealthChecksFailed warning), so a run waiting
on failing checks is visible in kubectl describe. With pre-hooks configured,
inter-batch checks are suppressed and emit no Events.
Health checks are also available on KubernetesUpgrade.
Policy¶
spec.policy tunes how the upgrade runs:
| Field | Default | Effect |
|---|---|---|
debug |
false |
Verbose upgrade logging. |
force |
false |
Skip etcd health checks. Dangerous - can break quorum. |
placement |
hard |
How strictly the upgrade Job avoids the target node (hard or soft). |
rebootMode |
default |
default, or powercycle for nodes that don't reboot cleanly. |
stage |
false |
Stage the upgrade and apply it on the next reboot (two reboots total). |
waitForVolumeDetach |
false |
Drain and wait for CSI volumes to detach before the reboot (see below). |
timeout |
30m |
Per-node upgrade timeout, including how long a node may take to become ready after its reboot before being marked failed. |
waitForVolumeDetach
Set this when a fast reboot could orphan a mount and pin a volume to the node -
which surfaces as a Multi-Attach error when the pod reschedules. tuppr drains
the node and waits for its CSI volumes to detach before rebooting. No effect on
single-node clusters.
Node selection¶
Without a selector, a plan targets all nodes. Use spec.nodeSelector (a
standard label selector) to scope it - useful for opt-in labels or splitting a
cluster into plans:
spec:
nodeSelector:
matchExpressions:
- { key: tuppr.home-operations.com/upgrade, operator: In, values: ["enabled"] }
- { key: node-role.kubernetes.io/control-plane, operator: DoesNotExist }
Multiple plans with different selectors queue; see Upgrade coordination.
Parallel upgrades¶
By default nodes upgrade one at a time. spec.parallelism raises the batch
size:
The webhook requires parallelism >= 1 and no greater than the number of nodes
the selector matches. When parallelism > 1:
- Health checks run once before each batch, not per node.
- Drain (if configured) runs on all batch nodes before any upgrade Job starts.
- The batch waits for every node's Job to finish before the next batch begins.
- Any failure in a batch stops further batches.
status.currentNodeslists the active batch.
Installer pre-pull¶
Before the first node is cordoned, tuppr pulls each node's resolved installer
image into the node's image store (the per-node resolution described in
How the upgrade image is resolved). An
unreachable registry, a missing schematic, or a wrong tag then parks the run
with a clear PrePullFailed status - before anything is drained - instead of
failing mid-roll with one node down. The upgrade's own image pull becomes a
no-op, since Talos skips images that are already present.
Pre-pull is on by default. It requires the node to be running Talos v1.13 or
newer (the ImageService API); older nodes are skipped with an event and
upgrade exactly as before. Disable it for airgapped clusters that seed images
out of band, or when the extra pull is unwanted on constrained links:
A pull that reports no progress for 90 seconds fails fast (a registry serving
errors that the node retries internally would otherwise be indistinguishable
from a slow link), while a slow but progressing pull gets up to 10 minutes. A
parked run retries with backoff (1 minute doubling to a 5 minute cap) and
resumes on its own once the registry serves the image; partial pulls resume
where they left off. The retry streak is tracked in status.prePullFailure
(attempt count and last error) and surfaced in the status message, so a
crash-looping pre-pull is visible in kubectl get talosupgrade rather than
only in events. Pulled images are
tracked per node in status.prePulledNodes, keyed by the resolved ref and the
Node UID: a node that joins (or becomes eligible) mid-run, is recreated under
the same name, or whose resolved image changes, is pre-pulled before its next
batch.
Draining¶
tuppr cordons and drains a node before its reboot and uncordons it after a verified upgrade. Draining is skipped on single-node clusters (there is nowhere to drain to, and evicting the upgrade pod would strand the node).
Pre/post-upgrade hooks¶
Run side-effecting Jobs around the whole run - for example, set and clear Ceph
noout so brief reboots don't trigger rebalancing:
spec:
talos:
version: v1.13.7
hooks:
pre:
- name: ceph-set-noout
image: ghcr.io/rook/rook:v1.18.7
command: ["sh", "-c"]
args: ["ceph osd set noout"]
envFrom:
- secretRef:
name: rook-ceph-mon
post:
- name: ceph-unset-noout
image: ghcr.io/rook/rook:v1.18.7
command: ["sh", "-c"]
args: ["ceph osd unset noout"]
envFrom:
- secretRef:
name: rook-ceph-mon
- Pre-hooks run sequentially after the initial health check, before any node
is touched. A failed pre-hook skips the upgrade and marks the run
Failed(post-hooks still run as cleanup). - Post-hooks run sequentially once the upgrade reaches a terminal state (success or failure), if any pre-hook was attempted. Their failures are logged and recorded but don't change the upgrade outcome.
- While pre-hooks are configured, inter-batch health checks are suppressed - the contract is that pre-hooks own cluster state for the upgrade window.
- Each hook is a Job in the controller namespace with the same non-root,
capabilities-dropped posture as the upgrade Job. Mount credentials via
volumes/envFrom, and setserviceAccountNameif you need cluster-API access.
Phase progression with hooks:
Pending → HealthChecking → PreHook → (Draining → Upgrading → Rebooting per batch) → PostHook → Completed
Maintenance windows¶
Restrict when upgrades may start. An in-progress upgrade always runs to completion.
spec:
maintenance:
windows:
- start: "0 2 * * 0" # cron: Sunday 02:00
duration: "4h" # max 168h; warns if < 1h
timezone: "Europe/Paris" # IANA tz, default UTC
- Upgrades stay
Pendingoutside every window. - Multiple windows are a union (any open window allows a start).
- A
TalosUpgradere-checks the window between nodes. - No windows configured → upgrades start immediately.
Alertmanager silences¶
Every node reboot during an upgrade fires a burst of expected alerts
(CephMonDown, KubeNodeUnreachable, TargetDown, ...). tuppr knows exactly
when that window starts and ends, so it can hold an Alertmanager silence open
for the run.
The connection is operator-level (credentials stay out of CRs - see the
chart values); which alerts get silenced is per-resource
and user-authored, same philosophy as healthChecks:
# Helm values
silences:
enabled: true
alertmanager: # where the silences are created
address: http://alertmanager.monitoring.svc:9093
secretName: tuppr-alertmanager # optional: Secret of HTTP headers (Authorization, X-Scope-OrgID)
# TalosUpgrade
spec:
silences: # requires silences.* configured in the Helm chart
- matchers:
- name: alertname
value: ^(CephMonDown|CephOSDDown|KubeNodeUnreachable|TargetDown)$
matchType: "=~" # "=", "!=", "=~", "!~"
maxDuration: 2h # default 4h
Each list entry is one Alertmanager silence, held for the whole run.
Matchers within one silence are ANDed
Alertmanager semantics: a silence matches an alert only when all its
matchers do. To cover alternatives, use a regex value (as above); to silence
independent scopes - say Ceph alerts only in namespace=rook-ceph plus a
cluster-wide TargetDown - use separate silences entries, one per scope.
Anything speaking the Alertmanager v2 silences API works: Prometheus Alertmanager, VMAlertmanager, or Grafana-managed alerting behind its prefix.
The silence is a lease, not a timer¶
tuppr never guesses how long the run will take. It creates a short silence (~25m) and re-extends it on every reconcile while the run is active, then expires it down to a ~5m tail at the terminal phase (so the last reboot's alert tail stays covered). Every failure mode converges to "the silence lapses within its TTL":
- Controller crash or CR deleted mid-run → lapses on its own.
- Alertmanager unreachable → logged and evented, never gates the upgrade.
- Run parked (
Pending,MaintenanceWindow, coordination wait) → lapses; re-established when the run resumes. A cluster parked overnight should alert. - Suspending the CR (the
suspendannotation) expires the silence immediately.
Two details worth knowing:
- The silence is created only once the run leaves its initial health check, so alerts stay live while the cluster is still being verified - and it keeps being extended through inter-batch health checks, so there is no mid-run gap.
- A run that is active but stuck (health checks failing for hours because
something is genuinely broken) stops extending once the continuous hold
exceeds an entry's
maxDuration, emitting aSilenceMaxDurationReachedevent (once) - you get paged again instead of masking a real failure. The budget re-arms when the hold is released, so a run resumed after a long park silences its remaining reboots normally.
The silence IDs are visible in .status.alertSilenceIDs (indexed like
spec.silences), and their lifecycle is emitted as Events (SilenceCreated,
SilenceExpired, ...) on the resource. Configuring spec.silences without the
operator-level Alertmanager connection warns at apply time (admission webhook)
and as a SilencesNotConfigured event.
Alternative: Alertmanager inhibition
If you'd rather keep alert routing entirely in Alertmanager, you can wire an
inhibition rule keyed on tuppr's tuppr_talos_upgrade_phase metric (via an
always-firing "upgrade in progress" alert) instead - zero tuppr configuration,
at the cost of maintaining the alert + inhibit_rules by hand.
Per-node overrides¶
Annotations on a Node override the plan for that node - handy for a canary version or a different installer flavor.
| Annotation | Purpose | Example |
|---|---|---|
tuppr.home-operations.com/version |
Target Talos version for this node. | v1.12.1 |
tuppr.home-operations.com/factory-url |
Switch the node's installer flavor on the next upgrade. | factory.talos.dev/aws-installer |
tuppr.home-operations.com/schematic |
Companion to factory-url; the schematic to build the image with. |
b55fbf... |
# Pin one node to a different version
kubectl annotate node worker-01 tuppr.home-operations.com/version="v1.12.1"
# Switch a node to a factory flavor at the next upgrade
kubectl annotate node hcloud-01 \
tuppr.home-operations.com/factory-url="factory.talos.dev/hcloud-installer" \
tuppr.home-operations.com/schematic="314b18a3f89d..."
How the upgrade image is resolved¶
tuppr derives the image from each node's runtime state and
.machine.install.image - no per-platform branching:
factory-urloverride → builds<factory-url>/<schematic>:<version>. The schematic comes from theschematicannotation if set, otherwise from the node's runtimeExtensionStatus.- Default → version-swaps the node's current install image. A factory
install stays on its factory base + schematic, and a private-registry path is
preserved. A vanilla install on
ghcr.io/siderolabs/installerstays vanilla up to v1.13; from v1.14, which Talos stopped publishing that image for, it moves to the Image Factory installer for the node's platform with the default (empty) schematic. - Safety net → refuses with a clear error (pointing at
factory-url) when the runtime schematic isn't in the install-image path, or when reinstalling the canonical generic installer would silently wipe installed system extensions.
talosctl image¶
The upgrade Job's talosctl version is auto-detected. Pin it if needed: