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 }