1
0

pull.go 11 KB

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