go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/led/ledcmd/edit_recipe_bundle.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ledcmd
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"fmt"
    21  	"io/ioutil"
    22  	"os"
    23  	"os/exec"
    24  	"path/filepath"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/golang/protobuf/jsonpb"
    29  	"go.chromium.org/luci/auth"
    30  	"go.chromium.org/luci/common/errors"
    31  	"go.chromium.org/luci/common/logging"
    32  
    33  	"go.chromium.org/luci/led/job"
    34  )
    35  
    36  // EditRecipeBundleOpts are user-provided options for the recipe bundling
    37  // process.
    38  type EditRecipeBundleOpts struct {
    39  	// Path on disk to the repo to extract the recipes from. May be a subdirectory
    40  	// of the repo, as long as `git rev-parse --show-toplevel` can find the root
    41  	// of the repository.
    42  	//
    43  	// If empty, uses the current working directory.
    44  	RepoDir string
    45  
    46  	// Overrides is a mapping of recipe project id (e.g. "recipe_engine") to
    47  	// a local path to a checkout of that repo (e.g. "/path/to/recipes-py.git").
    48  	//
    49  	// When the bundle is created, this local repo will be used instead of the
    50  	// pinned version of this recipe project id. This is helpful for preparing
    51  	// bundles which have code changes in multiple recipe repos.
    52  	Overrides map[string]string
    53  
    54  	// DebugSleep is the amount of time to wait after the recipe completes
    55  	// execution (either success or failure). This is injected into the generated
    56  	// recipe bundle as a 'sleep X' command after the invocation of the recipe
    57  	// itself.
    58  	DebugSleep time.Duration
    59  
    60  	// PropertyOnly determines whether to pass the recipe bundle's CAS reference
    61  	// as a property and preserve the executable and payload of the input job
    62  	// rather than overwriting it.
    63  	PropertyOnly bool
    64  }
    65  
    66  const (
    67  	// In PropertyOnly mode or if the "led_builder_is_bootstrapped" property
    68  	// of the build is true, this property will be set with the CAS digest
    69  	// of the executable of the recipe bundle.
    70  	CASRecipeBundleProperty = "led_cas_recipe_bundle"
    71  )
    72  
    73  // EditRecipeBundle overrides the recipe bundle in the given job with one
    74  // located on disk.
    75  //
    76  // It isolates the recipes from the repository in the given working directory
    77  // into the UserPayload under the directory "kitchen-checkout/". If there's an
    78  // existing directory in the UserPayload at that location, it will be removed.
    79  func EditRecipeBundle(ctx context.Context, authOpts auth.Options, jd *job.Definition, opts *EditRecipeBundleOpts) error {
    80  	if jd.GetBuildbucket() == nil {
    81  		return errors.New("ledcmd.EditRecipeBundle is only available for Buildbucket tasks")
    82  	}
    83  
    84  	if opts == nil {
    85  		opts = &EditRecipeBundleOpts{}
    86  	}
    87  
    88  	recipesPy, err := findRecipesPy(ctx, opts.RepoDir)
    89  	if err != nil {
    90  		return err
    91  	}
    92  	logging.Debugf(ctx, "using recipes.py: %q", recipesPy)
    93  
    94  	extraProperties := make(map[string]string)
    95  	setRecipeBundleProperty := opts.PropertyOnly || jd.GetBuildbucket().GetBbagentArgs().GetBuild().GetInput().GetProperties().GetFields()[job.LEDBuilderIsBootstrappedProperty].GetBoolValue()
    96  	if setRecipeBundleProperty {
    97  		// In property-only mode, we want to leave the original payload as is
    98  		// and just upload the recipe bundle as a brand new independent CAS
    99  		// archive for the job's executable to download.
   100  		bundlePath, err := ioutil.TempDir("", "led-recipe-bundle")
   101  		if err != nil {
   102  			return errors.Annotate(err, "creating temporary recipe bundle directory").Err()
   103  		}
   104  		if err := opts.prepBundle(ctx, opts.RepoDir, recipesPy, bundlePath); err != nil {
   105  			return err
   106  		}
   107  		logging.Infof(ctx, "isolating recipes")
   108  		casClient, err := newCASClient(ctx, authOpts, jd)
   109  		if err != nil {
   110  			return err
   111  		}
   112  		casRef, err := uploadToCas(ctx, casClient, bundlePath)
   113  		if err != nil {
   114  			return err
   115  		}
   116  		m := &jsonpb.Marshaler{OrigName: true}
   117  		jsonCASRef, err := m.MarshalToString(casRef)
   118  		if err != nil {
   119  			return errors.Annotate(err, "encoding CAS user payload").Err()
   120  		}
   121  		extraProperties[CASRecipeBundleProperty] = jsonCASRef
   122  	} else {
   123  		if err := EditIsolated(ctx, authOpts, jd, func(ctx context.Context, dir string) error {
   124  			bundlePath := dir
   125  			if !jd.GetBuildbucket().BbagentDownloadCIPDPkgs() {
   126  				bundlePath = filepath.Join(dir, job.RecipeDirectory)
   127  			}
   128  			// Remove existing bundled recipes, if any. Ignore the error.
   129  			os.RemoveAll(bundlePath)
   130  			if err := opts.prepBundle(ctx, opts.RepoDir, recipesPy, bundlePath); err != nil {
   131  				return err
   132  			}
   133  			logging.Infof(ctx, "isolating recipes")
   134  			return nil
   135  		}); err != nil {
   136  			return err
   137  		}
   138  	}
   139  
   140  	return jd.HighLevelEdit(func(je job.HighLevelEditor) {
   141  		if setRecipeBundleProperty {
   142  			je.Properties(extraProperties, false)
   143  		}
   144  		if opts.DebugSleep != 0 {
   145  			je.Env(map[string]string{
   146  				"RECIPES_DEBUG_SLEEP": fmt.Sprintf("%f", opts.DebugSleep.Seconds()),
   147  			})
   148  		}
   149  	})
   150  }
   151  
   152  func logCmd(ctx context.Context, inDir string, arg0 string, args ...string) *exec.Cmd {
   153  	ret := exec.CommandContext(ctx, arg0, args...)
   154  	ret.Dir = inDir
   155  	logging.Debugf(ctx, "Running (from %q) - %s %v", inDir, arg0, args)
   156  	return ret
   157  }
   158  
   159  func cmdErr(cmd *exec.Cmd, err error, reason string) error {
   160  	if err == nil {
   161  		return nil
   162  	}
   163  	var outErr string
   164  	if ee, ok := err.(*exec.ExitError); ok {
   165  		outErr = strings.TrimSpace(string(ee.Stderr))
   166  		if len(outErr) > 128 {
   167  			outErr = outErr[:128] + "..."
   168  		}
   169  	} else {
   170  		outErr = err.Error()
   171  	}
   172  	return errors.Annotate(err, "running %q: %s: %s", strings.Join(cmd.Args, " "), reason, outErr).Err()
   173  }
   174  
   175  func appendText(path, fmtStr string, items ...any) error {
   176  	file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0666)
   177  	if err != nil {
   178  		return err
   179  	}
   180  	defer file.Close()
   181  	_, err = fmt.Fprintf(file, fmtStr, items...)
   182  	return err
   183  }
   184  
   185  func (opts *EditRecipeBundleOpts) prepBundle(ctx context.Context, inDir, recipesPy, toDirectory string) error {
   186  	logging.Infof(ctx, "bundling recipes")
   187  	args := []string{
   188  		recipesPy,
   189  	}
   190  	if logging.GetLevel(ctx) < logging.Info {
   191  		args = append(args, "-v")
   192  	}
   193  	for projID, path := range opts.Overrides {
   194  		args = append(args, "-O", fmt.Sprintf("%s=%s", projID, path))
   195  	}
   196  	args = append(args, "bundle", "--destination", filepath.Join(toDirectory))
   197  
   198  	// Always prefer python3 to python
   199  	python, err := exec.LookPath("python3")
   200  	if err != nil {
   201  		python, err = exec.LookPath("python")
   202  	}
   203  	if err != nil {
   204  		return errors.Annotate(err, "unable to find python3 or python in $PATH").Err()
   205  	}
   206  
   207  	cmd := logCmd(ctx, inDir, python, args...)
   208  	if logging.GetLevel(ctx) < logging.Info {
   209  		cmd.Stdout = os.Stdout
   210  		cmd.Stderr = os.Stderr
   211  	}
   212  	return cmdErr(cmd, cmd.Run(), "creating bundle")
   213  }
   214  
   215  // findRecipesPy locates the current repo's `recipes.py`. It does this by:
   216  //   - invoking git to find the repo root
   217  //   - loading the recipes.cfg at infra/config/recipes.cfg
   218  //   - stat'ing the recipes.py implied by the recipes_path in that cfg file.
   219  //
   220  // Failure will return an error.
   221  //
   222  // On success, the absolute path to recipes.py is returned.
   223  func findRecipesPy(ctx context.Context, inDir string) (string, error) {
   224  	cmd := logCmd(ctx, inDir, "git", "rev-parse", "--show-toplevel")
   225  	out, err := cmd.Output()
   226  	if err = cmdErr(cmd, err, "finding git repo"); err != nil {
   227  		return "", err
   228  	}
   229  
   230  	repoRoot := strings.TrimSpace(string(out))
   231  
   232  	pth := filepath.Join(repoRoot, "infra", "config", "recipes.cfg")
   233  	switch st, err := os.Stat(pth); {
   234  	case err != nil:
   235  		return "", errors.Annotate(err, "reading recipes.cfg").Err()
   236  
   237  	case !st.Mode().IsRegular():
   238  		return "", errors.Reason("%q is not a regular file", pth).Err()
   239  	}
   240  
   241  	type recipesJSON struct {
   242  		RecipesPath string `json:"recipes_path"`
   243  	}
   244  	rj := &recipesJSON{}
   245  
   246  	f, err := os.Open(pth)
   247  	if err != nil {
   248  		return "", errors.Reason("reading recipes.cfg: %q", pth).Err()
   249  	}
   250  	defer f.Close()
   251  
   252  	if err := json.NewDecoder(f).Decode(rj); err != nil {
   253  		return "", errors.Reason("parsing recipes.cfg: %q", pth).Err()
   254  	}
   255  
   256  	return filepath.Join(
   257  		repoRoot, filepath.FromSlash(rj.RecipesPath), "recipes.py"), nil
   258  }