internal/cloudrunci/cloudrunjobs.go (227 lines of code) (raw):
// Copyright 2022 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"
"os/exec"
"strings"
"time"
"cloud.google.com/go/logging/logadmin"
"google.golang.org/api/iterator"
)
// Job describes a Cloud Run Job
// The typical usage flow of a Job is to call the following methods, which
// call the corresponding "gcloud run jobs" commands:
// Build(), Create(), Run().
// Note: The LogEntries() method cannot differentiate between executions at this
// time, so it is not recommended to call Run() multiple times on a single Job
// object.
type Job struct {
// Name is an ID, used for logging and to generate a unique version to this run.
Name string
// The directory containing the job's code.
Dir string
// The container image name to deploy. If left blank the container will be built
// and pushed to gcr.io/[ProjectID]/[Name]:[Revision]
Image string
// The project to deploy to.
ProjectID string
// The Region to deploy to.
// We do not use Platform objects, since Jobs do not have a '--platform'
// flag in gcloud.
Region string
// Additional runtime environment variable overrides for the app.
Env EnvVars
// Additional flags to be passed at Job creation.
ExtraCreateFlags []string
// Build this Image as a BuildPack, without using a Dockerfile
AsBuildpack bool
built bool // True if container image has been built.
created bool // True if job has been created.
started bool // true if the Job has been started.
}
// NewJob creates a new Job to be run with Cloud Run Jobs.
// It will default to the ManagedPlatform in region us-central1,
// and build a container image as needed for deployment.
func NewJob(name, projectID string) *Job {
return &Job{
Name: name,
ProjectID: projectID,
Region: "us-central1",
}
}
func (j *Job) CommonGCloudFlags() []string {
return []string{
"--region", j.Region,
"--project", j.ProjectID,
}
}
// validate confirms all required job properties are present.
func (j *Job) validate() error {
if j.ProjectID == "" {
return errors.New("Project ID missing")
}
if j.Region == "" {
return errors.New("Region missing")
}
if err := j.Env.Validate(); err != nil {
return err
}
return nil
}
// version returns the execution that the service will be deployed to.
// This identifier is also used to locate relevant log messages.
func (j *Job) version() string {
return j.Name + "-" + runID
}
// Creates the Cloud Run job, but does not start it.
// If an image has not been specified or previously built, it will call Build.
func (j *Job) Create() error {
// Don't deploy unless we're certain everything is ready for deployment
// (i.e. admin client is authenticated and authorized)
if err := j.validate(); err != nil {
return err
}
if j.Image == "" && !j.built {
if err := j.Build(); err != nil {
return err
}
}
if _, err := gcloud(fmt.Sprintf("%s: Creating Cloud Run Job", j.version()), j.createCmd()); err != nil {
return fmt.Errorf("gcloud: %s: %q", j.version(), err)
}
j.created = 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.
func (j *Job) Build() error {
if err := j.validate(); err != nil {
return err
}
if j.built {
return fmt.Errorf("container image already built")
}
if j.Image == "" {
ensureDefaultImageRepo(j.ProjectID, j.Region)
j.Image = fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s:%s",
j.Region, j.ProjectID, defaultRegistryName, j.Name, runID)
}
if _, err := gcloud(fmt.Sprintf("%s: Building image %s", j.version(), j.Image), j.buildCmd()); err != nil {
return fmt.Errorf("gcloud: %s: %q", j.Image, err)
}
j.built = true
return nil
}
// Run starts the Job in Cloud Run Jobs.
// This method will call Build and Create if necessary.
func (j *Job) Run() error {
if err := j.validate(); err != nil {
return err
}
// Create() checks that the image was built
if !j.created {
if err := j.Create(); err != nil {
return err
}
}
if _, err := gcloud(fmt.Sprintf("%s: Running cloud run job", j.version()), j.runCmd()); err != nil {
return fmt.Errorf("gcloud: %v: %q", j.version(), err)
}
return nil
}
// Clean deletes the created Cloud Run service.
func (j *Job) Clean() error {
// NOTE: don't check whether j.created is set.
// We may want to attempt to clean up if creation failed (i.e. to clean up the image).
if err := j.validate(); err != nil {
return err
}
if _, err := gcloud(fmt.Sprintf("%s: Deleting cloud run job", j.version()), j.deleteJobCmd()); err != nil {
return fmt.Errorf("gcloud: %v: %q", j.version(), err)
}
j.created = false
// If built is false, no image was created or is not managed by cloudrun-ci.
if j.built {
_, err := gcloud(fmt.Sprintf("%s: Deleting Image %s", j.version(), j.Image), j.deleteImageCmd())
if err != nil {
return fmt.Errorf("gcloud: %v: %q", j.version(), err)
}
j.built = false
}
return nil
}
func (j *Job) createCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"alpha",
"run",
"jobs",
"create",
j.version(),
"--image",
j.Image,
}, j.CommonGCloudFlags()...)
if j.Env != nil {
for k := range j.Env {
args = append(args, "--set-env-vars", j.Env.Variable(k))
}
}
args = append(args, j.ExtraCreateFlags...)
cmd := exec.Command(gcloudBin, args...)
cmd.Dir = j.Dir
return cmd
}
func (j *Job) buildCmd() *exec.Cmd {
args := []string{
"--quiet",
"builds",
"submit",
"--project",
j.ProjectID,
}
if j.AsBuildpack {
args = append(args, "--pack=image="+j.Image)
} else {
args = append(args, "--tag", j.Image)
}
cmd := exec.Command(gcloudBin, args...)
cmd.Dir = j.Dir
return cmd
}
// runCmd returns the gcloud command needed to start this RunJob
func (j *Job) runCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"alpha",
"run",
"jobs",
"execute",
j.version(),
"--wait", // Waits for job to complete before returning.
}, j.CommonGCloudFlags()...)
cmd := exec.Command(gcloudBin, args...)
cmd.Dir = j.Dir
return cmd
}
func (j *Job) deleteImageCmd() *exec.Cmd {
args := []string{
"--quiet",
"container",
"images",
"delete",
j.Image,
"--force-delete-tags",
}
cmd := exec.Command(gcloudBin, args...)
cmd.Dir = j.Dir
return cmd
}
func (j *Job) deleteJobCmd() *exec.Cmd {
args := append([]string{
"--quiet",
"alpha",
"run",
"jobs",
"delete",
j.version(),
}, j.CommonGCloudFlags()...)
cmd := exec.Command(gcloudBin, args...)
cmd.Dir = j.Dir
return cmd
}
func (j *Job) LogEntries(filter string, find string, maxAttempts int) (bool, error) {
ctx := context.Background()
client, err := logadmin.NewClient(ctx, j.ProjectID)
if err != nil {
return false, fmt.Errorf("logadmin.NewClient: %w", err)
}
defer client.Close()
preparedFilter := fmt.Sprintf(`resource.type="cloud_run_job" resource.labels.job_name="%s" %s`, j.version(), filter)
fmt.Printf("Using log filter: %s\n", preparedFilter)
for i := 1; i < maxAttempts; i++ {
fmt.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 {
fmt.Printf("entry.Payload: %v\n", entry.Payload)
}
if strings.Contains(payload, find) {
fmt.Printf("%q log entry found.\n", find)
return true, nil
}
}
time.Sleep(30 * time.Second)
}
return false, nil
}