feat(core): Allow overriding npm registry for community packages (#10325)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-08-14 09:44:19 +02:00 committed by GitHub
parent 838f13337f
commit 33a2703429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 68 additions and 19 deletions

View file

@ -26,6 +26,10 @@ class CommunityPackagesConfig {
@Env('N8N_COMMUNITY_PACKAGES_ENABLED') @Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true; enabled: boolean = true;
/** NPM registry URL to pull community packages from */
@Env('N8N_COMMUNITY_PACKAGES_REGISTRY')
registry: string = 'https://registry.npmjs.org';
/** Whether to reinstall any missing community packages */ /** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES') @Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false; reinstallMissing: boolean = false;

View file

@ -108,6 +108,7 @@ describe('GlobalConfig', () => {
nodes: { nodes: {
communityPackages: { communityPackages: {
enabled: true, enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false, reinstallMissing: false,
}, },
errorTriggerType: 'n8n-nodes-base.errorTrigger', errorTriggerType: 'n8n-nodes-base.errorTrigger',

View file

@ -305,6 +305,10 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER); return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER);
} }
isCustomNpmRegistryEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
}
getCurrentEntitlements() { getCurrentEntitlements() {
return this.manager?.getCurrentEntitlements() ?? []; return this.manager?.getCurrentEntitlements() ?? [];
} }

View file

@ -90,6 +90,7 @@ export const LICENSE_FEATURES = {
PROJECT_ROLE_ADMIN: 'feat:projectRole:admin', PROJECT_ROLE_ADMIN: 'feat:projectRole:admin',
PROJECT_ROLE_EDITOR: 'feat:projectRole:editor', PROJECT_ROLE_EDITOR: 'feat:projectRole:editor',
PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer', PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer',
COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry',
} as const; } as const;
export const LICENSE_QUOTAS = { export const LICENSE_QUOTAS = {

View file

@ -87,6 +87,7 @@ export class E2EController {
[LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false, [LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false,
[LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false, [LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false,
[LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false, [LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false,
[LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false,
}; };
private numericFeatures: Record<NumericLicenseFeature, number> = { private numericFeatures: Record<NumericLicenseFeature, number> = {

View file

@ -20,6 +20,7 @@ import { InstalledNodesRepository } from '@db/repositories/installedNodes.reposi
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 type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import type { License } from '@/License';
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';
@ -39,19 +40,20 @@ const execMock = ((...args) => {
}) as typeof exec; }) as typeof exec;
describe('CommunityPackagesService', () => { describe('CommunityPackagesService', () => {
const license = mock<License>();
const globalConfig = mock<GlobalConfig>({ const globalConfig = mock<GlobalConfig>({
nodes: { nodes: {
communityPackages: { communityPackages: {
reinstallMissing: false, reinstallMissing: false,
registry: 'some.random.host',
}, },
}, },
}); });
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>(); const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const nodeName = randomName();
const installedNodesRepository = mockInstance(InstalledNodesRepository); const installedNodesRepository = mockInstance(InstalledNodesRepository);
installedNodesRepository.create.mockImplementation(() => { installedNodesRepository.create.mockImplementation(() => {
const nodeName = randomName();
return Object.assign(new InstalledNodes(), { return Object.assign(new InstalledNodes(), {
name: nodeName, name: nodeName,
type: nodeName, type: nodeName,
@ -74,6 +76,7 @@ describe('CommunityPackagesService', () => {
mock(), mock(),
loadNodesAndCredentials, loadNodesAndCredentials,
mock(), mock(),
license,
globalConfig, globalConfig,
); );
@ -374,25 +377,23 @@ describe('CommunityPackagesService', () => {
}; };
describe('updateNpmModule', () => { describe('updateNpmModule', () => {
const packageDirectoryLoader = mock<PackageDirectoryLoader>(); const installedPackage = mock<InstalledPackages>({ packageName: mockPackageName() });
const packageDirectoryLoader = mock<PackageDirectoryLoader>({
loadedNodes: [{ name: nodeName, version: 1 }],
});
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader); loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader);
mocked(exec).mockImplementation(execMock);
}); });
test('should call `exec` with the correct command ', async () => { test('should call `exec` with the correct command and registry', async () => {
// //
// ARRANGE // ARRANGE
// //
const nodeName = randomName(); license.isCustomNpmRegistryEnabled.mockReturnValue(true);
packageDirectoryLoader.loadedNodes = [{ name: nodeName, version: 1 }];
const installedPackage = new InstalledPackages();
installedPackage.packageName = mockPackageName();
mocked(exec).mockImplementation(execMock);
// //
// ACT // ACT
@ -406,10 +407,32 @@ describe('CommunityPackagesService', () => {
expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenNthCalledWith( expect(exec).toHaveBeenNthCalledWith(
1, 1,
`npm install ${installedPackage.packageName}@latest`, `npm install ${installedPackage.packageName}@latest --registry=some.random.host`,
expect.any(Object), expect.any(Object),
expect.any(Function), expect.any(Function),
); );
}); });
test('should throw when not licensed', async () => {
//
// ARRANGE
//
license.isCustomNpmRegistryEnabled.mockReturnValue(false);
//
// ACT
//
const promise = communityPackagesService.updatePackage(
installedPackage.packageName,
installedPackage,
);
//
// ASSERT
//
await expect(promise).rejects.toThrow(
'Your license does not allow for feat:communityNodes:customRegistry.',
);
});
}); });
}); });

View file

@ -14,16 +14,21 @@ import { toError } from '@/utils';
import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository'; import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository';
import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
import { import {
LICENSE_FEATURES,
NODE_PACKAGE_PREFIX, NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS, NPM_COMMAND_TOKENS,
NPM_PACKAGE_STATUS_GOOD, NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
UNKNOWN_FAILURE_REASON, UNKNOWN_FAILURE_REASON,
} from '@/constants'; } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
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'; import { OrchestrationService } from './orchestration.service';
import { License } from '@/License';
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
const { const {
PACKAGE_NAME_NOT_PROVIDED, PACKAGE_NAME_NOT_PROVIDED,
@ -57,10 +62,9 @@ export class CommunityPackagesService {
private readonly installedPackageRepository: InstalledPackagesRepository, private readonly installedPackageRepository: InstalledPackagesRepository,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
private readonly orchestrationService: OrchestrationService, private readonly orchestrationService: OrchestrationService,
globalConfig: GlobalConfig, private readonly license: License,
) { private readonly globalConfig: GlobalConfig,
this.reinstallMissingPackages = globalConfig.nodes.communityPackages.reinstallMissing; ) {}
}
get hasMissingPackages() { get hasMissingPackages() {
return this.missingPackages.length > 0; return this.missingPackages.length > 0;
@ -279,7 +283,8 @@ export class CommunityPackagesService {
if (missingPackages.size === 0) return; if (missingPackages.size === 0) return;
if (this.reinstallMissingPackages) { const { reinstallMissing } = this.globalConfig.nodes.communityPackages;
if (reinstallMissing) {
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
@ -321,13 +326,21 @@ export class CommunityPackagesService {
await this.orchestrationService.publish('community-package-uninstall', { packageName }); await this.orchestrationService.publish('community-package-uninstall', { packageName });
} }
private getNpmRegistry() {
const { registry } = this.globalConfig.nodes.communityPackages;
if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
}
return registry;
}
private async installOrUpdatePackage( 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 packageVersion = isUpdate || !options.version ? 'latest' : options.version; const packageVersion = isUpdate || !options.version ? 'latest' : options.version;
const command = `npm install ${packageName}@${packageVersion}`; const command = `npm install ${packageName}@${packageVersion} --registry=${this.getNpmRegistry()}`;
try { try {
await this.executeNpmCommand(command); await this.executeNpmCommand(command);
@ -379,7 +392,9 @@ export class CommunityPackagesService {
} }
async installOrUpdateNpmPackage(packageName: string, packageVersion: string) { async installOrUpdateNpmPackage(packageName: string, packageVersion: string) {
await this.executeNpmCommand(`npm install ${packageName}@${packageVersion}`); await this.executeNpmCommand(
`npm install ${packageName}@${packageVersion} --registry=${this.getNpmRegistry()}`,
);
await this.loadNodesAndCredentials.loadPackage(packageName); await this.loadNodesAndCredentials.loadPackage(packageName);
await this.loadNodesAndCredentials.postProcessLoaders(); await this.loadNodesAndCredentials.postProcessLoaders();
this.logger.info(`Community package installed: ${packageName}`); this.logger.info(`Community package installed: ${packageName}`);