DockerRunner.test.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032
  1. import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
  2. import sinon from 'sinon'
  3. import Path from 'node:path'
  4. const modulePath = Path.join(
  5. import.meta.dirname,
  6. '../../../app/js/DockerRunner'
  7. )
  8. describe('DockerRunner', () => {
  9. beforeEach(async ctx => {
  10. let container
  11. ctx.container = container = {}
  12. ctx.Settings = {
  13. clsi: { docker: {} },
  14. path: {},
  15. }
  16. const Docker = (function () {
  17. const Docker = class Docker {
  18. static initClass() {
  19. this.prototype.getContainer = sinon.stub().returns(container)
  20. this.prototype.createContainer = sinon.stub().yields(null, container)
  21. this.prototype.listContainers = sinon.stub()
  22. }
  23. }
  24. Docker.initClass()
  25. return Docker
  26. })()
  27. vi.doMock('@overleaf/settings', () => ({
  28. default: ctx.Settings,
  29. }))
  30. vi.doMock('dockerode', () => ({
  31. default: Docker,
  32. }))
  33. vi.doMock('fs', () => ({
  34. default: (ctx.fs = {
  35. stat: sinon.stub().yields(null, {
  36. isDirectory() {
  37. return true
  38. },
  39. }),
  40. }),
  41. }))
  42. const Timer = class Timer {
  43. done() {}
  44. }
  45. vi.doMock('@overleaf/metrics', () => ({
  46. default: {
  47. Timer,
  48. },
  49. }))
  50. vi.doMock('../../../app/js/LockManager', () => ({
  51. default: {
  52. runWithLock(key, runner, callback) {
  53. return runner(callback)
  54. },
  55. },
  56. }))
  57. ctx.DockerRunner = (await import(modulePath)).default
  58. ctx.Docker = Docker
  59. ctx.getContainer = Docker.prototype.getContainer
  60. ctx.createContainer = Docker.prototype.createContainer
  61. ctx.listContainers = Docker.prototype.listContainers
  62. ctx.directory = '/local/compile/directory'
  63. ctx.mainFile = 'main-file.tex'
  64. ctx.compiler = 'pdflatex'
  65. ctx.image = 'example.com/overleaf/image:2016.2'
  66. ctx.env = {}
  67. ctx.callback = sinon.stub()
  68. ctx.project_id = 'project-id-123'
  69. ctx.volumes = { '/some/host/dir/compiles/directory': '/compile' }
  70. ctx.Settings.clsi.docker.image = ctx.defaultImage = 'default-image'
  71. ctx.Settings.path.sandboxedCompilesHostDirCompiles =
  72. '/some/host/dir/compiles'
  73. ctx.Settings.path.sandboxedCompilesHostDirOutput = '/some/host/dir/output'
  74. ctx.compileGroup = 'compile-group'
  75. return (ctx.Settings.clsi.docker.env = { PATH: 'mock-path' })
  76. })
  77. afterEach(ctx => {
  78. ctx.DockerRunner.stopContainerMonitor()
  79. })
  80. describe('run', () => {
  81. beforeEach(async ctx => {
  82. await new Promise((resolve, reject) => {
  83. ctx.DockerRunner._getContainerOptions = sinon
  84. .stub()
  85. .returns((ctx.options = { mockoptions: 'foo' }))
  86. ctx.DockerRunner._fingerprintContainer = sinon
  87. .stub()
  88. .returns((ctx.fingerprint = 'fingerprint'))
  89. ctx.containerName = `project-${ctx.project_id}-${ctx.fingerprint}`
  90. ctx.command = ['mock', 'command', '--outdir=$COMPILE_DIR']
  91. ctx.command_with_dir = ['mock', 'command', '--outdir=/compile']
  92. ctx.timeout = 42000
  93. return resolve()
  94. })
  95. })
  96. describe('successfully', () => {
  97. beforeEach(async ctx => {
  98. await new Promise((resolve, reject) => {
  99. ctx.DockerRunner._runAndWaitForContainer = sinon
  100. .stub()
  101. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  102. return ctx.DockerRunner.run(
  103. ctx.project_id,
  104. ctx.command,
  105. ctx.directory,
  106. ctx.image,
  107. ctx.timeout,
  108. ctx.env,
  109. ctx.compileGroup,
  110. (err, output) => {
  111. ctx.callback(err, output)
  112. return resolve()
  113. }
  114. )
  115. })
  116. })
  117. it('should generate the options for the container', ctx => {
  118. return ctx.DockerRunner._getContainerOptions
  119. .calledWith(ctx.command_with_dir, ctx.image, ctx.volumes, ctx.timeout)
  120. .should.equal(true)
  121. })
  122. it('should generate the fingerprint from the returned options', ctx => {
  123. return ctx.DockerRunner._fingerprintContainer
  124. .calledWith(ctx.options)
  125. .should.equal(true)
  126. })
  127. it('should do the run', ctx => {
  128. return ctx.DockerRunner._runAndWaitForContainer
  129. .calledWith(ctx.options, ctx.volumes, ctx.timeout)
  130. .should.equal(true)
  131. })
  132. return it('should call the callback', ctx => {
  133. return ctx.callback.calledWith(null, ctx.output).should.equal(true)
  134. })
  135. })
  136. describe('standard compile', () => {
  137. beforeEach(ctx => {
  138. ctx.directory = '/var/lib/overleaf/data/compiles/xyz'
  139. ctx.DockerRunner._runAndWaitForContainer = sinon
  140. .stub()
  141. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  142. return ctx.DockerRunner.run(
  143. ctx.project_id,
  144. ctx.command,
  145. ctx.directory,
  146. ctx.image,
  147. ctx.timeout,
  148. ctx.env,
  149. ctx.compileGroup,
  150. ctx.callback
  151. )
  152. })
  153. it('should re-write the bind directory', ctx => {
  154. const volumes =
  155. ctx.DockerRunner._runAndWaitForContainer.lastCall.args[1]
  156. return expect(volumes).to.deep.equal({
  157. '/some/host/dir/compiles/xyz': '/compile',
  158. })
  159. })
  160. return it('should call the callback', ctx => {
  161. return ctx.callback.calledWith(null, ctx.output).should.equal(true)
  162. })
  163. })
  164. describe('synctex-output', () => {
  165. beforeEach(ctx => {
  166. ctx.directory = '/var/lib/overleaf/data/output/xyz/generated-files/id'
  167. ctx.DockerRunner._runAndWaitForContainer = sinon
  168. .stub()
  169. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  170. ctx.DockerRunner.run(
  171. ctx.project_id,
  172. ctx.command,
  173. ctx.directory,
  174. ctx.image,
  175. ctx.timeout,
  176. ctx.env,
  177. 'synctex-output',
  178. ctx.callback
  179. )
  180. })
  181. it('should re-write the bind directory and set ro flag', ctx => {
  182. const volumes =
  183. ctx.DockerRunner._runAndWaitForContainer.lastCall.args[1]
  184. expect(volumes).to.deep.equal({
  185. '/some/host/dir/output/xyz/generated-files/id': '/compile:ro',
  186. })
  187. })
  188. it('should call the callback', ctx => {
  189. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  190. })
  191. })
  192. describe('synctex', () => {
  193. beforeEach(ctx => {
  194. ctx.directory = '/var/lib/overleaf/data/compile/xyz'
  195. ctx.DockerRunner._runAndWaitForContainer = sinon
  196. .stub()
  197. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  198. ctx.DockerRunner.run(
  199. ctx.project_id,
  200. ctx.command,
  201. ctx.directory,
  202. ctx.image,
  203. ctx.timeout,
  204. ctx.env,
  205. 'synctex',
  206. ctx.callback
  207. )
  208. })
  209. it('should re-write the bind directory', ctx => {
  210. const volumes =
  211. ctx.DockerRunner._runAndWaitForContainer.lastCall.args[1]
  212. expect(volumes).to.deep.equal({
  213. '/some/host/dir/compiles/xyz': '/compile:ro',
  214. })
  215. })
  216. it('should call the callback', ctx => {
  217. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  218. })
  219. })
  220. describe('wordcount', () => {
  221. beforeEach(ctx => {
  222. ctx.directory = '/var/lib/overleaf/data/compile/xyz'
  223. ctx.DockerRunner._runAndWaitForContainer = sinon
  224. .stub()
  225. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  226. ctx.DockerRunner.run(
  227. ctx.project_id,
  228. ctx.command,
  229. ctx.directory,
  230. ctx.image,
  231. ctx.timeout,
  232. ctx.env,
  233. 'wordcount',
  234. ctx.callback
  235. )
  236. })
  237. it('should re-write the bind directory', ctx => {
  238. const volumes =
  239. ctx.DockerRunner._runAndWaitForContainer.lastCall.args[1]
  240. expect(volumes).to.deep.equal({
  241. '/some/host/dir/compiles/xyz': '/compile:ro',
  242. })
  243. })
  244. it('should call the callback', ctx => {
  245. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  246. })
  247. })
  248. describe('when the run throws an error', () => {
  249. beforeEach(ctx => {
  250. let firstTime = true
  251. ctx.output = 'mock-output'
  252. ctx.DockerRunner._runAndWaitForContainer = (
  253. options,
  254. volumes,
  255. timeout,
  256. callback
  257. ) => {
  258. if (callback == null) {
  259. callback = function () {}
  260. }
  261. if (firstTime) {
  262. firstTime = false
  263. const error = new Error('(HTTP code 500) server error - ...')
  264. error.statusCode = 500
  265. callback(error)
  266. } else {
  267. callback(null, ctx.output)
  268. }
  269. }
  270. sinon.spy(ctx.DockerRunner, '_runAndWaitForContainer')
  271. ctx.DockerRunner.destroyContainer = sinon.stub().callsArg(3)
  272. ctx.DockerRunner.run(
  273. ctx.project_id,
  274. ctx.command,
  275. ctx.directory,
  276. ctx.image,
  277. ctx.timeout,
  278. ctx.env,
  279. ctx.compileGroup,
  280. ctx.callback
  281. )
  282. })
  283. it('should do the run twice', ctx => {
  284. ctx.DockerRunner._runAndWaitForContainer.calledTwice.should.equal(true)
  285. })
  286. it('should destroy the container in between', ctx => {
  287. ctx.DockerRunner.destroyContainer
  288. .calledWith(ctx.containerName, null)
  289. .should.equal(true)
  290. })
  291. it('should call the callback', ctx => {
  292. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  293. })
  294. })
  295. describe('with no image', () => {
  296. beforeEach(ctx => {
  297. ctx.DockerRunner._runAndWaitForContainer = sinon
  298. .stub()
  299. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  300. ctx.DockerRunner.run(
  301. ctx.project_id,
  302. ctx.command,
  303. ctx.directory,
  304. null,
  305. ctx.timeout,
  306. ctx.env,
  307. ctx.compileGroup,
  308. ctx.callback
  309. )
  310. })
  311. it('should use the default image', ctx => {
  312. ctx.DockerRunner._getContainerOptions
  313. .calledWith(
  314. ctx.command_with_dir,
  315. ctx.defaultImage,
  316. ctx.volumes,
  317. ctx.timeout
  318. )
  319. .should.equal(true)
  320. })
  321. })
  322. describe('with image override', () => {
  323. beforeEach(ctx => {
  324. ctx.Settings.texliveImageNameOveride = 'overrideimage.com/something'
  325. ctx.DockerRunner._runAndWaitForContainer = sinon
  326. .stub()
  327. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  328. ctx.DockerRunner.run(
  329. ctx.project_id,
  330. ctx.command,
  331. ctx.directory,
  332. ctx.image,
  333. ctx.timeout,
  334. ctx.env,
  335. ctx.compileGroup,
  336. ctx.callback
  337. )
  338. })
  339. it('should use the override and keep the tag', ctx => {
  340. const image = ctx.DockerRunner._getContainerOptions.args[0][1]
  341. image.should.equal('overrideimage.com/something/image:2016.2')
  342. })
  343. })
  344. describe('with image restriction', () => {
  345. beforeEach(ctx => {
  346. ctx.Settings.clsi.docker.allowedImages = [
  347. 'repo/image:tag1',
  348. 'repo/image:tag2',
  349. ]
  350. ctx.DockerRunner._runAndWaitForContainer = sinon
  351. .stub()
  352. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  353. })
  354. describe('with a valid image', () => {
  355. beforeEach(ctx => {
  356. ctx.DockerRunner.run(
  357. ctx.project_id,
  358. ctx.command,
  359. ctx.directory,
  360. 'repo/image:tag1',
  361. ctx.timeout,
  362. ctx.env,
  363. ctx.compileGroup,
  364. ctx.callback
  365. )
  366. })
  367. it('should setup the container', ctx => {
  368. ctx.DockerRunner._getContainerOptions.called.should.equal(true)
  369. })
  370. })
  371. describe('with a invalid image', () => {
  372. beforeEach(ctx => {
  373. ctx.DockerRunner.run(
  374. ctx.project_id,
  375. ctx.command,
  376. ctx.directory,
  377. 'something/different:evil',
  378. ctx.timeout,
  379. ctx.env,
  380. ctx.compileGroup,
  381. ctx.callback
  382. )
  383. })
  384. it('should call the callback with an error', ctx => {
  385. const err = new Error('image not allowed')
  386. ctx.callback.called.should.equal(true)
  387. ctx.callback.args[0][0].message.should.equal(err.message)
  388. })
  389. it('should not setup the container', ctx => {
  390. ctx.DockerRunner._getContainerOptions.called.should.equal(false)
  391. })
  392. })
  393. })
  394. })
  395. describe('run with _getOptions', () => {
  396. beforeEach(async ctx => {
  397. await new Promise((resolve, reject) => {
  398. // this.DockerRunner._getContainerOptions = sinon
  399. // .stub()
  400. // .returns((this.options = { mockoptions: 'foo' }))
  401. ctx.DockerRunner._fingerprintContainer = sinon
  402. .stub()
  403. .returns((ctx.fingerprint = 'fingerprint'))
  404. ctx.containerName = `project-${ctx.project_id}-${ctx.fingerprint}`
  405. ctx.command = ['mock', 'command', '--outdir=$COMPILE_DIR']
  406. ctx.command_with_dir = ['mock', 'command', '--outdir=/compile']
  407. ctx.timeout = 42000
  408. resolve()
  409. })
  410. })
  411. describe('when a compile group config is set', () => {
  412. beforeEach(ctx => {
  413. ctx.Settings.clsi.docker.compileGroupConfig = {
  414. 'compile-group': {
  415. 'HostConfig.newProperty': 'new-property',
  416. },
  417. 'other-group': { otherProperty: 'other-property' },
  418. }
  419. ctx.DockerRunner._runAndWaitForContainer = sinon
  420. .stub()
  421. .callsArgWith(3, null, (ctx.output = 'mock-output'))
  422. ctx.DockerRunner.run(
  423. ctx.project_id,
  424. ctx.command,
  425. ctx.directory,
  426. ctx.image,
  427. ctx.timeout,
  428. ctx.env,
  429. ctx.compileGroup,
  430. ctx.callback
  431. )
  432. })
  433. it('should set the docker options for the compile group', ctx => {
  434. const options =
  435. ctx.DockerRunner._runAndWaitForContainer.lastCall.args[0]
  436. expect(options.HostConfig).to.deep.include({
  437. Binds: ['/some/host/dir/compiles/directory:/compile:rw'],
  438. LogConfig: { Type: 'none', Config: {} },
  439. CapDrop: ['ALL'],
  440. SecurityOpt: ['no-new-privileges'],
  441. newProperty: 'new-property',
  442. })
  443. })
  444. it('should call the callback', ctx => {
  445. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  446. })
  447. })
  448. })
  449. describe('_runAndWaitForContainer', () => {
  450. beforeEach(ctx => {
  451. ctx.options = {
  452. mockoptions: 'foo',
  453. name: (ctx.containerName = 'mock-name'),
  454. }
  455. ctx.DockerRunner.startContainer = (
  456. options,
  457. volumes,
  458. attachStreamHandler,
  459. callback
  460. ) => {
  461. attachStreamHandler(null, (ctx.output = 'mock-output'))
  462. callback(null, (ctx.containerId = 'container-id'))
  463. }
  464. sinon.spy(ctx.DockerRunner, 'startContainer')
  465. ctx.DockerRunner.waitForContainer = sinon
  466. .stub()
  467. .callsArgWith(3, null, (ctx.exitCode = 42))
  468. ctx.DockerRunner._runAndWaitForContainer(
  469. ctx.options,
  470. ctx.volumes,
  471. ctx.timeout,
  472. ctx.callback
  473. )
  474. })
  475. it('should create/start the container', ctx => {
  476. ctx.DockerRunner.startContainer
  477. .calledWith(ctx.options, ctx.volumes)
  478. .should.equal(true)
  479. })
  480. it('should wait for the container to finish', ctx => {
  481. ctx.DockerRunner.waitForContainer
  482. .calledWith(ctx.containerName, ctx.timeout)
  483. .should.equal(true)
  484. })
  485. it('should call the callback with the output', ctx => {
  486. ctx.callback.calledWith(null, ctx.output).should.equal(true)
  487. })
  488. })
  489. describe('startContainer', () => {
  490. beforeEach(ctx => {
  491. ctx.attachStreamHandler = sinon.stub()
  492. ctx.attachStreamHandler.cock = true
  493. ctx.options = { mockoptions: 'foo', name: 'mock-name' }
  494. ctx.container.inspect = sinon.stub().callsArgWith(0)
  495. ctx.DockerRunner.attachToContainer = (
  496. containerId,
  497. attachStreamHandler,
  498. cb
  499. ) => {
  500. attachStreamHandler()
  501. cb()
  502. }
  503. sinon.spy(ctx.DockerRunner, 'attachToContainer')
  504. })
  505. describe('when the container exists', () => {
  506. beforeEach(ctx => {
  507. ctx.container.inspect = sinon.stub().callsArgWith(0)
  508. ctx.container.start = sinon.stub().yields()
  509. ctx.DockerRunner.startContainer(
  510. ctx.options,
  511. ctx.volumes,
  512. () => {},
  513. ctx.callback
  514. )
  515. })
  516. it('should start the container with the given name', ctx => {
  517. ctx.getContainer.calledWith(ctx.options.name).should.equal(true)
  518. ctx.container.start.called.should.equal(true)
  519. })
  520. it('should not try to create the container', ctx => {
  521. ctx.createContainer.called.should.equal(false)
  522. })
  523. it('should attach to the container', ctx => {
  524. ctx.DockerRunner.attachToContainer.called.should.equal(true)
  525. })
  526. it('should call the callback', ctx => {
  527. ctx.callback.called.should.equal(true)
  528. })
  529. it('should attach before the container starts', ctx => {
  530. sinon.assert.callOrder(
  531. ctx.DockerRunner.attachToContainer,
  532. ctx.container.start
  533. )
  534. })
  535. })
  536. describe('when the container does not exist', () => {
  537. beforeEach(ctx => {
  538. ctx.container.start = sinon.stub().yields()
  539. ctx.container.inspect = sinon
  540. .stub()
  541. .callsArgWith(0, { statusCode: 404 })
  542. ctx.DockerRunner.startContainer(
  543. ctx.options,
  544. ctx.volumes,
  545. ctx.attachStreamHandler,
  546. ctx.callback
  547. )
  548. })
  549. it('should create the container', ctx => {
  550. ctx.createContainer.calledWith(ctx.options).should.equal(true)
  551. })
  552. it('should call the callback and stream handler', ctx => {
  553. ctx.attachStreamHandler.called.should.equal(true)
  554. ctx.callback.called.should.equal(true)
  555. })
  556. it('should attach to the container', ctx => {
  557. ctx.DockerRunner.attachToContainer.called.should.equal(true)
  558. })
  559. it('should attach before the container starts', ctx => {
  560. sinon.assert.callOrder(
  561. ctx.DockerRunner.attachToContainer,
  562. ctx.container.start
  563. )
  564. })
  565. })
  566. describe('when the container is already running', () => {
  567. beforeEach(ctx => {
  568. const error = new Error(
  569. `HTTP code is 304 which indicates error: server error - start: Cannot start container ${ctx.containerName}: The container MOCKID is already running.`
  570. )
  571. error.statusCode = 304
  572. ctx.container.start = sinon.stub().yields(error)
  573. ctx.container.inspect = sinon.stub().callsArgWith(0)
  574. ctx.DockerRunner.startContainer(
  575. ctx.options,
  576. ctx.volumes,
  577. ctx.attachStreamHandler,
  578. ctx.callback
  579. )
  580. })
  581. it('should not try to create the container', ctx => {
  582. ctx.createContainer.called.should.equal(false)
  583. })
  584. it('should call the callback and stream handler without an error', ctx => {
  585. ctx.attachStreamHandler.called.should.equal(true)
  586. ctx.callback.called.should.equal(true)
  587. })
  588. })
  589. describe.todo(
  590. 'when the container tries to be created, but already has been (race condition)',
  591. () => {}
  592. )
  593. })
  594. describe('waitForContainer', () => {
  595. beforeEach(ctx => {
  596. ctx.containerId = 'container-id'
  597. ctx.timeout = 5000
  598. ctx.container.wait = sinon
  599. .stub()
  600. .yields(null, { StatusCode: (ctx.statusCode = 42) })
  601. ctx.container.kill = sinon.stub().yields()
  602. })
  603. describe('when the container returns in time', () => {
  604. beforeEach(ctx => {
  605. ctx.DockerRunner.waitForContainer(
  606. ctx.containerId,
  607. ctx.timeout,
  608. {},
  609. ctx.callback
  610. )
  611. })
  612. it('should wait for the container', ctx => {
  613. ctx.getContainer.calledWith(ctx.containerId).should.equal(true)
  614. ctx.container.wait.called.should.equal(true)
  615. })
  616. it('should call the callback with the exit', ctx => {
  617. ctx.callback.calledWith(null, ctx.statusCode).should.equal(true)
  618. })
  619. })
  620. describe('when the container is removed before waiting', () => {
  621. const err = new Error('not found')
  622. err.statusCode = 404
  623. beforeEach(ctx => {
  624. ctx.container.wait = sinon.stub().yields(err)
  625. })
  626. describe('AutoRemove not set', () => {
  627. beforeEach(ctx => {
  628. ctx.DockerRunner.waitForContainer(
  629. ctx.containerId,
  630. ctx.timeout,
  631. { HostConfig: {} },
  632. ctx.callback
  633. )
  634. })
  635. it('should wait for the container', ctx => {
  636. ctx.getContainer.calledWith(ctx.containerId).should.equal(true)
  637. ctx.container.wait.called.should.equal(true)
  638. })
  639. it('should call the callback with the error', ctx => {
  640. ctx.callback.calledWith(err).should.equal(true)
  641. })
  642. })
  643. describe('AutoRemove=true', () => {
  644. beforeEach(ctx => {
  645. ctx.DockerRunner.waitForContainer(
  646. ctx.containerId,
  647. ctx.timeout,
  648. { HostConfig: { AutoRemove: true } },
  649. ctx.callback
  650. )
  651. })
  652. it('should wait for the container', ctx => {
  653. ctx.getContainer.calledWith(ctx.containerId).should.equal(true)
  654. ctx.container.wait.called.should.equal(true)
  655. })
  656. it('should call the callback with exit code 0', ctx => {
  657. ctx.callback.calledWith(null, 0).should.equal(true)
  658. })
  659. })
  660. })
  661. describe('when the container does not return before the timeout', () => {
  662. beforeEach(async ctx => {
  663. await new Promise((resolve, reject) => {
  664. ctx.container.wait = function (callback) {
  665. if (callback == null) {
  666. callback = function () {}
  667. }
  668. setTimeout(() => callback(null, { StatusCode: 42 }), 100)
  669. }
  670. ctx.timeout = 5
  671. ctx.DockerRunner.waitForContainer(
  672. ctx.containerId,
  673. ctx.timeout,
  674. {},
  675. (...args) => {
  676. ctx.callback(...Array.from(args || []))
  677. resolve()
  678. }
  679. )
  680. })
  681. })
  682. it('should call kill on the container', ctx => {
  683. ctx.getContainer.calledWith(ctx.containerId).should.equal(true)
  684. ctx.container.kill.called.should.equal(true)
  685. })
  686. it('should call the callback with an error', ctx => {
  687. ctx.callback.calledWith(sinon.match(Error)).should.equal(true)
  688. const errorObj = ctx.callback.args[0][0]
  689. expect(errorObj.message).to.include('container timed out')
  690. expect(errorObj.timedout).equal(true)
  691. })
  692. })
  693. })
  694. describe('destroyOldContainers', () => {
  695. beforeEach(async ctx => {
  696. await new Promise((resolve, reject) => {
  697. const oneHourInSeconds = 60 * 60
  698. const oneHourInMilliseconds = oneHourInSeconds * 1000
  699. const nowInSeconds = Date.now() / 1000
  700. ctx.containers = [
  701. {
  702. Name: '/project-old-container-name',
  703. Id: 'old-container-id',
  704. Created: nowInSeconds - oneHourInSeconds - 100,
  705. },
  706. {
  707. Name: '/project-new-container-name',
  708. Id: 'new-container-id',
  709. Created: nowInSeconds - oneHourInSeconds + 100,
  710. },
  711. {
  712. Name: '/totally-not-a-project-container',
  713. Id: 'some-random-id',
  714. Created: nowInSeconds - 2 * oneHourInSeconds,
  715. },
  716. ]
  717. ctx.DockerRunner.MAX_CONTAINER_AGE = oneHourInMilliseconds
  718. ctx.listContainers.callsArgWith(1, null, ctx.containers)
  719. ctx.DockerRunner.destroyContainer = sinon.stub().callsArg(3)
  720. ctx.DockerRunner.destroyOldContainers(error => {
  721. ctx.callback(error)
  722. resolve()
  723. })
  724. })
  725. })
  726. it('should list all containers', ctx => {
  727. ctx.listContainers.calledWith({ all: true }).should.equal(true)
  728. })
  729. it('should destroy old containers', ctx => {
  730. ctx.DockerRunner.destroyContainer.callCount.should.equal(1)
  731. ctx.DockerRunner.destroyContainer
  732. .calledWith('project-old-container-name', 'old-container-id')
  733. .should.equal(true)
  734. })
  735. it('should not destroy new containers', ctx => {
  736. ctx.DockerRunner.destroyContainer
  737. .calledWith('project-new-container-name', 'new-container-id')
  738. .should.equal(false)
  739. })
  740. it('should not destroy non-project containers', ctx => {
  741. ctx.DockerRunner.destroyContainer
  742. .calledWith('totally-not-a-project-container', 'some-random-id')
  743. .should.equal(false)
  744. })
  745. it('should callback the callback', ctx => {
  746. ctx.callback.called.should.equal(true)
  747. })
  748. })
  749. describe('_destroyContainer', () => {
  750. beforeEach(ctx => {
  751. ctx.containerId = 'some_id'
  752. ctx.fakeContainer = { remove: sinon.stub().callsArgWith(1, null) }
  753. ctx.Docker.prototype.getContainer = sinon
  754. .stub()
  755. .returns(ctx.fakeContainer)
  756. })
  757. it('should get the container', async ctx => {
  758. await new Promise((resolve, reject) => {
  759. ctx.DockerRunner._destroyContainer(ctx.containerId, false, err => {
  760. if (err) return reject(err)
  761. ctx.Docker.prototype.getContainer.callCount.should.equal(1)
  762. ctx.Docker.prototype.getContainer
  763. .calledWith(ctx.containerId)
  764. .should.equal(true)
  765. resolve()
  766. })
  767. })
  768. })
  769. it('should try to force-destroy the container when shouldForce=true', async ctx => {
  770. await new Promise((resolve, reject) => {
  771. ctx.DockerRunner._destroyContainer(ctx.containerId, true, err => {
  772. if (err) return reject(err)
  773. ctx.fakeContainer.remove.callCount.should.equal(1)
  774. ctx.fakeContainer.remove
  775. .calledWith({ force: true, v: true })
  776. .should.equal(true)
  777. resolve()
  778. })
  779. })
  780. })
  781. it('should not try to force-destroy the container when shouldForce=false', async ctx => {
  782. await new Promise((resolve, reject) => {
  783. ctx.DockerRunner._destroyContainer(ctx.containerId, false, err => {
  784. if (err) return reject(err)
  785. ctx.fakeContainer.remove.callCount.should.equal(1)
  786. ctx.fakeContainer.remove
  787. .calledWith({ force: false, v: true })
  788. .should.equal(true)
  789. resolve()
  790. })
  791. })
  792. })
  793. it('should not produce an error', async ctx => {
  794. await new Promise((resolve, reject) => {
  795. ctx.DockerRunner._destroyContainer(ctx.containerId, false, err => {
  796. expect(err).to.equal(null)
  797. resolve()
  798. })
  799. })
  800. })
  801. describe('when the container is already gone', () => {
  802. beforeEach(ctx => {
  803. ctx.fakeError = new Error('woops')
  804. ctx.fakeError.statusCode = 404
  805. ctx.fakeContainer = {
  806. remove: sinon.stub().callsArgWith(1, ctx.fakeError),
  807. }
  808. ctx.Docker.prototype.getContainer = sinon
  809. .stub()
  810. .returns(ctx.fakeContainer)
  811. })
  812. it('should not produce an error', async ctx => {
  813. await new Promise((resolve, reject) => {
  814. ctx.DockerRunner._destroyContainer(ctx.containerId, false, err => {
  815. expect(err).to.equal(null)
  816. resolve()
  817. })
  818. })
  819. })
  820. })
  821. describe('when container.destroy produces an error', () => {
  822. beforeEach(ctx => {
  823. ctx.fakeError = new Error('woops')
  824. ctx.fakeError.statusCode = 500
  825. ctx.fakeContainer = {
  826. remove: sinon.stub().callsArgWith(1, ctx.fakeError),
  827. }
  828. ctx.Docker.prototype.getContainer = sinon
  829. .stub()
  830. .returns(ctx.fakeContainer)
  831. })
  832. it('should produce an error', async ctx => {
  833. await new Promise((resolve, reject) => {
  834. ctx.DockerRunner._destroyContainer(ctx.containerId, false, err => {
  835. expect(err).to.not.equal(null)
  836. expect(err).to.equal(ctx.fakeError)
  837. resolve()
  838. })
  839. })
  840. })
  841. })
  842. })
  843. describe('kill', () => {
  844. beforeEach(ctx => {
  845. ctx.containerId = 'some_id'
  846. ctx.fakeContainer = { kill: sinon.stub().callsArgWith(0, null) }
  847. ctx.Docker.prototype.getContainer = sinon
  848. .stub()
  849. .returns(ctx.fakeContainer)
  850. })
  851. it('should get the container', async ctx => {
  852. await new Promise((resolve, reject) => {
  853. ctx.DockerRunner.kill(ctx.containerId, err => {
  854. if (err) return reject(err)
  855. ctx.Docker.prototype.getContainer.callCount.should.equal(1)
  856. ctx.Docker.prototype.getContainer
  857. .calledWith(ctx.containerId)
  858. .should.equal(true)
  859. resolve()
  860. })
  861. })
  862. })
  863. it('should try to force-destroy the container', async ctx => {
  864. await new Promise((resolve, reject) => {
  865. ctx.DockerRunner.kill(ctx.containerId, err => {
  866. if (err) return reject(err)
  867. ctx.fakeContainer.kill.callCount.should.equal(1)
  868. resolve()
  869. })
  870. })
  871. })
  872. it('should not produce an error', async ctx => {
  873. await new Promise((resolve, reject) => {
  874. ctx.DockerRunner.kill(ctx.containerId, err => {
  875. expect(err).to.equal(undefined)
  876. resolve()
  877. })
  878. })
  879. })
  880. describe('when the container is not actually running', () => {
  881. beforeEach(ctx => {
  882. ctx.fakeError = new Error('woops')
  883. ctx.fakeError.statusCode = 500
  884. ctx.fakeError.message =
  885. 'Cannot kill container <whatever> is not running'
  886. ctx.fakeContainer = {
  887. kill: sinon.stub().callsArgWith(0, ctx.fakeError),
  888. }
  889. ctx.Docker.prototype.getContainer = sinon
  890. .stub()
  891. .returns(ctx.fakeContainer)
  892. })
  893. it('should not produce an error', async ctx => {
  894. await new Promise((resolve, reject) => {
  895. ctx.DockerRunner.kill(ctx.containerId, err => {
  896. expect(err).to.equal(undefined)
  897. resolve()
  898. })
  899. })
  900. })
  901. })
  902. describe('when container.kill produces a legitimate error', () => {
  903. beforeEach(ctx => {
  904. ctx.fakeError = new Error('woops')
  905. ctx.fakeError.statusCode = 500
  906. ctx.fakeError.message = 'Totally legitimate reason to throw an error'
  907. ctx.fakeContainer = {
  908. kill: sinon.stub().callsArgWith(0, ctx.fakeError),
  909. }
  910. ctx.Docker.prototype.getContainer = sinon
  911. .stub()
  912. .returns(ctx.fakeContainer)
  913. })
  914. it('should produce an error', async ctx => {
  915. await new Promise((resolve, reject) => {
  916. ctx.DockerRunner.kill(ctx.containerId, err => {
  917. expect(err).to.not.equal(undefined)
  918. expect(err).to.equal(ctx.fakeError)
  919. resolve()
  920. })
  921. })
  922. })
  923. })
  924. })
  925. })