DockerRunner.js 21 KB

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