Skip to content

Commit ce7952f

Browse files
committed
dashboard/app: send coverage report to ns-defined email
We periodically send coverage reports for the regressions detection.
1 parent 254b4cd commit ce7952f

File tree

9 files changed

+308
-25
lines changed

9 files changed

+308
-25
lines changed

dashboard/app/app_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,31 @@ var testConfig = &GlobalConfig{
579579
FindBugOriginTrees: true,
580580
RetestMissingBackports: true,
581581
},
582+
"coverage-tests": {
583+
Coverage: &CoverageConfig{
584+
EmailRegressionsTo: "test@test.test",
585+
RegressionThreshold: 1,
586+
},
587+
AccessLevel: AccessPublic,
588+
Key: "coveragetestskeycoveragetestskeycoveragetestskey",
589+
Repos: []KernelRepo{
590+
{
591+
URL: "git://syzkaller.org/test.git",
592+
Branch: "main",
593+
Alias: "main",
594+
},
595+
},
596+
Reporting: []Reporting{
597+
{
598+
Name: "non-public",
599+
DailyLimit: 1000,
600+
Filter: func(bug *Bug) FilterResult {
601+
return FilterReport
602+
},
603+
Config: &TestConfig{Index: 1},
604+
},
605+
},
606+
},
582607
},
583608
}
584609

dashboard/app/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ type ACLItem struct {
155155
}
156156

157157
const defaultDashboardClientName = "coverage-merger"
158+
const defaultRegressionThreshold = 50
158159

159160
type CoverageConfig struct {
160161
BatchProject string
@@ -167,6 +168,15 @@ type CoverageConfig struct {
167168
// WebGitURI specifies where can we get the kernel file source code directly from AppEngine.
168169
// It may be the Git or Gerrit compatible repo.
169170
WebGitURI string
171+
172+
// EmailRegressionsTo species the regressions recipient.
173+
// If empty, regression analysis is disabled.
174+
EmailRegressionsTo string
175+
176+
// RegressionThreshold is a minimal basic block coverage drop in a file.
177+
// The amount of files in the dir and other factors do not matter.
178+
// Defaults to defaultRegressionThreshold.
179+
RegressionThreshold int
170180
}
171181

172182
// DiscussionEmailConfig defines the correspondence between an email and a DiscussionSource.
@@ -585,6 +595,16 @@ func checkNamespace(ns string, cfg *Config, namespaces, clientNames map[string]b
585595
checkKernelRepos(ns, cfg, cfg.Repos)
586596
checkNamespaceReporting(ns, cfg)
587597
checkSubsystems(ns, cfg)
598+
checkCoverageConfig(ns, cfg)
599+
}
600+
601+
func checkCoverageConfig(ns string, cfg *Config) {
602+
if cfg.Coverage == nil || cfg.Coverage.EmailRegressionsTo == "" {
603+
return
604+
}
605+
if _, err := mail.ParseAddress(cfg.Coverage.EmailRegressionsTo); err != nil {
606+
panic(fmt.Sprintf("bad cfg.Coverage.EmailRegressionsTo in '%s': %s", ns, err.Error()))
607+
}
588608
}
589609

590610
func checkSubsystems(ns string, cfg *Config) {

dashboard/app/coverage.go

Lines changed: 75 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/google/syzkaller/pkg/coveragedb"
1818
"github.com/google/syzkaller/pkg/coveragedb/spannerclient"
1919
"github.com/google/syzkaller/pkg/covermerger"
20+
"github.com/google/syzkaller/pkg/html/urlutil"
2021
"github.com/google/syzkaller/pkg/validator"
2122
"google.golang.org/appengine/v2"
2223
)
@@ -71,6 +72,59 @@ func handleSubsystemsCoverageHeatmap(c context.Context, w http.ResponseWriter, r
7172
return handleHeatmap(c, w, r, cover.DoSubsystemsHeatMapStyleBodyJS)
7273
}
7374

75+
type covPageParam int
76+
77+
const (
78+
// keep-sorted start
79+
CommitHash covPageParam = iota
80+
DateTo
81+
FilePath
82+
ManagerName
83+
MinCoverLinesDrop
84+
OrderByCoverDrop
85+
PeriodCount
86+
PeriodType
87+
SubsystemName
88+
UniqueOnly
89+
// keep-sorted end
90+
)
91+
92+
var covPageParams = map[covPageParam]string{
93+
// keep-sorted start
94+
CommitHash: "commit",
95+
DateTo: "dateto",
96+
FilePath: "filepath",
97+
ManagerName: "manager",
98+
MinCoverLinesDrop: "min-cover-lines-drop",
99+
OrderByCoverDrop: "order-by-cover-lines-drop",
100+
PeriodCount: "period_count",
101+
PeriodType: "period",
102+
SubsystemName: "subsystem",
103+
UniqueOnly: "unique-only",
104+
// keep-sorted end
105+
}
106+
107+
func coveragePageLink(ns, periodType, dateTo string, minDrop, periodCount int, orderByCoverDrop bool) string {
108+
if periodType == "" {
109+
periodType = coveragedb.MonthPeriod
110+
}
111+
url := "/" + ns + "/coverage"
112+
url = urlutil.SetParam(url, covPageParams[PeriodType], periodType)
113+
if periodCount != 0 {
114+
url = urlutil.SetParam(url, covPageParams[PeriodCount], strconv.Itoa(periodCount))
115+
}
116+
if dateTo != "" {
117+
url = urlutil.SetParam(url, covPageParams[DateTo], dateTo)
118+
}
119+
if minDrop > 0 {
120+
url = urlutil.SetParam(url, covPageParams[MinCoverLinesDrop], strconv.Itoa(minDrop))
121+
}
122+
if orderByCoverDrop {
123+
url = urlutil.SetParam(url, covPageParams[OrderByCoverDrop], "1")
124+
}
125+
return url
126+
}
127+
74128
func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f funcStyleBodyJS) error {
75129
hdr, err := commonHeader(c, r, w, "")
76130
if err != nil {
@@ -80,10 +134,10 @@ func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f
80134
if nsConfig.Coverage == nil {
81135
return ErrClientNotFound
82136
}
83-
ss := r.FormValue("subsystem")
84-
manager := r.FormValue("manager")
137+
ss := r.FormValue(covPageParams[SubsystemName])
138+
manager := r.FormValue(covPageParams[ManagerName])
85139

86-
periodType := r.FormValue("period")
140+
periodType := r.FormValue(covPageParams[PeriodType])
87141
if periodType == "" {
88142
periodType = coveragedb.DayPeriod
89143
}
@@ -94,7 +148,7 @@ func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f
94148
periodType, ErrClientBadRequest)
95149
}
96150

97-
periodCount := r.FormValue("period_count")
151+
periodCount := r.FormValue(covPageParams[PeriodCount])
98152
if periodCount == "" {
99153
periodCount = "4"
100154
}
@@ -104,7 +158,7 @@ func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f
104158
}
105159

106160
dateTo := civil.DateOf(timeNow(c))
107-
if customDate := r.FormValue("dateto"); customDate != "" {
161+
if customDate := r.FormValue(covPageParams[DateTo]); customDate != "" {
108162
if dateTo, err = civil.ParseDate(customDate); err != nil {
109163
return fmt.Errorf("civil.ParseDate(%s): %w", customDate, err)
110164
}
@@ -126,12 +180,12 @@ func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f
126180
slices.Sort(managers)
127181
slices.Sort(subsystems)
128182

129-
onlyUnique := r.FormValue("unique-only") == "1"
130-
orderByCoverLinesDrop := r.FormValue("order-by-cover-lines-drop") == "1"
183+
onlyUnique := r.FormValue(covPageParams[UniqueOnly]) == "1"
184+
orderByCoverLinesDrop := r.FormValue(covPageParams[OrderByCoverDrop]) == "1"
131185
// Prefixing "0" we don't fail on empty string.
132-
minCoverLinesDrop, err := strconv.Atoi("0" + r.FormValue("min-cover-lines-drop"))
186+
minCoverLinesDrop, err := strconv.Atoi("0" + r.FormValue(covPageParams[MinCoverLinesDrop]))
133187
if err != nil {
134-
return fmt.Errorf("min-cover-lines-drop should be integer")
188+
return fmt.Errorf(covPageParams[MinCoverLinesDrop] + " should be integer")
135189
}
136190

137191
var style template.CSS
@@ -182,18 +236,18 @@ func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Reques
182236
if nsConfig.Coverage == nil || nsConfig.Coverage.WebGitURI == "" {
183237
return ErrClientNotFound
184238
}
185-
dateToStr := r.FormValue("dateto")
186-
periodType := r.FormValue("period")
187-
targetCommit := r.FormValue("commit")
188-
kernelFilePath := r.FormValue("filepath")
189-
manager := r.FormValue("manager")
239+
dateToStr := r.FormValue(covPageParams[DateTo])
240+
periodType := r.FormValue(covPageParams[PeriodType])
241+
targetCommit := r.FormValue(covPageParams[CommitHash])
242+
kernelFilePath := r.FormValue(covPageParams[FilePath])
243+
manager := r.FormValue(covPageParams[ManagerName])
190244
if err := validator.AnyError("input validation failed",
191-
validator.TimePeriodType(periodType, "period"),
192-
validator.CommitHash(targetCommit, "commit"),
193-
validator.KernelFilePath(kernelFilePath, "filepath"),
245+
validator.TimePeriodType(periodType, covPageParams[PeriodType]),
246+
validator.CommitHash(targetCommit, covPageParams[CommitHash]),
247+
validator.KernelFilePath(kernelFilePath, covPageParams[FilePath]),
194248
validator.AnyOk(
195-
validator.Allowlisted(manager, []string{"", "*"}, "manager"),
196-
validator.ManagerName(manager, "manager")),
249+
validator.Allowlisted(manager, []string{"", "*"}, covPageParams[ManagerName]),
250+
validator.ManagerName(manager, covPageParams[ManagerName])),
197251
); err != nil {
198252
return fmt.Errorf("%w: %w", err, ErrClientBadRequest)
199253
}
@@ -205,7 +259,7 @@ func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Reques
205259
if err != nil {
206260
return fmt.Errorf("coveragedb.MakeTimePeriod: %w", err)
207261
}
208-
onlyUnique := r.FormValue("unique-only") == "1"
262+
onlyUnique := r.FormValue(covPageParams[UniqueOnly]) == "1"
209263
mainNsRepo, _ := nsConfig.mainRepoBranch()
210264
client := GetCoverageDBClient(c)
211265
if client == nil {
@@ -268,7 +322,7 @@ func handleCoverageGraph(c context.Context, w http.ResponseWriter, r *http.Reque
268322
if nsConfig.Coverage == nil {
269323
return ErrClientNotFound
270324
}
271-
periodType := r.FormValue("period")
325+
periodType := r.FormValue(covPageParams[PeriodType])
272326
if periodType == "" {
273327
periodType = coveragedb.QuarterPeriod
274328
}

dashboard/app/cron.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ cron:
3333
# Export reproducers every week.
3434
- url: /cron/batch_db_export
3535
schedule: every saturday 00:00
36+
# Monthly coverage reports are regenerated every week.
37+
# Coverage data propagation may take up to ~48 hours.
38+
# 7 days + 2 days = 9 days is the maximum expected delay to guarantee monthly coverage data will not change.
39+
# We use 15 for convenience here.
40+
- url: /cron/email_coverage_reports
41+
schedule: 15 of month 00:00

dashboard/app/reporting_email.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import (
2020
"text/tabwriter"
2121
"time"
2222

23+
"cloud.google.com/go/civil"
2324
"github.com/google/syzkaller/dashboard/dashapi"
25+
"github.com/google/syzkaller/pkg/cover"
26+
"github.com/google/syzkaller/pkg/coveragedb"
2427
"github.com/google/syzkaller/pkg/email"
2528
"github.com/google/syzkaller/pkg/email/lore"
2629
"github.com/google/syzkaller/pkg/html"
@@ -34,6 +37,7 @@ import (
3437
// Email reporting interface.
3538

3639
func initEmailReporting() {
40+
http.HandleFunc("/cron/email_coverage_reports", handleCoverageReports)
3741
http.HandleFunc("/cron/email_poll", handleEmailPoll)
3842
http.HandleFunc("/_ah/mail/", handleIncomingMail)
3943
http.HandleFunc("/_ah/bounce", handleEmailBounce)
@@ -102,6 +106,115 @@ func (cfg *EmailConfig) Validate() error {
102106
return nil
103107
}
104108

109+
// handleCoverageReports sends a coverage report for the two full months preceding the current one.
110+
// Assuming it is called June 15, the monthly report will cover April-May diff.
111+
func handleCoverageReports(w http.ResponseWriter, r *http.Request) {
112+
ctx := r.Context()
113+
curHostPort := r.URL.Host
114+
targetDate := civil.DateOf(timeNow(ctx)).AddMonths(-1)
115+
periods, err := coveragedb.GenNPeriodsTill(2, targetDate, "month")
116+
if err != nil {
117+
msg := fmt.Sprintf("error generating coverage report: %s", err.Error())
118+
log.Errorf(ctx, "%s", msg)
119+
http.Error(w, "%s: %w", http.StatusBadRequest)
120+
return
121+
}
122+
wg := sync.WaitGroup{}
123+
for nsName, nsConfig := range getConfig(ctx).Namespaces {
124+
if nsConfig.Coverage == nil || nsConfig.Coverage.EmailRegressionsTo == "" {
125+
continue
126+
}
127+
emailTo := nsConfig.Coverage.EmailRegressionsTo
128+
minDrop := defaultRegressionThreshold
129+
if nsConfig.Coverage.RegressionThreshold > 0 {
130+
minDrop = nsConfig.Coverage.RegressionThreshold
131+
}
132+
133+
wg.Add(1)
134+
go func() {
135+
defer wg.Done()
136+
if err := sendNsCoverageReport(ctx, nsName, emailTo, curHostPort, periods, minDrop); err != nil {
137+
msg := fmt.Sprintf("error generating coverage report for ns '%s': %s", nsName, err.Error())
138+
log.Errorf(ctx, "%s", msg)
139+
return
140+
}
141+
}()
142+
}
143+
wg.Wait()
144+
}
145+
146+
func sendNsCoverageReport(ctx context.Context, ns, email, domain string,
147+
period []coveragedb.TimePeriod, minDrop int) error {
148+
var days int
149+
for _, p := range period {
150+
days += p.Days
151+
}
152+
periodFrom := fmt.Sprintf("%s %d", period[0].DateTo.Month.String(), period[0].DateTo.Year)
153+
periodTo := fmt.Sprintf("%s %d", period[1].DateTo.Month.String(), period[1].DateTo.Year)
154+
table, err := coverageTable(ctx, ns, period, minDrop)
155+
if err != nil {
156+
return fmt.Errorf("coverageTable: %w", err)
157+
}
158+
args := struct {
159+
Namespace string
160+
PeriodFrom string
161+
PeriodFromDays int
162+
PeriodTo string
163+
PeriodToDays int
164+
Link string
165+
Table string
166+
}{
167+
Namespace: ns,
168+
PeriodFrom: periodFrom,
169+
PeriodFromDays: period[0].Days,
170+
PeriodTo: periodTo,
171+
PeriodToDays: period[1].Days,
172+
Link: fmt.Sprintf("https://%s%s", domain,
173+
coveragePageLink(ns, period[1].Type, period[1].DateTo.String(), minDrop, 2, true)),
174+
Table: table,
175+
}
176+
title := fmt.Sprintf("%s coverage regression (%s)->(%s)", ns, periodFrom, periodTo)
177+
err = sendMailTemplate(ctx, &mailSendParams{
178+
templateName: "mail_ns_coverage.txt",
179+
templateArg: args,
180+
title: title,
181+
cfg: &EmailConfig{
182+
Email: email,
183+
},
184+
reportID: "coverage-report",
185+
})
186+
if err != nil {
187+
err2 := fmt.Errorf("error generating coverage report: %w", err)
188+
log.Errorf(ctx, "%s", err2.Error())
189+
return err2
190+
}
191+
return nil
192+
}
193+
194+
func coverageTable(ctx context.Context, ns string, fromTo []coveragedb.TimePeriod, minDrop int) (string, error) {
195+
covAndDates, err := coveragedb.FilesCoverageWithDetails(
196+
ctx,
197+
GetCoverageDBClient(ctx),
198+
&coveragedb.SelectScope{
199+
Ns: ns,
200+
Periods: fromTo,
201+
},
202+
false)
203+
if err != nil {
204+
return "", fmt.Errorf("coveragedb.FilesCoverageWithDetails: %w", err)
205+
}
206+
templData := cover.FilesCoverageToTemplateData(covAndDates)
207+
cover.FormatResult(templData, cover.Format{
208+
OrderByCoveredLinesDrop: true,
209+
FilterMinCoveredLinesDrop: minDrop,
210+
})
211+
res := "Blocks diff,\tPath\n"
212+
templData.Root.Visit(func(path string, summary int64) {
213+
res += fmt.Sprintf("% 11d\t%s\n", summary, path)
214+
})
215+
return res, nil
216+
}
217+
105218
// handleEmailPoll is called by cron and sends emails for new bugs, if any.
106219
func handleEmailPoll(w http.ResponseWriter, r *http.Request) {
107220
c := appengine.NewContext(r)

0 commit comments

Comments
 (0)