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 }