Merge pull request #14 from lancatlin/server-session-manage

* add `status` and `lastActivityAt` field to session
* automatically delete expiry container, mark session as inactive
* default retrieving only active session on `GET /sessions`
* create container on session creation, start container on socket connection
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-08-24 11:38:02 +08:00
commit 5da311d9a4
17 files changed
+173 -29

No files matched your search

-2
View File
@@ -11,7 +11,6 @@ services:
volumes: volumes:
- ./app/src:/app/src - ./app/src:/app/src
backend: backend:
restart: on-failure
build: build:
context: . context: .
dockerfile: server/Dockerfile.dev dockerfile: server/Dockerfile.dev
@@ -31,7 +30,6 @@ services:
- HOST_PWD=${PWD} - HOST_PWD=${PWD}
db: db:
image: mongo image: mongo
restart: always
networks: networks:
- internal - internal
ports: ports:
+5 -1
View File
@@ -7,8 +7,12 @@ inspect() {
docker compose logs $SERVICE -f --no-log-prefix docker compose logs $SERVICE -f --no-log-prefix
} }
list() {
docker ps -a | grep 'quest-' | awk '{print $1}'
}
clean() { clean() {
containers=$(docker ps -a | grep 'quest-' | awk '{print $1}') containers=$(list)
echo $containers echo $containers
docker rm -f $containers docker rm -f $containers
+2 -1
View File
@@ -16,7 +16,8 @@
"server": "yarn workspace server dev", "server": "yarn workspace server dev",
"cli": "yarn workspace client start", "cli": "yarn workspace client start",
"lint": "eslint --ext .js,.vue .", "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": { "devDependencies": {
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^7.1.0",
+3 -2
View File
@@ -6,8 +6,8 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon 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": { "dependencies": {
"commander": "^11.0.0", "commander": "^11.0.0",
@@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.1",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"mongoose": "^7.4.0", "mongoose": "^7.4.0",
"node-schedule": "^2.1.1",
"socket.io": "^4.7.1", "socket.io": "^4.7.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
@@ -26,10 +26,18 @@ export async function newCommand(req, res) {
const session = await Session.findById(sessionId) const session = await Session.findById(sessionId)
if (!session) { if (!session) {
res.status(401).json({ message: 'session not found' }) res.status(404).json({ message: 'session not found' })
return 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({ const c = new Command({
session, session,
command, command,
@@ -1,5 +1,6 @@
import Session from '../../models/session.js' import Session from '../../models/session.js'
import Quest from '../../models/quest.js' import Quest from '../../models/quest.js'
import { createContainer } from '../../containers/docker.js'
export async function getSessionList(req, res) { export async function getSessionList(req, res) {
try { try {
@@ -7,8 +8,29 @@ export async function getSessionList(req, res) {
if (req.query.quest_id) { if (req.query.quest_id) {
query.quest = req.query.quest_id query.quest = req.query.quest_id
} }
query.status = req.query.status || 'active'
const sessions = await Session.find(query) 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) { } catch (err) {
console.error(err) console.error(err)
res.status(500).json({ message: err.message }) res.status(500).json({ message: err.message })
@@ -24,11 +46,14 @@ export async function createSession(req, res) {
try { try {
const progress = quest.stages[0].id const progress = quest.stages[0].id
const container = await createContainer(
`quest-${quest.id}-${req.user.username}-${Date.now()}`
)
const newSession = new Session({ const newSession = new Session({
user: req.user, user: req.user,
quest, quest,
progress, progress,
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`, containerId: container.id,
}) })
const session = await newSession.save() const session = await newSession.save()
+3 -2
View File
@@ -1,5 +1,5 @@
import { Server } from 'socket.io' 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 Session from '../models/session.js'
import { defaultUser, genSessionJWT } from '../utils/auth.js' import { defaultUser, genSessionJWT } from '../utils/auth.js'
@@ -47,6 +47,7 @@ export default (server) => {
session = await Session.findOne({ session = await Session.findOne({
_id: sessionId, _id: sessionId,
user: socket.user, user: socket.user,
status: 'active',
}) })
if (!session) { if (!session) {
socket.send('Session not found.') socket.send('Session not found.')
@@ -61,7 +62,7 @@ export default (server) => {
const token = await genSessionJWT(session) const token = await genSessionJWT(session)
const container = await getOrCreateContainer(session.containerId) const container = await getAndStartContainer(session.containerId)
const stream = await attachContainer(container, { token }) const stream = await attachContainer(container, { token })
stream.on('data', (chunk) => { stream.on('data', (chunk) => {
+12
View File
@@ -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)
})
+1
View File
@@ -8,4 +8,5 @@ export default {
dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'), dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'),
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === 'production',
hostPwd: get('HOST_PWD', ''), hostPwd: get('HOST_PWD', ''),
expiry: get('EXPIRY', 1000 * 60 * 60),
} }
+24 -15
View File
@@ -24,7 +24,16 @@ const containerOptions = {
const network = engine.getNetwork(config.dockerNetwork) 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}`) console.log(`Getting container: ${id}`)
let container let container
try { try {
@@ -34,20 +43,7 @@ export async function getOrCreateContainer(id) {
} }
return container return container
} catch (error) { } catch (error) {
try { throw new Error(`Container not found: ${id}`)
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
} }
} }
@@ -69,3 +65,16 @@ export async function attachContainer(container, { token }) {
return execOutput.socket return execOutput.socket
} }
export async function deleteContainer(id) {
const container = engine.getContainer(id)
if (!container) {
return
}
try {
await container.stop()
await container.remove()
} catch (error) {
console.error(error)
}
}
+28
View File
@@ -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 = 'inactive'
session.containerId = null
await session.save()
})
)
console.log('Done checking for expired sessions')
}
export default function expiryRemovalScheduler() {
schedule.scheduleJob('*/5 * * * *', removeExpired)
}
+6
View File
@@ -42,6 +42,12 @@ async function commandHandler(session, commandInput, additionalData) {
session.progress = stage.next session.progress = stage.next
await session.save() await session.save()
if (stage.next === 'END') {
session.status = 'finished'
session.finishedAt = new Date()
await session.save()
}
return { return {
responses: stage.responses, responses: stage.responses,
hints: stage.hints, hints: stage.hints,
+2
View File
@@ -10,6 +10,7 @@ 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 config from './config.js'
import errorHandler from './middleware/error.js' import errorHandler from './middleware/error.js'
import expiryRemovalScheduler from './containers/expiryChecker.js'
async function main() { async function main() {
const app = express() const app = express()
@@ -17,6 +18,7 @@ async function main() {
socketServer(server) socketServer(server)
await connectDB() await connectDB()
await loadAndUpdateQuests() await loadAndUpdateQuests()
expiryRemovalScheduler()
const file = await fs.readFile('./swagger.yaml', 'utf8') const file = await fs.readFile('./swagger.yaml', 'utf8')
const swaggerDocument = YAML.parse(file) const swaggerDocument = YAML.parse(file)
+3 -3
View File
@@ -17,11 +17,11 @@ const Command = model(
type: String, type: String,
required: true, required: true,
}, },
exit_code: { output: {
type: Number, type: String,
required: false, required: false,
}, },
output: { error: {
type: String, type: String,
required: false, required: false,
}, },
+11
View File
@@ -13,7 +13,18 @@ const Session = model(
ref: 'Quest', ref: 'Quest',
}, },
containerId: String, containerId: String,
status: {
type: String,
enum: ['active', 'finished', 'inactive'],
required: true,
default: 'active',
},
finishedAt: Date, finishedAt: Date,
lastActivityAt: {
type: Date,
default: Date.now,
required: true,
},
progress: String, progress: String,
completion: [String], completion: [String],
hints: [String], hints: [String],
+6
View File
@@ -31,6 +31,12 @@ paths:
description: find sessions by quest id description: find sessions by quest id
schema: schema:
type: string type: string
- in: query
name: status
description: find sessions by status
schema:
type: string
enum: [active, finished, inactive]
responses: responses:
'200': '200':
description: list of existing session description: list of existing session
+31
View File
@@ -1618,6 +1618,13 @@ cpu-features@~0.0.8:
buildcheck "~0.0.6" buildcheck "~0.0.6"
nan "^2.17.0" 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: cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" 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" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 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: lru-cache@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -3416,6 +3428,11 @@ lru-cache@^6.0.0:
dependencies: dependencies:
yallist "^4.0.0" 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: magic-string@^0.30.0:
version "0.30.1" version "0.30.1"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.1.tgz#ce5cd4b0a81a5d032bd69aab4522299b2166284d" 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" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== 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: nodemon@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.0.1.tgz#affe822a2c5f21354466b2fc8ae83277d27dadc7" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.0.1.tgz#affe822a2c5f21354466b2fc8ae83277d27dadc7"
@@ -4308,6 +4334,11 @@ socks@^2.7.1:
ip "^2.0.0" ip "^2.0.0"
smart-buffer "^4.2.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: source-map-js@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"