DockerRunner.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. /* eslint-disable
  2. camelcase,
  3. handle-callback-err,
  4. no-return-assign,
  5. no-unused-vars,
  6. */
  7. // TODO: This file was created by bulk-decaffeinate.
  8. // Fix any style issues and re-enable lint.
  9. /*
  10. * decaffeinate suggestions:
  11. * DS101: Remove unnecessary use of Array.from
  12. * DS102: Remove unnecessary code created because of implicit returns
  13. * DS103: Rewrite code to no longer use __guard__
  14. * DS205: Consider reworking code to avoid use of IIFEs
  15. * DS207: Consider shorter variations of null checks
  16. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  17. */
  18. let DockerRunner, oneHour
  19. const Settings = require('settings-sharelatex')
  20. const logger = require('logger-sharelatex')
  21. const Docker = require('dockerode')
  22. const dockerode = new Docker()
  23. const crypto = require('crypto')
  24. const async = require('async')
  25. const LockManager = require('./DockerLockManager')
  26. const fs = require('fs')
  27. const Path = require('path')
  28. const _ = require('underscore')
  29. logger.info('using docker runner')
  30. const usingSiblingContainers = () =>
  31. __guard__(
  32. Settings != null ? Settings.path : undefined,
  33. x => x.sandboxedCompilesHostDir
  34. ) != null
  35. module.exports = DockerRunner = {
  36. ERR_NOT_DIRECTORY: new Error('not a directory'),
  37. ERR_TERMINATED: new Error('terminated'),
  38. ERR_EXITED: new Error('exited'),
  39. ERR_TIMED_OUT: new Error('container timed out'),
  40. run(project_id, command, directory, image, timeout, environment, callback) {
  41. let name
  42. if (callback == null) {
  43. callback = function(error, output) {}
  44. }
  45. if (usingSiblingContainers()) {
  46. const _newPath = Settings.path.sandboxedCompilesHostDir
  47. logger.log(
  48. { path: _newPath },
  49. 'altering bind path for sibling containers'
  50. )
  51. // Server Pro, example:
  52. // '/var/lib/sharelatex/data/compiles/<project-id>'
  53. // ... becomes ...
  54. // '/opt/sharelatex_data/data/compiles/<project-id>'
  55. directory = Path.join(
  56. Settings.path.sandboxedCompilesHostDir,
  57. Path.basename(directory)
  58. )
  59. }
  60. const volumes = {}
  61. volumes[directory] = '/compile'
  62. command = Array.from(command).map(arg =>
  63. __guardMethod__(arg.toString(), 'replace', o =>
  64. o.replace('$COMPILE_DIR', '/compile')
  65. )
  66. )
  67. if (image == null) {
  68. ;({ image } = Settings.clsi.docker)
  69. }
  70. if (Settings.texliveImageNameOveride != null) {
  71. const img = image.split('/')
  72. image = `${Settings.texliveImageNameOveride}/${img[2]}`
  73. }
  74. const options = DockerRunner._getContainerOptions(
  75. command,
  76. image,
  77. volumes,
  78. timeout,
  79. environment
  80. )
  81. const fingerprint = DockerRunner._fingerprintContainer(options)
  82. options.name = name = `project-${project_id}-${fingerprint}`
  83. // logOptions = _.clone(options)
  84. // logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
  85. logger.log({ project_id }, 'running docker container')
  86. DockerRunner._runAndWaitForContainer(options, volumes, timeout, function(
  87. error,
  88. output
  89. ) {
  90. if (
  91. __guard__(error != null ? error.message : undefined, x =>
  92. x.match('HTTP code is 500')
  93. )
  94. ) {
  95. logger.log(
  96. { err: error, project_id },
  97. 'error running container so destroying and retrying'
  98. )
  99. return DockerRunner.destroyContainer(name, null, true, function(error) {
  100. if (error != null) {
  101. return callback(error)
  102. }
  103. return DockerRunner._runAndWaitForContainer(
  104. options,
  105. volumes,
  106. timeout,
  107. callback
  108. )
  109. })
  110. } else {
  111. return callback(error, output)
  112. }
  113. })
  114. return name
  115. }, // pass back the container name to allow it to be killed
  116. kill(container_id, callback) {
  117. if (callback == null) {
  118. callback = function(error) {}
  119. }
  120. logger.log({ container_id }, 'sending kill signal to container')
  121. const container = dockerode.getContainer(container_id)
  122. return container.kill(function(error) {
  123. if (
  124. error != null &&
  125. __guardMethod__(error != null ? error.message : undefined, 'match', o =>
  126. o.match(/Cannot kill container .* is not running/)
  127. )
  128. ) {
  129. logger.warn(
  130. { err: error, container_id },
  131. 'container not running, continuing'
  132. )
  133. error = null
  134. }
  135. if (error != null) {
  136. logger.error({ err: error, container_id }, 'error killing container')
  137. return callback(error)
  138. } else {
  139. return callback()
  140. }
  141. })
  142. },
  143. _runAndWaitForContainer(options, volumes, timeout, _callback) {
  144. if (_callback == null) {
  145. _callback = function(error, output) {}
  146. }
  147. const callback = function(...args) {
  148. _callback(...Array.from(args || []))
  149. // Only call the callback once
  150. return (_callback = function() {})
  151. }
  152. const { name } = options
  153. let streamEnded = false
  154. let containerReturned = false
  155. let output = {}
  156. const callbackIfFinished = function() {
  157. if (streamEnded && containerReturned) {
  158. return callback(null, output)
  159. }
  160. }
  161. const attachStreamHandler = function(error, _output) {
  162. if (error != null) {
  163. return callback(error)
  164. }
  165. output = _output
  166. streamEnded = true
  167. return callbackIfFinished()
  168. }
  169. return DockerRunner.startContainer(
  170. options,
  171. volumes,
  172. attachStreamHandler,
  173. function(error, containerId) {
  174. if (error != null) {
  175. return callback(error)
  176. }
  177. return DockerRunner.waitForContainer(name, timeout, function(
  178. error,
  179. exitCode
  180. ) {
  181. let err
  182. if (error != null) {
  183. return callback(error)
  184. }
  185. if (exitCode === 137) {
  186. // exit status from kill -9
  187. err = DockerRunner.ERR_TERMINATED
  188. err.terminated = true
  189. return callback(err)
  190. }
  191. if (exitCode === 1) {
  192. // exit status from chktex
  193. err = DockerRunner.ERR_EXITED
  194. err.code = exitCode
  195. return callback(err)
  196. }
  197. containerReturned = true
  198. __guard__(
  199. options != null ? options.HostConfig : undefined,
  200. x => (x.SecurityOpt = null)
  201. ) // small log line
  202. logger.log({ err, exitCode, options }, 'docker container has exited')
  203. return callbackIfFinished()
  204. })
  205. }
  206. )
  207. },
  208. _getContainerOptions(command, image, volumes, timeout, environment) {
  209. let m, year
  210. let key, value, hostVol, dockerVol
  211. const timeoutInSeconds = timeout / 1000
  212. const dockerVolumes = {}
  213. for (hostVol in volumes) {
  214. dockerVol = volumes[hostVol]
  215. dockerVolumes[dockerVol] = {}
  216. if (volumes[hostVol].slice(-3).indexOf(':r') === -1) {
  217. volumes[hostVol] = `${dockerVol}:rw`
  218. }
  219. }
  220. // merge settings and environment parameter
  221. const env = {}
  222. for (const src of [Settings.clsi.docker.env, environment || {}]) {
  223. for (key in src) {
  224. value = src[key]
  225. env[key] = value
  226. }
  227. }
  228. // set the path based on the image year
  229. if ((m = image.match(/:([0-9]+)\.[0-9]+/))) {
  230. year = m[1]
  231. } else {
  232. year = '2014'
  233. }
  234. env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/`
  235. const options = {
  236. Cmd: command,
  237. Image: image,
  238. Volumes: dockerVolumes,
  239. WorkingDir: '/compile',
  240. NetworkDisabled: true,
  241. Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
  242. User: Settings.clsi.docker.user,
  243. Env: (() => {
  244. const result = []
  245. for (key in env) {
  246. value = env[key]
  247. result.push(`${key}=${value}`)
  248. }
  249. return result
  250. })(), // convert the environment hash to an array
  251. HostConfig: {
  252. Binds: (() => {
  253. const result1 = []
  254. for (hostVol in volumes) {
  255. dockerVol = volumes[hostVol]
  256. result1.push(`${hostVol}:${dockerVol}`)
  257. }
  258. return result1
  259. })(),
  260. LogConfig: { Type: 'none', Config: {} },
  261. Ulimits: [
  262. {
  263. Name: 'cpu',
  264. Soft: timeoutInSeconds + 5,
  265. Hard: timeoutInSeconds + 10
  266. }
  267. ],
  268. CapDrop: 'ALL',
  269. SecurityOpt: ['no-new-privileges']
  270. }
  271. }
  272. if (
  273. (Settings.path != null ? Settings.path.synctexBinHostPath : undefined) !=
  274. null
  275. ) {
  276. options.HostConfig.Binds.push(
  277. `${Settings.path.synctexBinHostPath}:/opt/synctex:ro`
  278. )
  279. }
  280. if (Settings.clsi.docker.seccomp_profile != null) {
  281. options.HostConfig.SecurityOpt.push(
  282. `seccomp=${Settings.clsi.docker.seccomp_profile}`
  283. )
  284. }
  285. return options
  286. },
  287. _fingerprintContainer(containerOptions) {
  288. // Yay, Hashing!
  289. const json = JSON.stringify(containerOptions)
  290. return crypto
  291. .createHash('md5')
  292. .update(json)
  293. .digest('hex')
  294. },
  295. startContainer(options, volumes, attachStreamHandler, callback) {
  296. return LockManager.runWithLock(
  297. options.name,
  298. releaseLock =>
  299. // Check that volumes exist before starting the container.
  300. // When a container is started with volume pointing to a
  301. // non-existent directory then docker creates the directory but
  302. // with root ownership.
  303. DockerRunner._checkVolumes(options, volumes, function(err) {
  304. if (err != null) {
  305. return releaseLock(err)
  306. }
  307. return DockerRunner._startContainer(
  308. options,
  309. volumes,
  310. attachStreamHandler,
  311. releaseLock
  312. )
  313. }),
  314. callback
  315. )
  316. },
  317. // Check that volumes exist and are directories
  318. _checkVolumes(options, volumes, callback) {
  319. if (callback == null) {
  320. callback = function(error, containerName) {}
  321. }
  322. if (usingSiblingContainers()) {
  323. // Server Pro, with sibling-containers active, skip checks
  324. return callback(null)
  325. }
  326. const checkVolume = (path, cb) =>
  327. fs.stat(path, function(err, stats) {
  328. if (err != null) {
  329. return cb(err)
  330. }
  331. if (!(stats != null ? stats.isDirectory() : undefined)) {
  332. return cb(DockerRunner.ERR_NOT_DIRECTORY)
  333. }
  334. return cb()
  335. })
  336. const jobs = []
  337. for (const vol in volumes) {
  338. ;(vol => jobs.push(cb => checkVolume(vol, cb)))(vol)
  339. }
  340. return async.series(jobs, callback)
  341. },
  342. _startContainer(options, volumes, attachStreamHandler, callback) {
  343. if (callback == null) {
  344. callback = function(error, output) {}
  345. }
  346. callback = _.once(callback)
  347. const { name } = options
  348. logger.log({ container_name: name }, 'starting container')
  349. const container = dockerode.getContainer(name)
  350. const createAndStartContainer = () =>
  351. dockerode.createContainer(options, function(error, container) {
  352. if (error != null) {
  353. return callback(error)
  354. }
  355. return startExistingContainer()
  356. })
  357. var startExistingContainer = () =>
  358. DockerRunner.attachToContainer(
  359. options.name,
  360. attachStreamHandler,
  361. function(error) {
  362. if (error != null) {
  363. return callback(error)
  364. }
  365. return container.start(function(error) {
  366. if (
  367. error != null &&
  368. (error != null ? error.statusCode : undefined) !== 304
  369. ) {
  370. // already running
  371. return callback(error)
  372. } else {
  373. return callback()
  374. }
  375. })
  376. }
  377. )
  378. return container.inspect(function(error, stats) {
  379. if ((error != null ? error.statusCode : undefined) === 404) {
  380. return createAndStartContainer()
  381. } else if (error != null) {
  382. logger.err(
  383. { container_name: name, error },
  384. 'unable to inspect container to start'
  385. )
  386. return callback(error)
  387. } else {
  388. return startExistingContainer()
  389. }
  390. })
  391. },
  392. attachToContainer(containerId, attachStreamHandler, attachStartCallback) {
  393. const container = dockerode.getContainer(containerId)
  394. return container.attach({ stdout: 1, stderr: 1, stream: 1 }, function(
  395. error,
  396. stream
  397. ) {
  398. if (error != null) {
  399. logger.error(
  400. { err: error, container_id: containerId },
  401. 'error attaching to container'
  402. )
  403. return attachStartCallback(error)
  404. } else {
  405. attachStartCallback()
  406. }
  407. logger.log({ container_id: containerId }, 'attached to container')
  408. const MAX_OUTPUT = 1024 * 1024 // limit output to 1MB
  409. const createStringOutputStream = function(name) {
  410. return {
  411. data: '',
  412. overflowed: false,
  413. write(data) {
  414. if (this.overflowed) {
  415. return
  416. }
  417. if (this.data.length < MAX_OUTPUT) {
  418. return (this.data += data)
  419. } else {
  420. logger.error(
  421. {
  422. container_id: containerId,
  423. length: this.data.length,
  424. maxLen: MAX_OUTPUT
  425. },
  426. `${name} exceeds max size`
  427. )
  428. this.data += `(...truncated at ${MAX_OUTPUT} chars...)`
  429. return (this.overflowed = true)
  430. }
  431. }
  432. // kill container if too much output
  433. // docker.containers.kill(containerId, () ->)
  434. }
  435. }
  436. const stdout = createStringOutputStream('stdout')
  437. const stderr = createStringOutputStream('stderr')
  438. container.modem.demuxStream(stream, stdout, stderr)
  439. stream.on('error', err =>
  440. logger.error(
  441. { err, container_id: containerId },
  442. 'error reading from container stream'
  443. )
  444. )
  445. return stream.on('end', () =>
  446. attachStreamHandler(null, { stdout: stdout.data, stderr: stderr.data })
  447. )
  448. })
  449. },
  450. waitForContainer(containerId, timeout, _callback) {
  451. if (_callback == null) {
  452. _callback = function(error, exitCode) {}
  453. }
  454. const callback = function(...args) {
  455. _callback(...Array.from(args || []))
  456. // Only call the callback once
  457. return (_callback = function() {})
  458. }
  459. const container = dockerode.getContainer(containerId)
  460. let timedOut = false
  461. const timeoutId = setTimeout(function() {
  462. timedOut = true
  463. logger.log(
  464. { container_id: containerId },
  465. 'timeout reached, killing container'
  466. )
  467. return container.kill(function() {})
  468. }, timeout)
  469. logger.log({ container_id: containerId }, 'waiting for docker container')
  470. return container.wait(function(error, res) {
  471. if (error != null) {
  472. clearTimeout(timeoutId)
  473. logger.error(
  474. { err: error, container_id: containerId },
  475. 'error waiting for container'
  476. )
  477. return callback(error)
  478. }
  479. if (timedOut) {
  480. logger.log({ containerId }, 'docker container timed out')
  481. error = DockerRunner.ERR_TIMED_OUT
  482. error.timedout = true
  483. return callback(error)
  484. } else {
  485. clearTimeout(timeoutId)
  486. logger.log(
  487. { container_id: containerId, exitCode: res.StatusCode },
  488. 'docker container returned'
  489. )
  490. return callback(null, res.StatusCode)
  491. }
  492. })
  493. },
  494. destroyContainer(containerName, containerId, shouldForce, callback) {
  495. // We want the containerName for the lock and, ideally, the
  496. // containerId to delete. There is a bug in the docker.io module
  497. // where if you delete by name and there is an error, it throws an
  498. // async exception, but if you delete by id it just does a normal
  499. // error callback. We fall back to deleting by name if no id is
  500. // supplied.
  501. if (callback == null) {
  502. callback = function(error) {}
  503. }
  504. return LockManager.runWithLock(
  505. containerName,
  506. releaseLock =>
  507. DockerRunner._destroyContainer(
  508. containerId || containerName,
  509. shouldForce,
  510. releaseLock
  511. ),
  512. callback
  513. )
  514. },
  515. _destroyContainer(containerId, shouldForce, callback) {
  516. if (callback == null) {
  517. callback = function(error) {}
  518. }
  519. logger.log({ container_id: containerId }, 'destroying docker container')
  520. const container = dockerode.getContainer(containerId)
  521. return container.remove({ force: shouldForce === true }, function(error) {
  522. if (
  523. error != null &&
  524. (error != null ? error.statusCode : undefined) === 404
  525. ) {
  526. logger.warn(
  527. { err: error, container_id: containerId },
  528. 'container not found, continuing'
  529. )
  530. error = null
  531. }
  532. if (error != null) {
  533. logger.error(
  534. { err: error, container_id: containerId },
  535. 'error destroying container'
  536. )
  537. } else {
  538. logger.log({ container_id: containerId }, 'destroyed container')
  539. }
  540. return callback(error)
  541. })
  542. },
  543. // handle expiry of docker containers
  544. MAX_CONTAINER_AGE:
  545. Settings.clsi.docker.maxContainerAge || (oneHour = 60 * 60 * 1000),
  546. examineOldContainer(container, callback) {
  547. if (callback == null) {
  548. callback = function(error, name, id, ttl) {}
  549. }
  550. const name =
  551. container.Name ||
  552. (container.Names != null ? container.Names[0] : undefined)
  553. const created = container.Created * 1000 // creation time is returned in seconds
  554. const now = Date.now()
  555. const age = now - created
  556. const maxAge = DockerRunner.MAX_CONTAINER_AGE
  557. const ttl = maxAge - age
  558. logger.log(
  559. { containerName: name, created, now, age, maxAge, ttl },
  560. 'checking whether to destroy container'
  561. )
  562. return callback(null, name, container.Id, ttl)
  563. },
  564. destroyOldContainers(callback) {
  565. if (callback == null) {
  566. callback = function(error) {}
  567. }
  568. return dockerode.listContainers({ all: true }, function(error, containers) {
  569. if (error != null) {
  570. return callback(error)
  571. }
  572. const jobs = []
  573. for (const container of Array.from(containers || [])) {
  574. ;(container =>
  575. DockerRunner.examineOldContainer(container, function(
  576. err,
  577. name,
  578. id,
  579. ttl
  580. ) {
  581. if (name.slice(0, 9) === '/project-' && ttl <= 0) {
  582. return jobs.push(cb =>
  583. DockerRunner.destroyContainer(name, id, false, () => cb())
  584. )
  585. }
  586. }))(container)
  587. }
  588. // Ignore errors because some containers get stuck but
  589. // will be destroyed next time
  590. return async.series(jobs, callback)
  591. })
  592. },
  593. startContainerMonitor() {
  594. logger.log(
  595. { maxAge: DockerRunner.MAX_CONTAINER_AGE },
  596. 'starting container expiry'
  597. )
  598. // randomise the start time
  599. const randomDelay = Math.floor(Math.random() * 5 * 60 * 1000)
  600. return setTimeout(
  601. () =>
  602. setInterval(
  603. () => DockerRunner.destroyOldContainers(),
  604. (oneHour = 60 * 60 * 1000)
  605. ),
  606. randomDelay
  607. )
  608. }
  609. }
  610. DockerRunner.startContainerMonitor()
  611. function __guard__(value, transform) {
  612. return typeof value !== 'undefined' && value !== null
  613. ? transform(value)
  614. : undefined
  615. }
  616. function __guardMethod__(obj, methodName, transform) {
  617. if (
  618. typeof obj !== 'undefined' &&
  619. obj !== null &&
  620. typeof obj[methodName] === 'function'
  621. ) {
  622. return transform(obj, methodName)
  623. } else {
  624. return undefined
  625. }
  626. }