Skip to content

feat(flux): kstatus health gates on CR-wrapping umbrella HelmReleases - #3264

Draft
myasnikovdaniil wants to merge 5 commits into
mainfrom
worktree-kstatus-umbrellas
Draft

myasnikovdaniil wants to merge 5 commits into
mainfrom
worktree-kstatus-umbrellas

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Under helm-controller v1.5.0 a HelmRelease that wraps a custom resource flips Ready as soon as helm applies the CR, before the operator wrote any status, so dependants start while the backing workload is not usable yet. This PR adds waitStrategy: poller and healthCheckExprs to the umbrella HelmReleases that render such a CR, and Ready follows the CR's own status now. Part of #2642.

Gated releases:

  • harbor -system: CNPG Cluster by the Ready condition
  • monitoring -system: VLCluster, VTCluster, VTSingle, VMCluster, VMAgent, VMAlert, VMAlertmanager by status.updateStatus, and the CNPG Cluster by Ready
  • tenant etcd child: EtcdCluster is current when Available=True, Progressing=False with reason Reconciled and readyMembers == spec.replicas, failed on Available=False with reason BootstrapFailed or DeadlineExceeded. Every condition is pinned to metadata.generation, because the status has no root observedGeneration
  • tenant gateway child: TenantGateway by Ready

CNPG sets one Ready=False reason for both bootstrap and persistent errors, so it has only a current expression and a broken database waits for the release timeout. Uninstall remediation on these releases would delete the CNPG Cluster with its PVCs, so harbor, tenant etcd and monitoring use RetryOnFailure on install and upgrade, and a timeout retries in place.

grafana-operator has no status conditions and its stages only confirm resource writes, so Grafana has no expression. The existing post-install users Job now renders in every mode and waits for the grafana-deployment rollout that matches the hash of the rendered Grafana spec, then does the users work only when oidc.users is set in an OIDC mode. The Job reads the API through a dedicated ServiceAccount that can get only grafana and grafana-deployment. It does not pass --request-timeout to kubectl, because any nonzero value makes kubectl skip the in-cluster credentials (#4288), and it keeps the API errors in the log.

Umbrellas over native Deployments, StatefulSets or child HelmReleases (openbao, qdrant, bucket, nats, external-dns, ingress, bootbox, cozystack-basics and the tenant seaweedfs, ingress, monitoring, info children) are not changed, kstatus already assesses their workloads.

The field paths and status values come from the CRDs and operator source, not from a live stand yet.

Tests

helm-unittest pins the rendered fields. test/healthchecks is a separate Go module that renders the real charts and runs the expressions through the CEL evaluator shipped with helm-controller against status fixtures, and runs the rendered Grafana Job script with fake kubectl and curl. It runs as make test-health-checks, included in make unit-tests.

Screenshots

Not a UI change.

Downstream repositories

Release note

feat(flux): harbor, monitoring and tenant etcd and gateway HelmReleases report Ready only after the operator-managed workload is healthy

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-kstatus-umbrellas

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/feature Categorizes issue or PR as related to a new feature size/L This PR changes 100-499 lines, ignoring generated files labels Jul 9, 2026
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Verification update — CEL status values confirmed against live CRs (read-only)

The convention-based status fields/values these gates key on are confirmed against live custom resources on a cluster running these apps:

  • CNPG Cluster (harbor DB, monitoring DB): Ready=True / reason ClusterIsReady when healthy; Ready=False / reason ClusterIsNotReady during bootstrap — and also for a genuinely-stuck cluster (e.g. out of disk), which the CEL intentionally treats as in-progress with the HR timeout as the backstop (matching the seaweedfs reference). ✓
  • monitoring VMCluster/VMAgent/VMAlert/VMAlertmanager/VLCluster: status.updateStatus == 'operational' when healthy. ✓
  • monitoring Grafana: status.stage == 'complete' && status.stageStatus == 'success'. ✓

Still pending (their CRDs weren't present on the verification cluster): tenant/etcd EtcdCluster (readyMembers gate) and tenant/gateway TenantGateway (Ready condition) — both field-verified from their CRDs, awaiting a cluster that runs them.

Scope note: this confirms the CELs reference the real status fields/values the operators actually emit. The end-to-end "HR stays not-Ready until the CR is healthy" behavior still needs a deploy of this PR's charts to a dev cluster before un-drafting.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

feat(flux): kstatus health gates on CR-wrapping umbrella HelmReleases. Two MAJOR issues: the failed predicates for CNPG Cluster and EtcdCluster are dead code, so genuinely-broken dependencies are classified inProgress forever instead of failed. (Noted: this PR is a draft with an open self-checklist — flagging early.)

Findings

[MAJOR] packages/apps/harbor/templates/harbor.yaml:112 and packages/extra/monitoring/templates/helmrelease.yaml:91 — the failed CEL predicate for the CNPG Cluster is dead code. The condition e.reason != 'ClusterIsNotReady' is unreachable: in the pinned CloudNativePG 1.27.1 (SetClusterReadyCondition, transactions.go:34-56), Ready=False for ANY non-healthy phase always carries reason: ClusterIsNotReady — there is no second reason; the differentiated cause goes into status.phase, not reason. So a genuinely-broken Postgres (harbor-db, alerta-db, grafana-db) is permanently classified inProgress, never failed; the only backstop is timeout + remediation.retries: -1 (infinite silent retry). The same bug is already in production in seaweedfs-db.yaml:33-34; this PR triples the blast radius. Match on status.phase (or drop the reason discriminator) instead.

[MAJOR] packages/apps/tenant/templates/etcd.yaml:50 — the failed predicate for EtcdCluster (status.brokenMembers > 0) is dead for the default PVC-backed storage. Confirmed even by the vendored CRD (etcdclusters.yaml:1859-1860: "While the auto-replacement predicate is a stub it is always 0") and upstream etcd-operator v0.5.2 (isBroken returns false except for StorageMediumMemory). A tenant etcd losing quorum stays inProgress forever, even though the operator already writes a legible Degraded=True, reason=QuorumLost in an adjacent field. Gate on that condition instead.

[MINOR] PR body is missing the required ```release-note block (.github/PULL_REQUEST_TEMPLATE.md).

Caveats

  • CI is inconclusive on the new logic: the single E2E run failed on an unrelated dependency (grafana-operator dashboards sidecar) and never reached the code this PR touches — so the new health-check logic was neither confirmed nor refuted by CI.
  • The other four new/extended predicates (VM* updateStatus, Grafana stage/stageStatus, TenantGateway Ready) were checked against upstream and are correct.

@myasnikovdaniil
myasnikovdaniil force-pushed the worktree-kstatus-umbrellas branch from efeae9e to 16e119d Compare September 10, 2026 18:12
@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM. The new Grafana readiness hook wedges the monitoring install, and CI reproduces it on this head.

Business context: an umbrella HelmRelease wrapping a custom resource flips Ready as soon as Helm applies the CR, so dependants start before the backing workload is usable. This gates those releases on the CR's own readiness.

Both major findings from the earlier review are fixed. I checked them against the pinned operators, not against the diff.

The CNPG failed predicate is gone, and harbor and monitoring now declare current only. The EtcdCluster gate no longer keys on brokenMembers; it matches Available=False with reason BootstrapFailed or DeadlineExceeded, pinned to metadata.generation. In the operator at the pinned appVersion v0.5.5 both reasons are written, and Progressing=False with reason Reconciled lands in the same status update as Available=True, so current is reachable. Worth knowing that EtcdClusterStatus has no root-level observedGeneration, so the per-condition generation clauses are the only staleness guard the evaluator gets here. Dropping them would silently remove it.

Data safety on the gated Postgres releases holds. Harbor, the tenant etcd child and monitoring all set RetryOnFailure on install and upgrade, so a gate that never goes green times out and retries in place instead of falling through to uninstall remediation, which is the path that deletes the CNPG Cluster and its PVCs.

Blockers

B1: the Grafana readiness hook never completes and blocks the install

packages/system/monitoring/templates/grafana/oidc-users-job.yaml. The Job now renders in every mode, not only when spec.oidc.users is non-empty, and it holds the release until it can read the Grafana CR and its Deployment. In the run on this head it never read either one.

The run for 16e119dba fails at the install step. Every other branch failing in that window fails at the test step instead, so this is not the fleet-wide red. The cluster report has the chain: monitoring-system reports Helm install failed ... failed post-install: timeout waiting for: [Job/tenant-root/monitoring-system-oidc-users status: 'InProgress'], then monitoring reports timeout waiting for: [HelmRelease/tenant-root/monitoring-system status: 'InProgress'], then tenant-root reports timeout waiting for: [HelmRelease/tenant-root/monitoring status: 'InProgress'].

The Job log is 114 identical lines of Cannot read Grafana or its Deployment; retrying named API GETs from 19:04:17, ending in Grafana did not reach the desired healthy rollout within 10m; aborting at 19:14:21. It never got past the two kubectl get --raw calls, so it never evaluated readiness once.

Grafana was already healthy before the Job started. grafana-deployment hit readyReplicas: 2, availableReplicas: 2, updatedReplicas: 2, observedGeneration: 1 at 19:02:51, carrying monitoring.cozystack.io/grafana-spec-hash: 7652101d... matching the CR. The hash propagation works: the operator copies the annotation to the Deployment and the ReplicaSet. Everything the gate checks was true 90 seconds before the Job started.

The rendered Job has no GF_ADMIN_USER, OIDC_MODE or DESIRED_USERS_JSON env, so $manageUsers was false, and the old if and (ne $mode "None") (gt (len $users) 0) guard would have emitted nothing. This configuration gained a blocking post-install hook it did not have before.

I could not work out why the reads failed, and B2 is why. RBAC is shaped right and was created at 19:00:55, the ServiceAccount exists, the pod carries policy.cozystack.io/allow-to-apiserver and the matching policy is valid. I considered that label newly switching on egress enforcement and ruled it out: allow-internal-communication and allow-to-dns both use an empty endpointSelector, so every pod in the namespace was already under egress policy and already had DNS. Treating it as unrelated.

Since this is a post-install hook timeout under RetryOnFailure, it retries indefinitely rather than surfacing, and every dependant waits behind it.

B2: the hook throws away the error from every API read

Same file. Both kubectl get --raw calls send stderr to /dev/null, and so does the jq after them. A permission denial, an unreachable API server, a missing object and a Grafana that is genuinely still rolling out all produce the same one-line message.

Ten minutes of one repeated line, no status code, no API error, nothing separating "not ready yet" from "cannot reach the API at all". That is the exact distinction this gate exists to make. It is also why B1 cannot be root-caused from artifacts the run already collected. Keep stderr and log it, at least on the final attempt before aborting, so the abort message carries the real failure.

B3: the PR description credits an authoring tool

The body ends with a Generated with [Claude Code] line. Contributor guidance forbids naming an AI model or its vendor in an authorship line in a PR description. The commits get this right: all five carry a bare Assisted-By: LLM trailer and a sign-off. Drop the line from the body.

Non-blocking

  1. The description drifted. The table still says monitoring extends the gate to Grafana on stage/stageStatus, but the rendered HelmRelease has six expressions and none is a Grafana; readiness moved to the rollout hook. The body also never mentions the health-check test module, the readiness RBAC, or the new make target, which is most of the diff.
  2. The release-note block the PR template asks for is still missing, as the earlier review said.
  3. One clause of the etcd current expression is unpinned. Changing status.readyMembers == spec.replicas to <= leaves the suite green, because no fixture pairs Available=True/QuorumHealthy and Progressing=False/Reconciled with partial membership. quorum-available-but-one-member-unhealthy has no Progressing condition, so a different clause holds it. The rest is pinned properly: renaming the Reconciled reason reddens healthy and single-member-healthy, which is how I checked.
  4. The VMAgent expression matches nothing in tenant-root or cozy-monitoring, since templates/vm/vmagent.yaml is guarded not to render there. An expression matching no object in the release is ignored without complaint, so the gate covers one resource fewer in those namespaces than the list reads. A comment would help if that is deliberate.

Rendering the real charts and running the shipped CEL evaluator against status fixtures is the right shape for this, and I confirmed by mutation that both it and the chart tests fail when the rendered values change.

A successful custom-resource apply can precede the workloads becoming usable.
Gate umbrella HelmReleases on operator status so dependants wait for the
backing services to converge.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
Partial etcd membership must not hide a stopped reconciliation, and stale
or paused clusters must not release tenant dependencies. CNPG uses one
nonready reason for both bootstrap and persistent errors, so retain its
timeout policy without a misleading failure classification.

Exercise the rendered gates with the evaluator shipped by helm-controller
to catch evaluation-order and operator-status regressions.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…ilure

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
Grafana operator stages only confirm resource writes, and an old Pod can
answer health requests during a failed upgrade. Tie the readiness hook
to the rendered desired spec and the complete owned Deployment rollout.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil
myasnikovdaniil force-pushed the worktree-kstatus-umbrellas branch from 16e119d to 29169b0 Compare September 30, 2026 08:03
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) thanks, pushed the fixes and rebased on main.

  1. B1: the reads fail because of --request-timeout=5s. Any nonzero value makes kubectl skip the in-cluster credentials and go to localhost:8080, same defect as bucket: the ACME cleanup hook passes --request-timeout, so it never reaches the apiserver and reports success anyway #4288 in bucket. The flag is removed and calls are bounded with timeout 10. The hook still renders in every mode, it is the only place where the Grafana rollout is checked. I did not run it on a stand yet, E2E on this head will show.
  2. B2: stderr is kept and logged on every change of state, the abort message ends with the last one. In the kubectl image without a cluster it now prints connection to the server localhost:8080 was refused.
  3. B3: removed. The description is rewritten too (Grafana hook, health-check tests, RBAC, make target, release-note), that covers non-blocking 1 and 2.

Non-blocking 3 has a fixture with Available=True, Reconciled and 2 of 3 members, <= turns it red. 4 is a comment in the template.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants