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