| // Copyright 2023 syzkaller project authors. All rights reserved. |
| // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/google/syzkaller/dashboard/dashapi" |
| "github.com/google/syzkaller/pkg/hash" |
| "google.golang.org/appengine/v2" |
| db "google.golang.org/appengine/v2/datastore" |
| "google.golang.org/appengine/v2/log" |
| ) |
| |
| // reportingPollBugLists is called by backends to get bug lists that need to be reported. |
| func reportingPollBugLists(c context.Context, typ string) []*dashapi.BugListReport { |
| state, err := loadReportingState(c) |
| if err != nil { |
| log.Errorf(c, "%v", err) |
| return nil |
| } |
| registry, err := makeSubsystemReportRegistry(c) |
| if err != nil { |
| log.Errorf(c, "%v", err) |
| return nil |
| } |
| ret := []*dashapi.BugListReport{} |
| for ns, nsConfig := range getConfig(c).Namespaces { |
| rConfig := nsConfig.Subsystems.Reminder |
| if rConfig == nil { |
| continue |
| } |
| reporting := nsConfig.ReportingByName(rConfig.SourceReporting) |
| stateEntry := state.getEntry(timeNow(c), ns, reporting.Name) |
| // The DB might well contain info about stale entities, but by querying the latest |
| // list of subsystems from the configuration, we make sure we only consider what's |
| // currently relevant. |
| rawSubsystems := nsConfig.Subsystems.Service.List() |
| // Sort to keep output stable. |
| sort.Slice(rawSubsystems, func(i, j int) bool { |
| return rawSubsystems[i].Name < rawSubsystems[j].Name |
| }) |
| for _, entry := range rawSubsystems { |
| if entry.NoReminders { |
| continue |
| } |
| for _, dbReport := range registry.get(ns, entry.Name) { |
| if stateEntry.Sent >= reporting.DailyLimit { |
| break |
| } |
| report, err := reportingBugListReport(c, dbReport, ns, entry.Name, typ) |
| if err != nil { |
| log.Errorf(c, "%v", err) |
| return nil |
| } |
| if report != nil { |
| ret = append(ret, report) |
| stateEntry.Sent++ |
| } |
| } |
| } |
| } |
| return ret |
| } |
| |
| const maxNewListsPerNs = 5 |
| |
| // handleSubsystemReports is periodically invoked to construct fresh SubsystemReport objects. |
| func handleSubsystemReports(w http.ResponseWriter, r *http.Request) { |
| c := appengine.NewContext(r) |
| registry, err := makeSubsystemRegistry(c) |
| if err != nil { |
| log.Errorf(c, "failed to load subsystems: %v", err) |
| return |
| } |
| for ns, nsConfig := range getConfig(c).Namespaces { |
| rConfig := nsConfig.Subsystems.Reminder |
| if rConfig == nil { |
| continue |
| } |
| minPeriod := 24 * time.Hour * time.Duration(rConfig.PeriodDays) |
| reporting := nsConfig.ReportingByName(rConfig.SourceReporting) |
| var subsystems []*Subsystem |
| for _, entry := range nsConfig.Subsystems.Service.List() { |
| if entry.NoReminders { |
| continue |
| } |
| subsystems = append(subsystems, registry.get(ns, entry.Name)) |
| } |
| // Poll subsystems in a round-robin manner. |
| sort.Slice(subsystems, func(i, j int) bool { |
| return subsystems[i].ListsQueried.Before(subsystems[j].ListsQueried) |
| }) |
| updateLimit := maxNewListsPerNs |
| for _, subsystem := range subsystems { |
| if updateLimit == 0 { |
| break |
| } |
| if timeNow(c).Before(subsystem.LastBugList.Add(minPeriod)) { |
| continue |
| } |
| report, err := querySubsystemReport(c, subsystem, reporting, rConfig) |
| if err != nil { |
| log.Errorf(c, "failed to query bug lists: %v", err) |
| return |
| } |
| if err := registry.updatePoll(c, subsystem, report != nil); err != nil { |
| log.Errorf(c, "failed to update subsystem: %v", err) |
| return |
| } |
| if report == nil { |
| continue |
| } |
| updateLimit-- |
| if err := storeSubsystemReport(c, subsystem, report); err != nil { |
| log.Errorf(c, "failed to save subsystem: %v", err) |
| return |
| } |
| } |
| } |
| } |
| |
| func reportingBugListCommand(c context.Context, cmd *dashapi.BugListUpdate) (string, error) { |
| // We have to execute it outside of the transacation, otherwise we get the |
| // "Only ancestor queries are allowed inside transactions." error. |
| subsystem, rawReport, _, err := findSubsystemReportByID(c, cmd.ID) |
| if err != nil { |
| return "", err |
| } |
| if subsystem == nil { |
| return "", fmt.Errorf("the bug list was not found") |
| } |
| reply := "" |
| tx := func(c context.Context) error { |
| subsystemKey := subsystemKey(c, subsystem) |
| reportKey := subsystemReportKey(c, subsystemKey, rawReport) |
| report := new(SubsystemReport) |
| if err := db.Get(c, reportKey, report); err != nil { |
| return fmt.Errorf("failed to query SubsystemReport (%v): %w", reportKey, err) |
| } |
| stage := report.findStage(cmd.ID) |
| if stage.ExtID == "" { |
| stage.ExtID = cmd.ExtID |
| } |
| if stage.Link == "" { |
| stage.Link = cmd.Link |
| } |
| // It might e.g. happen that we skipped a stage in reportingBugListReport. |
| // Make sure all skipped stages have non-nil Closed. |
| for i := range report.Stages { |
| item := &report.Stages[i] |
| if cmd.Command != dashapi.BugListRegenerateCmd && item == stage { |
| break |
| } |
| item.Closed = timeNow(c) |
| } |
| switch cmd.Command { |
| case dashapi.BugListSentCmd: |
| if !stage.Reported.IsZero() { |
| return fmt.Errorf("the reporting stage was already reported") |
| } |
| stage.Reported = timeNow(c) |
| |
| state, err := loadReportingState(c) |
| if err != nil { |
| return fmt.Errorf("failed to query state: %w", err) |
| } |
| stateEnt := state.getEntry(timeNow(c), subsystem.Namespace, |
| getConfig(c).Namespaces[subsystem.Namespace].Subsystems.Reminder.SourceReporting) |
| stateEnt.Sent++ |
| if err := saveReportingState(c, state); err != nil { |
| return fmt.Errorf("failed to save state: %w", err) |
| } |
| case dashapi.BugListUpstreamCmd: |
| if !stage.Moderation { |
| reply = `The report cannot be sent further upstream. |
| It's already at the last reporting stage.` |
| return nil |
| } |
| if !stage.Closed.IsZero() { |
| reply = `The bug list was already upstreamed. |
| Please visit the new discussion thread.` |
| return nil |
| } |
| stage.Closed = timeNow(c) |
| case dashapi.BugListRegenerateCmd: |
| dbSubsystem := new(Subsystem) |
| err := db.Get(c, subsystemKey, dbSubsystem) |
| if err != nil { |
| return fmt.Errorf("failed to get subsystem: %w", err) |
| } |
| dbSubsystem.LastBugList = time.Time{} |
| _, err = db.Put(c, subsystemKey, dbSubsystem) |
| if err != nil { |
| return fmt.Errorf("failed to save subsystem: %w", err) |
| } |
| } |
| _, err = db.Put(c, reportKey, report) |
| if err != nil { |
| return fmt.Errorf("failed to save the object: %w", err) |
| } |
| return nil |
| } |
| return reply, db.RunInTransaction(c, tx, &db.TransactionOptions{ |
| XG: true, |
| Attempts: 10, |
| }) |
| } |
| |
| func findSubsystemReportByID(c context.Context, ID string) (*Subsystem, |
| *SubsystemReport, *SubsystemReportStage, error) { |
| var subsystemReports []*SubsystemReport |
| reportKeys, err := db.NewQuery("SubsystemReport"). |
| Filter("Stages.ID=", ID). |
| Limit(1). |
| GetAll(c, &subsystemReports) |
| if err != nil { |
| return nil, nil, nil, fmt.Errorf("failed to query subsystem reports: %w", err) |
| } |
| if len(subsystemReports) == 0 { |
| return nil, nil, nil, nil |
| } |
| stage := subsystemReports[0].findStage(ID) |
| if stage == nil { |
| // This should never happen (provided that all the code is correct). |
| return nil, nil, nil, fmt.Errorf("bug list is found, but the stage is missing") |
| } |
| subsystem := new(Subsystem) |
| if err := db.Get(c, reportKeys[0].Parent(), subsystem); err != nil { |
| return nil, nil, nil, fmt.Errorf("failed to query subsystem: %w", err) |
| } |
| return subsystem, subsystemReports[0], stage, nil |
| } |
| |
| // querySubsystemReport queries the open bugs and constructs a new SubsystemReport object. |
| func querySubsystemReport(c context.Context, subsystem *Subsystem, reporting *Reporting, |
| config *BugListReportingConfig) (*SubsystemReport, error) { |
| rawOpenBugs, fixedBugs, err := queryMatchingBugs(c, subsystem.Namespace, |
| subsystem.Name, reporting) |
| if err != nil { |
| return nil, err |
| } |
| withRepro, noRepro := []*Bug{}, []*Bug{} |
| for _, bug := range rawOpenBugs { |
| const possiblyFixedTimespan = 24 * time.Hour * 14 |
| if bug.LastTime.Before(timeNow(c).Add(-possiblyFixedTimespan)) { |
| // The bug didn't happen recently, possibly it was already fixed. |
| // Let's not display such bugs in reminders. |
| continue |
| } |
| if bug.FirstTime.After(timeNow(c).Add(-config.MinBugAge)) { |
| // Don't take bugs which are too new -- they're still fresh in memory. |
| continue |
| } |
| if bug.prio() == LowPrioBug { |
| // Don't include low priority bugs in reports because the community |
| // actually perceives them as non-actionable. |
| continue |
| } |
| discussions := bug.discussionSummary() |
| if discussions.ExternalMessages > 0 && |
| discussions.LastMessage.After(timeNow(c).Add(-config.UserReplyFrist)) { |
| // Don't take bugs with recent user replies. |
| // As we don't keep exactly the date of the last user message, approximate it. |
| continue |
| } |
| if bug.HasLabel(NoRemindersLabel, "") { |
| // The bug was intentionally excluded from monthly reminders. |
| continue |
| } |
| if bug.ReproLevel == dashapi.ReproLevelNone { |
| noRepro = append(noRepro, bug) |
| } else { |
| withRepro = append(withRepro, bug) |
| } |
| } |
| // Let's reduce noise and don't remind about just one bug. |
| if len(noRepro)+len(withRepro) < config.MinBugsCount { |
| return nil, nil |
| } |
| // Even if we have enough bugs with a reproducer, there might still be bugs |
| // without a reproducer that have a lot of crashes. So let's take a small number |
| // of such bugs and give them a chance to be present in the final list. |
| takeNoRepro := 2 |
| if takeNoRepro+len(withRepro) < config.BugsInReport { |
| takeNoRepro = config.BugsInReport - len(withRepro) |
| } |
| if takeNoRepro > len(noRepro) { |
| takeNoRepro = len(noRepro) |
| } |
| sort.Slice(noRepro, func(i, j int) bool { |
| return noRepro[i].NumCrashes > noRepro[j].NumCrashes |
| }) |
| takeBugs := append(withRepro, noRepro[:takeNoRepro]...) |
| sort.Slice(takeBugs, func(i, j int) bool { |
| firstPrio, secondPrio := takeBugs[i].prio(), takeBugs[j].prio() |
| if firstPrio != secondPrio { |
| return !firstPrio.LessThan(secondPrio) |
| } |
| if takeBugs[i].NumCrashes != takeBugs[j].NumCrashes { |
| return takeBugs[i].NumCrashes > takeBugs[j].NumCrashes |
| } |
| return takeBugs[i].Title < takeBugs[j].Title |
| }) |
| keys := []*db.Key{} |
| for _, bug := range takeBugs { |
| keys = append(keys, bug.key(c)) |
| } |
| if len(keys) > config.BugsInReport { |
| keys = keys[:config.BugsInReport] |
| } |
| report := makeSubsystemReport(c, config, keys) |
| report.TotalStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, 0) |
| report.PeriodStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, config.PeriodDays) |
| return report, nil |
| } |
| |
| func makeSubsystemReportStats(c context.Context, open, fixed []*Bug, days int) SubsystemReportStats { |
| after := timeNow(c).Add(-time.Hour * 24 * time.Duration(days)) |
| ret := SubsystemReportStats{} |
| for _, bug := range open { |
| if days > 0 && bug.FirstTime.Before(after) { |
| continue |
| } |
| if bug.prio() == LowPrioBug { |
| ret.LowPrio++ |
| } else { |
| ret.Reported++ |
| } |
| } |
| for _, bug := range fixed { |
| if len(bug.CommitInfo) == 0 { |
| continue |
| } |
| if days > 0 && bug.CommitInfo[0].Date.Before(after) { |
| continue |
| } |
| ret.Fixed++ |
| } |
| return ret |
| } |
| |
| func queryMatchingBugs(c context.Context, ns, name string, reporting *Reporting) ([]*Bug, []*Bug, error) { |
| allOpenBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query { |
| return query.Filter("Namespace=", ns). |
| Filter("Status=", BugStatusOpen). |
| Filter("Labels.Label=", SubsystemLabel). |
| Filter("Labels.Value=", name) |
| }) |
| if err != nil { |
| return nil, nil, fmt.Errorf("failed to query open bugs for subsystem: %w", err) |
| } |
| allFixedBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query { |
| return query.Filter("Namespace=", ns). |
| Filter("Status=", BugStatusFixed). |
| Filter("Labels.Label=", SubsystemLabel). |
| Filter("Labels.Value=", name) |
| }) |
| if err != nil { |
| return nil, nil, fmt.Errorf("failed to query fixed bugs for subsystem: %w", err) |
| } |
| open, fixed := []*Bug{}, []*Bug{} |
| for _, bug := range append(allOpenBugs, allFixedBugs...) { |
| if len(bug.Commits) != 0 || bug.Status == BugStatusFixed { |
| // This bug is no longer really open. |
| fixed = append(fixed, bug) |
| continue |
| } |
| currReporting, _, _, _, err := currentReporting(c, bug) |
| if err != nil { |
| continue |
| } |
| if reporting.Name != currReporting.Name { |
| // The bug is not at the expected reporting stage. |
| continue |
| } |
| if currReporting.AccessLevel > reporting.AccessLevel { |
| continue |
| } |
| open = append(open, bug) |
| } |
| return open, fixed, nil |
| } |
| |
| // makeSubsystemReport creates a new SubsystemReminder object. |
| func makeSubsystemReport(c context.Context, config *BugListReportingConfig, |
| keys []*db.Key) *SubsystemReport { |
| ret := &SubsystemReport{ |
| Created: timeNow(c), |
| } |
| for _, key := range keys { |
| ret.BugKeys = append(ret.BugKeys, key.Encode()) |
| } |
| baseID := hash.String([]byte(fmt.Sprintf("%v-%v", timeNow(c), ret.BugKeys))) |
| if config.ModerationConfig != nil { |
| ret.Stages = append(ret.Stages, SubsystemReportStage{ |
| ID: bugListReportingHash(baseID, "moderation"), |
| Moderation: true, |
| }) |
| } |
| ret.Stages = append(ret.Stages, SubsystemReportStage{ |
| ID: bugListReportingHash(baseID, "public"), |
| }) |
| return ret |
| } |
| |
| const bugListHashPrefix = "list" |
| |
| func bugListReportingHash(base, name string) string { |
| return bugListHashPrefix + bugReportingHash(base, name) |
| } |
| |
| func isBugListHash(hash string) bool { |
| return strings.HasPrefix(hash, bugListHashPrefix) |
| } |
| |
| func reportingBugListReport(c context.Context, subsystemReport *SubsystemReport, |
| ns, name, targetReportingType string) (*dashapi.BugListReport, error) { |
| for _, stage := range subsystemReport.Stages { |
| if !stage.Closed.IsZero() { |
| continue |
| } |
| repConfig := bugListReportingConfig(c, ns, &stage) |
| if repConfig == nil { |
| // It might happen if e.g. Moderation was set to nil. |
| // Just skip the stage then. |
| continue |
| } |
| if !stage.Reported.IsZero() || repConfig.Type() != targetReportingType { |
| break |
| } |
| configJSON, err := json.Marshal(repConfig) |
| if err != nil { |
| return nil, err |
| } |
| ret := &dashapi.BugListReport{ |
| ID: stage.ID, |
| Created: subsystemReport.Created, |
| Config: configJSON, |
| Link: fmt.Sprintf("%v/%s/s/%s", appURL(c), ns, name), |
| Subsystem: name, |
| Maintainers: subsystemMaintainers(c, ns, name), |
| Moderation: stage.Moderation, |
| TotalStats: subsystemReport.TotalStats.toDashapi(), |
| PeriodStats: subsystemReport.PeriodStats.toDashapi(), |
| PeriodDays: getNsConfig(c, ns).Subsystems.Reminder.PeriodDays, |
| } |
| bugKeys, err := subsystemReport.getBugKeys() |
| if err != nil { |
| return nil, fmt.Errorf("failed to get bug keys: %w", err) |
| } |
| bugs := make([]*Bug, len(bugKeys)) |
| err = db.GetMulti(c, bugKeys, bugs) |
| if err != nil { |
| return nil, fmt.Errorf("failed to get bugs: %w", err) |
| } |
| for _, bug := range bugs { |
| bugReporting := bugReportingByName(bug, |
| getNsConfig(c, ns).Subsystems.Reminder.SourceReporting) |
| ret.Bugs = append(ret.Bugs, dashapi.BugListItem{ |
| Title: bug.displayTitle(), |
| Link: fmt.Sprintf("%v/bug?extid=%v", appURL(c), bugReporting.ID), |
| ReproLevel: bug.ReproLevel, |
| Hits: bug.NumCrashes, |
| }) |
| } |
| return ret, nil |
| } |
| return nil, nil |
| } |
| |
| func bugListReportingConfig(c context.Context, ns string, stage *SubsystemReportStage) ReportingType { |
| cfg := getNsConfig(c, ns).Subsystems.Reminder |
| if stage.Moderation { |
| return cfg.ModerationConfig |
| } |
| return cfg.Config |
| } |
| |
| func makeSubsystem(ns, name string) *Subsystem { |
| return &Subsystem{ |
| Namespace: ns, |
| Name: name, |
| } |
| } |
| |
| func subsystemKey(c context.Context, s *Subsystem) *db.Key { |
| return db.NewKey(c, "Subsystem", fmt.Sprintf("%v-%v", s.Namespace, s.Name), 0, nil) |
| } |
| |
| func subsystemReportKey(c context.Context, subsystemKey *db.Key, r *SubsystemReport) *db.Key { |
| return db.NewKey(c, "SubsystemReport", r.Created.UTC().Format(time.RFC822), 0, subsystemKey) |
| } |
| |
| type subsystemsRegistry struct { |
| entities map[string]map[string]*Subsystem |
| } |
| |
| func makeSubsystemRegistry(c context.Context) (*subsystemsRegistry, error) { |
| var subsystems []*Subsystem |
| if _, err := db.NewQuery("Subsystem").GetAll(c, &subsystems); err != nil { |
| return nil, err |
| } |
| ret := &subsystemsRegistry{ |
| entities: map[string]map[string]*Subsystem{}, |
| } |
| for _, item := range subsystems { |
| ret.store(item) |
| } |
| return ret, nil |
| } |
| |
| func (sr *subsystemsRegistry) get(ns, name string) *Subsystem { |
| ret := sr.entities[ns][name] |
| if ret == nil { |
| ret = makeSubsystem(ns, name) |
| } |
| return ret |
| } |
| |
| func (sr *subsystemsRegistry) store(item *Subsystem) { |
| if sr.entities[item.Namespace] == nil { |
| sr.entities[item.Namespace] = map[string]*Subsystem{} |
| } |
| sr.entities[item.Namespace][item.Name] = item |
| } |
| |
| func (sr *subsystemsRegistry) updatePoll(c context.Context, s *Subsystem, success bool) error { |
| key := subsystemKey(c, s) |
| return db.RunInTransaction(c, func(c context.Context) error { |
| dbSubsystem := new(Subsystem) |
| err := db.Get(c, key, dbSubsystem) |
| if err == db.ErrNoSuchEntity { |
| dbSubsystem = s |
| } else if err != nil { |
| return fmt.Errorf("failed to get Subsystem '%v': %w", key, err) |
| } |
| dbSubsystem.ListsQueried = timeNow(c) |
| if success { |
| dbSubsystem.LastBugList = timeNow(c) |
| } |
| if _, err := db.Put(c, key, dbSubsystem); err != nil { |
| return fmt.Errorf("failed to save Subsystem: %w", err) |
| } |
| sr.store(dbSubsystem) |
| return nil |
| }, nil) |
| } |
| |
| type subsystemReportRegistry struct { |
| entities map[string]map[string][]*SubsystemReport |
| } |
| |
| func makeSubsystemReportRegistry(c context.Context) (*subsystemReportRegistry, error) { |
| var reports []*SubsystemReport |
| reportKeys, err := db.NewQuery("SubsystemReport").GetAll(c, &reports) |
| if err != nil { |
| return nil, err |
| } |
| var subsystemKeys []*db.Key |
| for _, key := range reportKeys { |
| subsystemKeys = append(subsystemKeys, key.Parent()) |
| } |
| subsystems := make([]*Subsystem, len(subsystemKeys)) |
| if err := db.GetMulti(c, subsystemKeys, subsystems); err != nil { |
| return nil, fmt.Errorf("failed to query subsystems: %w", err) |
| } |
| ret := &subsystemReportRegistry{ |
| entities: map[string]map[string][]*SubsystemReport{}, |
| } |
| for i, item := range reports { |
| ret.store(subsystems[i].Namespace, subsystems[i].Name, item) |
| } |
| return ret, nil |
| } |
| |
| func (srr *subsystemReportRegistry) get(ns, name string) []*SubsystemReport { |
| return srr.entities[ns][name] |
| } |
| |
| func (srr *subsystemReportRegistry) store(ns, name string, item *SubsystemReport) { |
| if srr.entities[ns] == nil { |
| srr.entities[ns] = map[string][]*SubsystemReport{} |
| } |
| srr.entities[ns][name] = append(srr.entities[ns][name], item) |
| } |
| |
| func storeSubsystemReport(c context.Context, s *Subsystem, report *SubsystemReport) error { |
| key := subsystemKey(c, s) |
| return db.RunInTransaction(c, func(c context.Context) error { |
| // First close all previouly active per-subsystem reports. |
| var previous []*SubsystemReport |
| prevKeys, err := db.NewQuery("SubsystemReport"). |
| Ancestor(key). |
| Filter("Stages.Closed=", time.Time{}). |
| GetAll(c, &previous) |
| if err != nil { |
| return fmt.Errorf("failed to query old subsystem reports: %w", err) |
| } |
| for i, subsystem := range previous { |
| for i := range subsystem.Stages { |
| subsystem.Stages[i].Closed = timeNow(c) |
| } |
| if _, err := db.Put(c, prevKeys[i], subsystem); err != nil { |
| return fmt.Errorf("failed to save SubsystemReport: %w", err) |
| } |
| } |
| // Now save a new one. |
| reportKey := subsystemReportKey(c, key, report) |
| if _, err := db.Put(c, reportKey, report); err != nil { |
| return fmt.Errorf("failed to store new SubsystemReport: %w", err) |
| } |
| return nil |
| }, nil) |
| } |