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 }