Move images to core/images

Signed-off-by: Derek McGowan <derek@mcg.dev>
This commit is contained in:
Derek McGowan
2024-01-17 09:51:26 -08:00
parent 913edcd489
commit 57ea8aef3d
88 changed files with 90 additions and 90 deletions

View File

@@ -0,0 +1,23 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
const (
// AnnotationImageName is an annotation on a Descriptor in an index.json
// containing the `Name` value as used by an `Image` struct
AnnotationImageName = "io.containerd.image.name"
)

View File

@@ -0,0 +1,588 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package archive
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
"io"
"path"
"sort"
"strings"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/labels"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/log"
digest "github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
type exportOptions struct {
manifests []ocispec.Descriptor
platform platforms.MatchComparer
allPlatforms bool
skipDockerManifest bool
blobRecordOptions blobRecordOptions
}
// ExportOpt defines options for configuring exported descriptors
type ExportOpt func(context.Context, *exportOptions) error
// WithPlatform defines the platform to require manifest lists have
// not exporting all platforms.
// Additionally, platform is used to resolve image configs for
// Docker v1.1, v1.2 format compatibility.
func WithPlatform(p platforms.MatchComparer) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
o.platform = p
return nil
}
}
// WithAllPlatforms exports all manifests from a manifest list.
// Missing content will fail the export.
func WithAllPlatforms() ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
o.allPlatforms = true
return nil
}
}
// WithSkipDockerManifest skips creation of the Docker compatible
// manifest.json file.
func WithSkipDockerManifest() ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
o.skipDockerManifest = true
return nil
}
}
// WithImage adds the provided images to the exported archive.
func WithImage(is images.Store, name string) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
img, err := is.Get(ctx, name)
if err != nil {
return err
}
img.Target.Annotations = addNameAnnotation(name, img.Target.Annotations)
o.manifests = append(o.manifests, img.Target)
return nil
}
}
// WithImages adds multiples images to the exported archive.
func WithImages(imgs []images.Image) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
for _, img := range imgs {
img.Target.Annotations = addNameAnnotation(img.Name, img.Target.Annotations)
o.manifests = append(o.manifests, img.Target)
}
return nil
}
}
// WithManifest adds a manifest to the exported archive.
// When names are given they will be set on the manifest in the
// exported archive, creating an index record for each name.
// When no names are provided, it is up to caller to put name annotation to
// on the manifest descriptor if needed.
func WithManifest(manifest ocispec.Descriptor, names ...string) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
if len(names) == 0 {
o.manifests = append(o.manifests, manifest)
}
for _, name := range names {
mc := manifest
mc.Annotations = addNameAnnotation(name, manifest.Annotations)
o.manifests = append(o.manifests, mc)
}
return nil
}
}
// BlobFilter returns false if the blob should not be included in the archive.
type BlobFilter func(ocispec.Descriptor) bool
// WithBlobFilter specifies BlobFilter.
func WithBlobFilter(f BlobFilter) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
o.blobRecordOptions.blobFilter = f
return nil
}
}
// WithSkipNonDistributableBlobs excludes non-distributable blobs such as Windows base layers.
func WithSkipNonDistributableBlobs() ExportOpt {
f := func(desc ocispec.Descriptor) bool {
return !images.IsNonDistributable(desc.MediaType)
}
return WithBlobFilter(f)
}
// WithSkipMissing excludes blobs referenced by manifests if not all blobs
// would be included in the archive.
// The manifest itself is excluded only if it's not present locally.
// This allows to export multi-platform images if not all platforms are present
// while still persisting the multi-platform index.
func WithSkipMissing(store content.InfoReaderProvider) ExportOpt {
return func(ctx context.Context, o *exportOptions) error {
o.blobRecordOptions.childrenHandler = images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error) {
children, err := images.Children(ctx, store, desc)
if !images.IsManifestType(desc.MediaType) {
return children, err
}
if err != nil {
// If manifest itself is missing, skip it from export.
if errdefs.IsNotFound(err) {
return nil, images.ErrSkipDesc
}
return nil, err
}
// Don't export manifest descendants if any of them doesn't exist.
for _, child := range children {
exists, err := content.Exists(ctx, store, child)
if err != nil {
return nil, err
}
// If any child is missing, only export the manifest, but don't export its descendants.
if !exists {
return nil, nil
}
}
return children, nil
})
return nil
}
}
func addNameAnnotation(name string, base map[string]string) map[string]string {
annotations := map[string]string{}
for k, v := range base {
annotations[k] = v
}
annotations[images.AnnotationImageName] = name
annotations[ocispec.AnnotationRefName] = ociReferenceName(name)
return annotations
}
func copySourceLabels(ctx context.Context, infoProvider content.InfoProvider, desc ocispec.Descriptor) (ocispec.Descriptor, error) {
info, err := infoProvider.Info(ctx, desc.Digest)
if err != nil {
return desc, err
}
for k, v := range info.Labels {
if strings.HasPrefix(k, labels.LabelDistributionSource) {
if desc.Annotations == nil {
desc.Annotations = map[string]string{k: v}
} else {
desc.Annotations[k] = v
}
}
}
return desc, nil
}
// Export implements Exporter.
func Export(ctx context.Context, store content.InfoReaderProvider, writer io.Writer, opts ...ExportOpt) error {
var eo exportOptions
for _, opt := range opts {
if err := opt(ctx, &eo); err != nil {
return err
}
}
records := []tarRecord{
ociLayoutFile(""),
}
manifests := make([]ocispec.Descriptor, 0, len(eo.manifests))
for _, desc := range eo.manifests {
d, err := copySourceLabels(ctx, store, desc)
if err != nil {
log.G(ctx).WithError(err).WithField("desc", desc).Warn("failed to copy distribution.source labels")
continue
}
manifests = append(manifests, d)
}
algorithms := map[string]struct{}{}
dManifests := map[digest.Digest]*exportManifest{}
resolvedIndex := map[digest.Digest]digest.Digest{}
for _, desc := range manifests {
if images.IsManifestType(desc.MediaType) {
mt, ok := dManifests[desc.Digest]
if !ok {
// TODO(containerd): Skip if already added
r, err := getRecords(ctx, store, desc, algorithms, &eo.blobRecordOptions)
if err != nil {
return err
}
records = append(records, r...)
mt = &exportManifest{
manifest: desc,
}
dManifests[desc.Digest] = mt
}
name := desc.Annotations[images.AnnotationImageName]
if name != "" {
mt.names = append(mt.names, name)
}
} else if images.IsIndexType(desc.MediaType) {
d, ok := resolvedIndex[desc.Digest]
if !ok {
if err := desc.Digest.Validate(); err != nil {
return err
}
records = append(records, blobRecord(store, desc, &eo.blobRecordOptions))
p, err := content.ReadBlob(ctx, store, desc)
if err != nil {
return err
}
var index ocispec.Index
if err := json.Unmarshal(p, &index); err != nil {
return err
}
var manifests []ocispec.Descriptor
for _, m := range index.Manifests {
if eo.platform != nil {
if m.Platform == nil || eo.platform.Match(*m.Platform) {
manifests = append(manifests, m)
} else if !eo.allPlatforms {
continue
}
}
r, err := getRecords(ctx, store, m, algorithms, &eo.blobRecordOptions)
if err != nil {
return err
}
records = append(records, r...)
}
if len(manifests) >= 1 {
if len(manifests) > 1 {
sort.SliceStable(manifests, func(i, j int) bool {
if manifests[i].Platform == nil {
return false
}
if manifests[j].Platform == nil {
return true
}
return eo.platform.Less(*manifests[i].Platform, *manifests[j].Platform)
})
}
d = manifests[0].Digest
dManifests[d] = &exportManifest{
manifest: manifests[0],
}
} else if eo.platform != nil {
return fmt.Errorf("no manifest found for platform: %w", errdefs.ErrNotFound)
}
resolvedIndex[desc.Digest] = d
}
if d != "" {
if name := desc.Annotations[images.AnnotationImageName]; name != "" {
mt := dManifests[d]
mt.names = append(mt.names, name)
}
}
} else {
return fmt.Errorf("only manifests may be exported: %w", errdefs.ErrInvalidArgument)
}
}
records = append(records, ociIndexRecord(manifests))
if !eo.skipDockerManifest && len(dManifests) > 0 {
tr, err := manifestsRecord(ctx, store, dManifests)
if err != nil {
return fmt.Errorf("unable to create manifests file: %w", err)
}
records = append(records, tr)
}
if len(algorithms) > 0 {
records = append(records, directoryRecord("blobs/", 0755))
for alg := range algorithms {
records = append(records, directoryRecord("blobs/"+alg+"/", 0755))
}
}
tw := tar.NewWriter(writer)
defer tw.Close()
return writeTar(ctx, tw, records)
}
func getRecords(ctx context.Context, store content.Provider, desc ocispec.Descriptor, algorithms map[string]struct{}, brOpts *blobRecordOptions) ([]tarRecord, error) {
var records []tarRecord
exportHandler := func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if err := desc.Digest.Validate(); err != nil {
return nil, err
}
records = append(records, blobRecord(store, desc, brOpts))
algorithms[desc.Digest.Algorithm().String()] = struct{}{}
return nil, nil
}
childrenHandler := brOpts.childrenHandler
if childrenHandler == nil {
childrenHandler = images.ChildrenHandler(store)
}
handlers := images.Handlers(
childrenHandler,
images.HandlerFunc(exportHandler),
)
// Walk sequentially since the number of fetches is likely one and doing in
// parallel requires locking the export handler
if err := images.Walk(ctx, handlers, desc); err != nil {
return nil, err
}
return records, nil
}
type tarRecord struct {
Header *tar.Header
CopyTo func(context.Context, io.Writer) (int64, error)
}
type blobRecordOptions struct {
blobFilter BlobFilter
childrenHandler images.HandlerFunc
}
func blobRecord(cs content.Provider, desc ocispec.Descriptor, opts *blobRecordOptions) tarRecord {
if opts != nil && opts.blobFilter != nil && !opts.blobFilter(desc) {
return tarRecord{}
}
return tarRecord{
Header: &tar.Header{
Name: path.Join(ocispec.ImageBlobsDir, desc.Digest.Algorithm().String(), desc.Digest.Encoded()),
Mode: 0444,
Size: desc.Size,
Typeflag: tar.TypeReg,
},
CopyTo: func(ctx context.Context, w io.Writer) (int64, error) {
r, err := cs.ReaderAt(ctx, desc)
if err != nil {
return 0, fmt.Errorf("failed to get reader: %w", err)
}
defer r.Close()
// Verify digest
dgstr := desc.Digest.Algorithm().Digester()
n, err := io.Copy(io.MultiWriter(w, dgstr.Hash()), content.NewReader(r))
if err != nil {
return 0, fmt.Errorf("failed to copy to tar: %w", err)
}
if dgstr.Digest() != desc.Digest {
return 0, fmt.Errorf("unexpected digest %s copied", dgstr.Digest())
}
return n, nil
},
}
}
func directoryRecord(name string, mode int64) tarRecord {
return tarRecord{
Header: &tar.Header{
Name: name,
Mode: mode,
Typeflag: tar.TypeDir,
},
}
}
func ociLayoutFile(version string) tarRecord {
if version == "" {
version = ocispec.ImageLayoutVersion
}
layout := ocispec.ImageLayout{
Version: version,
}
b, err := json.Marshal(layout)
if err != nil {
panic(err)
}
return tarRecord{
Header: &tar.Header{
Name: ocispec.ImageLayoutFile,
Mode: 0444,
Size: int64(len(b)),
Typeflag: tar.TypeReg,
},
CopyTo: func(ctx context.Context, w io.Writer) (int64, error) {
n, err := w.Write(b)
return int64(n), err
},
}
}
func ociIndexRecord(manifests []ocispec.Descriptor) tarRecord {
index := ocispec.Index{
Versioned: ocispecs.Versioned{
SchemaVersion: 2,
},
Manifests: manifests,
}
b, err := json.Marshal(index)
if err != nil {
panic(err)
}
return tarRecord{
Header: &tar.Header{
Name: ocispec.ImageIndexFile,
Mode: 0644,
Size: int64(len(b)),
Typeflag: tar.TypeReg,
},
CopyTo: func(ctx context.Context, w io.Writer) (int64, error) {
n, err := w.Write(b)
return int64(n), err
},
}
}
type exportManifest struct {
manifest ocispec.Descriptor
names []string
}
func manifestsRecord(ctx context.Context, store content.Provider, manifests map[digest.Digest]*exportManifest) (tarRecord, error) {
mfsts := make([]struct {
Config string
RepoTags []string
Layers []string
}, len(manifests))
var i int
for _, m := range manifests {
p, err := content.ReadBlob(ctx, store, m.manifest)
if err != nil {
return tarRecord{}, err
}
var manifest ocispec.Manifest
if err := json.Unmarshal(p, &manifest); err != nil {
return tarRecord{}, err
}
dgst := manifest.Config.Digest
if err := dgst.Validate(); err != nil {
return tarRecord{}, err
}
mfsts[i].Config = path.Join(ocispec.ImageBlobsDir, dgst.Algorithm().String(), dgst.Encoded())
for _, l := range manifest.Layers {
mfsts[i].Layers = append(mfsts[i].Layers, path.Join(ocispec.ImageBlobsDir, l.Digest.Algorithm().String(), l.Digest.Encoded()))
}
for _, name := range m.names {
nname, err := familiarizeReference(name)
if err != nil {
return tarRecord{}, err
}
mfsts[i].RepoTags = append(mfsts[i].RepoTags, nname)
}
i++
}
b, err := json.Marshal(mfsts)
if err != nil {
return tarRecord{}, err
}
return tarRecord{
Header: &tar.Header{
Name: "manifest.json",
Mode: 0644,
Size: int64(len(b)),
Typeflag: tar.TypeReg,
},
CopyTo: func(ctx context.Context, w io.Writer) (int64, error) {
n, err := w.Write(b)
return int64(n), err
},
}, nil
}
func writeTar(ctx context.Context, tw *tar.Writer, recordsWithEmpty []tarRecord) error {
var records []tarRecord
for _, r := range recordsWithEmpty {
if r.Header != nil {
records = append(records, r)
}
}
sort.Slice(records, func(i, j int) bool {
return records[i].Header.Name < records[j].Header.Name
})
var last string
for _, record := range records {
if record.Header.Name == last {
continue
}
last = record.Header.Name
if err := tw.WriteHeader(record.Header); err != nil {
return err
}
if record.CopyTo != nil {
n, err := record.CopyTo(ctx, tw)
if err != nil {
return err
}
if n != record.Header.Size {
return fmt.Errorf("unexpected copy size for %s", record.Header.Name)
}
} else if record.Header.Size > 0 {
return fmt.Errorf("no content to write to record with non-zero size for %s", record.Header.Name)
}
}
return nil
}

View File

@@ -0,0 +1,420 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package archive provides a Docker and OCI compatible importer
package archive
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"github.com/containerd/containerd/v2/archive/compression"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/labels"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/log"
digest "github.com/opencontainers/go-digest"
specs "github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
type importOpts struct {
compress bool
}
// ImportOpt is an option for importing an OCI index
type ImportOpt func(*importOpts) error
// WithImportCompression compresses uncompressed layers on import.
// This is used for import formats which do not include the manifest.
func WithImportCompression() ImportOpt {
return func(io *importOpts) error {
io.compress = true
return nil
}
}
// ImportIndex imports an index from a tar archive image bundle
// - implements Docker v1.1, v1.2 and OCI v1.
// - prefers OCI v1 when provided
// - creates OCI index for Docker formats
// - normalizes Docker references and adds as OCI ref name
// e.g. alpine:latest -> docker.io/library/alpine:latest
// - existing OCI reference names are untouched
func ImportIndex(ctx context.Context, store content.Store, reader io.Reader, opts ...ImportOpt) (ocispec.Descriptor, error) {
var (
tr = tar.NewReader(reader)
ociLayout ocispec.ImageLayout
mfsts []struct {
Config string
RepoTags []string
Layers []string
}
symlinks = make(map[string]string)
blobs = make(map[string]ocispec.Descriptor)
iopts importOpts
)
for _, o := range opts {
if err := o(&iopts); err != nil {
return ocispec.Descriptor{}, err
}
}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return ocispec.Descriptor{}, err
}
if hdr.Typeflag == tar.TypeSymlink {
symlinks[hdr.Name] = path.Join(path.Dir(hdr.Name), hdr.Linkname)
}
//nolint:staticcheck // TypeRegA is deprecated but we may still receive an external tar with TypeRegA
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
if hdr.Typeflag != tar.TypeDir {
log.G(ctx).WithField("file", hdr.Name).Debug("file type ignored")
}
continue
}
hdrName := path.Clean(hdr.Name)
if hdrName == ocispec.ImageLayoutFile {
if err = onUntarJSON(tr, &ociLayout); err != nil {
return ocispec.Descriptor{}, fmt.Errorf("untar oci layout %q: %w", hdr.Name, err)
}
} else if hdrName == "manifest.json" {
if err = onUntarJSON(tr, &mfsts); err != nil {
return ocispec.Descriptor{}, fmt.Errorf("untar manifest %q: %w", hdr.Name, err)
}
} else {
dgst, err := onUntarBlob(ctx, tr, store, hdr.Size, "tar-"+hdrName)
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("failed to ingest %q: %w", hdr.Name, err)
}
blobs[hdrName] = ocispec.Descriptor{
Digest: dgst,
Size: hdr.Size,
}
}
}
// If OCI layout was given, interpret the tar as an OCI layout.
// When not provided, the layout of the tar will be interpreted
// as Docker v1.1 or v1.2.
if ociLayout.Version != "" {
if ociLayout.Version != ocispec.ImageLayoutVersion {
return ocispec.Descriptor{}, fmt.Errorf("unsupported OCI version %s", ociLayout.Version)
}
idx, ok := blobs[ocispec.ImageIndexFile]
if !ok {
return ocispec.Descriptor{}, fmt.Errorf("missing index.json in OCI layout %s", ocispec.ImageLayoutVersion)
}
idx.MediaType = ocispec.MediaTypeImageIndex
return idx, nil
}
if mfsts == nil {
return ocispec.Descriptor{}, errors.New("unrecognized image format")
}
for name, linkname := range symlinks {
desc, ok := blobs[linkname]
if !ok {
return ocispec.Descriptor{}, fmt.Errorf("no target for symlink layer from %q to %q", name, linkname)
}
blobs[name] = desc
}
idx := ocispec.Index{
Versioned: specs.Versioned{
SchemaVersion: 2,
},
}
for _, mfst := range mfsts {
config, ok := blobs[mfst.Config]
if !ok {
return ocispec.Descriptor{}, fmt.Errorf("image config %q not found", mfst.Config)
}
config.MediaType = images.MediaTypeDockerSchema2Config
layers, err := resolveLayers(ctx, store, mfst.Layers, blobs, iopts.compress)
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("failed to resolve layers: %w", err)
}
manifest := struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config ocispec.Descriptor `json:"config"`
Layers []ocispec.Descriptor `json:"layers"`
}{
SchemaVersion: 2,
MediaType: images.MediaTypeDockerSchema2Manifest,
Config: config,
Layers: layers,
}
desc, err := writeManifest(ctx, store, manifest, manifest.MediaType)
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("write docker manifest: %w", err)
}
imgPlatforms, err := images.Platforms(ctx, store, desc)
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("unable to resolve platform: %w", err)
}
if len(imgPlatforms) > 0 {
// Only one platform can be resolved from non-index manifest,
// The platform can only come from the config included above,
// if the config has no platform it can be safely omitted.
desc.Platform = &imgPlatforms[0]
// If the image we've just imported is a Windows image without the OSVersion set,
// we could just assume it matches this host's OS Version. Without this, the
// children labels might not be set on the image content, leading to it being
// garbage collected, breaking the image.
// See: https://github.com/containerd/containerd/issues/5690
if desc.Platform.OS == "windows" && desc.Platform.OSVersion == "" {
platform := platforms.DefaultSpec()
desc.Platform.OSVersion = platform.OSVersion
}
}
if len(mfst.RepoTags) == 0 {
idx.Manifests = append(idx.Manifests, desc)
} else {
// Add descriptor per tag
for _, ref := range mfst.RepoTags {
mfstdesc := desc
normalized, err := normalizeReference(ref)
if err != nil {
return ocispec.Descriptor{}, err
}
mfstdesc.Annotations = map[string]string{
images.AnnotationImageName: normalized,
ocispec.AnnotationRefName: ociReferenceName(normalized),
}
idx.Manifests = append(idx.Manifests, mfstdesc)
}
}
}
return writeManifest(ctx, store, idx, ocispec.MediaTypeImageIndex)
}
const (
kib = 1024
mib = 1024 * kib
jsonLimit = 20 * mib
)
func onUntarJSON(r io.Reader, j interface{}) error {
return json.NewDecoder(io.LimitReader(r, jsonLimit)).Decode(j)
}
func onUntarBlob(ctx context.Context, r io.Reader, store content.Ingester, size int64, ref string) (digest.Digest, error) {
dgstr := digest.Canonical.Digester()
if err := content.WriteBlob(ctx, store, ref, io.TeeReader(r, dgstr.Hash()), ocispec.Descriptor{Size: size}); err != nil {
return "", err
}
return dgstr.Digest(), nil
}
func resolveLayers(ctx context.Context, store content.Store, layerFiles []string, blobs map[string]ocispec.Descriptor, compress bool) ([]ocispec.Descriptor, error) {
layers := make([]ocispec.Descriptor, len(layerFiles))
descs := map[digest.Digest]*ocispec.Descriptor{}
filters := []string{}
for i, f := range layerFiles {
desc, ok := blobs[f]
if !ok {
return nil, fmt.Errorf("layer %q not found", f)
}
layers[i] = desc
descs[desc.Digest] = &layers[i]
filters = append(filters, fmt.Sprintf("labels.\"%s\"==%s", labels.LabelUncompressed, desc.Digest.String()))
}
err := store.Walk(ctx, func(info content.Info) error {
dgst, ok := info.Labels[labels.LabelUncompressed]
if ok {
desc := descs[digest.Digest(dgst)]
if desc != nil {
desc.Digest = info.Digest
desc.Size = info.Size
mediaType, err := detectLayerMediaType(ctx, store, *desc)
if err != nil {
return fmt.Errorf("failed to detect media type of layer: %w", err)
}
desc.MediaType = mediaType
}
}
return nil
}, filters...)
if err != nil {
return nil, fmt.Errorf("failure checking for compressed blobs: %w", err)
}
for i, desc := range layers {
if desc.MediaType != "" {
continue
}
// Open blob, resolve media type
ra, err := store.ReaderAt(ctx, desc)
if err != nil {
return nil, fmt.Errorf("failed to open %q (%s): %w", layerFiles[i], desc.Digest, err)
}
s, err := compression.DecompressStream(content.NewReader(ra))
if err != nil {
ra.Close()
return nil, fmt.Errorf("failed to detect compression for %q: %w", layerFiles[i], err)
}
if s.GetCompression() == compression.Uncompressed {
if compress {
if err := desc.Digest.Validate(); err != nil {
return nil, err
}
ref := fmt.Sprintf("compress-blob-%s-%s", desc.Digest.Algorithm().String(), desc.Digest.Encoded())
labels := map[string]string{
labels.LabelUncompressed: desc.Digest.String(),
}
layers[i], err = compressBlob(ctx, store, s, ref, content.WithLabels(labels))
if err != nil {
s.Close()
ra.Close()
return nil, err
}
layers[i].MediaType = images.MediaTypeDockerSchema2LayerGzip
} else {
layers[i].MediaType = images.MediaTypeDockerSchema2Layer
}
} else {
layers[i].MediaType = images.MediaTypeDockerSchema2LayerGzip
}
s.Close()
ra.Close()
}
return layers, nil
}
func compressBlob(ctx context.Context, cs content.Store, r io.Reader, ref string, opts ...content.Opt) (desc ocispec.Descriptor, err error) {
w, err := content.OpenWriter(ctx, cs, content.WithRef(ref))
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("failed to open writer: %w", err)
}
defer func() {
w.Close()
if err != nil {
cs.Abort(ctx, ref)
}
}()
if err := w.Truncate(0); err != nil {
return ocispec.Descriptor{}, fmt.Errorf("failed to truncate writer: %w", err)
}
cw, err := compression.CompressStream(w, compression.Gzip)
if err != nil {
return ocispec.Descriptor{}, err
}
if _, err := io.Copy(cw, r); err != nil {
return ocispec.Descriptor{}, err
}
if err := cw.Close(); err != nil {
return ocispec.Descriptor{}, err
}
cst, err := w.Status()
if err != nil {
return ocispec.Descriptor{}, fmt.Errorf("failed to get writer status: %w", err)
}
desc.Digest = w.Digest()
desc.Size = cst.Offset
if err := w.Commit(ctx, desc.Size, desc.Digest, opts...); err != nil {
if !errdefs.IsAlreadyExists(err) {
return ocispec.Descriptor{}, fmt.Errorf("failed to commit: %w", err)
}
}
return desc, nil
}
func writeManifest(ctx context.Context, cs content.Ingester, manifest interface{}, mediaType string) (ocispec.Descriptor, error) {
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return ocispec.Descriptor{}, err
}
desc := ocispec.Descriptor{
MediaType: mediaType,
Digest: digest.FromBytes(manifestBytes),
Size: int64(len(manifestBytes)),
}
if err := content.WriteBlob(ctx, cs, "manifest-"+desc.Digest.String(), bytes.NewReader(manifestBytes), desc); err != nil {
return ocispec.Descriptor{}, err
}
return desc, nil
}
func detectLayerMediaType(ctx context.Context, store content.Store, desc ocispec.Descriptor) (string, error) {
var mediaType string
// need to parse existing blob to use the proper media type
bytes := make([]byte, 10)
ra, err := store.ReaderAt(ctx, desc)
if err != nil {
return "", fmt.Errorf("failed to read content store to detect layer media type: %w", err)
}
defer ra.Close()
_, err = ra.ReadAt(bytes, 0)
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read header bytes from layer to detect media type: %w", err)
}
if err == io.EOF {
// in the case of an empty layer then the media type should be uncompressed
return images.MediaTypeDockerSchema2Layer, nil
}
switch c := compression.DetectCompression(bytes); c {
case compression.Uncompressed:
mediaType = images.MediaTypeDockerSchema2Layer
default:
mediaType = images.MediaTypeDockerSchema2LayerGzip
}
return mediaType, nil
}

View File

@@ -0,0 +1,115 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package archive
import (
"fmt"
"strings"
"github.com/containerd/containerd/v2/reference"
distref "github.com/distribution/reference"
"github.com/opencontainers/go-digest"
)
// FilterRefPrefix restricts references to having the given image
// prefix. Tag-only references will have the prefix prepended.
func FilterRefPrefix(image string) func(string) string {
return refTranslator(image, true)
}
// AddRefPrefix prepends the given image prefix to tag-only references,
// while leaving returning full references unmodified.
func AddRefPrefix(image string) func(string) string {
return refTranslator(image, false)
}
// refTranslator creates a reference which only has a tag or verifies
// a full reference.
func refTranslator(image string, checkPrefix bool) func(string) string {
return func(ref string) string {
if image == "" {
return ""
}
// Check if ref is full reference
if strings.ContainsAny(ref, "/:@") {
// If not prefixed, don't include image
if checkPrefix && !isImagePrefix(ref, image) {
return ""
}
return ref
}
return image + ":" + ref
}
}
func isImagePrefix(s, prefix string) bool {
if !strings.HasPrefix(s, prefix) {
return false
}
if len(s) > len(prefix) {
switch s[len(prefix)] {
case '/', ':', '@':
// Prevent matching partial namespaces
default:
return false
}
}
return true
}
func normalizeReference(ref string) (string, error) {
// TODO: Replace this function to not depend on reference package
normalized, err := distref.ParseDockerRef(ref)
if err != nil {
return "", fmt.Errorf("normalize image ref %q: %w", ref, err)
}
return normalized.String(), nil
}
func familiarizeReference(ref string) (string, error) {
named, err := distref.ParseNormalizedNamed(ref)
if err != nil {
return "", fmt.Errorf("failed to parse %q: %w", ref, err)
}
named = distref.TagNameOnly(named)
return distref.FamiliarString(named), nil
}
func ociReferenceName(name string) string {
// OCI defines the reference name as only a tag excluding the
// repository. The containerd annotation contains the full image name
// since the tag is insufficient for correctly naming and referring to an
// image
var ociRef string
if spec, err := reference.Parse(name); err == nil {
ociRef = spec.Object
} else {
ociRef = name
}
return ociRef
}
// DigestTranslator creates a digest reference by adding the
// digest to an image name
func DigestTranslator(prefix string) func(digest.Digest) string {
return func(dgst digest.Digest) string {
return prefix + "@" + dgst.String()
}
}

View File

@@ -0,0 +1,126 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package converter provides image converter
package converter
import (
"context"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/leases"
"github.com/containerd/containerd/v2/platforms"
)
type convertOpts struct {
layerConvertFunc ConvertFunc
docker2oci bool
indexConvertFunc ConvertFunc
platformMC platforms.MatchComparer
}
// Opt is an option for Convert()
type Opt func(*convertOpts) error
// WithLayerConvertFunc specifies the function that converts layers.
func WithLayerConvertFunc(fn ConvertFunc) Opt {
return func(copts *convertOpts) error {
copts.layerConvertFunc = fn
return nil
}
}
// WithDockerToOCI converts Docker media types into OCI ones.
func WithDockerToOCI(v bool) Opt {
return func(copts *convertOpts) error {
copts.docker2oci = true
return nil
}
}
// WithPlatform specifies the platform.
// Defaults to all platforms.
func WithPlatform(p platforms.MatchComparer) Opt {
return func(copts *convertOpts) error {
copts.platformMC = p
return nil
}
}
// WithIndexConvertFunc specifies the function that converts manifests and index (manifest lists).
// Defaults to DefaultIndexConvertFunc.
func WithIndexConvertFunc(fn ConvertFunc) Opt {
return func(copts *convertOpts) error {
copts.indexConvertFunc = fn
return nil
}
}
// Client is implemented by *containerd.Client .
type Client interface {
WithLease(ctx context.Context, opts ...leases.Opt) (context.Context, func(context.Context) error, error)
ContentStore() content.Store
ImageService() images.Store
}
// Convert converts an image.
func Convert(ctx context.Context, client Client, dstRef, srcRef string, opts ...Opt) (*images.Image, error) {
var copts convertOpts
for _, o := range opts {
if err := o(&copts); err != nil {
return nil, err
}
}
if copts.platformMC == nil {
copts.platformMC = platforms.All
}
if copts.indexConvertFunc == nil {
copts.indexConvertFunc = DefaultIndexConvertFunc(copts.layerConvertFunc, copts.docker2oci, copts.platformMC)
}
ctx, done, err := client.WithLease(ctx)
if err != nil {
return nil, err
}
defer done(ctx)
cs := client.ContentStore()
is := client.ImageService()
srcImg, err := is.Get(ctx, srcRef)
if err != nil {
return nil, err
}
dstDesc, err := copts.indexConvertFunc(ctx, cs, srcImg.Target)
if err != nil {
return nil, err
}
dstImg := srcImg
dstImg.Name = dstRef
if dstDesc != nil {
dstImg.Target = *dstDesc
}
var res images.Image
if dstRef != srcRef {
_ = is.Delete(ctx, dstRef)
res, err = is.Create(ctx, dstImg)
} else {
res, err = is.Update(ctx, dstImg)
}
return &res, err
}

View File

@@ -0,0 +1,454 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package converter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/log"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"golang.org/x/sync/errgroup"
)
// ConvertFunc returns a converted content descriptor.
// When the content was not converted, ConvertFunc returns nil.
type ConvertFunc func(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error)
// DefaultIndexConvertFunc is the default convert func used by Convert.
func DefaultIndexConvertFunc(layerConvertFunc ConvertFunc, docker2oci bool, platformMC platforms.MatchComparer) ConvertFunc {
c := &defaultConverter{
layerConvertFunc: layerConvertFunc,
docker2oci: docker2oci,
platformMC: platformMC,
diffIDMap: make(map[digest.Digest]digest.Digest),
}
return c.convert
}
// ConvertHookFunc is a callback function called during conversion of a blob.
// orgDesc is the target descriptor to convert. newDesc is passed if conversion happens.
type ConvertHookFunc func(ctx context.Context, cs content.Store, orgDesc ocispec.Descriptor, newDesc *ocispec.Descriptor) (*ocispec.Descriptor, error)
// ConvertHooks is a configuration for hook callbacks called during blob conversion.
type ConvertHooks struct {
// PostConvertHook is a callback function called for each blob after conversion is done.
PostConvertHook ConvertHookFunc
}
// IndexConvertFuncWithHook is the convert func used by Convert with hook functions support.
func IndexConvertFuncWithHook(layerConvertFunc ConvertFunc, docker2oci bool, platformMC platforms.MatchComparer, hooks ConvertHooks) ConvertFunc {
c := &defaultConverter{
layerConvertFunc: layerConvertFunc,
docker2oci: docker2oci,
platformMC: platformMC,
diffIDMap: make(map[digest.Digest]digest.Digest),
hooks: hooks,
}
return c.convert
}
type defaultConverter struct {
layerConvertFunc ConvertFunc
docker2oci bool
platformMC platforms.MatchComparer
diffIDMap map[digest.Digest]digest.Digest // key: old diffID, value: new diffID
diffIDMapMu sync.RWMutex
hooks ConvertHooks
}
// convert dispatches desc.MediaType and calls c.convert{Layer,Manifest,Index,Config}.
//
// Also converts media type if c.docker2oci is set.
func (c *defaultConverter) convert(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
var (
newDesc *ocispec.Descriptor
err error
)
if images.IsLayerType(desc.MediaType) {
newDesc, err = c.convertLayer(ctx, cs, desc)
} else if images.IsManifestType(desc.MediaType) {
newDesc, err = c.convertManifest(ctx, cs, desc)
} else if images.IsIndexType(desc.MediaType) {
newDesc, err = c.convertIndex(ctx, cs, desc)
} else if images.IsConfigType(desc.MediaType) {
newDesc, err = c.convertConfig(ctx, cs, desc)
}
if err != nil {
return nil, err
}
if c.hooks.PostConvertHook != nil {
if newDescPost, err := c.hooks.PostConvertHook(ctx, cs, desc, newDesc); err != nil {
return nil, err
} else if newDescPost != nil {
newDesc = newDescPost
}
}
if images.IsDockerType(desc.MediaType) {
if c.docker2oci {
if newDesc == nil {
newDesc = copyDesc(desc)
}
newDesc.MediaType = ConvertDockerMediaTypeToOCI(newDesc.MediaType)
} else if (newDesc == nil && len(desc.Annotations) != 0) || (newDesc != nil && len(newDesc.Annotations) != 0) {
// Annotations is supported only on OCI manifest.
// We need to remove annotations for Docker media types.
if newDesc == nil {
newDesc = copyDesc(desc)
}
newDesc.Annotations = nil
}
}
log.G(ctx).WithField("old", desc).WithField("new", newDesc).Debugf("converted")
return newDesc, nil
}
func copyDesc(desc ocispec.Descriptor) *ocispec.Descriptor {
descCopy := desc
return &descCopy
}
// convertLayer converts image layers if c.layerConvertFunc is set.
//
// c.layerConvertFunc can be nil, e.g., for converting Docker media types to OCI ones.
func (c *defaultConverter) convertLayer(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
if c.layerConvertFunc != nil {
return c.layerConvertFunc(ctx, cs, desc)
}
return nil, nil
}
// convertManifest converts image manifests.
//
// - converts `.mediaType` if the target format is OCI
// - records diff ID changes in c.diffIDMap
func (c *defaultConverter) convertManifest(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
var (
manifest ocispec.Manifest
modified bool
)
labels, err := readJSON(ctx, cs, &manifest, desc)
if err != nil {
return nil, err
}
if labels == nil {
labels = make(map[string]string)
}
if images.IsDockerType(manifest.MediaType) && c.docker2oci {
manifest.MediaType = ConvertDockerMediaTypeToOCI(manifest.MediaType)
modified = true
}
var mu sync.Mutex
eg, ctx2 := errgroup.WithContext(ctx)
for i, l := range manifest.Layers {
i := i
l := l
oldDiffID, err := images.GetDiffID(ctx, cs, l)
if err != nil {
return nil, err
}
eg.Go(func() error {
newL, err := c.convert(ctx2, cs, l)
if err != nil {
return err
}
if newL != nil {
mu.Lock()
// update GC labels
ClearGCLabels(labels, l.Digest)
labelKey := fmt.Sprintf("containerd.io/gc.ref.content.l.%d", i)
labels[labelKey] = newL.Digest.String()
manifest.Layers[i] = *newL
modified = true
mu.Unlock()
// diffID changes if the tar entries were modified.
// diffID stays same if only the compression type was changed.
// When diffID changed, add a map entry so that we can update image config.
newDiffID, err := images.GetDiffID(ctx, cs, *newL)
if err != nil {
return err
}
if newDiffID != oldDiffID {
c.diffIDMapMu.Lock()
c.diffIDMap[oldDiffID] = newDiffID
c.diffIDMapMu.Unlock()
}
}
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
newConfig, err := c.convert(ctx, cs, manifest.Config)
if err != nil {
return nil, err
}
if newConfig != nil {
ClearGCLabels(labels, manifest.Config.Digest)
labels["containerd.io/gc.ref.content.config"] = newConfig.Digest.String()
manifest.Config = *newConfig
modified = true
}
if modified {
return writeJSON(ctx, cs, &manifest, desc, labels)
}
return nil, nil
}
// convertIndex converts image index.
//
// - converts `.mediaType` if the target format is OCI
// - clears manifest entries that do not match c.platformMC
func (c *defaultConverter) convertIndex(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
var (
index ocispec.Index
modified bool
)
labels, err := readJSON(ctx, cs, &index, desc)
if err != nil {
return nil, err
}
if labels == nil {
labels = make(map[string]string)
}
if images.IsDockerType(index.MediaType) && c.docker2oci {
index.MediaType = ConvertDockerMediaTypeToOCI(index.MediaType)
modified = true
}
newManifests := make([]ocispec.Descriptor, len(index.Manifests))
newManifestsToBeRemoved := make(map[int]struct{}) // slice index
var mu sync.Mutex
eg, ctx2 := errgroup.WithContext(ctx)
for i, mani := range index.Manifests {
i := i
mani := mani
labelKey := fmt.Sprintf("containerd.io/gc.ref.content.m.%d", i)
eg.Go(func() error {
if mani.Platform != nil && !c.platformMC.Match(*mani.Platform) {
mu.Lock()
ClearGCLabels(labels, mani.Digest)
newManifestsToBeRemoved[i] = struct{}{}
modified = true
mu.Unlock()
return nil
}
newMani, err := c.convert(ctx2, cs, mani)
if err != nil {
return err
}
mu.Lock()
if newMani != nil {
ClearGCLabels(labels, mani.Digest)
labels[labelKey] = newMani.Digest.String()
// NOTE: for keeping manifest order, we specify `i` index explicitly
newManifests[i] = *newMani
modified = true
} else {
newManifests[i] = mani
}
mu.Unlock()
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
if modified {
var newManifestsClean []ocispec.Descriptor
for i, m := range newManifests {
if _, ok := newManifestsToBeRemoved[i]; !ok {
newManifestsClean = append(newManifestsClean, m)
}
}
index.Manifests = newManifestsClean
return writeJSON(ctx, cs, &index, desc, labels)
}
return nil, nil
}
// convertConfig converts image config contents.
//
// - updates `.rootfs.diff_ids` using c.diffIDMap .
//
// - clears legacy `.config.Image` and `.container_config.Image` fields if `.rootfs.diff_ids` was updated.
func (c *defaultConverter) convertConfig(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
var (
cfg DualConfig
cfgAsOCI ocispec.Image // read only, used for parsing cfg
modified bool
)
labels, err := readJSON(ctx, cs, &cfg, desc)
if err != nil {
return nil, err
}
if labels == nil {
labels = make(map[string]string)
}
if _, err := readJSON(ctx, cs, &cfgAsOCI, desc); err != nil {
return nil, err
}
if rootfs := cfgAsOCI.RootFS; rootfs.Type == "layers" {
rootfsModified := false
c.diffIDMapMu.RLock()
for i, oldDiffID := range rootfs.DiffIDs {
if newDiffID, ok := c.diffIDMap[oldDiffID]; ok && newDiffID != oldDiffID {
rootfs.DiffIDs[i] = newDiffID
rootfsModified = true
}
}
c.diffIDMapMu.RUnlock()
if rootfsModified {
rootfsB, err := json.Marshal(rootfs)
if err != nil {
return nil, err
}
cfg["rootfs"] = (*json.RawMessage)(&rootfsB)
modified = true
}
}
if modified {
// cfg may have dummy value for legacy `.config.Image` and `.container_config.Image`
// We should clear the ID if we changed the diff IDs.
if _, err := clearDockerV1DummyID(cfg); err != nil {
return nil, err
}
return writeJSON(ctx, cs, &cfg, desc, labels)
}
return nil, nil
}
// clearDockerV1DummyID clears the dummy values for legacy `.config.Image` and `.container_config.Image`.
// Returns true if the cfg was modified.
func clearDockerV1DummyID(cfg DualConfig) (bool, error) {
var modified bool
f := func(k string) error {
if configX, ok := cfg[k]; ok && configX != nil {
var configField map[string]*json.RawMessage
if err := json.Unmarshal(*configX, &configField); err != nil {
return err
}
delete(configField, "Image")
b, err := json.Marshal(configField)
if err != nil {
return err
}
cfg[k] = (*json.RawMessage)(&b)
modified = true
}
return nil
}
if err := f("config"); err != nil {
return modified, err
}
if err := f("container_config"); err != nil {
return modified, err
}
return modified, nil
}
// DualConfig covers Docker config (v1.0, v1.1, v1.2) and OCI config.
// Unmarshalled as map[string]*json.RawMessage to retain unknown fields on remarshalling.
type DualConfig map[string]*json.RawMessage
func readJSON(ctx context.Context, cs content.Store, x interface{}, desc ocispec.Descriptor) (map[string]string, error) {
info, err := cs.Info(ctx, desc.Digest)
if err != nil {
return nil, err
}
labels := info.Labels
b, err := content.ReadBlob(ctx, cs, desc)
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, x); err != nil {
return nil, err
}
return labels, nil
}
func writeJSON(ctx context.Context, cs content.Store, x interface{}, oldDesc ocispec.Descriptor, labels map[string]string) (*ocispec.Descriptor, error) {
b, err := json.Marshal(x)
if err != nil {
return nil, err
}
dgst := digest.SHA256.FromBytes(b)
ref := fmt.Sprintf("converter-write-json-%s", dgst.String())
w, err := content.OpenWriter(ctx, cs, content.WithRef(ref))
if err != nil {
return nil, err
}
if err := content.Copy(ctx, w, bytes.NewReader(b), int64(len(b)), dgst, content.WithLabels(labels)); err != nil {
w.Close()
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
newDesc := oldDesc
newDesc.Size = int64(len(b))
newDesc.Digest = dgst
return &newDesc, nil
}
// ConvertDockerMediaTypeToOCI converts a media type string
func ConvertDockerMediaTypeToOCI(mt string) string {
switch mt {
case images.MediaTypeDockerSchema2ManifestList:
return ocispec.MediaTypeImageIndex
case images.MediaTypeDockerSchema2Manifest:
return ocispec.MediaTypeImageManifest
case images.MediaTypeDockerSchema2LayerGzip:
return ocispec.MediaTypeImageLayerGzip
case images.MediaTypeDockerSchema2LayerForeignGzip:
return ocispec.MediaTypeImageLayerNonDistributableGzip //nolint:staticcheck // deprecated
case images.MediaTypeDockerSchema2Layer:
return ocispec.MediaTypeImageLayer
case images.MediaTypeDockerSchema2LayerForeign:
return ocispec.MediaTypeImageLayerNonDistributable //nolint:staticcheck // deprecated
case images.MediaTypeDockerSchema2Config:
return ocispec.MediaTypeImageConfig
default:
return mt
}
}
// ClearGCLabels clears GC labels for the given digest.
func ClearGCLabels(labels map[string]string, dgst digest.Digest) {
for k, v := range labels {
if v == dgst.String() && strings.HasPrefix(k, "containerd.io/gc.ref.content") {
delete(labels, k)
}
}
}

View File

@@ -0,0 +1,122 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package uncompress
import (
"context"
"fmt"
"io"
"github.com/containerd/containerd/v2/archive/compression"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/core/images/converter"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/labels"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
var _ converter.ConvertFunc = LayerConvertFunc
// LayerConvertFunc converts tar.gz layers into uncompressed tar layers.
// Media type is changed, e.g., "application/vnd.oci.image.layer.v1.tar+gzip" -> "application/vnd.oci.image.layer.v1.tar"
func LayerConvertFunc(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
if !images.IsLayerType(desc.MediaType) || IsUncompressedType(desc.MediaType) {
// No conversion. No need to return an error here.
return nil, nil
}
info, err := cs.Info(ctx, desc.Digest)
if err != nil {
return nil, err
}
readerAt, err := cs.ReaderAt(ctx, desc)
if err != nil {
return nil, err
}
defer readerAt.Close()
sr := io.NewSectionReader(readerAt, 0, desc.Size)
newR, err := compression.DecompressStream(sr)
if err != nil {
return nil, err
}
defer newR.Close()
ref := fmt.Sprintf("convert-uncompress-from-%s", desc.Digest)
w, err := content.OpenWriter(ctx, cs, content.WithRef(ref))
if err != nil {
return nil, err
}
defer w.Close()
// Reset the writing position
// Old writer possibly remains without aborted
// (e.g. conversion interrupted by a signal)
if err := w.Truncate(0); err != nil {
return nil, err
}
n, err := io.Copy(w, newR)
if err != nil {
return nil, err
}
if err := newR.Close(); err != nil {
return nil, err
}
// no need to retain "containerd.io/uncompressed" label, but retain other labels ("containerd.io/distribution.source.*")
labelsMap := info.Labels
delete(labelsMap, labels.LabelUncompressed)
if err = w.Commit(ctx, 0, "", content.WithLabels(labelsMap)); err != nil && !errdefs.IsAlreadyExists(err) {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
newDesc := desc
newDesc.Digest = w.Digest()
newDesc.Size = n
newDesc.MediaType = convertMediaType(newDesc.MediaType)
return &newDesc, nil
}
// IsUncompressedType returns whether the provided media type is considered
// an uncompressed layer type
func IsUncompressedType(mt string) bool {
switch mt {
case
images.MediaTypeDockerSchema2Layer,
images.MediaTypeDockerSchema2LayerForeign,
ocispec.MediaTypeImageLayer,
ocispec.MediaTypeImageLayerNonDistributable: //nolint:staticcheck // deprecated
return true
default:
return false
}
}
func convertMediaType(mt string) string {
switch mt {
case images.MediaTypeDockerSchema2LayerGzip:
return images.MediaTypeDockerSchema2Layer
case images.MediaTypeDockerSchema2LayerForeignGzip:
return images.MediaTypeDockerSchema2LayerForeign
case ocispec.MediaTypeImageLayerGzip, ocispec.MediaTypeImageLayerZstd:
return ocispec.MediaTypeImageLayer
case ocispec.MediaTypeImageLayerNonDistributableGzip, ocispec.MediaTypeImageLayerNonDistributableZstd: //nolint:staticcheck // deprecated
return ocispec.MediaTypeImageLayerNonDistributable //nolint:staticcheck // deprecated
default:
return mt
}
}

82
core/images/diffid.go Normal file
View File

@@ -0,0 +1,82 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"context"
"io"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/containerd/containerd/v2/archive/compression"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/labels"
"github.com/containerd/log"
)
// GetDiffID gets the diff ID of the layer blob descriptor.
func GetDiffID(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (digest.Digest, error) {
switch desc.MediaType {
case
// If the layer is already uncompressed, we can just return its digest
MediaTypeDockerSchema2Layer,
ocispec.MediaTypeImageLayer,
MediaTypeDockerSchema2LayerForeign,
ocispec.MediaTypeImageLayerNonDistributable: //nolint:staticcheck // deprecated
return desc.Digest, nil
}
info, err := cs.Info(ctx, desc.Digest)
if err != nil {
return "", err
}
v, ok := info.Labels[labels.LabelUncompressed]
if ok {
// Fast path: if the image is already unpacked, we can use the label value
return digest.Parse(v)
}
// if the image is not unpacked, we may not have the label
ra, err := cs.ReaderAt(ctx, desc)
if err != nil {
return "", err
}
defer ra.Close()
r := content.NewReader(ra)
uR, err := compression.DecompressStream(r)
if err != nil {
return "", err
}
defer uR.Close()
digester := digest.Canonical.Digester()
hashW := digester.Hash()
if _, err := io.Copy(hashW, uR); err != nil {
return "", err
}
if err := ra.Close(); err != nil {
return "", err
}
digest := digester.Digest()
// memorize the computed value
if info.Labels == nil {
info.Labels = make(map[string]string)
}
info.Labels[labels.LabelUncompressed] = digest.String()
if _, err := cs.Update(ctx, info, "labels"); err != nil {
log.G(ctx).WithError(err).Warnf("failed to set %s label for %s", labels.LabelUncompressed, desc.Digest)
}
return digest, nil
}

320
core/images/handlers.go Normal file
View File

@@ -0,0 +1,320 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"context"
"errors"
"fmt"
"sort"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/platforms"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
var (
// ErrSkipDesc is used to skip processing of a descriptor and
// its descendants.
ErrSkipDesc = errors.New("skip descriptor")
// ErrStopHandler is used to signify that the descriptor
// has been handled and should not be handled further.
// This applies only to a single descriptor in a handler
// chain and does not apply to descendant descriptors.
ErrStopHandler = errors.New("stop handler")
// ErrEmptyWalk is used when the WalkNotEmpty handlers return no
// children (e.g.: they were filtered out).
ErrEmptyWalk = errors.New("image might be filtered out")
)
// Handler handles image manifests
type Handler interface {
Handle(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error)
}
// HandlerFunc function implementing the Handler interface
type HandlerFunc func(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error)
// Handle image manifests
func (fn HandlerFunc) Handle(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error) {
return fn(ctx, desc)
}
// Handlers returns a handler that will run the handlers in sequence.
//
// A handler may return `ErrStopHandler` to stop calling additional handlers
func Handlers(handlers ...Handler) HandlerFunc {
return func(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error) {
var children []ocispec.Descriptor
for _, handler := range handlers {
ch, err := handler.Handle(ctx, desc)
if err != nil {
if errors.Is(err, ErrStopHandler) {
break
}
return nil, err
}
children = append(children, ch...)
}
return children, nil
}
}
// Walk the resources of an image and call the handler for each. If the handler
// decodes the sub-resources for each image,
//
// This differs from dispatch in that each sibling resource is considered
// synchronously.
func Walk(ctx context.Context, handler Handler, descs ...ocispec.Descriptor) error {
for _, desc := range descs {
children, err := handler.Handle(ctx, desc)
if err != nil {
if errors.Is(err, ErrSkipDesc) {
continue // don't traverse the children.
}
return err
}
if len(children) > 0 {
if err := Walk(ctx, handler, children...); err != nil {
return err
}
}
}
return nil
}
// WalkNotEmpty works the same way Walk does, with the exception that it ensures that
// some children are still found by Walking the descriptors (for example, not all of
// them have been filtered out by one of the handlers). If there are no children,
// then an ErrEmptyWalk error is returned.
func WalkNotEmpty(ctx context.Context, handler Handler, descs ...ocispec.Descriptor) error {
isEmpty := true
var notEmptyHandler HandlerFunc = func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
children, err := handler.Handle(ctx, desc)
if err != nil {
return children, err
}
if len(children) > 0 {
isEmpty = false
}
return children, nil
}
err := Walk(ctx, notEmptyHandler, descs...)
if err != nil {
return err
}
if isEmpty {
return ErrEmptyWalk
}
return nil
}
// Dispatch runs the provided handler for content specified by the descriptors.
// If the handler decode subresources, they will be visited, as well.
//
// Handlers for siblings are run in parallel on the provided descriptors. A
// handler may return `ErrSkipDesc` to signal to the dispatcher to not traverse
// any children.
//
// A concurrency limiter can be passed in to limit the number of concurrent
// handlers running. When limiter is nil, there is no limit.
//
// Typically, this function will be used with `FetchHandler`, often composed
// with other handlers.
//
// If any handler returns an error, the dispatch session will be canceled.
func Dispatch(ctx context.Context, handler Handler, limiter *semaphore.Weighted, descs ...ocispec.Descriptor) error {
eg, ctx2 := errgroup.WithContext(ctx)
for _, desc := range descs {
desc := desc
if limiter != nil {
if err := limiter.Acquire(ctx, 1); err != nil {
return err
}
}
eg.Go(func() error {
desc := desc
children, err := handler.Handle(ctx2, desc)
if limiter != nil {
limiter.Release(1)
}
if err != nil {
if errors.Is(err, ErrSkipDesc) {
return nil // don't traverse the children.
}
return err
}
if len(children) > 0 {
return Dispatch(ctx2, handler, limiter, children...)
}
return nil
})
}
return eg.Wait()
}
// ChildrenHandler decodes well-known manifest types and returns their children.
//
// This is useful for supporting recursive fetch and other use cases where you
// want to do a full walk of resources.
//
// One can also replace this with another implementation to allow descending of
// arbitrary types.
func ChildrenHandler(provider content.Provider) HandlerFunc {
return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
return Children(ctx, provider, desc)
}
}
// SetChildrenLabels is a handler wrapper which sets labels for the content on
// the children returned by the handler and passes through the children.
// Must follow a handler that returns the children to be labeled.
func SetChildrenLabels(manager content.Manager, f HandlerFunc) HandlerFunc {
return SetChildrenMappedLabels(manager, f, nil)
}
// SetChildrenMappedLabels is a handler wrapper which sets labels for the content on
// the children returned by the handler and passes through the children.
// Must follow a handler that returns the children to be labeled.
// The label map allows the caller to control the labels per child descriptor.
// For returned labels, the index of the child will be appended to the end
// except for the first index when the returned label does not end with '.'.
func SetChildrenMappedLabels(manager content.Manager, f HandlerFunc, labelMap func(ocispec.Descriptor) []string) HandlerFunc {
if labelMap == nil {
labelMap = ChildGCLabels
}
return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
children, err := f(ctx, desc)
if err != nil {
return children, err
}
if len(children) > 0 {
var (
info = content.Info{
Digest: desc.Digest,
Labels: map[string]string{},
}
fields = []string{}
keys = map[string]uint{}
)
for _, ch := range children {
labelKeys := labelMap(ch)
for _, key := range labelKeys {
idx := keys[key]
keys[key] = idx + 1
if idx > 0 || key[len(key)-1] == '.' {
key = fmt.Sprintf("%s%d", key, idx)
}
info.Labels[key] = ch.Digest.String()
fields = append(fields, "labels."+key)
}
}
_, err := manager.Update(ctx, info, fields...)
if err != nil {
return nil, err
}
}
return children, err
}
}
// FilterPlatforms is a handler wrapper which limits the descriptors returned
// based on matching the specified platform matcher.
func FilterPlatforms(f HandlerFunc, m platforms.Matcher) HandlerFunc {
return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
children, err := f(ctx, desc)
if err != nil {
return children, err
}
var descs []ocispec.Descriptor
if m == nil {
descs = children
} else {
for _, d := range children {
if d.Platform == nil || m.Match(*d.Platform) {
descs = append(descs, d)
}
}
}
return descs, nil
}
}
// LimitManifests is a handler wrapper which filters the manifest descriptors
// returned using the provided platform.
// The results will be ordered according to the comparison operator and
// use the ordering in the manifests for equal matches.
// A limit of 0 or less is considered no limit.
// A not found error is returned if no manifest is matched.
func LimitManifests(f HandlerFunc, m platforms.MatchComparer, n int) HandlerFunc {
return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
children, err := f(ctx, desc)
if err != nil {
return children, err
}
// only limit manifests from an index
if IsIndexType(desc.MediaType) {
sort.SliceStable(children, func(i, j int) bool {
if children[i].Platform == nil {
return false
}
if children[j].Platform == nil {
return true
}
return m.Less(*children[i].Platform, *children[j].Platform)
})
if n > 0 {
if len(children) == 0 {
return children, fmt.Errorf("no match for platform in manifest: %w", errdefs.ErrNotFound)
}
if len(children) > n {
children = children[:n]
}
}
}
return children, nil
}
}

437
core/images/image.go Normal file
View File

@@ -0,0 +1,437 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/log"
digest "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// Image provides the model for how containerd views container images.
type Image struct {
// Name of the image.
//
// To be pulled, it must be a reference compatible with resolvers.
//
// This field is required.
Name string
// Labels provide runtime decoration for the image record.
//
// There is no default behavior for how these labels are propagated. They
// only decorate the static metadata object.
//
// This field is optional.
Labels map[string]string
// Target describes the root content for this image. Typically, this is
// a manifest, index or manifest list.
Target ocispec.Descriptor
CreatedAt, UpdatedAt time.Time
}
// DeleteOptions provide options on image delete
type DeleteOptions struct {
Synchronous bool
Target *ocispec.Descriptor
}
// DeleteOpt allows configuring a delete operation
type DeleteOpt func(context.Context, *DeleteOptions) error
// SynchronousDelete is used to indicate that an image deletion and removal of
// the image resources should occur synchronously before returning a result.
func SynchronousDelete() DeleteOpt {
return func(ctx context.Context, o *DeleteOptions) error {
o.Synchronous = true
return nil
}
}
// DeleteTarget is used to specify the target value an image is expected
// to have when deleting. If the image has a different target, then
// NotFound is returned.
func DeleteTarget(target *ocispec.Descriptor) DeleteOpt {
return func(ctx context.Context, o *DeleteOptions) error {
o.Target = target
return nil
}
}
// Store and interact with images
type Store interface {
Get(ctx context.Context, name string) (Image, error)
List(ctx context.Context, filters ...string) ([]Image, error)
Create(ctx context.Context, image Image) (Image, error)
// Update will replace the data in the store with the provided image. If
// one or more fieldpaths are provided, only those fields will be updated.
Update(ctx context.Context, image Image, fieldpaths ...string) (Image, error)
Delete(ctx context.Context, name string, opts ...DeleteOpt) error
}
// TODO(stevvooe): Many of these functions make strong platform assumptions,
// which are untrue in a lot of cases. More refactoring must be done here to
// make this work in all cases.
// Config resolves the image configuration descriptor.
//
// The caller can then use the descriptor to resolve and process the
// configuration of the image.
func (image *Image) Config(ctx context.Context, provider content.Provider, platform platforms.MatchComparer) (ocispec.Descriptor, error) {
return Config(ctx, provider, image.Target, platform)
}
// RootFS returns the unpacked diffids that make up and images rootfs.
//
// These are used to verify that a set of layers unpacked to the expected
// values.
func (image *Image) RootFS(ctx context.Context, provider content.Provider, platform platforms.MatchComparer) ([]digest.Digest, error) {
desc, err := image.Config(ctx, provider, platform)
if err != nil {
return nil, err
}
return RootFS(ctx, provider, desc)
}
// Size returns the total size of an image's packed resources.
func (image *Image) Size(ctx context.Context, provider content.Provider, platform platforms.MatchComparer) (int64, error) {
var size int64
return size, Walk(ctx, Handlers(HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if desc.Size < 0 {
return nil, fmt.Errorf("invalid size %v in %v (%v)", desc.Size, desc.Digest, desc.MediaType)
}
size += desc.Size
return nil, nil
}), LimitManifests(FilterPlatforms(ChildrenHandler(provider), platform), platform, 1)), image.Target)
}
type platformManifest struct {
p *ocispec.Platform
m *ocispec.Manifest
}
// Manifest resolves a manifest from the image for the given platform.
//
// When a manifest descriptor inside of a manifest index does not have
// a platform defined, the platform from the image config is considered.
//
// If the descriptor points to a non-index manifest, then the manifest is
// unmarshalled and returned without considering the platform inside of the
// config.
//
// TODO(stevvooe): This violates the current platform agnostic approach to this
// package by returning a specific manifest type. We'll need to refactor this
// to return a manifest descriptor or decide that we want to bring the API in
// this direction because this abstraction is not needed.
func Manifest(ctx context.Context, provider content.Provider, image ocispec.Descriptor, platform platforms.MatchComparer) (ocispec.Manifest, error) {
var (
limit = 1
m []platformManifest
wasIndex bool
)
if err := Walk(ctx, HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if IsManifestType(desc.MediaType) {
p, err := content.ReadBlob(ctx, provider, desc)
if err != nil {
return nil, err
}
if err := validateMediaType(p, desc.MediaType); err != nil {
return nil, fmt.Errorf("manifest: invalid desc %s: %w", desc.Digest, err)
}
var manifest ocispec.Manifest
if err := json.Unmarshal(p, &manifest); err != nil {
return nil, err
}
if desc.Digest != image.Digest && platform != nil {
if desc.Platform != nil && !platform.Match(*desc.Platform) {
return nil, nil
}
if desc.Platform == nil {
imagePlatform, err := ConfigPlatform(ctx, provider, manifest.Config)
if err != nil {
return nil, err
}
if !platform.Match(imagePlatform) {
return nil, nil
}
}
}
m = append(m, platformManifest{
p: desc.Platform,
m: &manifest,
})
return nil, nil
} else if IsIndexType(desc.MediaType) {
p, err := content.ReadBlob(ctx, provider, desc)
if err != nil {
return nil, err
}
if err := validateMediaType(p, desc.MediaType); err != nil {
return nil, fmt.Errorf("manifest: invalid desc %s: %w", desc.Digest, err)
}
var idx ocispec.Index
if err := json.Unmarshal(p, &idx); err != nil {
return nil, err
}
if platform == nil {
return idx.Manifests, nil
}
var descs []ocispec.Descriptor
for _, d := range idx.Manifests {
if d.Platform == nil || platform.Match(*d.Platform) {
descs = append(descs, d)
}
}
sort.SliceStable(descs, func(i, j int) bool {
if descs[i].Platform == nil {
return false
}
if descs[j].Platform == nil {
return true
}
return platform.Less(*descs[i].Platform, *descs[j].Platform)
})
wasIndex = true
if len(descs) > limit {
return descs[:limit], nil
}
return descs, nil
}
return nil, fmt.Errorf("unexpected media type %v for %v: %w", desc.MediaType, desc.Digest, errdefs.ErrNotFound)
}), image); err != nil {
return ocispec.Manifest{}, err
}
if len(m) == 0 {
err := fmt.Errorf("manifest %v: %w", image.Digest, errdefs.ErrNotFound)
if wasIndex {
err = fmt.Errorf("no match for platform in manifest %v: %w", image.Digest, errdefs.ErrNotFound)
}
return ocispec.Manifest{}, err
}
return *m[0].m, nil
}
// Config resolves the image configuration descriptor using a content provided
// to resolve child resources on the image.
//
// The caller can then use the descriptor to resolve and process the
// configuration of the image.
func Config(ctx context.Context, provider content.Provider, image ocispec.Descriptor, platform platforms.MatchComparer) (ocispec.Descriptor, error) {
manifest, err := Manifest(ctx, provider, image, platform)
if err != nil {
return ocispec.Descriptor{}, err
}
return manifest.Config, nil
}
// Platforms returns one or more platforms supported by the image.
func Platforms(ctx context.Context, provider content.Provider, image ocispec.Descriptor) ([]ocispec.Platform, error) {
var platformSpecs []ocispec.Platform
return platformSpecs, Walk(ctx, Handlers(HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if desc.Platform != nil {
platformSpecs = append(platformSpecs, *desc.Platform)
return nil, ErrSkipDesc
}
if IsConfigType(desc.MediaType) {
imagePlatform, err := ConfigPlatform(ctx, provider, desc)
if err != nil {
return nil, err
}
platformSpecs = append(platformSpecs, imagePlatform)
}
return nil, nil
}), ChildrenHandler(provider)), image)
}
// Check returns nil if the all components of an image are available in the
// provider for the specified platform.
//
// If available is true, the caller can assume that required represents the
// complete set of content required for the image.
//
// missing will have the components that are part of required but not available
// in the provider.
//
// If there is a problem resolving content, an error will be returned.
func Check(ctx context.Context, provider content.Provider, image ocispec.Descriptor, platform platforms.MatchComparer) (available bool, required, present, missing []ocispec.Descriptor, err error) {
mfst, err := Manifest(ctx, provider, image, platform)
if err != nil {
if errdefs.IsNotFound(err) {
return false, []ocispec.Descriptor{image}, nil, []ocispec.Descriptor{image}, nil
}
return false, nil, nil, nil, fmt.Errorf("failed to check image %v: %w", image.Digest, err)
}
// TODO(stevvooe): It is possible that referenced components could have
// children, but this is rare. For now, we ignore this and only verify
// that manifest components are present.
required = append([]ocispec.Descriptor{mfst.Config}, mfst.Layers...)
for _, desc := range required {
ra, err := provider.ReaderAt(ctx, desc)
if err != nil {
if errdefs.IsNotFound(err) {
missing = append(missing, desc)
continue
} else {
return false, nil, nil, nil, fmt.Errorf("failed to check image %v: %w", desc.Digest, err)
}
}
ra.Close()
present = append(present, desc)
}
return true, required, present, missing, nil
}
// Children returns the immediate children of content described by the descriptor.
func Children(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
if IsManifestType(desc.MediaType) {
p, err := content.ReadBlob(ctx, provider, desc)
if err != nil {
return nil, err
}
if err := validateMediaType(p, desc.MediaType); err != nil {
return nil, fmt.Errorf("children: invalid desc %s: %w", desc.Digest, err)
}
// TODO(stevvooe): We just assume oci manifest, for now. There may be
// subtle differences from the docker version.
var manifest ocispec.Manifest
if err := json.Unmarshal(p, &manifest); err != nil {
return nil, err
}
return append([]ocispec.Descriptor{manifest.Config}, manifest.Layers...), nil
} else if IsIndexType(desc.MediaType) {
p, err := content.ReadBlob(ctx, provider, desc)
if err != nil {
return nil, err
}
if err := validateMediaType(p, desc.MediaType); err != nil {
return nil, fmt.Errorf("children: invalid desc %s: %w", desc.Digest, err)
}
var index ocispec.Index
if err := json.Unmarshal(p, &index); err != nil {
return nil, err
}
return append([]ocispec.Descriptor{}, index.Manifests...), nil
} else if !IsLayerType(desc.MediaType) && !IsKnownConfig(desc.MediaType) {
// Layers and configs are childless data types and should not be logged.
log.G(ctx).Debugf("encountered unknown type %v; children may not be fetched", desc.MediaType)
}
return nil, nil
}
// unknownDocument represents a manifest, manifest list, or index that has not
// yet been validated.
type unknownDocument struct {
MediaType string `json:"mediaType,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
Layers json.RawMessage `json:"layers,omitempty"`
Manifests json.RawMessage `json:"manifests,omitempty"`
FSLayers json.RawMessage `json:"fsLayers,omitempty"` // schema 1
}
// validateMediaType returns an error if the byte slice is invalid JSON,
// if the format of the blob is not supported, or if the media type
// identifies the blob as one format, but it identifies itself as, or
// contains elements of another format.
func validateMediaType(b []byte, mt string) error {
var doc unknownDocument
if err := json.Unmarshal(b, &doc); err != nil {
return err
}
if len(doc.FSLayers) != 0 {
return fmt.Errorf("media-type: schema 1 not supported")
}
if IsManifestType(mt) && (len(doc.Manifests) != 0 || IsIndexType(doc.MediaType)) {
return fmt.Errorf("media-type: expected manifest but found index (%s)", mt)
} else if IsIndexType(mt) && (len(doc.Config) != 0 || len(doc.Layers) != 0 || IsManifestType(doc.MediaType)) {
return fmt.Errorf("media-type: expected index but found manifest (%s)", mt)
}
return nil
}
// RootFS returns the unpacked diffids that make up and images rootfs.
//
// These are used to verify that a set of layers unpacked to the expected
// values.
func RootFS(ctx context.Context, provider content.Provider, configDesc ocispec.Descriptor) ([]digest.Digest, error) {
p, err := content.ReadBlob(ctx, provider, configDesc)
if err != nil {
return nil, err
}
var config ocispec.Image
if err := json.Unmarshal(p, &config); err != nil {
return nil, err
}
return config.RootFS.DiffIDs, nil
}
// ConfigPlatform returns a normalized platform from an image manifest config.
func ConfigPlatform(ctx context.Context, provider content.Provider, configDesc ocispec.Descriptor) (ocispec.Platform, error) {
p, err := content.ReadBlob(ctx, provider, configDesc)
if err != nil {
return ocispec.Platform{}, err
}
// Technically, this should be ocispec.Image, but we only need the
// ocispec.Platform that is embedded in the image struct.
var imagePlatform ocispec.Platform
if err := json.Unmarshal(p, &imagePlatform); err != nil {
return ocispec.Platform{}, err
}
return platforms.Normalize(imagePlatform), nil
}

127
core/images/image_test.go Normal file
View File

@@ -0,0 +1,127 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"encoding/json"
"testing"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateMediaType(t *testing.T) {
docTests := []struct {
mt string
index bool
}{
{MediaTypeDockerSchema2Manifest, false},
{ocispec.MediaTypeImageManifest, false},
{MediaTypeDockerSchema2ManifestList, true},
{ocispec.MediaTypeImageIndex, true},
}
for _, tc := range docTests {
t.Run("manifest-"+tc.mt, func(t *testing.T) {
manifest := ocispec.Manifest{
Config: ocispec.Descriptor{Size: 1},
Layers: []ocispec.Descriptor{{Size: 2}},
}
b, err := json.Marshal(manifest)
require.NoError(t, err, "failed to marshal manifest")
err = validateMediaType(b, tc.mt)
if tc.index {
assert.Error(t, err, "manifest should not be a valid index")
} else {
assert.NoError(t, err, "manifest should be valid")
}
})
t.Run("index-"+tc.mt, func(t *testing.T) {
index := ocispec.Index{
Manifests: []ocispec.Descriptor{{Size: 1}},
}
b, err := json.Marshal(index)
require.NoError(t, err, "failed to marshal index")
err = validateMediaType(b, tc.mt)
if tc.index {
assert.NoError(t, err, "index should be valid")
} else {
assert.Error(t, err, "index should not be a valid manifest")
}
})
}
mtTests := []struct {
mt string
valid []string
invalid []string
}{{
MediaTypeDockerSchema2Manifest,
[]string{MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest},
[]string{MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex},
}, {
ocispec.MediaTypeImageManifest,
[]string{MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest},
[]string{MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex},
}, {
MediaTypeDockerSchema2ManifestList,
[]string{MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex},
[]string{MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest},
}, {
ocispec.MediaTypeImageIndex,
[]string{MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex},
[]string{MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest},
}}
for _, tc := range mtTests {
for _, v := range tc.valid {
t.Run("valid-"+tc.mt+"-"+v, func(t *testing.T) {
doc := struct {
MediaType string `json:"mediaType"`
}{MediaType: v}
b, err := json.Marshal(doc)
require.NoError(t, err, "failed to marshal document")
err = validateMediaType(b, tc.mt)
assert.NoError(t, err, "document should be valid")
})
}
for _, iv := range tc.invalid {
t.Run("invalid-"+tc.mt+"-"+iv, func(t *testing.T) {
doc := struct {
MediaType string `json:"mediaType"`
}{MediaType: iv}
b, err := json.Marshal(doc)
require.NoError(t, err, "failed to marshal document")
err = validateMediaType(b, tc.mt)
assert.Error(t, err, "document should not be valid")
})
}
}
t.Run("schema1", func(t *testing.T) {
doc := struct {
FSLayers []string `json:"fsLayers"`
}{FSLayers: []string{"1"}}
b, err := json.Marshal(doc)
require.NoError(t, err, "failed to marshal document")
err = validateMediaType(b, "")
assert.Error(t, err, "document should not be valid")
})
}

View File

@@ -0,0 +1,276 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imagetest
import (
"bytes"
"context"
"encoding/json"
"math/rand"
"sync"
"testing"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/plugins/content/local"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// Content represents a piece of image content in the content store along with
// its relevant children and size.
type Content struct {
Descriptor ocispec.Descriptor
Labels map[string]string
Size Size
Children []Content
}
// ContentStore is a temporary content store which provides simple
// helper functions for quickly altering content in the store.
// Directly modifying the content store without using the helper functions
// may result in out of sync test content values, be careful of this
// when writing tests.
type ContentStore struct {
content.Store
ctx context.Context
t *testing.T
}
// NewContentStore creates a new content store in the testing's temporary directory
func NewContentStore(ctx context.Context, t *testing.T) ContentStore {
cs, err := local.NewLabeledStore(t.TempDir(), newMemoryLabelStore())
if err != nil {
t.Fatal(err)
}
return ContentStore{
Store: cs,
ctx: ctx,
t: t,
}
}
// Index creates an index with the provided manifests and stores it
// in the content store.
func (tc ContentStore) Index(manifests ...Content) Content {
var index ocispec.Index
for _, m := range manifests {
index.Manifests = append(index.Manifests, m.Descriptor)
}
idx := tc.JSONObject(ocispec.MediaTypeImageIndex, index)
idx.Children = manifests
return idx
}
// Manifest creates a manifest with the given config and layers then
// stores it in the content store.
func (tc ContentStore) Manifest(config Content, layers ...Content) Content {
var manifest ocispec.Manifest
manifest.Config = config.Descriptor
for _, l := range layers {
manifest.Layers = append(manifest.Layers, l.Descriptor)
}
m := tc.JSONObject(ocispec.MediaTypeImageManifest, manifest)
m.Children = append(m.Children, config)
m.Children = append(m.Children, layers...)
return m
}
// Blob creates a generic blob with the given data and media type
// and stores the data in the content store.
func (tc ContentStore) Blob(mediaType string, data []byte) Content {
tc.t.Helper()
descriptor := ocispec.Descriptor{
MediaType: mediaType,
Digest: digest.SHA256.FromBytes(data),
Size: int64(len(data)),
}
ref := string(descriptor.Digest) // TODO: Add random component?
if err := content.WriteBlob(tc.ctx, tc.Store, ref, bytes.NewReader(data), descriptor); err != nil {
tc.t.Fatal(err)
}
return Content{
Descriptor: descriptor,
Size: Size{
Manifest: descriptor.Size,
Content: descriptor.Size,
},
}
}
// RandomBlob creates a blob object in the content store with random data.
func (tc ContentStore) RandomBlob(mediaType string, n int) Content {
tc.t.Helper()
data := make([]byte, int64(n))
rand.New(rand.NewSource(int64(n))).Read(data)
return tc.Blob(mediaType, data)
}
// JSONObject creates an object in the content store by first marshaling
// to JSON and then storing the data.
func (tc ContentStore) JSONObject(mediaType string, i interface{}) Content {
tc.t.Helper()
data, err := json.Marshal(i)
if err != nil {
tc.t.Fatal(err)
}
return tc.Blob(mediaType, data)
}
// Walk walks all the children of the provided content and calls the provided
// function with the associated content store.
// Walk can be used to update an object and reflect that change in the content
// store.
func (tc ContentStore) Walk(c Content, fn func(context.Context, *Content, content.Store) error) Content {
tc.t.Helper()
if err := fn(tc.ctx, &c, tc.Store); err != nil {
tc.t.Fatal(err)
}
if len(c.Children) > 0 {
children := make([]Content, len(c.Children))
for i, child := range c.Children {
children[i] = tc.Walk(child, fn)
}
c.Children = children
}
return c
}
// AddPlatform alters the content desciptor by setting the platform
func AddPlatform(c Content, p ocispec.Platform) Content {
c.Descriptor.Platform = &p
return c
}
// LimitChildren limits the amount of children in the content.
// This function is non-recursive and uses the natural ordering.
func LimitChildren(c Content, limit int) Content {
if images.IsIndexType(c.Descriptor.MediaType) {
if len(c.Children) > limit {
c.Children = c.Children[0:limit]
}
}
return c
}
// ContentCreator is a simple interface for generating content for tests
type ContentCreator func(ContentStore) Content
// SimpleManifest generates a simple manifest with small config and random layer
// The layer produced is not a valid compressed tar, do not unpack it.
func SimpleManifest(layerSize int) ContentCreator {
return func(tc ContentStore) Content {
config := ocispec.ImageConfig{
Env: []string{"random"}, // TODO: Make random string
}
return tc.Manifest(
tc.JSONObject(ocispec.MediaTypeImageConfig, config),
tc.RandomBlob(ocispec.MediaTypeImageLayerGzip, layerSize))
}
}
// SimpleIndex generates a simple index with the number of simple
// manifests specified.
func SimpleIndex(manifests, layerSize int) ContentCreator {
manifestFn := SimpleManifest(layerSize)
return func(tc ContentStore) Content {
var m []Content
for i := 0; i < manifests; i++ {
m = append(m, manifestFn(tc))
}
return tc.Index(m...)
}
}
// StripLayers deletes all layer content from the content store
// and updates the content size to 0.
func StripLayers(cc ContentCreator) ContentCreator {
return func(tc ContentStore) Content {
return tc.Walk(cc(tc), func(ctx context.Context, c *Content, store content.Store) error {
if images.IsLayerType(c.Descriptor.MediaType) {
if err := store.Delete(tc.ctx, c.Descriptor.Digest); err != nil {
return err
}
c.Size.Content = 0
}
return nil
})
}
}
type memoryLabelStore struct {
l sync.Mutex
labels map[digest.Digest]map[string]string
}
// newMemoryLabelStore creates an inmemory label store for testing
func newMemoryLabelStore() local.LabelStore {
return &memoryLabelStore{
labels: map[digest.Digest]map[string]string{},
}
}
func (mls *memoryLabelStore) Get(d digest.Digest) (map[string]string, error) {
mls.l.Lock()
labels := mls.labels[d]
mls.l.Unlock()
return labels, nil
}
func (mls *memoryLabelStore) Set(d digest.Digest, labels map[string]string) error {
mls.l.Lock()
mls.labels[d] = labels
mls.l.Unlock()
return nil
}
func (mls *memoryLabelStore) Update(d digest.Digest, update map[string]string) (map[string]string, error) {
mls.l.Lock()
labels, ok := mls.labels[d]
if !ok {
labels = map[string]string{}
}
for k, v := range update {
if v == "" {
delete(labels, k)
} else {
labels[k] = v
}
}
mls.labels[d] = labels
mls.l.Unlock()
return labels, nil
}

View File

@@ -0,0 +1,58 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package imagetest
// Size represents the size of an object from different methods of counting, in bytes
type Size struct {
// Manifest is the total Manifest reported size of current object and children
Manifest int64
// Content is the total of store Content of current object and children
Content int64
// Unpacked is the total size of Unpacked data for the given snapshotter
// The key is the snapshotter name
// The value is the usage reported by the snapshotter for the image
Unpacked map[string]int64
// Uncompressed is the total Uncompressed tar stream data (used for size estimate)
Uncompressed int64
}
// ContentSizeCalculator calculates a size property for the test content
type ContentSizeCalculator func(Content) int64
// SizeOfManifest recursively calculates the manifest reported size,
// not accounting for duplicate entries.
func SizeOfManifest(t Content) int64 {
accumulated := t.Size.Manifest
for _, child := range t.Children {
accumulated = accumulated + SizeOfManifest(child)
}
return accumulated
}
// SizeOfContent recursively calculates the size of all stored content
// The calculation is a simple accumulation based on the structure,
// duplicate blobs are not accounted for.
func SizeOfContent(t Content) int64 {
accumulated := t.Size.Content
for _, child := range t.Children {
accumulated = accumulated + SizeOfContent(child)
}
return accumulated
}

View File

@@ -0,0 +1,37 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"context"
"io"
"github.com/containerd/containerd/v2/core/content"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// Importer is the interface for image importer.
type Importer interface {
// Import imports an image from a tar stream.
Import(ctx context.Context, store content.Store, reader io.Reader) (ocispec.Descriptor, error)
}
// Exporter is the interface for image exporter.
type Exporter interface {
// Export exports an image to a tar stream.
Export(ctx context.Context, store content.Provider, desc ocispec.Descriptor, writer io.Writer) error
}

21
core/images/labels.go Normal file
View File

@@ -0,0 +1,21 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
const (
ConvertedDockerSchema1LabelKey = "io.containerd.image/converted-docker-schema1"
)

215
core/images/mediatypes.go Normal file
View File

@@ -0,0 +1,215 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package images
import (
"context"
"fmt"
"sort"
"strings"
"github.com/containerd/containerd/v2/errdefs"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// mediatype definitions for image components handled in containerd.
//
// oci components are generally referenced directly, although we may centralize
// here for clarity.
const (
MediaTypeDockerSchema2Layer = "application/vnd.docker.image.rootfs.diff.tar"
MediaTypeDockerSchema2LayerForeign = "application/vnd.docker.image.rootfs.foreign.diff.tar"
MediaTypeDockerSchema2LayerGzip = "application/vnd.docker.image.rootfs.diff.tar.gzip"
MediaTypeDockerSchema2LayerForeignGzip = "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip"
MediaTypeDockerSchema2Config = "application/vnd.docker.container.image.v1+json"
MediaTypeDockerSchema2Manifest = "application/vnd.docker.distribution.manifest.v2+json"
MediaTypeDockerSchema2ManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
// Checkpoint/Restore Media Types
MediaTypeContainerd1Checkpoint = "application/vnd.containerd.container.criu.checkpoint.criu.tar"
MediaTypeContainerd1CheckpointPreDump = "application/vnd.containerd.container.criu.checkpoint.predump.tar"
MediaTypeContainerd1Resource = "application/vnd.containerd.container.resource.tar"
MediaTypeContainerd1RW = "application/vnd.containerd.container.rw.tar"
MediaTypeContainerd1CheckpointConfig = "application/vnd.containerd.container.checkpoint.config.v1+proto"
MediaTypeContainerd1CheckpointOptions = "application/vnd.containerd.container.checkpoint.options.v1+proto"
MediaTypeContainerd1CheckpointRuntimeName = "application/vnd.containerd.container.checkpoint.runtime.name"
MediaTypeContainerd1CheckpointRuntimeOptions = "application/vnd.containerd.container.checkpoint.runtime.options+proto"
// MediaTypeDockerSchema1Manifest is the legacy Docker schema1 manifest
MediaTypeDockerSchema1Manifest = "application/vnd.docker.distribution.manifest.v1+prettyjws"
// Encrypted media types
MediaTypeImageLayerEncrypted = ocispec.MediaTypeImageLayer + "+encrypted"
MediaTypeImageLayerGzipEncrypted = ocispec.MediaTypeImageLayerGzip + "+encrypted"
)
// DiffCompression returns the compression as defined by the layer diff media
// type. For Docker media types without compression, "unknown" is returned to
// indicate that the media type may be compressed. If the media type is not
// recognized as a layer diff, then it returns errdefs.ErrNotImplemented
func DiffCompression(ctx context.Context, mediaType string) (string, error) {
base, ext := parseMediaTypes(mediaType)
switch base {
case MediaTypeDockerSchema2Layer, MediaTypeDockerSchema2LayerForeign:
if len(ext) > 0 {
// Type is wrapped
return "", nil
}
// These media types may have been compressed but failed to
// use the correct media type. The decompression function
// should detect and handle this case.
return "unknown", nil
case MediaTypeDockerSchema2LayerGzip, MediaTypeDockerSchema2LayerForeignGzip:
if len(ext) > 0 {
// Type is wrapped
return "", nil
}
return "gzip", nil
case ocispec.MediaTypeImageLayer, ocispec.MediaTypeImageLayerNonDistributable: //nolint:staticcheck // Non-distributable layers are deprecated
if len(ext) > 0 {
switch ext[len(ext)-1] {
case "gzip":
return "gzip", nil
case "zstd":
return "zstd", nil
}
}
return "", nil
default:
return "", fmt.Errorf("unrecognised mediatype %s: %w", mediaType, errdefs.ErrNotImplemented)
}
}
// parseMediaTypes splits the media type into the base type and
// an array of sorted extensions
func parseMediaTypes(mt string) (mediaType string, suffixes []string) {
if mt == "" {
return "", []string{}
}
mediaType, ext, ok := strings.Cut(mt, "+")
if !ok {
return mediaType, []string{}
}
// Splitting the extensions following the mediatype "(+)gzip+encrypted".
// We expect this to be a limited list, so add an arbitrary limit (50).
//
// Note that DiffCompression is only using the last element, so perhaps we
// should split on the last "+" only.
suffixes = strings.SplitN(ext, "+", 50)
sort.Strings(suffixes)
return mediaType, suffixes
}
// IsNonDistributable returns true if the media type is non-distributable.
func IsNonDistributable(mt string) bool {
return strings.HasPrefix(mt, "application/vnd.oci.image.layer.nondistributable.") ||
strings.HasPrefix(mt, "application/vnd.docker.image.rootfs.foreign.")
}
// IsLayerType returns true if the media type is a layer
func IsLayerType(mt string) bool {
if strings.HasPrefix(mt, "application/vnd.oci.image.layer.") {
return true
}
// Parse Docker media types, strip off any + suffixes first
switch base, _ := parseMediaTypes(mt); base {
case MediaTypeDockerSchema2Layer, MediaTypeDockerSchema2LayerGzip,
MediaTypeDockerSchema2LayerForeign, MediaTypeDockerSchema2LayerForeignGzip:
return true
}
return false
}
// IsDockerType returns true if the media type has "application/vnd.docker." prefix
func IsDockerType(mt string) bool {
return strings.HasPrefix(mt, "application/vnd.docker.")
}
// IsManifestType returns true if the media type is an OCI-compatible manifest.
// No support for schema1 manifest.
func IsManifestType(mt string) bool {
switch mt {
case MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest:
return true
default:
return false
}
}
// IsIndexType returns true if the media type is an OCI-compatible index.
func IsIndexType(mt string) bool {
switch mt {
case ocispec.MediaTypeImageIndex, MediaTypeDockerSchema2ManifestList:
return true
default:
return false
}
}
// IsConfigType returns true if the media type is an OCI-compatible image config.
// No support for containerd checkpoint configs.
func IsConfigType(mt string) bool {
switch mt {
case MediaTypeDockerSchema2Config, ocispec.MediaTypeImageConfig:
return true
default:
return false
}
}
// IsKnownConfig returns true if the media type is a known config type,
// including containerd checkpoint configs
func IsKnownConfig(mt string) bool {
switch mt {
case MediaTypeDockerSchema2Config, ocispec.MediaTypeImageConfig,
MediaTypeContainerd1Checkpoint, MediaTypeContainerd1CheckpointConfig:
return true
}
return false
}
// ChildGCLabels returns the label for a given descriptor to reference it
func ChildGCLabels(desc ocispec.Descriptor) []string {
mt := desc.MediaType
if IsKnownConfig(mt) {
return []string{"containerd.io/gc.ref.content.config"}
}
switch mt {
case MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest:
return []string{"containerd.io/gc.ref.content.m."}
}
if IsLayerType(mt) {
return []string{"containerd.io/gc.ref.content.l."}
}
return []string{"containerd.io/gc.ref.content."}
}
// ChildGCLabelsFilterLayers returns the labels for a given descriptor to
// reference it, skipping layer media types
func ChildGCLabelsFilterLayers(desc ocispec.Descriptor) []string {
if IsLayerType(desc.MediaType) {
return nil
}
return ChildGCLabels(desc)
}

View File

@@ -0,0 +1,167 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package usage
import (
"context"
"strings"
"sync/atomic"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/errdefs"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/containerd/v2/snapshots"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"golang.org/x/sync/semaphore"
)
type usageOptions struct {
platform platforms.MatchComparer
manifestLimit int
manifestOnly bool
snapshots func(name string) snapshots.Snapshotter
}
type Opt func(*usageOptions) error
// WithManifestLimit sets the limit to the number of manifests which will
// be walked for usage. Setting this value to 0 will require all manifests to
// be walked, returning ErrNotFound if manifests are missing.
// NOTE: By default all manifests which exist will be walked
// and any non-existent manifests and their subobjects will be ignored.
func WithManifestLimit(platform platforms.MatchComparer, i int) Opt {
// If 0 then don't filter any manifests
// By default limits to current platform
return func(o *usageOptions) error {
o.manifestLimit = i
o.platform = platform
return nil
}
}
// WithSnapshotters will check for referenced snapshots from the image objects
// and include the snapshot size in the total usage.
func WithSnapshotters(f func(string) snapshots.Snapshotter) Opt {
return func(o *usageOptions) error {
o.snapshots = f
return nil
}
}
// WithManifestUsage is used to get the usage for an image based on what is
// reported by the manifests rather than what exists in the content store.
// NOTE: This function is best used with the manifest limit set to get a
// consistent value, otherwise non-existent manifests will be excluded.
func WithManifestUsage() Opt {
return func(o *usageOptions) error {
o.manifestOnly = true
return nil
}
}
func CalculateImageUsage(ctx context.Context, i images.Image, provider content.InfoReaderProvider, opts ...Opt) (int64, error) {
var config usageOptions
for _, opt := range opts {
if err := opt(&config); err != nil {
return 0, err
}
}
var (
handler = images.ChildrenHandler(provider)
size int64
mustExist bool
)
if config.platform != nil {
handler = images.LimitManifests(handler, config.platform, config.manifestLimit)
mustExist = true
}
var wh images.HandlerFunc = func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
var usage int64
children, err := handler(ctx, desc)
if err != nil {
if !errdefs.IsNotFound(err) || mustExist {
return nil, err
}
if !config.manifestOnly {
// Do not count size of non-existent objects
desc.Size = 0
}
} else if config.snapshots != nil || !config.manifestOnly {
info, err := provider.Info(ctx, desc.Digest)
if err != nil {
if !errdefs.IsNotFound(err) {
return nil, err
}
if !config.manifestOnly {
// Do not count size of non-existent objects
desc.Size = 0
}
} else {
if info.Size > desc.Size {
// Count actual usage, Size may be unset or -1
desc.Size = info.Size
}
if config.snapshots != nil {
for k, v := range info.Labels {
const prefix = "containerd.io/gc.ref.snapshot."
if !strings.HasPrefix(k, prefix) {
continue
}
sn := config.snapshots(k[len(prefix):])
if sn == nil {
continue
}
u, err := sn.Usage(ctx, v)
if err != nil {
if !errdefs.IsNotFound(err) && !errdefs.IsInvalidArgument(err) {
return nil, err
}
} else {
usage += u.Size
}
}
}
}
}
// Ignore unknown sizes. Generally unknown sizes should
// never be set in manifests, however, the usage
// calculation does not need to enforce this.
if desc.Size >= 0 {
usage += desc.Size
}
atomic.AddInt64(&size, usage)
return children, nil
}
l := semaphore.NewWeighted(3)
if err := images.Dispatch(ctx, wh, l, i.Target); err != nil {
return 0, err
}
return size, nil
}

View File

@@ -0,0 +1,100 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package usage
import (
"context"
"testing"
"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/core/images/imagetest"
"github.com/containerd/containerd/v2/platforms"
"github.com/containerd/log/logtest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
func TestUsageCalculation(t *testing.T) {
for _, tc := range []struct {
name string
target imagetest.ContentCreator
expected imagetest.ContentSizeCalculator
opts []Opt
}{
{
name: "simple",
target: imagetest.SimpleManifest(50),
expected: imagetest.SizeOfManifest,
opts: []Opt{WithManifestUsage()},
},
{
name: "simpleIndex",
target: imagetest.SimpleIndex(5, 50),
expected: imagetest.SizeOfContent,
},
{
name: "stripLayersManifestOnly",
target: imagetest.StripLayers(imagetest.SimpleIndex(3, 51)),
expected: imagetest.SizeOfManifest,
opts: []Opt{WithManifestUsage()},
},
{
name: "stripLayers",
target: imagetest.StripLayers(imagetest.SimpleIndex(4, 60)),
expected: imagetest.SizeOfContent,
},
{
name: "manifestlimit",
target: func(tc imagetest.ContentStore) imagetest.Content {
return tc.Index(
imagetest.AddPlatform(imagetest.SimpleManifest(5)(tc), ocispec.Platform{Architecture: "amd64", OS: "linux"}),
imagetest.AddPlatform(imagetest.SimpleManifest(10)(tc), ocispec.Platform{Architecture: "arm64", OS: "linux"}),
)
},
expected: func(t imagetest.Content) int64 { return imagetest.SizeOfManifest(imagetest.LimitChildren(t, 1)) },
opts: []Opt{
WithManifestLimit(platforms.Only(ocispec.Platform{Architecture: "amd64", OS: "linux"}), 1),
WithManifestUsage(),
},
},
// TODO: Add test with snapshot
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
ctx := logtest.WithT(context.Background(), t)
cs := imagetest.NewContentStore(ctx, t)
content := tc.target(cs)
img := images.Image{
Name: tc.name,
Target: content.Descriptor,
Labels: content.Labels,
}
usage, err := CalculateImageUsage(ctx, img, cs, tc.opts...)
if err != nil {
t.Fatal(err)
}
expected := tc.expected(content)
if expected != usage {
t.Fatalf("unexpected usage: %d, expected %d", usage, expected)
}
})
}
}