blob: e3d78b30778a136992ffb03640b06acd3ca4d6a6 [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"
"flag"
"fmt"
"io/ioutil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/golang/protobuf/proto"
"go.chromium.org/luci/common/retry"
"go.chromium.org/luci/common/retry/transient"
"go.fuchsia.dev/infra/checkout"
rbc "go.fuchsia.dev/infra/cmd/recipe_bootstrap/checkout"
"go.fuchsia.dev/infra/cmd/recipe_bootstrap/manifest"
"go.fuchsia.dev/infra/cmd/recipe_bootstrap/props"
"go.fuchsia.dev/infra/gerrit"
"go.fuchsia.dev/infra/gitiles"
"google.golang.org/protobuf/encoding/prototext"
"google.golang.org/protobuf/types/known/structpb"
"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"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/logdog/client/butlerlib/bootstrap"
"go.chromium.org/luci/logdog/client/butlerlib/streamclient"
"go.chromium.org/luci/luciexe"
)
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/master"
// 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"
// Recipe manifest filename relative to root of an integration checkout.
recipesManifest = "infra/recipes"
// Recipes.git remote.
recipesRemote = "https://fuchsia.googlesource.com/infra/recipes"
// Default timeout for checkout operations.
defaultCheckoutTimeout = 5 * time.Minute
)
// 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(ctx, 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(ctx, buildInput.GerritChanges[0].Host, buildInput.GerritChanges[0].Project, authClient)
if err != nil {
return "", fmt.Errorf("failed to initialize gerrit client: %w", err)
}
// GetChange may sometimes encounter transient failures which should be
// retried.
retryPolicy := transient.Only(func() retry.Iterator {
return &retry.ExponentialBackoff{
Limited: retry.Limited{
Delay: 5 * time.Second,
Retries: 3,
},
Multiplier: 1.5,
}
})
var resp *gerritpb.ChangeInfo
if err := retry.Retry(ctx, retryPolicy, func() error {
resp, err = client.GetChange(ctx, buildInput.GerritChanges[0].Change)
return err
}, nil); err != nil {
return "", fmt.Errorf("failed to get change info: %w", 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: %v", ref, err)
}
build.Input.GitilesCommit = &buildbucketpb.GitilesCommit{
Host: host,
Project: project,
Id: revision,
Ref: ref,
}
return nil
}
// getBuilderFilePath returns the builder file path to read the build input properties.
func getBuilderFilePath(integrationDir string, build *buildbucketpb.Build) string {
// TODO(fxbug.dev//71232): switch to "builders" directory once all maintained release branches are newer.
return filepath.Join(
integrationDir, "infra", "config", "generated", build.Builder.Project,
"for_review_only", "buildbucket", build.Builder.Bucket,
fmt.Sprintf("%s.textproto", build.Builder.Builder),
)
}
// 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, defaultCheckoutTimeout)
defer cancel()
return integrationDir, checkout.Checkout(tctx, build.Input, *integrationURL, integrationBaseRevision, integrationDir)
}
// 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
}
revision, err := props.GetStringInputProperty(build, "integration_base_revision")
if err != nil {
return nil, "", err
}
if revision == "" {
commit := build.Input.GitilesCommit
if integrationURL.Host == commit.Host && integrationURL.Path == commit.Project {
revision = build.Input.GitilesCommit.Id
} else {
var ref string
// 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.
if recipesHostOverride == "" {
ref = build.Input.GitilesCommit.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.
//
// If enable_property_versioning is set, merge the build input properties with
// integration properties.
func resolveBuildProperties(builderFilePath string, build *buildbucketpb.Build) error {
enablePropertyVersion, err := props.GetBoolInputProperty(build, "enable_property_versioning")
if err != nil {
return err
}
// If the enable_property_versioning is not set, we'll use the original input properties.
if !enablePropertyVersion {
return nil
}
// Make a copy of the original input properties.
originalProperties := build.Input.Properties.AsMap()
builderBytes, err := ioutil.ReadFile(builderFilePath)
if err != nil {
return fmt.Errorf("failed to read builder file %s: %v", builderFilePath, err)
}
// Replace the input properties with the resolved integration.git properties.
build.Input.Properties, err = builderProperties(builderBytes)
if err != nil {
return fmt.Errorf("failed to read builder properties from integration repo: %w", err)
}
// Merge the original input properties, which should contain only request properties, into integration.git properties.
for property, value := range originalProperties {
if err := props.SetInputProperty(build, property, value); err != nil {
return fmt.Errorf("failed to set the property %s: %v", property, err)
}
}
return nil
}
// Gets builder properties from buildbucketpb.Bucket bytes data.
func builderProperties(manifestBytes []byte) (*structpb.Struct, error) {
builderProto := &buildbucketpb.Bucket{}
if err := prototext.Unmarshal(manifestBytes, builderProto); err != nil {
return nil, err
}
builders := builderProto.GetSwarming().GetBuilders()
if len(builders) != 1 {
return nil, fmt.Errorf("expected 1 builder, got %d", len(builders))
}
properties := builders[0].GetProperties()
data := &structpb.Struct{}
if err := json.Unmarshal([]byte(properties), data); err != nil {
return nil, err
}
return data, nil
}
// checkoutRecipes resolves the recipe version to check out, checks out the recipes
// repo, and returns the path to the checked-out repository.
//
// If the build input has a recipe change, then checkout directly using the
// build input.
//
// Otherwise, checkout at the revision specified by Build.Input.Properties.
func checkoutRecipes(ctx context.Context, integrationDir string, build *buildbucketpb.Build) (string, error) {
// Do recipes checkout in a temporary directory to avoid checkout conflicts
// with the recipe's working directory.
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
recipesDir, err := ioutil.TempDir(cwd, "recipes-checkout")
if err != nil {
return recipesDir, fmt.Errorf("failed to get create tempdir: %w", err)
}
recipesURL, err := url.Parse(recipesRemote)
if err != nil {
return recipesDir, fmt.Errorf("could not parse URL %s", recipesRemote)
}
var input *buildbucketpb.Build_Input
hasRecipeChange, err := rbc.HasRepoChange(build.Input, recipesRemote)
if err != nil {
return recipesDir, fmt.Errorf("could not determine whether build input has recipe change: %w", err)
}
if hasRecipeChange {
input = build.Input
} else {
// Use the recipe version from the checkout integrationDir.
manifestXML, err := os.Open(filepath.Join(integrationDir, recipesManifest))
if err != nil {
return recipesDir, fmt.Errorf("failed to read recipes manifest file: %s: %v", filepath.Join(integrationDir, recipesManifest), err)
}
defer manifestXML.Close()
proj, err := manifest.ResolveRecipesProject(manifestXML, recipesRemote)
if err != nil {
return recipesDir, err
}
input = &buildbucketpb.Build_Input{
GitilesCommit: &buildbucketpb.GitilesCommit{
Host: recipesURL.Host,
Project: strings.TrimLeft(recipesURL.Path, "/"),
Id: proj.Revision,
},
}
}
tctx, cancel := context.WithTimeout(ctx, defaultCheckoutTimeout)
defer cancel()
if err := checkout.Checkout(tctx, input, *recipesURL, "", recipesDir); err != nil {
return recipesDir, fmt.Errorf("failed to checkout: %w", err)
}
return recipesDir, nil
}
// fetchRecipeDeps runs `recipes.py fetch`, which checks out all recipe git
// repos that the current recipes repo repends on. `recipes.py luciexe` would
// handle fetching deps even if we didn't run `recipes.py fetch` separately
// beforehand, but running `recipes.py fetch` separately makes it easier to
// distinguish between fetch errors (which are generally caused by transient Git
// server issues) and true build errors within the executed recipe.
func fetchRecipeDeps(ctx context.Context, recipesDir string) error {
cmd, err := recipeCommand(ctx, recipesDir, "fetch")
if err != nil {
return err
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to fetch recipe deps: %w", err)
}
return nil
}
// executeRecipe exec's `recipes.py luciexe`, passing build.proto through stdin.
func executeRecipe(ctx context.Context, recipesDir string, build *buildbucketpb.Build, outputPath string) error {
args := []string{"luciexe"}
if outputPath != "" {
args = append(args, "--output", outputPath)
}
cmd, err := recipeCommand(ctx, recipesDir, args...)
if err != nil {
return err
}
inputData, err := proto.Marshal(build)
if err != nil {
return fmt.Errorf("could not marshal input build: %w", err)
}
cmd.Stdin = bytes.NewBuffer(inputData)
return cmd.Run()
}
// recipeCommand constructs an exec.Cmd that will run the `recipes.py` script
// found in `recipesDir` with the specified arguments.
func recipeCommand(ctx context.Context, recipesDir string, recipesPyArgs ...string) (*exec.Cmd, error) {
args := []string{filepath.Join(recipesDir, "recipes.py")}
args = append(args, recipesPyArgs...)
python, err := exec.LookPath("python")
if err != nil {
return nil, fmt.Errorf("could not find python on PATH: %w", err)
}
cmd := exec.CommandContext(ctx, python, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd, nil
}
// outputBuildSummary outputs error details to the current build's
// summary_markdown in case of failure.
func outputBuildSummary(ctx context.Context, summary string) 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
}
build := &buildbucketpb.Build{}
build.SummaryMarkdown = summary
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 revision of recipes
// to checkout, then checks out the recipes repo at that revision. It returns
// the deserialized input build.proto along with the path to the recipes
// checkout.
func setUpRecipes(ctx context.Context) (build *buildbucketpb.Build, recipesDir string, err error) {
data, err := ioutil.ReadAll(os.Stdin)
if err != nil {
err = fmt.Errorf("failed to read build.proto from stdin: %w", err)
return
}
build = &buildbucketpb.Build{}
if err = proto.Unmarshal(data, build); err != nil {
err = fmt.Errorf("failed to unmarshal build.proto from stdin: %w", err)
return
}
if err = ensureGitilesCommit(ctx, build); err != nil {
err = fmt.Errorf("failed to ensure that build has a gitiles commit: %w", err)
return
}
integrationDir, err := checkoutIntegration(ctx, build)
defer os.RemoveAll(integrationDir)
if err != nil {
err = fmt.Errorf("failed to checkout integration bundle: %w", err)
return
}
if err = resolveBuildProperties(getBuilderFilePath(integrationDir, build), build); err != nil {
err = fmt.Errorf("failed to resolve the build properties: %w", err)
return
}
recipesDir, err = checkoutRecipes(ctx, integrationDir, build)
if err != nil {
err = fmt.Errorf("failed to download recipe bundle: %w", err)
return
}
if err = fetchRecipeDeps(ctx, recipesDir); err != nil {
return
}
b, err := json.MarshalIndent(build.Input.Properties, "", " ")
if err != nil {
err = fmt.Errorf("failed to marshal build input properties: %w", err)
return
}
// 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))
return build, recipesDir, nil
}
func execute(ctx context.Context) error {
outputPath := flag.String("output", "", "Path to write the final build.proto state to.")
flag.Parse()
build, recipesDir, err := setUpRecipes(ctx)
// `recipesDir` might not exist depending on the setup failure mode, so ignore any
// error when removing it.
defer os.RemoveAll(recipesDir)
if err != nil {
if outputErr := outputBuildSummary(ctx, err.Error()); outputErr != nil {
logging.Errorf(ctx, "Failed to output build summary after recipe setup failed: %v", outputErr)
}
return err
}
if err := executeRecipe(ctx, recipesDir, build, *outputPath); 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)
}
}