github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/hmac/main.go (about)

     1  /*
     2  Copyright 2020 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 main
    18  
    19  import (
    20  	"context"
    21  	"crypto/rand"
    22  	"encoding/hex"
    23  	"errors"
    24  	"flag"
    25  	"fmt"
    26  	"os"
    27  	"sort"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/sirupsen/logrus"
    32  	corev1 "k8s.io/api/core/v1"
    33  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    34  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    35  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    36  	"k8s.io/client-go/kubernetes"
    37  	"sigs.k8s.io/yaml"
    38  
    39  	"sigs.k8s.io/prow/pkg/config"
    40  	"sigs.k8s.io/prow/pkg/flagutil"
    41  	prowflagutil "sigs.k8s.io/prow/pkg/flagutil"
    42  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    43  	"sigs.k8s.io/prow/pkg/ghhook"
    44  	"sigs.k8s.io/prow/pkg/github"
    45  	"sigs.k8s.io/prow/pkg/logrusutil"
    46  )
    47  
    48  type options struct {
    49  	config configflagutil.ConfigOptions
    50  
    51  	dryRun        bool
    52  	github        prowflagutil.GitHubOptions
    53  	kubernetes    prowflagutil.KubernetesOptions
    54  	kubeconfigCtx string
    55  
    56  	hookUrl                  string
    57  	hmacTokenSecretNamespace string
    58  	hmacTokenSecretName      string
    59  	hmacTokenKey             string
    60  }
    61  
    62  func (o *options) validate() error {
    63  	for _, group := range []flagutil.OptionGroup{&o.kubernetes, &o.github, &o.config} {
    64  		if err := group.Validate(o.dryRun); err != nil {
    65  			return err
    66  		}
    67  	}
    68  
    69  	if o.kubeconfigCtx == "" {
    70  		return errors.New("required flag --kubeconfig-context was unset")
    71  	}
    72  	if o.hookUrl == "" {
    73  		return errors.New("required flag --hook-url was unset")
    74  	}
    75  	if o.hmacTokenSecretName == "" {
    76  		return errors.New("required flag --hmac-token-secret-name was unset")
    77  	}
    78  	if o.hmacTokenKey == "" {
    79  		return errors.New("required flag --hmac-token-key was unset")
    80  	}
    81  
    82  	return nil
    83  }
    84  
    85  func gatherOptions(fs *flag.FlagSet, args ...string) options {
    86  	var o options
    87  
    88  	o.config.AddFlags(fs)
    89  	o.github.AddFlags(fs)
    90  	o.kubernetes.AddFlags(fs)
    91  
    92  	fs.StringVar(&o.kubeconfigCtx, "kubeconfig-context", "", "Context of the Prow component cluster and namespace in the kubeconfig.")
    93  	fs.BoolVar(&o.dryRun, "dry-run", true, "Dry run for testing. Uses API tokens but does not mutate.")
    94  
    95  	fs.StringVar(&o.hookUrl, "hook-url", "", "Prow hook external webhook URL (e.g. https://prow.k8s.io/hook).")
    96  	fs.StringVar(&o.hmacTokenSecretNamespace, "hmac-token-secret-namespace", "default", "Name of the namespace on the cluster where the hmac-token secret is in.")
    97  	fs.StringVar(&o.hmacTokenSecretName, "hmac-token-secret-name", "", "Name of the secret on the cluster containing the GitHub HMAC secret.")
    98  	fs.StringVar(&o.hmacTokenKey, "hmac-token-key", "", "Key of the hmac token in the secret.")
    99  	fs.Parse(args)
   100  	return o
   101  }
   102  
   103  type client struct {
   104  	options options
   105  
   106  	kubernetesClient kubernetes.Interface
   107  	githubHookClient github.HookClient
   108  
   109  	currentHMACMap map[string]github.HMACsForRepo
   110  	newHMACConfig  config.ManagedWebhooks
   111  
   112  	hmacMapForBatchUpdate map[string]string
   113  	hmacMapForRecovery    map[string]github.HMACsForRepo
   114  }
   115  
   116  func main() {
   117  	logrusutil.ComponentInit()
   118  
   119  	o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...)
   120  	if err := o.validate(); err != nil {
   121  		logrus.WithError(err).Fatal("Invalid options")
   122  	}
   123  
   124  	kc, err := o.kubernetes.ClusterClientForContext(o.kubeconfigCtx, o.dryRun)
   125  	if err != nil {
   126  		logrus.WithError(err).Fatalf("Error creating Kubernetes client for cluster %q.", o.kubeconfigCtx)
   127  	}
   128  
   129  	configAgent, err := o.config.ConfigAgent()
   130  	if err != nil {
   131  		logrus.WithError(err).Fatal("Error starting config agent.")
   132  	}
   133  	newHMACConfig := configAgent.Config().ManagedWebhooks
   134  
   135  	gc, err := o.github.GitHubClient(o.dryRun)
   136  	if err != nil {
   137  		logrus.WithError(err).Fatal("Error creating github client")
   138  	}
   139  
   140  	currentHMACYaml, err := getCurrentHMACTokens(kc, o.hmacTokenSecretNamespace, o.hmacTokenSecretName, o.hmacTokenKey)
   141  	if err != nil {
   142  		logrus.WithError(err).Fatal("Error getting the current hmac yaml.")
   143  	}
   144  
   145  	currentHMACMap := map[string]github.HMACsForRepo{}
   146  	if err := yaml.Unmarshal(currentHMACYaml, &currentHMACMap); err != nil {
   147  		// When the token is still a single global token, respect_legacy_global_token must be set to true before running this tool.
   148  		// This can prevent the global token from being deleted by mistake before users migrate all repos/orgs to use auto-generated private tokens.
   149  		if !newHMACConfig.RespectLegacyGlobalToken {
   150  			logrus.Fatal("respect_legacy_global_token must be set to true before the hmac tool is run for the first time.")
   151  		}
   152  
   153  		logrus.WithError(err).Error("Couldn't unmarshal the hmac secret as hierarchical file. Parsing as a single global token and writing it back to the secret.")
   154  		currentHMACMap["*"] = github.HMACsForRepo{
   155  			github.HMACToken{
   156  				Value: strings.TrimSpace(string(currentHMACYaml)),
   157  			},
   158  		}
   159  	}
   160  
   161  	c := client{
   162  		kubernetesClient: kc,
   163  		githubHookClient: gc,
   164  		options:          o,
   165  
   166  		currentHMACMap:        currentHMACMap,
   167  		newHMACConfig:         newHMACConfig,
   168  		hmacMapForBatchUpdate: map[string]string{},
   169  		hmacMapForRecovery:    map[string]github.HMACsForRepo{},
   170  	}
   171  
   172  	if err := c.handleInvitation(); err != nil {
   173  		logrus.WithError(err).Fatal("Error accepting invitations.")
   174  	}
   175  
   176  	if err := c.handleConfigUpdate(); err != nil {
   177  		logrus.WithError(err).Fatal("Error handling hmac config update.")
   178  	}
   179  }
   180  
   181  func (c *client) handleInvitation() error {
   182  	if !c.newHMACConfig.AutoAcceptInvitation {
   183  		logrus.Debug("Skip accepting github invitations as not configured.")
   184  		return nil
   185  	}
   186  	// Accept repos invitations first
   187  	repoIvs, err := c.githubHookClient.ListCurrentUserRepoInvitations()
   188  	if err != nil {
   189  		return err
   190  	}
   191  	for _, iv := range repoIvs {
   192  		if iv.Permission != github.Admin {
   193  			logrus.Errorf("invalid invitation from %s is not accepted. Permission want: %v, got: %s",
   194  				iv.Repository.FullName, github.Admin, iv.Permission)
   195  			continue
   196  		}
   197  		for repoName := range c.newHMACConfig.OrgRepoConfig {
   198  			// Only consider strict matching for repo level invitation,
   199  			// reasons for not considering org matching:
   200  			// 1. The FullName is org/repo
   201  			// 2. If an org is defined as managed webhook but only invite
   202  			// bot as admin on repo level, the webhook setup will fail
   203  			// 3. Also we are not ready to receive spamming webhook from the
   204  			// org if it only configured a repo in hmac
   205  			if iv.Repository.FullName == repoName {
   206  				if err := c.githubHookClient.AcceptUserRepoInvitation(iv.InvitationID); err != nil {
   207  					return fmt.Errorf("failed accepting repo invitation from %s: %w", iv.Repository.FullName, err)
   208  				}
   209  				logrus.Infof("Successfully accepted invitation from %s", iv.Repository.FullName)
   210  			}
   211  		}
   212  	}
   213  	// Accept org invitation
   214  	orgIvs, err := c.githubHookClient.ListCurrentUserOrgInvitations()
   215  	if err != nil {
   216  		return err
   217  	}
   218  	for _, iv := range orgIvs {
   219  		if iv.Role != github.OrgAdmin {
   220  			logrus.Errorf("Invalid invitation from %s not accepted. Want: %v, got: %s",
   221  				iv.Org.Login, github.Admin, iv.Role)
   222  			continue
   223  		}
   224  		for repoName := range c.newHMACConfig.OrgRepoConfig {
   225  			// Accept org invitation even if only single repo want hmac
   226  			if repoName == iv.Org.Login || strings.HasPrefix(repoName, iv.Org.Login+"/") {
   227  				if err := c.githubHookClient.AcceptUserOrgInvitation(iv.Org.Login); err != nil {
   228  					return fmt.Errorf("failed accepting org invitation from %s: %w", iv.Org.Login, err)
   229  				}
   230  				logrus.Infof("Successfully accepted invitation from %s", iv.Org.Login)
   231  			}
   232  		}
   233  	}
   234  
   235  	return nil
   236  }
   237  
   238  func (c *client) handleConfigUpdate() error {
   239  	repoAdded := map[string]config.ManagedWebhookInfo{}
   240  	repoRemoved := map[string]bool{}
   241  	repoRotated := map[string]config.ManagedWebhookInfo{}
   242  
   243  	for repoName, hmacConfig := range c.newHMACConfig.OrgRepoConfig {
   244  		if _, ok := c.currentHMACMap[repoName]; ok {
   245  			repoRotated[repoName] = hmacConfig
   246  		} else {
   247  			repoAdded[repoName] = hmacConfig
   248  		}
   249  	}
   250  
   251  	for repoName := range c.currentHMACMap {
   252  		// Skip the global hmac token if it still needs to be respected.
   253  		if repoName == "*" && c.newHMACConfig.RespectLegacyGlobalToken {
   254  			continue
   255  		}
   256  		if _, ok := c.newHMACConfig.OrgRepoConfig[repoName]; !ok {
   257  			repoRemoved[repoName] = true
   258  		}
   259  	}
   260  
   261  	// Remove the webhooks for the given repos, as well as removing the tokens from the current hmac map.
   262  	if err := c.handleRemovedRepo(repoRemoved); err != nil {
   263  		return fmt.Errorf("error handling hmac update for removed repos: %w", err)
   264  	}
   265  
   266  	// Generate new hmac token for required repos, do batch update for the hmac token secret,
   267  	// and then iteratively update the webhook for each repo.
   268  	if err := c.handleAddedRepo(repoAdded); err != nil {
   269  		return fmt.Errorf("error handling hmac update for new repos: %w", err)
   270  	}
   271  	if err := c.handledRotatedRepo(repoRotated); err != nil {
   272  		return fmt.Errorf("error handling hmac rotations for the repos: %w", err)
   273  	}
   274  	// Update the hmac token secret first, to guarantee the new tokens are available to hook.
   275  	if err := c.updateHMACTokenSecret(); err != nil {
   276  		return fmt.Errorf("error updating hmac tokens: %w", err)
   277  	}
   278  	// HACK: waiting for the hmac k8s secret update to propagate to the pods that are using the secret,
   279  	// so that components like hook can start respecting the new hmac values.
   280  	time.Sleep(20 * time.Second)
   281  	errs := c.batchOnboardNewTokenForRepos()
   282  
   283  	// Do necessary cleanups after the token and webhook updates are done.
   284  	if err := c.cleanup(); err != nil {
   285  		errs = append(errs, fmt.Errorf("error cleaning up %w", err))
   286  	}
   287  
   288  	return utilerrors.NewAggregate(errs)
   289  }
   290  
   291  // handleRemoveRepo handles webhook removal and hmac token removal from the current hmac map for all repos removed from the declarative config.
   292  func (c *client) handleRemovedRepo(removed map[string]bool) error {
   293  	removeGlobalToken := false
   294  	repos := make([]string, 0)
   295  	for r := range removed {
   296  		if r == "*" {
   297  			removeGlobalToken = true
   298  		} else {
   299  			repos = append(repos, r)
   300  		}
   301  	}
   302  
   303  	if len(repos) != 0 {
   304  		o := ghhook.Options{
   305  			GitHubOptions:    c.options.github,
   306  			GitHubHookClient: c.githubHookClient,
   307  			Repos:            prowflagutil.NewStrings(repos...),
   308  			HookURL:          c.options.hookUrl,
   309  			ShouldDelete:     true,
   310  			Confirm:          true,
   311  		}
   312  		if err := o.Validate(); err != nil {
   313  			return fmt.Errorf("error validating the options: %w", err)
   314  		}
   315  
   316  		logrus.WithField("repos", repos).Debugf("Deleting webhooks for %q", c.options.hookUrl)
   317  		if err := o.HandleWebhookConfigChange(); err != nil {
   318  			return fmt.Errorf("error deleting webhook for repos %q: %w", repos, err)
   319  		}
   320  
   321  		for _, repo := range repos {
   322  			delete(c.currentHMACMap, repo)
   323  		}
   324  	}
   325  
   326  	if removeGlobalToken {
   327  		delete(c.currentHMACMap, "*")
   328  	}
   329  	// No need to update the secret here, the following update will commit the changes together.
   330  
   331  	return nil
   332  }
   333  
   334  func (c *client) handleAddedRepo(added map[string]config.ManagedWebhookInfo) error {
   335  	for repo := range added {
   336  		if err := c.addRepoToBatchUpdate(repo); err != nil {
   337  			return err
   338  		}
   339  	}
   340  	return nil
   341  }
   342  
   343  func (c *client) handledRotatedRepo(rotated map[string]config.ManagedWebhookInfo) error {
   344  	// For each rotated repo, we only onboard a new token when none of the existing tokens is created after user specified time.
   345  	for repo, hmacConfig := range rotated {
   346  		needsRotation := true
   347  		for _, token := range c.currentHMACMap[repo] {
   348  			// If the existing token is created after the user specified time, we do not need to rotate it.
   349  			if token.CreatedAt.After(hmacConfig.TokenCreatedAfter) {
   350  				needsRotation = false
   351  				break
   352  			}
   353  		}
   354  		if needsRotation {
   355  			if err := c.addRepoToBatchUpdate(repo); err != nil {
   356  				return err
   357  			}
   358  		}
   359  	}
   360  	return nil
   361  }
   362  
   363  func (c *client) addRepoToBatchUpdate(repo string) error {
   364  	generatedToken, err := generateNewHMACToken()
   365  	if err != nil {
   366  		return fmt.Errorf("error generating a new hmac token for %q: %w", repo, err)
   367  	}
   368  
   369  	updatedTokenList := github.HMACsForRepo{}
   370  	// Copy over all existing tokens for that repo, if it's already been configured.
   371  	if val, ok := c.currentHMACMap[repo]; ok {
   372  		updatedTokenList = append(updatedTokenList, val...)
   373  		// Back up the hmacs for the current repo, which we can use for recovery in case an error happens in updating the webhook.
   374  		c.hmacMapForRecovery[repo] = c.currentHMACMap[repo]
   375  		// Current webhook is possibly using global token so we need to promote that token to repo level, if it exists.
   376  	} else if globalTokens, ok := c.currentHMACMap["*"]; ok {
   377  		updatedTokenList = append(updatedTokenList, github.HMACToken{
   378  			Value: globalTokens[0].Value,
   379  			// Set CreatedAt as a time slightly before the TokenCreatedAfter time, so that the token can be properly pruned in the end.
   380  			CreatedAt: c.newHMACConfig.OrgRepoConfig[repo].TokenCreatedAfter.Add(-time.Second),
   381  		})
   382  	}
   383  
   384  	updatedTokenList = append(updatedTokenList, github.HMACToken{
   385  		Value: generatedToken, CreatedAt: time.Now()})
   386  	c.currentHMACMap[repo] = updatedTokenList
   387  	c.hmacMapForBatchUpdate[repo] = generatedToken
   388  
   389  	return nil
   390  }
   391  
   392  func (c *client) onboardNewTokenForRepo(repo, generatedToken string) error {
   393  	// Update the github webhook to use new token.
   394  	o := ghhook.Options{
   395  		GitHubOptions:    c.options.github,
   396  		GitHubHookClient: c.githubHookClient,
   397  		Repos:            prowflagutil.NewStrings(repo),
   398  		HookURL:          c.options.hookUrl,
   399  		HMACValue:        generatedToken,
   400  		// Receive hooks for all the events.
   401  		Events:  prowflagutil.NewStrings(github.AllHookEvents...),
   402  		Confirm: true,
   403  	}
   404  	if err := o.Validate(); err != nil {
   405  		return fmt.Errorf("error validating the options: %w", err)
   406  	}
   407  
   408  	logrus.WithField("repo", repo).Debugf("Updating the webhook for %q", c.options.hookUrl)
   409  	return o.HandleWebhookConfigChange()
   410  }
   411  
   412  func (c *client) batchOnboardNewTokenForRepos() []error {
   413  	var errs []error
   414  	for repo, generatedToken := range c.hmacMapForBatchUpdate {
   415  		if err := c.onboardNewTokenForRepo(repo, generatedToken); err != nil {
   416  			errs = append(errs, err)
   417  			logrus.WithError(err).Errorf("Error updating the webhook, will revert the hmacs for %q", repo)
   418  			if hmacs, exist := c.hmacMapForRecovery[repo]; exist {
   419  				c.currentHMACMap[repo] = hmacs
   420  			} else {
   421  				delete(c.currentHMACMap, repo)
   422  			}
   423  		}
   424  	}
   425  	return errs
   426  }
   427  
   428  // cleanup will do necessary cleanups after the token and webhook updates are done.
   429  func (c *client) cleanup() error {
   430  	// Prune old tokens from current config.
   431  	for repoName := range c.currentHMACMap {
   432  		c.pruneOldTokens(repoName)
   433  	}
   434  	// Update the secret.
   435  	if err := c.updateHMACTokenSecret(); err != nil {
   436  		return fmt.Errorf("error updating hmac tokens: %w", err)
   437  	}
   438  	return nil
   439  }
   440  
   441  // updateHMACTokenSecret saves given in-memory config to secret file used by prow cluster.
   442  func (c *client) updateHMACTokenSecret() error {
   443  	if c.options.dryRun {
   444  		logrus.Debug("dryrun option is enabled, updateHMACTokenSecret won't actually update the secret.")
   445  		return nil
   446  	}
   447  
   448  	secretContent, err := yaml.Marshal(&c.currentHMACMap)
   449  	if err != nil {
   450  		return fmt.Errorf("error converting hmac map to yaml: %w", err)
   451  	}
   452  	sec := &corev1.Secret{}
   453  	sec.Name = c.options.hmacTokenSecretName
   454  	sec.Namespace = c.options.hmacTokenSecretNamespace
   455  	sec.StringData = map[string]string{c.options.hmacTokenKey: string(secretContent)}
   456  	if _, err = c.kubernetesClient.CoreV1().Secrets(c.options.hmacTokenSecretNamespace).Update(context.TODO(), sec, metav1.UpdateOptions{}); err != nil {
   457  		return fmt.Errorf("error updating the secret: %w", err)
   458  	}
   459  	return nil
   460  }
   461  
   462  // pruneOldTokens removes all but most recent token from token config.
   463  func (c *client) pruneOldTokens(repo string) {
   464  	tokens := c.currentHMACMap[repo]
   465  	if len(tokens) <= 1 {
   466  		logrus.WithField("repo", repo).Debugf("Token size is %d, no need to prune", len(tokens))
   467  		return
   468  	}
   469  
   470  	logrus.WithField("repo", repo).Debugf("Token size is %d, prune to 1", len(tokens))
   471  	sort.SliceStable(tokens, func(i, j int) bool {
   472  		return tokens[i].CreatedAt.After(tokens[j].CreatedAt)
   473  	})
   474  	c.currentHMACMap[repo] = tokens[:1]
   475  }
   476  
   477  // generateNewHMACToken generates a hex encoded crypto random string of length 40.
   478  func generateNewHMACToken() (string, error) {
   479  	bytes := make([]byte, 20) // 20 bytes of entropy will result in a string of length 40 after hex encoding
   480  	if _, err := rand.Read(bytes); err != nil {
   481  		return "", err
   482  	}
   483  	return hex.EncodeToString(bytes), nil
   484  }
   485  
   486  // getCurrentHMACTokens returns the hmac tokens currently configured in the cluster.
   487  func getCurrentHMACTokens(kc kubernetes.Interface, ns, secName, key string) ([]byte, error) {
   488  	sec, err := kc.CoreV1().Secrets(ns).Get(context.TODO(), secName, metav1.GetOptions{})
   489  	if err != nil && !apierrors.IsNotFound(err) {
   490  		return nil, fmt.Errorf("error getting hmac secret %q: %w", secName, err)
   491  	}
   492  	if err == nil {
   493  		buf, ok := sec.Data[key]
   494  		if ok {
   495  			return buf, nil
   496  		}
   497  		return nil, fmt.Errorf("error getting key %q from the hmac secret %q", key, secName)
   498  	}
   499  	return nil, fmt.Errorf("error getting hmac token values: %w", err)
   500  }