github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/config/agent.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package config 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "path/filepath" 24 "sync" 25 "time" 26 27 "github.com/sirupsen/logrus" 28 "gopkg.in/fsnotify.v1" 29 "k8s.io/apimachinery/pkg/util/sets" 30 "sigs.k8s.io/prow/pkg/interrupts" 31 ) 32 33 // Delta represents the before and after states of a Config change detected by the Agent. 34 type Delta struct { 35 Before, After Config 36 } 37 38 // DeltaChan is a channel to receive config delta events when config changes. 39 type DeltaChan = chan<- Delta 40 41 // Agent watches a path and automatically loads the config stored 42 // therein. 43 type Agent struct { 44 mut sync.RWMutex // do not export Lock, etc methods 45 c *Config 46 subscriptions []DeltaChan 47 } 48 49 // IsConfigMapMount determines whether the provided directory is a configmap mounted directory 50 func IsConfigMapMount(path string) (bool, error) { 51 files, err := os.ReadDir(path) 52 if err != nil { 53 return false, fmt.Errorf("Could not read provided directory %s: %w", path, err) 54 } 55 for _, file := range files { 56 if file.Name() == "..data" { 57 return true, nil 58 } 59 } 60 return false, nil 61 } 62 63 // GetCMMountWatcher returns a function that watches a configmap mounted directory and runs the provided "eventFunc" every time 64 // the directory gets updated and the provided "errFunc" every time it encounters an error. 65 // Example of a possible eventFunc: 66 // 67 // func() error { 68 // value, err := RunUpdate() 69 // if err != nil { 70 // return err 71 // } 72 // globalValue = value 73 // return nil 74 // } 75 // 76 // Example of errFunc: 77 // 78 // func(err error, msg string) { 79 // logrus.WithError(err).Error(msg) 80 // } 81 func GetCMMountWatcher(eventFunc func() error, errFunc func(error, string), path string) (func(ctx context.Context), error) { 82 isCMMount, err := IsConfigMapMount(path) 83 if err != nil { 84 return nil, err 85 } else if !isCMMount { 86 return nil, fmt.Errorf("Provided directory %s is not a configmap directory", path) 87 } 88 w, err := fsnotify.NewWatcher() 89 if err != nil { 90 return nil, err 91 } 92 err = w.Add(path) 93 if err != nil { 94 return nil, err 95 } 96 logrus.Debugf("Watching %s", path) 97 dataPath := filepath.Join(path, "..data") 98 return func(ctx context.Context) { 99 for { 100 select { 101 case <-ctx.Done(): 102 if err := w.Close(); err != nil { 103 errFunc(err, fmt.Sprintf("failed to close fsnotify watcher for directory %s", path)) 104 } 105 return 106 case event := <-w.Events: 107 if event.Name == dataPath && event.Op == fsnotify.Create { 108 err := eventFunc() 109 if err != nil { 110 errFunc(err, fmt.Sprintf("event function for watch directory %s failed", path)) 111 } 112 } 113 case err := <-w.Errors: 114 errFunc(err, fmt.Sprintf("received fsnotify error for directory %s", path)) 115 } 116 } 117 }, nil 118 } 119 120 // GetFileWatcher returns a function that watches the specified file(s), running the "eventFunc" whenever an event for the file(s) occurs 121 // and the "errFunc" whenever an error is encountered. In this function, the eventFunc has access to the watcher, allowing the eventFunc 122 // to add new files/directories to be watched as needed. 123 // Example of a possible eventFunc: 124 // 125 // func(w *fsnotify.Watcher) error { 126 // value, err := RunUpdate() 127 // if err != nil { 128 // return err 129 // } 130 // globalValue = value 131 // newFiles := getNewFiles() 132 // for _, file := range newFiles { 133 // if err := w.Add(file); err != nil { 134 // return err 135 // } 136 // } 137 // return nil 138 // } 139 // 140 // Example of errFunc: 141 // 142 // func(err error, msg string) { 143 // logrus.WithError(err).Error(msg) 144 // } 145 func GetFileWatcher(eventFunc func(*fsnotify.Watcher) error, errFunc func(error, string), files ...string) (func(ctx context.Context), error) { 146 w, err := fsnotify.NewWatcher() 147 if err != nil { 148 return nil, err 149 } 150 for _, file := range files { 151 if err := w.Add(file); err != nil { 152 return nil, err 153 } 154 } 155 logrus.Debugf("Watching %d files", len(files)) 156 logrus.Tracef("Watching files: %v", files) 157 return func(ctx context.Context) { 158 for { 159 select { 160 case <-ctx.Done(): 161 if err := w.Close(); err != nil { 162 errFunc(err, fmt.Sprintf("failed to close fsnotify watcher for files: %v", files)) 163 } 164 return 165 case <-w.Events: 166 err := eventFunc(w) 167 if err != nil { 168 errFunc(err, fmt.Sprintf("event function failed watching files: %v", files)) 169 } 170 case err := <-w.Errors: 171 errFunc(err, fmt.Sprintf("received fsnotify error watching files: %v", files)) 172 } 173 } 174 }, nil 175 } 176 177 // ListCMsAndDirs returns a 2 sets of strings containing the paths of configmapped directories and standard 178 // directories respectively starting from the provided path. This can be used to watch a large number of 179 // files, some of which may be populated via configmaps 180 func ListCMsAndDirs(path string) (cms sets.Set[string], dirs sets.Set[string], err error) { 181 cms = sets.New[string]() 182 dirs = sets.New[string]() 183 err = filepath.Walk(path, func(path string, info os.FileInfo, _ error) error { 184 // We only need to watch directories as creation, deletion, and writes 185 // for files in a directory trigger events for the directory 186 if info != nil && info.IsDir() { 187 if isCM, err := IsConfigMapMount(path); err != nil { 188 return fmt.Errorf("Failed to check is path %s is configmap mounted: %w", path, err) 189 } else if isCM { 190 cms.Insert(path) 191 // configmaps can't have nested directories 192 return filepath.SkipDir 193 } else { 194 dirs.Insert(path) 195 return nil 196 } 197 } 198 return nil 199 }) 200 return cms, dirs, err 201 } 202 203 func watchConfigs(ca *Agent, prowConfig, jobConfig string, supplementalProwConfigDirs []string, supplementalProwConfigsFileNameSuffix string, additionals ...func(*Config) error) error { 204 cmEventFunc := func() error { 205 c, err := Load(prowConfig, jobConfig, supplementalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...) 206 if err != nil { 207 return err 208 } 209 ca.Set(c) 210 return nil 211 } 212 // We may need to add more directories to be watched 213 dirsEventFunc := func(w *fsnotify.Watcher) error { 214 c, err := Load(prowConfig, jobConfig, supplementalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...) 215 if err != nil { 216 return err 217 } 218 ca.Set(c) 219 // TODO(AlexNPavel): Is there a chance that a ConfigMap mounted directory may appear without making a new pod? If yes, handle that. 220 _, dirs, err := ListCMsAndDirs(jobConfig) 221 if err != nil { 222 return err 223 } 224 for _, supplementalProwConfigDir := range supplementalProwConfigDirs { 225 _, additionalDirs, err := ListCMsAndDirs(supplementalProwConfigDir) 226 if err != nil { 227 return err 228 } 229 dirs.Insert(additionalDirs.UnsortedList()...) 230 } 231 for dir := range dirs { 232 // Adding a file or directory that already exists in fsnotify is a no-op, so it is safe to always run Add 233 if err := w.Add(dir); err != nil { 234 return err 235 } 236 } 237 return nil 238 } 239 errFunc := func(err error, msg string) { 240 logrus.WithField("prowConfig", prowConfig). 241 WithField("jobConfig", jobConfig). 242 WithError(err).Error(msg) 243 } 244 cms := sets.New[string]() 245 dirs := sets.New[string]() 246 // TODO(AlexNPavel): allow empty jobConfig till fully migrate config to subdirs 247 if jobConfig != "" { 248 stat, err := os.Stat(jobConfig) 249 if err != nil { 250 return err 251 } 252 // TODO(AlexNPavel): allow single file jobConfig till fully migrate config to subdirs 253 if stat.IsDir() { 254 var err error 255 // jobConfig points to directories of configs that may be nested 256 cms, dirs, err = ListCMsAndDirs(jobConfig) 257 if err != nil { 258 return err 259 } 260 } else { 261 // If jobConfig is a single file, we handle it identically to how prowConfig is handled 262 if jobIsCMMounted, err := IsConfigMapMount(filepath.Dir(jobConfig)); err != nil { 263 return err 264 } else if jobIsCMMounted { 265 cms.Insert(filepath.Dir(jobConfig)) 266 } else { 267 dirs.Insert(jobConfig) 268 } 269 } 270 } 271 // The prow config is always a single file 272 if prowIsCMMounted, err := IsConfigMapMount(filepath.Dir(prowConfig)); err != nil { 273 return err 274 } else if prowIsCMMounted { 275 cms.Insert(filepath.Dir(prowConfig)) 276 } else { 277 dirs.Insert(prowConfig) 278 } 279 var runFuncs []func(context.Context) 280 for cm := range cms { 281 runFunc, err := GetCMMountWatcher(cmEventFunc, errFunc, cm) 282 if err != nil { 283 return err 284 } 285 runFuncs = append(runFuncs, runFunc) 286 } 287 if len(dirs) > 0 { 288 runFunc, err := GetFileWatcher(dirsEventFunc, errFunc, dirs.UnsortedList()...) 289 if err != nil { 290 return err 291 } 292 runFuncs = append(runFuncs, runFunc) 293 } 294 for _, runFunc := range runFuncs { 295 interrupts.Run(runFunc) 296 } 297 return nil 298 } 299 300 // StartWatch will begin watching the config files at the provided paths. If the 301 // first load fails, Start will return the error and abort. Future load failures 302 // will log the failure message but continue attempting to load. 303 // This function will replace Start in a future release. 304 func (ca *Agent) StartWatch(prowConfig, jobConfig string, supplementalProwConfigDirs []string, supplementalProwConfigsFileNameSuffix string, additionals ...func(*Config) error) error { 305 c, err := Load(prowConfig, jobConfig, supplementalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...) 306 if err != nil { 307 return err 308 } 309 ca.Set(c) 310 watchConfigs(ca, prowConfig, jobConfig, supplementalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...) 311 return nil 312 } 313 314 func lastConfigModTime(prowConfig, jobConfig string) (time.Time, error) { 315 // Check if the file changed to see if it needs to be re-read. 316 // os.Stat follows symbolic links, which is how ConfigMaps work. 317 prowStat, err := os.Stat(prowConfig) 318 if err != nil { 319 logrus.WithField("prowConfig", prowConfig).WithError(err).Error("Error loading prow config.") 320 return time.Time{}, err 321 } 322 recentModTime := prowStat.ModTime() 323 // TODO(krzyzacy): allow empty jobConfig till fully migrate config to subdirs 324 if jobConfig != "" { 325 jobConfigStat, err := os.Stat(jobConfig) 326 if err != nil { 327 logrus.WithField("jobConfig", jobConfig).WithError(err).Error("Error loading job configs.") 328 return time.Time{}, err 329 } 330 331 if jobConfigStat.ModTime().After(recentModTime) { 332 recentModTime = jobConfigStat.ModTime() 333 } 334 } 335 return recentModTime, nil 336 } 337 338 // Start will begin polling the config file at the path. If the first load 339 // fails, Start will return the error and abort. Future load failures will log 340 // the failure message but continue attempting to load. 341 func (ca *Agent) Start(prowConfig, jobConfig string, additionalProwConfigDirs []string, supplementalProwConfigsFileNameSuffix string, additionals ...func(*Config) error) error { 342 lastModTime, err := lastConfigModTime(prowConfig, jobConfig) 343 if err != nil { 344 lastModTime = time.Time{} 345 } 346 c, err := Load(prowConfig, jobConfig, additionalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...) 347 if err != nil { 348 return err 349 } 350 ca.Set(c) 351 go func() { 352 // Rarely, if two changes happen in the same second, mtime will 353 // be the same for the second change, and an mtime-based check would 354 // fail. Reload periodically just in case. 355 skips := 0 356 for range time.Tick(1 * time.Second) { 357 if skips < 600 { 358 recentModTime, err := lastConfigModTime(prowConfig, jobConfig) 359 if err != nil { 360 continue 361 } 362 if !recentModTime.After(lastModTime) { 363 skips++ 364 continue // file hasn't been modified 365 } 366 lastModTime = recentModTime 367 } 368 if c, err := Load(prowConfig, jobConfig, additionalProwConfigDirs, supplementalProwConfigsFileNameSuffix, additionals...); err != nil { 369 logrus.WithField("prowConfig", prowConfig). 370 WithField("jobConfig", jobConfig). 371 WithError(err).Error("Error loading config.") 372 } else { 373 skips = 0 374 ca.Set(c) 375 } 376 } 377 }() 378 return nil 379 } 380 381 // Subscribe registers the channel for messages on config reload. 382 // The caller can expect a copy of the previous and current config 383 // to be sent down the subscribed channel when a new configuration 384 // is loaded. 385 func (ca *Agent) Subscribe(subscription DeltaChan) { 386 ca.mut.Lock() 387 defer ca.mut.Unlock() 388 ca.subscriptions = append(ca.subscriptions, subscription) 389 } 390 391 // Getter returns the current Config in a thread-safe manner. 392 type Getter func() *Config 393 394 // Config returns the latest config. Do not modify the config. 395 func (ca *Agent) Config() *Config { 396 ca.mut.RLock() 397 defer ca.mut.RUnlock() 398 return ca.c 399 } 400 401 // Set sets the config. Useful for testing. 402 // Also used by statusreconciler to load last known config 403 func (ca *Agent) Set(c *Config) { 404 ca.mut.Lock() 405 defer ca.mut.Unlock() 406 var oldConfig Config 407 if ca.c != nil { 408 oldConfig = *ca.c 409 } 410 delta := Delta{oldConfig, *c} 411 ca.c = c 412 for _, subscription := range ca.subscriptions { 413 go func(sub DeltaChan) { // wait a minute to send each event 414 end := time.NewTimer(time.Minute) 415 select { 416 case sub <- delta: 417 case <-end.C: 418 } 419 if !end.Stop() { // prevent new events 420 <-end.C // drain the pending event 421 } 422 }(subscription) 423 } 424 } 425 426 // SetWithoutBroadcast sets the config, but does not broadcast the event to 427 // those listening for config reload changes. This is useful if you want to 428 // modify the Config in the Agent, from the point of view of the subscriber to 429 // the new one that was detected from the DeltaChan; if you just used Set() 430 // instead of this in such a situation, you would end up clogging the DeltaChan 431 // because you would be acting as both the consumer and producer of the 432 // DeltaChan. 433 func (ca *Agent) SetWithoutBroadcast(c *Config) { 434 ca.mut.Lock() 435 defer ca.mut.Unlock() 436 ca.c = c 437 }