github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/updateconfig/updateconfig.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 updateconfig
    18  
    19  import (
    20  	"bytes"
    21  	"compress/gzip"
    22  	"context"
    23  	"crypto/sha256"
    24  	"fmt"
    25  	"os"
    26  	"path"
    27  	"path/filepath"
    28  	"strings"
    29  	"unicode/utf8"
    30  
    31  	"github.com/mattn/go-zglob"
    32  	"github.com/prometheus/client_golang/prometheus"
    33  	"github.com/sirupsen/logrus"
    34  	coreapi "k8s.io/api/core/v1"
    35  	"k8s.io/apimachinery/pkg/api/errors"
    36  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    37  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    38  	"k8s.io/apimachinery/pkg/util/sets"
    39  	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
    40  
    41  	"sigs.k8s.io/prow/pkg/config"
    42  	"sigs.k8s.io/prow/pkg/git/v2"
    43  	"sigs.k8s.io/prow/pkg/github"
    44  	"sigs.k8s.io/prow/pkg/kube"
    45  	"sigs.k8s.io/prow/pkg/pluginhelp"
    46  	"sigs.k8s.io/prow/pkg/plugins"
    47  )
    48  
    49  const (
    50  	pluginName    = "config-updater"
    51  	bootstrapMode = false
    52  )
    53  
    54  func init() {
    55  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    56  }
    57  
    58  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    59  	var configInfo map[string]string
    60  	if len(enabledRepos) == 1 {
    61  		msg := ""
    62  		for configFileName, configMapSpec := range config.ConfigUpdater.Maps {
    63  			msg = msg + fmt.Sprintf(
    64  				"Files matching %s/%s are used to populate the %s ConfigMap in ",
    65  				enabledRepos[0],
    66  				configFileName,
    67  				configMapSpec.Name,
    68  			)
    69  		}
    70  		configInfo = map[string]string{"": msg}
    71  	}
    72  	return &pluginhelp.PluginHelp{
    73  			Description: "The config-updater plugin automatically redeploys configuration and plugin configuration files when they change. The plugin watches for pull request merges that modify either of the config files and updates the cluster's configmap resources in response.",
    74  			Config:      configInfo,
    75  		},
    76  		nil
    77  }
    78  
    79  type githubClient interface {
    80  	CreateComment(owner, repo string, number int, comment string) error
    81  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    82  }
    83  
    84  func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) error {
    85  	return handle(pc.GitHubClient, pc.GitClient, pc.KubernetesClient.CoreV1(), pc.BuildClusterCoreV1Clients, pc.Config.ProwJobNamespace, pc.Logger, pre, pc.PluginConfig.ConfigUpdater, pc.Metrics.ConfigMapGauges)
    86  }
    87  
    88  // FileGetter knows how to get the contents of a file by name
    89  type FileGetter interface {
    90  	GetFile(filename string) ([]byte, error)
    91  }
    92  
    93  type OSFileGetter struct {
    94  	Root string
    95  }
    96  
    97  func (g *OSFileGetter) GetFile(filename string) ([]byte, error) {
    98  	return os.ReadFile(filepath.Join(g.Root, filename))
    99  }
   100  
   101  // Update updates the configmap with the data from the identified files.
   102  // Existing configmap keys that are not included in the updates are left alone
   103  // unless bootstrap is true in which case they are deleted.
   104  func Update(fg FileGetter, kc corev1.ConfigMapInterface, name, namespace string, updates []ConfigMapUpdate, bootstrap bool, metrics *prometheus.GaugeVec, logger *logrus.Entry, sha string) error {
   105  	cm, getErr := kc.Get(context.TODO(), name, metav1.GetOptions{})
   106  	isNotFound := errors.IsNotFound(getErr)
   107  	if getErr != nil && !isNotFound {
   108  		return fmt.Errorf("failed to fetch current state of configmap: %w", getErr)
   109  	}
   110  
   111  	labels := map[string]string{
   112  		"app.kubernetes.io/name":      "prow",
   113  		"app.kubernetes.io/component": "updateconfig-plugin",
   114  	}
   115  
   116  	// For bootstrap mode, if the existing ConfigMap has any keys, make note of
   117  	// all the keys that we won't be updating in "updates" (let's call them
   118  	// "stale" keys), and mark them for deletion. This is because in
   119  	// bootstrap mode, the updates that we do see are considered to be the
   120  	// *only* keys that we want. So if there are any other keys that exist which
   121  	// do not get updated, they shouldn't exist. So delete them.
   122  	//
   123  	// As an additional safety measure, only mark stale keys for deletion if
   124  	// we had some number of updates to perform. This is to guard against cases
   125  	// where we accidentally get 0 updates and would end up marking all existing
   126  	// keys for deletion (bad idea). This could happen if for
   127  	// config-bootstrapper (which uses this library) if it was executed against
   128  	// the wrong local directory and could not find any files to generate any
   129  	// updates for a ConfigMap.
   130  	if bootstrap && len(updates) > 0 {
   131  		keysToDelete := MarkStaleKeysForDeletion(cm, updates)
   132  		for _, upd := range keysToDelete {
   133  			logger.WithField("key", upd.Key).Info("queueing for deletion")
   134  		}
   135  		updates = append(updates, keysToDelete...)
   136  	}
   137  
   138  	if cm == nil || isNotFound {
   139  		cm = &coreapi.ConfigMap{
   140  			ObjectMeta: metav1.ObjectMeta{
   141  				Name:      name,
   142  				Namespace: namespace,
   143  				Labels:    labels,
   144  			},
   145  		}
   146  	}
   147  
   148  	if cm.ObjectMeta.Labels == nil {
   149  		cm.ObjectMeta.Labels = labels
   150  	}
   151  
   152  	if cm.Data == nil || bootstrap {
   153  		cm.Data = map[string]string{}
   154  	}
   155  	if sha != "" {
   156  		cm.Data[config.ConfigVersionFileName] = sha
   157  	}
   158  	if cm.BinaryData == nil || bootstrap {
   159  		cm.BinaryData = map[string][]byte{}
   160  	}
   161  
   162  	for _, upd := range updates {
   163  		if upd.Filename == "" {
   164  			logger.WithField("key", upd.Key).Debug("Deleting key.")
   165  			delete(cm.Data, upd.Key)
   166  			delete(cm.BinaryData, upd.Key)
   167  			continue
   168  		}
   169  
   170  		content, err := fg.GetFile(upd.Filename)
   171  		if err != nil {
   172  			return fmt.Errorf("get file err: %w", err)
   173  		}
   174  		logger.WithFields(logrus.Fields{"key": upd.Key, "filename": upd.Filename}).Debug("Populating key.")
   175  		value := content
   176  		if upd.GZIP {
   177  			buff := bytes.NewBuffer([]byte{})
   178  			// TODO: this error is wildly unlikely for anything that
   179  			// would actually fit in a configmap, we could just as well return
   180  			// the error instead of falling back to the raw content
   181  			z := gzip.NewWriter(buff)
   182  			if _, err := z.Write(content); err != nil {
   183  				logger.WithError(err).Error("failed to gzip content, falling back to raw")
   184  			} else {
   185  				if err := z.Close(); err != nil {
   186  					logger.WithError(err).Error("failed to flush gzipped content (!?), falling back to raw")
   187  				} else {
   188  					value = buff.Bytes()
   189  				}
   190  			}
   191  		}
   192  		if utf8.ValidString(string(value)) {
   193  			delete(cm.BinaryData, upd.Key)
   194  			cm.Data[upd.Key] = string(value)
   195  		} else {
   196  			delete(cm.Data, upd.Key)
   197  			cm.BinaryData[upd.Key] = value
   198  		}
   199  	}
   200  
   201  	var updateErr error
   202  	var verb string
   203  	if getErr != nil && isNotFound {
   204  		verb = "create"
   205  		_, updateErr = kc.Create(context.TODO(), cm, metav1.CreateOptions{})
   206  	} else {
   207  		verb = "update"
   208  		_, updateErr = kc.Update(context.TODO(), cm, metav1.UpdateOptions{})
   209  	}
   210  	if updateErr != nil {
   211  		return fmt.Errorf("%s config map err: %w", verb, updateErr)
   212  	}
   213  	if metrics != nil {
   214  		var size float64
   215  		for _, data := range cm.Data {
   216  			size += float64(len(data))
   217  		}
   218  		for _, data := range cm.BinaryData {
   219  			size += float64(len(data))
   220  		}
   221  		// in a strict sense this can race to update the value with other goroutines
   222  		// handling other events, but as events are serialized due to the fact that
   223  		// merges are serial in repositories, this is effectively not an issue here
   224  		metrics.WithLabelValues(cm.Name, cm.Namespace).Set(size)
   225  	}
   226  	return nil
   227  }
   228  
   229  // MarkStaledKeysForDeletion returns a slice of ConfigMapUpdate entries for keys
   230  // that were missing from the updates, and which should be deleted. This only
   231  // makes sense for bootstrap mode (for the config-bootstrapper).
   232  func MarkStaleKeysForDeletion(
   233  	configMap *coreapi.ConfigMap,
   234  	updates []ConfigMapUpdate,
   235  ) []ConfigMapUpdate {
   236  
   237  	// If there are no updates, then do nothing. This is just in case we pass in
   238  	// an empty slice for "updates" by accident (we don't want to wipe the
   239  	// ConfigMap entirely --- that is never the intent here).
   240  	if len(updates) == 0 {
   241  		return nil
   242  	}
   243  
   244  	// Likewise if configMap itself is empty, do nothing because there's nothing
   245  	// to delete.
   246  	if configMap == nil {
   247  		return nil
   248  	}
   249  
   250  	updatedKeys := sets.New[string]()
   251  	for _, upd := range updates {
   252  		updatedKeys.Insert(upd.Key)
   253  	}
   254  
   255  	// Add deletion entries for stale keys there were missing from the updates.
   256  	toDelete := []ConfigMapUpdate{}
   257  	for key := range configMap.Data {
   258  		if updatedKeys.Has(key) {
   259  			continue
   260  		}
   261  		toDelete = append(toDelete, ConfigMapUpdate{Key: key})
   262  	}
   263  
   264  	for key := range configMap.BinaryData {
   265  		if updatedKeys.Has(key) {
   266  			continue
   267  		}
   268  		toDelete = append(toDelete, ConfigMapUpdate{Key: key})
   269  	}
   270  
   271  	return toDelete
   272  }
   273  
   274  // ConfigMapUpdate is populated with information about a config map that should
   275  // be updated. If the Filename is missing, then this update means that the key
   276  // should be deleted.
   277  type ConfigMapUpdate struct {
   278  	Key, Filename string
   279  	GZIP          bool
   280  }
   281  
   282  // FilterChanges determines which of the changes are relevant for config updating, returning mapping of
   283  // config map to key to filename to update that key from.
   284  func FilterChanges(cfg plugins.ConfigUpdater, changes []github.PullRequestChange, defaultNamespace string, bootstrap bool, log *logrus.Entry) map[plugins.ConfigMapID][]ConfigMapUpdate {
   285  	toUpdate := map[plugins.ConfigMapID][]ConfigMapUpdate{}
   286  
   287  	// Keep track of partitioned ConfigMaps that may need to be updated when bootstrapping
   288  	requireUpdate := map[plugins.ConfigMapID]bool{}
   289  	haveUpdate := map[plugins.ConfigMapID]bool{}
   290  	for _, change := range changes {
   291  		var cm plugins.ConfigMapSpec
   292  		found := false
   293  
   294  		for key, configMap := range cfg.Maps {
   295  			var matchErr error
   296  			found, matchErr = zglob.Match(key, change.Filename)
   297  			if matchErr != nil {
   298  				// Should not happen, log matchErr and continue
   299  				log.WithError(matchErr).Info("key matching error")
   300  				continue
   301  			}
   302  
   303  			if found {
   304  				cm = configMap
   305  				break
   306  			}
   307  		}
   308  
   309  		if !found {
   310  			continue // This file does not define a configmap
   311  		}
   312  
   313  		// Yes, update the configmap with the contents of this file
   314  		for cluster, namespaces := range cm.Clusters {
   315  			for _, ns := range namespaces {
   316  				idForKey := func(_ string) plugins.ConfigMapID {
   317  					return plugins.ConfigMapID{Name: cm.Name, Namespace: ns, Cluster: cluster}
   318  				}
   319  				if len(cm.PartitionedNames) > 0 {
   320  					idForKey = func(k string) plugins.ConfigMapID {
   321  						// Choose a name from PartitionedNames based on 'k'.
   322  						name := cm.PartitionedNames[int(sha256.Sum256([]byte(k))[0])%len(cm.PartitionedNames)]
   323  						for _, pn := range cm.PartitionedNames {
   324  							requireUpdate[plugins.ConfigMapID{Name: pn, Namespace: ns, Cluster: cluster}] = true
   325  						}
   326  						id := plugins.ConfigMapID{Name: name, Namespace: ns, Cluster: cluster}
   327  						haveUpdate[id] = true
   328  						return id
   329  					}
   330  				}
   331  				key := cm.Key
   332  				if key == "" {
   333  					if cm.UseFullPathAsKey {
   334  						key = strings.ReplaceAll(change.Filename, "/", "-")
   335  					} else {
   336  						key = path.Base(change.Filename)
   337  					}
   338  					// if the key changed, we need to remove the old key
   339  					if change.Status == github.PullRequestFileRenamed {
   340  						var oldKey string
   341  						if cm.UseFullPathAsKey {
   342  							oldKey = strings.ReplaceAll(change.PreviousFilename, "/", "-")
   343  						} else {
   344  							oldKey = path.Base(change.PreviousFilename)
   345  						}
   346  						// not setting the filename field will cause the key to be
   347  						// deleted
   348  						id := idForKey(oldKey)
   349  						toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: oldKey})
   350  					}
   351  				}
   352  				id := idForKey(key)
   353  				if change.Status == github.PullRequestFileRemoved {
   354  					toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: key})
   355  				} else {
   356  					gzip := cfg.GZIP
   357  					if cm.GZIP != nil {
   358  						gzip = *cm.GZIP
   359  					}
   360  					toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: key, Filename: change.Filename, GZIP: gzip})
   361  				}
   362  			}
   363  		}
   364  	}
   365  	if bootstrap {
   366  		// If we're in bootstrap mode and a partition is completely emptied we won't have any updates for it
   367  		// so the 'Update' function won't be able to remove the unrecognized keys (all of them) since it
   368  		// will never consider the ConfigMap. Removing an arbitrary key ensures `Update` processes this
   369  		// ConfigMap and gets a chance to remove all the keys.
   370  		for id := range requireUpdate {
   371  			if !haveUpdate[id] {
   372  				toUpdate[id] = append(toUpdate[id], ConfigMapUpdate{Key: ""})
   373  			}
   374  		}
   375  	}
   376  	return handleDefaultNamespace(toUpdate, defaultNamespace)
   377  }
   378  
   379  // handleDefaultNamespace ensures plugins.ConfigMapID.Namespace is not empty string
   380  func handleDefaultNamespace(toUpdate map[plugins.ConfigMapID][]ConfigMapUpdate, defaultNamespace string) map[plugins.ConfigMapID][]ConfigMapUpdate {
   381  	for cm, data := range toUpdate {
   382  		if cm.Namespace == "" {
   383  			key := plugins.ConfigMapID{Name: cm.Name, Namespace: defaultNamespace, Cluster: cm.Cluster}
   384  			toUpdate[key] = append(toUpdate[key], data...)
   385  			delete(toUpdate, cm)
   386  		}
   387  	}
   388  	return toUpdate
   389  }
   390  
   391  func handle(gc githubClient, gitClient git.ClientFactory, kc corev1.ConfigMapsGetter, buildClusterCoreV1Clients map[string]corev1.CoreV1Interface, defaultNamespace string, log *logrus.Entry, pre github.PullRequestEvent, config plugins.ConfigUpdater, metrics *prometheus.GaugeVec) error {
   392  	// Only consider newly merged PRs
   393  	if pre.Action != github.PullRequestActionClosed {
   394  		return nil
   395  	}
   396  
   397  	if len(config.Maps) == 0 { // Nothing to update
   398  		return nil
   399  	}
   400  
   401  	pr := pre.PullRequest
   402  
   403  	if !pr.Merged || pr.MergeSHA == nil || pr.Base.Repo.DefaultBranch != pr.Base.Ref {
   404  		return nil
   405  	}
   406  
   407  	org := pr.Base.Repo.Owner.Login
   408  	repo := pr.Base.Repo.Name
   409  
   410  	// Which files changed in this PR?
   411  	changes, err := gc.GetPullRequestChanges(org, repo, pr.Number)
   412  	if err != nil {
   413  		return err
   414  	}
   415  
   416  	message := func(name, cluster, namespace string, updates []ConfigMapUpdate, indent string) string {
   417  		identifier := fmt.Sprintf("`%s` configmap", name)
   418  		if namespace != "" {
   419  			identifier = fmt.Sprintf("%s in namespace `%s`", identifier, namespace)
   420  		}
   421  		if cluster != "" {
   422  			identifier = fmt.Sprintf("%s at cluster `%s`", identifier, cluster)
   423  		}
   424  		msg := fmt.Sprintf("%s using the following files:", identifier)
   425  		for _, u := range updates {
   426  			msg = fmt.Sprintf("%s\n%s- key `%s` using file `%s`", msg, indent, u.Key, u.Filename)
   427  		}
   428  		return msg
   429  	}
   430  
   431  	// Are any of the changes files ones that define a configmap we want to update?
   432  	toUpdate := FilterChanges(config, changes, defaultNamespace, bootstrapMode, log)
   433  	log.WithFields(logrus.Fields{
   434  		"configmaps_to_update": len(toUpdate),
   435  		"changes":              len(changes),
   436  	}).Debug("Identified configmaps to update")
   437  
   438  	var updated []string
   439  	indent := " " // one space
   440  	if len(toUpdate) > 1 {
   441  		indent = "   " // three spaces for sub bullets
   442  	}
   443  
   444  	gitRepo, err := gitClient.ClientFor(org, repo)
   445  	if err != nil {
   446  		return err
   447  	}
   448  	defer func() {
   449  		if err := gitRepo.Clean(); err != nil {
   450  			log.WithError(err).Error("Could not clean up git repo cache.")
   451  		}
   452  	}()
   453  	if err := gitRepo.Checkout(*pr.MergeSHA); err != nil {
   454  		return err
   455  	}
   456  
   457  	var errs []error
   458  	for cm, data := range toUpdate {
   459  		logger := log.WithFields(logrus.Fields{"configmap": map[string]string{"name": cm.Name, "namespace": cm.Namespace, "cluster": cm.Cluster}})
   460  		configMapClient, err := GetConfigMapClient(kc, cm.Namespace, buildClusterCoreV1Clients, cm.Cluster)
   461  		if err != nil {
   462  			log.WithError(err).Errorf("Failed to find configMap client")
   463  			errs = append(errs, err)
   464  			continue
   465  		}
   466  		if err := Update(&OSFileGetter{Root: gitRepo.Directory()}, configMapClient, cm.Name, cm.Namespace, data, bootstrapMode, metrics, logger, *pr.MergeSHA); err != nil {
   467  			errs = append(errs, err)
   468  			continue
   469  		}
   470  		updated = append(updated, message(cm.Name, cm.Cluster, cm.Namespace, data, indent))
   471  	}
   472  
   473  	var msg string
   474  	switch n := len(updated); n {
   475  	case 0:
   476  		return utilerrors.NewAggregate(errs)
   477  	case 1:
   478  		msg = fmt.Sprintf("Updated the %s", updated[0])
   479  	default:
   480  		msg = fmt.Sprintf("Updated the following %d configmaps:\n", n)
   481  		for _, updateMsg := range updated {
   482  			msg += fmt.Sprintf(" * %s\n", updateMsg) // one space indent
   483  		}
   484  	}
   485  
   486  	if err := gc.CreateComment(org, repo, pr.Number, plugins.FormatResponseRaw(pr.Body, pr.HTMLURL, pr.User.Login, msg)); err != nil {
   487  		errs = append(errs, fmt.Errorf("comment err: %w", err))
   488  	}
   489  	return utilerrors.NewAggregate(errs)
   490  }
   491  
   492  // GetConfigMapClient returns a configMap interface according to the given cluster and namespace
   493  func GetConfigMapClient(kc corev1.ConfigMapsGetter, namespace string, buildClusterCoreV1Clients map[string]corev1.CoreV1Interface, cluster string) (corev1.ConfigMapInterface, error) {
   494  	configMapClient := kc.ConfigMaps(namespace)
   495  	if cluster != kube.DefaultClusterAlias {
   496  		if client, ok := buildClusterCoreV1Clients[cluster]; ok {
   497  			configMapClient = client.ConfigMaps(namespace)
   498  		} else {
   499  			return nil, fmt.Errorf("no k8s client is found for build cluster: '%s'", cluster)
   500  		}
   501  	}
   502  	return configMapClient, nil
   503  }