feat(core): Support community packages in scaling-mode (#10228)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-08-05 11:52:06 +02:00 committed by GitHub
parent afa43e75f6
commit 88086a41ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 187 additions and 129 deletions

View file

@ -25,6 +25,10 @@ class CommunityPackagesConfig {
/** Whether to enable community packages */ /** Whether to enable community packages */
@Env('N8N_COMMUNITY_PACKAGES_ENABLED') @Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true; enabled: boolean = true;
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;
} }
@Config @Config

View file

@ -108,6 +108,7 @@ describe('GlobalConfig', () => {
nodes: { nodes: {
communityPackages: { communityPackages: {
enabled: true, enabled: true,
reinstallMissing: false,
}, },
errorTriggerType: 'n8n-nodes-base.errorTrigger', errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [], include: [],

View file

@ -44,13 +44,16 @@ export abstract class BaseCommand extends Command {
protected license: License; 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. * How long to wait for graceful shutdown before force killing the process.
*/ */
protected gracefulShutdownTimeoutInS = config.getEnv('generic.gracefulShutdownTimeout'); protected gracefulShutdownTimeoutInS = config.getEnv('generic.gracefulShutdownTimeout');
/** Whether to init community packages (if enabled) */
protected needsCommunityPackages = false;
async init(): Promise<void> { async init(): Promise<void> {
await initErrorHandling(); await initErrorHandling();
initExpressionEvaluator(); 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(PostHogClient).init();
await Container.get(InternalHooks).init(); await Container.get(InternalHooks).init();
await Container.get(TelemetryEventRelay).init(); await Container.get(TelemetryEventRelay).init();

View file

@ -27,6 +27,8 @@ export class Execute extends BaseCommand {
}), }),
}; };
override needsCommunityPackages = true;
async init() { async init() {
await super.init(); await super.init();
await this.initBinaryDataService(); await this.initBinaryDataService();

View file

@ -108,6 +108,8 @@ export class ExecuteBatch extends BaseCommand {
}), }),
}; };
override needsCommunityPackages = true;
/** /**
* Gracefully handles exit. * Gracefully handles exit.
* @param {boolean} skipExit Whether to skip exit or number according to received signal * @param {boolean} skipExit Whether to skip exit or number according to received signal

View file

@ -8,7 +8,6 @@ import { createReadStream, createWriteStream, existsSync } from 'fs';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import replaceStream from 'replacestream'; import replaceStream from 'replacestream';
import glob from 'fast-glob'; import glob from 'fast-glob';
import { GlobalConfig } from '@n8n/config';
import { jsonParse, randomString } from 'n8n-workflow'; import { jsonParse, randomString } from 'n8n-workflow';
import config from '@/config'; import config from '@/config';
@ -68,6 +67,8 @@ export class Start extends BaseCommand {
protected server = Container.get(Server); protected server = Container.get(Server);
override needsCommunityPackages = true;
constructor(argv: string[], cmdConfig: Config) { constructor(argv: string[], cmdConfig: Config) {
super(argv, cmdConfig); super(argv, cmdConfig);
this.setInstanceType('main'); this.setInstanceType('main');
@ -125,7 +126,6 @@ export class Start extends BaseCommand {
private async generateStaticAssets() { private async generateStaticAssets() {
// Read the index file and replace the path placeholder // Read the index file and replace the path placeholder
const n8nPath = this.globalConfig.path; const n8nPath = this.globalConfig.path;
const hooksUrls = config.getEnv('externalFrontendHooksUrls'); const hooksUrls = config.getEnv('externalFrontendHooksUrls');
let scriptsString = ''; let scriptsString = '';
@ -178,6 +178,22 @@ export class Start extends BaseCommand {
this.logger.debug(`Queue mode id: ${this.queueModeId}`); 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(); await super.init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager); this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
@ -251,18 +267,9 @@ export class Start extends BaseCommand {
config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value })); config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value }));
}); });
const globalConfig = Container.get(GlobalConfig); const { type: dbType } = this.globalConfig.database;
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;
if (dbType === 'sqlite') { if (dbType === 'sqlite') {
const shouldRunVacuum = globalConfig.database.sqlite.executeVacuumOnStartup; const shouldRunVacuum = this.globalConfig.database.sqlite.executeVacuumOnStartup;
if (shouldRunVacuum) { if (shouldRunVacuum) {
await Container.get(ExecutionRepository).query('VACUUM;'); await Container.get(ExecutionRepository).query('VACUUM;');
} }
@ -282,7 +289,7 @@ export class Start extends BaseCommand {
} }
const { default: localtunnel } = await import('@n8n/localtunnel'); const { default: localtunnel } = await import('@n8n/localtunnel');
const { port } = Container.get(GlobalConfig); const { port } = this.globalConfig;
const webhookTunnel = await localtunnel(port, { const webhookTunnel = await localtunnel(port, {
host: 'https://hooks.n8n.cloud', host: 'https://hooks.n8n.cloud',

View file

@ -22,6 +22,8 @@ export class Webhook extends BaseCommand {
protected server = Container.get(WebhookServer); protected server = Container.get(WebhookServer);
override needsCommunityPackages = true;
constructor(argv: string[], cmdConfig: Config) { constructor(argv: string[], cmdConfig: Config) {
super(argv, cmdConfig); super(argv, cmdConfig);
this.setInstanceType('webhook'); this.setInstanceType('webhook');

View file

@ -3,7 +3,6 @@ import { Flags, type Config } from '@oclif/core';
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import type PCancelable from 'p-cancelable'; import type PCancelable from 'p-cancelable';
import { GlobalConfig } from '@n8n/config';
import { WorkflowExecute } from 'n8n-core'; import { WorkflowExecute } from 'n8n-core';
import type { ExecutionStatus, IExecuteResponsePromiseData, INodeTypes, IRun } from 'n8n-workflow'; import type { ExecutionStatus, IExecuteResponsePromiseData, INodeTypes, IRun } from 'n8n-workflow';
import { Workflow, sleep, ApplicationError } from 'n8n-workflow'; import { Workflow, sleep, ApplicationError } from 'n8n-workflow';
@ -57,6 +56,8 @@ export class Worker extends BaseCommand {
redisSubscriber: RedisServicePubSubSubscriber; redisSubscriber: RedisServicePubSubSubscriber;
override needsCommunityPackages = true;
/** /**
* Stop n8n in a graceful way. * Stop n8n in a graceful way.
* Make for example sure that all the webhooks from third party services * Make for example sure that all the webhooks from third party services
@ -429,8 +430,7 @@ export class Worker extends BaseCommand {
let presetCredentialsLoaded = false; let presetCredentialsLoaded = false;
const globalConfig = Container.get(GlobalConfig); const endpointPresetCredentials = this.globalConfig.credentials.overwrite.endpoint;
const endpointPresetCredentials = globalConfig.credentials.overwrite.endpoint;
if (endpointPresetCredentials !== '') { if (endpointPresetCredentials !== '') {
// POST endpoint to set preset credentials // POST endpoint to set preset credentials
app.post( app.post(

View file

@ -1,11 +1,9 @@
import { Request, Response, NextFunction } from 'express';
import config from '@/config';
import { import {
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
STARTER_TEMPLATE_NAME, STARTER_TEMPLATE_NAME,
UNKNOWN_FAILURE_REASON, UNKNOWN_FAILURE_REASON,
} from '@/constants'; } 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 { NodeRequest } from '@/requests';
import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces'; import type { CommunityPackages } from '@/Interfaces';
@ -40,17 +38,6 @@ export class CommunityPackagesController {
private readonly eventService: EventService, 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('/') @Post('/')
@GlobalScope('communityPackage:install') @GlobalScope('communityPackage:install')
async installPackage(req: NodeRequest.Post) { async installPackage(req: NodeRequest.Post) {
@ -99,7 +86,7 @@ export class CommunityPackagesController {
let installedPackage: InstalledPackages; let installedPackage: InstalledPackages;
try { try {
installedPackage = await this.communityPackagesService.installNpmModule( installedPackage = await this.communityPackagesService.installPackage(
parsed.packageName, parsed.packageName,
parsed.version, parsed.version,
); );
@ -207,7 +194,7 @@ export class CommunityPackagesController {
} }
try { try {
await this.communityPackagesService.removeNpmModule(name, installedPackage); await this.communityPackagesService.removePackage(name, installedPackage);
} catch (error) { } catch (error) {
const message = [ const message = [
`Error removing package "${name}"`, `Error removing package "${name}"`,
@ -252,7 +239,7 @@ export class CommunityPackagesController {
} }
try { try {
const newInstalledPackage = await this.communityPackagesService.updateNpmModule( const newInstalledPackage = await this.communityPackagesService.updatePackage(
this.communityPackagesService.parseNpmPackageName(name).packageName, this.communityPackagesService.parseNpmPackageName(name).packageName,
previouslyInstalledPackage, previouslyInstalledPackage,
); );

View file

@ -2,8 +2,10 @@ import { exec } from 'child_process';
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
import axios from 'axios'; import axios from 'axios';
import { mocked } from 'jest-mock'; 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 { PublicInstalledPackage } from 'n8n-workflow';
import type { PackageDirectoryLoader } from 'n8n-core';
import { import {
NODE_PACKAGE_PREFIX, NODE_PACKAGE_PREFIX,
@ -11,21 +13,18 @@ import {
NPM_PACKAGE_STATUS_GOOD, NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
} from '@/constants'; } from '@/constants';
import config from '@/config';
import { InstalledPackages } from '@db/entities/InstalledPackages'; import { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces'; import type { CommunityPackages } from '@/Interfaces';
import { CommunityPackagesService } from '@/services/communityPackages.service'; import { CommunityPackagesService } from '@/services/communityPackages.service';
import { InstalledNodesRepository } from '@db/repositories/installedNodes.repository'; import { InstalledNodesRepository } from '@db/repositories/installedNodes.repository';
import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository'; import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository';
import { InstalledNodes } from '@db/entities/InstalledNodes'; import { InstalledNodes } from '@db/entities/InstalledNodes';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants'; import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
import { randomName } from '@test-integration/random'; import { randomName } from '@test-integration/random';
import { mockPackageName, mockPackagePair } from '@test-integration/utils'; import { mockPackageName, mockPackagePair } from '@test-integration/utils';
import { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { Logger } from '@/Logger';
jest.mock('fs/promises'); jest.mock('fs/promises');
jest.mock('child_process'); jest.mock('child_process');
@ -40,6 +39,15 @@ const execMock = ((...args) => {
}) as typeof exec; }) as typeof exec;
describe('CommunityPackagesService', () => { describe('CommunityPackagesService', () => {
const globalConfig = mock<GlobalConfig>({
nodes: {
communityPackages: {
reinstallMissing: false,
},
},
});
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const installedNodesRepository = mockInstance(InstalledNodesRepository); const installedNodesRepository = mockInstance(InstalledNodesRepository);
installedNodesRepository.create.mockImplementation(() => { installedNodesRepository.create.mockImplementation(() => {
const nodeName = randomName(); const nodeName = randomName();
@ -60,13 +68,14 @@ describe('CommunityPackagesService', () => {
}); });
}); });
mockInstance(LoadNodesAndCredentials); const communityPackagesService = new CommunityPackagesService(
mock(),
const communityPackagesService = Container.get(CommunityPackagesService); mock(),
mock(),
beforeEach(() => { loadNodesAndCredentials,
config.load(config.default); mock(),
}); globalConfig,
);
describe('parseNpmPackageName()', () => { describe('parseNpmPackageName()', () => {
test('should fail with empty package name', () => { test('should fail with empty package name', () => {
@ -365,29 +374,12 @@ describe('CommunityPackagesService', () => {
}; };
describe('updateNpmModule', () => { describe('updateNpmModule', () => {
let packageDirectoryLoader: PackageDirectoryLoader; const packageDirectoryLoader = mock<PackageDirectoryLoader>();
let communityPackagesService: CommunityPackagesService;
beforeEach(async () => { beforeEach(async () => {
jest.restoreAllMocks(); jest.clearAllMocks();
packageDirectoryLoader = mockInstance(PackageDirectoryLoader);
const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader); 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 () => { test('should call `exec` with the correct command ', async () => {
@ -405,10 +397,7 @@ describe('CommunityPackagesService', () => {
// //
// ACT // ACT
// //
await communityPackagesService.updateNpmModule( await communityPackagesService.updatePackage(installedPackage.packageName, installedPackage);
installedPackage.packageName,
installedPackage,
);
// //
// ASSERT // ASSERT

View file

@ -5,6 +5,7 @@ import { Service } from 'typedi';
import { promisify } from 'util'; import { promisify } from 'util';
import axios from 'axios'; import axios from 'axios';
import { GlobalConfig } from '@n8n/config';
import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow'; import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import type { PackageDirectoryLoader } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core';
@ -22,6 +23,7 @@ import {
import type { CommunityPackages } from '@/Interfaces'; import type { CommunityPackages } from '@/Interfaces';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { OrchestrationService } from './orchestration.service';
const { const {
PACKAGE_NAME_NOT_PROVIDED, PACKAGE_NAME_NOT_PROVIDED,
@ -45,6 +47,8 @@ const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/;
@Service() @Service()
export class CommunityPackagesService { export class CommunityPackagesService {
reinstallMissingPackages = false;
missingPackages: string[] = []; missingPackages: string[] = [];
constructor( constructor(
@ -52,7 +56,11 @@ export class CommunityPackagesService {
private readonly logger: Logger, private readonly logger: Logger,
private readonly installedPackageRepository: InstalledPackagesRepository, private readonly installedPackageRepository: InstalledPackagesRepository,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
) {} private readonly orchestrationService: OrchestrationService,
globalConfig: GlobalConfig,
) {
this.reinstallMissingPackages = globalConfig.nodes.communityPackages.reinstallMissing;
}
get hasMissingPackages() { get hasMissingPackages() {
return this.missingPackages.length > 0; return this.missingPackages.length > 0;
@ -73,11 +81,11 @@ export class CommunityPackagesService {
return await this.installedPackageRepository.find({ relations: ['installedNodes'] }); return await this.installedPackageRepository.find({ relations: ['installedNodes'] });
} }
async removePackageFromDatabase(packageName: InstalledPackages) { private async removePackageFromDatabase(packageName: InstalledPackages) {
return await this.installedPackageRepository.remove(packageName); return await this.installedPackageRepository.remove(packageName);
} }
async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { private async persistInstalledPackage(packageLoader: PackageDirectoryLoader) {
try { try {
return await this.installedPackageRepository.saveInstalledPackageWithNodes(packageLoader); return await this.installedPackageRepository.saveInstalledPackageWithNodes(packageLoader);
} catch (maybeError) { } catch (maybeError) {
@ -251,7 +259,7 @@ export class CommunityPackagesService {
} }
} }
async setMissingPackages({ reinstallMissingPackages }: { reinstallMissingPackages: boolean }) { async checkForMissingPackages() {
const installedPackages = await this.getAllInstalledPackages(); const installedPackages = await this.getAllInstalledPackages();
const missingPackages = new Set<{ packageName: string; version: string }>(); const missingPackages = new Set<{ packageName: string; version: string }>();
@ -271,24 +279,24 @@ export class CommunityPackagesService {
if (missingPackages.size === 0) return; if (missingPackages.size === 0) return;
this.logger.error( if (this.reinstallMissingPackages) {
'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) {
this.logger.info('Attempting to reinstall missing packages', { missingPackages }); this.logger.info('Attempting to reinstall missing packages', { missingPackages });
try { try {
// Optimistic approach - stop if any installation fails // Optimistic approach - stop if any installation fails
for (const missingPackage of missingPackages) { for (const missingPackage of missingPackages) {
await this.installNpmModule(missingPackage.packageName, missingPackage.version); await this.installPackage(missingPackage.packageName, missingPackage.version);
missingPackages.delete(missingPackage); missingPackages.delete(missingPackage);
} }
this.logger.info('Packages reinstalled successfully. Resuming regular initialization.'); this.logger.info('Packages reinstalled successfully. Resuming regular initialization.');
await this.loadNodesAndCredentials.postProcessLoaders();
} catch (error) { } catch (error) {
this.logger.error('n8n was unable to install the missing packages.'); 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( this.missingPackages = [...missingPackages].map(
@ -296,32 +304,30 @@ export class CommunityPackagesService {
); );
} }
async installNpmModule(packageName: string, version?: string): Promise<InstalledPackages> { async installPackage(packageName: string, version?: string): Promise<InstalledPackages> {
return await this.installOrUpdateNpmModule(packageName, { version }); return await this.installOrUpdatePackage(packageName, { version });
} }
async updateNpmModule( async updatePackage(
packageName: string, packageName: string,
installedPackage: InstalledPackages, installedPackage: InstalledPackages,
): Promise<InstalledPackages> { ): Promise<InstalledPackages> {
return await this.installOrUpdateNpmModule(packageName, { installedPackage }); return await this.installOrUpdatePackage(packageName, { installedPackage });
} }
async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise<void> { async removePackage(packageName: string, installedPackage: InstalledPackages): Promise<void> {
await this.executeNpmCommand(`npm remove ${packageName}`); await this.removeNpmPackage(packageName);
await this.removePackageFromDatabase(installedPackage); await this.removePackageFromDatabase(installedPackage);
await this.loadNodesAndCredentials.unloadPackage(packageName); await this.orchestrationService.publish('community-package-uninstall', { packageName });
await this.loadNodesAndCredentials.postProcessLoaders();
} }
private async installOrUpdateNpmModule( private async installOrUpdatePackage(
packageName: string, packageName: string,
options: { version?: string } | { installedPackage: InstalledPackages }, options: { version?: string } | { installedPackage: InstalledPackages },
) { ) {
const isUpdate = 'installedPackage' in options; const isUpdate = 'installedPackage' in options;
const command = isUpdate const packageVersion = isUpdate || !options.version ? 'latest' : options.version;
? `npm install ${packageName}@latest` const command = `npm install ${packageName}@${packageVersion}`;
: `npm install ${packageName}${options.version ? `@${options.version}` : ''}`;
try { try {
await this.executeNpmCommand(command); await this.executeNpmCommand(command);
@ -337,9 +343,8 @@ export class CommunityPackagesService {
loader = await this.loadNodesAndCredentials.loadPackage(packageName); loader = await this.loadNodesAndCredentials.loadPackage(packageName);
} catch (error) { } catch (error) {
// Remove this package since loading it failed // Remove this package since loading it failed
const removeCommand = `npm remove ${packageName}`;
try { try {
await this.executeNpmCommand(removeCommand); await this.executeNpmCommand(`npm remove ${packageName}`);
} catch {} } catch {}
throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error });
} }
@ -351,7 +356,12 @@ export class CommunityPackagesService {
await this.removePackageFromDatabase(options.installedPackage); await this.removePackageFromDatabase(options.installedPackage);
} }
const installedPackage = await this.persistInstalledPackage(loader); const installedPackage = await this.persistInstalledPackage(loader);
await this.orchestrationService.publish(
isUpdate ? 'community-package-update' : 'community-package-install',
{ packageName, packageVersion },
);
await this.loadNodesAndCredentials.postProcessLoaders(); await this.loadNodesAndCredentials.postProcessLoaders();
this.logger.info(`Community package installed: ${packageName}`);
return installedPackage; return installedPackage;
} catch (error) { } catch (error) {
throw new ApplicationError('Failed to save installed package', { throw new ApplicationError('Failed to save installed package', {
@ -361,12 +371,24 @@ export class CommunityPackagesService {
} }
} else { } else {
// Remove this package since it contains no loadable nodes // Remove this package since it contains no loadable nodes
const removeCommand = `npm remove ${packageName}`;
try { try {
await this.executeNpmCommand(removeCommand); await this.executeNpmCommand(`npm remove ${packageName}`);
} catch {} } catch {}
throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); 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}`);
}
} }

View file

@ -10,6 +10,7 @@ import { Push } from '@/push';
import { TestWebhooks } from '@/TestWebhooks'; import { TestWebhooks } from '@/TestWebhooks';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { CommunityPackagesService } from '@/services/communityPackages.service';
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
export async function handleCommandMessageMain(messageString: string) { export async function handleCommandMessageMain(messageString: string) {
@ -77,6 +78,20 @@ export async function handleCommandMessageMain(messageString: string) {
} }
await Container.get(ExternalSecretsManager).reloadAllProviders(); await Container.get(ExternalSecretsManager).reloadAllProviders();
break; 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': { case 'add-webhooks-triggers-and-pollers': {
if (!debounceMessageReceiver(message, 100)) { if (!debounceMessageReceiver(message, 100)) {

View file

@ -5,6 +5,7 @@ import Container from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { messageToRedisServiceCommandObject, debounceMessageReceiver } from '../helpers'; import { messageToRedisServiceCommandObject, debounceMessageReceiver } from '../helpers';
import config from '@/config'; import config from '@/config';
import { CommunityPackagesService } from '@/services/communityPackages.service';
export async function handleCommandMessageWebhook(messageString: string) { export async function handleCommandMessageWebhook(messageString: string) {
const queueModeId = config.getEnv('redis.queueModeId'); const queueModeId = config.getEnv('redis.queueModeId');
@ -63,6 +64,20 @@ export async function handleCommandMessageWebhook(messageString: string) {
} }
await Container.get(ExternalSecretsManager).reloadAllProviders(); await Container.get(ExternalSecretsManager).reloadAllProviders();
break; 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: default:
break; break;

View file

@ -10,6 +10,7 @@ import { debounceMessageReceiver, getOsCpuString } from '../helpers';
import type { WorkerCommandReceivedHandlerOptions } from './types'; import type { WorkerCommandReceivedHandlerOptions } from './types';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { CommunityPackagesService } from '@/services/communityPackages.service';
export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) { export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) {
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
@ -112,6 +113,18 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa
}); });
} }
break; 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': case 'reloadLicense':
if (!debounceMessageReceiver(message, 500)) return; if (!debounceMessageReceiver(message, 500)) return;
await Container.get(License).reload(); await Container.get(License).reload();

View file

@ -7,6 +7,9 @@ export type RedisServiceCommand =
| 'stopWorker' | 'stopWorker'
| 'reloadLicense' | 'reloadLicense'
| 'reloadExternalSecretsProviders' | 'reloadExternalSecretsProviders'
| 'community-package-install'
| 'community-package-update'
| 'community-package-uninstall'
| 'display-workflow-activation' // multi-main only | 'display-workflow-activation' // multi-main only
| 'display-workflow-deactivation' // multi-main only | 'display-workflow-deactivation' // multi-main only
| 'add-webhooks-triggers-and-pollers' // multi-main only | 'add-webhooks-triggers-and-pollers' // multi-main only
@ -26,7 +29,11 @@ export type RedisServiceBaseCommand =
senderId: string; senderId: string;
command: Exclude< command: Exclude<
RedisServiceCommand, 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?: { payload?: {
[key: string]: string | number | boolean | string[] | number[] | boolean[]; [key: string]: string | number | boolean | string[] | number[] | boolean[];
@ -41,6 +48,14 @@ export type RedisServiceBaseCommand =
senderId: string; senderId: string;
command: 'clear-test-webhooks'; command: 'clear-test-webhooks';
payload: { webhookKey: string; workflowEntity: IWorkflowDb; pushRef: string }; 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 = { export type RedisServiceWorkerResponseObject = {

View file

@ -179,7 +179,7 @@ describe('POST /community-packages', () => {
communityPackagesService.hasPackageLoaded.mockReturnValue(false); communityPackagesService.hasPackageLoaded.mockReturnValue(false);
communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' }); communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' });
communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName);
communityPackagesService.installNpmModule.mockResolvedValue(mockPackage()); communityPackagesService.installPackage.mockResolvedValue(mockPackage());
await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(200); 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); 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() }); await authAgent.patch('/community-packages').send({ name: mockPackageName() });
expect(communityPackagesService.updateNpmModule).toHaveBeenCalledTimes(1); expect(communityPackagesService.updatePackage).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -92,7 +92,6 @@ export const NPM_KEYWORD_SEARCH_URL =
'https://www.npmjs.com/search?q=keywords%3An8n-community-node-package'; '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 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_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 = export const COMMUNITY_NODES_NPM_INSTALLATION_URL =
'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm'; '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/`; export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/community-nodes/risks/`;

View file

@ -1562,7 +1562,6 @@
"settings.communityNodes.empty.description": "Install over {count} node packages contributed by our community.", "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.description.no-packages": "Install node packages contributed by our community.",
"settings.communityNodes.empty.installPackageLabel": "Install a community node", "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. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">More info</a>",
"settings.communityNodes.npmUnavailable.warning": "To use this feature, please <a href=\"{npmUrl}\" target=\"_blank\" title=\"How to install npm\">install npm</a> and restart n8n.", "settings.communityNodes.npmUnavailable.warning": "To use this feature, please <a href=\"{npmUrl}\" target=\"_blank\" title=\"How to install npm\">install npm</a> and restart n8n.",
"settings.communityNodes.notAvailableOnDesktop": "Feature unavailable on desktop. Please self-host to use community nodes.", "settings.communityNodes.notAvailableOnDesktop": "Feature unavailable on desktop. Please self-host to use community nodes.",
"settings.communityNodes.packageNodes.label": "{count} node | {count} nodes", "settings.communityNodes.packageNodes.label": "{count} node | {count} nodes",

View file

@ -3,25 +3,13 @@
<div :class="$style.headingContainer"> <div :class="$style.headingContainer">
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.communityNodes') }}</n8n-heading> <n8n-heading size="2xlarge">{{ $locale.baseText('settings.communityNodes') }}</n8n-heading>
<n8n-button <n8n-button
v-if=" v-if="communityNodesStore.getInstalledPackages.length > 0 && !loading"
!settingsStore.isQueueModeEnabled &&
communityNodesStore.getInstalledPackages.length > 0 &&
!loading
"
:label="$locale.baseText('settings.communityNodes.installModal.installButton.label')" :label="$locale.baseText('settings.communityNodes.installModal.installButton.label')"
size="large" size="large"
@click="openInstallModal" @click="openInstallModal"
/> />
</div> </div>
<div v-if="settingsStore.isQueueModeEnabled" :class="$style.actionBoxContainer"> <div v-if="loading" :class="$style.cardsContainer">
<n8n-action-box
:heading="$locale.baseText('settings.communityNodes.empty.title')"
:description="getEmptyStateDescription"
:callout-text="actionBoxConfig.calloutText"
:callout-theme="actionBoxConfig.calloutTheme"
/>
</div>
<div v-else-if="loading" :class="$style.cardsContainer">
<CommunityPackageCard <CommunityPackageCard
v-for="n in 2" v-for="n in 2"
:key="'index-' + n" :key="'index-' + n"
@ -55,7 +43,6 @@
import { import {
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
COMMUNITY_NODES_INSTALLATION_DOCS_URL, COMMUNITY_NODES_INSTALLATION_DOCS_URL,
COMMUNITY_NODES_MANUAL_INSTALLATION_DOCS_URL,
COMMUNITY_NODES_NPM_INSTALLATION_URL, COMMUNITY_NODES_NPM_INSTALLATION_URL,
} from '@/constants'; } from '@/constants';
import CommunityPackageCard from '@/components/CommunityPackageCard.vue'; import CommunityPackageCard from '@/components/CommunityPackageCard.vue';
@ -144,16 +131,6 @@ export default defineComponent({
}; };
} }
if (this.settingsStore.isQueueModeEnabled) {
return {
calloutText: this.$locale.baseText('settings.communityNodes.queueMode.warning', {
interpolate: { docURL: COMMUNITY_NODES_MANUAL_INSTALLATION_DOCS_URL },
}),
calloutTheme: 'warning',
hideButton: true,
};
}
return { return {
calloutText: '', calloutText: '',
calloutTheme: '', calloutTheme: '',