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  }