options.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /*
  2. Copyright 2023 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. "fmt"
  17. "io"
  18. "os"
  19. "sort"
  20. "strings"
  21. "text/tabwriter"
  22. "github.com/compose-spec/compose-go/v2/cli"
  23. "github.com/compose-spec/compose-go/v2/template"
  24. "github.com/compose-spec/compose-go/v2/types"
  25. "github.com/docker/cli/cli/command"
  26. "github.com/docker/compose/v2/internal/tracing"
  27. ui "github.com/docker/compose/v2/pkg/progress"
  28. "github.com/docker/compose/v2/pkg/prompt"
  29. "github.com/docker/compose/v2/pkg/utils"
  30. )
  31. func applyPlatforms(project *types.Project, buildForSinglePlatform bool) error {
  32. defaultPlatform := project.Environment["DOCKER_DEFAULT_PLATFORM"]
  33. for name, service := range project.Services {
  34. if service.Build == nil {
  35. continue
  36. }
  37. // default platform only applies if the service doesn't specify
  38. if defaultPlatform != "" && service.Platform == "" {
  39. if len(service.Build.Platforms) > 0 && !utils.StringContains(service.Build.Platforms, defaultPlatform) {
  40. return fmt.Errorf("service %q build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: %s", name, defaultPlatform)
  41. }
  42. service.Platform = defaultPlatform
  43. }
  44. if service.Platform != "" {
  45. if len(service.Build.Platforms) > 0 {
  46. if !utils.StringContains(service.Build.Platforms, service.Platform) {
  47. return fmt.Errorf("service %q build configuration does not support platform: %s", name, service.Platform)
  48. }
  49. }
  50. if buildForSinglePlatform || len(service.Build.Platforms) == 0 {
  51. // if we're building for a single platform, we want to build for the platform we'll use to run the image
  52. // similarly, if no build platforms were explicitly specified, it makes sense to build for the platform
  53. // the image is designed for rather than allowing the builder to infer the platform
  54. service.Build.Platforms = []string{service.Platform}
  55. }
  56. }
  57. // services can specify that they should be built for multiple platforms, which can be used
  58. // with `docker compose build` to produce a multi-arch image
  59. // other cases, such as `up` and `run`, need a single architecture to actually run
  60. // if there is only a single platform present (which might have been inferred
  61. // from service.Platform above), it will be used, even if it requires emulation.
  62. // if there's more than one platform, then the list is cleared so that the builder
  63. // can decide.
  64. // TODO(milas): there's no validation that the platform the builder will pick is actually one
  65. // of the supported platforms from the build definition
  66. // e.g. `build.platforms: [linux/arm64, linux/amd64]` on a `linux/ppc64` machine would build
  67. // for `linux/ppc64` instead of returning an error that it's not a valid platform for the service.
  68. if buildForSinglePlatform && len(service.Build.Platforms) > 1 {
  69. // empty indicates that the builder gets to decide
  70. service.Build.Platforms = nil
  71. }
  72. project.Services[name] = service
  73. }
  74. return nil
  75. }
  76. // isRemoteConfig checks if the main compose file is from a remote source (OCI or Git)
  77. func isRemoteConfig(dockerCli command.Cli, options buildOptions) bool {
  78. if len(options.ConfigPaths) == 0 {
  79. return false
  80. }
  81. remoteLoaders := options.remoteLoaders(dockerCli)
  82. for _, loader := range remoteLoaders {
  83. if loader.Accept(options.ConfigPaths[0]) {
  84. return true
  85. }
  86. }
  87. return false
  88. }
  89. // checksForRemoteStack handles environment variable prompts for remote configurations
  90. func checksForRemoteStack(ctx context.Context, dockerCli command.Cli, project *types.Project, options buildOptions, assumeYes bool, cmdEnvs []string) error {
  91. if !isRemoteConfig(dockerCli, options) {
  92. return nil
  93. }
  94. if metrics, ok := ctx.Value(tracing.MetricsKey{}).(tracing.Metrics); ok && metrics.CountIncludesRemote > 0 {
  95. if err := confirmRemoteIncludes(dockerCli, options, assumeYes); err != nil {
  96. return err
  97. }
  98. }
  99. displayLocationRemoteStack(dockerCli, project, options)
  100. return promptForInterpolatedVariables(ctx, dockerCli, options.ProjectOptions, assumeYes, cmdEnvs)
  101. }
  102. // Prepare the values map and collect all variables info
  103. type varInfo struct {
  104. name string
  105. value string
  106. source string
  107. required bool
  108. defaultValue string
  109. }
  110. // promptForInterpolatedVariables displays all variables and their values at once,
  111. // then prompts for confirmation
  112. func promptForInterpolatedVariables(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, assumeYes bool, cmdEnvs []string) error {
  113. if assumeYes {
  114. return nil
  115. }
  116. varsInfo, noVariables, err := extractInterpolationVariablesFromModel(ctx, dockerCli, projectOptions, cmdEnvs)
  117. if err != nil {
  118. return err
  119. }
  120. if noVariables {
  121. return nil
  122. }
  123. displayInterpolationVariables(dockerCli.Out(), varsInfo)
  124. // Prompt for confirmation
  125. userInput := prompt.NewPrompt(dockerCli.In(), dockerCli.Out())
  126. msg := "\nDo you want to proceed with these variables? [Y/n]: "
  127. confirmed, err := userInput.Confirm(msg, true)
  128. if err != nil {
  129. return err
  130. }
  131. if !confirmed {
  132. return fmt.Errorf("operation cancelled by user")
  133. }
  134. return nil
  135. }
  136. func extractInterpolationVariablesFromModel(ctx context.Context, dockerCli command.Cli, projectOptions *ProjectOptions, cmdEnvs []string) ([]varInfo, bool, error) {
  137. cmdEnvMap := extractEnvCLIDefined(cmdEnvs)
  138. // Create a model without interpolation to extract variables
  139. opts := configOptions{
  140. noInterpolate: true,
  141. ProjectOptions: projectOptions,
  142. }
  143. model, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
  144. if err != nil {
  145. return nil, false, err
  146. }
  147. // Extract variables that need interpolation
  148. variables := template.ExtractVariables(model, template.DefaultPattern)
  149. if len(variables) == 0 {
  150. return nil, true, nil
  151. }
  152. var varsInfo []varInfo
  153. proposedValues := make(map[string]string)
  154. for name, variable := range variables {
  155. info := varInfo{
  156. name: name,
  157. required: variable.Required,
  158. defaultValue: variable.DefaultValue,
  159. }
  160. // Determine value and source based on priority
  161. if value, exists := cmdEnvMap[name]; exists {
  162. info.value = value
  163. info.source = "command-line"
  164. proposedValues[name] = value
  165. } else if value, exists := os.LookupEnv(name); exists {
  166. info.value = value
  167. info.source = "environment"
  168. proposedValues[name] = value
  169. } else if variable.DefaultValue != "" {
  170. info.value = variable.DefaultValue
  171. info.source = "compose file"
  172. proposedValues[name] = variable.DefaultValue
  173. } else {
  174. info.value = "<unset>"
  175. info.source = "none"
  176. }
  177. varsInfo = append(varsInfo, info)
  178. }
  179. return varsInfo, false, nil
  180. }
  181. func extractEnvCLIDefined(cmdEnvs []string) map[string]string {
  182. // Parse command-line environment variables
  183. cmdEnvMap := make(map[string]string)
  184. for _, env := range cmdEnvs {
  185. parts := strings.SplitN(env, "=", 2)
  186. if len(parts) == 2 {
  187. cmdEnvMap[parts[0]] = parts[1]
  188. }
  189. }
  190. return cmdEnvMap
  191. }
  192. func displayInterpolationVariables(writer io.Writer, varsInfo []varInfo) {
  193. // Display all variables in a table format
  194. _, _ = fmt.Fprintln(writer, "\nFound the following variables in configuration:")
  195. w := tabwriter.NewWriter(writer, 0, 0, 3, ' ', 0)
  196. _, _ = fmt.Fprintln(w, "VARIABLE\tVALUE\tSOURCE\tREQUIRED\tDEFAULT")
  197. sort.Slice(varsInfo, func(a, b int) bool {
  198. return varsInfo[a].name < varsInfo[b].name
  199. })
  200. for _, info := range varsInfo {
  201. required := "no"
  202. if info.required {
  203. required = "yes"
  204. }
  205. _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
  206. info.name,
  207. info.value,
  208. info.source,
  209. required,
  210. info.defaultValue,
  211. )
  212. }
  213. _ = w.Flush()
  214. }
  215. func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, options buildOptions) {
  216. mainComposeFile := options.ProjectOptions.ConfigPaths[0]
  217. if ui.Mode != ui.ModeQuiet && ui.Mode != ui.ModeJSON {
  218. _, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir)
  219. }
  220. }
  221. func confirmRemoteIncludes(dockerCli command.Cli, options buildOptions, assumeYes bool) error {
  222. if assumeYes {
  223. return nil
  224. }
  225. var remoteIncludes []string
  226. remoteLoaders := options.ProjectOptions.remoteLoaders(dockerCli)
  227. for _, cf := range options.ProjectOptions.ConfigPaths {
  228. for _, loader := range remoteLoaders {
  229. if loader.Accept(cf) {
  230. remoteIncludes = append(remoteIncludes, cf)
  231. break
  232. }
  233. }
  234. }
  235. if len(remoteIncludes) == 0 {
  236. return nil
  237. }
  238. _, _ = fmt.Fprintln(dockerCli.Out(), "\nWarning: This Compose project includes files from remote sources:")
  239. for _, include := range remoteIncludes {
  240. _, _ = fmt.Fprintf(dockerCli.Out(), " - %s\n", include)
  241. }
  242. _, _ = fmt.Fprintln(dockerCli.Out(), "\nRemote includes could potentially be malicious. Make sure you trust the source.")
  243. msg := "Do you want to continue? [y/N]: "
  244. confirmed, err := prompt.NewPrompt(dockerCli.In(), dockerCli.Out()).Confirm(msg, false)
  245. if err != nil {
  246. return err
  247. }
  248. if !confirmed {
  249. return fmt.Errorf("operation cancelled by user")
  250. }
  251. return nil
  252. }