|
|
@@ -0,0 +1,202 @@
|
|
|
+package compose
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "fmt"
|
|
|
+ "sort"
|
|
|
+ "sync"
|
|
|
+
|
|
|
+ "github.com/compose-spec/compose-go/types"
|
|
|
+ "github.com/distribution/distribution/v3/reference"
|
|
|
+ moby "github.com/docker/docker/api/types"
|
|
|
+ "github.com/docker/docker/api/types/filters"
|
|
|
+ "github.com/docker/docker/client"
|
|
|
+ "github.com/docker/docker/errdefs"
|
|
|
+ "golang.org/x/sync/errgroup"
|
|
|
+
|
|
|
+ "github.com/docker/compose/v2/pkg/api"
|
|
|
+)
|
|
|
+
|
|
|
+// ImagePruneMode controls how aggressively images associated with the project
|
|
|
+// are removed from the engine.
|
|
|
+type ImagePruneMode string
|
|
|
+
|
|
|
+const (
|
|
|
+ // ImagePruneNone indicates that no project images should be removed.
|
|
|
+ ImagePruneNone ImagePruneMode = ""
|
|
|
+ // ImagePruneLocal indicates that only images built locally by Compose
|
|
|
+ // should be removed.
|
|
|
+ ImagePruneLocal ImagePruneMode = "local"
|
|
|
+ // ImagePruneAll indicates that all project-associated images, including
|
|
|
+ // remote images should be removed.
|
|
|
+ ImagePruneAll ImagePruneMode = "all"
|
|
|
+)
|
|
|
+
|
|
|
+// ImagePruneOptions controls the behavior of image pruning.
|
|
|
+type ImagePruneOptions struct {
|
|
|
+ Mode ImagePruneMode
|
|
|
+
|
|
|
+ // RemoveOrphans will result in the removal of images that were built for
|
|
|
+ // the project regardless of whether they are for a known service if true.
|
|
|
+ RemoveOrphans bool
|
|
|
+}
|
|
|
+
|
|
|
+// ImagePruner handles image removal during Compose `down` operations.
|
|
|
+type ImagePruner struct {
|
|
|
+ client client.ImageAPIClient
|
|
|
+ project *types.Project
|
|
|
+}
|
|
|
+
|
|
|
+// NewImagePruner creates an ImagePruner object for a project.
|
|
|
+func NewImagePruner(imageClient client.ImageAPIClient, project *types.Project) *ImagePruner {
|
|
|
+ return &ImagePruner{
|
|
|
+ client: imageClient,
|
|
|
+ project: project,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ImagesToPrune returns the set of images that should be removed.
|
|
|
+func (p *ImagePruner) ImagesToPrune(ctx context.Context, opts ImagePruneOptions) ([]string, error) {
|
|
|
+ if opts.Mode == ImagePruneNone {
|
|
|
+ return nil, nil
|
|
|
+ } else if opts.Mode != ImagePruneLocal && opts.Mode != ImagePruneAll {
|
|
|
+ return nil, fmt.Errorf("unsupported image prune mode: %s", opts.Mode)
|
|
|
+ }
|
|
|
+
|
|
|
+ var images []string
|
|
|
+
|
|
|
+ if opts.Mode == ImagePruneAll {
|
|
|
+ namedImages, err := p.namedImages(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ images = append(images, namedImages...)
|
|
|
+ }
|
|
|
+
|
|
|
+ projectImages, err := p.builtImagesForProject(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, img := range projectImages {
|
|
|
+ if len(img.RepoTags) == 0 {
|
|
|
+ // currently, we're only removing the tagged references, but
|
|
|
+ // if we start removing the dangling images and grouping by
|
|
|
+ // service, we can remove this (and should rely on `Image::ID`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ removeImage := opts.RemoveOrphans
|
|
|
+ if !removeImage {
|
|
|
+ service, err := p.project.GetService(img.Labels[api.ServiceLabel])
|
|
|
+ if err == nil && (opts.Mode == ImagePruneAll || service.Image == "") {
|
|
|
+ removeImage = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if removeImage {
|
|
|
+ images = append(images, img.RepoTags[0])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fallbackImages, err := p.unlabeledLocalImages(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ images = append(images, fallbackImages...)
|
|
|
+
|
|
|
+ images = normalizeAndDedupeImages(images)
|
|
|
+ return images, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (p *ImagePruner) builtImagesForProject(ctx context.Context) ([]moby.ImageSummary, error) {
|
|
|
+ imageListOpts := moby.ImageListOptions{
|
|
|
+ Filters: filters.NewArgs(
|
|
|
+ projectFilter(p.project.Name),
|
|
|
+ // TODO(milas): we should really clean up the dangling images as
|
|
|
+ // well (historically we have NOT); need to refactor this to handle
|
|
|
+ // it gracefully without producing confusing CLI output, i.e. we
|
|
|
+ // do not want to print out a bunch of untagged/dangling image IDs,
|
|
|
+ // they should be grouped into a logical operation for the relevant
|
|
|
+ // service
|
|
|
+ filters.Arg("dangling", "false"),
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ projectImages, err := p.client.ImageList(ctx, imageListOpts)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return projectImages, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (p *ImagePruner) namedImages(ctx context.Context) ([]string, error) {
|
|
|
+ var images []string
|
|
|
+ for _, service := range p.project.Services {
|
|
|
+ if service.Image == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ images = append(images, service.Image)
|
|
|
+ }
|
|
|
+ return p.filterImagesByExistence(ctx, images)
|
|
|
+}
|
|
|
+
|
|
|
+func (p *ImagePruner) filterImagesByExistence(ctx context.Context, imageNames []string) ([]string, error) {
|
|
|
+ var mu sync.Mutex
|
|
|
+ var ret []string
|
|
|
+
|
|
|
+ eg, ctx := errgroup.WithContext(ctx)
|
|
|
+ for _, img := range imageNames {
|
|
|
+ img := img
|
|
|
+ eg.Go(func() error {
|
|
|
+ _, _, err := p.client.ImageInspectWithRaw(ctx, img)
|
|
|
+ if errdefs.IsNotFound(err) {
|
|
|
+ // err on the side of caution: only skip if we successfully
|
|
|
+ // queried the API and got back a definitive "not exists"
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ mu.Lock()
|
|
|
+ defer mu.Unlock()
|
|
|
+ ret = append(ret, img)
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := eg.Wait(); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ return ret, nil
|
|
|
+}
|
|
|
+
|
|
|
+func (p *ImagePruner) unlabeledLocalImages(ctx context.Context) ([]string, error) {
|
|
|
+ var images []string
|
|
|
+ for _, service := range p.project.Services {
|
|
|
+ if service.Image != "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ img := api.GetImageNameOrDefault(service, p.project.Name)
|
|
|
+ images = append(images, img)
|
|
|
+ }
|
|
|
+ return p.filterImagesByExistence(ctx, images)
|
|
|
+}
|
|
|
+
|
|
|
+func normalizeAndDedupeImages(images []string) []string {
|
|
|
+ seen := make(map[string]struct{}, len(images))
|
|
|
+ for _, img := range images {
|
|
|
+ // since some references come from user input (service.image) and some
|
|
|
+ // come from the engine API, we standardize them, opting for the
|
|
|
+ // familiar name format since they'll also be displayed in the CLI
|
|
|
+ ref, err := reference.ParseNormalizedNamed(img)
|
|
|
+ if err == nil {
|
|
|
+ ref = reference.TagNameOnly(ref)
|
|
|
+ img = reference.FamiliarString(ref)
|
|
|
+ }
|
|
|
+ seen[img] = struct{}{}
|
|
|
+ }
|
|
|
+ ret := make([]string, 0, len(seen))
|
|
|
+ for v := range seen {
|
|
|
+ ret = append(ret, v)
|
|
|
+ }
|
|
|
+ sort.Strings(ret)
|
|
|
+ return ret
|
|
|
+}
|