github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/syz-cluster/dashboard/handler.go (about)

     1  // Copyright 2024 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  package main
     5  
     6  import (
     7  	"embed"
     8  	"errors"
     9  	"fmt"
    10  	"html/template"
    11  	"io"
    12  	"io/fs"
    13  	"net/http"
    14  	"strconv"
    15  	"time"
    16  
    17  	"github.com/google/syzkaller/pkg/html/urlutil"
    18  	"github.com/google/syzkaller/syz-cluster/pkg/app"
    19  	"github.com/google/syzkaller/syz-cluster/pkg/blob"
    20  	"github.com/google/syzkaller/syz-cluster/pkg/db"
    21  )
    22  
    23  type dashboardHandler struct {
    24  	title           string
    25  	buildRepo       *db.BuildRepository
    26  	seriesRepo      *db.SeriesRepository
    27  	sessionRepo     *db.SessionRepository
    28  	sessionTestRepo *db.SessionTestRepository
    29  	findingRepo     *db.FindingRepository
    30  	statsRepo       *db.StatsRepository
    31  	blobStorage     blob.Storage
    32  	templates       map[string]*template.Template
    33  }
    34  
    35  //go:embed templates/*
    36  var templates embed.FS
    37  
    38  func newHandler(env *app.AppEnvironment) (*dashboardHandler, error) {
    39  	perFile := map[string]*template.Template{}
    40  	var err error
    41  	for _, name := range []string{"index.html", "series.html", "graphs.html"} {
    42  		perFile[name], err = template.ParseFS(templates,
    43  			"templates/base.html", "templates/templates.html", "templates/"+name)
    44  		if err != nil {
    45  			return nil, err
    46  		}
    47  	}
    48  	return &dashboardHandler{
    49  		title:           env.Config.Name,
    50  		templates:       perFile,
    51  		blobStorage:     env.BlobStorage,
    52  		buildRepo:       db.NewBuildRepository(env.Spanner),
    53  		seriesRepo:      db.NewSeriesRepository(env.Spanner),
    54  		sessionRepo:     db.NewSessionRepository(env.Spanner),
    55  		sessionTestRepo: db.NewSessionTestRepository(env.Spanner),
    56  		findingRepo:     db.NewFindingRepository(env.Spanner),
    57  		statsRepo:       db.NewStatsRepository(env.Spanner),
    58  	}, nil
    59  }
    60  
    61  //go:embed static
    62  var staticFs embed.FS
    63  
    64  func (h *dashboardHandler) Mux() *http.ServeMux {
    65  	mux := http.NewServeMux()
    66  	mux.HandleFunc("/sessions/{id}/log", errToStatus(h.sessionLog))
    67  	mux.HandleFunc("/sessions/{id}/triage_log", errToStatus(h.sessionTriageLog))
    68  	mux.HandleFunc("/sessions/{id}/test_logs", errToStatus(h.sessionTestLog))
    69  	mux.HandleFunc("/sessions/{id}/test_artifacts", errToStatus(h.sessionTestArtifacts))
    70  	mux.HandleFunc("/series/{id}/all_patches", errToStatus(h.allPatches))
    71  	mux.HandleFunc("/series/{id}", errToStatus(h.seriesInfo))
    72  	mux.HandleFunc("/patches/{id}", errToStatus(h.patchContent))
    73  	mux.HandleFunc("/findings/{id}/{key}", errToStatus(h.findingInfo))
    74  	mux.HandleFunc("/builds/{id}/{key}", errToStatus(h.buildInfo))
    75  	mux.HandleFunc("/stats", errToStatus(h.statsPage))
    76  	mux.HandleFunc("/", errToStatus(h.seriesList))
    77  	staticFiles, err := fs.Sub(staticFs, "static")
    78  	if err != nil {
    79  		app.Fatalf("failed to parse templates: %v", err)
    80  	}
    81  	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles))))
    82  	return mux
    83  }
    84  
    85  var (
    86  	errNotFound   = errors.New("not found error")
    87  	errBadRequest = errors.New("bad request")
    88  )
    89  
    90  type statusOption struct {
    91  	Key   db.SessionStatus
    92  	Value string
    93  }
    94  
    95  func (h *dashboardHandler) seriesList(w http.ResponseWriter, r *http.Request) error {
    96  	type MainPageData struct {
    97  		// It's probably not the best idea to expose db entities here,
    98  		// but so far redefining the entity would just duplicate the code.
    99  		List     []*db.SeriesWithSession
   100  		Filter   db.SeriesFilter
   101  		Statuses []statusOption
   102  		// This is very primitive, but better than nothing.
   103  		FilterFormURL string
   104  		PrevPageURL   string
   105  		NextPageURL   string
   106  	}
   107  	const perPage = 100
   108  	offset, err := h.getOffset(r)
   109  	if err != nil {
   110  		return err
   111  	}
   112  	baseURL := r.URL.RequestURI()
   113  	data := MainPageData{
   114  		Filter: db.SeriesFilter{
   115  			Cc:           r.FormValue("cc"),
   116  			Status:       db.SessionStatus(r.FormValue("status")),
   117  			WithFindings: r.FormValue("with_findings") != "",
   118  			Limit:        perPage,
   119  			Offset:       offset,
   120  		},
   121  		// If the filters are changed, the old offset value is irrelevant.
   122  		FilterFormURL: urlutil.DropParam(baseURL, "offset", ""),
   123  		Statuses: []statusOption{
   124  			{db.SessionStatusAny, "any"},
   125  			{db.SessionStatusWaiting, "waiting"},
   126  			{db.SessionStatusInProgress, "in progress"},
   127  			{db.SessionStatusFinished, "finished"},
   128  			{db.SessionStatusSkipped, "skipped"},
   129  		},
   130  	}
   131  
   132  	data.List, err = h.seriesRepo.ListLatest(r.Context(), data.Filter, time.Time{})
   133  	if err != nil {
   134  		return fmt.Errorf("failed to query the list: %w", err)
   135  	}
   136  	if data.Filter.Offset > 0 {
   137  		data.PrevPageURL = urlutil.SetParam(baseURL, "offset",
   138  			fmt.Sprintf("%d", max(0, data.Filter.Offset-perPage)))
   139  	}
   140  	// TODO: this is not strictly correct (we also need to check whether there actually more rows).
   141  	// But let's tolerate it for now.
   142  	if len(data.List) == data.Filter.Limit {
   143  		data.NextPageURL = urlutil.SetParam(baseURL, "offset",
   144  			fmt.Sprintf("%d", data.Filter.Offset+len(data.List)))
   145  	}
   146  	return h.renderTemplate(w, "index.html", data)
   147  }
   148  
   149  func (h *dashboardHandler) getOffset(r *http.Request) (int, error) {
   150  	val := r.FormValue("offset")
   151  	if val == "" {
   152  		return 0, nil
   153  	}
   154  	i, err := strconv.Atoi(val)
   155  	if err != nil || i < 0 {
   156  		return 0, fmt.Errorf("%w: invalid offset value", errBadRequest)
   157  	}
   158  	return i, nil
   159  }
   160  
   161  func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) error {
   162  	type SessionTest struct {
   163  		*db.FullSessionTest
   164  		Findings []*db.Finding
   165  	}
   166  	type SessionData struct {
   167  		*db.Session
   168  		Tests []SessionTest
   169  	}
   170  	type SeriesData struct {
   171  		*db.Series
   172  		Patches      []*db.Patch
   173  		Sessions     []SessionData
   174  		TotalPatches int
   175  	}
   176  	var data SeriesData
   177  	var err error
   178  	ctx := r.Context()
   179  	data.Series, err = h.seriesRepo.GetByID(ctx, r.PathValue("id"))
   180  	if err != nil {
   181  		return fmt.Errorf("failed to query series: %w", err)
   182  	} else if data.Series == nil {
   183  		return fmt.Errorf("%w: series", errNotFound)
   184  	}
   185  	data.Patches, err = h.seriesRepo.ListPatches(ctx, data.Series)
   186  	if err != nil {
   187  		return fmt.Errorf("failed to query patches: %w", err)
   188  	}
   189  	data.TotalPatches = len(data.Patches)
   190  	sessions, err := h.sessionRepo.ListForSeries(ctx, data.Series)
   191  	if err != nil {
   192  		return fmt.Errorf("failed to query sessions: %w", err)
   193  	}
   194  	for _, session := range sessions {
   195  		rawTests, err := h.sessionTestRepo.BySession(ctx, session.ID)
   196  		if err != nil {
   197  			return fmt.Errorf("failed to query session tests: %w", err)
   198  		}
   199  		findings, err := h.findingRepo.ListForSession(ctx, session.ID, db.NoLimit)
   200  		if err != nil {
   201  			return fmt.Errorf("failed to query session findings: %w", err)
   202  		}
   203  		perName := groupFindings(findings)
   204  		sessionData := SessionData{
   205  			Session: session,
   206  		}
   207  		for _, test := range rawTests {
   208  			sessionData.Tests = append(sessionData.Tests, SessionTest{
   209  				FullSessionTest: test,
   210  				Findings:        perName[test.TestName],
   211  			})
   212  		}
   213  		data.Sessions = append(data.Sessions, sessionData)
   214  	}
   215  	return h.renderTemplate(w, "series.html", data)
   216  }
   217  
   218  func (h *dashboardHandler) statsPage(w http.ResponseWriter, r *http.Request) error {
   219  	type StatsPageData struct {
   220  		Processed    []*db.CountPerWeek
   221  		Findings     []*db.CountPerWeek
   222  		Reports      []*db.CountPerWeek
   223  		Delay        []*db.DelayPerWeek
   224  		Distribution []*db.StatusPerWeek
   225  	}
   226  	var data StatsPageData
   227  	var err error
   228  	data.Processed, err = h.statsRepo.ProcessedSeriesPerWeek(r.Context())
   229  	if err != nil {
   230  		return fmt.Errorf("failed to query processed series data: %w", err)
   231  	}
   232  	data.Findings, err = h.statsRepo.FindingsPerWeek(r.Context())
   233  	if err != nil {
   234  		return fmt.Errorf("failed to query findings data: %w", err)
   235  	}
   236  	data.Reports, err = h.statsRepo.ReportsPerWeek(r.Context())
   237  	if err != nil {
   238  		return fmt.Errorf("failed to query reports data: %w", err)
   239  	}
   240  	data.Delay, err = h.statsRepo.DelayPerWeek(r.Context())
   241  	if err != nil {
   242  		return fmt.Errorf("failed to query delay data: %w", err)
   243  	}
   244  	data.Distribution, err = h.statsRepo.SessionStatusPerWeek(r.Context())
   245  	if err != nil {
   246  		return fmt.Errorf("failed to query distribution data: %w", err)
   247  	}
   248  	return h.renderTemplate(w, "graphs.html", data)
   249  }
   250  
   251  func groupFindings(findings []*db.Finding) map[string][]*db.Finding {
   252  	ret := map[string][]*db.Finding{}
   253  	for _, finding := range findings {
   254  		ret[finding.TestName] = append(ret[finding.TestName], finding)
   255  	}
   256  	return ret
   257  }
   258  
   259  func (h *dashboardHandler) renderTemplate(w http.ResponseWriter, name string, data any) error {
   260  	type page struct {
   261  		Title    string
   262  		Template string
   263  		Data     any
   264  	}
   265  	return h.templates[name].ExecuteTemplate(w, "base.html", page{
   266  		Title:    h.title,
   267  		Template: name,
   268  		Data:     data,
   269  	})
   270  }
   271  
   272  // nolint:dupl
   273  func (h *dashboardHandler) sessionLog(w http.ResponseWriter, r *http.Request) error {
   274  	session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id"))
   275  	if err != nil {
   276  		return err
   277  	} else if session == nil {
   278  		return fmt.Errorf("%w: session", errNotFound)
   279  	}
   280  	return h.streamBlob(w, session.LogURI)
   281  }
   282  
   283  // nolint:dupl
   284  func (h *dashboardHandler) sessionTriageLog(w http.ResponseWriter, r *http.Request) error {
   285  	session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id"))
   286  	if err != nil {
   287  		return err
   288  	} else if session == nil {
   289  		return fmt.Errorf("%w: session", errNotFound)
   290  	}
   291  	return h.streamBlob(w, session.TriageLogURI)
   292  }
   293  
   294  // nolint:dupl
   295  func (h *dashboardHandler) patchContent(w http.ResponseWriter, r *http.Request) error {
   296  	patch, err := h.seriesRepo.PatchByID(r.Context(), r.PathValue("id"))
   297  	if err != nil {
   298  		return err
   299  	} else if patch == nil {
   300  		return fmt.Errorf("%w: patch", errNotFound)
   301  	}
   302  	return h.streamBlob(w, patch.BodyURI)
   303  }
   304  
   305  // nolint:dupl
   306  func (h *dashboardHandler) allPatches(w http.ResponseWriter, r *http.Request) error {
   307  	ctx := r.Context()
   308  	series, err := h.seriesRepo.GetByID(ctx, r.PathValue("id"))
   309  	if err != nil {
   310  		return fmt.Errorf("failed to query series: %w", err)
   311  	} else if series == nil {
   312  		return fmt.Errorf("%w: series", errNotFound)
   313  	}
   314  	patches, err := h.seriesRepo.ListPatches(ctx, series)
   315  	if err != nil {
   316  		return fmt.Errorf("failed to query patches: %w", err)
   317  	}
   318  	for _, patch := range patches {
   319  		err = h.streamBlob(w, patch.BodyURI)
   320  		if err != nil {
   321  			return err
   322  		}
   323  	}
   324  	return nil
   325  }
   326  
   327  func (h *dashboardHandler) findingInfo(w http.ResponseWriter, r *http.Request) error {
   328  	finding, err := h.findingRepo.GetByID(r.Context(), r.PathValue("id"))
   329  	if err != nil {
   330  		return err
   331  	} else if finding == nil {
   332  		return fmt.Errorf("%w: finding", errNotFound)
   333  	}
   334  	switch r.PathValue("key") {
   335  	case "report":
   336  		return h.streamBlob(w, finding.ReportURI)
   337  	case "log":
   338  		return h.streamBlob(w, finding.LogURI)
   339  	case "syz_repro":
   340  		opts, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproOptsURI)
   341  		if err != nil {
   342  			return err
   343  		}
   344  		repro, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproURI)
   345  		if err != nil {
   346  			return err
   347  		}
   348  		fmt.Fprintf(w, "# %s\n", opts)
   349  		_, err = w.Write(repro)
   350  		return err
   351  	case "c_repro":
   352  		return h.streamBlob(w, finding.CReproURI)
   353  	default:
   354  		return fmt.Errorf("%w: unknown key value", errBadRequest)
   355  	}
   356  }
   357  
   358  func (h *dashboardHandler) buildInfo(w http.ResponseWriter, r *http.Request) error {
   359  	build, err := h.buildRepo.GetByID(r.Context(), r.PathValue("id"))
   360  	if err != nil {
   361  		return err
   362  	} else if build == nil {
   363  		return fmt.Errorf("%w: build", errNotFound)
   364  	}
   365  	switch r.PathValue("key") {
   366  	case "log":
   367  		return h.streamBlob(w, build.LogURI)
   368  	case "config":
   369  		return h.streamBlob(w, build.ConfigURI)
   370  	default:
   371  		return fmt.Errorf("%w: unknown key value", errBadRequest)
   372  	}
   373  }
   374  
   375  func (h *dashboardHandler) sessionTestLog(w http.ResponseWriter, r *http.Request) error {
   376  	test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name"))
   377  	if err != nil {
   378  		return err
   379  	} else if test == nil {
   380  		return fmt.Errorf("%w: test", errNotFound)
   381  	}
   382  	return h.streamBlob(w, test.LogURI)
   383  }
   384  
   385  func (h *dashboardHandler) sessionTestArtifacts(w http.ResponseWriter, r *http.Request) error {
   386  	test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name"))
   387  	if err != nil {
   388  		return err
   389  	} else if test == nil {
   390  		return fmt.Errorf("%w: test", errNotFound)
   391  	}
   392  	filename := fmt.Sprintf("%s_%s.tar.gz", test.SessionID, test.TestName)
   393  	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
   394  	w.Header().Set("Content-Type", "application/octet-stream")
   395  	return h.streamBlob(w, test.ArtifactsArchiveURI)
   396  }
   397  
   398  func (h *dashboardHandler) streamBlob(w http.ResponseWriter, uri string) error {
   399  	if uri == "" {
   400  		return nil
   401  	}
   402  	reader, err := h.blobStorage.Read(uri)
   403  	if err != nil {
   404  		return err
   405  	}
   406  	defer reader.Close()
   407  	_, err = io.Copy(w, reader)
   408  	return err
   409  }
   410  
   411  func errToStatus(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
   412  	return func(w http.ResponseWriter, r *http.Request) {
   413  		err := f(w, r)
   414  		if errors.Is(err, errNotFound) {
   415  			http.Error(w, err.Error(), http.StatusNotFound)
   416  		} else if errors.Is(err, errBadRequest) {
   417  			http.Error(w, err.Error(), http.StatusBadRequest)
   418  		} else if err != nil {
   419  			// TODO: if the error happened in the template, likely we've already printed
   420  			// something to w. Unless we're in streamBlob(), it makes sense to first collect
   421  			// the output in some buffer and only dump it after the exit from the handler.
   422  			http.Error(w, err.Error(), http.StatusInternalServerError)
   423  		}
   424  	}
   425  }