Skip to content

Commit 8cf69fd

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

File tree

19 files changed

+990
-15
lines changed

19 files changed

+990
-15
lines changed

go.work.sum

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
3+
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
34
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
45
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
56
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
67
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
7-
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
8+
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e h1:BWhy2j3IXJhjCbC68FptL43tDKIq8FladmaTs3Xs7Z8=
89
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
9-
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
1010
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
1111
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
12-
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
13-
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
14-
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
15-
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
12+
github.com/mistifyio/go-zfs v2.1.1+incompatible h1:gAMO1HM9xBRONLHHYnu5iFsOJUiJdNZo6oqSENd4eW8=
1613
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
1714
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
1815
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
1916
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
17+
github.com/tchap/go-patricia v2.3.0+incompatible h1:GkY4dP3cEfEASBPPkWd+AmjYxhmDkqO9/zg7R0lSQRs=
2018
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
2119
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
2220
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
@@ -26,7 +24,6 @@ golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
2624
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
2725
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
2826
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29-
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3027
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3128
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3229
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

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

0 commit comments

Comments
 (0)