pull.go 11 KB

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