golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/relui/web.go (about) 1 // Copyright 2020 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package relui 6 7 import ( 8 "bytes" 9 "context" 10 "database/sql" 11 "encoding/json" 12 "errors" 13 "fmt" 14 "html/template" 15 "io" 16 "log" 17 "net/http" 18 "net/url" 19 "path" 20 "reflect" 21 "strconv" 22 "strings" 23 "time" 24 25 "github.com/google/uuid" 26 "github.com/jackc/pgx/v4" 27 "github.com/julienschmidt/httprouter" 28 "golang.org/x/build/internal/metrics" 29 "golang.org/x/build/internal/relui/db" 30 "golang.org/x/build/internal/task" 31 "golang.org/x/build/internal/workflow" 32 "golang.org/x/exp/slices" 33 ) 34 35 const DatetimeLocalLayout = "2006-01-02T15:04" 36 37 // SiteHeader configures the relui site header. 38 type SiteHeader struct { 39 Title string // Site title. For example, "Go Releases". 40 CSSClass string // Site header CSS class name. Optional. 41 Subtitle string 42 NameParam string 43 } 44 45 // Server implements the http handlers for relui. 46 type Server struct { 47 db db.PGDBTX 48 m *metricsRouter 49 w *Worker 50 scheduler *Scheduler 51 baseURL *url.URL // nil means "/". 52 header SiteHeader 53 // mux used if baseURL is set 54 bm *http.ServeMux 55 56 templates *template.Template 57 homeTmpl *template.Template 58 newWorkflowTmpl *template.Template 59 } 60 61 // NewServer initializes a server with the provided connection pool, 62 // worker, base URL and site header. 63 // 64 // The base URL may be nil, which is the same as "/". 65 func NewServer(p db.PGDBTX, w *Worker, baseURL *url.URL, header SiteHeader, ms *metrics.Service) *Server { 66 s := &Server{ 67 db: p, 68 m: &metricsRouter{router: httprouter.New()}, 69 w: w, 70 scheduler: NewScheduler(p, w), 71 baseURL: baseURL, 72 header: header, 73 } 74 if err := s.scheduler.Resume(context.Background()); err != nil { 75 log.Fatalf("s.scheduler.Resume() = %v", err) 76 } 77 helpers := map[string]interface{}{ 78 "allWorkflowsCount": s.allWorkflowsCount, 79 "baseLink": s.BaseLink, 80 "hasPrefix": strings.HasPrefix, 81 "pathBase": path.Base, 82 "prettySize": prettySize, 83 "sidebarWorkflows": s.sidebarWorkflows, 84 "unmarshalResultDetail": unmarshalResultDetail, 85 } 86 s.templates = template.Must(template.New("").Funcs(helpers).ParseFS(templates, "templates/*.html")) 87 s.homeTmpl = s.mustLookup("home.html") 88 s.newWorkflowTmpl = s.mustLookup("new_workflow.html") 89 s.m.GET("/workflows/:id", s.showWorkflowHandler) 90 s.m.POST("/workflows/:id/stop", s.stopWorkflowHandler) 91 s.m.POST("/workflows/:id/tasks/:name/retry", s.retryTaskHandler) 92 s.m.POST("/workflows/:id/tasks/:name/approve", s.approveTaskHandler) 93 s.m.POST("/schedules/:id/delete", s.deleteScheduleHandler) 94 s.m.Handler(http.MethodGet, "/metrics", ms) 95 s.m.Handler(http.MethodGet, "/new_workflow", http.HandlerFunc(s.newWorkflowHandler)) 96 s.m.Handler(http.MethodPost, "/workflows", http.HandlerFunc(s.createWorkflowHandler)) 97 s.m.ServeFiles("/static/*filepath", http.FS(static)) 98 s.m.Handler(http.MethodGet, "/", http.HandlerFunc(s.homeHandler)) 99 if baseURL != nil && baseURL.Path != "/" && baseURL.Path != "" { 100 nosuffix := strings.TrimSuffix(baseURL.Path, "/") 101 s.bm = new(http.ServeMux) 102 s.bm.Handle(nosuffix+"/", http.StripPrefix(nosuffix, s.m)) 103 s.bm.Handle("/", s.m) 104 } 105 return s 106 } 107 108 func (s *Server) allWorkflowsCount() int64 { 109 count, err := db.New(s.db).WorkflowCount(context.Background()) 110 if err != nil { 111 panic(fmt.Sprintf("allWorkflowsCount: %q", err)) 112 } 113 return count 114 } 115 116 func (s *Server) sidebarWorkflows(nameParam string) []db.WorkflowSidebarRow { 117 sb, err := db.New(s.db).WorkflowSidebar(context.Background()) 118 if err != nil { 119 panic(fmt.Sprintf("sidebarWorkflows: %q", err)) 120 } 121 var filtered []db.WorkflowSidebarRow 122 others := db.WorkflowSidebarRow{Name: sql.NullString{String: "Others", Valid: true}} 123 for _, row := range sb { 124 if s.w.dh.Definition(row.Name.String) == nil { 125 others.Count += row.Count 126 continue 127 } 128 filtered = append(filtered, row) 129 } 130 filtered = append(filtered, others) 131 // Add a new row when on the newWorkflowsHandler if the workflow has never been run. 132 if s.w.dh.Definition(nameParam) != nil && slices.IndexFunc(filtered, func(row db.WorkflowSidebarRow) bool { return row.Name.String == nameParam }) == -1 { 133 filtered = append(filtered, db.WorkflowSidebarRow{Name: sql.NullString{String: nameParam, Valid: true}}) 134 } 135 return filtered 136 } 137 138 func (s *Server) mustLookup(name string) *template.Template { 139 t := template.Must(template.Must(s.templates.Clone()).ParseFS(templates, path.Join("templates", name))).Lookup(name) 140 if t == nil { 141 panic(fmt.Errorf("template %q not found", name)) 142 } 143 return t 144 } 145 146 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 147 if s.bm != nil { 148 s.bm.ServeHTTP(w, r) 149 return 150 } 151 s.m.ServeHTTP(w, r) 152 } 153 154 func BaseLink(baseURL *url.URL) func(target string, extras ...string) string { 155 return func(target string, extras ...string) string { 156 u, err := url.Parse(target) 157 if err != nil { 158 log.Printf("BaseLink: url.Parse(%q) = %v, %v", target, u, err) 159 return path.Join(append([]string{target}, extras...)...) 160 } 161 u.Path = path.Join(append([]string{u.Path}, extras...)...) 162 if baseURL == nil || u.IsAbs() { 163 return u.String() 164 } 165 u.Scheme = baseURL.Scheme 166 u.Host = baseURL.Host 167 u.Path = path.Join(baseURL.Path, u.Path) 168 return u.String() 169 } 170 } 171 172 func (s *Server) BaseLink(target string, extras ...string) string { 173 return BaseLink(s.baseURL)(target, extras...) 174 } 175 176 type homeResponse struct { 177 SiteHeader SiteHeader 178 ActiveWorkflows []db.Workflow 179 InactiveWorkflows []db.Workflow 180 Schedules []ScheduleEntry 181 } 182 183 // homeHandler renders the homepage. 184 func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) { 185 q := db.New(s.db) 186 187 names, err := q.WorkflowNames(r.Context()) 188 if err != nil { 189 log.Printf("homeHandler: %v", err) 190 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 191 return 192 } 193 var others []string 194 for _, name := range names { 195 if s.w.dh.Definition(name) != nil { 196 continue 197 } 198 others = append(others, name) 199 } 200 201 name := r.URL.Query().Get("name") 202 hr := &homeResponse{SiteHeader: s.header} 203 hr.SiteHeader.NameParam = name 204 var ws []db.Workflow 205 switch name { 206 case "all", "All", "": 207 ws, err = q.Workflows(r.Context()) 208 hr.SiteHeader.NameParam = "All Workflows" 209 hr.Schedules = s.scheduler.Entries() 210 case "others", "Others": 211 ws, err = q.WorkflowsByNames(r.Context(), others) 212 hr.SiteHeader.NameParam = "Others" 213 hr.Schedules = s.scheduler.Entries(others...) 214 default: 215 ws, err = q.WorkflowsByName(r.Context(), sql.NullString{String: name, Valid: true}) 216 hr.Schedules = s.scheduler.Entries(name) 217 } 218 if err != nil { 219 log.Printf("homeHandler: %v", err) 220 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 221 return 222 } 223 for _, w := range ws { 224 if ok := s.w.workflowRunning(w.ID); ok { 225 hr.ActiveWorkflows = append(hr.ActiveWorkflows, w) 226 continue 227 } 228 hr.InactiveWorkflows = append(hr.InactiveWorkflows, w) 229 } 230 out := bytes.Buffer{} 231 if err := s.homeTmpl.Execute(&out, hr); err != nil { 232 log.Printf("homeHandler: %v", err) 233 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 234 return 235 } 236 io.Copy(w, &out) 237 } 238 239 type showWorkflowResponse struct { 240 SiteHeader SiteHeader 241 Workflow db.Workflow 242 Tasks []db.TasksForWorkflowSortedRow 243 // TaskLogs is a map of all logs for a db.Task, keyed on 244 // (db.Task).Name 245 TaskLogs map[string][]db.TaskLog 246 } 247 248 func (s *Server) showWorkflowHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 249 id, err := uuid.Parse(params.ByName("id")) 250 if err != nil { 251 log.Printf("showWorkflowHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err) 252 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 253 return 254 } 255 resp, err := s.buildShowWorkflowResponse(r.Context(), id) 256 if err != nil { 257 log.Printf("showWorkflowHandler: %v", err) 258 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 259 return 260 } 261 out := bytes.Buffer{} 262 if err := s.mustLookup("show_workflow.html").Execute(&out, resp); err != nil { 263 log.Printf("showWorkflowHandler: %v", err) 264 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 265 return 266 } 267 io.Copy(w, &out) 268 } 269 270 func (s *Server) buildShowWorkflowResponse(ctx context.Context, id uuid.UUID) (*showWorkflowResponse, error) { 271 q := db.New(s.db) 272 w, err := q.Workflow(ctx, id) 273 if err != nil { 274 return nil, err 275 } 276 tasks, err := q.TasksForWorkflowSorted(ctx, id) 277 if err != nil { 278 return nil, err 279 } 280 tlogs, err := q.TaskLogsForWorkflow(ctx, id) 281 if err != nil { 282 return nil, err 283 } 284 sr := &showWorkflowResponse{ 285 SiteHeader: s.header, 286 TaskLogs: make(map[string][]db.TaskLog), 287 Tasks: tasks, 288 Workflow: w, 289 } 290 sr.SiteHeader.Subtitle = w.Name.String 291 sr.SiteHeader.NameParam = w.Name.String 292 for _, l := range tlogs { 293 sr.TaskLogs[l.TaskName] = append(sr.TaskLogs[l.TaskName], l) 294 } 295 return sr, nil 296 } 297 298 type newWorkflowResponse struct { 299 SiteHeader SiteHeader 300 Definitions map[string]*workflow.Definition 301 Name string 302 ScheduleTypes []ScheduleType 303 Schedule ScheduleType 304 ScheduleMinTime string 305 } 306 307 func (n *newWorkflowResponse) Selected() *workflow.Definition { 308 return n.Definitions[n.Name] 309 } 310 311 // newWorkflowHandler presents a form for creating a new workflow. 312 func (s *Server) newWorkflowHandler(w http.ResponseWriter, r *http.Request) { 313 out := bytes.Buffer{} 314 name := r.FormValue("workflow.name") 315 resp := &newWorkflowResponse{ 316 SiteHeader: s.header, 317 Definitions: s.w.dh.Definitions(), 318 Name: name, 319 ScheduleTypes: ScheduleTypes, 320 Schedule: ScheduleImmediate, 321 ScheduleMinTime: time.Now().UTC().Format(DatetimeLocalLayout), 322 } 323 resp.SiteHeader.NameParam = name 324 selectedSchedule := ScheduleType(r.FormValue("workflow.schedule")) 325 if slices.Contains(ScheduleTypes, selectedSchedule) { 326 resp.Schedule = selectedSchedule 327 } 328 if err := s.newWorkflowTmpl.Execute(&out, resp); err != nil { 329 log.Printf("newWorkflowHandler: %v", err) 330 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 331 return 332 } 333 io.Copy(w, &out) 334 } 335 336 // createWorkflowHandler persists a new workflow in the datastore, and 337 // starts the workflow in a goroutine. 338 func (s *Server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) { 339 name := r.FormValue("workflow.name") 340 d := s.w.dh.Definition(name) 341 if d == nil { 342 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 343 return 344 } 345 params := make(map[string]interface{}) 346 for _, p := range d.Parameters() { 347 switch p.Type().String() { 348 case "string": 349 v := r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name())) 350 if err := p.Valid(v); err != nil { 351 http.Error(w, err.Error(), http.StatusBadRequest) 352 return 353 } 354 params[p.Name()] = v 355 case "[]string": 356 v := r.Form[fmt.Sprintf("workflow.params.%s", p.Name())] 357 if err := p.Valid(v); err != nil { 358 http.Error(w, err.Error(), http.StatusBadRequest) 359 return 360 } 361 params[p.Name()] = v 362 case "task.Date": 363 t, err := time.Parse("2006-01-02", r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name()))) 364 if err != nil { 365 http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", p.Name(), err), http.StatusBadRequest) 366 return 367 } 368 v := task.Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} 369 if err := p.Valid(v); err != nil { 370 http.Error(w, err.Error(), http.StatusBadRequest) 371 return 372 } 373 params[p.Name()] = v 374 case "bool": 375 vStr := r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name())) 376 var v bool 377 switch vStr { 378 case "on": 379 v = true 380 case "": 381 v = false 382 default: 383 http.Error(w, fmt.Sprintf("parameter %q has an unexpected value %q", p.Name(), vStr), http.StatusBadRequest) 384 return 385 } 386 if err := p.Valid(v); err != nil { 387 http.Error(w, err.Error(), http.StatusBadRequest) 388 return 389 } 390 params[p.Name()] = v 391 default: 392 http.Error(w, fmt.Sprintf("parameter %q has an unsupported type %q", p.Name(), p.Type()), http.StatusInternalServerError) 393 return 394 } 395 } 396 sched := Schedule{Type: ScheduleType(r.FormValue("workflow.schedule"))} 397 if sched.Type != ScheduleImmediate { 398 switch sched.Type { 399 case ScheduleOnce: 400 t, err := time.ParseInLocation(DatetimeLocalLayout, r.FormValue("workflow.schedule.datetime"), time.UTC) 401 if err != nil || t.Before(time.Now()) { 402 http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", "workflow.schedule.datetime", err), http.StatusBadRequest) 403 return 404 } 405 sched.Once = t 406 case ScheduleCron: 407 sched.Cron = r.FormValue("workflow.schedule.cron") 408 } 409 if err := sched.Valid(); err != nil { 410 http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", "workflow.schedule", err), http.StatusBadRequest) 411 return 412 } 413 if _, err := s.scheduler.Create(r.Context(), sched, name, params); err != nil { 414 http.Error(w, fmt.Sprintf("failed to create schedule: %v", err), http.StatusInternalServerError) 415 return 416 } 417 http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther) 418 return 419 } 420 id, err := s.w.StartWorkflow(r.Context(), name, params, 0) 421 if err != nil { 422 log.Printf("s.w.StartWorkflow(%v, %v, %v): %v", r.Context(), d, params, err) 423 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 424 return 425 } 426 http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther) 427 } 428 429 func (s *Server) retryTaskHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 430 id, err := uuid.Parse(params.ByName("id")) 431 if err != nil { 432 log.Printf("retryTaskHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err) 433 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 434 return 435 } 436 if err := s.w.RetryTask(r.Context(), id, params.ByName("name")); err != nil { 437 log.Printf("s.w.RetryTask(_, %q): %v", id, err) 438 } 439 http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther) 440 } 441 442 func (s *Server) approveTaskHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 443 id, err := uuid.Parse(params.ByName("id")) 444 if err != nil { 445 log.Printf("approveTaskHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err) 446 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 447 return 448 } 449 q := db.New(s.db) 450 t, err := q.ApproveTask(r.Context(), db.ApproveTaskParams{ 451 WorkflowID: id, 452 Name: params.ByName("name"), 453 ApprovedAt: sql.NullTime{Time: time.Now(), Valid: true}, 454 }) 455 if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { 456 http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 457 return 458 } else if err != nil { 459 log.Printf("q.ApproveTask(_, %q) = %v, %v", id, t, err) 460 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 461 return 462 } 463 s.w.l.Logger(id, t.Name).Printf("USER-APPROVED") 464 http.Redirect(w, r, s.BaseLink("/workflows", id.String()), http.StatusSeeOther) 465 } 466 467 func (s *Server) stopWorkflowHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 468 id, err := uuid.Parse(params.ByName("id")) 469 if err != nil { 470 log.Printf("stopWorkflowHandler(_, _, %v) uuid.Parse(%v): %v", params, params.ByName("id"), err) 471 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 472 return 473 } 474 if !s.w.cancelWorkflow(id) { 475 http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 476 return 477 } 478 http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther) 479 } 480 481 func (s *Server) deleteScheduleHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) { 482 id, err := strconv.Atoi(params.ByName("id")) 483 if err != nil { 484 log.Printf("deleteScheduleHandler(_, _, %v) strconv.Atoi(%q) = %d, %v", params, params.ByName("id"), id, err) 485 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 486 return 487 } 488 err = s.scheduler.Delete(r.Context(), id) 489 if err == ErrScheduleNotFound { 490 http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 491 return 492 } else if err != nil { 493 log.Printf("deleteScheduleHandler(_, _, %v) s.scheduler.Delete(_, %d) = %v", params, id, err) 494 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 495 return 496 } 497 498 http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther) 499 } 500 501 // resultDetail contains unmarshalled results from a workflow task, or 502 // workflow output. Only one field is expected to be populated. 503 // 504 // The UI implementation uses Kind to determine which result type to 505 // render. 506 type resultDetail struct { 507 Artifact artifact 508 Outputs map[string]*resultDetail 509 JSON map[string]interface{} 510 String string 511 Number float64 512 Slice []*resultDetail 513 Boolean bool 514 Unknown interface{} 515 } 516 517 func (r *resultDetail) Kind() string { 518 v := reflect.ValueOf(r) 519 if v.IsZero() { 520 return "" 521 } 522 v = v.Elem() 523 for i := 0; i < v.NumField(); i++ { 524 if v.Field(i).IsZero() { 525 continue 526 } 527 return v.Type().Field(i).Name 528 } 529 return "" 530 } 531 532 func (r *resultDetail) UnmarshalJSON(result []byte) error { 533 v := reflect.ValueOf(r).Elem() 534 for i := 0; i < v.NumField(); i++ { 535 f := v.Field(i) 536 if err := json.Unmarshal(result, f.Addr().Interface()); err == nil { 537 if f.IsZero() { 538 continue 539 } 540 return nil 541 } 542 } 543 return errors.New("unknown result type") 544 } 545 546 func unmarshalResultDetail(result string) *resultDetail { 547 ret := new(resultDetail) 548 if err := json.Unmarshal([]byte(result), &ret); err != nil { 549 ret.String = err.Error() 550 } 551 return ret 552 } 553 554 func prettySize(size int) string { 555 const mb = 1 << 20 556 if size == 0 { 557 return "" 558 } 559 if size < mb { 560 // All Go releases are >1mb, but handle this case anyway. 561 return fmt.Sprintf("%v bytes", size) 562 } 563 return fmt.Sprintf("%.0fMiB", float64(size)/mb) 564 }