internal/aeintegrate/aeintegrate.go (220 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package aeintegrate facilitates end-to-end testing against the production Google App Engine.
//
// This is a specialized tool that could be used in addition to unit tests. It
// calls the `gcloud app` command directly.
//
// gcloud(https://cloud.google.com/sdk) must be installed. You must be authorized via
// the gcloud command-line tool (`gcloud auth login`).
//
// You may specify the location of gcloud via the GCLOUD_BIN environment variable.
//
// Sample usage with `go test`:
//
// package myapp
//
// import (
// "testing"
// "google.golang.org/appengine/aeintegrate"
// )
//
// func TestApp(t *testing.T) {
// t.Parallel()
// app := aeintegrate.App{Name: "A", Dir: "app"},
// if err := app.Deploy(); err != nil {
// t.Fatalf("could not deploy app: %v", err)
// }
// defer app.Cleanup()
// resp, err := app.Get("/")
// ...
// }
package aeintegrate
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
"golang.org/x/oauth2/google"
appengine "google.golang.org/api/appengine/v1"
"gopkg.in/yaml.v2"
)
// runID is an identifier that changes between runs.
var runID = time.Now().Format("20060102-150405")
// App describes an App Engine application.
type App struct {
// Name is an ID, used for logging and to generate a unique version to this run.
Name string
// The root directory containing the app's source code.
Dir string
// The configuration (app.yaml) file, relative to Dir. Defaults to "app.yaml".
AppYaml string
// The project to deploy to.
ProjectID string
// The service/module to deploy to. Read only.
Service string
// Additional runtime environment variable overrides for the app.
Env map[string]string
deployed bool // Whether the app has been deployed.
adminService *appengine.APIService // Used during clean up to delete the deployed version.
// A temporary configuration file that includes modifications (e.g. environment variables)
tempAppYaml string
}
// Deployed reports whether the application has been deployed.
func (p *App) Deployed() bool {
return p.deployed
}
// Get issues a GET request against the base URL of the deployed application.
func (p *App) Get(path string) (*http.Response, error) {
if !p.deployed {
return nil, errors.New("Get called before Deploy")
}
url, _ := p.URL(path)
return http.Get(url)
}
// URL prepends the deployed application's base URL to the given path.
// Returns an error if the application has not been deployed.
func (p *App) URL(path string) (string, error) {
if !p.deployed {
return "", errors.New("URL called before Deploy")
}
return fmt.Sprintf("https://%s-dot-%s-dot-%s.appspot-preview.com%s", p.version(), p.Service, p.ProjectID, path), nil
}
// version returns the version that the app will be deployed to.
func (p *App) validate() error {
if p.ProjectID == "" {
return errors.New("Project ID missing")
}
return nil
}
// version returns the version that the app will be deployed to.
func (p *App) version() string {
return p.Name + "-" + runID
}
// Deploy deploys the application to App Engine. If the deployment fails, it tries to clean up the failed deployment.
func (p *App) Deploy() error {
// Don't deploy unless we're certain everything is ready for deployment
// (i.e. admin client is authenticated and authorized)
if err := p.validate(); err != nil {
return err
}
if err := p.readService(); err != nil {
return fmt.Errorf("could not read service: %w", err)
}
if err := p.initAdminService(); err != nil {
return fmt.Errorf("could not setup admin service: %w", err)
}
log.Printf("(%s) Deploying...", p.Name)
cmd, err := p.deployCmd()
if err != nil {
log.Printf("(%s) Could not get deploy command: %v", p.Name, err)
return err
}
out, err := cmd.CombinedOutput()
// TODO: add a flag for verbose output (e.g. when running with binary created with `go test -c`)
if err != nil {
log.Printf("(%s) Output from deploy:", p.Name)
os.Stderr.Write(out)
// Try to clean up resources.
p.Cleanup()
return err
}
p.deployed = true
log.Printf("(%s) Deploy successful.", p.Name)
return nil
}
// appYaml returns the path of the config file.
func (p *App) appYaml() string {
if p.AppYaml != "" {
return p.AppYaml
}
return "app.yaml"
}
// envAppYaml writes the temporary configuration file if it does not exist already,
// then returns the path of the temporary config file.
func (p *App) envAppYaml() (string, error) {
if p.tempAppYaml != "" {
return p.tempAppYaml, nil
}
base := p.appYaml()
tmp := "aeintegrate." + base
if len(p.Env) == 0 {
err := os.Symlink(filepath.Join(p.Dir, base), filepath.Join(p.Dir, tmp))
if err != nil {
return "", err
}
p.tempAppYaml = tmp
return p.tempAppYaml, nil
}
b, err := os.ReadFile(filepath.Join(p.Dir, base))
if err != nil {
return "", err
}
var c yaml.MapSlice
if err := yaml.Unmarshal(b, &c); err != nil {
return "", err
}
for _, e := range c {
k, ok := e.Key.(string)
if !ok || k != "env_variables" {
continue
}
yamlVals, ok := e.Value.(yaml.MapSlice)
if !ok {
return "", fmt.Errorf("expected MapSlice for env_variables")
}
ENTRY:
for mapKey, newVal := range p.Env {
for i, kv := range yamlVals {
yamlKey, ok := kv.Key.(string)
if !ok {
return "", fmt.Errorf("expected string for env_variables/%#v", kv.Key)
}
if yamlKey == mapKey {
yamlVals[i].Value = newVal
break ENTRY
}
}
return "", fmt.Errorf("could not find key %s in env_variables", mapKey)
}
}
b, err = yaml.Marshal(c)
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(p.Dir, tmp), b, 0755); err != nil {
return "", err
}
p.tempAppYaml = tmp
return p.tempAppYaml, nil
}
func (p *App) deployCmd() (*exec.Cmd, error) {
gcloudBin := os.Getenv("GCLOUD_BIN")
if gcloudBin == "" {
gcloudBin = "gcloud"
}
appYaml, err := p.envAppYaml()
if err != nil {
return nil, err
}
// NOTE: if the "app" component is not available, and this is run in parallel,
// gcloud will attempt to install those components multiple
// times and will eventually fail on IO.
cmd := exec.Command(gcloudBin,
"--quiet",
"app", "deploy", appYaml,
"--project", p.ProjectID,
"--version", p.version(),
"--no-promote")
cmd.Dir = p.Dir
return cmd, nil
}
// readService reads the service out of the app.yaml file.
func (p *App) readService() error {
if p.Service != "" {
return nil
}
b, err := os.ReadFile(filepath.Join(p.Dir, p.appYaml()))
if err != nil {
return err
}
var s struct {
Service string `yaml:"service"`
}
if err := yaml.Unmarshal(b, &s); err != nil {
return err
}
if s.Service == "" {
s.Service = "default"
}
p.Service = s.Service
return nil
}
// initAdminService populates p.adminService and checks that the user is authenticated and project ID is valid.
func (p *App) initAdminService() error {
c, err := google.DefaultClient(context.Background(), appengine.CloudPlatformScope)
if err != nil {
return err
}
if p.adminService, err = appengine.New(c); err != nil {
return err
}
if err := p.validate(); err != nil {
return err
}
// Check that the user is authenticated, etc.
_, err = p.adminService.Apps.Get(p.ProjectID).Do()
return err
}
// Cleanup deletes the created version from App Engine.
func (p *App) Cleanup() error {
// NOTE: don't check whether p.deployed is set.
// We may want to attempt to clean up if deployment failed.
// However, we require adminService to be set up, which happens during Deploy().
if p.adminService == nil {
return errors.New("Cleanup called before Deploy")
}
if err := p.validate(); err != nil {
return err
}
if p.tempAppYaml != "" {
if err := os.Remove(filepath.Join(p.Dir, p.tempAppYaml)); err != nil {
// Continue trying to clean up, even if the temp yaml file didn't get removed.
log.Print(err)
}
}
log.Printf("(%s) Cleaning up.", p.Name)
var err error
for try := 0; try < 10; try++ {
_, err = p.adminService.Apps.Services.Versions.Delete(p.ProjectID, p.Service, p.version()).Do()
if err == nil {
log.Printf("(%s) Successfully cleaned up.", p.Name)
break
}
time.Sleep(time.Second)
}
if err != nil {
err = fmt.Errorf("could not delete app module version %v/%v: %v", p.Service, p.version(), err)
}
return err
}