mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 03:42:16 +00:00
refactor(core): Centralize module management (#16464)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
@@ -196,8 +196,8 @@ export interface FrontendSettings {
|
|||||||
quota: number;
|
quota: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Backend modules that were loaded during startup based on user configuration and pre-init check. */
|
/** Backend modules that were initialized during startup. */
|
||||||
loadedModules: string[];
|
activeModules: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FrontendModuleSettings = {
|
export type FrontendModuleSettings = {
|
||||||
|
|||||||
@@ -72,19 +72,7 @@ export abstract class BaseCommand extends Command {
|
|||||||
protected needsTaskRunner = false;
|
protected needsTaskRunner = false;
|
||||||
|
|
||||||
protected async loadModules() {
|
protected async loadModules() {
|
||||||
for (const moduleName of this.modulesConfig.modules) {
|
await this.moduleRegistry.loadModules();
|
||||||
// add module to the registry for dependency injection
|
|
||||||
try {
|
|
||||||
await import(`../modules/${moduleName}/${moduleName}.module`);
|
|
||||||
} catch {
|
|
||||||
await import(`../modules/${moduleName}.ee/${moduleName}.module`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modulesConfig.addLoadedModule(moduleName);
|
|
||||||
this.logger.debug(`Loaded module "${moduleName}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.moduleRegistry.addEntities();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|||||||
186
packages/cli/src/modules/__tests__/module-registry.test.ts
Normal file
186
packages/cli/src/modules/__tests__/module-registry.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import type { LicenseState } from '@n8n/backend-common';
|
||||||
|
import type { ModuleInterface, ModuleMetadata } from '@n8n/decorators';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import { ModuleConfusionError } from '../errors/module-confusion.error';
|
||||||
|
import { ModuleRegistry } from '../module-registry';
|
||||||
|
import { MODULE_NAMES } from '../modules.config';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
process.env = {};
|
||||||
|
Container.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eligibleModules', () => {
|
||||||
|
it('should consider all default modules eligible', () => {
|
||||||
|
expect(Container.get(ModuleRegistry).eligibleModules).toEqual(MODULE_NAMES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should consider a module ineligible if it was disabled via env var', () => {
|
||||||
|
process.env.N8N_DISABLED_MODULES = 'insights';
|
||||||
|
expect(Container.get(ModuleRegistry).eligibleModules).toEqual(['external-secrets']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw `ModuleConfusionError` if a module is both enabled and disabled', () => {
|
||||||
|
process.env.N8N_ENABLED_MODULES = 'insights';
|
||||||
|
process.env.N8N_DISABLED_MODULES = 'insights';
|
||||||
|
expect(() => Container.get(ModuleRegistry).eligibleModules).toThrow(ModuleConfusionError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadModules', () => {
|
||||||
|
it('should load entities defined by modules', async () => {
|
||||||
|
const FirstEntity = class FirstEntityClass {};
|
||||||
|
const SecondEntity = class SecondEntityClass {};
|
||||||
|
|
||||||
|
const ModuleClass = {
|
||||||
|
entities: jest.fn().mockReturnValue([FirstEntity, SecondEntity]),
|
||||||
|
};
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getClasses: jest.fn().mockReturnValue([ModuleClass]),
|
||||||
|
});
|
||||||
|
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.loadModules([]);
|
||||||
|
|
||||||
|
expect(moduleRegistry.entities).toEqual([FirstEntity, SecondEntity]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load no entities if none are defined by modules', async () => {
|
||||||
|
const ModuleClass = { entities: jest.fn().mockReturnValue([]) };
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getClasses: jest.fn().mockReturnValue([ModuleClass]),
|
||||||
|
});
|
||||||
|
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.loadModules([]);
|
||||||
|
|
||||||
|
expect(moduleRegistry.entities).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initModules', () => {
|
||||||
|
it('should init module if it has no feature flag', async () => {
|
||||||
|
const ModuleClass = { init: jest.fn() };
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
|
||||||
|
});
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
expect(ModuleClass.init).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should init module if it is licensed', async () => {
|
||||||
|
const ModuleClass = { init: jest.fn() };
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([
|
||||||
|
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(true) });
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), licenseState, mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
expect(ModuleClass.init).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip init for unlicensed module', async () => {
|
||||||
|
const ModuleClass = { init: jest.fn() };
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([
|
||||||
|
['test-module', { licenseFlag: 'feat:testFeature', class: ModuleClass }],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(false) });
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), licenseState, mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
expect(ModuleClass.init).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept module without `init` method', async () => {
|
||||||
|
const ModuleClass = {};
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([['test-module', { licenseFlag: undefined, class: ModuleClass }]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
await expect(moduleRegistry.initModules()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers settings', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const moduleName = 'test-module';
|
||||||
|
const moduleSettings = { foo: 1 };
|
||||||
|
const ModuleClass: ModuleInterface = {
|
||||||
|
init: jest.fn(),
|
||||||
|
settings: jest.fn().mockReturnValue(moduleSettings),
|
||||||
|
};
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||||
|
});
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(ModuleClass.settings).toHaveBeenCalled();
|
||||||
|
expect(moduleRegistry.settings.has(moduleName)).toBe(true);
|
||||||
|
expect(moduleRegistry.settings.get(moduleName)).toBe(moduleSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates the module', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const moduleName = 'test-module';
|
||||||
|
const moduleSettings = { foo: 1 };
|
||||||
|
const ModuleClass: ModuleInterface = {
|
||||||
|
init: jest.fn(),
|
||||||
|
settings: jest.fn().mockReturnValue(moduleSettings),
|
||||||
|
};
|
||||||
|
const moduleMetadata = mock<ModuleMetadata>({
|
||||||
|
getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]),
|
||||||
|
});
|
||||||
|
Container.get = jest.fn().mockReturnValue(ModuleClass);
|
||||||
|
|
||||||
|
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await moduleRegistry.initModules();
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
|
||||||
|
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,51 +1,20 @@
|
|||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
|
||||||
|
|
||||||
|
import { UnknownModuleError } from '../errors/unknown-module.error';
|
||||||
import { ModulesConfig } from '../modules.config';
|
import { ModulesConfig } from '../modules.config';
|
||||||
|
|
||||||
describe('ModulesConfig', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
process.env = {};
|
process.env = {};
|
||||||
Container.reset();
|
Container.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with insights modules if no environment variable is set', () => {
|
it('should throw `UnknownModuleError` if any enabled module name is invalid', () => {
|
||||||
const config = Container.get(ModulesConfig);
|
|
||||||
expect(config.modules).toEqual(['insights', 'external-secrets']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse valid module names from environment variable', () => {
|
|
||||||
process.env.N8N_ENABLED_MODULES = 'insights';
|
|
||||||
const config = Container.get(ModulesConfig);
|
|
||||||
expect(config.modules).toEqual(['insights', 'external-secrets']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable valid module names from environment variable', () => {
|
|
||||||
process.env.N8N_DISABLED_MODULES = 'insights';
|
|
||||||
const config = Container.get(ModulesConfig);
|
|
||||||
expect(config.modules).toEqual(['external-secrets']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw UnexpectedError for invalid module names', () => {
|
|
||||||
process.env.N8N_ENABLED_MODULES = 'invalidModule';
|
|
||||||
expect(() => Container.get(ModulesConfig)).toThrow(UnexpectedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw UnexpectedError if any module is both enabled and disabled', () => {
|
|
||||||
process.env.N8N_ENABLED_MODULES = 'insights';
|
|
||||||
process.env.N8N_DISABLED_MODULES = 'insights';
|
|
||||||
const config = Container.get(ModulesConfig);
|
|
||||||
expect(() => config.modules).toThrow(UnexpectedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw UnexpectedError if any enabled module name is invalid', () => {
|
|
||||||
process.env.N8N_ENABLED_MODULES = 'insights,invalidModule';
|
process.env.N8N_ENABLED_MODULES = 'insights,invalidModule';
|
||||||
expect(() => Container.get(ModulesConfig)).toThrow(UnexpectedError);
|
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw UnexpectedError if any disabled module name is invalid', () => {
|
it('should throw `UnknownModuleError` if any disabled module name is invalid', () => {
|
||||||
process.env.N8N_DISABLED_MODULES = 'insights,invalidModule';
|
process.env.N8N_DISABLED_MODULES = 'insights,invalidModule';
|
||||||
expect(() => Container.get(ModulesConfig)).toThrow(UnexpectedError);
|
expect(() => Container.get(ModulesConfig)).toThrowError(UnknownModuleError);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
9
packages/cli/src/modules/errors/missing-module.error.ts
Normal file
9
packages/cli/src/modules/errors/missing-module.error.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { UserError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class MissingModuleError extends UserError {
|
||||||
|
constructor(moduleName: string, errorMsg: string) {
|
||||||
|
super(
|
||||||
|
`Failed to load module "${moduleName}": ${errorMsg}. Please review the module's entrypoint file name and the module's directory name.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/cli/src/modules/errors/module-confusion.error.ts
Normal file
11
packages/cli/src/modules/errors/module-confusion.error.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { UserError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class ModuleConfusionError extends UserError {
|
||||||
|
constructor(moduleNames: string[]) {
|
||||||
|
const modules = moduleNames.length > 1 ? 'modules' : 'a module';
|
||||||
|
|
||||||
|
super(
|
||||||
|
`Found ${modules} listed in both \`N8N_ENABLED_MODULES\` and \`N8N_DISABLED_MODULES\`: ${moduleNames.join(', ')}. Please review your environment variables, as a module cannot be both enabled and disabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/cli/src/modules/errors/unknown-module.error.ts
Normal file
7
packages/cli/src/modules/errors/unknown-module.error.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class UnknownModuleError extends UnexpectedError {
|
||||||
|
constructor(moduleName: string) {
|
||||||
|
super(`Unknown module "${moduleName}"`, { level: 'fatal' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import { InsightsCollectionService } from '../insights-collection.service';
|
|||||||
import { InsightsConfig } from '../insights.config';
|
import { InsightsConfig } from '../insights.config';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { InsightsCompactionService } from '../insights-compaction.service';
|
|||||||
import { InsightsConfig } from '../insights.config';
|
import { InsightsConfig } from '../insights.config';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { InsightsPruningService } from '../insights-pruning.service';
|
|||||||
import { InsightsConfig } from '../insights.config';
|
import { InsightsConfig } from '../insights.config';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { InsightsConfig } from '../insights.config';
|
|||||||
import { InsightsService } from '../insights.service';
|
import { InsightsService } from '../insights.service';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { TypeUnit } from '../insights-shared';
|
|||||||
let insightsRawRepository: InsightsRawRepository;
|
let insightsRawRepository: InsightsRawRepository;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
insightsRawRepository = Container.get(InsightsRawRepository);
|
insightsRawRepository = Container.get(InsightsRawRepository);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { InsightsByPeriodRepository } from '../insights-by-period.repository';
|
|||||||
|
|
||||||
describe('InsightsByPeriodRepository', () => {
|
describe('InsightsByPeriodRepository', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testModules.load(['insights']);
|
await testModules.loadModules(['insights']);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import type {
|
|||||||
Workflow,
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { MissingModuleError } from './errors/missing-module.error';
|
||||||
|
import { ModuleConfusionError } from './errors/module-confusion.error';
|
||||||
|
import { ModulesConfig } from './modules.config';
|
||||||
|
import type { ModuleName } from './modules.config';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class ModuleRegistry {
|
export class ModuleRegistry {
|
||||||
readonly entities: EntityClass[] = [];
|
readonly entities: EntityClass[] = [];
|
||||||
@@ -24,8 +29,63 @@ export class ModuleRegistry {
|
|||||||
private readonly lifecycleMetadata: LifecycleMetadata,
|
private readonly lifecycleMetadata: LifecycleMetadata,
|
||||||
private readonly licenseState: LicenseState,
|
private readonly licenseState: LicenseState,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
private readonly modulesConfig: ModulesConfig,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private readonly defaultModules: ModuleName[] = ['insights', 'external-secrets'];
|
||||||
|
|
||||||
|
private readonly activeModules: string[] = [];
|
||||||
|
|
||||||
|
get eligibleModules(): ModuleName[] {
|
||||||
|
const { enabledModules, disabledModules } = this.modulesConfig;
|
||||||
|
|
||||||
|
const doubleListed = enabledModules.filter((m) => disabledModules.includes(m));
|
||||||
|
|
||||||
|
if (doubleListed.length > 0) throw new ModuleConfusionError(doubleListed);
|
||||||
|
|
||||||
|
const defaultPlusEnabled = [...new Set([...this.defaultModules, ...enabledModules])];
|
||||||
|
|
||||||
|
return defaultPlusEnabled.filter((m) => !disabledModules.includes(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads [module name].module.ts for each eligible module.
|
||||||
|
* This only registers the database entities for module and should be done
|
||||||
|
* before instantiating the datasource.
|
||||||
|
*
|
||||||
|
* This will not register routes or do any other kind of module related
|
||||||
|
* setup.
|
||||||
|
*/
|
||||||
|
async loadModules(modules?: ModuleName[]) {
|
||||||
|
for (const moduleName of modules ?? this.eligibleModules) {
|
||||||
|
try {
|
||||||
|
await import(`../modules/${moduleName}/${moduleName}.module`);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await import(`../modules/${moduleName}.ee/${moduleName}.module`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ModuleClass of this.moduleMetadata.getClasses()) {
|
||||||
|
const entities = Container.get(ModuleClass).entities?.();
|
||||||
|
|
||||||
|
if (!entities || entities.length === 0) continue;
|
||||||
|
|
||||||
|
this.entities.push(...entities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `init` on each eligible module.
|
||||||
|
*
|
||||||
|
* This will do things like registering routes, setup timers or other module
|
||||||
|
* specific setup.
|
||||||
|
*
|
||||||
|
* `ModuleRegistry.loadModules` must have been called before.
|
||||||
|
*/
|
||||||
async initModules() {
|
async initModules() {
|
||||||
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
|
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
|
||||||
const { licenseFlag, class: ModuleClass } = moduleEntry;
|
const { licenseFlag, class: ModuleClass } = moduleEntry;
|
||||||
@@ -34,6 +94,7 @@ export class ModuleRegistry {
|
|||||||
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
|
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Container.get(ModuleClass).init?.();
|
await Container.get(ModuleClass).init?.();
|
||||||
|
|
||||||
const moduleSettings = await Container.get(ModuleClass).settings?.();
|
const moduleSettings = await Container.get(ModuleClass).settings?.();
|
||||||
@@ -41,17 +102,19 @@ export class ModuleRegistry {
|
|||||||
if (!moduleSettings) continue;
|
if (!moduleSettings) continue;
|
||||||
|
|
||||||
this.settings.set(moduleName, moduleSettings);
|
this.settings.set(moduleName, moduleSettings);
|
||||||
|
|
||||||
|
this.logger.debug(`Initialized module "${moduleName}"`);
|
||||||
|
|
||||||
|
this.activeModules.push(moduleName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEntities() {
|
isActive(moduleName: ModuleName) {
|
||||||
for (const ModuleClass of this.moduleMetadata.getClasses()) {
|
return this.activeModules.includes(moduleName);
|
||||||
const entities = Container.get(ModuleClass).entities?.();
|
|
||||||
|
|
||||||
if (!entities || entities.length === 0) continue;
|
|
||||||
|
|
||||||
this.entities.push(...entities);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveModules() {
|
||||||
|
return this.activeModules;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerLifecycleHooks(hooks: ExecutionLifecycleHooks) {
|
registerLifecycleHooks(hooks: ExecutionLifecycleHooks) {
|
||||||
|
|||||||
@@ -1,58 +1,28 @@
|
|||||||
import { CommaSeparatedStringArray, Config, Env } from '@n8n/config';
|
import { CommaSeparatedStringArray, Config, Env } from '@n8n/config';
|
||||||
import type { InstanceSettings } from 'n8n-core';
|
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export type ModulePreInitContext = {
|
import { UnknownModuleError } from './errors/unknown-module.error';
|
||||||
instance: InstanceSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModulePreInit = {
|
export const MODULE_NAMES = ['insights', 'external-secrets'] as const;
|
||||||
shouldLoadModule: (ctx: ModulePreInitContext) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleNames = ['insights', 'external-secrets'] as const;
|
export type ModuleName = (typeof MODULE_NAMES)[number];
|
||||||
export type ModuleName = (typeof moduleNames)[number];
|
|
||||||
|
|
||||||
class Modules extends CommaSeparatedStringArray<ModuleName> {
|
class ModuleArray extends CommaSeparatedStringArray<ModuleName> {
|
||||||
constructor(str: string) {
|
constructor(str: string) {
|
||||||
super(str);
|
super(str);
|
||||||
|
|
||||||
for (const moduleName of this) {
|
for (const moduleName of this) {
|
||||||
if (!moduleNames.includes(moduleName)) {
|
if (!MODULE_NAMES.includes(moduleName)) throw new UnknownModuleError(moduleName);
|
||||||
throw new UnexpectedError(`Unknown module name ${moduleName}`, { level: 'fatal' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
export class ModulesConfig {
|
export class ModulesConfig {
|
||||||
/** Comma-separated list of all modules enabled */
|
/** Comma-separated list of all enabled modules. */
|
||||||
@Env('N8N_ENABLED_MODULES')
|
@Env('N8N_ENABLED_MODULES')
|
||||||
enabledModules: Modules = [];
|
enabledModules: ModuleArray = [];
|
||||||
|
|
||||||
/** Comma-separated list of all disabled modules */
|
/** Comma-separated list of all disabled modules. */
|
||||||
@Env('N8N_DISABLED_MODULES')
|
@Env('N8N_DISABLED_MODULES')
|
||||||
disabledModules: Modules = [];
|
disabledModules: ModuleArray = [];
|
||||||
|
|
||||||
// Default modules are always enabled unless explicitly disabled
|
|
||||||
private readonly defaultModules: ModuleName[] = ['insights', 'external-secrets'];
|
|
||||||
|
|
||||||
// Loaded modules are the ones that have been loaded so far by the instance
|
|
||||||
readonly loadedModules = new Set<ModuleName>();
|
|
||||||
|
|
||||||
// Get all modules by merging default and enabled, and filtering out disabled modules
|
|
||||||
get modules(): ModuleName[] {
|
|
||||||
if (this.enabledModules.some((module) => this.disabledModules.includes(module))) {
|
|
||||||
throw new UnexpectedError('Module cannot be both enabled and disabled', { level: 'fatal' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledModules = Array.from(new Set(this.defaultModules.concat(this.enabledModules)));
|
|
||||||
|
|
||||||
return enabledModules.filter((module) => !this.disabledModules.includes(module));
|
|
||||||
}
|
|
||||||
|
|
||||||
addLoadedModule(module: ModuleName) {
|
|
||||||
this.loadedModules.add(module);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
|
|||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
import { ModuleRegistry } from '@/modules/module-registry';
|
import { ModuleRegistry } from '@/modules/module-registry';
|
||||||
import { ModulesConfig } from '@/modules/modules.config';
|
|
||||||
import { isApiEnabled } from '@/public-api';
|
import { isApiEnabled } from '@/public-api';
|
||||||
import { PushConfig } from '@/push/push.config';
|
import { PushConfig } from '@/push/push.config';
|
||||||
import type { CommunityPackagesService } from '@/services/community-packages.service';
|
import type { CommunityPackagesService } from '@/services/community-packages.service';
|
||||||
@@ -49,7 +48,6 @@ export class FrontendService {
|
|||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
private readonly securityConfig: SecurityConfig,
|
private readonly securityConfig: SecurityConfig,
|
||||||
private readonly modulesConfig: ModulesConfig,
|
|
||||||
private readonly pushConfig: PushConfig,
|
private readonly pushConfig: PushConfig,
|
||||||
private readonly binaryDataConfig: BinaryDataConfig,
|
private readonly binaryDataConfig: BinaryDataConfig,
|
||||||
private readonly licenseState: LicenseState,
|
private readonly licenseState: LicenseState,
|
||||||
@@ -256,7 +254,7 @@ export class FrontendService {
|
|||||||
evaluation: {
|
evaluation: {
|
||||||
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
|
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
|
||||||
},
|
},
|
||||||
loadedModules: this.modulesConfig.modules,
|
activeModules: this.moduleRegistry.getActiveModules(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
|
||||||
import { ModuleRegistry } from '@/modules/module-registry';
|
import { ModuleRegistry } from '@/modules/module-registry';
|
||||||
|
import type { ModuleName } from '@/modules/modules.config';
|
||||||
|
|
||||||
export async function load(moduleNames: string[]) {
|
export async function loadModules(moduleNames: ModuleName[]) {
|
||||||
for (const moduleName of moduleNames) {
|
await Container.get(ModuleRegistry).loadModules(moduleNames);
|
||||||
try {
|
|
||||||
await import(`../../../src/modules/${moduleName}/${moduleName}.module`);
|
|
||||||
} catch {
|
|
||||||
await import(`../../../src/modules/${moduleName}.ee/${moduleName}.module`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Container.get(ModuleRegistry).addEntities();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const setupTestServer = ({
|
|||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (modules) await testModules.load(modules);
|
if (modules) await testModules.loadModules(modules);
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
|
|
||||||
config.set('userManagement.jwtSecret', 'My JWT secret');
|
config.set('userManagement.jwtSecret', 'My JWT secret');
|
||||||
|
|||||||
@@ -148,5 +148,5 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
evaluation: {
|
evaluation: {
|
||||||
quota: 0,
|
quota: 0,
|
||||||
},
|
},
|
||||||
loadedModules: [],
|
activeModules: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const mainMenuItems = computed(() => [
|
|||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
route: { to: { name: VIEWS.INSIGHTS } },
|
route: { to: { name: VIEWS.INSIGHTS } },
|
||||||
available:
|
available:
|
||||||
settingsStore.settings.loadedModules.includes('insights') &&
|
settingsStore.settings.activeModules.includes('insights') &&
|
||||||
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
|
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const useInsightsStore = defineStore('insights', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isInsightsEnabled = computed(() =>
|
const isInsightsEnabled = computed(() =>
|
||||||
settingsStore.settings.loadedModules.includes('insights'),
|
settingsStore.settings.activeModules.includes('insights'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDashboardEnabled = computed(
|
const isDashboardEnabled = computed(
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
|
|
||||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||||
|
|
||||||
const loadedModules = computed(() => settings.value.loadedModules);
|
const activeModules = computed(() => settings.value.activeModules);
|
||||||
|
|
||||||
const setSettings = (newSettings: FrontendSettings) => {
|
const setSettings = (newSettings: FrontendSettings) => {
|
||||||
settings.value = newSettings;
|
settings.value = newSettings;
|
||||||
@@ -426,7 +426,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
getSettings,
|
getSettings,
|
||||||
setSettings,
|
setSettings,
|
||||||
initialize,
|
initialize,
|
||||||
loadedModules,
|
activeModules,
|
||||||
getModuleSettings,
|
getModuleSettings,
|
||||||
moduleSettings,
|
moduleSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ let telemetry: ReturnType<typeof useTelemetry>;
|
|||||||
describe('SigninView', () => {
|
describe('SigninView', () => {
|
||||||
const signInWithValidUser = async () => {
|
const signInWithValidUser = async () => {
|
||||||
settingsStore.isCloudDeployment = false;
|
settingsStore.isCloudDeployment = false;
|
||||||
settingsStore.loadedModules = [];
|
settingsStore.activeModules = [];
|
||||||
usersStore.loginWithCreds.mockResolvedValueOnce();
|
usersStore.loginWithCreds.mockResolvedValueOnce();
|
||||||
|
|
||||||
const { getByRole, queryByTestId, container } = renderComponent();
|
const { getByRole, queryByTestId, container } = renderComponent();
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const login = async (form: LoginRequestDto) => {
|
|||||||
}
|
}
|
||||||
await settingsStore.getSettings();
|
await settingsStore.getSettings();
|
||||||
|
|
||||||
if (settingsStore.loadedModules.length > 0) {
|
if (settingsStore.activeModules.length > 0) {
|
||||||
await settingsStore.getModuleSettings();
|
await settingsStore.getModuleSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user