github.com/juju/juju@v0.0.0-20240327075706-a90865de2538/worker/secretbackendrotate/rotate.go (about)

     1  // Copyright 2023 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package secretbackendrotate
     5  
     6  import (
     7  	"fmt"
     8  	"time"
     9  
    10  	"github.com/juju/clock"
    11  	"github.com/juju/errors"
    12  	"github.com/juju/worker/v3"
    13  	"github.com/juju/worker/v3/catacomb"
    14  
    15  	"github.com/juju/juju/core/watcher"
    16  )
    17  
    18  // logger is here to stop the desire of creating a package level logger.
    19  // Don't do this, instead use the one passed as manifold config.
    20  type logger interface{}
    21  
    22  var _ logger = struct{}{}
    23  
    24  // Logger represents the methods used by the worker to log information.
    25  type Logger interface {
    26  	Debugf(string, ...interface{})
    27  }
    28  
    29  // SecretBackendManagerFacade instances provide a watcher for secret rotation changes.
    30  type SecretBackendManagerFacade interface {
    31  	WatchTokenRotationChanges() (watcher.SecretBackendRotateWatcher, error)
    32  	RotateBackendTokens(info ...string) error
    33  }
    34  
    35  // Config defines the operation of the Worker.
    36  type Config struct {
    37  	SecretBackendManagerFacade SecretBackendManagerFacade
    38  	Logger                     Logger
    39  	Clock                      clock.Clock
    40  }
    41  
    42  // Validate returns an error if config cannot drive the Worker.
    43  func (config Config) Validate() error {
    44  	if config.SecretBackendManagerFacade == nil {
    45  		return errors.NotValidf("nil Facade")
    46  	}
    47  	if config.Clock == nil {
    48  		return errors.NotValidf("nil Clock")
    49  	}
    50  	if config.Logger == nil {
    51  		return errors.NotValidf("nil Logger")
    52  	}
    53  	return nil
    54  }
    55  
    56  // NewWorker returns a Secret Backend token rotation Worker backed by config, or an error.
    57  func NewWorker(config Config) (worker.Worker, error) {
    58  	if err := config.Validate(); err != nil {
    59  		return nil, errors.Trace(err)
    60  	}
    61  
    62  	w := &Worker{
    63  		config:      config,
    64  		backendInfo: make(map[string]tokenRotateInfo),
    65  	}
    66  	err := catacomb.Invoke(catacomb.Plan{
    67  		Site: &w.catacomb,
    68  		Work: w.loop,
    69  	})
    70  	return w, errors.Trace(err)
    71  }
    72  
    73  type tokenRotateInfo struct {
    74  	ID          string
    75  	backendName string
    76  	rotateTime  time.Time
    77  	whenFunc    func(rotateTime time.Time) time.Duration
    78  }
    79  
    80  func (s tokenRotateInfo) GoString() string {
    81  	return fmt.Sprintf("%s token rotation: in %v at %s", s.backendName, s.whenFunc(s.rotateTime), s.rotateTime.Format(time.RFC3339))
    82  }
    83  
    84  // Worker fires events when secrets should be rotated.
    85  type Worker struct {
    86  	catacomb catacomb.Catacomb
    87  	config   Config
    88  
    89  	backendInfo map[string]tokenRotateInfo
    90  
    91  	timer       clock.Timer
    92  	nextTrigger time.Time
    93  }
    94  
    95  // Kill is defined on worker.Worker.
    96  func (w *Worker) Kill() {
    97  	w.catacomb.Kill(nil)
    98  }
    99  
   100  // Wait is part of the worker.Worker interface.
   101  func (w *Worker) Wait() error {
   102  	return w.catacomb.Wait()
   103  }
   104  
   105  func (w *Worker) loop() (err error) {
   106  	changes, err := w.config.SecretBackendManagerFacade.WatchTokenRotationChanges()
   107  	if err != nil {
   108  		return errors.Trace(err)
   109  	}
   110  	if err := w.catacomb.Add(changes); err != nil {
   111  		return errors.Trace(err)
   112  	}
   113  	for {
   114  		var timeout <-chan time.Time
   115  		if w.timer != nil {
   116  			timeout = w.timer.Chan()
   117  		}
   118  		select {
   119  		case <-w.catacomb.Dying():
   120  			return w.catacomb.ErrDying()
   121  		case ch, ok := <-changes.Changes():
   122  			if !ok {
   123  				return errors.New("secret rotation change channel closed")
   124  			}
   125  			w.handleTokenRotateChanges(ch)
   126  		case now := <-timeout:
   127  			if err := w.rotate(now); err != nil {
   128  				return errors.Annotatef(err, "rotating secret backends")
   129  			}
   130  		}
   131  	}
   132  }
   133  
   134  func (w *Worker) rotate(now time.Time) error {
   135  	w.config.Logger.Debugf("processing secret backend token rotation at %s", now)
   136  
   137  	var toRotate []string
   138  	for id, info := range w.backendInfo {
   139  		w.config.Logger.Debugf("checking %s: rotate at %s... time diff %s", id, info.rotateTime, info.rotateTime.Sub(now))
   140  		// A one minute granularity is acceptable for secret rotation.
   141  		if info.rotateTime.Truncate(time.Minute).Before(now) {
   142  			w.config.Logger.Debugf("rotating token for %s", info.backendName)
   143  			toRotate = append(toRotate, id)
   144  			// Once backend has been queued for rotation, delete it here since
   145  			// it will re-appear via the watcher after the rotation is actually
   146  			// performed and the last rotated time is updated.
   147  			delete(w.backendInfo, id)
   148  		}
   149  	}
   150  
   151  	if err := w.config.SecretBackendManagerFacade.RotateBackendTokens(toRotate...); err != nil {
   152  		return errors.Annotatef(err, "cannot rotate secret backend tokens for backend ids %q", toRotate)
   153  	}
   154  	w.computeNextRotateTime()
   155  	return nil
   156  }
   157  
   158  func (w *Worker) handleTokenRotateChanges(changes []watcher.SecretBackendRotateChange) {
   159  	w.config.Logger.Debugf("got rotate secret changes: %#v", changes)
   160  	if len(changes) == 0 {
   161  		return
   162  	}
   163  
   164  	for _, ch := range changes {
   165  		// Next rotate time of 0 means the rotation has been deleted.
   166  		if ch.NextTriggerTime.IsZero() {
   167  			w.config.Logger.Debugf("token for %q no longer rotated", ch.Name)
   168  			delete(w.backendInfo, ch.ID)
   169  			continue
   170  		}
   171  		w.backendInfo[ch.ID] = tokenRotateInfo{
   172  			ID:          ch.ID,
   173  			backendName: ch.Name,
   174  			rotateTime:  ch.NextTriggerTime,
   175  			whenFunc:    func(rotateTime time.Time) time.Duration { return rotateTime.Sub(w.config.Clock.Now()) },
   176  		}
   177  	}
   178  	w.computeNextRotateTime()
   179  }
   180  
   181  func (w *Worker) computeNextRotateTime() {
   182  	w.config.Logger.Debugf("computing next rotated time for secret backends %#v", w.backendInfo)
   183  
   184  	if len(w.backendInfo) == 0 {
   185  		w.timer = nil
   186  		return
   187  	}
   188  
   189  	// Find the minimum (next) rotateTime from all the tokens.
   190  	var soonestRotateTime time.Time
   191  	for _, info := range w.backendInfo {
   192  		if !soonestRotateTime.IsZero() && info.rotateTime.After(soonestRotateTime) {
   193  			continue
   194  		}
   195  		soonestRotateTime = info.rotateTime
   196  	}
   197  	// There's no need to start or reset the timer if there's no changes to make.
   198  	if soonestRotateTime.IsZero() || w.nextTrigger == soonestRotateTime {
   199  		return
   200  	}
   201  
   202  	// Account for the worker not running when a secret
   203  	// should have been rotated.
   204  	now := w.config.Clock.Now()
   205  	if soonestRotateTime.Before(now) {
   206  		soonestRotateTime = now
   207  	}
   208  
   209  	nextDuration := soonestRotateTime.Sub(now)
   210  	w.config.Logger.Debugf("next token will rotate in %v at %s", nextDuration, soonestRotateTime)
   211  
   212  	w.nextTrigger = soonestRotateTime
   213  	if w.timer == nil {
   214  		w.timer = w.config.Clock.NewTimer(nextDuration)
   215  	} else {
   216  		// See the docs on Timer.Reset() that says it isn't safe to call
   217  		// on a non-stopped channel, and if it is stopped, you need to check
   218  		// if the channel needs to be drained anyway. It isn't safe to drain
   219  		// unconditionally in case another goroutine has already noticed,
   220  		// but make an attempt.
   221  		if !w.timer.Stop() {
   222  			select {
   223  			case <-w.timer.Chan():
   224  			default:
   225  			}
   226  		}
   227  		w.timer.Reset(nextDuration)
   228  	}
   229  }