Skip to content

fix(postgres-operator): block HelmRelease Ready until the cnpg webhook actually serves - #2482

Merged
Aleksei Sviridkin (lexfrei) merged 4 commits into
mainfrom
fix/cnpg-webhook-readiness
Apr 28, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 4 commits into
mainfrom
fix/cnpg-webhook-readiness

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Closes a bootstrap race where the cnpg-webhook-service Service gets its EndpointSlice populated and the data plane (kube-proxy / Cilium) programmed a second or two after helm install --wait on cozy-postgres-operator declares the HelmRelease Ready. Any HelmRelease that dependsOn: postgres-operator and creates a postgresql.cnpg.io/v1 resource in that window (cozy-keycloak, tenant Postgres apps) has kube-apiserver's call to mcluster.cnpg.io fail with

Internal error occurred: failed calling webhook "mcluster.cnpg.io":
Post "https://cnpg-webhook-service.cozy-postgres-operator.svc:443/...":
dial tcp <svc-ip>:443: connect: connection refused

which fails the downstream release's install.

The fix is a post-install / post-upgrade Helm hook (ServiceAccount + ClusterRole + ClusterRoleBinding + Job) that probes /readyz on the webhook through the apiserver service proxy. Apiserver's service proxy uses the same endpoint-resolution and apiserver-initiated pod dial as the admission webhook path, so once /readyz answers through the proxy, the subsequent admission call will also succeed. Helm --wait blocks the install from completing until the Job exits 0, so the HelmRelease Ready condition does not lie anymore.

Hardened per review:

  • RBAC scoped to a single services/proxy resourceName (https:cnpg-webhook-service:webhook-server) — the exact string the apiserver URL path parser expects for the Service proxy subresource.
  • A drift-guard helm-unittest test renders the vendored cnpg Service template and fails if its metadata.name / ports[0].name diverge from the literals in the hook, so a future make update that renames the service forces this template to be updated in the same change.
  • Image digest-pinned (clastix/kubectl:v1.32@sha256:…) with a renovate: annotation; imagePullPolicy: IfNotPresent when digest-pinned, Always when tag-only.
  • Job runs under PSA-restricted-compatible securityContext (non-root, seccomp RuntimeDefault, readOnlyRootFilesystem, drop ALL caps).
  • activeDeadlineSeconds is derived from maxAttempts × sleepSeconds + 60s so a values override raising retries does not get silently cut by a fixed deadline.
  • backoffLimit: 2 (configurable) so a transient pod-level failure (image pull rate limit, OOM, CNI hiccup) does not fail the whole HelmRelease.
  • On timeout the Job prints the last kubectl get --raw stderr so the operator can distinguish DNS / refused / 401 / TLS from the Job logs.

21 helm-unittest assertions cover ordering, RBAC/URL parity, subchart drift, image policy, retry-loop bounds, securityContext, and the deadline-scaling invariant. Wired via make test in packages/system/postgres-operator/Makefile.

First surfaced on #2470 E2E run 24862782568 where cozy-keycloak/keycloak failed with the exact signature above. Not tied to that PR's branch — this is independently applicable to main.

Release note

fix(postgres-operator): add a post-install readiness gate that blocks the HelmRelease from reporting Ready until the cnpg admission webhook actually serves through the cluster Service, preventing "dial tcp <svc-ip>:443: connect: connection refused" on the first HelmRelease that depends on postgres-operator (keycloak, tenant Postgres apps).

Summary by CodeRabbit

  • New Features

    • Added webhook readiness validation during install/upgrade to block completion until the admission webhook is reachable.
    • New configuration options for the readiness probe image and retry/timeouts.
  • Tests

    • Introduced a comprehensive test suite validating rendered manifests, probe behavior, RBAC, image rendering, and retry/timeout logic.
  • Chores

    • Added a test entry point to the project Makefile to run chart/unit tests.

…k actually serves

The cnpg admission webhook's controller pod passes readinessProbe as soon
as the local HTTPS server binds to :9443. The HelmRelease marks itself
Ready right after that via helm install --wait. But the Service
cnpg-webhook-service needs its EndpointSlice populated and the data plane
(kube-proxy / cilium) programmed before kube-apiserver can reach the
webhook through the Service ClusterIP. That gap is short but not zero,
and any HelmRelease that depends on postgres-operator (cozy-keycloak,
tenant Postgres apps) can fire its own install inside the window and hit

  Internal error occurred: failed calling webhook "mcluster.cnpg.io":
  failed to call webhook: Post "https://cnpg-webhook-service.cozy-postgres-operator.svc:443/...":
  dial tcp <svc-ip>:443: connect: connection refused

which fails the install of the downstream release. Seen on #2470
E2E run 24862782568.

Add a post-install,post-upgrade Helm hook Job that blocks the release
from reporting Ready until the webhook answers /readyz through the
apiserver service proxy. Apiserver proxy routes the call over the same
Service IP → EndpointSlice → pod path the admission webhook uses, so
once it responds, the webhook admission path is also working.

RBAC is minimal: a dedicated ServiceAccount with a ClusterRole that only
grants get on services/proxy scoped to https:cnpg-webhook-service:webhook-server.
The Job times out after 120s with 60 attempts at 2s intervals — longer
than any data-plane programming delay seen on E2E, but bounded.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
@coderabbitai

coderabbitai Bot commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b4b21f82-03e6-4b32-ba2a-af05e0d98ece

📥 Commits

Reviewing files that changed from the base of the PR and between 9b87bda and 7da29af.

📒 Files selected for processing (3)
  • packages/system/postgres-operator/templates/webhook-ready-hook.yaml
  • packages/system/postgres-operator/tests/webhook-ready-hook_test.yaml
  • packages/system/postgres-operator/values.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/system/postgres-operator/values.yaml
  • packages/system/postgres-operator/tests/webhook-ready-hook_test.yaml

📝 Walkthrough

Walkthrough

Adds a Helm-chart readiness gating feature for the CNPG admission webhook: a new Helm template that creates RBAC and a post-install/post-upgrade Job to poll the webhook via the Service proxy, new configurable values, a comprehensive helm-unittest suite, and a Makefile test target.

Changes

Cohort / File(s) Summary
Makefile
packages/system/postgres-operator/Makefile
Added test target to run helm unittest . against the chart directory.
Webhook readiness hook template
packages/system/postgres-operator/templates/webhook-ready-hook.yaml
New Helm template that renders ServiceAccount, Role, RoleBinding and a post-install/post-upgrade Job which polls /api/v1/namespaces/<ns>/services/<svc>/proxy/readyz via kubectl get --raw. Configures image (repo/tag/digest), retry params (maxAttempts, sleepSeconds, backoffLimit), computes activeDeadlineSeconds, and restricts RBAC resourceNames to the derived services/proxy resource.
Helm unittest suite
packages/system/postgres-operator/tests/webhook-ready-hook_test.yaml
New tests asserting four rendered manifests with hook annotations and order, RBAC resourceNames and Job probe path sync, support for digest-pinned and tag-only images, backoff/timeout behavior, security settings, resource requests/limits, and template render-failures for missing required image fields.
Values
packages/system/postgres-operator/values.yaml
Added webhookReady values block: image.repository, image.tag, optional image.digest, and retry controls maxAttempts, sleepSeconds, backoffLimit.

Sequence Diagram(s)

sequenceDiagram
    participant Helm as Helm Installer
    participant K8s as Kubernetes API
    participant RBAC as RBAC Controller
    participant JobCtrl as Job Controller
    participant Webhook as CNPG Webhook Service

    Helm->>K8s: Install/Upgrade → render & apply hook manifests
    K8s->>RBAC: Create ServiceAccount, Role, RoleBinding
    RBAC->>K8s: RBAC objects available
    K8s->>JobCtrl: Create readiness Job
    JobCtrl->>Webhook: kubectl GET /api/v1/namespaces/<ns>/services/<svc>/proxy/readyz (loop)
    alt Webhook ready
        Webhook-->>JobCtrl: 200 OK
        JobCtrl->>K8s: Job completes successfully
    else Not ready / retry
        Webhook-->>JobCtrl: error / non-200
        JobCtrl->>JobCtrl: sleep/backoff and retry until maxAttempts or activeDeadlineSeconds
        JobCtrl->>K8s: Job fails with last error after timeout
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Poem

🐰 I hop to check the webhook's door,
A Job that knocks and asks for more.
RBAC holds the tiny key,
Retries and timeouts—patience, we see.
Tests confirm the gate is sure. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a webhook readiness check to block the HelmRelease Ready status until the cnpg webhook is serving, which aligns with all file changes (new template, test, values, and Makefile target).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cnpg-webhook-readiness

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 and usage tips.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a robust readiness gate for the postgres-operator Helm chart to resolve a bootstrap race condition. By executing a post-install Job that verifies the CNPG admission webhook's availability through the Kubernetes API server proxy, it ensures that dependent resources are not deployed until the webhook is fully functional. This prevents intermittent 'connection refused' errors that occur when Helm reports a release as ready before the webhook's data plane is fully programmed.

Highlights

  • Readiness Gate Implementation: Added a post-install/post-upgrade Helm hook Job that probes the CNPG webhook's /readyz endpoint via the apiserver proxy to ensure it is fully reachable before the HelmRelease is marked as Ready.
  • Race Condition Resolution: Prevents 'connection refused' errors during bootstrap by blocking downstream HelmRelease installations until the CNPG webhook data plane is fully programmed.
  • Security and Hardening: The Job runs with restricted security context (non-root, read-only root filesystem, dropped capabilities) and uses a digest-pinned image for reliability.
  • Testing and Drift Guard: Introduced 21 helm-unittest assertions to validate RBAC, URL parity, security settings, and to ensure the hook configuration does not drift from the vendored CNPG service template.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added the kind/bug Categorizes issue or PR as related to a bug label Apr 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a post-install readiness gate for the Postgres operator using a Helm hook. This ensures the admission webhook is reachable via the apiserver proxy before the HelmRelease reports readiness, preventing bootstrap race conditions. I have reviewed the implementation and provided feedback regarding the use of namespaced RBAC resources and the inclusion of resource requests and limits for the readiness Job.

Comment on lines +54 to +83
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Name }}-webhook-ready
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
rules:
- apiGroups: [""]
resources: ["services/proxy"]
resourceNames: [{{ $resourceName | quote }}]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Name }}-webhook-ready
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ .Release.Name }}-webhook-ready
subjects:
- kind: ServiceAccount
name: {{ .Release.Name }}-webhook-ready
namespace: {{ .Release.Namespace }}

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.

medium

The RBAC resources for checking the service proxy are currently defined as a ClusterRole and ClusterRoleBinding. Since the services/proxy subresource is namespaced and the Job only needs to access the service within its own namespace, it is recommended to use a Role and RoleBinding instead. This adheres to the principle of least privilege and avoids creating global resources for a namespaced task. Note that if you apply this change, you should also update the corresponding unit tests in tests/webhook-ready-hook_test.yaml that check for the ClusterRole kind.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ .Release.Name }}-webhook-ready
  namespace: {{ .Release.Namespace }}
  annotations:
    helm.sh/hook: post-install,post-upgrade
    helm.sh/hook-weight: "0"
    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
rules:
  - apiGroups: [""]
    resources: ["services/proxy"]
    resourceNames: [{{ $resourceName | quote }}]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: {{ .Release.Name }}-webhook-ready
  namespace: {{ .Release.Namespace }}
  annotations:
    helm.sh/hook: post-install,post-upgrade
    helm.sh/hook-weight: "0"
    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: {{ .Release.Name }}-webhook-ready
subjects:
  - kind: ServiceAccount
    name: {{ .Release.Name }}-webhook-ready
    namespace: {{ .Release.Namespace }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — switched the RBAC to a namespaced Role and RoleBinding in .Release.Namespace since the Job only needs services/proxy on the webhook Service, which lives in the release namespace. Updated the kind assertions in tests/webhook-ready-hook_test.yaml so helm-unittest still guards the shape. Fixed in 648ad82.

Comment on lines +111 to +115
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

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.

medium

The wait container in the readiness Job is missing resource requests and limits. It is recommended to define them to ensure predictable scheduling and resource management, as per the project guidelines (line 99).

          resources:
            requests:
              cpu: 10m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
References
  1. Missing resource requests/limits on new workloads should be flagged. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added requests (10m CPU, 32Mi memory) and limits (100m CPU, 64Mi memory) on the wait container — matches the footprint of the kubectl polling loop and moves the pod out of BestEffort so namespace quotas apply. Added a matching unittest assertion to keep the values in sync with the template. Fixed in fc057c0.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/system/postgres-operator/templates/webhook-ready-hook.yaml (1)

127-137: Nit: off-by-one in the timeout message.

The first kubectl call is the until test itself (before i is incremented), so by the time i > max_attempts fires you've made max_attempts + 1 actual calls. The message reads "after ${max_attempts} attempts", i.e. 60 when 61 were made with defaults. Purely cosmetic — not a bug.

♻️ One-line fix
-              until last_err=$(kubectl get --raw "$proxy" 2>&1 >/dev/null); do
-                i=$((i + 1))
-                if [ $i -gt $max_attempts ]; then
+              until last_err=$(kubectl get --raw "$proxy" 2>&1 >/dev/null); do
+                i=$((i + 1))
+                if [ $i -ge $max_attempts ]; then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/system/postgres-operator/templates/webhook-ready-hook.yaml` around
lines 127 - 137, The timeout message incorrectly reports ${max_attempts}
attempts even though the loop makes one extra kubectl call; update the echo in
the failure branch inside the until loop (where variables i, max_attempts,
last_err, proxy, sleep_seconds are used) to report the actual number of attempts
(use the current i value or compute max_attempts+1) and adjust the parenthetical
sleep_seconds wording accordingly so the message accurately reflects how many
kubectl calls were made and the per-attempt sleep duration.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/system/postgres-operator/values.yaml`:
- Around line 25-30: The comment describing the interplay of backoffLimit and
activeDeadlineSeconds is inaccurate for the current defaults: update the comment
that references activeDeadlineSeconds/backoffLimit so it either uses numbers
that match the defaults (activeDeadlineSeconds = 60*2+60 = 180s) or explicitly
qualifies the example as requiring a larger maxAttempts/sleepSeconds override;
specifically edit the explanatory text around activeDeadlineSeconds,
backoffLimit, maxAttempts and sleepSeconds to either adjust the math to a 180s
total or add a parenthetical like "with a generous maxAttempts/sleepSeconds
override" so the scenario no longer conflicts with the default values.

---

Nitpick comments:
In `@packages/system/postgres-operator/templates/webhook-ready-hook.yaml`:
- Around line 127-137: The timeout message incorrectly reports ${max_attempts}
attempts even though the loop makes one extra kubectl call; update the echo in
the failure branch inside the until loop (where variables i, max_attempts,
last_err, proxy, sleep_seconds are used) to report the actual number of attempts
(use the current i value or compute max_attempts+1) and adjust the parenthetical
sleep_seconds wording accordingly so the message accurately reflects how many
kubectl calls were made and the per-attempt sleep duration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cae48463-bdd5-4fba-bf13-dd9ec693a397

📥 Commits

Reviewing files that changed from the base of the PR and between 9b0fe37 and 9b87bda.

📒 Files selected for processing (4)
  • packages/system/postgres-operator/Makefile
  • packages/system/postgres-operator/templates/webhook-ready-hook.yaml
  • packages/system/postgres-operator/tests/webhook-ready-hook_test.yaml
  • packages/system/postgres-operator/values.yaml

Comment thread packages/system/postgres-operator/values.yaml
…space

Address review feedback from gemini-code-assist on
packages/system/postgres-operator/templates/webhook-ready-hook.yaml:83.

The Job targets the cnpg-webhook-service/services/proxy subresource, which
is namespaced and lives in the release namespace. A namespaced Role and
RoleBinding grant the exact permission needed without creating global
RBAC for a namespaced probe, which is the principle of least privilege.

Also update the kind assertions in tests/webhook-ready-hook_test.yaml so
the unittest suite tracks the new Role/RoleBinding objects.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
Address review feedback from gemini-code-assist on
packages/system/postgres-operator/templates/webhook-ready-hook.yaml:115.

The wait container had no resource requests or limits, so schedulers
treated it as BestEffort and downstream quota enforcement had no signal.
Set small requests (10m CPU, 32Mi memory) and conservative limits
(100m CPU, 64Mi memory) matching the actual footprint of the kubectl
polling loop.

Add a matching unittest assertion so the values stay in sync if anyone
touches the template.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
…nt math

Address review feedback from coderabbitai on
packages/system/postgres-operator/values.yaml:30.

The old comment's 5-minute ImagePullBackOff scenario conflicted with
the 180s activeDeadlineSeconds that the default maxAttempts/sleepSeconds
resolve to, so the numbers could not both be taken at face value.

Rewrite the comment to state the actual deadline math and frame the two
gates as an AND with activeDeadlineSeconds being the shorter one under
defaults, so readers understand why backoffLimit has little headroom
without an accompanying maxAttempts/sleepSeconds bump.

Assisted-By: Claude <[email protected]>
Signed-off-by: Aleksei Sviridkin <[email protected]>
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Apr 28, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 6498c61 into main Apr 28, 2026
9 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/cnpg-webhook-readiness branch April 28, 2026 12:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Categorizes issue or PR as related to a bug lgtm This PR has been approved by a maintainer size/XS This PR changes 0-9 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants