image_pruner.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /*
  2. Copyright 2022 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package compose
  14. import (
  15. "context"
  16. "fmt"
  17. "sort"
  18. "sync"
  19. "github.com/compose-spec/compose-go/v2/types"
  20. "github.com/containerd/errdefs"
  21. "github.com/distribution/reference"
  22. "github.com/moby/moby/api/types/image"
  23. "github.com/moby/moby/client"
  24. "golang.org/x/sync/errgroup"
  25. "github.com/docker/compose/v5/pkg/api"
  26. )
  27. // ImagePruneMode controls how aggressively images associated with the project
  28. // are removed from the engine.
  29. type ImagePruneMode string
  30. const (
  31. // ImagePruneNone indicates that no project images should be removed.
  32. ImagePruneNone ImagePruneMode = ""
  33. // ImagePruneLocal indicates that only images built locally by Compose
  34. // should be removed.
  35. ImagePruneLocal ImagePruneMode = "local"
  36. // ImagePruneAll indicates that all project-associated images, including
  37. // remote images should be removed.
  38. ImagePruneAll ImagePruneMode = "all"
  39. )
  40. // ImagePruneOptions controls the behavior of image pruning.
  41. type ImagePruneOptions struct {
  42. Mode ImagePruneMode
  43. // RemoveOrphans will result in the removal of images that were built for
  44. // the project regardless of whether they are for a known service if true.
  45. RemoveOrphans bool
  46. }
  47. // ImagePruner handles image removal during Compose `down` operations.
  48. type ImagePruner struct {
  49. client client.ImageAPIClient
  50. project *types.Project
  51. }
  52. // NewImagePruner creates an ImagePruner object for a project.
  53. func NewImagePruner(imageClient client.ImageAPIClient, project *types.Project) *ImagePruner {
  54. return &ImagePruner{
  55. client: imageClient,
  56. project: project,
  57. }
  58. }
  59. // ImagesToPrune returns the set of images that should be removed.
  60. func (p *ImagePruner) ImagesToPrune(ctx context.Context, opts ImagePruneOptions) ([]string, error) {
  61. if opts.Mode == ImagePruneNone {
  62. return nil, nil
  63. } else if opts.Mode != ImagePruneLocal && opts.Mode != ImagePruneAll {
  64. return nil, fmt.Errorf("unsupported image prune mode: %s", opts.Mode)
  65. }
  66. var images []string
  67. if opts.Mode == ImagePruneAll {
  68. namedImages, err := p.namedImages(ctx)
  69. if err != nil {
  70. return nil, err
  71. }
  72. images = append(images, namedImages...)
  73. }
  74. projectImages, err := p.labeledLocalImages(ctx)
  75. if err != nil {
  76. return nil, err
  77. }
  78. for _, img := range projectImages {
  79. if len(img.RepoTags) == 0 {
  80. // currently, we're only pruning the tagged references, but
  81. // if we start removing the dangling images and grouping by
  82. // service, we can remove this (and should rely on `Image::ID`)
  83. continue
  84. }
  85. var shouldPrune bool
  86. if opts.RemoveOrphans {
  87. // indiscriminately prune all project images even if they're not
  88. // referenced by the current Compose state (e.g. the service was
  89. // removed from YAML)
  90. shouldPrune = true
  91. } else {
  92. // only prune the image if it belongs to a known service for the project.
  93. if _, err := p.project.GetService(img.Labels[api.ServiceLabel]); err == nil {
  94. shouldPrune = true
  95. }
  96. }
  97. if shouldPrune {
  98. images = append(images, img.RepoTags[0])
  99. }
  100. }
  101. fallbackImages, err := p.unlabeledLocalImages(ctx)
  102. if err != nil {
  103. return nil, err
  104. }
  105. images = append(images, fallbackImages...)
  106. images = normalizeAndDedupeImages(images)
  107. return images, nil
  108. }
  109. // namedImages are those that are explicitly named in the service config.
  110. //
  111. // These could be registry-only images (no local build), hybrid (support build
  112. // as a fallback if cannot pull), or local-only (image does not exist in a
  113. // registry).
  114. func (p *ImagePruner) namedImages(ctx context.Context) ([]string, error) {
  115. var images []string
  116. for _, service := range p.project.Services {
  117. if service.Image == "" {
  118. continue
  119. }
  120. images = append(images, service.Image)
  121. }
  122. return p.filterImagesByExistence(ctx, images)
  123. }
  124. // labeledLocalImages are images that were locally-built by a current version of
  125. // Compose (it did not always label built images).
  126. //
  127. // The image name could either have been defined by the user or implicitly
  128. // created from the project + service name.
  129. func (p *ImagePruner) labeledLocalImages(ctx context.Context) ([]image.Summary, error) {
  130. res, err := p.client.ImageList(ctx, client.ImageListOptions{
  131. // TODO(milas): we should really clean up the dangling images as
  132. // well (historically we have NOT); need to refactor this to handle
  133. // it gracefully without producing confusing CLI output, i.e. we
  134. // do not want to print out a bunch of untagged/dangling image IDs,
  135. // they should be grouped into a logical operation for the relevant
  136. // service
  137. Filters: projectFilter(p.project.Name).Add("dangling", "false"),
  138. })
  139. if err != nil {
  140. return nil, err
  141. }
  142. return res.Items, nil
  143. }
  144. // unlabeledLocalImages are images that match the implicit naming convention
  145. // for locally-built images but did not get labeled, presumably because they
  146. // were produced by an older version of Compose.
  147. //
  148. // This is transitional to ensure `down` continues to work as expected on
  149. // projects built/launched by previous versions of Compose. It can safely
  150. // be removed after some time.
  151. func (p *ImagePruner) unlabeledLocalImages(ctx context.Context) ([]string, error) {
  152. var images []string
  153. for _, service := range p.project.Services {
  154. if service.Image != "" {
  155. continue
  156. }
  157. img := api.GetImageNameOrDefault(service, p.project.Name)
  158. images = append(images, img)
  159. }
  160. return p.filterImagesByExistence(ctx, images)
  161. }
  162. // filterImagesByExistence returns the subset of images that exist in the
  163. // engine store.
  164. //
  165. // NOTE: Any transient errors communicating with the API will result in an
  166. // image being returned as "existing", as this method is exclusively used to
  167. // find images to remove, so the worst case of being conservative here is an
  168. // attempt to remove an image that doesn't exist, which will cause a warning
  169. // but is otherwise harmless.
  170. func (p *ImagePruner) filterImagesByExistence(ctx context.Context, imageNames []string) ([]string, error) {
  171. var mu sync.Mutex
  172. var ret []string
  173. eg, ctx := errgroup.WithContext(ctx)
  174. for _, img := range imageNames {
  175. eg.Go(func() error {
  176. _, err := p.client.ImageInspect(ctx, img)
  177. if errdefs.IsNotFound(err) {
  178. // err on the side of caution: only skip if we successfully
  179. // queried the API and got back a definitive "not exists"
  180. return nil
  181. }
  182. mu.Lock()
  183. defer mu.Unlock()
  184. ret = append(ret, img)
  185. return nil
  186. })
  187. }
  188. if err := eg.Wait(); err != nil {
  189. return nil, err
  190. }
  191. return ret, nil
  192. }
  193. // normalizeAndDedupeImages returns the unique set of images after normalization.
  194. func normalizeAndDedupeImages(images []string) []string {
  195. seen := make(map[string]struct{}, len(images))
  196. for _, img := range images {
  197. // since some references come from user input (service.image) and some
  198. // come from the engine API, we standardize them, opting for the
  199. // familiar name format since they'll also be displayed in the CLI
  200. ref, err := reference.ParseNormalizedNamed(img)
  201. if err == nil {
  202. ref = reference.TagNameOnly(ref)
  203. img = reference.FamiliarString(ref)
  204. }
  205. seen[img] = struct{}{}
  206. }
  207. ret := make([]string, 0, len(seen))
  208. for v := range seen {
  209. ret = append(ret, v)
  210. }
  211. // ensure a deterministic return result - the actual ordering is not useful
  212. sort.Strings(ret)
  213. return ret
  214. }