github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/dashboard/app/coverage.go (about) 1 // Copyright 2025 syzkaller project authors. All rights reserved. 2 // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. 3 4 package main 5 6 import ( 7 "context" 8 "fmt" 9 "html/template" 10 "net/http" 11 "os" 12 "slices" 13 "strconv" 14 "strings" 15 16 "cloud.google.com/go/civil" 17 "github.com/google/syzkaller/pkg/cover" 18 "github.com/google/syzkaller/pkg/coveragedb" 19 "github.com/google/syzkaller/pkg/coveragedb/spannerclient" 20 "github.com/google/syzkaller/pkg/covermerger" 21 "github.com/google/syzkaller/pkg/html/urlutil" 22 "github.com/google/syzkaller/pkg/validator" 23 "google.golang.org/appengine/v2" 24 "google.golang.org/appengine/v2/log" 25 ) 26 27 var coverageDBClient spannerclient.SpannerClient 28 29 func initCoverageDB() { 30 if !appengine.IsAppEngine() { 31 // It is a test environment. 32 // Use setCoverageDBClient to specify the coveragedb mock or emulator in every test. 33 return 34 } 35 projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") 36 var err error 37 coverageDBClient, err = spannerclient.NewClient(context.Background(), projectID) 38 if err != nil { 39 panic("spanner.NewClient: " + err.Error()) 40 } 41 } 42 43 var keyCoverageDBClient = "coveragedb client key" 44 45 func getCoverageDBClient(ctx context.Context) spannerclient.SpannerClient { 46 ctxClient, _ := ctx.Value(&keyCoverageDBClient).(spannerclient.SpannerClient) 47 if ctxClient == nil && coverageDBClient == nil { 48 panic("attempt to get coverage db client before it was set in tests") 49 } 50 if ctxClient != nil { 51 return ctxClient 52 } 53 return coverageDBClient 54 } 55 56 type funcStyleBodyJS func( 57 ctx context.Context, client spannerclient.SpannerClient, 58 scope *coveragedb.SelectScope, onlyUnique bool, sss, managers []string, dataFilters cover.Format, 59 ) (template.CSS, template.HTML, template.HTML, error) 60 61 type coverageHeatmapParams struct { 62 manager string 63 subsystem string 64 onlyUnique bool 65 periodType string 66 nPeriods int 67 dateTo civil.Date 68 cover.Format 69 } 70 71 const minPeriodsOnThePage = 1 72 const maxPeriodsOnThePage = 12 73 74 func makeHeatmapParams(ctx context.Context, r *http.Request) (*coverageHeatmapParams, error) { 75 onlyUnique := getParam[bool](r, UniqueOnly.ParamName(), false) 76 periodType := getParam[string](r, PeriodType.ParamName()) 77 if !slices.Contains(coveragedb.AllPeriods, periodType) { 78 return nil, fmt.Errorf("only {%s} are allowed, but received %s instead, %w", 79 strings.Join(coveragedb.AllPeriods, ", "), periodType, ErrClientBadRequest) 80 } 81 nPeriods := getParam[int](r, PeriodCount.ParamName(), 4) 82 if nPeriods > maxPeriodsOnThePage || nPeriods < minPeriodsOnThePage { 83 return nil, fmt.Errorf("periods_count is wrong, expected [%d, %d]", 84 minPeriodsOnThePage, maxPeriodsOnThePage) 85 } 86 87 return &coverageHeatmapParams{ 88 manager: getParam[string](r, ManagerName.ParamName()), 89 subsystem: getParam[string](r, SubsystemName.ParamName()), 90 onlyUnique: onlyUnique, 91 periodType: periodType, 92 nPeriods: nPeriods, 93 dateTo: getParam[civil.Date](r, DateTo.ParamName(), civil.DateOf(timeNow(ctx))), 94 Format: cover.Format{ 95 DropCoveredLines0: onlyUnique, 96 OrderByCoveredLinesDrop: getParam[bool](r, OrderByCoverDrop.ParamName()), 97 FilterMinCoveredLinesDrop: getParam[int](r, MinCoverLinesDrop.ParamName()), 98 }, 99 }, nil 100 } 101 102 func getParam[T int | string | bool | civil.Date](r *http.Request, name string, orDefault ...T) T { 103 var def T 104 if len(orDefault) > 0 { 105 def = orDefault[0] 106 } 107 if r.FormValue(name) == "" { 108 return def 109 } 110 var t T 111 return extractVal(t, r.FormValue(name)).(T) 112 } 113 114 func extractVal(t interface{}, val string) interface{} { 115 switch t.(type) { 116 case int: 117 res, _ := strconv.Atoi(val) 118 return res 119 case string: 120 return val 121 case bool: 122 res, _ := strconv.ParseBool(val) 123 return res 124 case civil.Date: 125 res, _ := civil.ParseDate(val) 126 return res 127 } 128 panic("unsupported type") 129 } 130 131 func handleCoverageHeatmap(c context.Context, w http.ResponseWriter, r *http.Request) error { 132 hdr, err := commonHeader(c, r, w, "") 133 if err != nil { 134 return err 135 } 136 params, err := makeHeatmapParams(c, r) 137 if err != nil { 138 return fmt.Errorf("%s: %w", err.Error(), ErrClientBadRequest) 139 } 140 if getParam[bool](r, "jsonl") { 141 ns := hdr.Namespace 142 repo, _ := getNsConfig(c, ns).mainRepoBranch() 143 w.Header().Set("Content-Type", "application/json") 144 return writeExtAPICoverageFor(c, w, ns, repo, params) 145 } 146 return handleHeatmap(c, w, hdr, params, cover.DoHeatMapStyleBodyJS) 147 } 148 149 func handleSubsystemsCoverageHeatmap(c context.Context, w http.ResponseWriter, r *http.Request) error { 150 hdr, err := commonHeader(c, r, w, "") 151 if err != nil { 152 return err 153 } 154 params, err := makeHeatmapParams(c, r) 155 if err != nil { 156 return fmt.Errorf("%s: %w", err.Error(), ErrClientBadRequest) 157 } 158 return handleHeatmap(c, w, hdr, params, cover.DoSubsystemsHeatMapStyleBodyJS) 159 } 160 161 type covPageParam string 162 163 func (p covPageParam) ParamName() string { 164 return string(p) 165 } 166 167 const ( 168 // keep-sorted start 169 CommitHash = covPageParam("commit") 170 DateTo = covPageParam("dateto") 171 FilePath = covPageParam("filepath") 172 ManagerName = covPageParam("manager") 173 MinCoverLinesDrop = covPageParam("min-cover-lines-drop") 174 OrderByCoverDrop = covPageParam("order-by-cover-lines-drop") 175 PeriodCount = covPageParam("period_count") 176 PeriodType = covPageParam("period") 177 SubsystemName = covPageParam("subsystem") 178 UniqueOnly = covPageParam("unique-only") 179 // keep-sorted end 180 ) 181 182 func coveragePageLink(ns, periodType, dateTo string, minDrop, periodCount int, orderByCoverDrop bool) string { 183 if periodType == "" { 184 periodType = coveragedb.MonthPeriod 185 } 186 url := "/" + ns + "/coverage" 187 url = urlutil.SetParam(url, PeriodType.ParamName(), periodType) 188 if periodCount != 0 { 189 url = urlutil.SetParam(url, PeriodCount.ParamName(), strconv.Itoa(periodCount)) 190 } 191 if dateTo != "" { 192 url = urlutil.SetParam(url, DateTo.ParamName(), dateTo) 193 } 194 if minDrop > 0 { 195 url = urlutil.SetParam(url, MinCoverLinesDrop.ParamName(), strconv.Itoa(minDrop)) 196 } 197 if orderByCoverDrop { 198 url = urlutil.SetParam(url, OrderByCoverDrop.ParamName(), "1") 199 } 200 return url 201 } 202 203 func handleHeatmap(c context.Context, w http.ResponseWriter, hdr *uiHeader, p *coverageHeatmapParams, 204 f funcStyleBodyJS) error { 205 nsConfig := getNsConfig(c, hdr.Namespace) 206 if nsConfig.Coverage == nil { 207 return ErrClientNotFound 208 } 209 210 periods, err := coveragedb.GenNPeriodsTill(p.nPeriods, p.dateTo, p.periodType) 211 if err != nil { 212 return fmt.Errorf("%s: %w", err.Error(), ErrClientBadRequest) 213 } 214 managers, err := CachedManagerList(c, hdr.Namespace) 215 if err != nil { 216 return err 217 } 218 var subsystems []string 219 if ssService := getNsConfig(c, hdr.Namespace).Subsystems.Service; ssService != nil { 220 for _, s := range ssService.List() { 221 subsystems = append(subsystems, s.Name) 222 } 223 } 224 slices.Sort(managers) 225 slices.Sort(subsystems) 226 227 var style template.CSS 228 var body, js template.HTML 229 if style, body, js, err = f(c, getCoverageDBClient(c), 230 &coveragedb.SelectScope{ 231 Ns: hdr.Namespace, 232 Subsystem: p.subsystem, 233 Manager: p.manager, 234 Periods: periods, 235 }, 236 p.onlyUnique, subsystems, managers, p.Format, 237 ); err != nil { 238 return fmt.Errorf("failed to generate heatmap: %w", err) 239 } 240 return serveTemplate(w, "custom_content.html", struct { 241 Header *uiHeader 242 *cover.StyleBodyJS 243 }{ 244 Header: hdr, 245 StyleBodyJS: &cover.StyleBodyJS{ 246 Style: style, 247 Body: body, 248 JS: js, 249 }, 250 }) 251 } 252 253 func makeProxyURIProvider(url string) covermerger.FuncProxyURI { 254 return func(filePath, commit string) string { 255 // Parameter format=TEXT is ignored by git servers but is processed by gerrit servers. 256 // Gerrit returns base64 encoded data. 257 // Git return the plain text data. 258 return fmt.Sprintf("%s/%s/%s?format=TEXT", url, commit, filePath) 259 } 260 } 261 262 func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Request) error { 263 hdr, err := commonHeader(c, r, w, "") 264 if err != nil { 265 return err 266 } 267 nsConfig := getNsConfig(c, hdr.Namespace) 268 if nsConfig.Coverage == nil || nsConfig.Coverage.WebGitURI == "" { 269 return ErrClientNotFound 270 } 271 dateToStr := r.FormValue(DateTo.ParamName()) 272 periodType := r.FormValue(PeriodType.ParamName()) 273 targetCommit := r.FormValue(CommitHash.ParamName()) 274 kernelFilePath := r.FormValue(FilePath.ParamName()) 275 manager := r.FormValue(ManagerName.ParamName()) 276 if err := validator.AnyError("input validation failed", 277 validator.TimePeriodType(periodType, PeriodType.ParamName()), 278 validator.CommitHash(targetCommit, CommitHash.ParamName()), 279 validator.KernelFilePath(kernelFilePath, FilePath.ParamName()), 280 validator.AnyOk( 281 validator.Allowlisted(manager, []string{"", "*"}, ManagerName.ParamName()), 282 validator.ManagerName(manager, ManagerName.ParamName())), 283 ); err != nil { 284 return fmt.Errorf("%w: %w", err, ErrClientBadRequest) 285 } 286 targetDate, err := civil.ParseDate(dateToStr) 287 if err != nil { 288 return fmt.Errorf("%w: civil.ParseDate(%s): %w", ErrClientBadRequest, dateToStr, err) 289 } 290 tp, err := coveragedb.MakeTimePeriod(targetDate, periodType) 291 if err != nil { 292 return fmt.Errorf("coveragedb.MakeTimePeriod: %w", err) 293 } 294 mainNsRepo, _ := nsConfig.mainRepoBranch() 295 client := getCoverageDBClient(c) 296 if client == nil { 297 return fmt.Errorf("spannerdb client is nil") 298 } 299 hitLines, hitCounts, err := coveragedb.ReadLinesHitCount( 300 c, client, hdr.Namespace, targetCommit, kernelFilePath, manager, tp) 301 covMap := coveragedb.MakeCovMap(hitLines, hitCounts) 302 if err != nil { 303 return fmt.Errorf("coveragedb.ReadLinesHitCount(%s): %w", manager, err) 304 } 305 if getParam[bool](r, UniqueOnly.ParamName()) { 306 // This request is expected to be made second by tests. 307 // Moving it to goroutine don't forget to change multiManagerCovDBFixture. 308 allHitLines, allHitCounts, err := coveragedb.ReadLinesHitCount( 309 c, client, hdr.Namespace, targetCommit, kernelFilePath, "*", tp) 310 if err != nil { 311 return fmt.Errorf("coveragedb.ReadLinesHitCount(*): %w", err) 312 } 313 covMap = coveragedb.UniqCoverage(coveragedb.MakeCovMap(allHitLines, allHitCounts), covMap) 314 } 315 316 webGit := getWebGit(c) // Get mock if available. 317 if webGit == nil { 318 webGit = covermerger.MakeWebGit(makeProxyURIProvider(nsConfig.Coverage.WebGitURI)) 319 } 320 321 content, err := cover.RendFileCoverage( 322 mainNsRepo, 323 targetCommit, 324 kernelFilePath, 325 webGit, 326 &covermerger.MergeResult{HitCounts: covMap}, 327 cover.DefaultHTMLRenderConfig()) 328 if err != nil { 329 return fmt.Errorf("cover.RendFileCoverage: %w", err) 330 } 331 w.Header().Set("Content-Type", "text/html") 332 w.Write([]byte(content)) 333 return nil 334 } 335 336 var keyWebGit = "file content provider" 337 338 func setWebGit(ctx context.Context, provider covermerger.FileVersProvider) context.Context { 339 return context.WithValue(ctx, &keyWebGit, provider) 340 } 341 342 func getWebGit(ctx context.Context) covermerger.FileVersProvider { 343 res, _ := ctx.Value(&keyWebGit).(covermerger.FileVersProvider) 344 return res 345 } 346 347 func handleCoverageGraph(c context.Context, w http.ResponseWriter, r *http.Request) error { 348 hdr, err := commonHeader(c, r, w, "") 349 if err != nil { 350 return err 351 } 352 nsConfig := getNsConfig(c, hdr.Namespace) 353 if nsConfig.Coverage == nil { 354 return ErrClientNotFound 355 } 356 periodType := r.FormValue(PeriodType.ParamName()) 357 if periodType == "" { 358 periodType = coveragedb.QuarterPeriod 359 } 360 if periodType != coveragedb.QuarterPeriod && periodType != coveragedb.MonthPeriod { 361 return fmt.Errorf("only quarter and month are allowed, but received %s instead", periodType) 362 } 363 hist, err := MergedCoverage(c, getCoverageDBClient(c), hdr.Namespace, periodType) 364 if err != nil { 365 return err 366 } 367 periodEndDates, err := coveragedb.GenNPeriodsTill(12, civil.DateOf(timeNow(c)), periodType) 368 if err != nil { 369 return err 370 } 371 cols := []uiGraphColumn{} 372 for _, periodEndDate := range periodEndDates { 373 date := periodEndDate.DateTo.String() 374 if _, ok := hist.covered[date]; !ok || hist.instrumented[date] == 0 { 375 cols = append(cols, uiGraphColumn{Hint: date, Vals: []uiGraphValue{{IsNull: true}}}) 376 } else { 377 val := float32(hist.covered[date]) / float32(hist.instrumented[date]) 378 cols = append(cols, uiGraphColumn{ 379 Hint: date, 380 Annotation: val, 381 Vals: []uiGraphValue{{Val: val}}, 382 }) 383 } 384 } 385 data := &uiHistogramPage{ 386 Title: hdr.Namespace + " coverage", 387 Header: hdr, 388 Graph: &uiGraph{ 389 Headers: []uiGraphHeader{ 390 {Name: "Total", Color: "Red"}, 391 }, 392 Columns: cols, 393 }, 394 } 395 return serveTemplate(w, "graph_histogram.html", data) 396 } 397 398 func handleUpdateCoverDBSubsystems(w http.ResponseWriter, r *http.Request) { 399 ctx := r.Context() 400 for ns, nsConfig := range getConfig(ctx).Namespaces { 401 service := nsConfig.Subsystems.Service 402 if service == nil { 403 continue 404 } 405 sss := service.List() 406 updatedRecords, err := coveragedb.RegenerateSubsystems(ctx, ns, sss, coverageDBClient) 407 if err != nil { 408 httpErr := fmt.Errorf("ns %s: %w", ns, err) 409 log.Errorf(ctx, "%s", httpErr.Error()) 410 http.Error(w, httpErr.Error(), http.StatusInternalServerError) 411 return 412 } 413 log.Infof(ctx, "%s: %v records updated\n", ns, updatedRecords) 414 fmt.Fprintf(w, "%s: %v records updated\n", ns, updatedRecords) 415 } 416 }