Skip to content

Commit 955d215

Browse files
committed
Add delta manifest and delta layer support
Generated-By: Claude Code Signed-off-by: Vance Raiti <[email protected]>
1 parent e5d9c8c commit 955d215

File tree

14 files changed

+420
-8
lines changed

14 files changed

+420
-8
lines changed

image/copy/compression.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ func (ic *imageCopier) blobPipelineCompressionStep(stream *sourceStream, canModi
120120
if canModifyBlob && layerCompressionChangeSupported {
121121
for _, fn := range []func(*sourceStream, bpDetectCompressionStepData) (*bpCompressionStepData, error){
122122
ic.bpcPreserveEncrypted,
123+
ic.bpcPreserveNoCompress,
123124
ic.bpcCompressUncompressed,
124125
ic.bpcRecompressCompressed,
125126
ic.bpcDecompressCompressed,
@@ -153,6 +154,22 @@ func (ic *imageCopier) bpcPreserveEncrypted(stream *sourceStream, _ bpDetectComp
153154
return nil, nil
154155
}
155156

157+
// bpcPreserveNoCompress checks if the input is a no-compress type (like tar-diff), and returns a *bpCompressionStepData if so.
158+
func (ic *imageCopier) bpcPreserveNoCompress(stream *sourceStream, _ bpDetectCompressionStepData) (*bpCompressionStepData, error) {
159+
if manifest.IsNoCompressType(stream.info.MediaType) {
160+
logrus.Debugf("Using original blob without modification for no-compress type")
161+
return &bpCompressionStepData{
162+
operation: bpcOpPreserveOpaque,
163+
uploadedOperation: types.PreserveOriginal,
164+
uploadedAlgorithm: nil,
165+
srcCompressorBaseVariantName: internalblobinfocache.UnknownCompression,
166+
uploadedCompressorBaseVariantName: internalblobinfocache.UnknownCompression,
167+
uploadedCompressorSpecificVariantName: internalblobinfocache.UnknownCompression,
168+
}, nil
169+
}
170+
return nil, nil
171+
}
172+
156173
// bpcCompressUncompressed checks if we should be compressing an uncompressed input, and returns a *bpCompressionStepData if so.
157174
func (ic *imageCopier) bpcCompressUncompressed(stream *sourceStream, detected bpDetectCompressionStepData) (*bpCompressionStepData, error) {
158175
if ic.c.dest.DesiredLayerCompression() == types.Compress && !detected.isCompressed {

image/copy/single.go

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import (
1010
"maps"
1111
"reflect"
1212
"slices"
13+
"sort"
1314
"strings"
1415
"sync"
1516

17+
tarpatch "github.com/containers/tar-diff/pkg/tar-patch"
1618
digest "github.com/opencontainers/go-digest"
1719
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
1820
"github.com/sirupsen/logrus"
@@ -30,6 +32,20 @@ import (
3032
chunkedToc "go.podman.io/storage/pkg/chunked/toc"
3133
)
3234

35+
// formatSize formats a size in bytes with dynamic units (B, KB, MB, GB)
36+
func formatSize(size int64) string {
37+
const unit = 1024
38+
if size < unit {
39+
return fmt.Sprintf("%d B", size)
40+
}
41+
div, exp := int64(unit), 0
42+
for n := size / unit; n >= unit; n /= unit {
43+
div *= unit
44+
exp++
45+
}
46+
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGT"[exp])
47+
}
48+
3349
// imageCopier tracks state specific to a single image (possibly an item of a manifest list)
3450
type imageCopier struct {
3551
c *copier
@@ -448,6 +464,11 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor
448464
srcInfosUpdated = true
449465
}
450466

467+
deltaLayers, err := types.ImageDeltaLayers(ic.src, ctx)
468+
if err != nil {
469+
return nil, err
470+
}
471+
451472
type copyLayerData struct {
452473
destInfo types.BlobInfo
453474
diffID digest.Digest
@@ -466,7 +487,7 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor
466487
copyGroup := sync.WaitGroup{}
467488

468489
data := make([]copyLayerData, len(srcInfos))
469-
copyLayerHelper := func(index int, srcLayer types.BlobInfo, toEncrypt bool, pool *mpb.Progress, srcRef reference.Named) {
490+
copyLayerHelper := func(index int, srcLayer types.BlobInfo, toEncrypt bool, pool *mpb.Progress, srcRef reference.Named, deltaLayers []types.BlobInfo) {
470491
defer ic.c.concurrentBlobCopiesSemaphore.Release(1)
471492
defer copyGroup.Done()
472493
cld := copyLayerData{}
@@ -481,7 +502,7 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor
481502
logrus.Debugf("Skipping foreign layer %q copy to %s", cld.destInfo.Digest, ic.c.dest.Reference().Transport().Name())
482503
}
483504
} else {
484-
cld.destInfo, cld.diffID, cld.err = ic.copyLayer(ctx, srcLayer, toEncrypt, pool, index, srcRef, manifestLayerInfos[index].EmptyLayer)
505+
cld.destInfo, cld.diffID, cld.err = ic.copyLayer(ctx, index, srcLayer, toEncrypt, pool, srcRef, manifestLayerInfos[index].EmptyLayer, deltaLayers)
485506
}
486507
data[index] = cld
487508
}
@@ -521,7 +542,7 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor
521542
return fmt.Errorf("copying layer: %w", err)
522543
}
523544
copyGroup.Add(1)
524-
go copyLayerHelper(i, srcLayer, layersToEncrypt.Contains(i), progressPool, ic.c.rawSource.Reference().DockerReference())
545+
go copyLayerHelper(i, srcLayer, layersToEncrypt.Contains(i), progressPool, ic.c.rawSource.Reference().DockerReference(), deltaLayers)
525546
}
526547

527548
// A call to copyGroup.Wait() is done at this point by the defer above.
@@ -690,10 +711,85 @@ func compressionEditsFromBlobInfo(srcInfo types.BlobInfo) (types.LayerCompressio
690711
}
691712
}
692713

714+
// getMatchingDeltaLayers gets all the deltas that apply to this layer
715+
func (ic *imageCopier) getMatchingDeltaLayers(ctx context.Context, srcIndex int, deltaLayers []types.BlobInfo) (digest.Digest, []*types.BlobInfo) {
716+
if deltaLayers == nil {
717+
return "", nil
718+
}
719+
config, _ := ic.src.OCIConfig(ctx)
720+
if config == nil || config.RootFS.DiffIDs == nil || len(config.RootFS.DiffIDs) <= srcIndex {
721+
return "", nil
722+
}
723+
724+
layerDiffID := config.RootFS.DiffIDs[srcIndex]
725+
726+
var matchingLayers []*types.BlobInfo
727+
for i := range deltaLayers {
728+
deltaLayer := &deltaLayers[i]
729+
to := deltaLayer.Annotations["io.github.containers.delta.to"]
730+
if to == layerDiffID.String() {
731+
matchingLayers = append(matchingLayers, deltaLayer)
732+
}
733+
}
734+
735+
return layerDiffID, matchingLayers
736+
}
737+
738+
// resolveDeltaLayer looks at which of the matching delta froms have locally available data and picks the best one
739+
func (ic *imageCopier) resolveDeltaLayer(ctx context.Context, matchingDeltas []*types.BlobInfo) (io.ReadCloser, tarpatch.DataSource, types.BlobInfo, error) {
740+
// Sort smallest deltas so we favour the smallest useable one
741+
sort.Slice(matchingDeltas, func(i, j int) bool {
742+
return matchingDeltas[i].Size < matchingDeltas[j].Size
743+
})
744+
745+
for i := range matchingDeltas {
746+
matchingDelta := matchingDeltas[i]
747+
from := matchingDelta.Annotations["io.github.containers.delta.from"]
748+
fromDigest, err := digest.Parse(from)
749+
if err != nil {
750+
continue // Silently ignore if server specified a weird format
751+
}
752+
753+
dataSource, err := types.ImageDestinationGetLayerDeltaData(ic.c.dest, ctx, fromDigest)
754+
if err != nil {
755+
return nil, nil, types.BlobInfo{}, err // Internal error
756+
}
757+
if dataSource == nil {
758+
continue // from layer doesn't exist
759+
}
760+
761+
logrus.Debugf("Using delta %v for DiffID %v", matchingDelta.Digest, fromDigest)
762+
763+
deltaStream, _, err := ic.c.rawSource.GetBlob(ctx, *matchingDelta, ic.c.blobInfoCache)
764+
if err != nil {
765+
return nil, nil, types.BlobInfo{}, fmt.Errorf("reading delta blob %s: %w", matchingDelta.Digest, err)
766+
}
767+
return deltaStream, dataSource, *matchingDelta, nil
768+
}
769+
return nil, nil, types.BlobInfo{}, nil
770+
}
771+
772+
// canUseDeltas checks if deltas can be used for this layer
773+
func (ic *imageCopier) canUseDeltas(srcInfo types.BlobInfo) (bool, string) {
774+
// Deltas rewrite the manifest to refer to the uncompressed digest, so we must be able to substitute blobs
775+
if !ic.canSubstituteBlobs {
776+
return false, ""
777+
}
778+
779+
switch srcInfo.MediaType {
780+
case manifest.DockerV2Schema2LayerMediaType, manifest.DockerV2SchemaLayerMediaTypeUncompressed:
781+
return true, manifest.DockerV2SchemaLayerMediaTypeUncompressed
782+
case imgspecv1.MediaTypeImageLayer, imgspecv1.MediaTypeImageLayerGzip, imgspecv1.MediaTypeImageLayerZstd:
783+
return true, imgspecv1.MediaTypeImageLayer
784+
}
785+
786+
return false, ""
787+
}
788+
693789
// copyLayer copies a layer with srcInfo (with known Digest and Annotations and possibly known Size) in src to dest, perhaps (de/re/)compressing it,
694790
// and returns a complete blobInfo of the copied layer, and a value for LayerDiffIDs if diffIDIsNeeded
695791
// srcRef can be used as an additional hint to the destination during checking whether a layer can be reused but srcRef can be nil.
696-
func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, toEncrypt bool, pool *mpb.Progress, layerIndex int, srcRef reference.Named, emptyLayer bool) (types.BlobInfo, digest.Digest, error) {
792+
func (ic *imageCopier) copyLayer(ctx context.Context, srcIndex int, srcInfo types.BlobInfo, toEncrypt bool, pool *mpb.Progress, srcRef reference.Named, emptyLayer bool, deltaLayers []types.BlobInfo) (types.BlobInfo, digest.Digest, error) {
697793
// If the srcInfo doesn't contain compression information, try to compute it from the
698794
// MediaType, which was either read from a manifest by way of LayerInfos() or constructed
699795
// by LayerInfosForCopy(), if it was supplied at all. If we succeed in copying the blob,
@@ -712,6 +808,59 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
712808

713809
ic.c.printCopyInfo("blob", srcInfo)
714810

811+
// First look for a delta that matches this layer and substitute the result of that
812+
if ok, deltaResultMediaType := ic.canUseDeltas(srcInfo); ok {
813+
// Get deltas going TO this layer
814+
deltaDiffID, matchingDeltas := ic.getMatchingDeltaLayers(ctx, srcIndex, deltaLayers)
815+
// Get best possible FROM delta
816+
deltaStream, deltaDataSource, matchingDelta, err := ic.resolveDeltaLayer(ctx, matchingDeltas)
817+
if err != nil {
818+
return types.BlobInfo{}, "", err
819+
}
820+
if deltaStream != nil {
821+
logrus.Debugf("Applying delta for layer %s (delta size: %.1f MB)", deltaDiffID, float64(matchingDelta.Size)/(1024.0*1024.0))
822+
bar, err := ic.c.createProgressBar(pool, false, matchingDelta, "delta", "done")
823+
if err != nil {
824+
return types.BlobInfo{}, "", err
825+
}
826+
827+
wrappedDeltaStream := bar.ProxyReader(deltaStream)
828+
829+
// Convert deltaStream to uncompressed tar layer stream
830+
pr, pw := io.Pipe()
831+
go func() {
832+
if err := tarpatch.Apply(wrappedDeltaStream, deltaDataSource, pw); err != nil {
833+
// We will notice this error when failing to verify the digest, so leave it be
834+
logrus.Infof("Failed to apply layer delta: %v", err)
835+
}
836+
deltaDataSource.Close()
837+
deltaStream.Close()
838+
wrappedDeltaStream.Close()
839+
pw.Close()
840+
}()
841+
defer pr.Close()
842+
843+
// Copy uncompressed tar layer to destination, verifying the diffID
844+
blobInfo, diffIDChan, err := ic.copyLayerFromStream(ctx, pr, types.BlobInfo{Digest: deltaDiffID, Size: -1, MediaType: deltaResultMediaType, Annotations: srcInfo.Annotations}, true, toEncrypt, bar, srcIndex, emptyLayer)
845+
if err != nil {
846+
return types.BlobInfo{}, "", err
847+
}
848+
849+
// Wait for diffID verification
850+
diffIDResult := <-diffIDChan
851+
if diffIDResult.err != nil {
852+
return types.BlobInfo{}, "", diffIDResult.err
853+
}
854+
855+
bar.SetTotal(matchingDelta.Size, true)
856+
857+
// Record the fact that this blob is uncompressed
858+
ic.c.blobInfoCache.RecordDigestUncompressedPair(diffIDResult.digest, diffIDResult.digest)
859+
860+
return blobInfo, diffIDResult.digest, nil
861+
}
862+
}
863+
715864
diffIDIsNeeded := false
716865
var cachedDiffID digest.Digest = ""
717866
if ic.diffIDsAreNeeded {
@@ -751,7 +900,7 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
751900
Cache: ic.c.blobInfoCache,
752901
CanSubstitute: canSubstitute,
753902
EmptyLayer: emptyLayer,
754-
LayerIndex: &layerIndex,
903+
LayerIndex: &srcIndex,
755904
SrcRef: srcRef,
756905
PossibleManifestFormats: append([]string{ic.manifestConversionPlan.preferredMIMEType}, ic.manifestConversionPlan.otherMIMETypeCandidates...),
757906
RequiredCompression: requiredCompression,
@@ -813,7 +962,7 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
813962
uploadedBlob, err := ic.c.dest.PutBlobPartial(ctx, &proxy, srcInfo, private.PutBlobPartialOptions{
814963
Cache: ic.c.blobInfoCache,
815964
EmptyLayer: emptyLayer,
816-
LayerIndex: layerIndex,
965+
LayerIndex: srcIndex,
817966
})
818967
if err == nil {
819968
if srcInfo.Size != -1 {
@@ -856,7 +1005,9 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
8561005
}
8571006
defer srcStream.Close()
8581007

859-
blobInfo, diffIDChan, err := ic.copyLayerFromStream(ctx, srcStream, types.BlobInfo{Digest: srcInfo.Digest, Size: srcBlobSize, MediaType: srcInfo.MediaType, Annotations: srcInfo.Annotations}, diffIDIsNeeded, toEncrypt, bar, layerIndex, emptyLayer)
1008+
logrus.Debugf("Downloading layer %s (blob size: %s)", srcInfo.Digest, formatSize(srcBlobSize))
1009+
1010+
blobInfo, diffIDChan, err := ic.copyLayerFromStream(ctx, srcStream, types.BlobInfo{Digest: srcInfo.Digest, Size: srcBlobSize, MediaType: srcInfo.MediaType, Annotations: srcInfo.Annotations}, diffIDIsNeeded, toEncrypt, bar, srcIndex, emptyLayer)
8601011
if err != nil {
8611012
return types.BlobInfo{}, "", err
8621013
}

image/docker/docker_image_src.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,47 @@ func (s *dockerImageSource) appendSignaturesFromSigstoreAttachments(ctx context.
657657
return nil
658658
}
659659

660+
// GetDeltaManifest returns the delta manifest for the current image, as well as its type, if it exists.
661+
// No error is returned if no delta manifest exists, just a nil slice
662+
func (s *dockerImageSource) GetDeltaManifest(ctx context.Context, instanceDigest *digest.Digest) ([]byte, string, error) {
663+
// Get the real manifest digest
664+
srcManifestDigest, err := s.manifestDigest(ctx, instanceDigest)
665+
if err != nil {
666+
return nil, "", err
667+
}
668+
669+
// Load the delta manifest index
670+
ib, _, err := s.fetchManifest(ctx, "_deltaindex")
671+
// Don't return error if the manifest doesn't exist, only for internal errors
672+
// Deltas are an optional optimization anyway
673+
if err == nil {
674+
index, err := manifest.OCI1IndexFromManifest(ib)
675+
if err != nil {
676+
return nil, "", err
677+
}
678+
679+
// Look up the delta manifest in the index by the real manifest digest
680+
for _, m := range index.Manifests {
681+
if m.Annotations["io.github.containers.delta.target"] == srcManifestDigest.String() {
682+
return s.fetchManifest(ctx, m.Digest.String())
683+
}
684+
}
685+
}
686+
687+
// No delta
688+
return nil, "", nil
689+
}
690+
691+
// GetDeltaIndex returns an ImageReference that can be used to update the delta index for deltas for Image.
692+
func (s *dockerImageSource) GetDeltaIndex(ctx context.Context) (types.ImageReference, error) {
693+
deltaRef, err := reference.WithTag(s.logicalRef.ref, "_deltaindex")
694+
if err != nil {
695+
return nil, err
696+
}
697+
698+
return newReference(deltaRef, false)
699+
}
700+
660701
// deleteImage deletes the named image from the registry, if supported.
661702
func deleteImage(ctx context.Context, sys *types.SystemContext, ref dockerReference) error {
662703
if ref.isUnknownDigest {

image/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/BurntSushi/toml v1.5.0
1111
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01
1212
github.com/containers/ocicrypt v1.2.1
13+
github.com/containers/tar-diff v0.1.2
1314
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467
1415
github.com/distribution/reference v0.6.0
1516
github.com/docker/cli v29.0.4+incompatible

image/internal/image/unparsed.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,23 @@ func (i *UnparsedImage) UntrustedSignatures(ctx context.Context) ([]signature.Si
123123
}
124124
return i.cachedSignatures, nil
125125
}
126+
127+
// DeltaLayers downloads and parses the delta manifest for the image, returning the available delta layers
128+
func (i *UnparsedImage) DeltaLayers(ctx context.Context) ([]types.BlobInfo, error) {
129+
// Note that GetDeltaManifest can return nil with a nil error. This is ok if no deltas exist
130+
mb, mt, err := types.ImageSourceGetDeltaManifest(i.src, ctx, i.instanceDigest)
131+
if mb == nil {
132+
return nil, err
133+
}
134+
135+
m, err := manifest.FromBlob(mb, mt)
136+
if err != nil {
137+
return nil, err
138+
}
139+
layerInfos := m.LayerInfos()
140+
blobInfos := make([]types.BlobInfo, len(layerInfos))
141+
for i, li := range layerInfos {
142+
blobInfos[i] = li.BlobInfo
143+
}
144+
return blobInfos, nil
145+
}

image/internal/testing/mocks/image_source.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,13 @@ func (f ForbiddenImageSource) GetSignatures(context.Context, *digest.Digest) ([]
4545
func (f ForbiddenImageSource) LayerInfosForCopy(context.Context, *digest.Digest) ([]types.BlobInfo, error) {
4646
panic("Unexpected call to a mock function")
4747
}
48+
49+
// GetDeltaManifest is a mock that panics.
50+
func (f ForbiddenImageSource) GetDeltaManifest(context.Context, *digest.Digest) ([]byte, string, error) {
51+
panic("Unexpected call to a mock function")
52+
}
53+
54+
// GetDeltaIndex is a mock that panics.
55+
func (f ForbiddenImageSource) GetDeltaIndex(context.Context) (types.ImageReference, error) {
56+
panic("Unexpected call to a mock function")
57+
}

image/internal/testing/mocks/unparsed_image.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ func (ref ForbiddenUnparsedImage) Signatures(context.Context) ([][]byte, error)
2929
func (ref ForbiddenUnparsedImage) UntrustedSignatures(ctx context.Context) ([]signature.Signature, error) {
3030
panic("unexpected call to a mock function")
3131
}
32+
33+
// DeltaLayers is a mock that panics.
34+
func (ref ForbiddenUnparsedImage) DeltaLayers(ctx context.Context) ([]types.BlobInfo, error) {
35+
panic("unexpected call to a mock function")
36+
}

0 commit comments

Comments
 (0)