blob: 0357d4327d992618c4fd4a8828d53920d25004af [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package cloudbuild wraps interaction with Google Cloud Build.
package cloudbuild
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"golang.org/x/oauth2"
"google.golang.org/api/cloudbuild/v1"
"google.golang.org/api/option"
"go.chromium.org/luci/common/errors"
"infra/cmd/cloudbuildhelper/docker"
"infra/cmd/cloudbuildhelper/manifest"
"infra/cmd/cloudbuildhelper/storage"
)
// Builder knows how to trigger Cloud Build builds and check their status.
type Builder struct {
builds *cloudbuild.ProjectsBuildsService
cfg manifest.CloudBuildConfig
}
// Request specifies what we want to build and push.
//
// It is passed to Trigger.
type Request struct {
// Source is a reference to the uploaded tarball with the context directory.
Source *storage.Object
// Image is a name of the image (perhaps with ":<tag>") to produce and push.
//
// Should include a docker registry part, e.g. have form "gcr.io/../...".
//
// If empty, Builder will still build an image, but will not push it anywhere.
// Useful to verify Dockerfile is working without accumulating cruft.
Image string
// Labels is a labels to put into the produced docker image (if any).
Labels docker.Labels
}
// Status is possible status of a Cloud Build.
type Status string
// See https://cloud.google.com/cloud-build/docs/api/reference/rest/Shared.Types/Status
const (
StatusUnknown Status = "STATUS_UNKNOWN"
StatusQueued Status = "QUEUED"
StatusWorking Status = "WORKING"
StatusSuccess Status = "SUCCESS"
StatusFailure Status = "FAILURE"
StatusInternalError Status = "INTERNAL_ERROR"
StatusTimeout Status = "TIMEOUT"
StatusCancelled Status = "CANCELLED"
)
// IsTerminal is true if the build is done (successfully or not).
func (s Status) IsTerminal() bool {
switch s {
case StatusSuccess, StatusFailure, StatusInternalError, StatusTimeout, StatusCancelled:
return true
default:
return false
}
}
// Build represents a pending, in-flight or completed build.
type Build struct {
// Theses fields are always available.
ID string // UUID string with the build ID
LogURL string // URL to a UI page (for humans) with build logs
Status Status // see the enum
StatusDetails string // human readable string with more details (if any)
// These fields are available only for successful builds.
InputHashes map[string]string // SHA256 hashes of build inputs ("gs://..." => SHA256)
OutputImage string // uploaded image name (if any)
OutputDigest string // digest (in "sha256:..." form) of the image
}
// New prepares a Builder instance.
func New(ctx context.Context, ts oauth2.TokenSource, cfg manifest.CloudBuildConfig) (*Builder, error) {
svc, err := cloudbuild.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
return nil, errors.Annotate(err, "failed to instantiate cloudbuild.Service").Err()
}
return &Builder{
builds: cloudbuild.NewProjectsBuildsService(svc),
cfg: cfg,
}, nil
}
// Trigger launches a new build, returning its details.
//
// ID of the returned build can be used to query its status later in Check(...).
func (b *Builder) Trigger(ctx context.Context, r Request) (*Build, error) {
var pushImages []string
dockerArgs := append([]string{
"build",
".",
"--network", "cloudbuild", // this is what "gcloud build submit" uses, it is documented
"--no-cache", // state of the cache on Cloud Build workers is not well defined
}, r.Labels.AsBuildArgs()...)
// If asked to push the image, tag it locally and ask Cloud Build to push out
// this tag to the registry as well.
if r.Image != "" {
dockerArgs = append(dockerArgs, "--tag", r.Image)
pushImages = append(pushImages, r.Image)
}
// Version of gcr.io/cloud-builders/docker image to use.
var dockerVer string
switch v := b.cfg.Docker; {
case v == "":
dockerVer = ":latest"
case strings.HasPrefix(v, "sha256:"):
dockerVer = "@" + v
default:
dockerVer = ":" + v
}
// Note: this call roughly matches what "gcloud build submit --tag ..." does,
// except we tweak options a bit.
call := b.builds.Create(b.cfg.Project, &cloudbuild.Build{
// See https://cloud.google.com/cloud-build/docs/api/reference/rest/Shared.Types/Build#BuildOptions
Options: &cloudbuild.BuildOptions{
LogStreamingOption: "STREAM_ON",
Logging: "GCS_ONLY",
RequestedVerifyOption: "VERIFIED",
SourceProvenanceHash: []string{"SHA256"},
},
// Where to fetch "." used from the build step below.
Source: &cloudbuild.Source{
StorageSource: &cloudbuild.StorageSource{
Bucket: r.Source.Bucket,
Object: r.Source.Name,
Generation: r.Source.Generation,
},
},
// Build "." and tag it locally (on the worker).
Steps: []*cloudbuild.BuildStep{
{
Name: "gcr.io/cloud-builders/docker" + dockerVer,
Args: dockerArgs,
},
},
// Push whatever we tagged (if anything) to the registry.
Images: pushImages,
})
op, err := call.Context(ctx).Do()
if err != nil {
return nil, errors.Annotate(err, "API call to Cloud Build failed").Err()
}
// Cloud Build returns triggered build details with operation's metadata.
var metadata struct {
Build *cloudbuild.Build `json:"build"`
}
if err := json.Unmarshal(op.Metadata, &metadata); err != nil {
return nil, errors.Annotate(err, "failed to unmarshal operations metadata %s", op.Metadata).Err()
}
if metadata.Build == nil {
return nil, errors.Reason("`build` field unexpectedly missing in the metadata %s", op.Metadata).Err()
}
return makeBuild(metadata.Build), nil
}
// Check returns details of a build given its ID.
func (b *Builder) Check(ctx context.Context, bid string) (*Build, error) {
build, err := b.builds.Get(b.cfg.Project, bid).Context(ctx).Do()
if err != nil {
return nil, errors.Annotate(err, "API call to Cloud Build failed").Err()
}
return makeBuild(build), nil
}
func makeBuild(b *cloudbuild.Build) *Build {
// Parse SourceProvenance into more digestible "file => SHA256" map.
var prov map[string]string
if b.SourceProvenance != nil {
prov = make(map[string]string, len(b.SourceProvenance.FileHashes))
for name, hashes := range b.SourceProvenance.FileHashes {
digest := "<unknown>"
for _, h := range hashes.FileHash {
if h.Type == "SHA256" {
digest = b64ToHex(h.Value)
break
}
}
prov[name] = digest
}
}
// Grab the image from the result. There should be at most one.
var outImg, outDigest string
if b.Results != nil {
for _, img := range b.Results.Images {
outImg = img.Name
outDigest = img.Digest
break
}
}
return &Build{
ID: b.Id,
LogURL: b.LogUrl,
Status: Status(b.Status),
StatusDetails: b.StatusDetail,
InputHashes: prov,
OutputImage: outImg,
OutputDigest: outDigest,
}
}
func b64ToHex(b string) string {
blob, err := base64.StdEncoding.DecodeString(b)
if err != nil {
return fmt.Sprintf("<bad hash %s>", err) // should not be happening
}
return hex.EncodeToString(blob)
}