// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package controller
import (
"context"
"fmt"
"strings"
"time"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/consul/internal/controller/cache"
"github.com/hashicorp/consul/internal/controller/cache/index"
"github.com/hashicorp/consul/internal/resource"
"github.com/hashicorp/consul/proto-public/pbresource"
)
// DependencyMapper is called when a dependency watched via WithWatch is changed
// to determine which of the controller's managed resources need to be reconciled.
type DependencyMapper func (
ctx context . Context ,
rt Runtime ,
res * pbresource . Resource ,
) ( [ ] Request , error )
// Controller runs a reconciliation loop to respond to changes in resources and
// their dependencies. It is heavily inspired by Kubernetes' controller pattern:
// https://kubernetes.io/docs/concepts/architecture/controller/
//
// Use the builder methods in this package (starting with NewController) to construct
// a controller, and then pass it to a Manager to be executed.
type Controller struct {
name string
reconciler Reconciler
initializer Initializer
managedTypeWatch * watch
watches map [ string ] * watch
queries map [ string ] cache . Query
customWatches [ ] customWatch
placement Placement
baseBackoff time . Duration
maxBackoff time . Duration
logger hclog . Logger
startCb RuntimeCallback
stopCb RuntimeCallback
// forceReconcileEvery is the time to wait after a successful reconciliation
// before forcing a reconciliation. The net result is a reconciliation of
// the managed type on a regular interval. This ensures that the state of the
// world is continually reconciled, hence correct in the face of missed events
// or other issues.
forceReconcileEvery time . Duration
}
type RuntimeCallback func ( context . Context , Runtime )
// NewController creates a controller that is setup to watched the managed type.
// Extra cache indexes may be provided as well and these indexes will be automatically managed.
// Typically, further calls to other builder methods will be needed to fully configure
// the controller such as using WithReconcile to define the code that will be called
// when the managed resource needs reconciliation.
func NewController ( name string , managedType * pbresource . Type , indexes ... * index . Index ) * Controller {
w := & watch {
watchedType : managedType ,
indexes : make ( map [ string ] * index . Index ) ,
}
for _ , idx := range indexes {
w . addIndex ( idx )
}
return & Controller {
name : name ,
managedTypeWatch : w ,
watches : make ( map [ string ] * watch ) ,
queries : make ( map [ string ] cache . Query ) ,
forceReconcileEvery : 8 * time . Hour ,
}
}
// WithNotifyStart registers a callback to be run when the controller is being started.
// This happens prior to watches being started and with a fresh cache.
func ( ctl * Controller ) WithNotifyStart ( start RuntimeCallback ) * Controller {
ctl . startCb = start
return ctl
}
// WithNotifyStop registers a callback to be run when the controller has been stopped.
// This happens after all the watches and mapper/reconcile queues have been stopped. The
// cache will contain everything that was present when we started stopping watches.
func ( ctl * Controller ) WithNotifyStop ( stop RuntimeCallback ) * Controller {
ctl . stopCb = stop
return ctl
}
// WithReconciler changes the controller's reconciler.
func ( ctl * Controller ) WithReconciler ( reconciler Reconciler ) * Controller {
if reconciler == nil {
panic ( "reconciler must not be nil" )
}
ctl . reconciler = reconciler
return ctl
}
// WithWatch enables watching of the specified resource type and mapping it to the managed type
// via the provided DependencyMapper. Extra cache indexes to calculate on the watched type
// may also be provided.
func ( ctl * Controller ) WithWatch ( watchedType * pbresource . Type , mapper DependencyMapper , indexes ... * index . Index ) * Controller {
key := resource . ToGVK ( watchedType )
_ , alreadyWatched := ctl . watches [ key ]
if alreadyWatched {
panic ( fmt . Sprintf ( "resource type %q already has a configured watch" , key ) )
}
w := newWatch ( watchedType , mapper )
for _ , idx := range indexes {
w . addIndex ( idx )
}
ctl . watches [ key ] = w
return ctl
}
// WithQuery will add a named query to the controllers cache for usage during reconcile or in dependency mappers
func ( ctl * Controller ) WithQuery ( queryName string , fn cache . Query ) * Controller {
_ , duplicate := ctl . queries [ queryName ]
if duplicate {
panic ( fmt . Sprintf ( "a predefined cache query with name %q already exists" , queryName ) )
}
ctl . queries [ queryName ] = fn
return ctl
}
// WithCustomWatch adds a new custom watch. Custom watches do not affect the controller cache.
func ( ctl * Controller ) WithCustomWatch ( source * Source , mapper CustomDependencyMapper ) * Controller {
if source == nil {
panic ( "source must not be nil" )
}
if mapper == nil {
panic ( "mapper must not be nil" )
}
ctl . customWatches = append ( ctl . customWatches , customWatch { source , mapper } )
return ctl
}
// WithLogger changes the controller's logger.
func ( ctl * Controller ) WithLogger ( logger hclog . Logger ) * Controller {
if logger == nil {
panic ( "logger must not be nil" )
}
ctl . logger = logger
return ctl
}
// WithBackoff changes the base and maximum backoff values for the controller's
// retry rate limiter.
func ( ctl * Controller ) WithBackoff ( base , max time . Duration ) * Controller {
ctl . baseBackoff = base
ctl . maxBackoff = max
return ctl
}
// WithPlacement changes where and how many replicas of the controller will run.
// In the majority of cases, the default placement (one leader elected instance
// per cluster) is the most appropriate and you shouldn't need to override it.
func ( ctl * Controller ) WithPlacement ( placement Placement ) * Controller {
ctl . placement = placement
return ctl
}
// WithForceReconcileEvery controls how often a resource gets periodically reconciled
// to ensure that the state of the world is correct (8 hours is the default).
// This exists for tests only and should not be customized by controller authors!
func ( ctl * Controller ) WithForceReconcileEvery ( duration time . Duration ) * Controller {
ctl . logger . Warn ( "WithForceReconcileEvery is for testing only and should not be set by controllers" )
ctl . forceReconcileEvery = duration
return ctl
}
// buildCache will construct a controller Cache given the watches/indexes that have
// been added to the controller. This is mainly to be used by the TestController and
// Manager when setting up how things
func ( ctl * Controller ) buildCache ( ) cache . Cache {
c := cache . New ( )
addWatchToCache ( c , ctl . managedTypeWatch )
for _ , w := range ctl . watches {
addWatchToCache ( c , w )
}
for name , query := range ctl . queries {
if err := c . AddQuery ( name , query ) ; err != nil {
panic ( err )
}
}
return c
}
// dryRunMapper will trigger the appropriate DependencyMapper for an update of
// the provided type and return the requested reconciles.
//
// This is mainly to be used by the TestController.
func ( ctl * Controller ) dryRunMapper (
ctx context . Context ,
rt Runtime ,
res * pbresource . Resource ,
) ( [ ] Request , error ) {
if resource . EqualType ( ctl . managedTypeWatch . watchedType , res . Id . Type ) {
return nil , nil // no-op
}
for _ , w := range ctl . watches {
if resource . EqualType ( w . watchedType , res . Id . Type ) {
return w . mapper ( ctx , rt , res )
}
}
return nil , fmt . Errorf ( "no mapper for type: %s" , resource . TypeToString ( res . Id . Type ) )
}
// String returns a textual description of the controller, useful for debugging.
func ( ctl * Controller ) String ( ) string {
watchedTypes := make ( [ ] string , 0 , len ( ctl . watches ) )
for watchedType := range ctl . watches {
watchedTypes = append ( watchedTypes , watchedType )
}
base , max := ctl . backoff ( )
return fmt . Sprintf (
"<Controller managed_type=%s, watched_types=[%s], backoff=<base=%s, max=%s>, placement=%s>" ,
resource . ToGVK ( ctl . managedTypeWatch . watchedType ) ,
strings . Join ( watchedTypes , ", " ) ,
base , max ,
ctl . placement ,
)
}
func ( ctl * Controller ) backoff ( ) ( time . Duration , time . Duration ) {
base := ctl . baseBackoff
if base == 0 {
base = 5 * time . Millisecond
}
max := ctl . maxBackoff
if max == 0 {
max = 1000 * time . Second
}
return base , max
}
func ( ctl * Controller ) buildLogger ( defaultLogger hclog . Logger ) hclog . Logger {
logger := defaultLogger
if ctl . logger != nil {
logger = ctl . logger
}
return logger . With ( "controller" , ctl . name , "managed_type" , resource . ToGVK ( ctl . managedTypeWatch . watchedType ) )
}
func addWatchToCache ( c cache . Cache , w * watch ) {
c . AddType ( w . watchedType )
for _ , index := range w . indexes {
if err := c . AddIndex ( w . watchedType , index ) ; err != nil {
panic ( err )
}
}
}
// Placement determines where and how many replicas of the controller will run.
type Placement int
const (
// PlacementSingleton ensures there is a single, leader-elected, instance of
// the controller running in the cluster at any time. It's the default and is
// suitable for most use-cases.
PlacementSingleton Placement = iota
// PlacementEachServer ensures there is a replica of the controller running on
// each server in the cluster. It is useful for cases where the controller is
// responsible for applying some configuration resource to the server whenever
// it changes (e.g. rate-limit configuration). Generally, controllers in this
// placement mode should not modify resources.
PlacementEachServer
)
// String satisfies the fmt.Stringer interface.
func ( p Placement ) String ( ) string {
switch p {
case PlacementSingleton :
return "singleton"
case PlacementEachServer :
return "each-server"
}
panic ( fmt . Sprintf ( "unknown placement %d" , p ) )
}
// Reconciler implements the business logic of a controller.
type Reconciler interface {
// Reconcile the resource identified by req.ID.
Reconcile ( ctx context . Context , rt Runtime , req Request ) error
}
// RequeueAfterError is an error that allows a Reconciler to override the
// exponential backoff behavior of the Controller, rather than applying
// the backoff algorithm, returning a RequeueAfterError will cause the
// Controller to reschedule the Request at a given time in the future.
type RequeueAfterError time . Duration
// Error implements the error interface.
func ( r RequeueAfterError ) Error ( ) string {
return fmt . Sprintf ( "requeue at %s" , time . Duration ( r ) )
}
// RequeueAfter constructs a RequeueAfterError with the given duration
// setting.
func RequeueAfter ( after time . Duration ) error {
return RequeueAfterError ( after )
}
// RequeueNow constructs a RequeueAfterError that reschedules the Request
// immediately.
func RequeueNow ( ) error {
return RequeueAfterError ( 0 )
}
// Request represents a request to reconcile the resource with the given ID.
type Request struct {
// ID of the resource that needs to be reconciled.
ID * pbresource . ID
}
// Key satisfies the queue.ItemType interface. It returns a string which will be
// used to de-duplicate requests in the queue.
func ( r Request ) Key ( ) string {
return fmt . Sprintf (
"part=%q,ns=%q,name=%q,uid=%q" ,
r . ID . Tenancy . Partition ,
r . ID . Tenancy . Namespace ,
r . ID . Name ,
r . ID . Uid ,
)
}
// Initializer implements the business logic that is executed when the
// controller is first started.
type Initializer interface {
Initialize ( ctx context . Context , rt Runtime ) error
}
// WithInitializer changes the controller's initializer.
func ( c * Controller ) WithInitializer ( initializer Initializer ) * Controller {
c . initializer = initializer
return c
}