go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/cmd/bbagent/cipd.go (about) 1 // Copyright 2022 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 main 16 17 import ( 18 "context" 19 "encoding/json" 20 "fmt" 21 "io" 22 "net/http" 23 "os" 24 "os/exec" 25 "path/filepath" 26 "strings" 27 "time" 28 29 bbpb "go.chromium.org/luci/buildbucket/proto" 30 cipdVersion "go.chromium.org/luci/cipd/version" 31 "go.chromium.org/luci/common/data/stringset" 32 "go.chromium.org/luci/common/errors" 33 "go.chromium.org/luci/common/logging" 34 "go.chromium.org/luci/common/retry" 35 "go.chromium.org/luci/common/retry/transient" 36 ) 37 38 const ( 39 ensureFileHeader = "$ServiceURL https://chrome-infra-packages.appspot.com/\n$ParanoidMode CheckPresence\n" 40 kitchenCheckout = "kitchen-checkout" 41 ) 42 43 // resultsFilePath is the path to the generated file from cipd ensure command. 44 // Placing it here to allow to replace it during testing. 45 var resultsFilePath = filepath.Join(os.TempDir(), "cipd_ensure_results.json") 46 47 // execCommandContext to allow to replace it during testing. 48 var execCommandContext = exec.CommandContext 49 50 type cipdPkg struct { 51 Package string `json:"package"` 52 InstanceID string `json:"instance_id"` 53 } 54 55 // cipdOut corresponds to the structure of the generated result json file from cipd ensure command. 56 type cipdOut struct { 57 Result map[string][]*cipdPkg `json:"result"` 58 } 59 60 func setPathEnv(workDir string, extraRelPaths []string) error { 61 if len(extraRelPaths) == 0 { 62 return nil 63 } 64 var extraAbsPaths []string 65 for _, p := range extraRelPaths { 66 extraAbsPaths = append(extraAbsPaths, filepath.Join(workDir, p)) 67 } 68 original := os.Getenv("PATH") 69 return os.Setenv("PATH", strings.Join(append(extraAbsPaths, original), string(os.PathListSeparator))) 70 } 71 72 func prependPath(bld *bbpb.Build, workDir string) error { 73 extraPathEnv := stringset.Set{} 74 for _, ref := range bld.Infra.Buildbucket.Agent.Input.Data { 75 extraPathEnv.AddAll(ref.OnPath) 76 } 77 return setPathEnv(workDir, extraPathEnv.ToSortedSlice()) 78 } 79 80 // getCipdClientWithRetry attempts to download the cipd client using http.Get with a retry strategy. 81 func getCipdClientWithRetry(ctx context.Context, cipdURL string) (resp *http.Response, err error) { 82 doGetCipd := func() error { 83 resp, err = http.Get(cipdURL) 84 if err != nil { 85 return transient.Tag.Apply(err) 86 } 87 if resp.StatusCode >= 200 && resp.StatusCode < 300 { 88 return nil 89 } 90 return transient.Tag.Apply(errors.New(fmt.Sprintf("HTTP request failed with status code: %d", resp.StatusCode))) 91 } 92 // Configure the retry 93 err = retry.Retry(ctx, transient.Only(func() retry.Iterator { 94 // Configure the retry strategy 95 return &retry.ExponentialBackoff{ 96 Limited: retry.Limited{ 97 Delay: 500 * time.Millisecond, // initial delay time 98 Retries: 5, // number of retries 99 }, 100 Multiplier: 2, // backoff multiplier 101 MaxDelay: 5 * time.Second, // maximum delay time 102 } 103 }), doGetCipd, nil) 104 return 105 } 106 107 // installCipd installs the cipd client provided to us for the build. It will return 108 // the path on disk of the binary so that installCipdPackages can use the downloaded cipd client. 109 func installCipd(ctx context.Context, build *bbpb.Build, workDir, cacheBase, platform string) error { 110 var cipdFile, cipdDir, cipdServer, cipdVersion string 111 var onPath []string 112 // We "loop" through this because it is a map, however, there should only be one entry in this map. 113 for relativePath, cipdSource := range build.Infra.Buildbucket.Agent.Input.CipdSource { 114 // The binary itself will be located at "workdir/cipd/cipd" 115 // where "workdir/cipd" is the directory. 116 cipdDir = filepath.Join(workDir, relativePath) 117 cipdFile = filepath.Join(cipdDir, "cipd") 118 cipdServer = cipdSource.GetCipd().Server 119 cipdVersion = cipdSource.GetCipd().Specs[0].Version 120 onPath = cipdSource.OnPath 121 break 122 } 123 if cipdVersion == "" { 124 return nil 125 } 126 127 cipdClientCache := build.Infra.Buildbucket.Agent.CipdClientCache 128 129 needtoDownload := true 130 if cipdClientCache != nil { 131 // Use cipd client cache. 132 cipdCacheDirRel := filepath.Join(cacheBase, cipdClientCache.Path) // cache/cipd_client 133 cipdCacheDir := filepath.Join(workDir, cipdCacheDirRel) // b/s/w/ir/cache/cipd_client 134 cipdCachePath := filepath.Join(cipdCacheDir, "cipd") // b/s/w/ir/cache/cipd_client/cipd 135 cipdCacheOnPath := []string{cipdCacheDirRel, filepath.Join(cipdCacheDirRel, "bin")} 136 137 _, err := os.Stat(cipdCachePath) 138 switch { 139 case err == nil: 140 // cache hit, use the client directly. 141 needtoDownload = false 142 onPath = cipdCacheOnPath 143 case os.IsNotExist(err): 144 // cache miss, download and install cipd client in cipdCacheDir. 145 cipdDir = cipdCacheDir 146 cipdFile = cipdCachePath 147 onPath = cipdCacheOnPath 148 default: 149 logging.Infof(ctx, "failed to get cipd client from cache: %s.", err) 150 // Forget about cache, download and install cipd client directly. 151 } 152 } 153 154 if needtoDownload { 155 if err := downloadCipd(ctx, cipdServer, platform, cipdVersion, cipdDir, cipdFile); err != nil { 156 return err 157 } 158 } 159 160 // Append cipd path to $PATH. 161 return setPathEnv(workDir, onPath) 162 } 163 164 func downloadCipd(ctx context.Context, cipdServer, platform, cipdVersion, cipdDir, cipdFile string) error { 165 // Pull cipd binary 166 cipdURL := fmt.Sprintf("https://%s/client?platform=%s&version=%s", cipdServer, platform, cipdVersion) 167 logging.Infof(ctx, "Install CIPD client from URL: %s into %s", cipdURL, cipdDir) 168 resp, err := getCipdClientWithRetry(ctx, cipdURL) 169 if err != nil { 170 return err 171 } 172 defer resp.Body.Close() 173 // Make the directory to save cipd client into if it does not exist. 174 err = os.MkdirAll(cipdDir, os.ModePerm) 175 if err != nil { 176 return err 177 } 178 // Create file to store cipd binary in 179 out, err := os.Create(cipdFile) 180 if err != nil { 181 return err 182 } 183 defer out.Close() 184 // Write binary to file 185 _, err = io.Copy(out, resp.Body) 186 if err != nil { 187 return err 188 } 189 // Give the binary executable permission. 190 err = os.Chmod(cipdFile, 0700) 191 if err != nil { 192 return err 193 } 194 return nil 195 } 196 197 // installCipdPackages installs cipd packages defined in build.Infra.Buildbucket.Agent.Input 198 // and build exe. 199 // 200 // This will update the following fields in build: 201 // - Infra.Buidlbucket.Agent.Output.ResolvedData 202 // - Infra.Buildbucket.Agent.Purposes 203 // 204 // Note: 205 // 1. It will use the `cipd` client tool binary path that is in path. 206 // 2. Hack: it includes bbagent version in the ensure file if it's called from 207 // a cipd installed bbagent. 208 func installCipdPackages(ctx context.Context, build *bbpb.Build, workDir, cacheBase string) error { 209 logging.Infof(ctx, "Installing cipd packages into %s", workDir) 210 inputData := build.Infra.Buildbucket.Agent.Input.Data 211 212 ensureFileBuilder := strings.Builder{} 213 ensureFileBuilder.WriteString(ensureFileHeader) 214 215 // TODO(crbug.com/1297809): Remove it once we decide to have a subfolder for 216 // non-bbagent packages in the post-migration stage. 217 switch ver, err := cipdVersion.GetStartupVersion(); { 218 case err != nil: 219 // If the binary is not installed via CIPD, err == nil && ver.InstanceID == "". 220 return errors.Annotate(err, "Failed to get the current executable startup version").Err() 221 default: 222 fmt.Fprintf(&ensureFileBuilder, "%s %s\n", ver.PackageName, ver.InstanceID) 223 } 224 225 payloadPath := kitchenCheckout 226 for dir, purpose := range build.Infra.Buildbucket.Agent.GetPurposes() { 227 if purpose == bbpb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD { 228 payloadPath = dir 229 } 230 } 231 payloadPathInAgentInput := false 232 for dir, pkgs := range inputData { 233 if pkgs.GetCipd() == nil { 234 continue 235 } 236 if dir == payloadPath { 237 payloadPathInAgentInput = true 238 } 239 fmt.Fprintf(&ensureFileBuilder, "@Subdir %s\n", dir) 240 for _, spec := range pkgs.GetCipd().Specs { 241 fmt.Fprintf(&ensureFileBuilder, "%s %s\n", spec.Package, spec.Version) 242 } 243 } 244 if !payloadPathInAgentInput && build.Exe != nil { 245 fmt.Fprintf(&ensureFileBuilder, "@Subdir %s\n", payloadPath) 246 fmt.Fprintf(&ensureFileBuilder, "%s %s\n", build.Exe.CipdPackage, build.Exe.CipdVersion) 247 if build.Infra.Buildbucket.Agent.Purposes == nil { 248 build.Infra.Buildbucket.Agent.Purposes = make(map[string]bbpb.BuildInfra_Buildbucket_Agent_Purpose, 1) 249 } 250 build.Infra.Buildbucket.Agent.Purposes[payloadPath] = bbpb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD 251 } 252 253 // TODO(crbug.com/1297809): Remove this redundant log once this feature development is done. 254 logging.Infof(ctx, "===ensure file===\n%s\n=========", ensureFileBuilder.String()) 255 256 // Find cipd packages cache and set $CIPD_CACHE_DIR. 257 cache := build.Infra.Buildbucket.Agent.CipdPackagesCache 258 if cache != nil { 259 cacheDir := filepath.Join(workDir, cacheBase, cache.Path) 260 logging.Infof(ctx, "Setting $CIPD_CACHE_DIR to %q", cacheDir) 261 if err := os.Setenv("CIPD_CACHE_DIR", cacheDir); err != nil { 262 return err 263 } 264 } 265 266 // Install packages 267 cmd := execCommandContext(ctx, "cipd", "ensure", "-root", workDir, "-ensure-file", "-", "-json-output", resultsFilePath) 268 logging.Infof(ctx, "Running command: %s", cmd.String()) 269 cmd.Stdin = strings.NewReader(ensureFileBuilder.String()) 270 cmd.Stdout = os.Stdout 271 cmd.Stderr = os.Stderr 272 if err := cmd.Run(); err != nil { 273 return errors.Annotate(err, "Failed to run cipd ensure command").Err() 274 } 275 276 resultsFile, err := os.Open(resultsFilePath) 277 if err != nil { 278 return err 279 } 280 defer resultsFile.Close() 281 cipdOutputs := cipdOut{} 282 jsonResults, err := io.ReadAll(resultsFile) 283 if err != nil { 284 return err 285 } 286 if err := json.Unmarshal(jsonResults, &cipdOutputs); err != nil { 287 return err 288 } 289 290 resolved := make(map[string]*bbpb.ResolvedDataRef, len(inputData)+1) 291 build.Infra.Buildbucket.Agent.Output.ResolvedData = resolved 292 for p, pkgs := range cipdOutputs.Result { 293 resolvedPkgs := make([]*bbpb.ResolvedDataRef_CIPD_PkgSpec, 0, len(pkgs)) 294 for _, pkg := range pkgs { 295 resolvedPkgs = append(resolvedPkgs, &bbpb.ResolvedDataRef_CIPD_PkgSpec{ 296 Package: pkg.Package, 297 Version: pkg.InstanceID, 298 }) 299 } 300 resolved[p] = &bbpb.ResolvedDataRef{ 301 DataType: &bbpb.ResolvedDataRef_Cipd{Cipd: &bbpb.ResolvedDataRef_CIPD{ 302 Specs: resolvedPkgs, 303 }}, 304 } 305 } 306 307 return nil 308 }