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(¶ms) 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 }