github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/prow/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 "encoding/base64" 22 "encoding/json" 23 "errors" 24 "flag" 25 "fmt" 26 "io/ioutil" 27 "net/http" 28 "net/url" 29 "path" 30 "regexp" 31 "time" 32 33 "github.com/NYTimes/gziphandler" 34 "github.com/ghodss/yaml" 35 "github.com/gorilla/sessions" 36 "github.com/sirupsen/logrus" 37 38 "golang.org/x/oauth2" 39 "golang.org/x/oauth2/github" 40 "k8s.io/test-infra/prow/config" 41 "k8s.io/test-infra/prow/deck/jobs" 42 "k8s.io/test-infra/prow/githuboauth" 43 "k8s.io/test-infra/prow/kube" 44 "k8s.io/test-infra/prow/logrusutil" 45 "k8s.io/test-infra/prow/pjutil" 46 "k8s.io/test-infra/prow/pluginhelp" 47 "k8s.io/test-infra/prow/prstatus" 48 ) 49 50 type options struct { 51 configPath string 52 jobConfigPath string 53 buildCluster string 54 tideURL string 55 hookURL string 56 oauthURL string 57 githubOAuthConfigFile string 58 cookieSecretFile string 59 redirectHTTPTo string 60 hiddenOnly bool 61 runLocal bool 62 } 63 64 func (o *options) Validate() error { 65 if o.configPath == "" { 66 return errors.New("required flag --config-path was unset") 67 } 68 if o.oauthURL != "" { 69 if o.githubOAuthConfigFile == "" { 70 return errors.New("an OAuth URL was provided but required flag --github-oauth-config-file was unset") 71 } 72 if o.cookieSecretFile == "" { 73 return errors.New("an OAuth URL was provided but required flag --cookie-secret was unset") 74 } 75 } 76 return nil 77 } 78 79 func gatherOptions() options { 80 o := options{} 81 flag.StringVar(&o.configPath, "config-path", "/etc/config/config.yaml", "Path to config.yaml.") 82 flag.StringVar(&o.jobConfigPath, "job-config-path", "", "Path to prow job configs.") 83 flag.StringVar(&o.buildCluster, "build-cluster", "", "Path to file containing a YAML-marshalled kube.Cluster object. If empty, uses the local cluster.") 84 flag.StringVar(&o.tideURL, "tide-url", "", "Path to tide. If empty, do not serve tide data.") 85 flag.StringVar(&o.hookURL, "hook-url", "", "Path to hook plugin help endpoint.") 86 flag.StringVar(&o.oauthURL, "oauth-url", "", "Path to deck user dashboard endpoint.") 87 flag.StringVar(&o.githubOAuthConfigFile, "github-oauth-config-file", "/etc/github/secret", "Path to the file containing the GitHub App Client secret.") 88 flag.StringVar(&o.cookieSecretFile, "cookie-secret", "/etc/cookie/secret", "Path to the file containing the cookie secret key.") 89 // use when behind a load balancer 90 flag.StringVar(&o.redirectHTTPTo, "redirect-http-to", "", "Host to redirect http->https to based on x-forwarded-proto == http.") 91 // use when behind an oauth proxy 92 flag.BoolVar(&o.hiddenOnly, "hidden-only", false, "Show only hidden jobs. Useful for serving hidden jobs behind an oauth proxy.") 93 flag.BoolVar(&o.runLocal, "run-local", false, "Serve a local copy of the UI, used by the prow/cmd/deck/runlocal script") 94 flag.Parse() 95 return o 96 } 97 98 var ( 99 // Matches letters, numbers, hyphens, and underscores. 100 objReg = regexp.MustCompile(`^[\w-]+$`) 101 staticFilesLocation = "./static" 102 ) 103 104 func main() { 105 o := gatherOptions() 106 if err := o.Validate(); err != nil { 107 logrus.Fatalf("Invalid options: %v", err) 108 } 109 110 logrus.SetFormatter( 111 logrusutil.NewDefaultFieldsFormatter(nil, logrus.Fields{"component": "deck"}), 112 ) 113 114 mux := http.NewServeMux() 115 116 staticHandlerFromDir := func(dir string) http.Handler { 117 return defaultExtension(".html", 118 gziphandler.GzipHandler(handleCached(http.FileServer(http.Dir(dir))))) 119 } 120 121 // setup config agent, pod log clients etc. 122 configAgent := &config.Agent{} 123 if err := configAgent.Start(o.configPath, o.jobConfigPath); err != nil { 124 logrus.WithError(err).Fatal("Error starting config agent.") 125 } 126 127 // setup common handlers for local and deployed runs 128 mux.Handle("/", staticHandlerFromDir(staticFilesLocation)) 129 mux.Handle("/config", gziphandler.GzipHandler(handleConfig(configAgent))) 130 mux.Handle("/branding.js", gziphandler.GzipHandler(handleBranding(configAgent))) 131 mux.Handle("/favicon.ico", gziphandler.GzipHandler(handleFavicon(configAgent))) 132 133 // when deployed, do the full main 134 if !o.runLocal { 135 mux = prodOnlyMain(configAgent, o, mux) 136 } 137 138 // setup done, actually start the server 139 logrus.WithError(http.ListenAndServe(":8080", mux)).Fatal("ListenAndServe returned.") 140 } 141 142 // prodOnlyMain contains logic only used when running deployed, not locally 143 func prodOnlyMain(configAgent *config.Agent, o options, mux *http.ServeMux) *http.ServeMux { 144 145 kc, err := kube.NewClientInCluster(configAgent.Config().ProwJobNamespace) 146 if err != nil { 147 logrus.WithError(err).Fatal("Error getting client.") 148 } 149 kc.SetHiddenReposProvider(func() []string { return configAgent.Config().Deck.HiddenRepos }, o.hiddenOnly) 150 151 var pkcs map[string]*kube.Client 152 if o.buildCluster == "" { 153 pkcs = map[string]*kube.Client{kube.DefaultClusterAlias: kc.Namespace(configAgent.Config().PodNamespace)} 154 } else { 155 pkcs, err = kube.ClientMapFromFile(o.buildCluster, configAgent.Config().PodNamespace) 156 if err != nil { 157 logrus.WithError(err).Fatal("Error getting kube client to build cluster.") 158 } 159 } 160 plClients := map[string]jobs.PodLogClient{} 161 for alias, client := range pkcs { 162 plClients[alias] = client 163 } 164 165 ja := jobs.NewJobAgent(kc, plClients, configAgent) 166 ja.Start() 167 168 // setup prod only handlers 169 mux.Handle("/data.js", gziphandler.GzipHandler(handleData(ja))) 170 mux.Handle("/prowjobs.js", gziphandler.GzipHandler(handleProwJobs(ja))) 171 mux.Handle("/badge.svg", gziphandler.GzipHandler(handleBadge(ja))) 172 mux.Handle("/log", gziphandler.GzipHandler(handleLog(ja))) 173 mux.Handle("/rerun", gziphandler.GzipHandler(handleRerun(kc))) 174 175 if o.hookURL != "" { 176 mux.Handle("/plugin-help.js", 177 gziphandler.GzipHandler(handlePluginHelp(newHelpAgent(o.hookURL)))) 178 } 179 180 if o.tideURL != "" { 181 ta := &tideAgent{ 182 log: logrus.WithField("agent", "tide"), 183 path: o.tideURL, 184 updatePeriod: func() time.Duration { 185 return configAgent.Config().Deck.TideUpdatePeriod 186 }, 187 hiddenRepos: configAgent.Config().Deck.HiddenRepos, 188 hiddenOnly: o.hiddenOnly, 189 } 190 ta.start() 191 mux.Handle("/tide.js", gziphandler.GzipHandler(handleTide(configAgent, ta))) 192 } 193 194 // Enable Git OAuth feature if oauthURL is provided. 195 if o.oauthURL != "" { 196 githubOAuthConfigRaw, err := loadToken(o.githubOAuthConfigFile) 197 if err != nil { 198 logrus.WithError(err).Fatal("Could not read github oauth config file.") 199 } 200 201 cookieSecretRaw, err := loadToken(o.cookieSecretFile) 202 if err != nil { 203 logrus.WithError(err).Fatal("Could not read cookie secret file.") 204 } 205 206 var githubOAuthConfig config.GithubOAuthConfig 207 if err := yaml.Unmarshal(githubOAuthConfigRaw, &githubOAuthConfig); err != nil { 208 logrus.WithError(err).Fatal("Error unmarshalling github oauth config") 209 } 210 if !isValidatedGitOAuthConfig(&githubOAuthConfig) { 211 logrus.Fatal("Error invalid github oauth config") 212 } 213 214 decodedSecret, err := base64.StdEncoding.DecodeString(string(cookieSecretRaw)) 215 if err != nil { 216 logrus.WithError(err).Fatal("Error decoding cookie secret") 217 } 218 if len(decodedSecret) == 0 { 219 logrus.Fatal("Cookie secret should not be empty") 220 } 221 cookie := sessions.NewCookieStore(decodedSecret) 222 githubOAuthConfig.InitGithubOAuthConfig(cookie) 223 224 goa := githuboauth.NewGithubOAuthAgent(&githubOAuthConfig, logrus.WithField("client", "githuboauth")) 225 oauthClient := &oauth2.Config{ 226 ClientID: githubOAuthConfig.ClientID, 227 ClientSecret: githubOAuthConfig.ClientSecret, 228 RedirectURL: githubOAuthConfig.RedirectURL, 229 Scopes: githubOAuthConfig.Scopes, 230 Endpoint: github.Endpoint, 231 } 232 233 repoSet := make(map[string]bool) 234 for r := range configAgent.Config().Presubmits { 235 repoSet[r] = true 236 } 237 for _, q := range configAgent.Config().Tide.Queries { 238 for _, v := range q.Repos { 239 repoSet[v] = true 240 } 241 } 242 var repos []string 243 for k, v := range repoSet { 244 if v { 245 repos = append(repos, k) 246 } 247 } 248 249 prStatusAgent := prstatus.NewDashboardAgent( 250 repos, 251 &githubOAuthConfig, 252 logrus.WithField("client", "pr-status")) 253 254 mux.Handle("/pr-data.js", handleNotCached( 255 prStatusAgent.HandlePrStatus(prStatusAgent))) 256 // Handles login request. 257 mux.Handle("/github-login", goa.HandleLogin(oauthClient)) 258 // Handles redirect from Github OAuth server. 259 mux.Handle("/github-login/redirect", goa.HandleRedirect(oauthClient, githuboauth.NewGithubClientGetter())) 260 } 261 262 // optionally inject http->https redirect handler when behind loadbalancer 263 if o.redirectHTTPTo != "" { 264 redirectMux := http.NewServeMux() 265 redirectMux.Handle("/", func(oldMux *http.ServeMux, host string) http.HandlerFunc { 266 return func(w http.ResponseWriter, r *http.Request) { 267 if r.Header.Get("x-forwarded-proto") == "http" { 268 redirectURL, err := url.Parse(r.URL.String()) 269 if err != nil { 270 logrus.Errorf("Failed to parse URL: %s.", r.URL.String()) 271 http.Error(w, "Failed to perform https redirect.", http.StatusInternalServerError) 272 return 273 } 274 redirectURL.Scheme = "https" 275 redirectURL.Host = host 276 http.Redirect(w, r, redirectURL.String(), http.StatusMovedPermanently) 277 } else { 278 oldMux.ServeHTTP(w, r) 279 } 280 } 281 }(mux, o.redirectHTTPTo)) 282 mux = redirectMux 283 } 284 return mux 285 } 286 287 func loadToken(file string) ([]byte, error) { 288 raw, err := ioutil.ReadFile(file) 289 if err != nil { 290 return []byte{}, err 291 } 292 return bytes.TrimSpace(raw), nil 293 } 294 295 // copy a http.Request 296 // see: https://go-review.googlesource.com/c/go/+/36483/3/src/net/http/server.go 297 func dupeRequest(original *http.Request) *http.Request { 298 r2 := new(http.Request) 299 *r2 = *original 300 r2.URL = new(url.URL) 301 *r2.URL = *original.URL 302 return r2 303 } 304 305 // serve with handler but map extensionless URLs to the default 306 func defaultExtension(extension string, h http.Handler) http.Handler { 307 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 308 if len(r.URL.Path) > 0 && 309 r.URL.Path[len(r.URL.Path)-1] != '/' && path.Ext(r.URL.Path) == "" { 310 r2 := dupeRequest(r) 311 r2.URL.Path = r.URL.Path + extension 312 h.ServeHTTP(w, r2) 313 } else { 314 h.ServeHTTP(w, r) 315 } 316 }) 317 } 318 319 func handleCached(next http.Handler) http.Handler { 320 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 321 // This looks ridiculous but actually no-cache means "revalidate" and 322 // "max-age=0" just means there is no time in which it can skip 323 // revalidation. We also need to set must-revalidate because no-cache 324 // doesn't imply must-revalidate when using the back button 325 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 326 // TODO(bentheelder): consider setting a longer max-age 327 // setting it this way means the content is always revalidated 328 w.Header().Set("Cache-Control", "public, max-age=0, no-cache, must-revalidate") 329 next.ServeHTTP(w, r) 330 }) 331 } 332 333 func setHeadersNoCaching(w http.ResponseWriter) { 334 // Note that we need to set both no-cache and no-store because only some 335 // broswers decided to (incorrectly) treat no-cache as "never store" 336 // IE "no-store". for good measure to cover older browsers we also set 337 // expires and pragma: https://stackoverflow.com/a/2068407 338 w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 339 w.Header().Set("Pragma", "no-cache") 340 w.Header().Set("Expires", "0") 341 } 342 343 func handleNotCached(next http.Handler) http.HandlerFunc { 344 return func(w http.ResponseWriter, r *http.Request) { 345 setHeadersNoCaching(w) 346 next.ServeHTTP(w, r) 347 } 348 } 349 350 func handleProwJobs(ja *jobs.JobAgent) http.HandlerFunc { 351 return func(w http.ResponseWriter, r *http.Request) { 352 setHeadersNoCaching(w) 353 jobs := ja.ProwJobs() 354 if v := r.URL.Query().Get("omit"); v == "pod_spec" { 355 for i := range jobs { 356 jobs[i].Spec.PodSpec = nil 357 } 358 } 359 jd, err := json.Marshal(struct { 360 Items []kube.ProwJob `json:"items"` 361 }{jobs}) 362 if err != nil { 363 logrus.WithError(err).Error("Error marshaling jobs.") 364 jd = []byte("{}") 365 } 366 // If we have a "var" query, then write out "var value = {...};". 367 // Otherwise, just write out the JSON. 368 if v := r.URL.Query().Get("var"); v != "" { 369 fmt.Fprintf(w, "var %s = %s;", v, string(jd)) 370 } else { 371 fmt.Fprint(w, string(jd)) 372 } 373 } 374 } 375 376 func handleData(ja *jobs.JobAgent) http.HandlerFunc { 377 return func(w http.ResponseWriter, r *http.Request) { 378 setHeadersNoCaching(w) 379 jobs := ja.Jobs() 380 jd, err := json.Marshal(jobs) 381 if err != nil { 382 logrus.WithError(err).Error("Error marshaling jobs.") 383 jd = []byte("[]") 384 } 385 // If we have a "var" query, then write out "var value = {...};". 386 // Otherwise, just write out the JSON. 387 if v := r.URL.Query().Get("var"); v != "" { 388 fmt.Fprintf(w, "var %s = %s;", v, string(jd)) 389 } else { 390 fmt.Fprint(w, string(jd)) 391 } 392 } 393 } 394 395 func handleBadge(ja *jobs.JobAgent) http.HandlerFunc { 396 return func(w http.ResponseWriter, r *http.Request) { 397 setHeadersNoCaching(w) 398 wantJobs := r.URL.Query().Get("jobs") 399 if wantJobs == "" { 400 http.Error(w, "missing jobs query parameter", http.StatusBadRequest) 401 return 402 } 403 w.Header().Set("Content-Type", "image/svg+xml") 404 405 allJobs := ja.ProwJobs() 406 _, _, svg := renderBadge(pickLatestJobs(allJobs, wantJobs)) 407 w.Write(svg) 408 } 409 } 410 411 func handleTide(ca *config.Agent, ta *tideAgent) http.HandlerFunc { 412 return func(w http.ResponseWriter, r *http.Request) { 413 setHeadersNoCaching(w) 414 queryConfigs := ca.Config().Tide.Queries 415 416 ta.Lock() 417 defer ta.Unlock() 418 pools := ta.pools 419 queryConfigs, pools = ta.filterHidden(queryConfigs, pools) 420 queries := make([]string, 0, len(queryConfigs)) 421 for _, qc := range queryConfigs { 422 queries = append(queries, qc.Query()) 423 } 424 425 payload := tideData{ 426 Queries: queries, 427 TideQueries: queryConfigs, 428 Pools: pools, 429 } 430 pd, err := json.Marshal(payload) 431 if err != nil { 432 logrus.WithError(err).Error("Error marshaling payload.") 433 pd = []byte("{}") 434 } 435 // If we have a "var" query, then write out "var value = {...};". 436 // Otherwise, just write out the JSON. 437 if v := r.URL.Query().Get("var"); v != "" { 438 fmt.Fprintf(w, "var %s = %s;", v, string(pd)) 439 } else { 440 fmt.Fprint(w, string(pd)) 441 } 442 443 } 444 } 445 446 func handlePluginHelp(ha *helpAgent) http.HandlerFunc { 447 return func(w http.ResponseWriter, r *http.Request) { 448 setHeadersNoCaching(w) 449 help, err := ha.getHelp() 450 if err != nil { 451 logrus.WithError(err).Error("Getting plugin help from hook.") 452 help = &pluginhelp.Help{} 453 } 454 b, err := json.Marshal(*help) 455 if err != nil { 456 logrus.WithError(err).Error("Marshaling plugin help.") 457 b = []byte("[]") 458 } 459 // If we have a "var" query, then write out "var value = [...];". 460 // Otherwise, just write out the JSON. 461 if v := r.URL.Query().Get("var"); v != "" { 462 fmt.Fprintf(w, "var %s = %s;", v, string(b)) 463 } else { 464 fmt.Fprint(w, string(b)) 465 } 466 } 467 } 468 469 type logClient interface { 470 GetJobLog(job, id string) ([]byte, error) 471 } 472 473 // TODO(spxtr): Cache, rate limit. 474 func handleLog(lc logClient) http.HandlerFunc { 475 return func(w http.ResponseWriter, r *http.Request) { 476 setHeadersNoCaching(w) 477 w.Header().Set("Access-Control-Allow-Origin", "*") 478 job := r.URL.Query().Get("job") 479 id := r.URL.Query().Get("id") 480 if err := validateLogRequest(r); err != nil { 481 http.Error(w, err.Error(), http.StatusBadRequest) 482 return 483 } 484 log, err := lc.GetJobLog(job, id) 485 if err != nil { 486 http.Error(w, fmt.Sprintf("Log not found: %v", err), http.StatusNotFound) 487 logrus.WithError(err).Warning("Log not found.") 488 return 489 } 490 if _, err = w.Write(log); err != nil { 491 logrus.WithError(err).Warning("Error writing log.") 492 } 493 } 494 } 495 496 func validateLogRequest(r *http.Request) error { 497 job := r.URL.Query().Get("job") 498 id := r.URL.Query().Get("id") 499 500 if job == "" { 501 return errors.New("Missing job query") 502 } 503 if id == "" { 504 return errors.New("Missing ID query") 505 } 506 if !objReg.MatchString(job) { 507 return fmt.Errorf("Invalid job query: %s", job) 508 } 509 if !objReg.MatchString(id) { 510 return fmt.Errorf("Invalid ID query: %s", id) 511 } 512 return nil 513 } 514 515 type pjClient interface { 516 GetProwJob(string) (kube.ProwJob, error) 517 } 518 519 func handleRerun(kc pjClient) http.HandlerFunc { 520 return func(w http.ResponseWriter, r *http.Request) { 521 name := r.URL.Query().Get("prowjob") 522 if !objReg.MatchString(name) { 523 http.Error(w, "Invalid ProwJob query", http.StatusBadRequest) 524 return 525 } 526 pj, err := kc.GetProwJob(name) 527 if err != nil { 528 http.Error(w, fmt.Sprintf("ProwJob not found: %v", err), http.StatusNotFound) 529 logrus.WithError(err).Warning("ProwJob not found.") 530 return 531 } 532 pjutil := pjutil.NewProwJob(pj.Spec, pj.ObjectMeta.Labels) 533 b, err := yaml.Marshal(&pjutil) 534 if err != nil { 535 http.Error(w, fmt.Sprintf("Error marshaling: %v", err), http.StatusInternalServerError) 536 logrus.WithError(err).Error("Error marshaling jobs.") 537 return 538 } 539 if _, err := w.Write(b); err != nil { 540 logrus.WithError(err).Error("Error writing log.") 541 } 542 } 543 } 544 545 func handleConfig(ca jobs.ConfigAgent) http.HandlerFunc { 546 return func(w http.ResponseWriter, r *http.Request) { 547 // TODO(bentheelder): add the ability to query for portions of the config? 548 setHeadersNoCaching(w) 549 config := ca.Config() 550 b, err := yaml.Marshal(config) 551 if err != nil { 552 logrus.WithError(err).Error("Error marshaling config.") 553 http.Error(w, "Failed to marhshal config.", http.StatusInternalServerError) 554 return 555 } 556 buff := bytes.NewBuffer(b) 557 _, err = buff.WriteTo(w) 558 if err != nil { 559 logrus.WithError(err).Error("Error writing config.") 560 http.Error(w, "Failed to write config.", http.StatusInternalServerError) 561 } 562 } 563 } 564 565 func handleBranding(ca jobs.ConfigAgent) http.HandlerFunc { 566 return func(w http.ResponseWriter, r *http.Request) { 567 setHeadersNoCaching(w) 568 config := ca.Config() 569 b, err := json.Marshal(config.Deck.Branding) 570 if err != nil { 571 logrus.WithError(err).Error("Error marshaling branding config.") 572 http.Error(w, "Failed to marshal branding config.", http.StatusInternalServerError) 573 return 574 } 575 // If we have a "var" query, then write out "var value = [...];". 576 // Otherwise, just write out the JSON. 577 if v := r.URL.Query().Get("var"); v != "" { 578 fmt.Fprintf(w, "var %s = %s;", v, string(b)) 579 } else { 580 fmt.Fprint(w, string(b)) 581 } 582 } 583 } 584 585 func handleFavicon(ca jobs.ConfigAgent) http.HandlerFunc { 586 return func(w http.ResponseWriter, r *http.Request) { 587 config := ca.Config() 588 if config.Deck.Branding != nil && config.Deck.Branding.Favicon != "" { 589 http.ServeFile(w, r, staticFilesLocation+"/"+config.Deck.Branding.Favicon) 590 } else { 591 http.ServeFile(w, r, staticFilesLocation+"/favicon.ico") 592 } 593 } 594 } 595 596 func isValidatedGitOAuthConfig(githubOAuthConfig *config.GithubOAuthConfig) bool { 597 return githubOAuthConfig.ClientID != "" && githubOAuthConfig.ClientSecret != "" && 598 githubOAuthConfig.RedirectURL != "" && 599 githubOAuthConfig.FinalRedirectURL != "" 600 }