image_pruner.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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/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 project.
  94. if _, err := p.project.GetService(img.Labels[api.ServiceLabel]); err == nil {
  95. shouldPrune = true
  96. }
  97. }
  98. if shouldPrune {
  99. images = append(images, img.RepoTags[0])
  100. }
  101. }
  102. fallbackImages, err := p.unlabeledLocalImages(ctx)
  103. if err != nil {
  104. return nil, err
  105. }
  106. images = append(images, fallbackImages...)
  107. images = normalizeAndDedupeImages(images)
  108. return images, nil
  109. }
  110. // namedImages are those that are explicitly named in the service config.
  111. //
  112. // These could be registry-only images (no local build), hybrid (support build
  113. // as a fallback if cannot pull), or local-only (image does not exist in a
  114. // registry).
  115. func (p *ImagePruner) namedImages(ctx context.Context) ([]string, error) {
  116. var images []string
  117. for _, service := range p.project.Services {
  118. if service.Image == "" {
  119. continue
  120. }
  121. images = append(images, service.Image)
  122. }
  123. return p.filterImagesByExistence(ctx, images)
  124. }
  125. // labeledLocalImages are images that were locally-built by a current version of
  126. // Compose (it did not always label built images).
  127. //
  128. // The image name could either have been defined by the user or implicitly
  129. // created from the project + service name.
  130. func (p *ImagePruner) labeledLocalImages(ctx context.Context) ([]moby.ImageSummary, error) {
  131. imageListOpts := moby.ImageListOptions{
  132. Filters: filters.NewArgs(
  133. projectFilter(p.project.Name),
  134. // TODO(milas): we should really clean up the dangling images as
  135. // well (historically we have NOT); need to refactor this to handle
  136. // it gracefully without producing confusing CLI output, i.e. we
  137. // do not want to print out a bunch of untagged/dangling image IDs,
  138. // they should be grouped into a logical operation for the relevant
  139. // service
  140. filters.Arg("dangling", "false"),
  141. ),
  142. }
  143. projectImages, err := p.client.ImageList(ctx, imageListOpts)
  144. if err != nil {
  145. return nil, err
  146. }
  147. return projectImages, nil
  148. }
  149. // unlabeledLocalImages are images that match the implicit naming convention
  150. // for locally-built images but did not get labeled, presumably because they
  151. // were produced by an older version of Compose.
  152. //
  153. // This is transitional to ensure `down` continues to work as expected on
  154. // projects built/launched by previous versions of Compose. It can safely
  155. // be removed after some time.
  156. func (p *ImagePruner) unlabeledLocalImages(ctx context.Context) ([]string, error) {
  157. var images []string
  158. for _, service := range p.project.Services {
  159. if service.Image != "" {
  160. continue
  161. }
  162. img := api.GetImageNameOrDefault(service, p.project.Name)
  163. images = append(images, img)
  164. }
  165. return p.filterImagesByExistence(ctx, images)
  166. }
  167. // filterImagesByExistence returns the subset of images that exist in the
  168. // engine store.
  169. //
  170. // NOTE: Any transient errors communicating with the API will result in an
  171. // image being returned as "existing", as this method is exclusively used to
  172. // find images to remove, so the worst case of being conservative here is an
  173. // attempt to remove an image that doesn't exist, which will cause a warning
  174. // but is otherwise harmless.
  175. func (p *ImagePruner) filterImagesByExistence(ctx context.Context, imageNames []string) ([]string, error) {
  176. var mu sync.Mutex
  177. var ret []string
  178. eg, ctx := errgroup.WithContext(ctx)
  179. for _, img := range imageNames {
  180. img := img
  181. eg.Go(func() error {
  182. _, _, err := p.client.ImageInspectWithRaw(ctx, img)
  183. if errdefs.IsNotFound(err) {
  184. // err on the side of caution: only skip if we successfully
  185. // queried the API and got back a definitive "not exists"
  186. return nil
  187. }
  188. mu.Lock()
  189. defer mu.Unlock()
  190. ret = append(ret, img)
  191. return nil
  192. })
  193. }
  194. if err := eg.Wait(); err != nil {
  195. return nil, err
  196. }
  197. return ret, nil
  198. }
  199. // normalizeAndDedupeImages returns the unique set of images after normalization.
  200. func normalizeAndDedupeImages(images []string) []string {
  201. seen := make(map[string]struct{}, len(images))
  202. for _, img := range images {
  203. // since some references come from user input (service.image) and some
  204. // come from the engine API, we standardize them, opting for the
  205. // familiar name format since they'll also be displayed in the CLI
  206. ref, err := reference.ParseNormalizedNamed(img)
  207. if err == nil {
  208. ref = reference.TagNameOnly(ref)
  209. img = reference.FamiliarString(ref)
  210. }
  211. seen[img] = struct{}{}
  212. }
  213. ret := make([]string, 0, len(seen))
  214. for v := range seen {
  215. ret = append(ret, v)
  216. }
  217. // ensure a deterministic return result - the actual ordering is not useful
  218. sort.Strings(ret)
  219. return ret
  220. }