sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/flagutil/github.go (about)

     1  /*
     2  Copyright 2018 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 flagutil
    18  
    19  import (
    20  	"crypto/rsa"
    21  	"errors"
    22  	"flag"
    23  	"fmt"
    24  	"net/url"
    25  	"strconv"
    26  	"strings"
    27  	"time"
    28  
    29  	"github.com/dgrijalva/jwt-go/v4"
    30  	"github.com/sirupsen/logrus"
    31  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    32  
    33  	"sigs.k8s.io/prow/pkg/config/secret"
    34  	gitv2 "sigs.k8s.io/prow/pkg/git/v2"
    35  	"sigs.k8s.io/prow/pkg/github"
    36  )
    37  
    38  // GitHubOptions holds options for interacting with GitHub.
    39  //
    40  // Set AllowAnonymous to be true if you want to allow anonymous github access.
    41  // Set AllowDirectAccess to be true if you want to suppress warnings on direct github access (without ghproxy).
    42  type GitHubOptions struct {
    43  	Host              string
    44  	endpoint          Strings
    45  	graphqlEndpoint   string
    46  	TokenPath         string
    47  	AllowAnonymous    bool
    48  	AllowDirectAccess bool
    49  	AppID             string
    50  	AppPrivateKeyPath string
    51  
    52  	ThrottleHourlyTokens int
    53  	ThrottleAllowBurst   int
    54  
    55  	OrgThrottlers       Strings
    56  	parsedOrgThrottlers map[string]throttlerSettings
    57  
    58  	// These will only be set after a github client was retrieved for the first time
    59  	tokenGenerator github.TokenGenerator
    60  	userGenerator  github.UserGenerator
    61  
    62  	// the following options determine how the client behaves around retries
    63  	maxRequestTime time.Duration
    64  	maxRetries     int
    65  	max404Retries  int
    66  	initialDelay   time.Duration
    67  	maxSleepTime   time.Duration
    68  }
    69  
    70  type throttlerSettings struct {
    71  	hourlyTokens int
    72  	burst        int
    73  }
    74  
    75  // flagParams struct is used indirectly by users of this package to customize
    76  // the common flags behavior, such as providing their own default values
    77  // or suppressing presence of certain flags.
    78  type flagParams struct {
    79  	defaults GitHubOptions
    80  
    81  	disableThrottlerOptions bool
    82  }
    83  
    84  type FlagParameter func(options *flagParams)
    85  
    86  // ThrottlerDefaults allows to customize the default values of flags
    87  // that control the throttler behavior. Setting `hourlyTokens` to zero
    88  // disables throttling by default.
    89  func ThrottlerDefaults(hourlyTokens, allowedBursts int) FlagParameter {
    90  	return func(o *flagParams) {
    91  		o.defaults.ThrottleHourlyTokens = hourlyTokens
    92  		o.defaults.ThrottleAllowBurst = allowedBursts
    93  	}
    94  }
    95  
    96  // DisableThrottlerOptions suppresses the presence of throttler-related flags,
    97  // effectively disallowing external users to parametrize default throttling
    98  // behavior. This is useful mostly when a program creates multiple GH clients
    99  // with different behavior.
   100  func DisableThrottlerOptions() FlagParameter {
   101  	return func(o *flagParams) {
   102  		o.disableThrottlerOptions = true
   103  	}
   104  }
   105  
   106  // AddCustomizedFlags injects GitHub options into the given FlagSet. Behavior can be customized
   107  // via the functional options.
   108  func (o *GitHubOptions) AddCustomizedFlags(fs *flag.FlagSet, paramFuncs ...FlagParameter) {
   109  	o.addFlags(fs, paramFuncs...)
   110  }
   111  
   112  // AddFlags injects GitHub options into the given FlagSet
   113  func (o *GitHubOptions) AddFlags(fs *flag.FlagSet) {
   114  	o.addFlags(fs)
   115  }
   116  
   117  func (o *GitHubOptions) addFlags(fs *flag.FlagSet, paramFuncs ...FlagParameter) {
   118  	params := flagParams{
   119  		defaults: GitHubOptions{
   120  			Host:            github.DefaultHost,
   121  			endpoint:        NewStrings(github.DefaultAPIEndpoint),
   122  			graphqlEndpoint: github.DefaultGraphQLEndpoint,
   123  		},
   124  	}
   125  
   126  	for _, parametrize := range paramFuncs {
   127  		parametrize(&params)
   128  	}
   129  
   130  	defaults := params.defaults
   131  	fs.StringVar(&o.Host, "github-host", defaults.Host, "GitHub's default host (may differ for enterprise)")
   132  	o.endpoint = NewStrings(defaults.endpoint.Strings()...)
   133  	fs.Var(&o.endpoint, "github-endpoint", "GitHub's API endpoint (may differ for enterprise).")
   134  	fs.StringVar(&o.graphqlEndpoint, "github-graphql-endpoint", defaults.graphqlEndpoint, "GitHub GraphQL API endpoint (may differ for enterprise).")
   135  	fs.StringVar(&o.TokenPath, "github-token-path", defaults.TokenPath, "Path to the file containing the GitHub OAuth secret.")
   136  	fs.StringVar(&o.AppID, "github-app-id", defaults.AppID, "ID of the GitHub app. If set, requires --github-app-private-key-path to be set and --github-token-path to be unset.")
   137  	fs.StringVar(&o.AppPrivateKeyPath, "github-app-private-key-path", defaults.AppPrivateKeyPath, "Path to the private key of the github app. If set, requires --github-app-id to bet set and --github-token-path to be unset")
   138  
   139  	if !params.disableThrottlerOptions {
   140  		fs.IntVar(&o.ThrottleHourlyTokens, "github-hourly-tokens", defaults.ThrottleHourlyTokens, "If set to a value larger than zero, enable client-side throttling to limit hourly token consumption. If set, --github-allowed-burst must be positive too.")
   141  		fs.IntVar(&o.ThrottleAllowBurst, "github-allowed-burst", defaults.ThrottleAllowBurst, "Size of token consumption bursts. If set, --github-hourly-tokens must be positive too and set to a higher or equal number.")
   142  		fs.Var(&o.OrgThrottlers, "github-throttle-org", "Throttler settings for a specific org in org:hourlyTokens:burst format. Can be passed multiple times. Only valid when using github apps auth.")
   143  	}
   144  
   145  	fs.DurationVar(&o.maxRequestTime, "github-client.request-timeout", github.DefaultMaxSleepTime, "Timeout for any single request to the GitHub API.")
   146  	fs.IntVar(&o.maxRetries, "github-client.max-retries", github.DefaultMaxRetries, "Maximum number of retries that will be used for a failing request to the GitHub API.")
   147  	fs.IntVar(&o.max404Retries, "github-client.max-404-retries", github.DefaultMax404Retries, "Maximum number of retries that will be used for a 404-ing request to the GitHub API.")
   148  	fs.DurationVar(&o.maxSleepTime, "github-client.backoff-timeout", github.DefaultMaxSleepTime, "Largest allowable Retry-After time for requests to the GitHub API.")
   149  	fs.DurationVar(&o.initialDelay, "github-client.initial-delay", github.DefaultInitialDelay, "Initial delay before retries begin for requests to the GitHub API.")
   150  }
   151  
   152  func (o *GitHubOptions) parseOrgThrottlers() error {
   153  	if len(o.OrgThrottlers.vals) == 0 {
   154  		return nil
   155  	}
   156  
   157  	if o.AppID == "" {
   158  		return errors.New("--github-throttle-org was passed, but client doesn't use apps auth")
   159  	}
   160  
   161  	o.parsedOrgThrottlers = make(map[string]throttlerSettings, len(o.OrgThrottlers.vals))
   162  	var errs []error
   163  	for _, orgThrottler := range o.OrgThrottlers.vals {
   164  		colonSplit := strings.Split(orgThrottler, ":")
   165  		if len(colonSplit) != 3 {
   166  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s is not in org:hourlyTokens:burst format", orgThrottler))
   167  			continue
   168  		}
   169  		org, hourlyTokensString, burstString := colonSplit[0], colonSplit[1], colonSplit[2]
   170  		hourlyTokens, err := strconv.ParseInt(hourlyTokensString, 10, 32)
   171  		if err != nil {
   172  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s is not in org:hourlyTokens:burst format: hourlyTokens is not an int", orgThrottler))
   173  			continue
   174  		}
   175  		burst, err := strconv.ParseInt(burstString, 10, 32)
   176  		if err != nil {
   177  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s is not in org:hourlyTokens:burst format: burst is not an int", orgThrottler))
   178  			continue
   179  		}
   180  		if hourlyTokens < 1 {
   181  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s: hourlyTokens must be > 0", orgThrottler))
   182  			continue
   183  		}
   184  		if burst < 1 {
   185  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s: burst must be > 0", orgThrottler))
   186  			continue
   187  		}
   188  		if burst > hourlyTokens {
   189  			errs = append(errs, fmt.Errorf("-github-throttle-org=%s: burst must not be greater than hourlyTokens", orgThrottler))
   190  			continue
   191  		}
   192  		if _, alreadyExists := o.parsedOrgThrottlers[org]; alreadyExists {
   193  			errs = append(errs, fmt.Errorf("got multiple -github-throttle-org for the %s org", org))
   194  			continue
   195  		}
   196  		o.parsedOrgThrottlers[org] = throttlerSettings{hourlyTokens: int(hourlyTokens), burst: int(burst)}
   197  	}
   198  
   199  	return utilerrors.NewAggregate(errs)
   200  }
   201  
   202  // Validate validates GitHub options. Note that validate updates the GitHubOptions
   203  // to add default values for TokenPath and graphqlEndpoint.
   204  func (o *GitHubOptions) Validate(bool) error {
   205  	endpoints := o.endpoint.Strings()
   206  	for i, uri := range endpoints {
   207  		if uri == "" {
   208  			endpoints[i] = github.DefaultAPIEndpoint
   209  		} else if _, err := url.ParseRequestURI(uri); err != nil {
   210  			return fmt.Errorf("invalid -github-endpoint URI: %q", uri)
   211  		}
   212  	}
   213  
   214  	if o.TokenPath != "" && (o.AppID != "" || o.AppPrivateKeyPath != "") {
   215  		return fmt.Errorf("--token-path is mutually exclusive with --app-id and --app-private-key-path")
   216  	}
   217  	if o.AppID == "" != (o.AppPrivateKeyPath == "") {
   218  		return errors.New("--app-id and --app-private-key-path must be set together")
   219  	}
   220  
   221  	if o.TokenPath != "" && len(endpoints) == 1 && endpoints[0] == github.DefaultAPIEndpoint && !o.AllowDirectAccess {
   222  		logrus.Warn("It doesn't look like you are using ghproxy to cache API calls to GitHub! This has become a required component of Prow and other components will soon be allowed to add features that may rapidly consume API ratelimit without caching. Starting May 1, 2020 use Prow components without ghproxy at your own risk! https://docs.prow.k8s.io/docs/ghproxy/")
   223  	}
   224  
   225  	if o.graphqlEndpoint == "" {
   226  		o.graphqlEndpoint = github.DefaultGraphQLEndpoint
   227  	} else if _, err := url.Parse(o.graphqlEndpoint); err != nil {
   228  		return fmt.Errorf("invalid -github-graphql-endpoint URI: %q", o.graphqlEndpoint)
   229  	}
   230  
   231  	if (o.ThrottleHourlyTokens > 0) != (o.ThrottleAllowBurst > 0) {
   232  		if o.ThrottleHourlyTokens == 0 {
   233  			// Tolerate `--github-hourly-tokens=0` alone to disable throttling
   234  			o.ThrottleAllowBurst = 0
   235  		} else {
   236  			return errors.New("--github-hourly-tokens and --github-allowed-burst must be either both higher than zero or both equal to zero")
   237  		}
   238  	}
   239  	if o.ThrottleAllowBurst > o.ThrottleHourlyTokens {
   240  		return errors.New("--github-allowed-burst must not be larger than --github-hourly-tokens")
   241  	}
   242  
   243  	return o.parseOrgThrottlers()
   244  }
   245  
   246  // GitHubClientWithLogFields returns a GitHub client with extra logging fields
   247  func (o *GitHubOptions) GitHubClientWithLogFields(dryRun bool, fields logrus.Fields) (github.Client, error) {
   248  	client, err := o.githubClient(dryRun)
   249  	if err != nil {
   250  		return nil, err
   251  	}
   252  	return client.WithFields(fields), nil
   253  }
   254  
   255  func (o *GitHubOptions) githubClient(dryRun bool) (github.Client, error) {
   256  	fields := logrus.Fields{}
   257  	options := o.baseClientOptions()
   258  	options.DryRun = dryRun
   259  
   260  	if o.TokenPath == "" && o.AppPrivateKeyPath == "" {
   261  		logrus.Warn("empty -github-token-path, will use anonymous github client")
   262  	}
   263  
   264  	if o.TokenPath == "" {
   265  		options.GetToken = func() []byte {
   266  			return []byte{}
   267  		}
   268  	} else {
   269  		if err := secret.Add(o.TokenPath); err != nil {
   270  			return nil, fmt.Errorf("failed to add GitHub token to secret agent: %w", err)
   271  		}
   272  		options.GetToken = secret.GetTokenGenerator(o.TokenPath)
   273  	}
   274  
   275  	if o.AppPrivateKeyPath != "" {
   276  		apk, err := o.appPrivateKeyGenerator()
   277  		if err != nil {
   278  			return nil, err
   279  		}
   280  		options.AppPrivateKey = apk
   281  	}
   282  
   283  	optionallyThrottled := func(c github.Client) (github.Client, error) {
   284  		// Throttle handles zeros as "disable throttling" so we do not need to call it conditionally
   285  		if err := c.Throttle(o.ThrottleHourlyTokens, o.ThrottleAllowBurst); err != nil {
   286  			return nil, fmt.Errorf("failed to throttle: %w", err)
   287  		}
   288  		for org, settings := range o.parsedOrgThrottlers {
   289  			if err := c.Throttle(settings.hourlyTokens, settings.burst, org); err != nil {
   290  				return nil, fmt.Errorf("failed to set up throttling for org %s: %w", org, err)
   291  			}
   292  		}
   293  		return c, nil
   294  	}
   295  
   296  	tokenGenerator, userGenerator, client, err := github.NewClientFromOptions(fields, options)
   297  	if err != nil {
   298  		return nil, fmt.Errorf("failed to construct github client: %w", err)
   299  	}
   300  	o.tokenGenerator = tokenGenerator
   301  	o.userGenerator = userGenerator
   302  	return optionallyThrottled(client)
   303  }
   304  
   305  // baseClientOptions populates client options that are derived from flags without processing
   306  func (o *GitHubOptions) baseClientOptions() github.ClientOptions {
   307  	return github.ClientOptions{
   308  		Censor:          secret.Censor,
   309  		AppID:           o.AppID,
   310  		GraphqlEndpoint: o.graphqlEndpoint,
   311  		Bases:           o.endpoint.Strings(),
   312  		MaxRequestTime:  o.maxRequestTime,
   313  		InitialDelay:    o.initialDelay,
   314  		MaxSleepTime:    o.maxSleepTime,
   315  		MaxRetries:      o.maxRetries,
   316  		Max404Retries:   o.max404Retries,
   317  	}
   318  }
   319  
   320  // GitHubClient returns a GitHub client.
   321  func (o *GitHubOptions) GitHubClient(dryRun bool) (github.Client, error) {
   322  	return o.GitHubClientWithLogFields(dryRun, logrus.Fields{})
   323  }
   324  
   325  // GitHubClientWithAccessToken creates a GitHub client from an access token.
   326  func (o *GitHubOptions) GitHubClientWithAccessToken(token string) (github.Client, error) {
   327  	options := o.baseClientOptions()
   328  	options.GetToken = func() []byte { return []byte(token) }
   329  	options.AppID = "" // Since we are using a token, we should not use the app auth
   330  	_, _, client, err := github.NewClientFromOptions(logrus.Fields{}, options)
   331  	return client, err
   332  }
   333  
   334  // GitClientFactory returns git.ClientFactory. Passing non-empty cookieFilePath
   335  // will result in git ClientFactory to work with Gerrit.
   336  // TODO(chaodaiG): move this logic to somewhere more appropriate instead of in
   337  // github.go.
   338  func (o *GitHubOptions) GitClientFactory(cookieFilePath string, cacheDir *string, dryRun, persistCache bool) (gitv2.ClientFactory, error) {
   339  	opts := gitv2.ClientFactoryOpts{
   340  		Censor:         secret.Censor,
   341  		CookieFilePath: cookieFilePath,
   342  		Host:           o.Host,
   343  		Persist:        &persistCache,
   344  	}
   345  	if cacheDir != nil && *cacheDir != "" {
   346  		opts.CacheDirBase = cacheDir
   347  	}
   348  
   349  	if cookieFilePath == "" && (o.TokenPath != "" || o.AppPrivateKeyPath != "") {
   350  		// Make a client with auth suitable for GitHub
   351  		user, generator, err := o.getGitHubAuthentication(dryRun)
   352  		if err != nil {
   353  			return nil, fmt.Errorf("failed to get git authentication: %w", err)
   354  		}
   355  		opts.Username = func() (string, error) { return user, nil }
   356  		opts.Token = generator
   357  	}
   358  	// If the client is for Gerrit we're already set with the cookie filepath.
   359  
   360  	gitClientFactory, err := gitv2.NewClientFactory(opts.Apply)
   361  	if err != nil {
   362  		return nil, fmt.Errorf("failed to create git client factory: %w", err)
   363  	}
   364  	return gitClientFactory, nil
   365  }
   366  
   367  func (o *GitHubOptions) getGitHubAuthentication(dryRun bool) (string, gitv2.TokenGetter, error) {
   368  	// the client must have been created at least once for us to have generators
   369  	if o.userGenerator == nil {
   370  		if _, err := o.GitHubClient(dryRun); err != nil {
   371  			return "", nil, fmt.Errorf("error getting GitHub client: %w", err)
   372  		}
   373  	}
   374  
   375  	login, err := o.userGenerator()
   376  	if err != nil {
   377  		return "", nil, fmt.Errorf("error getting bot name: %w", err)
   378  	}
   379  	return login, gitv2.TokenGetter(o.tokenGenerator), nil
   380  }
   381  
   382  func (o *GitHubOptions) appPrivateKeyGenerator() (func() *rsa.PrivateKey, error) {
   383  	generator, err := secret.AddWithParser(
   384  		o.AppPrivateKeyPath,
   385  		func(raw []byte) (*rsa.PrivateKey, error) {
   386  			privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(raw)
   387  			if err != nil {
   388  				return nil, fmt.Errorf("failed to parse rsa key from pem: %w", err)
   389  			}
   390  			return privateKey, nil
   391  		},
   392  	)
   393  	if err != nil {
   394  		return nil, fmt.Errorf("failed to add the key from --app-private-key-path to secret agent: %w", err)
   395  	}
   396  
   397  	return generator, nil
   398  }