fix(kubernetes): close admin-kubeconfig race on tenant cluster bootstrap (#2413)

## What this PR does

Closes #2412. On a cold tenant-Kubernetes bootstrap, the parent
HelmRelease raced the admin-kubeconfig Secret that Kamaji provisions
asynchronously. Three CP-side Deployments (cluster-autoscaler, kccm,
kcsi-controller) mounted that Secret as a hard volume, flux
helm-controller's default wait budget was too short for Kamaji cold
start, and `install.remediation { retries: -1 }` then uninstalled the
Cluster CR and restarted the cycle forever.

Implements a defense-in-depth fix:

- `optional: true` on the admin-kubeconfig Secret volume in all three
Deployments so kubelet no longer FailedMounts while Kamaji is still
bootstrapping.
- A shared `wait-for-kubeconfig` init container (in
`templates/_helpers.tpl`) that polls for `super-admin.svc` with a 10m
deadline, strictly below the HelmRelease Install.Timeout so a broken
tenant falls into CrashLoopBackOff visibly instead of hanging forever.
- Per-Application HelmRelease Install/Upgrade timeout, driven by a new
`release.cozystack.io/helm-install-timeout` annotation on
ApplicationDefinition. Kubernetes-rd sets it to `15m`; other kinds leave
it unset and keep flux defaults, so their failed installs remediate on
the normal cadence. Parser rejects ns/us/µs (accepted by
`time.ParseDuration`, rejected by Flux's CRD pattern) at startup.
- Soft-skip when `_namespace.etcd` is empty: the CP-side Deployments,
the Cluster/KamajiControlPlane/KubevirtCluster/WorkloadMonitor CRs, and
every child HelmRelease that references admin-kubeconfig now render only
when an etcd DataStore exists for this tenant. An `awaiting-etcd`
ConfigMap is emitted as a user-visible status beacon so `helm install`
still succeeds and flux retries on its 5m interval until the Tenant
chart catches up.
- e2e remediation guard built on `.status.history[].status` (the
Snapshot shape), not on `.status.installFailures` - `ClearFailures()`
zeroes the latter on every successful reconciliation, which made the
previous guard vacuous.

Tests:

- Go unit tests for the annotation parser (accepted/rejected units) and
the HR builder (table-driven across kinds).
- helm unittest for the per-template structure (optional volume, init
container, dataStoreName, awaiting-etcd beacon).
- bats unit tests for the shell guard (every combination of
empty/zero/positive history entries, plus pinned HR v2 shape).
- Chart-wide bats invariants: every Deployment mounting admin-kubeconfig
has the guards; zero such Deployments and zero HelmReleases render when
etcd is empty.

All wired into the existing `make unit-tests` target (`go-unit-tests`
added alongside `helm-unit-tests` and `bats-unit-tests`).

Option 2 from the ticket (separate HelmRelease with `dependsOn`) was
intentionally not taken: the combination above closes the same race
without restructuring the chart's HelmRelease topology.

### Release note

```release-note
fix(kubernetes): close admin-kubeconfig race on tenant Kubernetes bootstrap. The parent HelmRelease no longer enters an uninstall/retry cycle when Kamaji control-plane cold start exceeds flux's default wait budget. A Kubernetes tenant created before the parent Tenant application has etcd enabled now renders only an awaiting-etcd beacon ConfigMap and waits quietly for the DataStore to appear, instead of producing half-installed Deployments that CrashLoopBackOff forever.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Per-application Helm install/upgrade timeout via metadata annotation.
* Init-container guards that wait for admin kubeconfig before workloads
start.
  * Chart resources now render conditionally based on etcd presence.

* **Tests**
* Helm-template tests for admin-kubeconfig invariants and
remediation-cycle detection.
* New Go unit tests and CI Helm/unittest coverage plus test value files.

* **Chores**
* Added BusyBox image pin and new Makefile test targets (including Go
unit-tests).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aleksei Sviridkin 2026-04-27 15:31:52 +03:00 committed by GitHub
commit bdf23e66d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1059 additions and 48 deletions

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,145 @@
#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Chart-wide invariant for packages/apps/kubernetes:
#
# Every Deployment in this chart that mounts <release>-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)"
}
@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 \
--values packages/apps/kubernetes/tests/values-ci-no-etcd.yaml \
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)"
}
@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 \
--values packages/apps/kubernetes/tests/values-ci-no-etcd.yaml \
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)"
}

View file

@ -0,0 +1,39 @@
# Helpers for asserting that a Flux HelmRelease did not fall into an
# install/upgrade remediation cycle during an e2e run.
#
# 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.
#
# 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() {
statuses="$1"
if [ -z "${statuses}" ]; then
return 1
fi
# printf + grep over the pipe, rather than a heredoc plus while read.
# printf %s treats the status string as a literal payload, so any stray
# $ in a future caller's input does not trigger shell expansion. grep
# returns 0 iff at least one line matches the allowlist, which is
# exactly the contract the caller wants, so we can return its exit
# status directly.
if printf '%s\n' "${statuses}" | grep --extended-regexp --quiet '^(failed|uninstalled)$'; then
return 0
fi
return 1
}

View file

@ -1,3 +1,5 @@
. hack/e2e-apps/remediation-guard.sh
run_kubernetes_test() {
local version_expr="$1"
local test_name="$2"
@ -320,6 +322,35 @@ 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.
# 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}')
# Always emit the raw value so a silent future-Flux field rename shows
# up as "empty history on a Ready HR" in CI logs rather than vanishing.
echo "Parent HelmRelease history statuses:"
printf '%s\n' "${history_statuses:-<empty>}"
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." >&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}"

127
hack/remediation-guard.bats Normal file
View file

@ -0,0 +1,127 @@
#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for hack/e2e-apps/remediation-guard.sh
#
# 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).
#
# 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
# expressed as direct shell tests that exit non-zero on failure.
#
# Run with: hack/cozytest.sh hack/remediation-guard.bats
# -----------------------------------------------------------------------------
@test "empty history returns not-detected" {
. hack/e2e-apps/remediation-guard.sh
if helmrelease_has_remediation_cycle ""; then
echo "expected not-detected for empty history" >&2
exit 1
fi
}
@test "single deployed snapshot returns not-detected" {
. hack/e2e-apps/remediation-guard.sh
if helmrelease_has_remediation_cycle "deployed"; then
echo "expected not-detected for deployed-only history" >&2
exit 1
fi
}
@test "deployed then superseded returns not-detected" {
. hack/e2e-apps/remediation-guard.sh
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 "single failed snapshot returns detected" {
. hack/e2e-apps/remediation-guard.sh
if ! helmrelease_has_remediation_cycle "failed"; then
echo "expected detected when history contains failed snapshot" >&2
exit 1
fi
}
@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
if ! helmrelease_has_remediation_cycle "uninstalled"; then
echo "expected detected when history contains uninstalled snapshot" >&2
exit 1
fi
}
@test "uninstalled then deployed still returns detected" {
. hack/e2e-apps/remediation-guard.sh
statuses=$(printf 'uninstalled\ndeployed\n')
if ! helmrelease_has_remediation_cycle "${statuses}"; then
echo "expected detected despite later successful deploy" >&2
exit 1
fi
}
@test "deployed then failed still returns detected" {
. hack/e2e-apps/remediation-guard.sh
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 "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,
# 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
cat > "$tmp/hr.yaml" <<'YAML'
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kubernetes-test
namespace: tenant-test
status:
history:
- name: kubernetes-test
namespace: tenant-test
version: 1
status: uninstalled
- name: kubernetes-test
namespace: tenant-test
version: 2
status: deployed
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")
[ -n "$statuses" ]
echo "$statuses" | grep --quiet '^uninstalled$'
. hack/e2e-apps/remediation-guard.sh
if ! helmrelease_has_remediation_cycle "$statuses"; then
echo "expected detected for pinned HR snippet with uninstalled + deployed history" >&2
exit 1
fi
}

View file

@ -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
@ -67,3 +70,4 @@ 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

View file

@ -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

View file

@ -0,0 +1 @@
docker.io/library/busybox:1.37.0@sha256:1487d0af5f52b4ba31c7e465126ee2123fe3f2305d638e7827681e7cf6c83d5e

View file

@ -49,3 +49,52 @@ 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 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 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 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: "{{ default (.Files.Get "images/busybox.tag" | trim) .Values.images.waitForKubeconfig }}"
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 }}

View file

@ -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
@ -23,6 +34,8 @@ spec:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: "NoSchedule"
initContainers:
{{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }}
containers:
- image: "{{ $.Files.Get "images/cluster-autoscaler.tag" | trim }}"
name: cluster-autoscaler
@ -56,6 +69,7 @@ spec:
name: cloud-config
- secret:
secretName: {{ .Release.Name }}-admin-kubeconfig
optional: true
name: kubeconfig
serviceAccountName: {{ .Release.Name }}-cluster-autoscaler
terminationGracePeriodSeconds: 10
@ -105,3 +119,4 @@ rules:
- list
- update
- watch
{{- end }}

View file

@ -1,4 +1,15 @@
{{- $etcd := .Values._namespace.etcd }}
{{- /*
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 }}
@ -84,6 +95,26 @@ 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 }}
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
@ -404,3 +435,4 @@ metadata:
spec:
{{- .spec | toYaml | nindent 2 }}
{{- end }}
{{- end }}

View file

@ -1,3 +1,4 @@
{{- if .Values._namespace.etcd }}
kind: Deployment
apiVersion: apps/v1
metadata:
@ -24,6 +25,8 @@ spec:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: "NoSchedule"
initContainers:
{{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }}
containers:
- name: csi-driver
imagePullPolicy: Always
@ -234,4 +237,6 @@ spec:
emptyDir: {}
- secret:
secretName: {{ .Release.Name }}-admin-kubeconfig
optional: true
name: kubeconfig
{{- end }}

View file

@ -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:

View file

@ -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:

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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 }}

View file

@ -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:

View file

@ -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 }}

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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 }}

View file

@ -1,3 +1,4 @@
{{- if .Values._namespace.etcd }}
apiVersion: apps/v1
kind: Deployment
metadata:
@ -22,6 +23,8 @@ spec:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: "NoSchedule"
initContainers:
{{- include "kubernetes.waitForAdminKubeconfig" $ | nindent 6 }}
containers:
- name: kubevirt-cloud-controller-manager
args:
@ -55,5 +58,7 @@ spec:
name: cloud-config
- secret:
secretName: {{ .Release.Name }}-admin-kubeconfig
optional: true
name: kubeconfig
serviceAccountName: {{ .Release.Name }}-kccm
{{- end }}

View file

@ -0,0 +1,155 @@
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
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: 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
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
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: 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:
path: kind
value: KamajiControlPlane
asserts:
- equal:
path: spec.dataStoreName
value: tenant-root
- 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:
etcd: ""
monitoring: ""
ingress: ""
seaweedfs: ""
host: ""
asserts:
- hasDocuments:
count: 1
- isKind:
of: ConfigMap
- equal:
path: metadata.name
value: test-awaiting-etcd
- equal:
path: data.status
value: awaiting-etcd

View file

@ -0,0 +1,9 @@
_namespace:
etcd: ""
monitoring: ""
ingress: ""
seaweedfs: ""
host: ""
_cluster:
cluster-domain: cozy.local
nodeGroups: null

View file

@ -0,0 +1,9 @@
_namespace:
etcd: tenant-root
monitoring: ""
ingress: ""
seaweedfs: ""
host: ""
_cluster:
cluster-domain: cozy.local
nodeGroups: null

View file

@ -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": ""
}
}
}
}
}

View file

@ -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: ""

File diff suppressed because one or more lines are too long

View file

@ -160,6 +160,34 @@ 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. 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,
@ -168,15 +196,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)
}

View file

@ -16,6 +16,48 @@ limitations under the License.
package config
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 {
Resources []Resource `yaml:"resources"`
@ -41,6 +83,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.

119
pkg/config/config_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -1528,6 +1528,24 @@ func (r *REST) convertApplicationToHelmRelease(app *appsv1alpha1.Application) (*
},
}
// 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
helmRelease.Spec.Upgrade.Timeout = &timeout
}
return helmRelease, nil
}

View file

@ -0,0 +1,116 @@
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 newRESTForTimeout(kind, prefix string, helmInstallTimeout time.Duration) *REST {
return &REST{
kindName: kind,
releaseConfig: config.ReleaseConfig{
Prefix: prefix,
ChartRef: config.ChartRefConfig{
Kind: "HelmChart",
Name: "x",
Namespace: "cozy-system",
},
HelmInstallTimeout: helmInstallTimeout,
},
}
}
// 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,
},
{
// 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,
},
{
name: "arbitrary future kind with 20m configured gets 20m",
kind: "TalosCluster",
prefix: "talos-",
configured: 20 * time.Minute,
wantSet: true,
},
}
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"},
}
hr, err := r.convertApplicationToHelmRelease(app)
if err != nil {
t.Fatalf("convertApplicationToHelmRelease returned error: %v", err)
}
if hr.Spec.Install == nil || hr.Spec.Upgrade == nil {
t.Fatalf("Spec.Install/Upgrade must be non-nil")
}
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)
}
})
}
}