0006. Webhook serving certificates without cert-manager, failing closed
Status: Accepted · Date: 2026-10-25 · Design section: 4.5, 5.2.7
Decided: Run cert-controller in-process for the webhook serving certificate instead of depending on cert-manager
Context
Ichor needs validating webhooks from phase 1 on — deletion protection first,
then topology and immutability checks. Two project rules bear on how their
serving certificates work: the default install must not require cert-manager,
and nothing in the chart may produce a diff on every Argo CD sync. kubebuilder
4.15.0 scaffolds a config/certmanager/ Issuer and assumes cert-manager at
runtime; it offers no alternative.
What the investigation established:
- controller-runtime v0.24.1 only reloads certificates.
certwatcherwatches files andwebhook.Serverpoints atCertDir; nothing in it generates a CA or a serving certificate. caBundlecannot be avoided. It is optional in the API, but when it is absent the API server falls back to its own system trust roots — not the cluster CA. None of the built-in CSR signers in 1.36 (kube-apiserver-client,kube-apiserver-client-kubelet,kubelet-serving,legacy-unknown) issues a serving certificate the API server will trust for an admission webhook. So for a self-signed setup some component has to writecaBundleat runtime.github.com/open-policy-agent/cert-controller(Apache-2.0) does exactly that job as a library: generates a self-signed CA and serving certificate into a Secret, rotates ahead of expiry, and injectscaBundleinto validating and mutating webhook configurations, CRD conversion webhooks and APIServices. It builds against controller-runtime v0.24.1. Its last feature release is v0.16.0 (March 2025); since then it is dependency bumps only.- Gatekeeper's chart — cert-controller's own reference consumer — ships the
webhook configuration with no
caBundlekey, andcaBundleis written at runtime. It does, however, ship the certificate Secret, as metadata with nodata, and that turns out to be load-bearing: cert-controller v0.16.0 contains noCreatecall anywhere.refreshCertIfNeededdoesreader.Geton the Secret and returns a wrapped error if that fails, so on a cluster where the Secret does not exist the rotator errors out, the manager runnable fails and the process exits — on every restart. The empty Secret is what makesGetsucceed;secret.Data == nilis then what triggers generation, andUpdatefills it in. - On the Argo CD side,
ignoreDifferencesalone only hides the diff; self-heal still overwrites the field unless the Application also setsRespectIgnoreDifferences=true. Keying the exception onmanagedFieldsManagersrequires the writer to set a field manager name.
Decision
- Run
open-policy-agent/cert-controllerin-process inichor-controller, withCertRotator.FieldOwnerset toichor-controller, the field manager the naming conventions already assign to it. Serving certificates are read from the mounted Secret by controller-runtime'scertwatcher, so the two compose and Ichor owns no cryptography. - The chart and the Kustomize base ship the
ValidatingWebhookConfigurationwithout acaBundlekey, and ship the certificate Secret as metadata only, with nodata. No key material is templated, so no generated value can differ on a re-sync, but the object exists — which is what lets the rotator'sGetsucceed and itsUpdatefill it in. Shipping it in Git has a second benefit: if the Secret is ever deleted, Argo CD puts the empty one back and the controller refills it, instead of the operator having to. - The volume that mounts it is
optional: trueas well. With the Secret shipped this is belt and braces, but it changes the failure mode if the Secret goes missing: the pod starts and crash-loops with the reason in its logs, rather than sitting inFailedMountwhere the cause is far less visible. failurePolicy: Fail, which is where Ichor departs from Gatekeeper'sIgnore, deliberately. Gatekeeper validates the whole cluster, so a webhook that is down must not block everything. Ichor's webhooks match onlyichor.maesh.devresources, so failing closed blocks Ichor operations and nothing else — and the thing they mostly protect is deletion ofTalosClusterandTalosMachine, where failing open lets an Argo CD prune cascade into node resets. A deletion guard that opens when the controller is down is not a guard.- Set
RequireLeaderElectionon the rotator. The controller runs two replicas, and without it both run the rotator: on a cold start both can see no Secret and generate a different CA each.FieldOwneronly decides who writes last, so the losing replica would serve a certificate signed by a CA the livecaBundledoes not name until the mounted Secret resyncs. Letting only the leader rotate is the same instinct as principle 6 — one writer for a disruptive operation — and costs nothing, since both replicas need the Secret before they can serve anyway. - Ship, with the chart, an
ignoreDifferencesentry for the webhook configuration'scaBundleand one for the certificate Secret, both keyed onmanagedFieldsManagers: [ichor-controller], together with theRespectIgnoreDifferences=truesync option, and say in the docs that both are needed — the first one alone leaves self-heal to clobber the field. Example:dist/chart/examples/argocd-application.yaml. - cert-manager stays available as an opt-in: a chart value turns the in-process rotator off. The design requires only that it not be the default.
Consequences
- Until the controller is running and has written
caBundle, writes to Ichor's CRs are refused. That is the right order regardless — the sync waves already place Ichor's CRDs and controller ahead of Ichor's CRs — butichor inithas to wait for the webhook to answer before it creates the first CRs, and the bootstrap path must treat "webhook not ready yet" as something to wait on rather than an error. - If the Secret is deleted while the webhook configuration still carries the
old
caBundle, the rotator'sGetfails,Startreturns an error and the manager exits, so the pod crash-loops rather than serving with a stale certificate. Under Argo CD the empty Secret comes back on the next sync and the controller refills it. WithfailurePolicy: Failthat means Ichor operations stop until it recovers. That is the intended direction of failure, andichorCLI recovery paths must not depend on the webhook. - Neither library exposes certificate expiry as a metric or a condition. Ichor's own expiry gauge lands with the rest of the metrics in phase 9; until then expiry is visible only in logs. Recorded as a known gap, not a surprise.
- cert-controller is maintained but not developed. The surface Ichor uses is one function and one struct, so if it stops building against a future controller-runtime, replacing it with an in-house rotator is a contained change rather than a rewrite.
Alternatives considered
cert-manager as the default. Ruled out by the design, which wants the default install to carry no such dependency.
Our own rotator. Roughly 200 lines of certificate generation, rotation windows and failure handling that cert-controller already has, in exchange for one fewer dependency and metrics from day one. Kept as the fallback.
A Helm-hook Job (kube-webhook-certgen-style). Well established, used by
ingress-nginx, and needs no Go code from us — but it is a point-in-time Job
with no continuous rotation, and it adds hook-ordering bookkeeping under Argo
CD that the in-process rotator does not need.
Amendment: cold-start ordering, no crash restarts
Found on hardware: a fresh install never converged. The rotator is leader-only
(see the earlier amendment), but controller-runtime's webhook server started at
once, read tls.crt, found nothing and stopped the manager 15 ms after the lease
was acquired, before the rotator wrote anything. With the 120 s lease and no
release on cancel (ADR 0063, ADR 0089), every dead holder then blocked the next
container for up to 120 s and the pod backoff grew to 5 min. The "one or two
expected restarts" of ADR 0031 won by luck while the lease was short; it lost every
time afterwards.
Decision: the webhook server does not start until its certificate exists.
- controller-runtime v0.24.1 adds the webhook server to the manager lazily, on the
first
mgr.GetWebhookServer()(webhookServerOnce). So that call, everySetup*WebhookWithManagerand the readiness check all move behind one gate (webhooksUpincmd/main.go), in a goroutine. - The gate opens on either trigger: the rotator closes
IsReady(the leader), ortls.crtandtls.keyexist and are non-empty in the cert directory (polled every second,waitForCertFiles). The second is how a non-leader replica, which runs no rotator, serves once the leader has written the Secret and the kubelet has synced the volume. Either replica becomes Ready on its own. - Readiness stays false until the gate is open (
gatedChecker) and then delegates to the server'sStartedChecker, so Ready still means "the webhook answers". - Only the leader writes the certificate, so a single writer is kept and the cold-start CA race stays closed.
- The metrics server, when
--metrics-cert-pathis set, has the same problem and cannot be delayed (it is built into the manager). It now takes its certificate from aGetCertificatethat reads the files per handshake, so it starts first and follows rotation; scrapes fail until the files exist. - Whether a start-up failure before leading should hold the lease is moot: with no failure before the certificate exists, nothing exits holding it. A later failure still holds it for up to the lease duration, which is the accepted ADR 0063 trade-off.
logCertStatusBeforeStartnow says the server waits, not that restarts are expected. ADR 0031's "one or two restarts" no longer holds.
ichor init waits for the webhooks for 30 min by default (WebhookWait, was the
15 min node wait), and reports the last real server answer when that wait ends
rather than the client's own "rate limiter ... deadline exceeded".