publish.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. "crypto/sha256"
  17. "errors"
  18. "fmt"
  19. "os"
  20. "github.com/compose-spec/compose-go/v2/loader"
  21. "github.com/compose-spec/compose-go/v2/types"
  22. "github.com/distribution/reference"
  23. "github.com/docker/buildx/util/imagetools"
  24. "github.com/docker/cli/cli/command"
  25. "github.com/docker/compose/v2/internal/ocipush"
  26. "github.com/docker/compose/v2/pkg/api"
  27. "github.com/docker/compose/v2/pkg/progress"
  28. "github.com/docker/compose/v2/pkg/prompt"
  29. "github.com/mikefarah/yq/v4/pkg/yqlib"
  30. "github.com/sirupsen/logrus"
  31. "gopkg.in/op/go-logging.v1"
  32. )
  33. // _backend redirects go-logging.v1 (used by yqlib) to logrus
  34. type _backend struct{}
  35. func (b *_backend) Log(l logging.Level, _ int, r *logging.Record) error {
  36. switch l {
  37. case logging.CRITICAL, logging.ERROR:
  38. logrus.Error(r.Message())
  39. case logging.WARNING:
  40. logrus.Warn(r.Message())
  41. case logging.NOTICE, logging.INFO:
  42. logrus.Info(r.Message())
  43. case logging.DEBUG:
  44. logrus.Debug(r.Message())
  45. }
  46. return nil
  47. }
  48. func init() {
  49. logging.SetBackend(&_backend{})
  50. }
  51. func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
  52. return progress.RunWithTitle(ctx, func(ctx context.Context) error {
  53. return s.publish(ctx, project, repository, options)
  54. }, s.stdinfo(), "Publishing")
  55. }
  56. func (s *composeService) publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
  57. accept, err := s.preChecks(project, options)
  58. if err != nil {
  59. return err
  60. }
  61. if !accept {
  62. return nil
  63. }
  64. err = s.Push(ctx, project, api.PushOptions{IgnoreFailures: true, ImageMandatory: true})
  65. if err != nil {
  66. return err
  67. }
  68. named, err := reference.ParseDockerRef(repository)
  69. if err != nil {
  70. return err
  71. }
  72. resolver := imagetools.New(imagetools.Opt{
  73. Auth: s.configFile(),
  74. })
  75. yqlib.InitExpressionParser()
  76. var layers []ocipush.Pushable
  77. extFiles := map[string]string{}
  78. for _, file := range project.ComposeFiles {
  79. data, err := processFile(ctx, file, project, extFiles)
  80. if err != nil {
  81. return err
  82. }
  83. layerDescriptor := ocipush.DescriptorForComposeFile(file, data)
  84. layers = append(layers, ocipush.Pushable{
  85. Descriptor: layerDescriptor,
  86. Data: data,
  87. })
  88. }
  89. extLayers, err := processExtends(ctx, project, extFiles)
  90. if err != nil {
  91. return err
  92. }
  93. layers = append(layers, extLayers...)
  94. if options.WithEnvironment {
  95. layers = append(layers, envFileLayers(project)...)
  96. }
  97. if options.ResolveImageDigests {
  98. yaml, err := s.generateImageDigestsOverride(ctx, project)
  99. if err != nil {
  100. return err
  101. }
  102. layerDescriptor := ocipush.DescriptorForComposeFile("image-digests.yaml", yaml)
  103. layers = append(layers, ocipush.Pushable{
  104. Descriptor: layerDescriptor,
  105. Data: yaml,
  106. })
  107. }
  108. w := progress.ContextWriter(ctx)
  109. w.Event(progress.Event{
  110. ID: repository,
  111. Text: "publishing",
  112. Status: progress.Working,
  113. })
  114. if !s.dryRun {
  115. err = ocipush.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
  116. if err != nil {
  117. w.Event(progress.Event{
  118. ID: repository,
  119. Text: "publishing",
  120. Status: progress.Error,
  121. })
  122. return err
  123. }
  124. }
  125. w.Event(progress.Event{
  126. ID: repository,
  127. Text: "published",
  128. Status: progress.Done,
  129. })
  130. return nil
  131. }
  132. func processExtends(ctx context.Context, project *types.Project, extFiles map[string]string) ([]ocipush.Pushable, error) {
  133. var layers []ocipush.Pushable
  134. moreExtFiles := map[string]string{}
  135. for xf, hash := range extFiles {
  136. data, err := processFile(ctx, xf, project, moreExtFiles)
  137. if err != nil {
  138. return nil, err
  139. }
  140. layerDescriptor := ocipush.DescriptorForComposeFile(hash, data)
  141. layerDescriptor.Annotations["com.docker.compose.extends"] = "true"
  142. layers = append(layers, ocipush.Pushable{
  143. Descriptor: layerDescriptor,
  144. Data: data,
  145. })
  146. }
  147. for f, hash := range moreExtFiles {
  148. if _, ok := extFiles[f]; ok {
  149. delete(moreExtFiles, f)
  150. }
  151. extFiles[f] = hash
  152. }
  153. if len(moreExtFiles) > 0 {
  154. extLayers, err := processExtends(ctx, project, moreExtFiles)
  155. if err != nil {
  156. return nil, err
  157. }
  158. layers = append(layers, extLayers...)
  159. }
  160. return layers, nil
  161. }
  162. func processFile(ctx context.Context, file string, project *types.Project, extFiles map[string]string) ([]byte, error) {
  163. f, err := os.ReadFile(file)
  164. if err != nil {
  165. return nil, err
  166. }
  167. base, err := loader.LoadWithContext(ctx, types.ConfigDetails{
  168. WorkingDir: project.WorkingDir,
  169. Environment: project.Environment,
  170. ConfigFiles: []types.ConfigFile{
  171. {
  172. Filename: file,
  173. Content: f,
  174. },
  175. },
  176. }, func(options *loader.Options) {
  177. options.SkipValidation = true
  178. options.SkipExtends = true
  179. options.SkipConsistencyCheck = true
  180. options.ResolvePaths = true
  181. })
  182. if err != nil {
  183. return nil, err
  184. }
  185. for name, service := range base.Services {
  186. if service.Extends == nil {
  187. continue
  188. }
  189. xf := service.Extends.File
  190. if xf == "" {
  191. continue
  192. }
  193. if _, err = os.Stat(service.Extends.File); os.IsNotExist(err) {
  194. // No local file, while we loaded the project successfully: This is actually a remote resource
  195. continue
  196. }
  197. hash := fmt.Sprintf("%x.yaml", sha256.Sum256([]byte(xf)))
  198. extFiles[xf] = hash
  199. // FIXME implement without relying on yqlib so we don't get extra dependencies
  200. exp := fmt.Sprintf(".services.%s.extends.file = %q", name, hash)
  201. encoder := yqlib.NewYamlEncoder(yqlib.YamlPreferences{
  202. ColorsEnabled: false,
  203. })
  204. decoder := yqlib.NewYamlDecoder(yqlib.YamlPreferences{})
  205. out, err := yqlib.NewStringEvaluator().Evaluate(exp, string(f), encoder, decoder)
  206. if err != nil {
  207. return nil, err
  208. }
  209. f = []byte(out)
  210. }
  211. return f, nil
  212. }
  213. func (s *composeService) generateImageDigestsOverride(ctx context.Context, project *types.Project) ([]byte, error) {
  214. project, err := project.WithProfiles([]string{"*"})
  215. if err != nil {
  216. return nil, err
  217. }
  218. project, err = project.WithImagesResolved(ImageDigestResolver(ctx, s.configFile(), s.apiClient()))
  219. if err != nil {
  220. return nil, err
  221. }
  222. override := types.Project{
  223. Services: types.Services{},
  224. }
  225. for name, service := range project.Services {
  226. override.Services[name] = types.ServiceConfig{
  227. Image: service.Image,
  228. }
  229. }
  230. return override.MarshalYAML()
  231. }
  232. func (s *composeService) preChecks(project *types.Project, options api.PublishOptions) (bool, error) {
  233. if ok, err := s.checkOnlyBuildSection(project); !ok {
  234. return false, err
  235. }
  236. envVariables, err := s.checkEnvironmentVariables(project, options)
  237. if err != nil {
  238. return false, err
  239. }
  240. if !options.AssumeYes && len(envVariables) > 0 {
  241. fmt.Println("you are about to publish environment variables within your OCI artifact.\n" +
  242. "please double check that you are not leaking sensitive data")
  243. for key, val := range envVariables {
  244. _, _ = fmt.Fprintln(s.dockerCli.Out(), "Service/Config ", key)
  245. for k, v := range val {
  246. _, _ = fmt.Fprintf(s.dockerCli.Out(), "%s=%v\n", k, *v)
  247. }
  248. }
  249. return acceptPublishEnvVariables(s.dockerCli)
  250. }
  251. for name, config := range project.Services {
  252. for _, volume := range config.Volumes {
  253. if volume.Type == types.VolumeTypeBind {
  254. return false, fmt.Errorf("cannot publish compose file: service %q relies on bind-mount. You should use volumes", name)
  255. }
  256. }
  257. }
  258. return true, nil
  259. }
  260. func (s *composeService) checkEnvironmentVariables(project *types.Project, options api.PublishOptions) (map[string]types.MappingWithEquals, error) {
  261. envVarList := map[string]types.MappingWithEquals{}
  262. errorList := map[string][]string{}
  263. for _, service := range project.Services {
  264. if len(service.EnvFiles) > 0 {
  265. errorList[service.Name] = append(errorList[service.Name], fmt.Sprintf("service %q has env_file declared.", service.Name))
  266. }
  267. if len(service.Environment) > 0 {
  268. errorList[service.Name] = append(errorList[service.Name], fmt.Sprintf("service %q has environment variable(s) declared.", service.Name))
  269. envVarList[service.Name] = service.Environment
  270. }
  271. }
  272. for _, config := range project.Configs {
  273. if config.Environment != "" {
  274. errorList[config.Name] = append(errorList[config.Name], fmt.Sprintf("config %q is declare as an environment variable.", config.Name))
  275. envVarList[config.Name] = types.NewMappingWithEquals([]string{fmt.Sprintf("%s=%s", config.Name, config.Environment)})
  276. }
  277. }
  278. if !options.WithEnvironment && len(errorList) > 0 {
  279. errorMsgSuffix := "To avoid leaking sensitive data, you must either explicitly allow the sending of environment variables by using the --with-env flag,\n" +
  280. "or remove sensitive data from your Compose configuration"
  281. errorMsg := ""
  282. for _, errors := range errorList {
  283. for _, err := range errors {
  284. errorMsg += fmt.Sprintf("%s\n", err)
  285. }
  286. }
  287. return nil, fmt.Errorf("%s%s", errorMsg, errorMsgSuffix)
  288. }
  289. return envVarList, nil
  290. }
  291. func acceptPublishEnvVariables(cli command.Cli) (bool, error) {
  292. msg := "Are you ok to publish these environment variables? [y/N]: "
  293. confirm, err := prompt.NewPrompt(cli.In(), cli.Out()).Confirm(msg, false)
  294. return confirm, err
  295. }
  296. func envFileLayers(project *types.Project) []ocipush.Pushable {
  297. var layers []ocipush.Pushable
  298. for _, service := range project.Services {
  299. for _, envFile := range service.EnvFiles {
  300. f, err := os.ReadFile(envFile.Path)
  301. if err != nil {
  302. // if we can't read the file, skip to the next one
  303. continue
  304. }
  305. layerDescriptor := ocipush.DescriptorForEnvFile(envFile.Path, f)
  306. layers = append(layers, ocipush.Pushable{
  307. Descriptor: layerDescriptor,
  308. Data: f,
  309. })
  310. }
  311. }
  312. return layers
  313. }
  314. func (s *composeService) checkOnlyBuildSection(project *types.Project) (bool, error) {
  315. errorList := []string{}
  316. for _, service := range project.Services {
  317. if service.Image == "" && service.Build != nil {
  318. errorList = append(errorList, service.Name)
  319. }
  320. }
  321. if len(errorList) > 0 {
  322. errMsg := "your Compose stack cannot be published as it only contains a build section for service(s):\n"
  323. for _, serviceInError := range errorList {
  324. errMsg += fmt.Sprintf("- %q\n", serviceInError)
  325. }
  326. return false, errors.New(errMsg)
  327. }
  328. return true, nil
  329. }