github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/receiver/api.go (about)

     1  // Copyright 2018 The WPT Dashboard Project. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  //go:generate mockgen -destination mock_receiver/api_mock.go github.com/web-platform-tests/wpt.fyi/api/receiver API
     6  
     7  package receiver
     8  
     9  import (
    10  	"context"
    11  	"fmt"
    12  	"io"
    13  	"net/http"
    14  	"net/url"
    15  	"regexp"
    16  	"time"
    17  
    18  	"github.com/web-platform-tests/wpt.fyi/shared"
    19  )
    20  
    21  // gcsPattern is the pattern for gs:// URI.
    22  var gcsPattern = regexp.MustCompile(`^gs://([^/]+)/(.+)$`)
    23  
    24  // AuthenticateUploader checks the HTTP basic auth against SecretManager, and returns the username if
    25  // it's valid or "" otherwise.
    26  //
    27  // This function is not defined on API interface for easier reuse in other packages.
    28  func AuthenticateUploader(aeAPI shared.AppEngineAPI, r *http.Request) string {
    29  	username, password, ok := r.BasicAuth()
    30  	if !ok {
    31  		return ""
    32  	}
    33  	user, err := aeAPI.GetUploader(username)
    34  	if err != nil || user.Password != password {
    35  		return ""
    36  	}
    37  
    38  	return user.Username
    39  }
    40  
    41  // API abstracts all AppEngine/GCP APIs used by the results receiver.
    42  type API interface {
    43  	shared.AppEngineAPI
    44  
    45  	AddTestRun(testRun *shared.TestRun) (shared.Key, error)
    46  	IsAdmin(*http.Request) bool
    47  	ScheduleResultsTask(uploader string, results, screenshots []string, extraParams map[string]string) (string, error)
    48  	UpdatePendingTestRun(pendingRun shared.PendingTestRun) error
    49  	UploadToGCS(gcsPath string, f io.Reader, gzipped bool) error
    50  }
    51  
    52  type apiImpl struct {
    53  	shared.AppEngineAPI
    54  
    55  	gcs   gcs
    56  	store shared.Datastore
    57  	queue string
    58  
    59  	githubACLFactory func(*http.Request) (shared.GitHubAccessControl, error)
    60  }
    61  
    62  // NewAPI creates a real API from a given context.
    63  // nolint:ireturn // TODO: Fix ireturn lint error
    64  func NewAPI(ctx context.Context) API {
    65  	api := shared.NewAppEngineAPI(ctx)
    66  	store := shared.NewAppEngineDatastore(ctx, false)
    67  	// nolint:exhaustruct // TODO: Fix exhaustruct lint error
    68  	return &apiImpl{
    69  		AppEngineAPI: api,
    70  		store:        store,
    71  		queue:        ResultsQueue,
    72  		githubACLFactory: func(r *http.Request) (shared.GitHubAccessControl, error) {
    73  			return shared.NewGitHubAccessControlFromRequest(api, store, r)
    74  		},
    75  	}
    76  }
    77  
    78  // nolint:ireturn // TODO: Fix ireturn lint error
    79  func (a apiImpl) AddTestRun(testRun *shared.TestRun) (shared.Key, error) {
    80  	key := a.store.NewIDKey("TestRun", testRun.ID)
    81  	var err error
    82  	if testRun.ID != 0 {
    83  		err = a.store.Insert(key, testRun)
    84  	} else {
    85  		key, err = a.store.Put(key, testRun)
    86  	}
    87  	if err != nil {
    88  		return nil, err
    89  	}
    90  
    91  	return key, nil
    92  }
    93  
    94  func (a apiImpl) IsAdmin(r *http.Request) bool {
    95  	logger := shared.GetLogger(a.Context())
    96  	acl, err := a.githubACLFactory(r)
    97  	if err != nil {
    98  		logger.Errorf("Error creating GitHubAccessControl: %s", err.Error())
    99  
   100  		return false
   101  	}
   102  	if acl == nil {
   103  		return false
   104  	}
   105  	admin, err := acl.IsValidAdmin()
   106  	if err != nil {
   107  		logger.Errorf("Error checking admin: %s", err.Error())
   108  
   109  		return false
   110  	}
   111  
   112  	return admin
   113  }
   114  
   115  func (a apiImpl) UpdatePendingTestRun(newRun shared.PendingTestRun) error {
   116  	var buffer shared.PendingTestRun
   117  	key := a.store.NewIDKey("PendingTestRun", newRun.ID)
   118  
   119  	return a.store.Update(key, &buffer, func(obj interface{}) error {
   120  		run := obj.(*shared.PendingTestRun)
   121  		if newRun.Stage != 0 {
   122  			if err := run.Transition(newRun.Stage); err != nil {
   123  				return err
   124  			}
   125  		}
   126  		if newRun.Error != "" {
   127  			run.Error = newRun.Error
   128  		}
   129  		if newRun.CheckRunID != 0 {
   130  			run.CheckRunID = newRun.CheckRunID
   131  		}
   132  		if newRun.Uploader != "" {
   133  			run.Uploader = newRun.Uploader
   134  		}
   135  		// ProductAtRevision
   136  		if newRun.BrowserName != "" {
   137  			run.BrowserName = newRun.BrowserName
   138  		}
   139  		if newRun.BrowserVersion != "" {
   140  			run.BrowserVersion = newRun.BrowserVersion
   141  		}
   142  		if newRun.OSName != "" {
   143  			run.OSName = newRun.OSName
   144  		}
   145  		if newRun.OSVersion != "" {
   146  			run.OSVersion = newRun.OSVersion
   147  		}
   148  		// nolint:staticcheck // TODO: Fix staticcheck lint error (SA1019).
   149  		if newRun.FullRevisionHash != "" {
   150  			run.Revision = newRun.FullRevisionHash[:10]
   151  			run.FullRevisionHash = newRun.FullRevisionHash
   152  		}
   153  
   154  		if run.Created.IsZero() {
   155  			run.Created = time.Now()
   156  		}
   157  		run.Updated = time.Now()
   158  
   159  		return nil
   160  	})
   161  }
   162  
   163  func (a *apiImpl) UploadToGCS(gcsPath string, f io.Reader, gzipped bool) error {
   164  	matches := gcsPattern.FindStringSubmatch(gcsPath)
   165  	if len(matches) != 3 {
   166  		return fmt.Errorf("invalid GCS path: %s", gcsPath)
   167  	}
   168  	bucketName := matches[1]
   169  	fileName := matches[2]
   170  
   171  	if a.gcs == nil {
   172  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
   173  		a.gcs = &gcsImpl{ctx: a.Context()}
   174  	}
   175  
   176  	encoding := ""
   177  	if gzipped {
   178  		encoding = "gzip"
   179  	}
   180  	// We don't defer wc.Close() here so that the file is only closed (and
   181  	// hence saved) if nothing fails.
   182  	w, err := a.gcs.NewWriter(bucketName, fileName, "", encoding)
   183  	if err != nil {
   184  		return err
   185  	}
   186  	_, err = io.Copy(w, f)
   187  	if err != nil {
   188  		return err
   189  	}
   190  	err = w.Close()
   191  
   192  	return err
   193  }
   194  
   195  func (a apiImpl) ScheduleResultsTask(
   196  	uploader string, results, screenshots []string, extraParams map[string]string) (string, error) {
   197  	key, err := a.store.ReserveID("TestRun")
   198  	if err != nil {
   199  		return "", err
   200  	}
   201  
   202  	// nolint:exhaustruct // TODO: Fix exhaustruct lint error
   203  	pendingRun := shared.PendingTestRun{
   204  		ID:       key.IntID(),
   205  		Stage:    shared.StageWptFyiReceived,
   206  		Uploader: uploader,
   207  		ProductAtRevision: shared.ProductAtRevision{
   208  			FullRevisionHash: extraParams["revision"],
   209  		},
   210  	}
   211  	if err := a.UpdatePendingTestRun(pendingRun); err != nil {
   212  		return "", err
   213  	}
   214  
   215  	payload := url.Values{
   216  		"results":     results,
   217  		"screenshots": screenshots,
   218  	}
   219  	payload.Set("id", fmt.Sprint(key.IntID()))
   220  	payload.Set("uploader", uploader)
   221  
   222  	for k, v := range extraParams {
   223  		if v != "" {
   224  			payload.Set(k, v)
   225  		}
   226  	}
   227  
   228  	return a.ScheduleTask(ResultsQueue, fmt.Sprint(key.IntID()), ResultsTarget, payload)
   229  }