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 }