ClsiManager.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795
  1. const async = require('async')
  2. const Settings = require('settings-sharelatex')
  3. const request = require('request')
  4. const ProjectGetter = require('../Project/ProjectGetter')
  5. const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
  6. const logger = require('logger-sharelatex')
  7. const Url = require('url')
  8. const ClsiCookieManager = require('./ClsiCookieManager')(
  9. Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined
  10. )
  11. const NewBackendCloudClsiCookieManager = require('./ClsiCookieManager')(
  12. Settings.apis.clsi_new != null
  13. ? Settings.apis.clsi_new.backendGroupName
  14. : undefined
  15. )
  16. const ClsiStateManager = require('./ClsiStateManager')
  17. const _ = require('underscore')
  18. const ClsiFormatChecker = require('./ClsiFormatChecker')
  19. const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
  20. const Metrics = require('metrics-sharelatex')
  21. const Errors = require('../Errors/Errors')
  22. const ClsiManager = {
  23. sendRequest(projectId, userId, options, callback) {
  24. if (options == null) {
  25. options = {}
  26. }
  27. ClsiManager.sendRequestOnce(projectId, userId, options, function(
  28. error,
  29. status,
  30. ...result
  31. ) {
  32. if (error != null) {
  33. return callback(error)
  34. }
  35. if (status === 'conflict') {
  36. options = _.clone(options)
  37. options.syncType = 'full' // force full compile
  38. ClsiManager.sendRequestOnce(projectId, userId, options, callback) // try again
  39. } else {
  40. callback(error, status, ...result)
  41. }
  42. })
  43. },
  44. sendRequestOnce(projectId, userId, options, callback) {
  45. if (options == null) {
  46. options = {}
  47. }
  48. ClsiManager._buildRequest(projectId, options, function(error, req) {
  49. if (error != null) {
  50. if (error.message === 'no main file specified') {
  51. return callback(null, 'validation-problems', null, null, {
  52. mainFile: error.message
  53. })
  54. } else {
  55. return callback(error)
  56. }
  57. }
  58. logger.log({ projectId }, 'sending compile to CLSI')
  59. ClsiManager._sendBuiltRequest(projectId, userId, req, options, function(
  60. error,
  61. status,
  62. ...result
  63. ) {
  64. if (error != null) {
  65. return callback(error)
  66. }
  67. callback(error, status, ...result)
  68. })
  69. })
  70. },
  71. // for public API requests where there is no project id
  72. sendExternalRequest(submissionId, clsiRequest, options, callback) {
  73. if (options == null) {
  74. options = {}
  75. }
  76. logger.log(
  77. { submissionId },
  78. 'sending external compile to CLSI',
  79. clsiRequest
  80. )
  81. ClsiManager._sendBuiltRequest(
  82. submissionId,
  83. null,
  84. clsiRequest,
  85. options,
  86. function(error, status, ...result) {
  87. if (error != null) {
  88. return callback(error)
  89. }
  90. callback(error, status, ...result)
  91. }
  92. )
  93. },
  94. stopCompile(projectId, userId, options, callback) {
  95. const compilerUrl = this._getCompilerUrl(
  96. options != null ? options.compileGroup : undefined,
  97. projectId,
  98. userId,
  99. 'compile/stop'
  100. )
  101. const opts = {
  102. url: compilerUrl,
  103. method: 'POST'
  104. }
  105. ClsiManager._makeRequest(projectId, opts, callback)
  106. },
  107. deleteAuxFiles(projectId, userId, options, callback) {
  108. const compilerUrl = this._getCompilerUrl(
  109. options != null ? options.compileGroup : undefined,
  110. projectId,
  111. userId
  112. )
  113. const opts = {
  114. url: compilerUrl,
  115. method: 'DELETE'
  116. }
  117. ClsiManager._makeRequest(projectId, opts, clsiError =>
  118. // always clear the project state from the docupdater, even if there
  119. // was a problem with the request to the clsi
  120. DocumentUpdaterHandler.clearProjectState(projectId, function(
  121. docUpdaterError
  122. ) {
  123. const error = clsiError || docUpdaterError
  124. if (error != null) {
  125. return callback(error)
  126. }
  127. callback()
  128. })
  129. )
  130. },
  131. _sendBuiltRequest(projectId, userId, req, options, callback) {
  132. if (options == null) {
  133. options = {}
  134. }
  135. ClsiFormatChecker.checkRecoursesForProblems(
  136. req.compile != null ? req.compile.resources : undefined,
  137. function(err, validationProblems) {
  138. if (err != null) {
  139. logger.warn(
  140. err,
  141. projectId,
  142. 'could not check resources for potential problems before sending to clsi'
  143. )
  144. return callback(err)
  145. }
  146. if (validationProblems != null) {
  147. logger.log(
  148. { projectId, validationProblems },
  149. 'problems with users latex before compile was attempted'
  150. )
  151. return callback(
  152. null,
  153. 'validation-problems',
  154. null,
  155. null,
  156. validationProblems
  157. )
  158. }
  159. ClsiManager._postToClsi(
  160. projectId,
  161. userId,
  162. req,
  163. options.compileGroup,
  164. function(error, response) {
  165. if (error != null) {
  166. logger.warn(
  167. { err: error, projectId },
  168. 'error sending request to clsi'
  169. )
  170. return callback(error)
  171. }
  172. if (response != null) {
  173. logger.log(
  174. {
  175. projectId,
  176. outputFilesLength:
  177. response.outputFiles && response.outputFiles.length,
  178. status: response.status,
  179. compile_status: response.compile && response.compile.status
  180. },
  181. 'received compile response from CLSI'
  182. )
  183. }
  184. ClsiCookieManager._getServerId(projectId, function(
  185. err,
  186. clsiServerId
  187. ) {
  188. if (err != null) {
  189. logger.warn({ err, projectId }, 'error getting server id')
  190. return callback(err)
  191. }
  192. const outputFiles = ClsiManager._parseOutputFiles(
  193. projectId,
  194. response && response.compile && response.compile.outputFiles
  195. )
  196. callback(
  197. null,
  198. response && response.compile && response.compile.status,
  199. outputFiles,
  200. clsiServerId
  201. )
  202. })
  203. }
  204. )
  205. }
  206. )
  207. },
  208. _makeRequest(projectId, opts, callback) {
  209. async.series(
  210. {
  211. currentBackend(cb) {
  212. const startTime = new Date()
  213. ClsiCookieManager.getCookieJar(projectId, function(err, jar) {
  214. if (err != null) {
  215. logger.warn({ err }, 'error getting cookie jar for clsi request')
  216. return callback(err)
  217. }
  218. opts.jar = jar
  219. const timer = new Metrics.Timer('compile.currentBackend')
  220. request(opts, function(err, response, body) {
  221. timer.done()
  222. Metrics.inc(
  223. `compile.currentBackend.response.${
  224. response != null ? response.statusCode : undefined
  225. }`
  226. )
  227. if (err != null) {
  228. logger.warn(
  229. { err, projectId, url: opts != null ? opts.url : undefined },
  230. 'error making request to clsi'
  231. )
  232. return callback(err)
  233. }
  234. ClsiCookieManager.setServerId(projectId, response, function(err) {
  235. if (err != null) {
  236. logger.warn({ err, projectId }, 'error setting server id')
  237. }
  238. callback(err, response, body) // return as soon as the standard compile has returned
  239. cb(err, {
  240. response,
  241. body,
  242. finishTime: new Date() - startTime
  243. })
  244. })
  245. })
  246. })
  247. },
  248. newBackend(cb) {
  249. const startTime = new Date()
  250. ClsiManager._makeNewBackendRequest(projectId, opts, function(
  251. err,
  252. response,
  253. body
  254. ) {
  255. Metrics.inc(
  256. `compile.newBackend.response.${
  257. response != null ? response.statusCode : undefined
  258. }`
  259. )
  260. cb(err, {
  261. response,
  262. body,
  263. finishTime: new Date() - startTime
  264. })
  265. })
  266. }
  267. },
  268. function(err, results) {
  269. if (err != null) {
  270. logger.warn({ err }, 'Error making request to CLSI')
  271. return
  272. }
  273. const timeDifference =
  274. (results.newBackend != null
  275. ? results.newBackend.finishTime
  276. : undefined) -
  277. (results.currentBackend != null
  278. ? results.currentBackend.finishTime
  279. : undefined)
  280. const newStatusCode =
  281. results.newBackend &&
  282. results.newBackend.response &&
  283. results.newBackend.response.statusCode
  284. const currentStatusCode =
  285. results.currentBackend &&
  286. results.currentBackend.response &&
  287. results.currentBackend.response.statusCode
  288. const statusCodeSame = newStatusCode === currentStatusCode
  289. const currentCompileTime =
  290. results.currentBackend != null
  291. ? results.currentBackend.finishTime
  292. : undefined
  293. const newBackendCompileTime =
  294. results.newBackend != null ? results.newBackend.finishTime : undefined
  295. logger.log(
  296. {
  297. statusCodeSame,
  298. timeDifference,
  299. currentCompileTime,
  300. newBackendCompileTime,
  301. projectId
  302. },
  303. 'both clsi requests returned'
  304. )
  305. }
  306. )
  307. },
  308. _makeNewBackendRequest(projectId, baseOpts, callback) {
  309. if (
  310. (Settings.apis.clsi_new != null
  311. ? Settings.apis.clsi_new.url
  312. : undefined) == null
  313. ) {
  314. return callback()
  315. }
  316. const opts = _.clone(baseOpts)
  317. opts.url = opts.url.replace(
  318. Settings.apis.clsi.url,
  319. Settings.apis.clsi_new != null ? Settings.apis.clsi_new.url : undefined
  320. )
  321. NewBackendCloudClsiCookieManager.getCookieJar(projectId, function(
  322. err,
  323. jar
  324. ) {
  325. if (err != null) {
  326. logger.warn({ err }, 'error getting cookie jar for clsi request')
  327. return callback(err)
  328. }
  329. opts.jar = jar
  330. const timer = new Metrics.Timer('compile.newBackend')
  331. request(opts, function(err, response, body) {
  332. timer.done()
  333. if (err != null) {
  334. logger.warn(
  335. { err, projectId, url: opts != null ? opts.url : undefined },
  336. 'error making request to new clsi'
  337. )
  338. return callback(err)
  339. }
  340. NewBackendCloudClsiCookieManager.setServerId(
  341. projectId,
  342. response,
  343. function(err) {
  344. if (err != null) {
  345. logger.warn(
  346. { err, projectId },
  347. 'error setting server id new backend'
  348. )
  349. }
  350. callback(err, response, body)
  351. }
  352. )
  353. })
  354. })
  355. },
  356. _getCompilerUrl(compileGroup, projectId, userId, action) {
  357. const host = Settings.apis.clsi.url
  358. let path = `/project/${projectId}`
  359. if (userId != null) {
  360. path += `/user/${userId}`
  361. }
  362. if (action != null) {
  363. path += `/${action}`
  364. }
  365. return `${host}${path}`
  366. },
  367. _postToClsi(projectId, userId, req, compileGroup, callback) {
  368. const compileUrl = this._getCompilerUrl(
  369. compileGroup,
  370. projectId,
  371. userId,
  372. 'compile'
  373. )
  374. const opts = {
  375. url: compileUrl,
  376. json: req,
  377. method: 'POST'
  378. }
  379. ClsiManager._makeRequest(projectId, opts, function(error, response, body) {
  380. if (error != null) {
  381. return callback(error)
  382. }
  383. if (response.statusCode >= 200 && response.statusCode < 300) {
  384. callback(null, body)
  385. } else if (response.statusCode === 413) {
  386. callback(null, { compile: { status: 'project-too-large' } })
  387. } else if (response.statusCode === 409) {
  388. callback(null, { compile: { status: 'conflict' } })
  389. } else if (response.statusCode === 423) {
  390. callback(null, { compile: { status: 'compile-in-progress' } })
  391. } else {
  392. error = new Error(
  393. `CLSI returned non-success code: ${response.statusCode}`
  394. )
  395. logger.warn({ err: error, projectId }, 'CLSI returned failure code')
  396. callback(error, body)
  397. }
  398. })
  399. },
  400. _parseOutputFiles(projectId, rawOutputFiles) {
  401. if (rawOutputFiles == null) {
  402. rawOutputFiles = []
  403. }
  404. const outputFiles = []
  405. for (const file of rawOutputFiles) {
  406. outputFiles.push({
  407. path: file.path, // the clsi is now sending this to web
  408. url: Url.parse(file.url).path, // the location of the file on the clsi, excluding the host part
  409. type: file.type,
  410. build: file.build
  411. })
  412. }
  413. return outputFiles
  414. },
  415. VALID_COMPILERS: ['pdflatex', 'latex', 'xelatex', 'lualatex'],
  416. _buildRequest(projectId, options, callback) {
  417. if (options == null) {
  418. options = {}
  419. }
  420. ProjectGetter.getProject(
  421. projectId,
  422. { compiler: 1, rootDoc_id: 1, imageName: 1, rootFolder: 1 },
  423. function(error, project) {
  424. let timer
  425. if (error != null) {
  426. return callback(error)
  427. }
  428. if (project == null) {
  429. return callback(
  430. new Errors.NotFoundError(`project does not exist: ${projectId}`)
  431. )
  432. }
  433. if (!ClsiManager.VALID_COMPILERS.includes(project.compiler)) {
  434. project.compiler = 'pdflatex'
  435. }
  436. if (options.incrementalCompilesEnabled || options.syncType != null) {
  437. // new way, either incremental or full
  438. timer = new Metrics.Timer('editor.compile-getdocs-redis')
  439. ClsiManager.getContentFromDocUpdaterIfMatch(
  440. projectId,
  441. project,
  442. options,
  443. function(error, projectStateHash, docUpdaterDocs) {
  444. timer.done()
  445. if (error != null) {
  446. logger.error(
  447. { err: error, projectId },
  448. 'error checking project state'
  449. )
  450. // note: we don't bail out when there's an error getting
  451. // incremental files from the docupdater, we just fall back
  452. // to a normal compile below
  453. } else {
  454. logger.log(
  455. {
  456. projectId,
  457. projectStateHash,
  458. docs: docUpdaterDocs != null
  459. },
  460. 'checked project state'
  461. )
  462. }
  463. // see if we can send an incremental update to the CLSI
  464. if (
  465. docUpdaterDocs != null &&
  466. options.syncType !== 'full' &&
  467. error == null
  468. ) {
  469. Metrics.inc('compile-from-redis')
  470. ClsiManager._buildRequestFromDocupdater(
  471. projectId,
  472. options,
  473. project,
  474. projectStateHash,
  475. docUpdaterDocs,
  476. callback
  477. )
  478. } else {
  479. Metrics.inc('compile-from-mongo')
  480. ClsiManager._buildRequestFromMongo(
  481. projectId,
  482. options,
  483. project,
  484. projectStateHash,
  485. callback
  486. )
  487. }
  488. }
  489. )
  490. } else {
  491. // old way, always from mongo
  492. timer = new Metrics.Timer('editor.compile-getdocs-mongo')
  493. ClsiManager._getContentFromMongo(projectId, function(
  494. error,
  495. docs,
  496. files
  497. ) {
  498. timer.done()
  499. if (error != null) {
  500. return callback(error)
  501. }
  502. ClsiManager._finaliseRequest(
  503. projectId,
  504. options,
  505. project,
  506. docs,
  507. files,
  508. callback
  509. )
  510. })
  511. }
  512. }
  513. )
  514. },
  515. getContentFromDocUpdaterIfMatch(projectId, project, options, callback) {
  516. ClsiStateManager.computeHash(project, options, function(
  517. error,
  518. projectStateHash
  519. ) {
  520. if (error != null) {
  521. return callback(error)
  522. }
  523. DocumentUpdaterHandler.getProjectDocsIfMatch(
  524. projectId,
  525. projectStateHash,
  526. function(error, docs) {
  527. if (error != null) {
  528. return callback(error)
  529. }
  530. callback(null, projectStateHash, docs)
  531. }
  532. )
  533. })
  534. },
  535. getOutputFileStream(projectId, userId, buildId, outputFilePath, callback) {
  536. const url = `${
  537. Settings.apis.clsi.url
  538. }/project/${projectId}/user/${userId}/build/${buildId}/output/${outputFilePath}`
  539. ClsiCookieManager.getCookieJar(projectId, function(err, jar) {
  540. if (err != null) {
  541. return callback(err)
  542. }
  543. const options = { url, method: 'GET', timeout: 60 * 1000, jar }
  544. const readStream = request(options)
  545. callback(null, readStream)
  546. })
  547. },
  548. _buildRequestFromDocupdater(
  549. projectId,
  550. options,
  551. project,
  552. projectStateHash,
  553. docUpdaterDocs,
  554. callback
  555. ) {
  556. ProjectEntityHandler.getAllDocPathsFromProject(project, function(
  557. error,
  558. docPath
  559. ) {
  560. let path
  561. if (error != null) {
  562. return callback(error)
  563. }
  564. const docs = {}
  565. for (let doc of docUpdaterDocs || []) {
  566. path = docPath[doc._id]
  567. docs[path] = doc
  568. }
  569. // send new docs but not files as those are already on the clsi
  570. options = _.clone(options)
  571. options.syncType = 'incremental'
  572. options.syncState = projectStateHash
  573. // create stub doc entries for any possible root docs, if not
  574. // present in the docupdater. This allows finaliseRequest to
  575. // identify the root doc.
  576. const possibleRootDocIds = [options.rootDoc_id, project.rootDoc_id]
  577. for (const rootDocId of possibleRootDocIds) {
  578. if (rootDocId != null && rootDocId in docPath) {
  579. path = docPath[rootDocId]
  580. if (docs[path] == null) {
  581. docs[path] = { _id: rootDocId, path }
  582. }
  583. }
  584. }
  585. ClsiManager._finaliseRequest(
  586. projectId,
  587. options,
  588. project,
  589. docs,
  590. [],
  591. callback
  592. )
  593. })
  594. },
  595. _buildRequestFromMongo(
  596. projectId,
  597. options,
  598. project,
  599. projectStateHash,
  600. callback
  601. ) {
  602. ClsiManager._getContentFromMongo(projectId, function(error, docs, files) {
  603. if (error != null) {
  604. return callback(error)
  605. }
  606. options = _.clone(options)
  607. options.syncType = 'full'
  608. options.syncState = projectStateHash
  609. ClsiManager._finaliseRequest(
  610. projectId,
  611. options,
  612. project,
  613. docs,
  614. files,
  615. callback
  616. )
  617. })
  618. },
  619. _getContentFromMongo(projectId, callback) {
  620. DocumentUpdaterHandler.flushProjectToMongo(projectId, function(error) {
  621. if (error != null) {
  622. return callback(error)
  623. }
  624. ProjectEntityHandler.getAllDocs(projectId, function(error, docs) {
  625. if (docs == null) {
  626. docs = {}
  627. }
  628. if (error != null) {
  629. return callback(error)
  630. }
  631. ProjectEntityHandler.getAllFiles(projectId, function(error, files) {
  632. if (files == null) {
  633. files = {}
  634. }
  635. if (error != null) {
  636. return callback(error)
  637. }
  638. callback(null, docs, files)
  639. })
  640. })
  641. })
  642. },
  643. _finaliseRequest(projectId, options, project, docs, files, callback) {
  644. let doc, path
  645. const resources = []
  646. let rootResourcePath = null
  647. let rootResourcePathOverride = null
  648. let hasMainFile = false
  649. let numberOfDocsInProject = 0
  650. for (path in docs) {
  651. doc = docs[path]
  652. path = path.replace(/^\//, '') // Remove leading /
  653. numberOfDocsInProject++
  654. if (doc.lines != null) {
  655. // add doc to resources unless it is just a stub entry
  656. resources.push({
  657. path,
  658. content: doc.lines.join('\n')
  659. })
  660. }
  661. if (
  662. project.rootDoc_id != null &&
  663. doc._id.toString() === project.rootDoc_id.toString()
  664. ) {
  665. rootResourcePath = path
  666. }
  667. if (
  668. options.rootDoc_id != null &&
  669. doc._id.toString() === options.rootDoc_id.toString()
  670. ) {
  671. rootResourcePathOverride = path
  672. }
  673. if (path === 'main.tex') {
  674. hasMainFile = true
  675. }
  676. }
  677. if (rootResourcePathOverride != null) {
  678. rootResourcePath = rootResourcePathOverride
  679. }
  680. if (rootResourcePath == null) {
  681. if (hasMainFile) {
  682. logger.warn(
  683. { projectId },
  684. 'no root document found, setting to main.tex'
  685. )
  686. rootResourcePath = 'main.tex'
  687. } else if (numberOfDocsInProject === 1) {
  688. // only one file, must be the main document
  689. for (path in docs) {
  690. doc = docs[path]
  691. rootResourcePath = path.replace(/^\//, '')
  692. } // Remove leading /
  693. logger.warn(
  694. { projectId, rootResourcePath },
  695. 'no root document found, single document in project'
  696. )
  697. } else {
  698. return callback(new Error('no main file specified'))
  699. }
  700. }
  701. for (path in files) {
  702. const file = files[path]
  703. path = path.replace(/^\//, '') // Remove leading /
  704. resources.push({
  705. path,
  706. url: `${Settings.apis.filestore.url}/project/${project._id}/file/${
  707. file._id
  708. }`,
  709. modified: file.created != null ? file.created.getTime() : undefined
  710. })
  711. }
  712. callback(null, {
  713. compile: {
  714. options: {
  715. compiler: project.compiler,
  716. timeout: options.timeout,
  717. imageName: project.imageName,
  718. draft: !!options.draft,
  719. check: options.check,
  720. syncType: options.syncType,
  721. syncState: options.syncState
  722. },
  723. rootResourcePath,
  724. resources
  725. }
  726. })
  727. },
  728. wordCount(projectId, userId, file, options, callback) {
  729. ClsiManager._buildRequest(projectId, options, function(error, req) {
  730. if (error != null) {
  731. return callback(error)
  732. }
  733. const filename = file || req.compile.rootResourcePath
  734. const wordCountUrl = ClsiManager._getCompilerUrl(
  735. options != null ? options.compileGroup : undefined,
  736. projectId,
  737. userId,
  738. 'wordcount'
  739. )
  740. const opts = {
  741. url: wordCountUrl,
  742. qs: {
  743. file: filename,
  744. image: req.compile.options.imageName
  745. },
  746. method: 'GET'
  747. }
  748. ClsiManager._makeRequest(projectId, opts, function(
  749. error,
  750. response,
  751. body
  752. ) {
  753. if (error != null) {
  754. return callback(error)
  755. }
  756. if (response.statusCode >= 200 && response.statusCode < 300) {
  757. callback(null, body)
  758. } else {
  759. error = new Error(
  760. `CLSI returned non-success code: ${response.statusCode}`
  761. )
  762. logger.warn({ err: error, projectId }, 'CLSI returned failure code')
  763. callback(error, body)
  764. }
  765. })
  766. })
  767. }
  768. }
  769. module.exports = ClsiManager