go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/view_console.go (about) 1 // Copyright 2017 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package frontend 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/json" 21 "fmt" 22 "html/template" 23 "net/http" 24 "net/url" 25 "strings" 26 "time" 27 28 "google.golang.org/grpc/codes" 29 30 "go.chromium.org/luci/buildbucket/bbperms" 31 "go.chromium.org/luci/common/clock" 32 "go.chromium.org/luci/common/data/stringset" 33 "go.chromium.org/luci/common/errors" 34 "go.chromium.org/luci/common/logging" 35 "go.chromium.org/luci/common/sync/parallel" 36 "go.chromium.org/luci/grpc/grpcutil" 37 "go.chromium.org/luci/server/auth" 38 "go.chromium.org/luci/server/caching/layered" 39 "go.chromium.org/luci/server/router" 40 "go.chromium.org/luci/server/templates" 41 42 "go.chromium.org/luci/common/api/gitiles" 43 "go.chromium.org/luci/milo/frontend/ui" 44 "go.chromium.org/luci/milo/internal/buildsource" 45 "go.chromium.org/luci/milo/internal/git" 46 "go.chromium.org/luci/milo/internal/model" 47 "go.chromium.org/luci/milo/internal/projectconfig" 48 "go.chromium.org/luci/milo/internal/utils" 49 projectconfigpb "go.chromium.org/luci/milo/proto/projectconfig" 50 ) 51 52 func logTimer(c context.Context, message string) func() { 53 tStart := clock.Now(c) 54 return func() { 55 logging.Debugf(c, "%s: took %s", message, clock.Since(c, tStart)) 56 } 57 } 58 59 // validateFaviconURL checks to see if the URL is well-formed and from an allowed host. 60 func validateFaviconURL(faviconURL string) error { 61 parsedFaviconURL, err := url.Parse(faviconURL) 62 if err != nil { 63 return err 64 } 65 host := strings.SplitN(parsedFaviconURL.Host, ":", 2)[0] 66 if host != "storage.googleapis.com" { 67 return fmt.Errorf("%q is not a valid FaviconURL hostname", host) 68 } 69 return nil 70 } 71 72 func getFaviconURL(c context.Context, def *projectconfigpb.Console) string { 73 if def.FaviconUrl == "" { 74 return "" 75 } 76 if err := validateFaviconURL(def.FaviconUrl); err != nil { 77 logging.WithError(err).Warningf(c, "invalid favicon URL") 78 return "" 79 } 80 return def.FaviconUrl 81 } 82 83 // columnSummaryFn is called by buildTreeFromDef. 84 // 85 // columnIdx is the index of the current column we're processing. This 86 // corresponds to one of the Builder messages in the Console config proto 87 // message. 88 // 89 // This function should return a BuilderSummary and []BuildSummary for the 90 // specified console column. 91 type columnSummaryFn func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary) 92 93 func buildTreeFromDef(def *projectconfigpb.Console, getColumnSummaries columnSummaryFn) (*ui.Category, int) { 94 // Build console table tree from builders. 95 categoryTree := ui.NewCategory("") 96 depth := 0 97 for columnIdx, builderMsg := range def.Builders { 98 ref := &ui.BuilderRef{ 99 ShortName: builderMsg.ShortName, 100 } 101 ref.Builder, ref.Build = getColumnSummaries(columnIdx) 102 if ref.Builder != nil { 103 // TODO(iannucci): This is redundant; use Builder.BuilderID directly. 104 ref.ID = ref.Builder.BuilderID 105 } 106 categories := builderMsg.ParseCategory() 107 if len(categories) > depth { 108 depth = len(categories) 109 } 110 categoryTree.AddBuilder(categories, ref) 111 } 112 return categoryTree, depth 113 } 114 115 // getConsoleGroups extracts the console summaries for all header summaries 116 // out of the summaries map into console groups for the header. 117 func getConsoleGroups(def *projectconfigpb.Header, summaries map[projectconfig.ConsoleID]*ui.BuilderSummaryGroup) []ui.ConsoleGroup { 118 if def == nil || len(def.GetConsoleGroups()) == 0 { 119 // No header, no console groups. 120 return nil 121 } 122 groups := def.GetConsoleGroups() 123 consoleGroups := make([]ui.ConsoleGroup, len(groups)) 124 for i, group := range groups { 125 groupSummaries := make([]*ui.BuilderSummaryGroup, len(group.ConsoleIds)) 126 for j, id := range group.ConsoleIds { 127 cid, err := projectconfig.ParseConsoleID(id) 128 if err != nil { 129 // This should never happen, the consoleID was already validated further 130 // upstream. 131 panic(err) 132 } 133 if consoleSummary, ok := summaries[cid]; ok { 134 groupSummaries[j] = consoleSummary 135 } else { 136 // This should never happen. 137 panic(fmt.Sprintf("could not find console summary %s", id)) 138 } 139 } 140 consoleGroups[i].Consoles = groupSummaries 141 if group.Title != nil { 142 ariaLabel := "console group " + group.Title.Text 143 consoleGroups[i].Title = ui.NewLink(group.Title.Text, group.Title.Url, ariaLabel) 144 consoleGroups[i].Title.Alt = group.Title.Alt 145 } 146 } 147 return consoleGroups 148 } 149 150 func consoleRowCommits(c context.Context, project string, def *projectconfigpb.Console, limit int) ( 151 []*buildsource.ConsoleRow, []ui.Commit, error) { 152 153 tGitiles := logTimer(c, "Rows: loading commit from gitiles") 154 repoHost, repoProject, err := gitiles.ParseRepoURL(def.RepoUrl) 155 if err != nil { 156 return nil, nil, errors.Annotate(err, "invalid repo URL %q in the config", def.RepoUrl).Err() 157 } 158 rawCommits, err := git.Get(c).CombinedLogs(c, repoHost, repoProject, def.ExcludeRef, def.Refs, limit) 159 switch grpcutil.Code(err) { 160 case codes.OK: 161 // Do nothing, all is good. 162 case codes.NotFound: 163 return nil, nil, errors.Reason("incorrect repo URL %q in the config or no access", def.RepoUrl). 164 Tag(grpcutil.NotFoundTag).Err() 165 default: 166 return nil, nil, err 167 } 168 tGitiles() 169 170 commitIDs := make([]string, len(rawCommits)) 171 for i, c := range rawCommits { 172 commitIDs[i] = c.Id 173 } 174 175 tBuilds := logTimer(c, "Rows: loading builds") 176 rows, err := buildsource.GetConsoleRows(c, project, def, commitIDs) 177 tBuilds() 178 if err != nil { 179 return nil, nil, err 180 } 181 182 // Build list of commits. 183 commits := make([]ui.Commit, len(rawCommits)) 184 for row, commit := range rawCommits { 185 ct := commit.Committer.Time.AsTime() 186 commits[row] = ui.Commit{ 187 AuthorName: commit.Author.Name, 188 AuthorEmail: commit.Author.Email, 189 CommitTime: ct, 190 Repo: def.RepoUrl, 191 Description: commit.Message, 192 Revision: ui.NewLink( 193 commit.Id, 194 def.RepoUrl+"/+/"+commit.Id, 195 fmt.Sprintf("commit by %s", commit.Author.Email)), 196 } 197 } 198 199 return rows, commits, nil 200 } 201 202 func console(c context.Context, project, id string, limit int, con *projectconfig.Console, headerCons []*projectconfig.Console, consoleGroupsErr error) (*ui.Console, error) { 203 def := &con.Def 204 consoleID := projectconfig.ConsoleID{Project: project, ID: id} 205 var header *ui.ConsoleHeader 206 var rows []*buildsource.ConsoleRow 207 var commits []ui.Commit 208 var builderSummaries map[projectconfig.ConsoleID]*ui.BuilderSummaryGroup 209 // Get 3 things in parallel: 210 // 1. The console header (except for summaries) 211 // 2. The console header summaries + this console's builders summaries. 212 // 3. The console body (rows + commits) 213 if err := parallel.FanOutIn(func(ch chan<- func() error) { 214 if def.Header != nil { 215 ch <- func() (err error) { 216 defer logTimer(c, "header")() 217 header, err = consoleHeader(c, project, def.Header) 218 return 219 } 220 } 221 ch <- func() (err error) { 222 defer logTimer(c, "summaries")() 223 builderSummaries, err = buildsource.GetConsoleSummariesFromDefs(c, append(headerCons, con), project) 224 return 225 } 226 ch <- func() (err error) { 227 defer logTimer(c, "rows")() 228 rows, commits, err = consoleRowCommits(c, project, def, limit) 229 return 230 } 231 }); err != nil { 232 return nil, err 233 } 234 235 // Reassemble builder summaries into both the current console 236 // and also the header. 237 if header != nil { 238 if consoleGroupsErr == nil { 239 header.ConsoleGroups = getConsoleGroups(def.Header, builderSummaries) 240 } else { 241 header.ConsoleGroupsErr = consoleGroupsErr 242 } 243 } 244 245 // Reassemble the builder summaries and rows into the categoryTree. 246 categoryTree, depth := buildTreeFromDef(def, func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary) { 247 builds := make([]*model.BuildSummary, len(commits)) 248 for row := range commits { 249 if summaries := rows[row].Builds[columnIdx]; len(summaries) > 0 { 250 builds[row] = summaries[0] 251 } 252 } 253 return builderSummaries[consoleID].Builders[columnIdx], builds 254 }) 255 256 return &ui.Console{ 257 Name: def.Name, 258 Project: project, 259 Header: header, 260 Commit: commits, 261 Table: *categoryTree, 262 MaxDepth: depth + 1, 263 FaviconURL: getFaviconURL(c, def), 264 }, nil 265 } 266 267 var treeStatusCache = layered.RegisterCache(layered.Parameters[*ui.TreeStatus]{ 268 ProcessCacheCapacity: 256, 269 GlobalNamespace: "tree-status", 270 Marshal: func(item *ui.TreeStatus) ([]byte, error) { 271 return json.Marshal(item) 272 }, 273 Unmarshal: func(blob []byte) (*ui.TreeStatus, error) { 274 treeStatus := &ui.TreeStatus{} 275 err := json.Unmarshal(blob, treeStatus) 276 return treeStatus, err 277 }, 278 }) 279 280 // getTreeStatus returns the current tree status from the chromium-status app. 281 // This never errors, instead it constructs a fake purple TreeStatus 282 func getTreeStatus(c context.Context, host string) *ui.TreeStatus { 283 q := url.Values{} 284 q.Add("format", "json") 285 url := (&url.URL{ 286 Scheme: "https", 287 Host: host, 288 Path: "current", 289 RawQuery: q.Encode(), 290 }).String() 291 status, err := treeStatusCache.GetOrCreate(c, url, func() (v *ui.TreeStatus, exp time.Duration, err error) { 292 out := &ui.TreeStatus{} 293 if err := utils.GetJSONData(http.DefaultClient, url, out); err != nil { 294 return nil, 0, err 295 } 296 return out, 30 * time.Second, nil 297 }) 298 299 if err != nil { 300 // Generate a fake tree status. 301 logging.WithError(err).Errorf(c, "loading tree status") 302 status = &ui.TreeStatus{ 303 GeneralState: "maintenance", 304 Message: "could not load tree status", 305 } 306 } 307 308 return status 309 } 310 311 var oncallDataCache = layered.RegisterCache(layered.Parameters[*ui.Oncall]{ 312 ProcessCacheCapacity: 256, 313 GlobalNamespace: "oncall-data", 314 Marshal: func(item *ui.Oncall) ([]byte, error) { 315 return json.Marshal(item) 316 }, 317 Unmarshal: func(blob []byte) (*ui.Oncall, error) { 318 oncall := &ui.Oncall{} 319 err := json.Unmarshal(blob, oncall) 320 return oncall, err 321 }, 322 }) 323 324 // getOncallData fetches oncall data and caches it for 10 minutes. 325 func getOncallData(c context.Context, config *projectconfigpb.Oncall) (*ui.OncallSummary, error) { 326 oncall, err := oncallDataCache.GetOrCreate(c, config.Url, func() (v *ui.Oncall, exp time.Duration, err error) { 327 out := &ui.Oncall{} 328 if err := utils.GetJSONData(http.DefaultClient, config.Url, out); err != nil { 329 return nil, 0, err 330 } 331 return out, 10 * time.Minute, nil 332 }) 333 334 var renderedHTML template.HTML 335 if err == nil { 336 renderedHTML = renderOncallers(config, oncall) 337 } else { 338 renderedHTML = template.HTML("ERROR: Fetching oncall failed") 339 } 340 return &ui.OncallSummary{ 341 Name: config.Name, 342 Oncallers: renderedHTML, 343 }, nil 344 } 345 346 // renderOncallers renders a summary string to be displayed in the UI, showing 347 // the current oncallers. 348 func renderOncallers(config *projectconfigpb.Oncall, jsonResult *ui.Oncall) template.HTML { 349 var oncallers string 350 if len(jsonResult.Emails) == 1 { 351 oncallers = jsonResult.Emails[0] 352 } else if len(jsonResult.Emails) > 1 { 353 if config.ShowPrimarySecondaryLabels { 354 var sb strings.Builder 355 fmt.Fprintf(&sb, "%v (primary)", jsonResult.Emails[0]) 356 for _, oncaller := range jsonResult.Emails[1:] { 357 fmt.Fprintf(&sb, ", %v (secondary)", oncaller) 358 } 359 oncallers = sb.String() 360 } else { 361 oncallers = strings.Join(jsonResult.Emails, ", ") 362 } 363 } else if jsonResult.Primary != "" { 364 if len(jsonResult.Secondaries) > 0 { 365 var sb strings.Builder 366 fmt.Fprintf(&sb, "%v (primary)", jsonResult.Primary) 367 for _, oncaller := range jsonResult.Secondaries { 368 fmt.Fprintf(&sb, ", %v (secondary)", oncaller) 369 } 370 oncallers = sb.String() 371 } else { 372 oncallers = jsonResult.Primary 373 } 374 } else { 375 oncallers = "<none>" 376 } 377 return utils.ObfuscateEmail(utils.ShortenEmail(oncallers)) 378 } 379 380 func consoleHeaderOncall(c context.Context, config []*projectconfigpb.Oncall) ([]*ui.OncallSummary, error) { 381 // Get oncall data from URLs. 382 oncalls := make([]*ui.OncallSummary, len(config)) 383 err := parallel.WorkPool(8, func(ch chan<- func() error) { 384 for i, oc := range config { 385 i := i 386 oc := oc 387 ch <- func() (err error) { 388 oncalls[i], err = getOncallData(c, oc) 389 return 390 } 391 } 392 }) 393 return oncalls, err 394 } 395 396 func consoleHeader(c context.Context, project string, header *projectconfigpb.Header) (*ui.ConsoleHeader, error) { 397 // Return nil if the header is empty. 398 switch { 399 case len(header.Oncalls) != 0: 400 // continue 401 case len(header.Links) != 0: 402 // continue 403 case header.TreeStatusHost != "": 404 // continue 405 default: 406 return nil, nil 407 } 408 409 var oncalls []*ui.OncallSummary 410 var treeStatus *ui.TreeStatus 411 // Get the oncall and tree status concurrently. 412 if err := parallel.FanOutIn(func(ch chan<- func() error) { 413 ch <- func() (err error) { 414 // Hide the oncall section to external users. 415 // TODO(weiweilin): Once the upstream service (rotation proxy) supports 416 // ACL checks, we should use the user's credential to query the oncall 417 // data and display it. 418 user := auth.CurrentUser(c) 419 if !strings.HasSuffix(user.Email, "@google.com") { 420 return nil 421 } 422 423 oncalls, err = consoleHeaderOncall(c, header.Oncalls) 424 if err != nil { 425 logging.WithError(err).Errorf(c, "getting oncalls") 426 } 427 return nil 428 } 429 if header.TreeStatusHost != "" { 430 ch <- func() error { 431 treeStatus = getTreeStatus(c, header.TreeStatusHost) 432 treeStatus.URL = &url.URL{Scheme: "https", Host: header.TreeStatusHost} 433 return nil 434 } 435 } 436 }); err != nil { 437 return nil, err 438 } 439 440 // Restructure links as resp data structures. 441 // 442 // This should be a one-to-one transformation. 443 links := make([]ui.LinkGroup, len(header.Links)) 444 for i, linkGroup := range header.Links { 445 mlinks := make([]*ui.Link, len(linkGroup.Links)) 446 for j, link := range linkGroup.Links { 447 ariaLabel := fmt.Sprintf("%s in %s", link.Text, linkGroup.Name) 448 mlinks[j] = ui.NewLink(link.Text, link.Url, ariaLabel) 449 } 450 links[i] = ui.LinkGroup{ 451 Name: ui.NewLink(linkGroup.Name, "", ""), 452 Links: mlinks, 453 } 454 } 455 456 return &ui.ConsoleHeader{ 457 Oncalls: oncalls, 458 Links: links, 459 TreeStatus: treeStatus, 460 }, nil 461 } 462 463 // consoleRenderer is a wrapper around Console to provide additional methods. 464 type consoleRenderer struct { 465 *ui.Console 466 } 467 468 // ConsoleTable generates the main console table html. 469 // 470 // This cannot be generated with templates due to the 'recursive' nature of 471 // this layout. 472 func (c consoleRenderer) ConsoleTable() template.HTML { 473 var buffer bytes.Buffer 474 // The first node is a dummy node 475 for _, column := range c.Table.Children() { 476 column.RenderHTML(&buffer, 1, c.MaxDepth) 477 } 478 return template.HTML(buffer.String()) 479 } 480 481 // ConsoleSummary generates the html for the console's builders in the console list. 482 // 483 // It is similar to ConsoleTable, but flattens the structure. 484 func (c consoleRenderer) ConsoleSummary() template.HTML { 485 var buffer bytes.Buffer 486 for _, column := range c.Table.Children() { 487 column.RenderHTML(&buffer, 1, -1) 488 } 489 return template.HTML(buffer.String()) 490 } 491 492 func (c consoleRenderer) BuilderLink(bs *model.BuildSummary) (*ui.Link, error) { 493 _, _, builderName, err := buildsource.BuilderID(bs.BuilderID).Split() 494 if err != nil { 495 return nil, err 496 } 497 return ui.NewLink(builderName, "/"+bs.BuilderID, fmt.Sprintf("builder %s", builderName)), nil 498 } 499 500 // consoleHeaderGroupIDs extracts the console group IDs out of the header config. 501 func consoleHeaderGroupIDs(project string, config []*projectconfigpb.ConsoleSummaryGroup) ([]projectconfig.ConsoleID, error) { 502 consoleIDSet := map[projectconfig.ConsoleID]struct{}{} 503 for _, group := range config { 504 for _, id := range group.ConsoleIds { 505 cid, err := projectconfig.ParseConsoleID(id) 506 if err != nil { 507 return nil, err 508 } 509 consoleIDSet[cid] = struct{}{} 510 } 511 } 512 consoleIDs := make([]projectconfig.ConsoleID, 0, len(consoleIDSet)) 513 for cid := range consoleIDSet { 514 consoleIDs = append(consoleIDs, cid) 515 } 516 return consoleIDs, nil 517 } 518 519 // filterUnauthorizedBuildersFromConsoles filters out builders the user does not have access to. 520 func filterUnauthorizedBuildersFromConsoles(c context.Context, cons []*projectconfig.Console) error { 521 allRealms := stringset.New(0) 522 for _, con := range cons { 523 allRealms = allRealms.Union(con.BuilderRealms()) 524 } 525 526 allowedRealms := stringset.New(0) 527 for realm := range allRealms { 528 allowed, err := auth.HasPermission(c, bbperms.BuildsList, realm, nil) 529 if err != nil { 530 return err 531 } 532 if allowed { 533 allowedRealms.Add(realm) 534 } 535 } 536 537 for _, con := range cons { 538 con.FilterBuilders(allowedRealms) 539 } 540 return nil 541 } 542 543 // ConsoleHandler renders the console page. 544 func ConsoleHandler(c *router.Context) error { 545 project := c.Params.ByName("project") 546 if project == "" { 547 return errors.New("missing project", grpcutil.InvalidArgumentTag) 548 } 549 group := c.Params.ByName("group") 550 551 // Get console from datastore and filter out builders from the definition. 552 con, err := projectconfig.GetConsole(c.Request.Context(), project, group) 553 switch { 554 case err != nil: 555 return err 556 case con.IsExternal(): 557 // We don't allow navigating directly to external consoles. 558 return projectconfig.ErrConsoleNotFound 559 case con.Def.BuilderViewOnly: 560 redirect("/p/:project/g/:group/builders", http.StatusFound)(c) 561 return nil 562 } 563 564 defaultLimit := 50 565 if con.Def.DefaultCommitLimit > 0 { 566 defaultLimit = int(con.Def.DefaultCommitLimit) 567 } 568 const maxLimit = 1000 569 limit := defaultLimit 570 if tLimit := GetLimit(c.Request, -1); tLimit >= 0 { 571 limit = tLimit 572 } 573 if limit > maxLimit { 574 limit = maxLimit 575 } 576 577 var headerCons []*projectconfig.Console 578 var headerConsError error 579 if con.Def.Header != nil { 580 ids, err := consoleHeaderGroupIDs(project, con.Def.Header.GetConsoleGroups()) 581 if err != nil { 582 return err 583 } 584 headerCons, err = projectconfig.GetConsoles(c.Request.Context(), ids) 585 if err != nil { 586 headerConsError = errors.Annotate(err, "error getting header consoles").Err() 587 headerCons = make([]*projectconfig.Console, 0) 588 } 589 } 590 if err := filterUnauthorizedBuildersFromConsoles(c.Request.Context(), append(headerCons, con)); err != nil { 591 return errors.Annotate(err, "error authorizing user").Err() 592 } 593 594 // Process the request and generate a renderable structure. 595 result, err := console(c.Request.Context(), project, group, limit, con, headerCons, headerConsError) 596 if err != nil { 597 return err 598 } 599 600 templates.MustRender(c.Request.Context(), c.Writer, "pages/console.html", templates.Args{ 601 "Console": consoleRenderer{result}, 602 "Expand": con.Def.DefaultExpand, 603 }) 604 return nil 605 }