go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/tryjob/external_id.go (about)

     1  // Copyright 2021 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 tryjob
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strconv"
    21  	"strings"
    22  
    23  	"go.chromium.org/luci/common/clock"
    24  	"go.chromium.org/luci/common/errors"
    25  	"go.chromium.org/luci/common/retry/transient"
    26  	"go.chromium.org/luci/gae/service/datastore"
    27  
    28  	"go.chromium.org/luci/cv/internal/common"
    29  )
    30  
    31  // ExternalID is a unique ID deterministically constructed to identify Tryjobs.
    32  //
    33  // Currently, only Buildbucket is supported.
    34  type ExternalID string
    35  
    36  // BuildbucketID makes an ExternalID for a Buildbucket build.
    37  //
    38  // Host is typically "cr-buildbucket.appspot.com".
    39  // Build is a number, e.g. 8839722009404151168 for
    40  // https://ci.chromium.org/ui/p/infra/builders/try/infra-try-bionic-64/b8839722009404151168/overview
    41  func BuildbucketID(host string, build int64) (ExternalID, error) {
    42  	if strings.ContainsRune(host, '/') {
    43  		return "", errors.Reason("invalid host %q: must not contain /", host).Err()
    44  	}
    45  	return ExternalID(fmt.Sprintf("buildbucket/%s/%d", host, build)), nil
    46  }
    47  
    48  // MustBuildbucketID is like `BuildbucketID()` but panics on error.
    49  func MustBuildbucketID(host string, build int64) ExternalID {
    50  	ret, err := BuildbucketID(host, build)
    51  	if err != nil {
    52  		panic(err)
    53  	}
    54  	return ret
    55  }
    56  
    57  // ParseBuildbucketID returns the Buildbucket host and build if this is a
    58  // BuildbucketID.
    59  func (e ExternalID) ParseBuildbucketID() (host string, build int64, err error) {
    60  	parts := strings.Split(string(e), "/")
    61  	if len(parts) != 3 || parts[0] != "buildbucket" {
    62  		err = errors.Reason("%q is not a valid BuildbucketID", e).Err()
    63  		return
    64  	}
    65  	host = parts[1]
    66  	build, err = strconv.ParseInt(parts[2], 10, 64)
    67  	if err != nil {
    68  		err = errors.Annotate(err, "%q is not a valid BuildbucketID", e).Err()
    69  	}
    70  	return
    71  }
    72  
    73  // MustParseBuildbucketID is like `ParseBuildbucketID` but panics on error
    74  func (e ExternalID) MustParseBuildbucketID() (string, int64) {
    75  	host, build, err := e.ParseBuildbucketID()
    76  	if err != nil {
    77  		panic(err)
    78  	}
    79  	return host, build
    80  }
    81  
    82  // URL returns the Buildbucket URL of the Tryjob.
    83  func (e ExternalID) URL() (string, error) {
    84  	switch kind, err := e.Kind(); {
    85  	case err != nil:
    86  		return "", err
    87  	case kind == "buildbucket":
    88  		host, build, err := e.ParseBuildbucketID()
    89  		if err != nil {
    90  			return "", errors.Annotate(err, "invalid tryjob.ExternalID").Err()
    91  		}
    92  		return fmt.Sprintf("https://%s/build/%d", host, build), nil
    93  	default:
    94  		return "", errors.Reason("unrecognized ExternalID: %q", e).Err()
    95  	}
    96  }
    97  
    98  // MustURL is like `URL()` but panics on err.
    99  func (e ExternalID) MustURL() string {
   100  	ret, err := e.URL()
   101  	if err != nil {
   102  		panic(err)
   103  	}
   104  	return ret
   105  }
   106  
   107  // Kind identifies the backend that corresponds to the tryjob this ExternalID
   108  // applies to.
   109  func (e ExternalID) Kind() (string, error) {
   110  	s := string(e)
   111  	idx := strings.IndexRune(s, '/')
   112  	if idx <= 0 {
   113  		return "", errors.Reason("invalid ExternalID: %q", s).Err()
   114  	}
   115  	return s[:idx], nil
   116  }
   117  
   118  // Load looks up a Tryjob entity.
   119  //
   120  // If an entity referred to by the ExternalID does not exist in CV,
   121  // `nil, nil` will be returned.
   122  func (e ExternalID) Load(ctx context.Context) (*Tryjob, error) {
   123  	tjm := tryjobMap{ExternalID: e}
   124  	switch err := datastore.Get(ctx, &tjm); err {
   125  	case nil:
   126  		break
   127  	case datastore.ErrNoSuchEntity:
   128  		return nil, nil
   129  	default:
   130  		return nil, errors.Annotate(err, "resolving ExternalID %q to a Tryjob", e).Tag(transient.Tag).Err()
   131  	}
   132  
   133  	res := &Tryjob{ID: tjm.InternalID}
   134  	if err := datastore.Get(ctx, res); err != nil {
   135  		// It is unlikely that we'll find a tryjobMap referencing a Tryjob that
   136  		// doesn't exist. And if we do it'll most likely be due to a retention
   137  		// policy removing old entities, so the tryjobMap entity will be
   138  		// removed soon as well.
   139  		return nil, errors.Annotate(err, "retrieving Tryjob with ExternalID %q", e).Tag(transient.Tag).Err()
   140  	}
   141  	return res, nil
   142  }
   143  
   144  // MustLoad is like `Load` but panics on error.
   145  func (e ExternalID) MustLoad(ctx context.Context) *Tryjob {
   146  	tj, err := e.Load(ctx)
   147  	if err != nil {
   148  		panic(err)
   149  	}
   150  	return tj
   151  }
   152  
   153  // MustCreateIfNotExists is intended for testing only.
   154  //
   155  // If a Tryjob with this ExternalID exists, the Tryjob is loaded from
   156  // datastore. If it does not, it is created, saved and returned.
   157  //
   158  // Panics on error.
   159  func (e ExternalID) MustCreateIfNotExists(ctx context.Context) *Tryjob {
   160  	// Quick read-only path.
   161  	if tryjob, err := e.Load(ctx); err == nil && tryjob != nil {
   162  		return tryjob
   163  	}
   164  	// Transaction path.
   165  	var tryjob *Tryjob
   166  	err := datastore.RunInTransaction(ctx, func(ctx context.Context) (err error) {
   167  		tryjob, err = e.Load(ctx)
   168  		switch {
   169  		case err != nil:
   170  			return err
   171  		case tryjob != nil:
   172  			return nil
   173  		}
   174  		now := datastore.RoundTime(clock.Now(ctx).UTC())
   175  		tryjob = &Tryjob{
   176  			ExternalID:       e,
   177  			EVersion:         1,
   178  			EntityCreateTime: now,
   179  			EntityUpdateTime: now,
   180  		}
   181  		if err := datastore.AllocateIDs(ctx, tryjob); err != nil {
   182  			return err
   183  		}
   184  		m := tryjobMap{ExternalID: e, InternalID: tryjob.ID}
   185  		return datastore.Put(ctx, &m, tryjob)
   186  	}, nil)
   187  	if err != nil {
   188  		panic(err)
   189  	}
   190  	return tryjob
   191  }
   192  
   193  // Resolve converts ExternalIDs to internal TryjobIDs.
   194  func Resolve(ctx context.Context, eids ...ExternalID) (common.TryjobIDs, error) {
   195  	tjms := make([]tryjobMap, len(eids))
   196  	for i, eid := range eids {
   197  		tjms[i].ExternalID = eid
   198  	}
   199  
   200  	if errs := datastore.Get(ctx, tjms); errs != nil {
   201  		merr, _ := errs.(errors.MultiError)
   202  		if merr == nil {
   203  			return nil, errors.Annotate(errs, "failed to load tryjobMaps").Tag(transient.Tag).Err()
   204  		}
   205  		for _, err := range merr {
   206  			if err != nil && err != datastore.ErrNoSuchEntity {
   207  				return nil, errors.Annotate(common.MostSevereError(merr), "resolving ExternalIDs").Tag(transient.Tag).Err()
   208  			}
   209  		}
   210  	}
   211  
   212  	ret := make(common.TryjobIDs, len(eids))
   213  	for i, tjm := range tjms {
   214  		ret[i] = tjm.InternalID
   215  	}
   216  	return ret, nil
   217  }
   218  
   219  // MustResolve is like `Resolve` but panics on error
   220  func MustResolve(ctx context.Context, eids ...ExternalID) common.TryjobIDs {
   221  	tryjobIDs, err := Resolve(ctx, eids...)
   222  	if err != nil {
   223  		panic(err)
   224  	}
   225  	return tryjobIDs
   226  }
   227  
   228  // ResolveToTryjobs resolves ExternalIDs to Tryjob entities.
   229  //
   230  // If the external id can't be found inside CV, its corresponding Tryjob
   231  // entity will be nil.
   232  func ResolveToTryjobs(ctx context.Context, eids ...ExternalID) ([]*Tryjob, error) {
   233  	tjids, err := Resolve(ctx, eids...)
   234  	if err != nil {
   235  		return nil, err
   236  	}
   237  	ret := make([]*Tryjob, len(tjids))
   238  	var toLoad []*Tryjob
   239  	for i, id := range tjids {
   240  		if id != 0 {
   241  			ret[i] = &Tryjob{ID: id}
   242  			toLoad = append(toLoad, ret[i])
   243  		}
   244  	}
   245  	if len(toLoad) > 0 {
   246  		if err := datastore.Get(ctx, toLoad); err != nil {
   247  			return nil, errors.Annotate(err, "failed to load tryjobs").Tag(transient.Tag).Err()
   248  		}
   249  	}
   250  	return ret, nil
   251  }