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>
This commit is contained in:
Tonis Tiigi
2026-03-12 22:45:01 -07:00
parent 9894189361
commit 3e5c05cb1f
4 changed files with 363 additions and 74 deletions
+116 -1
View File
@@ -34,6 +34,7 @@ var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){
testImagetoolsCreatePlatformFilter,
testImagetoolsOCILayoutInspect,
testImagetoolsOCILayoutCreateSourceAndTarget,
testImagetoolsOCILayoutReferrers,
testImagetoolsOCILayoutMergeSources,
testImagetoolsOCILayoutTargetDigest,
testImagetoolsAppend,
@@ -450,6 +451,121 @@ func testImagetoolsOCILayoutCreateSourceAndTarget(t *testing.T, sb integration.S
require.Equal(t, sourceDigest, digest.FromBytes(dt))
}
// testImagetoolsOCILayoutReferrers verifies standalone referrers are recorded
// directly in OCI layout index.json with a subject annotation, while reachable
// attestation manifests are not duplicated there.
func testImagetoolsOCILayoutReferrers(t *testing.T, sb integration.Sandbox) {
if !isDockerContainerWorker(sb) {
t.Skip("only testing with docker-container worker, imagetools only runs on docker-container")
}
dir := createDockerfileWithArches(t, "amd64", "arm64")
registry1, err := sb.NewRegistry()
if errors.Is(err, integration.ErrRequirements) {
t.Skip(err.Error())
}
require.NoError(t, err)
registry2, err := sb.NewRegistry()
require.NoError(t, err)
source := registry1 + "/buildx/imtools-oci-layout-referrers-src:latest"
out, err := buildCmd(sb, withArgs(
"--output", "type=image,name="+source+",push=true,oci-mediatypes=true,oci-artifact=true",
"--platform=linux/amd64,linux/arm64",
"--provenance=mode=min",
dir,
))
require.NoError(t, err, string(out))
cmd := buildxCmd(sb, withArgs("imagetools", "inspect", source, "--raw"))
dt, err := cmd.CombinedOutput()
require.NoError(t, err, string(dt))
var srcIdx ocispecs.Index
err = json.Unmarshal(dt, &srcIdx)
require.NoError(t, err)
var attestations []ocispecs.Descriptor
for _, mfst := range srcIdx.Manifests {
if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" {
attestations = append(attestations, mfst)
}
}
require.Len(t, attestations, 2)
signatures := make([]ocispecs.Descriptor, 0, len(attestations))
for _, attestation := range attestations {
signatures = append(signatures, pushFakeSignatureReferrer(t, source, attestation))
}
layoutPath := filepath.Join(dir, "layout-referrers")
layoutRef := "oci-layout://" + layoutPath + ":latest"
cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", layoutRef, source))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))
idxBytes, err := os.ReadFile(filepath.Join(layoutPath, "index.json"))
require.NoError(t, err)
var layoutIdx ocispecs.Index
err = json.Unmarshal(idxBytes, &layoutIdx)
require.NoError(t, err)
directReferrers := map[digest.Digest]ocispecs.Descriptor{}
directReferrerCount := 0
for _, desc := range layoutIdx.Manifests {
if desc.Annotations["io.containerd.manifest.subject"] != "" {
directReferrerCount++
directReferrers[desc.Digest] = desc
}
}
require.Len(t, directReferrers, directReferrerCount)
require.Len(t, directReferrers, len(signatures))
for i, sig := range signatures {
desc, ok := directReferrers[sig.Digest]
require.True(t, ok)
require.Equal(t, attestations[i].Digest.String(), desc.Annotations["io.containerd.manifest.subject"])
}
for _, attestation := range attestations {
_, ok := directReferrers[attestation.Digest]
require.False(t, ok)
}
target := registry2 + "/buildx/imtools-oci-layout-referrers-dst:latest"
cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", target, layoutRef))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))
cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw"))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))
var dstIdx ocispecs.Index
err = json.Unmarshal(dt, &dstIdx)
require.NoError(t, err)
copiedAttestations := map[digest.Digest]struct{}{}
for _, mfst := range dstIdx.Manifests {
if mfst.Annotations["vnd.docker.reference.type"] == "attestation-manifest" {
copiedAttestations[mfst.Digest] = struct{}{}
}
}
require.Len(t, copiedAttestations, len(attestations))
for _, sig := range signatures {
cmd = buildxCmd(sb, withArgs("imagetools", "inspect", target+"@"+sig.Digest.String(), "--raw"))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))
var sigManifest ocispecs.Manifest
err = json.Unmarshal(dt, &sigManifest)
require.NoError(t, err)
require.NotNil(t, sigManifest.Subject)
_, ok := copiedAttestations[sigManifest.Subject.Digest]
require.True(t, ok)
}
}
// testImagetoolsOCILayoutMergeSources verifies create merges registry and local OCI layout sources.
func testImagetoolsOCILayoutMergeSources(t *testing.T, sb integration.Sandbox) {
if !isDockerContainerWorker(sb) {
@@ -871,7 +987,6 @@ func testImagetoolsCopyAttestationWithSignature(t *testing.T, sb integration.San
require.NotNil(t, signatureManifest.Subject)
require.Equal(t, attestationDesc.Digest, signatureManifest.Subject.Digest)
require.Equal(t, "dsse-envelope", signatureManifest.Annotations["dev.sigstore.bundle.content"])
}
// Only attestation signatures should be present after the copy. The
+33 -47
View File
@@ -273,7 +273,7 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error
return err
}
recorder := &recordingReferrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
referrers := &referrersProvider{base: referrersFunc(func(ctx context.Context, subject ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
descs, err := r.FetchReferrers(ctx, src.Ref, subject.Digest)
if err != nil {
return nil, err
@@ -287,12 +287,14 @@ func (r *Resolver) Copy(ctx context.Context, src *Source, dest *Location) error
return filtered, nil
})}
err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(recorder))
err = contentutil.CopyChain(ctx, ingester, provider, desc, contentutil.WithReferrers(referrers))
if err != nil {
return err
}
if dest.IsOCILayout() {
return r.writeRecordedReferrers(ctx, dest, recorder)
for subject, descs := range referrers.refs {
r.ociReferrers.record(dest.OCILayout().Path, subject, descs)
}
}
return nil
}
@@ -490,23 +492,40 @@ func (r *Resolver) filterPlatforms(ctx context.Context, dt []byte, desc ocispecs
return idxBytes, desc, mfstsWithSource, nil
}
type recordingReferrersProvider struct {
type referrersProvider struct {
base referrersFunc
refs map[digest.Digest][]ocispecs.Descriptor
}
func (r *recordingReferrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
func (r *referrersProvider) Referrers(ctx context.Context, desc ocispecs.Descriptor) ([]ocispecs.Descriptor, error) {
out, err := r.base(ctx, desc)
if err != nil {
return nil, err
}
out = dedupeDescriptors(out)
if r.refs == nil {
r.refs = map[digest.Digest][]ocispecs.Descriptor{}
}
r.refs[desc.Digest] = append(r.refs[desc.Digest], out...)
r.refs[desc.Digest] = dedupeDescriptors(append(r.refs[desc.Digest], out...))
return out, nil
}
func dedupeDescriptors(descs []ocispecs.Descriptor) []ocispecs.Descriptor {
if len(descs) < 2 {
return descs
}
seen := make(map[digest.Digest]struct{}, len(descs))
out := descs[:0]
for _, desc := range descs {
if _, ok := seen[desc.Digest]; ok {
continue
}
seen[desc.Digest] = struct{}{}
out = append(out, desc)
}
return out
}
func (r *Resolver) ingesterForLocation(loc *Location) (content.Ingester, error) {
if loc.IsRegistry() {
p, err := r.registryResolver().Pusher(context.TODO(), loc.Name())
@@ -552,52 +571,19 @@ func (r *Resolver) pushOCILayout(ctx context.Context, ref *Location, desc ocispe
idx := ociindex.NewStoreIndex(ref.OCILayout().Path)
switch {
case ref.Digest() != "":
return idx.Put(desc)
if err := idx.Put(desc); err != nil {
return err
}
case ref.Tag() != "":
return idx.Put(desc, ociindex.Tag(ref.Tag()))
if err := idx.Put(desc, ociindex.Tag(ref.Tag())); err != nil {
return err
}
default:
return idx.Put(desc, ociindex.Tag("latest"))
}
}
func (r *Resolver) writeRecordedReferrers(ctx context.Context, loc *Location, refs *recordingReferrersProvider) error {
if refs == nil || len(refs.refs) == 0 {
return nil
}
store, err := r.localStore(loc.OCILayout().Path)
if err != nil {
return err
}
idx := ociindex.NewStoreIndex(loc.OCILayout().Path)
for subject, manifests := range refs.refs {
fallback := ocispecs.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispecs.MediaTypeImageIndex,
Manifests: manifests,
}
dt, err := json.Marshal(fallback)
if err != nil {
return err
}
desc := ocispecs.Descriptor{
MediaType: ocispecs.MediaTypeImageIndex,
Digest: digest.FromBytes(dt),
Size: int64(len(dt)),
}
w, err := store.Writer(ctx, content.WithRef(desc.Digest.String()), content.WithDescriptor(desc))
if err != nil && !errdefs.IsAlreadyExists(err) {
return err
}
if err == nil {
if err := content.Copy(ctx, w, bytes.NewReader(dt), desc.Size, desc.Digest); err != nil && !errdefs.IsAlreadyExists(err) {
return err
}
}
if err := idx.Put(desc, ociindex.Tag("sha256-"+subject.Encoded())); err != nil {
if err := idx.Put(desc, ociindex.Tag("latest")); err != nil {
return err
}
}
return nil
return writePendingOCILayoutReferrers(ctx, r.ociReferrers.take(ref.OCILayout().Path), r.GetDescriptor, idx, ref)
}
func detectMediaType(dt []byte) (string, error) {
+2 -26
View File
@@ -3,7 +3,6 @@ package imagetools
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"sync"
@@ -38,6 +37,7 @@ type Resolver struct {
buffer contentutil.Buffer
localStoreMu sync.Mutex
localStores map[string]content.Store
ociReferrers ociLayoutReferrerRecorder
}
func New(opt Opt) *Resolver {
@@ -172,7 +172,7 @@ func (r *Resolver) localStore(path string) (content.Store, error) {
func (r *Resolver) FetchReferrers(ctx context.Context, loc *Location, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) {
if loc.IsOCILayout() {
return r.fetchOCILayoutReferrers(ctx, loc, dgst)
return fetchOCILayoutReferrers(ctx, r.GetDescriptor, loc, dgst)
}
f, err := r.registryResolver().Fetcher(ctx, loc.String())
if err != nil {
@@ -248,30 +248,6 @@ func (r *Resolver) resolveOCILayout(ctx context.Context, loc *Location) (string,
return loc.String(), *desc, nil
}
func (r *Resolver) fetchOCILayoutReferrers(ctx context.Context, loc *Location, dgst digest.Digest) ([]ocispecs.Descriptor, error) {
idx := ociindex.NewStoreIndex(loc.OCILayout().Path)
// TODO: temporary fallback tag, should use annotations instead
desc, err := idx.Get("sha256-" + dgst.Encoded())
if err != nil {
return nil, err
}
if desc == nil {
return nil, errors.WithStack(errdefs.ErrNotFound)
}
dt, err := r.GetDescriptor(ctx, loc, *desc)
if err != nil {
return nil, err
}
if desc.MediaType != ocispecs.MediaTypeImageIndex {
return nil, errors.Errorf("unsupported referrers media type %s", desc.MediaType)
}
var referrersIndex ocispecs.Index
if err := json.Unmarshal(dt, &referrersIndex); err != nil {
return nil, err
}
return referrersIndex.Manifests, nil
}
func parseRef(s string) (reference.Named, error) {
ref, err := reference.ParseNormalizedNamed(s)
if err != nil {
+212
View File
@@ -0,0 +1,212 @@
package imagetools
import (
"context"
"encoding/json"
"sync"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/errdefs"
"github.com/moby/buildkit/client/ociindex"
"github.com/moby/buildkit/util/attestation"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
)
type ociLayoutReferrerRecorder struct {
mu sync.Mutex
refs map[string]map[digest.Digest][]ocispecs.Descriptor
}
func (r *ociLayoutReferrerRecorder) record(path string, subject digest.Digest, descs []ocispecs.Descriptor) {
if len(descs) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.refs == nil {
r.refs = map[string]map[digest.Digest][]ocispecs.Descriptor{}
}
if r.refs[path] == nil {
r.refs[path] = map[digest.Digest][]ocispecs.Descriptor{}
}
r.refs[path][subject] = dedupeDescriptors(append(r.refs[path][subject], descs...))
}
func (r *ociLayoutReferrerRecorder) take(path string) map[digest.Digest][]ocispecs.Descriptor {
r.mu.Lock()
defer r.mu.Unlock()
if r.refs == nil {
return nil
}
out := r.refs[path]
delete(r.refs, path)
return out
}
func hasSubjectAnnotation(desc ocispecs.Descriptor) bool {
return desc.Annotations[images.AnnotationManifestSubject] != ""
}
// fetchOCILayoutReferrers resolves referrers for a subject from an OCI layout by
// combining directly indexed subject entries with referrers reachable from the
// regular named roots in index.json.
func fetchOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, subject digest.Digest) ([]ocispecs.Descriptor, error) {
idx, err := ociindex.NewStoreIndex(loc.OCILayout().Path).Read()
if err != nil {
return nil, err
}
out := map[digest.Digest]ocispecs.Descriptor{}
visited := map[digest.Digest]struct{}{}
for _, desc := range idx.Manifests {
if hasSubjectAnnotation(desc) {
continue
}
if err := collectReachableOCILayoutReferrers(ctx, getDescriptor, loc, desc, subject, visited, out); err != nil {
return nil, err
}
}
for _, desc := range idx.Manifests {
if desc.Annotations[images.AnnotationManifestSubject] == subject.String() {
out[desc.Digest] = desc
}
}
if len(out) == 0 {
return nil, errors.WithStack(errdefs.ErrNotFound)
}
refs := make([]ocispecs.Descriptor, 0, len(out))
for _, desc := range out {
refs = append(refs, desc)
}
return refs, nil
}
// collectReachableOCILayoutReferrers walks a regular OCI layout root and records
// referrer manifests for the requested subject that are already reachable from it.
func collectReachableOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, desc ocispecs.Descriptor, subject digest.Digest, visited map[digest.Digest]struct{}, out map[digest.Digest]ocispecs.Descriptor) error {
if _, ok := visited[desc.Digest]; ok {
return nil
}
visited[desc.Digest] = struct{}{}
if desc.Annotations[attestation.DockerAnnotationReferenceDigest] == subject.String() {
out[desc.Digest] = desc
}
switch desc.MediaType {
case ocispecs.MediaTypeImageIndex:
dt, err := getDescriptor(ctx, loc, desc)
if err != nil {
return err
}
var idx ocispecs.Index
if err := json.Unmarshal(dt, &idx); err != nil {
return errors.WithStack(err)
}
for _, child := range idx.Manifests {
if err := collectReachableOCILayoutReferrers(ctx, getDescriptor, loc, child, subject, visited, out); err != nil {
return err
}
}
case ocispecs.MediaTypeImageManifest:
dt, err := getDescriptor(ctx, loc, desc)
if err != nil {
return err
}
var mfst ocispecs.Manifest
if err := json.Unmarshal(dt, &mfst); err != nil {
return errors.WithStack(err)
}
if mfst.Subject != nil && mfst.Subject.Digest == subject {
out[desc.Digest] = desc
}
}
return nil
}
// writePendingOCILayoutReferrers adds copied referrers to index.json only when
// they are not already reachable from the regular top-level roots.
func writePendingOCILayoutReferrers(
ctx context.Context,
pending map[digest.Digest][]ocispecs.Descriptor,
getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error),
idx ociindex.StoreIndex,
loc *Location,
) error {
if len(pending) == 0 {
return nil
}
current, err := idx.Read()
if err != nil {
return err
}
reachable := map[digest.Digest]struct{}{}
visited := map[digest.Digest]struct{}{}
for _, desc := range current.Manifests {
if err := collectReachableDigests(ctx, getDescriptor, loc, desc, visited, reachable); err != nil {
return err
}
}
for subject, manifests := range pending {
for _, desc := range manifests {
if err := putSubjectReferrerIndexEntry(idx, reachable, subject, desc); err != nil {
return err
}
}
}
return nil
}
// collectReachableDigests records descriptors reachable from regular OCI layout
// roots so standalone subject-indexed referrers are not duplicated in index.json.
func collectReachableDigests(ctx context.Context, getDescriptor func(context.Context, *Location, ocispecs.Descriptor) ([]byte, error), loc *Location, desc ocispecs.Descriptor, visited map[digest.Digest]struct{}, reachable map[digest.Digest]struct{}) error {
if hasSubjectAnnotation(desc) {
return nil
}
if _, ok := visited[desc.Digest]; ok {
return nil
}
visited[desc.Digest] = struct{}{}
reachable[desc.Digest] = struct{}{}
if desc.MediaType != ocispecs.MediaTypeImageIndex {
return nil
}
dt, err := getDescriptor(ctx, loc, desc)
if err != nil {
return err
}
var idx ocispecs.Index
if err := json.Unmarshal(dt, &idx); err != nil {
return errors.WithStack(err)
}
for _, child := range idx.Manifests {
if err := collectReachableDigests(ctx, getDescriptor, loc, child, visited, reachable); err != nil {
return err
}
}
return nil
}
func putSubjectReferrerIndexEntry(idx ociindex.StoreIndex, reachable map[digest.Digest]struct{}, subject digest.Digest, desc ocispecs.Descriptor) error {
if _, ok := reachable[desc.Digest]; ok {
return nil
}
if desc.Annotations == nil {
desc.Annotations = map[string]string{}
}
if !hasSubjectAnnotation(desc) {
desc.Annotations[images.AnnotationManifestSubject] = subject.String()
}
return idx.Put(desc)
}