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 }