pull.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. /*
  2. Copyright 2020 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. "encoding/base64"
  17. "encoding/json"
  18. "errors"
  19. "fmt"
  20. "io"
  21. "strings"
  22. "sync"
  23. "time"
  24. "github.com/compose-spec/compose-go/v2/types"
  25. "github.com/distribution/reference"
  26. "github.com/docker/buildx/driver"
  27. "github.com/docker/cli/cli/config/configfile"
  28. "github.com/docker/docker/api/types/image"
  29. "github.com/docker/docker/client"
  30. "github.com/docker/docker/pkg/jsonmessage"
  31. "github.com/opencontainers/go-digest"
  32. "github.com/sirupsen/logrus"
  33. "golang.org/x/sync/errgroup"
  34. "github.com/docker/compose/v2/internal/registry"
  35. "github.com/docker/compose/v2/pkg/api"
  36. "github.com/docker/compose/v2/pkg/progress"
  37. )
  38. func (s *composeService) Pull(ctx context.Context, project *types.Project, options api.PullOptions) error {
  39. return progress.Run(ctx, func(ctx context.Context) error {
  40. return s.pull(ctx, project, options)
  41. }, "pull", s.events)
  42. }
  43. func (s *composeService) pull(ctx context.Context, project *types.Project, opts api.PullOptions) error { //nolint:gocyclo
  44. images, err := s.getLocalImagesDigests(ctx, project)
  45. if err != nil {
  46. return err
  47. }
  48. eg, ctx := errgroup.WithContext(ctx)
  49. eg.SetLimit(s.maxConcurrency)
  50. var (
  51. mustBuild []string
  52. pullErrors = make([]error, len(project.Services))
  53. imagesBeingPulled = map[string]string{}
  54. )
  55. i := 0
  56. for name, service := range project.Services {
  57. if service.Image == "" {
  58. s.events.On(progress.Event{
  59. ID: name,
  60. Status: progress.Done,
  61. Text: "Skipped",
  62. Details: "No image to be pulled",
  63. })
  64. continue
  65. }
  66. switch service.PullPolicy {
  67. case types.PullPolicyNever, types.PullPolicyBuild:
  68. s.events.On(progress.Event{
  69. ID: "Image " + service.Image,
  70. Status: progress.Done,
  71. Text: "Skipped",
  72. })
  73. continue
  74. case types.PullPolicyMissing, types.PullPolicyIfNotPresent:
  75. if imageAlreadyPresent(service.Image, images) {
  76. s.events.On(progress.Event{
  77. ID: "Image " + service.Image,
  78. Status: progress.Done,
  79. Text: "Skipped",
  80. Details: "Image is already present locally",
  81. })
  82. continue
  83. }
  84. }
  85. if service.Build != nil && opts.IgnoreBuildable {
  86. s.events.On(progress.Event{
  87. ID: "Image " + service.Image,
  88. Status: progress.Done,
  89. Text: "Skipped",
  90. Details: "Image can be built",
  91. })
  92. continue
  93. }
  94. if _, ok := imagesBeingPulled[service.Image]; ok {
  95. continue
  96. }
  97. imagesBeingPulled[service.Image] = service.Name
  98. idx := i
  99. eg.Go(func() error {
  100. _, err := s.pullServiceImage(ctx, service, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
  101. if err != nil {
  102. pullErrors[idx] = err
  103. if service.Build != nil {
  104. mustBuild = append(mustBuild, service.Name)
  105. }
  106. if !opts.IgnoreFailures && service.Build == nil {
  107. if s.dryRun {
  108. s.events.On(progress.ErrorEventf("Image "+service.Image,
  109. "error pulling image: %s", service.Image))
  110. }
  111. // fail fast if image can't be pulled nor built
  112. return err
  113. }
  114. }
  115. return nil
  116. })
  117. i++
  118. }
  119. err = eg.Wait()
  120. if len(mustBuild) > 0 {
  121. logrus.Warnf("WARNING: Some service image(s) must be built from source by running:\n docker compose build %s", strings.Join(mustBuild, " "))
  122. }
  123. if err != nil {
  124. return err
  125. }
  126. if opts.IgnoreFailures {
  127. return nil
  128. }
  129. return errors.Join(pullErrors...)
  130. }
  131. func imageAlreadyPresent(serviceImage string, localImages map[string]api.ImageSummary) bool {
  132. normalizedImage, err := reference.ParseDockerRef(serviceImage)
  133. if err != nil {
  134. return false
  135. }
  136. switch refType := normalizedImage.(type) {
  137. case reference.NamedTagged:
  138. _, ok := localImages[serviceImage]
  139. return ok && refType.Tag() != "latest"
  140. default:
  141. _, ok := localImages[serviceImage]
  142. return ok
  143. }
  144. }
  145. func getUnwrappedErrorMessage(err error) string {
  146. derr := errors.Unwrap(err)
  147. if derr != nil {
  148. return getUnwrappedErrorMessage(derr)
  149. }
  150. return err.Error()
  151. }
  152. func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, quietPull bool, defaultPlatform string) (string, error) {
  153. resource := "Image " + service.Image
  154. s.events.On(progress.PullingEvent(service.Image))
  155. ref, err := reference.ParseNormalizedNamed(service.Image)
  156. if err != nil {
  157. return "", err
  158. }
  159. encodedAuth, err := encodedAuth(ref, s.configFile())
  160. if err != nil {
  161. return "", err
  162. }
  163. platform := service.Platform
  164. if platform == "" {
  165. platform = defaultPlatform
  166. }
  167. stream, err := s.apiClient().ImagePull(ctx, service.Image, image.PullOptions{
  168. RegistryAuth: encodedAuth,
  169. Platform: platform,
  170. })
  171. if ctx.Err() != nil {
  172. s.events.On(progress.Event{
  173. ID: resource,
  174. Status: progress.Warning,
  175. Text: "Interrupted",
  176. })
  177. return "", nil
  178. }
  179. // check if has error and the service has a build section
  180. // then the status should be warning instead of error
  181. if err != nil && service.Build != nil {
  182. s.events.On(progress.Event{
  183. ID: resource,
  184. Status: progress.Warning,
  185. Text: getUnwrappedErrorMessage(err),
  186. })
  187. return "", err
  188. }
  189. if err != nil {
  190. s.events.On(progress.ErrorEvent(resource, getUnwrappedErrorMessage(err)))
  191. return "", err
  192. }
  193. dec := json.NewDecoder(stream)
  194. for {
  195. var jm jsonmessage.JSONMessage
  196. if err := dec.Decode(&jm); err != nil {
  197. if errors.Is(err, io.EOF) {
  198. break
  199. }
  200. return "", err
  201. }
  202. if jm.Error != nil {
  203. return "", errors.New(jm.Error.Message)
  204. }
  205. if !quietPull {
  206. toPullProgressEvent(resource, jm, s.events)
  207. }
  208. }
  209. s.events.On(progress.PulledEvent(service.Image))
  210. inspected, err := s.apiClient().ImageInspect(ctx, service.Image)
  211. if err != nil {
  212. return "", err
  213. }
  214. return inspected.ID, nil
  215. }
  216. // ImageDigestResolver creates a func able to resolve image digest from a docker ref,
  217. func ImageDigestResolver(ctx context.Context, file *configfile.ConfigFile, apiClient client.APIClient) func(named reference.Named) (digest.Digest, error) {
  218. return func(named reference.Named) (digest.Digest, error) {
  219. auth, err := encodedAuth(named, file)
  220. if err != nil {
  221. return "", err
  222. }
  223. inspect, err := apiClient.DistributionInspect(ctx, named.String(), auth)
  224. if err != nil {
  225. return "",
  226. fmt.Errorf("failed to resolve digest for %s: %w", named.String(), err)
  227. }
  228. return inspect.Descriptor.Digest, nil
  229. }
  230. }
  231. func encodedAuth(ref reference.Named, configFile driver.Auth) (string, error) {
  232. authConfig, err := configFile.GetAuthConfig(registry.GetAuthConfigKey(reference.Domain(ref)))
  233. if err != nil {
  234. return "", err
  235. }
  236. buf, err := json.Marshal(authConfig)
  237. if err != nil {
  238. return "", err
  239. }
  240. return base64.URLEncoding.EncodeToString(buf), nil
  241. }
  242. func (s *composeService) pullRequiredImages(ctx context.Context, project *types.Project, images map[string]api.ImageSummary, quietPull bool) error {
  243. needPull := map[string]types.ServiceConfig{}
  244. for name, service := range project.Services {
  245. pull, err := mustPull(service, images)
  246. if err != nil {
  247. return err
  248. }
  249. if pull {
  250. needPull[name] = service
  251. }
  252. for i, vol := range service.Volumes {
  253. if vol.Type == types.VolumeTypeImage {
  254. if _, ok := images[vol.Source]; !ok {
  255. // Hack: create a fake ServiceConfig so we pull missing volume image
  256. n := fmt.Sprintf("%s:volume %d", name, i)
  257. needPull[n] = types.ServiceConfig{
  258. Name: n,
  259. Image: vol.Source,
  260. }
  261. }
  262. }
  263. }
  264. }
  265. if len(needPull) == 0 {
  266. return nil
  267. }
  268. eg, ctx := errgroup.WithContext(ctx)
  269. eg.SetLimit(s.maxConcurrency)
  270. pulledImages := map[string]api.ImageSummary{}
  271. var mutex sync.Mutex
  272. for name, service := range needPull {
  273. eg.Go(func() error {
  274. id, err := s.pullServiceImage(ctx, service, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"])
  275. mutex.Lock()
  276. defer mutex.Unlock()
  277. pulledImages[name] = api.ImageSummary{
  278. ID: id,
  279. Repository: service.Image,
  280. LastTagTime: time.Now(),
  281. }
  282. if err != nil && isServiceImageToBuild(service, project.Services) {
  283. // image can be built, so we can ignore pull failure
  284. return nil
  285. }
  286. return err
  287. })
  288. }
  289. err := eg.Wait()
  290. for i, service := range needPull {
  291. if pulledImages[i].ID != "" {
  292. images[service.Image] = pulledImages[i]
  293. }
  294. }
  295. return err
  296. }
  297. func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) (bool, error) {
  298. if service.Provider != nil {
  299. return false, nil
  300. }
  301. if service.Image == "" {
  302. return false, nil
  303. }
  304. policy, duration, err := service.GetPullPolicy()
  305. if err != nil {
  306. return false, err
  307. }
  308. switch policy {
  309. case types.PullPolicyAlways:
  310. // force pull
  311. return true, nil
  312. case types.PullPolicyNever, types.PullPolicyBuild:
  313. return false, nil
  314. case types.PullPolicyRefresh:
  315. img, ok := images[service.Image]
  316. if !ok {
  317. return true, nil
  318. }
  319. return time.Now().After(img.LastTagTime.Add(duration)), nil
  320. default: // Pull if missing
  321. _, ok := images[service.Image]
  322. return !ok, nil
  323. }
  324. }
  325. func isServiceImageToBuild(service types.ServiceConfig, services types.Services) bool {
  326. if service.Build != nil {
  327. return true
  328. }
  329. if service.Image == "" {
  330. // N.B. this should be impossible as service must have either `build` or `image` (or both)
  331. return false
  332. }
  333. // look through the other services to see if another has a build definition for the same
  334. // image name
  335. for _, svc := range services {
  336. if svc.Image == service.Image && svc.Build != nil {
  337. return true
  338. }
  339. }
  340. return false
  341. }
  342. const (
  343. PreparingPhase = "Preparing"
  344. WaitingPhase = "Waiting"
  345. PullingFsPhase = "Pulling fs layer"
  346. DownloadingPhase = "Downloading"
  347. DownloadCompletePhase = "Download complete"
  348. ExtractingPhase = "Extracting"
  349. VerifyingChecksumPhase = "Verifying Checksum"
  350. AlreadyExistsPhase = "Already exists"
  351. PullCompletePhase = "Pull complete"
  352. )
  353. func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progress.EventProcessor) {
  354. if jm.ID == "" || jm.Progress == nil {
  355. return
  356. }
  357. var (
  358. text string
  359. total int64
  360. percent int
  361. current int64
  362. status = progress.Working
  363. )
  364. text = jm.Progress.String()
  365. switch jm.Status {
  366. case PreparingPhase, WaitingPhase, PullingFsPhase:
  367. percent = 0
  368. case DownloadingPhase, ExtractingPhase, VerifyingChecksumPhase:
  369. if jm.Progress != nil {
  370. current = jm.Progress.Current
  371. total = jm.Progress.Total
  372. if jm.Progress.Total > 0 {
  373. percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
  374. }
  375. }
  376. case DownloadCompletePhase, AlreadyExistsPhase, PullCompletePhase:
  377. status = progress.Done
  378. percent = 100
  379. }
  380. if strings.Contains(jm.Status, "Image is up to date") ||
  381. strings.Contains(jm.Status, "Downloaded newer image") {
  382. status = progress.Done
  383. percent = 100
  384. }
  385. if jm.Error != nil {
  386. status = progress.Error
  387. text = jm.Error.Message
  388. }
  389. events.On(progress.Event{
  390. ID: jm.ID,
  391. ParentID: parent,
  392. Current: current,
  393. Total: total,
  394. Percent: percent,
  395. Status: status,
  396. Text: text,
  397. })
  398. }