sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/pluginhelp/hook/hook.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 hook provides the plugin help components to be compiled into the hook binary. 18 // This includes the code to fetch help from normal and external plugins and the code to build and 19 // serve a pluginhelp.Help struct. 20 package hook 21 22 import ( 23 "bytes" 24 "encoding/json" 25 "fmt" 26 "net/http" 27 "net/url" 28 "path" 29 "strings" 30 "sync" 31 "time" 32 33 "github.com/sirupsen/logrus" 34 "k8s.io/apimachinery/pkg/util/errors" 35 "k8s.io/apimachinery/pkg/util/sets" 36 37 prowconfig "sigs.k8s.io/prow/pkg/config" 38 "sigs.k8s.io/prow/pkg/github" 39 "sigs.k8s.io/prow/pkg/pluginhelp" 40 "sigs.k8s.io/prow/pkg/pluginhelp/externalplugins" 41 "sigs.k8s.io/prow/pkg/plugins" 42 ) 43 44 // TODO: unit test to ensure that external plugins with the same name have the same endpoint and events. 45 46 const ( 47 // newRepoDetectionLimit is the maximum allowable time before a repo will appear in the help 48 // information if it is a new repo that is only referenced via its parent org in the config. 49 // (i.e. max time before help is available for brand new "kubernetes/foo" repo if only 50 // "kubernetes" is listed in the config) 51 newRepoDetectionLimit = time.Hour 52 ) 53 54 type pluginAgent interface { 55 Config() *plugins.Configuration 56 } 57 58 type githubClient interface { 59 GetRepos(org string, isUser bool) ([]github.Repo, error) 60 } 61 62 // HelpAgent is a handler that generates and serve plugin help information. 63 type HelpAgent struct { 64 log *logrus.Entry 65 pa pluginAgent 66 oa *orgAgent 67 } 68 69 // NewHelpAgent constructs a new HelpAgent. 70 func NewHelpAgent(pa pluginAgent, ghc githubClient) *HelpAgent { 71 l := logrus.WithField("client", "plugin-help") 72 return &HelpAgent{ 73 log: l, 74 pa: pa, 75 oa: newOrgAgent(l, ghc, newRepoDetectionLimit), 76 } 77 } 78 79 func (ha *HelpAgent) generateNormalPluginHelp(config *plugins.Configuration, revMap map[string][]prowconfig.OrgRepo) (allPlugins []string, pluginHelp map[string]pluginhelp.PluginHelp) { 80 pluginHelp = map[string]pluginhelp.PluginHelp{} 81 for name, provider := range plugins.HelpProviders() { 82 allPlugins = append(allPlugins, name) 83 if provider == nil { 84 ha.log.Warnf("No help is provided for plugin %q.", name) 85 continue 86 } 87 help, err := provider(config, revMap[name]) 88 if err != nil { 89 ha.log.WithError(err).Errorf("Generating help from normal plugin %q.", name) 90 continue 91 } 92 help.Events = plugins.EventsForPlugin(name) 93 pluginHelp[name] = *help 94 } 95 return 96 } 97 98 func (ha *HelpAgent) generateExternalPluginHelp(config *plugins.Configuration, revMap map[string][]prowconfig.OrgRepo) (allPlugins []string, pluginHelp map[string]pluginhelp.PluginHelp) { 99 externals := map[string]plugins.ExternalPlugin{} 100 for _, exts := range config.ExternalPlugins { 101 for _, ext := range exts { 102 externals[ext.Name] = ext 103 } 104 } 105 106 type externalResult struct { 107 name string 108 help *pluginhelp.PluginHelp 109 } 110 externalResultChan := make(chan externalResult, len(externals)) 111 for _, ext := range externals { 112 allPlugins = append(allPlugins, ext.Name) 113 go func(ext plugins.ExternalPlugin) { 114 help, err := externalHelpProvider(ext.Endpoint)(revMap[ext.Name]) 115 if err != nil { 116 ha.log.WithError(err).Errorf("Getting help from external plugin %q.", ext.Name) 117 help = nil 118 } else { 119 help.Events = ext.Events 120 } 121 externalResultChan <- externalResult{name: ext.Name, help: help} 122 }(ext) 123 } 124 125 pluginHelp = map[string]pluginhelp.PluginHelp{} 126 timeout := time.After(time.Second) 127 Done: 128 for { 129 select { 130 case <-timeout: 131 break Done 132 case result, ok := <-externalResultChan: 133 if !ok { 134 break Done 135 } 136 if result.help == nil { 137 continue 138 } 139 pluginHelp[result.name] = *result.help 140 } 141 } 142 return 143 } 144 145 // GeneratePluginHelp compiles and returns the help information for all plugins. 146 func (ha *HelpAgent) GeneratePluginHelp() *pluginhelp.Help { 147 config := ha.pa.Config() 148 orgToRepos := ha.oa.orgToReposMap(config) 149 150 normalRevMap, externalRevMap := reversePluginMaps(config, orgToRepos) 151 152 allPlugins, pluginHelp := ha.generateNormalPluginHelp(config, normalRevMap) 153 154 allExternalPlugins, externalPluginHelp := ha.generateExternalPluginHelp(config, externalRevMap) 155 156 // Load repo->plugins maps from config 157 repoPlugins := map[string][]string{ 158 "": allPlugins, 159 } 160 for repo, plugins := range config.Plugins { 161 repoPlugins[repo] = plugins.Plugins 162 } 163 repoExternalPlugins := map[string][]string{ 164 "": allExternalPlugins, 165 } 166 for repo, exts := range config.ExternalPlugins { 167 for _, ext := range exts { 168 repoExternalPlugins[repo] = append(repoExternalPlugins[repo], ext.Name) 169 } 170 } 171 172 return &pluginhelp.Help{ 173 AllRepos: allRepos(config, orgToRepos), 174 RepoPlugins: repoPlugins, 175 RepoExternalPlugins: repoExternalPlugins, 176 PluginHelp: pluginHelp, 177 ExternalPluginHelp: externalPluginHelp, 178 } 179 } 180 181 func allRepos(config *plugins.Configuration, orgToRepos map[string]sets.Set[string]) []string { 182 all := sets.New[string]() 183 for repo := range config.Plugins { 184 all.Insert(repo) 185 } 186 for repo := range config.ExternalPlugins { 187 all.Insert(repo) 188 } 189 190 flattened := sets.New[string]() 191 for repo := range all { 192 if strings.Contains(repo, "/") { 193 flattened.Insert(repo) 194 continue 195 } 196 flattened = flattened.Union(orgToRepos[repo]) 197 } 198 return sets.List(flattened) 199 } 200 201 func externalHelpProvider(endpoint string) externalplugins.ExternalPluginHelpProvider { 202 return func(enabledRepos []prowconfig.OrgRepo) (*pluginhelp.PluginHelp, error) { 203 u, err := url.Parse(endpoint) 204 if err != nil { 205 return nil, fmt.Errorf("error parsing url: %s err: %w", endpoint, err) 206 } 207 u.Path = path.Join(u.Path, "/help") 208 b, err := json.Marshal(enabledRepos) 209 if err != nil { 210 return nil, fmt.Errorf("error marshalling enabled repos: %q, err: %w", enabledRepos, err) 211 } 212 213 // Don't retry because user is waiting for response to their browser. 214 // If there is an error the user can refresh to get the plugin info that failed to load. 215 urlString := u.String() 216 resp, err := http.Post(urlString, "application/json", bytes.NewReader(b)) 217 if err != nil { 218 return nil, fmt.Errorf("error posting to %s err: %w", urlString, err) 219 } 220 defer resp.Body.Close() 221 if resp.StatusCode < 200 || resp.StatusCode > 299 { 222 return nil, fmt.Errorf("post to %s failed with status %d: %s", urlString, resp.StatusCode, resp.Status) 223 } 224 var help pluginhelp.PluginHelp 225 if err := json.NewDecoder(resp.Body).Decode(&help); err != nil { 226 return nil, fmt.Errorf("failed to decode json response from %s err: %w", urlString, err) 227 } 228 return &help, nil 229 } 230 } 231 232 // reversePluginMaps inverts the Configuration.Plugins and Configuration.ExternalPlugins maps and 233 // expands any org strings to org/repo strings. 234 // The returned values map plugin names to the set of org/repo strings they are enabled on. 235 func reversePluginMaps(config *plugins.Configuration, orgToRepos map[string]sets.Set[string]) (normal, external map[string][]prowconfig.OrgRepo) { 236 normal = map[string][]prowconfig.OrgRepo{} 237 for repo, enabledPlugins := range config.Plugins { 238 var repos []prowconfig.OrgRepo 239 if !strings.Contains(repo, "/") { 240 if flattened, ok := orgToRepos[repo]; ok { 241 repos = prowconfig.StringsToOrgRepos(sets.List(flattened)) 242 } 243 } else { 244 repos = []prowconfig.OrgRepo{*prowconfig.NewOrgRepo(repo)} 245 } 246 for _, plugin := range enabledPlugins.Plugins { 247 normal[plugin] = append(normal[plugin], repos...) 248 } 249 } 250 external = map[string][]prowconfig.OrgRepo{} 251 for repo, extPlugins := range config.ExternalPlugins { 252 var repos []prowconfig.OrgRepo 253 if flattened, ok := orgToRepos[repo]; ok { 254 repos = prowconfig.StringsToOrgRepos(sets.List(flattened)) 255 } else { 256 repos = []prowconfig.OrgRepo{*prowconfig.NewOrgRepo(repo)} 257 } 258 for _, plugin := range extPlugins { 259 external[plugin.Name] = append(external[plugin.Name], repos...) 260 } 261 } 262 return 263 } 264 265 // orgAgent provides a cached mapping of orgs to the repos that are in that org. 266 // Caching is necessary to prevent excessive github API token usage. 267 type orgAgent struct { 268 log *logrus.Entry 269 ghc githubClient 270 271 syncPeriod time.Duration 272 273 lock sync.Mutex 274 nextSync time.Time 275 orgs sets.Set[string] 276 orgToRepos map[string]sets.Set[string] 277 } 278 279 func newOrgAgent(log *logrus.Entry, ghc githubClient, syncPeriod time.Duration) *orgAgent { 280 return &orgAgent{ 281 log: log, 282 ghc: ghc, 283 syncPeriod: syncPeriod, 284 } 285 } 286 287 func (oa *orgAgent) orgToReposMap(config *plugins.Configuration) map[string]sets.Set[string] { 288 oa.lock.Lock() 289 defer oa.lock.Unlock() 290 // Only need to sync if either: 291 // - the sync period has passed (we sync periodically to pick up new repos in known orgs) 292 // or 293 // - new org(s) have been added in the config 294 var syncReason string 295 if time.Now().After(oa.nextSync) { 296 syncReason = "the sync period elapsed" 297 } else if diff := orgsInConfig(config).Difference(oa.orgs); diff.Len() > 0 { 298 syncReason = fmt.Sprintf("the following orgs were added to the config: %q", sets.List(diff)) 299 } 300 if syncReason != "" { 301 oa.log.Infof("Syncing org to repos mapping because %s.", syncReason) 302 oa.sync(config) 303 } 304 return oa.orgToRepos 305 } 306 307 func reposForOrgOrUser(ghc githubClient, orgOrUser string) ([]github.Repo, error) { 308 var errs [2]error 309 inError := map[bool]string{true: "user", false: "org"} 310 311 // Check as an org first, it is more likely in normal use case 312 for i, isUser := range []bool{false, true} { 313 if repos, err := ghc.GetRepos(orgOrUser, isUser); err == nil { 314 return repos, nil 315 } else { 316 errs[i] = fmt.Errorf("failed to get repos for %s %s: %w", inError[isUser], orgOrUser, err) 317 } 318 } 319 320 return nil, errors.NewAggregate(errs[:]) 321 } 322 323 func (oa *orgAgent) sync(config *plugins.Configuration) { 324 325 // QUESTION: If we fail to list repos for a single org should we reuse the old orgToRepos or just 326 // log the error and omit the org from orgToRepos as is done now? 327 // I could remove the failed org from 'orgs' to force a resync the next time it is called, but 328 // that could waste tokens if the call continues to fail for some reason. 329 330 orgs := orgsInConfig(config) 331 orgToRepos := map[string]sets.Set[string]{} 332 333 orgToReposLock := sync.Mutex{} 334 wg := sync.WaitGroup{} 335 for _, org := range sets.List(orgs) { 336 org := org 337 wg.Add(1) 338 339 go func() { 340 defer wg.Done() 341 342 repos, err := reposForOrgOrUser(oa.ghc, org) 343 if err != nil { 344 oa.log.WithError(err).Errorf("Getting repos for org or user: %s.", org) 345 // Remove 'org' from 'orgs' here to force future resync?w 346 return 347 } 348 repoSet := sets.New[string]() 349 for _, repo := range repos { 350 repoSet.Insert(repo.FullName) 351 } 352 orgToReposLock.Lock() 353 orgToRepos[org] = repoSet 354 orgToReposLock.Unlock() 355 }() 356 } 357 wg.Wait() 358 359 oa.orgs = orgs 360 oa.orgToRepos = orgToRepos 361 oa.nextSync = time.Now().Add(oa.syncPeriod) 362 } 363 364 // orgsInConfig gets all the org strings (not org/repo) in config.Plugins and config.ExternalPlugins. 365 func orgsInConfig(config *plugins.Configuration) sets.Set[string] { 366 orgs := sets.New[string]() 367 for repo := range config.Plugins { 368 if !strings.Contains(repo, "/") { 369 orgs.Insert(repo) 370 } 371 } 372 for repo := range config.ExternalPlugins { 373 if !strings.Contains(repo, "/") { 374 orgs.Insert(repo) 375 } 376 } 377 return orgs 378 } 379 380 func (ha *HelpAgent) ServeHTTP(w http.ResponseWriter, r *http.Request) { 381 w.Header().Set("Cache-Control", "no-cache") 382 383 serverError := func(action string, err error) { 384 ha.log.WithError(err).Errorf("Error %s.", action) 385 msg := fmt.Sprintf("500 Internal server error %s: %v", action, err) 386 http.Error(w, msg, http.StatusInternalServerError) 387 } 388 389 if r.Method != http.MethodGet { 390 ha.log.Errorf("Invalid request method: %v.", r.Method) 391 http.Error(w, "405 Method not allowed", http.StatusMethodNotAllowed) 392 return 393 } 394 help := ha.GeneratePluginHelp() 395 b, err := json.Marshal(help) 396 if err != nil { 397 serverError("marshaling plugin help", err) 398 return 399 } 400 401 fmt.Fprint(w, string(b)) 402 }