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 }