feat(core): Make detaching floatable entitlements on shutdown configurable (#14266)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Cornelius Suermann
2025-04-04 17:35:32 +02:00
committed by GitHub
parent 39e2d35a71
commit c9565fc0be
9 changed files with 55 additions and 48 deletions

View File

@@ -10,14 +10,14 @@ export class LicenseConfig {
@Env('N8N_LICENSE_AUTO_RENEW_ENABLED') @Env('N8N_LICENSE_AUTO_RENEW_ENABLED')
autoRenewalEnabled: boolean = true; autoRenewalEnabled: boolean = true;
/** How long (in seconds) before expiry a license should be autorenewed. */
@Env('N8N_LICENSE_AUTO_RENEW_OFFSET')
autoRenewOffset: number = 60 * 60 * 72; // 72 hours
/** Activation key to initialize license. */ /** Activation key to initialize license. */
@Env('N8N_LICENSE_ACTIVATION_KEY') @Env('N8N_LICENSE_ACTIVATION_KEY')
activationKey: string = ''; activationKey: string = '';
/** Whether floating entitlements should be returned to the pool on shutdown */
@Env('N8N_LICENSE_DETACH_FLOATING_ON_SHUTDOWN')
detachFloatingOnShutdown: boolean = true;
/** Tenant ID used by the license manager SDK, e.g. for self-hosted, sandbox, embed, cloud. */ /** Tenant ID used by the license manager SDK, e.g. for self-hosted, sandbox, embed, cloud. */
@Env('N8N_LICENSE_TENANT_ID') @Env('N8N_LICENSE_TENANT_ID')
tenantId: number = 1; tenantId: number = 1;

View File

@@ -274,7 +274,7 @@ describe('GlobalConfig', () => {
license: { license: {
serverUrl: 'https://license.n8n.io/v1', serverUrl: 'https://license.n8n.io/v1',
autoRenewalEnabled: true, autoRenewalEnabled: true,
autoRenewOffset: 60 * 60 * 72, detachFloatingOnShutdown: true,
activationKey: '', activationKey: '',
tenantId: 1, tenantId: 1,
cert: '', cert: '',

View File

@@ -99,7 +99,7 @@
"@n8n/task-runner": "workspace:*", "@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12", "@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.13.0", "@n8n_io/ai-assistant-sdk": "1.13.0",
"@n8n_io/license-sdk": "2.17.0", "@n8n_io/license-sdk": "2.19.0",
"@oclif/core": "4.0.7", "@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9", "@rudderstack/rudder-sdk-node": "2.0.9",
"@sentry/node": "catalog:", "@sentry/node": "catalog:",

View File

@@ -25,7 +25,7 @@ function makeDateWithHourOffset(offsetInHours: number): Date {
const licenseConfig: GlobalConfig['license'] = { const licenseConfig: GlobalConfig['license'] = {
serverUrl: MOCK_SERVER_URL, serverUrl: MOCK_SERVER_URL,
autoRenewalEnabled: true, autoRenewalEnabled: true,
autoRenewOffset: MOCK_RENEW_OFFSET, detachFloatingOnShutdown: true,
activationKey: MOCK_ACTIVATION_KEY, activationKey: MOCK_ACTIVATION_KEY,
tenantId: 1, tenantId: 1,
cert: '', cert: '',

View File

@@ -1,13 +1,11 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { SETTINGS_LICENSE_CERT_KEY } from '@/constants';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { License } from '@/license'; import { License } from '@/license';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class ClearLicenseCommand extends BaseCommand { export class ClearLicenseCommand extends BaseCommand {
static description = 'Clear license'; static description = 'Clear local license certificate';
static examples = ['$ n8n license:clear']; static examples = ['$ n8n license:clear'];
@@ -17,20 +15,12 @@ export class ClearLicenseCommand extends BaseCommand {
// Attempt to invoke shutdown() to force any floating entitlements to be released // Attempt to invoke shutdown() to force any floating entitlements to be released
const license = Container.get(License); const license = Container.get(License);
await license.init({ isCli: true }); await license.init({ isCli: true });
try { await license.clear();
await license.shutdown();
} catch {
this.logger.info('License shutdown failed. Continuing with clearing license from database.');
}
await Container.get(SettingsRepository).delete({
key: SETTINGS_LICENSE_CERT_KEY,
});
this.logger.info('Done. Restart n8n to take effect.'); this.logger.info('Done. Restart n8n to take effect.');
} }
async catch(error: Error) { async catch(error: Error) {
this.logger.error('Error updating database. See log messages for details.'); this.logger.error('Error. See log messages for details.');
this.logger.error('\nGOT ERROR'); this.logger.error('\nGOT ERROR');
this.logger.info('===================================='); this.logger.info('====================================');
this.logger.error(error.message); this.logger.error(error.message);

View File

@@ -14,6 +14,7 @@ import {
LICENSE_QUOTAS, LICENSE_QUOTAS,
N8N_VERSION, N8N_VERSION,
SETTINGS_LICENSE_CERT_KEY, SETTINGS_LICENSE_CERT_KEY,
Time,
UNLIMITED_LICENSE_QUOTA, UNLIMITED_LICENSE_QUOTA,
} from './constants'; } from './constants';
import type { BooleanLicenseFeature, NumericLicenseFeature } from './interfaces'; import type { BooleanLicenseFeature, NumericLicenseFeature } from './interfaces';
@@ -60,7 +61,7 @@ export class License {
const isMainInstance = instanceType === 'main'; const isMainInstance = instanceType === 'main';
const server = this.globalConfig.license.serverUrl; const server = this.globalConfig.license.serverUrl;
const offlineMode = !isMainInstance; const offlineMode = !isMainInstance;
const autoRenewOffset = this.globalConfig.license.autoRenewOffset; const autoRenewOffset = 72 * Time.hours.toSeconds;
const saveCertStr = isMainInstance const saveCertStr = isMainInstance
? async (value: TLicenseBlock) => await this.saveCertStr(value) ? async (value: TLicenseBlock) => await this.saveCertStr(value)
: async () => {}; : async () => {};
@@ -92,6 +93,7 @@ export class License {
autoRenewEnabled: shouldRenew, autoRenewEnabled: shouldRenew,
renewOnInit: shouldRenew, renewOnInit: shouldRenew,
autoRenewOffset, autoRenewOffset,
detachFloatingOnShutdown: this.globalConfig.license.detachFloatingOnShutdown,
offlineMode, offlineMode,
logger: this.logger, logger: this.logger,
loadCertStr: async () => await this.loadCertStr(), loadCertStr: async () => await this.loadCertStr(),
@@ -194,6 +196,15 @@ export class License {
this.logger.debug('License renewed'); this.logger.debug('License renewed');
} }
async clear() {
if (!this.manager) {
return;
}
await this.manager.clear();
this.logger.info('License cleared');
}
@OnShutdown() @OnShutdown()
async shutdown() { async shutdown() {
// Shut down License manager to unclaim any floating entitlements // Shut down License manager to unclaim any floating entitlements

View File

@@ -1,8 +1,6 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { ClearLicenseCommand } from '@/commands/license/clear'; import { ClearLicenseCommand } from '@/commands/license/clear';
import { SETTINGS_LICENSE_CERT_KEY } from '@/constants';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { License } from '@/license'; import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command'; import { setupTestCommand } from '@test-integration/utils/test-command';
@@ -10,26 +8,30 @@ import { setupTestCommand } from '@test-integration/utils/test-command';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
mockInstance(LoadNodesAndCredentials); mockInstance(LoadNodesAndCredentials);
const license = mockInstance(License);
const command = setupTestCommand(ClearLicenseCommand); const command = setupTestCommand(ClearLicenseCommand);
test('license:clear invokes shutdown() to release any floating entitlements', async () => { test('license:clear invokes clear() to release any floating entitlements and deletes the license cert from the DB', async () => {
await command.run(); const license = Container.get(License);
expect(license.init).toHaveBeenCalledTimes(1); const manager = {
expect(license.shutdown).toHaveBeenCalledTimes(1); clear: jest.fn().mockImplementation(async () => {
}); await license.saveCertStr('');
}),
};
test('license:clear deletes the license from the DB even if shutdown() fails', async () => { const initSpy = jest.spyOn(license, 'init').mockImplementation(async () => {
license.shutdown.mockRejectedValueOnce(new Error('shutdown failed')); Object.defineProperty(license, 'manager', {
value: manager,
const settingsRepository = Container.get(SettingsRepository); writable: true,
});
settingsRepository.delete = jest.fn();
await command.run();
expect(settingsRepository.delete).toHaveBeenCalledWith({
key: SETTINGS_LICENSE_CERT_KEY,
}); });
const clearSpy = jest.spyOn(license, 'clear');
const saveCertStrSpy = jest.spyOn(license, 'saveCertStr');
await command.run();
expect(initSpy).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(saveCertStrSpy).toHaveBeenCalledWith('');
}); });

View File

@@ -12,7 +12,6 @@ import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_SERVER_URL = 'https://server.com/v1';
const MOCK_RENEW_OFFSET = 259200;
let owner: User; let owner: User;
let member: User; let member: User;
@@ -30,7 +29,6 @@ beforeAll(async () => {
config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true); config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
}); });
afterEach(async () => { afterEach(async () => {

18
pnpm-lock.yaml generated
View File

@@ -924,8 +924,8 @@ importers:
specifier: 1.13.0 specifier: 1.13.0
version: 1.13.0 version: 1.13.0
'@n8n_io/license-sdk': '@n8n_io/license-sdk':
specifier: 2.17.0 specifier: 2.19.0
version: 2.17.0 version: 2.19.0
'@oclif/core': '@oclif/core':
specifier: 4.0.7 specifier: 4.0.7
version: 4.0.7 version: 4.0.7
@@ -4693,8 +4693,8 @@ packages:
resolution: {integrity: sha512-16kftFTeX3/lBinHJaBK0OL1lB4FpPaUoHX4h25AkvgHvmjUHpWNY2ZtKos0rY89+pkzDsNxMZqSUkeKU45iRg==} resolution: {integrity: sha512-16kftFTeX3/lBinHJaBK0OL1lB4FpPaUoHX4h25AkvgHvmjUHpWNY2ZtKos0rY89+pkzDsNxMZqSUkeKU45iRg==}
engines: {node: '>=20.15', pnpm: '>=8.14'} engines: {node: '>=20.15', pnpm: '>=8.14'}
'@n8n_io/license-sdk@2.17.0': '@n8n_io/license-sdk@2.19.0':
resolution: {integrity: sha512-oa+P1qnJtVDysLSyaeLqHwUwUd5tXqbiWnj1+kuZrtF9hrJUacxGUQdFuBlGJwr8wUTTJVh2XIcE5N2Mn7x2Bg==} resolution: {integrity: sha512-Jrdw0us1rvs1lvvJaF1EtfTHXmeHq+PK4RVhfBZuAZqpVOFfM8iqBr3GzzZFVF4k8BS12Y9KrkzPkLf6oOlFFw==}
engines: {node: '>=18.12.1'} engines: {node: '>=18.12.1'}
'@n8n_io/riot-tmpl@4.0.0': '@n8n_io/riot-tmpl@4.0.0':
@@ -13186,6 +13186,10 @@ packages:
resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
undici@7.7.0:
resolution: {integrity: sha512-tZ6+5NBq4KH35rr46XJ2JPFKxfcBlYNaqLF/wyWIO9RMHqqU/gx/CLB1Y2qMcgB8lWw/bKHa7qzspqCN7mUHvA==}
engines: {node: '>=20.18.1'}
unicode-canonical-property-names-ecmascript@2.0.1: unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -17053,12 +17057,12 @@ snapshots:
'@n8n_io/ai-assistant-sdk@1.13.0': {} '@n8n_io/ai-assistant-sdk@1.13.0': {}
'@n8n_io/license-sdk@2.17.0': '@n8n_io/license-sdk@2.19.0':
dependencies: dependencies:
crypto-js: 4.2.0 crypto-js: 4.2.0
node-machine-id: 1.1.12 node-machine-id: 1.1.12
node-rsa: 1.1.1 node-rsa: 1.1.1
undici: 6.21.1 undici: 7.7.0
'@n8n_io/riot-tmpl@4.0.0': '@n8n_io/riot-tmpl@4.0.0':
dependencies: dependencies:
@@ -27592,6 +27596,8 @@ snapshots:
undici@6.21.1: {} undici@6.21.1: {}
undici@7.7.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0: unicode-match-property-ecmascript@2.0.0: