diff --git a/daemon/containerd/image_exporter.go b/daemon/containerd/image_exporter.go index 601b9833ac..d5ebe2ecd7 100644 --- a/daemon/containerd/image_exporter.go +++ b/daemon/containerd/image_exporter.go @@ -2,6 +2,7 @@ package containerd import ( "context" + "encoding/json" "fmt" "io" "strings" @@ -20,6 +21,7 @@ import ( "github.com/moby/moby/v2/daemon/images" "github.com/moby/moby/v2/daemon/internal/streamformatter" "github.com/moby/moby/v2/errdefs" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -34,6 +36,7 @@ import ( func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error { // Get the platform matcher for the requested platforms (matches all platforms if none specified) pm := matchAnyWithPreference(i.hostPlatformMatcher(), platformList) + referrers := newReferrersForExport(i.content) opts := []archive.ExportOpt{ archive.WithSkipNonDistributableBlobs(), @@ -51,6 +54,7 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform // Importing the same archive into containerd, will not restrict the platforms. archive.WithPlatform(pm), archive.WithSkipMissing(i.content), + archive.WithReferrersProvider(referrers), } ctx, done, err := i.withLease(ctx, false) @@ -198,6 +202,8 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform } } + opts = append(opts, archive.WithReferrersProvider(referrers)) + return i.client.Export(ctx, outStream, opts...) } @@ -249,6 +255,8 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf opts := []containerd.ImportOpt{ containerd.WithImportPlatform(pm), + containerd.WithImportReferrers(newReferrersForImport(i.content)), + // Create an additional image with dangling name for imported images... containerd.WithDigestRef(danglingImageName), // ... but only if they don't have a name or it's invalid. @@ -444,3 +452,79 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platformNames)) } + +type referrersForImport struct { + store content.Store + candidates *referrersList +} + +func newReferrersForImport(store content.Store) *referrersForImport { + return &referrersForImport{ + store: store, + candidates: newReferrersList(), + } +} + +func (r *referrersForImport) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + if r.candidates.readFrom(ctx, r.store, desc) != nil { + return nil, nil + } + refs, ok := r.candidates.Get(desc.Digest) + if !ok { + return nil, nil + } + return refs, nil +} + +type referrersForExport struct { + store content.Store +} + +func newReferrersForExport(store content.Store) *referrersForExport { + return &referrersForExport{ + store: store, + } +} + +func (r *referrersForExport) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + info, err := r.store.Info(ctx, desc.Digest) + if err != nil { + if errors.Is(err, cerrdefs.ErrNotFound) { + return nil, nil + } + return nil, err + } + var refs []ocispec.Descriptor + for k, v := range info.Labels { + if strings.HasPrefix(k, "containerd.io/gc.ref.content.referrer.sha256.") { + dgst, err := digest.Parse(v) + if err != nil { + continue + } + var desc ocispec.Descriptor + desc.Digest = dgst + info, err := r.store.Info(ctx, dgst) + if err != nil { + continue + } + desc.Size = info.Size + // parse mediatype and artifact type + dt, err := content.ReadBlob(ctx, r.store, ocispec.Descriptor{Digest: dgst}) + if err != nil { + continue + } + var mfst ocispec.Manifest + if err := json.Unmarshal(dt, &mfst); err != nil { + continue + } + desc.MediaType = mfst.MediaType + if mfst.ArtifactType != "" { + desc.ArtifactType = mfst.ArtifactType + } + // TODO: we should only export signatures but cosign doesn't set artifact type on payload + refs = append(refs, desc) + } + } + + return refs, nil +} diff --git a/daemon/containerd/image_pull.go b/daemon/containerd/image_pull.go index 7a24a32000..fcb740fdee 100644 --- a/daemon/containerd/image_pull.go +++ b/daemon/containerd/image_pull.go @@ -2,19 +2,27 @@ package containerd import ( "context" + "encoding/json" "fmt" + "slices" "strings" + "sync" "sync/atomic" "time" containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" c8dimages "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" "github.com/containerd/containerd/v2/pkg/snapshotters" cerrdefs "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/platforms" "github.com/distribution/reference" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/util/attestation" "github.com/moby/moby/api/types/events" registrytypes "github.com/moby/moby/api/types/registry" "github.com/moby/moby/v2/daemon/internal/distribution" @@ -24,6 +32,8 @@ import ( "github.com/moby/moby/v2/daemon/internal/stringid" "github.com/moby/moby/v2/daemon/server/imagebackend" "github.com/moby/moby/v2/errdefs" + policyimage "github.com/moby/policy-helpers/image" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -214,7 +224,11 @@ func (i *ImageService) pullTag(ctx context.Context, ref reference.Named, platfor // this information is used to enable remote snapshotters like nydus and stargz to query a registry. // This is also needed for the pull progress to detect the `Extracting` status. infoHandler := snapshotters.AppendInfoHandlerWrapper(ref.String()) - opts = append(opts, containerd.WithImageHandlerWrapper(infoHandler)) + + referrers := newReferrersForPull(ref.String(), resolver, i.client.ContentStore()) + + opts = append(opts, containerd.WithImageHandlerWrapper(joinHandlerWrappers(infoHandler, referrers.Handler))) + opts = append(opts, containerd.WithReferrersProvider(referrers)) img, err := i.client.Pull(ctx, ref.String(), opts...) if err != nil { @@ -257,9 +271,195 @@ func (i *ImageService) pullTag(ctx context.Context, ref reference.Named, platfor i.LogImageEvent(ctx, reference.FamiliarString(ref), reference.FamiliarName(ref), events.ActionPull) outNewImg = img + return nil } +func joinHandlerWrappers(funcs ...func(c8dimages.Handler) c8dimages.Handler) func(c8dimages.Handler) c8dimages.Handler { + return func(h c8dimages.Handler) c8dimages.Handler { + for _, f := range funcs { + h = f(h) + } + return h + } +} + +type referrersForPull struct { + mu sync.Mutex + ref string + store content.Store + candidates *referrersList + isAttestationManifest map[digest.Digest]struct{} + resolver remotes.Resolver +} + +func newReferrersForPull(ref string, resolver remotes.Resolver, st content.Store) *referrersForPull { + return &referrersForPull{ + ref: ref, + candidates: newReferrersList(), + isAttestationManifest: make(map[digest.Digest]struct{}), + store: st, + resolver: resolver, + } +} + +func (h *referrersForPull) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + h.mu.Lock() + defer h.mu.Unlock() + + if m, ok := h.candidates.Get(desc.Digest); ok { + for i, att := range m { + if att.Annotations[attestation.DockerAnnotationReferenceType] == attestation.DockerAnnotationReferenceTypeDefault { + att.Platform = nil + h.isAttestationManifest[att.Digest] = struct{}{} + m[i] = att + } + } + return m, nil + } else if _, ok := h.isAttestationManifest[desc.Digest]; ok { + f, err := h.resolver.Fetcher(ctx, h.ref) + if err != nil { + return nil, err + } + referrers, ok := f.(remotes.ReferrersFetcher) + if !ok { + return nil, errors.New("resolver does not support fetching referrers") + } + + // we are currently intentionally not passing filter to FetchReferrers here because + // of known issue in AWS registry that return empty result when multiple filters are applied + descs, err := referrers.FetchReferrers(ctx, desc.Digest) + if err != nil { + return nil, err + } + // manual filtering to work around the issue mentioned above + filtered := make([]ocispec.Descriptor, 0, len(descs)) + for _, att := range descs { + switch att.ArtifactType { + case policyimage.ArtifactTypeCosignSignature, policyimage.ArtifactTypeSigstoreBundle: + filtered = append(filtered, att) + } + } + return filtered, nil + } + return nil, nil +} + +func (h *referrersForPull) Handler(f c8dimages.Handler) c8dimages.Handler { + return c8dimages.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + children, err := f.Handle(ctx, desc) + if err != nil { + return nil, err + } + + if err := h.candidates.readFrom(ctx, h.store, desc); err != nil { + return nil, err + } + + h.mu.Lock() + defer h.mu.Unlock() + if c8dimages.IsManifestType(desc.MediaType) { + if _, ok := h.isAttestationManifest[desc.Digest]; ok { + // for matched attestation manifest, we only need provenance attestation + dt, err := content.ReadBlob(ctx, h.store, desc) + if err != nil { + return nil, err + } + + var mfst ocispec.Manifest + if err := json.Unmarshal(dt, &mfst); err != nil { + return nil, err + } + var provenance []ocispec.Descriptor + for _, desc := range mfst.Layers { + pType, ok := desc.Annotations["in-toto.io/predicate-type"] + if !ok { + continue + } + switch pType { + case slsa1.PredicateSLSAProvenance, slsa02.PredicateSLSAProvenance: + provenance = append(provenance, desc) + default: + } + } + _ = provenance // TODO: filter out non-provenance attestation + } + } + return children, nil + }) +} + +type referrersList struct { + mu sync.RWMutex + m map[digest.Digest][]ocispec.Descriptor +} + +func newReferrersList() *referrersList { + return &referrersList{ + m: make(map[digest.Digest][]ocispec.Descriptor), + } +} +func (rl *referrersList) Get(dgst digest.Digest) ([]ocispec.Descriptor, bool) { + rl.mu.RLock() + defer rl.mu.RUnlock() + descs, ok := rl.m[dgst] + return descs, ok +} + +func (rl *referrersList) readFrom(ctx context.Context, st content.Store, desc ocispec.Descriptor) error { + if !c8dimages.IsIndexType(desc.MediaType) { + return nil + } + + p, err := content.ReadBlob(ctx, st, desc) + if err != nil { + return err + } + var index ocispec.Index + if err := json.Unmarshal(p, &index); err != nil { + return err + } + rl.mu.Lock() + defer rl.mu.Unlock() + if rl.m == nil { + rl.m = make(map[digest.Digest][]ocispec.Descriptor) + } + for _, desc := range index.Manifests { + if !c8dimages.IsManifestType(desc.MediaType) { + continue + } + subject, err := parseSubject(desc) + if err != nil || subject == "" { + continue + } + rl.m[subject] = slices.DeleteFunc(rl.m[subject], func(d ocispec.Descriptor) bool { + if d.Digest == desc.Digest { + return true + } + if _, ok := desc.Annotations[attestation.DockerAnnotationReferenceType]; ok { + // for inline attestation, last ref wins + return true + } + return false + }) + rl.m[subject] = append(rl.m[subject], desc) + } + return nil +} + +func parseSubject(desc ocispec.Descriptor) (digest.Digest, error) { + var dgstStr string + if refType, ok := desc.Annotations[attestation.DockerAnnotationReferenceType]; ok && refType == attestation.DockerAnnotationReferenceTypeDefault { + dgstStr, ok = desc.Annotations[attestation.DockerAnnotationReferenceDigest] + if !ok { + return "", errors.New("invalid referrer manifest: missing subject digest") + } + } else if subject, ok := desc.Annotations[c8dimages.AnnotationManifestSubject]; ok { + dgstStr = subject + } + return digest.Parse(dgstStr) +} + // writeStatus writes a status message to out. If newerDownloaded is true, the // status message indicates that a newer image was downloaded. Otherwise, it // indicates that the image is up to date. requestedTag is the tag the message diff --git a/daemon/hosts.go b/daemon/hosts.go index 9a76f2e956..6b2a0fe702 100644 --- a/daemon/hosts.go +++ b/daemon/hosts.go @@ -86,7 +86,7 @@ func mirrorsToRegistryHosts(mirrors []string, dHost docker.RegistryHost) []docke var mirrorHosts []docker.RegistryHost for _, mirror := range mirrors { h := dHost - h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve + h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers u, err := url.Parse(mirror) if err != nil || u.Host == "" { diff --git a/daemon/hosts_test.go b/daemon/hosts_test.go index c7764d31cd..2e0803aeaa 100644 --- a/daemon/hosts_test.go +++ b/daemon/hosts_test.go @@ -9,8 +9,8 @@ import ( ) func TestMirrorsToHosts(t *testing.T) { - pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve - allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush + pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers + allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush | docker.HostCapabilityReferrers defaultRegistry := testRegistryHost("https", "registry-1.docker.com", "/v2", allCaps) for _, tc := range []struct { mirrors []string diff --git a/daemon/pkg/plugin/registry.go b/daemon/pkg/plugin/registry.go index ebd55a721d..f2ffad09b4 100644 --- a/daemon/pkg/plugin/registry.go +++ b/daemon/pkg/plugin/registry.go @@ -73,7 +73,7 @@ func (pm *Manager) registryHostsFn(auth *registry.AuthConfig, httpFallback bool) continue } - caps := docker.HostCapabilityPull | docker.HostCapabilityResolve + caps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers if !ep.Mirror { caps = caps | docker.HostCapabilityPush } diff --git a/go.mod b/go.mod index 9b6308524d..4885be0a55 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/hashicorp/go-memdb v1.3.5 github.com/hashicorp/memberlist v0.4.0 github.com/hashicorp/serf v0.8.5 + github.com/in-toto/in-toto-golang v0.9.0 github.com/ishidawataru/sctp v0.0.0-20250829011129-4b890084db30 github.com/miekg/dns v1.1.66 github.com/mistifyio/go-zfs/v3 v3.0.1 @@ -63,6 +64,7 @@ require ( github.com/moby/moby/api v1.52.0 github.com/moby/moby/client v0.1.0 github.com/moby/patternmatcher v0.6.0 + github.com/moby/policy-helpers v0.0.0-20251105011237-bcaa71c99f14 github.com/moby/profiles/apparmor v0.1.0 github.com/moby/profiles/seccomp v0.1.0 github.com/moby/pubsub v1.0.0 @@ -188,13 +190,11 @@ require ( github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hiddeco/sshsig v0.2.0 // indirect - github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmoiron/sqlx v1.3.3 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/moby/policy-helpers v0.0.0-20251105011237-bcaa71c99f14 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect