Skip to content

Commit fa04ece

Browse files
robert-croninsozercan
authored andcommitted
fix: retain multiplatform manifest list annotations (#1120)
Signed-off-by: robert-cronin <[email protected]> Signed-off-by: Sertac Ozercan <[email protected]> Co-authored-by: Sertaç Özercan <[email protected]> Co-authored-by: Sertac Ozercan <[email protected]> Signed-off-by: ashnamehrotra <[email protected]>
1 parent 3401c5e commit fa04ece

File tree

4 files changed

+402
-16
lines changed

4 files changed

+402
-16
lines changed

integration/multiarch/fixtures/test-images.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"description": "Multi-platform .NET 6.0 runtime, with push",
3737
"ignoreErrors": false,
3838
"push": true,
39+
"skipAnnotations": true,
3940
"platforms": [
4041
"linux/arm/v7",
4142
"linux/arm64"

integration/multiarch/patch_test.go

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package integration
22

33
import (
4+
"context"
45
_ "embed"
56
"encoding/json"
67
"fmt"
@@ -12,22 +13,28 @@ import (
1213
"sync"
1314
"testing"
1415

16+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1517
"github.com/project-copacetic/copacetic/integration/common"
18+
"github.com/project-copacetic/copacetic/pkg/utils"
19+
"github.com/stretchr/testify/assert"
1620
"github.com/stretchr/testify/require"
1721
)
1822

1923
//go:embed fixtures/test-images.json
2024
var testImages []byte
2125

26+
const lastPatchedAnnotation = "sh.copa.image.patched"
27+
2228
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"`
3138
}
3239

3340
func TestPatch(t *testing.T) {
@@ -82,6 +89,11 @@ func TestPatch(t *testing.T) {
8289
t.Log("patching image with multiple architectures")
8390
patchMultiPlatform(t, ref, tagPatched, reportDir, img.IgnoreErrors, img.Push)
8491

92+
if img.Push && !img.SkipAnnotations {
93+
t.Log("verifying OCI annotations are preserved")
94+
verifyAnnotations(t, patchedRef, img.Platforms, reportDir)
95+
}
96+
8597
t.Log("scanning patched image for each platform")
8698
wg = sync.WaitGroup{}
8799
for _, platformStr := range img.Platforms {
@@ -340,3 +352,150 @@ func copyImage(t *testing.T, src, dst string) {
340352
out, err := cmd.CombinedOutput()
341353
require.NoError(t, err, string(out))
342354
}
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

Comments
 (0)