go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/cipd-resolver/resolve_test.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  	"fmt"
    10  	"math/rand"
    11  	"strconv"
    12  	"strings"
    13  	"testing"
    14  	"time"
    15  
    16  	"github.com/google/go-cmp/cmp"
    17  	"go.chromium.org/luci/cipd/client/cipd"
    18  	"go.chromium.org/luci/cipd/common"
    19  )
    20  
    21  type fakeInstance struct {
    22  	// Multiple refs are allowed but we only care about testing one at a time.
    23  	ref  string
    24  	tags []string
    25  }
    26  
    27  func TestResolver(t *testing.T) {
    28  	t.Parallel()
    29  
    30  	tests := []struct {
    31  		name     string
    32  		strict   []string
    33  		flexible []string
    34  		// Mock contents of CIPD's database.
    35  		db      map[string][]fakeInstance
    36  		want    []string
    37  		wantErr string
    38  	}{
    39  		{
    40  			name:     "single package",
    41  			flexible: []string{"foo"},
    42  			db: map[string][]fakeInstance{
    43  				"foo": {
    44  					{
    45  						ref:  "latest",
    46  						tags: []string{"version:0.9", "version:1.0"},
    47  					},
    48  				},
    49  			},
    50  			want: []string{"version:0.9", "version:1.0"},
    51  		},
    52  		{
    53  			name:   "single strict package",
    54  			strict: []string{"foo"},
    55  			db: map[string][]fakeInstance{
    56  				"foo": {
    57  					{
    58  						ref:  "latest",
    59  						tags: []string{"version:0.9", "version:1.0"},
    60  					},
    61  				},
    62  			},
    63  			want: []string{"version:0.9", "version:1.0"},
    64  		},
    65  		{
    66  			name:     "multiple flexible packages",
    67  			flexible: []string{"foo", "bar"},
    68  			db: map[string][]fakeInstance{
    69  				"foo": {
    70  					{
    71  						ref:  "latest",
    72  						tags: []string{"version:0.9", "version:1.0"},
    73  					},
    74  				},
    75  				"bar": {
    76  					{
    77  						ref:  "latest",
    78  						tags: []string{"version:0.8", "version:1.0"},
    79  					},
    80  				},
    81  			},
    82  			want: []string{"version:1.0"},
    83  		},
    84  		{
    85  			name:     "flexible packages out of sync",
    86  			flexible: []string{"foo", "bar"},
    87  			db: map[string][]fakeInstance{
    88  				"foo": {
    89  					{
    90  						ref:  "latest",
    91  						tags: []string{"version:1.0"},
    92  					},
    93  					{
    94  						tags: []string{"version:0.9"},
    95  					},
    96  				},
    97  				"bar": {
    98  					{
    99  						ref:  "latest",
   100  						tags: []string{"version:0.9"},
   101  					},
   102  				},
   103  			},
   104  			want: []string{"version:0.9"},
   105  		},
   106  		{
   107  			// The resolver should refuse to fall back to instances of strict
   108  			// packages that don't have the ref attached.
   109  			name:   "strict packages out of sync",
   110  			strict: []string{"foo", "bar"},
   111  			db: map[string][]fakeInstance{
   112  				"foo": {
   113  					{
   114  						ref:  "latest",
   115  						tags: []string{"version:1.0"},
   116  					},
   117  					{
   118  						tags: []string{"version:0.9"},
   119  					},
   120  				},
   121  				"bar": {
   122  					{
   123  						ref:  "latest",
   124  						tags: []string{"version:0.9"},
   125  					},
   126  				},
   127  			},
   128  			wantErr: "strict packages have no common tags",
   129  		},
   130  		{
   131  			name:     "mix of strict and flexible packages",
   132  			strict:   []string{"foo"},
   133  			flexible: []string{"bar"},
   134  			db: map[string][]fakeInstance{
   135  				"foo": {
   136  					{
   137  						ref:  "latest",
   138  						tags: []string{"version:0.9"},
   139  					},
   140  				},
   141  				"bar": {
   142  					{
   143  						ref:  "latest",
   144  						tags: []string{"version:1.0"},
   145  					},
   146  					{
   147  						tags: []string{"version:0.9"},
   148  					},
   149  				},
   150  			},
   151  			want: []string{"version:0.9"},
   152  		},
   153  		{
   154  			// It's okay if the ref doesn't exist on any of the flexible
   155  			// packages as long as it can be found on all the strict packages.
   156  			name:     "no flexible package has ref",
   157  			strict:   []string{"foo"},
   158  			flexible: []string{"bar", "baz"},
   159  			db: map[string][]fakeInstance{
   160  				"foo": {
   161  					{
   162  						ref:  "latest",
   163  						tags: []string{"version:0.9"},
   164  					},
   165  				},
   166  				"bar": {
   167  					{
   168  						tags: []string{"version:0.9"},
   169  					},
   170  				},
   171  				"baz": {
   172  					{
   173  						tags: []string{"version:0.9"},
   174  					},
   175  				},
   176  			},
   177  			want: []string{"version:0.9"},
   178  		},
   179  		{
   180  			// We should backtrack as far as necessary until we find a suitable
   181  			// tag; in this case we have to backtrack by two versions.
   182  			name:     "varying states of outdatedness",
   183  			flexible: []string{"foo", "bar", "baz"},
   184  			db: map[string][]fakeInstance{
   185  				"foo": {
   186  					{
   187  						ref:  "latest",
   188  						tags: []string{"version:1.0"},
   189  					},
   190  					{
   191  						tags: []string{"version:0.9"},
   192  					},
   193  					{
   194  						tags: []string{"version:0.8"},
   195  					},
   196  				},
   197  				"bar": {
   198  					{
   199  						ref:  "latest",
   200  						tags: []string{"version:0.9"},
   201  					},
   202  					{
   203  						tags: []string{"version:0.8"},
   204  					},
   205  				},
   206  				"baz": {
   207  					{
   208  						ref:  "latest",
   209  						tags: []string{"version:0.8"},
   210  					},
   211  				},
   212  			},
   213  			want: []string{"version:0.8"},
   214  		},
   215  		{
   216  			// If no package currently has the ref attached to any instance,
   217  			// then we should return an empty set of version tags because it's
   218  			// impossible to tell which versions are valid.
   219  			name:     "no package has ref",
   220  			flexible: []string{"foo", "bar"},
   221  			db: map[string][]fakeInstance{
   222  				"foo": {
   223  					{
   224  						tags: []string{"version:0.8"},
   225  					},
   226  				},
   227  				"bar": {
   228  					{
   229  						tags: []string{"version:0.8"},
   230  					},
   231  				},
   232  			},
   233  			wantErr: `none of the packages has the "latest" ref`,
   234  		},
   235  		{
   236  			name:     "no common tags",
   237  			flexible: []string{"foo", "bar"},
   238  			db: map[string][]fakeInstance{
   239  				"foo": {
   240  					{
   241  						ref:  "latest",
   242  						tags: []string{"version:1.0"},
   243  					},
   244  				},
   245  				"bar": {
   246  					{
   247  						ref:  "latest",
   248  						tags: []string{"version:0.9"},
   249  					},
   250  				},
   251  			},
   252  			wantErr: `none of the versions with the "latest" ref is currently available for all packages`,
   253  		},
   254  		{
   255  			name:     "no common tags with one strict package",
   256  			strict:   []string{"foo"},
   257  			flexible: []string{"bar"},
   258  			db: map[string][]fakeInstance{
   259  				"foo": {
   260  					{
   261  						ref:  "latest",
   262  						tags: []string{"version:1.0"},
   263  					},
   264  				},
   265  				"bar": {
   266  					{
   267  						ref:  "latest",
   268  						tags: []string{"version:0.9"},
   269  					},
   270  				},
   271  			},
   272  			wantErr: "failed to find common tags",
   273  		},
   274  		{
   275  			// Either version:0.9 or version:1.0 is a valid selection, but they
   276  			// point to different instances for one of the packages, so we
   277  			// should prefer the newer tag.
   278  			name:     "always selects newest possible version",
   279  			flexible: []string{"bar", "foo"},
   280  			db: map[string][]fakeInstance{
   281  				"bar": {
   282  					// Latest version of "bar" is pinned to a version that
   283  					// doesn't exist for "foo", so "foo" must be chosen as the
   284  					// anchor package. This triggers the condition we care
   285  					// about, where version:1.0 is chosen even when "foo" is the
   286  					// anchor package.
   287  					{
   288  						ref:  "latest",
   289  						tags: []string{"version:1.1"},
   290  					},
   291  					{
   292  						tags: []string{"version:1.0"},
   293  					},
   294  					{
   295  						tags: []string{"version:0.9"},
   296  					},
   297  				},
   298  				"foo": {
   299  					{
   300  						ref: "latest",
   301  						// The correctness of this test case relies on the
   302  						// RegisteredTs for the version:0.9 tag being earlier
   303  						// than the RegisteredTs for the version:1.0 tag, which
   304  						// is not guaranteed to be the case (versions may be
   305  						// registered out of order) but will generally be the
   306  						// case.
   307  						tags: []string{"version:0.9", "version:1.0"},
   308  					},
   309  				},
   310  			},
   311  			// version:0.9 would also be valid but it identifies a different set
   312  			// of package instances, and we should prefer to roll to as new a
   313  			// set of instances as possible.
   314  			//
   315  			// We shouldn't even include version:0.9 in the output because the
   316  			// output should unambiguously identify a set of instances. This
   317  			// makes it possible for a roller to determine if a set of package
   318  			// pins is already up-to-date just by checking whether the currently
   319  			// pinned version is in the set of resolved versions.
   320  			want: []string{"version:1.0"},
   321  		},
   322  		{
   323  			// version:0.8 exists for all the packages, but the latest ref has
   324  			// advanced to a later version for each package so version:0.8
   325  			// cannot be used as an anchor. However, no later version exists for
   326  			// all the packages so it's impossible to resolve all the packages
   327  			// to any later version either, so resolution fails.
   328  			//
   329  			// If it ever becomes possible to track the history of a ref, we
   330  			// could handle this case by using the ref history to determine that
   331  			// version:0.8 has had the ref before and is thus a valid version to
   332  			// resolve.
   333  			name:     "candidate version but no possible anchor",
   334  			flexible: []string{"foo", "bar", "baz"},
   335  			db: map[string][]fakeInstance{
   336  				// This package is fully up-to-date and available at all
   337  				// versions.
   338  				"foo": {
   339  					{
   340  						ref:  "latest",
   341  						tags: []string{"version:1.0"},
   342  					},
   343  					{
   344  						tags: []string{"version:0.9"},
   345  					},
   346  					{
   347  						tags: []string{"version:0.8"},
   348  					},
   349  				},
   350  				// This package is available at all versions except the most
   351  				// recent version.
   352  				"bar": {
   353  					{
   354  						ref:  "latest",
   355  						tags: []string{"version:0.9"},
   356  					},
   357  					{
   358  						tags: []string{"version:0.8"},
   359  					},
   360  				},
   361  				// This package is available at all versions except the *second*
   362  				// most recent version. This might happen if the job that
   363  				// produces the CIPD package fails on that version.
   364  				"baz": {
   365  					{
   366  						ref:  "latest",
   367  						tags: []string{"version:1.0"},
   368  					},
   369  					{
   370  						tags: []string{"version:0.8"},
   371  					},
   372  				},
   373  			},
   374  			wantErr: `none of the versions with the "latest" ref is currently available for all packages`,
   375  		},
   376  	}
   377  
   378  	for _, test := range tests {
   379  		t.Run(test.name, func(t *testing.T) {
   380  			client := newFakeCIPDClient(test.db)
   381  			resolver := cipdResolver{
   382  				client:  client,
   383  				tagName: "version",
   384  				ref:     "latest",
   385  			}
   386  
   387  			got, err := resolver.resolve(context.Background(), test.strict, test.flexible)
   388  			if err != nil {
   389  				if test.wantErr == "" {
   390  					t.Fatalf("Unexpected resolution error: %s", err)
   391  				}
   392  				if !strings.Contains(err.Error(), test.wantErr) {
   393  					t.Fatalf("Wanted an error like %q, but got: %s", test.wantErr, err)
   394  				}
   395  			} else if test.wantErr != "" {
   396  				t.Fatalf("Wanted an error like %q but got nil", test.wantErr)
   397  			}
   398  
   399  			if diff := cmp.Diff(test.want, got); diff != "" {
   400  				t.Errorf("Resolved wrong tags (-want +got):\n%s", diff)
   401  			}
   402  		})
   403  	}
   404  }
   405  
   406  type fakeCIPDClient struct {
   407  	instances []cipd.InstanceDescription
   408  }
   409  
   410  func newFakeCIPDClient(db map[string][]fakeInstance) *fakeCIPDClient {
   411  	// Convert the mock database, which is in a concise format for declaring new
   412  	// tests, into a data structure that uses the real CIPD types.
   413  	var client fakeCIPDClient
   414  	for pkg, instances := range db {
   415  		for _, inst := range instances {
   416  			instanceID := strconv.Itoa(rand.Int())
   417  			res := cipd.InstanceDescription{
   418  				InstanceInfo: cipd.InstanceInfo{
   419  					Pin: common.Pin{
   420  						PackageName: pkg,
   421  						InstanceID:  instanceID,
   422  					},
   423  				},
   424  			}
   425  
   426  			if inst.ref != "" {
   427  				res.Refs = append(res.Refs, cipd.RefInfo{
   428  					Ref:        inst.ref,
   429  					InstanceID: instanceID,
   430  				})
   431  			}
   432  
   433  			baseTime := time.Now().Add(-24 * time.Hour)
   434  			for i, tag := range inst.tags {
   435  				res.Tags = append(res.Tags, cipd.TagInfo{
   436  					Tag: tag,
   437  					// Add a fake timestamp to the tag to support logic that
   438  					// depends on timestamps. For simplicity we assume the
   439  					// timestamp for the different tags on this instance should
   440  					// be increasing, to make it easier to add tags with
   441  					// different timestamps on a single fake instance.
   442  					RegisteredTs: cipd.UnixTime(baseTime.Add(time.Duration(i) * time.Minute)),
   443  				})
   444  			}
   445  			client.instances = append(client.instances, res)
   446  		}
   447  	}
   448  	return &client
   449  }
   450  
   451  func (c *fakeCIPDClient) ResolveVersion(_ context.Context, pkg, version string) (common.Pin, error) {
   452  	isTag := strings.Contains(version, ":")
   453  
   454  	for _, inst := range c.instances {
   455  		if inst.Pin.PackageName != pkg {
   456  			continue
   457  		}
   458  		if isTag {
   459  			for _, tag := range inst.Tags {
   460  				if tag.Tag == version {
   461  					return inst.Pin, nil
   462  				}
   463  			}
   464  		} else {
   465  			for _, ref := range inst.Refs {
   466  				if ref.Ref == version {
   467  					return inst.Pin, nil
   468  				}
   469  			}
   470  		}
   471  	}
   472  
   473  	if isTag {
   474  		return common.Pin{}, fmt.Errorf("%s: %s", noSuchTagMessage, version)
   475  	}
   476  	return common.Pin{}, fmt.Errorf("%s: %s", noSuchRefMessage, version)
   477  }
   478  
   479  func (c *fakeCIPDClient) DescribeInstance(
   480  	_ context.Context, pin common.Pin, _ *cipd.DescribeInstanceOpts,
   481  ) (*cipd.InstanceDescription, error) {
   482  	for _, inst := range c.instances {
   483  		if inst.Pin == pin {
   484  			return &inst, nil
   485  		}
   486  	}
   487  	return nil, fmt.Errorf("failed to find matching instance")
   488  }