go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/recipe_wrapper/recipes/recipes.go (about)

     1  // Copyright 2021 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package recipes
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"net/url"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"runtime"
    17  	"strings"
    18  
    19  	"github.com/bazelbuild/remote-apis-sdks/go/pkg/digest"
    20  	"github.com/bazelbuild/remote-apis-sdks/go/pkg/filemetadata"
    21  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    22  	"go.chromium.org/luci/client/casclient"
    23  	"go.chromium.org/luci/common/logging"
    24  	"go.chromium.org/luci/hardcoded/chromeinfra"
    25  	"go.chromium.org/luci/luciexe/exe"
    26  	apipb "go.chromium.org/luci/swarming/proto/api"
    27  	"google.golang.org/protobuf/encoding/protojson"
    28  	"google.golang.org/protobuf/proto"
    29  	"google.golang.org/protobuf/types/known/structpb"
    30  
    31  	infracheckout "go.fuchsia.dev/infra/checkout"
    32  	rbc "go.fuchsia.dev/infra/cmd/recipe_wrapper/checkout"
    33  	"go.fuchsia.dev/infra/cmd/recipe_wrapper/git"
    34  	"go.fuchsia.dev/infra/cmd/recipe_wrapper/manifest"
    35  	"go.fuchsia.dev/infra/cmd/recipe_wrapper/props"
    36  )
    37  
    38  const (
    39  	// `led edit-recipe-bundle -property-only` sets this property. If set, its
    40  	// value will be a jsonpb representation of a CASReference pointing to the
    41  	// uploaded recipe bundle in CAS.
    42  	ledCASRecipeBundleProp = "led_cas_recipe_bundle"
    43  
    44  	// Recipe manifest filename relative to root of an integration checkout.
    45  	recipesManifest = "infra/recipes"
    46  
    47  	// Recipes.git remote.
    48  	recipesRemote = "https://fuchsia.googlesource.com/infra/recipes"
    49  
    50  	// Name of the build input property used by the recipe engine and various
    51  	// other tools to determine the recipe that a build uses.
    52  	recipeProperty = "recipe"
    53  )
    54  
    55  // Checkout represents a directory containing recipe code capable of being run
    56  // as a luciexe.
    57  type Checkout interface {
    58  	// LuciexeCommand returns a command that will run the recipes as a LUCI
    59  	// executable.
    60  	LuciexeCommand() ([]string, error)
    61  	// Repo returns the Git repo that these recipes were downloaded from.
    62  	// Returns an empty string if unknown.
    63  	Repo() string
    64  	// SHA1 returns the Git SHA1 that these recipes were found at.
    65  	// Returns an empty string if unknown.
    66  	SHA1() string
    67  }
    68  
    69  // bundle represents a recipe bundle directory downloaded from CAS and
    70  // originally produced using `recipes.py bundle`.
    71  type bundle struct {
    72  	dir string
    73  }
    74  
    75  func (r *bundle) LuciexeCommand() ([]string, error) {
    76  	return []string{filepath.Join(r.dir, "luciexe")}, nil
    77  }
    78  func (*bundle) Repo() string { return "" }
    79  func (*bundle) SHA1() string { return "" }
    80  
    81  // Repo represents a recipe repository downloaded from Git.
    82  type Repo struct {
    83  	dir, repo, sha1 string
    84  }
    85  
    86  func (r *Repo) LuciexeCommand() ([]string, error) {
    87  	return pyCommand(r.dir, "luciexe")
    88  }
    89  
    90  func (r *Repo) Repo() string { return r.repo }
    91  func (r *Repo) SHA1() string { return r.sha1 }
    92  
    93  // SetUp processes the build input to determine which version of recipes
    94  // to use (possibly from CAS rather than from Git if run by a led job) and
    95  // downloads that version of the recipes. It returns a `RecipesExe` that can be
    96  // used to run the recipes (whether they were downloaded from CAS or from Git)
    97  // as a luciexe, along with the deserialized build.proto.
    98  func SetUp(ctx context.Context, recipesDir string) (Checkout, *buildbucketpb.Build, error) {
    99  	data, err := io.ReadAll(os.Stdin)
   100  	if err != nil {
   101  		return nil, nil, fmt.Errorf("failed to read build.proto from stdin: %w", err)
   102  	}
   103  	build := &buildbucketpb.Build{}
   104  	if err := proto.Unmarshal(data, build); err != nil {
   105  		return nil, nil, fmt.Errorf("failed to unmarshal build.proto from stdin: %w", err)
   106  	}
   107  	if err := git.EnsureGitilesCommit(ctx, build); err != nil {
   108  		return nil, nil, fmt.Errorf("failed to ensure that build has a gitiles commit: %w", err)
   109  	}
   110  	integrationDir, err := git.CheckoutIntegration(ctx, build)
   111  	defer os.RemoveAll(integrationDir)
   112  	if err != nil {
   113  		return nil, nil, err
   114  	}
   115  	if err := resolveBuildProperties(ctx, integrationDir, build); err != nil {
   116  		return nil, nil, fmt.Errorf("failed to resolve the build properties: %w", err)
   117  	}
   118  	b, err := json.MarshalIndent(build.Input.Properties, "", "  ")
   119  	if err != nil {
   120  		return nil, nil, fmt.Errorf("failed to marshal build input properties: %w", err)
   121  	}
   122  	// TODO(fxbug.dev/83858): Stop setting this env var once once we no longer
   123  	// need to support running recipes from release branches where python3
   124  	// wasn't the default.
   125  	if err := os.Setenv("RECIPES_USE_PY3", "true"); err != nil {
   126  		return nil, nil, err
   127  	}
   128  	// TODO(fxbug.dev/72956): Emit the build properties to a separate output log
   129  	// once it's possible for recipe_wrapper to emit build.proto to the same
   130  	// Logdog stream as the recipe engine.
   131  	logging.Infof(ctx, "Resolved input properties: %s", string(b))
   132  	exe, err := downloadRecipes(ctx, build, integrationDir, recipesDir)
   133  	if err != nil {
   134  		return nil, nil, err
   135  	}
   136  	return exe, build, nil
   137  }
   138  
   139  // resolveBuildProperties resolves the build input properties to use by reading
   140  // the builder's properties from integration.git and merging them with the input
   141  // properties.
   142  func resolveBuildProperties(ctx context.Context, integrationDir string, build *buildbucketpb.Build) error {
   143  	versionedProperties, err := versionedProperties(ctx, integrationDir, build.Builder)
   144  	if err != nil {
   145  		return fmt.Errorf("failed to read builder properties from integration repo: %w", err)
   146  	}
   147  	// Make a copy of the original input properties, which we'll use to override
   148  	// the versioned properties.
   149  	originalProperties := build.Input.Properties.AsMap()
   150  
   151  	// The versioned "recipe" property should take precedence over the "recipe"
   152  	// property from the build input. This makes it possible to:
   153  	// 1) Test a configuration change that swaps a builder's recipe in presubmit.
   154  	// 2) Swap a builder's recipe without breaking release branches.
   155  	//
   156  	// The downside is that any out-of-band tooling that relies on this property
   157  	// will no longer behave correctly, but this isn't the end of the world
   158  	// because recipe swaps should be very rare and only affect a small number
   159  	// of release branch builds.
   160  	if versionedRecipe, ok := versionedProperties.AsMap()[recipeProperty]; ok {
   161  		originalProperties[recipeProperty] = versionedRecipe
   162  	}
   163  
   164  	// Replace the input properties with the resolved versioned properties.
   165  	build.Input.Properties = versionedProperties
   166  	// Merge the original input properties (primarily request properties) into
   167  	// the versioned properties, letting the original input properties take
   168  	// precedence.
   169  	return exe.WriteProperties(build.Input.Properties, originalProperties)
   170  }
   171  
   172  // versionedProperties loads the builder's properties from a JSON file in
   173  // the integration checkout.
   174  func versionedProperties(ctx context.Context, integrationDir string, builder *buildbucketpb.BuilderID) (*structpb.Struct, error) {
   175  	absPath := filepath.Join(integrationDir, propertiesFileForBuilder(builder))
   176  	logging.Infof(ctx, "Using versioned properties from %s", absPath)
   177  	contents, err := os.ReadFile(absPath)
   178  	if err != nil {
   179  		return nil, fmt.Errorf("failed to locate properties file for builder %s: %w", builder, err)
   180  	}
   181  	properties := &structpb.Struct{}
   182  	if err := protojson.Unmarshal(contents, properties); err != nil {
   183  		return nil, err
   184  	}
   185  	return properties, nil
   186  }
   187  
   188  func propertiesFileForBuilder(builder *buildbucketpb.BuilderID) string {
   189  	return filepath.Join(
   190  		"infra",
   191  		"config",
   192  		"generated",
   193  		builder.Project,
   194  		"properties",
   195  		// Builders launched by led with -real-build use shadow buckets in the form of
   196  		// `original_bucket.shadow`, but use the same properties file as the
   197  		// corresponding builder in the original bucket it shadows.
   198  		strings.ReplaceAll(builder.Bucket, ".shadow", ""),
   199  		builder.Builder+".json",
   200  	)
   201  }
   202  
   203  // downloadRecipes downloads recipes from Git or CAS depending on the build
   204  // input.
   205  func downloadRecipes(ctx context.Context, build *buildbucketpb.Build, integrationDir, recipesDir string) (Checkout, error) {
   206  	casRef := &apipb.CASReference{}
   207  	if err := props.Proto(build, ledCASRecipeBundleProp, casRef); err != nil {
   208  		return nil, err
   209  	}
   210  	if casRef.CasInstance != "" {
   211  		// We're running as a led job that was launched using `led
   212  		// edit-recipe-bundle -property-only`. So download the referenced recipe
   213  		// bundle from CAS and execute that, rather than checking out recipes
   214  		// from Git.
   215  		logging.Infof(ctx, "Downloading recipe bundle from CAS: %+v", casRef)
   216  		if err := downloadBundle(ctx, casRef, recipesDir); err != nil {
   217  			return nil, err
   218  		}
   219  		return &bundle{dir: recipesDir}, nil
   220  	}
   221  
   222  	input, err := checkout(ctx, build, recipesRemote, integrationDir, recipesDir)
   223  	if err != nil {
   224  		return nil, err
   225  	}
   226  	if err := fetchDeps(ctx, recipesDir); err != nil {
   227  		return nil, err
   228  	}
   229  	return &Repo{dir: recipesDir, repo: recipesRemote, sha1: input.GitilesCommit.Id}, nil
   230  }
   231  
   232  // checkout resolves the recipe version to check out, then checks out the
   233  // recipes repo at the specified recipesDir.
   234  // If the build input has a recipe change, then checkout directly using the
   235  // build input.
   236  // Otherwise, checkout at the revision specified by Build.Input.Properties.
   237  // The input resolved and used is returned.
   238  func checkout(ctx context.Context, build *buildbucketpb.Build, remote string, integrationDir, recipesDir string) (*buildbucketpb.Build_Input, error) {
   239  	recipesURL, err := url.Parse(remote)
   240  	if err != nil {
   241  		return nil, fmt.Errorf("could not parse URL %s", remote)
   242  	}
   243  	var input *buildbucketpb.Build_Input
   244  	hasRecipeChange, err := rbc.HasRepoChange(build.Input, recipesRemote)
   245  	if err != nil {
   246  		return nil, fmt.Errorf("could not determine whether build input has recipe change: %w", err)
   247  	}
   248  	if hasRecipeChange {
   249  		input = build.Input
   250  	} else {
   251  		// Use the recipe version from the checkout integrationDir.
   252  		manifestXML, err := os.Open(filepath.Join(integrationDir, recipesManifest))
   253  		if err != nil {
   254  			return nil, fmt.Errorf("failed to read recipes manifest file: %s: %w", filepath.Join(integrationDir, recipesManifest), err)
   255  		}
   256  		defer manifestXML.Close()
   257  		proj, err := manifest.ResolveRecipesProject(manifestXML, recipesRemote)
   258  		if err != nil {
   259  			return nil, err
   260  		}
   261  		input = &buildbucketpb.Build_Input{
   262  			GitilesCommit: &buildbucketpb.GitilesCommit{
   263  				Host:    recipesURL.Host,
   264  				Project: strings.TrimLeft(recipesURL.Path, "/"),
   265  				Id:      proj.Revision,
   266  			},
   267  		}
   268  	}
   269  	tctx, cancel := context.WithTimeout(ctx, rbc.DefaultCheckoutTimeout)
   270  	defer cancel()
   271  	if err := infracheckout.Checkout(tctx, input, *recipesURL, "", recipesDir); err != nil {
   272  		return nil, fmt.Errorf("failed to checkout recipes repo: %w", err)
   273  	}
   274  	return input, nil
   275  }
   276  
   277  // fetchDeps runs `recipes.py fetch`, which checks out all recipe git
   278  // repos that the current recipes repo repends on. `recipes.py luciexe` would
   279  // handle fetching deps even if we didn't run `recipes.py fetch` separately
   280  // beforehand, but running `recipes.py fetch` separately makes it easier to
   281  // distinguish between fetch errors (which are generally caused by transient Git
   282  // server issues) and true build errors within the executed recipe.
   283  func fetchDeps(ctx context.Context, recipesDir string) error {
   284  	args, err := pyCommand(recipesDir, "fetch")
   285  	if err != nil {
   286  		return err
   287  	}
   288  	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
   289  	// We want to make logs visible, but stdout is reserved for luciexe
   290  	// communication so we must pipe all output to stderr.
   291  	cmd.Stdout = os.Stderr
   292  	cmd.Stderr = os.Stderr
   293  	if err := cmd.Run(); err != nil {
   294  		return fmt.Errorf("failed to fetch recipe deps: %w", err)
   295  	}
   296  	return nil
   297  }
   298  
   299  // pyCommand constructs inputs to an exec.Cmd for running the `recipes.py`
   300  // script found in `recipesDir` with the specified arguments.
   301  func pyCommand(recipesDir string, recipesPyArgs ...string) ([]string, error) {
   302  	var cmd []string
   303  	if runtime.GOOS == "windows" {
   304  		// Windows doesn't support shebangs, so we can't run recipes.py
   305  		// directly. Instead we need to pass it as a script to the Python
   306  		// executable.
   307  		//
   308  		// TODO(olivernweman): For simplicity, we should really do this
   309  		// regardless of the OS once all release branches use a version of
   310  		// recipes.py that supports Python 3. It just so happens that no Windows
   311  		// builders run on release branches, which is why we can get away with
   312  		// doing this for Windows.
   313  		python, err := exec.LookPath("python3")
   314  		if err != nil {
   315  			return nil, fmt.Errorf("could not find python3 on PATH: %w", err)
   316  		}
   317  		cmd = append(cmd, python)
   318  	}
   319  	cmd = append(cmd, filepath.Join(recipesDir, "recipes.py"))
   320  	cmd = append(cmd, recipesPyArgs...)
   321  	return cmd, nil
   322  }
   323  
   324  // downloadBundle downloads the CAS archive identified by casRef to
   325  // recipesDir.
   326  func downloadBundle(ctx context.Context, casRef *apipb.CASReference, recipesDir string) error {
   327  	client, err := casclient.NewLegacy(ctx, casclient.AddrProd, casRef.CasInstance, chromeinfra.DefaultAuthOptions(), true)
   328  	if err != nil {
   329  		return err
   330  	}
   331  	defer client.Close()
   332  
   333  	d := digest.Digest{
   334  		Hash: casRef.Digest.Hash,
   335  		Size: casRef.Digest.SizeBytes,
   336  	}
   337  	if _, _, err := client.DownloadDirectory(ctx, d, recipesDir, filemetadata.NewNoopCache()); err != nil {
   338  		return fmt.Errorf("failed to download recipe bundle from CAS: %w", err)
   339  	}
   340  	return nil
   341  }