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 }