1
1
package integration
2
2
3
3
import (
4
+ "context"
4
5
_ "embed"
5
6
"encoding/json"
6
7
"fmt"
@@ -12,22 +13,28 @@ import (
12
13
"sync"
13
14
"testing"
14
15
16
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
15
17
"github.com/project-copacetic/copacetic/integration/common"
18
+ "github.com/project-copacetic/copacetic/pkg/utils"
19
+ "github.com/stretchr/testify/assert"
16
20
"github.com/stretchr/testify/require"
17
21
)
18
22
19
23
//go:embed fixtures/test-images.json
20
24
var testImages []byte
21
25
26
+ const lastPatchedAnnotation = "sh.copa.image.patched"
27
+
22
28
type testImage struct {
23
- OriginalImage string `json:"originalImage"`
24
- LocalImage string `json:"localImage"`
25
- Push bool `json:"push"`
26
- Tag string `json:"tag"`
27
- Distro string `json:"distro"`
28
- Description string `json:"description"`
29
- IgnoreErrors bool `json:"ignoreErrors"`
30
- Platforms []string `json:"platforms"`
29
+ OriginalImage string `json:"originalImage"`
30
+ LocalImage string `json:"localImage"`
31
+ Push bool `json:"push"`
32
+ Tag string `json:"tag"`
33
+ Distro string `json:"distro"`
34
+ Description string `json:"description"`
35
+ IgnoreErrors bool `json:"ignoreErrors"`
36
+ SkipAnnotations bool `json:"skipAnnotations,omitempty"`
37
+ Platforms []string `json:"platforms"`
31
38
}
32
39
33
40
func TestPatch (t * testing.T ) {
@@ -82,6 +89,11 @@ func TestPatch(t *testing.T) {
82
89
t .Log ("patching image with multiple architectures" )
83
90
patchMultiPlatform (t , ref , tagPatched , reportDir , img .IgnoreErrors , img .Push )
84
91
92
+ if img .Push && ! img .SkipAnnotations {
93
+ t .Log ("verifying OCI annotations are preserved" )
94
+ verifyAnnotations (t , patchedRef , img .Platforms , reportDir )
95
+ }
96
+
85
97
t .Log ("scanning patched image for each platform" )
86
98
wg = sync.WaitGroup {}
87
99
for _ , platformStr := range img .Platforms {
@@ -340,3 +352,150 @@ func copyImage(t *testing.T, src, dst string) {
340
352
out , err := cmd .CombinedOutput ()
341
353
require .NoError (t , err , string (out ))
342
354
}
355
+
356
+ // ManifestData represents the JSON structure returned by docker buildx imagetools inspect.
357
+ type ManifestData struct {
358
+ Annotations map [string ]string `json:"annotations"`
359
+ Manifests []ManifestEntry `json:"manifests"`
360
+ }
361
+
362
+ type ManifestEntry struct {
363
+ Platform PlatformInfo `json:"platform"`
364
+ Annotations map [string ]string `json:"annotations"`
365
+ }
366
+
367
+ type PlatformInfo struct {
368
+ Architecture string `json:"architecture"`
369
+ OS string `json:"os"`
370
+ Variant string `json:"variant,omitempty"`
371
+ }
372
+
373
+ // verifyAnnotations checks that Copa properly preserves OCI annotations.
374
+ // This function verifies:
375
+ // 1. Index-level annotations: Copa metadata (copacetic.patched, timestamps)
376
+ // 2. Manifest-level annotations: Original platform-specific annotations are preserved
377
+ // 3. Updated timestamps: Patched platforms get updated creation timestamps.
378
+ func verifyAnnotations (t * testing.T , patchedRef string , platforms []string , reportDir string ) {
379
+ t .Log ("checking index-level annotations" )
380
+
381
+ // Get the raw manifest using docker buildx imagetools
382
+ cmd := exec .Command ("docker" , "buildx" , "imagetools" , "inspect" , patchedRef , "--raw" )
383
+ cmd .Env = append (cmd .Env , os .Environ ()... )
384
+ cmd .Env = append (cmd .Env , common .DockerDINDAddress .Env ()... )
385
+ out , err := cmd .CombinedOutput ()
386
+ require .NoError (t , err , "failed to inspect patched image: %s" , string (out ))
387
+
388
+ var manifest ManifestData
389
+ err = json .Unmarshal (out , & manifest )
390
+ require .NoError (t , err , "failed to parse manifest JSON" )
391
+
392
+ // Check index-level annotations
393
+ assert .NotEmpty (t , manifest .Annotations , "index-level annotations should not be empty" )
394
+ assert .NotEmpty (t , manifest .Annotations ["org.opencontainers.image.created" ], "should have created annotation" )
395
+
396
+ t .Logf ("found %d index-level annotations" , len (manifest .Annotations ))
397
+
398
+ // Check manifest-level annotations for each platform
399
+ t .Log ("checking manifest-level annotations for patched platforms" )
400
+
401
+ for _ , manifestEntry := range manifest .Manifests {
402
+ platformStr := formatPlatform (manifestEntry .Platform )
403
+
404
+ // Only check platforms that actually have vulnerability reports (were patched)
405
+ if isPatchablePlatform (platformStr , platforms , reportDir ) {
406
+ t .Logf ("checking manifest annotations for patched platform %s" , platformStr )
407
+
408
+ // The created timestamp should be updated for patched platforms
409
+ if createdTime , exists := manifestEntry .Annotations ["org.opencontainers.image.created" ]; exists {
410
+ assert .NotEmpty (t , createdTime , "created timestamp should not be empty for patched platform %s" , platformStr )
411
+ t .Logf ("platform %s has updated created timestamp: %s" , platformStr , createdTime )
412
+ }
413
+
414
+ // Check for Copa image.patched annotation on patched platforms
415
+ lastPatched , exists := manifestEntry .Annotations [lastPatchedAnnotation ]
416
+ assert .True (t , exists , "patched platform %s should have %s annotation" , platformStr , lastPatchedAnnotation )
417
+ assert .NotEmpty (t , lastPatched , "%s timestamp should not be empty for patched platform %s" , lastPatchedAnnotation , platformStr )
418
+ t .Logf ("platform %s has %s timestamp: %s" , platformStr , lastPatchedAnnotation , lastPatched )
419
+
420
+ t .Logf ("platform %s has %d manifest-level annotations" , platformStr , len (manifestEntry .Annotations ))
421
+
422
+ // Verify that ALL original annotations are preserved
423
+ // Get the original image reference
424
+ originalRef := strings .Replace (patchedRef , "-patched" , "" , 1 )
425
+
426
+ // Get original platform annotations
427
+ // Create platform object from manifestEntry
428
+ platform := & ocispec.Platform {
429
+ OS : manifestEntry .Platform .OS ,
430
+ Architecture : manifestEntry .Platform .Architecture ,
431
+ Variant : manifestEntry .Platform .Variant ,
432
+ }
433
+ originalAnnotations , err := utils .GetPlatformManifestAnnotations (context .Background (), originalRef , platform )
434
+ require .NoError (t , err , "failed to get original annotations for platform %s" , platformStr )
435
+
436
+ // Check that every original annotation is present in the patched manifest
437
+ // Some annotations are expected to change during patching
438
+ annotationsThatChange := map [string ]bool {
439
+ "org.opencontainers.image.created" : true ,
440
+ "org.opencontainers.image.version" : true ,
441
+ }
442
+
443
+ for key , originalValue := range originalAnnotations {
444
+ patchedValue , exists := manifestEntry .Annotations [key ]
445
+ assert .True (t , exists , "original annotation %s is missing in patched manifest for platform %s" , key , platformStr )
446
+
447
+ if exists && ! annotationsThatChange [key ] {
448
+ // For annotations that shouldn't change, verify the values are equal
449
+ assert .Equal (t , originalValue , patchedValue , "annotation %s value changed for platform %s: original=%s, patched=%s" , key , platformStr , originalValue , patchedValue )
450
+ }
451
+ }
452
+
453
+ t .Logf ("verified %d original annotations are preserved for platform %s" , len (originalAnnotations ), platformStr )
454
+ } else {
455
+ t .Logf ("checking platform %s (no vulnerability report, not patched)" , platformStr )
456
+
457
+ // Non-patched platforms should NOT have the Copa image.patched annotation
458
+ _ , exists := manifestEntry .Annotations [lastPatchedAnnotation ]
459
+ assert .False (t , exists , "non-patched platform %s should not have %s annotation" , platformStr , lastPatchedAnnotation )
460
+ }
461
+ }
462
+ }
463
+
464
+ // formatPlatform creates a platform string from PlatformInfo.
465
+ func formatPlatform (p PlatformInfo ) string {
466
+ platform := p .OS + "/" + p .Architecture
467
+ if p .Variant != "" {
468
+ platform += "/" + p .Variant
469
+ }
470
+ return platform
471
+ }
472
+
473
+ // isPatchablePlatform checks if a platform actually has a vulnerability report file
474
+ // and therefore should have been patched by Copa.
475
+ func isPatchablePlatform (platform string , allPlatforms []string , reportDir string ) bool {
476
+ // First verify this platform is in the test's platform list
477
+ found := false
478
+ for _ , testPlatform := range allPlatforms {
479
+ if testPlatform == platform {
480
+ found = true
481
+ break
482
+ }
483
+ }
484
+ if ! found {
485
+ return false
486
+ }
487
+
488
+ // Check if a vulnerability report exists for this platform
489
+ // Report files are named like "report-linux-amd64.json"
490
+ suffix := strings .ReplaceAll (platform , "/" , "-" )
491
+ reportPath := filepath .Join (reportDir , "report-" + suffix + ".json" )
492
+
493
+ // Check if the report file exists and is not empty
494
+ if info , err := os .Stat (reportPath ); err == nil && info .Size () > 0 {
495
+ // A non-empty vulnerability report exists, so this platform should be patched
496
+ return true
497
+ }
498
+
499
+ // No vulnerability report or empty report means platform was not patched
500
+ return false
501
+ }
0 commit comments