go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/cipd-resolver/resolve.go (about) 1 // Copyright 2022 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 main 6 7 import ( 8 "context" 9 "encoding/json" 10 "errors" 11 "fmt" 12 "os" 13 "slices" 14 "strings" 15 "time" 16 17 "github.com/maruel/subcommands" 18 "go.chromium.org/luci/auth" 19 "go.chromium.org/luci/cipd/client/cipd" 20 "go.chromium.org/luci/cipd/common" 21 "go.chromium.org/luci/common/logging" 22 "go.chromium.org/luci/common/logging/gologger" 23 "golang.org/x/exp/maps" 24 25 "go.fuchsia.dev/infra/flagutil" 26 ) 27 28 const cipdHost = "https://chrome-infra-packages.appspot.com" 29 30 // Error strings emitted by CIPD if a ref or tag does not exist on a package. 31 const ( 32 noSuchRefMessage = "no such ref" 33 noSuchTagMessage = "no such tag" 34 ) 35 36 func cmdResolve(authOpts auth.Options) *subcommands.Command { 37 return &subcommands.Command{ 38 UsageLine: "resolve -ref <ref> -tag <tag> [-flexible-pkg <package1>] [-strict-pkg <packages2>]", 39 ShortDesc: "Resolve common tags among many CIPD packages.", 40 LongDesc: "Resolve common tags among many CIPD packages.", 41 CommandRun: func() subcommands.CommandRun { 42 var c resolveCmd 43 c.Init(authOpts) 44 return &c 45 }, 46 } 47 } 48 49 type resolveCmd struct { 50 commonFlags 51 52 ref string 53 tagName string 54 flexiblePackages flagutil.RepeatedStringValue 55 strictPackages flagutil.RepeatedStringValue 56 jsonOutputPath string 57 verbose bool 58 } 59 60 func (c *resolveCmd) Init(defaultAuthOpts auth.Options) { 61 c.commonFlags.Init(defaultAuthOpts) 62 c.Flags.StringVar(&c.ref, "ref", "latest", "Target ref to resolve.") 63 c.Flags.StringVar(&c.tagName, "tag", "", "Only tags with this name will be considered.") 64 c.Flags.Var(&c.strictPackages, "strict-pkg", "Strict packages which must all be pinned to the ref.") 65 c.Flags.Var(&c.flexiblePackages, "flexible-pkg", "Flexible packages which need not be pinned to the ref.") 66 c.Flags.StringVar(&c.jsonOutputPath, "json-output", "", "Path to dump output to (defaults to stdout).") 67 c.Flags.BoolVar(&c.verbose, "verbose", false, "Enable verbose output.") 68 } 69 70 func (c *resolveCmd) parseArgs() error { 71 if err := c.commonFlags.Parse(); err != nil { 72 return err 73 } 74 if len(c.flexiblePackages)+len(c.strictPackages) == 0 { 75 return errors.New("at least one of -flexible-pkg or -strict-pkg is required") 76 } 77 if c.ref == "" { 78 return errors.New("-ref is required") 79 } 80 if c.tagName == "" { 81 return errors.New("-tag is required") 82 } 83 return nil 84 } 85 86 func (c *resolveCmd) Run(a subcommands.Application, _ []string, _ subcommands.Env) int { 87 ctx := context.Background() 88 ctx = gologger.StdConfig.Use(ctx) 89 90 if err := c.parseArgs(); err != nil { 91 logging.Errorf(ctx, "%s: %s\n", a.GetName(), err) 92 return 1 93 } 94 95 level := logging.Error 96 if c.verbose { 97 level = logging.Info 98 } 99 ctx = logging.SetLevel(ctx, level) 100 101 if err := c.main(ctx); err != nil { 102 logging.Errorf(ctx, "%s: %s\n", a.GetName(), err) 103 return 1 104 } 105 return 0 106 } 107 108 func (c *resolveCmd) main(ctx context.Context) error { 109 authClient, err := auth.NewAuthenticator(ctx, auth.InteractiveLogin, c.parsedAuthOpts).Client() 110 if err != nil { 111 if err == auth.ErrLoginRequired { 112 fmt.Fprintf(os.Stderr, "You need to login first by running:\n") 113 fmt.Fprintf(os.Stderr, " luci-auth login -scopes %q\n", strings.Join(c.parsedAuthOpts.Scopes, " ")) 114 } 115 return err 116 } 117 cipdOpts := cipd.ClientOptions{ 118 ServiceURL: cipdHost, 119 AuthenticatedClient: authClient, 120 } 121 client, err := cipd.NewClient(cipdOpts) 122 if err != nil { 123 return fmt.Errorf("failed to create CIPD client: %w", err) 124 } 125 resolver := cipdResolver{client: client, ref: c.ref, tagName: c.tagName} 126 tags, err := resolver.resolve(ctx, c.strictPackages, c.flexiblePackages) 127 if err != nil { 128 return err 129 } 130 131 outWriter := os.Stdout 132 if c.jsonOutputPath != "" { 133 outWriter, err = os.Create(c.jsonOutputPath) 134 if err != nil { 135 return fmt.Errorf("failed to open -output-json file: %w", err) 136 } 137 defer outWriter.Close() 138 } 139 enc := json.NewEncoder(outWriter) 140 enc.SetIndent("", " ") 141 if tags == nil { 142 // Emit an empty JSON array instead of null. 143 tags = []string{} 144 } 145 return enc.Encode(tags) 146 } 147 148 type cipdClient interface { 149 ResolveVersion(context.Context, string, string) (common.Pin, error) 150 DescribeInstance(context.Context, common.Pin, *cipd.DescribeInstanceOpts) (*cipd.InstanceDescription, error) 151 } 152 153 type cacheKey struct { 154 pkg string 155 version string 156 } 157 158 type cipdResolver struct { 159 client cipdClient 160 ref string 161 tagName string 162 cache map[cacheKey]*cipd.InstanceDescription 163 } 164 165 // resolve finds common tags to which a set of CIPD packages can be pinned to 166 // ensure mutual interoperability. 167 // 168 // Given a set of CIPD package names, it resolves a set of tags T such that: 169 // 1. For every package in `strictPackages`, there exists an instance I, with 170 // all the tags in T, which *at some point* in had `ref` attached to it (we 171 // can't guarantee that it *currently* has `ref` attached because the ref may 172 // be moved to a different instance at any moment). 173 // 2. For each package P in `flexiblePackages`, there exists an instance I that has 174 // all the tags in T, although `ref` may not currently point to I or exist on 175 // any instance of P. If `strictPackages` is empty, then at least one package 176 // in `flexiblePackages` (the "anchor" package) will have an instance with 177 // all the tags in T and having had `ref` attached at some point. 178 // 3. T uniquely identifies a set of package instances; i.e. each tag in T 179 // points to the same instance for each package. This is important so that a 180 // roller calling this tool can check to see if the currently pinned version 181 // of a set of packages is up-to-date just by checking to see if it's present 182 // in the set of resolved tags. 183 // 4. T is the newest of all such sets of tags (as determined by the timestamps 184 // at which the tags are registered), which ensures that a roller using this 185 // tool will always roll to the latest possible version of a set of packages, 186 // and won't stall on an older version. 187 // 188 // resolve returns a slice of tags corresponding to T, sorted in approximately 189 // chronological order (oldest first). A client may safely pin all the packages 190 // to any of the returned tags, but it's strongly recommended to use only the 191 // oldest tag to avoid no-op rolls as new tags are attached to existing 192 // instances. 193 // 194 // Note that the resolution logic assumes that tags are immutable, which is true 195 // by convention but not guaranteed by CIPD's backend - a tag can be detached 196 // from one instance and reattached to another instance, or applied to two 197 // instances concurrently (in which case CIPD will not be able to resolve the 198 // tag). 199 func (cr *cipdResolver) resolve(ctx context.Context, strictPackages, flexiblePackages []string) ([]string, error) { 200 for _, sp := range strictPackages { 201 if slices.Contains(flexiblePackages, sp) { 202 return nil, fmt.Errorf("package %q cannot be both strict and flexible", sp) 203 } 204 } 205 206 if len(strictPackages) > 0 { 207 strictCommonTags := newTagSet(nil) 208 for _, pkg := range strictPackages { 209 tags, exists, err := cr.listTags(ctx, pkg, cr.ref) 210 if err != nil { 211 return nil, err 212 } 213 if !exists { 214 return nil, fmt.Errorf("strict package %q does not have the %q ref", pkg, cr.ref) 215 } 216 217 if len(strictCommonTags) == 0 { 218 // Initialize. This is necessary because unioning a set with an 219 // empty set always produces an empty set. 220 strictCommonTags = tags 221 } else { 222 strictCommonTags = strictCommonTags.intersection(tags) 223 } 224 225 if len(strictCommonTags) == 0 { 226 return nil, fmt.Errorf("strict packages have no common tags") 227 } 228 } 229 230 if len(flexiblePackages) == 0 { 231 return strictCommonTags.keys(), nil 232 } 233 234 commonTags, err := cr.filterCommonTags(ctx, flexiblePackages, strictCommonTags) 235 if err != nil { 236 return nil, err 237 } 238 239 if len(commonTags) == 0 { 240 return nil, fmt.Errorf( 241 "failed to find common tags; none of the strict packages "+ 242 "with the %q ref is currently pinned to a version that is "+ 243 "available for all packages", cr.ref) 244 } 245 246 return commonTags.keys(), nil 247 } 248 249 // Keep track of whether any package has the ref attached so we can report 250 // an error if none of them has it attached. 251 onePackageHasRef := false 252 253 // The tags that we've checked as potential candidates. 254 triedTags := newTagSet(nil) 255 256 // If there are no strict packages then we need to choose one of the 257 // flexible packages as the "anchor" to resolve the set of tags that are 258 // currently associated with the ref. 259 for i, anchorPkg := range flexiblePackages { 260 baseCommonTags, exists, err := cr.listTags(ctx, anchorPkg, cr.ref) 261 if err != nil { 262 return nil, err 263 } 264 if !exists { 265 continue 266 } 267 onePackageHasRef = true 268 269 otherPackages := append([]string{}, flexiblePackages[:i]...) 270 otherPackages = append(otherPackages, flexiblePackages[i+1:]...) 271 272 // Filter out tags that we've tried already. 273 baseCommonTags = baseCommonTags.difference(triedTags) 274 275 triedTags = triedTags.union(baseCommonTags) 276 277 commonTags, err := cr.filterCommonTags(ctx, otherPackages, baseCommonTags) 278 if err != nil { 279 return nil, err 280 } 281 if len(commonTags) > 0 { 282 return commonTags.keys(), nil 283 } 284 } 285 286 if !onePackageHasRef { 287 return nil, fmt.Errorf("none of the packages has the %q ref", cr.ref) 288 } 289 290 return nil, fmt.Errorf( 291 "none of the versions with the %q ref is currently available for all packages", cr.ref) 292 } 293 294 // filterCommonTags returns a subset S of candidateTags for which there exists 295 // an instance I of each package where I is tagged with every tag in S. 296 func (cr *cipdResolver) filterCommonTags(ctx context.Context, pkgs []string, candidateTags tagSet) (tagSet, error) { 297 allTags := maps.Values(candidateTags) 298 // Sort tags in reverse chronological order (newest first) to make sure we 299 // select the newest set of instances possible. This is only a best effort 300 // because tags may be registered out of order, so there's no guarantee that 301 // the timestamp ordering corresponds to the version ordering. 302 slices.SortFunc(allTags, func(a, b cipd.TagInfo) int { 303 return unixTimeCmp(b.RegisteredTs, a.RegisteredTs) 304 }) 305 306 for _, tagInfo := range allTags { 307 commonTags := candidateTags.copy() 308 for _, pkg := range pkgs { 309 tags, _, err := cr.listTags(ctx, pkg, tagInfo.Tag) 310 if err != nil { 311 return nil, err 312 } 313 commonTags = commonTags.intersection(tags) 314 if len(commonTags) == 0 { 315 logging.Infof(ctx, "Rejected candidate tag %q; missing for package %q", tagInfo.Tag, pkg) 316 break 317 } 318 } 319 320 if len(commonTags) > 0 { 321 return commonTags, nil 322 } 323 } 324 return nil, nil 325 } 326 327 // listTags returns a set of tags associated with the specified version (ref or 328 // tag) of a package. The boolean return value indicates whether an instance 329 // with that version exists, and it's up to the caller to decide whether that 330 // should be considered an error. 331 func (cr *cipdResolver) listTags(ctx context.Context, pkg, version string) (res tagSet, exists bool, err error) { 332 if cr.cache == nil { 333 cr.cache = make(map[cacheKey]*cipd.InstanceDescription) 334 } 335 inst, ok := cr.cache[cacheKey{pkg: pkg, version: version}] 336 if !ok { 337 pin, err := cr.client.ResolveVersion(ctx, pkg, version) 338 if err != nil { 339 if strings.Contains(err.Error(), noSuchRefMessage) || strings.Contains(err.Error(), noSuchTagMessage) { 340 return nil, false, nil 341 } 342 return nil, false, fmt.Errorf("could not resolve %s@%s: %w", pkg, version, err) 343 } 344 345 opts := &cipd.DescribeInstanceOpts{DescribeTags: true, DescribeRefs: true} 346 inst, err = cr.client.DescribeInstance(ctx, pin, opts) 347 if err != nil { 348 return nil, true, err 349 } 350 351 // Populate the cache with all of this instance's refs and tags so that 352 // we'll get cache hits even for different tags that point to the same 353 // instance. 354 for _, ref := range inst.Refs { 355 cr.cache[cacheKey{pkg: pkg, version: ref.Ref}] = inst 356 } 357 for _, tag := range inst.Tags { 358 cr.cache[cacheKey{pkg: pkg, version: tag.Tag}] = inst 359 } 360 } 361 362 // Ignore tags that have a name other than `tagName`. 363 var filtered []cipd.TagInfo 364 for _, t := range inst.Tags { 365 if strings.Split(t.Tag, ":")[0] == cr.tagName { 366 filtered = append(filtered, t) 367 } 368 } 369 return newTagSet(filtered), true, nil 370 } 371 372 // tagSet is a helper type for tracking and merging sets of CIPD tags. 373 type tagSet map[string]cipd.TagInfo 374 375 func newTagSet(tags []cipd.TagInfo) tagSet { 376 ss := tagSet{} 377 for _, t := range tags { 378 ss.add(t) 379 } 380 return ss 381 } 382 383 // union returns a new tagSet that contains every tag in `ts` or in 384 // `other`. 385 func (ts tagSet) union(other tagSet) tagSet { 386 res := ts.copy() 387 for _, v := range other { 388 res.add(v) 389 } 390 return res 391 } 392 393 // intersection returns a new tagSet that contains only the tags shared by both 394 // `ts` and `other`. 395 func (ts tagSet) intersection(other tagSet) tagSet { 396 res := newTagSet(nil) 397 for k, v := range ts { 398 if other.contains(k) { 399 res.add(v) 400 } 401 } 402 return res 403 } 404 405 // difference returns a new tagSet that contains the tags that are present in 406 // `ts` but not in `other`. 407 func (ts tagSet) difference(other tagSet) tagSet { 408 res := newTagSet(nil) 409 for k, v := range ts { 410 if !other.contains(k) { 411 res.add(v) 412 } 413 } 414 return res 415 } 416 417 func (ts tagSet) copy() tagSet { 418 res := newTagSet(nil) 419 for k, v := range ts { 420 res[k] = v 421 } 422 return res 423 } 424 425 func (ts tagSet) add(t cipd.TagInfo) { 426 ts[t.Tag] = t 427 } 428 429 func (ts tagSet) contains(t string) bool { 430 _, ok := ts[t] 431 return ok 432 } 433 434 // keys returns the names of all tags in the set, sorted by age (oldest first). 435 func (ts tagSet) keys() []string { 436 tags := maps.Values(ts) 437 slices.SortFunc(tags, func(a, b cipd.TagInfo) int { 438 return unixTimeCmp(a.RegisteredTs, b.RegisteredTs) 439 }) 440 var res []string 441 for _, t := range tags { 442 res = append(res, t.Tag) 443 } 444 return res 445 } 446 447 func unixTimeCmp(a, b cipd.UnixTime) int { 448 at, bt := time.Time(a), time.Time(b) 449 return at.Compare(bt) 450 }