| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743 |
- /* eslint-disable
- camelcase,
- handle-callback-err,
- no-return-assign,
- no-unused-vars,
- */
- // TODO: This file was created by bulk-decaffeinate.
- // Fix any style issues and re-enable lint.
- /*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * DS103: Rewrite code to no longer use __guard__
- * DS205: Consider reworking code to avoid use of IIFEs
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
- let DockerRunner, oneHour
- const Settings = require('settings-sharelatex')
- const logger = require('logger-sharelatex')
- const Docker = require('dockerode')
- const dockerode = new Docker()
- const crypto = require('crypto')
- const async = require('async')
- const LockManager = require('./DockerLockManager')
- const fs = require('fs')
- const Path = require('path')
- const _ = require('lodash')
- logger.info('using docker runner')
- const usingSiblingContainers = () =>
- __guard__(
- Settings != null ? Settings.path : undefined,
- (x) => x.sandboxedCompilesHostDir
- ) != null
- let containerMonitorTimeout
- let containerMonitorInterval
- module.exports = DockerRunner = {
- ERR_NOT_DIRECTORY: new Error('not a directory'),
- ERR_TERMINATED: new Error('terminated'),
- ERR_EXITED: new Error('exited'),
- ERR_TIMED_OUT: new Error('container timed out'),
- run(
- project_id,
- command,
- directory,
- image,
- timeout,
- environment,
- compileGroup,
- callback
- ) {
- let name
- if (callback == null) {
- callback = function (error, output) {}
- }
- if (usingSiblingContainers()) {
- const _newPath = Settings.path.sandboxedCompilesHostDir
- logger.log(
- { path: _newPath },
- 'altering bind path for sibling containers'
- )
- // Server Pro, example:
- // '/var/lib/sharelatex/data/compiles/<project-id>'
- // ... becomes ...
- // '/opt/sharelatex_data/data/compiles/<project-id>'
- directory = Path.join(
- Settings.path.sandboxedCompilesHostDir,
- Path.basename(directory)
- )
- }
- const volumes = {}
- volumes[directory] = '/compile'
- command = Array.from(command).map((arg) =>
- __guardMethod__(arg.toString(), 'replace', (o) =>
- o.replace('$COMPILE_DIR', '/compile')
- )
- )
- if (image == null) {
- ;({ image } = Settings.clsi.docker)
- }
- if (
- Settings.clsi.docker.allowedImages &&
- !Settings.clsi.docker.allowedImages.includes(image)
- ) {
- return callback(new Error('image not allowed'))
- }
- if (Settings.texliveImageNameOveride != null) {
- const img = image.split('/')
- image = `${Settings.texliveImageNameOveride}/${img[2]}`
- }
- const options = DockerRunner._getContainerOptions(
- command,
- image,
- volumes,
- timeout,
- environment,
- compileGroup
- )
- const fingerprint = DockerRunner._fingerprintContainer(options)
- options.name = name = `project-${project_id}-${fingerprint}`
- // logOptions = _.clone(options)
- // logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
- logger.log({ project_id }, 'running docker container')
- DockerRunner._runAndWaitForContainer(options, volumes, timeout, function (
- error,
- output
- ) {
- if (error && error.statusCode === 500) {
- logger.log(
- { err: error, project_id },
- 'error running container so destroying and retrying'
- )
- return DockerRunner.destroyContainer(name, null, true, function (
- error
- ) {
- if (error != null) {
- return callback(error)
- }
- return DockerRunner._runAndWaitForContainer(
- options,
- volumes,
- timeout,
- callback
- )
- })
- } else {
- return callback(error, output)
- }
- })
- return name
- }, // pass back the container name to allow it to be killed
- kill(container_id, callback) {
- if (callback == null) {
- callback = function (error) {}
- }
- logger.log({ container_id }, 'sending kill signal to container')
- const container = dockerode.getContainer(container_id)
- return container.kill(function (error) {
- if (
- error != null &&
- __guardMethod__(
- error != null ? error.message : undefined,
- 'match',
- (o) => o.match(/Cannot kill container .* is not running/)
- )
- ) {
- logger.warn(
- { err: error, container_id },
- 'container not running, continuing'
- )
- error = null
- }
- if (error != null) {
- logger.error({ err: error, container_id }, 'error killing container')
- return callback(error)
- } else {
- return callback()
- }
- })
- },
- _runAndWaitForContainer(options, volumes, timeout, _callback) {
- if (_callback == null) {
- _callback = function (error, output) {}
- }
- const callback = function (...args) {
- _callback(...Array.from(args || []))
- // Only call the callback once
- return (_callback = function () {})
- }
- const { name } = options
- let streamEnded = false
- let containerReturned = false
- let output = {}
- const callbackIfFinished = function () {
- if (streamEnded && containerReturned) {
- return callback(null, output)
- }
- }
- const attachStreamHandler = function (error, _output) {
- if (error != null) {
- return callback(error)
- }
- output = _output
- streamEnded = true
- return callbackIfFinished()
- }
- return DockerRunner.startContainer(
- options,
- volumes,
- attachStreamHandler,
- function (error, containerId) {
- if (error != null) {
- return callback(error)
- }
- return DockerRunner.waitForContainer(name, timeout, function (
- error,
- exitCode
- ) {
- let err
- if (error != null) {
- return callback(error)
- }
- if (exitCode === 137) {
- // exit status from kill -9
- err = DockerRunner.ERR_TERMINATED
- err.terminated = true
- return callback(err)
- }
- if (exitCode === 1) {
- // exit status from chktex
- err = DockerRunner.ERR_EXITED
- err.code = exitCode
- return callback(err)
- }
- containerReturned = true
- __guard__(
- options != null ? options.HostConfig : undefined,
- (x) => (x.SecurityOpt = null)
- ) // small log line
- logger.log({ err, exitCode, options }, 'docker container has exited')
- return callbackIfFinished()
- })
- }
- )
- },
- _getContainerOptions(
- command,
- image,
- volumes,
- timeout,
- environment,
- compileGroup
- ) {
- let m, year
- let key, value, hostVol, dockerVol
- const timeoutInSeconds = timeout / 1000
- const dockerVolumes = {}
- for (hostVol in volumes) {
- dockerVol = volumes[hostVol]
- dockerVolumes[dockerVol] = {}
- if (volumes[hostVol].slice(-3).indexOf(':r') === -1) {
- volumes[hostVol] = `${dockerVol}:rw`
- }
- }
- // merge settings and environment parameter
- const env = {}
- for (const src of [Settings.clsi.docker.env, environment || {}]) {
- for (key in src) {
- value = src[key]
- env[key] = value
- }
- }
- // set the path based on the image year
- if ((m = image.match(/:([0-9]+)\.[0-9]+/))) {
- year = m[1]
- } else {
- year = '2014'
- }
- env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/`
- const options = {
- Cmd: command,
- Image: image,
- Volumes: dockerVolumes,
- WorkingDir: '/compile',
- NetworkDisabled: true,
- Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
- User: Settings.clsi.docker.user,
- Env: (() => {
- const result = []
- for (key in env) {
- value = env[key]
- result.push(`${key}=${value}`)
- }
- return result
- })(), // convert the environment hash to an array
- HostConfig: {
- Binds: (() => {
- const result1 = []
- for (hostVol in volumes) {
- dockerVol = volumes[hostVol]
- result1.push(`${hostVol}:${dockerVol}`)
- }
- return result1
- })(),
- LogConfig: { Type: 'none', Config: {} },
- Ulimits: [
- {
- Name: 'cpu',
- Soft: timeoutInSeconds + 5,
- Hard: timeoutInSeconds + 10
- }
- ],
- CapDrop: 'ALL',
- SecurityOpt: ['no-new-privileges']
- }
- }
- if (
- (Settings.path != null ? Settings.path.synctexBinHostPath : undefined) !=
- null
- ) {
- options.HostConfig.Binds.push(
- `${Settings.path.synctexBinHostPath}:/opt/synctex:ro`
- )
- }
- if (Settings.clsi.docker.seccomp_profile != null) {
- options.HostConfig.SecurityOpt.push(
- `seccomp=${Settings.clsi.docker.seccomp_profile}`
- )
- }
- if (Settings.clsi.docker.runtime) {
- options.HostConfig.Runtime = Settings.clsi.docker.runtime
- }
- if (Settings.clsi.docker.Readonly) {
- options.HostConfig.ReadonlyRootfs = true
- options.HostConfig.Tmpfs = { '/tmp': 'rw,noexec,nosuid,size=65536k' }
- }
- // Allow per-compile group overriding of individual settings
- if (
- Settings.clsi.docker.compileGroupConfig &&
- Settings.clsi.docker.compileGroupConfig[compileGroup]
- ) {
- const override = Settings.clsi.docker.compileGroupConfig[compileGroup]
- let key
- for (key in override) {
- _.set(options, key, override[key])
- }
- }
- return options
- },
- _fingerprintContainer(containerOptions) {
- // Yay, Hashing!
- const json = JSON.stringify(containerOptions)
- return crypto.createHash('md5').update(json).digest('hex')
- },
- startContainer(options, volumes, attachStreamHandler, callback) {
- return LockManager.runWithLock(
- options.name,
- (releaseLock) =>
- // Check that volumes exist before starting the container.
- // When a container is started with volume pointing to a
- // non-existent directory then docker creates the directory but
- // with root ownership.
- DockerRunner._checkVolumes(options, volumes, function (err) {
- if (err != null) {
- return releaseLock(err)
- }
- return DockerRunner._startContainer(
- options,
- volumes,
- attachStreamHandler,
- releaseLock
- )
- }),
- callback
- )
- },
- // Check that volumes exist and are directories
- _checkVolumes(options, volumes, callback) {
- if (callback == null) {
- callback = function (error, containerName) {}
- }
- if (usingSiblingContainers()) {
- // Server Pro, with sibling-containers active, skip checks
- return callback(null)
- }
- const checkVolume = (path, cb) =>
- fs.stat(path, function (err, stats) {
- if (err != null) {
- return cb(err)
- }
- if (!(stats != null ? stats.isDirectory() : undefined)) {
- return cb(DockerRunner.ERR_NOT_DIRECTORY)
- }
- return cb()
- })
- const jobs = []
- for (const vol in volumes) {
- ;((vol) => jobs.push((cb) => checkVolume(vol, cb)))(vol)
- }
- return async.series(jobs, callback)
- },
- _startContainer(options, volumes, attachStreamHandler, callback) {
- if (callback == null) {
- callback = function (error, output) {}
- }
- callback = _.once(callback)
- const { name } = options
- logger.log({ container_name: name }, 'starting container')
- const container = dockerode.getContainer(name)
- const createAndStartContainer = () =>
- dockerode.createContainer(options, function (error, container) {
- if (error != null) {
- return callback(error)
- }
- return startExistingContainer()
- })
- var startExistingContainer = () =>
- DockerRunner.attachToContainer(
- options.name,
- attachStreamHandler,
- function (error) {
- if (error != null) {
- return callback(error)
- }
- return container.start(function (error) {
- if (
- error != null &&
- (error != null ? error.statusCode : undefined) !== 304
- ) {
- // already running
- return callback(error)
- } else {
- return callback()
- }
- })
- }
- )
- return container.inspect(function (error, stats) {
- if ((error != null ? error.statusCode : undefined) === 404) {
- return createAndStartContainer()
- } else if (error != null) {
- logger.err(
- { container_name: name, error },
- 'unable to inspect container to start'
- )
- return callback(error)
- } else {
- return startExistingContainer()
- }
- })
- },
- attachToContainer(containerId, attachStreamHandler, attachStartCallback) {
- const container = dockerode.getContainer(containerId)
- return container.attach({ stdout: 1, stderr: 1, stream: 1 }, function (
- error,
- stream
- ) {
- if (error != null) {
- logger.error(
- { err: error, container_id: containerId },
- 'error attaching to container'
- )
- return attachStartCallback(error)
- } else {
- attachStartCallback()
- }
- logger.log({ container_id: containerId }, 'attached to container')
- const MAX_OUTPUT = 1024 * 1024 // limit output to 1MB
- const createStringOutputStream = function (name) {
- return {
- data: '',
- overflowed: false,
- write(data) {
- if (this.overflowed) {
- return
- }
- if (this.data.length < MAX_OUTPUT) {
- return (this.data += data)
- } else {
- logger.error(
- {
- container_id: containerId,
- length: this.data.length,
- maxLen: MAX_OUTPUT
- },
- `${name} exceeds max size`
- )
- this.data += `(...truncated at ${MAX_OUTPUT} chars...)`
- return (this.overflowed = true)
- }
- }
- // kill container if too much output
- // docker.containers.kill(containerId, () ->)
- }
- }
- const stdout = createStringOutputStream('stdout')
- const stderr = createStringOutputStream('stderr')
- container.modem.demuxStream(stream, stdout, stderr)
- stream.on('error', (err) =>
- logger.error(
- { err, container_id: containerId },
- 'error reading from container stream'
- )
- )
- return stream.on('end', () =>
- attachStreamHandler(null, { stdout: stdout.data, stderr: stderr.data })
- )
- })
- },
- waitForContainer(containerId, timeout, _callback) {
- if (_callback == null) {
- _callback = function (error, exitCode) {}
- }
- const callback = function (...args) {
- _callback(...Array.from(args || []))
- // Only call the callback once
- return (_callback = function () {})
- }
- const container = dockerode.getContainer(containerId)
- let timedOut = false
- const timeoutId = setTimeout(function () {
- timedOut = true
- logger.log(
- { container_id: containerId },
- 'timeout reached, killing container'
- )
- return container.kill(function () {})
- }, timeout)
- logger.log({ container_id: containerId }, 'waiting for docker container')
- return container.wait(function (error, res) {
- if (error != null) {
- clearTimeout(timeoutId)
- logger.error(
- { err: error, container_id: containerId },
- 'error waiting for container'
- )
- return callback(error)
- }
- if (timedOut) {
- logger.log({ containerId }, 'docker container timed out')
- error = DockerRunner.ERR_TIMED_OUT
- error.timedout = true
- return callback(error)
- } else {
- clearTimeout(timeoutId)
- logger.log(
- { container_id: containerId, exitCode: res.StatusCode },
- 'docker container returned'
- )
- return callback(null, res.StatusCode)
- }
- })
- },
- destroyContainer(containerName, containerId, shouldForce, callback) {
- // We want the containerName for the lock and, ideally, the
- // containerId to delete. There is a bug in the docker.io module
- // where if you delete by name and there is an error, it throws an
- // async exception, but if you delete by id it just does a normal
- // error callback. We fall back to deleting by name if no id is
- // supplied.
- if (callback == null) {
- callback = function (error) {}
- }
- return LockManager.runWithLock(
- containerName,
- (releaseLock) =>
- DockerRunner._destroyContainer(
- containerId || containerName,
- shouldForce,
- releaseLock
- ),
- callback
- )
- },
- _destroyContainer(containerId, shouldForce, callback) {
- if (callback == null) {
- callback = function (error) {}
- }
- logger.log({ container_id: containerId }, 'destroying docker container')
- const container = dockerode.getContainer(containerId)
- return container.remove({ force: shouldForce === true }, function (error) {
- if (
- error != null &&
- (error != null ? error.statusCode : undefined) === 404
- ) {
- logger.warn(
- { err: error, container_id: containerId },
- 'container not found, continuing'
- )
- error = null
- }
- if (error != null) {
- logger.error(
- { err: error, container_id: containerId },
- 'error destroying container'
- )
- } else {
- logger.log({ container_id: containerId }, 'destroyed container')
- }
- return callback(error)
- })
- },
- // handle expiry of docker containers
- MAX_CONTAINER_AGE:
- Settings.clsi.docker.maxContainerAge || (oneHour = 60 * 60 * 1000),
- examineOldContainer(container, callback) {
- if (callback == null) {
- callback = function (error, name, id, ttl) {}
- }
- const name =
- container.Name ||
- (container.Names != null ? container.Names[0] : undefined)
- const created = container.Created * 1000 // creation time is returned in seconds
- const now = Date.now()
- const age = now - created
- const maxAge = DockerRunner.MAX_CONTAINER_AGE
- const ttl = maxAge - age
- logger.log(
- { containerName: name, created, now, age, maxAge, ttl },
- 'checking whether to destroy container'
- )
- return callback(null, name, container.Id, ttl)
- },
- destroyOldContainers(callback) {
- if (callback == null) {
- callback = function (error) {}
- }
- return dockerode.listContainers({ all: true }, function (
- error,
- containers
- ) {
- if (error != null) {
- return callback(error)
- }
- const jobs = []
- for (const container of Array.from(containers || [])) {
- ;((container) =>
- DockerRunner.examineOldContainer(container, function (
- err,
- name,
- id,
- ttl
- ) {
- if (name.slice(0, 9) === '/project-' && ttl <= 0) {
- // strip the / prefix
- // the LockManager uses the plain container name
- name = name.slice(1)
- return jobs.push((cb) =>
- DockerRunner.destroyContainer(name, id, false, () => cb())
- )
- }
- }))(container)
- }
- // Ignore errors because some containers get stuck but
- // will be destroyed next time
- return async.series(jobs, callback)
- })
- },
- startContainerMonitor() {
- logger.log(
- { maxAge: DockerRunner.MAX_CONTAINER_AGE },
- 'starting container expiry'
- )
- // guarantee only one monitor is running
- DockerRunner.stopContainerMonitor()
- // randomise the start time
- const randomDelay = Math.floor(Math.random() * 5 * 60 * 1000)
- containerMonitorTimeout = setTimeout(() => {
- containerMonitorInterval = setInterval(
- () => DockerRunner.destroyOldContainers(),
- (oneHour = 60 * 60 * 1000)
- )
- }, randomDelay)
- },
- stopContainerMonitor() {
- if (containerMonitorTimeout) {
- clearTimeout(containerMonitorTimeout)
- containerMonitorTimeout = undefined
- }
- if (containerMonitorInterval) {
- clearInterval(containerMonitorTimeout)
- containerMonitorTimeout = undefined
- }
- }
- }
- DockerRunner.startContainerMonitor()
- function __guard__(value, transform) {
- return typeof value !== 'undefined' && value !== null
- ? transform(value)
- : undefined
- }
- function __guardMethod__(obj, methodName, transform) {
- if (
- typeof obj !== 'undefined' &&
- obj !== null &&
- typeof obj[methodName] === 'function'
- ) {
- return transform(obj, methodName)
- } else {
- return undefined
- }
- }
|