blob: b014bf58376a3aae65c05b313e05e4fded9ee387 [file] [log] [blame]
// Copyright 2022 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package host
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
bbpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry"
"go.chromium.org/luci/common/retry/transient"
)
const (
ensureFileHeader = "$ServiceURL https://chrome-infra-packages.appspot.com/\n$ParanoidMode CheckPresence\n"
kitchenCheckout = "kitchen-checkout"
)
// resultsFilePath is the path to the generated file from cipd ensure command.
// Placing it here to allow to replace it during testing.
var resultsFilePath = filepath.Join(os.TempDir(), "cipd_ensure_results.json")
// execCommandContext to allow to replace it during testing.
var execCommandContext = exec.CommandContext
type cipdPkg struct {
Package string `json:"package"`
InstanceID string `json:"instance_id"`
}
// cipdOut corresponds to the structure of the generated result json file from cipd ensure command.
type cipdOut struct {
Result map[string][]*cipdPkg `json:"result"`
}
func setPathEnv(workDir string, extraRelPaths []string) error {
if len(extraRelPaths) == 0 {
return nil
}
var extraAbsPaths []string
for _, p := range extraRelPaths {
extraAbsPaths = append(extraAbsPaths, filepath.Join(workDir, p))
}
original := os.Getenv("PATH")
return os.Setenv("PATH", strings.Join(append(extraAbsPaths, original), string(os.PathListSeparator)))
}
func prependPath(bld *bbpb.Build, workDir string) error {
extraPathEnv := stringset.Set{}
for _, ref := range bld.Infra.Buildbucket.Agent.Input.Data {
extraPathEnv.AddAll(ref.OnPath)
}
return setPathEnv(workDir, extraPathEnv.ToSortedSlice())
}
// getCipdClientWithRetry attempts to download the cipd client using http.Get with a retry strategy.
func getCipdClientWithRetry(ctx context.Context, cipdURL string) (resp *http.Response, err error) {
doGetCipd := func() error {
resp, err = http.Get(cipdURL)
if err != nil {
return transient.Tag.Apply(err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return transient.Tag.Apply(errors.New(fmt.Sprintf("HTTP request failed with status code: %d", resp.StatusCode)))
}
// Configure the retry
err = retry.Retry(ctx, transient.Only(func() retry.Iterator {
// Configure the retry strategy
return &retry.ExponentialBackoff{
Limited: retry.Limited{
Delay: 500 * time.Millisecond, // initial delay time
Retries: 5, // number of retries
},
Multiplier: 2, // backoff multiplier
MaxDelay: 5 * time.Second, // maximum delay time
}
}), doGetCipd, nil)
return
}
// installCipd installs the cipd client provided to us for the build. It will return
// the path on disk of the binary so that installCipdPackages can use the downloaded cipd client.
func installCipd(ctx context.Context, build *bbpb.Build, workDir, cacheBase, platform string) error {
var cipdFile, cipdDir, cipdServer, cipdVersion string
var onPath []string
// We "loop" through this because it is a map, however, there should only be one entry in this map.
for relativePath, cipdSource := range build.Infra.Buildbucket.Agent.Input.CipdSource {
// The binary itself will be located at "workdir/cipd/cipd"
// where "workdir/cipd" is the directory.
cipdDir = filepath.Join(workDir, relativePath)
cipdFile = filepath.Join(cipdDir, "cipd")
cipdServer = cipdSource.GetCipd().Server
cipdVersion = cipdSource.GetCipd().Specs[0].Version
onPath = cipdSource.OnPath
break
}
if cipdVersion == "" {
return nil
}
cipdClientCache := build.Infra.Buildbucket.Agent.CipdClientCache
needtoDownload := true
if cipdClientCache != nil {
// Use cipd client cache.
cipdCacheDirRel := filepath.Join(cacheBase, cipdClientCache.Path) // cache/cipd_client
cipdCacheDir := filepath.Join(workDir, cipdCacheDirRel) // b/s/w/ir/cache/cipd_client
cipdCachePath := filepath.Join(cipdCacheDir, "cipd") // b/s/w/ir/cache/cipd_client/cipd
cipdCacheOnPath := []string{cipdCacheDirRel, filepath.Join(cipdCacheDirRel, "bin")}
_, err := os.Stat(cipdCachePath)
switch {
case err == nil:
// cache hit, use the client directly.
needtoDownload = false
onPath = cipdCacheOnPath
case os.IsNotExist(err):
// cache miss, download and install cipd client in cipdCacheDir.
cipdDir = cipdCacheDir
cipdFile = cipdCachePath
onPath = cipdCacheOnPath
default:
logging.Infof(ctx, "failed to get cipd client from cache: %s.", err)
// Forget about cache, download and install cipd client directly.
}
}
if needtoDownload {
if err := downloadCipd(ctx, cipdServer, platform, cipdVersion, cipdDir, cipdFile); err != nil {
return err
}
}
// Append cipd path to $PATH.
return setPathEnv(workDir, onPath)
}
func downloadCipd(ctx context.Context, cipdServer, platform, cipdVersion, cipdDir, cipdFile string) error {
// Pull cipd binary
cipdURL := fmt.Sprintf("https://%s/client?platform=%s&version=%s", cipdServer, platform, cipdVersion)
logging.Infof(ctx, "Install CIPD client from URL: %s into %s", cipdURL, cipdDir)
resp, err := getCipdClientWithRetry(ctx, cipdURL)
if err != nil {
return err
}
defer resp.Body.Close()
// Make the directory to save cipd client into if it does not exist.
err = os.MkdirAll(cipdDir, os.ModePerm)
if err != nil {
return err
}
// Create file to store cipd binary in
out, err := os.Create(cipdFile)
if err != nil {
return err
}
defer out.Close()
// Write binary to file
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
// Give the binary executable permission.
err = os.Chmod(cipdFile, 0700)
if err != nil {
return err
}
return nil
}
// installCipdPackages installs cipd packages defined in build.Infra.Buildbucket.Agent.Input
// and build exe.
//
// This will update the following fields in build:
// - Infra.Buidlbucket.Agent.Output.ResolvedData
// - Infra.Buildbucket.Agent.Purposes
//
// Note:
// 1. It will use the `cipd` client tool binary path that is in path.
// 2. Hack: it includes bbagent version in the ensure file if it's called from
// a cipd installed bbagent.
func installCipdPackages(ctx context.Context, build *bbpb.Build, workDir, cacheBase string) error {
logging.Infof(ctx, "Installing cipd packages into %s", workDir)
inputData := build.Infra.Buildbucket.Agent.Input.Data
ensureFileBuilder := strings.Builder{}
ensureFileBuilder.WriteString(ensureFileHeader)
payloadPath := kitchenCheckout
for dir, purpose := range build.Infra.Buildbucket.Agent.GetPurposes() {
if purpose == bbpb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD {
payloadPath = dir
}
}
payloadPathInAgentInput := false
for dir, pkgs := range inputData {
if pkgs.GetCipd() == nil {
continue
}
if dir == payloadPath {
payloadPathInAgentInput = true
}
fmt.Fprintf(&ensureFileBuilder, "@Subdir %s\n", dir)
for _, spec := range pkgs.GetCipd().Specs {
fmt.Fprintf(&ensureFileBuilder, "%s %s\n", spec.Package, spec.Version)
}
}
if !payloadPathInAgentInput && build.Exe != nil {
fmt.Fprintf(&ensureFileBuilder, "@Subdir %s\n", payloadPath)
fmt.Fprintf(&ensureFileBuilder, "%s %s\n", build.Exe.CipdPackage, build.Exe.CipdVersion)
if build.Infra.Buildbucket.Agent.Purposes == nil {
build.Infra.Buildbucket.Agent.Purposes = make(map[string]bbpb.BuildInfra_Buildbucket_Agent_Purpose, 1)
}
build.Infra.Buildbucket.Agent.Purposes[payloadPath] = bbpb.BuildInfra_Buildbucket_Agent_PURPOSE_EXE_PAYLOAD
}
// TODO(crbug.com/1297809): Remove this redundant log once this feature development is done.
logging.Infof(ctx, "===ensure file===\n%s\n=========", ensureFileBuilder.String())
// Find cipd packages cache and set $CIPD_CACHE_DIR.
cache := build.Infra.Buildbucket.Agent.CipdPackagesCache
if cache != nil {
cacheDir := filepath.Join(cacheBase, cache.Path)
logging.Infof(ctx, "Setting $CIPD_CACHE_DIR to %q", cacheDir)
if err := os.Setenv("CIPD_CACHE_DIR", cacheDir); err != nil {
return err
}
}
// Install packages
cmd := execCommandContext(ctx, "cipd", "ensure", "-root", workDir, "-ensure-file", "-", "-json-output", resultsFilePath)
logging.Infof(ctx, "Running command: %s", cmd.String())
cmd.Stdin = strings.NewReader(ensureFileBuilder.String())
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return errors.Annotate(err, "Failed to run cipd ensure command").Err()
}
resultsFile, err := os.Open(resultsFilePath)
if err != nil {
return err
}
defer resultsFile.Close()
cipdOutputs := cipdOut{}
jsonResults, err := io.ReadAll(resultsFile)
if err != nil {
return err
}
if err := json.Unmarshal(jsonResults, &cipdOutputs); err != nil {
return err
}
resolved := make(map[string]*bbpb.ResolvedDataRef, len(inputData)+1)
build.Infra.Buildbucket.Agent.Output.ResolvedData = resolved
for p, pkgs := range cipdOutputs.Result {
resolvedPkgs := make([]*bbpb.ResolvedDataRef_CIPD_PkgSpec, 0, len(pkgs))
for _, pkg := range pkgs {
resolvedPkgs = append(resolvedPkgs, &bbpb.ResolvedDataRef_CIPD_PkgSpec{
Package: pkg.Package,
Version: pkg.InstanceID,
})
}
resolved[p] = &bbpb.ResolvedDataRef{
DataType: &bbpb.ResolvedDataRef_Cipd{Cipd: &bbpb.ResolvedDataRef_CIPD{
Specs: resolvedPkgs,
}},
}
}
return nil
}