refactor(kubernetes): remove worker node pools from the chart

The KubevirtMachineTemplate/MachineDeployment/MachineHealthCheck/worker
WorkloadMonitor, the preserve-old-KMT block, the talos-reconcile Job and
its RBAC now live in the kubernetes-nodes chart. The kubernetes chart
renders the control plane only.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
This commit is contained in:
Ivan Okhotnikov authored and IvanHunters committed 2026-07-21 22:54:24 +03:00
1 parent 86ecedb3c0
commit 92b9785daa
5 files changed
+9 -1132

No files matched your search

@@ -135,24 +135,6 @@ enable the ingress-nginx addon, either supply an explicit nodeGroup with
autoscaler will bring the default md0 up in response to the ingress-nginx
controller Pods becoming Pending on install.
*/}}
{{- define "kubernetes.nodeGroups" -}}
{{- if .Values.nodeGroups -}}
{{ toYaml .Values.nodeGroups }}
{{- else -}}
md0:
minReplicas: 0
maxReplicas: 10
instanceType: "u1.medium"
diskSize: 20Gi
storageClass: ""
roles:
- ingress-nginx
resources: {}
gpus: []
kubelet: {}
{{- end -}}
{{- end }}
{{/*
OIDC clientId for the per-cluster Keycloak public client (mode: System).
@@ -52,11 +52,3 @@ These presets are for basic testing and not meant to be used in production
Convert a CPU quantity string (e.g. "100m", "0.5", "1", "4") to millicores.
Returns a number string: "100", "500", "1000", "4000".
*/}}
{{- define "kubernetes.cpuToMillicores" -}}
{{- $str := . | toString -}}
{{- if hasSuffix "m" $str -}}
{{- trimSuffix "m" $str -}}
{{- else -}}
{{- mulf ($str | float64) 1000.0 | int | toString -}}
{{- end -}}
{{- end -}}
+9 -533
View File
@@ -36,18 +36,15 @@
flux marks the HelmRelease Ready and retries its 5m reconcile loop until
the Tenant chart catches up.
*/}}
{{- /* The TalosConfigTemplate and the apiserver-Service ClusterIP-SAN
used to be rendered in this template behind a lookup gate that
races every fresh install: Service / Talos CA / K8s CA do not
exist on first Helm render, so the gate fails, the TCT is
skipped, and Helm's release-storage three-way merge then refuses
to upgrade because no values changed. Both objects are now
produced by the main-phase Job in
templates/talos/talos-reconcile-job.yaml, which blocks until the
four dependencies exist and then `kubectl apply`s deterministic
results. The Job owns the TCT through an ownerReference to the
KamajiControlPlane so K8s garbage-collects it naturally on
release uninstall. */}}
{{- /* Worker node pools (KubevirtMachineTemplate, MachineDeployment,
MachineHealthCheck, worker WorkloadMonitor) and the Talos
reconcile Job that produces each pool's TalosConfigTemplate now
live in the separate `kubernetes-nodes` chart (one HelmRelease
per pool). This chart renders the control plane only. The
reconcile Job in the kubernetes-nodes release still re-patches
this KamajiControlPlane's apiserver-Service ClusterIP-SAN and
owns each TalosConfigTemplate via an ownerReference to this KCP,
so garbage collection on control-plane uninstall is unchanged. */}}
{{- /*
Cluster pod/service CIDRs MUST match between the CAPI Cluster
spec.clusterNetwork below and the Talos worker machineconfig's
@@ -60,134 +57,6 @@
{{- $serviceCIDR := "10.95.0.0/16" }}
{{- $ingress := .Values._namespace.ingress }}
{{- $host := .Values._namespace.host }}
{{- $kubevirtmachinetemplateNames := list }}
{{- define "kubevirtmachinetemplate" -}}
spec:
{{- /* Talos has no SSH; use 'none' so CAPK does not block Machine.Ready
on an SSH probe that will never succeed. The Machine reaches Ready
once kubelet registers with the tenant apiserver via standard CSR. */}}
virtualMachineBootstrapCheck:
checkStrategy: none
virtualMachineTemplate:
metadata:
namespace: {{ $.Release.Namespace }}
labels:
{{- range .group.roles }}
node-role.kubernetes.io/{{ . }}: ""
{{- end }}
spec:
{{- /* Explicit resources take precedence over instanceType. When a node
group is sized by resources (both cpu and memory set), the
instancetype is omitted from the VM: KubeVirt rejects a
VirtualMachine that references an instancetype and also overrides
either domain.cpu or domain.memory. */}}
{{- $sizedByResources := and .group.resources .group.resources.cpu .group.resources.memory }}
{{- if and .group.instanceType (not $sizedByResources) }}
instancetype:
kind: VirtualMachineClusterInstancetype
name: {{ .group.instanceType }}
{{- end }}
runStrategy: Always
dataVolumeTemplates:
- metadata:
name: disk-system
annotations:
cdi.kubevirt.io/storage.usePopulator: "false"
spec:
{{- /* Use the openstack/configdrive Talos image: KubeVirt CAPK
injects bootstrap data via cloudInitConfigDrive (OpenStack
metadata service shape), and the openstack platform image
is the one that reads it. The nocloud-amd64 image silently
ignores configdrive and never applies the machineconfig. */}}
source:
http:
url: {{ $.Values.talos.imageFactoryURL | trimSuffix "/" }}/image/{{ $.Values.talos.schematicID }}/{{ $.Values.talos.version }}/openstack-amd64.raw.xz
{{- if hasKey .group "ephemeralStorage" }}
{{- fail (printf "nodeGroup %q: ephemeralStorage is no longer supported and should have been automatically migrated to diskSize by platform migration 41. If you see this error after upgrading, the migration did not run — check the cozystack-migration-hook Job logs in cozy-system." .groupName) }}
{{- end }}
storage:
resources:
requests:
storage: {{ .group.diskSize | default "20Gi" | quote }}
{{- /*
Worker VMs live-migrate, so their disks need ReadWriteMany. The
RWX access mode is supplied by the chosen StorageClass's CDI
StorageProfile — it is not set on the DataVolume here. linstor-csi
rejects RWX volumes that are not on a DRBD-backed StorageClass, so
an empty nodeGroup storageClass falls back to the application-level
storageClass (a replicated/DRBD class) instead of the cluster
default, which may be a non-DRBD class that the CSI driver would
reject. Both empty still omits the field so the cluster default
applies.
*/}}
{{- with .group.storageClass | default $.Values.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
template:
metadata:
annotations:
kubevirt.io/allow-pod-bridge-network-live-migration: "true"
labels:
apps.cozystack.io/application.group: apps.cozystack.io
apps.cozystack.io/application.kind: Kubernetes
apps.cozystack.io/application.name: {{ $.Release.Name | trimPrefix "kubernetes-" | quote }}
{{- range .group.roles }}
node-role.kubernetes.io/{{ . }}: ""
{{- end }}
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ .groupName }}
spec:
{{- if .Values._cluster.scheduling }}
{{- $rawConstraints := get .Values._cluster.scheduling "globalAppTopologySpreadConstraints" }}
{{- if $rawConstraints }}
{{- $rawConstraints | fromYaml | toYaml | nindent 10 }}
labelSelector:
matchLabels:
cluster.x-k8s.io/cluster-name: {{ $.Release.Name }}
{{- end }}
{{- end }}
domain:
{{- if and .group.resources .group.resources.cpu }}
cpu:
threads: 1
cores: {{ .group.resources.cpu }}
sockets: 1
{{- end }}
devices:
{{- if .group.gpus }}
gpus:
{{- range $i, $gpu := .group.gpus }}
- name: gpu{{ add $i 1 }}
deviceName: {{ $gpu.name }}
{{- end }}
{{- end }}
disks:
- name: system
disk:
bus: virtio
pciAddress: 0000:07:00.0
blockSize:
custom:
logical: 512
physical: 4096
interfaces:
- name: default
bridge: {}
{{- if and .group.resources .group.resources.memory }}
memory:
guest: {{ .group.resources.memory }}
{{- end }}
resources:
{{/* 2Gi is enough: persistent DataVolumes are not counted as pod ephemeral storage */}}
{{- include "cozy-lib.resources.sanitize" (list (dict "ephemeral-storage" "2Gi") $) | nindent 14 }}
evictionStrategy: External
volumes:
- name: system
dataVolume:
name: disk-system
networks:
- name: default
pod: {}
{{- end }}
{{- if not $etcd }}
---
apiVersion: v1
@@ -508,397 +377,4 @@ metadata:
cluster.x-k8s.io/managed-by: kamaji
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
{{- range $groupName, $group := (include "kubernetes.nodeGroups" $ | fromYaml) }}
{{- /* instanceType and explicit resources are alternative sizing sources, and
explicit resources win. KubeVirt rejects a VirtualMachine that references
an instancetype while also overriding either domain.cpu or domain.memory,
so when a group is sized by resources (both cpu and memory set) the
instancetype is omitted from the VM (see the VM spec above) and the lookup
below is skipped — a stale instancetype name cannot fail an otherwise
valid, resource-sized group. Explicit resources are all-or-nothing: a
partial spec (only one of cpu/memory) would emit one domain override
alongside the instancetype, which KubeVirt rejects, so it fails here with
a clear render-time error instead of a late admission rejection. */}}
{{- $hasCpu := and $group.resources $group.resources.cpu }}
{{- $hasMem := and $group.resources $group.resources.memory }}
{{- if and $group.instanceType (or (and $hasCpu (not $hasMem)) (and $hasMem (not $hasCpu))) }}
{{- fail (printf "nodeGroup %s: set both resources.cpu and resources.memory, or neither" $groupName) }}
{{- end }}
{{/* Resolve instanceType once per group — reused for kubelet reservations and capacity annotations. */}}
{{- $instanceType := dict }}
{{- if and $group.instanceType (not (and $hasCpu $hasMem)) }}
{{- $instanceType = (lookup "instancetype.kubevirt.io/v1beta1" "VirtualMachineClusterInstancetype" "" $group.instanceType) }}
{{- if not $instanceType }}
{{- fail (printf "nodeGroup %s: specified instanceType %q not found in cluster" $groupName $group.instanceType) }}
{{- end }}
{{- end }}
{{/* Compute kubelet reservations: explicit resources.memory/cpu wins over instanceType spec.
Auto-computed system/kube reserved memory = 5% of effective memory, clamped to [256Mi, 1Gi].
Auto-computed system/kube reserved CPU = 5% of effective CPU, clamped to [50m, 500m]. */}}
{{- $effectiveMemory := "" }}
{{- if and $group.resources $group.resources.memory }}
{{- $effectiveMemory = $group.resources.memory | toString }}
{{- else if and $instanceType $instanceType.spec $instanceType.spec.memory $instanceType.spec.memory.guest }}
{{- $effectiveMemory = $instanceType.spec.memory.guest | toString }}
{{- end }}
{{- $effectiveCpu := "" }}
{{- if and $group.resources $group.resources.cpu }}
{{- $effectiveCpu = $group.resources.cpu | toString }}
{{- else if and $instanceType $instanceType.spec $instanceType.spec.cpu $instanceType.spec.cpu.guest }}
{{- $effectiveCpu = $instanceType.spec.cpu.guest | toString }}
{{- end }}
{{- $autoReservedMi := 256 }}
{{- if $effectiveMemory }}
{{- $effectiveMemMi := divf (include "cozy-lib.resources.toFloat" $effectiveMemory | float64) 1048576.0 | int }}
{{- $fivePercentMi := mulf ($effectiveMemMi | float64) 0.05 | int }}
{{- $autoReservedMi = min (max $fivePercentMi 256) 1024 }}
{{- end }}
{{- $autoReservedMillicores := 50 }}
{{- if $effectiveCpu }}
{{- $effectiveCpuMillicores := include "kubernetes.cpuToMillicores" $effectiveCpu | int }}
{{- $fivePercentMillis := mulf ($effectiveCpuMillicores | float64) 0.05 | int }}
{{- $autoReservedMillicores = min (max $fivePercentMillis 50) 500 }}
{{- end }}
{{- $kubeletOverride := $group.kubelet | default dict }}
{{- $systemReservedMemory := $kubeletOverride.systemReservedMemory | default (printf "%dMi" $autoReservedMi) }}
{{- $kubeReservedMemory := $kubeletOverride.kubeReservedMemory | default (printf "%dMi" $autoReservedMi) }}
{{- $systemReservedCpu := $kubeletOverride.systemReservedCpu | default (printf "%dm" $autoReservedMillicores) }}
{{- $kubeReservedCpu := $kubeletOverride.kubeReservedCpu | default (printf "%dm" $autoReservedMillicores) }}
{{- $evictionHardMemory := $kubeletOverride.evictionHardMemory | default "7%" }}
{{- $evictionSoftMemory := $kubeletOverride.evictionSoftMemory | default "10%" }}
{{/* Validate reservation field formats.
These guards check the USER-SUPPLIED value (from values.yaml via
$kubeletOverride), not the resolved/computed default — the default is
always valid by construction. The eviction threshold validation below
differs: it validates the RESOLVED values (user-supplied or default). */}}
{{- $validations := list
(dict "key" "systemReservedMemory" "value" $kubeletOverride.systemReservedMemory "pattern" `^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$` "hint" "a valid Kubernetes memory quantity (e.g. 128Mi, 1Gi, 1e9, 500e6)")
(dict "key" "kubeReservedMemory" "value" $kubeletOverride.kubeReservedMemory "pattern" `^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$` "hint" "a valid Kubernetes memory quantity (e.g. 128Mi, 1Gi, 1e9, 500e6)")
(dict "key" "systemReservedCpu" "value" $kubeletOverride.systemReservedCpu "pattern" `^([0-9]+m|[0-9]+(\.[0-9]+)?)$` "hint" "a valid Kubernetes CPU quantity (integer millicores like 100m, or decimal cores like 0.5, 1)")
(dict "key" "kubeReservedCpu" "value" $kubeletOverride.kubeReservedCpu "pattern" `^([0-9]+m|[0-9]+(\.[0-9]+)?)$` "hint" "a valid Kubernetes CPU quantity (integer millicores like 100m, or decimal cores like 0.5, 1)")
}}
{{- range $validations }}
{{- if and .value (not (regexMatch .pattern (.value | toString))) }}
{{- fail (printf "nodeGroup %s: invalid %s value %q — must be %s" $groupName .key (.value | toString) .hint) }}
{{- end }}
{{- end }}
{{/* Validate that user-supplied reservation values are positive (non-zero) */}}
{{- range $_, $entry := list (dict "key" "systemReservedMemory" "value" $kubeletOverride.systemReservedMemory) (dict "key" "kubeReservedMemory" "value" $kubeletOverride.kubeReservedMemory) }}
{{- if $entry.value }}
{{- $bytes := include "cozy-lib.resources.toFloat" ($entry.value | toString) | float64 }}
{{- if le $bytes 0.0 }}
{{- fail (printf "nodeGroup %s: %s must be a positive value, got %s" $groupName $entry.key ($entry.value | toString)) }}
{{- end }}
{{- end }}
{{- end }}
{{- range $_, $entry := list (dict "key" "systemReservedCpu" "value" $kubeletOverride.systemReservedCpu) (dict "key" "kubeReservedCpu" "value" $kubeletOverride.kubeReservedCpu) }}
{{- if $entry.value }}
{{- $cpuMillis := include "kubernetes.cpuToMillicores" $entry.value | float64 }}
{{- if le $cpuMillis 0.0 }}
{{- fail (printf "nodeGroup %s: %s must be a positive value, got %s" $groupName $entry.key ($entry.value | toString)) }}
{{- end }}
{{- end }}
{{- end }}
{{/* Validate absolute eviction threshold formats before converting */}}
{{- if not (hasSuffix "%" $evictionHardMemory) }}
{{- if not (regexMatch `^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$` $evictionHardMemory) }}
{{- fail (printf "nodeGroup %s: invalid evictionHardMemory value %q — must be a percentage (e.g. 7%%) or a valid Kubernetes memory quantity (e.g. 500Mi, 1Gi, 1e9)" $groupName $evictionHardMemory) }}
{{- end }}
{{- end }}
{{- if not (hasSuffix "%" $evictionSoftMemory) }}
{{- if not (regexMatch `^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$` $evictionSoftMemory) }}
{{- fail (printf "nodeGroup %s: invalid evictionSoftMemory value %q — must be a percentage (e.g. 10%%) or a valid Kubernetes memory quantity (e.g. 500Mi, 1Gi, 1e9)" $groupName $evictionSoftMemory) }}
{{- end }}
{{- end }}
{{- if and (hasSuffix "%" $evictionHardMemory) (hasSuffix "%" $evictionSoftMemory) }}
{{- $hardPctStr := trimSuffix "%" $evictionHardMemory }}
{{- $softPctStr := trimSuffix "%" $evictionSoftMemory }}
{{- if not (regexMatch `^[0-9]+(\.[0-9]+)?$` $hardPctStr) }}
{{- fail (printf "nodeGroup %s: evictionHardMemory percentage value %q is not a valid number" $groupName $hardPctStr) }}
{{- end }}
{{- if not (regexMatch `^[0-9]+(\.[0-9]+)?$` $softPctStr) }}
{{- fail (printf "nodeGroup %s: evictionSoftMemory percentage value %q is not a valid number" $groupName $softPctStr) }}
{{- end }}
{{- $hardPct := $hardPctStr | float64 }}
{{- $softPct := $softPctStr | float64 }}
{{- if le $hardPct 0.0 }}
{{- fail (printf "nodeGroup %s: evictionHardMemory percentage must be greater than 0 and at most 100, got %s%%" $groupName $hardPctStr) }}
{{- end }}
{{- if gt $hardPct 100.0 }}
{{- fail (printf "nodeGroup %s: evictionHardMemory percentage must be greater than 0 and at most 100, got %s%%" $groupName $hardPctStr) }}
{{- end }}
{{- if le $softPct 0.0 }}
{{- fail (printf "nodeGroup %s: evictionSoftMemory percentage must be greater than 0 and at most 100, got %s%%" $groupName $softPctStr) }}
{{- end }}
{{- if gt $softPct 100.0 }}
{{- fail (printf "nodeGroup %s: evictionSoftMemory percentage must be greater than 0 and at most 100, got %s%%" $groupName $softPctStr) }}
{{- end }}
{{- if ge $hardPct $softPct }}
{{- fail (printf "nodeGroup %s: evictionHardMemory (%s) must be strictly less than evictionSoftMemory (%s)" $groupName $evictionHardMemory $evictionSoftMemory) }}
{{- end }}
{{- else if and (not (hasSuffix "%" $evictionHardMemory)) (not (hasSuffix "%" $evictionSoftMemory)) }}
{{- $hardBytes := include "cozy-lib.resources.toFloat" $evictionHardMemory | float64 }}
{{- $softBytes := include "cozy-lib.resources.toFloat" $evictionSoftMemory | float64 }}
{{- if ge $hardBytes $softBytes }}
{{- fail (printf "nodeGroup %s: evictionHardMemory (%s) must be strictly less than evictionSoftMemory (%s)" $groupName $evictionHardMemory $evictionSoftMemory) }}
{{- end }}
{{- else }}
{{- fail (printf "nodeGroup %s: evictionHardMemory (%s) and evictionSoftMemory (%s) must use the same unit type (both percentage or both absolute)" $groupName $evictionHardMemory $evictionSoftMemory) }}
{{- end }}
{{/* Resolve eviction hard threshold to bytes for arithmetic.
Percentage is relative to effective node memory; absolute values are used as-is. */}}
{{- $evictionHardBytes := 0.0 }}
{{- if $effectiveMemory }}
{{- if hasSuffix "%" $evictionHardMemory }}
{{- $evictionPct := trimSuffix "%" $evictionHardMemory | float64 }}
{{- $evictionHardBytes = mulf (include "cozy-lib.resources.toFloat" $effectiveMemory | float64) (divf $evictionPct 100.0) }}
{{- else }}
{{- $evictionHardBytes = include "cozy-lib.resources.toFloat" $evictionHardMemory | float64 }}
{{- end }}
{{- end }}
{{/* Validate that total reserved memory (system + kube + eviction) does not exceed effective node memory */}}
{{- if $effectiveMemory }}
{{- $sysBytes := include "cozy-lib.resources.toFloat" $systemReservedMemory | float64 }}
{{- $kubeBytes := include "cozy-lib.resources.toFloat" $kubeReservedMemory | float64 }}
{{- $totalReservedBytes := addf (addf $sysBytes $kubeBytes) $evictionHardBytes }}
{{- $effectiveMemBytes := include "cozy-lib.resources.toFloat" $effectiveMemory | float64 }}
{{- if ge $totalReservedBytes $effectiveMemBytes }}
{{- fail (printf "nodeGroup %s: total reserved memory (systemReservedMemory=%s + kubeReservedMemory=%s + evictionHardMemory=%s) must be less than effective node memory (%s)" $groupName $systemReservedMemory $kubeReservedMemory $evictionHardMemory $effectiveMemory) }}
{{- end }}
{{- end }}
{{/* Validate that total reserved CPU does not exceed effective node CPU */}}
{{- if $effectiveCpu }}
{{- $sysCpuMillis := include "kubernetes.cpuToMillicores" $systemReservedCpu | float64 }}
{{- $kubeCpuMillis := include "kubernetes.cpuToMillicores" $kubeReservedCpu | float64 }}
{{- $totalCpuMillis := addf $sysCpuMillis $kubeCpuMillis }}
{{- $effectiveCpuMillicoresV := include "kubernetes.cpuToMillicores" $effectiveCpu | int }}
{{- $effectiveCpuMillis := float64 $effectiveCpuMillicoresV }}
{{- if ge $totalCpuMillis $effectiveCpuMillis }}
{{- fail (printf "nodeGroup %s: total reserved CPU (systemReservedCpu=%s + kubeReservedCpu=%s) must be less than effective node CPU (%s cores)" $groupName $systemReservedCpu $kubeReservedCpu $effectiveCpu) }}
{{- end }}
{{- end }}
{{/* Guard: explicit kubelet overrides require effective memory for validation */}}
{{- if not $effectiveMemory }}
{{- $hasOverride := or $kubeletOverride.systemReservedMemory $kubeletOverride.kubeReservedMemory $kubeletOverride.evictionHardMemory $kubeletOverride.evictionSoftMemory }}
{{- if $hasOverride }}
{{- fail (printf "nodeGroup %s: kubelet memory reservations require effective memory to validate — set resources.memory or instanceType" $groupName) }}
{{- end }}
{{- end }}
{{/* Guard: explicit kubelet CPU overrides require effective CPU for validation */}}
{{- if not $effectiveCpu }}
{{- $hasCpuOverride := or $kubeletOverride.systemReservedCpu $kubeletOverride.kubeReservedCpu }}
{{- if $hasCpuOverride }}
{{- fail (printf "nodeGroup %s: cannot validate kubelet CPU reservations without effective CPU — set resources.cpu or a valid instanceType" $groupName) }}
{{- end }}
{{- end }}
---
{{- /* TalosConfigTemplate is no longer rendered here — the chart cannot
produce a valid Talos worker machineconfig at Helm template time
because three of the inputs (apiserver Service ClusterIP, Talos
CA from cert-manager, Kamaji-issued tenant CA) are created
asynchronously after Helm applies this manifest. The main-phase
Job in templates/talos/talos-reconcile-job.yaml waits for those
inputs and produces the TalosConfigTemplate with a
KamajiControlPlane ownerReference for natural GC. That Job runs in
the main install phase, so it applies the template as soon as the
inputs appear — in parallel with the rest of the install. The
MachineDeployment below still references the TCT name; CAPI's
Machine controller blocks on its absence and unblocks the moment
the Job applies it. */}}
---
{{- $context := deepCopy $ }}
{{- $_ := set $context "group" $group }}
{{- $_ := set $context "groupName" $groupName }}
{{- $kubevirtmachinetemplate := include "kubevirtmachinetemplate" $context }}
{{- $kubevirtmachinetemplateHash := $kubevirtmachinetemplate | sha256sum | trunc 6 }}
{{- $kubevirtmachinetemplateName := printf "%s-%s-%s" $.Release.Name $groupName $kubevirtmachinetemplateHash }}
{{- $kubevirtmachinetemplateNames = append $kubevirtmachinetemplateNames $kubevirtmachinetemplateName }}
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: KubevirtMachineTemplate
metadata:
name: {{ $.Release.Name }}-{{ $groupName }}-{{ $kubevirtmachinetemplateHash }}
namespace: {{ $.Release.Namespace }}
spec:
template:
{{- $kubevirtmachinetemplate | nindent 4 }}
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment
metadata:
name: {{ $.Release.Name }}-{{ $groupName }}
namespace: {{ $.Release.Namespace }}
annotations:
cluster.x-k8s.io/cluster-api-autoscaler-node-group-min-size: "{{ $group.minReplicas }}"
cluster.x-k8s.io/cluster-api-autoscaler-node-group-max-size: "{{ $group.maxReplicas }}"
{{- if $effectiveMemory }}
{{- $sysBytes := include "cozy-lib.resources.toFloat" $systemReservedMemory | float64 }}
{{- $kubeBytes := include "cozy-lib.resources.toFloat" $kubeReservedMemory | float64 }}
{{- $totalReservedBytes := addf (addf $sysBytes $kubeBytes) $evictionHardBytes }}
{{- $allocatableBytes := subf (include "cozy-lib.resources.toFloat" $effectiveMemory | float64) $totalReservedBytes }}
{{- $allocatableMi := divf $allocatableBytes 1048576.0 | floor }}
capacity.cluster-autoscaler.kubernetes.io/memory: "{{ $allocatableMi }}Mi"
{{- end }}
{{- if $effectiveCpu }}
{{- $sysCpuMillisA := include "kubernetes.cpuToMillicores" $systemReservedCpu | float64 }}
{{- $kubeCpuMillisA := include "kubernetes.cpuToMillicores" $kubeReservedCpu | float64 }}
{{- $effectiveCpuMillicoresA := include "kubernetes.cpuToMillicores" $effectiveCpu | int }}
{{- $effectiveCpuMillisA := float64 $effectiveCpuMillicoresA }}
{{- $allocatableCpuMillis := subf $effectiveCpuMillisA (addf $sysCpuMillisA $kubeCpuMillisA) }}
{{- $allocatableCpuMillisInt := $allocatableCpuMillis | ceil | int }}
capacity.cluster-autoscaler.kubernetes.io/cpu: "{{ $allocatableCpuMillisInt }}m"
{{- end }}
spec:
clusterName: {{ $.Release.Name }}
# `spec.replicas` is intentionally omitted so the field is owned by
# the cluster-autoscaler alone. Two independent CAPI mechanisms —
# not Server-Side-Apply field managers — carry the guarantee:
# 1. Create: the MachineDeployment defaulting webhook seeds
# `spec.replicas` from the `cluster-api-autoscaler-node-group-
# min-size` annotation (see
# https://cluster-api.sigs.k8s.io/tasks/automated-machine-management/autoscaling).
# 2. Update: `calculateMachineDeploymentReplicas` preserves the
# existing live value (`oldMD.Spec.Replicas`) whenever the
# incoming spec has `replicas: nil`, clamping into
# `[min, max]` if it drifts outside — so a cluster the
# autoscaler holds above min-size is not drained by a
# `helm upgrade` that would otherwise re-render this field.
# Flux helm-controller applies this chart through the Helm SDK
# three-way merge (not SSA), so field-manager ownership is not the
# mechanism at play. The chart guarantee reduces to "leave the
# field out of the rendered manifest and let CAPI's update-time
# defaulting keep the live value stable". Emitting a chart-managed
# `replicas: N` here (as this chart did before
# cozystack/cozystack#3231) drains workers on every `helm upgrade`
# action until the autoscaler notices unschedulable Pods and
# scales back up — a real disruption window on every routine
# platform bump.
strategy:
rollingUpdate:
maxSurge: {{ $group.maxReplicas }}
maxUnavailable: 1
type: RollingUpdate
selector:
matchLabels:
cluster.x-k8s.io/cluster-name: {{ $.Release.Name }}
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ $groupName }}
template:
metadata:
labels:
cluster.x-k8s.io/cluster-name: {{ $.Release.Name }}
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ $groupName }}
{{- range $group.roles }}
node-role.kubernetes.io/{{ . }}: ""
{{- end }}
spec:
bootstrap:
configRef:
apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3
kind: TalosConfigTemplate
name: {{ $.Release.Name }}-{{ $groupName }}
namespace: {{ $.Release.Namespace }}
clusterName: {{ $.Release.Name }}
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: KubevirtMachineTemplate
name: {{ $.Release.Name }}-{{ $groupName }}-{{ $kubevirtmachinetemplateHash }}
namespace: {{ $.Release.Namespace }}
version: {{ include "kubernetes.versionMap" $}}
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineHealthCheck
metadata:
name: {{ $.Release.Name }}-{{ $groupName }}
namespace: {{ $.Release.Namespace }}
spec:
clusterName: {{ $.Release.Name }}
{{- /* MHC spec.maxUnhealthy is an IntOrString. CAPI admission accepts a
bare integer (0, 1, ...) or a percentage string ("0%", "50%"), but
rejects a quoted plain integer ("0"). Quote only when the operator
supplied a percentage; otherwise render the value as a bare int so
the admission webhook is happy with the chart-default "0". A node
group may override the cluster-wide nodeHealthCheck via
nodeGroups.<name>.maxUnhealthy / .nodeStartupTimeout. */}}
{{- $maxUnhealthy := toString $.Values.nodeHealthCheck.maxUnhealthy }}
{{- $muSource := "nodeHealthCheck.maxUnhealthy" }}
{{- if hasKey $group "maxUnhealthy" }}
{{- $maxUnhealthy = toString $group.maxUnhealthy }}
{{- $muSource = printf "nodeGroups.%s.maxUnhealthy" $groupName }}
{{- end }}
{{- if hasSuffix "%" $maxUnhealthy }}
{{- if not (regexMatch "^[0-9]+%$" $maxUnhealthy) }}
{{- fail (printf "%s must be a bare integer (e.g. 0, 1) or an integer percentage (e.g. 50%%), got %q" $muSource $maxUnhealthy) }}
{{- end }}
maxUnhealthy: {{ $maxUnhealthy | quote }}
{{- else if regexMatch "^[0-9]+$" $maxUnhealthy }}
maxUnhealthy: {{ $maxUnhealthy | int }}
{{- else }}
{{- fail (printf "%s must be a bare integer (e.g. 0, 1) or an integer percentage (e.g. 50%%), got %q" $muSource $maxUnhealthy) }}
{{- end }}
{{- $nodeStartupTimeout := $.Values.nodeHealthCheck.nodeStartupTimeout }}
{{- if hasKey $group "nodeStartupTimeout" }}{{ $nodeStartupTimeout = $group.nodeStartupTimeout }}{{ end }}
nodeStartupTimeout: {{ $nodeStartupTimeout }}
selector:
matchLabels:
cluster.x-k8s.io/cluster-name: {{ $.Release.Name }}
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ $groupName }}
unhealthyConditions:
- type: Ready
status: Unknown
timeout: 30s
- type: Ready
status: "False"
timeout: 300s
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}-{{ $groupName }}
namespace: {{ $.Release.Namespace }}
spec:
minReplicas: {{ $group.minReplicas }}
kind: kubernetes
type: worker
selector:
cluster.x-k8s.io/cluster-name: {{ $.Release.Name }}
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ $groupName }}
cluster.x-k8s.io/role: worker
version: {{ $.Chart.Version }}
{{- end }}
---
{{- /*
We must preserve all previous KubevirtMachineTemplates until a MachineSet references them.
*/ -}}
{{- $mss := (lookup "cluster.x-k8s.io/v1beta1" "MachineSet" $.Release.Namespace "").items }}
{{- $oldKubevirtmachinetemplates := dict }}
{{- range $kmt := (lookup "infrastructure.cluster.x-k8s.io/v1alpha1" "KubevirtMachineTemplate" .Release.Namespace "").items }}
{{- range $or := $kmt.metadata.ownerReferences }}
{{- /* Only re-emit KubevirtMachineTemplates that belong to THIS release. The
Cluster ownerReference alone is not enough: worker pools split out into
kubernetes-nodes releases share the same CAPI Cluster ownerReference, so
without the release-name scoping this block would re-emit a pool's KMT
(annotated meta.helm.sh/release-name=kubernetes-nodes-<cluster>-<pool>)
and the next upgrade of this release would fail Helm ownership
validation. */}}
{{- if and (eq $or.kind "Cluster") (eq $or.name $.Release.Name) (eq (dig "annotations" "meta.helm.sh/release-name" "" $kmt.metadata) $.Release.Name) }}
{{- range $ms := $mss }}
{{- if and (eq $ms.spec.template.spec.infrastructureRef.kind "KubevirtMachineTemplate") (eq $ms.spec.template.spec.infrastructureRef.name $kmt.metadata.name) }}
{{- if not (has $kmt.metadata.name $kubevirtmachinetemplateNames) }}
{{- $oldKubevirtmachinetemplates = merge $oldKubevirtmachinetemplates (dict $kmt.metadata.name $kmt) }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- range $oldKubevirtmachinetemplates }}
---
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: KubevirtMachineTemplate
metadata:
name: {{ .metadata.name }}
namespace: {{ .metadata.Namespace }}
spec:
{{- .spec | toYaml | nindent 2 }}
{{- end }}
{{- end }}
@@ -30,9 +30,6 @@ rules:
- workloadmonitors
resourceNames:
- {{ .Release.Name }}
{{- range $groupName, $group := (include "kubernetes.nodeGroups" $ | fromYaml) }}
- {{ $.Release.Name }}-{{ $groupName }}
{{- end }}
verbs: ["get", "list", "watch"]
---
kind: RoleBinding
@@ -1,570 +0,0 @@
{{- /*
Main-phase Job (NOT a Helm hook) that resolves the chart's
lookup-and-reuse race for TalosConfigTemplate and
KamajiControlPlane.spec.network.certSANs.
Background. The Talos worker machineconfig and the apiserver Service
ClusterIP-SAN both need values that only exist AFTER Helm has applied
the first render: the Service is emitted by the Kamaji controller in
reaction to the KamajiControlPlane CR, the Talos-CA TLS Secret is
issued by cert-manager from the Talos PKI Certificate CR, and the
Kubernetes-CA Secret is generated by Kamaji as part of its tenant
control-plane bootstrap. A Helm template that uses `lookup` to read
any of these into the rendered manifest fails the race on every fresh
install — the lookup returns nil, the gated TalosConfigTemplate is
skipped, and Helm's release-storage three-way merge concludes the
release is in-sync on every subsequent reconcile because no chart
value changed. The result is a tenant whose KamajiControlPlane is
Ready but whose CAPI MachineDeployment is stuck on
`cannot create a new MachineSet when templates do not exist` forever.
This Job blocks until the four dependencies are observably present,
then produces both objects via `kubectl apply` (idempotent). The
TalosConfigTemplate carries an ownerReference to the KamajiControlPlane
so Kubernetes garbage-collects it through the standard owner-reference
cascade when the chart's release is uninstalled — Helm itself does not
need to track the resource, and a `pre-delete` hook is not required.
The certSANs patch is a merge against the KCP `spec.network.certSANs`
field; the Kamaji controller picks up the change and re-issues the
apiserver TLS cert with the live ClusterIP in its SAN list.
Why main phase, not a post-install hook. This Job is the only thing
that creates the TalosConfigTemplate (the chart no longer renders it —
see cluster.yaml). As a post-install hook it runs only AFTER Helm has
applied the release's main resources and, when Helm waits, after they
become Ready. But the same release also emits in-tenant addon
HelmReleases — notably cilium — whose readiness needs tenant worker
nodes, and those nodes can only be created once this Job has produced
the TalosConfigTemplate the worker MachineDeployment clones from. So
when the main-resource wait is in effect it DEADLOCKS: the install
waits for cilium to be Ready, cilium waits for worker nodes, the nodes
wait for the TalosConfigTemplate, and the template waits for this hook
— which never runs, because the install never finishes the wait that
precedes the post-install hook phase. The install times out and the
MachineDeployment never scales. (Observed on a failing run: the parent
HelmRelease stuck at `Running install action 20m`, NEITHER post-install
hook ever created, the cilium sub-release timing out on cilium-operator
— all while the four inputs were ready early. The hook never reached;
the inputs were not late.)
The release sets `helm-install-disable-wait` precisely to skip that
main-resource wait so the hooks run, but its effect is unreliable —
which is what makes the failure bimodal: when the wait is skipped the
hook runs and the template lands in ~46s; when it is not, the deadlock
above strands the install. Rendering this Job as an ordinary MAIN-phase
resource removes the dependency on both the hook phase and DisableWait.
The Job is created during the apply itself (not after the wait) and
runs on the management cluster — which has nodes, unlike the tenant
cilium-operator — so it produces the TalosConfigTemplate, the
MachineDeployment scales, workers join, cilium goes Ready, and the
main-resource wait (if any) closes within the install budget: the Job
completes in tens of seconds, node boot plus cilium readiness is a few
minutes. It works whether or not DisableWait takes effect. The Job's
RBAC, ServiceAccount and egress CiliumNetworkPolicy are main-phase too;
the wait loop tolerates its dependencies (and its own network policy)
not yet being in place, so Helm's kind-ordered apply needs no explicit
sequencing. The bootstrap-token Job stays a post-install hook.
Churn-free across upgrades. The Job is immutable (spec.template cannot
be patched), so it carries a content-hash name suffix — mirrors the
KubevirtMachineTemplate idiom in cluster.yaml. An identical render
yields the same name and Helm no-ops a Job it can never patch; any
change to a field that flows into the rendered Job spec (and therefore
into the TalosConfigTemplate it applies) yields a new name, so a fresh
Job re-applies the updated template and Helm prunes the superseded one.
Because the hash is taken over the whole rendered spec, no spec change
can ever land on the same name and trigger an immutable-field patch
error. The applied TalosConfigTemplate keeps a stable name
(<release>-<group>, referenced by MachineDeployment.bootstrap.configRef)
regardless of the Job's hash, so an unchanged render re-applies a
byte-identical TCT — a CAPI no-op that never rolls existing workers.
*/}}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: ["services"]
resourceNames: ["{{ .Release.Name }}"]
verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames:
- "{{ .Release.Name }}-talos-secrets"
- "{{ .Release.Name }}-talos-ca"
- "{{ .Release.Name }}-ca"
verbs: ["get"]
- apiGroups: ["controlplane.cluster.x-k8s.io"]
resources: ["kamajicontrolplanes"]
resourceNames: ["{{ .Release.Name }}"]
verbs: ["get", "patch"]
- apiGroups: ["bootstrap.cluster.x-k8s.io"]
resources: ["talosconfigtemplates"]
verbs: ["get", "create", "patch", "update"]
---
{{- /* The tenant-root chart installs a default-deny egress policy on every
pod in the tenant namespace; without an explicit allow, this Job
cannot reach the management-cluster apiserver to read the Service
ClusterIP / Secrets / KamajiControlPlane it has to reconcile. Cilium
policies are additive, so this rule layers on top of the tenant
default-deny without weakening it for anything else. Endpoint
selector is scoped to pods carrying the reconcile label only. */}}
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
spec:
endpointSelector:
matchLabels:
cozystack.io/talos-reconcile: "{{ .Release.Name }}"
egress:
- toEntities:
- kube-apiserver
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
"k8s:k8s-app": kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ .Release.Name }}-talos-reconcile
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
---
{{- /* Cluster-scoped read on the management CoreDNS Service so the Job
can look up its ClusterIP at runtime and inject it into the worker
machineconfig as a host-level nameserver. Tenant workers run as
KubeVirt VMs inside the management pod network; tenant in-cluster
pods still use kubelet --cluster-dns (tenant CoreDNS) via
dnsPolicy: ClusterFirst, but the worker host itself (kubelet's
mount syscall path, image pulls) consults /etc/resolv.conf which
Talos populates from machine.network.nameservers. Without
management CoreDNS as host resolver, mounting NFS from
kubevirt-csi (linstor-csi-nfs.cozy-linstor.svc) fails with
NXDOMAIN.
Ubuntu+kubeadm workers in main inherit pod DNS via cloud-init
and got management CoreDNS for free; Talos reads only what is
in machineconfig, so the chart has to plumb it explicitly.
resourceNames pins the grant to the single well-known Service
name kube-dns — cluster-wide get on services scoped to that
single name is functionally a single-resource permission. */}}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Namespace }}-{{ .Release.Name }}-talos-reconcile-coredns
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: ["services"]
resourceNames: ["kube-dns"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Namespace }}-{{ .Release.Name }}-talos-reconcile-coredns
labels:
{{- include "kubernetes.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ .Release.Namespace }}-{{ .Release.Name }}-talos-reconcile-coredns
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-talos-reconcile
namespace: {{ .Release.Namespace }}
{{- /* Job spec rendered through a named template so the Job name can carry
a content hash of the whole spec (see the header comment). The dot
passed in is a deepCopy of the root context with the per-nodeGroup
computed values set on it, so $.Release / $.Values resolve as usual
and the loop/computed locals are read as .group, .groupName, etc. */}}
{{- define "kubernetes.talos-reconcile-job-spec" -}}
spec:
{{- /* Each pod attempt runs the ~10-minute (120 × 5s) input-wait loop, so this
backoffLimit sets the self-heal window to roughly 5 hours. The Job does
not block the install — when disable-wait is in effect Helm does not wait
on it, and when Helm does wait the Job completes in tens of seconds — so a
long retry budget costs nothing on the install path. It only governs how
long a Job whose inputs are unusually slow keeps retrying on its own
before giving up. Bounded (not infinite) so a permanently-broken input
eventually stops spawning pods. */}}
backoffLimit: 30
{{- /* ttlSecondsAfterFinished clears the Job (Complete or Failed) shortly after
it finishes, so a later reconcile can recreate and retry it. Without the
TTL a Failed Job would keep the same content-hash name and be a no-op on
every later upgrade — Helm never patches the immutable Job, so it would
stay Failed until deleted by hand. No churn from the TTL: the repo runs no
Flux drift detection, so an interval reconcile never re-applies a cleared
Job; only an actual upgrade does, and that re-applies the byte-identical
TalosConfigTemplate (a CAPI no-op). Mirrors the original hook Job's value. */}}
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
cozystack.io/talos-reconcile: {{ $.Release.Name | quote }}
spec:
restartPolicy: OnFailure
serviceAccountName: {{ $.Release.Name }}-talos-reconcile
containers:
- name: kubectl
image: "{{ .kubectlImage }}"
env:
- name: RELEASE
value: {{ $.Release.Name | quote }}
- name: NS
value: {{ $.Release.Namespace | quote }}
- name: GROUP_NAME
value: {{ .groupName | quote }}
- name: TALOS_TOKEN
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-talos-secrets
key: token
- name: CLUSTER_ID
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-talos-secrets
key: clusterId
- name: CLUSTER_SECRET
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-talos-secrets
key: clusterSecret
- name: BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-talos-secrets
key: bootstrapToken
command:
- sh
- -ec
- |
echo "[$(date -u +%H:%M:%S)] talos-reconcile/${GROUP_NAME}: waiting for Service ClusterIP, Talos CA, K8s CA, management CoreDNS"
for i in $(seq 1 120); do
SVC_IP=$(kubectl -n "$NS" get svc "$RELEASE" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)
TALOS_CA_B64=$(kubectl -n "$NS" get secret "${RELEASE}-talos-ca" -o jsonpath='{.data.tls\.crt}' 2>/dev/null || true)
K8S_CA_B64=$(kubectl -n "$NS" get secret "${RELEASE}-ca" -o jsonpath='{.data.ca\.crt}' 2>/dev/null || true)
COREDNS_IP=$(kubectl -n kube-system get svc kube-dns -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)
if [ -n "$SVC_IP" ] && [ -n "$TALOS_CA_B64" ] && [ -n "$K8S_CA_B64" ] && [ -n "$COREDNS_IP" ]; then
break
fi
sleep 5
done
if [ -z "$SVC_IP" ] || [ -z "$TALOS_CA_B64" ] || [ -z "$K8S_CA_B64" ] || [ -z "$COREDNS_IP" ]; then
echo "[$(date -u +%H:%M:%S)] timed out waiting for lookup inputs (svc='$SVC_IP' talos-ca=$([ -n "$TALOS_CA_B64" ] && echo ok) k8s-ca=$([ -n "$K8S_CA_B64" ] && echo ok) coredns='$COREDNS_IP')"
exit 1
fi
echo "[$(date -u +%H:%M:%S)] inputs ready (svc=${SVC_IP} coredns=${COREDNS_IP})"
# Patch KamajiControlPlane.spec.network.certSANs so the apiserver
# TLS cert covers the live Service ClusterIP that Talos workers
# dial. JSON-merge-patch is array-replace semantics, which is what
# we want here — Kamaji owns the full SAN list.
kubectl -n "$NS" patch kamajicontrolplane "$RELEASE" --type=merge --patch "$(
cat <<JSON
{"spec":{"network":{"certSANs":["${RELEASE}.${NS}.svc","${RELEASE}.${NS}.svc.{{ .dnsDomain }}","${SVC_IP}"]}}}
JSON
)"
# Talos workers dial the apiserver Service via the DNS name
# (${RELEASE}.${NS}.svc) instead of the live ClusterIP, with
# machine.network.extraHostEntries injecting the IP into the
# worker's /etc/hosts. This avoids a TLS race against the
# csr-signer sidecar: the Talos PKI Certificate is issued
# with DNS SANs at chart render time and never needs to be
# patched after the Service ClusterIP becomes known. The
# apiserver Kubernetes TLS cert (handled by KCP certSANs above)
# auto-reloads on Kamaji reissue, but the csr-signer container
# loads its TLS cert once at startup and does not watch the
# file — so any in-place cert rotation leaves it serving the
# original (no-IP-SAN) cert until the Kamaji pod restarts.
# Routing through DNS sidesteps the whole problem.
KCP_UID=$(kubectl -n "$NS" get kamajicontrolplane "$RELEASE" -o jsonpath='{.metadata.uid}')
# Apply the TalosConfigTemplate. ownerReference back to the KCP
# makes the standard Kubernetes garbage collector clean it up on
# release uninstall; Helm itself does not track this resource.
cat <<EOF | kubectl apply -f -
apiVersion: bootstrap.cluster.x-k8s.io/v1alpha3
kind: TalosConfigTemplate
metadata:
name: ${RELEASE}-${GROUP_NAME}
namespace: ${NS}
ownerReferences:
- apiVersion: controlplane.cluster.x-k8s.io/v1alpha1
kind: KamajiControlPlane
name: ${RELEASE}
uid: ${KCP_UID}
controller: true
blockOwnerDeletion: true
spec:
template:
spec:
generateType: none
talosVersion: "{{ .talosVersion }}"
data: |
version: v1alpha1
persist: true
machine:
type: worker
token: ${TALOS_TOKEN}
network:
# Management CoreDNS as the worker's host
# resolver, plus management cluster search
# domains so partial names like
# "linstor-csi-nfs.cozy-linstor.svc" resolve
# the same way they do on Ubuntu+kubeadm
# workers in main (which got these via
# cloud-init/DHCP). Without searchDomains the
# kubevirt-csi NFS mount fails NXDOMAIN
# because mount.nfs hands the partial name
# to the resolver verbatim and management
# CoreDNS only knows full FQDNs.
#
# Tenant in-cluster pods are not affected:
# dnsPolicy: ClusterFirst routes them through
# kubelet --cluster-dns (tenant CoreDNS).
# This block only configures host-side
# resolution (kubelet mount syscalls, image
# pulls, pods opted out of cluster DNS).
nameservers:
- ${COREDNS_IP}
searchDomains:
- svc.{{ .mgmtClusterDomain }}
- {{ .mgmtClusterDomain }}
{{- if ne .mgmtClusterDomain "cluster.local" }}
- svc.cluster.local
- cluster.local
{{- end }}
extraHostEntries:
- ip: ${SVC_IP}
aliases:
- ${RELEASE}.${NS}.svc
- ${RELEASE}.${NS}.svc.{{ .dnsDomain }}
ca:
crt: ${TALOS_CA_B64}
{{- if .group.gpus }}
# GPU node-groups: label every node `gpu=on` so
# HAMi's hami-device-plugin DaemonSet (nodeSelector
# gpu=on) schedules and advertises nvidia.com/gpu.
# Without the label the plugin stays at DESIRED=0
# and no GPUs are exposed to the tenant. Mirrors
# main's kubeadm config (kubeletExtraArgs node-
# labels: "gpu=on") so HAMi behaviour is identical
# before and after the Talos worker rollover.
nodeLabels:
gpu: "on"
{{- end }}
kubelet:
image: ghcr.io/siderolabs/kubelet:{{ .kubeletVersion }}
extraConfig:
systemReserved:
cpu: "{{ .systemReservedCpu }}"
memory: "{{ .systemReservedMemory }}"
kubeReserved:
cpu: "{{ .kubeReservedCpu }}"
memory: "{{ .kubeReservedMemory }}"
evictionHard:
memory.available: "{{ .evictionHardMemory }}"
nodefs.available: "10%"
imagefs.available: "15%"
nodefs.inodesFree: "5%"
evictionSoft:
memory.available: "{{ .evictionSoftMemory }}"
nodefs.available: "15%"
imagefs.available: "20%"
evictionSoftGracePeriod:
memory.available: "1m30s"
nodefs.available: "1m30s"
imagefs.available: "1m30s"
evictionMinimumReclaim:
memory.available: "256Mi"
install:
disk: /dev/vda
image: {{ $.Values.talos.installerRepository | trimSuffix "/" }}/{{ $.Values.talos.schematicID }}:{{ .talosVersion }}
wipe: false
features:
rbac: true
kubePrism:
enabled: false
cluster:
id: ${CLUSTER_ID}
secret: ${CLUSTER_SECRET}
controlPlane:
endpoint: https://${RELEASE}.${NS}.svc:6443
clusterName: ${RELEASE}
network:
dnsDomain: {{ .dnsDomain }}
podSubnets:
- {{ .podCIDR }}
serviceSubnets:
- {{ .serviceCIDR }}
token: ${BOOTSTRAP_TOKEN}
ca:
crt: ${K8S_CA_B64}
discovery:
enabled: true
registries:
kubernetes:
disabled: true
service:
disabled: true
EOF
echo "[$(date -u +%H:%M:%S)] TalosConfigTemplate ${RELEASE}-${GROUP_NAME} applied"
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 100m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
{{- end }}
{{- $kubeletVersion := include "kubernetes.versionMap" $ | trim }}
{{- $talosVersion := $.Values.talos.version }}
{{- $podCIDR := "10.243.0.0/16" }}
{{- $serviceCIDR := "10.95.0.0/16" }}
{{- $dnsDomain := include "kubernetes.tenantClusterDomain" $ }}
{{- /* Management cluster DNS domain. Tenant Kubernetes workers run as
KubeVirt VMs inside the management cluster's pod network and need
to resolve management-cluster service short names ("<svc>.<ns>.svc")
the same way an Ubuntu+kubeadm worker did via cloud-init DHCP. The
kubevirt-csi NFS mount path is the load-bearing case: kubelet on
the worker host calls `mount.nfs linstor-csi-nfs.cozy-linstor.svc`
(no domain suffix), and without searchDomains in /etc/resolv.conf
the host resolver hands that name to management CoreDNS verbatim
and gets NXDOMAIN.
Distinct from tenantClusterDomain ("cluster.local"), which is the
*tenant* kubelet --cluster-dns zone. This value is the
*management* cluster-domain, sourced from .Values._cluster
(populated by cozystack-controller from the platform config). */}}
{{- $mgmtClusterDomain := (index .Values._cluster "cluster-domain") | default "cozy.local" }}
{{- range $groupName, $group := .Values.nodeGroups }}
{{- /* Mirror the kubelet-reservation computation from cluster.yaml so the
worker machineconfig the Job applies carries the same numbers the
MachineDeployment validator was happy with. Auto-computed
system/kube reserved memory = 5% of effective memory clamped to
[256Mi, 1Gi]; auto-computed system/kube reserved CPU = 5% of
effective CPU clamped to [50m, 500m]. instanceType lookup is
optional — operators that override resources.cpu / resources.memory
on the nodeGroup get those, otherwise the chart falls back to the
same static-defaults pair the cluster.yaml validator uses on a
no-instanceType setup. */}}
{{- $instanceType := dict }}
{{- if $group.instanceType }}
{{- $instanceType = lookup "instancetype.kubevirt.io/v1beta1" "VirtualMachineClusterInstancetype" "" $group.instanceType }}
{{- $instanceType = $instanceType | default dict }}
{{- end }}
{{- $effectiveMemory := "" }}
{{- if and $group.resources $group.resources.memory }}
{{- $effectiveMemory = $group.resources.memory | toString }}
{{- else if and $instanceType $instanceType.spec $instanceType.spec.memory $instanceType.spec.memory.guest }}
{{- $effectiveMemory = $instanceType.spec.memory.guest | toString }}
{{- end }}
{{- $effectiveCpu := "" }}
{{- if and $group.resources $group.resources.cpu }}
{{- $effectiveCpu = $group.resources.cpu | toString }}
{{- else if and $instanceType $instanceType.spec $instanceType.spec.cpu $instanceType.spec.cpu.guest }}
{{- $effectiveCpu = $instanceType.spec.cpu.guest | toString }}
{{- end }}
{{- $autoReservedMi := 256 }}
{{- if $effectiveMemory }}
{{- $effectiveMemMi := divf (include "cozy-lib.resources.toFloat" $effectiveMemory | float64) 1048576.0 | int }}
{{- $fivePercentMi := mulf ($effectiveMemMi | float64) 0.05 | int }}
{{- $autoReservedMi = min (max $fivePercentMi 256) 1024 }}
{{- end }}
{{- $autoReservedMillicores := 50 }}
{{- if $effectiveCpu }}
{{- $effectiveCpuMillicores := include "kubernetes.cpuToMillicores" $effectiveCpu | int }}
{{- $fivePercentMillis := mulf ($effectiveCpuMillicores | float64) 0.05 | int }}
{{- $autoReservedMillicores = min (max $fivePercentMillis 50) 500 }}
{{- end }}
{{- $kubeletOverride := $group.kubelet | default dict }}
{{- $systemReservedMemory := $kubeletOverride.systemReservedMemory | default (printf "%dMi" $autoReservedMi) }}
{{- $kubeReservedMemory := $kubeletOverride.kubeReservedMemory | default (printf "%dMi" $autoReservedMi) }}
{{- $systemReservedCpu := $kubeletOverride.systemReservedCpu | default (printf "%dm" $autoReservedMillicores) }}
{{- $kubeReservedCpu := $kubeletOverride.kubeReservedCpu | default (printf "%dm" $autoReservedMillicores) }}
{{- $evictionHardMemory := $kubeletOverride.evictionHardMemory | default "7%" }}
{{- $evictionSoftMemory := $kubeletOverride.evictionSoftMemory | default "10%" }}
{{- /* Render the Job spec, then hash it to a name suffix so the immutable
Job is churn-free across upgrades (see the header comment). Mirrors
the KubevirtMachineTemplate content-hash idiom in cluster.yaml. */}}
{{- $jobContext := deepCopy $ }}
{{- $_ := set $jobContext "group" $group }}
{{- $_ := set $jobContext "groupName" $groupName }}
{{- $_ := set $jobContext "kubeletVersion" $kubeletVersion }}
{{- $_ := set $jobContext "talosVersion" $talosVersion }}
{{- $_ := set $jobContext "podCIDR" $podCIDR }}
{{- $_ := set $jobContext "serviceCIDR" $serviceCIDR }}
{{- $_ := set $jobContext "dnsDomain" $dnsDomain }}
{{- $_ := set $jobContext "mgmtClusterDomain" $mgmtClusterDomain }}
{{- $_ := set $jobContext "systemReservedCpu" $systemReservedCpu }}
{{- $_ := set $jobContext "systemReservedMemory" $systemReservedMemory }}
{{- $_ := set $jobContext "kubeReservedCpu" $kubeReservedCpu }}
{{- $_ := set $jobContext "kubeReservedMemory" $kubeReservedMemory }}
{{- $_ := set $jobContext "evictionHardMemory" $evictionHardMemory }}
{{- $_ := set $jobContext "evictionSoftMemory" $evictionSoftMemory }}
{{- $_ := set $jobContext "kubectlImage" (default ($.Files.Get "images/kubectl.tag" | trim) $.Values.images.kubectl) }}
{{- $jobSpec := include "kubernetes.talos-reconcile-job-spec" $jobContext }}
{{- $jobHash := $jobSpec | sha256sum | trunc 6 }}
{{- /* Truncate the name prefix, never the hash, so the name stays within the
63-char DNS-label limit for long release/group names while the 6-char
content hash that keeps the immutable Job churn-free is always preserved.
A no-op for normal-length names (prefix << 56). */}}
{{- $jobName := printf "%s-talos-reconcile-%s" $.Release.Name $groupName | trunc 56 | trimSuffix "-" }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ $jobName }}-{{ $jobHash }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "kubernetes.labels" $ | nindent 4 }}
{{ $jobSpec }}
{{- end }}