fix(core): Fix orchestration flow with expired license (#12444)

This commit is contained in:
Iván Ovejero 2025-01-09 14:37:53 +01:00 committed by GitHub
parent 6711cbcc64
commit ecff3b732a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 72 additions and 122 deletions

View file

@ -3,7 +3,6 @@ import { LicenseManager } from '@n8n_io/license-sdk';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { License } from '@/license'; import { License } from '@/license';
import { mockLogger } from '@test/mocking'; import { mockLogger } from '@test/mocking';
@ -31,6 +30,7 @@ describe('License', () => {
const instanceSettings = mock<InstanceSettings>({ const instanceSettings = mock<InstanceSettings>({
instanceId: MOCK_INSTANCE_ID, instanceId: MOCK_INSTANCE_ID,
instanceType: 'main', instanceType: 'main',
isLeader: true,
}); });
beforeEach(async () => { beforeEach(async () => {
@ -67,7 +67,7 @@ describe('License', () => {
license = new License( license = new License(
logger, logger,
mock<InstanceSettings>({ instanceType: 'worker' }), mock<InstanceSettings>({ instanceType: 'worker', isLeader: false }),
mock(), mock(),
mock(), mock(),
mock<GlobalConfig>({ license: licenseConfig }), mock<GlobalConfig>({ license: licenseConfig }),
@ -197,17 +197,14 @@ describe('License', () => {
describe('License', () => { describe('License', () => {
describe('init', () => { describe('init', () => {
describe('in single-main setup', () => { it('when leader main with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => {
describe('with `license.autoRenewEnabled` enabled', () => {
it('should enable renewal', async () => {
const globalConfig = mock<GlobalConfig>({ const globalConfig = mock<GlobalConfig>({
license: licenseConfig, license: { ...licenseConfig, autoRenewalEnabled: true },
multiMainSetup: { enabled: false },
}); });
await new License( await new License(
mockLogger(), mockLogger(),
mock<InstanceSettings>({ instanceType: 'main' }), mock<InstanceSettings>({ instanceType: 'main', isLeader: true }),
mock(), mock(),
mock(), mock(),
globalConfig, globalConfig,
@ -217,74 +214,42 @@ describe('License', () => {
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }), expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
); );
}); });
it.each([
{
scenario: 'when leader main with N8N_LICENSE_AUTO_RENEW_ENABLED=false',
isLeader: true,
autoRenewalEnabled: false,
},
{
scenario: 'when follower main with N8N_LICENSE_AUTO_RENEW_ENABLED=true',
isLeader: false,
autoRenewalEnabled: true,
},
{
scenario: 'when follower main with N8N_LICENSE_AUTO_RENEW_ENABLED=false',
isLeader: false,
autoRenewalEnabled: false,
},
])('$scenario, should disable renewal', async ({ isLeader, autoRenewalEnabled }) => {
const globalConfig = mock<GlobalConfig>({
license: { ...licenseConfig, autoRenewalEnabled },
}); });
describe('with `license.autoRenewEnabled` disabled', () => {
it('should disable renewal', async () => {
await new License( await new License(
mockLogger(), mockLogger(),
mock<InstanceSettings>({ instanceType: 'main' }), mock<InstanceSettings>({ instanceType: 'main', isLeader }),
mock(),
mock(), mock(),
mock(), mock(),
globalConfig,
).init(); ).init();
expect(LicenseManager).toHaveBeenCalledWith( const expectedRenewalSettings =
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }), isLeader && autoRenewalEnabled
); ? { autoRenewEnabled: true, renewOnInit: true }
}); : { autoRenewEnabled: false, renewOnInit: false };
});
});
describe('in multi-main setup', () => { expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
describe('with `license.autoRenewEnabled` disabled', () => {
test.each(['unset', 'leader', 'follower'])(
'if %s status, should disable removal',
async (status) => {
const globalConfig = mock<GlobalConfig>({
license: { ...licenseConfig, autoRenewalEnabled: false },
multiMainSetup: { enabled: true },
});
config.set('multiMainSetup.instanceType', status);
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
},
);
});
describe('with `license.autoRenewEnabled` enabled', () => {
test.each(['unset', 'follower'])('if %s status, should disable removal', async (status) => {
const globalConfig = mock<GlobalConfig>({
license: { ...licenseConfig, autoRenewalEnabled: false },
multiMainSetup: { enabled: true },
});
config.set('multiMainSetup.instanceType', status);
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
});
it('if leader status, should enable renewal', async () => {
const globalConfig = mock<GlobalConfig>({
license: licenseConfig,
multiMainSetup: { enabled: true },
});
config.set('multiMainSetup.instanceType', 'leader');
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
});
}); });
}); });

View file

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core'; import { Flags } from '@oclif/core';
import glob from 'fast-glob'; import glob from 'fast-glob';
@ -21,7 +20,6 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service'; import { ExecutionService } from '@/executions/execution.service';
import { License } from '@/license';
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler'; import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { Server } from '@/server'; import { Server } from '@/server';
@ -192,18 +190,23 @@ export class Start extends BaseCommand {
await super.init(); await super.init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager); this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
this.instanceSettings.setMultiMainEnabled( const isMultiMainEnabled =
config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled, config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled;
);
await this.initLicense(); this.instanceSettings.setMultiMainEnabled(isMultiMainEnabled);
/**
* We temporarily license multi-main to allow orchestration to set instance
* role, which is needed by license init. Once the license is initialized,
* the actual value will be used for the license check.
*/
if (isMultiMainEnabled) this.instanceSettings.setMultiMainLicensed(true);
await this.initOrchestration(); await this.initOrchestration();
this.logger.debug('Orchestration init complete'); await this.initLicense();
if (!this.globalConfig.license.autoRenewalEnabled && this.instanceSettings.isLeader) { if (isMultiMainEnabled && !this.license.isMultiMainLicensed()) {
this.logger.warn( throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
'Automatic license renewal is disabled. The license will not renew automatically, and access to licensed features may be lost!',
);
} }
Container.get(WaitTracker).init(); Container.get(WaitTracker).init();
@ -237,13 +240,6 @@ export class Start extends BaseCommand {
return; return;
} }
if (
Container.get(GlobalConfig).multiMainSetup.enabled &&
!Container.get(License).isMultipleMainInstancesLicensed()
) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
}
const orchestrationService = Container.get(OrchestrationService); const orchestrationService = Container.get(OrchestrationService);
await orchestrationService.init(); await orchestrationService.init();

View file

@ -18,6 +18,9 @@ import {
} from './constants'; } from './constants';
import type { BooleanLicenseFeature, NumericLicenseFeature } from './interfaces'; import type { BooleanLicenseFeature, NumericLicenseFeature } from './interfaces';
const LICENSE_RENEWAL_DISABLED_WARNING =
'Automatic license renewal is disabled. The license will not renew automatically, and access to licensed features may be lost!';
export type FeatureReturnType = Partial< export type FeatureReturnType = Partial<
{ {
planName: string; planName: string;
@ -40,25 +43,6 @@ export class License {
this.logger = this.logger.scoped('license'); this.logger = this.logger.scoped('license');
} }
/**
* Whether this instance should renew the license - on init and periodically.
*/
private renewalEnabled() {
if (this.instanceSettings.instanceType !== 'main') return false;
const autoRenewEnabled = this.globalConfig.license.autoRenewalEnabled;
/**
* In multi-main setup, all mains start off with `unset` status and so renewal disabled.
* On becoming leader or follower, each will enable or disable renewal, respectively.
* This ensures the mains do not cause a 429 (too many requests) on license init.
*/
if (this.globalConfig.multiMainSetup.enabled) {
return autoRenewEnabled && this.instanceSettings.isLeader;
}
return autoRenewEnabled;
}
async init(forceRecreate = false) { async init(forceRecreate = false) {
if (this.manager && !forceRecreate) { if (this.manager && !forceRecreate) {
this.logger.warn('License manager already initialized or shutting down'); this.logger.warn('License manager already initialized or shutting down');
@ -87,15 +71,20 @@ export class License {
? async () => await this.licenseMetricsService.collectPassthroughData() ? async () => await this.licenseMetricsService.collectPassthroughData()
: async () => ({}); : async () => ({});
const renewalEnabled = this.renewalEnabled(); const { isLeader } = this.instanceSettings;
const { autoRenewalEnabled } = this.globalConfig.license;
const shouldRenew = isLeader && autoRenewalEnabled;
if (isLeader && !autoRenewalEnabled) this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING);
try { try {
this.manager = new LicenseManager({ this.manager = new LicenseManager({
server, server,
tenantId: this.globalConfig.license.tenantId, tenantId: this.globalConfig.license.tenantId,
productIdentifier: `n8n-${N8N_VERSION}`, productIdentifier: `n8n-${N8N_VERSION}`,
autoRenewEnabled: renewalEnabled, autoRenewEnabled: shouldRenew,
renewOnInit: renewalEnabled, renewOnInit: shouldRenew,
autoRenewOffset, autoRenewOffset,
offlineMode, offlineMode,
logger: this.logger, logger: this.logger,
@ -275,7 +264,7 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3); return this.isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3);
} }
isMultipleMainInstancesLicensed() { isMultiMainLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES); return this.isFeatureEnabled(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
} }