convergence_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  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. "fmt"
  17. "net/netip"
  18. "strings"
  19. "testing"
  20. "github.com/compose-spec/compose-go/v2/types"
  21. "github.com/docker/cli/cli/config/configfile"
  22. "github.com/google/go-cmp/cmp/cmpopts"
  23. "github.com/moby/moby/api/types/container"
  24. "github.com/moby/moby/api/types/network"
  25. "github.com/moby/moby/client"
  26. "go.uber.org/mock/gomock"
  27. "gotest.tools/v3/assert"
  28. "github.com/docker/compose/v5/pkg/api"
  29. "github.com/docker/compose/v5/pkg/mocks"
  30. )
  31. func TestContainerName(t *testing.T) {
  32. s := types.ServiceConfig{
  33. Name: "testservicename",
  34. ContainerName: "testcontainername",
  35. Scale: intPtr(1),
  36. Deploy: &types.DeployConfig{},
  37. }
  38. ret, err := getScale(s)
  39. assert.NilError(t, err)
  40. assert.Equal(t, ret, *s.Scale)
  41. s.Scale = intPtr(0)
  42. ret, err = getScale(s)
  43. assert.NilError(t, err)
  44. assert.Equal(t, ret, *s.Scale)
  45. s.Scale = intPtr(2)
  46. _, err = getScale(s)
  47. assert.Error(t, err, fmt.Sprintf(doubledContainerNameWarning, s.Name, s.ContainerName))
  48. }
  49. func intPtr(i int) *int {
  50. return &i
  51. }
  52. func TestServiceLinks(t *testing.T) {
  53. const dbContainerName = "/" + testProject + "-db-1"
  54. const webContainerName = "/" + testProject + "-web-1"
  55. s := types.ServiceConfig{
  56. Name: "web",
  57. Scale: intPtr(1),
  58. }
  59. containerListOptions := client.ContainerListOptions{
  60. Filters: projectFilter(testProject).Add("label",
  61. serviceFilter("db"),
  62. oneOffFilter(false),
  63. hasConfigHashLabel(),
  64. ),
  65. All: true,
  66. }
  67. t.Run("service links default", func(t *testing.T) {
  68. mockCtrl := gomock.NewController(t)
  69. defer mockCtrl.Finish()
  70. apiClient := mocks.NewMockAPIClient(mockCtrl)
  71. cli := mocks.NewMockCli(mockCtrl)
  72. tested, err := NewComposeService(cli)
  73. assert.NilError(t, err)
  74. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  75. s.Links = []string{"db"}
  76. c := testContainer("db", dbContainerName, false)
  77. apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return(client.ContainerListResult{
  78. Items: []container.Summary{c},
  79. }, nil)
  80. links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
  81. assert.NilError(t, err)
  82. assert.Equal(t, len(links), 3)
  83. assert.Equal(t, links[0], "testProject-db-1:db")
  84. assert.Equal(t, links[1], "testProject-db-1:db-1")
  85. assert.Equal(t, links[2], "testProject-db-1:testProject-db-1")
  86. })
  87. t.Run("service links", func(t *testing.T) {
  88. mockCtrl := gomock.NewController(t)
  89. defer mockCtrl.Finish()
  90. apiClient := mocks.NewMockAPIClient(mockCtrl)
  91. cli := mocks.NewMockCli(mockCtrl)
  92. tested, err := NewComposeService(cli)
  93. assert.NilError(t, err)
  94. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  95. s.Links = []string{"db:db"}
  96. c := testContainer("db", dbContainerName, false)
  97. apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return(client.ContainerListResult{
  98. Items: []container.Summary{c},
  99. }, nil)
  100. links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
  101. assert.NilError(t, err)
  102. assert.Equal(t, len(links), 3)
  103. assert.Equal(t, links[0], "testProject-db-1:db")
  104. assert.Equal(t, links[1], "testProject-db-1:db-1")
  105. assert.Equal(t, links[2], "testProject-db-1:testProject-db-1")
  106. })
  107. t.Run("service links name", func(t *testing.T) {
  108. mockCtrl := gomock.NewController(t)
  109. defer mockCtrl.Finish()
  110. apiClient := mocks.NewMockAPIClient(mockCtrl)
  111. cli := mocks.NewMockCli(mockCtrl)
  112. tested, err := NewComposeService(cli)
  113. assert.NilError(t, err)
  114. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  115. s.Links = []string{"db:dbname"}
  116. c := testContainer("db", dbContainerName, false)
  117. apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return(client.ContainerListResult{
  118. Items: []container.Summary{c},
  119. }, nil)
  120. links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
  121. assert.NilError(t, err)
  122. assert.Equal(t, len(links), 3)
  123. assert.Equal(t, links[0], "testProject-db-1:dbname")
  124. assert.Equal(t, links[1], "testProject-db-1:db-1")
  125. assert.Equal(t, links[2], "testProject-db-1:testProject-db-1")
  126. })
  127. t.Run("service links external links", func(t *testing.T) {
  128. mockCtrl := gomock.NewController(t)
  129. defer mockCtrl.Finish()
  130. apiClient := mocks.NewMockAPIClient(mockCtrl)
  131. cli := mocks.NewMockCli(mockCtrl)
  132. tested, err := NewComposeService(cli)
  133. assert.NilError(t, err)
  134. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  135. s.Links = []string{"db:dbname"}
  136. s.ExternalLinks = []string{"db1:db2"}
  137. c := testContainer("db", dbContainerName, false)
  138. apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return(client.ContainerListResult{
  139. Items: []container.Summary{c},
  140. }, nil)
  141. links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
  142. assert.NilError(t, err)
  143. assert.Equal(t, len(links), 4)
  144. assert.Equal(t, links[0], "testProject-db-1:dbname")
  145. assert.Equal(t, links[1], "testProject-db-1:db-1")
  146. assert.Equal(t, links[2], "testProject-db-1:testProject-db-1")
  147. // ExternalLink
  148. assert.Equal(t, links[3], "db1:db2")
  149. })
  150. t.Run("service links itself oneoff", func(t *testing.T) {
  151. mockCtrl := gomock.NewController(t)
  152. defer mockCtrl.Finish()
  153. apiClient := mocks.NewMockAPIClient(mockCtrl)
  154. cli := mocks.NewMockCli(mockCtrl)
  155. tested, err := NewComposeService(cli)
  156. assert.NilError(t, err)
  157. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  158. s.Links = []string{}
  159. s.ExternalLinks = []string{}
  160. s.Labels = s.Labels.Add(api.OneoffLabel, "True")
  161. c := testContainer("web", webContainerName, true)
  162. containerListOptionsOneOff := client.ContainerListOptions{
  163. Filters: projectFilter(testProject).Add("label",
  164. serviceFilter("web"),
  165. oneOffFilter(false),
  166. hasConfigHashLabel(),
  167. ),
  168. All: true,
  169. }
  170. apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptionsOneOff).Return(client.ContainerListResult{
  171. Items: []container.Summary{c},
  172. }, nil)
  173. links, err := tested.(*composeService).getLinks(t.Context(), testProject, s, 1)
  174. assert.NilError(t, err)
  175. assert.Equal(t, len(links), 3)
  176. assert.Equal(t, links[0], "testProject-web-1:web")
  177. assert.Equal(t, links[1], "testProject-web-1:web-1")
  178. assert.Equal(t, links[2], "testProject-web-1:testProject-web-1")
  179. })
  180. }
  181. func TestWaitDependencies(t *testing.T) {
  182. mockCtrl := gomock.NewController(t)
  183. defer mockCtrl.Finish()
  184. apiClient := mocks.NewMockAPIClient(mockCtrl)
  185. cli := mocks.NewMockCli(mockCtrl)
  186. tested, err := NewComposeService(cli)
  187. assert.NilError(t, err)
  188. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  189. t.Run("should skip dependencies with scale 0", func(t *testing.T) {
  190. dbService := types.ServiceConfig{Name: "db", Scale: intPtr(0)}
  191. redisService := types.ServiceConfig{Name: "redis", Scale: intPtr(0)}
  192. project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{
  193. "db": dbService,
  194. "redis": redisService,
  195. }}
  196. dependencies := types.DependsOnConfig{
  197. "db": {Condition: ServiceConditionRunningOrHealthy},
  198. "redis": {Condition: ServiceConditionRunningOrHealthy},
  199. }
  200. assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "", dependencies, nil, 0))
  201. })
  202. t.Run("should skip dependencies with condition service_started", func(t *testing.T) {
  203. dbService := types.ServiceConfig{Name: "db", Scale: intPtr(1)}
  204. redisService := types.ServiceConfig{Name: "redis", Scale: intPtr(1)}
  205. project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{
  206. "db": dbService,
  207. "redis": redisService,
  208. }}
  209. dependencies := types.DependsOnConfig{
  210. "db": {Condition: types.ServiceConditionStarted, Required: true},
  211. "redis": {Condition: types.ServiceConditionStarted, Required: true},
  212. }
  213. assert.NilError(t, tested.(*composeService).waitDependencies(t.Context(), &project, "", dependencies, nil, 0))
  214. })
  215. }
  216. func TestIsServiceHealthy(t *testing.T) {
  217. mockCtrl := gomock.NewController(t)
  218. defer mockCtrl.Finish()
  219. apiClient := mocks.NewMockAPIClient(mockCtrl)
  220. cli := mocks.NewMockCli(mockCtrl)
  221. tested, err := NewComposeService(cli)
  222. assert.NilError(t, err)
  223. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  224. ctx := t.Context()
  225. t.Run("disabled healthcheck with fallback to running", func(t *testing.T) {
  226. containerID := "test-container-id"
  227. containers := Containers{
  228. {ID: containerID},
  229. }
  230. // Container with disabled healthcheck (Test: ["NONE"])
  231. apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{
  232. Container: container.InspectResponse{
  233. ID: containerID,
  234. Name: "test-container",
  235. State: &container.State{Status: "running"},
  236. Config: &container.Config{
  237. Healthcheck: &container.HealthConfig{
  238. Test: []string{"NONE"},
  239. },
  240. },
  241. },
  242. }, nil)
  243. isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
  244. assert.NilError(t, err)
  245. assert.Equal(t, true, isHealthy, "Container with disabled healthcheck should be considered healthy when running with fallbackRunning=true")
  246. })
  247. t.Run("disabled healthcheck without fallback", func(t *testing.T) {
  248. containerID := "test-container-id"
  249. containers := Containers{
  250. {ID: containerID},
  251. }
  252. // Container with disabled healthcheck (Test: ["NONE"]) but fallbackRunning=false
  253. apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{
  254. Container: container.InspectResponse{
  255. ID: containerID,
  256. Name: "test-container",
  257. State: &container.State{Status: "running"},
  258. Config: &container.Config{
  259. Healthcheck: &container.HealthConfig{
  260. Test: []string{"NONE"},
  261. },
  262. },
  263. },
  264. }, nil)
  265. _, err := tested.(*composeService).isServiceHealthy(ctx, containers, false)
  266. assert.ErrorContains(t, err, "has no healthcheck configured")
  267. })
  268. t.Run("no healthcheck with fallback to running", func(t *testing.T) {
  269. containerID := "test-container-id"
  270. containers := Containers{
  271. {ID: containerID},
  272. }
  273. // Container with no healthcheck at all
  274. apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{
  275. Container: container.InspectResponse{
  276. ID: containerID,
  277. Name: "test-container",
  278. State: &container.State{Status: "running"},
  279. Config: &container.Config{
  280. Healthcheck: nil,
  281. },
  282. },
  283. }, nil)
  284. isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
  285. assert.NilError(t, err)
  286. assert.Equal(t, true, isHealthy, "Container with no healthcheck should be considered healthy when running with fallbackRunning=true")
  287. })
  288. t.Run("exited container with disabled healthcheck", func(t *testing.T) {
  289. containerID := "test-container-id"
  290. containers := Containers{
  291. {ID: containerID},
  292. }
  293. // Container with disabled healthcheck but exited
  294. apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{
  295. Container: container.InspectResponse{
  296. ID: containerID,
  297. Name: "test-container",
  298. State: &container.State{
  299. Status: "exited",
  300. ExitCode: 1,
  301. },
  302. Config: &container.Config{
  303. Healthcheck: &container.HealthConfig{
  304. Test: []string{"NONE"},
  305. },
  306. },
  307. },
  308. }, nil)
  309. _, err := tested.(*composeService).isServiceHealthy(ctx, containers, true)
  310. assert.ErrorContains(t, err, "exited")
  311. })
  312. t.Run("healthy container with healthcheck", func(t *testing.T) {
  313. containerID := "test-container-id"
  314. containers := Containers{
  315. {ID: containerID},
  316. }
  317. // Container with actual healthcheck that is healthy
  318. apiClient.EXPECT().ContainerInspect(ctx, containerID, gomock.Any()).Return(client.ContainerInspectResult{
  319. Container: container.InspectResponse{
  320. ID: containerID,
  321. Name: "test-container",
  322. State: &container.State{
  323. Status: "running",
  324. Health: &container.Health{
  325. Status: container.Healthy,
  326. },
  327. },
  328. Config: &container.Config{
  329. Healthcheck: &container.HealthConfig{
  330. Test: []string{"CMD", "curl", "-f", "http://localhost"},
  331. },
  332. },
  333. },
  334. }, nil)
  335. isHealthy, err := tested.(*composeService).isServiceHealthy(ctx, containers, false)
  336. assert.NilError(t, err)
  337. assert.Equal(t, true, isHealthy, "Container with healthy status should be healthy")
  338. })
  339. }
  340. func TestCreateMobyContainer(t *testing.T) {
  341. mockCtrl := gomock.NewController(t)
  342. defer mockCtrl.Finish()
  343. apiClient := mocks.NewMockAPIClient(mockCtrl)
  344. cli := mocks.NewMockCli(mockCtrl)
  345. tested, err := NewComposeService(cli)
  346. assert.NilError(t, err)
  347. cli.EXPECT().Client().Return(apiClient).AnyTimes()
  348. cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes()
  349. apiClient.EXPECT().DaemonHost().Return("").AnyTimes()
  350. apiClient.EXPECT().ImageInspect(anyCancellableContext(), gomock.Any()).Return(client.ImageInspectResult{}, nil).AnyTimes()
  351. // force `RuntimeVersion` to fetch fresh version
  352. runtimeVersion = runtimeVersionCache{}
  353. apiClient.EXPECT().ServerVersion(gomock.Any(), gomock.Any()).Return(client.ServerVersionResult{
  354. APIVersion: "1.44",
  355. }, nil).AnyTimes()
  356. service := types.ServiceConfig{
  357. Name: "test",
  358. Networks: map[string]*types.ServiceNetworkConfig{
  359. "a": {
  360. Priority: 10,
  361. },
  362. "b": {
  363. Priority: 100,
  364. },
  365. },
  366. }
  367. project := types.Project{
  368. Name: "bork",
  369. Services: types.Services{
  370. "test": service,
  371. },
  372. Networks: types.Networks{
  373. "a": types.NetworkConfig{
  374. Name: "a-moby-name",
  375. },
  376. "b": types.NetworkConfig{
  377. Name: "b-moby-name",
  378. },
  379. },
  380. }
  381. var got client.ContainerCreateOptions
  382. apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
  383. got = opts
  384. return client.ContainerCreateResult{ID: "an-id"}, nil
  385. })
  386. apiClient.EXPECT().ContainerInspect(gomock.Any(), gomock.Eq("an-id"), gomock.Any()).Times(1).Return(client.ContainerInspectResult{
  387. Container: container.InspectResponse{
  388. ID: "an-id",
  389. Name: "a-name",
  390. Config: &container.Config{},
  391. NetworkSettings: &container.NetworkSettings{},
  392. },
  393. }, nil)
  394. _, err = tested.(*composeService).createMobyContainer(t.Context(), &project, service, "test", 0, nil, createOptions{
  395. Labels: make(types.Labels),
  396. })
  397. var falseBool bool
  398. want := client.ContainerCreateOptions{
  399. Config: &container.Config{
  400. AttachStdout: true,
  401. AttachStderr: true,
  402. Image: "bork-test",
  403. Labels: map[string]string{
  404. "com.docker.compose.config-hash": "8dbce408396f8986266bc5deba0c09cfebac63c95c2238e405c7bee5f1bd84b8",
  405. "com.docker.compose.depends_on": "",
  406. },
  407. },
  408. HostConfig: &container.HostConfig{
  409. PortBindings: network.PortMap{},
  410. ExtraHosts: []string{},
  411. Tmpfs: map[string]string{},
  412. Resources: container.Resources{
  413. OomKillDisable: &falseBool,
  414. },
  415. NetworkMode: "b-moby-name",
  416. },
  417. NetworkingConfig: &network.NetworkingConfig{
  418. EndpointsConfig: map[string]*network.EndpointSettings{
  419. "a-moby-name": {
  420. IPAMConfig: &network.EndpointIPAMConfig{},
  421. Aliases: []string{"bork-test-0"},
  422. },
  423. "b-moby-name": {
  424. IPAMConfig: &network.EndpointIPAMConfig{},
  425. Aliases: []string{"bork-test-0"},
  426. },
  427. },
  428. },
  429. Name: "test",
  430. }
  431. assert.DeepEqual(t, want, got, cmpopts.EquateComparable(netip.Addr{}), cmpopts.EquateEmpty())
  432. assert.NilError(t, err)
  433. }