SRVOCF-1052: Reload serving TLS certificate at runtime - #173
Conversation
The backend previously loaded its serving certificate only during startup, so a rotated Secret was not reflected until the pod restarted. Reload the mounted pair periodically and retain the last valid pair when an update is incomplete or invalid, allowing new TLS connections to use the rotated certificate without interrupting existing ones.
The reloader polled every 30s, re-reading and re-parsing the cert/key on every tick and swapping the cached certificate even when nothing changed. It logged only failures, so a real rotation was invisible, and kept an unreachable nil guard in GetCertificate that New already makes impossible. Poll every 5 minutes and skip the parse and atomic swap when a content hash shows the on-disk pair is unchanged, so steady state costs only two small reads. Log successful loads with the cert expiry so rotations are observable, and drop the dead nil guard. Polling is kept (rather than inotify) because Kubernetes rotates mounted secrets via an atomic ..data symlink swap that file watches miss, and it avoids the k8s.io/apiserver dependency tree. The decision is recorded in docs/ARCHITECTURE.md. Issue SRVOCF-1052 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert that reload() returns the same cached *tls.Certificate pointer when the on-disk cert/key pair is unchanged, guarding the skip-reparse optimization against a future regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pipeline controller notification For optional jobs, comment This repository is configured in: LGTM mode |
|
@dsimansk: This pull request references SRVOCF-1052 which is a valid jira issue. Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the sub-task to target the "5.1.0" version, but no target version was set. DetailsIn response to this:
Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository. |
|
@dsimansk: all tests passed! Full PR test history. Your PR dashboard. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here. |
twoGiants
left a comment
There was a problem hiding this comment.
Thank you for this PR @dsimansk, security improvements are all so important and valued!
Writing it up in a testable packge is the way to go, great call!
I found a few things and left my review below.
Besides the review below I have an idea for an improvement on how you could refactor it towards a solution without a timer.
Reimplement the watch using fsnotify which is already in go.mod as an indirect dependency, so promoting it is alright. Here is a draft from Claude but there is probably an even simpler way:
type Reloader struct {
certFile string
keyFile string
current atomic.Pointer[tls.Certificate]
lastHash [sha256.Size]byte
pollInterval time.Duration // 0 uses defaultPollInterval; tests can override
}
const defaultPollInterval = 1 * time.Minute
// ...
// Run watches the cert/key files for changes and reloads on change.
// It blocks until ctx is cancelled.
func (r *Reloader) Run(ctx context.Context) {
for {
if err := r.watch(ctx); err != nil {
slog.Error("certificate watch failed, restarting", "err", err)
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
}
}
func (r *Reloader) watch(ctx context.Context) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("create fsnotify watcher: %w", err)
}
defer w.Close()
for _, f := range []string{r.certFile, r.keyFile} {
if err := w.Add(f); err != nil {
return fmt.Errorf("watch %s: %w", f, err)
}
}
interval := r.pollInterval
if interval == 0 {
interval = defaultPollInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case event, ok := <-w.Events:
if !ok {
return fmt.Errorf("fsnotify events channel closed")
}
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
_ = w.Remove(event.Name)
if err := w.Add(event.Name); err != nil {
return fmt.Errorf("re-watch %s: %w", event.Name, err)
}
}
if err := r.reload(); err != nil {
slog.Error("failed to reload TLS certificate", "err", err)
}
case err, ok := <-w.Errors:
if !ok {
return fmt.Errorf("fsnotify errors channel closed")
}
return fmt.Errorf("fsnotify error: %w", err)
case <-ticker.C:
if err := r.reload(); err != nil {
slog.Error("failed to reload TLS certificate (poll)", "err", err)
}
}
}
}| "time" | ||
| ) | ||
|
|
||
| const certificateReloadInterval = 5 * time.Minute |
There was a problem hiding this comment.
This should be configurable, so that we don't need a new commit to change it.
| } | ||
| } | ||
|
|
||
| func (r *Reloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { |
There was a problem hiding this comment.
This is a public method and Reloader can be initialized without using New -> so this can break.
I'd add correct error handling here.
And when this methods gets some logic it'll need test coverage.
| if err != nil { | ||
| return fmt.Errorf("load TLS certificate: %w", err) | ||
| } | ||
| r.current.Store(&cert) |
There was a problem hiding this comment.
Shouldn't CompareAndSwap be used here? Or Swap? That would probably be "safer".
| return nil | ||
| } | ||
|
|
||
| func (r *Reloader) Run() { |
There was a problem hiding this comment.
We probably should have graceful shutdown here (and in main.go!). The change here is small, can you add it?
func (r *Reloader) Run(ctx context.Context) {
ticker := time.NewTicker(certificateReloadInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.reload(); err != nil {
slog.Error("failed to reload TLS certificate", "err", err)
}
}
}
}And for main.go -> pls create a ticket.
| . "github.com/onsi/gomega" | ||
| ) | ||
|
|
||
| var _ = Describe("Reloader", func() { |
There was a problem hiding this comment.
Pls test the package using it's public api, not internal methods.
|
|
||
| func (r *Reloader) reload() error { | ||
| certPEM, err := os.ReadFile(r.certFile) | ||
| if err != nil { |
| if err != nil { | ||
| return fmt.Errorf("read TLS certificate: %w", err) | ||
| } | ||
| keyPEM, err := os.ReadFile(r.keyFile) |
| ) | ||
|
|
||
| var _ = Describe("Reloader", func() { | ||
| It("uses the certificate loaded after a rotation", func() { |
There was a problem hiding this comment.
You can add a test case for when the certs were deleted and are missing.
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Will this solve https://redhat.atlassian.net/browse/SRVOCF-1081 ? |
|
/retest |
pmeida
left a comment
There was a problem hiding this comment.
Great work!
Agreeing with Stanislav review I would only add one nit
|
|
||
| cert, err := tls.X509KeyPair(certPEM, keyPEM) | ||
| if err != nil { | ||
| return fmt.Errorf("load TLS certificate: %w", err) |
There was a problem hiding this comment.
| return fmt.Errorf("load TLS certificate: %w", err) | |
| return fmt.Errorf("parse TLS key pair %w", err) |
Otherwise the log from main.go
reloader, err := tlsreload.New(*certFile, *keyFile)
if err != nil {
log.Fatalf("Failed to load TLS certificate: %v", err)
}will result in an akward error message like Failed to load TLS certificate: load TLS certificate: ...
Summary
tls.LoadX509KeyPaircall at startup with atlsreload.Reloaderthat polls the mounted cert/key pair every 5 minutes and atomically swaps the cached certificate served viatls.Config.GetCertificate, so operator-rotated serving certs are picked up without restarting the pod.docs/ARCHITECTURE.md.Fixes SRVOCF-1052
Checklist
docs/ARCHITECTURE.md(if there are relevant changes to our layered architecture)