host-admin.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. const fs = require('fs')
  2. const { execFile } = require('child_process')
  3. const express = require('express')
  4. const bodyParser = require('body-parser')
  5. const {
  6. celebrate: validate,
  7. Joi,
  8. errors: handleValidationErrors,
  9. } = require('celebrate')
  10. const YAML = require('js-yaml')
  11. const FILES = {
  12. DOCKER_COMPOSE: 'docker-compose.override.yml',
  13. }
  14. const IMAGES = {
  15. CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
  16. PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
  17. }
  18. let mongoIsInitialized = false
  19. function readDockerComposeOverride() {
  20. try {
  21. return YAML.load(fs.readFileSync(FILES.DOCKER_COMPOSE, 'utf-8'))
  22. } catch (error) {
  23. if (error.code !== 'ENOENT') {
  24. throw error
  25. }
  26. return {
  27. services: {
  28. sharelatex: {
  29. environment: {},
  30. },
  31. },
  32. }
  33. }
  34. }
  35. function writeDockerComposeOverride(cfg) {
  36. fs.writeFileSync(FILES.DOCKER_COMPOSE, YAML.dump(cfg))
  37. }
  38. const app = express()
  39. app.get('/status', (req, res) => {
  40. res.send('host-admin is up')
  41. })
  42. app.use(bodyParser.json())
  43. app.use((req, res, next) => {
  44. // Basic access logs
  45. console.log(req.method, req.url, req.body)
  46. // Add CORS headers
  47. res.setHeader('Access-Control-Allow-Origin', 'http://sharelatex')
  48. res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
  49. next()
  50. })
  51. app.post(
  52. '/run/script',
  53. validate(
  54. {
  55. body: {
  56. cwd: Joi.string().required(),
  57. script: Joi.string().required(),
  58. args: Joi.array().items(Joi.string()),
  59. },
  60. },
  61. { allowUnknown: false }
  62. ),
  63. (req, res) => {
  64. const { cwd, script, args } = req.body
  65. execFile(
  66. 'docker',
  67. [
  68. 'compose',
  69. 'exec',
  70. 'sharelatex',
  71. 'bash',
  72. '-c',
  73. `source /etc/container_environment.sh && source /etc/overleaf/env.sh && cd ${JSON.stringify(cwd)} && node ${JSON.stringify(script)} ${args.map(a => JSON.stringify(a)).join(' ')}`,
  74. ],
  75. (error, stdout, stderr) => {
  76. res.json({
  77. error,
  78. stdout,
  79. stderr,
  80. })
  81. }
  82. )
  83. }
  84. )
  85. function setVersionDockerCompose({ pro, version }) {
  86. const cfg = readDockerComposeOverride()
  87. cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version}`
  88. writeDockerComposeOverride(cfg)
  89. }
  90. app.post(
  91. '/set/version',
  92. validate(
  93. {
  94. body: {
  95. pro: Joi.boolean(),
  96. version: Joi.string().required(),
  97. path: Joi.allow(
  98. 'docker-compose.yml'
  99. // When extending testing for Toolkit:
  100. // 'config/version'
  101. ),
  102. },
  103. },
  104. { allowUnknown: false }
  105. ),
  106. (req, res) => {
  107. const { pro, version } = req.body
  108. if (req.body.path === 'docker-compose.yml') {
  109. try {
  110. setVersionDockerCompose({ pro, version })
  111. } catch (error) {
  112. return res.json({ error })
  113. }
  114. }
  115. res.json({})
  116. }
  117. )
  118. const allowedVars = Joi.object().keys({
  119. OVERLEAF_APP_NAME: Joi.string(),
  120. OVERLEAF_LEFT_FOOTER: Joi.string(),
  121. OVERLEAF_RIGHT_FOOTER: Joi.string(),
  122. })
  123. function setVarsDockerCompose({ vars }) {
  124. const cfg = readDockerComposeOverride()
  125. cfg.services.sharelatex.environment = vars
  126. writeDockerComposeOverride(cfg)
  127. }
  128. app.post(
  129. '/set/vars',
  130. validate(
  131. {
  132. body: {
  133. vars: allowedVars,
  134. path: Joi.allow(
  135. 'docker-compose.yml'
  136. // When extending the testing for Toolkit:
  137. // 'overleaf.rc', 'variables.env'
  138. ),
  139. },
  140. },
  141. { allowUnknown: false }
  142. ),
  143. (req, res) => {
  144. if (req.body.path === 'docker-compose.yml') {
  145. const { vars } = req.body
  146. try {
  147. setVarsDockerCompose({ vars })
  148. } catch (error) {
  149. return res.json({ error })
  150. }
  151. }
  152. res.json({})
  153. }
  154. )
  155. app.post(
  156. '/docker/compose/:cmd',
  157. validate(
  158. {
  159. body: {
  160. args: Joi.array().allow(
  161. '--detach',
  162. '--wait',
  163. '--volumes',
  164. '--timeout',
  165. '0',
  166. 'sharelatex',
  167. 'mongo',
  168. 'redis'
  169. ),
  170. },
  171. params: {
  172. cmd: Joi.allow('up', 'stop', 'down', 'ps', 'logs'),
  173. },
  174. },
  175. { allowUnknown: false }
  176. ),
  177. (req, res) => {
  178. const { cmd } = req.params
  179. const { args } = req.body
  180. if (['stop', 'down'].includes(cmd)) {
  181. mongoIsInitialized = false
  182. }
  183. execFile('docker', ['compose', cmd, ...args], (error, stdout, stderr) => {
  184. res.json({ error, stdout, stderr })
  185. })
  186. }
  187. )
  188. function mongoInit(callback) {
  189. execFile(
  190. 'docker',
  191. [
  192. 'compose',
  193. 'exec',
  194. 'mongo',
  195. 'mongo',
  196. '--eval',
  197. 'rs.initiate({ _id: "overleaf", members: [ { _id: 0, host: "mongo:27017" } ] })',
  198. ],
  199. (error, stdout, stderr) => {
  200. if (!error) {
  201. mongoIsInitialized = true
  202. }
  203. callback(error, stdout, stderr)
  204. }
  205. )
  206. }
  207. app.post('/mongo/init', (req, res) => {
  208. mongoInit((error, stdout, stderr) => {
  209. res.json({ error, stdout, stderr })
  210. })
  211. })
  212. app.post(
  213. '/reconfigure',
  214. validate(
  215. {
  216. body: {
  217. pro: Joi.boolean().required(),
  218. version: Joi.string().required(),
  219. vars: allowedVars,
  220. },
  221. },
  222. { allowUnknown: false }
  223. ),
  224. (req, res) => {
  225. const doMongoInit = mongoIsInitialized ? cb => cb() : mongoInit
  226. doMongoInit((error, stdout, stderr) => {
  227. if (error) return res.json({ error, stdout, stderr })
  228. const { pro, version, vars } = req.body
  229. try {
  230. setVersionDockerCompose({ pro, version })
  231. setVarsDockerCompose({ vars })
  232. } catch (error) {
  233. return res.json({ error })
  234. }
  235. execFile(
  236. 'docker',
  237. ['compose', 'up', '--detach', '--wait', 'sharelatex'],
  238. (error, stdout, stderr) => {
  239. res.json({ error, stdout, stderr })
  240. }
  241. )
  242. })
  243. }
  244. )
  245. app.use(handleValidationErrors())
  246. // Init on startup
  247. mongoInit(err => {
  248. if (err) {
  249. console.error('mongo init failed', err)
  250. process.exit(1)
  251. }
  252. app.listen(80)
  253. })