From c9565fc0beea2751ba316fc363a275ea28d20653 Mon Sep 17 00:00:00 2001 From: Cornelius Suermann Date: Fri, 4 Apr 2025 17:35:32 +0200 Subject: [PATCH] feat(core): Make detaching floatable entitlements on shutdown configurable (#14266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Iván Ovejero --- .../@n8n/config/src/configs/license.config.ts | 8 ++-- packages/@n8n/config/test/config.test.ts | 2 +- packages/cli/package.json | 2 +- packages/cli/src/__tests__/license.test.ts | 2 +- packages/cli/src/commands/license/clear.ts | 16 ++------ packages/cli/src/license.ts | 13 +++++- .../integration/commands/license.cmd.test.ts | 40 ++++++++++--------- .../cli/test/integration/license.api.test.ts | 2 - pnpm-lock.yaml | 18 ++++++--- 9 files changed, 55 insertions(+), 48 deletions(-) diff --git a/packages/@n8n/config/src/configs/license.config.ts b/packages/@n8n/config/src/configs/license.config.ts index 58ccef450c..005b5f5dc2 100644 --- a/packages/@n8n/config/src/configs/license.config.ts +++ b/packages/@n8n/config/src/configs/license.config.ts @@ -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; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 81aff907b2..3d01912862 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -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: '', diff --git a/packages/cli/package.json b/packages/cli/package.json index f7e309bdad..68ae9d0f92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index 77dd8b2fc5..6217531892 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -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: '', diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index 566b538719..e75fefadf3 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -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); diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index 9730e07aec..9ea1d69949 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -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 diff --git a/packages/cli/test/integration/commands/license.cmd.test.ts b/packages/cli/test/integration/commands/license.cmd.test.ts index d35c4fdc4a..dcdac6840f 100644 --- a/packages/cli/test/integration/commands/license.cmd.test.ts +++ b/packages/cli/test/integration/commands/license.cmd.test.ts @@ -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(''); }); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index a06408c6cb..f57e886ff5 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -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 () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e18ae6aee..17d4958df4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: