From 88086a41ff5b804b35aa9d9503dc2d48836fe4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 5 Aug 2024 11:52:06 +0200 Subject: [PATCH] feat(core): Support community packages in scaling-mode (#10228) --- packages/@n8n/config/src/configs/nodes.ts | 4 + packages/@n8n/config/test/config.test.ts | 1 + packages/cli/src/commands/BaseCommand.ts | 11 ++- packages/cli/src/commands/execute.ts | 2 + packages/cli/src/commands/executeBatch.ts | 2 + packages/cli/src/commands/start.ts | 35 +++++---- packages/cli/src/commands/webhook.ts | 2 + packages/cli/src/commands/worker.ts | 6 +- .../communityPackages.controller.ts | 21 +---- .../communityPackages.service.test.ts | 59 ++++++-------- .../src/services/communityPackages.service.ts | 78 ++++++++++++------- .../main/handleCommandMessageMain.ts | 15 ++++ .../webhook/handleCommandMessageWebhook.ts | 15 ++++ .../worker/handleCommandMessageWorker.ts | 13 ++++ .../services/redis/RedisServiceCommands.ts | 17 +++- .../community-packages.api.test.ts | 6 +- packages/editor-ui/src/constants.ts | 1 - .../src/plugins/i18n/locales/en.json | 1 - .../src/views/SettingsCommunityNodesView.vue | 27 +------ 19 files changed, 187 insertions(+), 129 deletions(-) diff --git a/packages/@n8n/config/src/configs/nodes.ts b/packages/@n8n/config/src/configs/nodes.ts index dbe3705c6..3837b0a99 100644 --- a/packages/@n8n/config/src/configs/nodes.ts +++ b/packages/@n8n/config/src/configs/nodes.ts @@ -25,6 +25,10 @@ class CommunityPackagesConfig { /** Whether to enable community packages */ @Env('N8N_COMMUNITY_PACKAGES_ENABLED') enabled: boolean = true; + + /** Whether to reinstall any missing community packages */ + @Env('N8N_REINSTALL_MISSING_PACKAGES') + reinstallMissing: boolean = false; } @Config diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index db27b280e..e077e233b 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -108,6 +108,7 @@ describe('GlobalConfig', () => { nodes: { communityPackages: { enabled: true, + reinstallMissing: false, }, errorTriggerType: 'n8n-nodes-base.errorTrigger', include: [], diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 9828e6207..62ff002e4 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -44,13 +44,16 @@ export abstract class BaseCommand extends Command { protected license: License; - protected globalConfig = Container.get(GlobalConfig); + protected readonly globalConfig = Container.get(GlobalConfig); /** * How long to wait for graceful shutdown before force killing the process. */ protected gracefulShutdownTimeoutInS = config.getEnv('generic.gracefulShutdownTimeout'); + /** Whether to init community packages (if enabled) */ + protected needsCommunityPackages = false; + async init(): Promise { await initErrorHandling(); initExpressionEvaluator(); @@ -111,6 +114,12 @@ export abstract class BaseCommand extends Command { ); } + const { communityPackages } = this.globalConfig.nodes; + if (communityPackages.enabled && this.needsCommunityPackages) { + const { CommunityPackagesService } = await import('@/services/communityPackages.service'); + await Container.get(CommunityPackagesService).checkForMissingPackages(); + } + await Container.get(PostHogClient).init(); await Container.get(InternalHooks).init(); await Container.get(TelemetryEventRelay).init(); diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index a375d19c3..cdf949e87 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -27,6 +27,8 @@ export class Execute extends BaseCommand { }), }; + override needsCommunityPackages = true; + async init() { await super.init(); await this.initBinaryDataService(); diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index d8fdbeb8a..227dd962e 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -108,6 +108,8 @@ export class ExecuteBatch extends BaseCommand { }), }; + override needsCommunityPackages = true; + /** * Gracefully handles exit. * @param {boolean} skipExit Whether to skip exit or number according to received signal diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 76d8b4c7e..98e8e5b98 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -8,7 +8,6 @@ import { createReadStream, createWriteStream, existsSync } from 'fs'; import { pipeline } from 'stream/promises'; import replaceStream from 'replacestream'; import glob from 'fast-glob'; -import { GlobalConfig } from '@n8n/config'; import { jsonParse, randomString } from 'n8n-workflow'; import config from '@/config'; @@ -68,6 +67,8 @@ export class Start extends BaseCommand { protected server = Container.get(Server); + override needsCommunityPackages = true; + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('main'); @@ -125,7 +126,6 @@ export class Start extends BaseCommand { private async generateStaticAssets() { // Read the index file and replace the path placeholder const n8nPath = this.globalConfig.path; - const hooksUrls = config.getEnv('externalFrontendHooksUrls'); let scriptsString = ''; @@ -178,6 +178,22 @@ export class Start extends BaseCommand { this.logger.debug(`Queue mode id: ${this.queueModeId}`); } + const { flags } = await this.parse(Start); + const { communityPackages } = this.globalConfig.nodes; + // cli flag overrides the config env variable + if (flags.reinstallMissingPackages) { + if (communityPackages.enabled) { + this.logger.warn( + '`--reinstallMissingPackages` is deprecated: Please use the env variable `N8N_REINSTALL_MISSING_PACKAGES` instead', + ); + communityPackages.reinstallMissing = true; + } else { + this.logger.warn( + '`--reinstallMissingPackages` was passed, but community packages are disabled', + ); + } + } + await super.init(); this.activeWorkflowManager = Container.get(ActiveWorkflowManager); @@ -251,18 +267,9 @@ export class Start extends BaseCommand { config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value })); }); - const globalConfig = Container.get(GlobalConfig); - - if (globalConfig.nodes.communityPackages.enabled) { - const { CommunityPackagesService } = await import('@/services/communityPackages.service'); - await Container.get(CommunityPackagesService).setMissingPackages({ - reinstallMissingPackages: flags.reinstallMissingPackages, - }); - } - - const { type: dbType } = globalConfig.database; + const { type: dbType } = this.globalConfig.database; if (dbType === 'sqlite') { - const shouldRunVacuum = globalConfig.database.sqlite.executeVacuumOnStartup; + const shouldRunVacuum = this.globalConfig.database.sqlite.executeVacuumOnStartup; if (shouldRunVacuum) { await Container.get(ExecutionRepository).query('VACUUM;'); } @@ -282,7 +289,7 @@ export class Start extends BaseCommand { } const { default: localtunnel } = await import('@n8n/localtunnel'); - const { port } = Container.get(GlobalConfig); + const { port } = this.globalConfig; const webhookTunnel = await localtunnel(port, { host: 'https://hooks.n8n.cloud', diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 5b72c1eb8..e76ac3581 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -22,6 +22,8 @@ export class Webhook extends BaseCommand { protected server = Container.get(WebhookServer); + override needsCommunityPackages = true; + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('webhook'); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index cf4c23a08..151ecfdf9 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -3,7 +3,6 @@ import { Flags, type Config } from '@oclif/core'; import express from 'express'; import http from 'http'; import type PCancelable from 'p-cancelable'; -import { GlobalConfig } from '@n8n/config'; import { WorkflowExecute } from 'n8n-core'; import type { ExecutionStatus, IExecuteResponsePromiseData, INodeTypes, IRun } from 'n8n-workflow'; import { Workflow, sleep, ApplicationError } from 'n8n-workflow'; @@ -57,6 +56,8 @@ export class Worker extends BaseCommand { redisSubscriber: RedisServicePubSubSubscriber; + override needsCommunityPackages = true; + /** * Stop n8n in a graceful way. * Make for example sure that all the webhooks from third party services @@ -429,8 +430,7 @@ export class Worker extends BaseCommand { let presetCredentialsLoaded = false; - const globalConfig = Container.get(GlobalConfig); - const endpointPresetCredentials = globalConfig.credentials.overwrite.endpoint; + const endpointPresetCredentials = this.globalConfig.credentials.overwrite.endpoint; if (endpointPresetCredentials !== '') { // POST endpoint to set preset credentials app.post( diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index e6323c728..d9e7f4971 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -1,11 +1,9 @@ -import { Request, Response, NextFunction } from 'express'; -import config from '@/config'; import { RESPONSE_ERROR_MESSAGES, STARTER_TEMPLATE_NAME, UNKNOWN_FAILURE_REASON, } from '@/constants'; -import { Delete, Get, Middleware, Patch, Post, RestController, GlobalScope } from '@/decorators'; +import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators'; import { NodeRequest } from '@/requests'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; @@ -40,17 +38,6 @@ export class CommunityPackagesController { private readonly eventService: EventService, ) {} - // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` - @Middleware() - checkIfCommunityNodesEnabled(req: Request, res: Response, next: NextFunction) { - if (config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') - res.status(400).json({ - status: 'error', - message: 'Package management is disabled when running in "queue" mode', - }); - else next(); - } - @Post('/') @GlobalScope('communityPackage:install') async installPackage(req: NodeRequest.Post) { @@ -99,7 +86,7 @@ export class CommunityPackagesController { let installedPackage: InstalledPackages; try { - installedPackage = await this.communityPackagesService.installNpmModule( + installedPackage = await this.communityPackagesService.installPackage( parsed.packageName, parsed.version, ); @@ -207,7 +194,7 @@ export class CommunityPackagesController { } try { - await this.communityPackagesService.removeNpmModule(name, installedPackage); + await this.communityPackagesService.removePackage(name, installedPackage); } catch (error) { const message = [ `Error removing package "${name}"`, @@ -252,7 +239,7 @@ export class CommunityPackagesController { } try { - const newInstalledPackage = await this.communityPackagesService.updateNpmModule( + const newInstalledPackage = await this.communityPackagesService.updatePackage( this.communityPackagesService.parseNpmPackageName(name).packageName, previouslyInstalledPackage, ); diff --git a/packages/cli/src/services/__tests__/communityPackages.service.test.ts b/packages/cli/src/services/__tests__/communityPackages.service.test.ts index acfecc817..8d3289a86 100644 --- a/packages/cli/src/services/__tests__/communityPackages.service.test.ts +++ b/packages/cli/src/services/__tests__/communityPackages.service.test.ts @@ -2,8 +2,10 @@ import { exec } from 'child_process'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; import axios from 'axios'; import { mocked } from 'jest-mock'; -import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import type { GlobalConfig } from '@n8n/config'; import type { PublicInstalledPackage } from 'n8n-workflow'; +import type { PackageDirectoryLoader } from 'n8n-core'; import { NODE_PACKAGE_PREFIX, @@ -11,21 +13,18 @@ import { NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES, } from '@/constants'; -import config from '@/config'; import { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; import { CommunityPackagesService } from '@/services/communityPackages.service'; import { InstalledNodesRepository } from '@db/repositories/installedNodes.repository'; import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository'; import { InstalledNodes } from '@db/entities/InstalledNodes'; -import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { mockInstance } from '@test/mocking'; import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants'; import { randomName } from '@test-integration/random'; import { mockPackageName, mockPackagePair } from '@test-integration/utils'; -import { InstanceSettings, PackageDirectoryLoader } from 'n8n-core'; -import { Logger } from '@/Logger'; jest.mock('fs/promises'); jest.mock('child_process'); @@ -40,6 +39,15 @@ const execMock = ((...args) => { }) as typeof exec; describe('CommunityPackagesService', () => { + const globalConfig = mock({ + nodes: { + communityPackages: { + reinstallMissing: false, + }, + }, + }); + const loadNodesAndCredentials = mock(); + const installedNodesRepository = mockInstance(InstalledNodesRepository); installedNodesRepository.create.mockImplementation(() => { const nodeName = randomName(); @@ -60,13 +68,14 @@ describe('CommunityPackagesService', () => { }); }); - mockInstance(LoadNodesAndCredentials); - - const communityPackagesService = Container.get(CommunityPackagesService); - - beforeEach(() => { - config.load(config.default); - }); + const communityPackagesService = new CommunityPackagesService( + mock(), + mock(), + mock(), + loadNodesAndCredentials, + mock(), + globalConfig, + ); describe('parseNpmPackageName()', () => { test('should fail with empty package name', () => { @@ -365,29 +374,12 @@ describe('CommunityPackagesService', () => { }; describe('updateNpmModule', () => { - let packageDirectoryLoader: PackageDirectoryLoader; - let communityPackagesService: CommunityPackagesService; + const packageDirectoryLoader = mock(); beforeEach(async () => { - jest.restoreAllMocks(); + jest.clearAllMocks(); - packageDirectoryLoader = mockInstance(PackageDirectoryLoader); - const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials); loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader); - const instanceSettings = mockInstance(InstanceSettings); - const logger = mockInstance(Logger); - const installedPackagesRepository = mockInstance(InstalledPackagesRepository); - - communityPackagesService = new CommunityPackagesService( - instanceSettings, - logger, - installedPackagesRepository, - loadNodesAndCredentials, - ); - }); - - afterEach(async () => { - jest.restoreAllMocks(); }); test('should call `exec` with the correct command ', async () => { @@ -405,10 +397,7 @@ describe('CommunityPackagesService', () => { // // ACT // - await communityPackagesService.updateNpmModule( - installedPackage.packageName, - installedPackage, - ); + await communityPackagesService.updatePackage(installedPackage.packageName, installedPackage); // // ASSERT diff --git a/packages/cli/src/services/communityPackages.service.ts b/packages/cli/src/services/communityPackages.service.ts index 23bf9461d..b1f46d52f 100644 --- a/packages/cli/src/services/communityPackages.service.ts +++ b/packages/cli/src/services/communityPackages.service.ts @@ -5,6 +5,7 @@ import { Service } from 'typedi'; import { promisify } from 'util'; import axios from 'axios'; +import { GlobalConfig } from '@n8n/config'; import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow'; import { InstanceSettings } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core'; @@ -22,6 +23,7 @@ import { import type { CommunityPackages } from '@/Interfaces'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { Logger } from '@/Logger'; +import { OrchestrationService } from './orchestration.service'; const { PACKAGE_NAME_NOT_PROVIDED, @@ -45,6 +47,8 @@ const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/; @Service() export class CommunityPackagesService { + reinstallMissingPackages = false; + missingPackages: string[] = []; constructor( @@ -52,7 +56,11 @@ export class CommunityPackagesService { private readonly logger: Logger, private readonly installedPackageRepository: InstalledPackagesRepository, private readonly loadNodesAndCredentials: LoadNodesAndCredentials, - ) {} + private readonly orchestrationService: OrchestrationService, + globalConfig: GlobalConfig, + ) { + this.reinstallMissingPackages = globalConfig.nodes.communityPackages.reinstallMissing; + } get hasMissingPackages() { return this.missingPackages.length > 0; @@ -73,11 +81,11 @@ export class CommunityPackagesService { return await this.installedPackageRepository.find({ relations: ['installedNodes'] }); } - async removePackageFromDatabase(packageName: InstalledPackages) { + private async removePackageFromDatabase(packageName: InstalledPackages) { return await this.installedPackageRepository.remove(packageName); } - async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { + private async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { try { return await this.installedPackageRepository.saveInstalledPackageWithNodes(packageLoader); } catch (maybeError) { @@ -251,7 +259,7 @@ export class CommunityPackagesService { } } - async setMissingPackages({ reinstallMissingPackages }: { reinstallMissingPackages: boolean }) { + async checkForMissingPackages() { const installedPackages = await this.getAllInstalledPackages(); const missingPackages = new Set<{ packageName: string; version: string }>(); @@ -271,24 +279,24 @@ export class CommunityPackagesService { if (missingPackages.size === 0) return; - this.logger.error( - 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', - ); - - if (reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) { + if (this.reinstallMissingPackages) { this.logger.info('Attempting to reinstall missing packages', { missingPackages }); try { // Optimistic approach - stop if any installation fails - for (const missingPackage of missingPackages) { - await this.installNpmModule(missingPackage.packageName, missingPackage.version); + await this.installPackage(missingPackage.packageName, missingPackage.version); missingPackages.delete(missingPackage); } this.logger.info('Packages reinstalled successfully. Resuming regular initialization.'); + await this.loadNodesAndCredentials.postProcessLoaders(); } catch (error) { this.logger.error('n8n was unable to install the missing packages.'); } + } else { + this.logger.warn( + 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', + ); } this.missingPackages = [...missingPackages].map( @@ -296,32 +304,30 @@ export class CommunityPackagesService { ); } - async installNpmModule(packageName: string, version?: string): Promise { - return await this.installOrUpdateNpmModule(packageName, { version }); + async installPackage(packageName: string, version?: string): Promise { + return await this.installOrUpdatePackage(packageName, { version }); } - async updateNpmModule( + async updatePackage( packageName: string, installedPackage: InstalledPackages, ): Promise { - return await this.installOrUpdateNpmModule(packageName, { installedPackage }); + return await this.installOrUpdatePackage(packageName, { installedPackage }); } - async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { - await this.executeNpmCommand(`npm remove ${packageName}`); + async removePackage(packageName: string, installedPackage: InstalledPackages): Promise { + await this.removeNpmPackage(packageName); await this.removePackageFromDatabase(installedPackage); - await this.loadNodesAndCredentials.unloadPackage(packageName); - await this.loadNodesAndCredentials.postProcessLoaders(); + await this.orchestrationService.publish('community-package-uninstall', { packageName }); } - private async installOrUpdateNpmModule( + private async installOrUpdatePackage( packageName: string, options: { version?: string } | { installedPackage: InstalledPackages }, ) { const isUpdate = 'installedPackage' in options; - const command = isUpdate - ? `npm install ${packageName}@latest` - : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`; + const packageVersion = isUpdate || !options.version ? 'latest' : options.version; + const command = `npm install ${packageName}@${packageVersion}`; try { await this.executeNpmCommand(command); @@ -337,9 +343,8 @@ export class CommunityPackagesService { loader = await this.loadNodesAndCredentials.loadPackage(packageName); } catch (error) { // Remove this package since loading it failed - const removeCommand = `npm remove ${packageName}`; try { - await this.executeNpmCommand(removeCommand); + await this.executeNpmCommand(`npm remove ${packageName}`); } catch {} throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); } @@ -351,7 +356,12 @@ export class CommunityPackagesService { await this.removePackageFromDatabase(options.installedPackage); } const installedPackage = await this.persistInstalledPackage(loader); + await this.orchestrationService.publish( + isUpdate ? 'community-package-update' : 'community-package-install', + { packageName, packageVersion }, + ); await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package installed: ${packageName}`); return installedPackage; } catch (error) { throw new ApplicationError('Failed to save installed package', { @@ -361,12 +371,24 @@ export class CommunityPackagesService { } } else { // Remove this package since it contains no loadable nodes - const removeCommand = `npm remove ${packageName}`; try { - await this.executeNpmCommand(removeCommand); + await this.executeNpmCommand(`npm remove ${packageName}`); } catch {} - throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); } } + + async installOrUpdateNpmPackage(packageName: string, packageVersion: string) { + await this.executeNpmCommand(`npm install ${packageName}@${packageVersion}`); + await this.loadNodesAndCredentials.loadPackage(packageName); + await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package installed: ${packageName}`); + } + + async removeNpmPackage(packageName: string) { + await this.executeNpmCommand(`npm remove ${packageName}`); + await this.loadNodesAndCredentials.unloadPackage(packageName); + await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package uninstalled: ${packageName}`); + } } diff --git a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts index 8abcbe78b..7945f59bc 100644 --- a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts +++ b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts @@ -10,6 +10,7 @@ import { Push } from '@/push'; import { TestWebhooks } from '@/TestWebhooks'; import { OrchestrationService } from '@/services/orchestration.service'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; // eslint-disable-next-line complexity export async function handleCommandMessageMain(messageString: string) { @@ -77,6 +78,20 @@ export async function handleCommandMessageMain(messageString: string) { } await Container.get(ExternalSecretsManager).reloadAllProviders(); break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 200)) { + return message; + } + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; case 'add-webhooks-triggers-and-pollers': { if (!debounceMessageReceiver(message, 100)) { diff --git a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts index 9dc326978..e6f6e6562 100644 --- a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts +++ b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts @@ -5,6 +5,7 @@ import Container from 'typedi'; import { Logger } from 'winston'; import { messageToRedisServiceCommandObject, debounceMessageReceiver } from '../helpers'; import config from '@/config'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; export async function handleCommandMessageWebhook(messageString: string) { const queueModeId = config.getEnv('redis.queueModeId'); @@ -63,6 +64,20 @@ export async function handleCommandMessageWebhook(messageString: string) { } await Container.get(ExternalSecretsManager).reloadAllProviders(); break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 200)) { + return message; + } + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; default: break; diff --git a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts index fa9ee6767..23c96e1a4 100644 --- a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts +++ b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts @@ -10,6 +10,7 @@ import { debounceMessageReceiver, getOsCpuString } from '../helpers'; import type { WorkerCommandReceivedHandlerOptions } from './types'; import { Logger } from '@/Logger'; import { N8N_VERSION } from '@/constants'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) { // eslint-disable-next-line complexity @@ -112,6 +113,18 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa }); } break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 500)) return; + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; case 'reloadLicense': if (!debounceMessageReceiver(message, 500)) return; await Container.get(License).reload(); diff --git a/packages/cli/src/services/redis/RedisServiceCommands.ts b/packages/cli/src/services/redis/RedisServiceCommands.ts index 009f39ef6..a8ae41c11 100644 --- a/packages/cli/src/services/redis/RedisServiceCommands.ts +++ b/packages/cli/src/services/redis/RedisServiceCommands.ts @@ -7,6 +7,9 @@ export type RedisServiceCommand = | 'stopWorker' | 'reloadLicense' | 'reloadExternalSecretsProviders' + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall' | 'display-workflow-activation' // multi-main only | 'display-workflow-deactivation' // multi-main only | 'add-webhooks-triggers-and-pollers' // multi-main only @@ -26,7 +29,11 @@ export type RedisServiceBaseCommand = senderId: string; command: Exclude< RedisServiceCommand, - 'relay-execution-lifecycle-event' | 'clear-test-webhooks' + | 'relay-execution-lifecycle-event' + | 'clear-test-webhooks' + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall' >; payload?: { [key: string]: string | number | boolean | string[] | number[] | boolean[]; @@ -41,6 +48,14 @@ export type RedisServiceBaseCommand = senderId: string; command: 'clear-test-webhooks'; payload: { webhookKey: string; workflowEntity: IWorkflowDb; pushRef: string }; + } + | { + senderId: string; + command: + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall'; + payload: { packageName: string; packageVersion: string }; }; export type RedisServiceWorkerResponseObject = { diff --git a/packages/cli/test/integration/community-packages.api.test.ts b/packages/cli/test/integration/community-packages.api.test.ts index 661dbcd0f..46c7efad0 100644 --- a/packages/cli/test/integration/community-packages.api.test.ts +++ b/packages/cli/test/integration/community-packages.api.test.ts @@ -179,7 +179,7 @@ describe('POST /community-packages', () => { communityPackagesService.hasPackageLoaded.mockReturnValue(false); communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' }); communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); - communityPackagesService.installNpmModule.mockResolvedValue(mockPackage()); + communityPackagesService.installPackage.mockResolvedValue(mockPackage()); await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(200); @@ -219,7 +219,7 @@ describe('DELETE /community-packages', () => { await authAgent.delete('/community-packages').query({ name: mockPackageName() }).expect(200); - expect(communityPackagesService.removeNpmModule).toHaveBeenCalledTimes(1); + expect(communityPackagesService.removePackage).toHaveBeenCalledTimes(1); }); }); @@ -242,6 +242,6 @@ describe('PATCH /community-packages', () => { await authAgent.patch('/community-packages').send({ name: mockPackageName() }); - expect(communityPackagesService.updateNpmModule).toHaveBeenCalledTimes(1); + expect(communityPackagesService.updatePackage).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 8b30c972c..acf94fcc3 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -92,7 +92,6 @@ export const NPM_KEYWORD_SEARCH_URL = 'https://www.npmjs.com/search?q=keywords%3An8n-community-node-package'; export const N8N_QUEUE_MODE_DOCS_URL = `https://${DOCS_DOMAIN}/hosting/scaling/queue-mode/`; export const COMMUNITY_NODES_INSTALLATION_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/installation/gui-install/`; -export const COMMUNITY_NODES_MANUAL_INSTALLATION_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/installation/manual-install/`; export const COMMUNITY_NODES_NPM_INSTALLATION_URL = 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm'; export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/risks/`; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index e5ee00885..2acf1cfd3 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1562,7 +1562,6 @@ "settings.communityNodes.empty.description": "Install over {count} node packages contributed by our community.", "settings.communityNodes.empty.description.no-packages": "Install node packages contributed by our community.", "settings.communityNodes.empty.installPackageLabel": "Install a community node", - "settings.communityNodes.queueMode.warning": "You need to install community nodes manually because your instance is running in queue mode. More info", "settings.communityNodes.npmUnavailable.warning": "To use this feature, please install npm and restart n8n.", "settings.communityNodes.notAvailableOnDesktop": "Feature unavailable on desktop. Please self-host to use community nodes.", "settings.communityNodes.packageNodes.label": "{count} node | {count} nodes", diff --git a/packages/editor-ui/src/views/SettingsCommunityNodesView.vue b/packages/editor-ui/src/views/SettingsCommunityNodesView.vue index fcfb37423..194a289eb 100644 --- a/packages/editor-ui/src/views/SettingsCommunityNodesView.vue +++ b/packages/editor-ui/src/views/SettingsCommunityNodesView.vue @@ -3,25 +3,13 @@
{{ $locale.baseText('settings.communityNodes') }}
-
- -
-
+