publish.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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/json"
  17. "fmt"
  18. "os"
  19. "path/filepath"
  20. "strings"
  21. "time"
  22. "github.com/compose-spec/compose-go/types"
  23. "github.com/distribution/reference"
  24. "github.com/docker/buildx/util/imagetools"
  25. "github.com/docker/compose/v2/pkg/api"
  26. "github.com/docker/compose/v2/pkg/progress"
  27. "github.com/opencontainers/go-digest"
  28. "github.com/opencontainers/image-spec/specs-go"
  29. v1 "github.com/opencontainers/image-spec/specs-go/v1"
  30. )
  31. // ociCompatibilityMode controls manifest generation to ensure compatibility
  32. // with different registries.
  33. //
  34. // Currently, this is not exposed as an option to the user – Compose uses
  35. // OCI 1.0 mode automatically for ECR registries based on domain and OCI 1.1
  36. // for all other registries.
  37. //
  38. // There are likely other popular registries that do not support the OCI 1.1
  39. // format, so it might make sense to expose this as a CLI flag or see if
  40. // there's a way to generically probe the registry for support level.
  41. type ociCompatibilityMode string
  42. const (
  43. ociCompatibility1_0 ociCompatibilityMode = "1.0"
  44. ociCompatibility1_1 ociCompatibilityMode = "1.1"
  45. )
  46. func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
  47. return progress.RunWithTitle(ctx, func(ctx context.Context) error {
  48. return s.publish(ctx, project, repository, options)
  49. }, s.stdinfo(), "Publishing")
  50. }
  51. func (s *composeService) publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
  52. err := s.Push(ctx, project, api.PushOptions{})
  53. if err != nil {
  54. return err
  55. }
  56. named, err := reference.ParseDockerRef(repository)
  57. if err != nil {
  58. return err
  59. }
  60. resolver := imagetools.New(imagetools.Opt{
  61. Auth: s.configFile(),
  62. })
  63. var layers []v1.Descriptor
  64. for _, file := range project.ComposeFiles {
  65. f, err := os.ReadFile(file)
  66. if err != nil {
  67. return err
  68. }
  69. layer, err := s.pushComposeFile(ctx, file, f, resolver, named)
  70. if err != nil {
  71. return err
  72. }
  73. layers = append(layers, layer)
  74. }
  75. if options.ResolveImageDigests {
  76. yaml, err := s.generateImageDigestsOverride(ctx, project)
  77. if err != nil {
  78. return err
  79. }
  80. layer, err := s.pushComposeFile(ctx, "image-digests.yaml", yaml, resolver, named)
  81. if err != nil {
  82. return err
  83. }
  84. layers = append(layers, layer)
  85. }
  86. ociCompat := inferOCIVersion(named)
  87. toPush, err := s.generateManifest(layers, ociCompat)
  88. if err != nil {
  89. return err
  90. }
  91. w := progress.ContextWriter(ctx)
  92. w.Event(progress.Event{
  93. ID: repository,
  94. Text: "publishing",
  95. Status: progress.Working,
  96. })
  97. if !s.dryRun {
  98. for _, p := range toPush {
  99. err = resolver.Push(ctx, named, p.Descriptor, p.Data)
  100. if err != nil {
  101. return err
  102. }
  103. }
  104. if err != nil {
  105. w.Event(progress.Event{
  106. ID: repository,
  107. Text: "publishing",
  108. Status: progress.Error,
  109. })
  110. return err
  111. }
  112. }
  113. w.Event(progress.Event{
  114. ID: repository,
  115. Text: "published",
  116. Status: progress.Done,
  117. })
  118. return nil
  119. }
  120. type push struct {
  121. Descriptor v1.Descriptor
  122. Data []byte
  123. }
  124. func (s *composeService) generateManifest(layers []v1.Descriptor, ociCompat ociCompatibilityMode) ([]push, error) {
  125. var toPush []push
  126. var config v1.Descriptor
  127. var artifactType string
  128. switch ociCompat {
  129. case ociCompatibility1_0:
  130. configData, err := json.Marshal(v1.ImageConfig{})
  131. if err != nil {
  132. return nil, err
  133. }
  134. config = v1.Descriptor{
  135. MediaType: v1.MediaTypeImageConfig,
  136. Digest: digest.FromBytes(configData),
  137. Size: int64(len(configData)),
  138. }
  139. // N.B. OCI 1.0 does NOT support specifying the artifact type, so it's
  140. // left as an empty string to omit it from the marshaled JSON
  141. artifactType = ""
  142. toPush = append(toPush, push{Descriptor: config, Data: configData})
  143. case ociCompatibility1_1:
  144. config = v1.DescriptorEmptyJSON
  145. artifactType = "application/vnd.docker.compose.project"
  146. // N.B. the descriptor has the data embedded in it
  147. toPush = append(toPush, push{Descriptor: config, Data: nil})
  148. default:
  149. return nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
  150. }
  151. manifest, err := json.Marshal(v1.Manifest{
  152. Versioned: specs.Versioned{SchemaVersion: 2},
  153. MediaType: v1.MediaTypeImageManifest,
  154. ArtifactType: artifactType,
  155. Config: config,
  156. Layers: layers,
  157. Annotations: map[string]string{
  158. "org.opencontainers.image.created": time.Now().Format(time.RFC3339),
  159. },
  160. })
  161. if err != nil {
  162. return nil, err
  163. }
  164. manifestDescriptor := v1.Descriptor{
  165. MediaType: v1.MediaTypeImageManifest,
  166. Digest: digest.FromString(string(manifest)),
  167. Size: int64(len(manifest)),
  168. Annotations: map[string]string{
  169. "com.docker.compose.version": api.ComposeVersion,
  170. },
  171. ArtifactType: artifactType,
  172. }
  173. toPush = append(toPush, push{Descriptor: manifestDescriptor, Data: manifest})
  174. return toPush, nil
  175. }
  176. func (s *composeService) generateImageDigestsOverride(ctx context.Context, project *types.Project) ([]byte, error) {
  177. project.ApplyProfiles([]string{"*"})
  178. err := project.ResolveImages(func(named reference.Named) (digest.Digest, error) {
  179. auth, err := encodedAuth(named, s.configFile())
  180. if err != nil {
  181. return "", err
  182. }
  183. inspect, err := s.apiClient().DistributionInspect(ctx, named.String(), auth)
  184. if err != nil {
  185. return "", err
  186. }
  187. return inspect.Descriptor.Digest, nil
  188. })
  189. if err != nil {
  190. return nil, err
  191. }
  192. override := types.Project{}
  193. for _, service := range project.Services {
  194. override.Services = append(override.Services, types.ServiceConfig{
  195. Name: service.Name,
  196. Image: service.Image,
  197. })
  198. }
  199. return override.MarshalYAML()
  200. }
  201. func (s *composeService) pushComposeFile(ctx context.Context, file string, content []byte, resolver *imagetools.Resolver, named reference.Named) (v1.Descriptor, error) {
  202. w := progress.ContextWriter(ctx)
  203. w.Event(progress.Event{
  204. ID: file,
  205. Text: "publishing",
  206. Status: progress.Working,
  207. })
  208. layer := v1.Descriptor{
  209. MediaType: "application/vnd.docker.compose.file+yaml",
  210. Digest: digest.FromString(string(content)),
  211. Size: int64(len(content)),
  212. Annotations: map[string]string{
  213. "com.docker.compose.version": api.ComposeVersion,
  214. "com.docker.compose.file": filepath.Base(file),
  215. },
  216. }
  217. err := resolver.Push(ctx, named, layer, content)
  218. w.Event(progress.Event{
  219. ID: file,
  220. Text: "published",
  221. Status: statusFor(err),
  222. })
  223. return layer, err
  224. }
  225. func statusFor(err error) progress.EventStatus {
  226. if err != nil {
  227. return progress.Error
  228. }
  229. return progress.Done
  230. }
  231. // inferOCIVersion uses OCI 1.1 by default but falls back to OCI 1.0 if the
  232. // registry domain is known to require it.
  233. //
  234. // This is not ideal - with private registries, there isn't a bounded set of
  235. // domains. As it stands, it's primarily intended for compatibility with AWS
  236. // Elastic Container Registry (ECR) due to its ubiquity.
  237. func inferOCIVersion(named reference.Named) ociCompatibilityMode {
  238. domain := reference.Domain(named)
  239. if strings.HasSuffix(domain, "amazonaws.com") {
  240. return ociCompatibility1_0
  241. } else {
  242. return ociCompatibility1_1
  243. }
  244. }