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  }