From 2c866d2e834615f5c4a508d1ce044f6adff14388 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 23 Aug 2023 11:50:49 +0800 Subject: [PATCH 1/7] feat: add status, lastActivityAt column to session --- server/src/api/controllers/commandController.js | 10 +++++++++- server/src/game/commandHandler.js | 6 ++++++ server/src/models/session.js | 11 +++++++++++ server/swagger.yaml | 6 ++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index c37121d..5526308 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -26,10 +26,18 @@ export async function newCommand(req, res) { const session = await Session.findById(sessionId) if (!session) { - res.status(401).json({ message: 'session not found' }) + res.status(404).json({ message: 'session not found' }) return } + if (session.status !== 'active') { + res.status(400).json({ message: 'session is not active' }) + return + } + + session.lastActivityAt = new Date() + await session.save() + const c = new Command({ session, command, diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index b467b5c..35335f3 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -42,6 +42,12 @@ async function commandHandler(session, commandInput, additionalData) { session.progress = stage.next await session.save() + if (stage.next === 'END') { + session.status = 'finished' + session.finishedAt = new Date() + await session.save() + } + return { responses: stage.responses, hints: stage.hints, diff --git a/server/src/models/session.js b/server/src/models/session.js index 5ae0865..4757618 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -13,7 +13,18 @@ const Session = model( ref: 'Quest', }, containerId: String, + status: { + type: String, + enum: ['active', 'finished', 'deactive'], + required: true, + default: 'active', + }, finishedAt: Date, + lastActivityAt: { + type: Date, + default: Date.now, + required: true, + }, progress: String, completion: [String], hints: [String], diff --git a/server/swagger.yaml b/server/swagger.yaml index 5b671bb..d2dfceb 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -31,6 +31,12 @@ paths: description: find sessions by quest id schema: type: string + - in: query + name: status + description: find sessions by status + schema: + type: string + enum: [active, finished, deactive] responses: '200': description: list of existing session From 8e63284211ee3b021d246703a6833a46dfd3cd1f Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 23 Aug 2023 11:51:16 +0800 Subject: [PATCH 2/7] feat: seperate container creation and get --- .../src/api/controllers/sessionController.js | 31 +++++++++++++++++-- server/src/api/socket.js | 5 +-- server/src/containers/docker.js | 26 +++++++--------- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js index 79b0940..6272215 100644 --- a/server/src/api/controllers/sessionController.js +++ b/server/src/api/controllers/sessionController.js @@ -1,5 +1,6 @@ import Session from '../../models/session.js' import Quest from '../../models/quest.js' +import { createContainer } from '../../containers/docker.js' export async function getSessionList(req, res) { try { @@ -7,8 +8,31 @@ export async function getSessionList(req, res) { if (req.query.quest_id) { query.quest = req.query.quest_id } + if (req.query.status) { + query.status = req.query.status + } const sessions = await Session.find(query) - res.json(sessions) + res.json( + sessions.map( + ({ + _id, + user, + quest, + status, + progress, + createdAt, + lastActivityAt, + }) => ({ + _id, + user, + quest, + status, + progress, + createdAt, + lastActivityAt, + }) + ) + ) } catch (err) { console.error(err) res.status(500).json({ message: err.message }) @@ -24,11 +48,14 @@ export async function createSession(req, res) { try { const progress = quest.stages[0].id + const container = await createContainer( + `quest-${quest.id}-${req.user.username}-${Date.now()}` + ) const newSession = new Session({ user: req.user, quest, progress, - containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`, + containerId: container.id, }) const session = await newSession.save() diff --git a/server/src/api/socket.js b/server/src/api/socket.js index a56fcb8..25d26d6 100644 --- a/server/src/api/socket.js +++ b/server/src/api/socket.js @@ -1,5 +1,5 @@ import { Server } from 'socket.io' -import { getOrCreateContainer, attachContainer } from '../containers/docker.js' +import { getAndStartContainer, attachContainer } from '../containers/docker.js' import Session from '../models/session.js' import { defaultUser, genSessionJWT } from '../utils/auth.js' @@ -47,6 +47,7 @@ export default (server) => { session = await Session.findOne({ _id: sessionId, user: socket.user, + status: 'active', }) if (!session) { socket.send('Session not found.') @@ -61,7 +62,7 @@ export default (server) => { const token = await genSessionJWT(session) - const container = await getOrCreateContainer(session.containerId) + const container = await getAndStartContainer(session.containerId) const stream = await attachContainer(container, { token }) stream.on('data', (chunk) => { diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index f4a2493..8dc0fbb 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -24,7 +24,16 @@ const containerOptions = { const network = engine.getNetwork(config.dockerNetwork) -export async function getOrCreateContainer(id) { +export async function createContainer(name) { + const container = await engine.createContainer({ + ...containerOptions, + name, + }) + await network.connect({ Container: container.id }) + return container +} + +export async function getAndStartContainer(id) { console.log(`Getting container: ${id}`) let container try { @@ -34,20 +43,7 @@ export async function getOrCreateContainer(id) { } return container } catch (error) { - try { - container = await engine.createContainer({ - ...containerOptions, - name: id, - }) - } catch (err) { - console.log(err) - throw err - } - await network.connect({ Container: container.id }) - - await container.start() - console.log(container.id) - return container + throw new Error(`Container not found: ${id}`) } } From 22060070b99f2440743443303208d6b0684101e9 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 23 Aug 2023 12:22:15 +0800 Subject: [PATCH 3/7] feat: add session expiry deletion script and scheduler --- package.json | 3 ++- server/package.json | 5 +++-- server/src/cli/clean.js | 12 ++++++++++ server/src/config.js | 1 + server/src/containers/docker.js | 15 +++++++++++++ server/src/containers/expiryChecker.js | 28 +++++++++++++++++++++++ server/src/index.js | 2 ++ yarn.lock | 31 ++++++++++++++++++++++++++ 8 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 server/src/cli/clean.js create mode 100644 server/src/containers/expiryChecker.js diff --git a/package.json b/package.json index 6ecf712..3713418 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "server": "yarn workspace server dev", "cli": "yarn workspace client start", "lint": "eslint --ext .js,.vue .", - "format": "prettier --ignore-path .prettierignore --write app server" + "format": "prettier --ignore-path .prettierignore --write app server", + "clean": "yarn workspace server clean" }, "devDependencies": { "@vue/eslint-config-prettier": "^7.1.0", diff --git a/server/package.json b/server/package.json index 9895bae..0fe6b1f 100644 --- a/server/package.json +++ b/server/package.json @@ -6,8 +6,8 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "cli": "node src/client.js", - "test": "yarn node --experimental-vm-modules $(yarn bin jest)" + "test": "yarn node --experimental-vm-modules $(yarn bin jest)", + "clean": "node src/cli/clean.js" }, "dependencies": { "commander": "^11.0.0", @@ -16,6 +16,7 @@ "jsonwebtoken": "^9.0.1", "minimist": "^1.2.8", "mongoose": "^7.4.0", + "node-schedule": "^2.1.1", "socket.io": "^4.7.1", "swagger-ui-express": "^5.0.0", "yaml": "^2.3.1" diff --git a/server/src/cli/clean.js b/server/src/cli/clean.js new file mode 100644 index 0000000..ab26620 --- /dev/null +++ b/server/src/cli/clean.js @@ -0,0 +1,12 @@ +import { removeExpired } from '../containers/expiryChecker.js' +import connectDB from '../db.js' + +connectDB() + .then(removeExpired) + .then(() => { + process.exit(0) + }) + .catch((err) => { + console.error(err) + process.exit(1) + }) diff --git a/server/src/config.js b/server/src/config.js index 5e969da..9190ade 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -8,4 +8,5 @@ export default { dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'), isProduction: process.env.NODE_ENV === 'production', hostPwd: get('HOST_PWD', ''), + expiry: get('EXPIRY', 1000 * 60 * 60), } diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 8dc0fbb..99f6d59 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -65,3 +65,18 @@ export async function attachContainer(container, { token }) { return execOutput.socket } + +export async function deleteContainer(id) { + const container = engine.getContainer(id) + if (!container) { + return + } + try { + await container.stop() + await container.remove({ + force: true, + }) + } catch (error) { + console.log(error) + } +} diff --git a/server/src/containers/expiryChecker.js b/server/src/containers/expiryChecker.js new file mode 100644 index 0000000..e6aba70 --- /dev/null +++ b/server/src/containers/expiryChecker.js @@ -0,0 +1,28 @@ +import schedule from 'node-schedule' +import config from '../config.js' +import Session from '../models/session.js' +import { deleteContainer } from './docker.js' + +export async function removeExpired() { + // Find all sessions that are containerId is not null and lastActivityAt is + // less than the current time minus the expiry time + console.log('Checking for expired sessions') + const sessions = await Session.find({ + containerId: { $ne: null }, + lastActivityAt: { $lt: new Date(Date.now() - config.expiry) }, + }) + await Promise.all( + sessions.map(async (session) => { + console.log('Expiring session: ', session._id, session.containerId) + await deleteContainer(session.containerId) + session.status = 'deactive' + session.containerId = null + await session.save() + }) + ) + console.log('Done checking for expired sessions') +} + +export default function expiryRemovalScheduler() { + schedule.scheduleJob('*/5 * * * *', removeExpired) +} diff --git a/server/src/index.js b/server/src/index.js index 4b5ad47..d7570b3 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -10,6 +10,7 @@ import apiRouter from './api/routes/index.js' import loadAndUpdateQuests from './utils/quest.js' import config from './config.js' import errorHandler from './middleware/error.js' +import expiryRemovalScheduler from './containers/expiryChecker.js' async function main() { const app = express() @@ -17,6 +18,7 @@ async function main() { socketServer(server) await connectDB() await loadAndUpdateQuests() + expiryRemovalScheduler() const file = await fs.readFile('./swagger.yaml', 'utf8') const swaggerDocument = YAML.parse(file) diff --git a/yarn.lock b/yarn.lock index 0ed3bfe..874a6cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1618,6 +1618,13 @@ cpu-features@~0.0.8: buildcheck "~0.0.6" nan "^2.17.0" +cron-parser@^4.2.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -3402,6 +3409,11 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +long-timeout@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -3416,6 +3428,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^3.2.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.1.tgz#9147374b2c539e7893f906c420e9b080b59c5457" + integrity sha512-2USspxOCXWGIKHwuQ9XElxPPYrDOJHDQ5DQ870CoD+CxJbBnRDIBCfhioUJJjct7BKOy80Ia8cVstIcIMb/0+Q== + magic-string@^0.30.0: version "0.30.1" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.1.tgz#ce5cd4b0a81a5d032bd69aab4522299b2166284d" @@ -3617,6 +3634,15 @@ node-releases@^2.0.12: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== +node-schedule@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-2.1.1.tgz#6958b2c5af8834954f69bb0a7a97c62b97185de3" + integrity sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ== + dependencies: + cron-parser "^4.2.0" + long-timeout "0.1.1" + sorted-array-functions "^1.3.0" + nodemon@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.0.1.tgz#affe822a2c5f21354466b2fc8ae83277d27dadc7" @@ -4308,6 +4334,11 @@ socks@^2.7.1: ip "^2.0.0" smart-buffer "^4.2.0" +sorted-array-functions@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" + integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" From ad3dd2fb180e88dbb42da41fbb76333270d94389 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 23 Aug 2023 12:27:20 +0800 Subject: [PATCH 4/7] fix: default retrieve active session + typo 'deactive' -> 'inactive', --- server/src/api/controllers/sessionController.js | 4 +--- server/src/containers/expiryChecker.js | 2 +- server/src/models/session.js | 2 +- server/swagger.yaml | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js index 6272215..aa37963 100644 --- a/server/src/api/controllers/sessionController.js +++ b/server/src/api/controllers/sessionController.js @@ -8,9 +8,7 @@ export async function getSessionList(req, res) { if (req.query.quest_id) { query.quest = req.query.quest_id } - if (req.query.status) { - query.status = req.query.status - } + query.status = req.query.status || 'active' const sessions = await Session.find(query) res.json( sessions.map( diff --git a/server/src/containers/expiryChecker.js b/server/src/containers/expiryChecker.js index e6aba70..2a24136 100644 --- a/server/src/containers/expiryChecker.js +++ b/server/src/containers/expiryChecker.js @@ -15,7 +15,7 @@ export async function removeExpired() { sessions.map(async (session) => { console.log('Expiring session: ', session._id, session.containerId) await deleteContainer(session.containerId) - session.status = 'deactive' + session.status = 'inactive' session.containerId = null await session.save() }) diff --git a/server/src/models/session.js b/server/src/models/session.js index 4757618..1ae266c 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -15,7 +15,7 @@ const Session = model( containerId: String, status: { type: String, - enum: ['active', 'finished', 'deactive'], + enum: ['active', 'finished', 'inactive'], required: true, default: 'active', }, diff --git a/server/swagger.yaml b/server/swagger.yaml index d2dfceb..34f9bf1 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -36,7 +36,7 @@ paths: description: find sessions by status schema: type: string - enum: [active, finished, deactive] + enum: [active, finished, inactive] responses: '200': description: list of existing session From 27f85255851c9d1e272cc1c242e337931d22a42b Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 24 Aug 2023 11:35:43 +0800 Subject: [PATCH 5/7] fix: add missing "error" field in command --- server/src/models/command.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/models/command.js b/server/src/models/command.js index ce64e7c..277a2df 100644 --- a/server/src/models/command.js +++ b/server/src/models/command.js @@ -17,11 +17,11 @@ const Command = model( type: String, required: true, }, - exit_code: { - type: Number, + output: { + type: String, required: false, }, - output: { + error: { type: String, required: false, }, From 3f50b99d1ae2f5802e4100b86c6a0fd6905fe50f Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 24 Aug 2023 11:36:24 +0800 Subject: [PATCH 6/7] fix: not to use 'force' in container deletion --- server/src/containers/docker.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 99f6d59..4c9f989 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -73,10 +73,8 @@ export async function deleteContainer(id) { } try { await container.stop() - await container.remove({ - force: true, - }) + await container.remove() } catch (error) { - console.log(error) + console.error(error) } } From 4e03be94084570e5433a9627b4629802464875f8 Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 24 Aug 2023 11:36:36 +0800 Subject: [PATCH 7/7] fix: disable restart in dev docker compose --- docker-compose.yml | 2 -- docker-scripts.sh | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8c27a73..e5b2474 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: volumes: - ./app/src:/app/src backend: - restart: on-failure build: context: . dockerfile: server/Dockerfile.dev @@ -31,7 +30,6 @@ services: - HOST_PWD=${PWD} db: image: mongo - restart: always networks: - internal ports: diff --git a/docker-scripts.sh b/docker-scripts.sh index 89cea7b..7fcb8c3 100755 --- a/docker-scripts.sh +++ b/docker-scripts.sh @@ -7,8 +7,12 @@ inspect() { docker compose logs $SERVICE -f --no-log-prefix } +list() { + docker ps -a | grep 'quest-' | awk '{print $1}' +} + clean() { - containers=$(docker ps -a | grep 'quest-' | awk '{print $1}') + containers=$(list) echo $containers docker rm -f $containers