diff options
| author | Aleksandr Nogikh <nogikh@google.com> | 2026-02-16 08:48:25 +0000 |
|---|---|---|
| committer | Aleksandr Nogikh <nogikh@google.com> | 2026-03-04 18:19:12 +0000 |
| commit | 5675edac8a8dd3f3041df8d9ec79f95a42d434fa (patch) | |
| tree | eab8686c35dce571b119ddd3468ffc731688ec07 | |
| parent | 12c7ee42182673f63622aadb91d5da1f2eedb8f1 (diff) | |
syz-cluster: add ListPreviousFindings API
The API call returns an aggregated list of findings in all previous
versions of the specified patch series.
| -rw-r--r-- | syz-cluster/pkg/api/client.go | 14 | ||||
| -rw-r--r-- | syz-cluster/pkg/controller/api.go | 14 | ||||
| -rw-r--r-- | syz-cluster/pkg/controller/api_test.go | 107 | ||||
| -rw-r--r-- | syz-cluster/pkg/db/entities.go | 10 | ||||
| -rw-r--r-- | syz-cluster/pkg/service/finding.go | 91 |
5 files changed, 236 insertions, 0 deletions
diff --git a/syz-cluster/pkg/api/client.go b/syz-cluster/pkg/api/client.go index 83d4ebcaa..d7b1d37ea 100644 --- a/syz-cluster/pkg/api/client.go +++ b/syz-cluster/pkg/api/client.go @@ -132,6 +132,20 @@ func (client Client) BaseFindingStatus(ctx context.Context, req *BaseFindingInfo return postJSON[BaseFindingInfo, BaseFindingStatus](ctx, client.baseURL+"/base_findings/status", req) } +type ListPreviousFindingsReq struct { + SeriesID string `json:"series_id"` + Arch string `json:"arch"` + Config string `json:"config"` +} + +func (client Client) ListPreviousFindings(ctx context.Context, req *ListPreviousFindingsReq) ([]string, error) { + res, err := postJSON[ListPreviousFindingsReq, []string](ctx, client.baseURL+"/findings/previous", req) + if err != nil { + return nil, err + } + return *res, nil +} + func (client Client) GetFinding(ctx context.Context, id string) (*RawFinding, error) { return getJSON[RawFinding](ctx, client.baseURL+"/findings/"+id) } diff --git a/syz-cluster/pkg/controller/api.go b/syz-cluster/pkg/controller/api.go index a9e3058f4..5d6ed247d 100644 --- a/syz-cluster/pkg/controller/api.go +++ b/syz-cluster/pkg/controller/api.go @@ -40,6 +40,7 @@ func (c APIServer) Mux() *http.ServeMux { mux.HandleFunc("/builds/last", c.getLastBuild) mux.HandleFunc("/builds/upload", c.uploadBuild) mux.HandleFunc("/findings/upload", c.uploadFinding) + mux.HandleFunc("/findings/previous", c.getPreviousFindings) mux.HandleFunc("/findings/{finding_id}", c.getFinding) mux.HandleFunc("/series/upload", c.uploadSeries) mux.HandleFunc("/series/{series_id}", c.getSeries) @@ -166,6 +167,19 @@ func (c APIServer) uploadFinding(w http.ResponseWriter, r *http.Request) { api.ReplyJSON[any](w, nil) } +func (c APIServer) getPreviousFindings(w http.ResponseWriter, r *http.Request) { + req := api.ParseJSON[api.ListPreviousFindingsReq](w, r) + if req == nil { + return + } + findings, err := c.findingService.ListPreviousFindings(r.Context(), req) + if err != nil { + http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) + return + } + api.ReplyJSON(w, findings) +} + func (c APIServer) getFinding(w http.ResponseWriter, r *http.Request) { finding, err := c.findingService.Get(r.Context(), r.PathValue("finding_id")) if errors.Is(err, service.ErrFindingNotFound) { diff --git a/syz-cluster/pkg/controller/api_test.go b/syz-cluster/pkg/controller/api_test.go index ea82b21cb..c31092a05 100644 --- a/syz-cluster/pkg/controller/api_test.go +++ b/syz-cluster/pkg/controller/api_test.go @@ -209,6 +209,113 @@ var testBuild = &api.Build{ BuildSuccess: true, } +func TestAPIListPreviousFindings(t *testing.T) { + env, ctx := app.TestEnvironment(t) + client := TestServer(t, env) + + // Setup v1 Series. + // It has "Crash in foo" and "Crash in bar". + seriesV1 := DummySeries() + seriesV1.Version = 1 + seriesV1.ExtID = "ext-id-1" + idsV1 := UploadTestSeries(t, ctx, client, seriesV1) + + buildV1 := DummyBuild() + buildV1.ConfigName = "config-1" + buildV1Resp := UploadTestBuild(t, ctx, client, buildV1) + + require.NoError(t, client.UploadSessionTest(ctx, &api.SessionTest{ + SessionID: idsV1.SessionID, + TestName: "test", + Result: api.TestPassed, + PatchedBuildID: buildV1Resp.ID, + Log: []byte("log"), + })) + + require.NoError(t, client.UploadFinding(ctx, &api.RawFinding{ + SessionID: idsV1.SessionID, + Title: "Crash in foo", + TestName: "test", + })) + require.NoError(t, client.UploadFinding(ctx, &api.RawFinding{ + SessionID: idsV1.SessionID, + Title: "Crash in bar", + TestName: "test", + })) + MarkSessionFinished(t, env, idsV1.SessionID) + + // Setup v2 Series. + seriesV2 := DummySeries() + seriesV2.Version = 2 + seriesV2.ExtID = "ext-id-2" + idsV2 := UploadTestSeries(t, ctx, client, seriesV2) + + buildV2 := DummyBuild() + buildV2.ConfigName = "config-1" + buildV2Resp := UploadTestBuild(t, ctx, client, buildV2) + + require.NoError(t, client.UploadSessionTest(ctx, &api.SessionTest{ + SessionID: idsV2.SessionID, + TestName: "test", + Result: api.TestPassed, + PatchedBuildID: buildV2Resp.ID, + Log: []byte("log"), + })) + + require.NoError(t, client.UploadFinding(ctx, &api.RawFinding{ + SessionID: idsV2.SessionID, + Title: "Crash in foo", + TestName: "test", + })) + MarkSessionFinished(t, env, idsV2.SessionID) + + // Setup v3 Series. + seriesV3 := DummySeries() + seriesV3.Version = 3 + seriesV3.ExtID = "ext-id-3" + idsV3 := UploadTestSeries(t, ctx, client, seriesV3) + + list, err := client.ListPreviousFindings(ctx, &api.ListPreviousFindingsReq{ + SeriesID: idsV3.SeriesID, + }) + require.NoError(t, err) + require.Len(t, list, 2) + + finding1, err := client.GetFinding(ctx, list[0]) + require.NoError(t, err) + assert.Equal(t, "Crash in foo", finding1.Title) + assert.Equal(t, idsV2.SessionID, finding1.SessionID) + + finding2, err := client.GetFinding(ctx, list[1]) + require.NoError(t, err) + assert.Equal(t, "Crash in bar", finding2.Title) + assert.Equal(t, idsV1.SessionID, finding2.SessionID) + + list, err = client.ListPreviousFindings(ctx, &api.ListPreviousFindingsReq{ + SeriesID: idsV3.SeriesID, + Arch: buildV1.Arch, + Config: buildV1.ConfigName, + }) + require.NoError(t, err) + require.Len(t, list, 2) + + list, err = client.ListPreviousFindings(ctx, &api.ListPreviousFindingsReq{ + SeriesID: idsV3.SeriesID, + Arch: "wrong-arch", + Config: buildV1.ConfigName, + }) + require.NoError(t, err) + require.Empty(t, list) + + list, err = client.ListPreviousFindings(ctx, &api.ListPreviousFindingsReq{ + SeriesID: idsV2.SeriesID, + Arch: buildV1.Arch, + Config: "wrong-config", + }) + require.NoError(t, err) + require.Empty(t, list) +} + func TestAPIGetFinding(t *testing.T) { env, ctx := app.TestEnvironment(t) client := TestServer(t, env) diff --git a/syz-cluster/pkg/db/entities.go b/syz-cluster/pkg/db/entities.go index 94532e1b0..acf058e37 100644 --- a/syz-cluster/pkg/db/entities.go +++ b/syz-cluster/pkg/db/entities.go @@ -134,6 +134,16 @@ type SessionTest struct { ArtifactsArchiveURI string `spanner:"ArtifactsArchiveURI"` } +func (t *SessionTest) AnyBuildID() string { + if !t.PatchedBuildID.IsNull() { + return t.PatchedBuildID.StringVal + } + if !t.BaseBuildID.IsNull() { + return t.BaseBuildID.StringVal + } + return "" +} + type Finding struct { ID string `spanner:"ID"` SessionID string `spanner:"SessionID"` diff --git a/syz-cluster/pkg/service/finding.go b/syz-cluster/pkg/service/finding.go index 5b740757f..fa68fb993 100644 --- a/syz-cluster/pkg/service/finding.go +++ b/syz-cluster/pkg/service/finding.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "slices" "time" "github.com/google/syzkaller/syz-cluster/pkg/api" @@ -24,6 +25,7 @@ type FindingService struct { urls *api.URLGenerator blobStorage blob.Storage seriesRepo *db.SeriesRepository + sessionRepo *db.SessionRepository } func NewFindingService(env *app.AppEnvironment) *FindingService { @@ -34,6 +36,7 @@ func NewFindingService(env *app.AppEnvironment) *FindingService { buildRepo: db.NewBuildRepository(env.Spanner), sessionTestRepo: db.NewSessionTestRepository(env.Spanner), seriesRepo: db.NewSeriesRepository(env.Spanner), + sessionRepo: db.NewSessionRepository(env.Spanner), } } @@ -150,6 +153,64 @@ func (s *FindingService) List(ctx context.Context, sessionID string, limit int) return ret, nil } +func (s *FindingService) ListPreviousFindings(ctx context.Context, req *api.ListPreviousFindingsReq) ([]string, error) { + series, err := s.seriesRepo.GetByID(ctx, req.SeriesID) + if err != nil { + return nil, fmt.Errorf("failed to get series: %w", err) + } + if series == nil { + return nil, fmt.Errorf("series not found: %w", db.ErrEntityNotFound) + } + allVersions, err := s.seriesRepo.ListAllVersions(ctx, series.Title) + if err != nil { + return nil, fmt.Errorf("failed to list all versions: %w", err) + } + var ret []string + seenTitles := map[string]bool{} + // Prefer newer versions. + for _, ver := range slices.Backward(allVersions) { + if ver.ID == req.SeriesID { + continue + } + if ver.PublishedAt.After(series.PublishedAt) { + continue + } + + sessions, err := s.sessionRepo.ListForSeries(ctx, ver) + if err != nil { + return nil, fmt.Errorf("failed to list sessions for series %s: %w", ver.ID, err) + } + if len(sessions) == 0 { + continue + } + // For now let's only consider the latest session. + sessionID := sessions[0].ID + matches, err := s.matchesPrevFindingsReq(ctx, sessionID, req) + if err != nil { + return nil, err + } + if !matches { + continue + } + + findings, err := s.findingRepo.ListForSession(ctx, sessionID, 0) + if err != nil { + return nil, fmt.Errorf("failed to list findings for session %s: %w", sessionID, err) + } + for _, f := range findings { + if !f.InvalidatedAt.IsNull() { + continue + } + if seenTitles[f.Title] { + continue + } + seenTitles[f.Title] = true + ret = append(ret, f.ID) + } + } + return ret, nil +} + var ErrFindingNotFound = fmt.Errorf("finding not found") func (s *FindingService) Get(ctx context.Context, id string) (*api.RawFinding, error) { @@ -187,3 +248,33 @@ func (s *FindingService) Get(ctx context.Context, id string) (*api.RawFinding, e } return ret, nil } + +func (s *FindingService) matchesPrevFindingsReq( + ctx context.Context, sessionID string, req *api.ListPreviousFindingsReq) (bool, error) { + if req.Arch == "" && req.Config == "" { + return true, nil + } + tests, err := s.sessionTestRepo.BySession(ctx, sessionID) + if err != nil { + return false, fmt.Errorf("failed to get session tests: %w", err) + } + if len(tests) == 0 { + return false, nil + } + // It's enough to check one test. + buildID := tests[0].AnyBuildID() + if buildID == "" { + return false, nil + } + build, err := s.buildRepo.GetByID(ctx, buildID) + if err != nil { + return false, fmt.Errorf("failed to get build %s: %w", buildID, err) + } + if req.Arch != "" && build.Arch != req.Arch { + return false, nil + } + if req.Config != "" && build.ConfigName != req.Config { + return false, nil + } + return true, nil +} |
