Repository navigation
[API proposal] Typed healthCheckExprs / waitStrategy on ApplicationDefinition and ComponentInstall (kstatus readiness, #2642) #3263
Description
Summary
Add typed, first-class fields so a Cozystack package/app can declare the Flux helm-controller v1.5.0 kstatus health expression for the custom resource it wraps. This is the operator/apiserver half of #2642 (the template half — inner child HRs like seaweedfs-db — needs no API change). Requesting API-owner sign-off on the surface below before implementation.
Why an API change is needed
Under helm-controller v1.5.0 (shipped by #2602), HelmRelease.Ready means "every resource in the release manifest is healthy" per kstatus. For an HR whose manifest is a custom resource (CNPG Cluster, Strimzi Kafka, KubeVirt VirtualMachine, …), kstatus can't infer readiness — it needs a spec.healthCheckExprs CEL block, evaluated under spec.waitStrategy.name: poller. Today two HR builders emit those wrapping HRs and have no way to set these fields:
- apiserver —
pkg/registry/apps/application/rest.go::convertApplicationToHelmReleasebuilds the HR for every catalog app (postgres, kafka, mongodb, …). - operator —
internal/operator/package_reconciler.go::buildHelmReleaseSpecbuilds the HR for platform packages/singletons (KubeVirt, CDI, Linstor).
The template path already works (raw YAML, e.g. packages/extra/seaweedfs/templates/seaweedfs-db.yaml). This proposal covers only the two Go-built paths.
Hard prerequisite
go.mod currently pins github.com/fluxcd/helm-controller/api v1.4.3. The WaitStrategy and CustomHealthCheck Go types (and HelmReleaseSpec.HealthCheckExprs) do not exist in v1.4.3 — only the runtime CRD is v1.5.0. Step 0 of implementation is go get github.com/fluxcd/helm-controller/[email protected] (+ transitive review). No new field can compile until then.
Proposed API surface (decided: typed field, not annotation)
Reuse the upstream helmv2.CustomHealthCheck type so there is exactly one shape to learn and validate.
api/v1alpha1/applicationdefinitions_types.go — extend ApplicationDefinitionRelease:
type ApplicationDefinitionRelease struct {
ChartRef *helmv2.CrossNamespaceSourceReference `json:"chartRef"`
Labels map[string]string `json:"labels,omitempty"`
Prefix string `json:"prefix"`
// WaitStrategy sets HelmReleaseSpec.WaitStrategy (poller|legacy).
// +optional
// +kubebuilder:validation:Enum=poller;legacy
WaitStrategy string `json:"waitStrategy,omitempty"`
// HealthCheckExprs sets HelmReleaseSpec.HealthCheckExprs — CEL health
// expressions for the custom resource(s) this app renders.
// +optional
HealthCheckExprs []helmv2.CustomHealthCheck `json:"healthCheckExprs,omitempty"`
}api/v1alpha1/packagesource_types.go — the identical pair on ComponentInstall (platform singletons: KubeVirt/CDI/Linstor).
Threading:
- apiserver: populate
config.ReleaseConfigfrom the newspec.releasefields (same startup loader that already readsrelease.cozystack.io/*annotations) → setSpec.WaitStrategy/Spec.HealthCheckExprsinconvertApplicationToHelmRelease. - operator: set the same two fields in
buildHelmReleaseSpecfromComponentInstall. - Root
make generate(deepcopy + openapi) + regenerate the ApplicationDefinition CRD and cozyrds blobs. (Watch the cozyvalues-gen reorder trap — hand-edit generated blobs, don't blind-commitmake generate.)
Why typed over annotation
The existing precedent (release.cozystack.io/helm-install-disable-wait, -timeout) is a scalar annotation. healthCheckExprs is a structured list of multi-line CEL — JSON-in-an-annotation would be unreadable and unvalidated. A typed field gets openapi validation, the enum on waitStrategy, and reuses the upstream type verbatim.
Backward compatibility
- Both fields
omitempty/ optional → zero behavior change for any app/package that doesn't set them. - No change to the annotation precedent; annotations and typed fields can coexist.
- Setting
waitStrategy: polleris the v1.5.0 runtime default already, so an app that only addshealthCheckExprsbehaves identically to today until its CR reports status.
Adoption (post-merge, one PR per app, verified on a dev cluster)
The full CRD→CEL catalog (evidence-cited to each vendored CRD) is maintained separately as the readiness audit and is the appendix to this work. Leaves and their signals: postgres/CNPG, kafka/Strimzi, mongodb/Percona (string state), mariadb, clickhouse (+keeper, distinct API group clickhouse-keeper.altinity.com), rabbitmq, foundationdb (health struct), opensearch (string phase), vm-instance/vm-disk (KubeVirt/CDI — CRDs runtime-installed, values UNVERIFIED), etcd (EtcdCluster readyMembers). Platform singletons KubeVirt/CDI/Linstor via ComponentInstall.
Explicitly excluded: redis (RedisFailover has no status subresource — helm-controller can't gate on it; stays on WorkloadMonitor), valkey (does not exist in the repo), the 19 in-tenant-cluster kubernetes addon HRs (parent is disable-wait by design), and all config-only CRs.
Questions for API owners
- OK to depend on
helmv2.CustomHealthCheckdirectly incozystack.io/v1alpha1types (couples our API to the helm-controller Go module version)? Alternative: mirror a minimal local struct. - Field home on the apiserver side:
ApplicationDefinition.spec.release(proposed) vs a newspec.readinessblock? - Any concern bumping
helm-controller/apito v1.5.x in the sharedgo.modahead of the rest of the Flux Go deps?
Activity
kvaps commentedon Jul 9, 2026
API-owner review. The surface is sound and I'm good with the direction. Answers to your three questions, then a few non-blocking refinements.
Q1 — depend on
helmv2.CustomHealthCheckdirectly? Yes.ApplicationDefinitionReleasealready embeds*helmv2.CrossNamespaceSourceReferenceforchartRef, so the coupling to the helm-controllerapisubmodule version is a precedent we've already accepted. Mirroring a local struct would duplicate a type we don't own and invite drift; reusing it verbatim is the DRY choice and keeps exactly one CEL shape to learn. Keep the direct dependency.Q2 —
spec.releasevs a newspec.readiness? Keep it inspec.release. That block already is "the config that shapes the generated HelmRelease" (chartRef/labels/prefix), and both new fields map 1:1 ontoHelmReleaseSpec. A separatespec.readinesswould split HR-shaping config across two homes and wrongly imply per-instance/dynamic scope — this is per-kind template config, loaded once into the apiserver'sReleaseConfigat startup like the rest ofspec.release. On theComponentInstallside, keeping the two fields flat (next toupgradeCRDs, which already maps toUpgrade.CRDs) is the matching local convention — good.Q3 — bump
helm-controller/apito v1.5.x ahead of the rest of Flux? Fine, and it's fixing a skew rather than getting ahead: the runtime CRD is already v1.5.0 (shipped by #2602) while the Go types are pinned at v1.4.3. I confirmedWaitStrategy/CustomHealthCheck/HealthCheckExprsdo not exist in the v1.4.3apisubmodule (whileCrossNamespaceSourceReferencedoes). Theapisubmodule is intentionally thin and independently versioned, so bumping just it is low-risk. Do it as an isolated step-0 commit and glance over thego.mod/go.sumtransitive diff; just confirm a matchinggithub.com/fluxcd/helm-controller/apitag exists at v1.5.x.Refinements (none blocking):
waitStrategyshape. UpstreamHelmReleaseSpec.WaitStrategyis an object{name: poller|legacy}, not a scalar — the seaweedfs template already writeswaitStrategy: {name: poller}. YourWaitStrategy stringis nicer DX, but it contradicts the "reuse the upstream type verbatim, one shape to learn" rationale you use forhealthCheckExprs. I'm fine with keeping the scalar enum — just document explicitly in the field doc that it maps to.nameand is a deliberate simplification.Couple the default.
healthCheckExprsare only evaluated under thepollerstrategy. Today that works becausepolleris the v1.5.0 runtime default, but it makes evaluation silently dependent on that global default never flipping (theUseHelm3Defaultsgate the epic forbids). Suggest: whenhealthCheckExprsis non-empty andwaitStrategyis unset, have both builders default the HR topoller. That makes an app setting onlyhealthCheckExprsself-contained and removes the seaweedfs "must also set poller" footgun.disableWaitscope. [Epic] Adopt the kstatus readiness model platform-wide (Flux 2.8 / helm-controller v1.5.0) #2642 listsdisableWaitas a possible sibling field, but this proposal drops it. NotewaitStrategy: legacyis not equivalent todisableWait— legacy still waits (Helm-v3 wait on core kinds), disableWait skips waiting entirely. If dropping it is a conscious YAGNI cut (the kubernetes-package deadlock was resolved child-side, Option B, so no operatordisableWaitis needed now), I'm fine with it — just call it out so we add it later only when a concrete leaf needs a true "don't gate at all" escape hatch.Serialization sanity. On the apiserver path the fields thread through
config.ReleaseConfig. That struct carriesyaml:tags but is only ever built in-memory andjson.Marshaled (start.go), andhelmv2.CustomHealthCheckis json-tagged, so this is fine — just don't introduce agopkg.in/yaml.v3round-trip ofReleaseConfig, which wouldn't honor the json tags.make generatedeepcopy for[]helmv2.CustomHealthCheckwill delegate to helmv2's deepcopy the same waychartRefalready does.Failure mode to document. OpenAPI validates only the struct shape, not that the CEL compiles or that the referenced
apiVersion/kindCRD is installed. A bad expr or a not-yet-installed CRD (you already flag vm-instance/vm-disk as runtime-installed / UNVERIFIED) makes the HR hang totimeout. Worth a one-liner in the field doc that the HRtimeoutis the backstop.Backward compatibility is a non-issue: both fields are optional/omitempty, additive to two CRDs, and coexist with the existing
release.cozystack.io/*annotations. Good to proceed once the above are settled.feat(api): typed waitStrategy/healthCheckExprs for kstatus readiness
feat(api): typed waitStrategy/healthCheckExprs for kstatus readiness (#…
myasnikovdaniil commentedon Aug 14, 2026
Implemented by #3273, which landed the typed
waitStrategyandhealthCheckExprson bothComponentInstallandApplicationDefinitionReleaseand threaded them through the operator and the apiserver HR builders. Closing. Tracked in #2642 Task 3.