blob: f1c7a5f6df91f716500117a58301fcf887a77b4a [file] [log] [blame]
// Copyright 2019 The Fuchsia 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/url"
"os"
"os/exec"
"path/filepath"
"go.chromium.org/luci/auth"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
gerritapi "go.chromium.org/luci/common/api/gerrit"
gitilesapi "go.chromium.org/luci/common/api/gitiles"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/logging/gologger"
"go.chromium.org/luci/logdog/client/butlerlib/bootstrap"
"go.chromium.org/luci/logdog/client/butlerlib/streamclient"
"go.chromium.org/luci/luciexe"
"go.chromium.org/luci/luciexe/exe"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/encoding/prototext"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"go.fuchsia.dev/infra/checkout"
rbc "go.fuchsia.dev/infra/cmd/recipe_bootstrap/checkout"
"go.fuchsia.dev/infra/cmd/recipe_bootstrap/props"
"go.fuchsia.dev/infra/cmd/recipe_bootstrap/recipes"
"go.fuchsia.dev/infra/gerrit"
"go.fuchsia.dev/infra/gitiles"
)
const (
// Fallback ref to fetch, when build has no input, e.g. when the build is manually
// triggered with LUCI scheduler, or when the recipes_host_override property is set.
fallbackRef = "refs/heads/main"
// Default integration.git remote, for when build has no input, e.g. when
// the build is manually triggered with LUCI scheduler.
defaultIntegrationRemote = "https://fuchsia.googlesource.com/integration"
)
// resolveRef returns a resolved sha1 from a ref e.g. refs/heads/main.
func resolveRef(ctx context.Context, host, project, ref string) (string, error) {
authClient, err := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{Scopes: []string{gitilesapi.OAuthScope}}).Client()
if err != nil {
return "", fmt.Errorf("could not initialize auth client: %w", err)
}
client, err := gitiles.NewClient(host, project, authClient)
if err != nil {
return "", fmt.Errorf("could not initialize gitiles client: %w", err)
}
return client.LatestCommit(ctx, ref)
}
// resolveGitilesRef returns the ref associated with the build's input.
//
// If the build input has a GitilesCommit, return GitilesCommit.Ref.
//
// If the build input only has a GerritChange, query change info from Gerrit and
// return the ref.
// Note that this is the ref that the change targets i.e. refs/heads/*, not the
// magic change ref, e.g. refs/changes/*.
//
// If the build input has neither, return the fallbackRef.
func resolveGitilesRef(ctx context.Context, buildInput *buildbucketpb.Build_Input, fallbackRef string) (string, error) {
if buildInput.GitilesCommit != nil {
return buildInput.GitilesCommit.Ref, nil
}
if len(buildInput.GerritChanges) > 0 {
authClient, err := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{Scopes: []string{gerritapi.OAuthScope}}).Client()
if err != nil {
return "", fmt.Errorf("could not initialize auth client: %w", err)
}
client, err := gerrit.NewClient(buildInput.GerritChanges[0].Host, buildInput.GerritChanges[0].Project, authClient)
if err != nil {
return "", fmt.Errorf("failed to initialize gerrit client: %w", err)
}
resp, err := client.GetChange(ctx, buildInput.GerritChanges[0].Change)
if err != nil {
return "", err
}
return resp.Ref, nil
}
return fallbackRef, nil
}
// ensureGitilesCommit ensures that the incoming build always has an
// Input.GitilesCommit. This ensures that this build and any child builds
// always use a consistent HEAD.
//
// The host and project for the GitilesCommit are determined by the
// `ResolveRepo` strategy, with the fallback remote being either the default
// integration remote set in this file, or the override set by the
// `recipe_integration_remote` property.
func ensureGitilesCommit(ctx context.Context, build *buildbucketpb.Build) error {
if build.Input.GitilesCommit != nil {
return nil
}
fallbackRemote, err := props.GetStringInputProperty(build, "recipe_integration_remote")
if err != nil {
return err
}
if fallbackRemote == "" {
fallbackRemote = defaultIntegrationRemote
}
host, project, err := rbc.ResolveRepo(build.Input, fallbackRemote)
if err != nil {
return fmt.Errorf("failed to resolve host and project: %w", err)
}
ref, err := resolveGitilesRef(ctx, build.Input, fallbackRef)
if err != nil {
return fmt.Errorf("failed to resolve gitiles ref: %w", err)
}
revision, err := resolveRef(ctx, host, project, ref)
if err != nil {
return fmt.Errorf("failed to resolve %s HEAD: %w", ref, err)
}
build.Input.GitilesCommit = &buildbucketpb.GitilesCommit{
Host: host,
Project: project,
Id: revision,
Ref: ref,
}
return nil
}
// checkoutIntegration checks out the integration.git repo and returns the path
// to the directory containing the checkout.
func checkoutIntegration(ctx context.Context, build *buildbucketpb.Build) (string, error) {
// Do a checkout in a temporary directory to avoid checkout conflicts with
// the recipe's working directory.
cwd, err := os.Getwd()
if err != nil {
return "", err
}
integrationDir, err := ioutil.TempDir(cwd, "integration-checkout")
if err != nil {
return integrationDir, err
}
integrationURL, integrationBaseRevision, err := resolveIntegration(ctx, build)
if err != nil {
return integrationDir, err
}
if err := props.SetInputProperty(build, "integration_base_revision", integrationBaseRevision); err != nil {
return integrationDir, err
}
tctx, cancel := context.WithTimeout(ctx, rbc.DefaultCheckoutTimeout)
defer cancel()
if err := checkout.Checkout(tctx, build.Input, *integrationURL, integrationBaseRevision, integrationDir); err != nil {
return integrationDir, fmt.Errorf("failed to checkout integration repo: %w", err)
}
return integrationDir, nil
}
// resolveIntegration resolves the integration URL and revision to checkout.
//
// If the `recipes_host_override` property is specified, then override the
// integration host.
//
// If the `integration_base_revision` property is set, use this revision.
func resolveIntegration(ctx context.Context, build *buildbucketpb.Build) (*url.URL, string, error) {
recipesHostOverride, err := props.GetStringInputProperty(build, "recipes_host_override")
if err != nil {
return nil, "", err
}
integrationURL, err := rbc.ResolveIntegrationURL(build.Input, recipesHostOverride)
if err != nil {
return nil, "", err
}
baseRevisionProperty, err := props.GetStringInputProperty(build, "integration_base_revision")
if err != nil {
return nil, "", err
}
if baseRevisionProperty != "" {
// If `integration_base_revision` is set then we're probably running as
// a subbuild and `integration_base_revision` is the revision resolved
// by the parent build, so we should use the same revision.
return integrationURL, baseRevisionProperty, nil
}
commit := build.Input.GitilesCommit
if integrationURL.Host == commit.Host && integrationURL.Path == commit.Project {
// Either the build was triggered on an integration change and we
// already resolved a base revision with `ensureGitilesCommit`, or the
// build was triggered by a specific integration commit. In either case
// we want to use that resolved revision to ensure we check out the same
// version of integration as the recipe.
return integrationURL, commit.Id, nil
}
// Otherwise we haven't yet resolved an integration base revision, so we
// need to choose the correct integration ref to resolve and then resolve
// that ref to a revision.
overrideRef, err := props.GetStringInputProperty(build, "recipes_integration_ref_override")
if err != nil {
return nil, "", err
}
var ref string
if overrideRef != "" {
ref = overrideRef
} else if commit.Ref != "" {
// This assumes that for every ref `refs/heads/foo` of every repository
// pinned in integration that we care about submitting changes to, there
// exists a corresponding ref `refs/heads/foo` in the integration
// repository.
ref = commit.Ref
} else {
ref = fallbackRef
}
revision, err := resolveRef(ctx, integrationURL.Host, integrationURL.Path, ref)
if err != nil {
return nil, "", fmt.Errorf("failed to resolve integration HEAD: %w", err)
}
return integrationURL, revision, nil
}
// resolveBuildProperties resolves the build input properties to use by reading
// the builder's properties from integration.git and merging them with the input
// properties.
func resolveBuildProperties(ctx context.Context, integrationDir string, build *buildbucketpb.Build) error {
versionedProperties, err := readVersionedProperties(ctx, integrationDir, build.Builder)
if err != nil {
return fmt.Errorf("failed to read builder properties from integration repo: %w", err)
}
// Make a copy of the original input properties.
originalProperties := build.Input.Properties.AsMap()
// Replace the input properties with the resolved versioned properties.
build.Input.Properties = versionedProperties
// Merge the original input properties (primarily request properties) into
// the versioned properties, letting the request properties take precedence.
return exe.WriteProperties(build.Input.Properties, originalProperties)
}
// readVersionedProperties loads the builder's properties from an integration
// checkout.
//
// Depending on how recent this integration.git checkout is, the properties may
// come from one of several possible files. Older versions of integration.git
// stored properties at one of two paths in textproto files that conformed to
// the `Bucket` schema, whereas modern versions of integration.git store
// properties in dedicated JSON files.
//
// We prefer the most modern format available.
func readVersionedProperties(ctx context.Context, integrationDir string, builder *buildbucketpb.BuilderID) (*structpb.Struct, error) {
// Possible files from which we might be able to read properties. We want to
// prioritize more modern files, since older files might be malformed in
// later revisions that also have modern files. Hence we use an ordered
// slice of structs rather than an unordered map.
// TODO(olivernewman): Remove support for reading properties from bucket
// files once we no longer care about building versions of integration.git
// that don't contain JSON property files.
possiblePropertyFiles := []struct {
path string
// loadFunc specifies how to load properties from such a file into a
// proto struct.
loadFunc func([]byte, *structpb.Struct) error
}{
{
path: filepath.Join("properties", builder.Bucket, builder.Builder+".json"),
loadFunc: loadPropertiesFromJSONFile,
},
{
path: filepath.Join("builders", builder.Bucket, builder.Builder+".textproto"),
loadFunc: loadPropertiesFromBucketFile,
},
{
path: filepath.Join("for_review_only", "buildbucket", builder.Bucket, builder.Builder+".textproto"),
loadFunc: loadPropertiesFromBucketFile,
},
}
for _, f := range possiblePropertyFiles {
absPath := filepath.Join(generatedDirForBuilder(integrationDir, builder), f.path)
contents, err := ioutil.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
// If one file doesn't exist, fall back to a less modern format.
continue
}
return nil, err
}
properties := &structpb.Struct{}
if err := f.loadFunc(contents, properties); err != nil {
return nil, err
}
logging.Infof(ctx, "Using versioned properties from %s", absPath)
return properties, nil
}
return nil, fmt.Errorf("failed to locate properties file for builder %s", builder)
}
// generatedDirForBuilder returns the path in an integration.git checkout to the
// directory containing all the config files for a builder's LUCI project.
func generatedDirForBuilder(integrationDir string, builder *buildbucketpb.BuilderID) string {
return filepath.Join(integrationDir, "infra", "config", "generated", builder.Project)
}
// loadPropertiesFromBucketFile deserializes a builder's properties from a
// textproto file conforming to the `Bucket` schema.
func loadPropertiesFromBucketFile(contents []byte, properties *structpb.Struct) error {
builderProto := &buildbucketpb.Bucket{}
if err := prototext.Unmarshal(contents, builderProto); err != nil {
return err
}
builders := builderProto.GetSwarming().GetBuilders()
if len(builders) != 1 {
return fmt.Errorf("expected 1 builder, got %d", len(builders))
}
propertiesJSON := builders[0].GetProperties()
return protojson.Unmarshal([]byte(propertiesJSON), properties)
}
// loadPropertiesFromJSONFile deserializes a builder's properties from a JSON
// file containing a single JSON object corresponding to the properties.
func loadPropertiesFromJSONFile(contents []byte, properties *structpb.Struct) error {
return protojson.Unmarshal(contents, properties)
}
// outputBuildSummary outputs error details to the current build's
// summary_markdown in case of failure.
func outputBuildSummary(ctx context.Context, buildErr error) error {
bootstrap, err := bootstrap.Get()
if err != nil {
return err
}
stream, err := bootstrap.Client.NewDatagramStream(
ctx,
luciexe.BuildProtoStreamSuffix,
streamclient.WithContentType(luciexe.BuildProtoContentType),
)
if err != nil {
return err
}
defer stream.Close()
build := &buildbucketpb.Build{}
build.SummaryMarkdown = buildErr.Error()
// Check if the returned error declares whether or not it's an infra
// failure. If it doesn't declare one way or the other, we'll assume that it
// is an infra failure.
var maybeInfraErr interface{ IsInfraFailure() bool }
if errors.As(buildErr, &maybeInfraErr) && !maybeInfraErr.IsInfraFailure() {
build.Status = buildbucketpb.Status_FAILURE
} else {
build.Status = buildbucketpb.Status_INFRA_FAILURE
}
outputData, err := proto.Marshal(build)
if err != nil {
return fmt.Errorf("failed to marshal output build.proto: %w", err)
}
return stream.WriteDatagram(outputData)
}
// setUpRecipes processes the build input to determine which version of recipes
// to use (possibly from CAS rather than from Git if run by a led job) and
// downloads that version of the recipes. It returns a `RecipesExe` that can be
// used to run the recipes (whether they were downloaded from CAS or from Git)
// as a luciexe, along with the deserialized build.proto.
func setUpRecipes(ctx context.Context, recipesDir string) (recipes.RecipesExe, *buildbucketpb.Build, error) {
data, err := ioutil.ReadAll(os.Stdin)
if err != nil {
return nil, nil, fmt.Errorf("failed to read build.proto from stdin: %w", err)
}
build := &buildbucketpb.Build{}
if err := proto.Unmarshal(data, build); err != nil {
return nil, nil, fmt.Errorf("failed to unmarshal build.proto from stdin: %w", err)
}
if err := ensureGitilesCommit(ctx, build); err != nil {
return nil, nil, fmt.Errorf("failed to ensure that build has a gitiles commit: %w", err)
}
integrationDir, err := checkoutIntegration(ctx, build)
defer os.RemoveAll(integrationDir)
if err != nil {
return nil, nil, err
}
if err := resolveBuildProperties(ctx, integrationDir, build); err != nil {
return nil, nil, fmt.Errorf("failed to resolve the build properties: %w", err)
}
b, err := json.MarshalIndent(build.Input.Properties, "", " ")
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal build input properties: %w", err)
}
usePy3, err := props.GetBoolInputProperty(build, "recipes_use_py3")
if err != nil {
return nil, nil, err
}
if usePy3 {
// TODO(fxbug.dev/83858): Set this env var unconditionally
// once we no longer need to support running recipes from release
// branches where they didn't yet support Python 3.
if err := os.Setenv("RECIPES_USE_PY3", "true"); err != nil {
return nil, nil, err
}
logging.Infof(ctx, "Using Python 3")
}
// TODO(fxbug.dev/72956): Emit the build properties to a separate output log
// once it's possible for recipe_bootstrap to emit build.proto to the same
// Logdog stream as the recipe engine.
logging.Infof(ctx, "Resolved input properties: %s", string(b))
exe, err := recipes.DownloadRecipes(ctx, build, integrationDir, recipesDir)
if err != nil {
return nil, nil, err
}
return exe, build, nil
}
func execute(ctx context.Context) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
recipesDir, err := ioutil.TempDir(cwd, "recipes")
if err != nil {
return err
}
defer os.RemoveAll(recipesDir)
exe, build, err := setUpRecipes(ctx, recipesDir)
if err != nil {
if outputErr := outputBuildSummary(ctx, err); outputErr != nil {
logging.Errorf(ctx, "Failed to output build summary after recipe setup failed: %s", outputErr)
}
return err
}
commandLine, err := exe.LuciexeCommand()
if err != nil {
return err
}
// Forward any flags such as --output to the recipe.
commandLine = append(commandLine, os.Args[1:]...)
cmd := exec.CommandContext(ctx, commandLine[0], commandLine[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
inputData, err := proto.Marshal(build)
if err != nil {
return fmt.Errorf("could not marshal input build: %w", err)
}
cmd.Stdin = bytes.NewBuffer(inputData)
logging.Infof(ctx, "Running luciexe command: %s", cmd)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to execute recipe: %w", err)
}
return nil
}
func main() {
ctx := context.Background()
ctx = gologger.StdConfig.Use(ctx)
if err := execute(ctx); err != nil {
logging.Errorf(ctx, err.Error())
os.Exit(1)
}
}