blob: 843571d363567ae2d699feaba60eec5c117733a7 [file] [log] [blame]
// Copyright 2020 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 main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/ioutil"
"os"
"time"
"github.com/dustin/go-humanize"
"github.com/maruel/subcommands"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"infra/cmd/cloudbuildhelper/fileset"
"infra/cmd/cloudbuildhelper/manifest"
"infra/cmd/cloudbuildhelper/storage"
)
var cmdUpload = &subcommands.Command{
UsageLine: "upload <target-manifest-path> [...]",
ShortDesc: "uploads the tarball with the context directory to the storage",
LongDesc: `Uploads the tarball with the context directory to the storage.
Evaluates input YAML manifest specified via the positional argument, executes
all local build steps there, and rewrites Dockerfile to use pinned digests
instead of tags. Writes the resulting context dir to a temp *.tar.gz file and
then uploads this file to Google Storage, if not already there, using its SHA256
digest as a file name (the bucket and prefix are picked from the "infra" section
in the manifest).
Finally, attaches the given -canonical-tag to Google Storage object's metadata.
The first such attached tag becomes an immutable alias for this object. "upload"
subcommand returns it in its JSON output.
The canonical tag should identify the exact version of inputs (e.g. it usually
includes git revision or other unique version identifier).
If -canonical-tag is set to a literal constant ":inputs-hash", it is calculated
from SHA256 of the tarball. This is mostly needed for compatibility with
"build" subcommand.
`,
CommandRun: func() subcommands.CommandRun {
c := &cmdUploadRun{}
c.init()
return c
},
}
type cmdUploadRun struct {
commandBase
targetManifest string
}
func (c *cmdUploadRun) init() {
c.commandBase.init(c.exec,
extraFlags{
auth: true,
infra: true,
canonicalTag: true,
buildID: true,
jsonOutput: true,
},
[]*string{
&c.targetManifest,
},
)
}
func (c *cmdUploadRun) exec(ctx context.Context) error {
m, infra, err := c.loadManifest(c.targetManifest, true, false)
if err != nil {
return err
}
if err := validateCanonicalTag(c.canonicalTag); err != nil {
return err
}
// Initialize Storage instance based on what's in the manifest.
ts, err := c.tokenSource(ctx)
if err != nil {
return errors.Annotate(err, "failed to setup auth").Err()
}
store, err := storage.New(ctx, ts, infra.Storage)
if err != nil {
return errors.Annotate(err, "failed to initialize Storage").Err()
}
// Execute the main logic.
obj, digest, oldestRef, err := buildAndUpload(ctx, uploadParams{
Manifest: m,
CanonicalTag: c.canonicalTag,
BuildID: c.buildID,
Store: store,
})
// Return (GS path, SHA256 digest, canonical tag) or error as JSON output.
res := uploadResult{Name: m.Name}
if err == nil {
res.GS.Bucket = obj.Bucket
res.GS.Name = obj.Name
res.SHA256 = digest
res.CanonicalTag = oldestRef.CanonicalTag
} else {
res.Error = err.Error()
}
if jerr := c.writeJSONOutput(&res); jerr != nil {
return errors.Annotate(jerr, "failed to write JSON output").Err()
}
return err
}
////////////////////////////////////////////////////////////////////////////////
// uploadResult is returned as JSON output by `upload` command.
type uploadResult struct {
Name string `json:"name"` // artifacts name from the manifest YAML
Error string `json:"error,omitempty"` // non-empty if the upload failed
GS struct {
Bucket string `json:"bucket"` // GCS bucket with the file
Name string `json:"name"` // filename within the bucket
} `json:"gs,omitempty"`
SHA256 string `json:"sha256,omitempty"` // hex digest of the file
CanonicalTag string `json:"canonical_tag,omitempty"` // its oldest tag
}
// uploadParams are parameters for buildAndUpload.
type uploadParams struct {
// Inputs.
Manifest *manifest.Manifest
CanonicalTag string
BuildID string
// Infra.
Store storageImpl
}
// buildAndUpload builds the tarball, uploads it to the storage and updates
// its metadata.
//
// Returns the object in the storage (either new or an existing one) and
// the *oldest* buildRef associated with it.
func buildAndUpload(ctx context.Context, p uploadParams) (obj *storage.Object, digest string, oldestRef *buildRef, err error) {
err = stage(ctx, p.Manifest, func(out *fileset.Set) error {
var f *os.File
if f, digest, err = writeToTemp(ctx, out); err != nil {
return errors.Annotate(err, "failed to write the tarball with context dir").Err()
}
// Cleanup no matter what. Note that we don't care about IO flush errors in
// f.Close() as long as uploadToStorage sent everything successfully (as
// verified by checking the hash there).
defer func() {
f.Close()
os.Remove(f.Name())
}()
// Now that we know the inputs, we can resolve ":inputs-hash".
if p.CanonicalTag == inputsHashCanonicalTag {
p.CanonicalTag = calcInputsHashCanonicalTag(digest)
}
logging.Infof(ctx, "Canonical tag: %s", p.CanonicalTag)
// Upload the tarball (or grab metadata of the existing object).
obj, err = uploadToStorage(ctx, p.Store,
fmt.Sprintf("%s/%s.tar.gz", p.Manifest.Name, digest), digest, f)
if err != nil {
return err // err is annotated already
}
// Dump metadata into the log, just FYI. In particular this logs all
// previous buildRef's that reused this tarball.
obj.Log(ctx)
// Add metadata about *this* build to associate the tarball with it, grab
// oldest such association (perhaps the one we are adding) to use its tag
// as the immutable alias for the tarball.
oldestRef, err = updateMetadata(ctx, obj, p.Store, nil, &buildRef{
BuildID: p.BuildID,
CanonicalTag: p.CanonicalTag,
})
return err // already annotated
})
// Log if we are reusing an existing tarball with an existing canonical tag.
if err == nil {
if p.CanonicalTag == oldestRef.CanonicalTag {
logging.Infof(ctx, "New canonical tag %q", p.CanonicalTag)
} else {
logging.Infof(ctx,
"Reusing the existing canonical tag %q (built %s)",
oldestRef.CanonicalTag, humanize.Time(oldestRef.Timestamp))
}
}
return
}
// writeToTemp saves the fileset.Set as a temporary *.tar.gz file, returning it
// and its SHA256 hex digest.
//
// Logs the file size and its digest inside.
//
// The file is opened in read/write mode. The caller is responsible for closing
// and deleting it when done.
func writeToTemp(ctx context.Context, out *fileset.Set) (f *os.File, digest string, err error) {
logging.Infof(ctx, "Writing tarball with %d files to a temp file...", out.Len())
f, err = ioutil.TempFile("", "cloudbuildhelper_*.tar.gz")
if err != nil {
return nil, "", err
}
defer func() {
if err != nil {
f.Close()
os.Remove(f.Name())
}
}()
h := sha256.New()
if err := out.ToTarGz(io.MultiWriter(f, h)); err != nil {
return nil, "", err
}
digest = hex.EncodeToString(h.Sum(nil))
size, err := f.Seek(0, 1)
if err != nil {
return nil, "", errors.Annotate(err, "failed to query the size of the temp file").Err()
}
logging.Infof(ctx, "Tarball digest: %s", digest)
logging.Infof(ctx, "Tarball length: %s", humanize.Bytes(uint64(size)))
return f, digest, nil
}
// uploadToStorage uploads the given file to the storage if it's not there yet.
func uploadToStorage(ctx context.Context, s storageImpl, obj, digest string, f *os.File) (*storage.Object, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
switch uploaded, err := s.Check(ctx, obj); {
case err != nil:
return nil, errors.Annotate(err, "failed to query the storage for presence of uploaded tarball").Err()
case uploaded != nil:
return uploaded, nil
}
// Rewind the temp file we have open in read/write mode.
if _, err := f.Seek(0, 0); err != nil {
return nil, errors.Annotate(err, "failed to seek inside the temp file").Err()
}
uploaded, err := s.Upload(ctx, obj, digest, f)
return uploaded, errors.Annotate(err, "failed to upload the tarball").Err()
}