Skip to content

Commit 3e5c05c

Browse files
committed
imagetools: support oci-layout referrers
Handle OCI layout referrers via subject-annotated index entries and add integration coverage for copying signed attestations through oci-layout. Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
1 parent 9894189 commit 3e5c05c

4 files changed

Lines changed: 361 additions & 72 deletions

File tree

tests/imagetools.go

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){
3434
testImagetoolsCreatePlatformFilter,
3535
testImagetoolsOCILayoutInspect,
3636
testImagetoolsOCILayoutCreateSourceAndTarget,
37+
testImagetoolsOCILayoutReferrers,
3738
testImagetoolsOCILayoutMergeSources,
3839
testImagetoolsOCILayoutTargetDigest,
3940
testImagetoolsAppend,
@@ -450,6 +451,121 @@ func testImagetoolsOCILayoutCreateSourceAndTarget(t *testing.T, sb integration.S
450451
require.Equal(t, sourceDigest, digest.FromBytes(dt))
451452
}
452453

454+
// testImagetoolsOCILayoutReferrers verifies standalone referrers are recorded
455+
// directly in OCI layout index.json with a subject annotation, while reachable
456+
// attestation manifests are not duplicated there.
457+
func testImagetoolsOCILayoutReferrers(t *testing.T, sb integration.Sandbox) {
458+
if !isDockerContainerWorker(sb) {
459+
t.Skip("only testing with docker-container worker, imagetools only runs on docker-container")
460+
}
461+
462+
dir := createDockerfileWithArches(t, "amd64", "arm64")
463+
registry1, err := sb.NewRegistry()
464+
if errors.Is(err, integration.ErrRequirements) {
465+
t.Skip(err.Error())
466+
}
467+
require.NoError(t, err)
468+
registry2, err := sb.NewRegistry()
469+
require.NoError(t, err)
470+
471+
source := registry1 + "/buildx/imtools-oci-layout-referrers-src:latest"
472+
out, err := buildCmd(sb, withArgs(
473+
"--output", "type=image,name="+source+",push=true,oci-mediatypes=true,oci-artifact=true",
474+
"--platform=linux/amd64,linux/arm64",
475+
"--provenance=mode=min",
476+
dir,
477+
))
478+
require.NoError(t, err, string(out))
479+
480+
cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw"))
481+
dt, err := cmd.CombinedOutput()
482+
require.NoError(t, err, string(dt))
483+
484+
var srcIdx ocispecs.Index
485+
err = json.Unmarshal(dt, &srcIdx)
486+
require.NoError(t, err)
487+
488+
var attestations []ocispecs.Descriptor
489+
for _, mfst := range srcIdx.Manifests {
490+
if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" {
491+
attestations = append(attestations, mfst)
492+
}
493+
}
494+
require.Len(t, attestations, 2)
495+
496+
signatures := make([]ocispecs.Descriptor, 0, len(attestations))
497+
for _, attestation := range attestations {
498+
signatures = append(signatures, pushFakeSignatureReferrer(t, source, attestation))
499+
}
500+
501+
layoutPath := filepath.Join(dir, "layout-referrers")
502+
layoutRef := "oci-layout://" + layoutPath + ":latest"
503+
cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, source))
504+
dt, err = cmd.CombinedOutput()
505+
require.NoError(t, err, string(dt))
506+
507+
idxBytes, err := os.ReadFile(filepath.Join(layoutPath, "index.json"))
508+
require.NoError(t, err)
509+
510+
var layoutIdx ocispecs.Index
511+
err = json.Unmarshal(idxBytes, &layoutIdx)
512+
require.NoError(t, err)
513+
514+
directReferrers := map[digest.Digest]ocispecs.Descriptor{}
515+
directReferrerCount := 0
516+
for _, desc := range layoutIdx.Manifests {
517+
if desc.Annotations["io.containerd.manifest.subject"] != "" {
518+
directReferrerCount++
519+
directReferrers[desc.Digest] = desc
520+
}
521+
}
522+
require.Len(t, directReferrers, directReferrerCount)
523+
require.Len(t, directReferrers, len(signatures))
524+
for i, sig := range signatures {
525+
desc, ok := directReferrers[sig.Digest]
526+
require.True(t, ok)
527+
require.Equal(t, attestations[i].Digest.String(), desc.Annotations["io.containerd.manifest.subject"])
528+
}
529+
for _, attestation := range attestations {
530+
_, ok := directReferrers[attestation.Digest]
531+
require.False(t, ok)
532+
}
533+
534+
target := registry2 + "/buildx/imtools-oci-layout-referrers-dst:latest"
535+
cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", target, layoutRef))
536+
dt, err = cmd.CombinedOutput()
537+
require.NoError(t, err, string(dt))
538+
539+
cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw"))
540+
dt, err = cmd.CombinedOutput()
541+
require.NoError(t, err, string(dt))
542+
543+
var dstIdx ocispecs.Index
544+
err = json.Unmarshal(dt, &dstIdx)
545+
require.NoError(t, err)
546+
547+
copiedAttestations := map[digest.Digest]struct{}{}
548+
for _, mfst := range dstIdx.Manifests {
549+
if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" {
550+
copiedAttestations[mfst.Digest] = struct{}{}
551+
}
552+
}
553+
require.Len(t, copiedAttestations, len(attestations))
554+
555+
for _, sig := range signatures {
556+
cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target+"@"+sig.Digest.String(), "--raw"))
557+
dt, err = cmd.CombinedOutput()
558+
require.NoError(t, err, string(dt))
559+
560+
var sigManifest ocispecs.Manifest
561+
err = json.Unmarshal(dt, &sigManifest)
562+
require.NoError(t, err)
563+
require.NotNil(t, sigManifest.Subject)
564+
_, ok := copiedAttestations[sigManifest.Subject.Digest]
565+
require.True(t, ok)
566+
}
567+
}
568+
453569
// testImagetoolsOCILayoutMergeSources verifies create merges registry and local OCI layout sources.
454570
func testImagetoolsOCILayoutMergeSources(t *testing.T, sb integration.Sandbox) {
455571
if !isDockerContainerWorker(sb) {
@@ -871,7 +987,6 @@ func testImagetoolsCopyAttestationWithSignature(t *testing.T, sb integration.San
871987
require.NotNil(t, signatureManifest.Subject)
872988
require.Equal(t, attestationDesc.Digest, signatureManifest.Subject.Digest)
873989
require.Equal(t, "dsse-envelope", signatureManifest.Annotations["dev.sigstore.bundle.content"])
874-
875990
}
876991

877992
// Only attestation signatures should be present after the copy. The

util/imagetools/create.go

Lines changed: 31 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error
273273
return err
274274
}
275275

276-
recorder := &recordingReferrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
276+
referrers := &referrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
277277
descs, err := r.FetchReferrers(ctx, src.Ref, subject.Digest)
278278
if err != nil {
279279
return nil, err
@@ -287,12 +287,14 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error
287287
return filtered, nil
288288
})}
289289

290-
err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(recorder))
290+
err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(referrers))
291291
if err != nil {
292292
return err
293293
}
294294
if dest.IsOCILayout() {
295-
return r.writeRecordedReferrers(ctx, dest, recorder)
295+
for subject, descs := range referrers.refs {
296+
r.ociReferrers.record(dest.OCILayout().Path, subject, descs)
297+
}
296298
}
297299
return nil
298300
}
@@ -490,23 +492,40 @@ func (r *Resolver) filterPlatforms(ctx context.Context, dt []byte, desc ocispecs
490492
return idxBytes, desc, mfstsWithSource, nil
491493
}
492494

493-
type recordingReferrersProvider struct {
495+
type referrersProvider struct {
494496
base referrersFunc
495497
refs map[digest.Digest][]ocispecs.Descriptor
496498
}
497499

498-
func (r *recordingReferrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
500+
func (r *referrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
499501
out, err := r.base(ctx, desc)
500502
if err != nil {
501503
return nil, err
502504
}
505+
out = dedupeDescriptors(out)
503506
if r.refs == nil {
504507
r.refs = map[digest.Digest][]ocispecs.Descriptor{}
505508
}
506-
r.refs[desc.Digest] = append(r.refs[desc.Digest], out...)
509+
r.refs[desc.Digest] = dedupeDescriptors(append(r.refs[desc.Digest], out...))
507510
return out, nil
508511
}
509512

513+
func dedupeDescriptors(descs []ocispecs.Descriptor) []ocispecs.Descriptor {
514+
if len(descs) < 2 {
515+
return descs
516+
}
517+
seen := make(map[digest.Digest]struct{}, len(descs))
518+
out := descs[:0]
519+
for _, desc := range descs {
520+
if _, ok := seen[desc.Digest]; ok {
521+
continue
522+
}
523+
seen[desc.Digest] = struct{}{}
524+
out = append(out, desc)
525+
}
526+
return out
527+
}
528+
510529
func (r *Resolver) ingesterForLocation(loc *Location) (content.Ingester, error) {
511530
if loc.IsRegistry() {
512531
p, err := r.registryResolver().Pusher(context.TODO(), loc.Name())
@@ -552,52 +571,19 @@ func (r *Resolver) pushOCILayout(ctx context.Context, ref *Location, desc ocispe
552571
idx := ociindex.NewStoreIndex(ref.OCILayout().Path)
553572
switch {
554573
case ref.Digest() != "":
555-
return idx.Put(desc)
556-
case ref.Tag() != "":
557-
return idx.Put(desc, ociindex.Tag(ref.Tag()))
558-
default:
559-
return idx.Put(desc, ociindex.Tag("latest"))
560-
}
561-
}
562-
563-
func (r *Resolver) writeRecordedReferrers(ctx context.Context, loc *Location, refs *recordingReferrersProvider) error {
564-
if refs == nil || len(refs.refs) == 0 {
565-
return nil
566-
}
567-
store, err := r.localStore(loc.OCILayout().Path)
568-
if err != nil {
569-
return err
570-
}
571-
idx := ociindex.NewStoreIndex(loc.OCILayout().Path)
572-
for subject, manifests := range refs.refs {
573-
fallback := ocispecs.Index{
574-
Versioned: specs.Versioned{SchemaVersion: 2},
575-
MediaType: ocispecs.MediaTypeImageIndex,
576-
Manifests: manifests,
577-
}
578-
dt, err := json.Marshal(fallback)
579-
if err != nil {
574+
if err := idx.Put(desc); err != nil {
580575
return err
581576
}
582-
desc := ocispecs.Descriptor{
583-
MediaType: ocispecs.MediaTypeImageIndex,
584-
Digest: digest.FromBytes(dt),
585-
Size: int64(len(dt)),
586-
}
587-
w, err := store.Writer(ctx, content.WithRef(desc.Digest.String()), content.WithDescriptor(desc))
588-
if err != nil && !errdefs.IsAlreadyExists(err) {
577+
case ref.Tag() != "":
578+
if err := idx.Put(desc, ociindex.Tag(ref.Tag())); err != nil {
589579
return err
590580
}
591-
if err == nil {
592-
if err := content.Copy(ctx, w, bytes.NewReader(dt), desc.Size, desc.Digest); err != nil && !errdefs.IsAlreadyExists(err) {
593-
return err
594-
}
595-
}
596-
if err := idx.Put(desc, ociindex.Tag("sha256-"+subject.Encoded())); err != nil {
581+
default:
582+
if err := idx.Put(desc, ociindex.Tag("latest")); err != nil {
597583
return err
598584
}
599585
}
600-
return nil
586+
return writePendingOCILayoutReferrers(ctx, r.ociReferrers.take(ref.OCILayout().Path), r.GetDescriptor, idx, ref)
601587
}
602588

603589
func detectMediaType(dt []byte) (string, error) {

util/imagetools/inspect.go

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package imagetools
33
import (
44
"bytes"
55
"context"
6-
"encoding/json"
76
"io"
87
"net/http"
98
"sync"
@@ -38,6 +37,7 @@ type Resolver struct {
3837
buffer contentutil.Buffer
3938
localStoreMu sync.Mutex
4039
localStores map[string]content.Store
40+
ociReferrers ociLayoutReferrerRecorder
4141
}
4242

4343
func New(opt Opt) *Resolver {
@@ -172,7 +172,7 @@ func (r *Resolver) localStore(path string) (content.Store, error) {
172172

173173
func (r *Resolver) FetchReferrers(ctx context.Context, loc *Location, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) {
174174
if loc.IsOCILayout() {
175-
return r.fetchOCILayoutReferrers(ctx, loc, dgst)
175+
return fetchOCILayoutReferrers(ctx, r.GetDescriptor, loc, dgst)
176176
}
177177
f, err := r.registryResolver().Fetcher(ctx, loc.String())
178178
if err != nil {
@@ -248,30 +248,6 @@ func (r *Resolver) resolveOCILayout(ctx context.Context, loc *Location) (string,
248248
return loc.String(), *desc, nil
249249
}
250250

251-
func (r *Resolver) fetchOCILayoutReferrers(ctx context.Context, loc *Location, dgst digest.Digest) ([]ocispecs.Descriptor, error) {
252-
idx := ociindex.NewStoreIndex(loc.OCILayout().Path)
253-
// TODO: temporary fallback tag, should use annotations instead
254-
desc, err := idx.Get("sha256-" + dgst.Encoded())
255-
if err != nil {
256-
return nil, err
257-
}
258-
if desc == nil {
259-
return nil, errors.WithStack(errdefs.ErrNotFound)
260-
}
261-
dt, err := r.GetDescriptor(ctx, loc, *desc)
262-
if err != nil {
263-
return nil, err
264-
}
265-
if desc.MediaType != ocispecs.MediaTypeImageIndex {
266-
return nil, errors.Errorf("unsupported referrers media type %s", desc.MediaType)
267-
}
268-
var referrersIndex ocispecs.Index
269-
if err := json.Unmarshal(dt, &referrersIndex); err != nil {
270-
return nil, err
271-
}
272-
return referrersIndex.Manifests, nil
273-
}
274-
275251
func parseRef(s string) (reference.Named, error) {
276252
ref, err := reference.ParseNormalizedNamed(s)
277253
if err != nil {

0 commit comments

Comments
 (0)