| package containerd |
| |
| import ( |
| "context" |
| "encoding/json" |
| "runtime" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/containerd/containerd/content" |
| "github.com/containerd/containerd/images" |
| "github.com/containerd/containerd/labels" |
| "github.com/containerd/containerd/platforms" |
| cplatforms "github.com/containerd/containerd/platforms" |
| "github.com/containerd/containerd/snapshots" |
| cerrdefs "github.com/containerd/errdefs" |
| "github.com/containerd/log" |
| "github.com/distribution/reference" |
| "github.com/docker/docker/api/types/backend" |
| "github.com/docker/docker/api/types/filters" |
| imagetypes "github.com/docker/docker/api/types/image" |
| timetypes "github.com/docker/docker/api/types/time" |
| "github.com/docker/docker/container" |
| "github.com/docker/docker/errdefs" |
| dockerspec "github.com/moby/docker-image-spec/specs-go/v1" |
| "github.com/opencontainers/go-digest" |
| "github.com/opencontainers/image-spec/identity" |
| ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
| "github.com/pkg/errors" |
| "golang.org/x/sync/errgroup" |
| ) |
| |
| // Subset of ocispec.Image that only contains Labels |
| type configLabels struct { |
| // Created is the combined date and time at which the image was created, formatted as defined by RFC 3339, section 5.6. |
| Created *time.Time `json:"created,omitempty"` |
| |
| Config struct { |
| Labels map[string]string `json:"Labels,omitempty"` |
| } `json:"config,omitempty"` |
| } |
| |
| var acceptedImageFilterTags = map[string]bool{ |
| "dangling": true, |
| "label": true, |
| "label!": true, |
| "before": true, |
| "since": true, |
| "reference": true, |
| "until": true, |
| } |
| |
| // byCreated is a temporary type used to sort a list of images by creation |
| // time. |
| type byCreated []*imagetypes.Summary |
| |
| func (r byCreated) Len() int { return len(r) } |
| func (r byCreated) Swap(i, j int) { r[i], r[j] = r[j], r[i] } |
| func (r byCreated) Less(i, j int) bool { return r[i].Created < r[j].Created } |
| |
| // Images returns a filtered list of images. |
| // |
| // TODO(thaJeztah): verify behavior of `RepoDigests` and `RepoTags` for images without (untagged) or multiple tags; see https://github.com/moby/moby/issues/43861 |
| // TODO(thaJeztah): verify "Size" vs "VirtualSize" in images; see https://github.com/moby/moby/issues/43862 |
| func (i *ImageService) Images(ctx context.Context, opts imagetypes.ListOptions) ([]*imagetypes.Summary, error) { |
| if err := opts.Filters.Validate(acceptedImageFilterTags); err != nil { |
| return nil, err |
| } |
| |
| filter, err := i.setupFilters(ctx, opts.Filters) |
| if err != nil { |
| return nil, err |
| } |
| |
| imgs, err := i.images.List(ctx) |
| if err != nil { |
| return nil, err |
| } |
| |
| // TODO(thaJeztah): do we need to take multiple snapshotters into account? See https://github.com/moby/moby/issues/45273 |
| snapshotter := i.snapshotterService(i.snapshotter) |
| sizeCache := make(map[digest.Digest]int64) |
| snapshotSizeFn := func(d digest.Digest) (int64, error) { |
| if s, ok := sizeCache[d]; ok { |
| return s, nil |
| } |
| usage, err := snapshotter.Usage(ctx, d.String()) |
| if err != nil { |
| return 0, err |
| } |
| sizeCache[d] = usage.Size |
| return usage.Size, nil |
| } |
| |
| uniqueImages := map[digest.Digest]images.Image{} |
| tagsByDigest := map[digest.Digest][]string{} |
| intermediateImages := map[digest.Digest]struct{}{} |
| |
| hideIntermediate := !opts.All |
| if hideIntermediate { |
| for _, img := range imgs { |
| parent, ok := img.Labels[imageLabelClassicBuilderParent] |
| if ok && parent != "" { |
| dgst, err := digest.Parse(parent) |
| if err != nil { |
| log.G(ctx).WithFields(log.Fields{ |
| "error": err, |
| "value": parent, |
| }).Warnf("invalid %s label value", imageLabelClassicBuilderParent) |
| } |
| intermediateImages[dgst] = struct{}{} |
| } |
| } |
| } |
| |
| // TODO: Allow platform override? |
| platformMatcher := matchAllWithPreference(cplatforms.Default()) |
| |
| for _, img := range imgs { |
| isDangling := isDanglingImage(img) |
| |
| if hideIntermediate && isDangling { |
| if _, ok := intermediateImages[img.Target.Digest]; ok { |
| continue |
| } |
| } |
| |
| if !filter(img) { |
| continue |
| } |
| |
| dgst := img.Target.Digest |
| uniqueImages[dgst] = img |
| |
| if isDangling { |
| continue |
| } |
| |
| ref, err := reference.ParseNormalizedNamed(img.Name) |
| if err != nil { |
| continue |
| } |
| tagsByDigest[dgst] = append(tagsByDigest[dgst], reference.FamiliarString(ref)) |
| } |
| |
| resultsMut := sync.Mutex{} |
| eg, egCtx := errgroup.WithContext(ctx) |
| eg.SetLimit(runtime.NumCPU() * 2) |
| |
| var ( |
| summaries = make([]*imagetypes.Summary, 0, len(imgs)) |
| root []*[]digest.Digest |
| layers map[digest.Digest]int |
| ) |
| if opts.SharedSize { |
| root = make([]*[]digest.Digest, 0, len(imgs)) |
| layers = make(map[digest.Digest]int) |
| } |
| |
| for _, img := range uniqueImages { |
| img := img |
| eg.Go(func() error { |
| image, allChainsIDs, err := i.imageSummary(egCtx, img, platformMatcher, opts, tagsByDigest) |
| if err != nil { |
| return err |
| } |
| // No error, but image should be skipped. |
| if image == nil { |
| return nil |
| } |
| |
| resultsMut.Lock() |
| summaries = append(summaries, image) |
| |
| if opts.SharedSize { |
| root = append(root, &allChainsIDs) |
| for _, id := range allChainsIDs { |
| layers[id] = layers[id] + 1 |
| } |
| } |
| resultsMut.Unlock() |
| return nil |
| }) |
| } |
| |
| if err := eg.Wait(); err != nil { |
| return nil, err |
| } |
| |
| if opts.SharedSize { |
| for n, chainIDs := range root { |
| sharedSize, err := computeSharedSize(*chainIDs, layers, snapshotSizeFn) |
| if err != nil { |
| return nil, err |
| } |
| summaries[n].SharedSize = sharedSize |
| } |
| } |
| |
| sort.Sort(sort.Reverse(byCreated(summaries))) |
| |
| return summaries, nil |
| } |
| |
| // imageSummary returns a summary of the image, including the total size of the image and all its platforms. |
| // It also returns the chainIDs of all the layers of the image (including all its platforms). |
| // All return values will be nil if the image should be skipped. |
| func (i *ImageService) imageSummary(ctx context.Context, img images.Image, platformMatcher platforms.MatchComparer, |
| opts imagetypes.ListOptions, tagsByDigest map[digest.Digest][]string, |
| ) (_ *imagetypes.Summary, allChainIDs []digest.Digest, _ error) { |
| |
| // Total size of the image including all its platform |
| var totalSize int64 |
| |
| // ChainIDs of all the layers of the image (including all its platform) |
| var allChainsIDs []digest.Digest |
| |
| // Count of containers using the image |
| var containersCount int64 |
| |
| // Single platform image manifest preferred by the platform matcher |
| var best *ImageManifest |
| var bestPlatform ocispec.Platform |
| |
| err := i.walkImageManifests(ctx, img, func(img *ImageManifest) error { |
| if isPseudo, err := img.IsPseudoImage(ctx); isPseudo || err != nil { |
| return nil |
| } |
| |
| available, err := img.CheckContentAvailable(ctx) |
| if err != nil { |
| log.G(ctx).WithFields(log.Fields{ |
| "error": err, |
| "manifest": img.Target(), |
| "image": img.Name(), |
| }).Warn("checking availability of platform specific manifest failed") |
| return nil |
| } |
| |
| if !available { |
| return nil |
| } |
| |
| conf, err := img.Config(ctx) |
| if err != nil { |
| return err |
| } |
| |
| var dockerImage dockerspec.DockerOCIImage |
| if err := readConfig(ctx, i.content, conf, &dockerImage); err != nil { |
| return err |
| } |
| |
| target := img.Target() |
| |
| diffIDs, err := img.RootFS(ctx) |
| if err != nil { |
| return err |
| } |
| |
| chainIDs := identity.ChainIDs(diffIDs) |
| |
| ts, _, err := i.singlePlatformSize(ctx, img) |
| if err != nil { |
| return err |
| } |
| |
| totalSize += ts |
| allChainsIDs = append(allChainsIDs, chainIDs...) |
| |
| if opts.ContainerCount { |
| i.containers.ApplyAll(func(c *container.Container) { |
| if c.ImageManifest != nil && c.ImageManifest.Digest == target.Digest { |
| containersCount++ |
| } |
| }) |
| } |
| |
| var platform ocispec.Platform |
| if target.Platform != nil { |
| platform = *target.Platform |
| } else { |
| platform = dockerImage.Platform |
| } |
| |
| // Filter out platforms that don't match the requested platform. Do it |
| // after the size, container count and chainIDs are summed up to have |
| // the single combined entry still represent the whole multi-platform |
| // image. |
| if !platformMatcher.Match(platform) { |
| return nil |
| } |
| |
| if best == nil || platformMatcher.Less(platform, bestPlatform) { |
| best = img |
| bestPlatform = platform |
| } |
| |
| return nil |
| }) |
| if err != nil { |
| if errors.Is(err, errNotManifestOrIndex) { |
| log.G(ctx).WithFields(log.Fields{ |
| "error": err, |
| "image": img.Name, |
| }).Warn("unexpected image target (neither a manifest nor index)") |
| return nil, nil, nil |
| } |
| return nil, nil, err |
| } |
| |
| if best == nil { |
| // TODO we should probably show *something* for images we've pulled |
| // but are 100% shallow or an empty manifest list/index |
| // ("tianon/scratch:index" is an empty example image index and |
| // "tianon/scratch:list" is an empty example manifest list) |
| return nil, nil, nil |
| } |
| |
| image, err := i.singlePlatformImage(ctx, i.content, tagsByDigest[best.RealTarget.Digest], best) |
| if err != nil { |
| return nil, nil, err |
| } |
| image.Size = totalSize |
| |
| if opts.ContainerCount { |
| image.Containers = containersCount |
| } |
| return image, allChainsIDs, nil |
| } |
| |
| func (i *ImageService) singlePlatformSize(ctx context.Context, imgMfst *ImageManifest) (totalSize int64, contentSize int64, _ error) { |
| // TODO(thaJeztah): do we need to take multiple snapshotters into account? See https://github.com/moby/moby/issues/45273 |
| snapshotter := i.snapshotterService(i.snapshotter) |
| |
| diffIDs, err := imgMfst.RootFS(ctx) |
| if err != nil { |
| return -1, -1, errors.Wrapf(err, "failed to get rootfs of image %s", imgMfst.Name()) |
| } |
| |
| imageSnapshotID := identity.ChainID(diffIDs).String() |
| unpackedUsage, err := calculateSnapshotTotalUsage(ctx, snapshotter, imageSnapshotID) |
| if err != nil { |
| if !cerrdefs.IsNotFound(err) { |
| log.G(ctx).WithError(err).WithFields(log.Fields{ |
| "image": imgMfst.Name(), |
| "snapshotID": imageSnapshotID, |
| }).Warn("failed to calculate unpacked size of image") |
| } |
| unpackedUsage = snapshots.Usage{Size: 0} |
| } |
| |
| contentSize, err = imgMfst.Size(ctx) |
| if err != nil { |
| return -1, -1, err |
| } |
| |
| // totalSize is the size of the image's packed layers and snapshots |
| // (unpacked layers) combined. |
| totalSize = contentSize + unpackedUsage.Size |
| return totalSize, contentSize, nil |
| } |
| |
| func (i *ImageService) singlePlatformImage(ctx context.Context, contentStore content.Store, repoTags []string, imageManifest *ImageManifest) (*imagetypes.Summary, error) { |
| var repoDigests []string |
| rawImg := imageManifest.Metadata() |
| target := rawImg.Target.Digest |
| |
| logger := log.G(ctx).WithFields(log.Fields{ |
| "name": rawImg.Name, |
| "digest": target, |
| }) |
| |
| ref, err := reference.ParseNamed(rawImg.Name) |
| if err != nil { |
| // If the image has unexpected name format (not a Named reference or a dangling image) |
| // add the offending name to RepoTags but also log an error to make it clear to the |
| // administrator that this is unexpected. |
| // TODO: Reconsider when containerd is more strict on image names, see: |
| // https://github.com/containerd/containerd/issues/7986 |
| if !isDanglingImage(rawImg) { |
| logger.WithError(err).Error("failed to parse image name as reference") |
| repoTags = append(repoTags, rawImg.Name) |
| } |
| } else { |
| digested, err := reference.WithDigest(reference.TrimNamed(ref), target) |
| if err != nil { |
| logger.WithError(err).Error("failed to create digested reference") |
| } else { |
| repoDigests = append(repoDigests, reference.FamiliarString(digested)) |
| } |
| } |
| |
| cfgDesc, err := imageManifest.Image.Config(ctx) |
| if err != nil { |
| return nil, err |
| } |
| var cfg configLabels |
| if err := readConfig(ctx, contentStore, cfgDesc, &cfg); err != nil { |
| return nil, err |
| } |
| |
| totalSize, _, err := i.singlePlatformSize(ctx, imageManifest) |
| if err != nil { |
| return nil, errors.Wrapf(err, "failed to calculate size of image %s", imageManifest.Name()) |
| } |
| |
| summary := &imagetypes.Summary{ |
| ParentID: rawImg.Labels[imageLabelClassicBuilderParent], |
| ID: target.String(), |
| RepoDigests: repoDigests, |
| RepoTags: repoTags, |
| Size: totalSize, |
| Labels: cfg.Config.Labels, |
| // -1 indicates that the value has not been set (avoids ambiguity |
| // between 0 (default) and "not set". We cannot use a pointer (nil) |
| // for this, as the JSON representation uses "omitempty", which would |
| // consider both "0" and "nil" to be "empty". |
| SharedSize: -1, |
| Containers: -1, |
| } |
| if cfg.Created != nil { |
| summary.Created = cfg.Created.Unix() |
| } |
| |
| return summary, nil |
| } |
| |
| type imageFilterFunc func(image images.Image) bool |
| |
| // setupFilters constructs an imageFilterFunc from the given imageFilters. |
| // |
| // filterFunc is a function that checks whether given image matches the filters. |
| // TODO(thaJeztah): reimplement filters using containerd filters if possible: see https://github.com/moby/moby/issues/43845 |
| func (i *ImageService) setupFilters(ctx context.Context, imageFilters filters.Args) (filterFunc imageFilterFunc, outErr error) { |
| var fltrs []imageFilterFunc |
| err := imageFilters.WalkValues("before", func(value string) error { |
| img, err := i.GetImage(ctx, value, backend.GetImageOpts{}) |
| if err != nil { |
| return err |
| } |
| if img != nil && img.Created != nil { |
| fltrs = append(fltrs, func(candidate images.Image) bool { |
| cand, err := i.GetImage(ctx, candidate.Name, backend.GetImageOpts{}) |
| if err != nil { |
| return false |
| } |
| return cand.Created != nil && cand.Created.Before(*img.Created) |
| }) |
| } |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| err = imageFilters.WalkValues("since", func(value string) error { |
| img, err := i.GetImage(ctx, value, backend.GetImageOpts{}) |
| if err != nil { |
| return err |
| } |
| if img != nil && img.Created != nil { |
| fltrs = append(fltrs, func(candidate images.Image) bool { |
| cand, err := i.GetImage(ctx, candidate.Name, backend.GetImageOpts{}) |
| if err != nil { |
| return false |
| } |
| return cand.Created != nil && cand.Created.After(*img.Created) |
| }) |
| } |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| err = imageFilters.WalkValues("until", func(value string) error { |
| ts, err := timetypes.GetTimestamp(value, time.Now()) |
| if err != nil { |
| return err |
| } |
| seconds, nanoseconds, err := timetypes.ParseTimestamps(ts, 0) |
| if err != nil { |
| return err |
| } |
| until := time.Unix(seconds, nanoseconds) |
| |
| fltrs = append(fltrs, func(image images.Image) bool { |
| created := image.CreatedAt |
| return created.Before(until) |
| }) |
| return err |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| labelFn, err := setupLabelFilter(ctx, i.content, imageFilters) |
| if err != nil { |
| return nil, err |
| } |
| if labelFn != nil { |
| fltrs = append(fltrs, labelFn) |
| } |
| |
| if imageFilters.Contains("dangling") { |
| danglingValue, err := imageFilters.GetBoolOrDefault("dangling", false) |
| if err != nil { |
| return nil, err |
| } |
| fltrs = append(fltrs, func(image images.Image) bool { |
| return danglingValue == isDanglingImage(image) |
| }) |
| } |
| |
| if refs := imageFilters.Get("reference"); len(refs) != 0 { |
| fltrs = append(fltrs, func(image images.Image) bool { |
| ref, err := reference.ParseNormalizedNamed(image.Name) |
| if err != nil { |
| return false |
| } |
| for _, value := range refs { |
| found, err := reference.FamiliarMatch(value, ref) |
| if err != nil { |
| return false |
| } |
| if found { |
| return found |
| } |
| } |
| return false |
| }) |
| } |
| |
| return func(image images.Image) bool { |
| for _, filter := range fltrs { |
| if !filter(image) { |
| return false |
| } |
| } |
| return true |
| }, nil |
| } |
| |
| // setupLabelFilter parses filter args for "label" and "label!" and returns a |
| // filter func which will check if any image config from the given image has |
| // labels that match given predicates. |
| func setupLabelFilter(ctx context.Context, store content.Store, fltrs filters.Args) (func(image images.Image) bool, error) { |
| type labelCheck struct { |
| key string |
| value string |
| onlyExists bool |
| negate bool |
| } |
| |
| var checks []labelCheck |
| for _, fltrName := range []string{"label", "label!"} { |
| for _, l := range fltrs.Get(fltrName) { |
| k, v, found := strings.Cut(l, "=") |
| err := labels.Validate(k, v) |
| if err != nil { |
| return nil, err |
| } |
| |
| negate := strings.HasSuffix(fltrName, "!") |
| |
| // If filter value is key!=value then flip the above. |
| if strings.HasSuffix(k, "!") { |
| k = strings.TrimSuffix(k, "!") |
| negate = !negate |
| } |
| |
| checks = append(checks, labelCheck{ |
| key: k, |
| value: v, |
| onlyExists: !found, |
| negate: negate, |
| }) |
| } |
| } |
| |
| if len(checks) == 0 { |
| return nil, nil |
| } |
| |
| return func(image images.Image) bool { |
| // This is not an error, but a signal to Dispatch that it should stop |
| // processing more content (otherwise it will run for all children). |
| // It will be returned once a matching config is found. |
| errFoundConfig := errors.New("success, found matching config") |
| |
| err := images.Dispatch(ctx, presentChildrenHandler(store, images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) (subdescs []ocispec.Descriptor, err error) { |
| if !images.IsConfigType(desc.MediaType) { |
| return nil, nil |
| } |
| var cfg configLabels |
| if err := readConfig(ctx, store, desc, &cfg); err != nil { |
| if errdefs.IsNotFound(err) { |
| return nil, nil |
| } |
| return nil, err |
| } |
| |
| for _, check := range checks { |
| value, exists := cfg.Config.Labels[check.key] |
| |
| if check.onlyExists { |
| // label! given without value, check if doesn't exist |
| if check.negate { |
| // Label exists, config doesn't match |
| if exists { |
| return nil, nil |
| } |
| } else { |
| // Label should exist |
| if !exists { |
| // Label doesn't exist, config doesn't match |
| return nil, nil |
| } |
| } |
| continue |
| } else if !exists { |
| // We are checking value and label doesn't exist. |
| return nil, nil |
| } |
| |
| valueEquals := value == check.value |
| if valueEquals == check.negate { |
| return nil, nil |
| } |
| } |
| |
| // This config matches the filter so we need to shop this image, stop dispatch. |
| return nil, errFoundConfig |
| })), nil, image.Target) |
| |
| if err == errFoundConfig { |
| return true |
| } |
| if err != nil { |
| log.G(ctx).WithFields(log.Fields{ |
| "error": err, |
| "image": image.Name, |
| "checks": checks, |
| }).Error("failed to check image labels") |
| } |
| |
| return false |
| }, nil |
| } |
| |
| func computeSharedSize(chainIDs []digest.Digest, layers map[digest.Digest]int, sizeFn func(d digest.Digest) (int64, error)) (int64, error) { |
| var sharedSize int64 |
| for _, chainID := range chainIDs { |
| if layers[chainID] == 1 { |
| continue |
| } |
| size, err := sizeFn(chainID) |
| if err != nil { |
| // Several images might share the same layer and neither of them |
| // might be unpacked (for example if it's a non-host platform). |
| if cerrdefs.IsNotFound(err) { |
| continue |
| } |
| return 0, err |
| } |
| sharedSize += size |
| } |
| return sharedSize, nil |
| } |
| |
| // readConfig reads content pointed by the descriptor and unmarshals it into a specified output. |
| func readConfig(ctx context.Context, store content.Provider, desc ocispec.Descriptor, out interface{}) error { |
| data, err := content.ReadBlob(ctx, store, desc) |
| if err != nil { |
| err = errors.Wrapf(err, "failed to read config content") |
| if cerrdefs.IsNotFound(err) { |
| return errdefs.NotFound(err) |
| } |
| return err |
| } |
| |
| err = json.Unmarshal(data, out) |
| if err != nil { |
| err = errors.Wrapf(err, "could not deserialize image config") |
| if cerrdefs.IsNotFound(err) { |
| return errdefs.NotFound(err) |
| } |
| return err |
| } |
| |
| return nil |
| } |