From debd01c82e02facb3a5e9c5d369fa62f4c1c3892 Mon Sep 17 00:00:00 2001 From: 0fatal <72899968+0fatal@users.noreply.github.com> Date: Mon, 5 Feb 2024 16:39:18 +0800 Subject: [PATCH] feat(runtime&server): support updating dependencies without restarting (#1823) * feat(runtime): support updating dependencies without restarting * feat(server): publish conf when dep changes * change dev script * clear module cache * fix upload node_modules --- runtimes/nodejs/Dockerfile | 2 + runtimes/nodejs/Dockerfile.init | 5 + runtimes/nodejs/init.sh | 33 +--- runtimes/nodejs/package.json | 2 +- .../conf-change-stream.ts | 44 ++++- runtimes/nodejs/src/support/engine/module.ts | 6 +- .../nodejs/src/support/module-hot-reload.ts | 163 ++++++++++++++++++ runtimes/nodejs/start.sh | 7 + runtimes/nodejs/upload-dependencies.sh | 33 ++++ server/src/dependency/dependency.service.ts | 24 ++- server/src/instance/instance.service.ts | 12 ++ 11 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 runtimes/nodejs/src/support/module-hot-reload.ts create mode 100644 runtimes/nodejs/upload-dependencies.sh diff --git a/runtimes/nodejs/Dockerfile b/runtimes/nodejs/Dockerfile index c1ededacf9..1a2f0ebe29 100644 --- a/runtimes/nodejs/Dockerfile +++ b/runtimes/nodejs/Dockerfile @@ -16,8 +16,10 @@ ENV FORCE_COLOR=1 COPY . /app # COPY --chown=node:node . /app RUN mkdir /app/data || true +RUN mkdir /tmp/custom_dependency || true RUN chown node:node /app/data RUN chown node:node /app/functions +RUN chown node:node /tmp/custom_dependency # RUN npm install # RUN npm run build RUN chown -R node:node /app/node_modules diff --git a/runtimes/nodejs/Dockerfile.init b/runtimes/nodejs/Dockerfile.init index 2955ab2412..52d0404daa 100644 --- a/runtimes/nodejs/Dockerfile.init +++ b/runtimes/nodejs/Dockerfile.init @@ -3,5 +3,10 @@ FROM node:20.10.0 WORKDIR /app COPY ./init.sh /app/init.sh +COPY ./upload-dependencies.sh /app/upload-dependencies.sh + +RUN chown -R node:node /app + +USER node CMD [ "sh", "/app/init.sh" ] diff --git a/runtimes/nodejs/init.sh b/runtimes/nodejs/init.sh index c7167c7018..0850d403c8 100644 --- a/runtimes/nodejs/init.sh +++ b/runtimes/nodejs/init.sh @@ -83,35 +83,4 @@ end_time=$(date +%s) elapsed_time=$(expr $end_time - $start_time) echo "Installed dependencies in $elapsed_time seconds." -### cache node_modules ### -# if $NODE_MODULES_PUSH_URL is not empty -if [ -n "$NODE_MODULES_PUSH_URL" ]; then - # temporarily disable set -e - set +e - - start_time=$(date +%s) - echo $DEPENDENCIES > node_modules/.dependencies - echo "Uploading node_modules to $NODE_MODULES_PUSH_URL" - - # tar `node_modules` to node_modules.tar - tar -cf node_modules.tar ./node_modules - - end_time_1=$(date +%s) - elapsed_time=$(expr $end_time_1 - $start_time) - echo "Compressed node_modules in $elapsed_time seconds." - - # upload node_modules.tar to $NODE_MODULES_PUSH_URL - curl -sSfL -X PUT -T node_modules.tar $NODE_MODULES_PUSH_URL - - - if [ $? -ne 0 ]; then - echo "Failed to upload node_modules cache." - else - end_time_2=$(date +%s) - elapsed_time_2=$(expr $end_time_2 - $end_time) - echo "Uploaded node_modules.tar in $elapsed_time_2 seconds." - fi - - # re-enable set -e - set -e -fi +sh /app/upload-dependencies.sh /app \ No newline at end of file diff --git a/runtimes/nodejs/package.json b/runtimes/nodejs/package.json index eb49911867..ab406d4784 100644 --- a/runtimes/nodejs/package.json +++ b/runtimes/nodejs/package.json @@ -7,7 +7,7 @@ "private": "true", "scripts": { "start": "node ./dist/index.js", - "dev": "npx concurrently npm:dev:*", + "dev": "npx ts-node ./src/index.ts", "dev:start": "npx nodemon ./dist/index.js", "dev:watch": "npm run watch", "build": "tsc -p tsconfig.json", diff --git a/runtimes/nodejs/src/support/database-change-stream/conf-change-stream.ts b/runtimes/nodejs/src/support/database-change-stream/conf-change-stream.ts index c79adcfcc6..ea0bf13076 100644 --- a/runtimes/nodejs/src/support/database-change-stream/conf-change-stream.ts +++ b/runtimes/nodejs/src/support/database-change-stream/conf-change-stream.ts @@ -1,18 +1,23 @@ import { CONFIG_COLLECTION } from '../../constants' import { DatabaseAgent } from '../../db' import { DatabaseChangeStream } from '.' +import { + installDependencies, + uninstallDependencies, +} from '../module-hot-reload' export class ConfChangeStream { + static dependencies = [] + static initialize() { - this.updateEnvironments() + this.updateConfig(true) - DatabaseChangeStream.onStreamChange( - CONFIG_COLLECTION, - this.updateEnvironments, + DatabaseChangeStream.onStreamChange(CONFIG_COLLECTION, () => + this.updateConfig(false), ) } - private static async updateEnvironments() { + private static async updateConfig(init = false) { const conf = await DatabaseAgent.db .collection(CONFIG_COLLECTION) .findOne({}) @@ -25,5 +30,34 @@ export class ConfChangeStream { for (const env of environments) { process.env[env.name] = env.value } + + if (init) { + ConfChangeStream.dependencies = conf.dependencies + return + } + + const newDeps = [] + const unneededDeps = [] + + for (const dep of conf.dependencies) { + if (!ConfChangeStream.dependencies.includes(dep)) { + newDeps.push(dep) + } + } + + for (const dep of ConfChangeStream.dependencies) { + if (!conf.dependencies.includes(dep)) { + unneededDeps.push(dep) + } + } + + ConfChangeStream.dependencies = conf.dependencies + + if (newDeps.length > 0) { + installDependencies(newDeps) + } + if (unneededDeps.length > 0) { + uninstallDependencies(unneededDeps) + } } } diff --git a/runtimes/nodejs/src/support/engine/module.ts b/runtimes/nodejs/src/support/engine/module.ts index a486f90c2a..dfff3f3371 100644 --- a/runtimes/nodejs/src/support/engine/module.ts +++ b/runtimes/nodejs/src/support/engine/module.ts @@ -7,14 +7,12 @@ import { createRequire } from 'node:module' import * as path from 'node:path' import { ObjectId } from 'mongodb' -const CUSTOM_DEPENDENCY_NODE_MODULES_PATH = `${Config.CUSTOM_DEPENDENCY_BASE_PATH}/node_modules/` +export const CUSTOM_DEPENDENCY_NODE_MODULES_PATH = `${Config.CUSTOM_DEPENDENCY_BASE_PATH}/node_modules/` export class FunctionModule { protected static cache: Map = new Map() - private static customRequire = createRequire( - CUSTOM_DEPENDENCY_NODE_MODULES_PATH, - ) + static customRequire = createRequire(CUSTOM_DEPENDENCY_NODE_MODULES_PATH) static get(functionName: string): any { const moduleName = `@/${functionName}` diff --git a/runtimes/nodejs/src/support/module-hot-reload.ts b/runtimes/nodejs/src/support/module-hot-reload.ts new file mode 100644 index 0000000000..ebbe69e5c6 --- /dev/null +++ b/runtimes/nodejs/src/support/module-hot-reload.ts @@ -0,0 +1,163 @@ +import { exec } from 'child_process' +import * as fs from 'fs' +import Module from 'module' +import path from 'path' +import Config from '../config' +import { logger } from './logger' +import { + CUSTOM_DEPENDENCY_NODE_MODULES_PATH, + FunctionModule, +} from './engine/module' + +// === override to disable cache +// @ts-ignore +const originModuleStat = Module._stat +// @ts-ignore +Module._stat = (filename: string) => { + if (!filename.startsWith(CUSTOM_DEPENDENCY_NODE_MODULES_PATH)) { + return originModuleStat(filename) + } + filename = path.toNamespacedPath(filename) + + let stat + try { + stat = fs.statSync(filename) + } catch (e) { + return -2 // not found + } + if (stat.isDirectory()) { + return 1 + } + return 0 +} + +// @ts-ignore +const originModuleReadPackage = Module._readPackage +// @ts-ignore +Module._readPackage = (requestPath: string) => { + const pkg = originModuleReadPackage(requestPath) + if ( + pkg.exists === false && + pkg.pjsonPath.startsWith(CUSTOM_DEPENDENCY_NODE_MODULES_PATH) + ) { + try { + const _pkg = JSON.parse(fs.readFileSync(pkg.pjsonPath, 'utf8')) + pkg.main = _pkg.main + pkg.exists = true + } catch {} + } + return pkg +} +// === + +export function clearModuleCache(moduleId: string) { + let filePath: string + + try { + filePath = FunctionModule.customRequire.resolve(moduleId) + } catch {} + + if (!filePath) { + return + } + + // Delete itself from module parent + if (require.cache[filePath] && require.cache[filePath].parent) { + let i = require.cache[filePath].parent.children.length + + while (i--) { + if (require.cache[filePath].parent.children[i].id === filePath) { + require.cache[filePath].parent.children.splice(i, 1) + } + } + } + + // Remove all descendants from cache as well + if (require.cache[filePath]) { + const children = require.cache[filePath].children.map((child) => child.id) + + // Delete module from cache + delete require.cache[filePath] + + for (const id of children) { + clearModuleCache(id) + } + } +} + +const getPackageNameWithoutVersion = (name: string) => + name.slice(0, name.indexOf('@', 1)) + +export function installDependency(packageName: string) { + return new Promise((resolve, reject) => { + logger.info(`Installing package ${packageName} ...`) + exec( + `cd ${ + Config.CUSTOM_DEPENDENCY_BASE_PATH + } && npm install ${packageName} && (sh ${process.cwd()}/upload-dependencies.sh ${ + Config.CUSTOM_DEPENDENCY_BASE_PATH + } > /dev/null 2>&1) &`, + (error, stdout) => { + if (error) { + logger.error(`Error installing package ${packageName}: ${error}`) + return reject(error) + } + // if (stderr) { + // logger.error(`Error installing package ${packageName}: ${stderr}`) + // return reject(new Error(stderr)) + // } + logger.info(`Package ${packageName} installed success`) + resolve(stdout) + }, + ) + }) +} + +export function installDependencies(packageName: string[]) { + return installDependency(packageName.join(' ')) + .catch(() => {}) + .finally(() => { + packageName.forEach((v) => { + clearModuleCache(getPackageNameWithoutVersion(v)) + }) + }) +} + +export function uninstallDependency(packageName: string) { + return new Promise((resolve, reject) => { + logger.info(`Uninstalling package ${packageName} ...`) + exec( + `cd ${ + Config.CUSTOM_DEPENDENCY_BASE_PATH + } && npm uninstall ${packageName} && (sh ${process.cwd()}/upload-dependencies.sh ${ + Config.CUSTOM_DEPENDENCY_BASE_PATH + } > /dev/null 2>&1) &`, + (error, stdout) => { + if (error) { + logger.error(`Error uninstalling package ${packageName}: ${error}`) + return reject(error) + } + // if (stderr) { + // logger.error(`Error uninstalling package ${packageName}: ${stderr}`) + // return reject(new Error(stderr)) + // } + logger.info(`Package ${packageName} uninstalled success`) + resolve(stdout) + }, + ) + }) +} + +export function uninstallDependencies(packageName: string[]) { + packageName.forEach((v) => clearModuleCache(getPackageNameWithoutVersion(v))) + + return uninstallDependency( + packageName.map((v) => getPackageNameWithoutVersion(v)).join(' '), + ) + .catch(() => {}) + .finally(() => { + packageName.forEach((v) => + clearModuleCache(getPackageNameWithoutVersion(v)), + ) + }) +} diff --git a/runtimes/nodejs/start.sh b/runtimes/nodejs/start.sh index f2ac8d0427..a463201966 100644 --- a/runtimes/nodejs/start.sh +++ b/runtimes/nodejs/start.sh @@ -2,6 +2,13 @@ ln -s $CUSTOM_DEPENDENCY_BASE_PATH/node_modules $PWD/functions/node_modules > /dev/null 2>&1 +# generate package.json +( + cd $CUSTOM_DEPENDENCY_BASE_PATH + echo '{}' > package.json + npm install $NPM_INSTALL_FLAGS > /dev/null 2>&1 +) + # source .env echo "****** start service: node $FLAGS --experimental-vm-modules --experimental-fetch ./dist/index.js *******" exec node $FLAGS --experimental-vm-modules --experimental-fetch ./dist/index.js \ No newline at end of file diff --git a/runtimes/nodejs/upload-dependencies.sh b/runtimes/nodejs/upload-dependencies.sh new file mode 100644 index 0000000000..2b25b67411 --- /dev/null +++ b/runtimes/nodejs/upload-dependencies.sh @@ -0,0 +1,33 @@ +### cache node_modules ### +# if $NODE_MODULES_PUSH_URL is not empty +if [ -n "$NODE_MODULES_PUSH_URL" ]; then + NODE_MODULES_PATH=$1 + # temporarily disable set -e + set +e + + start_time=$(date +%s) + echo $DEPENDENCIES > $NODE_MODULES_PATH/node_modules/.dependencies + echo "Uploading node_modules to $NODE_MODULES_PUSH_URL" + + # tar `node_modules` to node_modules.tar + tar -cf $NODE_MODULES_PATH/node_modules.tar $NODE_MODULES_PATH/node_modules + + end_time_1=$(date +%s) + elapsed_time=$(expr $end_time_1 - $start_time) + echo "Compressed node_modules in $elapsed_time seconds." + + # upload node_modules.tar to $NODE_MODULES_PUSH_URL + curl -sSfL -X PUT -T $NODE_MODULES_PATH/node_modules.tar $NODE_MODULES_PUSH_URL + + + if [ $? -ne 0 ]; then + echo "Failed to upload node_modules cache." + else + end_time_2=$(date +%s) + elapsed_time_2=$(expr $end_time_2 - $end_time) + echo "Uploaded node_modules.tar in $elapsed_time_2 seconds." + fi + + # re-enable set -e + set -e +fi \ No newline at end of file diff --git a/server/src/dependency/dependency.service.ts b/server/src/dependency/dependency.service.ts index a24e685ffd..1083548f95 100644 --- a/server/src/dependency/dependency.service.ts +++ b/server/src/dependency/dependency.service.ts @@ -5,6 +5,7 @@ import { CreateDependencyDto } from './dto/create-dependency.dto' import { UpdateDependencyDto } from './dto/update-dependency.dto' import { SystemDatabase } from 'src/system-database' import { ApplicationConfiguration } from 'src/application/entities/application-configuration' +import { ApplicationConfigurationService } from 'src/application/configuration.service' export class Dependency { name: string @@ -18,6 +19,8 @@ export class DependencyService { private readonly logger = new Logger(DependencyService.name) private readonly db = SystemDatabase.db + constructor(private readonly confService: ApplicationConfigurationService) {} + /** * Get app merged dependencies in `Dependency` array * @param appid @@ -52,13 +55,14 @@ export class DependencyService { const new_deps = dto.map((dep) => `${dep.name}@${dep.spec}`) const deps = extras.concat(new_deps) - await this.db + const res = await this.db .collection('ApplicationConfiguration') - .updateOne( + .findOneAndUpdate( { appid }, { $set: { dependencies: deps, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) - + await this.confService.publish(res.value) return true } @@ -83,13 +87,14 @@ export class DependencyService { const deps = filtered.concat(new_deps) - await this.db + const res = await this.db .collection('ApplicationConfiguration') - .updateOne( + .findOneAndUpdate( { appid }, { $set: { dependencies: deps, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) - + await this.confService.publish(res.value) return true } @@ -102,13 +107,14 @@ export class DependencyService { if (filtered.length === deps.length) return false - await this.db + const res = await this.db .collection('ApplicationConfiguration') - .updateOne( + .findOneAndUpdate( { appid }, { $set: { dependencies: filtered, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) - + await this.confService.publish(res.value) return true } diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index c03024ef5c..6e80867c22 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -376,6 +376,9 @@ export class InstanceService { allowPrivilegeEscalation: false, readOnlyRootFilesystem: false, privileged: false, + capabilities: { + drop: ['ALL'], + }, }, }, ], @@ -419,6 +422,15 @@ export class InstanceService { }, }, ], + securityContext: { + runAsUser: 1000, // node + runAsGroup: 2000, + runAsNonRoot: true, + fsGroup: 2000, + seccompProfile: { + type: 'RuntimeDefault', + }, + }, }, // end of spec {} }, // end of template {} }