github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/shared/appengine.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 sharedtest/appengine_mock.go -package sharedtest github.com/web-platform-tests/wpt.fyi/shared AppEngineAPI
     6  
     7  package shared
     8  
     9  import (
    10  	"context"
    11  	"fmt"
    12  	"log"
    13  	"net/http"
    14  	"net/url"
    15  	"os"
    16  	"strings"
    17  	"time"
    18  
    19  	cloudtasks "cloud.google.com/go/cloudtasks/apiv2"
    20  	"cloud.google.com/go/datastore"
    21  	gclog "cloud.google.com/go/logging"
    22  	secretmanager "cloud.google.com/go/secretmanager/apiv1"
    23  	"github.com/gomodule/redigo/redis"
    24  	"github.com/google/go-github/v47/github"
    25  	apps "google.golang.org/api/appengine/v1"
    26  	"google.golang.org/api/option"
    27  	mrpb "google.golang.org/genproto/googleapis/api/monitoredres"
    28  	taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2"
    29  	"google.golang.org/grpc"
    30  	"google.golang.org/grpc/keepalive"
    31  )
    32  
    33  type clientsImpl struct {
    34  	cloudtasks    *cloudtasks.Client
    35  	datastore     *datastore.Client
    36  	gclogClient   *gclog.Client
    37  	childLogger   *gclog.Logger
    38  	parentLogger  *gclog.Logger
    39  	redisPool     *redis.Pool
    40  	secretManager *secretmanager.Client
    41  }
    42  
    43  // Clients is a singleton containing heavyweight (e.g. with connection pools)
    44  // clients that should be bound to the runtime instead of each request in order
    45  // to be reused. They are initialized and authenticated at startup using the
    46  // background context; each request should use its own context.
    47  var Clients clientsImpl
    48  
    49  // Init initializes all clients in Clients. If an error is encountered, it
    50  // returns immediately without trying to initialize the remaining clients.
    51  func (c *clientsImpl) Init(ctx context.Context) (err error) {
    52  	if isDevAppserver() {
    53  		// Use empty project ID to pick up emulator settings.
    54  		c.datastore, err = datastore.NewClient(ctx, "")
    55  		// When running in dev_appserver, do not create other real clients.
    56  		return err
    57  	}
    58  
    59  	keepAlive := option.WithGRPCDialOption(grpc.WithKeepaliveParams(keepalive.ClientParameters{
    60  		Time: 5 * time.Minute,
    61  	}))
    62  
    63  	// Cloud Tasks
    64  	// Use keepalive to work around https://github.com/googleapis/google-cloud-go/issues/3205
    65  	c.cloudtasks, err = cloudtasks.NewClient(ctx, keepAlive)
    66  	if err != nil {
    67  		return err
    68  	}
    69  
    70  	// Cloud Datastore
    71  	c.datastore, err = datastore.NewClient(ctx, runtimeIdentity.AppID)
    72  	if err != nil {
    73  		return err
    74  	}
    75  
    76  	// Cloud Logging
    77  	c.gclogClient, err = gclog.NewClient(ctx, runtimeIdentity.AppID)
    78  	if err != nil {
    79  		return err
    80  	}
    81  
    82  	// Cloud Secret Manager
    83  	c.secretManager, err = secretmanager.NewClient(ctx)
    84  	if err != nil {
    85  		return err
    86  	}
    87  
    88  	monitoredResource := mrpb.MonitoredResource{
    89  		Type: "gae_app",
    90  		Labels: map[string]string{
    91  			"project_id": runtimeIdentity.AppID,
    92  			"module_id":  runtimeIdentity.Service,
    93  			"version_id": runtimeIdentity.Version,
    94  		},
    95  	}
    96  	// Reuse loggers to prevent leaking goroutines: https://github.com/googleapis/google-cloud-go/issues/720#issuecomment-346199870
    97  	c.childLogger = c.gclogClient.Logger("request_log_entries", gclog.CommonResource(&monitoredResource))
    98  	c.parentLogger = c.gclogClient.Logger("request_log", gclog.CommonResource(&monitoredResource))
    99  
   100  	// Cloud Memorystore (Redis)
   101  	// Based on https://cloud.google.com/appengine/docs/standard/go/using-memorystore#importing_and_creating_the_client
   102  	redisHost := os.Getenv("REDISHOST")
   103  	redisPort := os.Getenv("REDISPORT")
   104  	redisAddr := fmt.Sprintf("%s:%s", redisHost, redisPort)
   105  	const maxConnections = 10
   106  	Clients.redisPool = redis.NewPool(func() (redis.Conn, error) {
   107  		return redis.Dial("tcp", redisAddr)
   108  	}, maxConnections)
   109  
   110  	return nil
   111  }
   112  
   113  // Close closes all clients in Clients. It must be called once and only once
   114  // before the server exits. Do not use AppEngineAPI afterwards.
   115  func (c *clientsImpl) Close() {
   116  	log.Println("Closing clients")
   117  	// In the code below, we set clients to nil before closing them. This would
   118  	// cause a panic if we use a client that's being closed, which should never
   119  	// happen but we are not sure how exactly App Engine manages instances.
   120  
   121  	if c.cloudtasks != nil {
   122  		client := c.cloudtasks
   123  		c.cloudtasks = nil
   124  		if err := client.Close(); err != nil {
   125  			log.Printf("Error closing cloudtasks: %s", err.Error())
   126  		}
   127  	}
   128  
   129  	if c.datastore != nil {
   130  		client := c.datastore
   131  		c.datastore = nil
   132  		if err := client.Close(); err != nil {
   133  			log.Printf("Error closing datastore: %s", err.Error())
   134  		}
   135  	}
   136  
   137  	if c.gclogClient != nil {
   138  		client := c.gclogClient
   139  		c.gclogClient = nil
   140  		c.childLogger = nil
   141  		c.parentLogger = nil
   142  		if err := client.Close(); err != nil {
   143  			log.Printf("Error closing gclog client: %s", err.Error())
   144  		}
   145  	}
   146  
   147  	if c.redisPool != nil {
   148  		client := c.redisPool
   149  		c.redisPool = nil
   150  		if err := client.Close(); err != nil {
   151  			log.Printf("Error closing redis client: %s", err.Error())
   152  		}
   153  	}
   154  
   155  	if c.secretManager != nil {
   156  		client := c.secretManager
   157  		c.secretManager = nil
   158  		if err := client.Close(); err != nil {
   159  			log.Printf("Error closing secret manager client: %s", err.Error())
   160  		}
   161  	}
   162  }
   163  
   164  // AppEngineAPI is an abstraction of some appengine context helper methods.
   165  type AppEngineAPI interface {
   166  	Context() context.Context
   167  
   168  	// GitHub OAuth client using the bot account (wptfyibot), which has
   169  	// repo and read:org permissions.
   170  	GetGitHubClient() (*github.Client, error)
   171  
   172  	// http.Client
   173  	GetHTTPClient() *http.Client
   174  	GetHTTPClientWithTimeout(time.Duration) *http.Client
   175  
   176  	// GetVersion returns the version name for the current environment.
   177  	GetVersion() string
   178  	// GetHostname returns the canonical hostname for the current AppEngine
   179  	// project, i.e. staging.wpt.fyi or wpt.fyi.
   180  	GetHostname() string
   181  	// GetVersionedHostname returns the AppEngine hostname for the current
   182  	// version of the default service, i.e.,
   183  	//   VERSION-dot-wptdashboard{,-staging}.REGION.r.appspot.com.
   184  	// Note: if the default service does not have the current version,
   185  	// AppEngine routing will find a version according to traffic split.
   186  	// https://cloud.google.com/appengine/docs/standard/go/how-requests-are-routed#soft_routing
   187  	GetVersionedHostname() string
   188  	// GetServiceHostname returns the AppEngine hostname for the current
   189  	// version of the given service, i.e.,
   190  	//   VERSION-dot-SERVICE-dot-wptdashboard{,-staging}.REGION.r.appspot.com.
   191  	// Note: if the specified service does not have the current version,
   192  	// AppEngine routing will find a version according to traffic split;
   193  	// if the service does not exist at all, AppEngine will fall back to
   194  	// the default service.
   195  	GetServiceHostname(service string) string
   196  
   197  	// GetResultsURL returns a URL to {staging.,}wpt.fyi/results with the
   198  	// given filter.
   199  	GetResultsURL(filter TestRunFilter) *url.URL
   200  	// GetRunsURL returns a URL to {staging.,}wpt.fyi/runs with the given
   201  	// filter.
   202  	GetRunsURL(filter TestRunFilter) *url.URL
   203  	// GetResultsUploadURL returns a URL for uploading results.
   204  	GetResultsUploadURL() *url.URL
   205  
   206  	// Simple wrappers that delegate to Datastore
   207  	IsFeatureEnabled(featureName string) bool
   208  	GetUploader(uploader string) (Uploader, error)
   209  
   210  	// ScheduleTask schedules an AppEngine POST task on Cloud Tasks.
   211  	// taskName can be empty, in which case one will be generated by Cloud
   212  	// Tasks. Returns the final taskName and error.
   213  	ScheduleTask(queueName, taskName, target string, params url.Values) (string, error)
   214  }
   215  
   216  // runtimeIdentity contains the identity of the current AppEngine service when
   217  // running on GAE, or empty when running locally.
   218  var runtimeIdentity struct {
   219  	LocationID string
   220  	AppID      string
   221  	Service    string
   222  	Version    string
   223  
   224  	// Internal details of the application identity
   225  	application *apps.Application
   226  }
   227  
   228  func init() {
   229  	// Env vars available on GAE:
   230  	// https://cloud.google.com/appengine/docs/standard/go/runtime#environment_variables
   231  	// Note: the "region code" part of GAE_APPLICATION is NOT location ID.
   232  	if proj := os.Getenv("GOOGLE_CLOUD_PROJECT"); proj != "" {
   233  		runtimeIdentity.AppID = proj
   234  		runtimeIdentity.Service = os.Getenv("GAE_SERVICE")
   235  		if runtimeIdentity.Service == "" {
   236  			panic("Missing environment variable: GAE_SERVICE")
   237  		}
   238  		runtimeIdentity.Version = os.Getenv("GAE_VERSION")
   239  		if runtimeIdentity.Version == "" {
   240  			panic("Missing environment variable: GAE_VERSION")
   241  		}
   242  		if service, err := apps.NewService(context.Background()); err != nil {
   243  			panic(err)
   244  		} else {
   245  			if runtimeIdentity.application, err = service.Apps.Get(proj).Do(); err != nil {
   246  				panic(err)
   247  			}
   248  		}
   249  		runtimeIdentity.LocationID = runtimeIdentity.application.LocationId
   250  
   251  	}
   252  }
   253  
   254  func isDevAppserver() bool {
   255  	return runtimeIdentity.AppID == ""
   256  }
   257  
   258  // NewAppEngineAPI returns an AppEngineAPI for the given context.
   259  func NewAppEngineAPI(ctx context.Context) AppEngineAPI {
   260  	return &appEngineAPIImpl{
   261  		ctx: ctx,
   262  	}
   263  }
   264  
   265  // appEngineAPIImpl implements the AppEngineAPI interface.
   266  type appEngineAPIImpl struct {
   267  	ctx          context.Context
   268  	githubClient *github.Client
   269  }
   270  
   271  func (a appEngineAPIImpl) Context() context.Context {
   272  	return a.ctx
   273  }
   274  
   275  func (a appEngineAPIImpl) GetHTTPClient() *http.Client {
   276  	// Set timeout to 5s for compatibility with legacy appengine.urlfetch.Client.
   277  	return a.GetHTTPClientWithTimeout(time.Second * 5)
   278  }
   279  
   280  func (a appEngineAPIImpl) GetHTTPClientWithTimeout(timeout time.Duration) *http.Client {
   281  	return &http.Client{Timeout: timeout}
   282  }
   283  
   284  func (a *appEngineAPIImpl) GetGitHubClient() (*github.Client, error) {
   285  	if a.githubClient == nil {
   286  		secret, err := GetSecret(NewAppEngineDatastore(a.ctx, false), "github-wpt-fyi-bot-token")
   287  		if err != nil {
   288  			return nil, err
   289  		}
   290  		a.githubClient = NewGitHubClientFromToken(a.ctx, secret)
   291  	}
   292  	return a.githubClient, nil
   293  }
   294  
   295  func (a appEngineAPIImpl) IsFeatureEnabled(featureName string) bool {
   296  	ds := NewAppEngineDatastore(a.ctx, false)
   297  	return IsFeatureEnabled(ds, featureName)
   298  }
   299  
   300  func (a appEngineAPIImpl) GetUploader(uploader string) (Uploader, error) {
   301  	m := NewAppEngineSecretManager(a.ctx, runtimeIdentity.AppID)
   302  	return GetUploader(m, uploader)
   303  }
   304  
   305  func (a appEngineAPIImpl) GetHostname() string {
   306  	if runtimeIdentity.AppID == "wptdashboard" {
   307  		return "wpt.fyi"
   308  	} else if runtimeIdentity.AppID == "wptdashboard-staging" {
   309  		return "staging.wpt.fyi"
   310  	} else if runtimeIdentity.application != nil {
   311  		return runtimeIdentity.application.DefaultHostname
   312  	}
   313  	return "localhost"
   314  }
   315  
   316  func (a appEngineAPIImpl) GetVersion() string {
   317  	if runtimeIdentity.Version != "" {
   318  		return runtimeIdentity.Version
   319  	}
   320  	return "local dev_appserver"
   321  }
   322  
   323  func (a appEngineAPIImpl) GetVersionedHostname() string {
   324  	if runtimeIdentity.application != nil {
   325  		return fmt.Sprintf("%s-dot-%s", a.GetVersion(), runtimeIdentity.application.DefaultHostname)
   326  	}
   327  	return "localhost"
   328  }
   329  
   330  func (a appEngineAPIImpl) GetServiceHostname(service string) string {
   331  	if runtimeIdentity.application != nil {
   332  		return fmt.Sprintf("%s-dot-%s-dot-%s", a.GetVersion(), service, runtimeIdentity.application.DefaultHostname)
   333  	}
   334  	return "localhost"
   335  }
   336  
   337  func (a appEngineAPIImpl) GetResultsURL(filter TestRunFilter) *url.URL {
   338  	return getURL(a.GetHostname(), "/results/", filter)
   339  }
   340  
   341  func (a appEngineAPIImpl) GetRunsURL(filter TestRunFilter) *url.URL {
   342  	return getURL(a.GetHostname(), "/runs", filter)
   343  }
   344  
   345  func (a appEngineAPIImpl) GetResultsUploadURL() *url.URL {
   346  	result, _ := url.Parse(fmt.Sprintf("https://%s%s", a.GetVersionedHostname(), "/api/results/upload"))
   347  	return result
   348  }
   349  
   350  func (a appEngineAPIImpl) ScheduleTask(queueName, taskName, target string, params url.Values) (string, error) {
   351  	if Clients.cloudtasks == nil {
   352  		panic("Clients.cloudtasks is nil")
   353  	}
   354  
   355  	taskPrefix, req := createTaskRequest(queueName, taskName, target, params)
   356  	createdTask, err := Clients.cloudtasks.CreateTask(a.ctx, req)
   357  	if err != nil {
   358  		return "", err
   359  	}
   360  
   361  	createdTaskName := createdTask.Name
   362  	logger := GetLogger(a.ctx)
   363  	if strings.HasPrefix(createdTaskName, taskPrefix) {
   364  		if createdTaskName != taskName && taskName != "" {
   365  			logger.Warningf("Requested task name %s but got %s", taskName, createdTaskName)
   366  		}
   367  		createdTaskName = strings.TrimPrefix(createdTaskName, taskPrefix)
   368  	} else {
   369  		logger.Errorf("Got unknown task name: %s", createdTaskName)
   370  	}
   371  
   372  	return createdTaskName, nil
   373  }
   374  
   375  func getURL(host, path string, filter TestRunFilter) *url.URL {
   376  	detailsURL, _ := url.Parse(fmt.Sprintf("https://%s%s", host, path))
   377  	detailsURL.RawQuery = filter.ToQuery().Encode()
   378  	return detailsURL
   379  }
   380  
   381  func createTaskRequest(queueName, taskName, target string, params url.Values) (taskPrefix string, req *taskspb.CreateTaskRequest) {
   382  	// HACK (https://cloud.google.com/tasks/docs/dual-overview):
   383  	// "Note that two locations, called europe-west and us-central in App
   384  	// Engine commands, are called, respectively, europe-west1 and
   385  	// us-central1 in Cloud Tasks commands."
   386  	location := runtimeIdentity.LocationID
   387  	if location == "us-central" {
   388  		location = "us-central1"
   389  	}
   390  
   391  	// Based on https://cloud.google.com/tasks/docs/creating-appengine-tasks#go
   392  	queuePath := fmt.Sprintf("projects/%s/locations/%s/queues/%s",
   393  		runtimeIdentity.AppID, location, queueName)
   394  	taskPrefix = queuePath + "/tasks/"
   395  	if taskName != "" {
   396  		taskName = taskPrefix + taskName
   397  	}
   398  	return taskPrefix, &taskspb.CreateTaskRequest{
   399  		Parent: queuePath,
   400  		Task: &taskspb.Task{
   401  			Name: taskName,
   402  			MessageType: &taskspb.Task_AppEngineHttpRequest{
   403  				AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{
   404  					HttpMethod:  taskspb.HttpMethod_POST,
   405  					RelativeUri: target,
   406  					// In appengine.taskqueue, The default for POST task was
   407  					// application/x-www-form-urlencoded, but the new SDK
   408  					// defaults to application/octet-stream.
   409  					Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
   410  					Body:    []byte(params.Encode()),
   411  				},
   412  			},
   413  		},
   414  	}
   415  }