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 type { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { N8N_VERSION } from '@/constants';
import { License } from '@/license';
import { mockLogger } from '@test/mocking';
@ -31,6 +30,7 @@ describe('License', () => {
const instanceSettings = mock<InstanceSettings>({
instanceId: MOCK_INSTANCE_ID,
instanceType: 'main',
isLeader: true,
});
beforeEach(async () => {
@ -67,7 +67,7 @@ describe('License', () => {
license = new License(
logger,
mock<InstanceSettings>({ instanceType: 'worker' }),
mock<InstanceSettings>({ instanceType: 'worker', isLeader: false }),
mock(),
mock(),
mock<GlobalConfig>({ license: licenseConfig }),
@ -197,94 +197,59 @@ describe('License', () => {
describe('License', () => {
describe('init', () => {
describe('in single-main setup', () => {
describe('with `license.autoRenewEnabled` enabled', () => {
it('should enable renewal', async () => {
const globalConfig = mock<GlobalConfig>({
license: licenseConfig,
multiMainSetup: { enabled: false },
});
await new License(
mockLogger(),
mock<InstanceSettings>({ instanceType: 'main' }),
mock(),
mock(),
globalConfig,
).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
it('when leader main with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => {
const globalConfig = mock<GlobalConfig>({
license: { ...licenseConfig, autoRenewalEnabled: true },
});
describe('with `license.autoRenewEnabled` disabled', () => {
it('should disable renewal', async () => {
await new License(
mockLogger(),
mock<InstanceSettings>({ instanceType: 'main' }),
mock(),
mock(),
mock(),
).init();
await new License(
mockLogger(),
mock<InstanceSettings>({ instanceType: 'main', isLeader: true }),
mock(),
mock(),
globalConfig,
).init();
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: false, renewOnInit: false }),
);
});
});
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
describe('in multi-main setup', () => {
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 }),
);
},
);
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` 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<InstanceSettings>({ instanceType: 'main', isLeader }),
mock(),
mock(),
globalConfig,
).init();
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init();
const expectedRenewalSettings =
isLeader && autoRenewalEnabled
? { autoRenewEnabled: true, renewOnInit: true }
: { autoRenewEnabled: false, renewOnInit: false };
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 }),
);
});
});
expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
});
});

View file

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
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 { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service';
import { License } from '@/license';
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { Server } from '@/server';
@ -192,18 +190,23 @@ export class Start extends BaseCommand {
await super.init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
this.instanceSettings.setMultiMainEnabled(
config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled,
);
await this.initLicense();
const isMultiMainEnabled =
config.getEnv('executions.mode') === 'queue' && this.globalConfig.multiMainSetup.enabled;
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();
this.logger.debug('Orchestration init complete');
await this.initLicense();
if (!this.globalConfig.license.autoRenewalEnabled && this.instanceSettings.isLeader) {
this.logger.warn(
'Automatic license renewal is disabled. The license will not renew automatically, and access to licensed features may be lost!',
);
if (isMultiMainEnabled && !this.license.isMultiMainLicensed()) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
}
Container.get(WaitTracker).init();
@ -237,13 +240,6 @@ export class Start extends BaseCommand {
return;
}
if (
Container.get(GlobalConfig).multiMainSetup.enabled &&
!Container.get(License).isMultipleMainInstancesLicensed()
) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
}
const orchestrationService = Container.get(OrchestrationService);
await orchestrationService.init();

View file

@ -18,6 +18,9 @@ import {
} from './constants';
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<
{
planName: string;
@ -40,25 +43,6 @@ export class 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) {
if (this.manager && !forceRecreate) {
this.logger.warn('License manager already initialized or shutting down');
@ -87,15 +71,20 @@ export class License {
? async () => await this.licenseMetricsService.collectPassthroughData()
: 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 {
this.manager = new LicenseManager({
server,
tenantId: this.globalConfig.license.tenantId,
productIdentifier: `n8n-${N8N_VERSION}`,
autoRenewEnabled: renewalEnabled,
renewOnInit: renewalEnabled,
autoRenewEnabled: shouldRenew,
renewOnInit: shouldRenew,
autoRenewOffset,
offlineMode,
logger: this.logger,
@ -275,7 +264,7 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.BINARY_DATA_S3);
}
isMultipleMainInstancesLicensed() {
isMultiMainLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES);
}