| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "os" |
| "slices" |
| "strings" |
| "time" |
| |
| "github.com/maruel/subcommands" |
| "go.chromium.org/luci/auth" |
| "go.chromium.org/luci/cipd/client/cipd" |
| "go.chromium.org/luci/cipd/common" |
| "go.chromium.org/luci/common/logging" |
| "go.chromium.org/luci/common/logging/gologger" |
| "golang.org/x/exp/maps" |
| |
| "go.fuchsia.dev/infra/flagutil" |
| ) |
| |
| const cipdHost = "https://chrome-infra-packages.appspot.com" |
| |
| // Error strings emitted by CIPD if a ref or tag does not exist on a package. |
| const ( |
| noSuchRefMessage = "no such ref" |
| noSuchTagMessage = "no such tag" |
| ) |
| |
| func cmdResolve(authOpts auth.Options) *subcommands.Command { |
| return &subcommands.Command{ |
| UsageLine: "resolve -ref <ref> -tag <tag> [-flexible-pkg <package1>] [-strict-pkg <packages2>]", |
| ShortDesc: "Resolve common tags among many CIPD packages.", |
| LongDesc: "Resolve common tags among many CIPD packages.", |
| CommandRun: func() subcommands.CommandRun { |
| var c resolveCmd |
| c.Init(authOpts) |
| return &c |
| }, |
| } |
| } |
| |
| type resolveCmd struct { |
| commonFlags |
| |
| ref string |
| tagName string |
| flexiblePackages flagutil.RepeatedStringValue |
| strictPackages flagutil.RepeatedStringValue |
| jsonOutputPath string |
| verbose bool |
| } |
| |
| func (c *resolveCmd) Init(defaultAuthOpts auth.Options) { |
| c.commonFlags.Init(defaultAuthOpts) |
| c.Flags.StringVar(&c.ref, "ref", "latest", "Target ref to resolve.") |
| c.Flags.StringVar(&c.tagName, "tag", "", "Only tags with this name will be considered.") |
| c.Flags.Var(&c.strictPackages, "strict-pkg", "Strict packages which must all be pinned to the ref.") |
| c.Flags.Var(&c.flexiblePackages, "flexible-pkg", "Flexible packages which need not be pinned to the ref.") |
| c.Flags.StringVar(&c.jsonOutputPath, "json-output", "", "Path to dump output to (defaults to stdout).") |
| c.Flags.BoolVar(&c.verbose, "verbose", false, "Enable verbose output.") |
| } |
| |
| func (c *resolveCmd) parseArgs() error { |
| if err := c.commonFlags.Parse(); err != nil { |
| return err |
| } |
| if len(c.flexiblePackages)+len(c.strictPackages) == 0 { |
| return errors.New("at least one of -flexible-pkg or -strict-pkg is required") |
| } |
| if c.ref == "" { |
| return errors.New("-ref is required") |
| } |
| if c.tagName == "" { |
| return errors.New("-tag is required") |
| } |
| return nil |
| } |
| |
| func (c *resolveCmd) Run(a subcommands.Application, _ []string, _ subcommands.Env) int { |
| ctx := context.Background() |
| ctx = gologger.StdConfig.Use(ctx) |
| |
| if err := c.parseArgs(); err != nil { |
| logging.Errorf(ctx, "%s: %s\n", a.GetName(), err) |
| return 1 |
| } |
| |
| level := logging.Error |
| if c.verbose { |
| level = logging.Info |
| } |
| ctx = logging.SetLevel(ctx, level) |
| |
| if err := c.main(ctx); err != nil { |
| logging.Errorf(ctx, "%s: %s\n", a.GetName(), err) |
| return 1 |
| } |
| return 0 |
| } |
| |
| func (c *resolveCmd) main(ctx context.Context) error { |
| authClient, err := auth.NewAuthenticator(ctx, auth.InteractiveLogin, c.parsedAuthOpts).Client() |
| if err != nil { |
| if err == auth.ErrLoginRequired { |
| fmt.Fprintf(os.Stderr, "You need to login first by running:\n") |
| fmt.Fprintf(os.Stderr, " luci-auth login -scopes %q\n", strings.Join(c.parsedAuthOpts.Scopes, " ")) |
| } |
| return err |
| } |
| cipdOpts := cipd.ClientOptions{ |
| ServiceURL: cipdHost, |
| AuthenticatedClient: authClient, |
| } |
| client, err := cipd.NewClient(cipdOpts) |
| if err != nil { |
| return fmt.Errorf("failed to create CIPD client: %w", err) |
| } |
| resolver := cipdResolver{client: client, ref: c.ref, tagName: c.tagName} |
| tags, err := resolver.resolve(ctx, c.strictPackages, c.flexiblePackages) |
| if err != nil { |
| return err |
| } |
| |
| outWriter := os.Stdout |
| if c.jsonOutputPath != "" { |
| outWriter, err = os.Create(c.jsonOutputPath) |
| if err != nil { |
| return fmt.Errorf("failed to open -output-json file: %w", err) |
| } |
| defer outWriter.Close() |
| } |
| enc := json.NewEncoder(outWriter) |
| enc.SetIndent("", " ") |
| if tags == nil { |
| // Emit an empty JSON array instead of null. |
| tags = []string{} |
| } |
| return enc.Encode(tags) |
| } |
| |
| type cipdClient interface { |
| ResolveVersion(context.Context, string, string) (common.Pin, error) |
| DescribeInstance(context.Context, common.Pin, *cipd.DescribeInstanceOpts) (*cipd.InstanceDescription, error) |
| } |
| |
| type cacheKey struct { |
| pkg string |
| version string |
| } |
| |
| type cipdResolver struct { |
| client cipdClient |
| ref string |
| tagName string |
| cache map[cacheKey]*cipd.InstanceDescription |
| } |
| |
| // resolve finds common tags to which a set of CIPD packages can be pinned to |
| // ensure mutual interoperability. |
| // |
| // Given a set of CIPD package names, it resolves a set of tags T such that: |
| // 1. For every package in `strictPackages`, there exists an instance I, with |
| // all the tags in T, which *at some point* in had `ref` attached to it (we |
| // can't guarantee that it *currently* has `ref` attached because the ref may |
| // be moved to a different instance at any moment). |
| // 2. For each package P in `flexiblePackages`, there exists an instance I that has |
| // all the tags in T, although `ref` may not currently point to I or exist on |
| // any instance of P. If `strictPackages` is empty, then at least one package |
| // in `flexiblePackages` (the "anchor" package) will have an instance with |
| // all the tags in T and having had `ref` attached at some point. |
| // 3. T uniquely identifies a set of package instances; i.e. each tag in T |
| // points to the same instance for each package. This is important so that a |
| // roller calling this tool can check to see if the currently pinned version |
| // of a set of packages is up-to-date just by checking to see if it's present |
| // in the set of resolved tags. |
| // 4. T is the newest of all such sets of tags (as determined by the timestamps |
| // at which the tags are registered), which ensures that a roller using this |
| // tool will always roll to the latest possible version of a set of packages, |
| // and won't stall on an older version. |
| // |
| // resolve returns a slice of tags corresponding to T, sorted in approximately |
| // chronological order (oldest first). A client may safely pin all the packages |
| // to any of the returned tags, but it's strongly recommended to use only the |
| // oldest tag to avoid no-op rolls as new tags are attached to existing |
| // instances. |
| // |
| // Note that the resolution logic assumes that tags are immutable, which is true |
| // by convention but not guaranteed by CIPD's backend - a tag can be detached |
| // from one instance and reattached to another instance, or applied to two |
| // instances concurrently (in which case CIPD will not be able to resolve the |
| // tag). |
| func (cr *cipdResolver) resolve(ctx context.Context, strictPackages, flexiblePackages []string) ([]string, error) { |
| for _, sp := range strictPackages { |
| if slices.Contains(flexiblePackages, sp) { |
| return nil, fmt.Errorf("package %q cannot be both strict and flexible", sp) |
| } |
| } |
| |
| if len(strictPackages) > 0 { |
| strictCommonTags := newTagSet(nil) |
| for _, pkg := range strictPackages { |
| tags, exists, err := cr.listTags(ctx, pkg, cr.ref) |
| if err != nil { |
| return nil, err |
| } |
| if !exists { |
| return nil, fmt.Errorf("strict package %q does not have the %q ref", pkg, cr.ref) |
| } |
| |
| if len(strictCommonTags) == 0 { |
| // Initialize. This is necessary because unioning a set with an |
| // empty set always produces an empty set. |
| strictCommonTags = tags |
| } else { |
| strictCommonTags = strictCommonTags.intersection(tags) |
| } |
| |
| if len(strictCommonTags) == 0 { |
| return nil, fmt.Errorf("strict packages have no common tags") |
| } |
| } |
| |
| if len(flexiblePackages) == 0 { |
| return strictCommonTags.keys(), nil |
| } |
| |
| commonTags, err := cr.filterCommonTags(ctx, flexiblePackages, strictCommonTags) |
| if err != nil { |
| return nil, err |
| } |
| |
| if len(commonTags) == 0 { |
| return nil, fmt.Errorf( |
| "failed to find common tags; none of the strict packages "+ |
| "with the %q ref is currently pinned to a version that is "+ |
| "available for all packages", cr.ref) |
| } |
| |
| return commonTags.keys(), nil |
| } |
| |
| // Keep track of whether any package has the ref attached so we can report |
| // an error if none of them has it attached. |
| onePackageHasRef := false |
| |
| // The tags that we've checked as potential candidates. |
| triedTags := newTagSet(nil) |
| |
| // If there are no strict packages then we need to choose one of the |
| // flexible packages as the "anchor" to resolve the set of tags that are |
| // currently associated with the ref. |
| for i, anchorPkg := range flexiblePackages { |
| baseCommonTags, exists, err := cr.listTags(ctx, anchorPkg, cr.ref) |
| if err != nil { |
| return nil, err |
| } |
| if !exists { |
| continue |
| } |
| onePackageHasRef = true |
| |
| otherPackages := append([]string{}, flexiblePackages[:i]...) |
| otherPackages = append(otherPackages, flexiblePackages[i+1:]...) |
| |
| // Filter out tags that we've tried already. |
| baseCommonTags = baseCommonTags.difference(triedTags) |
| |
| triedTags = triedTags.union(baseCommonTags) |
| |
| commonTags, err := cr.filterCommonTags(ctx, otherPackages, baseCommonTags) |
| if err != nil { |
| return nil, err |
| } |
| if len(commonTags) > 0 { |
| return commonTags.keys(), nil |
| } |
| } |
| |
| if !onePackageHasRef { |
| return nil, fmt.Errorf("none of the packages has the %q ref", cr.ref) |
| } |
| |
| return nil, fmt.Errorf( |
| "none of the versions with the %q ref is currently available for all packages", cr.ref) |
| } |
| |
| // filterCommonTags returns a subset S of candidateTags for which there exists |
| // an instance I of each package where I is tagged with every tag in S. |
| func (cr *cipdResolver) filterCommonTags(ctx context.Context, pkgs []string, candidateTags tagSet) (tagSet, error) { |
| allTags := maps.Values(candidateTags) |
| slices.SortFunc(allTags, func(a, b cipd.TagInfo) int { |
| // Sort tags in reverse chronological order (newest first) to try to |
| // select the most up-to-date set of instances possible. This is only a |
| // best effort because tags may be registered out of order, so there's |
| // no guarantee that the timestamp ordering corresponds to the semantic |
| // version ordering. |
| if c := unixTimeCmp(b.RegisteredTs, a.RegisteredTs); c != 0 { |
| return c |
| } |
| // Fall back to sorting by tag name if the timestamps are equal to |
| // ensure determinism. |
| return strings.Compare(a.Tag, b.Tag) |
| }) |
| |
| for _, tagInfo := range allTags { |
| commonTags := candidateTags.copy() |
| for _, pkg := range pkgs { |
| tags, _, err := cr.listTags(ctx, pkg, tagInfo.Tag) |
| if err != nil { |
| return nil, err |
| } |
| commonTags = commonTags.intersection(tags) |
| if len(commonTags) == 0 { |
| logging.Infof(ctx, "Rejected candidate tag %q; missing for package %q", tagInfo.Tag, pkg) |
| break |
| } |
| } |
| |
| if len(commonTags) > 0 { |
| return commonTags, nil |
| } |
| } |
| return nil, nil |
| } |
| |
| // listTags returns a set of tags associated with the specified version (ref or |
| // tag) of a package. The boolean return value indicates whether an instance |
| // with that version exists, and it's up to the caller to decide whether that |
| // should be considered an error. |
| func (cr *cipdResolver) listTags(ctx context.Context, pkg, version string) (res tagSet, exists bool, err error) { |
| if cr.cache == nil { |
| cr.cache = make(map[cacheKey]*cipd.InstanceDescription) |
| } |
| inst, ok := cr.cache[cacheKey{pkg: pkg, version: version}] |
| if !ok { |
| pin, err := cr.client.ResolveVersion(ctx, pkg, version) |
| if err != nil { |
| if strings.Contains(err.Error(), noSuchRefMessage) || strings.Contains(err.Error(), noSuchTagMessage) { |
| return nil, false, nil |
| } |
| return nil, false, fmt.Errorf("could not resolve %s@%s: %w", pkg, version, err) |
| } |
| |
| opts := &cipd.DescribeInstanceOpts{DescribeTags: true, DescribeRefs: true} |
| inst, err = cr.client.DescribeInstance(ctx, pin, opts) |
| if err != nil { |
| return nil, true, err |
| } |
| |
| // Populate the cache with all of this instance's refs and tags so that |
| // we'll get cache hits even for different tags that point to the same |
| // instance. |
| for _, ref := range inst.Refs { |
| cr.cache[cacheKey{pkg: pkg, version: ref.Ref}] = inst |
| } |
| for _, tag := range inst.Tags { |
| cr.cache[cacheKey{pkg: pkg, version: tag.Tag}] = inst |
| } |
| } |
| |
| // Ignore tags that have a name other than `tagName`. |
| var filtered []cipd.TagInfo |
| for _, t := range inst.Tags { |
| if strings.Split(t.Tag, ":")[0] == cr.tagName { |
| filtered = append(filtered, t) |
| } |
| } |
| return newTagSet(filtered), true, nil |
| } |
| |
| // tagSet is a helper type for tracking and merging sets of CIPD tags. |
| type tagSet map[string]cipd.TagInfo |
| |
| func newTagSet(tags []cipd.TagInfo) tagSet { |
| ss := tagSet{} |
| for _, t := range tags { |
| ss.add(t) |
| } |
| return ss |
| } |
| |
| // union returns a new tagSet that contains every tag in `ts` or in |
| // `other`. |
| func (ts tagSet) union(other tagSet) tagSet { |
| res := ts.copy() |
| for _, v := range other { |
| res.add(v) |
| } |
| return res |
| } |
| |
| // intersection returns a new tagSet that contains only the tags shared by both |
| // `ts` and `other`. |
| func (ts tagSet) intersection(other tagSet) tagSet { |
| res := newTagSet(nil) |
| for k, v := range ts { |
| if other.contains(k) { |
| res.add(v) |
| } |
| } |
| return res |
| } |
| |
| // difference returns a new tagSet that contains the tags that are present in |
| // `ts` but not in `other`. |
| func (ts tagSet) difference(other tagSet) tagSet { |
| res := newTagSet(nil) |
| for k, v := range ts { |
| if !other.contains(k) { |
| res.add(v) |
| } |
| } |
| return res |
| } |
| |
| func (ts tagSet) copy() tagSet { |
| res := newTagSet(nil) |
| for k, v := range ts { |
| res[k] = v |
| } |
| return res |
| } |
| |
| func (ts tagSet) add(t cipd.TagInfo) { |
| ts[t.Tag] = t |
| } |
| |
| func (ts tagSet) contains(t string) bool { |
| _, ok := ts[t] |
| return ok |
| } |
| |
| // keys returns the names of all tags in the set, sorted by age (oldest first). |
| func (ts tagSet) keys() []string { |
| tags := maps.Values(ts) |
| slices.SortFunc(tags, func(a, b cipd.TagInfo) int { |
| if c := unixTimeCmp(a.RegisteredTs, b.RegisteredTs); c != 0 { |
| return c |
| } |
| // Fall back to sorting by tag name if the timestamps are equal to |
| // ensure determinism. |
| return strings.Compare(a.Tag, b.Tag) |
| }) |
| var res []string |
| for _, t := range tags { |
| res = append(res, t.Tag) |
| } |
| return res |
| } |
| |
| func unixTimeCmp(a, b cipd.UnixTime) int { |
| at, bt := time.Time(a), time.Time(b) |
| return at.Compare(bt) |
| } |