Skip to content

Commit 42f7918

Browse files
authored
Merge pull request #3053 from sbueringer/cert-watcher-plus-0.18
[release-0.18] 🌱 Add fsnotify watcher+polling
2 parents 12955b3 + ff25ed3 commit 42f7918

File tree

5 files changed

+99
-16
lines changed

5 files changed

+99
-16
lines changed

examples/scratch-env/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/davecgh/go-spew v1.1.1 // indirect
1515
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
1616
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
17+
github.com/fsnotify/fsnotify v1.7.0 // indirect
1718
github.com/go-logr/logr v1.4.2 // indirect
1819
github.com/go-logr/zapr v1.3.0 // indirect
1920
github.com/go-openapi/jsonpointer v0.19.6 // indirect

examples/scratch-env/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH
1212
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
1313
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
1414
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
15+
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
16+
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
1517
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
1618
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
1719
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.22.0
55
require (
66
github.com/evanphx/json-patch v4.12.0+incompatible // Using v4 to match upstream
77
github.com/evanphx/json-patch/v5 v5.9.0
8-
github.com/fsnotify/fsnotify v1.7.0 // indirect
8+
github.com/fsnotify/fsnotify v1.7.0
99
github.com/go-logr/logr v1.4.2
1010
github.com/go-logr/zapr v1.3.0
1111
github.com/google/go-cmp v0.6.0

pkg/certwatcher/certwatcher.go

+81-7
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ import (
2020
"bytes"
2121
"context"
2222
"crypto/tls"
23+
"fmt"
2324
"os"
2425
"sync"
2526
"time"
2627

28+
"github.com/fsnotify/fsnotify"
29+
kerrors "k8s.io/apimachinery/pkg/util/errors"
30+
"k8s.io/apimachinery/pkg/util/sets"
31+
"k8s.io/apimachinery/pkg/util/wait"
2732
"sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics"
2833
logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
2934
)
@@ -40,6 +45,7 @@ type CertWatcher struct {
4045
sync.RWMutex
4146

4247
currentCert *tls.Certificate
48+
watcher *fsnotify.Watcher
4349
interval time.Duration
4450

4551
certPath string
@@ -53,13 +59,25 @@ type CertWatcher struct {
5359

5460
// New returns a new CertWatcher watching the given certificate and key.
5561
func New(certPath, keyPath string) (*CertWatcher, error) {
62+
var err error
63+
5664
cw := &CertWatcher{
5765
certPath: certPath,
5866
keyPath: keyPath,
5967
interval: defaultWatchInterval,
6068
}
6169

62-
return cw, cw.ReadCertificate()
70+
// Initial read of certificate and key.
71+
if err := cw.ReadCertificate(); err != nil {
72+
return nil, err
73+
}
74+
75+
cw.watcher, err = fsnotify.NewWatcher()
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
return cw, nil
6381
}
6482

6583
// WithWatchInterval sets the watch interval and returns the CertWatcher pointer
@@ -88,14 +106,35 @@ func (cw *CertWatcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate,
88106

89107
// Start starts the watch on the certificate and key files.
90108
func (cw *CertWatcher) Start(ctx context.Context) error {
109+
files := sets.New(cw.certPath, cw.keyPath)
110+
111+
{
112+
var watchErr error
113+
if err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 10*time.Second, true, func(ctx context.Context) (done bool, err error) {
114+
for _, f := range files.UnsortedList() {
115+
if err := cw.watcher.Add(f); err != nil {
116+
watchErr = err
117+
return false, nil //nolint:nilerr // We want to keep trying.
118+
}
119+
// We've added the watch, remove it from the set.
120+
files.Delete(f)
121+
}
122+
return true, nil
123+
}); err != nil {
124+
return fmt.Errorf("failed to add watches: %w", kerrors.NewAggregate([]error{err, watchErr}))
125+
}
126+
}
127+
128+
go cw.Watch()
129+
91130
ticker := time.NewTicker(cw.interval)
92131
defer ticker.Stop()
93132

94-
log.Info("Starting certificate watcher")
133+
log.Info("Starting certificate poll+watcher", "interval", cw.interval)
95134
for {
96135
select {
97136
case <-ctx.Done():
98-
return nil
137+
return cw.watcher.Close()
99138
case <-ticker.C:
100139
if err := cw.ReadCertificate(); err != nil {
101140
log.Error(err, "failed read certificate")
@@ -104,11 +143,26 @@ func (cw *CertWatcher) Start(ctx context.Context) error {
104143
}
105144
}
106145

107-
// Watch used to read events from the watcher's channel and reacts to changes,
108-
// it has currently no function and it's left here for backward compatibility until a future release.
109-
//
110-
// Deprecated: fsnotify has been removed and Start() is now polling instead.
146+
// Watch reads events from the watcher's channel and reacts to changes.
111147
func (cw *CertWatcher) Watch() {
148+
for {
149+
select {
150+
case event, ok := <-cw.watcher.Events:
151+
// Channel is closed.
152+
if !ok {
153+
return
154+
}
155+
156+
cw.handleEvent(event)
157+
case err, ok := <-cw.watcher.Errors:
158+
// Channel is closed.
159+
if !ok {
160+
return
161+
}
162+
163+
log.Error(err, "certificate watch error")
164+
}
165+
}
112166
}
113167

114168
// updateCachedCertificate checks if the new certificate differs from the cache,
@@ -166,3 +220,23 @@ func (cw *CertWatcher) ReadCertificate() error {
166220
}
167221
return nil
168222
}
223+
224+
func (cw *CertWatcher) handleEvent(event fsnotify.Event) {
225+
// Only care about events which may modify the contents of the file.
226+
switch {
227+
case event.Op.Has(fsnotify.Write):
228+
case event.Op.Has(fsnotify.Create):
229+
case event.Op.Has(fsnotify.Chmod), event.Op.Has(fsnotify.Remove):
230+
// If the file was removed or renamed, re-add the watch to the previous name
231+
if err := cw.watcher.Add(event.Name); err != nil {
232+
log.Error(err, "error re-watching file")
233+
}
234+
default:
235+
return
236+
}
237+
238+
log.V(1).Info("certificate event", "event", event)
239+
if err := cw.ReadCertificate(); err != nil {
240+
log.Error(err, "error re-reading certificate")
241+
}
242+
}

pkg/certwatcher/certwatcher_test.go

+14-8
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,12 @@ var _ = Describe("CertWatcher", func() {
7777
Expect(err).ToNot(HaveOccurred())
7878
})
7979

80-
startWatcher := func() (done <-chan struct{}) {
80+
startWatcher := func(interval time.Duration) (done <-chan struct{}) {
8181
doneCh := make(chan struct{})
8282
go func() {
8383
defer GinkgoRecover()
8484
defer close(doneCh)
85-
Expect(watcher.WithWatchInterval(time.Second).Start(ctx)).To(Succeed())
85+
Expect(watcher.WithWatchInterval(interval).Start(ctx)).To(Succeed())
8686
}()
8787
// wait till we read first cert
8888
Eventually(func() error {
@@ -93,14 +93,16 @@ var _ = Describe("CertWatcher", func() {
9393
}
9494

9595
It("should read the initial cert/key", func() {
96-
doneCh := startWatcher()
96+
// This test verifies the initial read succeeded. So interval doesn't matter.
97+
doneCh := startWatcher(10 * time.Second)
9798

9899
ctxCancel()
99100
Eventually(doneCh, "4s").Should(BeClosed())
100101
})
101102

102103
It("should reload currentCert when changed", func() {
103-
doneCh := startWatcher()
104+
// This test verifies fsnotify detects the cert change. So interval doesn't matter.
105+
doneCh := startWatcher(10 * time.Second)
104106
called := atomic.Int64{}
105107
watcher.RegisterCallback(func(crt tls.Certificate) {
106108
called.Add(1)
@@ -124,7 +126,8 @@ var _ = Describe("CertWatcher", func() {
124126
})
125127

126128
It("should reload currentCert when changed with rename", func() {
127-
doneCh := startWatcher()
129+
// This test verifies fsnotify detects the cert change. So interval doesn't matter.
130+
doneCh := startWatcher(10 * time.Second)
128131
called := atomic.Int64{}
129132
watcher.RegisterCallback(func(crt tls.Certificate) {
130133
called.Add(1)
@@ -154,7 +157,8 @@ var _ = Describe("CertWatcher", func() {
154157
})
155158

156159
It("should reload currentCert after move out", func() {
157-
doneCh := startWatcher()
160+
// This test verifies poll works, so we'll use 1s as interval (fsnotify doesn't detect this change).
161+
doneCh := startWatcher(1 * time.Second)
158162
called := atomic.Int64{}
159163
watcher.RegisterCallback(func(crt tls.Certificate) {
160164
called.Add(1)
@@ -190,7 +194,8 @@ var _ = Describe("CertWatcher", func() {
190194
})
191195

192196
It("should get updated on successful certificate read", func() {
193-
doneCh := startWatcher()
197+
// This test verifies fsnotify, so interval doesn't matter.
198+
doneCh := startWatcher(10 * time.Second)
194199

195200
Eventually(func() error {
196201
readCertificateTotalAfter := testutil.ToFloat64(metrics.ReadCertificateTotal)
@@ -205,7 +210,8 @@ var _ = Describe("CertWatcher", func() {
205210
})
206211

207212
It("should get updated on read certificate errors", func() {
208-
doneCh := startWatcher()
213+
// This test works with fsnotify, so interval doesn't matter.
214+
doneCh := startWatcher(10 * time.Second)
209215

210216
Eventually(func() error {
211217
readCertificateTotalAfter := testutil.ToFloat64(metrics.ReadCertificateTotal)

0 commit comments

Comments
 (0)