| // 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" |
| "fmt" |
| "math/rand" |
| "strconv" |
| "strings" |
| "testing" |
| "time" |
| |
| "github.com/google/go-cmp/cmp" |
| "go.chromium.org/luci/cipd/client/cipd" |
| "go.chromium.org/luci/cipd/common" |
| ) |
| |
| type fakeInstance struct { |
| // Multiple refs are allowed but we only care about testing one at a time. |
| ref string |
| tags []string |
| } |
| |
| func TestResolver(t *testing.T) { |
| t.Parallel() |
| |
| tests := []struct { |
| name string |
| strict []string |
| flexible []string |
| // Mock contents of CIPD's database. |
| db map[string][]fakeInstance |
| want []string |
| wantErr string |
| }{ |
| { |
| name: "single package", |
| flexible: []string{"foo"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9", "version:1.0"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.9", "version:1.0"}, |
| }, |
| { |
| name: "single strict package", |
| strict: []string{"foo"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9", "version:1.0"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.9", "version:1.0"}, |
| }, |
| { |
| name: "multiple flexible packages", |
| flexible: []string{"foo", "bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9", "version:1.0"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.8", "version:1.0"}, |
| }, |
| }, |
| }, |
| want: []string{"version:1.0"}, |
| }, |
| { |
| name: "flexible packages out of sync", |
| flexible: []string{"foo", "bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.9"}, |
| }, |
| { |
| // The resolver should refuse to fall back to instances of strict |
| // packages that don't have the ref attached. |
| name: "strict packages out of sync", |
| strict: []string{"foo", "bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| wantErr: "strict packages have no common tags", |
| }, |
| { |
| name: "mix of strict and flexible packages", |
| strict: []string{"foo"}, |
| flexible: []string{"bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.9"}, |
| }, |
| { |
| // It's okay if the ref doesn't exist on any of the flexible |
| // packages as long as it can be found on all the strict packages. |
| name: "no flexible package has ref", |
| strict: []string{"foo"}, |
| flexible: []string{"bar", "baz"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "bar": { |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "baz": { |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.9"}, |
| }, |
| { |
| // We should backtrack as far as necessary until we find a suitable |
| // tag; in this case we have to backtrack by two versions. |
| name: "varying states of outdatedness", |
| flexible: []string{"foo", "bar", "baz"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| "baz": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| }, |
| want: []string{"version:0.8"}, |
| }, |
| { |
| // If no package currently has the ref attached to any instance, |
| // then we should return an empty set of version tags because it's |
| // impossible to tell which versions are valid. |
| name: "no package has ref", |
| flexible: []string{"foo", "bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| "bar": { |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| }, |
| wantErr: `none of the packages has the "latest" ref`, |
| }, |
| { |
| name: "no common tags", |
| flexible: []string{"foo", "bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| wantErr: `none of the versions with the "latest" ref is currently available for all packages`, |
| }, |
| { |
| name: "no common tags with one strict package", |
| strict: []string{"foo"}, |
| flexible: []string{"bar"}, |
| db: map[string][]fakeInstance{ |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| }, |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| }, |
| wantErr: "failed to find common tags", |
| }, |
| { |
| // Either version:0.9 or version:1.0 is a valid selection, but they |
| // point to different instances for one of the packages, so we |
| // should prefer the newer tag. |
| name: "always selects newest possible version", |
| flexible: []string{"bar", "foo"}, |
| db: map[string][]fakeInstance{ |
| "bar": { |
| // Latest version of "bar" is pinned to a version that |
| // doesn't exist for "foo", so "foo" must be chosen as the |
| // anchor package. This triggers the condition we care |
| // about, where version:1.0 is chosen even when "foo" is the |
| // anchor package. |
| { |
| ref: "latest", |
| tags: []string{"version:1.1"}, |
| }, |
| { |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| }, |
| "foo": { |
| { |
| ref: "latest", |
| // The correctness of this test case relies on the |
| // RegisteredTs for the version:0.9 tag being earlier |
| // than the RegisteredTs for the version:1.0 tag, which |
| // is not guaranteed to be the case (versions may be |
| // registered out of order) but will generally be the |
| // case. |
| tags: []string{"version:0.9", "version:1.0"}, |
| }, |
| }, |
| }, |
| // version:0.9 would also be valid but it identifies a different set |
| // of package instances, and we should prefer to roll to as new a |
| // set of instances as possible. |
| // |
| // We shouldn't even include version:0.9 in the output because the |
| // output should unambiguously identify a set of instances. This |
| // makes it possible for a roller to determine if a set of package |
| // pins is already up-to-date just by checking whether the currently |
| // pinned version is in the set of resolved versions. |
| want: []string{"version:1.0"}, |
| }, |
| { |
| // version:0.8 exists for all the packages, but the latest ref has |
| // advanced to a later version for each package so version:0.8 |
| // cannot be used as an anchor. However, no later version exists for |
| // all the packages so it's impossible to resolve all the packages |
| // to any later version either, so resolution fails. |
| // |
| // If it ever becomes possible to track the history of a ref, we |
| // could handle this case by using the ref history to determine that |
| // version:0.8 has had the ref before and is thus a valid version to |
| // resolve. |
| name: "candidate version but no possible anchor", |
| flexible: []string{"foo", "bar", "baz"}, |
| db: map[string][]fakeInstance{ |
| // This package is fully up-to-date and available at all |
| // versions. |
| "foo": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.9"}, |
| }, |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| // This package is available at all versions except the most |
| // recent version. |
| "bar": { |
| { |
| ref: "latest", |
| tags: []string{"version:0.9"}, |
| }, |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| // This package is available at all versions except the *second* |
| // most recent version. This might happen if the job that |
| // produces the CIPD package fails on that version. |
| "baz": { |
| { |
| ref: "latest", |
| tags: []string{"version:1.0"}, |
| }, |
| { |
| tags: []string{"version:0.8"}, |
| }, |
| }, |
| }, |
| wantErr: `none of the versions with the "latest" ref is currently available for all packages`, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(test.name, func(t *testing.T) { |
| client := newFakeCIPDClient(test.db) |
| resolver := cipdResolver{ |
| client: client, |
| tagName: "version", |
| ref: "latest", |
| } |
| |
| got, err := resolver.resolve(context.Background(), test.strict, test.flexible) |
| if err != nil { |
| if test.wantErr == "" { |
| t.Fatalf("Unexpected resolution error: %s", err) |
| } |
| if !strings.Contains(err.Error(), test.wantErr) { |
| t.Fatalf("Wanted an error like %q, but got: %s", test.wantErr, err) |
| } |
| } else if test.wantErr != "" { |
| t.Fatalf("Wanted an error like %q but got nil", test.wantErr) |
| } |
| |
| if diff := cmp.Diff(test.want, got); diff != "" { |
| t.Errorf("Resolved wrong tags (-want +got):\n%s", diff) |
| } |
| }) |
| } |
| } |
| |
| type fakeCIPDClient struct { |
| instances []cipd.InstanceDescription |
| } |
| |
| func newFakeCIPDClient(db map[string][]fakeInstance) *fakeCIPDClient { |
| // Convert the mock database, which is in a concise format for declaring new |
| // tests, into a data structure that uses the real CIPD types. |
| var client fakeCIPDClient |
| for pkg, instances := range db { |
| for _, inst := range instances { |
| instanceID := strconv.Itoa(rand.Int()) |
| res := cipd.InstanceDescription{ |
| InstanceInfo: cipd.InstanceInfo{ |
| Pin: common.Pin{ |
| PackageName: pkg, |
| InstanceID: instanceID, |
| }, |
| }, |
| } |
| |
| if inst.ref != "" { |
| res.Refs = append(res.Refs, cipd.RefInfo{ |
| Ref: inst.ref, |
| InstanceID: instanceID, |
| }) |
| } |
| |
| baseTime := time.Now().Add(-24 * time.Hour) |
| for i, tag := range inst.tags { |
| res.Tags = append(res.Tags, cipd.TagInfo{ |
| Tag: tag, |
| // Add a fake timestamp to the tag to support logic that |
| // depends on timestamps. For simplicity we assume the |
| // timestamp for the different tags on this instance should |
| // be increasing, to make it easier to add tags with |
| // different timestamps on a single fake instance. |
| RegisteredTs: cipd.UnixTime(baseTime.Add(time.Duration(i) * time.Minute)), |
| }) |
| } |
| client.instances = append(client.instances, res) |
| } |
| } |
| return &client |
| } |
| |
| func (c *fakeCIPDClient) ResolveVersion(_ context.Context, pkg, version string) (common.Pin, error) { |
| isTag := strings.Contains(version, ":") |
| |
| for _, inst := range c.instances { |
| if inst.Pin.PackageName != pkg { |
| continue |
| } |
| if isTag { |
| for _, tag := range inst.Tags { |
| if tag.Tag == version { |
| return inst.Pin, nil |
| } |
| } |
| } else { |
| for _, ref := range inst.Refs { |
| if ref.Ref == version { |
| return inst.Pin, nil |
| } |
| } |
| } |
| } |
| |
| if isTag { |
| return common.Pin{}, fmt.Errorf("%s: %s", noSuchTagMessage, version) |
| } |
| return common.Pin{}, fmt.Errorf("%s: %s", noSuchRefMessage, version) |
| } |
| |
| func (c *fakeCIPDClient) DescribeInstance( |
| _ context.Context, pin common.Pin, _ *cipd.DescribeInstanceOpts, |
| ) (*cipd.InstanceDescription, error) { |
| for _, inst := range c.instances { |
| if inst.Pin == pin { |
| return &inst, nil |
| } |
| } |
| return nil, fmt.Errorf("failed to find matching instance") |
| } |