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  }