From 7797f4956918f1631c52714034ee1690b8fd3fd3 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:20:41 +0300 Subject: [PATCH 01/26] test(api): assert parent HelmRelease Install/Upgrade Timeout >= 15m Adds a failing unit test for convertApplicationToHelmRelease asserting that Install.Timeout and Upgrade.Timeout are at least 15 minutes. The default flux helm-controller timeout is too short to cover cold-start Kamaji control-plane bootstrap (image pull + etcd bootstrap + apiserver Ready + admin-kubeconfig Secret generation) and causes install remediation loops. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../apps/application/rest_timeout_test.go | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 pkg/registry/apps/application/rest_timeout_test.go diff --git a/pkg/registry/apps/application/rest_timeout_test.go b/pkg/registry/apps/application/rest_timeout_test.go new file mode 100644 index 00000000..8ae13183 --- /dev/null +++ b/pkg/registry/apps/application/rest_timeout_test.go @@ -0,0 +1,53 @@ +package application + +import ( + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1" + "github.com/cozystack/cozystack/pkg/config" +) + +func TestConvertApplicationToHelmRelease_SetsInstallAndUpgradeTimeout(t *testing.T) { + r := &REST{ + releaseConfig: config.ReleaseConfig{ + Prefix: "kubernetes-", + ChartRef: config.ChartRefConfig{ + Kind: "HelmChart", + Name: "kubernetes", + Namespace: "cozy-system", + }, + }, + } + + app := &appsv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, + } + + hr, err := r.convertApplicationToHelmRelease(app) + if err != nil { + t.Fatalf("convertApplicationToHelmRelease returned error: %v", err) + } + + if hr.Spec.Install == nil { + t.Fatal("Spec.Install must not be nil") + } + if hr.Spec.Install.Timeout == nil { + t.Fatal("Spec.Install.Timeout must be set to cover async admin-kubeconfig provisioning") + } + if hr.Spec.Install.Timeout.Duration < 15*time.Minute { + t.Errorf("Spec.Install.Timeout must be >= 15m (cold bootstrap budget), got %v", hr.Spec.Install.Timeout.Duration) + } + + if hr.Spec.Upgrade == nil { + t.Fatal("Spec.Upgrade must not be nil") + } + if hr.Spec.Upgrade.Timeout == nil { + t.Fatal("Spec.Upgrade.Timeout must be set to cover async admin-kubeconfig provisioning") + } + if hr.Spec.Upgrade.Timeout.Duration < 15*time.Minute { + t.Errorf("Spec.Upgrade.Timeout must be >= 15m (cold bootstrap budget), got %v", hr.Spec.Upgrade.Timeout.Duration) + } +} From e4f279f8e2cb08e271130b4a5193d9e0e9d9d3a7 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:21:15 +0300 Subject: [PATCH 02/26] fix(api): set 15m Install/Upgrade Timeout for parent HelmRelease Parent HelmRelease created by cozystack-api for Kubernetes tenants contains CAPI/Kamaji resources (Cluster, KamajiControlPlane, MachineDeployment) that asynchronously provision the *-admin-kubeconfig Secret. Three Deployments in the same chart (cluster-autoscaler, kccm, kcsi-controller) mount that Secret directly, so the helm-wait cannot complete until control-plane bootstrap finishes. Default flux helm-controller timeout is too short for a cold-node first-tenant bootstrap (image pull + etcd bootstrap + apiserver Ready + admin-kubeconfig generation routinely exceed it). On timeout, install.remediation triggers uninstall, which removes the Cluster CR and restarts the cycle indefinitely. Bumping Install.Timeout and Upgrade.Timeout to 15m gives realistic bootstrap headroom while remaining bounded. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/registry/apps/application/rest.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/registry/apps/application/rest.go b/pkg/registry/apps/application/rest.go index 0728ea13..77d1d458 100644 --- a/pkg/registry/apps/application/rest.go +++ b/pkg/registry/apps/application/rest.go @@ -1509,11 +1509,13 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (* }, Interval: metav1.Duration{Duration: 5 * time.Minute}, Install: &helmv2.Install{ + Timeout: &metav1.Duration{Duration: 15 * time.Minute}, Remediation: &helmv2.InstallRemediation{ Retries: -1, }, }, Upgrade: &helmv2.Upgrade{ + Timeout: &metav1.Duration{Duration: 15 * time.Minute}, Remediation: &helmv2.UpgradeRemediation{ Retries: -1, }, From 3e26234a1c98f138542471a7f4395532e15c7b46 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:24:20 +0300 Subject: [PATCH 03/26] test(kubernetes): assert admin-kubeconfig wait pattern and etcd guard Adds failing helm unittest suite for packages/apps/kubernetes covering: - cluster-autoscaler, kccm, and csi controller Deployments mount the admin-kubeconfig Secret with optional: true - each of those Deployments has a wait-for-kubeconfig initContainer that mounts the same kubeconfig path - cluster.yaml renders a helm fail with a descriptive message when the tenant has no etcd DataStore (empty _namespace.etcd) Also wires up a test target in the chart Makefile so helm-unit-tests.sh picks it up. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/Makefile | 3 + .../tests/admin_kubeconfig_wait_test.yaml | 85 +++++++++++++++++++ packages/apps/kubernetes/tests/values-ci.yaml | 9 ++ 3 files changed, 97 insertions(+) create mode 100644 packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml create mode 100644 packages/apps/kubernetes/tests/values-ci.yaml diff --git a/packages/apps/kubernetes/Makefile b/packages/apps/kubernetes/Makefile index 01cf736d..0f9e6d57 100644 --- a/packages/apps/kubernetes/Makefile +++ b/packages/apps/kubernetes/Makefile @@ -4,6 +4,9 @@ KUBERNETES_PKG_TAG = $(shell awk '$$1 == "version:" {print $$2}' Chart.yaml) include ../../../hack/common-envs.mk include ../../../hack/package.mk +test: + helm unittest . + generate: cozyvalues-gen -m 'kubernetes' -v values.yaml -s values.schema.json -r README.md -g ../../../api/apps/v1alpha1/kubernetes/types.go ../../../hack/update-crd.sh diff --git a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml new file mode 100644 index 00000000..e2d9f359 --- /dev/null +++ b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml @@ -0,0 +1,85 @@ +suite: admin-kubeconfig wait guards + +release: + name: test + namespace: tenant-root + +values: + - values-ci.yaml + +tests: + - it: cluster-autoscaler mounts admin-kubeconfig as optional + template: templates/cluster-autoscaler/deployment.yaml + documentSelector: + path: kind + value: Deployment + asserts: + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.secretName + value: test-admin-kubeconfig + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.optional + value: true + + - it: cluster-autoscaler waits for admin-kubeconfig via initContainer + template: templates/cluster-autoscaler/deployment.yaml + documentSelector: + path: kind + value: Deployment + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: wait-for-kubeconfig + - contains: + path: spec.template.spec.initContainers[0].volumeMounts + content: + name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true + + - it: kccm mounts admin-kubeconfig as optional + template: templates/kccm/manager.yaml + asserts: + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.secretName + value: test-admin-kubeconfig + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.optional + value: true + + - it: kccm waits for admin-kubeconfig via initContainer + template: templates/kccm/manager.yaml + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: wait-for-kubeconfig + + - it: csi controller mounts admin-kubeconfig as optional + template: templates/csi/deploy.yaml + asserts: + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.secretName + value: test-admin-kubeconfig + - equal: + path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.optional + value: true + + - it: csi controller waits for admin-kubeconfig via initContainer + template: templates/csi/deploy.yaml + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: wait-for-kubeconfig + + - it: cluster.yaml fails render when tenant has no etcd DataStore + template: templates/cluster.yaml + set: + _namespace: + etcd: "" + monitoring: "" + ingress: "" + seaweedfs: "" + host: "" + asserts: + - failedTemplate: + errorPattern: "requires a parent tenant with etcd enabled" diff --git a/packages/apps/kubernetes/tests/values-ci.yaml b/packages/apps/kubernetes/tests/values-ci.yaml new file mode 100644 index 00000000..13365e8c --- /dev/null +++ b/packages/apps/kubernetes/tests/values-ci.yaml @@ -0,0 +1,9 @@ +_namespace: + etcd: tenant-root + monitoring: "" + ingress: "" + seaweedfs: "" + host: "" +_cluster: + cluster-domain: cozy.local +nodeGroups: null From ca33cc4e3c4b032293fceec2b7d3c1eb13a241bb Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:25:42 +0300 Subject: [PATCH 04/26] fix(kubernetes): wait for admin-kubeconfig before starting CP-side pods Three Deployments in the Kubernetes app chart mount the tenant admin-kubeconfig Secret directly as a volume: cluster-autoscaler, kccm, and the kcsi controller. That Secret is provisioned asynchronously by Kamaji after control-plane bootstrap, so on a fresh install the pods used to hit FailedMount and the parent HelmRelease ran out of its wait budget. Mark the Secret volume optional and add a wait-for-kubeconfig initContainer that polls the mounted path until the Secret appears. Kubelet remounts the optional Secret within its sync period once Kamaji publishes it, the init container exits, and the main container starts cleanly. The Deployment becomes Available without the helm-wait ever seeing a FailedMount. Pins a busybox image for the init container via images/busybox.tag (same format as the other pinned tags in this chart). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/images/busybox.tag | 1 + .../cluster-autoscaler/deployment.yaml | 17 +++++++++++++++++ .../apps/kubernetes/templates/csi/deploy.yaml | 17 +++++++++++++++++ .../apps/kubernetes/templates/kccm/manager.yaml | 17 +++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 packages/apps/kubernetes/images/busybox.tag diff --git a/packages/apps/kubernetes/images/busybox.tag b/packages/apps/kubernetes/images/busybox.tag new file mode 100644 index 00000000..e358c12e --- /dev/null +++ b/packages/apps/kubernetes/images/busybox.tag @@ -0,0 +1 @@ +busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e diff --git a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml index a00e0155..47c22f76 100644 --- a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml +++ b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml @@ -23,6 +23,22 @@ spec: - key: node-role.kubernetes.io/control-plane operator: Exists effect: "NoSchedule" + initContainers: + - name: wait-for-kubeconfig + image: "{{ $.Files.Get "images/busybox.tag" | trim }}" + command: + - sh + - -c + - | + set -eu + until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + echo "waiting for admin kubeconfig to be provisioned by Kamaji..." + sleep 5 + done + volumeMounts: + - name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true containers: - image: "{{ $.Files.Get "images/cluster-autoscaler.tag" | trim }}" name: cluster-autoscaler @@ -56,6 +72,7 @@ spec: name: cloud-config - secret: secretName: {{ .Release.Name }}-admin-kubeconfig + optional: true name: kubeconfig serviceAccountName: {{ .Release.Name }}-cluster-autoscaler terminationGracePeriodSeconds: 10 diff --git a/packages/apps/kubernetes/templates/csi/deploy.yaml b/packages/apps/kubernetes/templates/csi/deploy.yaml index 938b6d67..2979c75c 100644 --- a/packages/apps/kubernetes/templates/csi/deploy.yaml +++ b/packages/apps/kubernetes/templates/csi/deploy.yaml @@ -24,6 +24,22 @@ spec: - key: node-role.kubernetes.io/control-plane operator: Exists effect: "NoSchedule" + initContainers: + - name: wait-for-kubeconfig + image: "{{ $.Files.Get "images/busybox.tag" | trim }}" + command: + - sh + - -c + - | + set -eu + until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + echo "waiting for admin kubeconfig to be provisioned by Kamaji..." + sleep 5 + done + volumeMounts: + - name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true containers: - name: csi-driver imagePullPolicy: Always @@ -234,4 +250,5 @@ spec: emptyDir: {} - secret: secretName: {{ .Release.Name }}-admin-kubeconfig + optional: true name: kubeconfig diff --git a/packages/apps/kubernetes/templates/kccm/manager.yaml b/packages/apps/kubernetes/templates/kccm/manager.yaml index 81426d4e..5c7f15a4 100644 --- a/packages/apps/kubernetes/templates/kccm/manager.yaml +++ b/packages/apps/kubernetes/templates/kccm/manager.yaml @@ -22,6 +22,22 @@ spec: - key: node-role.kubernetes.io/control-plane operator: Exists effect: "NoSchedule" + initContainers: + - name: wait-for-kubeconfig + image: "{{ $.Files.Get "images/busybox.tag" | trim }}" + command: + - sh + - -c + - | + set -eu + until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + echo "waiting for admin kubeconfig to be provisioned by Kamaji..." + sleep 5 + done + volumeMounts: + - name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true containers: - name: kubevirt-cloud-controller-manager args: @@ -55,5 +71,6 @@ spec: name: cloud-config - secret: secretName: {{ .Release.Name }}-admin-kubeconfig + optional: true name: kubeconfig serviceAccountName: {{ .Release.Name }}-kccm From cac514b60fc00717df37780d2183e1702b07e9c6 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:26:09 +0300 Subject: [PATCH 05/26] fix(kubernetes): fail fast when tenant has no etcd DataStore When a Kubernetes tenant is created without a parent tenant that has etcd enabled, .Values._namespace.etcd is empty and the rendered KamajiControlPlane spec carries an empty dataStoreName. The Kamaji admission webhook then rejects every TenantControlPlane create with "tenant-root DataStore does not exist" and the control plane never comes up. Add a helm template-level guard that fails rendering with a descriptive, actionable error message before the HelmRelease even reaches the webhook. This also closes the narrow race where a Kubernetes HelmRelease reconciles before the etcd HelmRelease has created the DataStore CR - flux retries on its interval and picks up the DataStore once it appears. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/templates/cluster.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/apps/kubernetes/templates/cluster.yaml b/packages/apps/kubernetes/templates/cluster.yaml index 10d6fd80..99249c1a 100644 --- a/packages/apps/kubernetes/templates/cluster.yaml +++ b/packages/apps/kubernetes/templates/cluster.yaml @@ -1,4 +1,7 @@ {{- $etcd := .Values._namespace.etcd }} +{{- if not $etcd }} +{{- fail "Kubernetes tenant requires a parent tenant with etcd enabled: set .Values.etcd=true on tenant-root (or any ancestor tenant), wait for the etcd HelmRelease to reconcile (DataStore CR appears in the tenant namespace), then retry." }} +{{- end }} {{- $ingress := .Values._namespace.ingress }} {{- $host := .Values._namespace.host }} {{- $kubevirtmachinetemplateNames := list }} From a7d994365d067dee5d9546a9b95bb555b206a6af Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:26:47 +0300 Subject: [PATCH 06/26] test(kubernetes): assert parent HelmRelease did not remediate in e2e Before cleanup, inspect the parent HelmRelease installFailures and upgradeFailures counters. A non-zero value means flux helm-controller hit its wait timeout, ran install/upgrade remediation (uninstall), and re-installed - the exact race condition this PR closes. Fail the bats test in that case so the signal surfaces in CI instead of being masked by a green retry. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/run-kubernetes.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hack/e2e-apps/run-kubernetes.sh b/hack/e2e-apps/run-kubernetes.sh index be6dcd6f..f05ec370 100644 --- a/hack/e2e-apps/run-kubernetes.sh +++ b/hack/e2e-apps/run-kubernetes.sh @@ -320,6 +320,19 @@ EOF done kubectl wait hr kubernetes-${test_name}-ingress-nginx -n tenant-test --timeout=5m --for=condition=ready + # Guard: parent HelmRelease must not have entered an install/upgrade remediation cycle. + # A non-zero installFailures/upgradeFailures indicates the helm-wait budget expired while + # admin-kubeconfig was still being provisioned, which would trigger uninstall remediation + # and churn the Cluster CR. + install_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.installFailures}') + upgrade_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.upgradeFailures}') + if [ "${install_failures:-0}" != "0" ] && [ -n "${install_failures}" ] || \ + [ "${upgrade_failures:-0}" != "0" ] && [ -n "${upgrade_failures}" ]; then + echo "Parent HelmRelease entered remediation cycle: installFailures=${install_failures:-0}, upgradeFailures=${upgrade_failures:-0}" >&2 + kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 + exit 1 + fi + # Clean up pkill -f "port-forward.*${port}:" 2>/dev/null || true rm -f "tenantkubeconfig-${test_name}" From f87834a3edba32945f22e8d419a9592e5303b34b Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:48:47 +0300 Subject: [PATCH 07/26] fix(hack): group e2e remediation guard conditions correctly Shell && and || have equal precedence and left-to-right associativity, so the previous guard parsed as (((A && B) || C) && D) and silently passed on the canonical failure mode: install_failures=1 with an empty upgrade_failures. Extract the check into helmrelease_has_remediation_cycle() in a dedicated helper sourced from run-kubernetes.sh, and add unit tests under hack/remediation-guard.bats that pin the expected behavior for every combination of empty, zero, and positive counter values. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/remediation-guard.sh | 24 +++++++++ hack/e2e-apps/run-kubernetes.sh | 5 +- hack/remediation-guard.bats | 87 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 hack/e2e-apps/remediation-guard.sh create mode 100644 hack/remediation-guard.bats diff --git a/hack/e2e-apps/remediation-guard.sh b/hack/e2e-apps/remediation-guard.sh new file mode 100644 index 00000000..6da69f13 --- /dev/null +++ b/hack/e2e-apps/remediation-guard.sh @@ -0,0 +1,24 @@ +# Helpers for asserting that a Flux HelmRelease did not fall into an +# install/upgrade remediation cycle during an e2e run. +# +# A non-zero installFailures/upgradeFailures counter means flux +# helm-controller hit its wait timeout, ran remediation (uninstall), +# and re-installed. That is exactly the race this guard is meant to +# catch, so the function returns success (0) when a cycle is detected +# and failure (1) otherwise. +# +# Both arguments may be empty strings, the literal "0", or a positive +# integer. Shell's && and || have equal precedence with left-to-right +# associativity, so each half of the disjunction is grouped explicitly +# to avoid (A && B) || C && D parsing that masks the common +# install_failures=1, upgrade_failures="" case. + +helmrelease_has_remediation_cycle() { + install_failures="$1" + upgrade_failures="$2" + if { [ -n "${install_failures}" ] && [ "${install_failures}" != "0" ]; } || \ + { [ -n "${upgrade_failures}" ] && [ "${upgrade_failures}" != "0" ]; }; then + return 0 + fi + return 1 +} diff --git a/hack/e2e-apps/run-kubernetes.sh b/hack/e2e-apps/run-kubernetes.sh index f05ec370..811f42a7 100644 --- a/hack/e2e-apps/run-kubernetes.sh +++ b/hack/e2e-apps/run-kubernetes.sh @@ -1,3 +1,5 @@ +. hack/e2e-apps/remediation-guard.sh + run_kubernetes_test() { local version_expr="$1" local test_name="$2" @@ -326,8 +328,7 @@ EOF # and churn the Cluster CR. install_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.installFailures}') upgrade_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.upgradeFailures}') - if [ "${install_failures:-0}" != "0" ] && [ -n "${install_failures}" ] || \ - [ "${upgrade_failures:-0}" != "0" ] && [ -n "${upgrade_failures}" ]; then + if helmrelease_has_remediation_cycle "${install_failures}" "${upgrade_failures}"; then echo "Parent HelmRelease entered remediation cycle: installFailures=${install_failures:-0}, upgradeFailures=${upgrade_failures:-0}" >&2 kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 exit 1 diff --git a/hack/remediation-guard.bats b/hack/remediation-guard.bats new file mode 100644 index 00000000..64aa83af --- /dev/null +++ b/hack/remediation-guard.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# ----------------------------------------------------------------------------- +# Unit tests for hack/e2e-apps/remediation-guard.sh +# +# helmrelease_has_remediation_cycle is consumed from e2e tests to assert that +# the parent HelmRelease did not hit flux helm-controller's wait timeout and +# enter uninstall remediation. The function accepts two arguments (values of +# .status.installFailures and .status.upgradeFailures) and returns 0 when a +# remediation cycle is detected, 1 otherwise. +# +# Each argument can be empty (controller never populated the field), "0" +# (populated but never failed), or a positive integer. Shell's && and || +# have equal precedence with left-to-right associativity, which used to +# break this check on the most common failure mode - install_failures=1 +# and upgrade_failures="". These tests pin the correct behavior. +# +# cozytest.sh's awk parser recognizes only @test blocks and a bare `}` on +# its own line; there is no bats `run` or `$status`. Assertions are +# expressed as direct shell tests that exit non-zero on failure. +# +# Run with: hack/cozytest.sh hack/remediation-guard.bats +# ----------------------------------------------------------------------------- + +@test "no counters set returns not-detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "" "" || rc=$? + [ "$rc" -eq 1 ] +} + +@test "both counters zero returns not-detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "0" "0" || rc=$? + [ "$rc" -eq 1 ] +} + +@test "install zero upgrade empty returns not-detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "0" "" || rc=$? + [ "$rc" -eq 1 ] +} + +@test "install empty upgrade zero returns not-detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "" "0" || rc=$? + [ "$rc" -eq 1 ] +} + +@test "install one upgrade empty returns detected" { + # Canonical race: first install exceeded helm-wait, remediation fired, + # no upgrade has happened yet. + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "1" "" || rc=$? + [ "$rc" -eq 0 ] +} + +@test "install empty upgrade one returns detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "" "1" || rc=$? + [ "$rc" -eq 0 ] +} + +@test "install two upgrade zero returns detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "2" "0" || rc=$? + [ "$rc" -eq 0 ] +} + +@test "install zero upgrade two returns detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "0" "2" || rc=$? + [ "$rc" -eq 0 ] +} + +@test "both counters positive returns detected" { + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "3" "5" || rc=$? + [ "$rc" -eq 0 ] +} From 73b80bfb940ec24459a826b32540f676a00bca36 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:51:32 +0300 Subject: [PATCH 08/26] fix(api): scope 15m helm wait budget to Kubernetes Application kind The previous change set Install.Timeout and Upgrade.Timeout to 15m on every Application's parent HelmRelease, but the admin-kubeconfig race documented in #2412 is specific to the Kubernetes kind: only its parent chart creates CAPI/Kamaji resources whose admin-kubeconfig Secret is asynchronously provisioned and mounted by Deployments in the same chart. Other kinds (Qdrant, MongoDB, Postgres, ...) have no such race and should not have their failed installs linger three times longer before flux triggers remediation. Gate the timeout on r.kindName == "Kubernetes". Rewrite rest_timeout_test.go to cover both sides: Kubernetes must get a >= 15m timeout, other kinds must keep the flux defaults. Both tests also pin Install/Upgrade Remediation.Retries == -1 so a future edit that removes unbounded remediation would show up as a red test. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/registry/apps/application/rest.go | 16 ++++- .../apps/application/rest_timeout_test.go | 58 ++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/pkg/registry/apps/application/rest.go b/pkg/registry/apps/application/rest.go index 77d1d458..9b92d2c0 100644 --- a/pkg/registry/apps/application/rest.go +++ b/pkg/registry/apps/application/rest.go @@ -1509,13 +1509,11 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (* }, Interval: metav1.Duration{Duration: 5 * time.Minute}, Install: &helmv2.Install{ - Timeout: &metav1.Duration{Duration: 15 * time.Minute}, Remediation: &helmv2.InstallRemediation{ Retries: -1, }, }, Upgrade: &helmv2.Upgrade{ - Timeout: &metav1.Duration{Duration: 15 * time.Minute}, Remediation: &helmv2.UpgradeRemediation{ Retries: -1, }, @@ -1530,6 +1528,20 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (* }, } + // The Kubernetes Application's parent chart creates CAPI/Kamaji resources + // whose admin-kubeconfig Secret is provisioned asynchronously and mounted + // by Deployments in the same chart. On a cold node, Kamaji control-plane + // bootstrap routinely exceeds flux helm-controller's default wait budget, + // so install remediation loops uninstall the Cluster CR and churn. Extend + // the wait budget to 15m for Kubernetes only - other Application kinds + // without this race keep flux defaults, so their failed installs do not + // linger unnecessarily before remediation fires. + if r.kindName == "Kubernetes" { + timeout := metav1.Duration{Duration: 15 * time.Minute} + helmRelease.Spec.Install.Timeout = &timeout + helmRelease.Spec.Upgrade.Timeout = &timeout + } + return helmRelease, nil } diff --git a/pkg/registry/apps/application/rest_timeout_test.go b/pkg/registry/apps/application/rest_timeout_test.go index 8ae13183..fa6d5427 100644 --- a/pkg/registry/apps/application/rest_timeout_test.go +++ b/pkg/registry/apps/application/rest_timeout_test.go @@ -10,18 +10,22 @@ import ( "github.com/cozystack/cozystack/pkg/config" ) -func TestConvertApplicationToHelmRelease_SetsInstallAndUpgradeTimeout(t *testing.T) { - r := &REST{ +func newRESTForKind(kind, prefix string) *REST { + return &REST{ + kindName: kind, releaseConfig: config.ReleaseConfig{ - Prefix: "kubernetes-", + Prefix: prefix, ChartRef: config.ChartRefConfig{ Kind: "HelmChart", - Name: "kubernetes", + Name: "x", Namespace: "cozy-system", }, }, } +} +func TestConvertApplicationToHelmRelease_KubernetesKindGetsLongTimeout(t *testing.T) { + r := newRESTForKind("Kubernetes", "kubernetes-") app := &appsv1alpha1.Application{ ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, } @@ -35,19 +39,57 @@ func TestConvertApplicationToHelmRelease_SetsInstallAndUpgradeTimeout(t *testing t.Fatal("Spec.Install must not be nil") } if hr.Spec.Install.Timeout == nil { - t.Fatal("Spec.Install.Timeout must be set to cover async admin-kubeconfig provisioning") + t.Fatal("Spec.Install.Timeout must be set for Kubernetes kind") } if hr.Spec.Install.Timeout.Duration < 15*time.Minute { - t.Errorf("Spec.Install.Timeout must be >= 15m (cold bootstrap budget), got %v", hr.Spec.Install.Timeout.Duration) + t.Errorf("Spec.Install.Timeout must be >= 15m for Kubernetes, got %v", hr.Spec.Install.Timeout.Duration) } if hr.Spec.Upgrade == nil { t.Fatal("Spec.Upgrade must not be nil") } if hr.Spec.Upgrade.Timeout == nil { - t.Fatal("Spec.Upgrade.Timeout must be set to cover async admin-kubeconfig provisioning") + t.Fatal("Spec.Upgrade.Timeout must be set for Kubernetes kind") } if hr.Spec.Upgrade.Timeout.Duration < 15*time.Minute { - t.Errorf("Spec.Upgrade.Timeout must be >= 15m (cold bootstrap budget), got %v", hr.Spec.Upgrade.Timeout.Duration) + t.Errorf("Spec.Upgrade.Timeout must be >= 15m for Kubernetes, got %v", hr.Spec.Upgrade.Timeout.Duration) + } + + if hr.Spec.Install.Remediation == nil || hr.Spec.Install.Remediation.Retries != -1 { + t.Errorf("Spec.Install.Remediation.Retries must remain -1, got %+v", hr.Spec.Install.Remediation) + } + if hr.Spec.Upgrade.Remediation == nil || hr.Spec.Upgrade.Remediation.Retries != -1 { + t.Errorf("Spec.Upgrade.Remediation.Retries must remain -1, got %+v", hr.Spec.Upgrade.Remediation) + } +} + +func TestConvertApplicationToHelmRelease_NonKubernetesKindKeepsFluxDefaults(t *testing.T) { + // For Applications whose parent chart has no admin-kubeconfig race + // (Qdrant, MongoDB, Postgres, etc.), do NOT extend the helm-wait + // budget - otherwise failed installs would block three times as long + // before Flux starts remediating. + r := newRESTForKind("Qdrant", "qdrant-") + app := &appsv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, + } + + hr, err := r.convertApplicationToHelmRelease(app) + if err != nil { + t.Fatalf("convertApplicationToHelmRelease returned error: %v", err) + } + + if hr.Spec.Install != nil && hr.Spec.Install.Timeout != nil { + t.Errorf("Spec.Install.Timeout must be unset for non-Kubernetes kinds, got %v", hr.Spec.Install.Timeout.Duration) + } + if hr.Spec.Upgrade != nil && hr.Spec.Upgrade.Timeout != nil { + t.Errorf("Spec.Upgrade.Timeout must be unset for non-Kubernetes kinds, got %v", hr.Spec.Upgrade.Timeout.Duration) + } + + // But remediation must still be -1 across the board. + if hr.Spec.Install.Remediation == nil || hr.Spec.Install.Remediation.Retries != -1 { + t.Errorf("Spec.Install.Remediation.Retries must remain -1, got %+v", hr.Spec.Install.Remediation) + } + if hr.Spec.Upgrade.Remediation == nil || hr.Spec.Upgrade.Remediation.Retries != -1 { + t.Errorf("Spec.Upgrade.Remediation.Retries must remain -1, got %+v", hr.Spec.Upgrade.Remediation) } } From 03606091df5672b6e857057a349875d534990f3a Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:53:17 +0300 Subject: [PATCH 09/26] chore(kubernetes): align busybox image with project convention Adds images/busybox/Dockerfile and an image-busybox Makefile target that mirror the same pattern as the rest of this chart's images (the Dockerfile pins the upstream busybox by digest; the Makefile target builds and tags for ghcr.io/cozystack/cozystack/busybox the same way cluster-autoscaler et al. are handled). Also wires it into the umbrella image target so 'make image' rebuilds everything. Until the first release build runs image-busybox and rewrites the .tag to point at ghcr.io, the .tag keeps a fully-qualified docker.io/library/busybox:1.37.0@sha256:... reference so pods do not silently resolve the short name via the default registry and pulls remain immutable by digest. The release workflow overwrites this file the same way it does for the other images. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/Makefile | 14 +++++++++++++- packages/apps/kubernetes/images/busybox.tag | 2 +- packages/apps/kubernetes/images/busybox/Dockerfile | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 packages/apps/kubernetes/images/busybox/Dockerfile diff --git a/packages/apps/kubernetes/Makefile b/packages/apps/kubernetes/Makefile index 0f9e6d57..eb23c2ef 100644 --- a/packages/apps/kubernetes/Makefile +++ b/packages/apps/kubernetes/Makefile @@ -15,7 +15,7 @@ update: hack/update-versions.sh make generate -image: image-ubuntu-container-disk image-kubevirt-cloud-provider image-kubevirt-csi-driver image-cluster-autoscaler +image: image-ubuntu-container-disk image-kubevirt-cloud-provider image-kubevirt-csi-driver image-cluster-autoscaler image-busybox image-ubuntu-container-disk: $(foreach ver,$(KUBERNETES_VERSIONS), \ @@ -70,3 +70,15 @@ image-cluster-autoscaler: echo "$(REGISTRY)/cluster-autoscaler:$(call settag,$(KUBERNETES_PKG_TAG))@$$(yq e '."containerimage.digest"' images/cluster-autoscaler.json -o json -r)" \ > images/cluster-autoscaler.tag rm -f images/cluster-autoscaler.json + +image-busybox: + docker buildx build images/busybox \ + --tag $(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG)) \ + --tag $(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG)-$(TAG)) \ + --cache-from type=registry,ref=$(REGISTRY)/busybox:latest \ + --cache-to type=inline \ + --metadata-file images/busybox.json \ + $(BUILDX_ARGS) + echo "$(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG))@$$(yq e '."containerimage.digest"' images/busybox.json -o json -r)" \ + > images/busybox.tag + rm -f images/busybox.json diff --git a/packages/apps/kubernetes/images/busybox.tag b/packages/apps/kubernetes/images/busybox.tag index e358c12e..39de220a 100644 --- a/packages/apps/kubernetes/images/busybox.tag +++ b/packages/apps/kubernetes/images/busybox.tag @@ -1 +1 @@ -busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e +docker.io/library/busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e diff --git a/packages/apps/kubernetes/images/busybox/Dockerfile b/packages/apps/kubernetes/images/busybox/Dockerfile new file mode 100644 index 00000000..0c57fd39 --- /dev/null +++ b/packages/apps/kubernetes/images/busybox/Dockerfile @@ -0,0 +1 @@ +FROM docker.io/library/busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e From 97696b2b036be79ecafd8e96401dc2af3bc39d32 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:54:10 +0300 Subject: [PATCH 10/26] test(kubernetes): add positive cluster render test and pin document kind Adds an assertion that cluster.yaml renders successfully when _namespace.etcd is set and produces a KamajiControlPlane whose dataStoreName equals the tenant's etcd DataStore name. Without this positive case a future edit that inverts or removes the existing etcd guard would pass the suite as long as the negative case still fails. Also adds documentSelector: kind=Deployment to the kccm and csi controller assertions so the jsonpath filter operates on a single document, matching the cluster-autoscaler case and removing reliance on helm-unittest filter-vs-single-value coercion. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../tests/admin_kubeconfig_wait_test.yaml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml index e2d9f359..3b2b1d72 100644 --- a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml +++ b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml @@ -39,6 +39,9 @@ tests: - it: kccm mounts admin-kubeconfig as optional template: templates/kccm/manager.yaml + documentSelector: + path: kind + value: Deployment asserts: - equal: path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.secretName @@ -49,13 +52,25 @@ tests: - it: kccm waits for admin-kubeconfig via initContainer template: templates/kccm/manager.yaml + documentSelector: + path: kind + value: Deployment asserts: - equal: path: spec.template.spec.initContainers[0].name value: wait-for-kubeconfig + - contains: + path: spec.template.spec.initContainers[0].volumeMounts + content: + name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true - it: csi controller mounts admin-kubeconfig as optional template: templates/csi/deploy.yaml + documentSelector: + path: kind + value: Deployment asserts: - equal: path: spec.template.spec.volumes[?(@.name=="kubeconfig")].secret.secretName @@ -66,10 +81,29 @@ tests: - it: csi controller waits for admin-kubeconfig via initContainer template: templates/csi/deploy.yaml + documentSelector: + path: kind + value: Deployment asserts: - equal: path: spec.template.spec.initContainers[0].name value: wait-for-kubeconfig + - contains: + path: spec.template.spec.initContainers[0].volumeMounts + content: + name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true + + - it: cluster.yaml renders and wires dataStoreName when tenant has etcd + template: templates/cluster.yaml + documentSelector: + path: kind + value: KamajiControlPlane + asserts: + - equal: + path: spec.dataStoreName + value: tenant-root - it: cluster.yaml fails render when tenant has no etcd DataStore template: templates/cluster.yaml From 6afc0eb370f827c55f478045d1aff2a329b25100 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 20:55:17 +0300 Subject: [PATCH 11/26] fix(kubernetes): bound init wait and reword fail-fast message Cap the wait-for-kubeconfig init container at 20m. If Kamaji genuinely fails to produce the admin-kubeconfig Secret (misconfigured tenant, etcd outage after the guard already passed, Kamaji crash-loop), the pod now exits non-zero and goes CrashLoopBackOff so the failure is visible in dashboards, instead of silently sleeping in Init forever and leaving only the flux helm-wait timeout to surface the problem. Reword the etcd DataStore guard to reference the parent Tenant application's etcd flag (not .Values.etcd of the Kubernetes chart, which is a different chart). Update the helm unittest errorPattern accordingly. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../kubernetes/templates/cluster-autoscaler/deployment.yaml | 5 +++++ packages/apps/kubernetes/templates/cluster.yaml | 2 +- packages/apps/kubernetes/templates/csi/deploy.yaml | 5 +++++ packages/apps/kubernetes/templates/kccm/manager.yaml | 5 +++++ .../apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml | 2 +- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml index 47c22f76..277b6df8 100644 --- a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml +++ b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml @@ -31,7 +31,12 @@ spec: - -c - | set -eu + deadline=$(( $(date +%s) + 1200 )) until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 + exit 1 + fi echo "waiting for admin kubeconfig to be provisioned by Kamaji..." sleep 5 done diff --git a/packages/apps/kubernetes/templates/cluster.yaml b/packages/apps/kubernetes/templates/cluster.yaml index 99249c1a..1747df4e 100644 --- a/packages/apps/kubernetes/templates/cluster.yaml +++ b/packages/apps/kubernetes/templates/cluster.yaml @@ -1,6 +1,6 @@ {{- $etcd := .Values._namespace.etcd }} {{- if not $etcd }} -{{- fail "Kubernetes tenant requires a parent tenant with etcd enabled: set .Values.etcd=true on tenant-root (or any ancestor tenant), wait for the etcd HelmRelease to reconcile (DataStore CR appears in the tenant namespace), then retry." }} +{{- fail "Kubernetes tenant requires a parent Tenant application with etcd: true so the etcd module deploys a DataStore CR in the tenant namespace. Set spec.etcd: true on the root Tenant (or any ancestor Tenant), wait for the etcd HelmRelease to reconcile and the DataStore CR to appear, then retry." }} {{- end }} {{- $ingress := .Values._namespace.ingress }} {{- $host := .Values._namespace.host }} diff --git a/packages/apps/kubernetes/templates/csi/deploy.yaml b/packages/apps/kubernetes/templates/csi/deploy.yaml index 2979c75c..ff170cb8 100644 --- a/packages/apps/kubernetes/templates/csi/deploy.yaml +++ b/packages/apps/kubernetes/templates/csi/deploy.yaml @@ -32,7 +32,12 @@ spec: - -c - | set -eu + deadline=$(( $(date +%s) + 1200 )) until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 + exit 1 + fi echo "waiting for admin kubeconfig to be provisioned by Kamaji..." sleep 5 done diff --git a/packages/apps/kubernetes/templates/kccm/manager.yaml b/packages/apps/kubernetes/templates/kccm/manager.yaml index 5c7f15a4..67321647 100644 --- a/packages/apps/kubernetes/templates/kccm/manager.yaml +++ b/packages/apps/kubernetes/templates/kccm/manager.yaml @@ -30,7 +30,12 @@ spec: - -c - | set -eu + deadline=$(( $(date +%s) + 1200 )) until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 + exit 1 + fi echo "waiting for admin kubeconfig to be provisioned by Kamaji..." sleep 5 done diff --git a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml index 3b2b1d72..2a43e13f 100644 --- a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml +++ b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml @@ -116,4 +116,4 @@ tests: host: "" asserts: - failedTemplate: - errorPattern: "requires a parent tenant with etcd enabled" + errorPattern: "requires a parent Tenant application with etcd: true" From b38ae605495ff30a71013037af94839c4944e815 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:05:23 +0300 Subject: [PATCH 12/26] fix(kubernetes): soft-skip cluster resources when tenant has no DataStore A hard helm fail in cluster.yaml made every cold bootstrap racy: if the parent Tenant chart had not yet populated _namespace.etcd in cozystack-values when the Kubernetes HelmRelease first reconciled, the fail fired, install.remediation triggered, installFailures incremented and the new e2e remediation-guard flagged it as a bug. That directly contradicts the race the rest of this PR is trying to close. Replace fail with a graceful skip: render only a status-beacon ConfigMap (test-awaiting-etcd) when etcd is empty, wrap all CAPI/Kamaji resources in {{ if $etcd }}. The HelmRelease installs successfully and goes Ready; flux retries on its 5m interval and picks up the DataStore as soon as the Tenant chart finishes reconciling. Update the helm unittest: positive test still asserts dataStoreName on KamajiControlPlane; the negative test now asserts exactly one ConfigMap document with status=awaiting-etcd, no Cluster / KCP / KubevirtCluster / WorkloadMonitor rendered. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../apps/kubernetes/templates/cluster.yaml | 37 +++++++++++++++++-- .../tests/admin_kubeconfig_wait_test.yaml | 19 ++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/apps/kubernetes/templates/cluster.yaml b/packages/apps/kubernetes/templates/cluster.yaml index 1747df4e..12da9818 100644 --- a/packages/apps/kubernetes/templates/cluster.yaml +++ b/packages/apps/kubernetes/templates/cluster.yaml @@ -1,7 +1,15 @@ {{- $etcd := .Values._namespace.etcd }} -{{- if not $etcd }} -{{- fail "Kubernetes tenant requires a parent Tenant application with etcd: true so the etcd module deploys a DataStore CR in the tenant namespace. Set spec.etcd: true on the root Tenant (or any ancestor Tenant), wait for the etcd HelmRelease to reconcile and the DataStore CR to appear, then retry." }} -{{- end }} +{{- /* + When $etcd is empty, the parent Tenant application has not populated + _namespace.etcd in cozystack-values yet - either the operator forgot to + set etcd: true on an ancestor Tenant, or the Tenant HelmRelease is still + reconciling. Either way, rendering a KamajiControlPlane with an empty + dataStoreName would be rejected by Kamaji's admission webhook and the + HelmRelease would fail to install, triggering remediation. Instead, emit + a single ConfigMap as a user-visible status beacon and skip the rest so + flux marks the HelmRelease Ready and retries its 5m reconcile loop until + the Tenant chart catches up. +*/}} {{- $ingress := .Values._namespace.ingress }} {{- $host := .Values._namespace.host }} {{- $kubevirtmachinetemplateNames := list }} @@ -87,6 +95,28 @@ spec: - name: default pod: {} {{- end }} +{{- if not $etcd }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-awaiting-etcd + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: kubernetes + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + cozystack.io/status-beacon: "true" +data: + status: "awaiting-etcd" + message: | + No DataStore is available for this tenant Kubernetes cluster. The parent + Tenant application has not populated _namespace.etcd. Set spec.etcd: true + on an ancestor Tenant (usually tenant-root) and wait for its HelmRelease + to reconcile - this HelmRelease will pick up the DataStore on its next + 5m reconcile loop and provision the cluster. +{{- else }} --- apiVersion: cluster.x-k8s.io/v1beta1 kind: Cluster @@ -407,3 +437,4 @@ metadata: spec: {{- .spec | toYaml | nindent 2 }} {{- end }} +{{- end }} diff --git a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml index 2a43e13f..e7c03bd1 100644 --- a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml +++ b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml @@ -105,7 +105,12 @@ tests: path: spec.dataStoreName value: tenant-root - - it: cluster.yaml fails render when tenant has no etcd DataStore + - it: cluster.yaml skips Cluster resources when tenant has no etcd DataStore + # Must NOT fail rendering - the parent Tenant chart populates + # _namespace.etcd asynchronously, so rendering failures here would cause + # flux install remediation on every cold bootstrap. Instead, emit only a + # ConfigMap status beacon so the HelmRelease reports Ready while flux + # retries on its interval until the DataStore appears. template: templates/cluster.yaml set: _namespace: @@ -115,5 +120,13 @@ tests: seaweedfs: "" host: "" asserts: - - failedTemplate: - errorPattern: "requires a parent Tenant application with etcd: true" + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: test-awaiting-etcd + - equal: + path: data.status + value: awaiting-etcd From 1757567218ac313a030da1f82e64541a3f9bfbae Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:06:47 +0300 Subject: [PATCH 13/26] refactor(kubernetes): extract wait-for-kubeconfig init into shared helper The three control-plane-side Deployments (cluster-autoscaler, kccm, kcsi-controller) carried three copies of the same 20-line init container. That already drifted: the CSI copy used 4-space nesting while the other two used 2-space. Any future update to the image, the deadline, or the poll script had to land in three places or silently diverge. Extract the block into a new kubernetes.waitForAdminKubeconfig helper in templates/_helpers.tpl and include it at each call site. Tighten the deadline from 20m to 10m so it stays strictly below the 15m HelmRelease Install.Timeout and the CrashLoopBackOff surfaces in dashboards before flux remediation can fire. Also clarify the wait message so operators debugging a stuck init container do not chase Kamaji for what is actually kubelet's optional-Secret refresh cadence. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../apps/kubernetes/templates/_helpers.tpl | 40 +++++++++++++++++++ .../cluster-autoscaler/deployment.yaml | 21 +--------- .../apps/kubernetes/templates/csi/deploy.yaml | 21 +--------- .../kubernetes/templates/kccm/manager.yaml | 21 +--------- 4 files changed, 43 insertions(+), 60 deletions(-) diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index 36c06b64..89f06934 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -49,3 +49,43 @@ Selector labels app.kubernetes.io/name: {{ include "kubernetes.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} + +{{/* +wait-for-kubeconfig init container shared by the control-plane-side +Deployments (cluster-autoscaler, kccm, kcsi-controller) that mount the +*-admin-kubeconfig Secret provisioned asynchronously by Kamaji. The +Secret volume is declared optional so kubelet does not FailedMount while +Kamaji is still bootstrapping; this container polls the mounted path and +exits only when super-admin.svc appears, which happens after kubelet's +optional-Secret refresh cycle. + +The 10m deadline stays strictly below the 15m HelmRelease +Install.Timeout scoped to the Kubernetes Application kind so the +CrashLoopBackOff surfaces before flux remediation fires and uninstalls +the Cluster CR. + +Call site owns the surrounding volumes block; the kubeconfig volume +must exist on the pod and mount at /etc/kubernetes/kubeconfig. +*/}} +{{- define "kubernetes.waitForAdminKubeconfig" -}} +- name: wait-for-kubeconfig + image: "{{ .Files.Get "images/busybox.tag" | trim }}" + command: + - sh + - -c + - | + set -eu + deadline=$(( $(date +%s) + 600 )) + until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "admin kubeconfig was not provisioned within 10m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 + exit 1 + fi + echo "waiting for admin kubeconfig (provisioned by Kamaji, visible after kubelet Secret refresh)..." + sleep 5 + done + volumeMounts: + - name: kubeconfig + mountPath: /etc/kubernetes/kubeconfig + readOnly: true +{{- end }} diff --git a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml index 277b6df8..348b017d 100644 --- a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml +++ b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml @@ -24,26 +24,7 @@ spec: operator: Exists effect: "NoSchedule" initContainers: - - name: wait-for-kubeconfig - image: "{{ $.Files.Get "images/busybox.tag" | trim }}" - command: - - sh - - -c - - | - set -eu - deadline=$(( $(date +%s) + 1200 )) - until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 - exit 1 - fi - echo "waiting for admin kubeconfig to be provisioned by Kamaji..." - sleep 5 - done - volumeMounts: - - name: kubeconfig - mountPath: /etc/kubernetes/kubeconfig - readOnly: true + {{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }} containers: - image: "{{ $.Files.Get "images/cluster-autoscaler.tag" | trim }}" name: cluster-autoscaler diff --git a/packages/apps/kubernetes/templates/csi/deploy.yaml b/packages/apps/kubernetes/templates/csi/deploy.yaml index ff170cb8..c1af7f13 100644 --- a/packages/apps/kubernetes/templates/csi/deploy.yaml +++ b/packages/apps/kubernetes/templates/csi/deploy.yaml @@ -25,26 +25,7 @@ spec: operator: Exists effect: "NoSchedule" initContainers: - - name: wait-for-kubeconfig - image: "{{ $.Files.Get "images/busybox.tag" | trim }}" - command: - - sh - - -c - - | - set -eu - deadline=$(( $(date +%s) + 1200 )) - until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 - exit 1 - fi - echo "waiting for admin kubeconfig to be provisioned by Kamaji..." - sleep 5 - done - volumeMounts: - - name: kubeconfig - mountPath: /etc/kubernetes/kubeconfig - readOnly: true + {{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }} containers: - name: csi-driver imagePullPolicy: Always diff --git a/packages/apps/kubernetes/templates/kccm/manager.yaml b/packages/apps/kubernetes/templates/kccm/manager.yaml index 67321647..20ac0a1e 100644 --- a/packages/apps/kubernetes/templates/kccm/manager.yaml +++ b/packages/apps/kubernetes/templates/kccm/manager.yaml @@ -23,26 +23,7 @@ spec: operator: Exists effect: "NoSchedule" initContainers: - - name: wait-for-kubeconfig - image: "{{ $.Files.Get "images/busybox.tag" | trim }}" - command: - - sh - - -c - - | - set -eu - deadline=$(( $(date +%s) + 1200 )) - until [ -s /etc/kubernetes/kubeconfig/super-admin.svc ]; do - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "admin kubeconfig was not provisioned within 20m; exiting so the pod goes CrashLoopBackOff and surfaces in dashboards" >&2 - exit 1 - fi - echo "waiting for admin kubeconfig to be provisioned by Kamaji..." - sleep 5 - done - volumeMounts: - - name: kubeconfig - mountPath: /etc/kubernetes/kubeconfig - readOnly: true + {{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }} containers: - name: kubevirt-cloud-controller-manager args: From 7b146cbe56c26511d2228c17cdb7c2201f480395 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:09:35 +0300 Subject: [PATCH 14/26] feat(api): make HelmRelease Install/Upgrade timeout per-Application Replace the hardcoded r.kindName == "Kubernetes" switch in rest.go with a config-driven path. The ApplicationDefinition CR now accepts a release.cozystack.io/helm-install-timeout annotation that is parsed at cozystack-api startup into config.ReleaseConfig.HelmInstallTimeout and applied to both Install.Timeout and Upgrade.Timeout on the rendered HelmRelease. Applications that leave the annotation unset keep flux defaults so their failed installs remediate on the normal cadence - only the Kubernetes kind carries the override and gets a 15m budget. New kinds with a similar race can opt in by setting the same annotation; no rest.go patch needed. Kubernetes-rd sets the annotation to 15m. Table-driven test in rest_timeout_test.go covers three cases: Kubernetes with 15m, Qdrant unset, and an arbitrary future kind with 20m - all of which pin the Remediation.Retries == -1 contract as well. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../kubernetes-rd/cozyrds/kubernetes.yaml | 8 + pkg/cmd/server/start.go | 35 +++-- pkg/config/config.go | 8 + pkg/registry/apps/application/rest.go | 21 +-- .../apps/application/rest_timeout_test.go | 143 ++++++++++-------- 5 files changed, 133 insertions(+), 82 deletions(-) diff --git a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml index 5e9e8f94..1128abe8 100644 --- a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml +++ b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml @@ -2,6 +2,14 @@ apiVersion: cozystack.io/v1alpha1 kind: ApplicationDefinition metadata: name: kubernetes + annotations: + # Kubernetes tenants boot a Kamaji control plane whose admin-kubeconfig + # Secret is provisioned asynchronously. Cold Kamaji start (image pull + + # etcd + apiserver Ready) plus admin-kubeconfig generation can exceed + # Flux helm-controller's default wait budget, causing remediation loops + # that uninstall the Cluster CR. This override applied by cozystack-api + # to the HelmRelease Spec.Install.Timeout and Spec.Upgrade.Timeout. + release.cozystack.io/helm-install-timeout: "15m" spec: application: kind: Kubernetes diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index f7791332..3e905735 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -160,6 +160,31 @@ func (o *CozyServerOptions) Complete() error { // Convert to ResourceConfig o.ResourceConfig = &config.ResourceConfig{} for _, crd := range crdList.Items { + release := config.ReleaseConfig{ + Prefix: crd.Spec.Release.Prefix, + Labels: crd.Spec.Release.Labels, + ChartRef: config.ChartRefConfig{ + Kind: crd.Spec.Release.ChartRef.Kind, + Name: crd.Spec.Release.ChartRef.Name, + Namespace: crd.Spec.Release.ChartRef.Namespace, + }, + } + // Per-Application HelmRelease Install/Upgrade timeout. Applications + // whose parent chart contains asynchronously-provisioned resources + // the chart itself depends on (for example, the Kamaji-provisioned + // admin-kubeconfig Secret for Kubernetes tenants) need a longer + // wait budget than the Flux default. Consumed by the REST storage + // layer when building the HelmRelease Spec. + if raw, ok := crd.Annotations["release.cozystack.io/helm-install-timeout"]; ok && raw != "" { + d, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf( + "ApplicationDefinition %q has invalid release.cozystack.io/helm-install-timeout %q: %w", + crd.Name, raw, err, + ) + } + release.HelmInstallTimeout = d + } resource := config.Resource{ Application: config.ApplicationConfig{ Kind: crd.Spec.Application.Kind, @@ -168,15 +193,7 @@ func (o *CozyServerOptions) Complete() error { ShortNames: []string{}, // TODO: implement shortnames OpenAPISchema: crd.Spec.Application.OpenAPISchema, }, - Release: config.ReleaseConfig{ - Prefix: crd.Spec.Release.Prefix, - Labels: crd.Spec.Release.Labels, - ChartRef: config.ChartRefConfig{ - Kind: crd.Spec.Release.ChartRef.Kind, - Name: crd.Spec.Release.ChartRef.Name, - Namespace: crd.Spec.Release.ChartRef.Namespace, - }, - }, + Release: release, } o.ResourceConfig.Resources = append(o.ResourceConfig.Resources, resource) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 1e123e2c..16cf4f0c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,8 @@ limitations under the License. package config +import "time" + // ResourceConfig represents the structure of the configuration file. type ResourceConfig struct { Resources []Resource `yaml:"resources"` @@ -41,6 +43,12 @@ type ReleaseConfig struct { Prefix string `yaml:"prefix"` Labels map[string]string `yaml:"labels"` ChartRef ChartRefConfig `yaml:"chartRef"` + // HelmInstallTimeout overrides the Flux HelmRelease Install.Timeout and + // Upgrade.Timeout for this Application kind. When zero, flux defaults + // apply. Populated from the + // release.cozystack.io/helm-install-timeout annotation on the + // ApplicationDefinition at start-up. + HelmInstallTimeout time.Duration `yaml:"helmInstallTimeout,omitempty"` } // ChartRefConfig references a Flux source artifact for the Helm chart. diff --git a/pkg/registry/apps/application/rest.go b/pkg/registry/apps/application/rest.go index 9b92d2c0..406d3738 100644 --- a/pkg/registry/apps/application/rest.go +++ b/pkg/registry/apps/application/rest.go @@ -1528,16 +1528,17 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (* }, } - // The Kubernetes Application's parent chart creates CAPI/Kamaji resources - // whose admin-kubeconfig Secret is provisioned asynchronously and mounted - // by Deployments in the same chart. On a cold node, Kamaji control-plane - // bootstrap routinely exceeds flux helm-controller's default wait budget, - // so install remediation loops uninstall the Cluster CR and churn. Extend - // the wait budget to 15m for Kubernetes only - other Application kinds - // without this race keep flux defaults, so their failed installs do not - // linger unnecessarily before remediation fires. - if r.kindName == "Kubernetes" { - timeout := metav1.Duration{Duration: 15 * time.Minute} + // Per-Application HelmRelease wait budget. When an ApplicationDefinition + // sets release.cozystack.io/helm-install-timeout, the annotation is + // parsed at startup into ReleaseConfig.HelmInstallTimeout and applied + // to both Install and Upgrade here. Applications that leave it unset + // (the common case) keep flux defaults, so their failed installs + // remediate on the normal cadence. Needed for the Kubernetes kind + // because its parent chart contains CAPI/Kamaji resources whose + // admin-kubeconfig Secret is provisioned asynchronously and Kamaji + // cold-start routinely exceeds flux's default wait budget. + if r.releaseConfig.HelmInstallTimeout > 0 { + timeout := metav1.Duration{Duration: r.releaseConfig.HelmInstallTimeout} helmRelease.Spec.Install.Timeout = &timeout helmRelease.Spec.Upgrade.Timeout = &timeout } diff --git a/pkg/registry/apps/application/rest_timeout_test.go b/pkg/registry/apps/application/rest_timeout_test.go index fa6d5427..8474d1cb 100644 --- a/pkg/registry/apps/application/rest_timeout_test.go +++ b/pkg/registry/apps/application/rest_timeout_test.go @@ -10,7 +10,7 @@ import ( "github.com/cozystack/cozystack/pkg/config" ) -func newRESTForKind(kind, prefix string) *REST { +func newRESTForTimeout(kind, prefix string, helmInstallTimeout time.Duration) *REST { return &REST{ kindName: kind, releaseConfig: config.ReleaseConfig{ @@ -20,76 +20,93 @@ func newRESTForKind(kind, prefix string) *REST { Name: "x", Namespace: "cozy-system", }, + HelmInstallTimeout: helmInstallTimeout, }, } } -func TestConvertApplicationToHelmRelease_KubernetesKindGetsLongTimeout(t *testing.T) { - r := newRESTForKind("Kubernetes", "kubernetes-") - app := &appsv1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, +// Table-driven: every Application kind carries a per-CRD HelmRelease wait +// budget. The Kubernetes kind's parent chart contains CAPI/Kamaji resources +// whose admin-kubeconfig Secret is provisioned asynchronously, so its +// ApplicationDefinition sets release.cozystack.io/helm-install-timeout=15m +// (or longer). Other kinds leave the annotation unset and keep flux defaults +// so their failed installs remediate on the normal cadence. The test must +// cover both paths: a kind with the timeout set and one without. +func TestConvertApplicationToHelmRelease_AppliesReleaseConfigTimeout(t *testing.T) { + cases := []struct { + name string + kind string + prefix string + configured time.Duration + wantSet bool + }{ + { + name: "Kubernetes kind with 15m configured gets Install and Upgrade Timeout", + kind: "Kubernetes", + prefix: "kubernetes-", + configured: 15 * time.Minute, + wantSet: true, + }, + { + name: "Qdrant kind without configured timeout keeps flux defaults", + kind: "Qdrant", + prefix: "qdrant-", + configured: 0, + wantSet: false, + }, + { + name: "arbitrary future kind with 20m configured gets 20m", + kind: "TalosCluster", + prefix: "talos-", + configured: 20 * time.Minute, + wantSet: true, + }, } - hr, err := r.convertApplicationToHelmRelease(app) - if err != nil { - t.Fatalf("convertApplicationToHelmRelease returned error: %v", err) - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := newRESTForTimeout(tc.kind, tc.prefix, tc.configured) + app := &appsv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, + } - if hr.Spec.Install == nil { - t.Fatal("Spec.Install must not be nil") - } - if hr.Spec.Install.Timeout == nil { - t.Fatal("Spec.Install.Timeout must be set for Kubernetes kind") - } - if hr.Spec.Install.Timeout.Duration < 15*time.Minute { - t.Errorf("Spec.Install.Timeout must be >= 15m for Kubernetes, got %v", hr.Spec.Install.Timeout.Duration) - } + hr, err := r.convertApplicationToHelmRelease(app) + if err != nil { + t.Fatalf("convertApplicationToHelmRelease returned error: %v", err) + } - if hr.Spec.Upgrade == nil { - t.Fatal("Spec.Upgrade must not be nil") - } - if hr.Spec.Upgrade.Timeout == nil { - t.Fatal("Spec.Upgrade.Timeout must be set for Kubernetes kind") - } - if hr.Spec.Upgrade.Timeout.Duration < 15*time.Minute { - t.Errorf("Spec.Upgrade.Timeout must be >= 15m for Kubernetes, got %v", hr.Spec.Upgrade.Timeout.Duration) - } + if hr.Spec.Install == nil || hr.Spec.Upgrade == nil { + t.Fatalf("Spec.Install/Upgrade must be non-nil") + } - if hr.Spec.Install.Remediation == nil || hr.Spec.Install.Remediation.Retries != -1 { - t.Errorf("Spec.Install.Remediation.Retries must remain -1, got %+v", hr.Spec.Install.Remediation) - } - if hr.Spec.Upgrade.Remediation == nil || hr.Spec.Upgrade.Remediation.Retries != -1 { - t.Errorf("Spec.Upgrade.Remediation.Retries must remain -1, got %+v", hr.Spec.Upgrade.Remediation) - } -} - -func TestConvertApplicationToHelmRelease_NonKubernetesKindKeepsFluxDefaults(t *testing.T) { - // For Applications whose parent chart has no admin-kubeconfig race - // (Qdrant, MongoDB, Postgres, etc.), do NOT extend the helm-wait - // budget - otherwise failed installs would block three times as long - // before Flux starts remediating. - r := newRESTForKind("Qdrant", "qdrant-") - app := &appsv1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "tenant-root"}, - } - - hr, err := r.convertApplicationToHelmRelease(app) - if err != nil { - t.Fatalf("convertApplicationToHelmRelease returned error: %v", err) - } - - if hr.Spec.Install != nil && hr.Spec.Install.Timeout != nil { - t.Errorf("Spec.Install.Timeout must be unset for non-Kubernetes kinds, got %v", hr.Spec.Install.Timeout.Duration) - } - if hr.Spec.Upgrade != nil && hr.Spec.Upgrade.Timeout != nil { - t.Errorf("Spec.Upgrade.Timeout must be unset for non-Kubernetes kinds, got %v", hr.Spec.Upgrade.Timeout.Duration) - } - - // But remediation must still be -1 across the board. - if hr.Spec.Install.Remediation == nil || hr.Spec.Install.Remediation.Retries != -1 { - t.Errorf("Spec.Install.Remediation.Retries must remain -1, got %+v", hr.Spec.Install.Remediation) - } - if hr.Spec.Upgrade.Remediation == nil || hr.Spec.Upgrade.Remediation.Retries != -1 { - t.Errorf("Spec.Upgrade.Remediation.Retries must remain -1, got %+v", hr.Spec.Upgrade.Remediation) + if tc.wantSet { + if hr.Spec.Install.Timeout == nil { + t.Fatalf("Spec.Install.Timeout must be set when HelmInstallTimeout=%v", tc.configured) + } + if hr.Spec.Install.Timeout.Duration != tc.configured { + t.Errorf("Spec.Install.Timeout = %v, want %v", hr.Spec.Install.Timeout.Duration, tc.configured) + } + if hr.Spec.Upgrade.Timeout == nil { + t.Fatalf("Spec.Upgrade.Timeout must be set when HelmInstallTimeout=%v", tc.configured) + } + if hr.Spec.Upgrade.Timeout.Duration != tc.configured { + t.Errorf("Spec.Upgrade.Timeout = %v, want %v", hr.Spec.Upgrade.Timeout.Duration, tc.configured) + } + } else { + if hr.Spec.Install.Timeout != nil { + t.Errorf("Spec.Install.Timeout must be nil when HelmInstallTimeout=0, got %v", hr.Spec.Install.Timeout.Duration) + } + if hr.Spec.Upgrade.Timeout != nil { + t.Errorf("Spec.Upgrade.Timeout must be nil when HelmInstallTimeout=0, got %v", hr.Spec.Upgrade.Timeout.Duration) + } + } + + if hr.Spec.Install.Remediation == nil || hr.Spec.Install.Remediation.Retries != -1 { + t.Errorf("Spec.Install.Remediation.Retries must remain -1, got %+v", hr.Spec.Install.Remediation) + } + if hr.Spec.Upgrade.Remediation == nil || hr.Spec.Upgrade.Remediation.Retries != -1 { + t.Errorf("Spec.Upgrade.Remediation.Retries must remain -1, got %+v", hr.Spec.Upgrade.Remediation) + } + }) } } From bc5473d4fc9bb1b43be79ea5eb8d370d9e6c00b7 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:11:38 +0300 Subject: [PATCH 15/26] test(kubernetes): chart-wide invariant for admin-kubeconfig guards The per-template unittests in packages/apps/kubernetes/tests/ assert that cluster-autoscaler, kccm, and the csi controller each mount the admin-kubeconfig Secret optional and carry the wait-for-kubeconfig init. That locks in today's three Deployments by name - a fourth Deployment that mounts the same Secret but forgets the guard would slip past them. Add a bats-unit test that renders the entire chart, enumerates every Deployment whose spec mounts a Secret ending in -admin-kubeconfig, and asserts optional:true plus wait-for-kubeconfig init on all of them. Verified by temporarily removing optional:true from csi/deploy.yaml: the test correctly flagged invariant-kcsi-controller as an offender. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/admin-kubeconfig-invariant.bats | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 hack/admin-kubeconfig-invariant.bats diff --git a/hack/admin-kubeconfig-invariant.bats b/hack/admin-kubeconfig-invariant.bats new file mode 100644 index 00000000..d699de83 --- /dev/null +++ b/hack/admin-kubeconfig-invariant.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats +# ----------------------------------------------------------------------------- +# Chart-wide invariant for packages/apps/kubernetes: +# +# Every Deployment in this chart that mounts -admin-kubeconfig as a +# Secret volume MUST: +# - declare that volume optional: true (so kubelet does not FailedMount +# while Kamaji is still provisioning the Secret), AND +# - include the wait-for-kubeconfig init container (so the pod becomes +# Ready only after Kamaji publishes the Secret). +# +# The per-template unittests in packages/apps/kubernetes/tests/ lock in +# today's three Deployments (cluster-autoscaler, kccm, csi controller) by +# name. This invariant is stricter: any future Deployment added to this +# chart that mounts the same Secret but forgets the guard will fail here. +# +# Requires: helm, yq (mikefarah v4+), jq. All three are available on the +# project's CI runners and on the maintainer workstation. +# ----------------------------------------------------------------------------- + +@test "every Deployment mounting admin-kubeconfig has optional:true and wait-for-kubeconfig init" { + values_file="packages/apps/kubernetes/tests/values-ci.yaml" + [ -f "$values_file" ] + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + helm template invariant packages/apps/kubernetes \ + --namespace tenant-root \ + --values "$values_file" \ + 2>/dev/null > "$tmp/rendered.yaml" + + # yq streams one JSON object per input document. jq -s slurps the stream + # into an array so we can treat all Deployments as a single collection. + yq --output-format=json eval-all '.' "$tmp/rendered.yaml" \ + | jq -s --raw-output ' + map(select(.kind == "Deployment")) | + map({ + name: .metadata.name, + volumes: (.spec.template.spec.volumes // []), + initNames: ((.spec.template.spec.initContainers // []) | map(.name)), + }) | + map( + .name as $n | + .initNames as $ins | + (.volumes[] | select(.secret.secretName | test("-admin-kubeconfig$")?)) + | { + name: $n, + optional: (.secret.optional == true), + hasInit: ($ins | index("wait-for-kubeconfig") != null), + } + ) + ' > "$tmp/summary.json" + + # At least one Deployment must match; if a refactor removes every + # admin-kubeconfig volume from this chart, the test must be updated + # deliberately rather than silently passing. + matched=$(jq 'length' "$tmp/summary.json") + [ "$matched" -ge 1 ] + + offenders=$(jq --raw-output '.[] | select(.optional != true or .hasInit != true) | .name' "$tmp/summary.json") + + if [ -n "$offenders" ]; then + echo "Deployments mounting *-admin-kubeconfig without optional:true + wait-for-kubeconfig init:" >&2 + echo "$offenders" >&2 + echo "Full summary:" >&2 + cat "$tmp/summary.json" >&2 + exit 1 + fi + + echo "Invariant holds for $matched Deployment(s)" +} From 03426fbd718029e40d6680f299189e97f915a614 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:13:06 +0300 Subject: [PATCH 16/26] test(hack): pin HelmRelease v2 status shape used by remediation guard run-kubernetes.sh extracts .status.installFailures and .status.upgradeFailures via kubectl -o jsonpath. If a future flux release renames the counters, kubectl returns empty, the guard reports no cycle, and e2e silently misses real remediation loops. Add a bats unit test that feeds a pinned HelmRelease v2 status snippet through the same jsonpath and asserts the extraction still yields the expected values. Also leave a pointer comment in run-kubernetes.sh so a future flux bump surfaces the version coupling in review. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/run-kubernetes.sh | 7 ++++++ hack/remediation-guard.bats | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/hack/e2e-apps/run-kubernetes.sh b/hack/e2e-apps/run-kubernetes.sh index 811f42a7..e6002b53 100644 --- a/hack/e2e-apps/run-kubernetes.sh +++ b/hack/e2e-apps/run-kubernetes.sh @@ -326,6 +326,13 @@ EOF # A non-zero installFailures/upgradeFailures indicates the helm-wait budget expired while # admin-kubeconfig was still being provisioned, which would trigger uninstall remediation # and churn the Cluster CR. + # Flux helm-controller v2 status shape: .status.installFailures and + # .status.upgradeFailures are counters populated by the controller on + # every failed install/upgrade. If a future flux release renames them, + # kubectl returns the empty string and the guard silently passes. The + # shape is pinned by hack/remediation-guard.bats (see that file for + # details), and the vendored API types live under + # vendor/github.com/fluxcd/helm-controller/api/v2. install_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.installFailures}') upgrade_failures=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" -ojsonpath='{.status.upgradeFailures}') if helmrelease_has_remediation_cycle "${install_failures}" "${upgrade_failures}"; then diff --git a/hack/remediation-guard.bats b/hack/remediation-guard.bats index 64aa83af..9c33452a 100644 --- a/hack/remediation-guard.bats +++ b/hack/remediation-guard.bats @@ -85,3 +85,47 @@ helmrelease_has_remediation_cycle "3" "5" || rc=$? [ "$rc" -eq 0 ] } + +@test "installFailures and upgradeFailures extraction pins HR v2 status shape" { + # Pins the Flux HelmRelease v2 status shape that run-kubernetes.sh relies + # on. If a future Flux version renames .status.installFailures (or + # .status.upgradeFailures), kubectl get -o jsonpath returns an empty + # string, the guard quietly says "no cycle", and real remediation loops + # slip past the e2e assertion. + # + # This test uses yq to read the exact path used in the e2e script. yq + # evaluates the same json-ish jsonpath against a pinned HR snippet, so + # the test fails loudly if the field ever disappears or moves. Cross + # reference: vendor/github.com/fluxcd/helm-controller/api/v2/ status + # struct field tags. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + cat > "$tmp/hr.yaml" <<'YAML' +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: kubernetes-test + namespace: tenant-test +spec: + interval: 5m +status: + installFailures: 2 + upgradeFailures: 0 + conditions: + - type: Ready + status: "False" + reason: UninstallSucceeded +YAML + + install_failures=$(yq '.status.installFailures' "$tmp/hr.yaml") + upgrade_failures=$(yq '.status.upgradeFailures' "$tmp/hr.yaml") + + [ "$install_failures" = "2" ] + [ "$upgrade_failures" = "0" ] + + . hack/e2e-apps/remediation-guard.sh + rc=0 + helmrelease_has_remediation_cycle "$install_failures" "$upgrade_failures" || rc=$? + [ "$rc" -eq 0 ] +} From 12632c60c76b77639c8e2edd7ab5bdb248398a7e Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:25:50 +0300 Subject: [PATCH 17/26] fix(kubernetes): gate CP-side Deployments on tenant etcd DataStore The soft-skip wrap in cluster.yaml only silenced Cluster and KamajiControlPlane rendering when _namespace.etcd is empty. The three CP-side Deployments (cluster-autoscaler, kccm, kcsi-controller) still rendered, their wait-for-kubeconfig init containers CrashLoopBackOff'd forever (no KamajiControlPlane = no admin-kubeconfig Secret), HelmRelease hit its 15m wait timeout and triggered the very install remediation cycle the rest of this PR prevents. Self-contradiction. Wrap each of the three Deployment templates in {{- if .Values._namespace.etcd }}...{{- end }} so they render only when there is a DataStore to back them. Add an invariant bats test that renders the whole chart with etcd empty and asserts zero Deployments reference *-admin-kubeconfig. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/admin-kubeconfig-invariant.bats | 45 +++++++++++++++++++ .../cluster-autoscaler/deployment.yaml | 12 +++++ .../apps/kubernetes/templates/csi/deploy.yaml | 2 + .../kubernetes/templates/kccm/manager.yaml | 2 + 4 files changed, 61 insertions(+) diff --git a/hack/admin-kubeconfig-invariant.bats b/hack/admin-kubeconfig-invariant.bats index d699de83..c027e2c4 100644 --- a/hack/admin-kubeconfig-invariant.bats +++ b/hack/admin-kubeconfig-invariant.bats @@ -70,3 +70,48 @@ echo "Invariant holds for $matched Deployment(s)" } + +@test "chart emits zero admin-kubeconfig Deployments when tenant has no etcd DataStore" { + # Without a DataStore (parent Tenant has not populated _namespace.etcd yet) + # the control-plane-side Deployments must NOT render at all. If they did, + # the wait-for-kubeconfig init would CrashLoopBackOff indefinitely - there + # would be no KamajiControlPlane to provision the Secret - consuming the + # HelmRelease wait budget and triggering exactly the remediation cycle the + # rest of this chart tries to avoid. This test renders the whole chart + # with etcd empty and asserts no Deployment references the admin-kubeconfig + # Secret. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + helm template invariant packages/apps/kubernetes \ + --namespace tenant-root \ + --set _namespace.etcd="" \ + --set _namespace.monitoring="" \ + --set _namespace.ingress="" \ + --set _namespace.seaweedfs="" \ + --set _namespace.host="" \ + --set _cluster.cluster-domain=cozy.local \ + --set 'nodeGroups=null' \ + 2>/dev/null > "$tmp/rendered.yaml" + + matched=$( + yq --output-format=json eval-all '.' "$tmp/rendered.yaml" \ + | jq -s ' + map(select(.kind == "Deployment")) | + map(select( + (.spec.template.spec.volumes // []) + | any(.secret.secretName | test("-admin-kubeconfig$")?) + )) | + length + ' + ) + + if [ "$matched" -ne 0 ]; then + echo "Expected zero Deployments mounting *-admin-kubeconfig when etcd is empty, got $matched:" >&2 + yq --output-format=json eval-all '.' "$tmp/rendered.yaml" \ + | jq -s 'map(select(.kind == "Deployment") | .metadata.name)' >&2 + exit 1 + fi + + echo "No admin-kubeconfig Deployments rendered for empty etcd (as expected)" +} diff --git a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml index 348b017d..298d86db 100644 --- a/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml +++ b/packages/apps/kubernetes/templates/cluster-autoscaler/deployment.yaml @@ -1,3 +1,14 @@ +{{- /* + Gate the control-plane-side workloads on the parent tenant having an etcd + DataStore. Without it no KamajiControlPlane is ever created, Kamaji never + provisions -admin-kubeconfig, and rendering these Deployments would cause + the wait-for-kubeconfig init to CrashLoopBackOff indefinitely, consuming + the parent HelmRelease install timeout and triggering the very uninstall + remediation cycle this chart is supposed to avoid. Rendering them only + when $etcd is set keeps the HelmRelease Ready while flux retries on its + interval and picks up the DataStore as soon as the Tenant chart finishes. +*/}} +{{- if .Values._namespace.etcd }} --- apiVersion: apps/v1 kind: Deployment @@ -108,3 +119,4 @@ rules: - list - update - watch +{{- end }} diff --git a/packages/apps/kubernetes/templates/csi/deploy.yaml b/packages/apps/kubernetes/templates/csi/deploy.yaml index c1af7f13..de62104c 100644 --- a/packages/apps/kubernetes/templates/csi/deploy.yaml +++ b/packages/apps/kubernetes/templates/csi/deploy.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} kind: Deployment apiVersion: apps/v1 metadata: @@ -238,3 +239,4 @@ spec: secretName: {{ .Release.Name }}-admin-kubeconfig optional: true name: kubeconfig +{{- end }} diff --git a/packages/apps/kubernetes/templates/kccm/manager.yaml b/packages/apps/kubernetes/templates/kccm/manager.yaml index 20ac0a1e..bd9e2798 100644 --- a/packages/apps/kubernetes/templates/kccm/manager.yaml +++ b/packages/apps/kubernetes/templates/kccm/manager.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} apiVersion: apps/v1 kind: Deployment metadata: @@ -60,3 +61,4 @@ spec: optional: true name: kubeconfig serviceAccountName: {{ .Release.Name }}-kccm +{{- end }} From 1ddd2aaea97024f6803c60d29a946273b55e4185 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:28:11 +0300 Subject: [PATCH 18/26] fix(hack): detect remediation via status.history, not transient counters Flux helm-controller's ClearFailures() zeroes installFailures and upgradeFailures on every successful reconciliation (see the upstream HelmReleaseStatus method). The previous guard ran after the HelmRelease was Ready, at which point the counters were always 0 - the assertion was vacuous and would have passed against a reverted fix. Switch to .status.history, which retains per-revision release Snapshots that survive a subsequent successful reconciliation. A remediation cycle leaves behind a Snapshot with status=uninstalled (the install-remediation code path) or status=failed (Helm release failure that remediation then uninstalled). Either one signals the race actually fired. Rewrite the bats unit tests to cover: empty history, deployed-only, deployed+superseded (happy path - not detected), single failed, single uninstalled, uninstalled-then-deployed, and deployed-then-failed (all detected). The pinned-shape test feeds a realistic HR status snippet through yq the same way run-kubernetes.sh does via kubectl -o jsonpath. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/remediation-guard.sh | 45 +++++--- hack/e2e-apps/run-kubernetes.sh | 24 +++-- hack/remediation-guard.bats | 158 ++++++++++++++--------------- 3 files changed, 120 insertions(+), 107 deletions(-) diff --git a/hack/e2e-apps/remediation-guard.sh b/hack/e2e-apps/remediation-guard.sh index 6da69f13..38463310 100644 --- a/hack/e2e-apps/remediation-guard.sh +++ b/hack/e2e-apps/remediation-guard.sh @@ -1,24 +1,39 @@ # Helpers for asserting that a Flux HelmRelease did not fall into an # install/upgrade remediation cycle during an e2e run. # -# A non-zero installFailures/upgradeFailures counter means flux -# helm-controller hit its wait timeout, ran remediation (uninstall), -# and re-installed. That is exactly the race this guard is meant to -# catch, so the function returns success (0) when a cycle is detected -# and failure (1) otherwise. +# Background: Flux helm-controller's ClearFailures() zeroes +# .status.installFailures / .status.upgradeFailures on every successful +# reconciliation (see the upstream ClearFailures method on +# HelmReleaseStatus). That makes those counters useless for a guard that +# runs after the HelmRelease has reached Ready - the values are always 0. # -# Both arguments may be empty strings, the literal "0", or a positive -# integer. Shell's && and || have equal precedence with left-to-right -# associativity, so each half of the disjunction is grouped explicitly -# to avoid (A && B) || C && D parsing that masks the common -# install_failures=1, upgrade_failures="" case. +# What survives a successful reconciliation is .status.history, a bounded +# list of release Snapshots. Each Snapshot carries a status field that +# tracks the Helm release state: deployed, superseded, failed, uninstalled, +# and so on. A remediation cycle leaves the footprint behind: a snapshot +# with status "uninstalled" (from install/upgrade remediation) or "failed" +# (Helm release failure that remediation then uninstalled). Those stay in +# history even after a subsequent successful reinstall. +# +# helmrelease_has_remediation_cycle takes a newline-delimited list of +# snapshot statuses (whatever the caller extracted via kubectl -o jsonpath +# or equivalent) and returns 0 (detected) when any entry is "failed" or +# "uninstalled", 1 otherwise. Empty input is treated as "no history yet, +# no cycle observed". helmrelease_has_remediation_cycle() { - install_failures="$1" - upgrade_failures="$2" - if { [ -n "${install_failures}" ] && [ "${install_failures}" != "0" ]; } || \ - { [ -n "${upgrade_failures}" ] && [ "${upgrade_failures}" != "0" ]; }; then - return 0 + statuses="$1" + if [ -z "${statuses}" ]; then + return 1 fi + while IFS= read -r status; do + case "${status}" in + failed|uninstalled) + return 0 + ;; + esac + done <&2 + # Flux helm-controller v2 retains per-revision release Snapshots in + # .status.history; each Snapshot's .status reflects the Helm release + # state (deployed/superseded/failed/uninstalled). A remediation cycle + # leaves a "failed" or "uninstalled" entry behind that survives a later + # successful reinstall, unlike the installFailures/upgradeFailures + # counters (which ClearFailures zeroes on every successful reconcile). + # The shape is pinned by hack/remediation-guard.bats; the upstream + # types are github.com/fluxcd/helm-controller/api v2 Snapshot. + history_statuses=$(kubectl get hr -n tenant-test "kubernetes-${test_name}" \ + -ojsonpath='{range .status.history[*]}{.status}{"\n"}{end}') + if helmrelease_has_remediation_cycle "${history_statuses}"; then + echo "Parent HelmRelease entered remediation cycle. History statuses:" >&2 + printf '%s\n' "${history_statuses}" >&2 kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 exit 1 fi diff --git a/hack/remediation-guard.bats b/hack/remediation-guard.bats index 9c33452a..30e4e04c 100644 --- a/hack/remediation-guard.bats +++ b/hack/remediation-guard.bats @@ -2,17 +2,15 @@ # ----------------------------------------------------------------------------- # Unit tests for hack/e2e-apps/remediation-guard.sh # -# helmrelease_has_remediation_cycle is consumed from e2e tests to assert that -# the parent HelmRelease did not hit flux helm-controller's wait timeout and -# enter uninstall remediation. The function accepts two arguments (values of -# .status.installFailures and .status.upgradeFailures) and returns 0 when a -# remediation cycle is detected, 1 otherwise. +# helmrelease_has_remediation_cycle takes a newline-delimited list of +# HelmRelease history snapshot status values (deployed/superseded/failed/ +# uninstalled/...) and returns 0 when any entry is "failed" or "uninstalled" +# (meaning flux helm-controller performed install/upgrade remediation). # -# Each argument can be empty (controller never populated the field), "0" -# (populated but never failed), or a positive integer. Shell's && and || -# have equal precedence with left-to-right associativity, which used to -# break this check on the most common failure mode - install_failures=1 -# and upgrade_failures="". These tests pin the correct behavior. +# This is used by the e2e script after the HelmRelease reaches Ready. The +# failure/upgrade counters (.status.installFailures / .status.upgradeFailures) +# are useless there because flux's ClearFailures zeroes them on successful +# reconciliation; .status.history retains the snapshot trail. # # cozytest.sh's awk parser recognizes only @test blocks and a bare `}` on # its own line; there is no bats `run` or `$status`. Assertions are @@ -21,83 +19,76 @@ # Run with: hack/cozytest.sh hack/remediation-guard.bats # ----------------------------------------------------------------------------- -@test "no counters set returns not-detected" { +@test "empty history returns not-detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "" "" || rc=$? - [ "$rc" -eq 1 ] + if helmrelease_has_remediation_cycle ""; then + echo "expected not-detected for empty history" >&2 + exit 1 + fi } -@test "both counters zero returns not-detected" { +@test "single deployed snapshot returns not-detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "0" "0" || rc=$? - [ "$rc" -eq 1 ] + if helmrelease_has_remediation_cycle "deployed"; then + echo "expected not-detected for deployed-only history" >&2 + exit 1 + fi } -@test "install zero upgrade empty returns not-detected" { +@test "deployed then superseded returns not-detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "0" "" || rc=$? - [ "$rc" -eq 1 ] + statuses=$(printf 'deployed\nsuperseded\n') + if helmrelease_has_remediation_cycle "${statuses}"; then + echo "expected not-detected for deployed+superseded history" >&2 + exit 1 + fi } -@test "install empty upgrade zero returns not-detected" { +@test "single failed snapshot returns detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "" "0" || rc=$? - [ "$rc" -eq 1 ] + if ! helmrelease_has_remediation_cycle "failed"; then + echo "expected detected when history contains failed snapshot" >&2 + exit 1 + fi } -@test "install one upgrade empty returns detected" { - # Canonical race: first install exceeded helm-wait, remediation fired, - # no upgrade has happened yet. +@test "single uninstalled snapshot returns detected" { + # The exact signature of the install-remediation race: the first install + # exceeded flux's wait budget, remediation uninstalled, the next retry + # eventually succeeded. History still carries the uninstalled snapshot. . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "1" "" || rc=$? - [ "$rc" -eq 0 ] + if ! helmrelease_has_remediation_cycle "uninstalled"; then + echo "expected detected when history contains uninstalled snapshot" >&2 + exit 1 + fi } -@test "install empty upgrade one returns detected" { +@test "uninstalled then deployed still returns detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "" "1" || rc=$? - [ "$rc" -eq 0 ] + statuses=$(printf 'uninstalled\ndeployed\n') + if ! helmrelease_has_remediation_cycle "${statuses}"; then + echo "expected detected despite later successful deploy" >&2 + exit 1 + fi } -@test "install two upgrade zero returns detected" { +@test "deployed then failed still returns detected" { . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "2" "0" || rc=$? - [ "$rc" -eq 0 ] + statuses=$(printf 'deployed\nfailed\n') + if ! helmrelease_has_remediation_cycle "${statuses}"; then + echo "expected detected when any entry is failed" >&2 + exit 1 + fi } -@test "install zero upgrade two returns detected" { - . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "0" "2" || rc=$? - [ "$rc" -eq 0 ] -} - -@test "both counters positive returns detected" { - . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "3" "5" || rc=$? - [ "$rc" -eq 0 ] -} - -@test "installFailures and upgradeFailures extraction pins HR v2 status shape" { - # Pins the Flux HelmRelease v2 status shape that run-kubernetes.sh relies - # on. If a future Flux version renames .status.installFailures (or - # .status.upgradeFailures), kubectl get -o jsonpath returns an empty - # string, the guard quietly says "no cycle", and real remediation loops - # slip past the e2e assertion. - # - # This test uses yq to read the exact path used in the e2e script. yq - # evaluates the same json-ish jsonpath against a pinned HR snippet, so - # the test fails loudly if the field ever disappears or moves. Cross - # reference: vendor/github.com/fluxcd/helm-controller/api/v2/ status - # struct field tags. +@test "installFailures extraction pins HR v2 status.history shape" { + # Pins the Flux HelmRelease v2 .status.history[].status shape that + # run-kubernetes.sh relies on. If a future flux release renames the + # field, the jsonpath returns nothing, the guard reports no cycle, + # and real remediation loops slip past the e2e assertion. This test + # uses yq to read the exact path used in the e2e script; the upstream + # Snapshot type lives at + # github.com/fluxcd/helm-controller/api/v2.Snapshot (via go.mod). tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT @@ -107,25 +98,30 @@ kind: HelmRelease metadata: name: kubernetes-test namespace: tenant-test -spec: - interval: 5m status: - installFailures: 2 - upgradeFailures: 0 - conditions: - - type: Ready - status: "False" - reason: UninstallSucceeded + history: + - name: kubernetes-test + namespace: tenant-test + version: 1 + status: uninstalled + - name: kubernetes-test + namespace: tenant-test + version: 2 + status: deployed YAML - install_failures=$(yq '.status.installFailures' "$tmp/hr.yaml") - upgrade_failures=$(yq '.status.upgradeFailures' "$tmp/hr.yaml") + # Default yq output is yaml scalar format, which for string values emits + # bare unquoted tokens - matching what kubectl -o jsonpath produces in + # e2e. Do not switch to JSON output here; that would quote the values + # and break the loop in helmrelease_has_remediation_cycle. + statuses=$(yq '.status.history[].status' "$tmp/hr.yaml") - [ "$install_failures" = "2" ] - [ "$upgrade_failures" = "0" ] + [ -n "$statuses" ] + echo "$statuses" | grep --quiet '^uninstalled$' . hack/e2e-apps/remediation-guard.sh - rc=0 - helmrelease_has_remediation_cycle "$install_failures" "$upgrade_failures" || rc=$? - [ "$rc" -eq 0 ] + if ! helmrelease_has_remediation_cycle "$statuses"; then + echo "expected detected for pinned HR snippet with uninstalled + deployed history" >&2 + exit 1 + fi } From 48312cc3696904235e87fcf2bc33d05e21a77b72 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:29:49 +0300 Subject: [PATCH 19/26] build: wire go-unit-tests into make unit-tests CI runs make unit-tests on every PR, which already covers helm unittests and bats (hack/admin-kubeconfig-invariant.bats and hack/remediation-guard.bats are both picked up by the existing hack/*.bats glob). What was missing was any go test invocation. Add a go-unit-tests target scoped to pkg/registry, pkg/config, and pkg/cmd/server - the cozystack-api surface this repo actually owns and tests in-tree. Running go test ./... pulls in generated-code round-trip suites whose behavior is governed by generator tool versions outside this repo's control; those are better exercised from their own generator workflows. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0e94c5e6..10a55314 100644 --- a/Makefile +++ b/Makefile @@ -82,11 +82,19 @@ test: make -C packages/core/testing apply make -C packages/core/testing test -unit-tests: helm-unit-tests bats-unit-tests +unit-tests: helm-unit-tests bats-unit-tests go-unit-tests helm-unit-tests: hack/helm-unit-tests.sh +# Scoped go test over the cozystack-api surface that this repo owns. Kept +# narrow intentionally - running `go test ./...` pulls in generated code +# round-trip suites whose behavior depends on tool versions outside this +# repo's control (kubebuilder, openapi-gen, etc.) and is better exercised +# from their generator workflows. +go-unit-tests: + go test ./pkg/registry/... ./pkg/config/... ./pkg/cmd/server/... + # Discover every hack/*.bats file that is NOT an e2e test and run it # through cozytest.sh. Drop a new *.bats file in hack/ and it is picked # up automatically on the next `make unit-tests` run. From d2e8f7e86cc7157aa83a7130b3f9857f10518218 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:31:29 +0300 Subject: [PATCH 20/26] chore(kubernetes): drop busybox mirror Containerfile, pin upstream directly The image-busybox Makefile target + images/busybox/Dockerfile wrapper just rebuilt an upstream busybox digest as ghcr.io/cozystack/cozystack/ busybox. Payload is a one-shot sh loop run once per pod; the pinned upstream digest is already immutable, so maintaining a private mirror adds churn (rebuild on every release) for no real hardening benefit. Delete the wrapper and reference docker.io/library/busybox: directly from images/busybox.tag. Document the choice in _helpers.tpl. Also drop the false coupling in the go table test: the "unrelated kind without configured timeout" case used the real kind name Qdrant, which tied the test to the Qdrant ApplicationDefinition for no reason. Switch to a clearly fictional kind so a future Qdrant timeout override does not break this assertion. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/Makefile | 13 +------------ packages/apps/kubernetes/images/busybox/Dockerfile | 1 - packages/apps/kubernetes/templates/_helpers.tpl | 9 ++++++++- pkg/registry/apps/application/rest_timeout_test.go | 10 +++++++--- 4 files changed, 16 insertions(+), 17 deletions(-) delete mode 100644 packages/apps/kubernetes/images/busybox/Dockerfile diff --git a/packages/apps/kubernetes/Makefile b/packages/apps/kubernetes/Makefile index eb23c2ef..4fea42ef 100644 --- a/packages/apps/kubernetes/Makefile +++ b/packages/apps/kubernetes/Makefile @@ -15,7 +15,7 @@ update: hack/update-versions.sh make generate -image: image-ubuntu-container-disk image-kubevirt-cloud-provider image-kubevirt-csi-driver image-cluster-autoscaler image-busybox +image: image-ubuntu-container-disk image-kubevirt-cloud-provider image-kubevirt-csi-driver image-cluster-autoscaler image-ubuntu-container-disk: $(foreach ver,$(KUBERNETES_VERSIONS), \ @@ -71,14 +71,3 @@ image-cluster-autoscaler: > images/cluster-autoscaler.tag rm -f images/cluster-autoscaler.json -image-busybox: - docker buildx build images/busybox \ - --tag $(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG)) \ - --tag $(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG)-$(TAG)) \ - --cache-from type=registry,ref=$(REGISTRY)/busybox:latest \ - --cache-to type=inline \ - --metadata-file images/busybox.json \ - $(BUILDX_ARGS) - echo "$(REGISTRY)/busybox:$(call settag,$(KUBERNETES_PKG_TAG))@$$(yq e '."containerimage.digest"' images/busybox.json -o json -r)" \ - > images/busybox.tag - rm -f images/busybox.json diff --git a/packages/apps/kubernetes/images/busybox/Dockerfile b/packages/apps/kubernetes/images/busybox/Dockerfile deleted file mode 100644 index 0c57fd39..00000000 --- a/packages/apps/kubernetes/images/busybox/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM docker.io/library/busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index 89f06934..e6ad9dcf 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -60,10 +60,17 @@ exits only when super-admin.svc appears, which happens after kubelet's optional-Secret refresh cycle. The 10m deadline stays strictly below the 15m HelmRelease -Install.Timeout scoped to the Kubernetes Application kind so the +Install.Timeout set by cozystack-api for the Kubernetes kind (via the +release.cozystack.io/helm-install-timeout annotation) so the CrashLoopBackOff surfaces before flux remediation fires and uninstalls the Cluster CR. +The pinned busybox image in images/busybox.tag points directly at +docker.io by digest (not mirrored to ghcr.io like the other .tag files +here): the payload is a one-shot sh loop, the digest pin makes the +pull immutable, and the cost of maintaining a private mirror of a tiny +upstream image that does not move often is not worth it. + Call site owns the surrounding volumes block; the kubeconfig volume must exist on the pod and mount at /etc/kubernetes/kubeconfig. */}} diff --git a/pkg/registry/apps/application/rest_timeout_test.go b/pkg/registry/apps/application/rest_timeout_test.go index 8474d1cb..85ac0b97 100644 --- a/pkg/registry/apps/application/rest_timeout_test.go +++ b/pkg/registry/apps/application/rest_timeout_test.go @@ -48,9 +48,13 @@ func TestConvertApplicationToHelmRelease_AppliesReleaseConfigTimeout(t *testing. wantSet: true, }, { - name: "Qdrant kind without configured timeout keeps flux defaults", - kind: "Qdrant", - prefix: "qdrant-", + // Fictional kind on purpose: the test is about the unset path + // regardless of which real Application kind ends up needing a + // timeout override. Using a real kind name would create false + // coupling to that Application's ApplicationDefinition. + name: "unrelated kind without configured timeout keeps flux defaults", + kind: "PlaceholderKindForDefaults", + prefix: "placeholder-", configured: 0, wantSet: false, }, From 64b216e10a2392394add9eb7fbf7e908f4c8864a Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:46:01 +0300 Subject: [PATCH 21/26] fix(kubernetes): gate child HelmReleases on tenant etcd DataStore 17 child HelmReleases (cilium, coredns, csi, cert-manager, metrics-server, ...) referenced *-admin-kubeconfig via kubeConfig.secretRef and rendered even when _namespace.etcd was empty. On an etcd-less tenant each one sat in NotReady forever because the admin-kubeconfig Secret only exists after a KamajiControlPlane reconciles, and KamajiControlPlane now only renders when etcd is set. The outcome contradicted the "beacon only" contract claimed in the soft-skip commit. Extend the existing addon guards to also require _namespace.etcd, and wrap the four unconditional HelmReleases (csi, metrics-server, prometheus-operator-crds, volumesnapshot-crd) plus the always-on cilium/coredns HR resources in the same gate. Add an invariant bats test that renders the whole chart with etcd empty and asserts zero HelmReleases reference *-admin-kubeconfig. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/admin-kubeconfig-invariant.bats | 40 +++++++++++++++++++ .../helmreleases/cert-manager-crds.yaml | 2 +- .../templates/helmreleases/cert-manager.yaml | 2 +- .../templates/helmreleases/cilium.yaml | 2 + .../templates/helmreleases/coredns.yaml | 2 + .../templates/helmreleases/csi.yaml | 2 + .../templates/helmreleases/fluxcd.yaml | 2 +- .../helmreleases/gateway-api-crds.yaml | 2 +- .../templates/helmreleases/gpu-operator.yaml | 2 +- .../templates/helmreleases/ingress-nginx.yaml | 2 +- .../helmreleases/metrics-server.yaml | 2 + .../helmreleases/monitoring-agents.yaml | 2 +- .../prometheus-operator-crds.yaml | 2 + .../templates/helmreleases/velero.yaml | 2 +- .../vertical-pod-autoscaler-crds.yaml | 2 +- .../helmreleases/vertical-pod-autoscaler.yaml | 2 +- .../victoria-metrics-operator.yaml | 2 +- .../helmreleases/volumesnapshot-crd.yaml | 2 + 18 files changed, 63 insertions(+), 11 deletions(-) diff --git a/hack/admin-kubeconfig-invariant.bats b/hack/admin-kubeconfig-invariant.bats index c027e2c4..4c809c41 100644 --- a/hack/admin-kubeconfig-invariant.bats +++ b/hack/admin-kubeconfig-invariant.bats @@ -115,3 +115,43 @@ echo "No admin-kubeconfig Deployments rendered for empty etcd (as expected)" } + +@test "chart emits zero admin-kubeconfig HelmReleases when tenant has no etcd DataStore" { + # Same principle as the Deployment variant above, extended to every child + # HelmRelease (cilium, coredns, csi, cert-manager, ...). They reference + # *-admin-kubeconfig via kubeConfig.secretRef and would otherwise sit in + # NotReady forever on an etcd-less tenant, polluting the HelmRelease list + # the operator sees and contradicting the "awaiting-etcd beacon only" + # contract of the soft-skip path. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + helm template invariant packages/apps/kubernetes \ + --namespace tenant-root \ + --set _namespace.etcd="" \ + --set _namespace.monitoring="" \ + --set _namespace.ingress="" \ + --set _namespace.seaweedfs="" \ + --set _namespace.host="" \ + --set _cluster.cluster-domain=cozy.local \ + --set 'nodeGroups=null' \ + 2>/dev/null > "$tmp/rendered.yaml" + + matched=$( + yq --output-format=json eval-all '.' "$tmp/rendered.yaml" \ + | jq -s ' + map(select(.kind == "HelmRelease")) | + map(select(.spec.kubeConfig.secretRef.name | test("-admin-kubeconfig$")?)) | + length + ' + ) + + if [ "$matched" -ne 0 ]; then + echo "Expected zero HelmReleases referencing *-admin-kubeconfig when etcd is empty, got $matched:" >&2 + yq --output-format=json eval-all '.' "$tmp/rendered.yaml" \ + | jq -s 'map(select(.kind == "HelmRelease") | .metadata.name)' >&2 + exit 1 + fi + + echo "No admin-kubeconfig HelmReleases rendered for empty etcd (as expected)" +} diff --git a/packages/apps/kubernetes/templates/helmreleases/cert-manager-crds.yaml b/packages/apps/kubernetes/templates/helmreleases/cert-manager-crds.yaml index be07a8b9..fd9dac7e 100644 --- a/packages/apps/kubernetes/templates/helmreleases/cert-manager-crds.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/cert-manager-crds.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.certManager.enabled }} +{{- if and .Values.addons.certManager.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/cert-manager.yaml b/packages/apps/kubernetes/templates/helmreleases/cert-manager.yaml index 6857581a..700b666e 100644 --- a/packages/apps/kubernetes/templates/helmreleases/cert-manager.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/cert-manager.yaml @@ -8,7 +8,7 @@ cert-manager: {{- end }} {{- end }} -{{- if .Values.addons.certManager.enabled }} +{{- if and .Values.addons.certManager.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/cilium.yaml b/packages/apps/kubernetes/templates/helmreleases/cilium.yaml index d032f5b6..d8c90cbf 100644 --- a/packages/apps/kubernetes/templates/helmreleases/cilium.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/cilium.yaml @@ -14,6 +14,7 @@ cilium: {{- end }} {{- end }} +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -55,3 +56,4 @@ spec: - name: {{ .Release.Name }}-gateway-api-crds namespace: {{ .Release.Namespace }} {{- end }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/helmreleases/coredns.yaml b/packages/apps/kubernetes/templates/helmreleases/coredns.yaml index bdb6c682..0711a51d 100644 --- a/packages/apps/kubernetes/templates/helmreleases/coredns.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/coredns.yaml @@ -4,6 +4,7 @@ coredns: clusterIP: "10.95.0.10" {{- end }} +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -42,3 +43,4 @@ spec: {{- end }} - name: {{ .Release.Name }}-cilium namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/helmreleases/csi.yaml b/packages/apps/kubernetes/templates/helmreleases/csi.yaml index dd2c69a6..109d78e1 100644 --- a/packages/apps/kubernetes/templates/helmreleases/csi.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/csi.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -39,3 +40,4 @@ spec: - name: {{ .Release.Name }} namespace: {{ .Release.Namespace }} {{- end }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/helmreleases/fluxcd.yaml b/packages/apps/kubernetes/templates/helmreleases/fluxcd.yaml index 76499dfe..25fff01c 100644 --- a/packages/apps/kubernetes/templates/helmreleases/fluxcd.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/fluxcd.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.fluxcd.enabled }} +{{- if and .Values.addons.fluxcd.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/gateway-api-crds.yaml b/packages/apps/kubernetes/templates/helmreleases/gateway-api-crds.yaml index 2bcc8d4d..b4172ed1 100644 --- a/packages/apps/kubernetes/templates/helmreleases/gateway-api-crds.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/gateway-api-crds.yaml @@ -1,4 +1,4 @@ -{{- if $.Values.addons.gatewayAPI.enabled }} +{{- if and $.Values.addons.gatewayAPI.enabled $.Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/gpu-operator.yaml b/packages/apps/kubernetes/templates/helmreleases/gpu-operator.yaml index 5ef48912..e243bfad 100644 --- a/packages/apps/kubernetes/templates/helmreleases/gpu-operator.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/gpu-operator.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.gpuOperator.enabled }} +{{- if and .Values.addons.gpuOperator.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/ingress-nginx.yaml b/packages/apps/kubernetes/templates/helmreleases/ingress-nginx.yaml index 5cafff90..6e2183d3 100644 --- a/packages/apps/kubernetes/templates/helmreleases/ingress-nginx.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/ingress-nginx.yaml @@ -20,7 +20,7 @@ ingress-nginx: node-role.kubernetes.io/ingress-nginx: "" {{- end }} -{{- if .Values.addons.ingressNginx.enabled }} +{{- if and .Values.addons.ingressNginx.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/metrics-server.yaml b/packages/apps/kubernetes/templates/helmreleases/metrics-server.yaml index 3cc81a14..3e6f9660 100644 --- a/packages/apps/kubernetes/templates/helmreleases/metrics-server.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/metrics-server.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -36,3 +37,4 @@ spec: namespace: {{ .Release.Namespace }} - name: {{ .Release.Name }}-prometheus-operator-crds namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/helmreleases/monitoring-agents.yaml b/packages/apps/kubernetes/templates/helmreleases/monitoring-agents.yaml index ea84dec0..a811f7dd 100644 --- a/packages/apps/kubernetes/templates/helmreleases/monitoring-agents.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/monitoring-agents.yaml @@ -1,6 +1,6 @@ {{- $targetTenant := .Values._namespace.monitoring }} {{- $clusterDomain := (index .Values._cluster "cluster-domain") | default "cozy.local" }} -{{- if .Values.addons.monitoringAgents.enabled }} +{{- if and .Values.addons.monitoringAgents.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/prometheus-operator-crds.yaml b/packages/apps/kubernetes/templates/helmreleases/prometheus-operator-crds.yaml index 600a7994..3038a058 100644 --- a/packages/apps/kubernetes/templates/helmreleases/prometheus-operator-crds.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/prometheus-operator-crds.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -31,3 +32,4 @@ spec: - name: {{ .Release.Name }} namespace: {{ .Release.Namespace }} {{- end }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/helmreleases/velero.yaml b/packages/apps/kubernetes/templates/helmreleases/velero.yaml index ad236d53..781b9c49 100644 --- a/packages/apps/kubernetes/templates/helmreleases/velero.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/velero.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.velero.enabled }} +{{- if and .Values.addons.velero.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler-crds.yaml b/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler-crds.yaml index a3b7a9b4..55a5faac 100644 --- a/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler-crds.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler-crds.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.monitoringAgents.enabled }} +{{- if and .Values.addons.monitoringAgents.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler.yaml b/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler.yaml index 178df3e3..74fb5a39 100644 --- a/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/vertical-pod-autoscaler.yaml @@ -24,7 +24,7 @@ vertical-pod-autoscaler: memory: 1600Mi {{- end }} -{{- if .Values.addons.monitoringAgents.enabled }} +{{- if and .Values.addons.monitoringAgents.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/victoria-metrics-operator.yaml b/packages/apps/kubernetes/templates/helmreleases/victoria-metrics-operator.yaml index 99744277..7302f8f5 100644 --- a/packages/apps/kubernetes/templates/helmreleases/victoria-metrics-operator.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/victoria-metrics-operator.yaml @@ -1,4 +1,4 @@ -{{- if .Values.addons.monitoringAgents.enabled }} +{{- if and .Values.addons.monitoringAgents.enabled .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: diff --git a/packages/apps/kubernetes/templates/helmreleases/volumesnapshot-crd.yaml b/packages/apps/kubernetes/templates/helmreleases/volumesnapshot-crd.yaml index 025f01b7..d50fd93c 100644 --- a/packages/apps/kubernetes/templates/helmreleases/volumesnapshot-crd.yaml +++ b/packages/apps/kubernetes/templates/helmreleases/volumesnapshot-crd.yaml @@ -1,3 +1,4 @@ +{{- if .Values._namespace.etcd }} apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -33,3 +34,4 @@ spec: - name: {{ .Release.Name }} namespace: {{ .Release.Namespace }} {{- end }} +{{- end }} From 6072723e1e03c0072fe3bd83e2a5723c50d6ebd8 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:47:19 +0300 Subject: [PATCH 22/26] feat(config): extract + test annotation-timeout parser with Flux unit gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the release.cozystack.io/helm-install-timeout parsing out of start.go into ParseHelmInstallTimeoutAnnotation in pkg/config. The helper rejects units that time.ParseDuration accepts but Flux helm-controller rejects (ns, us, µs): feeding one of those through would cause the HelmRelease admission webhook to reject the object at install time, giving a silent drop to flux defaults that is hard to debug. Fail loudly at cozystack-api startup instead. Adds a table-driven unit test covering: unset (empty), accepted units ms/s/m/h, compound 2h30m, decimal 1.5m, and the rejected shapes (bare digits, garbage, negative, ns/us/µs). The test lives in pkg/config so it runs under the existing go-unit-tests make target. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- pkg/cmd/server/start.go | 23 ++++---- pkg/config/config.go | 42 +++++++++++++- pkg/config/config_test.go | 119 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index 3e905735..7ebbbc89 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -174,17 +174,20 @@ func (o *CozyServerOptions) Complete() error { // the chart itself depends on (for example, the Kamaji-provisioned // admin-kubeconfig Secret for Kubernetes tenants) need a longer // wait budget than the Flux default. Consumed by the REST storage - // layer when building the HelmRelease Spec. - if raw, ok := crd.Annotations["release.cozystack.io/helm-install-timeout"]; ok && raw != "" { - d, err := time.ParseDuration(raw) - if err != nil { - return fmt.Errorf( - "ApplicationDefinition %q has invalid release.cozystack.io/helm-install-timeout %q: %w", - crd.Name, raw, err, - ) - } - release.HelmInstallTimeout = d + // layer when building the HelmRelease Spec. The parser rejects + // units Flux would reject at webhook time, so a bad annotation + // surfaces as a loud startup failure instead of a silent drop to + // defaults. + d, err := config.ParseHelmInstallTimeoutAnnotation( + crd.Annotations[config.HelmInstallTimeoutAnnotation], + ) + if err != nil { + return fmt.Errorf( + "ApplicationDefinition %q has invalid %s annotation: %w", + crd.Name, config.HelmInstallTimeoutAnnotation, err, + ) } + release.HelmInstallTimeout = d resource := config.Resource{ Application: config.ApplicationConfig{ Kind: crd.Spec.Application.Kind, diff --git a/pkg/config/config.go b/pkg/config/config.go index 16cf4f0c..21de27b2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,7 +16,47 @@ limitations under the License. package config -import "time" +import ( + "fmt" + "regexp" + "time" +) + +// HelmInstallTimeoutAnnotation is the ApplicationDefinition metadata +// annotation key that overrides the Flux HelmRelease Install.Timeout and +// Upgrade.Timeout for a given Application kind. +const HelmInstallTimeoutAnnotation = "release.cozystack.io/helm-install-timeout" + +// helmTimeoutPattern mirrors the CRD validation pattern used by Flux +// helm-controller on HelmReleaseSpec.Install.Timeout (ms/s/m/h units only). +// time.ParseDuration accepts ns/us/µs, but Flux rejects them - parsing here +// with the same shape avoids feeding the controller a value it will later +// reject at webhook time. See +// github.com/fluxcd/helm-controller/api/v2 HelmReleaseSpec.Install.Timeout +// in the go module cache. +var helmTimeoutPattern = regexp.MustCompile(`^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$`) + +// ParseHelmInstallTimeoutAnnotation parses the value of the +// release.cozystack.io/helm-install-timeout annotation. The empty string is +// treated as "unset" and returns (0, nil) so callers can leave +// HelmInstallTimeout zeroed and let flux defaults apply. Values accepted by +// time.ParseDuration but rejected by Flux (ns/us/µs) return a helpful +// error instead of silently parsing and failing later at HelmRelease +// admission. +func ParseHelmInstallTimeoutAnnotation(raw string) (time.Duration, error) { + if raw == "" { + return 0, nil + } + if !helmTimeoutPattern.MatchString(raw) { + return 0, fmt.Errorf("must match %s (Flux accepts ms/s/m/h units only), got %q", + helmTimeoutPattern, raw) + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("time.ParseDuration(%q): %w", raw, err) + } + return d, nil +} // ResourceConfig represents the structure of the configuration file. type ResourceConfig struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..eb6990af --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +// Cover the annotation parser used by cozystack-api at startup. The parser +// is consumed by pkg/cmd/server/start.go on every ApplicationDefinition; a +// typo here silently drops back to flux defaults and the Kubernetes tenant +// race described in cozystack#2412 reappears, so the table must exercise: +// - the unset path (empty string treated as "no override"), +// - every unit Flux accepts (ms, s, m, h), +// - compound forms (the CRD pattern accepts repeats), +// - units time.ParseDuration accepts but Flux rejects (ns, us, µs), +// - outright garbage. +func TestParseHelmInstallTimeoutAnnotation(t *testing.T) { + cases := []struct { + name string + input string + want time.Duration + wantErr bool + errMatch string + }{ + { + name: "empty string leaves flux defaults in place", + input: "", + want: 0, + }, + { + name: "minutes", + input: "15m", + want: 15 * time.Minute, + }, + { + name: "hours", + input: "1h", + want: time.Hour, + }, + { + name: "seconds", + input: "45s", + want: 45 * time.Second, + }, + { + name: "milliseconds", + input: "500ms", + want: 500 * time.Millisecond, + }, + { + name: "compound hour and minutes", + input: "2h30m", + want: 2*time.Hour + 30*time.Minute, + }, + { + name: "decimal minutes", + input: "1.5m", + want: 90 * time.Second, + }, + { + name: "nanoseconds rejected - Flux CRD pattern excludes ns", + input: "500ns", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + { + name: "microseconds rejected - Flux CRD pattern excludes us", + input: "500us", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + { + name: "microseconds unicode rejected", + input: "500µs", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + { + name: "bare digits rejected", + input: "15", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + { + name: "garbage rejected", + input: "abc", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + { + name: "negative rejected", + input: "-15m", + wantErr: true, + errMatch: "Flux accepts ms/s/m/h units only", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseHelmInstallTimeoutAnnotation(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got duration=%v", got) + } + if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) { + t.Errorf("error %q does not contain %q", err.Error(), tc.errMatch) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} From b8aec9a9731885cae9217271381680f3e274f46f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 21:50:10 +0300 Subject: [PATCH 23/26] fix(kubernetes): history guard non-empty check + nits from review - Log .status.history regardless of content so a silently empty result (Flux field rename) shows up in CI logs, and treat empty history on a Ready HelmRelease as a distinct failure. A Ready HR by definition has at least one snapshot; anything else is a shape-drift signal. - Replace the unquoted heredoc in remediation-guard.sh with a printf | grep pipeline. printf %s treats statuses as literal payload (no $ expansion surprises for future callers), grep --quiet --extended-regexp returns exit status the caller can forward directly. - Share the etcd-absent values file between both invariant tests (packages/apps/kubernetes/tests/values-ci-no-etcd.yaml) instead of duplicating the --set block. - Fix typo "override applied" -> "override is applied" in the Kubernetes ApplicationDefinition. - Add a coupling comment in the ApplicationDefinition annotation that points at the wait-for-kubeconfig init deadline in _helpers.tpl, so a future operator raising the HR timeout updates the init deadline too. - Clarify the per-annotation timeout comment in rest.go so it stops implying the feature is Kubernetes-only (it is not - only today's one user is). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/admin-kubeconfig-invariant.bats | 16 ++------------ hack/e2e-apps/remediation-guard.sh | 18 ++++++++-------- hack/e2e-apps/run-kubernetes.sh | 12 +++++++++-- .../kubernetes/tests/values-ci-no-etcd.yaml | 9 ++++++++ .../kubernetes-rd/cozyrds/kubernetes.yaml | 8 ++++++- pkg/registry/apps/application/rest.go | 21 +++++++++++-------- 6 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 packages/apps/kubernetes/tests/values-ci-no-etcd.yaml diff --git a/hack/admin-kubeconfig-invariant.bats b/hack/admin-kubeconfig-invariant.bats index 4c809c41..a4b98b00 100644 --- a/hack/admin-kubeconfig-invariant.bats +++ b/hack/admin-kubeconfig-invariant.bats @@ -85,13 +85,7 @@ helm template invariant packages/apps/kubernetes \ --namespace tenant-root \ - --set _namespace.etcd="" \ - --set _namespace.monitoring="" \ - --set _namespace.ingress="" \ - --set _namespace.seaweedfs="" \ - --set _namespace.host="" \ - --set _cluster.cluster-domain=cozy.local \ - --set 'nodeGroups=null' \ + --values packages/apps/kubernetes/tests/values-ci-no-etcd.yaml \ 2>/dev/null > "$tmp/rendered.yaml" matched=$( @@ -128,13 +122,7 @@ helm template invariant packages/apps/kubernetes \ --namespace tenant-root \ - --set _namespace.etcd="" \ - --set _namespace.monitoring="" \ - --set _namespace.ingress="" \ - --set _namespace.seaweedfs="" \ - --set _namespace.host="" \ - --set _cluster.cluster-domain=cozy.local \ - --set 'nodeGroups=null' \ + --values packages/apps/kubernetes/tests/values-ci-no-etcd.yaml \ 2>/dev/null > "$tmp/rendered.yaml" matched=$( diff --git a/hack/e2e-apps/remediation-guard.sh b/hack/e2e-apps/remediation-guard.sh index 38463310..b45561b9 100644 --- a/hack/e2e-apps/remediation-guard.sh +++ b/hack/e2e-apps/remediation-guard.sh @@ -26,14 +26,14 @@ helmrelease_has_remediation_cycle() { if [ -z "${statuses}" ]; then return 1 fi - while IFS= read -r status; do - case "${status}" in - failed|uninstalled) - return 0 - ;; - esac - done <}" + if [ -z "${history_statuses}" ]; then + echo "Unexpected empty .status.history on a Ready HelmRelease - Flux API shape may have changed." >&2 + kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 + exit 1 + fi if helmrelease_has_remediation_cycle "${history_statuses}"; then - echo "Parent HelmRelease entered remediation cycle. History statuses:" >&2 - printf '%s\n' "${history_statuses}" >&2 + echo "Parent HelmRelease entered remediation cycle." >&2 kubectl -n tenant-test describe hr "kubernetes-${test_name}" >&2 exit 1 fi diff --git a/packages/apps/kubernetes/tests/values-ci-no-etcd.yaml b/packages/apps/kubernetes/tests/values-ci-no-etcd.yaml new file mode 100644 index 00000000..c7c8196f --- /dev/null +++ b/packages/apps/kubernetes/tests/values-ci-no-etcd.yaml @@ -0,0 +1,9 @@ +_namespace: + etcd: "" + monitoring: "" + ingress: "" + seaweedfs: "" + host: "" +_cluster: + cluster-domain: cozy.local +nodeGroups: null diff --git a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml index 1128abe8..aaa01247 100644 --- a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml +++ b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml @@ -7,8 +7,14 @@ metadata: # Secret is provisioned asynchronously. Cold Kamaji start (image pull + # etcd + apiserver Ready) plus admin-kubeconfig generation can exceed # Flux helm-controller's default wait budget, causing remediation loops - # that uninstall the Cluster CR. This override applied by cozystack-api + # that uninstall the Cluster CR. This override is applied by cozystack-api # to the HelmRelease Spec.Install.Timeout and Spec.Upgrade.Timeout. + # + # Coupling: the wait-for-kubeconfig init container in + # packages/apps/kubernetes/templates/_helpers.tpl hard-codes a 10m + # deadline chosen to stay strictly below this value so the pod's + # CrashLoopBackOff surfaces before flux remediation fires. If this + # annotation is raised, update that init deadline correspondingly. release.cozystack.io/helm-install-timeout: "15m" spec: application: diff --git a/pkg/registry/apps/application/rest.go b/pkg/registry/apps/application/rest.go index 406d3738..d233ecc3 100644 --- a/pkg/registry/apps/application/rest.go +++ b/pkg/registry/apps/application/rest.go @@ -1528,15 +1528,18 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (* }, } - // Per-Application HelmRelease wait budget. When an ApplicationDefinition - // sets release.cozystack.io/helm-install-timeout, the annotation is - // parsed at startup into ReleaseConfig.HelmInstallTimeout and applied - // to both Install and Upgrade here. Applications that leave it unset - // (the common case) keep flux defaults, so their failed installs - // remediate on the normal cadence. Needed for the Kubernetes kind - // because its parent chart contains CAPI/Kamaji resources whose - // admin-kubeconfig Secret is provisioned asynchronously and Kamaji - // cold-start routinely exceeds flux's default wait budget. + // Per-Application HelmRelease wait budget. The mechanism is generic: + // an ApplicationDefinition that sets + // release.cozystack.io/helm-install-timeout gets Install.Timeout and + // Upgrade.Timeout populated from ReleaseConfig.HelmInstallTimeout + // (parsed at startup). Applications that leave it unset keep flux + // defaults so their failed installs remediate on the normal cadence. + // Today only kubernetes-rd carries the annotation because the + // Kubernetes Application's parent chart contains CAPI/Kamaji + // resources whose admin-kubeconfig Secret is provisioned + // asynchronously and Kamaji cold-start routinely exceeds flux's + // default wait budget; any future kind with the same shape can opt + // in by setting the same annotation. if r.releaseConfig.HelmInstallTimeout > 0 { timeout := metav1.Duration{Duration: r.releaseConfig.HelmInstallTimeout} helmRelease.Spec.Install.Timeout = &timeout From 39b8f0252b6fb07d510c83aacf48fc20d7fdd96c Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Thu, 16 Apr 2026 22:32:24 +0300 Subject: [PATCH 24/26] test(hack): rename remediation-guard bats test to match what it pins The test body asserts .status.history[].status extraction, but the test name still referenced the old installFailures counter (leftover from when the guard used that field before switching to status.history to avoid ClearFailures zeroing the counters on successful reconcile). Address review feedback from coderabbitai on hack/remediation-guard.bats:84: rename so grep for what the test actually pins matches the test name. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/remediation-guard.bats | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hack/remediation-guard.bats b/hack/remediation-guard.bats index 30e4e04c..092fe06d 100644 --- a/hack/remediation-guard.bats +++ b/hack/remediation-guard.bats @@ -81,7 +81,7 @@ fi } -@test "installFailures extraction pins HR v2 status.history shape" { +@test "status.history extraction pins HR v2 status.history shape" { # Pins the Flux HelmRelease v2 .status.history[].status shape that # run-kubernetes.sh relies on. If a future flux release renames the # field, the jsonpath returns nothing, the guard reports no cycle, From 37ecd7b3af9f96ef8cbcb097b8b88a4ffbcef548 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 27 Apr 2026 13:50:08 +0300 Subject: [PATCH 25/26] feat(kubernetes): make wait-for-kubeconfig image overridable for air-gapped registries Operators in air-gapped or rate-limited environments cannot reach docker.io and the bundled busybox digest pin gives them no escape hatch. Add an optional images.waitForKubeconfig chart value that, when set, replaces the helper's image reference with any registry path kubelet can pull. Empty value falls back to images/busybox.tag, so the prior digest-pinned default is preserved. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- api/apps/v1alpha1/kubernetes/types.go | 9 ++++ packages/apps/kubernetes/README.md | 52 ++++++++++--------- .../apps/kubernetes/templates/_helpers.tpl | 12 +++-- .../tests/admin_kubeconfig_wait_test.yaml | 23 ++++++++ packages/apps/kubernetes/values.schema.json | 12 +++++ packages/apps/kubernetes/values.yaml | 7 +++ .../kubernetes-rd/cozyrds/kubernetes.yaml | 4 +- 7 files changed, 87 insertions(+), 32 deletions(-) diff --git a/api/apps/v1alpha1/kubernetes/types.go b/api/apps/v1alpha1/kubernetes/types.go index 774fcdc5..ea4a2b64 100644 --- a/api/apps/v1alpha1/kubernetes/types.go +++ b/api/apps/v1alpha1/kubernetes/types.go @@ -36,6 +36,9 @@ type ConfigSpec struct { // Kubernetes control-plane configuration. // +kubebuilder:default:={} ControlPlane ControlPlane `json:"controlPlane"` + // Optional image overrides for air-gapped or rate-limited registries. + // +kubebuilder:default:={} + Images Images `json:"images"` } type APIServer struct { @@ -157,6 +160,12 @@ type GatewayAPIAddon struct { Enabled bool `json:"enabled"` } +type Images struct { + // Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag. + // +kubebuilder:default:="" + WaitForKubeconfig string `json:"waitForKubeconfig,omitempty"` +} + type IngressNginxAddon struct { // Enable the controller (requires nodes labeled `ingress-nginx`). // +kubebuilder:default:=false diff --git a/packages/apps/kubernetes/README.md b/packages/apps/kubernetes/README.md index d62d3a39..ecbbc31b 100644 --- a/packages/apps/kubernetes/README.md +++ b/packages/apps/kubernetes/README.md @@ -145,31 +145,33 @@ See the reference for components utilized in this service: ### Kubernetes Control Plane Configuration -| Name | Description | Type | Value | -| --------------------------------------------------- | ------------------------------------------------ | ---------- | ------- | -| `controlPlane` | Kubernetes control-plane configuration. | `object` | `{}` | -| `controlPlane.replicas` | Number of control-plane replicas. | `int` | `2` | -| `controlPlane.apiServer` | API Server configuration. | `object` | `{}` | -| `controlPlane.apiServer.resources` | CPU and memory resources for API Server. | `object` | `{}` | -| `controlPlane.apiServer.resources.cpu` | CPU available. | `quantity` | `""` | -| `controlPlane.apiServer.resources.memory` | Memory (RAM) available. | `quantity` | `""` | -| `controlPlane.apiServer.resourcesPreset` | Preset if `resources` omitted. | `string` | `large` | -| `controlPlane.controllerManager` | Controller Manager configuration. | `object` | `{}` | -| `controlPlane.controllerManager.resources` | CPU and memory resources for Controller Manager. | `object` | `{}` | -| `controlPlane.controllerManager.resources.cpu` | CPU available. | `quantity` | `""` | -| `controlPlane.controllerManager.resources.memory` | Memory (RAM) available. | `quantity` | `""` | -| `controlPlane.controllerManager.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | -| `controlPlane.scheduler` | Scheduler configuration. | `object` | `{}` | -| `controlPlane.scheduler.resources` | CPU and memory resources for Scheduler. | `object` | `{}` | -| `controlPlane.scheduler.resources.cpu` | CPU available. | `quantity` | `""` | -| `controlPlane.scheduler.resources.memory` | Memory (RAM) available. | `quantity` | `""` | -| `controlPlane.scheduler.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | -| `controlPlane.konnectivity` | Konnectivity configuration. | `object` | `{}` | -| `controlPlane.konnectivity.server` | Konnectivity Server configuration. | `object` | `{}` | -| `controlPlane.konnectivity.server.resources` | CPU and memory resources for Konnectivity. | `object` | `{}` | -| `controlPlane.konnectivity.server.resources.cpu` | CPU available. | `quantity` | `""` | -| `controlPlane.konnectivity.server.resources.memory` | Memory (RAM) available. | `quantity` | `""` | -| `controlPlane.konnectivity.server.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | +| Name | Description | Type | Value | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------- | ------- | +| `controlPlane` | Kubernetes control-plane configuration. | `object` | `{}` | +| `controlPlane.replicas` | Number of control-plane replicas. | `int` | `2` | +| `controlPlane.apiServer` | API Server configuration. | `object` | `{}` | +| `controlPlane.apiServer.resources` | CPU and memory resources for API Server. | `object` | `{}` | +| `controlPlane.apiServer.resources.cpu` | CPU available. | `quantity` | `""` | +| `controlPlane.apiServer.resources.memory` | Memory (RAM) available. | `quantity` | `""` | +| `controlPlane.apiServer.resourcesPreset` | Preset if `resources` omitted. | `string` | `large` | +| `controlPlane.controllerManager` | Controller Manager configuration. | `object` | `{}` | +| `controlPlane.controllerManager.resources` | CPU and memory resources for Controller Manager. | `object` | `{}` | +| `controlPlane.controllerManager.resources.cpu` | CPU available. | `quantity` | `""` | +| `controlPlane.controllerManager.resources.memory` | Memory (RAM) available. | `quantity` | `""` | +| `controlPlane.controllerManager.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | +| `controlPlane.scheduler` | Scheduler configuration. | `object` | `{}` | +| `controlPlane.scheduler.resources` | CPU and memory resources for Scheduler. | `object` | `{}` | +| `controlPlane.scheduler.resources.cpu` | CPU available. | `quantity` | `""` | +| `controlPlane.scheduler.resources.memory` | Memory (RAM) available. | `quantity` | `""` | +| `controlPlane.scheduler.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | +| `controlPlane.konnectivity` | Konnectivity configuration. | `object` | `{}` | +| `controlPlane.konnectivity.server` | Konnectivity Server configuration. | `object` | `{}` | +| `controlPlane.konnectivity.server.resources` | CPU and memory resources for Konnectivity. | `object` | `{}` | +| `controlPlane.konnectivity.server.resources.cpu` | CPU available. | `quantity` | `""` | +| `controlPlane.konnectivity.server.resources.memory` | Memory (RAM) available. | `quantity` | `""` | +| `controlPlane.konnectivity.server.resourcesPreset` | Preset if `resources` omitted. | `string` | `micro` | +| `images` | Optional image overrides for air-gapped or rate-limited registries. | `object` | `{}` | +| `images.waitForKubeconfig` | Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag. | `string` | `""` | ## Parameter examples and reference diff --git a/packages/apps/kubernetes/templates/_helpers.tpl b/packages/apps/kubernetes/templates/_helpers.tpl index e6ad9dcf..40a8ae7a 100644 --- a/packages/apps/kubernetes/templates/_helpers.tpl +++ b/packages/apps/kubernetes/templates/_helpers.tpl @@ -65,18 +65,20 @@ release.cozystack.io/helm-install-timeout annotation) so the CrashLoopBackOff surfaces before flux remediation fires and uninstalls the Cluster CR. -The pinned busybox image in images/busybox.tag points directly at +The default image lives in images/busybox.tag and points directly at docker.io by digest (not mirrored to ghcr.io like the other .tag files -here): the payload is a one-shot sh loop, the digest pin makes the -pull immutable, and the cost of maintaining a private mirror of a tiny -upstream image that does not move often is not worth it. +here): the payload is a one-shot sh loop and the digest pin makes the +pull immutable. Operators in air-gapped or rate-limited environments +can override it via .Values.images.waitForKubeconfig (any registry +reference kubelet can pull). When the value is empty the chart falls +back to the bundled digest pin, preserving the prior default. Call site owns the surrounding volumes block; the kubeconfig volume must exist on the pod and mount at /etc/kubernetes/kubeconfig. */}} {{- define "kubernetes.waitForAdminKubeconfig" -}} - name: wait-for-kubeconfig - image: "{{ .Files.Get "images/busybox.tag" | trim }}" + image: "{{ default (.Files.Get "images/busybox.tag" | trim) .Values.images.waitForKubeconfig }}" command: - sh - -c diff --git a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml index e7c03bd1..507e00af 100644 --- a/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml +++ b/packages/apps/kubernetes/tests/admin_kubeconfig_wait_test.yaml @@ -95,6 +95,29 @@ tests: mountPath: /etc/kubernetes/kubeconfig readOnly: true + - it: wait-for-kubeconfig defaults to bundled busybox digest pin + template: templates/cluster-autoscaler/deployment.yaml + documentSelector: + path: kind + value: Deployment + asserts: + - matchRegex: + path: spec.template.spec.initContainers[0].image + pattern: '^docker\.io/library/busybox:[^@]+@sha256:[0-9a-f]{64}$' + + - it: wait-for-kubeconfig honours images.waitForKubeconfig override + template: templates/cluster-autoscaler/deployment.yaml + documentSelector: + path: kind + value: Deployment + set: + images: + waitForKubeconfig: "registry.example.test/library/busybox:1.37.0@sha256:0000000000000000000000000000000000000000000000000000000000000000" + asserts: + - equal: + path: spec.template.spec.initContainers[0].image + value: "registry.example.test/library/busybox:1.37.0@sha256:0000000000000000000000000000000000000000000000000000000000000000" + - it: cluster.yaml renders and wires dataStoreName when tenant has etcd template: templates/cluster.yaml documentSelector: diff --git a/packages/apps/kubernetes/values.schema.json b/packages/apps/kubernetes/values.schema.json index 1beeff9c..81e9e2b8 100644 --- a/packages/apps/kubernetes/values.schema.json +++ b/packages/apps/kubernetes/values.schema.json @@ -630,6 +630,18 @@ } } } + }, + "images": { + "description": "Optional image overrides for air-gapped or rate-limited registries.", + "type": "object", + "default": {}, + "properties": { + "waitForKubeconfig": { + "description": "Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag.", + "type": "string", + "default": "" + } + } } } } diff --git a/packages/apps/kubernetes/values.yaml b/packages/apps/kubernetes/values.yaml index a67b5d69..68ba07b0 100644 --- a/packages/apps/kubernetes/values.yaml +++ b/packages/apps/kubernetes/values.yaml @@ -197,3 +197,10 @@ controlPlane: server: resources: {} resourcesPreset: "micro" + +## @typedef {struct} Images - Optional image overrides for chart-internal helpers. +## @field {string} [waitForKubeconfig] - Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag. + +## @param {Images} images - Optional image overrides for air-gapped or rate-limited registries. +images: + waitForKubeconfig: "" diff --git a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml index aaa01247..1631c86e 100644 --- a/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml +++ b/packages/system/kubernetes-rd/cozyrds/kubernetes.yaml @@ -22,7 +22,7 @@ spec: singular: kubernetes plural: kuberneteses openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"},"nodeGroups":{"description":"Worker nodes configuration map.","type":"object","default":{"md0":{"ephemeralStorage":"20Gi","gpus":[],"instanceType":"u1.medium","maxReplicas":10,"minReplicas":0,"resources":{},"roles":["ingress-nginx"]}},"additionalProperties":{"type":"object","required":["ephemeralStorage","instanceType","maxReplicas","minReplicas","resources"],"properties":{"ephemeralStorage":{"description":"Ephemeral storage size.","default":"20Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"gpus":{"description":"List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM).","type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"description":"Name of GPU, such as \"nvidia.com/AD102GL_L40S\".","type":"string"}}}},"instanceType":{"description":"Virtual machine instance type.","type":"string","default":"u1.medium"},"maxReplicas":{"description":"Maximum number of replicas.","type":"integer","default":10},"minReplicas":{"description":"Minimum number of replicas.","type":"integer","default":0},"resources":{"description":"CPU and memory resources for each worker node.","type":"object","properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"roles":{"description":"List of node roles.","type":"array","items":{"type":"string"}}}}},"version":{"description":"Kubernetes major.minor version to deploy","type":"string","default":"v1.35","enum":["v1.35","v1.34","v1.33","v1.32","v1.31","v1.30"]},"host":{"description":"External hostname for Kubernetes cluster. Defaults to `.` if empty.","type":"string","default":""},"addons":{"description":"Cluster addons configuration.","type":"object","default":{},"required":["certManager","cilium","coredns","fluxcd","gatewayAPI","gpuOperator","ingressNginx","monitoringAgents","velero","verticalPodAutoscaler"],"properties":{"certManager":{"description":"Cert-manager addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable cert-manager.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"cilium":{"description":"Cilium CNI plugin.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"coredns":{"description":"CoreDNS addon.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"fluxcd":{"description":"FluxCD GitOps operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable FluxCD.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"gatewayAPI":{"description":"Gateway API addon.","type":"object","default":{},"required":["enabled"],"properties":{"enabled":{"description":"Enable Gateway API.","type":"boolean","default":false}}},"gpuOperator":{"description":"NVIDIA GPU Operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable GPU Operator.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ingressNginx":{"description":"Ingress-NGINX controller.","type":"object","default":{},"required":["enabled","exposeMethod","valuesOverride"],"properties":{"enabled":{"description":"Enable the controller (requires nodes labeled `ingress-nginx`).","type":"boolean","default":false},"exposeMethod":{"description":"Method to expose the controller. Allowed values: `Proxied`, `LoadBalancer`.","type":"string","default":"Proxied","enum":["Proxied","LoadBalancer"]},"hosts":{"description":"Domains routed to this tenant cluster when `exposeMethod` is `Proxied`.","type":"array","default":[],"items":{"type":"string"}},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"monitoringAgents":{"description":"Monitoring agents.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable monitoring agents.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"velero":{"description":"Velero backup/restore addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable Velero.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"verticalPodAutoscaler":{"description":"Vertical Pod Autoscaler.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}}}},"controlPlane":{"description":"Kubernetes control-plane configuration.","type":"object","default":{},"required":["apiServer","controllerManager","konnectivity","replicas","scheduler"],"properties":{"apiServer":{"description":"API Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for API Server.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"large","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}},"controllerManager":{"description":"Controller Manager configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Controller Manager.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}},"konnectivity":{"description":"Konnectivity configuration.","type":"object","default":{},"required":["server"],"properties":{"server":{"description":"Konnectivity Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Konnectivity.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"replicas":{"description":"Number of control-plane replicas.","type":"integer","default":2},"scheduler":{"description":"Scheduler configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Scheduler.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}}}}}} + {"title":"Chart Values","type":"object","properties":{"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":"replicated"},"nodeGroups":{"description":"Worker nodes configuration map.","type":"object","default":{"md0":{"ephemeralStorage":"20Gi","gpus":[],"instanceType":"u1.medium","maxReplicas":10,"minReplicas":0,"resources":{},"roles":["ingress-nginx"]}},"additionalProperties":{"type":"object","required":["ephemeralStorage","instanceType","maxReplicas","minReplicas","resources"],"properties":{"ephemeralStorage":{"description":"Ephemeral storage size.","default":"20Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"gpus":{"description":"List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM).","type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"description":"Name of GPU, such as \"nvidia.com/AD102GL_L40S\".","type":"string"}}}},"instanceType":{"description":"Virtual machine instance type.","type":"string","default":"u1.medium"},"maxReplicas":{"description":"Maximum number of replicas.","type":"integer","default":10},"minReplicas":{"description":"Minimum number of replicas.","type":"integer","default":0},"resources":{"description":"CPU and memory resources for each worker node.","type":"object","properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"roles":{"description":"List of node roles.","type":"array","items":{"type":"string"}}}}},"version":{"description":"Kubernetes major.minor version to deploy","type":"string","default":"v1.35","enum":["v1.35","v1.34","v1.33","v1.32","v1.31","v1.30"]},"host":{"description":"External hostname for Kubernetes cluster. Defaults to `.` if empty.","type":"string","default":""},"addons":{"description":"Cluster addons configuration.","type":"object","default":{},"required":["certManager","cilium","coredns","fluxcd","gatewayAPI","gpuOperator","ingressNginx","monitoringAgents","velero","verticalPodAutoscaler"],"properties":{"certManager":{"description":"Cert-manager addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable cert-manager.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"cilium":{"description":"Cilium CNI plugin.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"coredns":{"description":"CoreDNS addon.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"fluxcd":{"description":"FluxCD GitOps operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable FluxCD.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"gatewayAPI":{"description":"Gateway API addon.","type":"object","default":{},"required":["enabled"],"properties":{"enabled":{"description":"Enable Gateway API.","type":"boolean","default":false}}},"gpuOperator":{"description":"NVIDIA GPU Operator.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable GPU Operator.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"ingressNginx":{"description":"Ingress-NGINX controller.","type":"object","default":{},"required":["enabled","exposeMethod","valuesOverride"],"properties":{"enabled":{"description":"Enable the controller (requires nodes labeled `ingress-nginx`).","type":"boolean","default":false},"exposeMethod":{"description":"Method to expose the controller. Allowed values: `Proxied`, `LoadBalancer`.","type":"string","default":"Proxied","enum":["Proxied","LoadBalancer"]},"hosts":{"description":"Domains routed to this tenant cluster when `exposeMethod` is `Proxied`.","type":"array","default":[],"items":{"type":"string"}},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"monitoringAgents":{"description":"Monitoring agents.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable monitoring agents.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"velero":{"description":"Velero backup/restore addon.","type":"object","default":{},"required":["enabled","valuesOverride"],"properties":{"enabled":{"description":"Enable Velero.","type":"boolean","default":false},"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}},"verticalPodAutoscaler":{"description":"Vertical Pod Autoscaler.","type":"object","default":{},"required":["valuesOverride"],"properties":{"valuesOverride":{"description":"Custom Helm values overrides.","type":"object","default":{},"x-kubernetes-preserve-unknown-fields":true}}}}},"controlPlane":{"description":"Kubernetes control-plane configuration.","type":"object","default":{},"required":["apiServer","controllerManager","konnectivity","replicas","scheduler"],"properties":{"apiServer":{"description":"API Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for API Server.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"large","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}},"controllerManager":{"description":"Controller Manager configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Controller Manager.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}},"konnectivity":{"description":"Konnectivity configuration.","type":"object","default":{},"required":["server"],"properties":{"server":{"description":"Konnectivity Server configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Konnectivity.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"replicas":{"description":"Number of control-plane replicas.","type":"integer","default":2},"scheduler":{"description":"Scheduler configuration.","type":"object","default":{},"required":["resources","resourcesPreset"],"properties":{"resources":{"description":"CPU and memory resources for Scheduler.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Preset if `resources` omitted.","type":"string","default":"micro","enum":["nano","micro","small","medium","large","xlarge","2xlarge"]}}}}},"images":{"description":"Optional image overrides for air-gapped or rate-limited registries.","type":"object","default":{},"properties":{"waitForKubeconfig":{"description":"Image used by the wait-for-kubeconfig init container. Empty falls back to images/busybox.tag.","type":"string","default":""}}}}} release: prefix: kubernetes- labels: @@ -40,7 +40,7 @@ spec: tags: - compute icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNDQiIGhlaWdodD0iMTQ0IiByeD0iMjQiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82ODFfMjg0NSkiLz4KPHBhdGggZD0iTTcxLjk5NjggMTlDNzAuMzAzOSAxOS4wMDAyIDY4LjkzMTIgMjAuNTMyMiA2OC45MzE0IDIyLjQyMjFDNjguOTMxNCAyMi40NTExIDY4LjkzNzMgMjIuNDc4OCA2OC45Mzc5IDIyLjUwNzZDNjguOTM1NCAyMi43NjQ0IDY4LjkyMzEgMjMuMDczNyA2OC45MzE0IDIzLjI5NzNDNjguOTcxNyAyNC4zODczIDY5LjIwODIgMjUuMjIxNiA2OS4zNTA2IDI2LjIyNThDNjkuNjA4NCAyOC4zNzUyIDY5LjgyNDUgMzAuMTU2OSA2OS42OTEyIDMxLjgxM0M2OS41NjE1IDMyLjQzNzUgNjkuMTAzNyAzMy4wMDg2IDY4LjY5NTYgMzMuNDA1Nkw2OC42MjM1IDM0LjcwODZDNjYuNzgzOSAzNC44NjE3IDY0LjkzMTkgMzUuMTQyMSA2My4wODIxIDM1LjU2NDFDNTUuMTIyNiAzNy4zNzk4IDQ4LjI2OTUgNDEuNDk5MSA0My4wNTIgNDcuMDYwOUM0Mi43MTM0IDQ2LjgyODggNDIuMTIxMSA0Ni40MDE5IDQxLjk0NSA0Ni4yNzEyQzQxLjM5NzcgNDYuMzQ1NCA0MC44NDQ1IDQ2LjUxNTEgNDAuMTI0MSA0Ni4wOTM1QzM4Ljc1MjIgNDUuMTY1NyAzNy41MDI4IDQzLjg4NTEgMzUuOTkxIDQyLjM0MjRDMzUuMjk4MiA0MS42MDQ0IDM0Ljc5NjYgNDAuOTAxOCAzMy45NzM1IDQwLjE5MDRDMzMuNzg2NiA0MC4wMjg5IDMzLjUwMTQgMzkuODEwNCAzMy4yOTIzIDM5LjY0NDJDMzIuNjQ4OSAzOS4xMjg4IDMxLjg5IDM4Ljg2IDMxLjE1NyAzOC44MzQ4QzMwLjIxNDcgMzguODAyNCAyOS4zMDc1IDM5LjE3MjUgMjguNzEzOCAzOS45MjA2QzI3LjY1ODQgNDEuMjUwNiAyNy45OTYzIDQzLjI4MzMgMjkuNDY3MSA0NC40NjE0QzI5LjQ4MiA0NC40NzM0IDI5LjQ5NzkgNDQuNDgyNyAyOS41MTI5IDQ0LjQ5NDNDMjkuNzE1IDQ0LjY1ODkgMjkuOTYyNSA0NC44Njk4IDMwLjE0ODMgNDUuMDA3NkMzMS4wMjE3IDQ1LjY1NTUgMzEuODE5NSA0NS45ODcyIDMyLjY4OTcgNDYuNTAxNUMzNC41MjMxIDQ3LjYzOTEgMzYuMDQzIDQ4LjU4MjMgMzcuMjQ4NiA0OS43MTk2QzM3LjcxOTQgNTAuMjIzNyAzNy44MDE2IDUxLjExMjIgMzcuODY0MyA1MS40OTY0TDM4Ljg0NjggNTIuMzc4MkMzMy41ODcyIDYwLjMzMDggMzEuMTUzIDcwLjE1MzkgMzIuNTkxNSA4MC4xNjI3TDMxLjMwNzcgODAuNTM3OEMzMC45NjkzIDgwLjk3NjggMzAuNDkxMiA4MS42Njc2IDI5Ljk5MTEgODEuODczOEMyOC40MTM4IDgyLjM3MjkgMjYuNjM4NyA4Mi41NTYyIDI0LjQ5NTYgODIuNzgxOUMyMy40ODk0IDgyLjg2NiAyMi42MjEzIDgyLjgxNTggMjEuNTU0NiA4My4wMTg4QzIxLjMxOTggODMuMDYzNSAyMC45OTI3IDgzLjE0OTEgMjAuNzM1OCA4My4yMDk3QzIwLjcyNjkgODMuMjExNiAyMC43MTg2IDgzLjIxNDIgMjAuNzA5NiA4My4yMTYyQzIwLjY5NTYgODMuMjE5NSAyMC42NzcyIDgzLjIyNjMgMjAuNjYzOCA4My4yMjk0QzE4Ljg1NyA4My42NjggMTcuNjk2MyA4NS4zMzY1IDE4LjA2OTkgODYuOTgwNUMxOC40NDM3IDg4LjYyNDggMjAuMjA4NiA4OS42MjQ4IDIyLjAyNjIgODkuMjMxMkMyMi4wMzkzIDg5LjIyODIgMjIuMDU4NCA4OS4yMjc3IDIyLjA3MiA4OS4yMjQ2QzIyLjA5MjYgODkuMjE5OSAyMi4xMTA2IDg5LjIwOTkgMjIuMTMxIDg5LjIwNDlDMjIuMzg0NCA4OS4xNDkgMjIuNzAxOSA4OS4wODY4IDIyLjkyMzYgODkuMDI3MkMyMy45NzIzIDg4Ljc0NTEgMjQuNzMxOCA4OC4zMzA2IDI1LjY3NDYgODcuOTY3N0MyNy43MDI5IDg3LjIzNjggMjkuMzgyOCA4Ni42MjYyIDMxLjAxOTUgODYuMzg4M0MzMS43MDMgODYuMzM0NSAzMi40MjMyIDg2LjgxMiAzMi43ODE0IDg3LjAxMzRMMzQuMTE3NyA4Ni43ODMxQzM3LjE5MjYgOTYuMzYxMyA0My42MzY2IDEwNC4xMDMgNTEuNzk2MyAxMDguOTYxTDUxLjIzOTYgMTEwLjMwM0M1MS40NDAzIDExMC44MjQgNTEuNjYxNiAxMTEuNTMgNTEuNTEyMSAxMTIuMDQ1QzUwLjkxNzEgMTEzLjU5NSA0OS44OTggMTE1LjIzMSA0OC43Mzc0IDExNy4wNTVDNDguMTc1NSAxMTcuODk4IDQ3LjYwMDQgMTE4LjU1MiA0Ny4wOTM0IDExOS41MTZDNDYuOTcyIDExOS43NDcgNDYuODE3NSAxMjAuMTAyIDQ2LjcwMDQgMTIwLjM0NkM0NS45MTI1IDEyMi4wMzkgNDYuNDkwNCAxMjMuOTkgNDguMDAzOCAxMjQuNzIyQzQ5LjUyNjggMTI1LjQ1OSA1MS40MTcxIDEyNC42ODIgNTIuMjM1MiAxMjIuOTg1QzUyLjIzNjQgMTIyLjk4MiA1Mi4yNDA2IDEyMi45OCA1Mi4yNDE3IDEyMi45NzhDNTIuMjQyNiAxMjIuOTc2IDUyLjI0MDkgMTIyLjk3MyA1Mi4yNDE3IDEyMi45NzFDNTIuMzU4MiAxMjIuNzMxIDUyLjUyMzMgMTIyLjQxNSA1Mi42MjE2IDEyMi4xODhDNTMuMDU2IDEyMS4xODkgNTMuMjAwNSAxMjAuMzMyIDUzLjUwNTkgMTE5LjM2NUM1NC4zMTcgMTE3LjMxOCA1NC43NjI2IDExNS4xNyA1NS44NzkxIDExMy44MzJDNTYuMTg0OSAxMTMuNDY2IDU2LjY4MzMgMTEzLjMyNSA1Ny4yMDAxIDExMy4xODZMNTcuODk0NCAxMTEuOTIyQzY1LjAwOCAxMTQuNjY1IDcyLjk3MDUgMTE1LjQwMiA4MC45MjQ1IDExMy41ODdDODIuNzM5MSAxMTMuMTczIDg0LjQ5MDggMTEyLjYzNyA4Ni4xODQzIDExMS45OTRDODYuMzc5NCAxMTIuMzQyIDg2Ljc0MiAxMTMuMDExIDg2LjgzOTMgMTEzLjE3OUM4Ny4zNjQ0IDExMy4zNTEgODcuOTM3NyAxMTMuNDM5IDg4LjQwNDcgMTE0LjEzM0M4OS4yNDAxIDExNS41NjcgODkuODExNCAxMTcuMjYzIDkwLjUwNzMgMTE5LjMxMkM5MC44MTI4IDEyMC4yNzkgOTAuOTYzOCAxMjEuMTM2IDkxLjM5ODEgMTIyLjEzNkM5MS40OTcxIDEyMi4zNjMgOTEuNjYxNCAxMjIuNjg0IDkxLjc3OCAxMjIuOTI1QzkyLjU5NDQgMTI0LjYyOCA5NC40OTA3IDEyNS40MDcgOTYuMDE1OSAxMjQuNjY5Qzk3LjUyOTIgMTIzLjkzNyA5OC4xMDc3IDEyMS45ODYgOTcuMzE5NCAxMjAuMjkzQzk3LjIwMjMgMTIwLjA0OSA5Ny4wNDEyIDExOS42OTUgOTYuOTE5OCAxMTkuNDY0Qzk2LjQxMjcgMTE4LjQ5OSA5NS44Mzc3IDExNy44NTIgOTUuMjc1OCAxMTcuMDA5Qzk0LjExNTIgMTE1LjE4NSA5My4xNTI2IDExMy42NyA5Mi41NTc1IDExMi4xMkM5Mi4zMDg3IDExMS4zMiA5Mi41OTk1IDExMC44MjMgOTIuNzkzMyAxMTAuMzAzQzkyLjY3NzIgMTEwLjE3IDkyLjQyODggMTA5LjQxNCA5Mi4yODI0IDEwOS4wNTlDMTAwLjc2MiAxMDQuMDI5IDEwNy4wMTcgOTUuOTk4NSAxMDkuOTU1IDg2LjcyMzlDMTEwLjM1MSA4Ni43ODY1IDExMS4wNDEgODYuOTA5MSAxMTEuMjY1IDg2Ljk1NDJDMTExLjcyNiA4Ni42NDg3IDExMi4xNDkgODYuMjUwMSAxMTIuOTgxIDg2LjMxNTlDMTE0LjYxNyA4Ni41NTM3IDExNi4yOTcgODcuMTY0NSAxMTguMzI2IDg3Ljg5NTNDMTE5LjI2OCA4OC4yNTgxIDEyMC4wMjggODguNjc5MyAxMjEuMDc3IDg4Ljk2MTRDMTIxLjI5OCA4OS4wMjEgMTIxLjYxNiA4OS4wNzY2IDEyMS44NjkgODkuMTMyNUMxMjEuODg5IDg5LjEzNzUgMTIxLjkwOCA4OS4xNDc1IDEyMS45MjggODkuMTUyMkMxMjEuOTQyIDg5LjE1NTMgMTIxLjk2MSA4OS4xNTU4IDEyMS45NzQgODkuMTU4OEMxMjMuNzkyIDg5LjU1MiAxMjUuNTU3IDg4LjU1MjYgMTI1LjkzIDg2LjkwODFDMTI2LjMwMyA4NS4yNjQxIDEyNS4xNDMgODMuNTk1MiAxMjMuMzM2IDgzLjE1N0MxMjMuMDc0IDgzLjA5NyAxMjIuNzAxIDgyLjk5NSAxMjIuNDQ2IDgyLjk0NjVDMTIxLjM3OSA4Mi43NDM1IDEyMC41MTEgODIuNzkzNSAxMTkuNTA1IDgyLjcwOTVDMTE3LjM2MSA4Mi40ODM5IDExNS41ODYgODIuMzAwNCAxMTQuMDA5IDgxLjgwMTRDMTEzLjM2NiA4MS41NTA3IDExMi45MDggODAuNzgxOSAxMTIuNjg2IDgwLjQ2NTVMMTExLjQ0OCA4MC4xMDM1QzExMi4wOSA3NS40MzggMTExLjkxNyA3MC41ODI1IDExMC44MDYgNjUuNzI0M0MxMDkuNjg1IDYwLjgyMDggMTA3LjcwNCA1Ni4zMzYxIDEwNS4wNjIgNTIuMzg0OEMxMDUuMzc5IDUyLjA5NDggMTA1Ljk3OSA1MS41NjEyIDEwNi4xNDkgNTEuNDA0M0MxMDYuMTk5IDUwLjg1MTcgMTA2LjE1NiA1MC4yNzIyIDEwNi43MjUgNDkuNjYwM0MxMDcuOTMxIDQ4LjUyMyAxMDkuNDUxIDQ3LjU3OTkgMTExLjI4NCA0Ni40NDIzQzExMi4xNTQgNDUuOTI3OSAxMTIuOTU5IDQ1LjU5NjQgMTEzLjgzMiA0NC45NDg0QzExNC4wMyA0NC44MDE5IDExNC4yOTkgNDQuNTY5OSAxMTQuNTA3IDQ0LjQwMjJDMTE1Ljk3NyA0My4yMjM3IDExNi4zMTYgNDEuMTkxMSAxMTUuMjYgMzkuODYxNEMxMTQuMjA0IDM4LjUzMTcgMTEyLjE1OSAzOC40MDY1IDExMC42ODggMzkuNTg1QzExMC40NzkgMzkuNzUxNiAxMTAuMTk1IDM5Ljk2ODggMTEwLjAwNyA0MC4xMzEyQzEwOS4xODQgNDAuODQyNiAxMDguNjc2IDQxLjU0NTIgMTA3Ljk4MyA0Mi4yODMyQzEwNi40NzEgNDMuODI1OSAxMDUuMjIyIDQ1LjExMyAxMDMuODUgNDYuMDQwOUMxMDMuMjU1IDQ2LjM4ODUgMTAyLjM4NSA0Ni4yNjgyIDEwMS45OSA0Ni4yNDQ5TDEwMC44MjQgNDcuMDgwNkM5NC4xNzUzIDQwLjA3NjMgODUuMTIzNSAzNS41OTgyIDc1LjM3NjYgMzQuNzI4M0M3NS4zNDk0IDM0LjMxNzkgNzUuMzEzNyAzMy41NzYxIDc1LjMwNDYgMzMuMzUyOUM3NC45MDU2IDMyLjk2OTMgNzQuNDIzNSAzMi42NDE4IDc0LjMwMjQgMzEuODEzQzc0LjE2OTEgMzAuMTU2OSA3NC4zOTE3IDI4LjM3NTIgNzQuNjQ5NiAyNi4yMjU4Qzc0Ljc5MTkgMjUuMjIxNiA3NS4wMjg0IDI0LjM4NzMgNzUuMDY4OCAyMy4yOTczQzc1LjA3OCAyMy4wNDk1IDc1LjA2MzIgMjIuNjkgNzUuMDYyMiAyMi40MjIxQzc1LjA2MiAyMC41MzIyIDczLjY4OTggMTguOTk5OCA3MS45OTY4IDE5Wk02OC4xNTg1IDQyLjg4ODZMNjcuMjQ4IDU5LjA0NDdMNjcuMTgyNSA1OS4wNzc2QzY3LjEyMTQgNjAuNTIyOSA2NS45Mzc1IDYxLjY3NyA2NC40ODM5IDYxLjY3N0M2My44ODg0IDYxLjY3NyA2My4zMzg4IDYxLjQ4NDkgNjIuODkyMiA2MS4xNTcxTDYyLjg2NiA2MS4xNzAzTDQ5LjY4MDcgNTEuNzc5NEM1My43MzMxIDQ3Ljc3NTkgNTguOTE2NCA0NC44MTcyIDY0Ljg5IDQzLjQ1NDZDNjUuOTgxMiA0My4yMDU2IDY3LjA3MTkgNDMuMDIwOSA2OC4xNTg1IDQyLjg4ODZaTTc1Ljg0MTcgNDIuODg4NkM4Mi44MTU5IDQzLjc1MDQgODkuMjY1NyA0Ni45MjMyIDk0LjIwODEgNTEuNzg2TDgxLjEwOCA2MS4xMTc2TDgxLjA2MjEgNjEuMDk3OUM3OS44OTk0IDYxLjk1MTIgNzguMjYxMSA2MS43Mzk0IDc3LjM1NDggNjAuNTk3OEM3Ni45ODM1IDYwLjEzMDEgNzYuNzg4NyA1OS41ODAxIDc2Ljc2NTMgNTkuMDI0OUw3Ni43NTIyIDU5LjAxODRMNzUuODQxNyA0Mi44ODg2Wk00NC44OTkxIDU3LjgxNEw1Ni45MzgyIDY4LjYzM0w1Ni45MjUxIDY4LjY5ODhDNTguMDExNyA2OS42NDc5IDU4LjE3MiA3MS4yOTQ5IDU3LjI2NTcgNzIuNDM2OEM1Ni44OTQ0IDcyLjkwNDUgNTYuMzk3NSA3My4yMTgyIDU1Ljg2MzkgNzMuMzY0N0w1NS44NTA4IDczLjQxNzNMNDAuNDE4OCA3Ny44OTIzQzM5LjYzMzQgNzAuNjc2NSA0MS4zMjYxIDYzLjY2MjEgNDQuODk5MSA1Ny44MTRaTTk5LjAwOTQgNTcuODIwNkMxMDAuNzk4IDYwLjczMzYgMTAyLjE1MyA2My45ODcxIDEwMi45NTkgNjcuNTE0M0MxMDMuNzU2IDcwLjk5OTEgMTAzLjk1NiA3NC40Nzc4IDEwMy42MjcgNzcuODM5N0w4OC4xMTY2IDczLjM1MTVMODguMTAzNSA3My4yODU3Qzg2LjcxNDUgNzIuOTA0MyA4NS44NjA5IDcxLjQ4NDggODYuMTg0MyA3MC4wNjExQzg2LjMxNjggNjkuNDc3OCA4Ni42MjQ5IDY4Ljk4NDQgODcuMDQyMyA2OC42MTk4TDg3LjAzNTggNjguNTg2OUw5OS4wMDk0IDU3LjgyMDZaTTY5LjUyNzQgNjkuNDY4OEg3NC40NTk2TDc3LjUyNTEgNzMuMzE4Nkw3Ni40MjQ3IDc4LjEyMjZMNzEuOTk2OCA4MC4yNjE0TDY3LjU1NTggNzguMTE2MUw2Ni40NTU0IDczLjMxMkw2OS41Mjc0IDY5LjQ2ODhaTTg1LjMzOTMgODIuNjQzN0M4NS41NDg5IDgyLjYzMzEgODUuNzU3NiA4Mi42NTIgODUuOTYxNiA4Mi42ODk4TDg1Ljk4NzggODIuNjU2OUwxMDEuOTUgODUuMzY4MkM5OS42MTQyIDkxLjk2MjQgOTUuMTQ0IDk3LjY3NSA4OS4xNzExIDEwMS40OThMODIuOTc0NyA4Ni40NjA2TDgyLjk5NDQgODYuNDM0M0M4Mi40MjUyIDg1LjEwNTUgODIuOTk0OCA4My41NDcyIDg0LjMwNDQgODIuOTEzNUM4NC42Mzk3IDgyLjc1MTMgODQuOTkgODIuNjYxNCA4NS4zMzkzIDgyLjY0MzdaTTU4LjUyOTggODIuNzA5NUM1OS43NDggODIuNzI2NyA2MC44NDA2IDgzLjU3NjEgNjEuMTIzNyA4NC44MjJDNjEuMjU2MiA4NS40MDUyIDYxLjE5MTcgODUuOTgzMSA2MC45NzMgODYuNDkzNUw2MS4wMTg5IDg2LjU1MjdMNTQuODg4IDEwMS40MzlDNDkuMTU1OSA5Ny43NDMyIDQ0LjU5MDQgOTIuMjA5OSA0Mi4xNDgxIDg1LjQyMDhMNTcuOTczMSA4Mi43MjI3TDU3Ljk5OTMgODIuNzU1NkM1OC4xNzYzIDgyLjcyMjkgNTguMzU1OCA4Mi43MDcxIDU4LjUyOTggODIuNzA5NVpNNzEuODk4NiA4OS4yMzEyQzcyLjMyMjkgODkuMjE1NSA3Mi43NTM0IDg5LjMwMyA3My4xNjI3IDg5LjUwMUM3My42OTkyIDg5Ljc2MDYgNzQuMTEzNiA5MC4xNjkyIDc0LjM3NDUgOTAuNjU5Mkg3NC40MzM0TDgyLjIzNDYgMTA0LjgyMUM4MS4yMjIxIDEwNS4xNjIgODAuMTgxMyAxMDUuNDU0IDc5LjExNjcgMTA1LjY5N0M3My4xNTA1IDEwNy4wNTggNjcuMjAzMiAxMDYuNjQ1IDYxLjgxOCAxMDQuODAyTDY5LjU5OTUgOTAuNjY1OEg2OS42MTI2QzcwLjA3OTUgODkuNzg4OCA3MC45NjUgODkuMjY1NiA3MS44OTg2IDg5LjIzMTJaIiBmaWxsPSJ3aGl0ZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIwLjI1Ii8+CjxkZWZzPgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MF9saW5lYXJfNjgxXzI4NDUiIHgxPSIxMCIgeTE9IjE1LjUiIHgyPSIxNDQiIHkyPSIxMzEuNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjNEQ4N0ZGIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzA1NDdEMCIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo= - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "storageClass"], ["spec", "nodeGroups"], ["spec", "nodeGroups", "md0"], ["spec", "nodeGroups", "md0", "minReplicas"], ["spec", "nodeGroups", "md0", "maxReplicas"], ["spec", "nodeGroups", "md0", "instanceType"], ["spec", "nodeGroups", "md0", "ephemeralStorage"], ["spec", "nodeGroups", "md0", "roles"], ["spec", "nodeGroups", "md0", "resources"], ["spec", "nodeGroups", "md0", "gpus"], ["spec", "version"], ["spec", "host"], ["spec", "addons"], ["spec", "addons", "certManager"], ["spec", "addons", "certManager", "enabled"], ["spec", "addons", "certManager", "valuesOverride"], ["spec", "addons", "cilium"], ["spec", "addons", "cilium", "valuesOverride"], ["spec", "addons", "gatewayAPI"], ["spec", "addons", "gatewayAPI", "enabled"], ["spec", "addons", "ingressNginx"], ["spec", "addons", "ingressNginx", "enabled"], ["spec", "addons", "ingressNginx", "exposeMethod"], ["spec", "addons", "ingressNginx", "hosts"], ["spec", "addons", "ingressNginx", "valuesOverride"], ["spec", "addons", "gpuOperator"], ["spec", "addons", "gpuOperator", "enabled"], ["spec", "addons", "gpuOperator", "valuesOverride"], ["spec", "addons", "fluxcd"], ["spec", "addons", "fluxcd", "enabled"], ["spec", "addons", "fluxcd", "valuesOverride"], ["spec", "addons", "monitoringAgents"], ["spec", "addons", "monitoringAgents", "enabled"], ["spec", "addons", "monitoringAgents", "valuesOverride"], ["spec", "addons", "verticalPodAutoscaler"], ["spec", "addons", "verticalPodAutoscaler", "valuesOverride"], ["spec", "addons", "velero"], ["spec", "addons", "velero", "enabled"], ["spec", "addons", "velero", "valuesOverride"], ["spec", "addons", "coredns"], ["spec", "addons", "coredns", "valuesOverride"], ["spec", "controlPlane"], ["spec", "controlPlane", "replicas"], ["spec", "controlPlane", "apiServer"], ["spec", "controlPlane", "apiServer", "resources"], ["spec", "controlPlane", "apiServer", "resourcesPreset"], ["spec", "controlPlane", "controllerManager"], ["spec", "controlPlane", "controllerManager", "resources"], ["spec", "controlPlane", "controllerManager", "resourcesPreset"], ["spec", "controlPlane", "scheduler"], ["spec", "controlPlane", "scheduler", "resources"], ["spec", "controlPlane", "scheduler", "resourcesPreset"], ["spec", "controlPlane", "konnectivity"], ["spec", "controlPlane", "konnectivity", "server"], ["spec", "controlPlane", "konnectivity", "server", "resources"], ["spec", "controlPlane", "konnectivity", "server", "resourcesPreset"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "storageClass"], ["spec", "nodeGroups"], ["spec", "nodeGroups", "md0"], ["spec", "nodeGroups", "md0", "minReplicas"], ["spec", "nodeGroups", "md0", "maxReplicas"], ["spec", "nodeGroups", "md0", "instanceType"], ["spec", "nodeGroups", "md0", "ephemeralStorage"], ["spec", "nodeGroups", "md0", "roles"], ["spec", "nodeGroups", "md0", "resources"], ["spec", "nodeGroups", "md0", "gpus"], ["spec", "version"], ["spec", "host"], ["spec", "addons"], ["spec", "addons", "certManager"], ["spec", "addons", "certManager", "enabled"], ["spec", "addons", "certManager", "valuesOverride"], ["spec", "addons", "cilium"], ["spec", "addons", "cilium", "valuesOverride"], ["spec", "addons", "gatewayAPI"], ["spec", "addons", "gatewayAPI", "enabled"], ["spec", "addons", "ingressNginx"], ["spec", "addons", "ingressNginx", "enabled"], ["spec", "addons", "ingressNginx", "exposeMethod"], ["spec", "addons", "ingressNginx", "hosts"], ["spec", "addons", "ingressNginx", "valuesOverride"], ["spec", "addons", "gpuOperator"], ["spec", "addons", "gpuOperator", "enabled"], ["spec", "addons", "gpuOperator", "valuesOverride"], ["spec", "addons", "fluxcd"], ["spec", "addons", "fluxcd", "enabled"], ["spec", "addons", "fluxcd", "valuesOverride"], ["spec", "addons", "monitoringAgents"], ["spec", "addons", "monitoringAgents", "enabled"], ["spec", "addons", "monitoringAgents", "valuesOverride"], ["spec", "addons", "verticalPodAutoscaler"], ["spec", "addons", "verticalPodAutoscaler", "valuesOverride"], ["spec", "addons", "velero"], ["spec", "addons", "velero", "enabled"], ["spec", "addons", "velero", "valuesOverride"], ["spec", "addons", "coredns"], ["spec", "addons", "coredns", "valuesOverride"], ["spec", "controlPlane"], ["spec", "controlPlane", "replicas"], ["spec", "controlPlane", "apiServer"], ["spec", "controlPlane", "apiServer", "resources"], ["spec", "controlPlane", "apiServer", "resourcesPreset"], ["spec", "controlPlane", "controllerManager"], ["spec", "controlPlane", "controllerManager", "resources"], ["spec", "controlPlane", "controllerManager", "resourcesPreset"], ["spec", "controlPlane", "scheduler"], ["spec", "controlPlane", "scheduler", "resources"], ["spec", "controlPlane", "scheduler", "resourcesPreset"], ["spec", "controlPlane", "konnectivity"], ["spec", "controlPlane", "konnectivity", "server"], ["spec", "controlPlane", "konnectivity", "server", "resources"], ["spec", "controlPlane", "konnectivity", "server", "resourcesPreset"], ["spec", "images"], ["spec", "images", "waitForKubeconfig"]] secrets: exclude: [] include: From fb1ef59287114fe9ef6eec2351875139d4d84e97 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 27 Apr 2026 13:50:18 +0300 Subject: [PATCH 26/26] fix(kubernetes): drop undocumented status-beacon annotation from awaiting-etcd ConfigMap The cozystack.io/status-beacon: "true" annotation had no consumer in the chart, no documented contract, and no convention defined for other charts to follow. It would have become accidental precedent for contributors copying the pattern without understanding it. The ConfigMap itself is self-explanatory: the name -awaiting-etcd, data.status: "awaiting-etcd", and the human-readable message in data.message all surface the same operator signal via kubectl get cm. Drop the annotation; keep the ConfigMap. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/apps/kubernetes/templates/cluster.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/apps/kubernetes/templates/cluster.yaml b/packages/apps/kubernetes/templates/cluster.yaml index 12da9818..e1494e64 100644 --- a/packages/apps/kubernetes/templates/cluster.yaml +++ b/packages/apps/kubernetes/templates/cluster.yaml @@ -106,8 +106,6 @@ metadata: app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/name: kubernetes app.kubernetes.io/instance: {{ .Release.Name }} - annotations: - cozystack.io/status-beacon: "true" data: status: "awaiting-etcd" message: |