blob: 685d18e4391d54b620450b4d5b03e023d7987c84 [file] [log] [blame]
// 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")
}