go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/recipe_wrapper/git/git.go (about)

     1  // Copyright 2023 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 git
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"net/url"
    11  	"os"
    12  
    13  	"go.chromium.org/luci/auth"
    14  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    15  	gerritapi "go.chromium.org/luci/common/api/gerrit"
    16  	gitilesapi "go.chromium.org/luci/common/api/gitiles"
    17  	"go.fuchsia.dev/infra/checkout"
    18  	rbc "go.fuchsia.dev/infra/cmd/recipe_wrapper/checkout"
    19  	"go.fuchsia.dev/infra/cmd/recipe_wrapper/props"
    20  	"go.fuchsia.dev/infra/gerrit"
    21  	"go.fuchsia.dev/infra/gitiles"
    22  	"google.golang.org/protobuf/types/known/structpb"
    23  )
    24  
    25  const (
    26  	// Fallback ref to fetch, when build has no input, e.g. when the build is manually
    27  	// triggered with LUCI scheduler, or when the recipes_host_override property is set.
    28  	FallbackRef = "refs/heads/main"
    29  
    30  	// Default integration.git remote, for when build has no input, e.g. when
    31  	// the build is manually triggered with LUCI scheduler.
    32  	defaultIntegrationRemote = "https://fuchsia.googlesource.com/integration"
    33  )
    34  
    35  // resolveRef returns a resolved sha1 from a ref e.g. refs/heads/main.
    36  func resolveRef(ctx context.Context, host, project, ref string) (string, error) {
    37  	authClient, err := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{Scopes: []string{gitilesapi.OAuthScope}}).Client()
    38  	if err != nil {
    39  		return "", fmt.Errorf("could not initialize auth client: %w", err)
    40  	}
    41  	client, err := gitiles.NewClient(host, project, authClient)
    42  	if err != nil {
    43  		return "", fmt.Errorf("could not initialize gitiles client: %w", err)
    44  	}
    45  	return client.LatestCommit(ctx, ref)
    46  }
    47  
    48  // resolveGitilesRef returns the ref associated with the build's input.
    49  //
    50  // If the build input has a GitilesCommit, return GitilesCommit.Ref.
    51  //
    52  // If the build input only has a GerritChange, query change info from Gerrit and
    53  // return the ref.
    54  // Note that this is the ref that the change targets i.e. refs/heads/*, not the
    55  // magic change ref, e.g. refs/changes/*.
    56  //
    57  // If the build input has neither, return the FallbackRef.
    58  func resolveGitilesRef(ctx context.Context, buildInput *buildbucketpb.Build_Input, FallbackRef string) (string, error) {
    59  	if buildInput.GitilesCommit != nil {
    60  		return buildInput.GitilesCommit.Ref, nil
    61  	}
    62  	if len(buildInput.GerritChanges) > 0 {
    63  		authClient, err := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{Scopes: []string{gerritapi.OAuthScope}}).Client()
    64  		if err != nil {
    65  			return "", fmt.Errorf("could not initialize auth client: %w", err)
    66  		}
    67  		client, err := gerrit.NewClient(buildInput.GerritChanges[0].Host, buildInput.GerritChanges[0].Project, authClient)
    68  		if err != nil {
    69  			return "", fmt.Errorf("failed to initialize gerrit client: %w", err)
    70  		}
    71  		resp, err := client.GetChange(ctx, buildInput.GerritChanges[0].Change)
    72  		if err != nil {
    73  			return "", err
    74  		}
    75  		return resp.Ref, nil
    76  	}
    77  	return FallbackRef, nil
    78  }
    79  
    80  // EnsureGitilesCommit ensures that the incoming build always has an
    81  // Input.GitilesCommit. This ensures that this build and any child builds
    82  // always use a consistent HEAD.
    83  //
    84  // The host and project for the GitilesCommit are determined by the
    85  // `ResolveRepo` strategy, with the fallback remote being either the default
    86  // integration remote set in this file, or the override set by the
    87  // `recipe_integration_remote` property.
    88  func EnsureGitilesCommit(ctx context.Context, build *buildbucketpb.Build) error {
    89  	if build.Input.GitilesCommit != nil {
    90  		return nil
    91  	}
    92  	fallbackRemote, err := props.String(build, "recipe_integration_remote")
    93  	if err != nil {
    94  		return err
    95  	}
    96  	if fallbackRemote == "" {
    97  		fallbackRemote = defaultIntegrationRemote
    98  	}
    99  	host, project, err := rbc.ResolveRepo(build.Input, fallbackRemote)
   100  	if err != nil {
   101  		return fmt.Errorf("failed to resolve host and project: %w", err)
   102  	}
   103  	ref, err := resolveGitilesRef(ctx, build.Input, FallbackRef)
   104  	if err != nil {
   105  		return fmt.Errorf("failed to resolve gitiles ref: %w", err)
   106  	}
   107  	revision, err := resolveRef(ctx, host, project, ref)
   108  	if err != nil {
   109  		return fmt.Errorf("failed to resolve %s HEAD: %w", ref, err)
   110  	}
   111  	build.Input.GitilesCommit = &buildbucketpb.GitilesCommit{
   112  		Host:    host,
   113  		Project: project,
   114  		Id:      revision,
   115  		Ref:     ref,
   116  	}
   117  	return nil
   118  }
   119  
   120  // CheckoutIntegration checks out the integration.git repo and returns the path
   121  // to the directory containing the checkout.
   122  func CheckoutIntegration(ctx context.Context, build *buildbucketpb.Build) (string, error) {
   123  	// Do a checkout in a temporary directory to avoid checkout conflicts with
   124  	// the recipe's working directory.
   125  	cwd, err := os.Getwd()
   126  	if err != nil {
   127  		return "", err
   128  	}
   129  	integrationDir, err := os.MkdirTemp(cwd, "integration-checkout")
   130  	if err != nil {
   131  		return integrationDir, err
   132  	}
   133  	integrationURL, integrationBaseRevision, err := resolveIntegration(ctx, build)
   134  	if err != nil {
   135  		return integrationDir, err
   136  	}
   137  	if err := props.SetBuildInputProperty(build, "integration_base_revision", integrationBaseRevision); err != nil {
   138  		return integrationDir, err
   139  	}
   140  	tctx, cancel := context.WithTimeout(ctx, rbc.DefaultCheckoutTimeout)
   141  	defer cancel()
   142  	if err := checkout.Checkout(tctx, build.Input, *integrationURL, integrationBaseRevision, integrationDir); err != nil {
   143  		return integrationDir, fmt.Errorf("failed to checkout integration repo: %w", err)
   144  	}
   145  	return integrationDir, nil
   146  }
   147  
   148  // resolveIntegration resolves the integration URL and revision to checkout.
   149  //
   150  // If the `recipes_host_override` property is specified, then override the
   151  // integration host.
   152  //
   153  // If the `recipes_integration_project_override` property is specified, then
   154  // override the integration project.
   155  //
   156  // If the `integration_base_revision` property is set, use this revision.
   157  func resolveIntegration(ctx context.Context, build *buildbucketpb.Build) (*url.URL, string, error) {
   158  	recipesHostOverride, err := props.String(build, "recipes_host_override")
   159  	if err != nil {
   160  		return nil, "", err
   161  	}
   162  	recipesIntegrationProjectOverride, err := props.String(build, "recipes_integration_project_override")
   163  	if err != nil {
   164  		return nil, "", err
   165  	}
   166  	integrationURL, err := rbc.ResolveIntegrationURL(build.Input, recipesHostOverride, recipesIntegrationProjectOverride)
   167  	if err != nil {
   168  		return nil, "", err
   169  	}
   170  
   171  	baseRevisionProperty, err := props.String(build, "integration_base_revision")
   172  	if err != nil {
   173  		return nil, "", err
   174  	}
   175  	if baseRevisionProperty != "" {
   176  		// If `integration_base_revision` is set then we're probably running as
   177  		// a subbuild and `integration_base_revision` is the revision resolved
   178  		// by the parent build, so we should use the same revision.
   179  		return integrationURL, baseRevisionProperty, nil
   180  	}
   181  
   182  	commit := build.Input.GitilesCommit
   183  	if integrationURL.Host == commit.Host && integrationURL.Path == commit.Project {
   184  		// Either the build was triggered on an integration change and we
   185  		// already resolved a base revision with `ensureGitilesCommit`, or the
   186  		// build was triggered by a specific integration commit.  In either case
   187  		// we want to use that resolved revision to ensure we check out the same
   188  		// version of integration as the recipe.
   189  		return integrationURL, commit.Id, nil
   190  	}
   191  
   192  	// Otherwise we haven't yet resolved an integration base revision, so we
   193  	// need to choose the correct integration ref to resolve and then resolve
   194  	// that ref to a revision.
   195  	ref, err := chooseRef(build)
   196  	if err != nil {
   197  		return nil, "", err
   198  	}
   199  
   200  	revision, err := resolveRef(ctx, integrationURL.Host, integrationURL.Path, ref)
   201  	if err != nil {
   202  		return nil, "", fmt.Errorf("failed to resolve integration HEAD: %w", err)
   203  	}
   204  
   205  	return integrationURL, revision, nil
   206  }
   207  
   208  func chooseRef(build *buildbucketpb.Build) (string, error) {
   209  	overrideRef, err := props.String(build, "recipes_integration_ref_override")
   210  	if err != nil {
   211  		return "", err
   212  	}
   213  	var ref string
   214  	if overrideRef != "" {
   215  		ref = overrideRef
   216  	} else if build.Input.GitilesCommit.Ref != "" {
   217  		// This assumes that for every ref `refs/heads/foo` of every repository
   218  		// pinned in integration that we care about submitting changes to, there
   219  		// exists a corresponding ref `refs/heads/foo` in the integration
   220  		// repository.
   221  		ref = build.Input.GitilesCommit.Ref
   222  	} else {
   223  		ref = FallbackRef
   224  	}
   225  
   226  	refMapping := &structpb.Struct{}
   227  	if err := props.Proto(build, "recipes_integration_ref_mapping", refMapping); err != nil {
   228  		return "", err
   229  	}
   230  
   231  	if rawMappedRef, ok := refMapping.AsMap()[ref]; ok {
   232  		if mappedRef, ok := rawMappedRef.(string); ok {
   233  			ref = mappedRef
   234  		} else {
   235  			return "", fmt.Errorf(
   236  				"invalid type for recipes_integration_ref_mapping value: %+v",
   237  				rawMappedRef)
   238  		}
   239  	}
   240  
   241  	return ref, nil
   242  }