| package main | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "net/http" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| "text/template" | |
| "time" | |
| "cloud.google.com/go/pubsub" | |
| "cloud.google.com/go/storage" | |
| "github.com/cenkalti/backoff/v4" | |
| "github.com/go-chi/chi/v5" | |
| "github.com/unrolled/secure" | |
| "golang.org/x/oauth2/google" | |
| "google.golang.org/api/option" | |
| "go.skia.org/infra/codesize/go/bloaty" | |
| "go.skia.org/infra/codesize/go/codesizeserver/rpc" | |
| "go.skia.org/infra/codesize/go/store" | |
| "go.skia.org/infra/go/baseapp" | |
| "go.skia.org/infra/go/fileutil" | |
| "go.skia.org/infra/go/gcs/gcsclient" | |
| "go.skia.org/infra/go/httputils" | |
| "go.skia.org/infra/go/now" | |
| "go.skia.org/infra/go/pubsub/sub" | |
| "go.skia.org/infra/go/skerr" | |
| "go.skia.org/infra/go/sklog" | |
| ) | |
| const ( | |
| gcpProjectName = "skia-public" | |
| gcsBucket = "skia-codesize" | |
| pubSubTopic = "skia-codesize-files" | |
| // numMostRecentBinaries is the number of most recent binaries to show on the index page (grouped | |
| // by changelist or patchset). | |
| numMostRecentBinaries = 20 | |
| // Preload the last 10 days (arbitrarily chosen) worth of data. Past that is probably not | |
| // relevant to most developers, and loading too much data can make the server take a long | |
| // time to load. | |
| daysToPreload = 10 | |
| // Some sections (e.g. .text) have a lot of children. This groups them up beyond a certain | |
| // point to help the treemap not be too hard to read or slow to render. | |
| maxChildrenPerParent = 200 | |
| ) | |
| var exponentialBackoffSettings = &backoff.ExponentialBackOff{ | |
| InitialInterval: 5 * time.Second, | |
| RandomizationFactor: 0.5, | |
| Multiplier: 2, | |
| MaxInterval: time.Minute, | |
| MaxElapsedTime: 5 * time.Minute, | |
| Clock: backoff.SystemClock, | |
| } | |
| // gcsPubSubEvent is the payload of the PubSub events sent by GCS on file uploads. See | |
| // https://cloud.google.com/storage/docs/json_api/v1/objects#resource-representations. | |
| type gcsPubSubEvent struct { | |
| // Bucket name, e.g. "skia-codesize". | |
| Bucket string `json:"bucket"` | |
| // Name of the affected file (relative to the bucket), e.g. "foo/bar/baz.txt". | |
| Name string `json:"name"` | |
| } | |
| type server struct { | |
| templates *template.Template | |
| gcsClient *gcsclient.StorageClient | |
| store store.Store | |
| } | |
| // See baseapp.Constructor. | |
| func new() (baseapp.App, error) { | |
| ctx := context.Background() | |
| srv := &server{} | |
| // Set up GCS client. | |
| ts, err := google.DefaultTokenSource(ctx, storage.ScopeReadWrite) | |
| if err != nil { | |
| return nil, skerr.Wrapf(err, "failed to get token source") | |
| } | |
| client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client() | |
| storageClient, err := storage.NewClient(ctx, option.WithHTTPClient(client)) | |
| if err != nil { | |
| return nil, skerr.Wrapf(err, "failed to create storage client") | |
| } | |
| srv.gcsClient = gcsclient.New(storageClient, gcsBucket) | |
| // Set up Store. | |
| s := store.New(func(ctx context.Context, path string) ([]byte, error) { | |
| sklog.Infof("Downloading %s from GCS", path) | |
| var contents []byte | |
| downloadFunc := func() error { | |
| contents, err = srv.gcsClient.GetFileContents(ctx, path) | |
| return err | |
| } | |
| if err := backoff.Retry(downloadFunc, exponentialBackoffSettings); err != nil { | |
| return nil, skerr.Wrapf(err, "even with exponential backoff, could not download %s", path) | |
| } | |
| return contents, nil | |
| }) | |
| srv.store = s | |
| // Preload the latest Bloaty outputs. | |
| if err := srv.preloadBloatyFiles(ctx); err != nil { | |
| return nil, skerr.Wrapf(err, "failed to preload Bloaty outputs from GCS") | |
| } | |
| // Subscribe to the PubSub topic via which GCS will notify us of file uploads. We use a broadcast | |
| // name provider to ensure that all replicas are notified of each file upload. We specify an | |
| // expiration policy to shorten the time until unused subscriptions are garbage-collected after | |
| // redeploying the service. | |
| expirationPolicy := time.Hour * 24 * 7 | |
| subscription, err := sub.NewWithSubNameProviderAndExpirationPolicy(ctx, gcpProjectName, pubSubTopic, sub.NewBroadcastNameProvider(*baseapp.Local, pubSubTopic), &expirationPolicy, 1) | |
| if err != nil { | |
| return nil, skerr.Wrapf(err, "failed to subscribe to PubSub topic") | |
| } | |
| // Launch a goroutine to listen for PubSub messages. | |
| go func() { | |
| sklog.Infof("Listening for PubSub messages.") | |
| for { | |
| if err := subscription.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { | |
| evt := gcsPubSubEvent{} | |
| if err := json.Unmarshal(msg.Data, &evt); err != nil { | |
| // This should never happen. But if it does, then GCS is sending spurious messages, or | |
| // something else is publishing on the topic. | |
| msg.Ack() // No point in retrying a spurious message. | |
| // TODO(lovisolo): Add a metrics counter. | |
| sklog.Errorf("Received malformed PubSub message. JSON Unmarshal error: %s\n", err) | |
| return | |
| } | |
| sklog.Infof("Received PubSub message: [bucket: %s, name: %s].\n", evt.Bucket, evt.Name) | |
| // We should only receive events for files in our GCS bucket. | |
| if evt.Bucket != gcsBucket { | |
| msg.Ack() // No point in retrying a spurious message. | |
| // TODO(lovisolo): Add a metrics counter. | |
| sklog.Errorf("Received a PubSub message from an unknown GCS bucket %q, but %q was expected. Potential configuration error.", evt.Bucket, gcsBucket) | |
| return | |
| } | |
| // Process message. | |
| if err := srv.handleFileUploadNotification(ctx, evt.Name); err != nil { | |
| // TODO(lovisolo): Add a metrics counter. | |
| sklog.Warningf("Failed to process PubSub message: [bucket: %s, name: %s]. Error: %s.\n", evt.Bucket, evt.Name, err) | |
| // We Ack messages we failed to process to prevent them from being continuously retried. | |
| // Some of these errors might be retriable, such as transient network errors while | |
| // downloading from GCS, but I don't anticipate this to happen often. As is, such an error | |
| // would prevent the replica from picking up the latest Bloaty output, but this would | |
| // resolve itself as soon as the next Skia commit lands. | |
| // | |
| // If our metrics indicate that we're failing to process lots of messages, an alternative | |
| // is to Nack failed messages so as to retry them, and set up a dead-letter topic | |
| // (https://cloud.google.com/pubsub/docs/handling-failures) with a small | |
| // MaxDeliveryAttempts value in order to limit the number of retries. | |
| msg.Ack() | |
| } else { | |
| // TODO(lovisolo): Add a metrics counter. | |
| sklog.Infof("Done processing PubSub message: [bucket: %s, name: %s].\n", evt.Bucket, evt.Name) | |
| msg.Ack() | |
| } | |
| }); err != nil { | |
| // Receive returns a non-retryable error, so we log with Fatal to exit the program. | |
| sklog.Fatal(err) | |
| } | |
| } | |
| }() | |
| srv.loadTemplates() | |
| return srv, nil | |
| } | |
| // preloadBloatyFiles preloads the latest Bloaty outputs for each supported build artifact. | |
| func (s *server) preloadBloatyFiles(ctx context.Context) error { | |
| // We'll filter out anything that isn't under a YYYY/MM/DD directory and doesn't end in ".tsv". | |
| // This excludes debug files that we occasionally upload to GCS. | |
| bloatyOutputFilePattern := regexp.MustCompile(`^[0-9]{4}/[0-9]{2}/[0-9]{2}/.*\.tsv$`) | |
| n := now.Now(ctx).UTC() | |
| dirs := fileutil.GetHourlyDirs("", n.Add(-daysToPreload*24*time.Hour), n) | |
| if *baseapp.Local { | |
| dirs = fileutil.GetHourlyDirs("", n.Add(-8*time.Hour), n) | |
| } | |
| sklog.Debugf("Preloading data, starting in folder %s", dirs[0]) | |
| for _, dir := range dirs { | |
| err := s.gcsClient.AllFilesInDirectory(ctx, dir, func(item *storage.ObjectAttrs) error { | |
| if !bloatyOutputFilePattern.MatchString(item.Name) { | |
| return nil | |
| } | |
| if err := s.store.Index(ctx, item.Name); err != nil { | |
| // If this happens often (e.g. because we're hitting a GCS QPS limit) we can do a combination | |
| // of limiting our QPS rate and only fetching the most recent files. | |
| // | |
| // As is, this is probably fine. At worst, the app will crash and Kubernetes will retry | |
| // creating the pod, entering a crash loop if we're still over the QPS quota. Exponential | |
| // backoff would be a potential mitigation. | |
| sklog.Fatalf("Error while preloading %s: %s\n", item.Name, err) | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| return skerr.Wrap(err) | |
| } | |
| } | |
| return nil | |
| } | |
| // handleFileUploadNotification is called when a new file is uploaded to the GCS bucket. | |
| func (s *server) handleFileUploadNotification(ctx context.Context, path string) error { | |
| sklog.Infof("Received file upload PubSub message: %s", path) | |
| if strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".diff.txt") { | |
| sklog.Infof("Ignoring %s because we index .json and .diff.txt files when we see a corresponding .tsv file", path) | |
| return nil | |
| } | |
| if err := s.store.Index(ctx, path); err != nil { | |
| return skerr.Wrap(err) | |
| } | |
| return nil | |
| } | |
| // loadTemplates loads the HTML templates to serve to the UI. | |
| func (s *server) loadTemplates() { | |
| s.templates = template.Must(template.New("").Delims("{%", "%}").ParseGlob( | |
| filepath.Join(*baseapp.ResourcesDir, "*.html"), | |
| )) | |
| } | |
| // sendJSONResponse sends a JSON representation of any data structure as an HTTP response. If the | |
| // conversion to JSON has an error, the error is logged. | |
| func sendJSONResponse(data interface{}, w http.ResponseWriter) { | |
| w.Header().Set("Content-Type", "application/json") | |
| if err := json.NewEncoder(w).Encode(data); err != nil { | |
| sklog.Errorf("Failed to write response: %s", err) | |
| } | |
| } | |
| // sendHTMLResponse renders the given template, passing it the current context's CSP nonce. If | |
| // template rendering fails, it logs an error. | |
| func (s *server) sendHTMLResponse(templateName string, w http.ResponseWriter, r *http.Request) { | |
| w.Header().Set("Content-Type", "text/html") | |
| if err := s.templates.ExecuteTemplate(w, templateName, map[string]string{ | |
| "Nonce": secure.CSPNonce(r.Context()), | |
| }); err != nil { | |
| sklog.Errorf("Failed to expand template: %s", err) | |
| } | |
| } | |
| func (s *server) indexPageHandler(w http.ResponseWriter, r *http.Request) { | |
| s.sendHTMLResponse("index.html", w, r) | |
| } | |
| func (s *server) binaryPageHandler(w http.ResponseWriter, r *http.Request) { | |
| s.sendHTMLResponse("binary.html", w, r) | |
| } | |
| func (s *server) binaryDiffPageHandler(w http.ResponseWriter, r *http.Request) { | |
| s.sendHTMLResponse("binary_diff.html", w, r) | |
| } | |
| func (s *server) binaryRPCHandler(w http.ResponseWriter, r *http.Request) { | |
| req := rpc.BinaryRPCRequest{} | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| httputils.ReportError(w, err, "Failed to parse request", http.StatusBadRequest) | |
| return | |
| } | |
| binary, ok := s.store.GetBinary(req.CommitOrPatchset, req.BinaryName, req.CompileTaskName) | |
| if !ok { | |
| httputils.ReportError(w, nil, "Binary not found in Store", http.StatusNotFound) | |
| return | |
| } | |
| bytes, err := s.store.GetBloatyOutputFileContents(r.Context(), binary) | |
| if err != nil { | |
| httputils.ReportError(w, err, "Failed to retrieve Bloaty output file", http.StatusInternalServerError) | |
| return | |
| } | |
| outputItems, err := bloaty.ParseTSVOutput(string(bytes)) | |
| if err != nil { | |
| httputils.ReportError(w, err, "Failed to parse Bloaty output file.", http.StatusInternalServerError) | |
| return | |
| } | |
| res := rpc.BinaryRPCResponse{ | |
| Metadata: binary.Metadata, | |
| Rows: bloaty.GenTreeMapDataTableRows(outputItems, maxChildrenPerParent), | |
| } | |
| sendJSONResponse(res, w) | |
| } | |
| func (s *server) binarySizeDiffRPCHandler(w http.ResponseWriter, r *http.Request) { | |
| req := rpc.BinarySizeDiffRPCRequest{} | |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| httputils.ReportError(w, err, "Failed to parse request", http.StatusBadRequest) | |
| return | |
| } | |
| binary, ok := s.store.GetBinary(req.CommitOrPatchset, req.BinaryName, req.CompileTaskName) | |
| if !ok { | |
| httputils.ReportError(w, nil, "Binary not found in Store", http.StatusNotFound) | |
| return | |
| } | |
| bytes, err := s.store.GetBloatySizeDiffOutputFileContents(r.Context(), binary) | |
| if err != nil { | |
| httputils.ReportError(w, err, "Failed to retrieve Bloaty output file", http.StatusInternalServerError) | |
| return | |
| } | |
| res := rpc.BinarySizeDiffRPCResponse{ | |
| Metadata: binary.Metadata, | |
| RawDiff: string(bytes), | |
| } | |
| sendJSONResponse(res, w) | |
| } | |
| func (s *server) mostRecentBinariesRPCHandler(w http.ResponseWriter, r *http.Request) { | |
| binaries := s.store.GetMostRecentBinaries(numMostRecentBinaries) | |
| res := rpc.MostRecentBinariesRPCResponse{ | |
| Binaries: binaries, | |
| } | |
| sendJSONResponse(res, w) | |
| } | |
| // See baseapp.App. | |
| func (s *server) AddHandlers(r chi.Router) { | |
| r.Get("/", s.indexPageHandler) | |
| r.Get("/binary", s.binaryPageHandler) | |
| r.Get("/binary_diff", s.binaryDiffPageHandler) | |
| r.Post("/rpc/binary/v1", s.binaryRPCHandler) | |
| r.Post("/rpc/binary_size_diff/v1", s.binarySizeDiffRPCHandler) | |
| r.Get("/rpc/most_recent_binaries/v1", s.mostRecentBinariesRPCHandler) | |
| } | |
| // See baseapp.App. | |
| func (s *server) AddMiddleware() []func(http.Handler) http.Handler { | |
| return []func(http.Handler) http.Handler{} | |
| } | |
| func main() { | |
| baseapp.Serve(new, []string{"codesize.skia.org"}) | |
| } |