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 }