DockerRunner.test.js 34 KB

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