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