mirror of
https://github.com/moby/moby.git
synced 2026-01-11 02:31:44 +00:00
image: pull/load/save attestation manifest and signatures with image
Updates docker pull to pull related attestation manifest and any signatures for that manifest in cosign referrer objects. These objects are transferred with the image when running docker save and docker load and can be used to identify the image in future updates. Push is not updated atm as the currect push semantics in containerd mode do not have correct immutability guaranteed and don't work with image indexes. Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
4
go.mod
4
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
|
||||
|
||||
Reference in New Issue
Block a user