Skip to content

Commit 383f567

Browse files
committed
server(plugins): upate without downtime
Create a new folder each time a new plugin/theme is installed or updated. The folder name is created based on the package.json content hash. closes Chocobozzz#4828
1 parent ca26687 commit 383f567

File tree

7 files changed

+95
-40
lines changed

7 files changed

+95
-40
lines changed

packages/server-commands/src/server/plugins-command.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,6 @@ export class PluginsCommand extends AbstractCommand {
253253
}
254254

255255
private getPackageJSONPath (npmName: string) {
256-
return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json'))
256+
return this.server.servers.buildDirectory(join('plugins', 'latest', 'node_modules', npmName, 'package.json'))
257257
}
258258
}

packages/tests/src/api/server/plugins.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ describe('Test plugins', function () {
370370
const query = `UPDATE "application" SET "nodeABIVersion" = 1`
371371
await sqlCommand.updateQuery(query)
372372

373-
const baseNativeModule = server.servers.buildDirectory(join('plugins', 'node_modules', 'a-native-example'))
373+
const baseNativeModule = server.servers.buildDirectory(join('plugins', 'latest', 'node_modules', 'a-native-example'))
374374

375375
await removeNativeModule()
376376
await server.kill()

server/core/helpers/core-utils.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import { promisify1, promisify2, promisify3 } from '@peertube/peertube-core-utils'
99
import { exec, ExecOptions } from 'child_process'
10-
import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
10+
import { createHash, ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
11+
import { createReadStream } from 'fs'
1112
import truncate from 'lodash-es/truncate.js'
1213
import { pipeline } from 'stream'
1314
import { URL } from 'url'
@@ -261,6 +262,17 @@ function generateED25519KeyPairPromise () {
261262
})
262263
}
263264

265+
function getContentHash (filePath: string): Promise<string> {
266+
return new Promise((resolve, reject) => {
267+
const hash = createHash('md5')
268+
hash.update(new Date().toString())
269+
const stream = createReadStream(filePath)
270+
stream.on('error', err => reject(err))
271+
stream.on('data', chunk => hash.update(chunk))
272+
stream.on('end', () => resolve(hash.digest('hex')))
273+
})
274+
}
275+
264276
// ---------------------------------------------------------------------------
265277

266278
const randomBytesPromise = promisify1<number, Buffer>(randomBytes)
@@ -285,6 +297,8 @@ export {
285297

286298
scryptPromise,
287299

300+
getContentHash,
301+
288302
randomBytesPromise,
289303

290304
generateRSAKeyPairPromise,

server/core/helpers/decache.ts

+1-14
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,6 @@
44
import { Module } from 'module'
55
import { extname } from 'path'
66

7-
function decachePlugin (require: NodeRequire, libraryPath: string) {
8-
const moduleName = find(require, libraryPath)
9-
10-
if (!moduleName) return
11-
12-
searchCache(require, moduleName, function (mod) {
13-
delete require.cache[mod.id]
14-
15-
removeCachedPath(mod.path)
16-
})
17-
}
18-
197
function decacheModule (require: NodeRequire, name: string) {
208
const moduleName = find(require, name)
219

@@ -31,8 +19,7 @@ function decacheModule (require: NodeRequire, name: string) {
3119
// ---------------------------------------------------------------------------
3220

3321
export {
34-
decacheModule,
35-
decachePlugin
22+
decacheModule
3623
}
3724

3825
// ---------------------------------------------------------------------------

server/core/lib/plugins/plugin-manager.ts

+34-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from 'express'
2-
import { createReadStream, createWriteStream } from 'fs'
3-
import { ensureDir, outputFile, readJSON } from 'fs-extra/esm'
2+
import { createReadStream, createWriteStream, statSync } from 'fs'
3+
import { mkdir, readlink } from 'fs/promises'
4+
import { ensureDir, outputFile, readJSON, ensureSymlink } from 'fs-extra/esm'
45
import { Server } from 'http'
56
import { createRequire } from 'module'
67
import { basename, join } from 'path'
@@ -16,7 +17,6 @@ import {
1617
ServerHook,
1718
ServerHookName
1819
} from '@peertube/peertube-models'
19-
import { decachePlugin } from '@server/helpers/decache.js'
2020
import { ApplicationModel } from '@server/models/application/application.js'
2121
import { MOAuthTokenUser, MUser } from '@server/types/models/index.js'
2222
import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins.js'
@@ -77,13 +77,25 @@ export class PluginManager implements ServerHook {
7777
private hooks: { [name: string]: HookInformationValue[] } = {}
7878
private translations: PluginLocalesTranslations = {}
7979

80+
private readonly latestDirectory = join(CONFIG.STORAGE.PLUGINS_DIR, 'latest')
81+
8082
private server: Server
8183

8284
private constructor () {
8385
}
8486

85-
init (server: Server) {
87+
async init (server: Server) {
8688
this.server = server
89+
90+
try {
91+
statSync(this.latestDirectory)
92+
} catch (err) {
93+
const workingDir = join(CONFIG.STORAGE.PLUGINS_DIR, Date.now().toString())
94+
await mkdir(workingDir)
95+
await ensureSymlink(workingDir, this.latestDirectory)
96+
// await writeJSON(join(this.latestDirectory, 'package.json'), {})
97+
// await writeFile(join(this.latestDirectory, 'yarn.lock'), '')
98+
}
8799
}
88100

89101
registerWebSocketRouter () {
@@ -374,6 +386,11 @@ export class PluginManager implements ServerHook {
374386
logger.info('Successful installation of plugin %s.', toInstall)
375387

376388
if (register) {
389+
// Unregister old hooks if it's an update
390+
try {
391+
await this.unregister(npmName)
392+
} catch (err) {}
393+
377394
await this.registerPluginOrTheme(plugin)
378395
}
379396
} catch (rootErr) {
@@ -394,6 +411,12 @@ export class PluginManager implements ServerHook {
394411
}
395412

396413
throw rootErr
414+
} finally {
415+
// Update plugin paths
416+
for (const npmName in this.registeredPlugins) {
417+
const { name, type } = this.registeredPlugins[npmName]
418+
this.registeredPlugins[npmName].path = await this.getPluginPath(name, type)
419+
}
397420
}
398421

399422
return plugin
@@ -411,9 +434,6 @@ export class PluginManager implements ServerHook {
411434
version = plugin.latestVersion
412435
}
413436

414-
// Unregister old hooks
415-
await this.unregister(npmName)
416-
417437
return this.install({ toInstall: toUpdate, version, fromDisk })
418438
}
419439

@@ -463,7 +483,7 @@ export class PluginManager implements ServerHook {
463483
logger.info('Registering plugin or theme %s.', npmName)
464484

465485
const packageJSON = await this.getPackageJSON(plugin.name, plugin.type)
466-
const pluginPath = this.getPluginPath(plugin.name, plugin.type)
486+
const pluginPath = await this.getPluginPath(plugin.name, plugin.type)
467487

468488
this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, plugin.type)
469489

@@ -503,9 +523,7 @@ export class PluginManager implements ServerHook {
503523
private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {
504524
const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
505525

506-
// Delete cache if needed
507526
const modulePath = join(pluginPath, packageJSON.library)
508-
decachePlugin(require, modulePath)
509527
const library: PluginLibrary = require(modulePath)
510528

511529
if (!isLibraryCodeValid(library)) {
@@ -530,7 +548,7 @@ export class PluginManager implements ServerHook {
530548
private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PluginTranslationPathsJSON) {
531549
for (const locale of Object.keys(translationPaths)) {
532550
const path = translationPaths[locale]
533-
const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path))
551+
const json = await readJSON(join(await this.getPluginPath(plugin.name, plugin.type), path))
534552

535553
const completeLocale = getCompleteLocale(locale)
536554

@@ -591,16 +609,17 @@ export class PluginManager implements ServerHook {
591609
}
592610
}
593611

594-
private getPackageJSON (pluginName: string, pluginType: PluginType_Type) {
595-
const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
612+
private async getPackageJSON (pluginName: string, pluginType: PluginType_Type) {
613+
const pluginPath = join(await this.getPluginPath(pluginName, pluginType), 'package.json')
596614

597615
return readJSON(pluginPath) as Promise<PluginPackageJSON>
598616
}
599617

600-
private getPluginPath (pluginName: string, pluginType: PluginType_Type) {
618+
private async getPluginPath (pluginName: string, pluginType: PluginType_Type) {
601619
const npmName = PluginModel.buildNpmName(pluginName, pluginType)
620+
const currentDirectory = await readlink(join(CONFIG.STORAGE.PLUGINS_DIR, 'latest'))
602621

603-
return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName)
622+
return join(currentDirectory, 'node_modules', npmName)
604623
}
605624

606625
private getAuth (npmName: string, authName: string) {

server/core/lib/plugins/yarn.ts

+42-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { outputJSON, pathExists } from 'fs-extra/esm'
1+
import { copy, ensureSymlink, remove, outputJSON, pathExists } from 'fs-extra/esm'
2+
import { mkdir, readlink } from 'fs/promises'
23
import { join } from 'path'
3-
import { execShell } from '../../helpers/core-utils.js'
4+
import { execShell, getContentHash } from '../../helpers/core-utils.js'
45
import { isNpmPluginNameValid, isPluginStableOrUnstableVersionValid } from '../../helpers/custom-validators/plugins.js'
56
import { logger } from '../../helpers/logger.js'
67
import { CONFIG } from '../../initializers/config.js'
@@ -16,7 +17,7 @@ async function installNpmPlugin (npmName: string, versionArg?: string) {
1617
let toInstall = npmName
1718
if (version) toInstall += `@${version}`
1819

19-
const { stdout } = await execYarn('add ' + toInstall)
20+
const stdout = await execYarn('add ' + toInstall)
2021

2122
logger.debug('Added a yarn package.', { yarnStdout: stdout })
2223
}
@@ -47,21 +48,55 @@ export {
4748
// ############################################################################
4849

4950
async function execYarn (command: string) {
51+
const latestDirectory = join(CONFIG.STORAGE.PLUGINS_DIR, 'latest')
52+
const currentDirectory = await readlink(latestDirectory)
53+
let workingDirectory: string
54+
let stdout: string
55+
5056
try {
51-
const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR
52-
const pluginPackageJSON = join(pluginDirectory, 'package.json')
57+
const pluginPackageJSON = join(currentDirectory, 'package.json')
5358

5459
// Create empty package.json file if needed
5560
if (!await pathExists(pluginPackageJSON)) {
5661
await outputJSON(pluginPackageJSON, {})
5762
}
5863

59-
return execShell(`yarn ${command}`, { cwd: pluginDirectory })
64+
const hash = await getContentHash(pluginPackageJSON)
65+
66+
workingDirectory = join(CONFIG.STORAGE.PLUGINS_DIR, hash)
67+
await mkdir(workingDirectory)
68+
await copy(join(currentDirectory, 'package.json'), join(workingDirectory, 'package.json'))
69+
70+
try {
71+
await copy(join(currentDirectory, 'yarn.lock'), join(workingDirectory, 'yarn.lock'))
72+
} catch (err) {
73+
logger.debug('No yarn.lock file to copy, will continue without.')
74+
}
75+
76+
const result = await execShell(`yarn ${command}`, { cwd: workingDirectory })
77+
stdout = result.stdout
6078
} catch (result) {
61-
logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr })
79+
logger.error('Cannot exec yarn.', { command, err: result, stderr: result.stderr })
80+
81+
await remove(workingDirectory)
6282

6383
throw result.err
6484
}
85+
86+
try {
87+
await remove(latestDirectory)
88+
await ensureSymlink(workingDirectory, latestDirectory)
89+
} catch (err) {
90+
logger.error('Cannot create symlink for new plugin set. Trying to restore the old one.', { err })
91+
await ensureSymlink(currentDirectory, latestDirectory)
92+
logger.info('Succeeded to restore old plugin set.')
93+
94+
throw err
95+
}
96+
97+
await remove(currentDirectory)
98+
99+
return stdout
65100
}
66101

67102
function checkNpmPluginNameOrThrow (name: string) {

server/server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ async function startApplication () {
335335

336336
OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
337337

338-
PluginManager.Instance.init(server)
338+
await PluginManager.Instance.init(server)
339339
// Before PeerTubeSocket init
340340
PluginManager.Instance.registerWebSocketRouter()
341341

0 commit comments

Comments
 (0)