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
+1 -1
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:",
+1 -1
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: '',
+3 -13
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);
+12 -1
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
@@ -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('');
});
@@ -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 () => {