fix(kube-ovn): reload kubeovn-webhook serving certificate on cert-manager renewal - #3557
Conversation
The webhook loaded its TLS key pair once at startup and served it as a static tls.Config.Certificates entry for the lifetime of the process. A cert-manager renewal of the backing Secret was never picked up by a running pod, so once the certificate expired (about a year after install) the pod kept presenting the expired certificate. The kube-apiserver's TLS call to the webhook then failed and, because the MutatingWebhookConfiguration uses failurePolicy: Fail, every pod creation in tenant namespaces was rejected (including virt-launcher pods, blocking all VMIs). Serve the certificate through a reloading tls.Config.GetCertificate callback that re-reads the key pair when the mounted files change and keeps serving the last good certificate on a failed reload. Add a regression test that fails against the static implementation and passes once the certificate is reloaded. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
renewBefore: 24h on a one-year certificate leaves a one-day window for cert-manager to rotate the Secret and for the webhook to pick it up. Widen it to 720h (30 days) so renewal happens well ahead of expiry. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
💤 Files with no reviewable changes (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis change adds runtime TLS certificate reloading to the Kube-OVN webhook, uses the reloading configuration at server startup, adds integration coverage, configures server timeouts, and increases the cert-manager renewal window. ChangesKube-OVN webhook TLS reload
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant http.Server
participant tls.Config.GetCertificate
participant CertFiles
Client->>http.Server: start TLS handshake
http.Server->>tls.Config.GetCertificate: request server certificate
tls.Config.GetCertificate->>CertFiles: load certificate and key files
alt files load successfully
CertFiles-->>tls.Config.GetCertificate: current certificate
tls.Config.GetCertificate-->>http.Server: certificate
http.Server-->>Client: complete handshake
else file loading fails
CertFiles-->>tls.Config.GetCertificate: read error
tls.Config.GetCertificate-->>http.Server: handshake error
end
Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Address review of the reload path: - Observe the cert file mtime before reading the key pair, so a Secret swap racing the read is retried on the next handshake instead of being cached under a newer mtime and missed. - Record the load attempt regardless of outcome, so a persistently unreadable file is retried only once its mtime advances, not on every handshake. - Detect change with mtime inequality instead of a strictly-newer comparison, catching equal-mtime replacements and backward clock steps. - Log stat failures instead of swallowing them, so a broken mount is not invisible until expiry. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
A previous revision recorded the certificate file mtime even when the reload failed, to avoid re-reading a persistently bad file on every handshake. That conflated two failure modes: a transient, content- independent error (fd exhaustion, a torn read) on a VALID renewed file advanced the recorded mtime, so the staleness check never fired again and the renewed certificate was never loaded. The cached certificate then expired within renewBefore, and failurePolicy: Fail blocked all tenant pod creation - exactly the outage this reloader prevents. Advance the recorded mtime only on a successful load, and bound retries of a failed load with a wall-clock interval instead. A transient failure is now retried until it succeeds, while a persistently unreadable file is still retried only once per interval, not on every handshake. Keep a single reload in flight to avoid a thundering herd, and rate-limit the failure log. Add a regression test that fails when the mtime is advanced on failure. Detect change with mtime inequality, so a backward clock step is also picked up. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/system/kubeovn-webhook/images/kubeovn-webhook/main.go (1)
35-38: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winAdd timeouts to the
http.Server.The server sets no read, write, or idle timeout. A client that opens a connection and sends headers slowly holds a goroutine and a file descriptor for an unbounded time. The webhook is cluster-internal, so the exposure is limited, but the timeouts are cheap to add while this literal is being changed.
⏱️ Proposed change to bound request handling
server := &http.Server{ - Addr: ":8443", - TLSConfig: tlsConfig, - Handler: mux, + Addr: ":8443", + TLSConfig: tlsConfig, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, }This change requires the
timeimport inmain.go.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/system/kubeovn-webhook/images/kubeovn-webhook/main.go` around lines 35 - 38, Update the `http.Server` literal in `main` to set explicit `ReadTimeout`, `WriteTimeout`, and `IdleTimeout` values, and add the required `time` import in `main.go`. Keep the existing `Addr`, `TLSConfig`, and `Handler` setup unchanged while using the `server` symbol to bound slow or idle client connections.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/system/kubeovn-webhook/images/kubeovn-webhook/main.go`:
- Around line 35-38: Update the `http.Server` literal in `main` to set explicit
`ReadTimeout`, `WriteTimeout`, and `IdleTimeout` values, and add the required
`time` import in `main.go`. Keep the existing `Addr`, `TLSConfig`, and `Handler`
setup unchanged while using the `server` symbol to bound slow or idle client
connections.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1a756297-42d6-49dc-a677-3acf19536952
📒 Files selected for processing (4)
packages/system/kubeovn-webhook/images/kubeovn-webhook/cert.gopackages/system/kubeovn-webhook/images/kubeovn-webhook/cert_test.gopackages/system/kubeovn-webhook/images/kubeovn-webhook/main.gopackages/system/kubeovn-webhook/templates/certmanager.yaml
Timofei Larkin (lllamnyp)
left a comment
There was a problem hiding this comment.
The bug this fixes is real and well diagnosed: the webhook loaded its TLS serving cert once at startup into a static tls.Config.Certificates, so a cert-manager renewal of the Secret was never picked up by a running pod, and once the cert expired the apiserver's TLS call to the webhook failed — with failurePolicy: Fail, that takes down pod creation cluster-wide. Good catch, and the renewBefore: 24h → 720h change in templates/certmanager.yaml is a sensible independent hardening on top of it (30 days of margin for a renewal retry vs. 1 day).
On the implementation: I think cert.go's certReloader (mtime tracking, serialized refresh, retry backoff, rate-limited logging, last-good-certificate fallback — 155 lines, plus 245 lines of tests) can collapse to a GetCertificate closure that just re-reads the files on every handshake, with no watcher, cache, or extra state:
package main
import "crypto/tls"
// newReloadingTLSConfig returns a tls.Config that reloads the certificate and key
// from disk on every TLS handshake, so a cert-manager renewal of the mounted Secret
// is picked up by the running process without a restart.
func newReloadingTLSConfig(certFile, keyFile string) (*tls.Config, error) {
if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
return nil, err
}
return &tls.Config{
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &cert, nil
},
}, nil
}Same function signature, so main.go doesn't need to change at all — this is a deletion inside cert.go, not a rewrite. A few things that make me confident this is sufficient rather than under-engineered:
GetCertificateis called on every handshake wheneverCertificatesis left unset, percrypto/tlsdocs — no SNI dependency, no gap. I verified this empirically too: a standalone listener using exactly the closure above serves a stale cert before a file rewrite and the renewed one immediately after, across two separate connections, no polling involved.- The torn-read scenario
certReloaderguards against (reading a cert/key pair mid-write,cert.go:81-107, tested incert_test.go:165-245) isn't reachable here:templates/deployment.yamlmountswebhook-certsas a plainsecret:volume with nosubPath, and Kubernetes' atomic writer for Secret volumes swaps the whole..datadirectory via a single symlink flip. A reader always sees a complete old generation or a complete new one, never a mixture — of a single file or of the cert/key pair. That's what the two extra tests are actually exercising: that the fallback machinery works internally, not that it's needed for anything this deployment can produce. - The per-handshake
LoadX509KeyPaircost (a few KB of file I/O plus a PEM/key parse) is negligible next to the asymmetric crypto a TLS handshake is already doing (ECDHE key exchange, certificate signing) — it shouldn't need caching to stay cheap. - No mTLS is configured on this webhook (
mutatingwebhookconfiguration.yamlhas no client-cert verification, and neither version ofcert.gosetsClientCAs), soGetCertificate's well-known limitation — it doesn't refresh CA pools, only server/client leaf certs — doesn't bite here.
One real trade-off worth naming: if the mounted cert genuinely becomes unreadable (Secret deleted, permissions changed — not a normal renewal), the closure above fails every handshake and logs on each one, where the current certReloader keeps serving the last good cert and rate-limits the log line. That's a narrow, admin-error-only scenario, and failing loudly there seems fine to me, but it's the one place the extra code isn't purely redundant.
Happy to open a follow-up PR with the smaller diff if that's useful, or if there's a reason to keep the extra machinery I'm missing, I'd like to hear it.
(Review drafted with Claude Code assistance.)
…osure Collapse the certReloader (mtime tracking, serialized refresh, retry backoff, rate-limited logging, last-good-certificate fallback) into a GetCertificate callback that simply re-reads the key pair from disk on every handshake. The signature is unchanged, so main.go is untouched. The extra machinery guarded scenarios this deployment cannot produce: - Torn reads are unreachable: the cert/key files come from a plain Secret volume with no subPath, and Kubernetes' atomic writer swaps the whole ..data directory via a single symlink flip, so a reader always sees a complete old or complete new generation of both files. - The per-handshake LoadX509KeyPair cost is negligible next to the asymmetric crypto the handshake already performs, so no cache is needed. - The webhook configures no mTLS, so GetCertificate not refreshing client CA pools does not apply. If the mounted files genuinely become unreadable (an operator error, not a renewal) the handshake now fails loudly instead of silently serving a stale certificate. The core regression test is retained and remains non-vacuous: it fails against a static tls.Config.Certificates and passes with the reloading callback. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
The webhook http.Server set no read, write, or idle timeout, so a client that opens a connection and sends headers slowly could hold a goroutine and file descriptor indefinitely. Add ReadHeaderTimeout, ReadTimeout, WriteTimeout, and IdleTimeout. Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
|
Successfully created backport PR for |
|
Successfully created backport PR for |
What this PR does
The
kube-ovn-webhookloaded its TLS serving certificate once at process startup (tls.LoadX509KeyPairinto a statictls.Config.Certificates) and never re-read it. cert-manager renews the backing Secret ahead of expiry, but a running pod kept serving the certificate it loaded at boot. Once that certificate expired — roughly one year after install, on any cluster whose webhook pods had not been restarted after the renewal — the pod served an expired certificate, the kube-apiserver's TLS call to the webhook failed verification, and because theMutatingWebhookConfigurationusesfailurePolicy: Fail, every pod creation in tenant namespaces was rejected. In practice this blocksvirt-launcherpods, so no VMI can start.This PR:
tls.Config.GetCertificatecallback that re-reads the key pair when the mounted files change, and keeps serving the last good certificate if a reload fails (e.g. a torn read while the volume is being updated). cert-manager renewals are now honoured without a pod restart.renewBeforefrom24hto720h, so renewal happens well ahead of expiry instead of one day before.Screenshots
Not applicable (no UI changes).
Downstream repositories
Walked the trigger map file-by-file against the diff: the change is confined to the
system/kubeovn-webhookimage and its cert-managerCertificaterenewBefore; it touches no package underapps//extra/, novalues.schema.json, no appvalues.yamldefault, noApplicationDefinition, no platform values, no CRD, and nohack/tooling.Release note
Summary by CodeRabbit
New Features
Bug Fixes
Configuration