blob: 504718536cb9ae2367252259d9d97390c60c7912 [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"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"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/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"
// Name of the build input property used by the recipe engine and various
// other tools to determine the recipe that a build uses.
recipeProperty = "recipe"
// 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 := os.MkdirTemp(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, which we'll use to override
// the versioned properties.
originalProperties := build.Input.Properties.AsMap()
// The versioned "recipe" property should take precedence over the "recipe"
// property from the build input. This makes it possible to:
// 1) Test a configuration change that swaps a builder's recipe in presubmit.
// 2) Swap a builder's recipe without breaking release branches.
//
// The downside is that any out-of-band tooling that relies on this property
// will no longer behave correctly, but this isn't the end of the world
// because recipe swaps should be very rare and only affect a small number
// of release branch builds.
if versionedRecipe, ok := versionedProperties.AsMap()[recipeProperty]; ok {
originalProperties[recipeProperty] = versionedRecipe
}
// 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 original input properties take
// precedence.
return exe.WriteProperties(build.Input.Properties, originalProperties)
}
// readVersionedProperties loads the builder's properties from a JSON file in
// the integration checkout.
func readVersionedProperties(ctx context.Context, integrationDir string, builder *buildbucketpb.BuilderID) (*structpb.Struct, error) {
absPath := filepath.Join(integrationDir, propertiesFileForBuilder(builder))
logging.Infof(ctx, "Using versioned properties from %s", absPath)
contents, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to locate properties file for builder %s: %w", builder, err)
}
properties := &structpb.Struct{}
if err := protojson.Unmarshal(contents, properties); err != nil {
return nil, err
}
return properties, nil
}
func propertiesFileForBuilder(builder *buildbucketpb.BuilderID) string {
return filepath.Join(
"infra",
"config",
"generated",
builder.Project,
"properties",
builder.Bucket,
builder.Builder+".json",
)
}
// 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 := io.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)
}
// TODO(fxbug.dev/83858): Stop setting this env var once once we no longer
// need to support running recipes from release branches where python3
// wasn't the default.
if err := os.Setenv("RECIPES_USE_PY3", "true"); err != nil {
return nil, nil, err
}
// 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 installPy2(ctx context.Context, dir string) ([]string, error) {
cmd := exec.CommandContext(ctx, "cipd", "init", dir, "-force")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("cipd init failed: %w", err)
}
pkgs := []struct {
name string
version string
binDir string
}{
{
name: "infra/tools/luci/vpython/${platform}",
version: "git_revision:3d6ee7542a04be92a48ff1c5dc28cb3c5c15dd00",
binDir: "",
},
{
name: "infra/3pp/tools/cpython/${platform}",
version: "version:2@2.7.18.chromium.44",
binDir: "bin",
},
}
var binDirs []string
for _, pkg := range pkgs {
cmd := exec.CommandContext(ctx, "cipd", "install", pkg.name, pkg.version, "-root", dir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to install %s: %w", pkg.name, err)
}
binDirs = append(binDirs, filepath.Join(dir, pkg.binDir))
}
return binDirs, nil
}
func execute(ctx context.Context) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
recipesDir, err := os.MkdirTemp(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
}
// TODO(fxbug.dev/89307): Remove support for Python 2 once branches prior to
// f11 are no longer supported.
noPy2, err := props.GetBoolInputProperty(build, "no_python2")
if err != nil {
return err
}
env := os.Environ()
if noPy2 {
logging.Infof(ctx, "no_python2=true, not installing python 2")
} else {
logging.Infof(ctx, "no_python2=false, installing python 2")
py2Dir, err := os.MkdirTemp("", "py2")
if err != nil {
return err
}
defer os.RemoveAll(py2Dir)
binDirs, err := installPy2(ctx, py2Dir)
if err != nil {
return err
}
path := strings.Join(append(binDirs, os.Getenv("PATH")), string(os.PathListSeparator))
env = append(env, "PATH="+path)
}
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
cmd.Env = env
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)
}
}