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 }