mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -08:00
feat(core): Allow overriding npm registry for community packages (#10325)
This commit is contained in:
parent
838f13337f
commit
33a2703429
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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() ?? [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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> = {
|
||||||
|
|
|
@ -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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}`);
|
||||||
|
|
Loading…
Reference in a new issue