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  }