sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/cmd/deck/main.go (about)

     1  /*
     2  Copyright 2016 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 main
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"crypto/sha256"
    23  	"encoding/base64"
    24  	"encoding/json"
    25  	"errors"
    26  	"flag"
    27  	"fmt"
    28  	"html/template"
    29  	stdio "io"
    30  	"net/http"
    31  	"net/http/httputil"
    32  	"net/url"
    33  	"os"
    34  	"path"
    35  	"sort"
    36  	"strings"
    37  	"time"
    38  
    39  	gerritsource "sigs.k8s.io/prow/pkg/gerrit/source"
    40  	"sigs.k8s.io/prow/pkg/io/providers"
    41  	"sigs.k8s.io/prow/pkg/tide"
    42  
    43  	"github.com/NYTimes/gziphandler"
    44  	"github.com/gorilla/csrf"
    45  	"github.com/gorilla/sessions"
    46  	"github.com/prometheus/client_golang/prometheus"
    47  	"github.com/sirupsen/logrus"
    48  	"golang.org/x/oauth2"
    49  	coreapi "k8s.io/api/core/v1"
    50  	kerrors "k8s.io/apimachinery/pkg/api/errors"
    51  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    52  	"k8s.io/apimachinery/pkg/util/sets"
    53  	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
    54  	ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    55  	"sigs.k8s.io/controller-runtime/pkg/manager"
    56  	pkgFlagutil "sigs.k8s.io/prow/pkg/flagutil"
    57  	"sigs.k8s.io/prow/pkg/pjutil/pprof"
    58  	"sigs.k8s.io/yaml"
    59  
    60  	prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1"
    61  	prowv1 "sigs.k8s.io/prow/pkg/client/clientset/versioned/typed/prowjobs/v1"
    62  	"sigs.k8s.io/prow/pkg/config"
    63  	"sigs.k8s.io/prow/pkg/deck/jobs"
    64  	prowflagutil "sigs.k8s.io/prow/pkg/flagutil"
    65  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    66  	pluginsflagutil "sigs.k8s.io/prow/pkg/flagutil/plugins"
    67  	"sigs.k8s.io/prow/pkg/git/v2"
    68  	prowgithub "sigs.k8s.io/prow/pkg/github"
    69  	"sigs.k8s.io/prow/pkg/githuboauth"
    70  	"sigs.k8s.io/prow/pkg/interrupts"
    71  	"sigs.k8s.io/prow/pkg/io"
    72  	"sigs.k8s.io/prow/pkg/kube"
    73  	"sigs.k8s.io/prow/pkg/logrusutil"
    74  	"sigs.k8s.io/prow/pkg/metrics"
    75  	"sigs.k8s.io/prow/pkg/pjutil"
    76  	"sigs.k8s.io/prow/pkg/pluginhelp"
    77  	"sigs.k8s.io/prow/pkg/plugins"
    78  	"sigs.k8s.io/prow/pkg/prstatus"
    79  	"sigs.k8s.io/prow/pkg/simplifypath"
    80  	"sigs.k8s.io/prow/pkg/spyglass"
    81  	spyglassapi "sigs.k8s.io/prow/pkg/spyglass/api"
    82  	"sigs.k8s.io/prow/pkg/spyglass/lenses/common"
    83  
    84  	// Import standard spyglass viewers
    85  
    86  	"sigs.k8s.io/prow/pkg/spyglass/lenses"
    87  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/buildlog"
    88  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/coverage"
    89  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/html"
    90  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/junit"
    91  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/links"
    92  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/metadata"
    93  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/podinfo"
    94  	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/restcoverage"
    95  )
    96  
    97  // Omittable ProwJob fields.
    98  const (
    99  	// Annotations maps to the serialized value of <ProwJob>.Annotations.
   100  	Annotations string = "annotations"
   101  	// Labels maps to the serialized value of <ProwJob>.Labels.
   102  	Labels string = "labels"
   103  	// DecorationConfig maps to the serialized value of <ProwJob>.Spec.DecorationConfig.
   104  	DecorationConfig string = "decoration_config"
   105  	// PodSpec maps to the serialized value of <ProwJob>.Spec.PodSpec.
   106  	PodSpec string = "pod_spec"
   107  
   108  	defaultStaticFilesLocation   = "/static"
   109  	defaultTemplateFilesLocation = "/template"
   110  	defaultSpyglassFilesLocation = "/lenses"
   111  
   112  	defaultPRHistLinkTemplate = "/pr-history?org={{.Org}}&repo={{.Repo}}&pr={{.Number}}"
   113  )
   114  
   115  type options struct {
   116  	config                configflagutil.ConfigOptions
   117  	pluginsConfig         pluginsflagutil.PluginOptions
   118  	instrumentation       prowflagutil.InstrumentationOptions
   119  	kubernetes            prowflagutil.KubernetesOptions
   120  	github                prowflagutil.GitHubOptions
   121  	tideURL               string
   122  	hookURL               string
   123  	oauthURL              string
   124  	githubOAuthConfigFile string
   125  	cookieSecretFile      string
   126  	redirectHTTPTo        string
   127  	hiddenOnly            bool
   128  	pregeneratedData      string
   129  	staticFilesLocation   string
   130  	templateFilesLocation string
   131  	showHidden            bool
   132  	spyglass              bool
   133  	spyglassFilesLocation string
   134  	storage               prowflagutil.StorageClientOptions
   135  	gcsCookieAuth         bool
   136  	rerunCreatesJob       bool
   137  	allowInsecure         bool
   138  	controllerManager     prowflagutil.ControllerManagerOptions
   139  	dryRun                bool
   140  	tenantIDs             prowflagutil.Strings
   141  }
   142  
   143  func (o *options) Validate() error {
   144  	for _, group := range []pkgFlagutil.OptionGroup{&o.kubernetes, &o.github, &o.config, &o.pluginsConfig, &o.controllerManager} {
   145  		if err := group.Validate(o.dryRun); err != nil {
   146  			return err
   147  		}
   148  	}
   149  
   150  	if o.oauthURL != "" {
   151  		if o.githubOAuthConfigFile == "" {
   152  			return errors.New("an OAuth URL was provided but required flag --github-oauth-config-file was unset")
   153  		}
   154  		if o.cookieSecretFile == "" {
   155  			return errors.New("an OAuth URL was provided but required flag --cookie-secret was unset")
   156  		}
   157  	}
   158  
   159  	if (o.hiddenOnly && o.showHidden) || (o.tenantIDs.Strings() != nil && (o.hiddenOnly || o.showHidden)) {
   160  		return errors.New("'--hidden-only', '--tenant-id', and '--show-hidden' are mutually exclusive, 'hidden-only' shows only hidden job, '--tenant-id' shows all jobs with matching ID and 'show-hidden' shows both hidden and non-hidden jobs")
   161  	}
   162  	return nil
   163  }
   164  
   165  func gatherOptions(fs *flag.FlagSet, args ...string) options {
   166  	var o options
   167  	fs.StringVar(&o.tideURL, "tide-url", "", "Path to tide. If empty, do not serve tide data.")
   168  	fs.StringVar(&o.hookURL, "hook-url", "", "Path to hook plugin help endpoint.")
   169  	fs.StringVar(&o.oauthURL, "oauth-url", "", "Path to deck user dashboard endpoint.")
   170  	fs.StringVar(&o.githubOAuthConfigFile, "github-oauth-config-file", "/etc/github/secret", "Path to the file containing the GitHub App Client secret.")
   171  	fs.StringVar(&o.cookieSecretFile, "cookie-secret", "", "Path to the file containing the cookie secret key.")
   172  	// use when behind a load balancer
   173  	fs.StringVar(&o.redirectHTTPTo, "redirect-http-to", "", "Host to redirect http->https to based on x-forwarded-proto == http.")
   174  	// use when behind an oauth proxy
   175  	fs.BoolVar(&o.hiddenOnly, "hidden-only", false, "Show only hidden jobs. Useful for serving hidden jobs behind an oauth proxy.")
   176  	fs.StringVar(&o.pregeneratedData, "pregenerated-data", "", "Use API output from another prow instance. Used by the prow/cmd/deck/runlocal script")
   177  	fs.BoolVar(&o.showHidden, "show-hidden", false, "Show all jobs, including hidden ones")
   178  	fs.BoolVar(&o.spyglass, "spyglass", false, "Use Prow built-in job viewing instead of Gubernator")
   179  	fs.StringVar(&o.spyglassFilesLocation, "spyglass-files-location", fmt.Sprintf("%s%s", os.Getenv("KO_DATA_PATH"), defaultSpyglassFilesLocation), "Location of the static files for spyglass.")
   180  	fs.StringVar(&o.staticFilesLocation, "static-files-location", fmt.Sprintf("%s%s", os.Getenv("KO_DATA_PATH"), defaultStaticFilesLocation), "Path to the static files")
   181  	fs.StringVar(&o.templateFilesLocation, "template-files-location", fmt.Sprintf("%s%s", os.Getenv("KO_DATA_PATH"), defaultTemplateFilesLocation), "Path to the template files")
   182  	fs.BoolVar(&o.gcsCookieAuth, "gcs-cookie-auth", false, "Use storage.cloud.google.com instead of signed URLs")
   183  	fs.BoolVar(&o.rerunCreatesJob, "rerun-creates-job", false, "Change the re-run option in Deck to actually create the job. **WARNING:** Only use this with non-public deck instances, otherwise strangers can DOS your Prow instance")
   184  	fs.BoolVar(&o.allowInsecure, "allow-insecure", false, "Allows insecure requests for CSRF and GitHub oauth.")
   185  	fs.BoolVar(&o.dryRun, "dry-run", false, "Whether or not to make mutating API calls to GitHub.")
   186  	fs.Var(&o.tenantIDs, "tenant-id", "The tenantID(s) used by the ProwJobs that should be displayed by this instance of Deck. This flag can be repeated.")
   187  	o.config.AddFlags(fs)
   188  	o.instrumentation.AddFlags(fs)
   189  	o.controllerManager.TimeoutListingProwJobsDefault = 30 * time.Second
   190  	o.controllerManager.AddFlags(fs)
   191  	o.kubernetes.AddFlags(fs)
   192  	o.github.AddFlags(fs)
   193  	o.github.AllowAnonymous = true
   194  	o.github.AllowDirectAccess = true
   195  	o.storage.AddFlags(fs)
   196  	o.pluginsConfig.AddFlags(fs)
   197  	fs.Parse(args)
   198  
   199  	return o
   200  }
   201  
   202  func staticHandlerFromDir(dir string) http.Handler {
   203  	return gziphandler.GzipHandler(handleCached(http.FileServer(http.Dir(dir))))
   204  }
   205  
   206  var (
   207  	httpRequestDuration = metrics.HttpRequestDuration("deck", 0.005, 20)
   208  	httpResponseSize    = metrics.HttpResponseSize("deck", 16384, 33554432)
   209  	traceHandler        = metrics.TraceHandler(simplifier, httpRequestDuration, httpResponseSize)
   210  )
   211  
   212  type authCfgGetter func(*prowapi.ProwJobSpec) *prowapi.RerunAuthConfig
   213  
   214  func init() {
   215  	prometheus.MustRegister(httpRequestDuration)
   216  	prometheus.MustRegister(httpResponseSize)
   217  }
   218  
   219  var simplifier = simplifypath.NewSimplifier(l("", // shadow element mimicing the root
   220  	l(""),
   221  	l("badge.svg"),
   222  	l("command-help"),
   223  	l("config"),
   224  	l("data.js"),
   225  	l("favicon.ico"),
   226  	l("github-login",
   227  		l("redirect")),
   228  	l("github-link"),
   229  	l("git-provider-link"),
   230  	l("job-history",
   231  		v("job")),
   232  	l("log"),
   233  	l("plugin-config"),
   234  	l("plugin-help"),
   235  	l("plugins"),
   236  	l("pr"),
   237  	l("pr-data.js"),
   238  	l("pr-history"),
   239  	l("prowjob"),
   240  	l("prowjobs.js"),
   241  	l("rerun"),
   242  	l("spyglass",
   243  		l("static",
   244  			simplifypath.VGreedy("path")),
   245  		l("lens",
   246  			v("lens",
   247  				v("job")),
   248  		)),
   249  	l("static",
   250  		simplifypath.VGreedy("path")),
   251  	l("tide"),
   252  	l("tide-history"),
   253  	l("tide-history.js"),
   254  	l("tide.js"),
   255  	l("view",
   256  		v("job"),
   257  		l("gs", v("bucket", l("logs", v("job", v("build"))))),
   258  	),
   259  ))
   260  
   261  // l and v keep the tree legible
   262  
   263  func l(fragment string, children ...simplifypath.Node) simplifypath.Node {
   264  	return simplifypath.L(fragment, children...)
   265  }
   266  
   267  func v(fragment string, children ...simplifypath.Node) simplifypath.Node {
   268  	return simplifypath.V(fragment, children...)
   269  }
   270  
   271  func main() {
   272  	logrusutil.ComponentInit()
   273  
   274  	o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...)
   275  	if err := o.Validate(); err != nil {
   276  		logrus.WithError(err).Fatal("Invalid options")
   277  	}
   278  
   279  	defer interrupts.WaitForGracefulShutdown()
   280  	pprof.Instrument(o.instrumentation)
   281  
   282  	// setup config agent, pod log clients etc.
   283  	configAgent, err := o.config.ConfigAgentWithAdditionals(&config.Agent{}, []func(*config.Config) error{spglassConfigDefaulting})
   284  	if err != nil {
   285  		logrus.WithError(err).Fatal("Error starting config agent.")
   286  	}
   287  	cfg := configAgent.Config
   288  	disableClustersSet := sets.New[string](cfg().DisabledClusters...)
   289  	o.kubernetes.SetDisabledClusters(disableClustersSet)
   290  
   291  	var pluginAgent *plugins.ConfigAgent
   292  	if o.pluginsConfig.PluginConfigPath != "" {
   293  		pluginAgent, err = o.pluginsConfig.PluginAgent()
   294  		if err != nil {
   295  			logrus.WithError(err).Fatal("Error loading Prow plugin config.")
   296  		}
   297  	} else {
   298  		logrus.Info("No plugins configuration was provided to deck. You must provide one to reuse /test checks for rerun")
   299  	}
   300  	metrics.ExposeMetrics("deck", cfg().PushGateway, o.instrumentation.MetricsPort)
   301  
   302  	// signal to the world that we are healthy
   303  	// this needs to be in a separate port as we don't start the
   304  	// main server with the main mux until we're ready
   305  	health := pjutil.NewHealthOnPort(o.instrumentation.HealthPort)
   306  
   307  	mux := http.NewServeMux()
   308  	// setup common handlers for local and deployed runs
   309  	mux.Handle("/static/", http.StripPrefix("/static", staticHandlerFromDir(o.staticFilesLocation)))
   310  	mux.Handle("/config", gziphandler.GzipHandler(handleConfig(cfg, logrus.WithField("handler", "/config"))))
   311  	mux.Handle("/plugin-config", gziphandler.GzipHandler(handlePluginConfig(pluginAgent, logrus.WithField("handler", "/plugin-config"))))
   312  	mux.Handle("/favicon.ico", gziphandler.GzipHandler(handleFavicon(o.staticFilesLocation, cfg)))
   313  
   314  	// Set up handlers for template pages.
   315  	mux.Handle("/pr", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "pr.html", nil)))
   316  	mux.Handle("/command-help", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "command-help.html", nil)))
   317  	mux.Handle("/plugin-help", http.RedirectHandler("/command-help", http.StatusMovedPermanently))
   318  	mux.Handle("/tide", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "tide.html", nil)))
   319  	mux.Handle("/tide-history", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "tide-history.html", nil)))
   320  	mux.Handle("/plugins", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "plugins.html", nil)))
   321  
   322  	runLocal := o.pregeneratedData != ""
   323  
   324  	var fallbackHandler func(http.ResponseWriter, *http.Request)
   325  	var pjListingClient jobs.PJListingClient
   326  	var githubClient deckGitHubClient
   327  	var gitClient git.ClientFactory
   328  	var podLogClients map[string]jobs.PodLogClient
   329  	if runLocal {
   330  		localDataHandler := staticHandlerFromDir(o.pregeneratedData)
   331  		fallbackHandler = localDataHandler.ServeHTTP
   332  
   333  		var fjc fakePjListingClientWrapper
   334  		var pjs prowapi.ProwJobList
   335  		staticPjsPath := path.Join(o.pregeneratedData, "prowjobs.json")
   336  		content, err := os.ReadFile(staticPjsPath)
   337  		if err != nil {
   338  			logrus.WithError(err).Fatal("Failed to read jobs from prowjobs.json.")
   339  		}
   340  		if err = json.Unmarshal(content, &pjs); err != nil {
   341  			logrus.WithError(err).Fatal("Failed to unmarshal jobs from prowjobs.json.")
   342  		}
   343  		fjc.pjs = &pjs
   344  		pjListingClient = &fjc
   345  	} else {
   346  		fallbackHandler = http.NotFound
   347  
   348  		restCfg, err := o.kubernetes.InfrastructureClusterConfig(false)
   349  		if err != nil {
   350  			logrus.WithError(err).Fatal("Error getting infrastructure cluster config.")
   351  		}
   352  		mgr, err := manager.New(restCfg, manager.Options{
   353  			Namespace:          cfg().ProwJobNamespace,
   354  			MetricsBindAddress: "0",
   355  			LeaderElection:     false},
   356  		)
   357  		if err != nil {
   358  			logrus.WithError(err).Fatal("Error getting manager.")
   359  		}
   360  		// Force a cache for ProwJobs
   361  		if _, err := mgr.GetCache().GetInformer(interrupts.Context(), &prowapi.ProwJob{}); err != nil {
   362  			logrus.WithError(err).Fatal("Failed to get prowjob informer")
   363  		}
   364  		go func() {
   365  			if err := mgr.Start(interrupts.Context()); err != nil {
   366  				logrus.WithError(err).Fatal("Error starting manager.")
   367  			} else {
   368  				logrus.Info("Manager stopped gracefully.")
   369  			}
   370  		}()
   371  		mgrSyncCtx, mgrSyncCtxCancel := context.WithTimeout(context.Background(), o.controllerManager.TimeoutListingProwJobs)
   372  		defer mgrSyncCtxCancel()
   373  		if synced := mgr.GetCache().WaitForCacheSync(mgrSyncCtx); !synced {
   374  			logrus.Fatal("Timed out waiting for cachesync")
   375  		}
   376  
   377  		// The watch apimachinery doesn't support restarts, so just exit the binary if a kubeconfig changes
   378  		// to make the kubelet restart us.
   379  		if err := o.kubernetes.AddKubeconfigChangeCallback(func() {
   380  			logrus.Info("Kubeconfig changed, exiting to trigger a restart")
   381  			interrupts.Terminate()
   382  		}); err != nil {
   383  			logrus.WithError(err).Fatal("Failed to register kubeconfig change callback")
   384  		}
   385  
   386  		pjListingClient = &pjListingClientWrapper{mgr.GetClient()}
   387  
   388  		// We use the GH client to resolve GH teams when determining who is permitted to rerun a job.
   389  		// When inrepoconfig is enabled, both the GitHubClient and the gitClient are used to resolve
   390  		// presubmits dynamically which we need for the PR history page.
   391  		if o.github.TokenPath != "" || o.github.AppID != "" {
   392  			githubClient, err = o.github.GitHubClient(o.dryRun)
   393  			if err != nil {
   394  				logrus.WithError(err).Fatal("Error getting GitHub client.")
   395  			}
   396  			gitClient, err = o.github.GitClientFactory("", &o.config.InRepoConfigCacheDirBase, o.dryRun, false)
   397  			if err != nil {
   398  				logrus.WithError(err).Fatal("Error getting Git client.")
   399  			}
   400  		} else {
   401  			if len(cfg().InRepoConfig.Enabled) > 0 {
   402  				logrus.Info(" --github-token-path not configured. InRepoConfigEnabled, but current configuration won't display full PR history")
   403  			}
   404  		}
   405  
   406  		buildClusterClients, err := o.kubernetes.BuildClusterClients(cfg().PodNamespace, false)
   407  		if err != nil {
   408  			logrus.WithError(err).Fatal("Error getting Kubernetes client.")
   409  		}
   410  
   411  		podLogClients = make(map[string]jobs.PodLogClient)
   412  		for clusterContext, client := range buildClusterClients {
   413  			podLogClients[clusterContext] = &podLogClient{client: client}
   414  		}
   415  	}
   416  
   417  	authCfgGetter := func(jobSpec *prowapi.ProwJobSpec) *prowapi.RerunAuthConfig {
   418  		return cfg().Deck.GetRerunAuthConfig(jobSpec)
   419  	}
   420  
   421  	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
   422  		if r.URL.Path != "/" {
   423  			fallbackHandler(w, r)
   424  			return
   425  		}
   426  		indexHandler := handleSimpleTemplate(o, cfg, "index.html", struct {
   427  			SpyglassEnabled bool
   428  			ReRunCreatesJob bool
   429  		}{
   430  			SpyglassEnabled: o.spyglass,
   431  			ReRunCreatesJob: o.rerunCreatesJob})
   432  		indexHandler(w, r)
   433  	})
   434  
   435  	ja := jobs.NewJobAgent(context.Background(), pjListingClient, o.hiddenOnly, o.showHidden, o.tenantIDs.Strings(), podLogClients, cfg)
   436  	ja.Start()
   437  
   438  	// setup prod only handlers. These handlers can work with runlocal as long
   439  	// as ja is properly mocked, more specifically pjListingClient inside ja
   440  	mux.Handle("/data.js", gziphandler.GzipHandler(handleData(ja, logrus.WithField("handler", "/data.js"))))
   441  	mux.Handle("/prowjobs.js", gziphandler.GzipHandler(handleProwJobs(ja, logrus.WithField("handler", "/prowjobs.js"))))
   442  	mux.Handle("/badge.svg", gziphandler.GzipHandler(handleBadge(ja)))
   443  	mux.Handle("/log", gziphandler.GzipHandler(handleLog(ja, logrus.WithField("handler", "/log"))))
   444  
   445  	if o.spyglass {
   446  		initSpyglass(cfg, o, mux, ja, githubClient, gitClient)
   447  	}
   448  
   449  	if runLocal {
   450  		mux = localOnlyMain(cfg, o, mux)
   451  	} else {
   452  		mux = prodOnlyMain(cfg, pluginAgent, authCfgGetter, githubClient, o, mux)
   453  	}
   454  
   455  	// signal to the world that we're ready
   456  	health.ServeReady()
   457  
   458  	// cookie secret will be used for CSRF protection and should be exactly 32 bytes
   459  	// we sometimes accept different lengths to stay backwards compatible
   460  	var csrfToken []byte
   461  	if o.cookieSecretFile != "" {
   462  		cookieSecretRaw, err := loadToken(o.cookieSecretFile)
   463  		if err != nil {
   464  			logrus.WithError(err).Fatal("Could not read cookie secret file")
   465  		}
   466  		decodedSecret, err := base64.StdEncoding.DecodeString(string(cookieSecretRaw))
   467  		if err != nil {
   468  			logrus.WithError(err).Fatal("Error decoding cookie secret")
   469  		}
   470  		if len(decodedSecret) == 32 {
   471  			csrfToken = decodedSecret
   472  		}
   473  		if len(decodedSecret) > 32 {
   474  			logrus.Warning("Cookie secret should be exactly 32 bytes. Consider truncating the existing cookie to that length")
   475  			hash := sha256.Sum256(decodedSecret)
   476  			csrfToken = hash[:]
   477  		}
   478  		if len(decodedSecret) < 32 {
   479  			if o.rerunCreatesJob {
   480  				logrus.Fatal("Cookie secret must be exactly 32 bytes")
   481  				return
   482  			}
   483  			logrus.Warning("Cookie secret should be exactly 32 bytes")
   484  		}
   485  	}
   486  
   487  	// if we allow direct reruns, we must protect against CSRF in all post requests using the cookie secret as a token
   488  	// for more information about CSRF, see https://docs.prow.k8s.io/docs/components/core/deck/csrf/
   489  	empty := prowapi.ProwJobSpec{}
   490  	if o.rerunCreatesJob && csrfToken == nil && !authCfgGetter(&empty).IsAllowAnyone() {
   491  		logrus.Fatal("Rerun creates job cannot be enabled without CSRF protection, which requires --cookie-secret to be exactly 32 bytes")
   492  		return
   493  	}
   494  
   495  	if csrfToken != nil {
   496  		CSRF := csrf.Protect(csrfToken, csrf.Path("/"), csrf.Secure(!o.allowInsecure))
   497  		logrus.WithError(http.ListenAndServe(":8080", CSRF(traceHandler(mux)))).Fatal("ListenAndServe returned.")
   498  		return
   499  	}
   500  	// setup done, actually start the server
   501  	server := &http.Server{Addr: ":8080", Handler: traceHandler(mux)}
   502  	interrupts.ListenAndServe(server, 5*time.Second)
   503  }
   504  
   505  // localOnlyMain contains logic used only when running locally, and is mutually exclusive with
   506  // prodOnlyMain.
   507  func localOnlyMain(cfg config.Getter, o options, mux *http.ServeMux) *http.ServeMux {
   508  	mux.Handle("/github-login", gziphandler.GzipHandler(handleSimpleTemplate(o, cfg, "github-login.html", nil)))
   509  
   510  	return mux
   511  }
   512  
   513  type podLogClient struct {
   514  	client corev1.PodInterface
   515  }
   516  
   517  func (c *podLogClient) GetLogs(name, container string) ([]byte, error) {
   518  	reader, err := c.client.GetLogs(name, &coreapi.PodLogOptions{Container: container}).Stream(context.TODO())
   519  	if err != nil {
   520  		return nil, err
   521  	}
   522  	defer reader.Close()
   523  	return stdio.ReadAll(reader)
   524  }
   525  
   526  type pjListingClientWrapper struct {
   527  	reader ctrlruntimeclient.Reader
   528  }
   529  
   530  func (w *pjListingClientWrapper) List(
   531  	ctx context.Context,
   532  	pjl *prowapi.ProwJobList,
   533  	opts ...ctrlruntimeclient.ListOption) error {
   534  	return w.reader.List(ctx, pjl, opts...)
   535  }
   536  
   537  // fakePjListingClientWrapper implements pjListingClient for runlocal
   538  type fakePjListingClientWrapper struct {
   539  	pjs *prowapi.ProwJobList
   540  }
   541  
   542  func (fjc *fakePjListingClientWrapper) List(ctx context.Context, pjl *prowapi.ProwJobList, lo ...ctrlruntimeclient.ListOption) error {
   543  	*pjl = *fjc.pjs
   544  	return nil
   545  }
   546  
   547  // prodOnlyMain contains logic only used when running deployed, not locally
   548  func prodOnlyMain(cfg config.Getter, pluginAgent *plugins.ConfigAgent, authCfgGetter authCfgGetter, githubClient deckGitHubClient, o options, mux *http.ServeMux) *http.ServeMux {
   549  	prowJobClient, err := o.kubernetes.ProwJobClient(cfg().ProwJobNamespace, false)
   550  	if err != nil {
   551  		logrus.WithError(err).Fatal("Error getting ProwJob client for infrastructure cluster.")
   552  	}
   553  
   554  	// prowjob still needs prowJobClient for retrieving log
   555  	mux.Handle("/prowjob", gziphandler.GzipHandler(handleProwJob(prowJobClient, logrus.WithField("handler", "/prowjob"))))
   556  
   557  	if o.hookURL != "" {
   558  		mux.Handle("/plugin-help.js",
   559  			gziphandler.GzipHandler(handlePluginHelp(newHelpAgent(o.hookURL), logrus.WithField("handler", "/plugin-help.js"))))
   560  	}
   561  
   562  	// tide could potentially be mocked by static data
   563  	if o.tideURL != "" {
   564  		ta := &tideAgent{
   565  			log:  logrus.WithField("agent", "tide"),
   566  			path: o.tideURL,
   567  			updatePeriod: func() time.Duration {
   568  				return cfg().Deck.TideUpdatePeriod.Duration
   569  			},
   570  			hiddenRepos: func() []string {
   571  				return cfg().Deck.HiddenRepos
   572  			},
   573  			hiddenOnly: o.hiddenOnly,
   574  			showHidden: o.showHidden,
   575  			tenantIDs:  sets.New[string](o.tenantIDs.Strings()...),
   576  			cfg:        cfg,
   577  		}
   578  		go func() {
   579  			ta.start()
   580  			mux.Handle("/tide.js", gziphandler.GzipHandler(handleTidePools(cfg, ta, logrus.WithField("handler", "/tide.js"))))
   581  			mux.Handle("/tide-history.js", gziphandler.GzipHandler(handleTideHistory(ta, logrus.WithField("handler", "/tide-history.js"))))
   582  		}()
   583  	}
   584  
   585  	secure := !o.allowInsecure
   586  
   587  	// Handles link to github
   588  	mux.HandleFunc("/github-link", HandleGitHubLink(o.github.Host, secure))
   589  	mux.HandleFunc("/git-provider-link", HandleGitProviderLink(o.github.Host, secure))
   590  
   591  	// Enable Git OAuth feature if oauthURL is provided.
   592  	var goa *githuboauth.Agent
   593  	if o.oauthURL != "" {
   594  		githubOAuthConfigRaw, err := loadToken(o.githubOAuthConfigFile)
   595  		if err != nil {
   596  			logrus.WithError(err).Fatal("Could not read github oauth config file.")
   597  		}
   598  
   599  		cookieSecretRaw, err := loadToken(o.cookieSecretFile)
   600  		if err != nil {
   601  			logrus.WithError(err).Fatal("Could not read cookie secret file.")
   602  		}
   603  
   604  		var githubOAuthConfig githuboauth.Config
   605  		if err := yaml.Unmarshal(githubOAuthConfigRaw, &githubOAuthConfig); err != nil {
   606  			logrus.WithError(err).Fatal("Error unmarshalling github oauth config")
   607  		}
   608  		if !isValidatedGitOAuthConfig(&githubOAuthConfig) {
   609  			logrus.Fatal("Error invalid github oauth config")
   610  		}
   611  
   612  		decodedSecret, err := base64.StdEncoding.DecodeString(string(cookieSecretRaw))
   613  		if err != nil {
   614  			logrus.WithError(err).Fatal("Error decoding cookie secret")
   615  		}
   616  		if len(decodedSecret) == 0 {
   617  			logrus.Fatal("Cookie secret should not be empty")
   618  		}
   619  		cookie := sessions.NewCookieStore(decodedSecret)
   620  		githubOAuthConfig.InitGitHubOAuthConfig(cookie)
   621  
   622  		goa = githuboauth.NewAgent(&githubOAuthConfig, logrus.WithField("client", "githuboauth"))
   623  		oauthClient := githuboauth.NewClient(&oauth2.Config{
   624  			ClientID:     githubOAuthConfig.ClientID,
   625  			ClientSecret: githubOAuthConfig.ClientSecret,
   626  			RedirectURL:  githubOAuthConfig.RedirectURL,
   627  			Scopes:       githubOAuthConfig.Scopes,
   628  			Endpoint: oauth2.Endpoint{
   629  				AuthURL:  fmt.Sprintf("https://%s/login/oauth/authorize", o.github.Host),
   630  				TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", o.github.Host),
   631  			},
   632  		})
   633  
   634  		repos := sets.List(cfg().AllRepos)
   635  
   636  		prStatusAgent := prstatus.NewDashboardAgent(repos, &githubOAuthConfig, logrus.WithField("client", "pr-status"))
   637  
   638  		clientCreator := func(accessToken string) (prstatus.GitHubClient, error) {
   639  			return o.github.GitHubClientWithAccessToken(accessToken)
   640  		}
   641  		mux.Handle("/pr-data.js", handleNotCached(
   642  			prStatusAgent.HandlePrStatus(prStatusAgent, clientCreator)))
   643  		// Handles login request.
   644  		mux.Handle("/github-login", goa.HandleLogin(oauthClient, secure))
   645  		// Handles redirect from GitHub OAuth server.
   646  		mux.Handle("/github-login/redirect", goa.HandleRedirect(oauthClient, githuboauth.NewAuthenticatedUserIdentifier(&o.github), secure))
   647  	}
   648  
   649  	mux.Handle("/rerun", gziphandler.GzipHandler(handleRerun(cfg, prowJobClient, o.rerunCreatesJob, authCfgGetter, goa, githuboauth.NewAuthenticatedUserIdentifier(&o.github), githubClient, pluginAgent, logrus.WithField("handler", "/rerun"))))
   650  	mux.Handle("/abort", gziphandler.GzipHandler(handleAbort(prowJobClient, authCfgGetter, goa, githuboauth.NewAuthenticatedUserIdentifier(&o.github), githubClient, pluginAgent, logrus.WithField("handler", "/abort"))))
   651  
   652  	// optionally inject http->https redirect handler when behind loadbalancer
   653  	if o.redirectHTTPTo != "" {
   654  		redirectMux := http.NewServeMux()
   655  		redirectMux.Handle("/", func(oldMux *http.ServeMux, host string) http.HandlerFunc {
   656  			return func(w http.ResponseWriter, r *http.Request) {
   657  				if r.Header.Get("x-forwarded-proto") == "http" {
   658  					redirectURL, err := url.Parse(r.URL.String())
   659  					if err != nil {
   660  						logrus.Errorf("Failed to parse URL: %s.", r.URL.String())
   661  						http.Error(w, "Failed to perform https redirect.", http.StatusInternalServerError)
   662  						return
   663  					}
   664  					redirectURL.Scheme = "https"
   665  					redirectURL.Host = host
   666  					http.Redirect(w, r, redirectURL.String(), http.StatusMovedPermanently)
   667  				} else {
   668  					oldMux.ServeHTTP(w, r)
   669  				}
   670  			}
   671  		}(mux, o.redirectHTTPTo))
   672  		mux = redirectMux
   673  	}
   674  
   675  	return mux
   676  }
   677  
   678  func initSpyglass(cfg config.Getter, o options, mux *http.ServeMux, ja *jobs.JobAgent, gitHubClient deckGitHubClient, gitClient git.ClientFactory) {
   679  	ctx := context.TODO()
   680  	opener, err := io.NewOpener(ctx, o.storage.GCSCredentialsFile, o.storage.S3CredentialsFile)
   681  	if err != nil {
   682  		logrus.WithError(err).Fatal("Error creating opener")
   683  	}
   684  	sg := spyglass.New(ctx, ja, cfg, opener, o.gcsCookieAuth)
   685  	sg.Start()
   686  
   687  	mux.Handle("/spyglass/static/", http.StripPrefix("/spyglass/static", staticHandlerFromDir(o.spyglassFilesLocation)))
   688  	mux.Handle("/spyglass/lens/", gziphandler.GzipHandler(http.StripPrefix("/spyglass/lens/", handleArtifactView(o, sg, cfg))))
   689  	mux.Handle("/view/", gziphandler.GzipHandler(handleRequestJobViews(sg, cfg, o, logrus.WithField("handler", "/view"))))
   690  	mux.Handle("/job-history/", gziphandler.GzipHandler(handleJobHistory(o, cfg, opener, logrus.WithField("handler", "/job-history"))))
   691  	mux.Handle("/pr-history/", gziphandler.GzipHandler(handlePRHistory(o, cfg, opener, gitHubClient, gitClient, logrus.WithField("handler", "/pr-history"))))
   692  	if err := initLocalLensHandler(cfg, o, sg); err != nil {
   693  		logrus.WithError(err).Fatal("Failed to initialize local lens handler")
   694  	}
   695  }
   696  
   697  func initLocalLensHandler(cfg config.Getter, o options, sg *spyglass.Spyglass) error {
   698  	var localLenses []common.LensWithConfiguration
   699  	for _, lfc := range cfg().Deck.Spyglass.Lenses {
   700  		if !strings.HasPrefix(strings.TrimPrefix(lfc.RemoteConfig.Endpoint, "http://"), spyglassLocalLensListenerAddr) {
   701  			continue
   702  		}
   703  
   704  		lens, err := lenses.GetLens(lfc.Lens.Name)
   705  		if err != nil {
   706  			return fmt.Errorf("couldn't find local lens %q: %w", lfc.Lens.Name, err)
   707  		}
   708  		localLenses = append(localLenses, common.LensWithConfiguration{
   709  			Config: common.LensOpt{
   710  				LensResourcesDir: lenses.ResourceDirForLens(o.spyglassFilesLocation, lfc.Lens.Name),
   711  				LensName:         lfc.Lens.Name,
   712  				LensTitle:        lfc.RemoteConfig.Title,
   713  			},
   714  			Lens: lens,
   715  		})
   716  	}
   717  
   718  	lensServer, err := common.NewLensServer(spyglassLocalLensListenerAddr, sg.JobAgent, sg.StorageArtifactFetcher, sg.PodLogArtifactFetcher, cfg, localLenses)
   719  	if err != nil {
   720  		return fmt.Errorf("constructing local lens server: %w", err)
   721  	}
   722  
   723  	interrupts.ListenAndServe(lensServer, 5*time.Second)
   724  	return nil
   725  }
   726  
   727  func loadToken(file string) ([]byte, error) {
   728  	raw, err := os.ReadFile(file)
   729  	if err != nil {
   730  		return []byte{}, err
   731  	}
   732  	return bytes.TrimSpace(raw), nil
   733  }
   734  
   735  func handleCached(next http.Handler) http.Handler {
   736  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   737  		// Since all static assets have a cache busting parameter
   738  		// attached, which forces a reload whenever Deck is updated,
   739  		// we can send strong cache headers.
   740  		w.Header().Set("Cache-Control", "public, max-age=315360000") // 315360000 is 10 years, i.e. forever
   741  		next.ServeHTTP(w, r)
   742  	})
   743  }
   744  
   745  func setHeadersNoCaching(w http.ResponseWriter) {
   746  	// This follows the "ignore IE6, but allow prehistoric HTTP/1.0-only proxies"
   747  	// recommendation from https://stackoverflow.com/a/2068407 to prevent clients
   748  	// from caching the HTTP response.
   749  	w.Header().Set("Cache-Control", "no-store, must-revalidate")
   750  	w.Header().Set("Expires", "0")
   751  }
   752  
   753  func writeJSONResponse(w http.ResponseWriter, r *http.Request, d []byte) {
   754  	// If we have a "var" query, then write out "var value = {...};".
   755  	// Otherwise, just write out the JSON.
   756  	if v := r.URL.Query().Get("var"); v != "" {
   757  		w.Header().Set("Content-Type", "application/javascript")
   758  		fmt.Fprintf(w, "var %s = %s;", v, string(d))
   759  	} else {
   760  		w.Header().Set("Content-Type", "application/json")
   761  		fmt.Fprint(w, string(d))
   762  	}
   763  }
   764  
   765  func handleNotCached(next http.Handler) http.HandlerFunc {
   766  	return func(w http.ResponseWriter, r *http.Request) {
   767  		setHeadersNoCaching(w)
   768  		next.ServeHTTP(w, r)
   769  	}
   770  }
   771  
   772  func handleProwJobs(ja *jobs.JobAgent, log *logrus.Entry) http.HandlerFunc {
   773  	return func(w http.ResponseWriter, r *http.Request) {
   774  		setHeadersNoCaching(w)
   775  		jobs := ja.ProwJobs()
   776  		omit := r.URL.Query().Get("omit")
   777  
   778  		if set := sets.New[string](strings.Split(omit, ",")...); set.Len() > 0 {
   779  			for i := range jobs {
   780  				jobs[i].ManagedFields = nil
   781  				if set.Has(Annotations) {
   782  					jobs[i].Annotations = nil
   783  				}
   784  				if set.Has(Labels) {
   785  					jobs[i].Labels = nil
   786  				}
   787  				if set.Has(DecorationConfig) {
   788  					jobs[i].Spec.DecorationConfig = nil
   789  				}
   790  				if set.Has(PodSpec) {
   791  					// when we omit the podspec, we don't set it completely to nil
   792  					// instead, we set it to a new podspec that just has an empty container for each container that exists in the actual podspec
   793  					// this is so we can determine how many containers there are for a given prowjob without fetching all of the podspec details
   794  					// this is necessary for prow/cmd/deck/static/prow/pkg.ts to determine whether the logIcon should link to a log endpoint or to spyglass
   795  					if jobs[i].Spec.PodSpec != nil {
   796  						emptyContainers := []coreapi.Container{}
   797  						for range jobs[i].Spec.PodSpec.Containers {
   798  							emptyContainers = append(emptyContainers, coreapi.Container{})
   799  						}
   800  						jobs[i].Spec.PodSpec = &coreapi.PodSpec{
   801  							Containers: emptyContainers,
   802  						}
   803  					}
   804  				}
   805  			}
   806  		}
   807  
   808  		jd, err := json.Marshal(struct {
   809  			Items []prowapi.ProwJob `json:"items"`
   810  		}{jobs})
   811  		if err != nil {
   812  			log.WithError(err).Error("Error marshaling jobs.")
   813  			jd = []byte("{}")
   814  		}
   815  		writeJSONResponse(w, r, jd)
   816  	}
   817  }
   818  
   819  func handleData(ja *jobs.JobAgent, log *logrus.Entry) http.HandlerFunc {
   820  	return func(w http.ResponseWriter, r *http.Request) {
   821  		setHeadersNoCaching(w)
   822  		jobs := ja.Jobs()
   823  		jd, err := json.Marshal(jobs)
   824  		if err != nil {
   825  			log.WithError(err).Error("Error marshaling jobs.")
   826  			jd = []byte("[]")
   827  		}
   828  		writeJSONResponse(w, r, jd)
   829  	}
   830  }
   831  
   832  // handleBadge handles requests to get a badge for one or more jobs
   833  // The url must look like this, where `jobs` is a comma-separated
   834  // list of globs:
   835  //
   836  // /badge.svg?jobs=<glob>[,<glob2>]
   837  //
   838  // Examples:
   839  // - /badge.svg?jobs=pull-kubernetes-bazel-build
   840  // - /badge.svg?jobs=pull-kubernetes-*
   841  // - /badge.svg?jobs=pull-kubernetes-e2e*,pull-kubernetes-*,pull-kubernetes-integration-*
   842  func handleBadge(ja *jobs.JobAgent) http.HandlerFunc {
   843  	return func(w http.ResponseWriter, r *http.Request) {
   844  		setHeadersNoCaching(w)
   845  		wantJobs := r.URL.Query().Get("jobs")
   846  		if wantJobs == "" {
   847  			http.Error(w, "missing jobs query parameter", http.StatusBadRequest)
   848  			return
   849  		}
   850  		w.Header().Set("Content-Type", "image/svg+xml")
   851  
   852  		allJobs := ja.ProwJobs()
   853  		_, _, svg := renderBadge(pickLatestJobs(allJobs, wantJobs))
   854  		w.Write(svg)
   855  	}
   856  }
   857  
   858  // handleJobHistory handles requests to get the history of a given job
   859  // There is also a new format since we started supporting other storageProvider
   860  // like s3 and not only GCS.
   861  // The url must look like one of these for presubmits:
   862  //
   863  // - /job-history/<gcs-bucket-name>/pr-logs/directory/<job-name>
   864  // - /job-history/<storage-provider>/<bucket-name>/pr-logs/directory/<job-name>
   865  //
   866  // Example:
   867  // - /job-history/kubernetes-jenkins/pr-logs/directory/pull-test-infra-verify-gofmt
   868  // - /job-history/gs/kubernetes-jenkins/pr-logs/directory/pull-test-infra-verify-gofmt
   869  //
   870  // For periodics or postsubmits, the url must look like one of these:
   871  //
   872  // - /job-history/<gcs-bucket-name>/logs/<job-name>
   873  // - /job-history/<storage-provider>/<bucket-name>/logs/<job-name>
   874  //
   875  // Example:
   876  // - /job-history/kubernetes-jenkins/logs/ci-kubernetes-e2e-prow-canary
   877  // - /job-history/gs/kubernetes-jenkins/logs/ci-kubernetes-e2e-prow-canary
   878  func handleJobHistory(o options, cfg config.Getter, opener io.Opener, log *logrus.Entry) http.HandlerFunc {
   879  	return func(w http.ResponseWriter, r *http.Request) {
   880  		setHeadersNoCaching(w)
   881  		tmpl, err := getJobHistory(r.Context(), r.URL, cfg, opener)
   882  		if err != nil {
   883  			msg := fmt.Sprintf("failed to get job history: %v", err)
   884  			if shouldLogHTTPErrors(err) {
   885  				log.WithField("url", r.URL.String()).WithError(err).Warn(msg)
   886  			} else {
   887  				log.WithField("url", r.URL.String()).WithError(err).Debug(msg)
   888  			}
   889  			http.Error(w, msg, httpStatusForError(err))
   890  			return
   891  		}
   892  		for idx, build := range tmpl.Builds {
   893  			tmpl.Builds[idx].Result = strings.ToUpper(build.Result)
   894  
   895  		}
   896  		handleSimpleTemplate(o, cfg, "job-history.html", tmpl)(w, r)
   897  	}
   898  }
   899  
   900  // handlePRHistory handles requests to get the test history if a given PR
   901  // The url must look like this:
   902  //
   903  // /pr-history?org=<org>&repo=<repo>&pr=<pr number>
   904  func handlePRHistory(o options, cfg config.Getter, opener io.Opener, gitHubClient deckGitHubClient, gitClient git.ClientFactory, log *logrus.Entry) http.HandlerFunc {
   905  	return func(w http.ResponseWriter, r *http.Request) {
   906  		setHeadersNoCaching(w)
   907  		tmpl, err := getPRHistory(r.Context(), r.URL, cfg(), opener, gitHubClient, gitClient, o.github.Host)
   908  		if err != nil {
   909  			msg := fmt.Sprintf("failed to get PR history: %v", err)
   910  			log.WithField("url", r.URL.String()).Info(msg)
   911  			http.Error(w, msg, http.StatusInternalServerError)
   912  			return
   913  		}
   914  		for idx := range tmpl.Jobs {
   915  			for jdx, build := range tmpl.Jobs[idx].Builds {
   916  				tmpl.Jobs[idx].Builds[jdx].Result = strings.ToUpper(build.Result)
   917  			}
   918  		}
   919  		handleSimpleTemplate(o, cfg, "pr-history.html", tmpl)(w, r)
   920  	}
   921  }
   922  
   923  // handleRequestJobViews handles requests to get all available artifact views for a given job.
   924  // The url must specify a storage key type, such as "prowjob" or "gcs":
   925  //
   926  // /view/<key-type>/<key>
   927  //
   928  // Examples:
   929  // - /view/gcs/kubernetes-jenkins/pr-logs/pull/test-infra/9557/pull-test-infra-verify-gofmt/15688/
   930  // - /view/prowjob/echo-test/1046875594609922048
   931  func handleRequestJobViews(sg *spyglass.Spyglass, cfg config.Getter, o options, log *logrus.Entry) http.HandlerFunc {
   932  	return func(w http.ResponseWriter, r *http.Request) {
   933  		start := time.Now()
   934  		setHeadersNoCaching(w)
   935  		src := strings.TrimPrefix(r.URL.Path, "/view/")
   936  
   937  		csrfToken := csrf.Token(r)
   938  		page, err := renderSpyglass(r.Context(), sg, cfg, src, o, csrfToken, log)
   939  		if err != nil {
   940  			msg := fmt.Sprintf("error rendering spyglass page: %v", err)
   941  			if shouldLogHTTPErrors(err) {
   942  				log.WithError(err).Debug(msg)
   943  			}
   944  			http.Error(w, msg, httpStatusForError(err))
   945  			return
   946  		}
   947  
   948  		fmt.Fprint(w, page)
   949  		elapsed := time.Since(start)
   950  		log.WithFields(logrus.Fields{
   951  			"duration": elapsed.String(),
   952  			"endpoint": r.URL.Path,
   953  			"source":   src,
   954  		}).Info("Loading view completed.")
   955  	}
   956  }
   957  
   958  // renderSpyglass returns a pre-rendered Spyglass page from the given source string
   959  func renderSpyglass(ctx context.Context, sg *spyglass.Spyglass, cfg config.Getter, src string, o options, csrfToken string, log *logrus.Entry) (string, error) {
   960  	renderStart := time.Now()
   961  
   962  	src = strings.TrimSuffix(src, "/")
   963  	realPath, err := sg.ResolveSymlink(src)
   964  	if err != nil {
   965  		return "", fmt.Errorf("error when resolving real path %s: %w", src, err)
   966  	}
   967  	src = realPath
   968  	artifactNames, err := sg.ListArtifacts(ctx, src)
   969  	if err != nil {
   970  		return "", fmt.Errorf("error listing artifacts: %w", err)
   971  	}
   972  	if len(artifactNames) == 0 {
   973  		log.Infof("found no artifacts for %s", src)
   974  	}
   975  
   976  	regexCache := cfg().Deck.Spyglass.RegexCache
   977  	lensCache := map[int][]string{}
   978  	var lensIndexes []int
   979  lensesLoop:
   980  	for i, lfc := range cfg().Deck.Spyglass.Lenses {
   981  		matches := sets.Set[string]{}
   982  		for _, re := range lfc.RequiredFiles {
   983  			found := false
   984  			for _, a := range artifactNames {
   985  				if regexCache[re].MatchString(a) {
   986  					matches.Insert(a)
   987  					found = true
   988  				}
   989  			}
   990  			if !found {
   991  				continue lensesLoop
   992  			}
   993  		}
   994  
   995  		for _, re := range lfc.OptionalFiles {
   996  			for _, a := range artifactNames {
   997  				if regexCache[re].MatchString(a) {
   998  					matches.Insert(a)
   999  				}
  1000  			}
  1001  		}
  1002  
  1003  		lensCache[i] = sets.List(matches)
  1004  		lensIndexes = append(lensIndexes, i)
  1005  	}
  1006  
  1007  	lensIndexes, ls := sg.Lenses(lensIndexes)
  1008  
  1009  	jobHistLink := ""
  1010  	jobPath, err := sg.JobPath(src)
  1011  	if err == nil {
  1012  		jobHistLink = path.Join("/job-history", jobPath)
  1013  	}
  1014  
  1015  	var prowJobLink string
  1016  	prowJob, prowJobName, prowJobState, err := sg.ProwJob(src)
  1017  	if err == nil {
  1018  		if prowJobName != "" {
  1019  			u, err := url.Parse("/prowjob")
  1020  			if err != nil {
  1021  				return "", fmt.Errorf("error parsing prowjob path: %w", err)
  1022  			}
  1023  			query := url.Values{}
  1024  			query.Set("prowjob", prowJobName)
  1025  			u.RawQuery = query.Encode()
  1026  			prowJobLink = u.String()
  1027  		}
  1028  	} else {
  1029  		log.WithError(err).Warningf("Error getting ProwJob name for source %q.", src)
  1030  	}
  1031  
  1032  	prHistLink := ""
  1033  	org, repo, number, err := sg.RunToPR(src)
  1034  	if err == nil && !cfg().Deck.Spyglass.HidePRHistLink {
  1035  		prHistLinkTemplate := cfg().Deck.Spyglass.PRHistLinkTemplate
  1036  		if prHistLinkTemplate == "" { // Not defined globally
  1037  			prHistLinkTemplate = defaultPRHistLinkTemplate
  1038  		}
  1039  		prHistLink, err = prHistLinkFromTemplate(prHistLinkTemplate, org, repo, number)
  1040  		if err != nil {
  1041  			return "", err
  1042  		}
  1043  	}
  1044  
  1045  	artifactsLink := ""
  1046  	bucket := ""
  1047  	if jobPath != "" && (strings.HasPrefix(jobPath, providers.GS) || strings.HasPrefix(jobPath, providers.S3)) {
  1048  		bucket = strings.Split(jobPath, "/")[1] // The provider (gs) will be in index 0, followed by the bucket name
  1049  	}
  1050  	gcswebPrefix := cfg().Deck.Spyglass.GetGCSBrowserPrefix(org, repo, bucket)
  1051  	if gcswebPrefix != "" {
  1052  		runPath, err := sg.RunPath(src)
  1053  		if err == nil {
  1054  			artifactsLink = gcswebPrefix + runPath
  1055  			// gcsweb wants us to end URLs with a trailing slash
  1056  			if !strings.HasSuffix(artifactsLink, "/") {
  1057  				artifactsLink += "/"
  1058  			}
  1059  		}
  1060  	}
  1061  
  1062  	jobName, buildID, err := common.KeyToJob(src)
  1063  	if err != nil {
  1064  		return "", fmt.Errorf("error determining jobName / buildID: %w", err)
  1065  	}
  1066  
  1067  	prLink := ""
  1068  	j, err := sg.JobAgent.GetProwJob(jobName, buildID)
  1069  	if err == nil && j.Spec.Refs != nil && len(j.Spec.Refs.Pulls) > 0 {
  1070  		prLink = j.Spec.Refs.Pulls[0].Link
  1071  	}
  1072  
  1073  	announcement := ""
  1074  	if cfg().Deck.Spyglass.Announcement != "" {
  1075  		announcementTmpl, err := template.New("announcement").Parse(cfg().Deck.Spyglass.Announcement)
  1076  		if err != nil {
  1077  			return "", fmt.Errorf("error parsing announcement template: %w", err)
  1078  		}
  1079  		runPath, err := sg.RunPath(src)
  1080  		if err != nil {
  1081  			runPath = ""
  1082  		}
  1083  		var announcementBuf bytes.Buffer
  1084  		err = announcementTmpl.Execute(&announcementBuf, struct {
  1085  			ArtifactPath string
  1086  		}{
  1087  			ArtifactPath: runPath,
  1088  		})
  1089  		if err != nil {
  1090  			return "", fmt.Errorf("error executing announcement template: %w", err)
  1091  		}
  1092  		announcement = announcementBuf.String()
  1093  	}
  1094  
  1095  	tgLink, err := sg.TestGridLink(src)
  1096  	if err != nil {
  1097  		tgLink = ""
  1098  	}
  1099  
  1100  	extraLinks, err := sg.ExtraLinks(ctx, src)
  1101  	if err != nil {
  1102  		log.WithError(err).WithField("page", src).Warn("Failed to fetch extra links.")
  1103  		// This is annoying but not a fatal error, should keep going so that the
  1104  		// other infos fetched above are displayed to user.
  1105  		extraLinks = nil
  1106  	}
  1107  
  1108  	var viewBuf bytes.Buffer
  1109  	type spyglassTemplate struct {
  1110  		Lenses          map[int]spyglass.LensConfig
  1111  		LensIndexes     []int
  1112  		Source          string
  1113  		LensArtifacts   map[int][]string
  1114  		JobHistLink     string
  1115  		ProwJobLink     string
  1116  		ArtifactsLink   string
  1117  		PRHistLink      string
  1118  		Announcement    template.HTML
  1119  		TestgridLink    string
  1120  		JobName         string
  1121  		BuildID         string
  1122  		PRLink          string
  1123  		ExtraLinks      []spyglass.ExtraLink
  1124  		ReRunCreatesJob bool
  1125  		ProwJob         string
  1126  		ProwJobName     string
  1127  		ProwJobState    string
  1128  	}
  1129  	sTmpl := spyglassTemplate{
  1130  		Lenses:          ls,
  1131  		LensIndexes:     lensIndexes,
  1132  		Source:          src,
  1133  		LensArtifacts:   lensCache,
  1134  		JobHistLink:     jobHistLink,
  1135  		ProwJobLink:     prowJobLink,
  1136  		ArtifactsLink:   artifactsLink,
  1137  		PRHistLink:      prHistLink,
  1138  		Announcement:    template.HTML(announcement),
  1139  		TestgridLink:    tgLink,
  1140  		JobName:         jobName,
  1141  		BuildID:         buildID,
  1142  		PRLink:          prLink,
  1143  		ExtraLinks:      extraLinks,
  1144  		ReRunCreatesJob: o.rerunCreatesJob,
  1145  		ProwJob:         prowJob,
  1146  		ProwJobName:     prowJobName,
  1147  		ProwJobState:    string(prowJobState),
  1148  	}
  1149  	t := template.New("spyglass.html")
  1150  
  1151  	if _, err := prepareBaseTemplate(o, cfg, csrfToken, t); err != nil {
  1152  		return "", fmt.Errorf("error preparing base template: %w", err)
  1153  	}
  1154  	t, err = t.ParseFiles(path.Join(o.templateFilesLocation, "spyglass.html"))
  1155  	if err != nil {
  1156  		return "", fmt.Errorf("error parsing template: %w", err)
  1157  	}
  1158  
  1159  	if err = t.Execute(&viewBuf, sTmpl); err != nil {
  1160  		return "", fmt.Errorf("error rendering template: %w", err)
  1161  	}
  1162  	renderElapsed := time.Since(renderStart)
  1163  	log.WithFields(logrus.Fields{
  1164  		"duration": renderElapsed.String(),
  1165  		"source":   src,
  1166  	}).Info("Rendered spyglass views.")
  1167  	return viewBuf.String(), nil
  1168  }
  1169  
  1170  func prHistLinkFromTemplate(prHistLinkTemplate, org, repo string, number int) (string, error) {
  1171  	tmp, err := template.New("t").Parse(prHistLinkTemplate)
  1172  	if err != nil {
  1173  		return "", fmt.Errorf("failed compiling template %q: %v", prHistLinkTemplate, err)
  1174  	}
  1175  	tmpBuff := bytes.Buffer{}
  1176  	if err = tmp.Execute(&tmpBuff, struct {
  1177  		Org    string
  1178  		Repo   string
  1179  		Number int
  1180  	}{org, repo, number}); err != nil {
  1181  		return "", fmt.Errorf("failed executing template %q: %v", prHistLinkTemplate, err)
  1182  	}
  1183  
  1184  	return tmpBuff.String(), nil
  1185  }
  1186  
  1187  // handleArtifactView handles requests to load a single view for a job. This is what viewers
  1188  // will use to call back to themselves.
  1189  // Query params:
  1190  // - name: required, specifies the name of the viewer to load
  1191  // - src: required, specifies the job source from which to fetch artifacts
  1192  func handleArtifactView(o options, sg *spyglass.Spyglass, cfg config.Getter) http.HandlerFunc {
  1193  	return func(w http.ResponseWriter, r *http.Request) {
  1194  		setHeadersNoCaching(w)
  1195  		pathSegments := strings.Split(r.URL.Path, "/")
  1196  		if len(pathSegments) != 2 {
  1197  			http.NotFound(w, r)
  1198  			return
  1199  		}
  1200  		lensName := pathSegments[0]
  1201  		resource := pathSegments[1]
  1202  
  1203  		var lens *config.LensFileConfig
  1204  		for _, configLens := range cfg().Deck.Spyglass.Lenses {
  1205  			if configLens.Lens.Name == lensName {
  1206  
  1207  				// Directly followed by break, so this is ok
  1208  				// nolint: exportloopref
  1209  				lens = &configLens
  1210  				break
  1211  			}
  1212  		}
  1213  		if lens == nil {
  1214  			http.Error(w, fmt.Sprintf("No such template: %s", lensName), http.StatusNotFound)
  1215  			return
  1216  		}
  1217  
  1218  		reqString := r.URL.Query().Get("req")
  1219  		var request spyglass.LensRequest
  1220  		if err := json.Unmarshal([]byte(reqString), &request); err != nil {
  1221  			http.Error(w, fmt.Sprintf("Failed to parse request: %v", err), http.StatusBadRequest)
  1222  			return
  1223  		}
  1224  		if err := validateStoragePath(cfg, request.Source); err != nil {
  1225  			http.Error(w, fmt.Sprintf("Failed to process request: %v", err), httpStatusForError(err))
  1226  			return
  1227  		}
  1228  
  1229  		handleRemoteLens(*lens, w, r, resource, request)
  1230  	}
  1231  }
  1232  
  1233  func handleRemoteLens(lens config.LensFileConfig, w http.ResponseWriter, r *http.Request, resource string, request spyglass.LensRequest) {
  1234  	var requestType spyglassapi.RequestAction
  1235  	switch resource {
  1236  	case "iframe":
  1237  		requestType = spyglassapi.RequestActionInitial
  1238  	case "rerender":
  1239  		requestType = spyglassapi.RequestActionRerender
  1240  	case "callback":
  1241  		requestType = spyglassapi.RequestActionCallBack
  1242  	default:
  1243  		http.NotFound(w, r)
  1244  		return
  1245  	}
  1246  
  1247  	var data string
  1248  	if requestType != spyglassapi.RequestActionInitial {
  1249  		dataBytes, err := stdio.ReadAll(r.Body)
  1250  		if err != nil {
  1251  			http.Error(w, fmt.Sprintf("Failed to read body: %v", err), http.StatusInternalServerError)
  1252  			return
  1253  		}
  1254  		data = string(dataBytes)
  1255  	}
  1256  
  1257  	lensRequest := spyglassapi.LensRequest{
  1258  		Action:         requestType,
  1259  		Data:           data,
  1260  		Config:         lens.Lens.Config,
  1261  		ResourceRoot:   "/spyglass/static/" + lens.Lens.Name + "/",
  1262  		Artifacts:      request.Artifacts,
  1263  		ArtifactSource: request.Source,
  1264  		LensIndex:      request.Index,
  1265  	}
  1266  	serializedRequest, err := json.Marshal(lensRequest)
  1267  	if err != nil {
  1268  		http.Error(w, fmt.Sprintf("failed to marshal request to lens backend: %v", err), http.StatusInternalServerError)
  1269  		return
  1270  	}
  1271  
  1272  	(&httputil.ReverseProxy{
  1273  		Director: func(r *http.Request) {
  1274  			r.URL = lens.RemoteConfig.ParsedEndpoint
  1275  			r.ContentLength = int64(len(serializedRequest))
  1276  			r.Body = stdio.NopCloser(bytes.NewBuffer(serializedRequest))
  1277  		},
  1278  	}).ServeHTTP(w, r)
  1279  }
  1280  
  1281  func handleTidePools(cfg config.Getter, ta *tideAgent, log *logrus.Entry) http.HandlerFunc {
  1282  	return func(w http.ResponseWriter, r *http.Request) {
  1283  		setHeadersNoCaching(w)
  1284  		queryConfigs := ta.filterQueries(cfg().Tide.Queries)
  1285  		queries := make([]string, 0, len(queryConfigs))
  1286  		for _, qc := range queryConfigs {
  1287  			queries = append(queries, qc.Query())
  1288  		}
  1289  
  1290  		ta.Lock()
  1291  		pools := ta.pools
  1292  		ta.Unlock()
  1293  
  1294  		var poolsForDeck []tide.PoolForDeck
  1295  		for _, pool := range pools {
  1296  			poolsForDeck = append(poolsForDeck, *tide.PoolToPoolForDeck(&pool))
  1297  		}
  1298  		payload := tidePools{
  1299  			Queries:     queries,
  1300  			TideQueries: queryConfigs,
  1301  			Pools:       poolsForDeck,
  1302  		}
  1303  		pd, err := json.Marshal(payload)
  1304  		if err != nil {
  1305  			log.WithError(err).Error("Error marshaling payload.")
  1306  			pd = []byte("{}")
  1307  		}
  1308  		writeJSONResponse(w, r, pd)
  1309  	}
  1310  }
  1311  
  1312  func handleTideHistory(ta *tideAgent, log *logrus.Entry) http.HandlerFunc {
  1313  	return func(w http.ResponseWriter, r *http.Request) {
  1314  		setHeadersNoCaching(w)
  1315  
  1316  		ta.Lock()
  1317  		history := ta.history
  1318  		ta.Unlock()
  1319  
  1320  		payload := tideHistory{
  1321  			History: history,
  1322  		}
  1323  		pd, err := json.Marshal(payload)
  1324  		if err != nil {
  1325  			log.WithError(err).Error("Error marshaling payload.")
  1326  			pd = []byte("{}")
  1327  		}
  1328  		writeJSONResponse(w, r, pd)
  1329  	}
  1330  }
  1331  
  1332  func handlePluginHelp(ha *helpAgent, log *logrus.Entry) http.HandlerFunc {
  1333  	return func(w http.ResponseWriter, r *http.Request) {
  1334  		setHeadersNoCaching(w)
  1335  		help, err := ha.getHelp()
  1336  		if err != nil {
  1337  			log.WithError(err).Error("Getting plugin help from hook.")
  1338  			help = &pluginhelp.Help{}
  1339  		}
  1340  		b, err := json.Marshal(*help)
  1341  		if err != nil {
  1342  			log.WithError(err).Error("Marshaling plugin help.")
  1343  			b = []byte("[]")
  1344  		}
  1345  		writeJSONResponse(w, r, b)
  1346  	}
  1347  }
  1348  
  1349  type logClient interface {
  1350  	GetJobLog(job, id, container string) ([]byte, error)
  1351  }
  1352  
  1353  // TODO(spxtr): Cache, rate limit.
  1354  func handleLog(lc logClient, log *logrus.Entry) http.HandlerFunc {
  1355  	return func(w http.ResponseWriter, r *http.Request) {
  1356  		setHeadersNoCaching(w)
  1357  		w.Header().Set("Access-Control-Allow-Origin", "*")
  1358  		job := r.URL.Query().Get("job")
  1359  		id := r.URL.Query().Get("id")
  1360  		container := r.URL.Query().Get("container")
  1361  		if container == "" {
  1362  			container = kube.TestContainerName
  1363  		}
  1364  		logger := log.WithFields(logrus.Fields{"job": job, "id": id, "container": container})
  1365  		if err := validateLogRequest(r); err != nil {
  1366  			http.Error(w, err.Error(), http.StatusBadRequest)
  1367  			return
  1368  		}
  1369  		jobLog, err := lc.GetJobLog(job, id, container)
  1370  		if err != nil {
  1371  			http.Error(w, fmt.Sprintf("Log not found: %v", err), http.StatusNotFound)
  1372  			logger := logger.WithError(err)
  1373  			msg := "Log not found."
  1374  			if strings.Contains(err.Error(), "PodInitializing") || strings.Contains(err.Error(), "not found") ||
  1375  				strings.Contains(err.Error(), "terminated") {
  1376  				// PodInitializing is really common and not something
  1377  				// that has any actionable items for administrators
  1378  				// monitoring logs, so we should log it as information.
  1379  				// Similarly, if a user asks us to proxy through logs
  1380  				// for a Pod or ProwJob that doesn't exit, it's not
  1381  				// something an administrator wants to see in logs.
  1382  				logger.Info(msg)
  1383  			} else {
  1384  				logger.Warning(msg)
  1385  			}
  1386  			return
  1387  		}
  1388  		if _, err = w.Write(jobLog); err != nil {
  1389  			logger.WithError(err).Warning("Error writing log.")
  1390  		}
  1391  	}
  1392  }
  1393  
  1394  func validateLogRequest(r *http.Request) error {
  1395  	job := r.URL.Query().Get("job")
  1396  	id := r.URL.Query().Get("id")
  1397  
  1398  	if job == "" {
  1399  		return errors.New("request did not provide the 'job' query parameter")
  1400  	}
  1401  	if id == "" {
  1402  		return errors.New("request did not provide the 'id' query parameter")
  1403  	}
  1404  	return nil
  1405  }
  1406  
  1407  func handleProwJob(prowJobClient prowv1.ProwJobInterface, log *logrus.Entry) http.HandlerFunc {
  1408  	return func(w http.ResponseWriter, r *http.Request) {
  1409  		name := r.URL.Query().Get("prowjob")
  1410  		l := log.WithField("prowjob", name)
  1411  		if name == "" {
  1412  			http.Error(w, "request did not provide the 'prowjob' query parameter", http.StatusBadRequest)
  1413  			return
  1414  		}
  1415  
  1416  		pj, err := prowJobClient.Get(context.TODO(), name, metav1.GetOptions{})
  1417  		if err != nil {
  1418  			http.Error(w, fmt.Sprintf("ProwJob not found: %v", err), http.StatusNotFound)
  1419  			if !kerrors.IsNotFound(err) {
  1420  				// admins only care about errors other than not found
  1421  				l.WithError(err).Debug("ProwJob not found.")
  1422  			}
  1423  			return
  1424  		}
  1425  		pj.ManagedFields = nil
  1426  		handleSerialize(w, "prowjob", pj, l)
  1427  	}
  1428  }
  1429  
  1430  func handleSerialize(w http.ResponseWriter, name string, data interface{}, l *logrus.Entry) {
  1431  	setHeadersNoCaching(w)
  1432  	b, err := yaml.Marshal(data)
  1433  	if err != nil {
  1434  		msg := fmt.Sprintf("Error marshaling %q.", name)
  1435  		l.WithError(err).Error(msg)
  1436  		http.Error(w, msg, http.StatusInternalServerError)
  1437  		return
  1438  	}
  1439  	w.Header().Set("Content-Type", "text/plain")
  1440  	buff := bytes.NewBuffer(b)
  1441  	_, err = buff.WriteTo(w)
  1442  	if err != nil {
  1443  		msg := fmt.Sprintf("Error writing %q.", name)
  1444  		l.WithError(err).Error(msg)
  1445  		http.Error(w, msg, http.StatusInternalServerError)
  1446  	}
  1447  }
  1448  
  1449  func handleConfig(cfg config.Getter, log *logrus.Entry) http.HandlerFunc {
  1450  	return func(w http.ResponseWriter, r *http.Request) {
  1451  		// TODO: add the ability to query for any portions of the config?
  1452  		k := r.URL.Query().Get("key")
  1453  		switch k {
  1454  		case "disabled-clusters":
  1455  			l := sets.New[string](cfg().DisabledClusters...).UnsortedList()
  1456  			sort.Strings(l)
  1457  			handleSerialize(w, "disabled-clusters.yaml", l, log)
  1458  		case "":
  1459  			handleSerialize(w, "config.yaml", cfg(), log)
  1460  		default:
  1461  			msg := fmt.Sprintf("getting config for key %s is not supported", k)
  1462  			log.Error(msg)
  1463  			http.Error(w, msg, http.StatusInternalServerError)
  1464  			return
  1465  		}
  1466  	}
  1467  }
  1468  
  1469  func handlePluginConfig(pluginAgent *plugins.ConfigAgent, log *logrus.Entry) http.HandlerFunc {
  1470  	return func(w http.ResponseWriter, r *http.Request) {
  1471  		if pluginAgent != nil {
  1472  			handleSerialize(w, "plugins.yaml", pluginAgent.Config(), log)
  1473  			return
  1474  		}
  1475  		msg := "Please use the --plugin-config flag to specify the location of the plugin config."
  1476  		log.Infof("Could not serve request. %s", msg)
  1477  		http.Error(w, msg, http.StatusInternalServerError)
  1478  	}
  1479  }
  1480  
  1481  func handleFavicon(staticFilesLocation string, cfg config.Getter) http.HandlerFunc {
  1482  	return func(w http.ResponseWriter, r *http.Request) {
  1483  		config := cfg()
  1484  		if config.Deck.Branding != nil && config.Deck.Branding.Favicon != "" {
  1485  			http.ServeFile(w, r, staticFilesLocation+"/"+config.Deck.Branding.Favicon)
  1486  		} else {
  1487  			http.ServeFile(w, r, staticFilesLocation+"/favicon.ico")
  1488  		}
  1489  	}
  1490  }
  1491  
  1492  func HandleGitHubLink(githubHost string, secure bool) http.HandlerFunc {
  1493  	return func(w http.ResponseWriter, r *http.Request) {
  1494  		scheme := "http"
  1495  		if secure {
  1496  			scheme = "https"
  1497  		}
  1498  		redirectURL := scheme + "://" + githubHost + "/" + r.URL.Query().Get("dest")
  1499  		http.Redirect(w, r, redirectURL, http.StatusFound)
  1500  	}
  1501  }
  1502  
  1503  // HandleGenericProviderLink returns link based on different providers.
  1504  func HandleGitProviderLink(githubHost string, secure bool) http.HandlerFunc {
  1505  	return func(w http.ResponseWriter, r *http.Request) {
  1506  		var redirectURL string
  1507  
  1508  		vals := r.URL.Query()
  1509  		target := vals.Get("target")
  1510  		repo, branch, number, commit, author := vals.Get("repo"), vals.Get("branch"), vals.Get("number"), vals.Get("commit"), vals.Get("author")
  1511  		// repo could be passed in with single quote, as it might contains https://
  1512  		repo = strings.Trim(repo, "'")
  1513  		if gerritsource.IsGerritOrg(repo) {
  1514  			org, repo, err := gerritsource.OrgRepoFromCloneURI(repo)
  1515  			if err != nil {
  1516  				logrus.WithError(err).WithField("cloneURI", repo).Warn("Failed resolve org and repo from cloneURI.")
  1517  				http.Redirect(w, r, "", http.StatusNotFound)
  1518  				return
  1519  			}
  1520  			orgCodeURL, err := gerritsource.CodeURL(org)
  1521  			if err != nil {
  1522  				logrus.WithError(err).WithField("cloneURI", repo).Warn("Failed deriving source code URL from cloneURI.")
  1523  				http.Redirect(w, r, "", http.StatusNotFound)
  1524  				return
  1525  			}
  1526  			switch target {
  1527  			case "commit":
  1528  				fallthrough
  1529  			case "prcommit":
  1530  				redirectURL = orgCodeURL + "/" + repo + "/+/" + commit
  1531  			case "branch":
  1532  				redirectURL = orgCodeURL + "/" + repo + "/+/refs/heads/" + branch
  1533  			case "pr":
  1534  				redirectURL = org + "/c/" + repo + "/+/" + number
  1535  			}
  1536  		} else {
  1537  			scheme := "http"
  1538  			if secure {
  1539  				scheme = "https"
  1540  			}
  1541  			prefix := scheme + "://" + githubHost + "/"
  1542  			switch target {
  1543  			case "commit":
  1544  				redirectURL = prefix + repo + "/commit/" + commit
  1545  			case "branch":
  1546  				redirectURL = prefix + repo + "/tree/" + branch
  1547  			case "pr":
  1548  				redirectURL = prefix + repo + "/pull/" + number
  1549  			case "prcommit":
  1550  				redirectURL = prefix + repo + "/pull/" + number + "/" + commit
  1551  			case "author":
  1552  				redirectURL = prefix + author
  1553  			}
  1554  		}
  1555  		http.Redirect(w, r, redirectURL, http.StatusFound)
  1556  	}
  1557  }
  1558  
  1559  func isValidatedGitOAuthConfig(githubOAuthConfig *githuboauth.Config) bool {
  1560  	return githubOAuthConfig.ClientID != "" && githubOAuthConfig.ClientSecret != "" &&
  1561  		githubOAuthConfig.RedirectURL != ""
  1562  }
  1563  
  1564  type deckGitHubClient interface {
  1565  	prowgithub.RerunClient
  1566  	GetPullRequest(org, repo string, number int) (*prowgithub.PullRequest, error)
  1567  	GetRef(org, repo, ref string) (string, error)
  1568  	BotUserChecker() (func(candidate string) bool, error)
  1569  }
  1570  
  1571  func spglassConfigDefaulting(c *config.Config) error {
  1572  
  1573  	for idx := range c.Deck.Spyglass.Lenses {
  1574  		if err := defaultLensRemoteConfig(&c.Deck.Spyglass.Lenses[idx]); err != nil {
  1575  			return err
  1576  		}
  1577  		parsedEndpoint, err := url.Parse(c.Deck.Spyglass.Lenses[idx].RemoteConfig.Endpoint)
  1578  		if err != nil {
  1579  			return fmt.Errorf("failed to parse url %q for remote lens %q: %w", c.Deck.Spyglass.Lenses[idx].RemoteConfig.Endpoint, c.Deck.Spyglass.Lenses[idx].Lens.Name, err)
  1580  		}
  1581  		c.Deck.Spyglass.Lenses[idx].RemoteConfig.ParsedEndpoint = parsedEndpoint
  1582  	}
  1583  
  1584  	return nil
  1585  }
  1586  
  1587  const spyglassLocalLensListenerAddr = "127.0.0.1:1234"
  1588  
  1589  func defaultLensRemoteConfig(lfc *config.LensFileConfig) error {
  1590  	if lfc.RemoteConfig != nil && lfc.RemoteConfig.Endpoint != "" {
  1591  		return nil
  1592  	}
  1593  
  1594  	lens, err := lenses.GetLens(lfc.Lens.Name)
  1595  	if err != nil {
  1596  		return fmt.Errorf("lens %q has no remote_config and could not get default: %w", lfc.Lens.Name, err)
  1597  	}
  1598  
  1599  	if lfc.RemoteConfig == nil {
  1600  		lfc.RemoteConfig = &config.LensRemoteConfig{}
  1601  	}
  1602  
  1603  	if lfc.RemoteConfig.Endpoint == "" {
  1604  		// Must not have a slash in between, DyanmicPathForLens already returns a slash-prefixed path
  1605  		lfc.RemoteConfig.Endpoint = fmt.Sprintf("http://%s%s", spyglassLocalLensListenerAddr, common.DyanmicPathForLens(lfc.Lens.Name))
  1606  	}
  1607  
  1608  	if lfc.RemoteConfig.Title == "" {
  1609  		lfc.RemoteConfig.Title = lens.Config().Title
  1610  	}
  1611  
  1612  	if lfc.RemoteConfig.Priority == nil {
  1613  		p := lens.Config().Priority
  1614  		lfc.RemoteConfig.Priority = &p
  1615  	}
  1616  
  1617  	if lfc.RemoteConfig.HideTitle == nil {
  1618  		hideTitle := lens.Config().HideTitle
  1619  		lfc.RemoteConfig.HideTitle = &hideTitle
  1620  	}
  1621  
  1622  	return nil
  1623  }
  1624  
  1625  func validateStoragePath(cfg config.Getter, path string) error {
  1626  	parts := strings.Split(path, "/")
  1627  	if len(parts) < 3 {
  1628  		return fmt.Errorf("invalid path: %s (expecting format <storageType>/<bucket>/<folders...>)", path)
  1629  	}
  1630  	bucketName := parts[1]
  1631  	if err := cfg().ValidateStorageBucket(bucketName); err != nil {
  1632  		return httpError{
  1633  			error:      err,
  1634  			statusCode: http.StatusBadRequest,
  1635  		}
  1636  	}
  1637  	return nil
  1638  }
  1639  
  1640  type httpError struct {
  1641  	error
  1642  	statusCode int
  1643  }
  1644  
  1645  func httpStatusForError(e error) int {
  1646  	var httpErr httpError
  1647  	if ok := errors.As(e, &httpErr); ok {
  1648  		return httpErr.statusCode
  1649  	}
  1650  	return http.StatusInternalServerError
  1651  }
  1652  
  1653  func shouldLogHTTPErrors(e error) bool {
  1654  	return !errors.Is(e, context.Canceled) || httpStatusForError(e) >= http.StatusInternalServerError // 5XX
  1655  }