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  }