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  }