From 8bbd5da5131486bd2417441ad4dd462a44b79fc5 Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 16 Nov 2023 19:48:22 +0800 Subject: [PATCH 1/4] feat: load create session by quest image --- docker-compose.yml | 6 ------ packages/models/quest.js | 4 ++++ quests/helloworld/Dockerfile | 4 +--- quests/spell/Dockerfile | 2 +- server/src/config.js | 3 +++ server/src/containers/docker.js | 23 +++++++++++------------ server/src/game/sessionManager.js | 3 ++- server/src/utils/quest.js | 27 +++++++++++++++++---------- 8 files changed, 39 insertions(+), 33 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b2b3e9f..e509387 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,12 +87,6 @@ services: context: . dockerfile: ./quests/Dockerfile - helloworld: - image: lancatlin/linux-odyssey:helloworld - build: - context: . - dockerfile: quests/helloworld/Dockerfile - networks: internal: players: diff --git a/packages/models/quest.js b/packages/models/quest.js index 04c9d10..4c500bd 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -79,6 +79,10 @@ const Quest = model( type: String, required: true, }, + image: { + type: String, + required: true, + }, requirements: { type: [ { diff --git a/quests/helloworld/Dockerfile b/quests/helloworld/Dockerfile index e845379..f053a50 100644 --- a/quests/helloworld/Dockerfile +++ b/quests/helloworld/Dockerfile @@ -1,5 +1,3 @@ FROM lancatlin/linux-odyssey:base -COPY ./quests/helloworld/home ${HOME} - -CMD [ "/bin/zsh" ] +COPY ./home ${HOME} diff --git a/quests/spell/Dockerfile b/quests/spell/Dockerfile index 631485c..f053a50 100644 --- a/quests/spell/Dockerfile +++ b/quests/spell/Dockerfile @@ -1,3 +1,3 @@ FROM lancatlin/linux-odyssey:base -CMD [ "/bin/zsh" ] +COPY ./home ${HOME} diff --git a/server/src/config.js b/server/src/config.js index 75f43d8..b904580 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -40,6 +40,9 @@ const config = { trustedProxies: getTrustProxies('TRUSTED_PROXIES'), surveyUrl: getUrl('SURVEY_URL', ''), bugReportUrl: getUrl('BUG_REPORT_URL', ''), + dockerImage: get('DOCKER_IMAGE', 'lancatlin/linux-odyssey'), + + mountQuest: get('MOUNT_QUEST', ''), } config.baseUrl = get('BASE_URL', `http://${config.host}:${config.port}`) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 5f21147..b1485ce 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -4,7 +4,6 @@ import config from '../config.js' const engine = new Docker() const containerOptions = { - Image: 'lancatlin/linux-odyssey:helloworld', AttachStdin: true, AttachStdout: true, AttachStderr: true, @@ -13,23 +12,24 @@ const containerOptions = { StdinOnce: false, HostConfig: { NetworkMode: config.dockerNetwork, - Binds: - !config.isProduction && config.hostPwd - ? [ - `${config.hostPwd}/quests/helloworld/home:/home/commander`, - `${config.hostPwd}/packages/container:/usr/local/lib/container`, - ] - : [], }, } // const network = engine.getNetwork(config.dockerNetwork) -export function createContainer(name) { - return engine.createContainer({ +export function createContainer(name, tag) { + const option = { ...containerOptions, name, - }) + Image: `${config.dockerImage}:${tag}`, + } + if (!config.isProduction && config.hostPwd && config.mountQuest) { + option.HostConfig.Bind = [ + `${config.hostPwd}/quests/${config.mountQuest}/home:/home/commander`, + `${config.hostPwd}/packages/container:/usr/local/lib/container`, + ] + } + return engine.createContainer(option) } export async function getAndStartContainer(id) { @@ -45,7 +45,6 @@ export async function getAndStartContainer(id) { } export async function attachContainer(container, { token }) { - // Create an exec instance with bash shell const exec = await container.exec({ AttachStdin: true, AttachStdout: true, diff --git a/server/src/game/sessionManager.js b/server/src/game/sessionManager.js index 19e79c8..97ef708 100644 --- a/server/src/game/sessionManager.js +++ b/server/src/game/sessionManager.js @@ -33,7 +33,8 @@ export async function createNewSession(user, questId) { }) const container = await createContainer( - `quest-${quest.id}-${user.username}-${Date.now()}` + `quest-${quest.id}-${user.username}-${Date.now()}`, + quest.image ) const sessionHandler = new SessionHandler( diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index ffb4041..b189059 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -7,31 +7,38 @@ import { Quest } from '@linux-odyssey/models' const questDirectory = path.join(process.cwd(), '..', 'quests') export default async function loadAndUpdateQuests() { - const questNames = await fs.readdir(questDirectory) + const questNames = await fs.readdir(questDirectory, { + withFileTypes: true, + }) const quests = Promise.all( questNames - .filter((name) => !name.startsWith('.')) - .map(async (id) => { - // Check if id is directory - const stat = await fs.stat(path.join(questDirectory, id)) - if (!stat.isDirectory()) { - return null + .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith('.')) + .map(async (dirent) => { + const id = dirent.name + const questPath = path.join(questDirectory, id) + const files = await fs.readdir(questPath) + if (!files.includes('game.yml')) { + throw new Error(`Quest ${id} is missing game.yml`) } - const fullPath = path.join(questDirectory, id, 'game.yml') try { - // Read and parse the README.md file. - const body = await fs.readFile(fullPath, 'utf-8') + const body = await fs.readFile( + path.join(questPath, 'game.yml'), + 'utf-8' + ) const quest = yaml.parse(body, { merge: true, }) + const image = files.includes('Dockerfile') ? id : 'base' + return Quest.findByIdAndUpdate( id, { _id: id, + image, ...quest, }, { From 7b1591a6e3ca6ef6d628b195164f6b8f3ee39a67 Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 16 Nov 2023 20:49:54 +0800 Subject: [PATCH 2/4] feat: add build qeust images script --- package.json | 3 +- quests/helloworld/Dockerfile | 2 +- quests/spell/Dockerfile | 2 +- server/package.json | 3 +- server/src/cli/build.js | 24 ++++++++++ server/src/containers/docker.js | 38 ++++++++++++++++ server/src/index.js | 2 +- server/src/utils/quest.js | 81 ++++++++++++++++++--------------- 8 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 server/src/cli/build.js diff --git a/package.json b/package.json index e7724c5..1456510 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "test:e2e": "yarn workspace tests test", "analytics": "yarn workspace analytics dev", "setup": "yarn workspace server setup", - "clean": "yarn workspace server clean" + "clean": "yarn workspace server clean", + "build:image": "yarn workspace server build" }, "devDependencies": { "@vue/eslint-config-prettier": "^7.1.0", diff --git a/quests/helloworld/Dockerfile b/quests/helloworld/Dockerfile index f053a50..fe63a59 100644 --- a/quests/helloworld/Dockerfile +++ b/quests/helloworld/Dockerfile @@ -1,3 +1,3 @@ FROM lancatlin/linux-odyssey:base -COPY ./home ${HOME} +COPY home ${HOME} diff --git a/quests/spell/Dockerfile b/quests/spell/Dockerfile index f053a50..fe63a59 100644 --- a/quests/spell/Dockerfile +++ b/quests/spell/Dockerfile @@ -1,3 +1,3 @@ FROM lancatlin/linux-odyssey:base -COPY ./home ${HOME} +COPY home ${HOME} diff --git a/server/package.json b/server/package.json index f65ca21..0c4e5f8 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,8 @@ "dev": "nodemon src/index.js", "test": "yarn node --experimental-vm-modules $(yarn bin jest)", "setup": "node src/cli/setup.js", - "clean": "node src/cli/clean.js" + "clean": "node src/cli/clean.js", + "build": "node src/cli/build.js" }, "dependencies": { "@linux-odyssey/constants": "*", diff --git a/server/src/cli/build.js b/server/src/cli/build.js new file mode 100644 index 0000000..41c95d4 --- /dev/null +++ b/server/src/cli/build.js @@ -0,0 +1,24 @@ +import { getQuestDockerfiles } from '../utils/quest.js' +import { buildQuestImage } from '../containers/docker.js' + +async function buildImages() { + const quests = await getQuestDockerfiles() + console.log(`Building ${quests.length} images`) + return Promise.all( + quests.map(({ id, questPath }) => { + console.log(`Building image for ${id}`) + return buildQuestImage(questPath, id) + }) + ) +} + +buildImages() + .then((result) => { + console.log(result) + console.log('Done!') + process.exit(0) + }) + .catch((err) => { + console.error(err) + process.exit(1) + }) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index b1485ce..42a386a 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -78,3 +78,41 @@ export async function deleteContainer(id) { } await container.remove() } + +export function buildQuestImage(questPath, questId) { + return new Promise((resolve, reject) => { + engine.buildImage( + { + context: questPath, + src: ['Dockerfile', 'home'], + }, + { t: `${config.dockerImage}:${questId}` }, + (err, response) => { + if (err) { + reject(err) + } + response.on('data', (data) => { + // Process the data (this could be Docker build output) + const { stream, error } = JSON.parse(data.toString()) + if (stream) { + console.log(stream) + } + if (error) { + console.error(error) + reject(error) + } + }) + + response.on('end', () => { + console.log(`Build completed for ${questId}`) + resolve(questId) + }) + + response.on('error', (error) => { + console.error(`Build failed for ${questId}:`, error) + reject(error) + }) + } + ) + }) +} diff --git a/server/src/index.js b/server/src/index.js index 52c2d88..fda82b1 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -9,7 +9,7 @@ import connectDB from '@linux-odyssey/models' import './auth/passport.js' import socketServer from './api/socket.js' import apiRouter from './api/routes/index.js' -import loadAndUpdateQuests from './utils/quest.js' +import { loadAndUpdateQuests } from './utils/quest.js' import config from './config.js' import errorHandler from './middleware/error.js' import { globalRateLimit } from './middleware/rateLimiter.js' diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index b189059..5b8a3fc 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -6,51 +6,60 @@ import { Quest } from '@linux-odyssey/models' const questDirectory = path.join(process.cwd(), '..', 'quests') -export default async function loadAndUpdateQuests() { +async function mapQuests(callback) { const questNames = await fs.readdir(questDirectory, { withFileTypes: true, }) - const quests = Promise.all( + return Promise.all( questNames .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith('.')) - .map(async (dirent) => { + .map((dirent) => { const id = dirent.name const questPath = path.join(questDirectory, id) - const files = await fs.readdir(questPath) - if (!files.includes('game.yml')) { - throw new Error(`Quest ${id} is missing game.yml`) - } - - try { - const body = await fs.readFile( - path.join(questPath, 'game.yml'), - 'utf-8' - ) - - const quest = yaml.parse(body, { - merge: true, - }) - - const image = files.includes('Dockerfile') ? id : 'base' - - return Quest.findByIdAndUpdate( - id, - { - _id: id, - image, - ...quest, - }, - { - upsert: true, - } - ) - } catch (error) { - console.error(`Error parsing quest ${id}:`, error) - throw error - } + return callback(id, questPath) }) ) +} - return quests +export function loadAndUpdateQuests() { + return mapQuests(async (id, questPath) => { + const files = await fs.readdir(questPath) + if (!files.includes('game.yml')) { + throw new Error(`Quest ${id} is missing game.yml`) + } + + try { + const body = await fs.readFile(path.join(questPath, 'game.yml'), 'utf-8') + + const quest = yaml.parse(body, { + merge: true, + }) + + const image = files.includes('Dockerfile') ? id : 'base' + + return Quest.findByIdAndUpdate( + id, + { + _id: id, + image, + ...quest, + }, + { + upsert: true, + } + ) + } catch (error) { + console.error(`Error parsing quest ${id}:`, error) + throw error + } + }) +} + +export async function getQuestDockerfiles() { + const quests = await mapQuests(async (id, questPath) => { + const files = await fs.readdir(questPath) + return files.includes('Dockerfile') ? { id, questPath } : null + }) + return quests.filter((quest) => quest !== null) } From cd22988a493f9c51f9c0a3b442b29c17a92fc3a6 Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 16 Nov 2023 21:11:32 +0800 Subject: [PATCH 3/4] feat: limit networking and memory during build --- server/src/containers/docker.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 42a386a..f7343fd 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -86,7 +86,11 @@ export function buildQuestImage(questPath, questId) { context: questPath, src: ['Dockerfile', 'home'], }, - { t: `${config.dockerImage}:${questId}` }, + { + t: `${config.dockerImage}:${questId}`, + networkmode: 'none', + memory: 10 * 1e6, + }, (err, response) => { if (err) { reject(err) From c438756bded0b873e1d72d1f9937946f9bb7b533 Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 16 Nov 2023 21:13:18 +0800 Subject: [PATCH 4/4] feat: update CI/CD work, compose, and README --- .github/workflows/ci_cd.yml | 12 ++---------- .gitignore | 1 - README.md | 27 +++++++++++++++++++++++++++ docker-compose.prod.yml | 6 ------ docker-compose.testing.yml | 6 ------ package.json | 2 +- quests/spell/home/ancient_scroll.txt | 0 server/package.json | 2 +- 8 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 quests/spell/home/ancient_scroll.txt diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 73fbcb8..b0ce4f4 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -33,8 +33,8 @@ jobs: - name: Build Test Environment, Stage 1 run: docker-compose -f docker-compose.testing.yml build base backend app-test - - name: Build Test Environment, Stage 2 - run: docker-compose -f docker-compose.testing.yml build helloworld + - name: Build Test Quests + run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests - name: Run Cypress Tests env: @@ -99,14 +99,6 @@ jobs: push: true tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:base - - name: Build quest helloworld - uses: docker/build-push-action@v2 - with: - context: . - file: ./quests/helloworld/Dockerfile - push: true - tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:helloworld - deploy: needs: build-and-push runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8534ca9..a389d25 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ lerna-debug.log* .bash_history .viminfo *.swp -home # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/README.md b/README.md index 7f649e2..67e69df 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,33 @@ If you want to enable social login, you should have OAuth client token in a `.en docker compose --env-file .env up -d +### Build Quest Images + +In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`. + + quests/ + ├── Dockerfile # Base image + ├── discover + │ └── game.yml + ├── helloworld + │ ├── Dockerfile # Overwrite the image + │ ├── game.yml + │ └── home # Include additional files + │ └── forgotten_scroll.txt + ├── read + │ └── game.yml + └── spell + ├── Dockerfile + ├── game.yml + └── home + └── ancient_scroll.txt + +To build all quests: + + yarn build:quests + +The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests. + ## Testing Run Cypress locally, using the current development containers: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5174a73..23c00ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -81,12 +81,6 @@ services: context: . dockerfile: ./quests/Dockerfile - helloworld: - image: lancatlin/linux-odyssey:helloworld - build: - context: . - dockerfile: quests/helloworld/Dockerfile - networks: internal: players: diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml index b0c2cca..b0ff2f3 100644 --- a/docker-compose.testing.yml +++ b/docker-compose.testing.yml @@ -69,12 +69,6 @@ services: context: . dockerfile: ./quests/Dockerfile - helloworld: - image: lancatlin/linux-odyssey:helloworld - build: - context: . - dockerfile: quests/helloworld/Dockerfile - networks: internal: players: diff --git a/package.json b/package.json index 1456510..fb0741a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "analytics": "yarn workspace analytics dev", "setup": "yarn workspace server setup", "clean": "yarn workspace server clean", - "build:image": "yarn workspace server build" + "build:quests": "yarn workspace server build:quests" }, "devDependencies": { "@vue/eslint-config-prettier": "^7.1.0", diff --git a/quests/spell/home/ancient_scroll.txt b/quests/spell/home/ancient_scroll.txt new file mode 100644 index 0000000..e69de29 diff --git a/server/package.json b/server/package.json index 0c4e5f8..f7023b8 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "test": "yarn node --experimental-vm-modules $(yarn bin jest)", "setup": "node src/cli/setup.js", "clean": "node src/cli/clean.js", - "build": "node src/cli/build.js" + "build:quests": "node src/cli/build.js" }, "dependencies": { "@linux-odyssey/constants": "*",