aboutsummaryrefslogtreecommitdiffstats
path: root/syz-cluster/pkg/controller
diff options
context:
space:
mode:
authorAleksandr Nogikh <nogikh@google.com>2025-02-13 13:59:19 +0100
committerAleksandr Nogikh <nogikh@google.com>2025-02-14 13:40:12 +0000
commiteaf86f3f4dc8a7190abf09fe840e20bcf83709d8 (patch)
treec28d030a8923833ffc39005b1a57946cd48fea62 /syz-cluster/pkg/controller
parent59c86b9e1c7a0f91fbb1b680676f33b4cc7bf137 (diff)
syz-cluster/controller: move the API server to pkg/controller
This will facilitate its reuse in tests.
Diffstat (limited to 'syz-cluster/pkg/controller')
-rw-r--r--syz-cluster/pkg/controller/api.go198
-rw-r--r--syz-cluster/pkg/controller/api_test.go108
-rw-r--r--syz-cluster/pkg/controller/testutil.go43
3 files changed, 349 insertions, 0 deletions
diff --git a/syz-cluster/pkg/controller/api.go b/syz-cluster/pkg/controller/api.go
new file mode 100644
index 000000000..270803c3b
--- /dev/null
+++ b/syz-cluster/pkg/controller/api.go
@@ -0,0 +1,198 @@
+// Copyright 2024 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.
+
+// nolint: dupl // The methods look similar, but extracting the common parts will only make the code worse.
+package controller
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/google/syzkaller/syz-cluster/pkg/api"
+ "github.com/google/syzkaller/syz-cluster/pkg/app"
+ "github.com/google/syzkaller/syz-cluster/pkg/service"
+)
+
+type APIServer struct {
+ seriesService *service.SeriesService
+ sessionService *service.SessionService
+ buildService *service.BuildService
+ testService *service.SessionTestService
+ findingService *service.FindingService
+}
+
+func NewAPIServer(env *app.AppEnvironment) *APIServer {
+ return &APIServer{
+ seriesService: service.NewSeriesService(env),
+ sessionService: service.NewSessionService(env),
+ buildService: service.NewBuildService(env),
+ testService: service.NewSessionTestService(env),
+ findingService: service.NewFindingService(env),
+ }
+}
+
+func (c APIServer) Mux() *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/sessions/{session_id}/series", c.getSessionSeries)
+ mux.HandleFunc("/sessions/{session_id}/skip", c.skipSession)
+ mux.HandleFunc("/sessions/upload", c.uploadSession)
+ mux.HandleFunc("/series/{series_id}", c.getSeries)
+ mux.HandleFunc("/builds/last", c.getLastBuild)
+ mux.HandleFunc("/builds/upload", c.uploadBuild)
+ mux.HandleFunc("/tests/upload", c.uploadTest)
+ mux.HandleFunc("/findings/upload", c.uploadFinding)
+ mux.HandleFunc("/series/upload", c.uploadSeries)
+ return mux
+}
+
+func (c APIServer) getSessionSeries(w http.ResponseWriter, r *http.Request) {
+ resp, err := c.seriesService.GetSessionSeries(r.Context(), r.PathValue("session_id"))
+ if err == service.ErrSeriesNotFound || err == service.ErrSessionNotFound {
+ http.Error(w, fmt.Sprint(err), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply(w, resp)
+}
+
+func (c APIServer) skipSession(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.SkipRequest](w, r)
+ if req == nil {
+ return
+ }
+ err := c.sessionService.SkipSession(r.Context(), r.PathValue("session_id"), req)
+ if errors.Is(err, service.ErrSessionNotFound) {
+ http.Error(w, fmt.Sprint(err), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[interface{}](w, nil)
+}
+
+func (c APIServer) getSeries(w http.ResponseWriter, r *http.Request) {
+ resp, err := c.seriesService.GetSeries(r.Context(), r.PathValue("series_id"))
+ if errors.Is(err, service.ErrSeriesNotFound) {
+ http.Error(w, fmt.Sprint(err), http.StatusNotFound)
+ return
+ } else if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply(w, resp)
+}
+
+func (c APIServer) uploadBuild(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.UploadBuildReq](w, r)
+ if req == nil {
+ return
+ }
+ resp, err := c.buildService.Upload(r.Context(), req)
+ if err != nil {
+ // TODO: sometimes it's not StatusInternalServerError.
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply(w, resp)
+}
+
+func (c APIServer) uploadTest(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.TestResult](w, r)
+ if req == nil {
+ return
+ }
+ // TODO: add parameters validation (and also of the Log size).
+ err := c.testService.Save(r.Context(), req)
+ if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[interface{}](w, nil)
+}
+
+func (c APIServer) uploadFinding(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.Finding](w, r)
+ if req == nil {
+ return
+ }
+ // TODO: add parameters validation.
+ err := c.findingService.Save(r.Context(), req)
+ if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[interface{}](w, nil)
+}
+
+func (c APIServer) getLastBuild(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.LastBuildReq](w, r)
+ if req == nil {
+ return
+ }
+ resp, err := c.buildService.LastBuild(r.Context(), req)
+ if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[*api.Build](w, resp)
+}
+
+func (c APIServer) uploadSeries(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.Series](w, r)
+ if req == nil {
+ return
+ }
+ resp, err := c.seriesService.UploadSeries(r.Context(), req)
+ if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[*api.UploadSeriesResp](w, resp)
+}
+
+func (c APIServer) uploadSession(w http.ResponseWriter, r *http.Request) {
+ req := api.ParseJSON[api.NewSession](w, r)
+ if req == nil {
+ return
+ }
+ resp, err := c.sessionService.UploadSession(r.Context(), req)
+ if err != nil {
+ http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
+ return
+ }
+ reply[*api.UploadSessionResp](w, resp)
+}
+
+func reply[T any](w http.ResponseWriter, resp T) {
+ w.Header().Set("Content-Type", "application/json")
+ err := json.NewEncoder(w).Encode(resp)
+ if err != nil {
+ http.Error(w, "failed to serialize the response", http.StatusInternalServerError)
+ return
+ }
+}
+
+func parseBody[T any](w http.ResponseWriter, r *http.Request) *T {
+ if r.Method != http.MethodPost {
+ http.Error(w, "must be called via POST", http.StatusMethodNotAllowed)
+ return nil
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "failed to read body", http.StatusBadRequest)
+ return nil
+ }
+ var data T
+ err = json.Unmarshal(body, &data)
+ if err != nil {
+ http.Error(w, "invalid body", http.StatusBadRequest)
+ return nil
+ }
+ return &data
+}
diff --git a/syz-cluster/pkg/controller/api_test.go b/syz-cluster/pkg/controller/api_test.go
new file mode 100644
index 000000000..2e3483414
--- /dev/null
+++ b/syz-cluster/pkg/controller/api_test.go
@@ -0,0 +1,108 @@
+// Copyright 2024 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 controller
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/syzkaller/syz-cluster/pkg/api"
+ "github.com/google/syzkaller/syz-cluster/pkg/app"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIGetSeries(t *testing.T) {
+ env, ctx := app.TestEnvironment(t)
+ client := TestServer(t, env)
+ seriesID, sessionID := UploadTestSeries(t, ctx, client, testSeries)
+
+ ret, err := client.GetSessionSeries(ctx, sessionID)
+ assert.NoError(t, err)
+ ret.ID = ""
+ assert.Equal(t, testSeries, ret)
+
+ ret, err = client.GetSeries(ctx, seriesID)
+ assert.NoError(t, err)
+ ret.ID = ""
+ assert.Equal(t, testSeries, ret)
+}
+
+func TestAPISuccessfulBuild(t *testing.T) {
+ env, ctx := app.TestEnvironment(t)
+ client := TestServer(t, env)
+ UploadTestBuild(t, ctx, client, testBuild)
+ info, err := client.LastSuccessfulBuild(ctx, &api.LastBuildReq{
+ Arch: testBuild.Arch,
+ TreeName: testBuild.TreeName,
+ ConfigName: testBuild.ConfigName,
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, testBuild, info)
+}
+
+func TestAPISaveFinding(t *testing.T) {
+ env, ctx := app.TestEnvironment(t)
+ client := TestServer(t, env)
+
+ _, sessionID := UploadTestSeries(t, ctx, client, testSeries)
+ buildResp := UploadTestBuild(t, ctx, client, testBuild)
+ err := client.UploadTestResult(ctx, &api.TestResult{
+ SessionID: sessionID,
+ BaseBuildID: buildResp.ID,
+ TestName: "test",
+ Result: api.TestRunning,
+ Log: []byte("some log"),
+ })
+ assert.NoError(t, err)
+
+ t.Run("not existing test", func(t *testing.T) {
+ err = client.UploadFinding(ctx, &api.Finding{
+ SessionID: sessionID,
+ TestName: "unknown test",
+ })
+ assert.Error(t, err)
+ })
+
+ t.Run("must succeed", func(t *testing.T) {
+ finding := &api.Finding{
+ SessionID: sessionID,
+ TestName: "test",
+ Report: []byte("report"),
+ Log: []byte("log"),
+ }
+ err = client.UploadFinding(ctx, finding)
+ assert.NoError(t, err)
+ // Even if the finding is reported the second time, it must still not fail.
+ err = client.UploadFinding(ctx, finding)
+ assert.NoError(t, err)
+ })
+}
+
+var testSeries = &api.Series{
+ ExtID: "ext-id",
+ AuthorEmail: "some@email.com",
+ Title: "test series name",
+ Version: 2,
+ PublishedAt: time.Date(2020, time.January, 1, 3, 0, 0, 0, time.UTC),
+ Cc: []string{"email"},
+ Patches: []api.SeriesPatch{
+ {
+ Seq: 1,
+ Body: []byte("first content"),
+ },
+ {
+ Seq: 2,
+ Body: []byte("second content"),
+ },
+ },
+}
+
+var testBuild = &api.Build{
+ Arch: "amd64",
+ TreeName: "mainline",
+ ConfigName: "config",
+ CommitHash: "abcd",
+ CommitDate: time.Date(2020, time.January, 1, 3, 0, 0, 0, time.UTC),
+ BuildSuccess: true,
+}
diff --git a/syz-cluster/pkg/controller/testutil.go b/syz-cluster/pkg/controller/testutil.go
new file mode 100644
index 000000000..0c33df139
--- /dev/null
+++ b/syz-cluster/pkg/controller/testutil.go
@@ -0,0 +1,43 @@
+// Copyright 2025 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 controller
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/google/syzkaller/syz-cluster/pkg/api"
+ "github.com/google/syzkaller/syz-cluster/pkg/app"
+ "github.com/stretchr/testify/assert"
+)
+
+// UploadTestSeries returns a (session ID, series ID) tuple.
+func UploadTestSeries(t *testing.T, ctx context.Context,
+ client *api.Client, series *api.Series) (string, string) {
+ retSeries, err := client.UploadSeries(ctx, series)
+ assert.NoError(t, err)
+ retSession, err := client.UploadSession(ctx, &api.NewSession{
+ ExtID: series.ExtID,
+ })
+ assert.NoError(t, err)
+ return retSeries.ID, retSession.ID
+}
+
+func UploadTestBuild(t *testing.T, ctx context.Context, client *api.Client,
+ build *api.Build) *api.UploadBuildResp {
+ ret, err := client.UploadBuild(ctx, &api.UploadBuildReq{
+ Build: *build,
+ })
+ assert.NoError(t, err)
+ assert.NotEmpty(t, ret.ID)
+ return ret
+}
+
+func TestServer(t *testing.T, env *app.AppEnvironment) *api.Client {
+ apiServer := NewAPIServer(env)
+ server := httptest.NewServer(apiServer.Mux())
+ t.Cleanup(server.Close)
+ return api.NewClient(server.URL)
+}