internal/cloudrunci/cloudrunci.go (376 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 cloudrunci facilitates end-to-end testing against the production Cloud Run.
//
// This is a specialized tool that could be used in addition to unit tests. It
// calls the `gcloud beta run` 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.
package cloudrunci
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os/exec"
"path"
"strings"
"time"
"cloud.google.com/go/logging/logadmin"
"google.golang.org/api/iterator"
)
// labels are used in operation-related logs.
const (
labelOperationDeploy = "deploy service"
labelOperationBuild = "build container image"
labelOperationDeleteService = "delete service"
labelOperationDeleteImage = "delete container image"
labelOperationGetURL = "get url"
defaultRegistryName = "cloudrunci"
)
// Service describes a Cloud Run service
type Service struct {
// Name is an ID, used for logging and to generate a unique version to this run.
Name string
// The root directory containing the service's source code.
Dir string
// The container image name to deploy. If left blank the container will be built
// and pushed to gcr.io/[ProjectID]/cloudrunci/[Name]:[Revision]
Image string
// The project to deploy to.
ProjectID string
// Allow unauthenticated request.
AllowUnauthenticated bool
// The platform to deploy to.
Platform Platform
// Additional runtime environment variable overrides for the app.
Env EnvVars
// Build the image without Dockerfile, using Google Cloud buildpacks.
AsBuildpack bool
// Strictly HTTP/2 serving
HTTP2 bool
deployed bool // Whether the service has been deployed.
built bool // Whether the container image has been built.
url *url.URL // The url of the deployed service.
// Location to deploy the Service, and related artifacts
Location string
}
// runID is an identifier that changes between runs.
var runID = time.Now().Format("20060102-150405")
// NewService creates a new Service based on the name and projectID provided.
// It will default to the ManagedPlatform in region us-central1,
// and build a container image as needed for deployment.
func NewService(name, projectID string) *Service {
return &Service{
Name: name,
ProjectID: projectID,
Platform: ManagedPlatform{Region: "us-central1"},
Location: "us-central1",
}
}
// Deployed reports whether the service has been deployed.
func (s *Service) Deployed() bool {
return s.deployed
}
// RetryOptions holds options for Service.Request's retry behavior
type RetryOptions struct {
MaxAttempts int
Delay time.Duration
ShouldAccept func(*http.Response) bool
}
func getDefaultRetryOptions() RetryOptions {
return RetryOptions{
MaxAttempts: 5,
Delay: 20 * time.Second,
ShouldAccept: Accept2xx,
}
}
// Accept2xx returns true for responses in the 200 class of http response codes
func Accept2xx(r *http.Response) bool {
return r.StatusCode >= 200 && r.StatusCode < 300
}
// AcceptNonServerError returns true for any non-500 http response
func AcceptNonServerError(r *http.Response) bool {
return r.StatusCode < 500
}
func WithAttempts(n int) func(*RetryOptions) {
return func(r *RetryOptions) {
r.MaxAttempts = n
}
}
func WithDelay(d time.Duration) func(*RetryOptions) {
return func(r *RetryOptions) {
r.Delay = d
}
}
func WithAcceptFunc(f func(*http.Response) bool) func(*RetryOptions) {
return func(r *RetryOptions) {
r.ShouldAccept = f
}
}
// Do executes the provided http.Request using the default http client
func (s *Service) Do(req *http.Request, opts ...func(*RetryOptions)) (*http.Response, error) {
if !s.deployed {
return nil, errors.New("Request called before Deploy")
}
options := getDefaultRetryOptions()
for _, fn := range opts {
fn(&options)
}
var lastSeen error
resp := &http.Response{}
for i := 0; i < options.MaxAttempts; i++ {
defaultClient := &http.Client{}
resp, lastSeen = defaultClient.Do(req)
if lastSeen != nil {
continue
}
if options.ShouldAccept(resp) {
return resp, nil
}
time.Sleep(options.Delay)
}
// Too many attempts, return the last result.
return resp, fmt.Errorf("no acceptable response after %d retries: %w", options.MaxAttempts, lastSeen)
}
// ImageRepoURL returns the base URL for building docker images
func (s *Service) ImageRepoURL() string {
return fmt.Sprintf("%s-docker.pkg.dev/%s/%s", s.Location, s.ProjectID, defaultRegistryName)
}
// ensureDefaultImageRepo uses gcloud to create a default Image registry.
func (s *Service) ensureDefaultImageRepo() error {
return ensureDefaultImageRepo(s.ProjectID, s.Location)
}
// Request issues an HTTP request to the deployed service.
func (s *Service) Request(method string, path string, opts ...func(*RetryOptions)) (*http.Response, error) {
req, err := s.NewRequest(method, path)
if err != nil {
return &http.Response{}, err
}
return s.Do(req, opts...)
}
// NewRequest creates a new http.Request for the deployed service.
func (s *Service) NewRequest(method, path string) (*http.Request, error) {
if !s.deployed {
return nil, errors.New("NewRequest called before Deploy")
}
url, err := s.URL(path)
if err != nil {
return nil, fmt.Errorf("service.URL: %w", err)
}
return s.Platform.NewRequest(method, url)
}
// URL prepends the deployed service's base URL to the given path.
// Returns an error if the application has not been deployed.
func (s *Service) URL(p string) (string, error) {
u, err := s.ParsedURL()
if err != nil {
return "", fmt.Errorf("service.ParsedURL: %w", err)
}
modified := &url.URL{}
*modified = *u
modified.Path = path.Join(modified.Path, p)
return modified.String(), nil
}
// Host returns the host:port of the service to facilitate new gRPC connections.
func (s *Service) Host() (string, error) {
u, err := s.ParsedURL()
if err != nil {
return "", fmt.Errorf("service.ParsedURL: %w", err)
}
return u.Host + ":443", nil
}
// ParsedURL retrieves the parsed URL of the service.
// This URL is stored on the service struct for repeated retrieval.
func (s *Service) ParsedURL() (*url.URL, error) {
if !s.deployed {
return nil, errors.New("URL called before Deploy")
}
if s.url == nil {
out, err := gcloud(s.operationLabel(labelOperationGetURL), s.urlCmd())
if err != nil {
return nil, fmt.Errorf("gcloud: %s: %q", s.Name, err)
}
sURL := string(out)
u, err := url.Parse(sURL)
if err != nil {
return nil, fmt.Errorf("url.Parse: %w", err)
}
s.url = u
}
return s.url, nil
}
// validate confirms all required service properties are present.
func (s *Service) validate() error {
if s.ProjectID == "" {
return errors.New("Project ID missing")
}
if s.Platform == nil {
return errors.New("Platform configuration missing")
}
if err := s.Platform.Validate(); err != nil {
return err
}
if err := s.Env.Validate(); err != nil {
return err
}
return nil
}
// revision returns the revision that the service will be deployed to.
// NOTE: Until traffic splitting is available, this will be used as the service name.
func (s *Service) version() string {
return s.Name + "-" + runID
}
// Deploy deploys the service to Cloud Run.
// If an image has not been specified or previously built, it will call Build.
// If the deployment fails, it tries to clean up the failed deployment.
func (s *Service) Deploy() error {
// Don't deploy unless we're certain everything is ready for deployment
// (i.e. admin client is authenticated and authorized)
if err := s.validate(); err != nil {
return err
}
if s.Image == "" && !s.built {
if err := s.Build(); err != nil {
return err
}
}
if _, err := gcloud(s.operationLabel(labelOperationDeploy), s.deployCmd()); err != nil {
return fmt.Errorf("gcloud: %s: %q", s.version(), err)
}
s.deployed = true
return nil
}
// Build builds a container image if one has not already been built.
// If service.Image is specified and this is directly called, it
// could overwrite an existing container image.
// If service.Image is not specified, the service.Deploy() function will
// call Build().
func (s *Service) Build() error {
if err := s.validate(); err != nil {
return err
}
if s.built {
return fmt.Errorf("container image already built")
}
if s.Image == "" {
err := s.ensureDefaultImageRepo()
if err != nil {
return fmt.Errorf("failed to create image repository: %w", err)
}
s.Image = fmt.Sprintf("%s/%s:%s", s.ImageRepoURL(), s.Name, runID)
}
if out, err := gcloud(s.operationLabel(labelOperationBuild), s.buildCmd()); err != nil {
log.Print(string(out))
return fmt.Errorf("gcloud: %s: %q", s.Image, err)
}
s.built = true
return nil
}
// Clean deletes the created Cloud Run service.
func (s *Service) Clean() error {
// NOTE: don't check whether p.deployed is set.
// We may want to attempt to clean up if deployment failed.
if err := s.validate(); err != nil {
return err
}
if _, err := gcloud(s.operationLabel(labelOperationDeleteService), s.deleteServiceCmd()); err != nil {
return fmt.Errorf("gcloud: %v: %q", s.version(), err)
}
s.deployed = false
// If s.built is false no image was created or is not managed by cloudrun-ci.
if s.built {
_, err := gcloud(s.operationLabel("delete container image"), s.deleteImageCmd())
if err != nil {
return fmt.Errorf("gcloud: %v: %q", s.version(), err)
}
s.built = false
}
return nil
}
func (s *Service) operationLabel(op string) string {
return fmt.Sprintf("operation [%s] for service [%s]", op, s.Name)
}
func (s *Service) deployCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"alpha", // TODO until --use-http2 goes GA
"run",
"deploy",
s.version(),
"--project",
s.ProjectID,
"--image",
s.Image,
}, s.Platform.CommandFlags()...)
if s.Env != nil {
for k := range s.Env {
args = append(args, "--set-env-vars", s.Env.Variable(k))
}
}
if s.AllowUnauthenticated {
args = append(args, "--allow-unauthenticated")
}
if s.HTTP2 {
args = append(args, "--use-http2")
}
// NOTE: if the "beta" 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, args...)
cmd.Dir = s.Dir
return cmd
}
func (s *Service) buildCmd() *exec.Cmd {
args := []string{
"--quiet",
"beta", // TODO until --pack goes to GA
"builds",
"submit",
"--project",
s.ProjectID,
}
if !s.AsBuildpack {
args = append(args, "--tag", s.Image)
} else {
args = append(args, "--pack=image="+s.Image)
}
// NOTE: if the "beta" 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, args...)
cmd.Dir = s.Dir
return cmd
}
func (s *Service) deleteImageCmd() *exec.Cmd {
args := []string{
"--quiet",
"container",
"images",
"delete",
s.Image,
}
// NOTE: if the "beta" 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, args...)
cmd.Dir = s.Dir
return cmd
}
func (s *Service) deleteServiceCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"run",
"services",
"delete",
s.version(),
"--project",
s.ProjectID,
}, s.Platform.CommandFlags()...)
// NOTE: if the "beta" 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, args...)
cmd.Dir = s.Dir
return cmd
}
func (s *Service) urlCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"run",
"services",
"describe",
s.version(),
"--project",
s.ProjectID,
"--format",
"value(status.url)",
}, s.Platform.CommandFlags()...)
// NOTE: if the "beta" 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, args...)
cmd.Dir = s.Dir
return cmd
}
func (s *Service) LogEntries(filter string, find string, maxAttempts int) (bool, error) {
ctx := context.Background()
client, err := logadmin.NewClient(ctx, s.ProjectID)
if err != nil {
return false, fmt.Errorf("logadmin.NewClient: %w", err)
}
defer client.Close()
preparedFilter := fmt.Sprintf(`resource.type="cloud_run_revision" resource.labels.service_name="%s" %s`, s.version(), filter)
log.Printf("Using log filter: %s\n", preparedFilter)
log.Println("Waiting for logs...")
time.Sleep(3 * time.Minute)
for i := 1; i < maxAttempts; i++ {
log.Printf("Attempt #%d\n", i)
it := client.Entries(ctx, logadmin.Filter(preparedFilter))
for {
entry, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return false, fmt.Errorf("it.Next: %w", err)
}
payload := fmt.Sprintf("%v", entry.Payload)
if len(payload) > 0 {
log.Printf("entry.Payload: %v\n", entry.Payload)
}
if strings.Contains(payload, find) {
log.Printf("%q log entry found.\n", find)
return true, nil
}
}
time.Sleep(15 * time.Second)
}
return false, nil
}
// ensureDefaultImageRepo creates a default docker repo in the given project and location
// if it does not already exist.
func ensureDefaultImageRepo(project string, location string) error {
cmd := exec.Command(gcloudBin,
"artifacts", "repositories", "create", defaultRegistryName,
"--project",
project,
"--repository-format=docker",
"--location", location)
o, err := gcloudWithoutRetry("ensure image repo", cmd)
if err == nil {
return nil
}
if strings.Contains(string(o), "ALREADY_EXISTS") {
return nil
}
return err
}