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')
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. */
@Env('N8N_LICENSE_ACTIVATION_KEY')
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. */
@Env('N8N_LICENSE_TENANT_ID')
tenantId: number = 1;

View File

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

View File

@@ -99,7 +99,7 @@
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@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",
"@rudderstack/rudder-sdk-node": "2.0.9",
"@sentry/node": "catalog:",

View File

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

View File

@@ -1,13 +1,11 @@
import { Container } from '@n8n/di';
import { SETTINGS_LICENSE_CERT_KEY } from '@/constants';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { License } from '@/license';
import { BaseCommand } from '../base-command';
export class ClearLicenseCommand extends BaseCommand {
static description = 'Clear license';
static description = 'Clear local license certificate';
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
const license = Container.get(License);
await license.init({ isCli: true });
try {
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,
});
await license.clear();
this.logger.info('Done. Restart n8n to take effect.');
}
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.info('====================================');
this.logger.error(error.message);

View File

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

View File

@@ -1,8 +1,6 @@
import { Container } from '@n8n/di';
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 { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
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';
mockInstance(LoadNodesAndCredentials);
const license = mockInstance(License);
const command = setupTestCommand(ClearLicenseCommand);
test('license:clear invokes shutdown() to release any floating entitlements', async () => {
await command.run();
test('license:clear invokes clear() to release any floating entitlements and deletes the license cert from the DB', async () => {
const license = Container.get(License);
expect(license.init).toHaveBeenCalledTimes(1);
expect(license.shutdown).toHaveBeenCalledTimes(1);
});
const manager = {
clear: jest.fn().mockImplementation(async () => {
await license.saveCertStr('');
}),
};
test('license:clear deletes the license from the DB even if shutdown() fails', async () => {
license.shutdown.mockRejectedValueOnce(new Error('shutdown failed'));
const settingsRepository = Container.get(SettingsRepository);
settingsRepository.delete = jest.fn();
await command.run();
expect(settingsRepository.delete).toHaveBeenCalledWith({
key: SETTINGS_LICENSE_CERT_KEY,
const initSpy = jest.spyOn(license, 'init').mockImplementation(async () => {
Object.defineProperty(license, 'manager', {
value: manager,
writable: true,
});
});
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/';
const MOCK_SERVER_URL = 'https://server.com/v1';
const MOCK_RENEW_OFFSET = 259200;
let owner: User;
let member: User;
@@ -30,7 +29,6 @@ beforeAll(async () => {
config.set('license.serverUrl', MOCK_SERVER_URL);
config.set('license.autoRenewEnabled', true);
config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET);
});
afterEach(async () => {

18
pnpm-lock.yaml generated
View File

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