github.com/Jeffail/benthos/v3@v3.65.0/internal/config/reader.go (about)

     1  package config
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"path/filepath"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/Jeffail/benthos/v3/internal/bundle"
    14  	"github.com/Jeffail/benthos/v3/internal/docs"
    15  	"github.com/Jeffail/benthos/v3/lib/config"
    16  	"github.com/Jeffail/benthos/v3/lib/stream"
    17  	"github.com/Jeffail/gabs/v2"
    18  	"github.com/fsnotify/fsnotify"
    19  	"gopkg.in/yaml.v3"
    20  )
    21  
    22  const (
    23  	defaultChangeFlushPeriod = 50 * time.Millisecond
    24  	defaultChangeDelayPeriod = time.Second
    25  )
    26  
    27  type configFileInfo struct {
    28  	updatedAt time.Time
    29  }
    30  
    31  type streamFileInfo struct {
    32  	configFileInfo
    33  
    34  	id string
    35  }
    36  
    37  // Reader provides utilities for parsing a Benthos config as a main file with
    38  // a collection of resource files, and options such as overrides.
    39  type Reader struct {
    40  	// The suffix given to unit test definition files, this is used in order to
    41  	// exclude unit tests from being run in streams mode with arbitrary
    42  	// directory walking.
    43  	testSuffix string
    44  
    45  	mainPath      string
    46  	resourcePaths []string
    47  	streamsPaths  []string
    48  	overrides     []string
    49  
    50  	// Controls whether the main config should include input, output, etc.
    51  	streamsMode bool
    52  
    53  	// Tracks the details of the config file when we last read it.
    54  	configFileInfo configFileInfo
    55  
    56  	// Tracks the details of stream config files when we last read them.
    57  	streamFileInfo map[string]streamFileInfo
    58  
    59  	// Tracks the details of resource config files when we last read them,
    60  	// including information such as the specific resources that were created
    61  	// from it.
    62  	resourceFileInfo    map[string]resourceFileInfo
    63  	resourceFileInfoMut sync.Mutex
    64  
    65  	mainUpdateFn   MainUpdateFunc
    66  	streamUpdateFn StreamUpdateFunc
    67  	watcher        *fsnotify.Watcher
    68  
    69  	changeFlushPeriod time.Duration
    70  	changeDelayPeriod time.Duration
    71  }
    72  
    73  // NewReader creates a new config reader.
    74  func NewReader(mainPath string, resourcePaths []string, opts ...OptFunc) *Reader {
    75  	r := &Reader{
    76  		testSuffix:        "_benthos_test",
    77  		mainPath:          mainPath,
    78  		resourcePaths:     resourcePaths,
    79  		streamFileInfo:    map[string]streamFileInfo{},
    80  		resourceFileInfo:  map[string]resourceFileInfo{},
    81  		changeFlushPeriod: defaultChangeFlushPeriod,
    82  		changeDelayPeriod: defaultChangeDelayPeriod,
    83  	}
    84  	for _, opt := range opts {
    85  		opt(r)
    86  	}
    87  	return r
    88  }
    89  
    90  //------------------------------------------------------------------------------
    91  
    92  // OptFunc is an opt function that changes the behaviour of a config reader.
    93  type OptFunc func(*Reader)
    94  
    95  // OptTestSuffix configures the suffix given to unit test definition files, this
    96  // is used in order to exclude unit tests from being run in streams mode with
    97  // arbitrary directory walking.
    98  func OptTestSuffix(suffix string) OptFunc {
    99  	return func(r *Reader) {
   100  		r.testSuffix = suffix
   101  	}
   102  }
   103  
   104  // OptAddOverrides adds one or more override expressions to the config reader,
   105  // each of the form `path=value`.
   106  func OptAddOverrides(overrides ...string) OptFunc {
   107  	return func(r *Reader) {
   108  		r.overrides = append(r.overrides, overrides...)
   109  	}
   110  }
   111  
   112  // OptSetStreamPaths marks this config reader as operating in streams mode, and
   113  // adds a list of paths to obtain individual stream configs from.
   114  func OptSetStreamPaths(streamsPaths ...string) OptFunc {
   115  	return func(r *Reader) {
   116  		r.streamsPaths = streamsPaths
   117  		r.streamsMode = true
   118  	}
   119  }
   120  
   121  //------------------------------------------------------------------------------
   122  
   123  // Read a Benthos config from the files and options specified.
   124  func (r *Reader) Read(conf *config.Type) (lints []string, err error) {
   125  	if lints, err = r.readMain(conf); err != nil {
   126  		return
   127  	}
   128  	var rLints []string
   129  	if rLints, err = r.readResources(&conf.ResourceConfig); err != nil {
   130  		return
   131  	}
   132  	lints = append(lints, rLints...)
   133  	return
   134  }
   135  
   136  // ReadStreams attempts to read Benthos stream configs from one or more paths.
   137  // Stream configs are extracted and added to a provided map, where the id is
   138  // derived from the path of the stream config file.
   139  func (r *Reader) ReadStreams(confs map[string]stream.Config) (lints []string, err error) {
   140  	return r.readStreamFiles(confs)
   141  }
   142  
   143  // MainUpdateFunc is a closure function called whenever a main config has been
   144  // updated. A boolean should be returned indicating whether the stream was
   145  // successfully updated, if false then the attempt will be made again after a
   146  // grace period.
   147  type MainUpdateFunc func(conf stream.Config) bool
   148  
   149  // SubscribeConfigChanges registers a closure function to be called whenever the
   150  // main configuration file is updated.
   151  //
   152  // The provided closure should return true if the stream was successfully
   153  // replaced.
   154  func (r *Reader) SubscribeConfigChanges(fn MainUpdateFunc) error {
   155  	if r.watcher != nil {
   156  		return errors.New("a file watcher has already been started")
   157  	}
   158  
   159  	r.mainUpdateFn = fn
   160  	return nil
   161  }
   162  
   163  // StreamUpdateFunc is a closure function called whenever a stream config has
   164  // been updated. A boolean should be returned indicating whether the stream was
   165  // successfully updated, if false then the attempt will be made again after a
   166  // grace period.
   167  type StreamUpdateFunc func(id string, conf stream.Config) bool
   168  
   169  // SubscribeStreamChanges registers a closure to be called whenever the
   170  // configuration of a stream is updated.
   171  //
   172  // The provided closure should return true if the stream was successfully
   173  // replaced.
   174  func (r *Reader) SubscribeStreamChanges(fn StreamUpdateFunc) error {
   175  	if r.watcher != nil {
   176  		return errors.New("a file watcher has already been started")
   177  	}
   178  
   179  	r.streamUpdateFn = fn
   180  	return nil
   181  }
   182  
   183  // BeginFileWatching creates a goroutine that watches all active configuration
   184  // files for changes. If a resource is changed then it is swapped out
   185  // automatically through the provided manager. If a main config or stream config
   186  // changes then the closures registered with either SubscribeConfigChanges or
   187  // SubscribeStreamChanges will be called.
   188  //
   189  // WARNING: Either SubscribeConfigChanges or SubscribeStreamChanges must be
   190  // called before this, as otherwise it is unsafe to register them during
   191  // watching.
   192  func (r *Reader) BeginFileWatching(mgr bundle.NewManagement, strict bool) error {
   193  	if r.watcher != nil {
   194  		return errors.New("a file watcher has already been started")
   195  	}
   196  	if r.mainUpdateFn == nil && r.streamUpdateFn == nil {
   197  		return errors.New("a file watcher cannot be started without a subscription function registered")
   198  	}
   199  
   200  	watcher, err := fsnotify.NewWatcher()
   201  	if err != nil {
   202  		return err
   203  	}
   204  	r.watcher = watcher
   205  
   206  	go func() {
   207  		ticker := time.NewTicker(r.changeFlushPeriod)
   208  		defer ticker.Stop()
   209  
   210  		collapsedChanges := map[string]time.Time{}
   211  		lostNames := map[string]struct{}{}
   212  		for {
   213  			select {
   214  			case event, ok := <-watcher.Events:
   215  				if !ok {
   216  					return
   217  				}
   218  				switch {
   219  				case event.Op&fsnotify.Write == fsnotify.Write:
   220  					collapsedChanges[filepath.Clean(event.Name)] = time.Now()
   221  
   222  				case event.Op&fsnotify.Remove == fsnotify.Remove ||
   223  					event.Op&fsnotify.Rename == fsnotify.Rename:
   224  					_ = watcher.Remove(event.Name)
   225  					lostNames[filepath.Clean(event.Name)] = struct{}{}
   226  				}
   227  			case <-ticker.C:
   228  				for nameClean, changed := range collapsedChanges {
   229  					if time.Since(changed) < r.changeDelayPeriod {
   230  						continue
   231  					}
   232  					var succeeded bool
   233  					if nameClean == filepath.Clean(r.mainPath) {
   234  						succeeded = r.reactMainUpdate(mgr, strict)
   235  					} else if _, exists := r.streamFileInfo[nameClean]; exists {
   236  						succeeded = r.reactStreamUpdate(mgr, strict, nameClean)
   237  					} else {
   238  						succeeded = r.reactResourceUpdate(mgr, strict, nameClean)
   239  					}
   240  					if succeeded {
   241  						delete(collapsedChanges, nameClean)
   242  					} else {
   243  						collapsedChanges[nameClean] = time.Now()
   244  					}
   245  				}
   246  				for lostName := range lostNames {
   247  					if err := watcher.Add(lostName); err == nil {
   248  						collapsedChanges[lostName] = time.Now()
   249  						delete(lostNames, lostName)
   250  					}
   251  				}
   252  			case err, ok := <-watcher.Errors:
   253  				if !ok {
   254  					return
   255  				}
   256  				mgr.Logger().Errorf("Config watcher error: %v", err)
   257  			}
   258  		}
   259  	}()
   260  
   261  	if !r.streamsMode && r.mainPath != "" {
   262  		if err := watcher.Add(r.mainPath); err != nil {
   263  			_ = watcher.Close()
   264  			return err
   265  		}
   266  	}
   267  	for _, p := range r.streamsPaths {
   268  		if err := watcher.Add(p); err != nil {
   269  			_ = watcher.Close()
   270  			return err
   271  		}
   272  	}
   273  	for _, p := range r.resourcePaths {
   274  		if err := watcher.Add(p); err != nil {
   275  			_ = watcher.Close()
   276  			return err
   277  		}
   278  	}
   279  	return nil
   280  }
   281  
   282  // Close the reader, when this method exits all reloading will be stopped.
   283  func (r *Reader) Close(ctx context.Context) error {
   284  	if r.watcher != nil {
   285  		return r.watcher.Close()
   286  	}
   287  	return nil
   288  }
   289  
   290  //------------------------------------------------------------------------------
   291  
   292  func applyOverrides(specs docs.FieldSpecs, root *yaml.Node, overrides ...string) error {
   293  	for _, override := range overrides {
   294  		eqIndex := strings.Index(override, "=")
   295  		if eqIndex == -1 {
   296  			return fmt.Errorf("invalid set expression '%v': expected foo=bar syntax", override)
   297  		}
   298  
   299  		path := override[:eqIndex]
   300  		value := override[eqIndex+1:]
   301  		if path == "" || value == "" {
   302  			return fmt.Errorf("invalid set expression '%v': expected foo=bar syntax", override)
   303  		}
   304  
   305  		valNode := yaml.Node{
   306  			Kind:  yaml.ScalarNode,
   307  			Value: value,
   308  		}
   309  		if err := specs.SetYAMLPath(nil, root, &valNode, gabs.DotPathToSlice(path)...); err != nil {
   310  			return fmt.Errorf("failed to set config field override: %w", err)
   311  		}
   312  	}
   313  	return nil
   314  }
   315  
   316  func (r *Reader) readMain(conf *config.Type) (lints []string, err error) {
   317  	defer func() {
   318  		if err != nil && r.mainPath != "" {
   319  			err = fmt.Errorf("%v: %w", r.mainPath, err)
   320  		}
   321  	}()
   322  
   323  	if r.mainPath == "" && len(r.overrides) == 0 {
   324  		return
   325  	}
   326  
   327  	var rawNode yaml.Node
   328  	var confBytes []byte
   329  	if r.mainPath != "" {
   330  		if confBytes, lints, err = config.ReadWithJSONPointersLinted(r.mainPath, true); err != nil {
   331  			return
   332  		}
   333  		if err = yaml.Unmarshal(confBytes, &rawNode); err != nil {
   334  			return
   335  		}
   336  	}
   337  
   338  	// This is an unlikely race condition as the file could've been updated
   339  	// exactly when we were reading/linting. However, we'd need to fork
   340  	// ReadWithJSONPointersLinted in order to pull the file info out, and since
   341  	// it's going to be removed in V4 I'm just going with the simpler option for
   342  	// now (ignoring the issue).
   343  	r.configFileInfo.updatedAt = time.Now()
   344  
   345  	confSpec := config.Spec()
   346  	if r.streamsMode {
   347  		// Spec is limited to just non-stream fields when in streams mode (no
   348  		// input, output, etc)
   349  		confSpec = config.SpecWithoutStream()
   350  	}
   351  	if err = applyOverrides(confSpec, &rawNode, r.overrides...); err != nil {
   352  		return
   353  	}
   354  
   355  	if !bytes.HasPrefix(confBytes, []byte("# BENTHOS LINT DISABLE")) {
   356  		lintFilePrefix := ""
   357  		if r.mainPath != "" {
   358  			lintFilePrefix = fmt.Sprintf("%v: ", r.mainPath)
   359  		}
   360  		for _, lint := range confSpec.LintYAML(docs.NewLintContext(), &rawNode) {
   361  			lints = append(lints, fmt.Sprintf("%vline %v: %v", lintFilePrefix, lint.Line, lint.What))
   362  		}
   363  	}
   364  
   365  	err = rawNode.Decode(conf)
   366  	return
   367  }
   368  
   369  func (r *Reader) reactMainUpdate(mgr bundle.NewManagement, strict bool) bool {
   370  	if r.mainUpdateFn == nil {
   371  		return true
   372  	}
   373  
   374  	mgr.Logger().Infoln("Main config updated, attempting to update pipeline.")
   375  
   376  	conf := config.New()
   377  	lints, err := r.readMain(&conf)
   378  	if err != nil {
   379  		mgr.Logger().Errorf("Failed to read updated config: %v", err)
   380  
   381  		// Rejecting due to invalid file means we do not want to try again.
   382  		return true
   383  	}
   384  
   385  	lintlog := mgr.Logger().NewModule(".linter")
   386  	for _, lint := range lints {
   387  		lintlog.Infoln(lint)
   388  	}
   389  	if strict && len(lints) > 0 {
   390  		mgr.Logger().Errorln("Rejecting updated main config due to linter errors, to allow linting errors run Benthos with --chilled")
   391  
   392  		// Rejecting from linters means we do not want to try again.
   393  		return true
   394  	}
   395  
   396  	// Update any resources within the file.
   397  	if newInfo := resInfoFromConfig(&conf.ResourceConfig); !newInfo.applyChanges(mgr) {
   398  		return false
   399  	}
   400  
   401  	return r.mainUpdateFn(conf.Config)
   402  }