image_pruner.go 8.1 KB

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