image_pruner.go 7.8 KB

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