diff --git a/server-ce/init_scripts/910_check_texlive_images b/server-ce/init_scripts/910_check_texlive_images new file mode 100755 index 0000000000..63fc1ba8fb --- /dev/null +++ b/server-ce/init_scripts/910_check_texlive_images @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +echo "Checking texlive images" +cd /overleaf/services/web +node modules/server-ce-scripts/scripts/check-texlive-images diff --git a/services/web/migrations/20240730155209_create_project_imageName_index.js b/services/web/migrations/20240730155209_create_project_imageName_index.js new file mode 100644 index 0000000000..71e3945c48 --- /dev/null +++ b/services/web/migrations/20240730155209_create_project_imageName_index.js @@ -0,0 +1,18 @@ +const Helpers = require('./lib/helpers') + +exports.tags = ['server-ce', 'server-pro'] + +const index = { + key: { imageName: 1 }, + name: 'imageName_1', +} + +exports.migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.projects, [index]) +} + +exports.rollback = async client => { + const { db } = client + await Helpers.dropIndexesFromCollection(db.projects, [index]) +} diff --git a/services/web/modules/server-ce-scripts/scripts/check-texlive-images.js b/services/web/modules/server-ce-scripts/scripts/check-texlive-images.js new file mode 100644 index 0000000000..110b884134 --- /dev/null +++ b/services/web/modules/server-ce-scripts/scripts/check-texlive-images.js @@ -0,0 +1,94 @@ +const { waitForDb, db } = require('../../../app/src/infrastructure/mongodb') + +async function readImagesInUse() { + await waitForDb() + const projectCount = await db.projects.countDocuments() + if (projectCount === 0) { + return [] + } + const images = await db.projects.distinct('imageName') + + if (!images || images.length === 0 || images.includes(null)) { + console.error(`'project.imageName' is not set for some projects`) + console.error( + `Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.js' to initialise TexLive image in existing projects.` + ) + console.error( + `After running the script, remove SKIP_TEX_LIVE_CHECK from config/variables.env and restart the instance.` + ) + process.exit(1) + } + return images +} + +function checkSandboxedCompilesAreEnabled() { + if (process.env.SANDBOXED_COMPILES !== 'true') { + console.log('Sandboxed compiles disabled, skipping TexLive checks') + process.exit(0) + } +} + +function checkTexLiveEnvVariablesAreProvided() { + if ( + !process.env.TEX_LIVE_DOCKER_IMAGE || + !process.env.ALL_TEX_LIVE_DOCKER_IMAGES + ) { + console.error( + 'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set.' + ) + process.exit(1) + } +} + +async function main() { + if (process.env.SKIP_TEX_LIVE_CHECK === 'true') { + console.log(`SKIP_TEX_LIVE_CHECK=true, skipping TexLive images check`) + process.exit(0) + } + + checkSandboxedCompilesAreEnabled() + checkTexLiveEnvVariablesAreProvided() + + const allTexLiveImages = process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',') + + if (!allTexLiveImages.includes(process.env.TEX_LIVE_DOCKER_IMAGE)) { + console.error( + `TEX_LIVE_DOCKER_IMAGE must be included in ALL_TEX_LIVE_DOCKER_IMAGES` + ) + process.exit(1) + } + + const currentImages = await readImagesInUse() + + const danglingImages = [] + for (const image of currentImages) { + if (!allTexLiveImages.includes(image)) { + danglingImages.push(image) + } + } + if (danglingImages.length > 0) { + danglingImages.forEach(image => + console.error( + `${image} is currently in use but it's not included in ALL_TEX_LIVE_DOCKER_IMAGES` + ) + ) + console.error( + `Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/update_project_image_name.js ' to update projects to a new image.` + ) + console.error( + `After running the script, remove SKIP_TEX_LIVE_CHECK from config/variables.env and restart the instance.` + ) + process.exit(1) + } + + console.log('Done.') +} + +main() + .then(() => { + process.exit(0) + }) + .catch(err => { + console.error(err) + process.exit(1) + }) diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/Init.js b/services/web/modules/server-ce-scripts/test/acceptance/src/Init.js index 3757f183ac..2bb408cbb7 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/Init.js +++ b/services/web/modules/server-ce-scripts/test/acceptance/src/Init.js @@ -1 +1,14 @@ require('../../../../../test/acceptance/src/helpers/InitApp') +const MockProjectHistoryApi = require('../../../../../test/acceptance/src/mocks/MockProjectHistoryApi') +const MockDocstoreApi = require('../../../../../test/acceptance/src/mocks/MockDocstoreApi') +const MockDocUpdaterApi = require('../../../../../test/acceptance/src/mocks/MockDocUpdaterApi') +const MockV1Api = require('../../../../admin-panel/test/acceptance/src/mocks/MockV1Api') + +const mockOpts = { + debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS), +} + +MockDocstoreApi.initialize(23016, mockOpts) +MockDocUpdaterApi.initialize(23003, mockOpts) +MockProjectHistoryApi.initialize(23054, mockOpts) +MockV1Api.initialize(25000, mockOpts) diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.js b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.js index 01641d8929..61726ed39f 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.js +++ b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.js @@ -1,9 +1,9 @@ -const Settings = require('@overleaf/settings') const { execSync } = require('child_process') +const fs = require('fs') +const Settings = require('@overleaf/settings') const { expect } = require('chai') const { db } = require('../../../../../app/src/infrastructure/mongodb') const User = require('../../../../../test/acceptance/src/helpers/User').promises -const fs = require('fs') /** * @param {string} cmd @@ -20,6 +20,21 @@ function run(cmd) { }).toString() } +function runAndExpectError(cmd, errorMessages) { + try { + run(cmd) + } catch (error) { + expect(error.status).to.equal(1) + if (errorMessages) { + errorMessages.forEach(errorMessage => + expect(error.stderr.toString()).to.include(errorMessage) + ) + } + return + } + expect.fail('command should have failed') +} + async function getUser(email) { return db.users.findOne({ email }, { projection: { _id: 0, isAdmin: 1 } }) } @@ -497,4 +512,171 @@ describe('ServerCEScripts', function () { }) }) }) + + describe('check-texlive-images', function () { + const TEST_TL_IMAGE = 'sharelatex/texlive:2023' + const TEST_TL_IMAGE_LIST = + 'sharelatex/texlive:2021,sharelatex/texlive:2022,sharelatex/texlive:2023' + + let output + + function buildCheckTexLiveCmd({ + SANDBOXED_COMPILES, + TEX_LIVE_DOCKER_IMAGE, + ALL_TEX_LIVE_DOCKER_IMAGES, + }) { + let cmd = `SANDBOXED_COMPILES=${SANDBOXED_COMPILES ? 'true' : 'false'}` + if (TEX_LIVE_DOCKER_IMAGE) { + cmd += ` TEX_LIVE_DOCKER_IMAGE='${TEX_LIVE_DOCKER_IMAGE}'` + } + if (ALL_TEX_LIVE_DOCKER_IMAGES) { + cmd += ` ALL_TEX_LIVE_DOCKER_IMAGES='${ALL_TEX_LIVE_DOCKER_IMAGES}'` + } + return ( + cmd + ' node modules/server-ce-scripts/scripts/check-texlive-images' + ) + } + + beforeEach(async function () { + const user = new User() + await user.ensureUserExists() + await user.login() + await user.createProject('test-project') + }) + + describe('when sandboxed compiles are disabled', function () { + beforeEach('run script', function () { + output = run(buildCheckTexLiveCmd({ SANDBOXED_COMPILES: false })) + }) + + it('should skip checks', function () { + expect(output).to.include( + 'Sandboxed compiles disabled, skipping TexLive checks' + ) + }) + }) + + describe('when texlive configuration is incorrect', function () { + it('should fail when TEX_LIVE_DOCKER_IMAGE is not set', function () { + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST, + }), + [ + 'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set', + ] + ) + }) + + it('should fail when ALL_TEX_LIVE_DOCKER_IMAGES is not set', function () { + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE, + }), + [ + 'Sandboxed compiles require TEX_LIVE_DOCKER_IMAGE and ALL_TEX_LIVE_DOCKER_IMAGES being set', + ] + ) + }) + + it('should fail when TEX_LIVE_DOCKER_IMAGE is not defined in ALL_TEX_LIVE_DOCKER_IMAGES', function () { + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: 'tl-1', + ALL_TEX_LIVE_DOCKER_IMAGES: 'tl-2,tl-3', + }), + [ + 'TEX_LIVE_DOCKER_IMAGE must be included in ALL_TEX_LIVE_DOCKER_IMAGES', + ] + ) + }) + }) + + describe(`when projects don't have 'imageName' set`, function () { + beforeEach(async function () { + await db.projects.updateMany({}, { $unset: { imageName: 1 } }) + }) + + it('should fail and suggest running backfilling scripts', function () { + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE, + ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST, + }), + [ + `'project.imageName' is not set for some projects`, + `Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.js' to initialise TexLive image in existing projects`, + ] + ) + }) + }) + + describe(`when projects have a null 'imageName'`, function () { + beforeEach(async function () { + await db.projects.updateMany({}, { $set: { imageName: null } }) + }) + + it('should fail and suggest running backfilling scripts', function () { + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE, + ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST, + }), + [ + `'project.imageName' is not set for some projects`, + `Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/backfill_project_image_name.js' to initialise TexLive image in existing projects`, + ] + ) + }) + }) + + describe('when TexLive ALL_TEX_LIVE_DOCKER_IMAGES are upgraded and used images are no longer available', function () { + it('should suggest running a fixing script', async function () { + await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } }) + runAndExpectError( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: 'tl-1', + ALL_TEX_LIVE_DOCKER_IMAGES: 'tl-1,tl-2', + }), + [ + `Set SKIP_TEX_LIVE_CHECK=true in config/variables.env, restart the instance and run 'bin/run-script scripts/update_project_image_name.js ' to update projects to a new image`, + ] + ) + }) + }) + + describe('success scenarios', function () { + beforeEach(async function () { + await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } }) + }) + + it('should succeed when there are no changes to the TexLive images', function () { + const output = run( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE, + ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST, + }) + ) + expect(output).to.include('Done.') + }) + + it('should succeed when there are valid changes to the TexLive images', function () { + const output = run( + buildCheckTexLiveCmd({ + SANDBOXED_COMPILES: true, + TEX_LIVE_DOCKER_IMAGE: 'new-image', + ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST + ',new-image', + }) + ) + expect(output).to.include('Done.') + }) + }) + }) }) diff --git a/services/web/scripts/backfill_project_image_name.js b/services/web/scripts/backfill_project_image_name.js index f9010cb367..278a11f07b 100644 --- a/services/web/scripts/backfill_project_image_name.js +++ b/services/web/scripts/backfill_project_image_name.js @@ -3,6 +3,44 @@ const { batchedUpdateWithResultHandling } = require('./helpers/batchedUpdate') const argv = minimist(process.argv.slice(2)) const commit = argv.commit !== undefined +let imageName = argv._[0] + +function usage() { + console.log( + 'Usage: node backfill_project_image_name.js --commit ' + ) + console.log( + 'Argument is not required when TEX_LIVE_DOCKER_IMAGE is set.' + ) + console.log( + 'Environment variable ALL_TEX_LIVE_DOCKER_IMAGES must contain .' + ) +} + +if (!imageName && process.env.TEX_LIVE_DOCKER_IMAGE) { + imageName = process.env.TEX_LIVE_DOCKER_IMAGE +} + +if (!imageName) { + usage() + process.exit(1) +} + +if (!process.env.ALL_TEX_LIVE_DOCKER_IMAGES) { + console.error( + 'Error: environment variable ALL_TEX_LIVE_DOCKER_IMAGES is not defined.' + ) + usage() + process.exit(1) +} + +if (!process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',').includes(imageName)) { + console.error( + `Error: ALL_TEX_LIVE_DOCKER_IMAGES doesn't contain ${imageName}` + ) + usage() + process.exit(1) +} if (!commit) { console.error('DOING DRY RUN. TO SAVE CHANGES PASS --commit') @@ -12,5 +50,5 @@ if (!commit) { batchedUpdateWithResultHandling( 'projects', { imageName: null }, - { $set: { imageName: 'quay.io/sharelatex/texlive-full:2014.2' } } + { $set: { imageName } } ) diff --git a/services/web/scripts/update_project_image_name.js b/services/web/scripts/update_project_image_name.js new file mode 100644 index 0000000000..c50869ef6e --- /dev/null +++ b/services/web/scripts/update_project_image_name.js @@ -0,0 +1,46 @@ +const { batchedUpdate } = require('./helpers/batchedUpdate') + +const oldImage = process.argv[2] +const newImage = process.argv[3] + +function usage() { + console.log( + `Usage: update_project_image_name.js ` + ) + console.log( + 'Environment variable ALL_TEX_LIVE_DOCKER_IMAGES must contain .' + ) +} + +if (!oldImage || !newImage) { + usage() + process.exit(1) +} + +if (!process.env.ALL_TEX_LIVE_DOCKER_IMAGES) { + console.error( + 'Error: environment variable ALL_TEX_LIVE_DOCKER_IMAGES is not defined.' + ) + usage() + process.exit(1) +} + +if (!process.env.ALL_TEX_LIVE_DOCKER_IMAGES.split(',').includes(newImage)) { + console.error(`Error: ALL_TEX_LIVE_DOCKER_IMAGES doesn't contain ${newImage}`) + usage() + process.exit(1) +} + +batchedUpdate( + 'projects', + { imageName: oldImage }, + { $set: { imageName: newImage } } +) + .then(() => { + console.log('Done') + process.exit(0) + }) + .catch(error => { + console.error(error) + process.exit(1) + })