OLD | NEW |
1 // Copyright 2013 Canonical Ltd. | 1 // Copyright 2013 Canonical Ltd. |
2 // Licensed under the AGPLv3, see LICENCE file for details. | 2 // Licensed under the AGPLv3, see LICENCE file for details. |
3 | 3 |
4 package tools_test | 4 package tools_test |
5 | 5 |
6 import ( | 6 import ( |
7 "flag" | 7 "flag" |
8 "fmt" | 8 "fmt" |
9 » "path/filepath" | 9 » "io" |
10 "reflect" | 10 "reflect" |
11 "testing" | 11 "testing" |
12 | 12 |
13 "launchpad.net/goamz/aws" | 13 "launchpad.net/goamz/aws" |
14 gc "launchpad.net/gocheck" | 14 gc "launchpad.net/gocheck" |
15 | 15 |
16 "launchpad.net/juju-core/environs/filestorage" | 16 "launchpad.net/juju-core/environs/filestorage" |
17 "launchpad.net/juju-core/environs/simplestreams" | 17 "launchpad.net/juju-core/environs/simplestreams" |
18 sstesting "launchpad.net/juju-core/environs/simplestreams/testing" | 18 sstesting "launchpad.net/juju-core/environs/simplestreams/testing" |
| 19 "launchpad.net/juju-core/environs/storage" |
19 "launchpad.net/juju-core/environs/tools" | 20 "launchpad.net/juju-core/environs/tools" |
20 ttesting "launchpad.net/juju-core/environs/tools/testing" | 21 ttesting "launchpad.net/juju-core/environs/tools/testing" |
| 22 "launchpad.net/juju-core/testing/testbase" |
21 coretools "launchpad.net/juju-core/tools" | 23 coretools "launchpad.net/juju-core/tools" |
22 "launchpad.net/juju-core/version" | 24 "launchpad.net/juju-core/version" |
23 ) | 25 ) |
24 | 26 |
25 var live = flag.Bool("live", false, "Include live simplestreams tests") | 27 var live = flag.Bool("live", false, "Include live simplestreams tests") |
26 var vendor = flag.String("vendor", "", "The vendor representing the source of th
e simplestream data") | 28 var vendor = flag.String("vendor", "", "The vendor representing the source of th
e simplestream data") |
27 | 29 |
28 type liveTestData struct { | 30 type liveTestData struct { |
29 baseURL string | 31 baseURL string |
30 requireSigned bool | 32 requireSigned bool |
(...skipping 237 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
268 Size: 2973595, | 270 Size: 2973595, |
269 Path: "mirrored-path/juju-1.13.0-precise-amd64.tgz", | 271 Path: "mirrored-path/juju-1.13.0-precise-amd64.tgz", |
270 FullPath: "test:/mirrored-path/juju-1.13.0-precise-amd64.tgz", | 272 FullPath: "test:/mirrored-path/juju-1.13.0-precise-amd64.tgz", |
271 FileType: "tar.gz", | 273 FileType: "tar.gz", |
272 SHA256: "447aeb6a934a5eaec4f703eda4ef2dde", | 274 SHA256: "447aeb6a934a5eaec4f703eda4ef2dde", |
273 } | 275 } |
274 c.Assert(err, gc.IsNil) | 276 c.Assert(err, gc.IsNil) |
275 c.Assert(toolsMetadata[0], gc.DeepEquals, expectedMetadata) | 277 c.Assert(toolsMetadata[0], gc.DeepEquals, expectedMetadata) |
276 } | 278 } |
277 | 279 |
278 func assertMetadataMatches(c *gc.C, toolList coretools.List, metadata []*tools.T
oolsMetadata) { | 280 func assertMetadataMatches(c *gc.C, storageDir string, toolList coretools.List,
metadata []*tools.ToolsMetadata) { |
279 var expectedMetadata []*tools.ToolsMetadata = make([]*tools.ToolsMetadat
a, len(toolList)) | 281 var expectedMetadata []*tools.ToolsMetadata = make([]*tools.ToolsMetadat
a, len(toolList)) |
280 for i, tool := range toolList { | 282 for i, tool := range toolList { |
281 if tool.URL != "" { | |
282 size, sha256 := ttesting.SHA256sum(c, tool.URL) | |
283 tool.SHA256 = sha256 | |
284 tool.Size = size | |
285 } | |
286 expectedMetadata[i] = &tools.ToolsMetadata{ | 283 expectedMetadata[i] = &tools.ToolsMetadata{ |
287 Release: tool.Version.Series, | 284 Release: tool.Version.Series, |
288 Version: tool.Version.Number.String(), | 285 Version: tool.Version.Number.String(), |
289 Arch: tool.Version.Arch, | 286 Arch: tool.Version.Arch, |
290 Size: tool.Size, | 287 Size: tool.Size, |
291 Path: fmt.Sprintf("releases/juju-%s.tgz", tool.Versi
on.String()), | 288 Path: fmt.Sprintf("releases/juju-%s.tgz", tool.Versi
on.String()), |
292 FileType: "tar.gz", | 289 FileType: "tar.gz", |
293 SHA256: tool.SHA256, | 290 SHA256: tool.SHA256, |
294 } | 291 } |
295 } | 292 } |
296 c.Assert(metadata, gc.DeepEquals, expectedMetadata) | 293 c.Assert(metadata, gc.DeepEquals, expectedMetadata) |
297 } | 294 } |
298 | 295 |
299 func (s *simplestreamsSuite) TestWriteMetadataNoFetch(c *gc.C) { | 296 func (s *simplestreamsSuite) TestWriteMetadataNoFetch(c *gc.C) { |
300 toolsList := coretools.List{ | 297 toolsList := coretools.List{ |
301 { | 298 { |
302 Version: version.MustParseBinary("1.2.3-precise-amd64"), | 299 Version: version.MustParseBinary("1.2.3-precise-amd64"), |
303 Size: 123, | 300 Size: 123, |
304 SHA256: "abcd", | 301 SHA256: "abcd", |
305 }, { | 302 }, { |
306 Version: version.MustParseBinary("2.0.1-raring-amd64"), | 303 Version: version.MustParseBinary("2.0.1-raring-amd64"), |
307 Size: 456, | 304 Size: 456, |
308 SHA256: "xyz", | 305 SHA256: "xyz", |
309 }, | 306 }, |
310 } | 307 } |
311 dir := c.MkDir() | 308 dir := c.MkDir() |
312 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) | 309 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) |
313 c.Assert(err, gc.IsNil) | 310 c.Assert(err, gc.IsNil) |
314 » err = tools.WriteMetadata(toolsList, false, writer) | 311 » err = tools.MergeAndWriteMetadata(writer, toolsList) |
315 c.Assert(err, gc.IsNil) | 312 c.Assert(err, gc.IsNil) |
316 metadata := ttesting.ParseMetadata(c, dir) | 313 metadata := ttesting.ParseMetadata(c, dir) |
317 » assertMetadataMatches(c, toolsList, metadata) | 314 » assertMetadataMatches(c, dir, toolsList, metadata) |
318 } | 315 } |
319 | 316 |
320 func (s *simplestreamsSuite) TestWriteMetadata(c *gc.C) { | 317 func (s *simplestreamsSuite) TestWriteMetadata(c *gc.C) { |
321 var versionStrings = []string{ | 318 var versionStrings = []string{ |
322 "1.2.3-precise-amd64", | 319 "1.2.3-precise-amd64", |
323 "2.0.1-raring-amd64", | 320 "2.0.1-raring-amd64", |
324 } | 321 } |
325 dir := c.MkDir() | 322 dir := c.MkDir() |
326 ttesting.MakeTools(c, dir, "releases", versionStrings) | 323 ttesting.MakeTools(c, dir, "releases", versionStrings) |
327 | 324 |
328 toolsList := coretools.List{ | 325 toolsList := coretools.List{ |
329 { | 326 { |
330 // If sha256/size is already known, do not recalculate | 327 // If sha256/size is already known, do not recalculate |
331 Version: version.MustParseBinary("1.2.3-precise-amd64"), | 328 Version: version.MustParseBinary("1.2.3-precise-amd64"), |
332 Size: 123, | 329 Size: 123, |
333 SHA256: "abcd", | 330 SHA256: "abcd", |
334 }, { | 331 }, { |
335 Version: version.MustParseBinary("2.0.1-raring-amd64"), | 332 Version: version.MustParseBinary("2.0.1-raring-amd64"), |
336 » » » URL: "file://" + filepath.Join(dir, "tools/releases/
juju-2.0.1-raring-amd64.tgz"), | 333 » » » // The URL is not used for generating metadata. |
| 334 » » » URL: "bogus://", |
337 }, | 335 }, |
338 } | 336 } |
339 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) | 337 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) |
340 c.Assert(err, gc.IsNil) | 338 c.Assert(err, gc.IsNil) |
341 » err = tools.WriteMetadata(toolsList, true, writer) | 339 » err = tools.MergeAndWriteMetadata(writer, toolsList) |
342 c.Assert(err, gc.IsNil) | 340 c.Assert(err, gc.IsNil) |
343 metadata := ttesting.ParseMetadata(c, dir) | 341 metadata := ttesting.ParseMetadata(c, dir) |
344 » assertMetadataMatches(c, toolsList, metadata) | 342 » assertMetadataMatches(c, dir, toolsList, metadata) |
345 } | 343 } |
346 | 344 |
347 func (s *simplestreamsSuite) TestWriteMetadataMergeWithExisting(c *gc.C) { | 345 func (s *simplestreamsSuite) TestWriteMetadataMergeWithExisting(c *gc.C) { |
348 dir := c.MkDir() | 346 dir := c.MkDir() |
349 existingToolsList := coretools.List{ | 347 existingToolsList := coretools.List{ |
350 { | 348 { |
351 Version: version.MustParseBinary("1.2.3-precise-amd64"), | 349 Version: version.MustParseBinary("1.2.3-precise-amd64"), |
352 Size: 123, | 350 Size: 123, |
353 SHA256: "abc", | 351 SHA256: "abc", |
354 }, { | 352 }, { |
355 Version: version.MustParseBinary("2.0.1-raring-amd64"), | 353 Version: version.MustParseBinary("2.0.1-raring-amd64"), |
356 Size: 456, | 354 Size: 456, |
357 SHA256: "xyz", | 355 SHA256: "xyz", |
358 }, | 356 }, |
359 } | 357 } |
360 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) | 358 writer, err := filestorage.NewFileStorageWriter(dir, filestorage.UseDefa
ultTmpDir) |
361 c.Assert(err, gc.IsNil) | 359 c.Assert(err, gc.IsNil) |
362 » err = tools.WriteMetadata(existingToolsList, true, writer) | 360 » err = tools.MergeAndWriteMetadata(writer, existingToolsList) |
363 c.Assert(err, gc.IsNil) | 361 c.Assert(err, gc.IsNil) |
364 newToolsList := coretools.List{ | 362 newToolsList := coretools.List{ |
365 existingToolsList[0], | 363 existingToolsList[0], |
366 { | 364 { |
367 Version: version.MustParseBinary("2.1.0-raring-amd64"), | 365 Version: version.MustParseBinary("2.1.0-raring-amd64"), |
368 Size: 789, | 366 Size: 789, |
369 SHA256: "def", | 367 SHA256: "def", |
370 }, | 368 }, |
371 } | 369 } |
372 » err = tools.WriteMetadata(newToolsList, true, writer) | 370 » err = tools.MergeAndWriteMetadata(writer, newToolsList) |
373 c.Assert(err, gc.IsNil) | 371 c.Assert(err, gc.IsNil) |
374 requiredToolsList := append(existingToolsList, newToolsList[1]) | 372 requiredToolsList := append(existingToolsList, newToolsList[1]) |
375 metadata := ttesting.ParseMetadata(c, dir) | 373 metadata := ttesting.ParseMetadata(c, dir) |
376 » assertMetadataMatches(c, requiredToolsList, metadata) | 374 » assertMetadataMatches(c, dir, requiredToolsList, metadata) |
377 } | 375 } |
378 | 376 |
379 type productSpecSuite struct{} | 377 type productSpecSuite struct{} |
380 | 378 |
381 var _ = gc.Suite(&productSpecSuite{}) | 379 var _ = gc.Suite(&productSpecSuite{}) |
382 | 380 |
383 func (s *productSpecSuite) TestId(c *gc.C) { | 381 func (s *productSpecSuite) TestId(c *gc.C) { |
384 toolsConstraint := tools.NewVersionedToolsConstraint("1.13.0", simplestr
eams.LookupParams{ | 382 toolsConstraint := tools.NewVersionedToolsConstraint("1.13.0", simplestr
eams.LookupParams{ |
385 Series: []string{"precise"}, | 383 Series: []string{"precise"}, |
386 Arches: []string{"amd64"}, | 384 Arches: []string{"amd64"}, |
(...skipping 80 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
467 c.Assert(product, gc.NotNil) | 465 c.Assert(product, gc.NotNil) |
468 c.Assert(product.Items, gc.HasLen, 1) | 466 c.Assert(product.Items, gc.HasLen, 1) |
469 version := product.Items["20133008"] | 467 version := product.Items["20133008"] |
470 c.Assert(version, gc.NotNil) | 468 c.Assert(version, gc.NotNil) |
471 c.Assert(version.Items, gc.HasLen, 1) | 469 c.Assert(version.Items, gc.HasLen, 1) |
472 item := version.Items["1.10.0-precise-amd64"] | 470 item := version.Items["1.10.0-precise-amd64"] |
473 c.Assert(item, gc.NotNil) | 471 c.Assert(item, gc.NotNil) |
474 c.Assert(item, gc.FitsTypeOf, &tools.ToolsMetadata{}) | 472 c.Assert(item, gc.FitsTypeOf, &tools.ToolsMetadata{}) |
475 c.Assert(item.(*tools.ToolsMetadata).Size, gc.Equals, int64(922337203685
4775807)) | 473 c.Assert(item.(*tools.ToolsMetadata).Size, gc.Equals, int64(922337203685
4775807)) |
476 } | 474 } |
| 475 |
| 476 type metadataHelperSuite struct { |
| 477 testbase.LoggingSuite |
| 478 } |
| 479 |
| 480 var _ = gc.Suite(&metadataHelperSuite{}) |
| 481 |
| 482 func (*metadataHelperSuite) TestMetadataFromTools(c *gc.C) { |
| 483 metadata := tools.MetadataFromTools(nil) |
| 484 c.Assert(metadata, gc.HasLen, 0) |
| 485 |
| 486 toolsList := coretools.List{{ |
| 487 Version: version.MustParseBinary("1.2.3-precise-amd64"), |
| 488 Size: 123, |
| 489 SHA256: "abc", |
| 490 }, { |
| 491 Version: version.MustParseBinary("2.0.1-raring-amd64"), |
| 492 URL: "file:///tmp/releases/juju-2.0.1-raring-amd64.tgz", |
| 493 Size: 456, |
| 494 SHA256: "xyz", |
| 495 }} |
| 496 metadata = tools.MetadataFromTools(toolsList) |
| 497 c.Assert(metadata, gc.HasLen, len(toolsList)) |
| 498 for i, t := range toolsList { |
| 499 md := metadata[i] |
| 500 c.Assert(md.Release, gc.Equals, t.Version.Series) |
| 501 c.Assert(md.Version, gc.Equals, t.Version.Number.String()) |
| 502 c.Assert(md.Arch, gc.Equals, t.Version.Arch) |
| 503 c.Assert(md.FullPath, gc.Equals, t.URL) |
| 504 c.Assert(md.Path, gc.Equals, tools.StorageName(t.Version)[len("t
ools/"):]) |
| 505 c.Assert(md.FileType, gc.Equals, "tar.gz") |
| 506 c.Assert(md.Size, gc.Equals, t.Size) |
| 507 c.Assert(md.SHA256, gc.Equals, t.SHA256) |
| 508 } |
| 509 } |
| 510 |
| 511 type countingStorage struct { |
| 512 storage.StorageReader |
| 513 counter int |
| 514 } |
| 515 |
| 516 func (c *countingStorage) Get(name string) (io.ReadCloser, error) { |
| 517 c.counter++ |
| 518 return c.StorageReader.Get(name) |
| 519 } |
| 520 |
| 521 func (*metadataHelperSuite) TestResolveMetadata(c *gc.C) { |
| 522 var versionStrings = []string{"1.2.3-precise-amd64"} |
| 523 dir := c.MkDir() |
| 524 ttesting.MakeTools(c, dir, "releases", versionStrings) |
| 525 toolsList := coretools.List{{ |
| 526 Version: version.MustParseBinary(versionStrings[0]), |
| 527 Size: 123, |
| 528 SHA256: "abc", |
| 529 }} |
| 530 |
| 531 stor, err := filestorage.NewFileStorageReader(dir) |
| 532 c.Assert(err, gc.IsNil) |
| 533 err = tools.ResolveMetadata(stor, nil) |
| 534 c.Assert(err, gc.IsNil) |
| 535 |
| 536 // We already have size/sha256, so ensure that storage isn't consulted. |
| 537 countingStorage := &countingStorage{StorageReader: stor} |
| 538 metadata := tools.MetadataFromTools(toolsList) |
| 539 err = tools.ResolveMetadata(countingStorage, metadata) |
| 540 c.Assert(err, gc.IsNil) |
| 541 c.Assert(countingStorage.counter, gc.Equals, 0) |
| 542 |
| 543 // Now clear size/sha256, and check that it is called, and |
| 544 // the size/sha256 sum are updated. |
| 545 metadata[0].Size = 0 |
| 546 metadata[0].SHA256 = "" |
| 547 err = tools.ResolveMetadata(countingStorage, metadata) |
| 548 c.Assert(err, gc.IsNil) |
| 549 c.Assert(countingStorage.counter, gc.Equals, 1) |
| 550 c.Assert(metadata[0].Size, gc.Not(gc.Equals), 0) |
| 551 c.Assert(metadata[0].SHA256, gc.Not(gc.Equals), "") |
| 552 } |
| 553 |
| 554 func (*metadataHelperSuite) TestMergeMetadata(c *gc.C) { |
| 555 md1 := &tools.ToolsMetadata{ |
| 556 Release: "precise", |
| 557 Version: "1.2.3", |
| 558 Arch: "amd64", |
| 559 Path: "path1", |
| 560 } |
| 561 md2 := &tools.ToolsMetadata{ |
| 562 Release: "precise", |
| 563 Version: "1.2.3", |
| 564 Arch: "amd64", |
| 565 Path: "path2", |
| 566 } |
| 567 md3 := &tools.ToolsMetadata{ |
| 568 Release: "raring", |
| 569 Version: "1.2.3", |
| 570 Arch: "amd64", |
| 571 Path: "path3", |
| 572 } |
| 573 |
| 574 withSize := func(md *tools.ToolsMetadata, size int64) *tools.ToolsMetada
ta { |
| 575 clone := *md |
| 576 clone.Size = size |
| 577 return &clone |
| 578 } |
| 579 withSHA256 := func(md *tools.ToolsMetadata, sha256 string) *tools.ToolsM
etadata { |
| 580 clone := *md |
| 581 clone.SHA256 = sha256 |
| 582 return &clone |
| 583 } |
| 584 |
| 585 type mdlist []*tools.ToolsMetadata |
| 586 type test struct { |
| 587 name string |
| 588 lhs, rhs, merged []*tools.ToolsMetadata |
| 589 err string |
| 590 } |
| 591 tests := []test{{ |
| 592 name: "non-empty lhs, empty rhs", |
| 593 lhs: mdlist{md1}, |
| 594 rhs: nil, |
| 595 merged: mdlist{md1}, |
| 596 }, { |
| 597 name: "empty lhs, non-empty rhs", |
| 598 lhs: nil, |
| 599 rhs: mdlist{md2}, |
| 600 merged: mdlist{md2}, |
| 601 }, { |
| 602 name: "identical lhs, rhs", |
| 603 lhs: mdlist{md1}, |
| 604 rhs: mdlist{md1}, |
| 605 merged: mdlist{md1}, |
| 606 }, { |
| 607 name: "same tools in lhs and rhs, neither have size: prefer lh
s", |
| 608 lhs: mdlist{md1}, |
| 609 rhs: mdlist{md2}, |
| 610 merged: mdlist{md1}, |
| 611 }, { |
| 612 name: "same tools in lhs and rhs, only lhs has a size: prefer
lhs", |
| 613 lhs: mdlist{withSize(md1, 123)}, |
| 614 rhs: mdlist{md2}, |
| 615 merged: mdlist{withSize(md1, 123)}, |
| 616 }, { |
| 617 name: "same tools in lhs and rhs, only rhs has a size: prefer
rhs", |
| 618 lhs: mdlist{md1}, |
| 619 rhs: mdlist{withSize(md2, 123)}, |
| 620 merged: mdlist{withSize(md2, 123)}, |
| 621 }, { |
| 622 name: "same tools in lhs and rhs, both have the same size: pre
fer lhs", |
| 623 lhs: mdlist{withSize(md1, 123)}, |
| 624 rhs: mdlist{withSize(md2, 123)}, |
| 625 merged: mdlist{withSize(md1, 123)}, |
| 626 }, { |
| 627 name: "same tools in lhs and rhs, both have different sizes: err
or", |
| 628 lhs: mdlist{withSize(md1, 123)}, |
| 629 rhs: mdlist{withSize(md2, 456)}, |
| 630 err: "metadata mismatch for 1\\.2\\.3-precise-amd64: sizes=\\(1
23,456\\) sha256=\\(,\\)", |
| 631 }, { |
| 632 name: "same tools in lhs and rhs, both have same size but differ
ent sha256: error", |
| 633 lhs: mdlist{withSHA256(withSize(md1, 123), "a")}, |
| 634 rhs: mdlist{withSHA256(withSize(md2, 123), "b")}, |
| 635 err: "metadata mismatch for 1\\.2\\.3-precise-amd64: sizes=\\(1
23,123\\) sha256=\\(a,b\\)", |
| 636 }, { |
| 637 name: "lhs is a proper superset of rhs: union of lhs and rhs", |
| 638 lhs: mdlist{md1, md3}, |
| 639 rhs: mdlist{md1}, |
| 640 merged: mdlist{md1, md3}, |
| 641 }, { |
| 642 name: "rhs is a proper superset of lhs: union of lhs and rhs", |
| 643 lhs: mdlist{md1}, |
| 644 rhs: mdlist{md1, md3}, |
| 645 merged: mdlist{md1, md3}, |
| 646 }} |
| 647 for i, test := range tests { |
| 648 c.Logf("test %d: %s", i, test.name) |
| 649 merged, err := tools.MergeMetadata(test.lhs, test.rhs) |
| 650 if test.err == "" { |
| 651 c.Assert(err, gc.IsNil) |
| 652 c.Assert(merged, gc.DeepEquals, test.merged) |
| 653 } else { |
| 654 c.Assert(err, gc.ErrorMatches, test.err) |
| 655 c.Assert(merged, gc.IsNil) |
| 656 } |
| 657 } |
| 658 } |
| 659 |
| 660 func (*metadataHelperSuite) TestReadWriteMetadata(c *gc.C) { |
| 661 metadata := []*tools.ToolsMetadata{{ |
| 662 Release: "precise", |
| 663 Version: "1.2.3", |
| 664 Arch: "amd64", |
| 665 Path: "path1", |
| 666 }, { |
| 667 Release: "raring", |
| 668 Version: "1.2.3", |
| 669 Arch: "amd64", |
| 670 Path: "path2", |
| 671 }} |
| 672 |
| 673 stor, err := filestorage.NewFileStorageWriter(c.MkDir(), filestorage.Use
DefaultTmpDir) |
| 674 c.Assert(err, gc.IsNil) |
| 675 out, err := tools.ReadMetadata(stor) |
| 676 c.Assert(out, gc.HasLen, 0) |
| 677 c.Assert(err, gc.IsNil) // non-existence is not an error |
| 678 err = tools.WriteMetadata(stor, metadata) |
| 679 c.Assert(err, gc.IsNil) |
| 680 out, err = tools.ReadMetadata(stor) |
| 681 for _, md := range out { |
| 682 // FullPath is set by ReadMetadata. |
| 683 c.Assert(md.FullPath, gc.Not(gc.Equals), "") |
| 684 md.FullPath = "" |
| 685 } |
| 686 c.Assert(out, gc.DeepEquals, metadata) |
| 687 } |
OLD | NEW |