github.com/juju/juju@v0.0.0-20240327075706-a90865de2538/worker/secretexpire/secretexpire.go (about) 1 // Copyright 2022 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package secretexpire 5 6 import ( 7 "fmt" 8 "time" 9 10 "github.com/juju/clock" 11 "github.com/juju/errors" 12 "github.com/juju/names/v5" 13 "github.com/juju/worker/v3" 14 "github.com/juju/worker/v3/catacomb" 15 16 "github.com/juju/juju/core/secrets" 17 "github.com/juju/juju/core/watcher" 18 ) 19 20 // logger is here to stop the desire of creating a package level logger. 21 // Don't do this, instead use the one passed as manifold config. 22 type logger interface{} 23 24 var _ logger = struct{}{} 25 26 // Logger represents the methods used by the worker to log information. 27 type Logger interface { 28 Debugf(string, ...interface{}) 29 Warningf(string, ...interface{}) 30 } 31 32 // SecretManagerFacade instances provide a watcher for secret revision expiry changes. 33 type SecretManagerFacade interface { 34 WatchSecretRevisionsExpiryChanges(ownerTags ...names.Tag) (watcher.SecretTriggerWatcher, error) 35 } 36 37 // Config defines the operation of the Worker. 38 type Config struct { 39 SecretManagerFacade SecretManagerFacade 40 Logger Logger 41 Clock clock.Clock 42 43 SecretOwners []names.Tag 44 ExpireRevisions chan<- []string 45 } 46 47 // Validate returns an error if config cannot drive the Worker. 48 func (config Config) Validate() error { 49 if config.SecretManagerFacade == nil { 50 return errors.NotValidf("nil Facade") 51 } 52 if config.Clock == nil { 53 return errors.NotValidf("nil Clock") 54 } 55 if config.Logger == nil { 56 return errors.NotValidf("nil Logger") 57 } 58 if len(config.SecretOwners) == 0 { 59 return errors.NotValidf("empty SecretOwners") 60 } 61 if config.ExpireRevisions == nil { 62 return errors.NotValidf("nil ExpireRevisionsChannel") 63 } 64 return nil 65 } 66 67 // New returns a Secret Expiry Worker backed by config, or an error. 68 func New(config Config) (worker.Worker, error) { 69 if err := config.Validate(); err != nil { 70 return nil, errors.Trace(err) 71 } 72 73 w := &Worker{ 74 config: config, 75 secretRevisions: make(map[string]secretRevisionExpiryInfo), 76 } 77 err := catacomb.Invoke(catacomb.Plan{ 78 Site: &w.catacomb, 79 Work: w.loop, 80 }) 81 return w, errors.Trace(err) 82 } 83 84 type secretRevisionExpiryInfo struct { 85 uri *secrets.URI 86 revision int 87 expireTime time.Time 88 retryCount int 89 } 90 91 func (s secretRevisionExpiryInfo) GoString() string { 92 interval := s.expireTime.Sub(time.Now()) 93 if interval < 0 { 94 return fmt.Sprintf("%s expiry: %v ago at %s", expiryKey(s.uri, s.revision), -interval, s.expireTime.Format(time.RFC3339)) 95 } 96 return fmt.Sprintf("%s expiry: in %v at %s", expiryKey(s.uri, s.revision), interval, s.expireTime.Format(time.RFC3339)) 97 } 98 99 // Worker fires events when secret revisions should be expired. 100 type Worker struct { 101 catacomb catacomb.Catacomb 102 config Config 103 104 secretRevisions map[string]secretRevisionExpiryInfo 105 106 timer clock.Timer 107 nextTrigger time.Time 108 } 109 110 // Kill is defined on worker.Worker. 111 func (w *Worker) Kill() { 112 w.catacomb.Kill(nil) 113 } 114 115 // Wait is part of the worker.Worker interface. 116 func (w *Worker) Wait() error { 117 return w.catacomb.Wait() 118 } 119 120 func (w *Worker) loop() (err error) { 121 changes, err := w.config.SecretManagerFacade.WatchSecretRevisionsExpiryChanges(w.config.SecretOwners...) 122 if err != nil { 123 return errors.Trace(err) 124 } 125 if err := w.catacomb.Add(changes); err != nil { 126 return errors.Trace(err) 127 } 128 for { 129 var timeout <-chan time.Time 130 if w.timer != nil { 131 timeout = w.timer.Chan() 132 } 133 select { 134 case <-w.catacomb.Dying(): 135 return w.catacomb.ErrDying() 136 case ch, ok := <-changes.Changes(): 137 if !ok { 138 return errors.New("secret revision expiry change channel closed") 139 } 140 w.handleSecretRevisionExpiryChanges(ch) 141 case now := <-timeout: 142 w.expire(now) 143 } 144 } 145 } 146 147 func (w *Worker) expire(now time.Time) { 148 w.config.Logger.Debugf("processing secret expiry for %q at %s", w.config.SecretOwners, now) 149 150 var toExpire []string 151 for id, info := range w.secretRevisions { 152 w.config.Logger.Debugf("expire %s at %s... time diff %s", id, info.expireTime, info.expireTime.Sub(now)) 153 // A one minute granularity is acceptable for secret expiry. 154 if info.expireTime.Truncate(time.Minute).Before(now) { 155 if info.retryCount > 0 { 156 w.config.Logger.Warningf("retry attempt %d to expire secret %q revision %d", info.retryCount, info.uri, info.revision) 157 } 158 toExpire = append(toExpire, expiryKey(info.uri, info.revision)) 159 // Once secret revision has been queued for expiry, requeue it 160 // a short time later. The charm is expected to delete the revision 161 // on expiry; if not, the expire hook will run until it does. 162 newInfo := info 163 newInfo.expireTime = info.expireTime.Add(secrets.ExpireRetryDelay) 164 newInfo.retryCount++ 165 w.secretRevisions[id] = newInfo 166 } 167 } 168 169 if len(toExpire) > 0 { 170 select { 171 case <-w.catacomb.Dying(): 172 return 173 case w.config.ExpireRevisions <- toExpire: 174 } 175 } 176 w.computeNextExpireTime() 177 } 178 179 func expiryKey(uri *secrets.URI, revision int) string { 180 return fmt.Sprintf("%s/%d", uri.ID, revision) 181 } 182 183 func (w *Worker) handleSecretRevisionExpiryChanges(changes []watcher.SecretTriggerChange) { 184 w.config.Logger.Debugf("got revision expiry secret changes: %#v", changes) 185 if len(changes) == 0 { 186 return 187 } 188 189 for _, ch := range changes { 190 // Next trigger time of 0 means the expiry has been deleted. 191 if ch.NextTriggerTime.IsZero() { 192 w.config.Logger.Debugf("secret revision %d no longer expires: %v", ch.URI.ID, ch.Revision) 193 delete(w.secretRevisions, expiryKey(ch.URI, ch.Revision)) 194 continue 195 } 196 w.secretRevisions[expiryKey(ch.URI, ch.Revision)] = secretRevisionExpiryInfo{ 197 uri: ch.URI, 198 revision: ch.Revision, 199 expireTime: ch.NextTriggerTime, 200 } 201 } 202 w.computeNextExpireTime() 203 } 204 205 func (w *Worker) computeNextExpireTime() { 206 w.config.Logger.Debugf("computing next expire time for secret revisions %#v", w.secretRevisions) 207 208 if len(w.secretRevisions) == 0 { 209 w.timer = nil 210 return 211 } 212 213 // Find the minimum (next) expireTime from all the secrets. 214 var soonestExpireTime time.Time 215 now := w.config.Clock.Now() 216 for id, info := range w.secretRevisions { 217 if !soonestExpireTime.IsZero() && info.expireTime.After(soonestExpireTime) { 218 continue 219 } 220 // Account for the worker not running when a secret 221 // revision should have been expired. 222 if info.expireTime.Before(now) { 223 info.expireTime = now 224 w.secretRevisions[id] = info 225 } 226 soonestExpireTime = info.expireTime 227 } 228 // There's no need to start or reset the timer if there's no changes to make. 229 if soonestExpireTime.IsZero() || w.nextTrigger == soonestExpireTime { 230 return 231 } 232 233 nextDuration := soonestExpireTime.Sub(now) 234 w.config.Logger.Debugf("next secret revision for %q will expire in %v at %s", w.config.SecretOwners, nextDuration, soonestExpireTime) 235 236 w.nextTrigger = soonestExpireTime 237 if w.timer == nil { 238 w.timer = w.config.Clock.NewTimer(nextDuration) 239 } else { 240 // See the docs on Timer.Reset() that says it isn't safe to call 241 // on a non-stopped channel, and if it is stopped, you need to check 242 // if the channel needs to be drained anyway. It isn't safe to drain 243 // unconditionally in case another goroutine has already noticed, 244 // but make an attempt. 245 if !w.timer.Stop() { 246 select { 247 case <-w.timer.Chan(): 248 default: 249 } 250 } 251 w.timer.Reset(nextDuration) 252 } 253 }