DockerRunner.test.js 32 KB

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