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  }