options_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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. "bytes"
  16. "context"
  17. "fmt"
  18. "io"
  19. "os"
  20. "path/filepath"
  21. "strings"
  22. "testing"
  23. "github.com/compose-spec/compose-go/v2/types"
  24. "github.com/docker/cli/cli/streams"
  25. "github.com/docker/compose/v2/pkg/mocks"
  26. "github.com/stretchr/testify/require"
  27. "go.uber.org/mock/gomock"
  28. )
  29. func TestApplyPlatforms_InferFromRuntime(t *testing.T) {
  30. makeProject := func() *types.Project {
  31. return &types.Project{
  32. Services: types.Services{
  33. "test": {
  34. Name: "test",
  35. Image: "foo",
  36. Build: &types.BuildConfig{
  37. Context: ".",
  38. Platforms: []string{
  39. "linux/amd64",
  40. "linux/arm64",
  41. "alice/32",
  42. },
  43. },
  44. Platform: "alice/32",
  45. },
  46. },
  47. }
  48. }
  49. t.Run("SinglePlatform", func(t *testing.T) {
  50. project := makeProject()
  51. require.NoError(t, applyPlatforms(project, true))
  52. require.EqualValues(t, []string{"alice/32"}, project.Services["test"].Build.Platforms)
  53. })
  54. t.Run("MultiPlatform", func(t *testing.T) {
  55. project := makeProject()
  56. require.NoError(t, applyPlatforms(project, false))
  57. require.EqualValues(t, []string{"linux/amd64", "linux/arm64", "alice/32"},
  58. project.Services["test"].Build.Platforms)
  59. })
  60. }
  61. func TestApplyPlatforms_DockerDefaultPlatform(t *testing.T) {
  62. makeProject := func() *types.Project {
  63. return &types.Project{
  64. Environment: map[string]string{
  65. "DOCKER_DEFAULT_PLATFORM": "linux/amd64",
  66. },
  67. Services: types.Services{
  68. "test": {
  69. Name: "test",
  70. Image: "foo",
  71. Build: &types.BuildConfig{
  72. Context: ".",
  73. Platforms: []string{
  74. "linux/amd64",
  75. "linux/arm64",
  76. },
  77. },
  78. },
  79. },
  80. }
  81. }
  82. t.Run("SinglePlatform", func(t *testing.T) {
  83. project := makeProject()
  84. require.NoError(t, applyPlatforms(project, true))
  85. require.EqualValues(t, []string{"linux/amd64"}, project.Services["test"].Build.Platforms)
  86. })
  87. t.Run("MultiPlatform", func(t *testing.T) {
  88. project := makeProject()
  89. require.NoError(t, applyPlatforms(project, false))
  90. require.EqualValues(t, []string{"linux/amd64", "linux/arm64"},
  91. project.Services["test"].Build.Platforms)
  92. })
  93. }
  94. func TestApplyPlatforms_UnsupportedPlatform(t *testing.T) {
  95. makeProject := func() *types.Project {
  96. return &types.Project{
  97. Environment: map[string]string{
  98. "DOCKER_DEFAULT_PLATFORM": "commodore/64",
  99. },
  100. Services: types.Services{
  101. "test": {
  102. Name: "test",
  103. Image: "foo",
  104. Build: &types.BuildConfig{
  105. Context: ".",
  106. Platforms: []string{
  107. "linux/amd64",
  108. "linux/arm64",
  109. },
  110. },
  111. },
  112. },
  113. }
  114. }
  115. t.Run("SinglePlatform", func(t *testing.T) {
  116. project := makeProject()
  117. require.EqualError(t, applyPlatforms(project, true),
  118. `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`)
  119. })
  120. t.Run("MultiPlatform", func(t *testing.T) {
  121. project := makeProject()
  122. require.EqualError(t, applyPlatforms(project, false),
  123. `service "test" build.platforms does not support value set by DOCKER_DEFAULT_PLATFORM: commodore/64`)
  124. })
  125. }
  126. func TestIsRemoteConfig(t *testing.T) {
  127. ctrl := gomock.NewController(t)
  128. defer ctrl.Finish()
  129. cli := mocks.NewMockCli(ctrl)
  130. tests := []struct {
  131. name string
  132. configPaths []string
  133. want bool
  134. }{
  135. {
  136. name: "empty config paths",
  137. configPaths: []string{},
  138. want: false,
  139. },
  140. {
  141. name: "local file",
  142. configPaths: []string{"docker-compose.yaml"},
  143. want: false,
  144. },
  145. {
  146. name: "OCI reference",
  147. configPaths: []string{"oci://registry.example.com/stack:latest"},
  148. want: true,
  149. },
  150. {
  151. name: "GIT reference",
  152. configPaths: []string{"git://github.com/user/repo.git"},
  153. want: true,
  154. },
  155. }
  156. for _, tt := range tests {
  157. t.Run(tt.name, func(t *testing.T) {
  158. opts := buildOptions{
  159. ProjectOptions: &ProjectOptions{
  160. ConfigPaths: tt.configPaths,
  161. },
  162. }
  163. got := isRemoteConfig(cli, opts)
  164. require.Equal(t, tt.want, got)
  165. })
  166. }
  167. }
  168. func TestDisplayLocationRemoteStack(t *testing.T) {
  169. ctrl := gomock.NewController(t)
  170. defer ctrl.Finish()
  171. cli := mocks.NewMockCli(ctrl)
  172. buf := new(bytes.Buffer)
  173. cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
  174. project := &types.Project{
  175. Name: "test-project",
  176. WorkingDir: "/tmp/test",
  177. }
  178. options := buildOptions{
  179. ProjectOptions: &ProjectOptions{
  180. ConfigPaths: []string{"oci://registry.example.com/stack:latest"},
  181. },
  182. }
  183. displayLocationRemoteStack(cli, project, options)
  184. output := buf.String()
  185. require.Equal(t, output, fmt.Sprintf("Your compose stack %q is stored in %q\n", "oci://registry.example.com/stack:latest", "/tmp/test"))
  186. }
  187. func TestDisplayInterpolationVariables(t *testing.T) {
  188. ctrl := gomock.NewController(t)
  189. defer ctrl.Finish()
  190. // Create a temporary directory for the test
  191. tmpDir, err := os.MkdirTemp("", "compose-test")
  192. require.NoError(t, err)
  193. defer func() { _ = os.RemoveAll(tmpDir) }()
  194. // Create a temporary compose file
  195. composeContent := `
  196. services:
  197. app:
  198. image: nginx
  199. environment:
  200. - TEST_VAR=${TEST_VAR:?required} # required with default
  201. - API_KEY=${API_KEY:?} # required without default
  202. - DEBUG=${DEBUG:-true} # optional with default
  203. - UNSET_VAR # optional without default
  204. `
  205. composePath := filepath.Join(tmpDir, "docker-compose.yml")
  206. err = os.WriteFile(composePath, []byte(composeContent), 0o644)
  207. require.NoError(t, err)
  208. buf := new(bytes.Buffer)
  209. cli := mocks.NewMockCli(ctrl)
  210. cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
  211. // Create ProjectOptions with the temporary compose file
  212. projectOptions := &ProjectOptions{
  213. ConfigPaths: []string{composePath},
  214. }
  215. // Set up the context with necessary environment variables
  216. ctx := context.Background()
  217. _ = os.Setenv("TEST_VAR", "test-value")
  218. _ = os.Setenv("API_KEY", "123456")
  219. defer func() {
  220. _ = os.Unsetenv("TEST_VAR")
  221. _ = os.Unsetenv("API_KEY")
  222. }()
  223. // Extract variables from the model
  224. info, noVariables, err := extractInterpolationVariablesFromModel(ctx, cli, projectOptions, []string{})
  225. require.NoError(t, err)
  226. require.False(t, noVariables)
  227. // Display the variables
  228. displayInterpolationVariables(cli.Out(), info)
  229. // Expected output format with proper spacing
  230. expected := "\nFound the following variables in configuration:\n" +
  231. "VARIABLE VALUE SOURCE REQUIRED DEFAULT\n" +
  232. "API_KEY 123456 environment yes \n" +
  233. "DEBUG true compose file no true\n" +
  234. "TEST_VAR test-value environment yes \n"
  235. // Normalize spaces and newlines for comparison
  236. normalizeSpaces := func(s string) string {
  237. // Replace multiple spaces with a single space
  238. s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
  239. return s
  240. }
  241. actualOutput := buf.String()
  242. // Compare normalized strings
  243. require.Equal(t,
  244. normalizeSpaces(expected),
  245. normalizeSpaces(actualOutput),
  246. "\nExpected:\n%s\nGot:\n%s", expected, actualOutput)
  247. }
  248. func TestConfirmRemoteIncludes(t *testing.T) {
  249. ctrl := gomock.NewController(t)
  250. defer ctrl.Finish()
  251. cli := mocks.NewMockCli(ctrl)
  252. tests := []struct {
  253. name string
  254. opts buildOptions
  255. assumeYes bool
  256. userInput string
  257. wantErr bool
  258. errMessage string
  259. wantPrompt bool
  260. wantOutput string
  261. }{
  262. {
  263. name: "no remote includes",
  264. opts: buildOptions{
  265. ProjectOptions: &ProjectOptions{
  266. ConfigPaths: []string{
  267. "docker-compose.yaml",
  268. "./local/path/compose.yaml",
  269. },
  270. },
  271. },
  272. assumeYes: false,
  273. wantErr: false,
  274. wantPrompt: false,
  275. },
  276. {
  277. name: "assume yes with remote includes",
  278. opts: buildOptions{
  279. ProjectOptions: &ProjectOptions{
  280. ConfigPaths: []string{
  281. "oci://registry.example.com/stack:latest",
  282. "git://github.com/user/repo.git",
  283. },
  284. },
  285. },
  286. assumeYes: true,
  287. wantErr: false,
  288. wantPrompt: false,
  289. },
  290. {
  291. name: "user confirms remote includes",
  292. opts: buildOptions{
  293. ProjectOptions: &ProjectOptions{
  294. ConfigPaths: []string{
  295. "oci://registry.example.com/stack:latest",
  296. "git://github.com/user/repo.git",
  297. },
  298. },
  299. },
  300. assumeYes: false,
  301. userInput: "y\n",
  302. wantErr: false,
  303. wantPrompt: true,
  304. wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
  305. " - oci://registry.example.com/stack:latest\n" +
  306. " - git://github.com/user/repo.git\n" +
  307. "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
  308. "Do you want to continue? [y/N]: ",
  309. },
  310. {
  311. name: "user rejects remote includes",
  312. opts: buildOptions{
  313. ProjectOptions: &ProjectOptions{
  314. ConfigPaths: []string{
  315. "oci://registry.example.com/stack:latest",
  316. },
  317. },
  318. },
  319. assumeYes: false,
  320. userInput: "n\n",
  321. wantErr: true,
  322. errMessage: "operation cancelled by user",
  323. wantPrompt: true,
  324. wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
  325. " - oci://registry.example.com/stack:latest\n" +
  326. "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
  327. "Do you want to continue? [y/N]: ",
  328. },
  329. }
  330. buf := new(bytes.Buffer)
  331. for _, tt := range tests {
  332. t.Run(tt.name, func(t *testing.T) {
  333. cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
  334. if tt.wantPrompt {
  335. inbuf := io.NopCloser(bytes.NewBufferString(tt.userInput))
  336. cli.EXPECT().In().Return(streams.NewIn(inbuf)).AnyTimes()
  337. }
  338. err := confirmRemoteIncludes(cli, tt.opts, tt.assumeYes)
  339. if tt.wantErr {
  340. require.Error(t, err)
  341. require.Equal(t, tt.errMessage, err.Error())
  342. } else {
  343. require.NoError(t, err)
  344. }
  345. if tt.wantOutput != "" {
  346. require.Equal(t, tt.wantOutput, buf.String())
  347. }
  348. buf.Reset()
  349. })
  350. }
  351. }