aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAleksandr Nogikh <nogikh@google.com>2026-02-16 08:48:25 +0000
committerAleksandr Nogikh <nogikh@google.com>2026-03-04 18:19:12 +0000
commit5675edac8a8dd3f3041df8d9ec79f95a42d434fa (patch)
treeeab8686c35dce571b119ddd3468ffc731688ec07
parent12c7ee42182673f63622aadb91d5da1f2eedb8f1 (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.go14
-rw-r--r--syz-cluster/pkg/controller/api.go14
-rw-r--r--syz-cluster/pkg/controller/api_test.go107
-rw-r--r--syz-cluster/pkg/db/entities.go10
-rw-r--r--syz-cluster/pkg/service/finding.go91
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
+}